mirror of
https://github.com/oqyude/nixos.git
synced 2026-10-07 20:47:14 +03:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85d3e3747b | ||
|
|
1c77ae658e |
+2
-1
@@ -1,2 +1,3 @@
|
|||||||
.vscode
|
.vscode
|
||||||
.omo
|
.omo
|
||||||
|
__pycache__
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
# Declarative OpenCode + oh-my-openagent (oh-my-opencode) plugin setup.
|
||||||
|
#
|
||||||
|
# Mirrors ~/.config/opencode/ on the current workstation.
|
||||||
|
# Imported by home/server.nix (sapphira). Auto-enables programs.opencode.
|
||||||
|
#
|
||||||
|
# Three files this module owns on disk (via xdg.configFile):
|
||||||
|
# ~/.config/opencode/opencode.json <- programs.opencode.settings
|
||||||
|
# ~/.config/opencode/tui.json <- programs.opencode.tui
|
||||||
|
# ~/.config/opencode/oh-my-openagent.json <- oh-my-openagent plugin config
|
||||||
|
#
|
||||||
|
# Override any field in the importing module if needed.
|
||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
# Body of ~/.config/opencode/oh-my-openagent.json.
|
||||||
|
# Loaded by the oh-my-openagent opencode plugin on startup.
|
||||||
|
ohMyOpenagentConfig = {
|
||||||
|
"$schema" = "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json";
|
||||||
|
|
||||||
|
agents = {
|
||||||
|
sisyphus = {
|
||||||
|
model = "opencode/claude-opus-5";
|
||||||
|
variant = "max";
|
||||||
|
fallback_models = [
|
||||||
|
{ model = "opencode/kimi-k3"; }
|
||||||
|
{
|
||||||
|
model = "opencode/gpt-5.6-sol";
|
||||||
|
variant = "medium";
|
||||||
|
}
|
||||||
|
{ model = "opencode/glm-5"; }
|
||||||
|
{ model = "opencode/big-pickle"; }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
hephaestus = {
|
||||||
|
model = "opencode/gpt-5.6-sol";
|
||||||
|
variant = "medium";
|
||||||
|
};
|
||||||
|
oracle = {
|
||||||
|
model = "opencode/gpt-5.6-sol";
|
||||||
|
variant = "xhigh";
|
||||||
|
fallback_models = [
|
||||||
|
{
|
||||||
|
model = "opencode/gemini-3.1-pro";
|
||||||
|
variant = "high";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
model = "opencode/claude-opus-5";
|
||||||
|
variant = "max";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
librarian = {
|
||||||
|
model = "minimax-coding-plan/MiniMax-M3";
|
||||||
|
};
|
||||||
|
explore = {
|
||||||
|
model = "opencode/gpt-5-nano";
|
||||||
|
fallback_models = [
|
||||||
|
{ model = "minimax-coding-plan/MiniMax-M3"; }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
"multimodal-looker" = {
|
||||||
|
model = "opencode/gpt-5.6-sol";
|
||||||
|
variant = "low";
|
||||||
|
fallback_models = [
|
||||||
|
{ model = "opencode/gpt-5-nano"; }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
prometheus = {
|
||||||
|
model = "opencode/claude-fable-5";
|
||||||
|
variant = "high";
|
||||||
|
fallback_models = [
|
||||||
|
{
|
||||||
|
model = "opencode/kimi-k3";
|
||||||
|
variant = "high";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
metis = {
|
||||||
|
model = "opencode/claude-opus-5";
|
||||||
|
variant = "high";
|
||||||
|
fallback_models = [
|
||||||
|
{
|
||||||
|
model = "opencode/kimi-k3";
|
||||||
|
variant = "low";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
momus = {
|
||||||
|
model = "opencode/gpt-5.6-sol";
|
||||||
|
variant = "xhigh";
|
||||||
|
fallback_models = [
|
||||||
|
{
|
||||||
|
model = "opencode/claude-opus-5";
|
||||||
|
variant = "max";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
model = "opencode/gemini-3.1-pro";
|
||||||
|
variant = "high";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
atlas = {
|
||||||
|
model = "opencode/claude-sonnet-4-6";
|
||||||
|
fallback_models = [
|
||||||
|
{
|
||||||
|
model = "opencode/gpt-5.6-sol";
|
||||||
|
variant = "medium";
|
||||||
|
}
|
||||||
|
{ model = "minimax-coding-plan/MiniMax-M3"; }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
"sisyphus-junior" = {
|
||||||
|
model = "opencode/claude-sonnet-4-6";
|
||||||
|
fallback_models = [
|
||||||
|
{
|
||||||
|
model = "opencode/gpt-5.6-sol";
|
||||||
|
variant = "medium";
|
||||||
|
}
|
||||||
|
{ model = "minimax-coding-plan/MiniMax-M3"; }
|
||||||
|
{ model = "opencode/big-pickle"; }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
categories = {
|
||||||
|
"visual-engineering" = {
|
||||||
|
model = "opencode/gemini-3.1-pro";
|
||||||
|
variant = "high";
|
||||||
|
fallback_models = [
|
||||||
|
{ model = "opencode/glm-5"; }
|
||||||
|
{
|
||||||
|
model = "opencode/claude-opus-5";
|
||||||
|
variant = "max";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
ultrabrain = {
|
||||||
|
model = "opencode/gpt-5.6-sol";
|
||||||
|
variant = "xhigh";
|
||||||
|
fallback_models = [
|
||||||
|
{
|
||||||
|
model = "opencode/gemini-3.1-pro";
|
||||||
|
variant = "high";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
model = "opencode/claude-opus-5";
|
||||||
|
variant = "max";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
deep = {
|
||||||
|
model = "opencode/gpt-5.6-sol";
|
||||||
|
variant = "medium";
|
||||||
|
fallback_models = [
|
||||||
|
{
|
||||||
|
model = "opencode/claude-opus-5";
|
||||||
|
variant = "max";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
model = "opencode/gemini-3.1-pro";
|
||||||
|
variant = "high";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
artistry = {
|
||||||
|
model = "opencode/gemini-3.1-pro";
|
||||||
|
variant = "high";
|
||||||
|
fallback_models = [
|
||||||
|
{
|
||||||
|
model = "opencode/claude-opus-5";
|
||||||
|
variant = "max";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
model = "opencode/gpt-5.6-sol";
|
||||||
|
variant = "high";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
quick = {
|
||||||
|
model = "opencode/gpt-5.4-mini";
|
||||||
|
fallback_models = [
|
||||||
|
{ model = "opencode/gemini-3-flash"; }
|
||||||
|
{ model = "minimax-coding-plan/MiniMax-M3"; }
|
||||||
|
{ model = "opencode/gpt-5-nano"; }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
"unspecified-low" = {
|
||||||
|
model = "opencode/claude-sonnet-4-6";
|
||||||
|
fallback_models = [
|
||||||
|
{
|
||||||
|
model = "opencode/gpt-5.6-sol";
|
||||||
|
variant = "medium";
|
||||||
|
}
|
||||||
|
{ model = "opencode/gemini-3-flash"; }
|
||||||
|
{ model = "minimax-coding-plan/MiniMax-M3"; }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
"unspecified-high" = {
|
||||||
|
model = "opencode/claude-sonnet-4-6";
|
||||||
|
fallback_models = [
|
||||||
|
{
|
||||||
|
model = "opencode/gpt-5.6-sol";
|
||||||
|
variant = "medium";
|
||||||
|
}
|
||||||
|
{ model = "opencode/gemini-3-flash"; }
|
||||||
|
{ model = "minimax-coding-plan/MiniMax-M3"; }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
writing = {
|
||||||
|
model = "opencode/gemini-3-flash";
|
||||||
|
fallback_models = [
|
||||||
|
{ model = "opencode/claude-sonnet-4-6"; }
|
||||||
|
{ model = "minimax-coding-plan/MiniMax-M3"; }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
programs.opencode = {
|
||||||
|
enable = true;
|
||||||
|
|
||||||
|
# npx for MCP servers (webpage-mcp) and the plugin loader itself.
|
||||||
|
# nodejs_22 includes npm; plain nodejs does NOT.
|
||||||
|
extraPackages = [ pkgs.nodejs_22 ];
|
||||||
|
|
||||||
|
# ~/.config/opencode/opencode.json
|
||||||
|
settings = {
|
||||||
|
plugin = [ "oh-my-openagent@latest" ];
|
||||||
|
mcp = {
|
||||||
|
webpage = {
|
||||||
|
type = "local";
|
||||||
|
command = [
|
||||||
|
"npx"
|
||||||
|
"-y"
|
||||||
|
"-p"
|
||||||
|
"webpage-mcp@latest"
|
||||||
|
"webpage-mcp-stdio"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# ~/.config/opencode/tui.json
|
||||||
|
# Mirrors workstation: oh-my-openagent also registered for the TUI.
|
||||||
|
tui = {
|
||||||
|
plugin = [ "oh-my-openagent@latest" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# ~/.config/opencode/oh-my-openagent.json — read by the plugin on startup.
|
||||||
|
xdg.configFile."opencode/oh-my-openagent.json".text = builtins.toJSON ohMyOpenagentConfig;
|
||||||
|
|
||||||
|
# Expose `opencode web` as a systemd user service. nginx on sapphira
|
||||||
|
# proxies https://opencode.zeroq.su -> 127.0.0.1:4096.
|
||||||
|
#
|
||||||
|
# --hostname 0.0.0.0 binds the listener to every interface (matches the
|
||||||
|
# "0.0.0.0" intent; nginx then reverse-proxies 127.0.0.1:4096 internally).
|
||||||
|
# --cors https://opencode.zeroq.su lets the browser session reach the
|
||||||
|
# server from that origin without CORS rejection.
|
||||||
|
#
|
||||||
|
# SECURITY: with no password, anyone reaching the upstream socket gets full
|
||||||
|
# opencode. Bind 0.0.0.0 + listener == bridge == shell. The password is
|
||||||
|
# supplied via sops-managed EnvironmentFile, declared in modules/users.nix
|
||||||
|
# and decrypted to a path hardcoded here (home-manager modules cannot read
|
||||||
|
# `config.sops.*` — sops-nix options are NixOS-only).
|
||||||
|
programs.opencode.web = {
|
||||||
|
enable = true;
|
||||||
|
environmentFile = "${config.home.homeDirectory}/.config/opencode/server.env";
|
||||||
|
extraArgs = [
|
||||||
|
"--hostname"
|
||||||
|
"0.0.0.0"
|
||||||
|
"--cors"
|
||||||
|
"https://opencode.zeroq.su"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
{
|
{
|
||||||
imports = [
|
imports = [
|
||||||
./minimal.nix
|
./minimal.nix
|
||||||
|
./modules/opencode.nix
|
||||||
];
|
];
|
||||||
home.file = xlib.helpers.mkSymlinks config {
|
home.file = xlib.helpers.mkSymlinks config {
|
||||||
"${config.home.homeDirectory}/External/Music" = "Music";
|
"${config.home.homeDirectory}/External/Music" = "Music";
|
||||||
|
|||||||
@@ -13,24 +13,18 @@ let
|
|||||||
# config revision: edit a file, `nixos-rebuild`, and the unit below rebuilds
|
# config revision: edit a file, `nixos-rebuild`, and the unit below rebuilds
|
||||||
# and restarts. Reading the context off a checkout at runtime would leave the
|
# and restarts. Reading the context off a checkout at runtime would leave the
|
||||||
# running container untraceable back to any config.
|
# running container untraceable back to any config.
|
||||||
source = pkgs.linkFarm "kokoro-tts-source" [
|
#
|
||||||
{
|
# runCommand rather than linkFarm: linkFarm entries are symlinks into other
|
||||||
name = "Dockerfile";
|
# store paths, and `podman build` only mounts the context root, so every COPY
|
||||||
path = toString ./kokoro-tts/Dockerfile;
|
# fails with "copier: get: lstat ...: no such file or directory". Copying the
|
||||||
}
|
# bytes in leaves the context with no symlinks that escape its root.
|
||||||
{
|
source = pkgs.runCommand "kokoro-tts-source" { } ''
|
||||||
name = "app.py";
|
mkdir -p "$out"
|
||||||
path = toString ./kokoro-tts/app.py;
|
cp -L ${./kokoro-tts/Dockerfile} "$out/Dockerfile"
|
||||||
}
|
cp -L ${./kokoro-tts/app.py} "$out/app.py"
|
||||||
{
|
cp -L ${./kokoro-tts/fetch_assets.py} "$out/fetch_assets.py"
|
||||||
name = "fetch_assets.py";
|
cp -L ${./kokoro-tts/requirements.txt} "$out/requirements.txt"
|
||||||
path = toString ./kokoro-tts/fetch_assets.py;
|
'';
|
||||||
}
|
|
||||||
{
|
|
||||||
name = "requirements.txt";
|
|
||||||
path = toString ./kokoro-tts/requirements.txt;
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
image = "localhost/kokoro-tts:latest";
|
image = "localhost/kokoro-tts:latest";
|
||||||
|
|
||||||
@@ -64,11 +58,16 @@ in
|
|||||||
];
|
];
|
||||||
|
|
||||||
environment = {
|
environment = {
|
||||||
# Inference is CPU-bound and already threaded inside torch; these
|
# Inference is CPU-bound and already threaded inside torch. Measured
|
||||||
# keep it from oversubscribing a small machine.
|
# on a 24-logical-core host: median end-to-end latency for a 5.6 s
|
||||||
KOKORO_THREADS = "4";
|
# utterance was 1.203 s at 4 threads, 0.979 s at 12, 0.980 s at 16
|
||||||
OMP_NUM_THREADS = "4";
|
# and 1.87 s at 24, so the useful ceiling is the physical core count
|
||||||
MKL_NUM_THREADS = "4";
|
# and oversubscribing it roughly doubles the wait. These three must
|
||||||
|
# stay equal to the Dockerfile ENV and the app.py default: whichever
|
||||||
|
# of the three is set wins over the others.
|
||||||
|
KOKORO_THREADS = "12";
|
||||||
|
OMP_NUM_THREADS = "12";
|
||||||
|
MKL_NUM_THREADS = "12";
|
||||||
TZ = "Europe/Moscow";
|
TZ = "Europe/Moscow";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -113,4 +112,4 @@ in
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
FROM python:3.12-slim-bookworm
|
# Fully qualified on purpose: NixOS ships a podman registries.conf without
|
||||||
|
# unqualified-search-registries, so a bare "python:3.12-slim-bookworm" fails to
|
||||||
|
# resolve before the build even starts.
|
||||||
|
FROM docker.io/library/python:3.12-slim-bookworm
|
||||||
|
|
||||||
# Pinned, not "main": a rebuild that only touched the Nix module must not
|
# Pinned, not "main": a rebuild that only touched the Nix module must not
|
||||||
# silently pick up different weights. Bump these deliberately.
|
# silently pick up different weights. Bump these deliberately.
|
||||||
@@ -8,6 +11,9 @@ ARG KOKORO_RU_REVISION=d649c57b239b18c4c384378127cbf01dba039bc1
|
|||||||
# a second 327 MB one.
|
# a second 327 MB one.
|
||||||
ARG KOKORO_RU_VOICES=sveta,masha,dima
|
ARG KOKORO_RU_VOICES=sveta,masha,dima
|
||||||
|
|
||||||
|
# Thread counts, not a guess: see app.py THREADS. 12 was the measured plateau on
|
||||||
|
# a 24-logical-core host, and 24 was ~2x worse. Must stay equal to the Nix
|
||||||
|
# module's environment.environment, which wins over this ENV.
|
||||||
ENV PYTHONUNBUFFERED=1 \
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
PIP_NO_CACHE_DIR=1 \
|
PIP_NO_CACHE_DIR=1 \
|
||||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||||
@@ -17,9 +23,9 @@ ENV PYTHONUNBUFFERED=1 \
|
|||||||
KOKORO_RU_REVISION=${KOKORO_RU_REVISION} \
|
KOKORO_RU_REVISION=${KOKORO_RU_REVISION} \
|
||||||
KOKORO_RU_VOICES=${KOKORO_RU_VOICES} \
|
KOKORO_RU_VOICES=${KOKORO_RU_VOICES} \
|
||||||
KOKORO_MODEL_DIR=/app/kokoro-ru \
|
KOKORO_MODEL_DIR=/app/kokoro-ru \
|
||||||
KOKORO_THREADS=4 \
|
KOKORO_THREADS=12 \
|
||||||
OMP_NUM_THREADS=4 \
|
OMP_NUM_THREADS=12 \
|
||||||
MKL_NUM_THREADS=4 \
|
MKL_NUM_THREADS=12 \
|
||||||
TZ=Europe/Moscow
|
TZ=Europe/Moscow
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -39,13 +45,19 @@ RUN pip install --index-url https://download.pytorch.org/whl/cpu torch
|
|||||||
COPY requirements.txt ./
|
COPY requirements.txt ./
|
||||||
RUN pip install -r requirements.txt
|
RUN pip install -r requirements.txt
|
||||||
|
|
||||||
COPY app.py fetch_assets.py ./
|
# fetch_assets.py is copied on its own and app.py only after the snapshot, never
|
||||||
|
# as one COPY. A single COPY would tie the 639 MB download to the application
|
||||||
|
# source: any edit to app.py would invalidate this layer and refetch every
|
||||||
|
# checkpoint as hundreds of anonymous, rate-limited requests.
|
||||||
|
COPY fetch_assets.py ./
|
||||||
|
|
||||||
# Bakes the checkpoints, the acute-aware espeak data and ruaccent's ONNX models
|
# Bakes the checkpoints, the acute-aware espeak data and ruaccent's ONNX models
|
||||||
# into the layer, which is what lets the container start with no network and no
|
# into the layer, which is what lets the container start with no network and no
|
||||||
# writable volume.
|
# writable volume.
|
||||||
RUN python fetch_assets.py
|
RUN python fetch_assets.py
|
||||||
|
|
||||||
|
COPY app.py ./
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=180s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=180s --retries=3 \
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ non-native (measured 27% vs 22% round-trip WER, per the model card).
|
|||||||
So: text -> RuG2P.phonemize -> KModel(ipa, voicepack[len(ipa) - 1]) -> waveform.
|
So: text -> RuG2P.phonemize -> KModel(ipa, voicepack[len(ipa) - 1]) -> waveform.
|
||||||
|
|
||||||
Endpoints
|
Endpoints
|
||||||
POST /v1/audio/speech OpenAI text-to-speech
|
POST /v1/audio/speech OpenAI text-to-speech
|
||||||
GET /v1/models OpenAI model list
|
POST /v1/audio/speech/stream same, but mp3/opus emitted while synthesising
|
||||||
GET /v1/voices voice inventory (extension, not part of OpenAI)
|
GET /v1/models OpenAI model list
|
||||||
GET /healthz readiness, 503 until the model is loaded
|
GET /v1/voices voice inventory (extension, not part of OpenAI)
|
||||||
|
GET /healthz readiness, 503 until the model is loaded
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -21,6 +22,7 @@ from __future__ import annotations
|
|||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import queue
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -28,11 +30,11 @@ import threading
|
|||||||
import wave
|
import wave
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Literal
|
from typing import TYPE_CHECKING, Iterator, Literal
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.responses import JSONResponse, Response
|
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
if TYPE_CHECKING: # torch is imported lazily so /healthz answers during boot
|
if TYPE_CHECKING: # torch is imported lazily so /healthz answers during boot
|
||||||
@@ -42,7 +44,12 @@ MODEL_ID = "kokoro-ru"
|
|||||||
SAMPLE_RATE = 24000
|
SAMPLE_RATE = 24000
|
||||||
MODEL_DIR = Path(os.environ.get("KOKORO_MODEL_DIR", "/app/kokoro-ru"))
|
MODEL_DIR = Path(os.environ.get("KOKORO_MODEL_DIR", "/app/kokoro-ru"))
|
||||||
DEFAULT_VOICE = os.environ.get("KOKORO_DEFAULT_VOICE", "sveta")
|
DEFAULT_VOICE = os.environ.get("KOKORO_DEFAULT_VOICE", "sveta")
|
||||||
THREADS = int(os.environ.get("KOKORO_THREADS", os.cpu_count() or 4))
|
# Measured on the host this was tuned for (Ryzen AI 9 HX 370, 24 logical cores):
|
||||||
|
# median end-to-end latency for a 5.6 s utterance was 1.203 s @ 4 threads,
|
||||||
|
# 1.066 s @ 8, 0.979 s @ 12, 0.980 s @ 16, then 1.87 s @ 24. The gain stops at
|
||||||
|
# the physical core count and SMT oversubscription costs ~2x, so cap instead of
|
||||||
|
# trusting os.cpu_count(), which reports logical CPUs. Override on other hosts.
|
||||||
|
THREADS = int(os.environ.get("KOKORO_THREADS", min(12, os.cpu_count() or 4)))
|
||||||
# 2026-07-29, when the kokoro-ru revision we pin was published. Clients that
|
# 2026-07-29, when the kokoro-ru revision we pin was published. Clients that
|
||||||
# cache on this treat any change as a new model, so it must stay stable.
|
# cache on this treat any change as a new model, so it must stay stable.
|
||||||
MODEL_CREATED = 1785353253
|
MODEL_CREATED = 1785353253
|
||||||
@@ -223,30 +230,39 @@ class KokoroRu:
|
|||||||
if ps:
|
if ps:
|
||||||
yield from split_phonemes(ps)
|
yield from split_phonemes(ps)
|
||||||
|
|
||||||
def synthesize(self, text: str, voice: str, speed: float) -> np.ndarray:
|
def iter_audio_chunks(self, text: str, voice: str, speed: float):
|
||||||
|
"""Yields float32 audio per phoneme chunk, silence gaps interleaved.
|
||||||
|
|
||||||
|
The engine lock is held for the whole iteration, so a caller that stops
|
||||||
|
consuming early releases synthesis for everyone else.
|
||||||
|
"""
|
||||||
torch = self._torch
|
torch = self._torch
|
||||||
assert torch is not None, "synthesize() before load()"
|
assert torch is not None, "synthesize() before load()"
|
||||||
stem, _gender = VOICE_SPECS[voice]
|
stem, _gender = VOICE_SPECS[voice]
|
||||||
model = self._models[stem]
|
model = self._models[stem]
|
||||||
pack = self._packs[voice]
|
pack = self._packs[voice]
|
||||||
|
|
||||||
gap = torch.zeros(int(CHUNK_GAP_S * SAMPLE_RATE), dtype=torch.float32)
|
gap = np.zeros(int(CHUNK_GAP_S * SAMPLE_RATE), dtype=np.float32)
|
||||||
pieces: list[torch.Tensor] = []
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
for ps in self.phonemes(text):
|
for index, ps in enumerate(self.phonemes(text)):
|
||||||
# The style vector is picked by phoneme-string length, which is
|
# The style vector is picked by phoneme-string length, which is
|
||||||
# why the model sounds deterministic for identical text.
|
# why the model sounds deterministic for identical text.
|
||||||
style = pack[len(ps) - 1]
|
style = pack[len(ps) - 1]
|
||||||
# The packs ship as [510, 256]; KModel wants a batch of one.
|
# The packs ship as [510, 256]; KModel wants a batch of one.
|
||||||
if style.dim() == 1:
|
if style.dim() == 1:
|
||||||
style = style.unsqueeze(0)
|
style = style.unsqueeze(0)
|
||||||
if pieces:
|
if index:
|
||||||
pieces.append(gap)
|
yield gap
|
||||||
pieces.append(model(ps, style, speed, return_output=True).audio)
|
yield np.asarray(
|
||||||
|
model(ps, style, speed, return_output=True).audio,
|
||||||
|
dtype=np.float32,
|
||||||
|
).reshape(-1)
|
||||||
|
|
||||||
if not pieces:
|
def synthesize(self, text: str, voice: str, speed: float) -> np.ndarray:
|
||||||
|
chunks = list(self.iter_audio_chunks(text, voice, speed))
|
||||||
|
if not chunks:
|
||||||
return np.zeros(0, dtype=np.float32)
|
return np.zeros(0, dtype=np.float32)
|
||||||
return torch.cat(pieces).numpy().astype(np.float32, copy=False)
|
return np.concatenate(chunks)
|
||||||
|
|
||||||
|
|
||||||
def encode(audio: np.ndarray, fmt: str) -> bytes:
|
def encode(audio: np.ndarray, fmt: str) -> bytes:
|
||||||
@@ -290,6 +306,86 @@ def encode(audio: np.ndarray, fmt: str) -> bytes:
|
|||||||
return done.stdout
|
return done.stdout
|
||||||
|
|
||||||
|
|
||||||
|
class StreamEncoder:
|
||||||
|
"""One long-lived ffmpeg per request: raw PCM in, encoded bytes out.
|
||||||
|
|
||||||
|
A single process is what keeps the container valid. Handing it the audio in
|
||||||
|
pieces as they are synthesised avoids any byte-level concatenation, whereas
|
||||||
|
encoding the pieces separately and joining the results would emit chained
|
||||||
|
Ogg for opus, which plenty of players reject.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, fmt: str) -> None:
|
||||||
|
import imageio_ffmpeg
|
||||||
|
|
||||||
|
self._proc = subprocess.Popen(
|
||||||
|
[
|
||||||
|
imageio_ffmpeg.get_ffmpeg_exe(),
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-f",
|
||||||
|
"s16le",
|
||||||
|
"-ar",
|
||||||
|
str(SAMPLE_RATE),
|
||||||
|
"-ac",
|
||||||
|
"1",
|
||||||
|
"-i",
|
||||||
|
"pipe:0",
|
||||||
|
*FFMPEG_ARGS[fmt],
|
||||||
|
"-f",
|
||||||
|
FFMPEG_CONTAINERS[fmt],
|
||||||
|
"pipe:1",
|
||||||
|
],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
self._blocks: queue.Queue[bytes | None] = queue.Queue()
|
||||||
|
self._reader = threading.Thread(target=self._pump, daemon=True)
|
||||||
|
self._reader.start()
|
||||||
|
|
||||||
|
def _pump(self) -> None:
|
||||||
|
assert self._proc.stdout is not None
|
||||||
|
while True:
|
||||||
|
block = self._proc.stdout.read(8192)
|
||||||
|
if not block:
|
||||||
|
break
|
||||||
|
self._blocks.put(block)
|
||||||
|
self._blocks.put(None)
|
||||||
|
|
||||||
|
def push(self, audio: np.ndarray) -> None:
|
||||||
|
assert self._proc.stdin is not None
|
||||||
|
clipped = np.clip(audio, -1.0, 1.0)
|
||||||
|
self._proc.stdin.write((clipped * 32767.0).astype("<i2").tobytes())
|
||||||
|
self._proc.stdin.flush()
|
||||||
|
|
||||||
|
def drain(self) -> Iterator[bytes]:
|
||||||
|
"""Yields whatever ffmpeg has already emitted, without waiting for more."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
block = self._blocks.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
return
|
||||||
|
if block is None:
|
||||||
|
return
|
||||||
|
yield block
|
||||||
|
|
||||||
|
def finish(self) -> Iterator[bytes]:
|
||||||
|
assert self._proc.stdin is not None
|
||||||
|
self._proc.stdin.close()
|
||||||
|
self._reader.join(timeout=120)
|
||||||
|
code = self._proc.wait(timeout=30)
|
||||||
|
error = self._proc.stderr.read().decode("utf-8", "replace").strip()[-400:]
|
||||||
|
if code != 0:
|
||||||
|
raise RuntimeError(error or f"ffmpeg exited with {code}")
|
||||||
|
yield from self.drain()
|
||||||
|
|
||||||
|
def abort(self) -> None:
|
||||||
|
if self._proc.poll() is None:
|
||||||
|
self._proc.kill()
|
||||||
|
|
||||||
|
|
||||||
engine = KokoroRu()
|
engine = KokoroRu()
|
||||||
state: dict[str, str | None] = {"status": "loading", "error": None}
|
state: dict[str, str | None] = {"status": "loading", "error": None}
|
||||||
|
|
||||||
@@ -331,6 +427,13 @@ class SpeechRequest(BaseModel):
|
|||||||
speed: float | None = Field(default=None, ge=0.25, le=4.0)
|
speed: float | None = Field(default=None, ge=0.25, le=4.0)
|
||||||
|
|
||||||
|
|
||||||
|
class StreamSpeechRequest(SpeechRequest):
|
||||||
|
# Streaming needs a container that tolerates unknown length up front, so wav
|
||||||
|
# (whose header declares the final sizes) and the raw formats are out. mp3
|
||||||
|
# and opus emit bytes as they go, which is the whole point of the endpoint.
|
||||||
|
response_format: Literal["mp3", "opus"] = "mp3"
|
||||||
|
|
||||||
|
|
||||||
def fail(status: int, message: str, param: str | None = None, code: str | None = None) -> JSONResponse:
|
def fail(status: int, message: str, param: str | None = None, code: str | None = None) -> JSONResponse:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status,
|
status_code=status,
|
||||||
@@ -395,6 +498,58 @@ def create_speech(request: SpeechRequest) -> Response | JSONResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# response_model=None for the same reason as create_speech above.
|
||||||
|
@app.post("/v1/audio/speech/stream", response_model=None)
|
||||||
|
def stream_speech(request: StreamSpeechRequest) -> Response | JSONResponse:
|
||||||
|
if state["status"] != "ready":
|
||||||
|
return fail(503, f"model is not ready: {state['status']}", code="model_not_ready")
|
||||||
|
|
||||||
|
voice = resolve_voice(request.voice)
|
||||||
|
if voice is None:
|
||||||
|
available = ", ".join(engine.available_voices())
|
||||||
|
return fail(
|
||||||
|
400,
|
||||||
|
f"unknown voice {request.voice!r}; available: {available}",
|
||||||
|
param="voice",
|
||||||
|
code="unknown_voice",
|
||||||
|
)
|
||||||
|
|
||||||
|
chunks = engine.iter_audio_chunks(request.input, voice, request.speed or 1.0)
|
||||||
|
try:
|
||||||
|
# Pulled before responding: once the status line is sent it cannot become
|
||||||
|
# a 400, and input with no speakable text has to keep failing that way.
|
||||||
|
first = next(chunks)
|
||||||
|
except StopIteration:
|
||||||
|
return fail(
|
||||||
|
400,
|
||||||
|
"input contains no speakable text for the Russian G2P",
|
||||||
|
param="input",
|
||||||
|
code="no_phonemes",
|
||||||
|
)
|
||||||
|
|
||||||
|
def body() -> Iterator[bytes]:
|
||||||
|
encoder = StreamEncoder(request.response_format)
|
||||||
|
try:
|
||||||
|
encoder.push(first)
|
||||||
|
yield from encoder.drain()
|
||||||
|
for chunk in chunks:
|
||||||
|
encoder.push(chunk)
|
||||||
|
yield from encoder.drain()
|
||||||
|
yield from encoder.finish()
|
||||||
|
except Exception:
|
||||||
|
log.exception("streaming synthesis failed")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
chunks.close()
|
||||||
|
encoder.abort()
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
body(),
|
||||||
|
media_type=CONTENT_TYPES[request.response_format],
|
||||||
|
headers={"model-id": MODEL_ID, "voice-id": voice},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/v1/models")
|
@app.get("/v1/models")
|
||||||
def list_models() -> dict:
|
def list_models() -> dict:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -194,6 +194,17 @@ in
|
|||||||
proxyWebsockets = true;
|
proxyWebsockets = true;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
# sapphira itself: opencode web runs as a systemd user service
|
||||||
|
# (programs.opencode.web.enable in home/modules/opencode.nix) on
|
||||||
|
# 127.0.0.1:4096 with --hostname 0.0.0.0.
|
||||||
|
"opencode.zeroq.su" = {
|
||||||
|
forceSSL = true;
|
||||||
|
enableACME = true;
|
||||||
|
locations."/" = {
|
||||||
|
proxyPass = "http://127.0.0.1:4096";
|
||||||
|
proxyWebsockets = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
"nextcloud.zeroq.su" = {
|
"nextcloud.zeroq.su" = {
|
||||||
forceSSL = true;
|
forceSSL = true;
|
||||||
enableACME = true;
|
enableACME = true;
|
||||||
|
|||||||
+37
-4
@@ -8,7 +8,9 @@ let
|
|||||||
user = "${xlib.device.username}";
|
user = "${xlib.device.username}";
|
||||||
userGroup = config.users.users."${user}".group;
|
userGroup = config.users.users."${user}".group;
|
||||||
|
|
||||||
# sops secret factory: name == key by default, owner/group default to root
|
# sops secret factory: name == key by default, owner/group default to root.
|
||||||
|
# `format` and `sopsFile` default to yaml + defaultSopsFile, matching every
|
||||||
|
# pre-existing caller.
|
||||||
mkSecret =
|
mkSecret =
|
||||||
{
|
{
|
||||||
path,
|
path,
|
||||||
@@ -16,14 +18,16 @@ let
|
|||||||
key ? null,
|
key ? null,
|
||||||
owner ? null,
|
owner ? null,
|
||||||
group ? null,
|
group ? null,
|
||||||
|
format ? "yaml",
|
||||||
|
sopsFile ? null,
|
||||||
}:
|
}:
|
||||||
{
|
{
|
||||||
format = "yaml";
|
inherit format path mode;
|
||||||
inherit path mode;
|
|
||||||
}
|
}
|
||||||
// lib.optionalAttrs (key != null) { inherit key; }
|
// lib.optionalAttrs (key != null) { inherit key; }
|
||||||
// lib.optionalAttrs (owner != null) { inherit owner; }
|
// lib.optionalAttrs (owner != null) { inherit owner; }
|
||||||
// lib.optionalAttrs (group != null) { inherit group; };
|
// lib.optionalAttrs (group != null) { inherit group; }
|
||||||
|
// lib.optionalAttrs (sopsFile != null) { inherit sopsFile; };
|
||||||
|
|
||||||
# default owner = device user
|
# default owner = device user
|
||||||
mkUserSecret =
|
mkUserSecret =
|
||||||
@@ -98,6 +102,35 @@ in
|
|||||||
path = "${xlib.dirs.user-home}/.config/sops/age/keys.txt";
|
path = "${xlib.dirs.user-home}/.config/sops/age/keys.txt";
|
||||||
mode = "0600";
|
mode = "0600";
|
||||||
};
|
};
|
||||||
|
# opencode web server creds + Gemini API key.
|
||||||
|
# Decrypted as a single dotenv file (no `key`) and consumed by the
|
||||||
|
# systemd user unit opencode-web as EnvironmentFile.
|
||||||
|
# Source: secrets/opencode.env (encrypted, see sops/age below).
|
||||||
|
opencode_server = mkUserSecret {
|
||||||
|
path = "${xlib.dirs.user-home}/.config/opencode/server.env";
|
||||||
|
mode = "0600";
|
||||||
|
format = "dotenv";
|
||||||
|
sopsFile = ../secrets/opencode.env;
|
||||||
|
};
|
||||||
|
# opencode provider credentials (XDG_DATA_HOME/opencode/...).
|
||||||
|
# Both files are read by opencode at startup to populate the providers
|
||||||
|
# list. Mirror of ~/.local/share/opencode/ on the workstation.
|
||||||
|
# key = "" → decrypt the WHOLE file as-is (the JSON has no top-level
|
||||||
|
# field named after the secret; it IS the secret).
|
||||||
|
opencode_auth = mkUserSecret {
|
||||||
|
path = "${xlib.dirs.user-home}/.local/share/opencode/auth.json";
|
||||||
|
mode = "0600";
|
||||||
|
format = "json";
|
||||||
|
sopsFile = ../secrets/opencode-auth.json;
|
||||||
|
key = "";
|
||||||
|
};
|
||||||
|
opencode_account = mkUserSecret {
|
||||||
|
path = "${xlib.dirs.user-home}/.local/share/opencode/account.json";
|
||||||
|
mode = "0600";
|
||||||
|
format = "json";
|
||||||
|
sopsFile = ../secrets/opencode-account.json;
|
||||||
|
key = "";
|
||||||
|
};
|
||||||
ssh_key_private = mkUserSecret {
|
ssh_key_private = mkUserSecret {
|
||||||
path = "${xlib.dirs.user-home}/.ssh/id_ed25519";
|
path = "${xlib.dirs.user-home}/.ssh/id_ed25519";
|
||||||
mode = "0600";
|
mode = "0600";
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"version": "ENC[AES256_GCM,data:Og==,iv:7CSdsBk5u2UKOn1dE6CYOiPlmYYkUmmxV1VD6nVIIoc=,tag:z1z57WidbvdlIY5CHQU/TA==,type:int]",
|
||||||
|
"accounts": {
|
||||||
|
"fa02561b1001pVHOSO4a6JW9Cv": {
|
||||||
|
"id": "ENC[AES256_GCM,data:Xm+h0mYIkOAhRI2MZt/nNxMZ+UW7twFu3a4=,iv:PQlE5hc5UPpShQju31AjNKlmaBovCH0eKGnMi1wIfwg=,tag:P1qA5OAQMyICDk52m3jCfA==,type:str]",
|
||||||
|
"serviceID": "ENC[AES256_GCM,data:5S0wMZ1ES5GjSnp1uXhuxLdjlA==,iv:6DvaCiDjBo/tKpjVSazxSNtticZjAmrcA00jjzXsKmc=,tag:S24kxmT6GJyoKSywJGCYlw==,type:str]",
|
||||||
|
"description": "ENC[AES256_GCM,data:HEISFOphDA==,iv:3IvEjXPs1RA0xeOO2k3ECdGUXb55ueR0yxJSdWDqqho=,tag:YgtEMx7nPxgY4xjr8B3isA==,type:str]",
|
||||||
|
"credential": {
|
||||||
|
"type": "ENC[AES256_GCM,data:kdOU,iv:8umxWXKvaDqYO5woOQDqTuuwtdgJ7oVJD8XU7D841k4=,tag:QBRDcCCIqbfbvY3d2CD67w==,type:str]",
|
||||||
|
"key": "ENC[AES256_GCM,data:PGzIgMDQkclf4RiDO3lQE/fQ4V0hM6nvFB/XpUqdMiAKvW/cQyCdmxdwwJQe4FSPHwyYzYDfi0VULf/tfYq+hOx5mC5F0/9ldLw2cbf68TPdcCMjIZEokLUAqe0qJlTnGxdO4PRzzL1LvOKr3Mlo4KcgoUpmeFPxwvxL2Wk=,iv:Z4gna9cUuz3YjtS2v0+WsKmJV66dryl+XtBdLbUGhrI=,tag:WPgVnEFfrJtG2pXRHGXVDQ==,type:str]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"active": {
|
||||||
|
"minimax-coding-plan": "ENC[AES256_GCM,data:Bt0Yhiz5qmJ1BIU8bDle7mVtyVC6r/lX4gY=,iv:CRmuL1bL0Jc+RFeoSbZyyIHSyBd/RojNjzzVRRODFjs=,tag:mOdKWxDWQLgYSVYiM6hugw==,type:str]"
|
||||||
|
},
|
||||||
|
"sops": {
|
||||||
|
"age": [
|
||||||
|
{
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAyUk03UnVZOUtKcDJhTUdy\nMUhBcDNROGxHR1RPcEZjeHdnYmtWck5KcVZnClIvckVxZmdBQWg3b0FsVFRVRVBP\nRUVaVTFqeDFQbVYvNk42MnlFVlJpTmsKLS0tIFBBaWJwb09ySGFGUHZsajhlb01v\nek10Q3FBWUM5Wms0UUFtV1p1b29mL1kK+hr3lbwBDmtedEeA0hnXSAxC/HOE/9iz\n5kMSbzno+UXnVG/EfLHsks2G43caBmCZlUp7spTt5DLnpwL923GnFQ==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||||
|
"recipient": "age13l2gtk0nzr484zprp7e0pkrt0ne0j4asyn2pjmlaw73nte7t7d8q4sqtxm"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lastmodified": "2026-10-03T13:34:07Z",
|
||||||
|
"mac": "ENC[AES256_GCM,data:e+4yZeDnprtRi1jkP8A9DxNOjemIIi9VwOANAnT2f1UEJVBm6v3MPrlLPZ3Kyg7I2wnIw25Ih6boDn92fxWrIut8PmFPFmlCUu0v4mK49YQmB4dA/i/RYQMAZ6pG2JtPMUWOYsHppU67RB/hKQmJigZSz49tBOHiDrgUa+kfK9c=,iv:872D82r8gIl+mMYNy7iEhnxG/1HwrNg1TO6QXIf7Vo4=,tag:Sd8pSaYZhRxRY6jUL9DxhQ==,type:str]",
|
||||||
|
"unencrypted_suffix": "_unencrypted",
|
||||||
|
"version": "3.13.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"minimax-coding-plan": {
|
||||||
|
"type": "ENC[AES256_GCM,data:cW4a,iv:giJwLOEm5ZoyX1fly0R0xTUsMqtAClmpuSk6d9kaHb4=,tag:YrR/kW3ZFOxot9WeP9kibw==,type:str]",
|
||||||
|
"key": "ENC[AES256_GCM,data:SvZTe8f3/Es1/DOLpcXuY7IyJpLlkuEN38wUd1uBdOdkzA9QZxkroQ93fuvyqSDFAIfDEfSQsAElME3VzaFVB0Y8t97wB2gXWm4xHXjFyzcu+uaOq/deBQ+B13/xMFfjsMlKR1H1WJ4VRZYY3sc70Wsvid+6hxOdO/a/i3k=,iv:x53HYXFeQ9NEMr5SDM8KEOsrzIMzdNAtSeY26FdKkMw=,tag:uW2xCO+cA7nKHWHpBZCVkw==,type:str]"
|
||||||
|
},
|
||||||
|
"sops": {
|
||||||
|
"age": [
|
||||||
|
{
|
||||||
|
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBlSkw3Z0VrTjBBa1VsQlRp\nbHUrZ3hXb1Q1Z0kxdVJhV1hVWnNLUUdDclhjCjg0aTNuUlhNNzdFajhPNE1DN2Fn\nZzJZNVRUYUxpNDFJK3pLTkE4UWFsbkUKLS0tIE8wUkZuN21aaXhpZlNzUnBZR0tC\nU2xwcjRJNnRPdTJ6elRhS08ybjlOS1kKBIfDuZSIOFoxCUc21GnqxipT0ouxDHsB\nXGBvj8zkJ+07tDVMYAhjWYkQK9wBNtUMvgxKedvLZNIn0Hm0Z0rPkw==\n-----END AGE ENCRYPTED FILE-----\n",
|
||||||
|
"recipient": "age13l2gtk0nzr484zprp7e0pkrt0ne0j4asyn2pjmlaw73nte7t7d8q4sqtxm"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lastmodified": "2026-10-03T13:34:07Z",
|
||||||
|
"mac": "ENC[AES256_GCM,data:lUAw+AL62sxoy695aV1lYgUm4JQwzC+7c36vupe/mJCeNNzVm5ZadGaGsno28EmF4fGxOVsJzn939nhNRtQZ6MwZNhShoSZBmpO51vHRm1YsfAR1sWi/u9akbcu906fjrJLyql9sWWmnxiqxn70gq4Ov6ptsx0VjtiwyWOk+cps=,iv:zckNKtUMu+4DKYp9hwbMPtm6bh46iRNGguff3AIfOa0=,tag:b7svS76JLEJmb+gkPNhQhQ==,type:str]",
|
||||||
|
"unencrypted_suffix": "_unencrypted",
|
||||||
|
"version": "3.13.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
OPENCODE_SERVER_USERNAME=ENC[AES256_GCM,data:6dqD4TnURrk=,iv:UUOBwiykDe9Wv/78wmmx5JnJEWScQRQh2yM+806lF7Y=,tag:vpSB652uQ+ASZQh4PS12Sw==,type:str]
|
||||||
|
OPENCODE_SERVER_PASSWORD=ENC[AES256_GCM,data:mAIHJ5+pupEFdbTurc6davXecgPrHA==,iv:n3BNhwxbJJ6WVq02ANUi0nNAploCsPQIF/JBT1TXxHg=,tag:2YKnOytFny9x8rg8X/7nxA==,type:str]
|
||||||
|
GEMINI_API_KEY=ENC[AES256_GCM,data:hKbpsv1ZrhROPMHYUUAc/oErz0JPSORLr7/B8N1VgbqVZ1FoVf7LM5o=,iv:+3hzXJkjSwpBdwB231PnuE7T+c7A6bmYCckKAqljO0Q=,tag:VVKsFxptQEg6GZAdCQ1A+g==,type:str]
|
||||||
|
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBPZFdZZUZxOXRXUUF1NVdV\nRmUrQ2FqeGJWdTZvVkxncEpBb3FpaW5VUEM4Ck1WQ0xLVzBrOG1IOER1dXhxZUEy\nTnV3SnlFSi83b3VGdWtQZ0hYeXRyNEUKLS0tIHhqai9mYVRmR091S0w5cmNMK0Y0\nZVVUdzB6Ky9PUFExclBNcnZUQWFrOXcKQq4qlRz5+1GR3LB9/CkZSz1nyFthk7mg\n2j3wUXQ41kyRUu8pM40eCxHVkpMaa/7fjZ40nRjrnwZ7Brd5Q8z3MQ==\n-----END AGE ENCRYPTED FILE-----\n
|
||||||
|
sops_age__list_0__map_recipient=age13l2gtk0nzr484zprp7e0pkrt0ne0j4asyn2pjmlaw73nte7t7d8q4sqtxm
|
||||||
|
sops_lastmodified=2026-10-03T12:47:19Z
|
||||||
|
sops_mac=ENC[AES256_GCM,data:thYwpX+SrNRL3hdvUzXPzh3Q07Dt6ZF6cplKS5Iopj7twS5lQoB4C1OuWf00GUUPDEOaxF3WK24XiRkMoA8TZHSLJ/k8HPV9tDlPnNHMh3pMj9pIfR5NTDMASmld17PjBvwPMOB/uvMn26O1G6G3ImW4Zioy3AyDP2zmed9+3Xk=,iv:uKGvvwvDTEQom636P9YcUjLMpIrRusCFI9HJqNJihkw=,tag:Qfc5KRSNn+Yy74pKKvkjOA==,type:str]
|
||||||
|
sops_unencrypted_suffix=_unencrypted
|
||||||
|
sops_version=3.13.3
|
||||||
Reference in New Issue
Block a user