Compare commits

...
2 Commits
Author SHA1 Message Date
Codex 56e0305188 fix(tools-pve/asr-pipeline): mount gitea ssh key for Obsidian vault sync and fix provider source_detail
Build ASR Pipeline / image (push) Canceled after 0s
Build image / build (push) Canceled after 0s
2026-11-02 02:57:42 -08:00
Codex c42456be22 Retire ZenMux subscription and keep BYOK as terminal fallback 2026-11-01 23:25:00 -08:00
23 changed files with 494 additions and 58 deletions
+11
View File
@@ -1,5 +1,16 @@
# DC1 公网模型代理
## 2026-11-02:取消 ZenMux 订阅
已禁用订阅 Key。Claude/开源模型采用 OpenRouter → ZenMux BYOK,后者只作最后备用;
显式 `zenmux-payg/...` 仍直连 BYOK。旧 `zenmux/...` 名称保留作兼容入口,
不代表订阅后端仍存在;新配置优先用 `auto/...`。
`asr-gpt`:OpenAI 官方 → ZenMux BYOK;`asr-qwen`:已配置的百炼北京 → ZenMux BYOK。
豆包/MiMo 没有已配置的同模型替代后端,因此只能经 BYOK,不跨型号替换。
SMART、个人词库及会议处理配置不变。BYOK 是按量付费,不与 OpenRouter 共享缓存。
OpenRouter Claude 合成直连在两台生产主机均返回条款限制类 403,首选上游未通过验收。
2026-10-29从HK迁移,保留`https://api.opfo.xyz/v1`、原客户端认证、全部旧别名、
Vertex ADC与SMART个人听写、四款ZenMux ASR、百炼及开源模型主备。
无数据库,不迁移`.49`的OAuth或数据库,不从其他Agent复制refresh token。
+1 -1
View File
@@ -34,7 +34,6 @@ services:
VERTEXAI_LOCATION: ${VERTEXAI_LOCATION:-global}
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY}
VERTEXAI_ENABLED: "true"
ZENMUX_SUBSCRIPTION_API_KEY: ${ZENMUX_SUBSCRIPTION_API_KEY:-}
ZENMUX_PAYG_API_KEY: ${ZENMUX_PAYG_API_KEY:-}
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
@@ -43,6 +42,7 @@ services:
volumes:
- ./models.yaml:/etc/litellm/models.yaml:ro
- ./provider_routes.py:/etc/litellm/provider_routes.py:ro
- ./provider_policy.py:/etc/litellm/provider_policy.py:ro
- ./render_config.py:/etc/litellm/render_config.py:ro
- ./vertex_dictation.py:/etc/litellm/vertex_dictation.py:ro
- ./zenmux_transcription.py:/etc/litellm/zenmux_transcription.py:ro
+2 -1
View File
@@ -1,4 +1,5 @@
# 除 Google 自带 latest 别名外,本项目映射不会自行追踪上游发布。
zenmux_subscription_enabled: false
zenmux:
claude-sonnet-latest: anthropic/claude-sonnet-5.5
claude-opus-latest: anthropic/claude-opus-5.5
@@ -10,7 +11,7 @@ openrouter:
glm-flash-latest: z-ai/glm-5.3-flash
kimi-latest: moonshotai/kimi-k3
mimo-flash-latest: xiaomi/mimo-v2.6-flash
# These existing public aliases are subscription-first, not load-balanced.
# OpenRouter first; ZenMux BYOK is the final same-model fallback.
zenmux_open_models:
deepseek-flash-latest: deepseek/deepseek-v4.1-flash
deepseek-pro-latest: deepseek/deepseek-v4-pro
@@ -0,0 +1,118 @@
"""Subscription retirement: existing aliases stay usable, BYOK is terminal only."""
from copy import deepcopy
def subscription_disabled(catalog):
return catalog.get('zenmux_subscription_enabled') is False
def without_subscription(catalog, env):
if not subscription_disabled(catalog):
return env
return {key: value for key, value in env.items()
if key != 'ZENMUX_SUBSCRIPTION_API_KEY'}
def apply_provider_policy(config, catalog, env):
if not subscription_disabled(catalog):
return config
routes = config['model_list']
if any(r['litellm_params'].get('api_key') ==
'os.environ/ZENMUX_SUBSCRIPTION_API_KEY' for r in routes):
raise ValueError('Retired subscription route remains')
indexed = {r['model_name']: r for r in routes}
groups = {k: list(v) for row in config['router_settings']['fallbacks']
for k, v in row.items()}
def add(name, params):
if name in indexed:
return indexed[name]
route = {'model_name': name, 'litellm_params': deepcopy(params)}
indexed[name] = route
routes.append(route)
return route
def fallback(source, target):
if source == target or source not in indexed or target not in indexed:
return
targets = groups.setdefault(source, [])
if target not in targets:
targets.append(target)
def payg_params(model, anthropic=False):
return {'model': ('anthropic/' if anthropic else 'openai/') + model,
'api_key': 'os.environ/ZENMUX_PAYG_API_KEY',
'api_base': 'https://zenmux.ai/api/anthropic' if anthropic
else 'https://zenmux.ai/api/v1',
'max_retries': 0}
# Explicit BYOK aliases remain direct; automatic/legacy aliases prefer OpenRouter.
for family in ('zenmux', 'zenmux_open_models'):
for alias, model in catalog.get(family, {}).items():
byok = 'zenmux-payg/' + alias
if env.get('ZENMUX_PAYG_API_KEY'):
add(byok, payg_params(model, anthropic=family == 'zenmux'))
primary = None
if env.get('OPENROUTER_API_KEY'):
primary = {'model': 'openrouter/' + model,
'api_key': 'os.environ/OPENROUTER_API_KEY', 'max_retries': 0}
elif byok in indexed:
primary = indexed[byok]['litellm_params']
if primary is None:
continue
names = [alias, 'auto/' + alias, 'zenmux/' + alias]
if env.get('OPENROUTER_API_KEY'):
names += ['openrouter/' + alias]
if family == 'zenmux_open_models':
names += [alias + '-openrouter']
for name in names:
route = add(name, primary)
if route['litellm_params'].get('api_key') != 'os.environ/ZENMUX_PAYG_API_KEY':
fallback(name, byok)
# Former Gemini subscription aliases follow the existing Google primary.
for alias, model in catalog.get('zenmux_chat', {}).items():
byok = 'zenmux-payg/' + alias.replace('-zenmux', '-latest')
if env.get('ZENMUX_PAYG_API_KEY'):
add(byok, payg_params(model))
google = indexed.get('aistudio/gemini-flash-latest') or indexed.get('vertex/gemini-flash-latest')
primary = google or indexed.get(byok)
if primary:
for name in [alias, 'zenmux/' + alias.replace('-zenmux', '-latest')]:
route = add(name, primary['litellm_params'])
if route['litellm_params'].get('api_key') != 'os.environ/ZENMUX_PAYG_API_KEY':
fallback(name, byok)
# Public ASR compatibility aliases use their same-model direct provider first.
asr = {'asr-gpt': ('openai/gpt-transcribe', 'openai/gpt-transcribe'),
'asr-qwen': ('bailian-cn/qwen-audio-3.1-asr-flash', 'qwen/qwen-audio-3.1-asr-flash'),
'asr-doubao': (None, 'bytedance/doubao-seed-asr-2.0'),
'asr-mimo': (None, 'xiaomi/mimo-v2.5-asr')}
for alias, (direct, model) in asr.items():
# Do not introduce new ASR models to the LAN instance.
if not config.get('_public_gateway'):
break
byok = 'zenmux-payg/' + model.split('/', 1)[1]
if env.get('ZENMUX_PAYG_API_KEY'):
add(byok, payg_params(model))
primary = indexed.get(direct) or indexed.get(byok)
if not primary:
continue
if alias in indexed:
indexed[alias]['litellm_params'] = deepcopy(primary['litellm_params'])
else:
add(alias, primary['litellm_params'])
# Old explicit ZenMux names are compatibility aliases, no subscription calls.
add('zenmux/' + model.split('/', 1)[1], primary['litellm_params'])
if primary['litellm_params'].get('api_key') != 'os.environ/ZENMUX_PAYG_API_KEY':
fallback(alias, byok)
fallback('zenmux/' + model.split('/', 1)[1], byok)
config.pop('_public_gateway', None)
config['router_settings']['fallbacks'] = [{k: v} for k, v in groups.items() if v]
for source, targets in groups.items():
if source not in indexed or any(t not in indexed for t in targets):
raise ValueError('Dangling fallback: ' + source)
if indexed[source]['litellm_params'].get('api_key') == 'os.environ/ZENMUX_PAYG_API_KEY':
raise ValueError('BYOK must be terminal: ' + source)
return config
@@ -5,6 +5,7 @@ from copy import deepcopy
from pathlib import Path
import yaml
from provider_policy import without_subscription
def oauth_ready(path):
@@ -92,6 +93,7 @@ def add_bailian_routes(config, env, vocabulary_path):
def build_config(catalog, env, exists, namespaced=True):
env = without_subscription(catalog, env)
routes = []
fallbacks = []
+4 -1
View File
@@ -5,9 +5,11 @@ from pathlib import Path
import yaml
from provider_routes import build_config, namespace_config, add_bailian_routes
from provider_policy import without_subscription, apply_provider_policy
def build_hk_config(catalog, env):
env = without_subscription(catalog, env)
config = build_config(catalog, env, lambda path: False, namespaced=False)
config['general_settings'] = {'master_key': 'os.environ/LITELLM_MASTER_KEY'}
routes = config['model_list']
@@ -62,7 +64,8 @@ def build_hk_config(catalog, env):
'api_key': 'os.environ/OPENAI_API_KEY', 'max_retries': 0}})
if env.get('ZENMUX_SUBSCRIPTION_API_KEY'):
config['router_settings']['fallbacks'].append({'asr-gpt': ['zenmux/gpt-transcribe']})
return config
config['_public_gateway'] = True
return apply_provider_policy(config, catalog, env)
if __name__ == '__main__':
+9 -7
View File
@@ -9,13 +9,13 @@ from render_config import build_hk_config
class RoutingTests(unittest.TestCase):
def test_official_api_names_do_not_replace_hk_routes(self):
catalog = yaml.safe_load(Path(__file__).with_name('models.yaml').read_text())
env = {'ZENMUX_SUBSCRIPTION_API_KEY': 'synthetic', 'OPENAI_API_KEY': 'synthetic'}
env = {'ZENMUX_SUBSCRIPTION_API_KEY': 'synthetic', 'ZENMUX_PAYG_API_KEY': 'synthetic', 'OPENAI_API_KEY': 'synthetic'}
config = build_hk_config(catalog, env)
routes = {r['model_name']: r['litellm_params'] for r in config['model_list']}
self.assertEqual(routes['openai/gpt-transcribe']['api_base'], 'https://api.openai.com/v1')
self.assertEqual(routes['zenmux/gpt-transcribe']['api_base'], 'https://zenmux.ai/api/v1')
self.assertEqual(routes['zenmux/gpt-transcribe']['api_base'], 'https://api.openai.com/v1')
self.assertEqual(routes['asr-gpt'], routes['openai/gpt-transcribe'])
self.assertIn({'asr-gpt': ['zenmux/gpt-transcribe']}, config['router_settings']['fallbacks'])
self.assertIn({'asr-gpt': ['zenmux-payg/gpt-transcribe']}, config['router_settings']['fallbacks'])
self.assertEqual(routes['openai/gpt-sol-latest']['model'], 'openai/gpt-6.1-sol')
self.assertNotIn('codex/gpt-sol-latest', routes)
self.assertNotIn('synthetic', yaml.safe_dump(config))
@@ -25,14 +25,14 @@ class RoutingTests(unittest.TestCase):
config = build_hk_config(catalog, {'OPENAI_API_KEY': 'synthetic'})
routes = {r['model_name']: r['litellm_params'] for r in config['model_list']}
self.assertEqual(routes['asr-gpt']['model'], 'openai/gpt-transcribe')
self.assertNotIn('zenmux/gpt-transcribe', routes)
self.assertEqual(routes['zenmux/gpt-transcribe'], routes['asr-gpt'])
self.assertNotIn({'asr-gpt': ['zenmux/gpt-transcribe']}, config['router_settings']['fallbacks'])
def test_bailian_personal_uses_hk_private_vocabulary(self):
catalog = yaml.safe_load(Path(__file__).with_name('models.yaml').read_text())
routes = build_hk_config(catalog, {'BAILIAN_CN_API_KEY':'synthetic',
'BAILIAN_CN_API_BASE':'https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1'})['model_list']
self.assertEqual(len(routes),2)
self.assertEqual(len(routes),4)
self.assertEqual(routes[1]['litellm_params']['bailian_vocabulary_path'],
'/credentials/personal-vocabulary.json')
@@ -56,10 +56,12 @@ class RoutingTests(unittest.TestCase):
self.assertEqual(routes['asr-mimo']['model'], 'openai/xiaomi/mimo-v2.5-asr')
for alias in ['asr-gpt', 'asr-qwen', 'asr-doubao', 'asr-mimo']:
self.assertNotIn('personal_dictation', routes[alias])
self.assertEqual(routes[alias]['api_key'], 'os.environ/ZENMUX_SUBSCRIPTION_API_KEY')
self.assertEqual(routes[alias]['api_key'], 'os.environ/ZENMUX_PAYG_API_KEY')
self.assertEqual(routes['gemini-transcribe']['model'], 'gemini/gemini-3.5-transcribe')
self.assertEqual(routes['gemini-3.6-flash']['model'], 'vertex_ai/gemini-3.8-flash')
self.assertEqual(len(result['router_settings']['fallbacks']), 12)
self.assertTrue(result['router_settings']['fallbacks'])
self.assertNotIn('os.environ/ZENMUX_SUBSCRIPTION_API_KEY', yaml.safe_dump(result))
self.assertEqual(routes['auto/glm-latest']['api_key'], 'os.environ/OPENROUTER_API_KEY')
self.assertEqual(routes['vertex/gemini-transcribe-personal'], routes['gemini-transcribe-personal'])
self.assertNotIn('aistudio/gemini-transcribe-personal', routes)
self.assertEqual(routes['zenmux/gpt-transcribe'], routes['asr-gpt'])
+1 -1
View File
@@ -2,7 +2,7 @@ FROM python:3.13-slim-bookworm@sha256:ed86c82274b3c69b52fb5820f358f0bd7df0b60333
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates curl ffmpeg git tini \
ca-certificates curl ffmpeg git openssh-client tini \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
@@ -16,12 +16,18 @@ import urllib.error
import urllib.request
import uuid
from dataclasses import dataclass, field
import http.client
from pathlib import Path
from typing import Any
from .config import CFG
from .log import log
class EmptyAudioError(RuntimeError):
"""Raised when an audio file contains silence/noise or produces zero ASR speech tokens."""
pass
PROVIDERS: dict[str, dict[str, str]] = {
"qwen3cam": {
"label": "内网 Qwen3-ASR 1.7B + CAM++ (分角色 + 声纹)",
@@ -203,7 +209,9 @@ def format_diarized_transcript(
matched_names: list[str] | None = None,
) -> str:
if not segments:
raise RuntimeError(f"ASR ({provider}) response contains no segments")
raise EmptyAudioError(
f"ASR ({provider}) 未识别到有效语音片段(音频可能为静音、设备未拾音或音量过低)"
)
speaker_map: dict[str, str] = {}
turns: list[list[str]] = []
speakers_seen: set[str] = set()
@@ -221,7 +229,9 @@ def format_diarized_transcript(
else:
turns.append([spk, text])
if not turns:
raise RuntimeError(f"ASR ({provider}) segments contained no text")
raise EmptyAudioError(
f"ASR ({provider}) 未识别到任何有效文字(音频可能为静音或底噪)"
)
lines = [f"<!-- asr_provider: {provider} model: {model} -->"]
if voiceprint_session or provider == "qwen3cam":
@@ -240,7 +250,7 @@ def format_diarized_transcript(
def transcript_from_qwen3cam_response(payload: dict[str, Any]) -> str:
segments = payload.get("segments")
if not isinstance(segments, list) or not segments:
raise RuntimeError("ASR response contains no segments")
raise EmptyAudioError("ASR (qwen3cam) 未识别到有效语音片段(音频可能为静音、设备未拾音或音量过低)")
vp = payload.get("voiceprint") or {}
sid = str(vp.get("session_id") or "")
matched = [
@@ -431,16 +441,33 @@ def _http_multipart(
file_path: Path,
headers: dict[str, str] | None = None,
timeout: int = 900,
retries: int = 3,
) -> dict[str, Any]:
data, content_type = _encode_multipart(fields, file_field, file_path)
req_headers = {"Content-Type": content_type, **(headers or {})}
req = urllib.request.Request(url, data=data, headers=req_headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", errors="ignore")
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="ignore")[:300]
raise RuntimeError(f"ASR HTTP {exc.code}: {detail}") from exc
last_exc: Exception | None = None
for attempt in range(1, max(1, retries) + 1):
req = urllib.request.Request(url, data=data, headers=req_headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", errors="ignore")
break
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="ignore")[:300]
if exc.code in (502, 503, 504) and attempt < retries:
last_exc = exc
time.sleep(2 * attempt)
continue
raise RuntimeError(f"ASR HTTP {exc.code}: {detail}") from exc
except (urllib.error.URLError, http.client.HTTPException, ConnectionError, TimeoutError, OSError) as exc:
last_exc = exc
if attempt < retries:
log(f"multipart upload transient error (attempt {attempt}/{retries}): {exc}")
time.sleep(2 * attempt)
continue
raise RuntimeError(f"ASR upload connection error after {retries} attempts: {exc}") from exc
else:
raise RuntimeError(f"ASR upload failed: {last_exc}")
try:
return json.loads(raw)
except ValueError as exc:
@@ -454,18 +481,34 @@ def _http_json(
payload: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
timeout: int = 120,
retries: int = 3,
) -> dict[str, Any]:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else None
req_headers = {"Accept": "application/json", **(headers or {})}
if data is not None:
req_headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=req_headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", errors="ignore")
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="ignore")[:300]
raise RuntimeError(f"ASR HTTP {exc.code}: {detail}") from exc
last_exc: Exception | None = None
for attempt in range(1, max(1, retries) + 1):
req = urllib.request.Request(url, data=data, headers=req_headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", errors="ignore")
break
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="ignore")[:300]
if exc.code in (502, 503, 504) and attempt < retries:
last_exc = exc
time.sleep(1.5 * attempt)
continue
raise RuntimeError(f"ASR HTTP {exc.code}: {detail}") from exc
except (urllib.error.URLError, http.client.HTTPException, ConnectionError, TimeoutError, OSError) as exc:
last_exc = exc
if attempt < retries:
time.sleep(1.5 * attempt)
continue
raise RuntimeError(f"ASR HTTP request failed after {retries} attempts: {exc}") from exc
else:
raise RuntimeError(f"ASR HTTP request failed: {last_exc}")
if not raw.strip():
return {}
try:
@@ -575,14 +618,29 @@ def transcribe_soniox(
chosen_model = model or CFG.soniox_model or "stt-async-v5"
headers = {"Authorization": f"Bearer {key}"}
file_resp = _http_multipart(
f"{base}/files",
fields={},
file_field="file",
file_path=audio,
headers=headers,
timeout=600,
)
with tempfile.TemporaryDirectory(prefix="asr-soniox-") as tmp:
upload_path = audio
if audio.exists() and audio.stat().st_size > 8 * 1024 * 1024 and shutil.which("ffmpeg"):
compressed = Path(tmp) / "soniox_upload.mp3"
try:
subprocess.run([
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-i", str(audio), "-ar", "16000", "-ac", "1", "-b:a", "48k",
str(compressed),
], check=True, timeout=600)
if compressed.exists() and 0 < compressed.stat().st_size < audio.stat().st_size:
upload_path = compressed
except Exception as exc:
log(f"soniox audio pre-compress skipped: {exc}")
file_resp = _http_multipart(
f"{base}/files",
fields={},
file_field="file",
file_path=upload_path,
headers=headers,
timeout=600,
)
file_id = str(file_resp.get("id") or "")
if not file_id:
raise RuntimeError(f"Soniox file upload returned no id: {file_resp}")
@@ -622,7 +680,7 @@ def transcribe_soniox(
else:
raise RuntimeError("Soniox transcription timed out waiting for completion")
if "tokens" in status_resp or "segments" in status_resp:
if status_resp.get("tokens") or status_resp.get("segments"):
transcript_payload = status_resp
else:
transcript_payload = _http_json(
@@ -377,9 +377,13 @@ def write_draft(cleaned: str, meta: dict, context: str, date: str,
if ctx:
ctx = "本场背景(来自既有笔记与长期记忆;引用其中内容时必须按上面第 3 条加 〔背景〕):\n" \
+ context
detail = SOURCE_DETAIL.get(meta.get("backend", ""), "ASR")
backend = meta.get("backend") or meta.get("asr_provider") or ""
model_name = meta.get("asr_model") or ""
detail = SOURCE_DETAIL.get(backend, "ASR")
if model_name and backend in ("soniox", "gemini", "openai", "grok") and model_name not in detail:
detail = f"{detail} ({model_name})"
prompt = prompts.DRAFT_PROMPT.format(
speaker_note=prompts.SPEAKER_NOTE.get(meta.get("backend", ""), ""),
speaker_note=prompts.SPEAKER_NOTE.get(backend, ""),
context=ctx, date=date, source_detail=detail,
transcript=transcript_path, extra_fm=extra, body=cleaned)
return chat(CFG.draft_model, prompt, max_tokens=12000, usage=usage)
@@ -54,12 +54,14 @@ def _safe_title(markdown: str) -> str:
return title[:72] or "通话记录"
def _formal_path(markdown: str) -> Path:
def _formal_path(markdown: str, root: Path | None = None) -> Path:
target_dir = root or CFG.minutes
target_dir.mkdir(parents=True, exist_ok=True)
date = _field(markdown, "date") or time.strftime("%Y-%m-%d")
base = CFG.minutes / f"{date}-{_safe_title(markdown)}.md"
base = target_dir / f"{date}-{_safe_title(markdown)}.md"
transcript = _field(markdown, "transcript")
if CFG.minutes.exists() and transcript:
for path in CFG.minutes.glob("*.md"):
if target_dir.exists() and transcript:
for path in target_dir.glob("*.md"):
try:
if _field(path.read_text(errors="ignore"), "transcript") == transcript:
return path
@@ -256,13 +258,21 @@ def publish(path: Path) -> bool:
destination = _formal_path(out)
vault.write_draft_file(destination, out)
vault_minutes_dir = CFG.vault / "minutes"
vault_destination = (
destination
if destination.parent.resolve() == vault_minutes_dir.resolve()
else (vault_minutes_dir / destination.name)
)
if vault_destination != destination:
vault.write_draft_file(vault_destination, out)
date = _field(out, "date") or time.strftime("%Y-%m-%d")
touched = [destination]
touched = [vault_destination]
for name, kind in _answered_card_requests(path):
card = _create_card(name, kind, destination, date)
card = _create_card(name, kind, vault_destination, date)
if card:
touched.append(card)
state = vault.sync(touched, f"minutes: {destination.stem}(Web 确认后正式发布)")
state = vault.sync(touched, f"minutes: {vault_destination.stem}(Web 确认后正式发布)")
if state == "error":
raise RuntimeError("formal minute commit failed")
@@ -22,7 +22,7 @@ import re
from dataclasses import dataclass, field
VOICEPRINT_HDR = re.compile(r"^<!--\s*voiceprint_session:")
PROVIDER_HDR = re.compile(r"^<!--\s*asr_provider:\s*(\S+)(?:\s+model:\s*(\S+))?\s*-->")
PROVIDER_HDR = re.compile(r"^<!--\s*asr_provider:\s*(\S+)(?:\s+model:\s*(\S+))?\s*-->", re.M)
SEGMENT_BANNER = re.compile(r"^---\s*第\d+段.*---\s*$")
TURN_TAG = re.compile(r"^【([^】]+)】\s*(.*)$", re.S)
VIBE_TAG = re.compile(r"^\[(\d{1,2}:\d{2})\]\s*(S\d+)\s*[::]\s*(.*)$", re.S)
@@ -148,8 +148,10 @@ def parse(raw: str) -> tuple[list[Turn], dict]:
continue
body_lines.append(ln)
backend = detect_backend(raw)
backend = meta["asr_provider"] or str(meta["intake"].get("asr_provider") or "") or detect_backend(raw)
meta["backend"] = backend
if not meta["asr_model"] and meta["intake"].get("asr_model"):
meta["asr_model"] = str(meta["intake"]["asr_model"])
turns: list[Turn] = []
if backend == "vibevoice":
@@ -90,8 +90,26 @@ def update_pending(date: str, title: str, path: Path) -> None:
def _git(*args, check=False) -> subprocess.CompletedProcess:
return subprocess.run(["git", *args], cwd=CFG.vault, capture_output=True,
text=True, timeout=180, check=check)
return subprocess.run(
["git", "-c", f"safe.directory={CFG.vault}", *args],
cwd=CFG.vault, capture_output=True,
text=True, timeout=180, check=check,
)
def pull() -> str:
"""Pull latest remote changes into CFG.vault when git_sync is enabled."""
if not CFG.git_sync or not (CFG.vault / ".git").exists():
return "disabled"
try:
r = _git("pull", "--rebase", "--autostash", "-q")
if r.returncode != 0:
log(f"git pull failed: {r.stderr.strip()[:200]}", level="WARN")
return "error"
return "pulled"
except Exception as e:
log(f"git pull error: {e}", level="WARN")
return "error"
def sync(paths: list[Path], message: str) -> str:
@@ -99,9 +117,18 @@ def sync(paths: list[Path], message: str) -> str:
if not CFG.git_sync:
return "disabled"
try:
rel = [str(p.relative_to(CFG.vault)) for p in paths if p.exists()]
vault_root = CFG.vault.resolve()
rel = []
for p in paths:
if not p.exists():
continue
try:
rel.append(str(p.resolve().relative_to(vault_root)))
except ValueError:
continue
if not rel:
return "noop"
_git("pull", "--rebase", "--autostash", "-q")
_git("add", "--", *rel)
if _git("diff", "--cached", "--quiet").returncode == 0:
log("nothing staged (likely already committed by the auto-sync cron)")
@@ -1808,9 +1808,10 @@ class Handler(BaseHTTPRequestHandler):
return self.redirect("/lexicon?tab=relations&msg=" + urllib.parse.quote(f"已删除关系:{old_s} {old_p} {old_o}"))
if path == "/lexicon/sync":
p_res = vault.pull()
l_res = lexicon.sync_from_vault(force=True)
g_res = graph.sync(force=True)
return self.redirect("/lexicon?msg=" + urllib.parse.quote(f"同步完成:词库 {l_res},图谱 {g_res}"))
return self.redirect("/lexicon?msg=" + urllib.parse.quote(f"同步完成(Git:{p_res}):词库 {l_res},图谱 {g_res}"))
# ---- SQLite Settings Save ----
if path == "/settings":
@@ -191,13 +191,14 @@ def scan_inbox() -> None:
process_audio(source, job)
except Exception as exc:
attempts = int((db.get_job(job["id"]) or {}).get("attempts") or 0)
retry = attempts < MAX_ATTEMPTS
is_empty_audio = isinstance(exc, providers.EmptyAudioError)
retry = (attempts < MAX_ATTEMPTS) and not is_empty_audio
db.update_job(
job["id"], "queued" if retry else "failed",
f"处理失败,等待重试 ({exc})" if retry else f"处理失败:{exc}",
error_code=type(exc).__name__,
)
log(f"job={job['id']} failed type={type(exc).__name__} err={exc}")
log(f"job={job['id']} failed type={type(exc).__name__} retry={retry} err={exc}")
target = INBOX / source.name if retry else FAILED / source.name
if source.exists():
shutil.move(str(source), target)
@@ -255,6 +256,7 @@ def recover() -> None:
def sync_vault_knowledge() -> None:
try:
vault.pull()
lexicon.sync_from_vault()
graph.sync()
except Exception as exc:
@@ -42,6 +42,7 @@ services:
volumes:
- /mnt/truenas/personal/ASR-Pipeline:/data
- /opt/asr-pipeline/state:/state
- /opt/asr-pipeline/ssh:/root/.ssh:ro
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8788/health"]
interval: 30s
@@ -63,6 +64,7 @@ services:
volumes:
- /mnt/truenas/personal/ASR-Pipeline:/data
- /opt/asr-pipeline/state:/state
- /opt/asr-pipeline/ssh:/root/.ssh:ro
healthcheck:
test: ["CMD", "python", "-m", "asrservice.healthcheck"]
interval: 30s
@@ -71,15 +71,20 @@ class ProviderAndServiceTests(unittest.TestCase):
]
}
text = providers.transcript_from_soniox_response(payload, model="stt-async-v5")
parsed_turns, meta = segment.parse(text)
with_intake = '<!-- intake: {"job_id": "abc", "asr_provider": "soniox", "asr_model": "stt-async-v5"} -->\n' + text
parsed_turns, meta = segment.parse(with_intake)
self.assertEqual(meta["asr_provider"], "soniox")
self.assertEqual(meta["asr_model"], "stt-async-v5")
self.assertEqual(meta["backend"], "soniox")
self.assertEqual(len(parsed_turns), 2)
self.assertEqual(parsed_turns[0].speaker, "speaker_0")
self.assertEqual(parsed_turns[0].text, "大家好,今天讨论预算。")
self.assertEqual(parsed_turns[1].speaker, "speaker_1")
self.assertEqual(parsed_turns[1].text, "收到,我来汇报。")
with self.assertRaises(providers.EmptyAudioError):
providers.transcript_from_soniox_response({"tokens": [], "text": ""}, model="stt-async-v5")
def test_soniox_context_includes_lexicon_and_intake(self):
lexicon.learn_alias("lite网关", "LiteLLM", note="test")
ctx = providers.build_soniox_context(
+17
View File
@@ -1,5 +1,22 @@
# tools-pve 统一模型代理
## ZenMux 订阅退役(2026-11-02)
本节覆盖下文历史的订阅优先策略。`zenmux_subscription_enabled: false`,Compose 不再
传入订阅 Key;即使旧环境中仍有该变量,renderer 也忽略它。
- Claude、DeepSeek、GLM、Kimi、MiMo:OpenRouter 优先,ZenMux BYOK/PAYG 最后备用。
- `auto/...`、无前缀兼容名使用上述顺序;旧 `zenmux/...` 为避免客户端中断保留,
现在也遵循主备,不代表仍使用 ZenMux 订阅。新配置推荐用 `auto/...`。
- `zenmux-payg/...` 显式指定 BYOK,仍可直调;不能给显式 provider 路由虚构优先级。
- Gemini 旧 ZenMux 兼容名优先使用已有 Google 路由,BYOK 最后。
- OpenAI 官方 API、Codex OAuth、xAI OAuth、Google 路由顺序不变。
- 没有其他同模型后端的路由,只能用保留的 BYOK,不做跨型号伪回退。
- BYOK 按量付费,不再受已取消订阅配额保护;跨 provider 缓存不共享。
OpenRouter Claude 合成直连探针在两台生产主机均返回条款限制类 403,
不是订阅退役是否生效的判断依据,也不能宣称首选上游已通过。
## OpenAI 官方 API 与订阅回退(2026-10-29)
官方 API key 由用户生成,经过明确授权,仅加入本 tools-pve 项目的 Arcane 私有
+1 -1
View File
@@ -34,7 +34,6 @@ services:
DATABASE_URL: postgresql://litellm:${POSTGRES_PASSWORD:?required}@postgres:5432/litellm
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY:?required}
LITELLM_SALT_KEY: ${LITELLM_SALT_KEY:?required}
ZENMUX_SUBSCRIPTION_API_KEY: ${ZENMUX_SUBSCRIPTION_API_KEY:-}
ZENMUX_PAYG_API_KEY: ${ZENMUX_PAYG_API_KEY:-}
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
@@ -51,6 +50,7 @@ services:
volumes:
- ./models.yaml:/etc/litellm/models.yaml:ro
- ./render_config.py:/etc/litellm/render_config.py:ro
- ./provider_policy.py:/etc/litellm/provider_policy.py:ro
- ./personal_transcription.py:/etc/litellm/personal_transcription.py:ro
- ./bailian_transcription.py:/etc/litellm/bailian_transcription.py:ro
- /opt/litellm/personal-vocabulary.json:/etc/litellm/personal-vocabulary.json:ro
+2 -1
View File
@@ -1,4 +1,5 @@
# 除 Google 自带 latest 别名外,本项目映射不会自行追踪上游发布。
zenmux_subscription_enabled: false
zenmux:
claude-sonnet-latest: anthropic/claude-sonnet-5.5
claude-opus-latest: anthropic/claude-opus-5.5
@@ -10,7 +11,7 @@ openrouter:
glm-flash-latest: z-ai/glm-5.3-flash
kimi-latest: moonshotai/kimi-k3
mimo-flash-latest: xiaomi/mimo-v2.6-flash
# These existing public aliases are subscription-first, not load-balanced.
# OpenRouter first; ZenMux BYOK is the final same-model fallback.
zenmux_open_models:
deepseek-flash-latest: deepseek/deepseek-v4.1-flash
deepseek-pro-latest: deepseek/deepseek-v4-pro
@@ -0,0 +1,118 @@
"""Subscription retirement: existing aliases stay usable, BYOK is terminal only."""
from copy import deepcopy
def subscription_disabled(catalog):
return catalog.get('zenmux_subscription_enabled') is False
def without_subscription(catalog, env):
if not subscription_disabled(catalog):
return env
return {key: value for key, value in env.items()
if key != 'ZENMUX_SUBSCRIPTION_API_KEY'}
def apply_provider_policy(config, catalog, env):
if not subscription_disabled(catalog):
return config
routes = config['model_list']
if any(r['litellm_params'].get('api_key') ==
'os.environ/ZENMUX_SUBSCRIPTION_API_KEY' for r in routes):
raise ValueError('Retired subscription route remains')
indexed = {r['model_name']: r for r in routes}
groups = {k: list(v) for row in config['router_settings']['fallbacks']
for k, v in row.items()}
def add(name, params):
if name in indexed:
return indexed[name]
route = {'model_name': name, 'litellm_params': deepcopy(params)}
indexed[name] = route
routes.append(route)
return route
def fallback(source, target):
if source == target or source not in indexed or target not in indexed:
return
targets = groups.setdefault(source, [])
if target not in targets:
targets.append(target)
def payg_params(model, anthropic=False):
return {'model': ('anthropic/' if anthropic else 'openai/') + model,
'api_key': 'os.environ/ZENMUX_PAYG_API_KEY',
'api_base': 'https://zenmux.ai/api/anthropic' if anthropic
else 'https://zenmux.ai/api/v1',
'max_retries': 0}
# Explicit BYOK aliases remain direct; automatic/legacy aliases prefer OpenRouter.
for family in ('zenmux', 'zenmux_open_models'):
for alias, model in catalog.get(family, {}).items():
byok = 'zenmux-payg/' + alias
if env.get('ZENMUX_PAYG_API_KEY'):
add(byok, payg_params(model, anthropic=family == 'zenmux'))
primary = None
if env.get('OPENROUTER_API_KEY'):
primary = {'model': 'openrouter/' + model,
'api_key': 'os.environ/OPENROUTER_API_KEY', 'max_retries': 0}
elif byok in indexed:
primary = indexed[byok]['litellm_params']
if primary is None:
continue
names = [alias, 'auto/' + alias, 'zenmux/' + alias]
if env.get('OPENROUTER_API_KEY'):
names += ['openrouter/' + alias]
if family == 'zenmux_open_models':
names += [alias + '-openrouter']
for name in names:
route = add(name, primary)
if route['litellm_params'].get('api_key') != 'os.environ/ZENMUX_PAYG_API_KEY':
fallback(name, byok)
# Former Gemini subscription aliases follow the existing Google primary.
for alias, model in catalog.get('zenmux_chat', {}).items():
byok = 'zenmux-payg/' + alias.replace('-zenmux', '-latest')
if env.get('ZENMUX_PAYG_API_KEY'):
add(byok, payg_params(model))
google = indexed.get('aistudio/gemini-flash-latest') or indexed.get('vertex/gemini-flash-latest')
primary = google or indexed.get(byok)
if primary:
for name in [alias, 'zenmux/' + alias.replace('-zenmux', '-latest')]:
route = add(name, primary['litellm_params'])
if route['litellm_params'].get('api_key') != 'os.environ/ZENMUX_PAYG_API_KEY':
fallback(name, byok)
# Public ASR compatibility aliases use their same-model direct provider first.
asr = {'asr-gpt': ('openai/gpt-transcribe', 'openai/gpt-transcribe'),
'asr-qwen': ('bailian-cn/qwen-audio-3.1-asr-flash', 'qwen/qwen-audio-3.1-asr-flash'),
'asr-doubao': (None, 'bytedance/doubao-seed-asr-2.0'),
'asr-mimo': (None, 'xiaomi/mimo-v2.5-asr')}
for alias, (direct, model) in asr.items():
# Do not introduce new ASR models to the LAN instance.
if not config.get('_public_gateway'):
break
byok = 'zenmux-payg/' + model.split('/', 1)[1]
if env.get('ZENMUX_PAYG_API_KEY'):
add(byok, payg_params(model))
primary = indexed.get(direct) or indexed.get(byok)
if not primary:
continue
if alias in indexed:
indexed[alias]['litellm_params'] = deepcopy(primary['litellm_params'])
else:
add(alias, primary['litellm_params'])
# Old explicit ZenMux names are compatibility aliases, no subscription calls.
add('zenmux/' + model.split('/', 1)[1], primary['litellm_params'])
if primary['litellm_params'].get('api_key') != 'os.environ/ZENMUX_PAYG_API_KEY':
fallback(alias, byok)
fallback('zenmux/' + model.split('/', 1)[1], byok)
config.pop('_public_gateway', None)
config['router_settings']['fallbacks'] = [{k: v} for k, v in groups.items() if v]
for source, targets in groups.items():
if source not in indexed or any(t not in indexed for t in targets):
raise ValueError('Dangling fallback: ' + source)
if indexed[source]['litellm_params'].get('api_key') == 'os.environ/ZENMUX_PAYG_API_KEY':
raise ValueError('BYOK must be terminal: ' + source)
return config
+4 -1
View File
@@ -5,6 +5,7 @@ from copy import deepcopy
from pathlib import Path
import yaml
from provider_policy import without_subscription, apply_provider_policy
def oauth_ready(path):
@@ -125,6 +126,7 @@ def add_openai_routes(config, catalog, env):
def build_config(catalog, env, exists, namespaced=True):
env = without_subscription(catalog, env)
routes = []
fallbacks = []
@@ -198,7 +200,8 @@ def build_config(catalog, env, exists, namespaced=True):
if namespaced:
config = add_bailian_routes(namespace_config(config), env,
'/etc/litellm/personal-vocabulary.json')
return add_openai_routes(config, catalog, env)
config = add_openai_routes(config, catalog, env)
return apply_provider_policy(config, catalog, env) if namespaced else config
if __name__ == '__main__':
@@ -0,0 +1,49 @@
import unittest
from pathlib import Path
import yaml
from render_config import build_config
class PolicyTests(unittest.TestCase):
def build(self, **env):
catalog = yaml.safe_load(Path(__file__).with_name('models.yaml').read_text())
return build_config(catalog, env, lambda _: True)
def test_subscription_is_ignored_even_if_old_env_still_exists(self):
c = self.build(ZENMUX_SUBSCRIPTION_API_KEY='retired-test')
self.assertNotIn('ZENMUX_SUBSCRIPTION_API_KEY', yaml.safe_dump(c))
self.assertFalse(any('zenmux.ai' in r['litellm_params'].get('api_base', '') for r in c['model_list']))
def test_openrouter_primary_and_byok_last_for_all_supported_aliases(self):
c = self.build(ZENMUX_SUBSCRIPTION_API_KEY='retired', ZENMUX_PAYG_API_KEY='test', OPENROUTER_API_KEY='test')
routes = {r['model_name']: r['litellm_params'] for r in c['model_list']}
groups = {k:v for row in c['router_settings']['fallbacks'] for k,v in row.items()}
for alias in ['claude-sonnet-latest', 'claude-opus-latest', 'claude-haiku-latest',
'deepseek-flash-latest', 'glm-latest', 'kimi-latest', 'mimo-flash-latest']:
for name in [alias, 'auto/'+alias, 'zenmux/'+alias, 'openrouter/'+alias]:
self.assertEqual(routes[name]['api_key'], 'os.environ/OPENROUTER_API_KEY')
self.assertEqual(groups[name][-1], 'zenmux-payg/'+alias)
self.assertEqual(routes['zenmux-payg/'+alias]['api_key'], 'os.environ/ZENMUX_PAYG_API_KEY')
for source, targets in groups.items():
self.assertTrue(all(target in routes for target in targets))
self.assertNotEqual(routes[source].get('api_key'), 'os.environ/ZENMUX_PAYG_API_KEY')
self.assertEqual(len(routes), len(c['model_list']))
def test_no_byok_key_no_dangling_fallback_and_no_subscription(self):
c = self.build(OPENROUTER_API_KEY='test', ZENMUX_SUBSCRIPTION_API_KEY='retired')
self.assertEqual(c['router_settings']['fallbacks'], [])
self.assertNotIn('ZENMUX_SUBSCRIPTION_API_KEY', yaml.safe_dump(c))
def test_oauth_official_fallback_stays_unchanged(self):
c = self.build(OPENAI_API_KEY='test', OPENROUTER_API_KEY='test', ZENMUX_PAYG_API_KEY='test')
groups = {k:v for row in c['router_settings']['fallbacks'] for k,v in row.items()}
self.assertEqual(groups['codex/gpt-sol-latest'], ['openai/gpt-sol-latest'])
def test_google_primary_before_gemini_byok(self):
c = self.build(GOOGLE_AI_STUDIO_API_KEY='test', ZENMUX_PAYG_API_KEY='test')
routes = {r['model_name']: r['litellm_params'] for r in c['model_list']}
self.assertEqual(routes['zenmux/gemini-flash-latest']['api_key'], 'os.environ/GOOGLE_AI_STUDIO_API_KEY')
if __name__ == '__main__':
unittest.main()