Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56e0305188 | ||
|
|
c42456be22 |
@@ -1,5 +1,16 @@
|
|||||||
# DC1 公网模型代理
|
# 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`、原客户端认证、全部旧别名、
|
2026-10-29从HK迁移,保留`https://api.opfo.xyz/v1`、原客户端认证、全部旧别名、
|
||||||
Vertex ADC与SMART个人听写、四款ZenMux ASR、百炼及开源模型主备。
|
Vertex ADC与SMART个人听写、四款ZenMux ASR、百炼及开源模型主备。
|
||||||
无数据库,不迁移`.49`的OAuth或数据库,不从其他Agent复制refresh token。
|
无数据库,不迁移`.49`的OAuth或数据库,不从其他Agent复制refresh token。
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ services:
|
|||||||
VERTEXAI_LOCATION: ${VERTEXAI_LOCATION:-global}
|
VERTEXAI_LOCATION: ${VERTEXAI_LOCATION:-global}
|
||||||
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY}
|
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY}
|
||||||
VERTEXAI_ENABLED: "true"
|
VERTEXAI_ENABLED: "true"
|
||||||
ZENMUX_SUBSCRIPTION_API_KEY: ${ZENMUX_SUBSCRIPTION_API_KEY:-}
|
|
||||||
ZENMUX_PAYG_API_KEY: ${ZENMUX_PAYG_API_KEY:-}
|
ZENMUX_PAYG_API_KEY: ${ZENMUX_PAYG_API_KEY:-}
|
||||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
|
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
|
||||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
@@ -43,6 +42,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./models.yaml:/etc/litellm/models.yaml:ro
|
- ./models.yaml:/etc/litellm/models.yaml:ro
|
||||||
- ./provider_routes.py:/etc/litellm/provider_routes.py: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
|
- ./render_config.py:/etc/litellm/render_config.py:ro
|
||||||
- ./vertex_dictation.py:/etc/litellm/vertex_dictation.py:ro
|
- ./vertex_dictation.py:/etc/litellm/vertex_dictation.py:ro
|
||||||
- ./zenmux_transcription.py:/etc/litellm/zenmux_transcription.py:ro
|
- ./zenmux_transcription.py:/etc/litellm/zenmux_transcription.py:ro
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
# 除 Google 自带 latest 别名外,本项目映射不会自行追踪上游发布。
|
# 除 Google 自带 latest 别名外,本项目映射不会自行追踪上游发布。
|
||||||
|
zenmux_subscription_enabled: false
|
||||||
zenmux:
|
zenmux:
|
||||||
claude-sonnet-latest: anthropic/claude-sonnet-5.5
|
claude-sonnet-latest: anthropic/claude-sonnet-5.5
|
||||||
claude-opus-latest: anthropic/claude-opus-5.5
|
claude-opus-latest: anthropic/claude-opus-5.5
|
||||||
@@ -10,7 +11,7 @@ openrouter:
|
|||||||
glm-flash-latest: z-ai/glm-5.3-flash
|
glm-flash-latest: z-ai/glm-5.3-flash
|
||||||
kimi-latest: moonshotai/kimi-k3
|
kimi-latest: moonshotai/kimi-k3
|
||||||
mimo-flash-latest: xiaomi/mimo-v2.6-flash
|
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:
|
zenmux_open_models:
|
||||||
deepseek-flash-latest: deepseek/deepseek-v4.1-flash
|
deepseek-flash-latest: deepseek/deepseek-v4.1-flash
|
||||||
deepseek-pro-latest: deepseek/deepseek-v4-pro
|
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
|
from pathlib import Path
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
from provider_policy import without_subscription
|
||||||
|
|
||||||
|
|
||||||
def oauth_ready(path):
|
def oauth_ready(path):
|
||||||
@@ -92,6 +93,7 @@ def add_bailian_routes(config, env, vocabulary_path):
|
|||||||
|
|
||||||
|
|
||||||
def build_config(catalog, env, exists, namespaced=True):
|
def build_config(catalog, env, exists, namespaced=True):
|
||||||
|
env = without_subscription(catalog, env)
|
||||||
routes = []
|
routes = []
|
||||||
fallbacks = []
|
fallbacks = []
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ from pathlib import Path
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from provider_routes import build_config, namespace_config, add_bailian_routes
|
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):
|
def build_hk_config(catalog, env):
|
||||||
|
env = without_subscription(catalog, env)
|
||||||
config = build_config(catalog, env, lambda path: False, namespaced=False)
|
config = build_config(catalog, env, lambda path: False, namespaced=False)
|
||||||
config['general_settings'] = {'master_key': 'os.environ/LITELLM_MASTER_KEY'}
|
config['general_settings'] = {'master_key': 'os.environ/LITELLM_MASTER_KEY'}
|
||||||
routes = config['model_list']
|
routes = config['model_list']
|
||||||
@@ -62,7 +64,8 @@ def build_hk_config(catalog, env):
|
|||||||
'api_key': 'os.environ/OPENAI_API_KEY', 'max_retries': 0}})
|
'api_key': 'os.environ/OPENAI_API_KEY', 'max_retries': 0}})
|
||||||
if env.get('ZENMUX_SUBSCRIPTION_API_KEY'):
|
if env.get('ZENMUX_SUBSCRIPTION_API_KEY'):
|
||||||
config['router_settings']['fallbacks'].append({'asr-gpt': ['zenmux/gpt-transcribe']})
|
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__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ from render_config import build_hk_config
|
|||||||
class RoutingTests(unittest.TestCase):
|
class RoutingTests(unittest.TestCase):
|
||||||
def test_official_api_names_do_not_replace_hk_routes(self):
|
def test_official_api_names_do_not_replace_hk_routes(self):
|
||||||
catalog = yaml.safe_load(Path(__file__).with_name('models.yaml').read_text())
|
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)
|
config = build_hk_config(catalog, env)
|
||||||
routes = {r['model_name']: r['litellm_params'] for r in config['model_list']}
|
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['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.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.assertEqual(routes['openai/gpt-sol-latest']['model'], 'openai/gpt-6.1-sol')
|
||||||
self.assertNotIn('codex/gpt-sol-latest', routes)
|
self.assertNotIn('codex/gpt-sol-latest', routes)
|
||||||
self.assertNotIn('synthetic', yaml.safe_dump(config))
|
self.assertNotIn('synthetic', yaml.safe_dump(config))
|
||||||
@@ -25,14 +25,14 @@ class RoutingTests(unittest.TestCase):
|
|||||||
config = build_hk_config(catalog, {'OPENAI_API_KEY': 'synthetic'})
|
config = build_hk_config(catalog, {'OPENAI_API_KEY': 'synthetic'})
|
||||||
routes = {r['model_name']: r['litellm_params'] for r in config['model_list']}
|
routes = {r['model_name']: r['litellm_params'] for r in config['model_list']}
|
||||||
self.assertEqual(routes['asr-gpt']['model'], 'openai/gpt-transcribe')
|
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'])
|
self.assertNotIn({'asr-gpt': ['zenmux/gpt-transcribe']}, config['router_settings']['fallbacks'])
|
||||||
|
|
||||||
def test_bailian_personal_uses_hk_private_vocabulary(self):
|
def test_bailian_personal_uses_hk_private_vocabulary(self):
|
||||||
catalog = yaml.safe_load(Path(__file__).with_name('models.yaml').read_text())
|
catalog = yaml.safe_load(Path(__file__).with_name('models.yaml').read_text())
|
||||||
routes = build_hk_config(catalog, {'BAILIAN_CN_API_KEY':'synthetic',
|
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']
|
'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'],
|
self.assertEqual(routes[1]['litellm_params']['bailian_vocabulary_path'],
|
||||||
'/credentials/personal-vocabulary.json')
|
'/credentials/personal-vocabulary.json')
|
||||||
|
|
||||||
@@ -56,10 +56,12 @@ class RoutingTests(unittest.TestCase):
|
|||||||
self.assertEqual(routes['asr-mimo']['model'], 'openai/xiaomi/mimo-v2.5-asr')
|
self.assertEqual(routes['asr-mimo']['model'], 'openai/xiaomi/mimo-v2.5-asr')
|
||||||
for alias in ['asr-gpt', 'asr-qwen', 'asr-doubao', 'asr-mimo']:
|
for alias in ['asr-gpt', 'asr-qwen', 'asr-doubao', 'asr-mimo']:
|
||||||
self.assertNotIn('personal_dictation', routes[alias])
|
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-transcribe']['model'], 'gemini/gemini-3.5-transcribe')
|
||||||
self.assertEqual(routes['gemini-3.6-flash']['model'], 'vertex_ai/gemini-3.8-flash')
|
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.assertEqual(routes['vertex/gemini-transcribe-personal'], routes['gemini-transcribe-personal'])
|
||||||
self.assertNotIn('aistudio/gemini-transcribe-personal', routes)
|
self.assertNotIn('aistudio/gemini-transcribe-personal', routes)
|
||||||
self.assertEqual(routes['zenmux/gpt-transcribe'], routes['asr-gpt'])
|
self.assertEqual(routes['zenmux/gpt-transcribe'], routes['asr-gpt'])
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ FROM python:3.13-slim-bookworm@sha256:ed86c82274b3c69b52fb5820f358f0bd7df0b60333
|
|||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
&& 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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -16,12 +16,18 @@ import urllib.error
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
import http.client
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .config import CFG
|
from .config import CFG
|
||||||
from .log import log
|
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]] = {
|
PROVIDERS: dict[str, dict[str, str]] = {
|
||||||
"qwen3cam": {
|
"qwen3cam": {
|
||||||
"label": "内网 Qwen3-ASR 1.7B + CAM++ (分角色 + 声纹)",
|
"label": "内网 Qwen3-ASR 1.7B + CAM++ (分角色 + 声纹)",
|
||||||
@@ -203,7 +209,9 @@ def format_diarized_transcript(
|
|||||||
matched_names: list[str] | None = None,
|
matched_names: list[str] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
if not segments:
|
if not segments:
|
||||||
raise RuntimeError(f"ASR ({provider}) response contains no segments")
|
raise EmptyAudioError(
|
||||||
|
f"ASR ({provider}) 未识别到有效语音片段(音频可能为静音、设备未拾音或音量过低)"
|
||||||
|
)
|
||||||
speaker_map: dict[str, str] = {}
|
speaker_map: dict[str, str] = {}
|
||||||
turns: list[list[str]] = []
|
turns: list[list[str]] = []
|
||||||
speakers_seen: set[str] = set()
|
speakers_seen: set[str] = set()
|
||||||
@@ -221,7 +229,9 @@ def format_diarized_transcript(
|
|||||||
else:
|
else:
|
||||||
turns.append([spk, text])
|
turns.append([spk, text])
|
||||||
if not turns:
|
if not turns:
|
||||||
raise RuntimeError(f"ASR ({provider}) segments contained no text")
|
raise EmptyAudioError(
|
||||||
|
f"ASR ({provider}) 未识别到任何有效文字(音频可能为静音或底噪)"
|
||||||
|
)
|
||||||
|
|
||||||
lines = [f"<!-- asr_provider: {provider} model: {model} -->"]
|
lines = [f"<!-- asr_provider: {provider} model: {model} -->"]
|
||||||
if voiceprint_session or provider == "qwen3cam":
|
if voiceprint_session or provider == "qwen3cam":
|
||||||
@@ -240,7 +250,7 @@ def format_diarized_transcript(
|
|||||||
def transcript_from_qwen3cam_response(payload: dict[str, Any]) -> str:
|
def transcript_from_qwen3cam_response(payload: dict[str, Any]) -> str:
|
||||||
segments = payload.get("segments")
|
segments = payload.get("segments")
|
||||||
if not isinstance(segments, list) or not 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 {}
|
vp = payload.get("voiceprint") or {}
|
||||||
sid = str(vp.get("session_id") or "")
|
sid = str(vp.get("session_id") or "")
|
||||||
matched = [
|
matched = [
|
||||||
@@ -431,16 +441,33 @@ def _http_multipart(
|
|||||||
file_path: Path,
|
file_path: Path,
|
||||||
headers: dict[str, str] | None = None,
|
headers: dict[str, str] | None = None,
|
||||||
timeout: int = 900,
|
timeout: int = 900,
|
||||||
|
retries: int = 3,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
data, content_type = _encode_multipart(fields, file_field, file_path)
|
data, content_type = _encode_multipart(fields, file_field, file_path)
|
||||||
req_headers = {"Content-Type": content_type, **(headers or {})}
|
req_headers = {"Content-Type": content_type, **(headers or {})}
|
||||||
|
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")
|
req = urllib.request.Request(url, data=data, headers=req_headers, method="POST")
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
raw = resp.read().decode("utf-8", errors="ignore")
|
raw = resp.read().decode("utf-8", errors="ignore")
|
||||||
|
break
|
||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
detail = exc.read().decode("utf-8", errors="ignore")[:300]
|
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
|
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:
|
try:
|
||||||
return json.loads(raw)
|
return json.loads(raw)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@@ -454,18 +481,34 @@ def _http_json(
|
|||||||
payload: dict[str, Any] | None = None,
|
payload: dict[str, Any] | None = None,
|
||||||
headers: dict[str, str] | None = None,
|
headers: dict[str, str] | None = None,
|
||||||
timeout: int = 120,
|
timeout: int = 120,
|
||||||
|
retries: int = 3,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else None
|
data = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else None
|
||||||
req_headers = {"Accept": "application/json", **(headers or {})}
|
req_headers = {"Accept": "application/json", **(headers or {})}
|
||||||
if data is not None:
|
if data is not None:
|
||||||
req_headers["Content-Type"] = "application/json"
|
req_headers["Content-Type"] = "application/json"
|
||||||
|
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)
|
req = urllib.request.Request(url, data=data, headers=req_headers, method=method)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
raw = resp.read().decode("utf-8", errors="ignore")
|
raw = resp.read().decode("utf-8", errors="ignore")
|
||||||
|
break
|
||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
detail = exc.read().decode("utf-8", errors="ignore")[:300]
|
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
|
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():
|
if not raw.strip():
|
||||||
return {}
|
return {}
|
||||||
try:
|
try:
|
||||||
@@ -575,11 +618,26 @@ def transcribe_soniox(
|
|||||||
chosen_model = model or CFG.soniox_model or "stt-async-v5"
|
chosen_model = model or CFG.soniox_model or "stt-async-v5"
|
||||||
headers = {"Authorization": f"Bearer {key}"}
|
headers = {"Authorization": f"Bearer {key}"}
|
||||||
|
|
||||||
|
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(
|
file_resp = _http_multipart(
|
||||||
f"{base}/files",
|
f"{base}/files",
|
||||||
fields={},
|
fields={},
|
||||||
file_field="file",
|
file_field="file",
|
||||||
file_path=audio,
|
file_path=upload_path,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=600,
|
timeout=600,
|
||||||
)
|
)
|
||||||
@@ -622,7 +680,7 @@ def transcribe_soniox(
|
|||||||
else:
|
else:
|
||||||
raise RuntimeError("Soniox transcription timed out waiting for completion")
|
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
|
transcript_payload = status_resp
|
||||||
else:
|
else:
|
||||||
transcript_payload = _http_json(
|
transcript_payload = _http_json(
|
||||||
|
|||||||
@@ -377,9 +377,13 @@ def write_draft(cleaned: str, meta: dict, context: str, date: str,
|
|||||||
if ctx:
|
if ctx:
|
||||||
ctx = "本场背景(来自既有笔记与长期记忆;引用其中内容时必须按上面第 3 条加 〔背景〕):\n" \
|
ctx = "本场背景(来自既有笔记与长期记忆;引用其中内容时必须按上面第 3 条加 〔背景〕):\n" \
|
||||||
+ context
|
+ 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(
|
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,
|
context=ctx, date=date, source_detail=detail,
|
||||||
transcript=transcript_path, extra_fm=extra, body=cleaned)
|
transcript=transcript_path, extra_fm=extra, body=cleaned)
|
||||||
return chat(CFG.draft_model, prompt, max_tokens=12000, usage=usage)
|
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 "通话记录"
|
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")
|
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")
|
transcript = _field(markdown, "transcript")
|
||||||
if CFG.minutes.exists() and transcript:
|
if target_dir.exists() and transcript:
|
||||||
for path in CFG.minutes.glob("*.md"):
|
for path in target_dir.glob("*.md"):
|
||||||
try:
|
try:
|
||||||
if _field(path.read_text(errors="ignore"), "transcript") == transcript:
|
if _field(path.read_text(errors="ignore"), "transcript") == transcript:
|
||||||
return path
|
return path
|
||||||
@@ -256,13 +258,21 @@ def publish(path: Path) -> bool:
|
|||||||
|
|
||||||
destination = _formal_path(out)
|
destination = _formal_path(out)
|
||||||
vault.write_draft_file(destination, 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")
|
date = _field(out, "date") or time.strftime("%Y-%m-%d")
|
||||||
touched = [destination]
|
touched = [vault_destination]
|
||||||
for name, kind in _answered_card_requests(path):
|
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:
|
if card:
|
||||||
touched.append(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":
|
if state == "error":
|
||||||
raise RuntimeError("formal minute commit failed")
|
raise RuntimeError("formal minute commit failed")
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import re
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
VOICEPRINT_HDR = re.compile(r"^<!--\s*voiceprint_session:")
|
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*$")
|
SEGMENT_BANNER = re.compile(r"^---\s*第\d+段.*---\s*$")
|
||||||
TURN_TAG = re.compile(r"^【([^】]+)】\s*(.*)$", re.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)
|
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
|
continue
|
||||||
body_lines.append(ln)
|
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
|
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] = []
|
turns: list[Turn] = []
|
||||||
|
|
||||||
if backend == "vibevoice":
|
if backend == "vibevoice":
|
||||||
|
|||||||
@@ -90,8 +90,26 @@ def update_pending(date: str, title: str, path: Path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _git(*args, check=False) -> subprocess.CompletedProcess:
|
def _git(*args, check=False) -> subprocess.CompletedProcess:
|
||||||
return subprocess.run(["git", *args], cwd=CFG.vault, capture_output=True,
|
return subprocess.run(
|
||||||
text=True, timeout=180, check=check)
|
["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:
|
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:
|
if not CFG.git_sync:
|
||||||
return "disabled"
|
return "disabled"
|
||||||
try:
|
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:
|
if not rel:
|
||||||
return "noop"
|
return "noop"
|
||||||
|
_git("pull", "--rebase", "--autostash", "-q")
|
||||||
_git("add", "--", *rel)
|
_git("add", "--", *rel)
|
||||||
if _git("diff", "--cached", "--quiet").returncode == 0:
|
if _git("diff", "--cached", "--quiet").returncode == 0:
|
||||||
log("nothing staged (likely already committed by the auto-sync cron)")
|
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}"))
|
return self.redirect("/lexicon?tab=relations&msg=" + urllib.parse.quote(f"已删除关系:{old_s} {old_p} {old_o}"))
|
||||||
|
|
||||||
if path == "/lexicon/sync":
|
if path == "/lexicon/sync":
|
||||||
|
p_res = vault.pull()
|
||||||
l_res = lexicon.sync_from_vault(force=True)
|
l_res = lexicon.sync_from_vault(force=True)
|
||||||
g_res = graph.sync(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 ----
|
# ---- SQLite Settings Save ----
|
||||||
if path == "/settings":
|
if path == "/settings":
|
||||||
|
|||||||
@@ -191,13 +191,14 @@ def scan_inbox() -> None:
|
|||||||
process_audio(source, job)
|
process_audio(source, job)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
attempts = int((db.get_job(job["id"]) or {}).get("attempts") or 0)
|
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(
|
db.update_job(
|
||||||
job["id"], "queued" if retry else "failed",
|
job["id"], "queued" if retry else "failed",
|
||||||
f"处理失败,等待重试 ({exc})" if retry else f"处理失败:{exc}",
|
f"处理失败,等待重试 ({exc})" if retry else f"处理失败:{exc}",
|
||||||
error_code=type(exc).__name__,
|
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
|
target = INBOX / source.name if retry else FAILED / source.name
|
||||||
if source.exists():
|
if source.exists():
|
||||||
shutil.move(str(source), target)
|
shutil.move(str(source), target)
|
||||||
@@ -255,6 +256,7 @@ def recover() -> None:
|
|||||||
|
|
||||||
def sync_vault_knowledge() -> None:
|
def sync_vault_knowledge() -> None:
|
||||||
try:
|
try:
|
||||||
|
vault.pull()
|
||||||
lexicon.sync_from_vault()
|
lexicon.sync_from_vault()
|
||||||
graph.sync()
|
graph.sync()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /mnt/truenas/personal/ASR-Pipeline:/data
|
- /mnt/truenas/personal/ASR-Pipeline:/data
|
||||||
- /opt/asr-pipeline/state:/state
|
- /opt/asr-pipeline/state:/state
|
||||||
|
- /opt/asr-pipeline/ssh:/root/.ssh:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8788/health"]
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8788/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -63,6 +64,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /mnt/truenas/personal/ASR-Pipeline:/data
|
- /mnt/truenas/personal/ASR-Pipeline:/data
|
||||||
- /opt/asr-pipeline/state:/state
|
- /opt/asr-pipeline/state:/state
|
||||||
|
- /opt/asr-pipeline/ssh:/root/.ssh:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-m", "asrservice.healthcheck"]
|
test: ["CMD", "python", "-m", "asrservice.healthcheck"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
@@ -71,15 +71,20 @@ class ProviderAndServiceTests(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
text = providers.transcript_from_soniox_response(payload, model="stt-async-v5")
|
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_provider"], "soniox")
|
||||||
self.assertEqual(meta["asr_model"], "stt-async-v5")
|
self.assertEqual(meta["asr_model"], "stt-async-v5")
|
||||||
|
self.assertEqual(meta["backend"], "soniox")
|
||||||
self.assertEqual(len(parsed_turns), 2)
|
self.assertEqual(len(parsed_turns), 2)
|
||||||
self.assertEqual(parsed_turns[0].speaker, "speaker_0")
|
self.assertEqual(parsed_turns[0].speaker, "speaker_0")
|
||||||
self.assertEqual(parsed_turns[0].text, "大家好,今天讨论预算。")
|
self.assertEqual(parsed_turns[0].text, "大家好,今天讨论预算。")
|
||||||
self.assertEqual(parsed_turns[1].speaker, "speaker_1")
|
self.assertEqual(parsed_turns[1].speaker, "speaker_1")
|
||||||
self.assertEqual(parsed_turns[1].text, "收到,我来汇报。")
|
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):
|
def test_soniox_context_includes_lexicon_and_intake(self):
|
||||||
lexicon.learn_alias("lite网关", "LiteLLM", note="test")
|
lexicon.learn_alias("lite网关", "LiteLLM", note="test")
|
||||||
ctx = providers.build_soniox_context(
|
ctx = providers.build_soniox_context(
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
# tools-pve 统一模型代理
|
# 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)
|
## OpenAI 官方 API 与订阅回退(2026-10-29)
|
||||||
|
|
||||||
官方 API key 由用户生成,经过明确授权,仅加入本 tools-pve 项目的 Arcane 私有
|
官方 API key 由用户生成,经过明确授权,仅加入本 tools-pve 项目的 Arcane 私有
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ services:
|
|||||||
DATABASE_URL: postgresql://litellm:${POSTGRES_PASSWORD:?required}@postgres:5432/litellm
|
DATABASE_URL: postgresql://litellm:${POSTGRES_PASSWORD:?required}@postgres:5432/litellm
|
||||||
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY:?required}
|
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY:?required}
|
||||||
LITELLM_SALT_KEY: ${LITELLM_SALT_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:-}
|
ZENMUX_PAYG_API_KEY: ${ZENMUX_PAYG_API_KEY:-}
|
||||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
|
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
|
||||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
@@ -51,6 +50,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./models.yaml:/etc/litellm/models.yaml:ro
|
- ./models.yaml:/etc/litellm/models.yaml:ro
|
||||||
- ./render_config.py:/etc/litellm/render_config.py: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
|
- ./personal_transcription.py:/etc/litellm/personal_transcription.py:ro
|
||||||
- ./bailian_transcription.py:/etc/litellm/bailian_transcription.py:ro
|
- ./bailian_transcription.py:/etc/litellm/bailian_transcription.py:ro
|
||||||
- /opt/litellm/personal-vocabulary.json:/etc/litellm/personal-vocabulary.json:ro
|
- /opt/litellm/personal-vocabulary.json:/etc/litellm/personal-vocabulary.json:ro
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
# 除 Google 自带 latest 别名外,本项目映射不会自行追踪上游发布。
|
# 除 Google 自带 latest 别名外,本项目映射不会自行追踪上游发布。
|
||||||
|
zenmux_subscription_enabled: false
|
||||||
zenmux:
|
zenmux:
|
||||||
claude-sonnet-latest: anthropic/claude-sonnet-5.5
|
claude-sonnet-latest: anthropic/claude-sonnet-5.5
|
||||||
claude-opus-latest: anthropic/claude-opus-5.5
|
claude-opus-latest: anthropic/claude-opus-5.5
|
||||||
@@ -10,7 +11,7 @@ openrouter:
|
|||||||
glm-flash-latest: z-ai/glm-5.3-flash
|
glm-flash-latest: z-ai/glm-5.3-flash
|
||||||
kimi-latest: moonshotai/kimi-k3
|
kimi-latest: moonshotai/kimi-k3
|
||||||
mimo-flash-latest: xiaomi/mimo-v2.6-flash
|
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:
|
zenmux_open_models:
|
||||||
deepseek-flash-latest: deepseek/deepseek-v4.1-flash
|
deepseek-flash-latest: deepseek/deepseek-v4.1-flash
|
||||||
deepseek-pro-latest: deepseek/deepseek-v4-pro
|
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
|
from pathlib import Path
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
from provider_policy import without_subscription, apply_provider_policy
|
||||||
|
|
||||||
|
|
||||||
def oauth_ready(path):
|
def oauth_ready(path):
|
||||||
@@ -125,6 +126,7 @@ def add_openai_routes(config, catalog, env):
|
|||||||
|
|
||||||
|
|
||||||
def build_config(catalog, env, exists, namespaced=True):
|
def build_config(catalog, env, exists, namespaced=True):
|
||||||
|
env = without_subscription(catalog, env)
|
||||||
routes = []
|
routes = []
|
||||||
fallbacks = []
|
fallbacks = []
|
||||||
|
|
||||||
@@ -198,7 +200,8 @@ def build_config(catalog, env, exists, namespaced=True):
|
|||||||
if namespaced:
|
if namespaced:
|
||||||
config = add_bailian_routes(namespace_config(config), env,
|
config = add_bailian_routes(namespace_config(config), env,
|
||||||
'/etc/litellm/personal-vocabulary.json')
|
'/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__':
|
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()
|
||||||
Reference in New Issue
Block a user