Compare commits

...
44 Commits
Author SHA1 Message Date
Codex 2367c5eb4e feat(tools-pve/asr-pipeline): multi-backend ASR web app with LiteLLM routing
Build ASR Pipeline / image (push) Canceled after 0s
Build image / build (push) Canceled after 0s
2026-11-01 22:07:57 -08:00
Codex d1790a05b0 Upgrade BookOrbit to 3.6.0 2026-10-29 20:57:56 -07:00
Codex 3d44b74d8c Route asr-gpt to official OpenAI with ZenMux backup 2026-10-28 19:23:58 -07:00
Codex df391cd41f Deploy public LiteLLM on US DC1 with preserved HK routes 2026-10-28 19:12:03 -07:00
Codex 81c475464e 补充 OpenAI 主备验收与可复用双协议测试 2026-10-28 18:23:21 -07:00
Codex 205bd3bbbc 接入 OpenAI 官方 API 并为 Codex 订阅配置同型号回退 2026-10-28 18:19:04 -07:00
Codex ed5f6514bb 升级 Hindsight 到 1.1.0 固定镜像 2026-10-27 19:00:54 -07:00
Codex d583490bad feat(litellm): add native Beijing Bailian ASR and personal vocabulary 2026-10-26 17:52:46 -07:00
Codex daa907d029 feat(litellm): add provider namespaces with legacy compatibility 2026-10-26 17:38:55 -07:00
Codex 6a88160e0f Upgrade BookOrbit to 3.5.0
Pin the app image to the current GHCR OCI index. Leave PostgreSQL unchanged.
2026-10-26 06:46:39 -07:00
Codex 91daf426f2 在香港 LiteLLM 接入四款 ZenMux ASR 对比路由 2026-10-25 05:22:50 -07:00
Codex 45293b1b53 将个人听写词库收敛为高频人名与公司名 2026-10-25 01:57:25 -07:00
Codex 05e0800581 记录 tools-pve SMART 真实测试与响应格式限制 2026-10-25 01:12:24 -07:00
Codex f9ccf7af2b 为 tools-pve 个人语音输入启用 SMART 2026-10-25 01:09:53 -07:00
Codex 443ed46bf9 记录香港个人语音输入验收及 JSON 响应限制 2026-10-25 01:01:32 -07:00
Codex 71ccac6d72 接入香港 Vertex SMART 个人语音输入与热词 2026-10-25 00:59:39 -07:00
Codex ba0c198c0c 在香港禁用区域不可用的 AI Studio 路由 2026-10-25 00:46:03 -07:00
Codex 641991043f 同步香港 LiteLLM 镜像与多提供商路由 2026-10-25 00:42:57 -07:00
Codex 7831d5f319 Document isolated personal vocabulary transcription route 2026-10-25 00:22:07 -07:00
Codex 035daa177d Add isolated Gemini dictation route with native personal vocabulary 2026-10-25 00:19:22 -07:00
Codex 8746be24ce Upgrade BookOrbit to 3.4.0 2026-10-19 05:34:58 -07:00
Codex 94ffeca06e Allow Camoufox execution from Byparr cache 2026-10-12 22:57:22 -07:00
Codex d5625cc610 Re-enable Byparr debug for cache diagnosis 2026-10-12 22:55:46 -07:00
Codex 7672ff4f6c Fix Byparr browser cache capacity 2026-10-12 22:53:53 -07:00
Codex c8a5fe3d03 Enable temporary Byparr debug logging 2026-10-12 22:52:01 -07:00
Codex a0e86bb47f Deploy Byparr on tools-pve 2026-10-12 22:45:03 -07:00
Codex c4a2182b5e Document Hermes latest Grok Luna Sol policy 2026-10-11 23:10:51 -07:00
Codex 8db1483345 Add Hermes auxiliary and subscription model routes 2026-10-11 22:44:10 -07:00
Codex cd50972093 Route open models through ZenMux with OpenRouter fallback 2026-10-11 18:40:45 -07:00
Codex 9ebc213d71 Keep Google rerank synthetic-only per user decision 2026-10-09 09:29:31 -07:00
Codex a8c2a8d88f 升级 Hindsight 至 0.10.3 并配置 Google rerank 回退链 2026-10-09 09:22:04 -07:00
Codex 3f4b329018 开放 Google Discovery Engine 两条 rerank 路由 2026-10-09 09:16:00 -07:00
Codex 6791addafd 记录 Google 赠金接入与检索模型兼容性验收 2026-10-09 09:05:36 -07:00
Codex d3a487f86a 接入 Google Agent Platform 并开放已验证的 AI Studio embedding 2026-10-09 09:02:00 -07:00
Codex db09881b2d docs(litellm): record verified provider routes and client access 2026-10-08 21:56:31 -07:00
Codex 9af7b98f17 feat(litellm): enable OAuth models and AI Studio aliases 2026-10-08 21:53:19 -07:00
Codex 6473297555 fix(litellm): gate OAuth routes on real credentials 2026-10-08 21:43:18 -07:00
Codex 9a4020b7ec fix(litellm): restrict UI login to database users 2026-10-08 20:56:30 -07:00
Codex aaff6db668 docs(litellm): record live gateway and verified prompt cache 2026-10-08 20:41:28 -07:00
Codex 8b5c1b9927 feat(tools-pve): prepare LiteLLM gateway with persistent OAuth routes 2026-10-08 19:41:22 -07:00
Codex ac7508fdd9 Update BookOrbit to 3.3.0 2026-10-07 20:01:47 -07:00
Codex 15383c2dc9 Mount clean Grimmory library alongside legacy catalog 2026-10-07 07:50:38 -07:00
Codex 3e4caaef91 Point Grimmory at isolated clean library 2026-10-07 07:46:50 -07:00
Codex f691817d64 Add Grimmory evaluation stack for media-core 2026-10-07 06:53:43 -07:00
69 changed files with 5277 additions and 465 deletions
+1
View File
@@ -0,0 +1 @@
__pycache__/
+40 -2
View File
@@ -1,4 +1,4 @@
# Hong Kong CMI LiteLLM Vertex AI Relay # 香港 CMI LiteLLM 多提供商代理
- 公网 OpenAI-compatible 入口为 `https://api.opfo.xyz/v1`,由 Caddy 终止 TLS 后通过外部 Docker 网络 `hk-web` 反向代理到 `litellm-api:1049`。 - 公网 OpenAI-compatible 入口为 `https://api.opfo.xyz/v1`,由 Caddy 终止 TLS 后通过外部 Docker 网络 `hk-web` 反向代理到 `litellm-api:1049`。
- LiteLLM 不使用 host network,也不向宿主机发布端口;容器内监听 `0.0.0.0:1049` 仅供 `hk-web` 上的 Caddy 访问。 - LiteLLM 不使用 host network,也不向宿主机发布端口;容器内监听 `0.0.0.0:1049` 仅供 `hk-web` 上的 Caddy 访问。
@@ -8,4 +8,42 @@
- 对外模型别名包括 `gemini-3.6-flash`、`gemini-3.5-flash-lite`、 - 对外模型别名包括 `gemini-3.6-flash`、`gemini-3.5-flash-lite`、
`google/gemini-embedding-2` 和 `google/gemini-3.8-flash`;后两者仍通过 `google/gemini-embedding-2` 和 `google/gemini-3.8-flash`;后两者仍通过
Vertex AI ADC 路由。 Vertex AI ADC 路由。
- 后续 Vertex Anthropic 模型在 `config.yaml` 增加独立模型项。 - 2026-10-25 同步 tools-pve 已验证的 LiteLLM 1.104.2 固定镜像,以及 `models.yaml` 中的提供商映射。`latest` 是维护的别名,不是自动发现所有厂商的新模型。
- `provider_routes.py` 是同日 tools-pve 的路由生成器快照;更新提供商逻辑时应同步比对两端。HK 的 `render_config.py` 不接入数据库管理或 OAuth,保留现有主密钥认证,个人语音输入走独立的 Vertex 路线。
- Arcane Project 本地变量增加 `ZENMUX_SUBSCRIPTION_API_KEY`、`ZENMUX_PAYG_API_KEY`、`OPENROUTER_API_KEY`。只有相应凭据存在时才生成该组路由;真实值不进入 Git。
- Claude 使用 ZenMux 原生 Anthropic Messages 路径,订阅与 `-payg` 显式分开;开源模型使用 ZenMux 订阅优先、同模型 OpenRouter 后备。不自动回退至 ZenMux PAYG。
- HK 合成请求实测 AI Studio 返回 `User location is not supported for the API use`,因此 Compose 不传入 AI Studio 凭据,也不发布其路线。tools-pve 上原有 AI Studio 功能不受影响。OAuth 会话和会议处理程序没有复制至 HK。
- Vertex 继续使用 HK 原有 ADC。旧 `gemini-3.6-flash` 别名保留,但指向当前映射的 `gemini-3.8-flash`;其余旧别名保留。
- 当前实际配置由启动脚本生成至 `/tmp/litellm.yaml`;旧 `config.yaml` 仅作为历史参考,不再挂载。
- 升级只重建 LiteLLM,不变更 Caddy、HAProxy、其他项目及 ADC 卷。失败时回退本次 Git 提交并同步,再按需要 Redeploy,禁止删除卷。
## Spokenly 个人语音输入
- 基础地址:`https://api.opfo.xyz/v1`。
- 转写完整地址:`https://api.opfo.xyz/v1/audio/transcriptions`。
- 模型:`gemini-transcribe-personal`,真实上游为 `vertex_ai/gemini-3.5-transcribe`,区域 `global`,复用 HK ADC。
- API Key:HK 现有 LiteLLM 主密钥,不是 tools-pve 客户端 key,也不是 ZenMux key。
- 响应使用 `json`;不请求时间戳、说话人分离或 `verbose_json`。当前 LiteLLM 请求 `text` 时实测仍返回 JSON 包装,因此不要选 text。额外 prompt 留空;去口语化由 SMART 负责,不靠提示词。
- 仅显式带 `personal_dictation: true` 的这条路由经 `vertex_dictation.py` 添加 `generationConfig.audioTranscriptionConfig.mode=SMART` 和 `customVocabulary`。其他转写路线、会议录音以及 tools-pve 配置不改。
- 当前个人词库只有 10 个人名、10 个公司名,与 tools-pve 相同;从 Obsidian People/Companies 卡片取规范名,以正式工作笔记文档频次与 Hindsight 实体 mention_count 交叉筛选。产品、项目、文档标题和错误别名暂不纳入。旧 SQLite 自动抽取的 100 词快照已因过度引导被替代,SMART 保留。
- `PERSONAL_VOCABULARY_B64` 只存 Arcane Project 环境(Base64 不是加密);init 将其解码至现有私有凭据卷 `/credentials/personal-vocabulary.json`,umask 077,LiteLLM 只读挂载,不进入 Git。
- 更新词库时使用 server-management 的 `scripts/export-entity-dictation-vocabulary.py`,先审查纯实体名称与频次,替换 Arcane 中上述变量并重建 init;单改环境变量不会更新已生成文件。保留旧私有词库以便回滚。升级 LiteLLM 前重跑 `scripts/test-vertex-dictation-wire.py`,确认原生 SMART/热词和普通路线隔离。
- 验收:OpenAI multipart 音频端点接受一秒静音及 Google 公开 Brooklyn Bridge 语音示例,json 均返回 200,公开样本正确输出 `How old is the Brooklyn Bridge?`。测试证明接口和原生参数有效,不代表中文口语及个人名词已进行识别质量对比。
## 四款 ZenMux ASR 对比入口
同一 Base URL `https://api.opfo.xyz/v1`、HK 现有密钥,Spokenly 选择自定义 OpenAI-compatible transcription,response_format=json,Prompt 留空,额外 AI Processing 关闭。只切换模型名:
| 客户端模型名 | ZenMux 实际型号 |
| --- | --- |
| `asr-gpt` | `openai/gpt-transcribe` |
| `asr-qwen` | `qwen/qwen-audio-3.1-asr-flash` |
| `asr-doubao` | `bytedance/doubao-seed-asr-2.0` |
| `asr-mimo` | `xiaomi/mimo-v2.5-asr` |
- 四条路线只使用 ZenMux 订阅 key,不注入个人热词或 SMART、不加额外 LLM 改写,不自动切模型或 PAYG。供应商自身默认的标点和规范化未强制覆盖。
- `zenmux_transcription.py` 将 multipart 文件转换成 ZenMux 要求的 JSON/base64,固定上游 `/api/v1/audio/transcriptions`;不新增服务、端口或音频存储。
- 适配器只识别四个上游型号,校验固定 ZenMux API 地址,其他 OpenAI/Vertex 路线仍用原适配器。原 `gemini-transcribe-personal` 的 SMART 与个人词库不变,会议管线不动。
- 本轮只验收非流式文件转写和 JSON 输出。不要请求 speaker labels、时间戳或 verbose_json,也不使用提示词来冒充润色能力。
- ZenMux 没有返回细分 audio/text token 计量,适配器只转发其音频秒数,不编造 token 拆分;费用以 ZenMux 的实际用量记录为准。
- 每次升级 LiteLLM,重跑 server-management 的 `scripts/test-zenmux-asr-wire.py`(同步/异步、原音频字节、JSON 转换、其他 provider 不变)和现有 Vertex dictation 回归测试。
@@ -0,0 +1,119 @@
"""Bridge OpenAI file uploads to Beijing Bailian's native ASR API."""
import base64
import io
import json
from pathlib import Path
from urllib.parse import urlsplit
import wave
from litellm.llms.base_llm.audio_transcription.transformation import AudioTranscriptionRequestData
from litellm.llms.openai.transcriptions.whisper_transformation import OpenAIWhisperAudioTranscriptionConfig
from litellm.litellm_core_utils.audio_utils.utils import process_audio_file
from litellm.types.utils import TranscriptionResponse, TranscriptionUsageDurationObject
MODEL = 'qwen-audio-3.1-asr-flash'
FORMATS = {'audio/wav':'wav','audio/x-wav':'wav','audio/wave':'wav',
'audio/mpeg':'mp3','audio/mp3':'mp3','audio/flac':'flac','audio/x-flac':'flac',
'audio/mp4':'m4a','audio/m4a':'m4a','audio/x-m4a':'m4a',
'audio/ogg':'ogg','audio/webm':'webm','video/webm':'webm','audio/aac':'aac'}
VOCABULARY_PATHS = frozenset({'/etc/litellm/personal-vocabulary.json',
'/credentials/personal-vocabulary.json'})
def vocabulary(path):
if path not in VOCABULARY_PATHS:
raise ValueError('Unexpected personal vocabulary path')
words = json.loads(Path(path).read_text())
if not isinstance(words,list) or not 1 <= len(words) <= 100:
raise ValueError('Personal vocabulary must contain 1-100 terms')
if any(not isinstance(w,str) or not w.strip() or len(w)>100 for w in words):
raise ValueError('Invalid personal vocabulary term')
return {w.strip():3 for w in words}
class BailianTranscriptionConfig(OpenAIWhisperAudioTranscriptionConfig):
@property
def has_native_transcription_endpoint(self):
return True
def get_supported_openai_params(self,model):
return ['language','response_format','prompt']
def map_openai_params(self,non_default_params,optional_params,model,drop_params):
result=super().map_openai_params(non_default_params,optional_params,model,drop_params)
if result.get('response_format') not in (None,'json','text'):
raise ValueError('Bailian dictation supports json/text without timestamps')
return result
def validate_environment(self,headers,model,messages,optional_params,litellm_params,
api_key=None,api_base=None):
if not api_key:raise ValueError('Beijing Bailian API key required')
return {**headers,'Authorization':'Bearer '+api_key,'Content-Type':'application/json',
'X-DashScope-SSE':'disable'}
def get_complete_url(self,api_base,api_key,model,optional_params,litellm_params,stream=None):
parsed=urlsplit(api_base or '')
if (model != MODEL or parsed.scheme != 'https' or
not (parsed.hostname or '').endswith('.cn-beijing.maas.aliyuncs.com') or
parsed.path.rstrip('/') != '/api/v1' or parsed.port not in (None,443) or
parsed.username or parsed.password or parsed.query or parsed.fragment):
raise ValueError('Bailian ASR restricted to Beijing workspace native endpoints')
if stream:raise ValueError('Streaming not enabled on this dictation route')
return api_base.rstrip('/')+'/services/aigc/multimodal-generation/generation'
def transform_audio_transcription_request(self,model,audio_file,optional_params,litellm_params):
if model != MODEL or optional_params.get('timestamp_granularities'):
raise ValueError('Unsupported Bailian dictation model or timestamps')
audio=process_audio_file(audio_file)
mime=audio.content_type.split(';',1)[0].lower()
if mime not in FORMATS:raise ValueError('Unsupported Bailian audio MIME type')
if len(audio.file_content)>10_000_000:raise ValueError('Bailian inline audio exceeds 10 MB')
parameters={'format':FORMATS[mime]}
if parameters['format']=='wav':
with wave.open(io.BytesIO(audio.file_content),'rb') as wav:
parameters['sample_rate']=str(wav.getframerate())
language=optional_params.get('language')
if language:parameters['language_hints']=[language]
# LiteLLM classifies DashScope as OpenAI-compatible and nests custom kwargs.
extra=optional_params.get('extra_body') or {}
path=optional_params.get('bailian_vocabulary_path') or extra.get('bailian_vocabulary_path')
if path:parameters['vocabulary']=vocabulary(path)
prompt=optional_params.get('prompt')
messages=[]
if prompt:
if not isinstance(prompt,str) or len(prompt)>400:
raise ValueError('Bailian context prompt must be at most 400 characters')
messages.append({'role':'user','content':[{'type':'input_text','text':prompt}]})
# Always preserve the original bytes; only WAV metadata supplies sample rate.
uri_mime='audio/mp3' if parameters['format']=='mp3' else mime
data_uri='data:'+uri_mime+';base64,'+base64.b64encode(audio.file_content).decode()
if len(data_uri)>10_000_000:
raise ValueError('Bailian inline audio exceeds 10 MB after Base64 encoding')
messages.append({'role':'user','content':[{'type':'input_audio','input_audio':{
'data':data_uri}}]})
return AudioTranscriptionRequestData(data={'model':MODEL,'input':{'messages':messages},
'parameters':parameters})
def transform_audio_transcription_response(self,raw_response):
body=raw_response.json()
text=body.get('output',{}).get('text',body.get('text'))
if not isinstance(text,str):raise ValueError('Bailian returned no transcript')
result=TranscriptionResponse(text=text)
duration=(body.get('usage') or {}).get('duration')
if duration is not None:
result.usage=TranscriptionUsageDurationObject(type='duration',seconds=duration)
result['duration']=duration
return result
def install():
import litellm
from litellm.utils import ProviderConfigManager
original=ProviderConfigManager.get_provider_audio_transcription_config
if getattr(original,'_bailian_asr_adapter',False):return
def select(model,provider):
if provider==litellm.LlmProviders.DASHSCOPE and model==MODEL:
return BailianTranscriptionConfig()
return original(model,provider)
select._bailian_asr_adapter=True
ProviderConfigManager.get_provider_audio_transcription_config=staticmethod(select)
+19 -3
View File
@@ -4,17 +4,20 @@ services:
restart: "no" restart: "no"
environment: environment:
GOOGLE_ADC_B64: ${GOOGLE_ADC_B64} GOOGLE_ADC_B64: ${GOOGLE_ADC_B64}
PERSONAL_VOCABULARY_B64: ${PERSONAL_VOCABULARY_B64:?Personal vocabulary is required}
volumes: volumes:
- google-adc:/credentials - google-adc:/credentials
command: command:
- /bin/sh - /bin/sh
- -c - -c
- | - |
set -eu
umask 077 umask 077
printf '%s' "$${GOOGLE_ADC_B64}" | base64 -d > /credentials/application_default_credentials.json printf '%s' "$${GOOGLE_ADC_B64}" | base64 -d > /credentials/application_default_credentials.json
printf '%s' "$${PERSONAL_VOCABULARY_B64}" | base64 -d > /credentials/personal-vocabulary.json
litellm: litellm:
image: ghcr.io/berriai/litellm:main-stable@sha256:625981c83410a3ea68eb0697590a57ec1d764d634514d54fa5db0591077ee839 image: ghcr.io/berriai/litellm:main-stable@sha256:3d88bd757134a00e8c00237b85a021ab1f6d54abb9c1df750f411dbe7256a500
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
adc-init: adc-init:
@@ -28,10 +31,23 @@ services:
VERTEXAI_PROJECT: ${VERTEXAI_PROJECT} VERTEXAI_PROJECT: ${VERTEXAI_PROJECT}
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"
ZENMUX_SUBSCRIPTION_API_KEY: ${ZENMUX_SUBSCRIPTION_API_KEY:-}
ZENMUX_PAYG_API_KEY: ${ZENMUX_PAYG_API_KEY:-}
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
BAILIAN_CN_API_KEY: ${BAILIAN_CN_API_KEY:-}
BAILIAN_CN_API_BASE: ${BAILIAN_CN_API_BASE:-https://ws-v68kvjnmhkiejaxo.cn-beijing.maas.aliyuncs.com/api/v1}
volumes: volumes:
- ./config.yaml:/app/config.yaml:ro - ./models.yaml:/etc/litellm/models.yaml:ro
- ./provider_routes.py:/etc/litellm/provider_routes.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
- ./bailian_transcription.py:/etc/litellm/bailian_transcription.py:ro
- google-adc:/credentials:ro - google-adc:/credentials:ro
command: ["--config", "/app/config.yaml", "--host", "0.0.0.0", "--port", "${LITELLM_PORT:-1049}"] entrypoint: ["/bin/sh", "-c"]
command:
- python /etc/litellm/render_config.py && exec python /etc/litellm/vertex_dictation.py --config /tmp/litellm.yaml --host 0.0.0.0 --port ${LITELLM_PORT:-1049} --num_workers 1
logging: logging:
driver: json-file driver: json-file
options: options:
+43
View File
@@ -0,0 +1,43 @@
# 除 Google 自带 latest 别名外,本项目映射不会自行追踪上游发布。
zenmux:
claude-sonnet-latest: anthropic/claude-sonnet-5.5
claude-opus-latest: anthropic/claude-opus-5.5
claude-haiku-latest: anthropic/claude-haiku-5.5
openrouter:
deepseek-flash-latest: deepseek/deepseek-v4.1-flash
deepseek-pro-latest: deepseek/deepseek-v4-pro
glm-latest: z-ai/glm-5.3-prime
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.
zenmux_open_models:
deepseek-flash-latest: deepseek/deepseek-v4.1-flash
deepseek-pro-latest: deepseek/deepseek-v4-pro
glm-latest: z-ai/glm-5.3-prime
glm-flash-latest: z-ai/glm-5.3-flash
kimi-latest: moonshotai/kimi-k3
mimo-flash-latest: xiaomi/mimo-v2.6-flash
zenmux_chat:
gemini-flash-zenmux: google/gemini-3.8-flash
google_ai_studio:
gemini-flash-latest: gemini-flash-latest
gemini-flash-lite-latest: gemini-flash-lite-latest
gemini-pro-latest: gemini-pro-latest
gemini-embedding-2-studio: gemini-embedding-2
google_vertex:
gemini-flash-vertex: gemini-3.8-flash
gemini-flash-lite-vertex: gemini-3.5-flash-lite
gemini-pro-vertex: gemini-3.1-pro-preview
google-rerank-default: semantic-ranker-default@latest
google-rerank-fast: semantic-ranker-fast@latest
# 已有 Hindsight 部署用过的候选模型;本代理仍需独立授权与请求验收。
# renderer 在实际 token 出现前排除这些路线。
chatgpt:
gpt-luna-latest: gpt-6-luna
gpt-sol-latest: gpt-6.1-sol
gpt-work-sol: gpt-6-sol
gpt-review-5.5: gpt-5.5
gpt-astra-latest: gpt-6-astra
xai:
grok-latest: grok-4.7
@@ -0,0 +1,175 @@
"""Render only credential-ready routes; credentials remain environment references."""
import os
import json
from copy import deepcopy
from pathlib import Path
import yaml
def oauth_ready(path):
try:
record = json.loads(path.read_text())
return isinstance(record, dict) and bool(record.get('access_token'))
except (OSError, ValueError):
return False
def namespace_config(config):
"""Add explicit provider names without removing existing client aliases."""
routes = config['model_list']
existing = {r['model_name']: r for r in routes}
fallbacks = config['router_settings']['fallbacks']
automatic = {name for group in fallbacks for name in group}
renamed = {}
def clone(route, alias):
if alias in existing:
if existing[alias]['litellm_params'] != route['litellm_params']:
raise ValueError('Conflicting namespace alias: ' + alias)
return
new = deepcopy(route)
new['model_name'] = alias
existing[alias] = new
routes.append(new)
for route in list(routes):
alias = route['model_name']
model = route['litellm_params']['model']
if alias in {'gemini-3.6-flash', 'gemini-3.5-flash-lite',
'google/gemini-embedding-2', 'google/gemini-3.8-flash'}:
continue
if alias.endswith('-openrouter'):
new = 'openrouter/' + alias.removesuffix('-openrouter')
elif alias in automatic:
new = 'auto/' + alias
clone(route, 'zenmux/' + alias)
elif model.startswith('anthropic/'):
new = ('zenmux-payg/' + alias.removesuffix('-payg')
if alias.endswith('-payg') else 'zenmux/' + alias)
elif model.startswith('openai/'):
name = model.removeprefix('openai/').split('/', 1)[-1]
new = 'zenmux/' + (name if alias.startswith('asr-') else
alias.replace('-zenmux', '-latest'))
elif model.startswith('openrouter/'):
new = 'openrouter/' + alias
elif model.startswith('gemini/'):
new = 'aistudio/' + alias.removesuffix('-studio')
elif model.startswith('vertex_ai/'):
name = {'google-rerank-default': 'rerank-default-latest',
'google-rerank-fast': 'rerank-fast-latest'}.get(alias,
alias.replace('-vertex', '-latest'))
new = 'vertex/' + name
elif model.startswith('chatgpt/'):
name = model.split('/', 1)[1] if alias in {'gpt-work-sol', 'gpt-review-5.5'} else alias
new = 'codex/' + name
elif model.startswith('xai/'):
new = 'xai/' + alias
else:
raise ValueError('Unrecognized namespace provider: ' + alias)
clone(route, new)
renamed[alias] = new
for group in list(fallbacks):
for source, targets in group.items():
fallbacks.append({renamed[source]: [renamed[target] for target in targets]})
return config
def add_bailian_routes(config, env, vocabulary_path):
if env.get('BAILIAN_CN_API_KEY'):
base = env.get('BAILIAN_CN_API_BASE')
if not base:
raise ValueError('BAILIAN_CN_API_BASE required for Beijing Bailian')
for personal in [False, True]:
params = {'model': 'dashscope/qwen-audio-3.1-asr-flash',
'api_base': base, 'api_key': 'os.environ/BAILIAN_CN_API_KEY',
'max_retries': 0}
if personal:
params['bailian_vocabulary_path'] = vocabulary_path
config['model_list'].append({'model_name': 'bailian-cn/qwen-audio-3.1-asr-flash' +
('-personal' if personal else ''), 'litellm_params': params})
return config
def build_config(catalog, env, exists, namespaced=True):
routes = []
fallbacks = []
def add(alias, model, key=None, **params):
if key:
params['api_key'] = 'os.environ/' + key
routes.append({'model_name': alias, 'litellm_params': {'model': model, **params}})
for key, suffix in [('ZENMUX_SUBSCRIPTION_API_KEY', ''), ('ZENMUX_PAYG_API_KEY', '-payg')]:
if env.get(key):
for alias, model in catalog['zenmux'].items():
# Strip only the LiteLLM provider prefix, retaining ZenMux's vendor prefix.
add(alias + suffix, 'anthropic/' + model, key,
api_base='https://zenmux.ai/api/anthropic')
if env.get('ZENMUX_SUBSCRIPTION_API_KEY'):
for alias, model in catalog.get('zenmux_chat', {}).items():
add(alias, 'openai/' + model, 'ZENMUX_SUBSCRIPTION_API_KEY',
api_base='https://zenmux.ai/api/v1')
if env.get('OPENROUTER_API_KEY'):
for alias, model in catalog['openrouter'].items():
primary = catalog.get('zenmux_open_models', {}).get(alias)
if primary and env.get('ZENMUX_SUBSCRIPTION_API_KEY'):
if primary != model:
raise ValueError('Cross-provider model mismatch: ' + alias)
add(alias, 'openai/' + primary, 'ZENMUX_SUBSCRIPTION_API_KEY',
api_base='https://zenmux.ai/api/v1', max_retries=0)
routes[-1]['model_info'] = {'cooldown_time': 60,
'allowed_fails_policy': {'RateLimitErrorAllowedFails': 0,
'AuthenticationErrorAllowedFails': 0,
'InternalServerErrorAllowedFails': 0,
'ServiceUnavailableErrorAllowedFails': 0,
'TimeoutErrorAllowedFails': 0}}
add(alias + '-openrouter', 'openrouter/' + model, 'OPENROUTER_API_KEY')
fallbacks.append({alias: [alias + '-openrouter']})
else:
add(alias, 'openrouter/' + model, 'OPENROUTER_API_KEY')
elif env.get('ZENMUX_SUBSCRIPTION_API_KEY'):
for alias, model in catalog.get('zenmux_open_models', {}).items():
add(alias, 'openai/' + model, 'ZENMUX_SUBSCRIPTION_API_KEY',
api_base='https://zenmux.ai/api/v1', max_retries=0)
if env.get('GOOGLE_AI_STUDIO_API_KEY'):
add('gemini-transcribe-personal', 'gemini/gemini-3.5-transcribe',
'GOOGLE_AI_STUDIO_API_KEY', personal_vocabulary=True)
for alias, model in catalog['google_ai_studio'].items():
add(alias, 'gemini/' + model, 'GOOGLE_AI_STUDIO_API_KEY')
if env.get('VERTEXAI_ENABLED', '').lower() == 'true' and env.get('VERTEXAI_PROJECT'):
for alias, model in catalog.get('google_vertex', {}).items():
add(alias, 'vertex_ai/' + model,
vertex_project='os.environ/VERTEXAI_PROJECT',
vertex_location='os.environ/VERTEXAI_LOCATION')
for provider, token_var, default_dir in [
('chatgpt', 'CHATGPT_TOKEN_DIR', '/tokens/chatgpt'),
('xai', 'XAI_OAUTH_TOKEN_DIR', '/tokens/xai'),
]:
if exists(Path(env.get(token_var, default_dir)) / 'auth.json'):
for alias, model in catalog[provider].items():
extra = {'use_xai_oauth': True} if provider == 'xai' else {}
add(alias, provider + '/' + model, **extra)
config = {
'model_list': routes,
'general_settings': {
'master_key': 'os.environ/LITELLM_MASTER_KEY',
'database_url': 'os.environ/DATABASE_URL',
'store_model_in_db': False,
'disable_env_credential_login': True,
},
'litellm_settings': {'drop_params': False, 'set_verbose': False},
# Only explicitly approved open-model routes have cross-provider fallbacks.
'router_settings': {'num_retries': 1, 'timeout': 180, 'fallbacks': fallbacks},
}
return add_bailian_routes(namespace_config(config), env,
'/etc/litellm/personal-vocabulary.json') if namespaced else config
if __name__ == '__main__':
catalog = yaml.safe_load(Path('/etc/litellm/models.yaml').read_text())
config = build_config(catalog, os.environ, oauth_ready)
output = Path('/tmp/litellm.yaml')
output.write_text(yaml.safe_dump(config, allow_unicode=True, sort_keys=False))
output.chmod(0o600)
print('Configured routes:', ', '.join(route['model_name'] for route in config['model_list']))
@@ -0,0 +1,59 @@
"""HK provider routes and separately opted-in personal Vertex dictation."""
import os
from pathlib import Path
import yaml
from provider_routes import build_config, namespace_config, add_bailian_routes
def build_hk_config(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']
routes[:] = [route for route in routes if route['model_name'] != 'gemini-transcribe-personal']
if env.get('ZENMUX_SUBSCRIPTION_API_KEY'):
asr_models = {
'asr-gpt': 'openai/gpt-transcribe',
'asr-qwen': 'qwen/qwen-audio-3.1-asr-flash',
'asr-doubao': 'bytedance/doubao-seed-asr-2.0',
'asr-mimo': 'xiaomi/mimo-v2.5-asr',
}
for alias, model in asr_models.items():
routes.append({'model_name': alias, 'litellm_params': {
'model': 'openai/' + model,
'api_key': 'os.environ/ZENMUX_SUBSCRIPTION_API_KEY',
'api_base': 'https://zenmux.ai/api/v1',
'max_retries': 0}})
if env.get('GOOGLE_AI_STUDIO_API_KEY'):
routes.append({'model_name': 'gemini-transcribe', 'litellm_params': {
'model': 'gemini/gemini-3.5-transcribe',
'api_key': 'os.environ/GOOGLE_AI_STUDIO_API_KEY'}})
if env.get('VERTEXAI_ENABLED', '').lower() == 'true' and env.get('VERTEXAI_PROJECT'):
routes.append({'model_name': 'gemini-transcribe-personal', 'litellm_params': {
'model': 'vertex_ai/gemini-3.5-transcribe',
'vertex_project': 'os.environ/VERTEXAI_PROJECT',
'vertex_location': 'global',
'personal_dictation': True}})
# Keep old client aliases; the old 3.6 alias now follows the verified Flash map.
legacy = {
'gemini-3.6-flash': catalog['google_vertex']['gemini-flash-vertex'],
'gemini-3.5-flash-lite': catalog['google_vertex']['gemini-flash-lite-vertex'],
'google/gemini-embedding-2': 'gemini-embedding-2',
'google/gemini-3.8-flash': catalog['google_vertex']['gemini-flash-vertex'],
}
for alias, model in legacy.items():
routes.append({'model_name': alias, 'litellm_params': {
'model': 'vertex_ai/' + model,
'vertex_project': 'os.environ/VERTEXAI_PROJECT',
'vertex_location': 'os.environ/VERTEXAI_LOCATION'}})
return add_bailian_routes(namespace_config(config), env, '/credentials/personal-vocabulary.json')
if __name__ == '__main__':
catalog = yaml.safe_load(Path('/etc/litellm/models.yaml').read_text())
config = build_hk_config(catalog, os.environ)
output = Path('/tmp/litellm.yaml')
output.write_text(yaml.safe_dump(config, allow_unicode=True, sort_keys=False))
output.chmod(0o600)
print('Configured routes:', ', '.join(route['model_name'] for route in config['model_list']))
+56
View File
@@ -0,0 +1,56 @@
import unittest
from pathlib import Path
import yaml
from render_config import build_hk_config
class RoutingTests(unittest.TestCase):
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(routes[1]['litellm_params']['bailian_vocabulary_path'],
'/credentials/personal-vocabulary.json')
def test_all_providers(self):
catalog = yaml.safe_load(Path(__file__).with_name('models.yaml').read_text())
env = {key: 'synthetic' for key in ['ZENMUX_SUBSCRIPTION_API_KEY',
'ZENMUX_PAYG_API_KEY', 'OPENROUTER_API_KEY', 'GOOGLE_AI_STUDIO_API_KEY',
'VERTEXAI_PROJECT']}
env['VERTEXAI_ENABLED'] = 'true'
result = build_hk_config(catalog, env)
routes = {r['model_name']: r['litellm_params'] for r in result['model_list']}
self.assertEqual(len(routes), len(result['model_list']))
self.assertEqual(routes['gemini-transcribe-personal']['model'], 'vertex_ai/gemini-3.5-transcribe')
self.assertIs(routes['gemini-transcribe-personal']['personal_dictation'], True)
self.assertEqual(routes['gemini-transcribe-personal']['vertex_location'], 'global')
self.assertNotIn('grok-latest', routes)
self.assertNotIn('gpt-sol-latest', routes)
self.assertEqual(routes['asr-gpt']['model'], 'openai/openai/gpt-transcribe')
self.assertEqual(routes['asr-qwen']['model'], 'openai/qwen/qwen-audio-3.1-asr-flash')
self.assertEqual(routes['asr-doubao']['model'], 'openai/bytedance/doubao-seed-asr-2.0')
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['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.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'])
self.assertEqual(result['general_settings'], {'master_key': 'os.environ/LITELLM_MASTER_KEY'})
self.assertNotIn('synthetic', yaml.safe_dump(result))
def test_missing_credentials(self):
catalog = yaml.safe_load(Path(__file__).with_name('models.yaml').read_text())
result = build_hk_config(catalog, {})
self.assertEqual(result['model_list'], [])
self.assertEqual(result['router_settings']['fallbacks'], [])
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,54 @@
"""Scoped Vertex dictation settings; ordinary transcription calls stay unchanged."""
import json
from pathlib import Path
VOCABULARY = Path('/credentials/personal-vocabulary.json')
def vocabulary(path=VOCABULARY):
words = json.loads(path.read_text())
if not isinstance(words, list) or not 1 <= len(words) <= 100:
raise ValueError('Personal vocabulary must contain 1-100 terms')
if any(not isinstance(word, str) or not word.strip() or len(word) > 100 for word in words):
raise ValueError('Invalid personal vocabulary term')
return list(dict.fromkeys(word.strip() for word in words))
def install():
from litellm.llms.vertex_ai.audio_transcription.gemini_transcribe_transformation import (
VertexGeminiAudioTranscriptionConfig,
)
original = VertexGeminiAudioTranscriptionConfig.transform_audio_transcription_request
if getattr(original, '_personal_dictation_patch', False):
return
def transform(self, model, audio_file, optional_params, litellm_params):
if optional_params.get('personal_dictation') is not True:
return original(self, model, audio_file, optional_params, litellm_params)
if model.removeprefix('vertex_ai/') != 'gemini-3.5-transcribe':
raise ValueError('Personal dictation is restricted to Vertex Gemini 3.5 Transcribe')
if optional_params.get('timestamp_granularities') or optional_params.get('response_format') not in (None, 'json', 'text'):
raise ValueError('Personal dictation supports json/text without timestamps')
clean = {key: val for key, val in optional_params.items() if key != 'personal_dictation'}
result = original(self, model, audio_file, clean, litellm_params)
config = result.data.setdefault('generationConfig', {}).setdefault('audioTranscriptionConfig', {})
if set(config) - {'mode', 'languageCodes'} or config.get('mode') not in (None, 'VERBATIM'):
raise ValueError('Unexpected Vertex transcription defaults; review adapter compatibility')
config['mode'] = 'SMART'
config['customVocabulary'] = vocabulary()
return result
transform._personal_dictation_patch = True
VertexGeminiAudioTranscriptionConfig.transform_audio_transcription_request = transform
if __name__ == '__main__':
import sys
from litellm import run_server
from zenmux_transcription import install as install_zenmux_asr
from bailian_transcription import install as install_bailian_asr
install_zenmux_asr()
install_bailian_asr()
install()
sys.argv[0] = 'litellm'
sys.exit(run_server())
@@ -0,0 +1,91 @@
"""Adapt four allowlisted ZenMux JSON ASR models to LiteLLM's audio endpoint."""
import base64
from litellm.llms.base_llm.audio_transcription.transformation import AudioTranscriptionRequestData
from litellm.llms.openai.transcriptions.whisper_transformation import OpenAIWhisperAudioTranscriptionConfig
from litellm.litellm_core_utils.audio_utils.utils import process_audio_file
from litellm.types.utils import TranscriptionResponse, TranscriptionUsageDurationObject
MODELS = frozenset({'openai/gpt-transcribe', 'qwen/qwen-audio-3.1-asr-flash',
'bytedance/doubao-seed-asr-2.0', 'xiaomi/mimo-v2.5-asr'})
API_BASE = 'https://zenmux.ai/api/v1'
class ZenMuxTranscriptionConfig(OpenAIWhisperAudioTranscriptionConfig):
@property
def has_native_transcription_endpoint(self):
return True
def get_supported_openai_params(self, model):
return ['language', 'response_format', 'prompt']
def map_openai_params(self, non_default_params, optional_params, model, drop_params):
params = super().map_openai_params(non_default_params, optional_params, model, drop_params)
if params.get('prompt'):
raise ValueError('ZenMux comparison routes do not accept prompts; leave prompt empty')
if params.get('response_format') not in (None, 'json', 'text'):
raise ValueError('ZenMux comparison routes support json/text only')
return params
def validate_environment(self, headers, model, messages, optional_params,
litellm_params, api_key=None, api_base=None):
if not api_key:
raise ValueError('ZenMux subscription API key is required')
return {**headers, 'Authorization': 'Bearer ' + api_key, 'Content-Type': 'application/json'}
def get_complete_url(self, api_base, api_key, model, optional_params, litellm_params, stream=None):
if (api_base or '').rstrip('/') != API_BASE or model not in MODELS:
raise ValueError('ZenMux ASR adapter is restricted to the configured endpoint and model allowlist')
if stream:
raise ValueError('Streaming is not enabled for comparison dictation routes')
return API_BASE + '/audio/transcriptions'
def transform_audio_transcription_request(self, model, audio_file, optional_params, litellm_params):
if model not in MODELS:
raise ValueError('Unknown ZenMux ASR model')
if optional_params.get('timestamp_granularities') or optional_params.get('prompt'):
raise ValueError('Timestamps and prompt injection are disabled on comparison routes')
audio = process_audio_file(audio_file)
formats = {'audio/wav': 'wav', 'audio/x-wav': 'wav', 'audio/wave': 'wav',
'audio/mpeg': 'mp3', 'audio/mp3': 'mp3', 'audio/flac': 'flac',
'audio/x-flac': 'flac', 'audio/m4a': 'm4a', 'audio/mp4': 'm4a',
'audio/x-m4a': 'm4a', 'audio/ogg': 'ogg', 'audio/webm': 'webm',
'video/webm': 'webm', 'audio/aac': 'aac'}
mime = audio.content_type.split(';', 1)[0].lower()
if mime not in formats:
raise ValueError('Unsupported audio MIME type for ZenMux ASR')
body = {'model': model, 'input_audio': {
'data': base64.b64encode(audio.file_content).decode(), 'format': formats[mime]},
'stream': False}
language = optional_params.get('language')
if language:
body['language'] = language
return AudioTranscriptionRequestData(data=body)
def transform_audio_transcription_response(self, raw_response):
body = raw_response.json()
if not isinstance(body.get('text'), str):
raise ValueError('ZenMux ASR returned no transcript text')
response = TranscriptionResponse(text=body['text'])
usage = body.get('usage') or {}
if usage.get('seconds') is not None:
# ZenMux has no audio/text token breakdown; report duration without inventing one.
response.usage = TranscriptionUsageDurationObject(type='duration', seconds=usage['seconds'])
response['duration'] = usage['seconds']
return response
def install():
import litellm
from litellm.utils import ProviderConfigManager
original = ProviderConfigManager.get_provider_audio_transcription_config
if getattr(original, '_zenmux_asr_adapter', False):
return
def select(model, provider):
if provider == litellm.LlmProviders.OPENAI and model in MODELS:
return ZenMuxTranscriptionConfig()
return original(model, provider)
select._zenmux_asr_adapter = True
ProviderConfigManager.get_provider_audio_transcription_config = staticmethod(select)
@@ -0,0 +1,24 @@
# 模型命名空间
2026-10-27 起新增客户端命名空间,保留旧名兼容。实际型号、协议、凭据、个人热词和 SMART 均不改变;latest 不代表自动跟踪发布。
| 前缀 | 含义 |
| --- | --- |
| zenmux/ | ZenMux 订阅直连,不跨 provider 回退 |
| zenmux-payg/ | ZenMux PAYG,仅显式请求 |
| openrouter/ | OpenRouter 直连 |
| auto/ | 开源模型 ZenMux 订阅优先,故障回退 OpenRouter |
| aistudio/ | Google AI Studio |
| vertex/ | Google Vertex |
| codex/ | Codex OAuth 订阅 |
| xai/ | xAI OAuth 订阅 |
常规旧别名加对应前缀。特殊转换:`gemini-flash-zenmux` → `zenmux/gemini-flash-latest`;`gemini-*-vertex` → `vertex/gemini-*-latest`;`gemini-embedding-2-studio` → `aistudio/gemini-embedding-2`;`google-rerank-default/fast` → `vertex/rerank-default/fast-latest`;`gpt-work-sol` → `codex/gpt-6-sol`;`gpt-review-5.5` → `codex/gpt-5.5`。PAYG 去掉旧 `-payg` 后缀,改加 `zenmux-payg/` 前缀。
六组开源模型 `deepseek-flash-latest`、`deepseek-pro-latest`、`glm-latest`、`glm-flash-latest`、`kimi-latest`、`mimo-flash-latest` 各提供 auto/、zenmux/、openrouter/ 三种选择。旧自动回退名和旧 -openrouter 名保留。
tools-pve 的个人语音名为 `aistudio/gemini-transcribe-personal`;HK 使用 `vertex/gemini-transcribe-personal`,不能互换两端 provider。HK 四款 ZenMux ASR 别名为 `zenmux/gpt-transcribe`、`zenmux/qwen-audio-3.1-asr-flash`、`zenmux/doubao-seed-asr-2.0`、`zenmux/mimo-v2.5-asr`。HK 的四个历史 Google 兼容名保留。
只有凭据已就绪的路线才生成。新旧别名的 litellm_params 必须完全相同,不能把客户端前缀当作上游 provider 参数。受模型白名单限制的 virtual key 需要另行授权新名;不扩大既有 key 权限。旧名暂不删除,待所有客户端完成迁移后单独处理。
修改 renderer 后,通过 Arcane Git Sync 同步,并重启对应 LiteLLM 进程使 /tmp/litellm.yaml 重建;仅同步代码不代表进程已加载。逐节点确认 /v1/models、新旧路由参数等价、fallback 配置及实际请求。
@@ -0,0 +1,24 @@
# 百炼北京原厂 ASR
2026-10-27 上线,仅北京,不配置新加坡或跨地域回退。客户端沿用本机 LiteLLM 的 /v1/audio/transcriptions、现有客户端 key、multipart 文件上传,推荐 response_format=json。
| 模型名 | 行为 |
| --- | --- |
| bailian-cn/qwen-audio-3.1-asr-flash | 原厂识别,无词表注入 |
| bailian-cn/qwen-audio-3.1-asr-flash-personal | 相同模型,原生 vocabulary 注入既有 20 个人名/公司名,每词权重 3 |
上游为业务空间专属域名 `https://ws-v68kvjnmhkiejaxo.cn-beijing.maas.aliyuncs.com/api/v1`,使用原生 `/services/aigc/multimodal-generation/generation`,不是 compatible-mode。已用 .145 现有 BAILIAN_API_KEY 实测该空间可用;部署后凭据权威是各 Arcane Project 的 BAILIAN_CN_API_KEY,原始 key 不进入 Git。
当前 LiteLLM 1.104.2 没有 DashScope 原生转写配置,启动适配器仅接管 dashscope provider 的 qwen-audio-3.1-asr-flash。普通 Gemini、Vertex、ZenMux 和 OpenAI 不变。不增加服务、端口或镜像,不重新部署会议管线。
文件原字节转为 Base64 Data URI。WAV 从文件头读取采样率,非 WAV 不伪造采样率;上游可选 prompt 作为 input_text 上下文,不覆盖、不做二次 LLM 改写,最多 400 字。生产个人词表从当前节点已有私有文件读取,不把词表放到 Git。DashScope 自定义参数在 SDK 中进入 extra_body,适配器需取出私有词表路径,再生成 parameters.vocabulary;私有路径不能泄漏上游。
不启用 streaming、时间戳或说话人分离,不承诺自动去口语化;原厂默认识别行为不额外覆盖。上传 Base64 Data URI 保守限制 10 MB,超限明确拒绝,不自动把文件上传公网。json 为已验证的推荐响应格式。
配置验证:先运行每目录 test_config.py,再在固定镜像中执行 scripts/test-bailian-asr-wire.py,检查同步/异步真实 SDK 的 HTTP 请求体、热词、原字节保持及其他 provider 不受影响。
原厂 A/B:2026-07-21 电话前 90 秒,无热词两处“洛奇”,指定临时热词“洛启、普华”(权重 3)后两处“洛启”,耗时分别 3.432/3.649 秒。该结果只证明该片段短词表有改善,不代表已有 20 词表一定同等改善或整体 CER/WER。
上线后必须验证:两节点普通/个人路由公开音频、现有客户端 key 权限、词表开关和 native vocabulary 请求体。新模型不因部署便自动获得 virtual key 权限;仅为用户批准的应用增加这两条路线,不扩大到全部模型。
官方文档:https://help.aliyun.com/zh/model-studio/fun-asr-flash-recorded-speech-recognition-http-api
@@ -20,3 +20,10 @@ dc1.opfo.xyz {
</html> </html>
HTML HTML
} }
api.opfo.xyz {
encode zstd gzip
reverse_proxy litellm-api:1049 {
flush_interval -1
}
}
@@ -28,6 +28,9 @@ services:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data - caddy-data:/data
- caddy-config:/config - caddy-config:/config
networks:
- default
- edge-api
logging: logging:
driver: json-file driver: json-file
options: options:
@@ -39,6 +42,9 @@ volumes:
caddy-config: caddy-config:
networks: networks:
edge-api:
external: true
name: dc1-api
proxy: proxy:
external: true external: true
name: dc1-proxy name: dc1-proxy
+3
View File
@@ -0,0 +1,3 @@
__pycache__/
*.pyc
.env
+26
View File
@@ -0,0 +1,26 @@
# DC1 公网模型代理
2026-10-29从HK迁移,保留`https://api.opfo.xyz/v1`、原客户端认证、全部旧别名、
Vertex ADC与SMART个人听写、四款ZenMux ASR、百炼及开源模型主备。
无数据库,不迁移`.49`的OAuth或数据库,不从其他Agent复制refresh token。
代码与非敏感配置在本目录,真实环境变量在DC1 Arcane `litellm-managed`私有.env。
ADC和个人词库从原HK项目环境快照初始化到私有named volume,只读提供给应用;
Base64不是加密,不能提交env或凭据卷。旧HK保留作回滚,不自动删除任何数据。
单worker,应用CPU上限1核、内存896MiB;不在VPS构建镜像。固定原LiteLLM镜像,
本轮不升级镜像或SDK。Docker网络`dc1-api`由此项目创建,现有frontdoor Caddy加入它,
HAProxy的既有SNI分流与代理端口不变,公网不直接发布LiteLLM或管理端口。
新增`openai/...`为官方API独立路由,只有明确批准的OPENAI_API_KEY出现才启用;
不把ZenMux GPT转写别名偷换为官方API。这里没有订阅OAuth,因此不伪造Codex备用链。
`.49`的订阅到API回退是另一个独立实例。本次不自动启用AI Studio。
迁移顺序:源元数据/私有环境备份;DC1新Git Sync/Project;部署后以原key验证;
再更新frontdoor并确认HTTPS签发条件;最后只切api.opfo.xyz的CNAME,保留旧值回滚。
TLS证书由DC1 Caddy自动签发,正式DNS切换后须检查证书链和SSE/音频请求,失败回滚。
其他域名、HK网站、Gitea、代理协议、防火墙和Tailscale不改。
```bash
python3 -m unittest discover -s . -p test_config.py
```
@@ -0,0 +1,119 @@
"""Bridge OpenAI file uploads to Beijing Bailian's native ASR API."""
import base64
import io
import json
from pathlib import Path
from urllib.parse import urlsplit
import wave
from litellm.llms.base_llm.audio_transcription.transformation import AudioTranscriptionRequestData
from litellm.llms.openai.transcriptions.whisper_transformation import OpenAIWhisperAudioTranscriptionConfig
from litellm.litellm_core_utils.audio_utils.utils import process_audio_file
from litellm.types.utils import TranscriptionResponse, TranscriptionUsageDurationObject
MODEL = 'qwen-audio-3.1-asr-flash'
FORMATS = {'audio/wav':'wav','audio/x-wav':'wav','audio/wave':'wav',
'audio/mpeg':'mp3','audio/mp3':'mp3','audio/flac':'flac','audio/x-flac':'flac',
'audio/mp4':'m4a','audio/m4a':'m4a','audio/x-m4a':'m4a',
'audio/ogg':'ogg','audio/webm':'webm','video/webm':'webm','audio/aac':'aac'}
VOCABULARY_PATHS = frozenset({'/etc/litellm/personal-vocabulary.json',
'/credentials/personal-vocabulary.json'})
def vocabulary(path):
if path not in VOCABULARY_PATHS:
raise ValueError('Unexpected personal vocabulary path')
words = json.loads(Path(path).read_text())
if not isinstance(words,list) or not 1 <= len(words) <= 100:
raise ValueError('Personal vocabulary must contain 1-100 terms')
if any(not isinstance(w,str) or not w.strip() or len(w)>100 for w in words):
raise ValueError('Invalid personal vocabulary term')
return {w.strip():3 for w in words}
class BailianTranscriptionConfig(OpenAIWhisperAudioTranscriptionConfig):
@property
def has_native_transcription_endpoint(self):
return True
def get_supported_openai_params(self,model):
return ['language','response_format','prompt']
def map_openai_params(self,non_default_params,optional_params,model,drop_params):
result=super().map_openai_params(non_default_params,optional_params,model,drop_params)
if result.get('response_format') not in (None,'json','text'):
raise ValueError('Bailian dictation supports json/text without timestamps')
return result
def validate_environment(self,headers,model,messages,optional_params,litellm_params,
api_key=None,api_base=None):
if not api_key:raise ValueError('Beijing Bailian API key required')
return {**headers,'Authorization':'Bearer '+api_key,'Content-Type':'application/json',
'X-DashScope-SSE':'disable'}
def get_complete_url(self,api_base,api_key,model,optional_params,litellm_params,stream=None):
parsed=urlsplit(api_base or '')
if (model != MODEL or parsed.scheme != 'https' or
not (parsed.hostname or '').endswith('.cn-beijing.maas.aliyuncs.com') or
parsed.path.rstrip('/') != '/api/v1' or parsed.port not in (None,443) or
parsed.username or parsed.password or parsed.query or parsed.fragment):
raise ValueError('Bailian ASR restricted to Beijing workspace native endpoints')
if stream:raise ValueError('Streaming not enabled on this dictation route')
return api_base.rstrip('/')+'/services/aigc/multimodal-generation/generation'
def transform_audio_transcription_request(self,model,audio_file,optional_params,litellm_params):
if model != MODEL or optional_params.get('timestamp_granularities'):
raise ValueError('Unsupported Bailian dictation model or timestamps')
audio=process_audio_file(audio_file)
mime=audio.content_type.split(';',1)[0].lower()
if mime not in FORMATS:raise ValueError('Unsupported Bailian audio MIME type')
if len(audio.file_content)>10_000_000:raise ValueError('Bailian inline audio exceeds 10 MB')
parameters={'format':FORMATS[mime]}
if parameters['format']=='wav':
with wave.open(io.BytesIO(audio.file_content),'rb') as wav:
parameters['sample_rate']=str(wav.getframerate())
language=optional_params.get('language')
if language:parameters['language_hints']=[language]
# LiteLLM classifies DashScope as OpenAI-compatible and nests custom kwargs.
extra=optional_params.get('extra_body') or {}
path=optional_params.get('bailian_vocabulary_path') or extra.get('bailian_vocabulary_path')
if path:parameters['vocabulary']=vocabulary(path)
prompt=optional_params.get('prompt')
messages=[]
if prompt:
if not isinstance(prompt,str) or len(prompt)>400:
raise ValueError('Bailian context prompt must be at most 400 characters')
messages.append({'role':'user','content':[{'type':'input_text','text':prompt}]})
# Always preserve the original bytes; only WAV metadata supplies sample rate.
uri_mime='audio/mp3' if parameters['format']=='mp3' else mime
data_uri='data:'+uri_mime+';base64,'+base64.b64encode(audio.file_content).decode()
if len(data_uri)>10_000_000:
raise ValueError('Bailian inline audio exceeds 10 MB after Base64 encoding')
messages.append({'role':'user','content':[{'type':'input_audio','input_audio':{
'data':data_uri}}]})
return AudioTranscriptionRequestData(data={'model':MODEL,'input':{'messages':messages},
'parameters':parameters})
def transform_audio_transcription_response(self,raw_response):
body=raw_response.json()
text=body.get('output',{}).get('text',body.get('text'))
if not isinstance(text,str):raise ValueError('Bailian returned no transcript')
result=TranscriptionResponse(text=text)
duration=(body.get('usage') or {}).get('duration')
if duration is not None:
result.usage=TranscriptionUsageDurationObject(type='duration',seconds=duration)
result['duration']=duration
return result
def install():
import litellm
from litellm.utils import ProviderConfigManager
original=ProviderConfigManager.get_provider_audio_transcription_config
if getattr(original,'_bailian_asr_adapter',False):return
def select(model,provider):
if provider==litellm.LlmProviders.DASHSCOPE and model==MODEL:
return BailianTranscriptionConfig()
return original(model,provider)
select._bailian_asr_adapter=True
ProviderConfigManager.get_provider_audio_transcription_config=staticmethod(select)
+71
View File
@@ -0,0 +1,71 @@
services:
adc-init:
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
restart: "no"
environment:
GOOGLE_ADC_B64: ${GOOGLE_ADC_B64}
PERSONAL_VOCABULARY_B64: ${PERSONAL_VOCABULARY_B64:?Personal vocabulary is required}
volumes:
- google-adc:/credentials
command:
- /bin/sh
- -c
- |
set -eu
umask 077
printf '%s' "$${GOOGLE_ADC_B64}" | base64 -d > /credentials/application_default_credentials.json
printf '%s' "$${PERSONAL_VOCABULARY_B64}" | base64 -d > /credentials/personal-vocabulary.json
litellm:
image: ghcr.io/berriai/litellm:main-stable@sha256:3d88bd757134a00e8c00237b85a021ab1f6d54abb9c1df750f411dbe7256a500
restart: unless-stopped
cpus: "1.0"
mem_limit: 896m
depends_on:
adc-init:
condition: service_completed_successfully
networks:
edge-api:
aliases:
- litellm-api
environment:
GOOGLE_APPLICATION_CREDENTIALS: /credentials/application_default_credentials.json
VERTEXAI_PROJECT: ${VERTEXAI_PROJECT}
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:-}
BAILIAN_CN_API_KEY: ${BAILIAN_CN_API_KEY:-}
BAILIAN_CN_API_BASE: ${BAILIAN_CN_API_BASE:-https://ws-v68kvjnmhkiejaxo.cn-beijing.maas.aliyuncs.com/api/v1}
volumes:
- ./models.yaml:/etc/litellm/models.yaml:ro
- ./provider_routes.py:/etc/litellm/provider_routes.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
- ./bailian_transcription.py:/etc/litellm/bailian_transcription.py:ro
- google-adc:/credentials:ro
entrypoint: ["/bin/sh", "-c"]
command:
- python /etc/litellm/render_config.py && exec python /etc/litellm/vertex_dictation.py --config /tmp/litellm.yaml --host 0.0.0.0 --port ${LITELLM_PORT:-1049} --num_workers 1
logging:
driver: json-file
options:
max-size: 1m
max-file: "2"
healthcheck:
test: [CMD, python, -c, 'import urllib.request; urllib.request.urlopen("http://127.0.0.1:1049/health/liveliness", timeout=5)']
interval: 30s
timeout: 10s
retries: 5
start_period: 120s
volumes:
google-adc:
networks:
edge-api:
name: dc1-api
+54
View File
@@ -0,0 +1,54 @@
# 除 Google 自带 latest 别名外,本项目映射不会自行追踪上游发布。
zenmux:
claude-sonnet-latest: anthropic/claude-sonnet-5.5
claude-opus-latest: anthropic/claude-opus-5.5
claude-haiku-latest: anthropic/claude-haiku-5.5
openrouter:
deepseek-flash-latest: deepseek/deepseek-v4.1-flash
deepseek-pro-latest: deepseek/deepseek-v4-pro
glm-latest: z-ai/glm-5.3-prime
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.
zenmux_open_models:
deepseek-flash-latest: deepseek/deepseek-v4.1-flash
deepseek-pro-latest: deepseek/deepseek-v4-pro
glm-latest: z-ai/glm-5.3-prime
glm-flash-latest: z-ai/glm-5.3-flash
kimi-latest: moonshotai/kimi-k3
mimo-flash-latest: xiaomi/mimo-v2.6-flash
zenmux_chat:
gemini-flash-zenmux: google/gemini-3.8-flash
google_ai_studio:
gemini-flash-latest: gemini-flash-latest
gemini-flash-lite-latest: gemini-flash-lite-latest
gemini-pro-latest: gemini-pro-latest
gemini-embedding-2-studio: gemini-embedding-2
google_vertex:
gemini-flash-vertex: gemini-3.8-flash
gemini-flash-lite-vertex: gemini-3.5-flash-lite
gemini-pro-vertex: gemini-3.1-pro-preview
google-rerank-default: semantic-ranker-default@latest
google-rerank-fast: semantic-ranker-fast@latest
# 已有 Hindsight 部署用过的候选模型;本代理仍需独立授权与请求验收。
# renderer 在实际 token 出现前排除这些路线。
chatgpt:
gpt-luna-latest: gpt-6-luna
gpt-sol-latest: gpt-6.1-sol
gpt-work-sol: gpt-6-sol
gpt-review-5.5: gpt-5.5
gpt-astra-latest: gpt-6-astra
xai:
grok-latest: grok-4.7
openai:
gpt-terra-latest: gpt-6.1-terra
gpt-transcribe: gpt-transcribe
gpt-4o-transcribe: gpt-4o-transcribe
gpt-4o-mini-transcribe: gpt-4o-mini-transcribe
gpt-4o-transcribe-diarize: gpt-4o-transcribe-diarize
text-embedding-3-small: text-embedding-3-small
text-embedding-3-large: text-embedding-3-large
gpt-image-2.5-flare: gpt-image-2.5-flare
gpt-image-2.5-sunburst: gpt-image-2.5-sunburst
gpt-4o-mini-tts: gpt-4o-mini-tts
@@ -0,0 +1,175 @@
"""Render only credential-ready routes; credentials remain environment references."""
import os
import json
from copy import deepcopy
from pathlib import Path
import yaml
def oauth_ready(path):
try:
record = json.loads(path.read_text())
return isinstance(record, dict) and bool(record.get('access_token'))
except (OSError, ValueError):
return False
def namespace_config(config):
"""Add explicit provider names without removing existing client aliases."""
routes = config['model_list']
existing = {r['model_name']: r for r in routes}
fallbacks = config['router_settings']['fallbacks']
automatic = {name for group in fallbacks for name in group}
renamed = {}
def clone(route, alias):
if alias in existing:
if existing[alias]['litellm_params'] != route['litellm_params']:
raise ValueError('Conflicting namespace alias: ' + alias)
return
new = deepcopy(route)
new['model_name'] = alias
existing[alias] = new
routes.append(new)
for route in list(routes):
alias = route['model_name']
model = route['litellm_params']['model']
if alias in {'gemini-3.6-flash', 'gemini-3.5-flash-lite',
'google/gemini-embedding-2', 'google/gemini-3.8-flash'}:
continue
if alias.endswith('-openrouter'):
new = 'openrouter/' + alias.removesuffix('-openrouter')
elif alias in automatic:
new = 'auto/' + alias
clone(route, 'zenmux/' + alias)
elif model.startswith('anthropic/'):
new = ('zenmux-payg/' + alias.removesuffix('-payg')
if alias.endswith('-payg') else 'zenmux/' + alias)
elif model.startswith('openai/'):
name = model.removeprefix('openai/').split('/', 1)[-1]
new = 'zenmux/' + (name if alias.startswith('asr-') else
alias.replace('-zenmux', '-latest'))
elif model.startswith('openrouter/'):
new = 'openrouter/' + alias
elif model.startswith('gemini/'):
new = 'aistudio/' + alias.removesuffix('-studio')
elif model.startswith('vertex_ai/'):
name = {'google-rerank-default': 'rerank-default-latest',
'google-rerank-fast': 'rerank-fast-latest'}.get(alias,
alias.replace('-vertex', '-latest'))
new = 'vertex/' + name
elif model.startswith('chatgpt/'):
name = model.split('/', 1)[1] if alias in {'gpt-work-sol', 'gpt-review-5.5'} else alias
new = 'codex/' + name
elif model.startswith('xai/'):
new = 'xai/' + alias
else:
raise ValueError('Unrecognized namespace provider: ' + alias)
clone(route, new)
renamed[alias] = new
for group in list(fallbacks):
for source, targets in group.items():
fallbacks.append({renamed[source]: [renamed[target] for target in targets]})
return config
def add_bailian_routes(config, env, vocabulary_path):
if env.get('BAILIAN_CN_API_KEY'):
base = env.get('BAILIAN_CN_API_BASE')
if not base:
raise ValueError('BAILIAN_CN_API_BASE required for Beijing Bailian')
for personal in [False, True]:
params = {'model': 'dashscope/qwen-audio-3.1-asr-flash',
'api_base': base, 'api_key': 'os.environ/BAILIAN_CN_API_KEY',
'max_retries': 0}
if personal:
params['bailian_vocabulary_path'] = vocabulary_path
config['model_list'].append({'model_name': 'bailian-cn/qwen-audio-3.1-asr-flash' +
('-personal' if personal else ''), 'litellm_params': params})
return config
def build_config(catalog, env, exists, namespaced=True):
routes = []
fallbacks = []
def add(alias, model, key=None, **params):
if key:
params['api_key'] = 'os.environ/' + key
routes.append({'model_name': alias, 'litellm_params': {'model': model, **params}})
for key, suffix in [('ZENMUX_SUBSCRIPTION_API_KEY', ''), ('ZENMUX_PAYG_API_KEY', '-payg')]:
if env.get(key):
for alias, model in catalog['zenmux'].items():
# Strip only the LiteLLM provider prefix, retaining ZenMux's vendor prefix.
add(alias + suffix, 'anthropic/' + model, key,
api_base='https://zenmux.ai/api/anthropic')
if env.get('ZENMUX_SUBSCRIPTION_API_KEY'):
for alias, model in catalog.get('zenmux_chat', {}).items():
add(alias, 'openai/' + model, 'ZENMUX_SUBSCRIPTION_API_KEY',
api_base='https://zenmux.ai/api/v1')
if env.get('OPENROUTER_API_KEY'):
for alias, model in catalog['openrouter'].items():
primary = catalog.get('zenmux_open_models', {}).get(alias)
if primary and env.get('ZENMUX_SUBSCRIPTION_API_KEY'):
if primary != model:
raise ValueError('Cross-provider model mismatch: ' + alias)
add(alias, 'openai/' + primary, 'ZENMUX_SUBSCRIPTION_API_KEY',
api_base='https://zenmux.ai/api/v1', max_retries=0)
routes[-1]['model_info'] = {'cooldown_time': 60,
'allowed_fails_policy': {'RateLimitErrorAllowedFails': 0,
'AuthenticationErrorAllowedFails': 0,
'InternalServerErrorAllowedFails': 0,
'ServiceUnavailableErrorAllowedFails': 0,
'TimeoutErrorAllowedFails': 0}}
add(alias + '-openrouter', 'openrouter/' + model, 'OPENROUTER_API_KEY')
fallbacks.append({alias: [alias + '-openrouter']})
else:
add(alias, 'openrouter/' + model, 'OPENROUTER_API_KEY')
elif env.get('ZENMUX_SUBSCRIPTION_API_KEY'):
for alias, model in catalog.get('zenmux_open_models', {}).items():
add(alias, 'openai/' + model, 'ZENMUX_SUBSCRIPTION_API_KEY',
api_base='https://zenmux.ai/api/v1', max_retries=0)
if env.get('GOOGLE_AI_STUDIO_API_KEY'):
add('gemini-transcribe-personal', 'gemini/gemini-3.5-transcribe',
'GOOGLE_AI_STUDIO_API_KEY', personal_vocabulary=True)
for alias, model in catalog['google_ai_studio'].items():
add(alias, 'gemini/' + model, 'GOOGLE_AI_STUDIO_API_KEY')
if env.get('VERTEXAI_ENABLED', '').lower() == 'true' and env.get('VERTEXAI_PROJECT'):
for alias, model in catalog.get('google_vertex', {}).items():
add(alias, 'vertex_ai/' + model,
vertex_project='os.environ/VERTEXAI_PROJECT',
vertex_location='os.environ/VERTEXAI_LOCATION')
for provider, token_var, default_dir in [
('chatgpt', 'CHATGPT_TOKEN_DIR', '/tokens/chatgpt'),
('xai', 'XAI_OAUTH_TOKEN_DIR', '/tokens/xai'),
]:
if exists(Path(env.get(token_var, default_dir)) / 'auth.json'):
for alias, model in catalog[provider].items():
extra = {'use_xai_oauth': True} if provider == 'xai' else {}
add(alias, provider + '/' + model, **extra)
config = {
'model_list': routes,
'general_settings': {
'master_key': 'os.environ/LITELLM_MASTER_KEY',
'database_url': 'os.environ/DATABASE_URL',
'store_model_in_db': False,
'disable_env_credential_login': True,
},
'litellm_settings': {'drop_params': False, 'set_verbose': False},
# Only explicitly approved open-model routes have cross-provider fallbacks.
'router_settings': {'num_retries': 1, 'timeout': 180, 'fallbacks': fallbacks},
}
return add_bailian_routes(namespace_config(config), env,
'/etc/litellm/personal-vocabulary.json') if namespaced else config
if __name__ == '__main__':
catalog = yaml.safe_load(Path('/etc/litellm/models.yaml').read_text())
config = build_config(catalog, os.environ, oauth_ready)
output = Path('/tmp/litellm.yaml')
output.write_text(yaml.safe_dump(config, allow_unicode=True, sort_keys=False))
output.chmod(0o600)
print('Configured routes:', ', '.join(route['model_name'] for route in config['model_list']))
@@ -0,0 +1,74 @@
"""HK-compatible public routes on DC1, with separately named official OpenAI API."""
import os
from pathlib import Path
import yaml
from provider_routes import build_config, namespace_config, add_bailian_routes
def build_hk_config(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']
routes[:] = [route for route in routes if route['model_name'] != 'gemini-transcribe-personal']
if env.get('ZENMUX_SUBSCRIPTION_API_KEY'):
asr_models = {
'asr-gpt': 'openai/gpt-transcribe',
'asr-qwen': 'qwen/qwen-audio-3.1-asr-flash',
'asr-doubao': 'bytedance/doubao-seed-asr-2.0',
'asr-mimo': 'xiaomi/mimo-v2.5-asr',
}
for alias, model in asr_models.items():
routes.append({'model_name': alias, 'litellm_params': {
'model': 'openai/' + model,
'api_key': 'os.environ/ZENMUX_SUBSCRIPTION_API_KEY',
'api_base': 'https://zenmux.ai/api/v1',
'max_retries': 0}})
if env.get('GOOGLE_AI_STUDIO_API_KEY'):
routes.append({'model_name': 'gemini-transcribe', 'litellm_params': {
'model': 'gemini/gemini-3.5-transcribe',
'api_key': 'os.environ/GOOGLE_AI_STUDIO_API_KEY'}})
if env.get('VERTEXAI_ENABLED', '').lower() == 'true' and env.get('VERTEXAI_PROJECT'):
routes.append({'model_name': 'gemini-transcribe-personal', 'litellm_params': {
'model': 'vertex_ai/gemini-3.5-transcribe',
'vertex_project': 'os.environ/VERTEXAI_PROJECT',
'vertex_location': 'global',
'personal_dictation': True}})
# Keep old client aliases; the old 3.6 alias now follows the verified Flash map.
legacy = {
'gemini-3.6-flash': catalog['google_vertex']['gemini-flash-vertex'],
'gemini-3.5-flash-lite': catalog['google_vertex']['gemini-flash-lite-vertex'],
'google/gemini-embedding-2': 'gemini-embedding-2',
'google/gemini-3.8-flash': catalog['google_vertex']['gemini-flash-vertex'],
}
for alias, model in legacy.items():
routes.append({'model_name': alias, 'litellm_params': {
'model': 'vertex_ai/' + model,
'vertex_project': 'os.environ/VERTEXAI_PROJECT',
'vertex_location': 'os.environ/VERTEXAI_LOCATION'}})
config = add_bailian_routes(namespace_config(config), env, '/credentials/personal-vocabulary.json')
if env.get('OPENAI_API_KEY'):
models = dict(catalog.get('openai', {}))
for alias, model in catalog.get('chatgpt', {}).items():
models[alias if alias.endswith('-latest') else model] = model
for alias, model in models.items():
config['model_list'].append({'model_name': 'openai/' + alias, 'litellm_params': {
'model': 'openai/' + model, 'api_base': 'https://api.openai.com/v1',
'api_key': 'os.environ/OPENAI_API_KEY', 'max_retries': 0}})
config['model_list'] = [r for r in config['model_list'] if r['model_name'] != 'asr-gpt']
config['model_list'].append({'model_name': 'asr-gpt', 'litellm_params': {
'model': 'openai/gpt-transcribe', 'api_base': 'https://api.openai.com/v1',
'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
if __name__ == '__main__':
catalog = yaml.safe_load(Path('/etc/litellm/models.yaml').read_text())
config = build_hk_config(catalog, os.environ)
output = Path('/tmp/litellm.yaml')
output.write_text(yaml.safe_dump(config, allow_unicode=True, sort_keys=False))
output.chmod(0o600)
print('Configured routes:', ', '.join(route['model_name'] for route in config['model_list']))
+77
View File
@@ -0,0 +1,77 @@
import unittest
from pathlib import Path
import yaml
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'}
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['asr-gpt'], routes['openai/gpt-transcribe'])
self.assertIn({'asr-gpt': ['zenmux/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))
def test_official_asr_without_zenmux_has_no_dangling_fallback(self):
catalog = yaml.safe_load(Path(__file__).with_name('models.yaml').read_text())
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.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(routes[1]['litellm_params']['bailian_vocabulary_path'],
'/credentials/personal-vocabulary.json')
def test_all_providers(self):
catalog = yaml.safe_load(Path(__file__).with_name('models.yaml').read_text())
env = {key: 'synthetic' for key in ['ZENMUX_SUBSCRIPTION_API_KEY',
'ZENMUX_PAYG_API_KEY', 'OPENROUTER_API_KEY', 'GOOGLE_AI_STUDIO_API_KEY',
'VERTEXAI_PROJECT']}
env['VERTEXAI_ENABLED'] = 'true'
result = build_hk_config(catalog, env)
routes = {r['model_name']: r['litellm_params'] for r in result['model_list']}
self.assertEqual(len(routes), len(result['model_list']))
self.assertEqual(routes['gemini-transcribe-personal']['model'], 'vertex_ai/gemini-3.5-transcribe')
self.assertIs(routes['gemini-transcribe-personal']['personal_dictation'], True)
self.assertEqual(routes['gemini-transcribe-personal']['vertex_location'], 'global')
self.assertNotIn('grok-latest', routes)
self.assertNotIn('gpt-sol-latest', routes)
self.assertEqual(routes['asr-gpt']['model'], 'openai/openai/gpt-transcribe')
self.assertEqual(routes['asr-qwen']['model'], 'openai/qwen/qwen-audio-3.1-asr-flash')
self.assertEqual(routes['asr-doubao']['model'], 'openai/bytedance/doubao-seed-asr-2.0')
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['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.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'])
self.assertEqual(result['general_settings'], {'master_key': 'os.environ/LITELLM_MASTER_KEY'})
self.assertNotIn('synthetic', yaml.safe_dump(result))
def test_missing_credentials(self):
catalog = yaml.safe_load(Path(__file__).with_name('models.yaml').read_text())
result = build_hk_config(catalog, {})
self.assertEqual(result['model_list'], [])
self.assertEqual(result['router_settings']['fallbacks'], [])
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,54 @@
"""Scoped Vertex dictation settings; ordinary transcription calls stay unchanged."""
import json
from pathlib import Path
VOCABULARY = Path('/credentials/personal-vocabulary.json')
def vocabulary(path=VOCABULARY):
words = json.loads(path.read_text())
if not isinstance(words, list) or not 1 <= len(words) <= 100:
raise ValueError('Personal vocabulary must contain 1-100 terms')
if any(not isinstance(word, str) or not word.strip() or len(word) > 100 for word in words):
raise ValueError('Invalid personal vocabulary term')
return list(dict.fromkeys(word.strip() for word in words))
def install():
from litellm.llms.vertex_ai.audio_transcription.gemini_transcribe_transformation import (
VertexGeminiAudioTranscriptionConfig,
)
original = VertexGeminiAudioTranscriptionConfig.transform_audio_transcription_request
if getattr(original, '_personal_dictation_patch', False):
return
def transform(self, model, audio_file, optional_params, litellm_params):
if optional_params.get('personal_dictation') is not True:
return original(self, model, audio_file, optional_params, litellm_params)
if model.removeprefix('vertex_ai/') != 'gemini-3.5-transcribe':
raise ValueError('Personal dictation is restricted to Vertex Gemini 3.5 Transcribe')
if optional_params.get('timestamp_granularities') or optional_params.get('response_format') not in (None, 'json', 'text'):
raise ValueError('Personal dictation supports json/text without timestamps')
clean = {key: val for key, val in optional_params.items() if key != 'personal_dictation'}
result = original(self, model, audio_file, clean, litellm_params)
config = result.data.setdefault('generationConfig', {}).setdefault('audioTranscriptionConfig', {})
if set(config) - {'mode', 'languageCodes'} or config.get('mode') not in (None, 'VERBATIM'):
raise ValueError('Unexpected Vertex transcription defaults; review adapter compatibility')
config['mode'] = 'SMART'
config['customVocabulary'] = vocabulary()
return result
transform._personal_dictation_patch = True
VertexGeminiAudioTranscriptionConfig.transform_audio_transcription_request = transform
if __name__ == '__main__':
import sys
from litellm import run_server
from zenmux_transcription import install as install_zenmux_asr
from bailian_transcription import install as install_bailian_asr
install_zenmux_asr()
install_bailian_asr()
install()
sys.argv[0] = 'litellm'
sys.exit(run_server())
@@ -0,0 +1,91 @@
"""Adapt four allowlisted ZenMux JSON ASR models to LiteLLM's audio endpoint."""
import base64
from litellm.llms.base_llm.audio_transcription.transformation import AudioTranscriptionRequestData
from litellm.llms.openai.transcriptions.whisper_transformation import OpenAIWhisperAudioTranscriptionConfig
from litellm.litellm_core_utils.audio_utils.utils import process_audio_file
from litellm.types.utils import TranscriptionResponse, TranscriptionUsageDurationObject
MODELS = frozenset({'openai/gpt-transcribe', 'qwen/qwen-audio-3.1-asr-flash',
'bytedance/doubao-seed-asr-2.0', 'xiaomi/mimo-v2.5-asr'})
API_BASE = 'https://zenmux.ai/api/v1'
class ZenMuxTranscriptionConfig(OpenAIWhisperAudioTranscriptionConfig):
@property
def has_native_transcription_endpoint(self):
return True
def get_supported_openai_params(self, model):
return ['language', 'response_format', 'prompt']
def map_openai_params(self, non_default_params, optional_params, model, drop_params):
params = super().map_openai_params(non_default_params, optional_params, model, drop_params)
if params.get('prompt'):
raise ValueError('ZenMux comparison routes do not accept prompts; leave prompt empty')
if params.get('response_format') not in (None, 'json', 'text'):
raise ValueError('ZenMux comparison routes support json/text only')
return params
def validate_environment(self, headers, model, messages, optional_params,
litellm_params, api_key=None, api_base=None):
if not api_key:
raise ValueError('ZenMux subscription API key is required')
return {**headers, 'Authorization': 'Bearer ' + api_key, 'Content-Type': 'application/json'}
def get_complete_url(self, api_base, api_key, model, optional_params, litellm_params, stream=None):
if (api_base or '').rstrip('/') != API_BASE or model not in MODELS:
raise ValueError('ZenMux ASR adapter is restricted to the configured endpoint and model allowlist')
if stream:
raise ValueError('Streaming is not enabled for comparison dictation routes')
return API_BASE + '/audio/transcriptions'
def transform_audio_transcription_request(self, model, audio_file, optional_params, litellm_params):
if model not in MODELS:
raise ValueError('Unknown ZenMux ASR model')
if optional_params.get('timestamp_granularities') or optional_params.get('prompt'):
raise ValueError('Timestamps and prompt injection are disabled on comparison routes')
audio = process_audio_file(audio_file)
formats = {'audio/wav': 'wav', 'audio/x-wav': 'wav', 'audio/wave': 'wav',
'audio/mpeg': 'mp3', 'audio/mp3': 'mp3', 'audio/flac': 'flac',
'audio/x-flac': 'flac', 'audio/m4a': 'm4a', 'audio/mp4': 'm4a',
'audio/x-m4a': 'm4a', 'audio/ogg': 'ogg', 'audio/webm': 'webm',
'video/webm': 'webm', 'audio/aac': 'aac'}
mime = audio.content_type.split(';', 1)[0].lower()
if mime not in formats:
raise ValueError('Unsupported audio MIME type for ZenMux ASR')
body = {'model': model, 'input_audio': {
'data': base64.b64encode(audio.file_content).decode(), 'format': formats[mime]},
'stream': False}
language = optional_params.get('language')
if language:
body['language'] = language
return AudioTranscriptionRequestData(data=body)
def transform_audio_transcription_response(self, raw_response):
body = raw_response.json()
if not isinstance(body.get('text'), str):
raise ValueError('ZenMux ASR returned no transcript text')
response = TranscriptionResponse(text=body['text'])
usage = body.get('usage') or {}
if usage.get('seconds') is not None:
# ZenMux has no audio/text token breakdown; report duration without inventing one.
response.usage = TranscriptionUsageDurationObject(type='duration', seconds=usage['seconds'])
response['duration'] = usage['seconds']
return response
def install():
import litellm
from litellm.utils import ProviderConfigManager
original = ProviderConfigManager.get_provider_audio_transcription_config
if getattr(original, '_zenmux_asr_adapter', False):
return
def select(model, provider):
if provider == litellm.LlmProviders.OPENAI and model in MODELS:
return ZenMuxTranscriptionConfig()
return original(model, provider)
select._zenmux_asr_adapter = True
ProviderConfigManager.get_provider_audio_transcription_config = staticmethod(select)
+5 -1
View File
@@ -70,8 +70,12 @@ HINDSIGHT_API_EMBEDDINGS_OPENROUTER_MODEL=baai/bge-m3
HINDSIGHT_API_EMBEDDINGS_OPENAI_BATCH_SIZE=32 HINDSIGHT_API_EMBEDDINGS_OPENAI_BATCH_SIZE=32
HINDSIGHT_API_EMBEDDINGS_MAX_INPUT_TOKENS=8192 HINDSIGHT_API_EMBEDDINGS_MAX_INPUT_TOKENS=8192
# Local TEI reranker, then OpenRouter, then fail-open RRF. # Google tested with synthetic text only; keep production reranking local.
HINDSIGHT_API_RERANKER_PROVIDER=tei HINDSIGHT_API_RERANKER_PROVIDER=tei
HINDSIGHT_API_RERANKER_LITELLM_API_BASE=http://192.168.50.49:4000
HINDSIGHT_API_RERANKER_LITELLM_API_KEY=replace-with-litellm-client-key
HINDSIGHT_API_RERANKER_LITELLM_MODEL=google-rerank-default
HINDSIGHT_API_RERANKER_LITELLM_TIMEOUT=10
HINDSIGHT_API_RERANKER_TEI_URL=http://192.168.50.100:18084 HINDSIGHT_API_RERANKER_TEI_URL=http://192.168.50.100:18084
HINDSIGHT_API_RERANKER_TEI_BATCH_SIZE=32 HINDSIGHT_API_RERANKER_TEI_BATCH_SIZE=32
HINDSIGHT_API_RERANKER_TEI_MAX_CONCURRENT=4 HINDSIGHT_API_RERANKER_TEI_MAX_CONCURRENT=4
+17 -6
View File
@@ -1,17 +1,28 @@
# Hindsight 独立 VM # Hindsight 独立 VM
本项目部署到 PVE VM `hindsight`(`192.168.50.47`)的 Arcane Environment。 本项目部署到 PVE VM `hindsight`(`192.168.50.47`)的 Arcane Environment。
它是新一代空白实例,不直接挂载或修改 unRaid 上的旧数据库。 它是已迁移生产数据的独立实例,不直接挂载 unRaid 上的旧数据库。
## 组件 ## 组件
- Hindsight `0.10.1-slim`; - Hindsight `0.10.3-slim`;
- PostgreSQL 18 + pgvector; - PostgreSQL 18 + pgvector;
- unRaid GPU0 上的本地 TEI `BAAI/bge-m3` embedding;OpenRouter 同模型保留为手动回滚; - unRaid GPU0 上的本地 TEI `BAAI/bge-m3` embedding;OpenRouter 同模型保留为手动回滚;
- unRaid GPU0 上的本地 TEI `Alibaba-NLP/gte-multilingual-reranker-base` reranker,失败时依次回退 OpenRouter `cohere/rerank-4-fast` 与 RRF; - 本地 TEI 用于生产 rerank,保留原有 OpenRouter `cohere/rerank-4-fast` 与 RRF 回退;Google 仅用于合成测试;
- ZenMux GPT-6 Luna 用于 Retain; - Codex OAuth GPT-6 Luna 用于 Retain;
- ZenMux Grok 4.7 用于 Consolidation; - xAI OAuth Grok 4.7 用于 Consolidation;
- ZenMux Sonnet 5 用于 Reflect。 - Codex OAuth GPT-6 Sol 用于 Reflect 与知识页刷新;原有 ZenMux 回退保持不变。
## 本次升级验证
升级前已生成 `/srv/hindsight/backups/pre-google-0.10.3-20261010.dump`,并通过
`pg_restore --list` 校验。应用和数据库均健康,数据库容器没有重建。
embedding 仍为本地 bge-m3,未重新生成向量。
六组合成排序样本在 Google default、fast 与本地 TEI 上均通过,不代表已证明
Google 质量优于 TEI。Google 单次请求约 1 秒,本地 TEI 为毫秒级。
用户选择暂不向 Google 发送私人记忆,因此生产排序已恢复本地 TEI,
Google 不在生产排序链中。embedding 迁移另行安排。
运行数据固定在 `/srv/hindsight/postgres`。Compose 和非敏感模板进入 Git,真实 运行数据固定在 `/srv/hindsight/postgres`。Compose 和非敏感模板进入 Git,真实
数据库密码、ZenMux key 和 OpenRouter key 仅保存在该 Arcane Environment 的 数据库密码、ZenMux key 和 OpenRouter key 仅保存在该 Arcane Environment 的
+1 -1
View File
@@ -26,7 +26,7 @@ services:
max-file: "3" max-file: "3"
hindsight: hindsight:
image: ghcr.io/vectorize-io/hindsight:0.10.1-slim@sha256:4d14ce6643ecb2dacb9446e0b967a35f811ce8d0172a39131281520b4c425da9 image: ghcr.io/vectorize-io/hindsight:1.1.0-slim@sha256:fbc37d194bce007b6cce2fd9f66cd7f8c050bd18b80e662090a92835d9cb191d
container_name: hindsight container_name: hindsight
restart: unless-stopped restart: unless-stopped
env_file: env_file:
+1 -1
View File
@@ -5,7 +5,7 @@ x-arcane:
services: services:
app: app:
image: ghcr.io/bookorbit/bookorbit:3.1.0@sha256:78b6eac18306eb7eba0dbf6592ecdee01b15f24de9a941cb853db4a0232d7999 image: ghcr.io/bookorbit/bookorbit:3.6.0@sha256:bbb5da52e96dbbff1dd36f55454f9845e6cde264b4b3428a3ea783124a4ca62e
container_name: bookorbit-app container_name: bookorbit-app
restart: unless-stopped restart: unless-stopped
init: true init: true
+2
View File
@@ -0,0 +1,2 @@
GRIMMORY_DB_PASSWORD=replace-with-a-random-password
GRIMMORY_DB_ROOT_PASSWORD=replace-with-a-different-random-password
+30
View File
@@ -0,0 +1,30 @@
# Grimmory on media-core
- LAN URL: `http://192.168.50.46:6060`
- Legacy evaluation library: `/mnt/truenas/multimedia/books` -> `/books` (read-only)
- Clean library projection: `/mnt/truenas/multimedia/grimmory/library` -> `/books-clean` (read-only)
- BookDrop: `/mnt/truenas/multimedia/grimmory/staging` -> `/bookdrop`
- Catalog snapshots and review reports: `/mnt/truenas/multimedia/grimmory/manifests`
- Rebuild state: `/opt/media-core/appdata/grimmory-rebuild/catalog.sqlite3`
- Application data: `/opt/media-core/appdata/grimmory/data`
- MariaDB data: `/opt/media-core/appdata/grimmory/mariadb`
- Storage mode: `NETWORK`
This is a parallel evaluation deployment. BookOrbit remains on the legacy
`/mnt/truenas/multimedia/books` tree, while Grimmory indexes a separately
materialized Work -> Edition -> Asset projection. Only Grimmory's dedicated
BookDrop is writable. Do not enable file rename, move, delete, or metadata
write-back against `/books` during the evaluation.
Keep the existing `Main Library` at `/books` until the clean projection has
been accepted. Create a second library at `/books-clean` with organization
mode `Book Per Folder`, metadata source `Prefer Sidecar`, and folder watching
disabled. Organization mode is immutable after library creation.
Set
`GRIMMORY_DB_PASSWORD` and `GRIMMORY_DB_ROOT_PASSWORD` only in the Arcane
project environment; do not commit their values.
Before an upgrade, dump the `grimmory` MariaDB database and back up
`/opt/media-core/appdata/grimmory/data`. Image tags describe the release and
the pinned OCI index digests are authoritative.
+94
View File
@@ -0,0 +1,94 @@
x-arcane:
icon: book-open
urls:
- http://192.168.50.46:6060
services:
grimmory:
image: ghcr.io/grimmory-tools/grimmory:v3.5.0@sha256:bf6fe21c6e247597f7869664a440b5a34242e2d2940575cae39c17bfaa66dc0b
container_name: grimmory
restart: unless-stopped
init: true
depends_on:
mariadb:
condition: service_healthy
ports:
- "6060:6060"
environment:
USER_ID: "1000"
GROUP_ID: "1000"
TZ: Asia/Shanghai
DATABASE_URL: jdbc:mariadb://mariadb:3306/grimmory
DATABASE_USERNAME: grimmory
DATABASE_PASSWORD: ${GRIMMORY_DB_PASSWORD:?set GRIMMORY_DB_PASSWORD in the Arcane project environment}
SWAGGER_ENABLED: "false"
FORCE_DISABLE_OIDC: "false"
DISK_TYPE: NETWORK
volumes:
- type: bind
source: /opt/media-core/appdata/grimmory/data
target: /app/data
bind:
create_host_path: false
- type: bind
source: /mnt/truenas/multimedia/books
target: /books
read_only: true
bind:
create_host_path: false
- type: bind
source: /mnt/truenas/multimedia/grimmory/library
target: /books-clean
read_only: true
bind:
create_host_path: false
- type: bind
source: /mnt/truenas/multimedia/grimmory/staging
target: /bookdrop
bind:
create_host_path: false
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:6060/api/v1/healthcheck"]
interval: 30s
timeout: 10s
retries: 5
start_period: 90s
labels:
com.getarcaneapp.arcane.icon: book-open
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
mariadb:
image: lscr.io/linuxserver/mariadb:11.4.8@sha256:91de7f701bc7fc3a424b81beafca7a7c6c4c5b7c8be6afd2ae148698695c0b0c
container_name: grimmory-db
restart: unless-stopped
environment:
PUID: "1000"
PGID: "1000"
TZ: Asia/Shanghai
MYSQL_ROOT_PASSWORD: ${GRIMMORY_DB_ROOT_PASSWORD:?set GRIMMORY_DB_ROOT_PASSWORD in the Arcane project environment}
MYSQL_DATABASE: grimmory
MYSQL_USER: grimmory
MYSQL_PASSWORD: ${GRIMMORY_DB_PASSWORD:?set GRIMMORY_DB_PASSWORD in the Arcane project environment}
volumes:
- type: bind
source: /opt/media-core/appdata/grimmory/mariadb
target: /config
bind:
create_host_path: false
healthcheck:
test: ["CMD", "mariadb-admin", "ping", "-h", "localhost"]
interval: 5s
timeout: 5s
retries: 12
start_period: 30s
labels:
com.getarcaneapp.arcane.icon: mariadb
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
+6 -4
View File
@@ -1,7 +1,9 @@
.git .git
.env .venv
tests
README.md
__pycache__ __pycache__
*.pyc *.pyc
seed state/
cache/
dry-run/
logs/
.env
+40 -7
View File
@@ -1,11 +1,44 @@
ZENMUX_API_KEY= # Deployment example only. Do not commit real secrets.
ASR_AGENT_TOKEN= ASR_WORKSPACE=/home/claw/codex-workspace/meeting-asr
ASR_PUBLIC_URL=http://192.168.50.49:8788 ASR_VAULT=/home/claw/obsidian-vault
ASR_STATE_DIR=/home/claw/codex-workspace/meeting-asr/state
ASR_CACHE_DIR=/home/claw/codex-workspace/meeting-asr/cache
ASR_LOG=/home/claw/codex-workspace/meeting-asr/logs/asr-pipeline.log
ASR_ENV_FILE=/home/claw/codex-workspace/meeting-asr/.env
ASR_RECORDINGS_ROOT=/home/claw/codex-workspace/meeting-asr/recordings
MEETING_INBOX_BASE=/home/claw/codex-workspace/meeting-asr/recordings
ASR_CONTROL_DB=/home/claw/codex-workspace/meeting-asr/state/intake.db
# ASR Provider: qwen3cam | soniox | gemini | openai | grok
ASR_PROVIDER=qwen3cam
ASR_DIARIZE_URL=http://192.168.50.100:10099/asr ASR_DIARIZE_URL=http://192.168.50.100:10099/asr
ASR_VOICEPRINT_ENROLL_URL=http://192.168.50.100:10099/voiceprints/enroll
# Unified LiteLLM gateway (replaces deprecated ZenMux)
LITELLM_BASE_URL=http://192.168.50.49:4000/v1
LITELLM_API_KEY=
ASR_GEMINI_MODEL=aistudio/gemini-3.5-transcribe
ASR_OPENAI_MODEL=openai/gpt-4o-transcribe-diarize
ASR_CLEAN_MODEL=aistudio/gemini-flash-lite-latest
ASR_KEYWORD_MODEL=aistudio/gemini-flash-lite-latest
ASR_DRAFT_MODEL=codex/gpt-sol-latest
# Direct cloud ASR keys (when not routed through LiteLLM)
SONIOX_API_KEY=
SONIOX_MODEL=stt-async-v5
XAI_API_KEY=
ASR_GROK_MODEL=grok-voice-transcribe-2.0
# Knowledge & Obsidian integration
HINDSIGHT_API=http://192.168.50.47:8888 HINDSIGHT_API=http://192.168.50.47:8888
HINDSIGHT_BANK=personal-context HINDSIGHT_BANK=personal-context
ASR_GIT_SYNC=0
# Web & Notifications
ASR_PUBLIC_URL=http://192.168.50.49:8788
ASR_REVIEW_PUBLIC_HOST=192.168.50.145
ASR_REVIEW_PORT=8788
ASR_AGENT_TOKEN=
ASR_WEB_PASSWORD=
ASR_NOTIFY=0
ASR_NOTIFY_BACKEND=none ASR_NOTIFY_BACKEND=none
HERMES_WEBHOOK_URL=
HERMES_WEBHOOK_SECRET=
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
+7 -1
View File
@@ -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 tini \ ca-certificates curl ffmpeg git tini \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
@@ -15,6 +15,7 @@ COPY termbank/ ./termbank/
ENV PYTHONUNBUFFERED=1 \ ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \ PYTHONDONTWRITEBYTECODE=1 \
PYTHONPATH=/app \ PYTHONPATH=/app \
ASR_DATA_DIR=/data \
ASR_STATE_DIR=/state \ ASR_STATE_DIR=/state \
ASR_VAULT=/data/vault \ ASR_VAULT=/data/vault \
ASR_TERMBANK_DIR=/app/termbank \ ASR_TERMBANK_DIR=/app/termbank \
@@ -24,6 +25,11 @@ ENV PYTHONUNBUFFERED=1 \
ASR_DRAFTS_DIR=/data/artifacts/drafts \ ASR_DRAFTS_DIR=/data/artifacts/drafts \
ASR_RESULTS_DIR=/data/artifacts/results \ ASR_RESULTS_DIR=/data/artifacts/results \
ASR_REVISION_QUEUE_DIR=/state/revision-queue \ ASR_REVISION_QUEUE_DIR=/state/revision-queue \
ASR_PROVIDER=qwen3cam \
LITELLM_BASE_URL=http://192.168.50.49:4000/v1 \
ASR_CLEAN_MODEL=aistudio/gemini-flash-lite-latest \
ASR_KEYWORD_MODEL=aistudio/gemini-flash-lite-latest \
ASR_DRAFT_MODEL=codex/gpt-sol-latest \
ASR_GIT_SYNC=0 \ ASR_GIT_SYNC=0 \
ASR_NOTIFY=0 \ ASR_NOTIFY=0 \
ASR_REVIEW_HOST=0.0.0.0 \ ASR_REVIEW_HOST=0.0.0.0 \
+46 -42
View File
@@ -1,51 +1,55 @@
# ASR Pipeline on tools-pve # Meeting ASR
独立的录音转写、精修、人工审核和导出服务。WebUI 与 Agent API 共用 `192.168.50.49:8788`;worker 调用 unRaid 上的 Qwen3-ASR/CAM++ 服务以及 ZenMux 模型 API。 独立的会议/通话录音转写与整理 Web 应用(`asrservice` + `asrpipe`)。支持本地 GPU 声纹服务(`qwen3cam`)与云端多模型语音识别(`soniox`、`gemini`、`openai`、`grok`),可通过 Arcane 部署到本地 VM(`tools-pve`)或外网 VPS。
## 数据边界 ## 核心架构
- `/mnt/truenas/personal/ASR-Pipeline` → 容器 `/data`:原始录音、转写、ASR 原始响应、草稿、正式稿、日志和 SQLite 在线备份。 1. **Web 控制与审核面(`asrservice.web`)**
- `/opt/asr-pipeline/state` → 容器 `/state`:活动 SQLite、worker heartbeat、revision queue。SQLite WAL 不放在 NFS。 - 极简零构建服务端渲染 HTML + 原生 JS(深浅色自适应)。
- `/srv/asr/inbox`:指向 TrueNAS inbox 的宿主机链接,供 `scp`/`sftp` 上传。 - 上传录音时可按任务切换 ASR 引擎(`qwen3cam` / `soniox` / `gemini` / `openai` / `grok`)。
- Secret 只存在于 Arcane Project 环境和 tools-pve 的 `0600` 恢复副本,不进入 Git。 - 任务详情页内置支持 HTTP Range 的音频播放器、失败一键切换模型重试、草稿与纪要 Markdown 在线编辑。
- `/review/` 提供低置信度词语、关系确认与说话人身份确认(本地 `qwen3cam` 自动注册声纹至 `:10099`,线上多说话人模型直接更新草稿与词库而不依赖 `:10099`)。
- `/lexicon` 提供热词、同音纠错、人物关系管理与 Obsidian 词库一键同步。
- `/settings` 展示全部 ASR 引擎、LiteLLM 网关、Obsidian 与 Hindsight 实时就绪状态。
- 支持可选 `ASR_WEB_PASSWORD` Cookie 鉴权(外网 VPS 部署时自动保护 `/ui/*` 与 `/review/*`)。
SSH 大文件建议先上传为 `.part`,完成后在同一目录原子改名为受支持扩展名: 2. **后台消费与修订队列(`asrservice.worker`)**
- 监控 `.asr-inbox/`(Web 上传或目录投递)并认领 `intake.db` 任务。
- 调用统一多引擎层 `asrpipe.providers.transcribe_audio()` 生成带结构化注释头的原始转写。
- 执行 `asr-refine draft`:结合 SQLite 词库、Obsidian Vault(`People/`、`Companies/`、`Projects/`、`minutes/`、`_glossary.md`)与 Hindsight(`personal-context`)进行分段去口语化、保真检查与会议纪要生成。
- 监控 `state/revision-queue/` 处理 Web 审核提交的修订重跑请求,确认通过后发布正式纪要至 Obsidian 并可选执行 Git 自动提交推送。
3. **语音识别(ASR)与大模型网关边界**
- **LLM 后处理**:彻底移除 ZenMux,统一通过 **LiteLLM**(内网 `http://192.168.50.49:4000/v1` / 外网 `https://api.opfo.xyz/v1`)调用:
- `ASR_CLEAN_MODEL` / `ASR_KEYWORD_MODEL`:默认 `aistudio/gemini-flash-lite-latest`
- `ASR_DRAFT_MODEL`:默认 `codex/gpt-sol-latest`
- **ASR 引擎**:
- `qwen3cam`(内网默认):对接局域网 GPU `Qwen3-ASR 1.7B + CAM++`(`http://192.168.50.100:10099/asr`),支持声纹特征比对与增量注册。
- `soniox`(线上高精度首选):因 LiteLLM 同步 `/v1/audio/transcriptions` 不支持 Soniox 异步 Files API、结构化 `context` 热词注入与逐词 `speaker` 分离且有 180s 超时限制,由 `asrpipe.providers` 通过 `SONIOX_API_KEY` 直连 Soniox `stt-async-v5`(自动注入 SQLite 词库热词与任务背景,完成后自动清理云端临时音频)。
- `gemini`:通过 LiteLLM 统一网关调用 `aistudio/gemini-3.5-transcribe`(长音频自动 `ffmpeg` 切片)。
- `openai`:通过 LiteLLM 统一网关调用 `openai/gpt-4o-transcribe-diarize`(支持 `diarized_json` 角色分离与 >23MB 自动压缩切片)。
- `grok`:通过 `XAI_API_KEY` 调用 xAI `grok-voice-transcribe-2.0`(支持多说话人分离)。
## 目录结构
- `asrpipe/`:核心转写引擎适配(`providers.py`)、词库召回(`lexicon.py`)、分段去口语化(`segment.py`、`refine.py`)、审核流(`review.py`)与 Obsidian/Hindsight 集成。
- `asrservice/`:容器化 Web 服务(`web.py`)、后台 Worker(`worker.py`)、SQLite 任务状态机(`db.py`)、Webhook 通知(`notify.py`)与健康检查(`healthcheck.py`)。
- `recordings/bin/`:录音监听与 CLI 转写脚本(兼容 `MEETING_ASR_BACKEND` 多引擎调度)。
- `termbank/`:专业词库源数据。
- `tests/`:核心管线、多引擎契约与 Web 服务单元测试。
- `Dockerfile` & `compose.yaml`:面向 Arcane 部署的单镜像双角色(`asr-web` + `asr-worker`)容器编排。
## 本地开发与测试
```bash ```bash
scp call.m4a tools@192.168.50.49:/srv/asr/inbox/call.m4a.part python3 -m venv .venv
ssh tools@192.168.50.49 'mv /srv/asr/inbox/call.m4a.part /srv/asr/inbox/call.m4a' .venv/bin/pip install -e .
ASR_NOTIFY=0 ASR_GIT_SYNC=0 .venv/bin/python -m unittest discover -s tests -v
``` ```
## Agent API ## Arcane 部署(内网 VM / 外网 VPS)
所有 `/api/v1/*` 路由使用 `Authorization: Bearer $ASR_AGENT_TOKEN`。 1. 复制 `.env.example` 为 `.env`,按环境配置:
- **内网 VM(`tools-pve`)**:`MEETING_ASR_BACKEND=qwen3cam`(同时可配 `SONIOX_API_KEY` 等以便在 Web UI 按需切换)、`LITELLM_BASE_URL=http://192.168.50.49:4000/v1`、`HINDSIGHT_URL=http://192.168.50.47:8888`。
```bash - **外网 VPS**:`MEETING_ASR_BACKEND=soniox`(或 `gemini` / `openai` / `grok`)、`LITELLM_BASE_URL=https://api.opfo.xyz/v1`、设置 `ASR_WEB_PASSWORD` 开启 Web 登录保护。
curl -X POST http://192.168.50.49:8788/api/v1/jobs \ 2. 使用 `compose.yaml` 启动 `asr-web`(默认 `:8788`)与 `asr-worker`。
-H "Authorization: Bearer $ASR_AGENT_TOKEN" \
-H 'X-Filename: call.m4a' -H 'X-Title: 项目通话' \
-H 'X-Context: 参与人和项目背景' --data-binary @call.m4a
curl -H "Authorization: Bearer $ASR_AGENT_TOKEN" \
http://192.168.50.49:8788/api/v1/jobs/JOB_ID
```
路由:
- `POST /api/v1/jobs`:上传并创建异步任务。
- `GET /api/v1/jobs?limit=&offset=&status=`:分页查询。
- `GET /api/v1/jobs/{id}`:任务详情、事件时间线与产物链接。
- `GET /api/v1/jobs/{id}/artifacts/{transcript|draft|result}`:读取产物。
- `GET /api/v1/jobs/{id}/reviews`:待人工确认项。
- `POST /api/v1/jobs/{id}/reviews/{question_id}`:JSON `{"answer":"..."}`。
- `POST /api/v1/jobs/{id}/retry`:重试失败任务。
`/healthz` 仅表示 Web 进程存活;`/readyz` 同时检查数据库与 worker heartbeat。
## 通知
`ASR_NOTIFY_BACKEND=none|telegram|hermes`。Telegram 需要 `TELEGRAM_BOT_TOKEN` 与 `TELEGRAM_CHAT_ID`;Hermes 需要一个明确存在的签名 webhook URL 与共享密钥。发送失败只影响提醒,不改变任务结果。
## 运维
项目由 Arcane Git Sync 管理,自动同步、自动 pull 和自动 redeploy 均关闭。Compose 变更先在 Git 中验证,再对 tools-pve 的准确 Sync 手动同步。活动数据库每小时通过 SQLite backup API 写到 `/data/backups/*.latest`。
@@ -209,7 +209,9 @@ def cmd_doctor(a):
print("credentials") print("credentials")
try: try:
k = CFG.api_key() k = CFG.api_key()
chk("api key", len(k) > 20, f"len={len(k)} source={'env' if os.environ.get('ANTHROPIC_AUTH_TOKEN') else CFG.env_file.name}") env_set = any(os.environ.get(x) for x in (
"LITELLM_API_KEY", "ASR_LLM_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_AUTH_TOKEN"))
chk("api key", len(k) > 10, f"len={len(k)} source={'env' if env_set else CFG.env_file.name}")
except Exception as e: except Exception as e:
chk("api key", False, str(e)) chk("api key", False, str(e))
+124 -62
View File
@@ -1,15 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Configuration for the standalone ASR post-processing pipeline. """Configuration for the standalone ASR post-processing pipeline and web service.
This program deliberately has **no dependency on Claude Code, opencode, or any This program has **no dependency on Claude Code, opencode, or any agent CLI**.
agent CLI**. It talks to the model provider over plain HTTPS and does its own file It talks to LiteLLM / model providers over plain HTTPS and does its own file
and git handling, so it can run from systemd, cron, or a shell with nothing but and git handling, so it can run inside Docker (via Arcane), systemd, or a shell.
python3 and a network route.
Everything is overridable by environment variable so the systemd unit stays the Everything is overridable by environment variable.
single place where deployment-specific values live. Credentials are read from the
same 600-perm env file the legacy unit uses, so token rotation remains a one-file
change.
""" """
from __future__ import annotations from __future__ import annotations
@@ -18,8 +14,8 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
PKG = Path(__file__).resolve().parent PKG = Path(__file__).resolve().parent
ROOT = PKG.parent # OBSIDIAN/asr-pipeline ROOT = PKG.parent
OBSIDIAN = ROOT.parent # OBSIDIAN project dir OBSIDIAN = Path(os.environ.get("ASR_WORKSPACE", str(ROOT)))
def _env(name: str, default: str) -> str: def _env(name: str, default: str) -> str:
@@ -46,60 +42,124 @@ class Config:
# ---- paths ---------------------------------------------------------- # ---- paths ----------------------------------------------------------
vault: Path = field(default_factory=lambda: Path( vault: Path = field(default_factory=lambda: Path(
_env("ASR_VAULT", str(Path.home() / "obsidian-vault")))) _env("ASR_VAULT", str(Path.home() / "obsidian-vault"))))
obsidian: Path = OBSIDIAN obsidian: Path = field(default_factory=lambda: Path(
_env("ASR_WORKSPACE", str(ROOT))))
root: Path = ROOT root: Path = ROOT
# ---- models --------------------------------------------------------- # ---- LiteLLM / LLM routing (ZenMux deprecated) ----------------------
# Cleaning: measured against claude-haiku-4.5 on a 118k-char English-mixed call, litellm_base_url: str = field(default_factory=lambda: _env(
# gemini-3.5-flash-lite tripped the fidelity gate 19/87 times vs haiku's 78/87 "LITELLM_BASE_URL",
# (haiku's failures were 69x "over-compression" — it behaves like a summarizer _env("ASR_LLM_BASE_URL", "http://192.168.50.49:4000/v1")).rstrip("/"))
# and deleted terms such as AUTOIMMUNE/ADCS/CD), while being 2x faster and
# 2.9x cheaper. Hence flash-lite is the default cleaner.
clean_model: str = _env("ASR_CLEAN_MODEL", "google/gemini-3.5-flash-lite")
# Drafting: one call, needs judgement and structure. sonnet-5.5 replaces the
# legacy headless opus-4-8.
draft_model: str = _env("ASR_DRAFT_MODEL", "anthropic/claude-sonnet-5.5")
keyword_model: str = _env("ASR_KEYWORD_MODEL", "google/gemini-3.5-flash-lite")
endpoint: str = _env("ASR_ENDPOINT", "https://zenmux.ai/api/v1/chat/completions") clean_model: str = field(default_factory=lambda: _env(
# ZenMux returned HTTP 429 at ~12 concurrent requests and was stable at 6. "ASR_CLEAN_MODEL", "aistudio/gemini-flash-lite-latest"))
concurrency: int = _envi("CONCURRENCY", 6) draft_model: str = field(default_factory=lambda: _env(
http_timeout: float = _envf("HTTP_TIMEOUT", 420.0) "ASR_DRAFT_MODEL", "codex/gpt-sol-latest"))
max_retries: int = _envi("MAX_RETRIES", 5) keyword_model: str = field(default_factory=lambda: _env(
"ASR_KEYWORD_MODEL", "aistudio/gemini-flash-lite-latest"))
endpoint: str = field(default_factory=lambda: _env(
"ASR_ENDPOINT",
_env("LITELLM_BASE_URL",
_env("ASR_LLM_BASE_URL", "http://192.168.50.49:4000/v1")).rstrip("/")
+ "/chat/completions"))
transcriptions_endpoint: str = field(default_factory=lambda: _env(
"ASR_TRANSCRIPTIONS_ENDPOINT",
_env("LITELLM_BASE_URL",
_env("ASR_LLM_BASE_URL", "http://192.168.50.49:4000/v1")).rstrip("/")
+ "/audio/transcriptions"))
concurrency: int = field(default_factory=lambda: _envi("CONCURRENCY", 6))
http_timeout: float = field(default_factory=lambda: _envf("HTTP_TIMEOUT", 420.0))
max_retries: int = field(default_factory=lambda: _envi("MAX_RETRIES", 5))
# ---- ASR providers --------------------------------------------------
asr_provider: str = field(default_factory=lambda: _env(
"ASR_PROVIDER", _env("MEETING_ASR_BACKEND", "qwen3cam")).lower())
diarize_url: str = field(default_factory=lambda: _env(
"ASR_DIARIZE_URL",
_env("MEETING_DIARIZE_URL", "http://192.168.50.100:10099/asr")))
voiceprint_enroll_url: str = field(default_factory=lambda: _env(
"ASR_VOICEPRINT_ENROLL_URL",
"http://192.168.50.100:10099/voiceprints/enroll"))
soniox_api_key: str = field(default_factory=lambda: _env("SONIOX_API_KEY", ""))
soniox_api_base: str = field(default_factory=lambda: _env(
"SONIOX_API_BASE", "https://api.soniox.com/v1").rstrip("/"))
soniox_model: str = field(default_factory=lambda: _env(
"SONIOX_MODEL", "stt-async-v5"))
gemini_asr_model: str = field(default_factory=lambda: _env(
"ASR_GEMINI_MODEL", "aistudio/gemini-3.5-transcribe"))
openai_asr_model: str = field(default_factory=lambda: _env(
"ASR_OPENAI_MODEL", "openai/gpt-4o-transcribe-diarize"))
grok_api_key: str = field(default_factory=lambda: _env(
"XAI_API_KEY", _env("GROK_API_KEY", "")))
grok_api_base: str = field(default_factory=lambda: _env(
"XAI_API_BASE", "https://api.x.ai/v1").rstrip("/"))
grok_asr_model: str = field(default_factory=lambda: _env(
"ASR_GROK_MODEL", "grok-voice-transcribe-2.0"))
segment_seconds: int = field(default_factory=lambda: _envi(
"SEGMENT_SECONDS", int(_env("MEETING_ASR_SEGMENT_SECONDS", "540"))))
# ---- chunking ------------------------------------------------------- # ---- chunking -------------------------------------------------------
chunk_budget: int = _envi("CHUNK_BUDGET", 1800) chunk_budget: int = field(default_factory=lambda: _envi("CHUNK_BUDGET", 1800))
chunk_overlap: int = _envi("CHUNK_OVERLAP", 200) chunk_overlap: int = field(default_factory=lambda: _envi("CHUNK_OVERLAP", 200))
collapse_noise: bool = _env("ASR_COLLAPSE_NOISE", "1") != "0" collapse_noise: bool = field(
default_factory=lambda: _env("ASR_COLLAPSE_NOISE", "1") != "0")
# ---- fidelity gate -------------------------------------------------- # ---- fidelity gate --------------------------------------------------
gate_enabled: bool = _env("ASR_GATE", "1") != "0" gate_enabled: bool = field(
gate_min_ratio: float = _envf("GATE_MIN_RATIO", 0.55) default_factory=lambda: _env("ASR_GATE", "1") != "0")
gate_max_ratio: float = _envf("GATE_MAX_RATIO", 1.35) gate_min_ratio: float = field(
default_factory=lambda: _envf("GATE_MIN_RATIO", 0.55))
gate_max_ratio: float = field(
default_factory=lambda: _envf("GATE_MAX_RATIO", 1.35))
# ---- knowledge sources ---------------------------------------------- # ---- knowledge sources ----------------------------------------------
use_termbank: bool = _env("ASR_TERMBANK", "1") != "0" use_termbank: bool = field(
use_hindsight: bool = _env("ASR_HINDSIGHT", "1") != "0" default_factory=lambda: _env("ASR_TERMBANK", "1") != "0")
use_keyword_recall: bool = _env("ASR_KEYWORD_RECALL", "1") != "0" use_hindsight: bool = field(
use_lexicon: bool = _env("ASR_LEXICON", "1") != "0" default_factory=lambda: _env("ASR_HINDSIGHT", "1") != "0")
use_normalize: bool = _env("ASR_NORMALIZE", "1") != "0" use_keyword_recall: bool = field(
use_evidence: bool = _env("ASR_EVIDENCE", "1") != "0" default_factory=lambda: _env("ASR_KEYWORD_RECALL", "1") != "0")
hindsight_api: str = _env("HINDSIGHT_API", "http://192.168.50.47:8888") use_lexicon: bool = field(
hindsight_bank: str = _env("HINDSIGHT_BANK", "personal-context") default_factory=lambda: _env("ASR_LEXICON", "1") != "0")
hindsight_timeout: float = _envf("HINDSIGHT_TIMEOUT", 12.0) use_normalize: bool = field(
flag_min_score: float = _envf("FLAG_MIN_SCORE", 0.86) default_factory=lambda: _env("ASR_NORMALIZE", "1") != "0")
review_min_score: float = _envf("REVIEW_MIN_SCORE", 0.82) use_evidence: bool = field(
review_max_score: float = _envf("REVIEW_MAX_SCORE", 0.96) default_factory=lambda: _env("ASR_EVIDENCE", "1") != "0")
review_host: str = _env("ASR_REVIEW_HOST", "0.0.0.0") hindsight_api: str = field(default_factory=lambda: _env(
review_public_host: str = _env("ASR_REVIEW_PUBLIC_HOST", "192.168.50.145") "HINDSIGHT_API", "http://192.168.50.47:8888"))
review_port: int = _envi("REVIEW_PORT", 8765) hindsight_bank: str = field(default_factory=lambda: _env(
review_idle_seconds: int = _envi("REVIEW_IDLE_SECONDS", 2700) "HINDSIGHT_BANK", "personal-context"))
review_max_seconds: int = _envi("REVIEW_MAX_SECONDS", 7200) hindsight_timeout: float = field(
default_factory=lambda: _envf("HINDSIGHT_TIMEOUT", 12.0))
flag_min_score: float = field(
default_factory=lambda: _envf("FLAG_MIN_SCORE", 0.86))
review_min_score: float = field(
default_factory=lambda: _envf("REVIEW_MIN_SCORE", 0.82))
review_max_score: float = field(
default_factory=lambda: _envf("REVIEW_MAX_SCORE", 0.96))
review_host: str = field(
default_factory=lambda: _env("ASR_REVIEW_HOST", "0.0.0.0"))
review_public_host: str = field(
default_factory=lambda: _env("ASR_REVIEW_PUBLIC_HOST", "192.168.50.145"))
review_port: int = field(default_factory=lambda: _envi("REVIEW_PORT", 8765))
review_idle_seconds: int = field(
default_factory=lambda: _envi("REVIEW_IDLE_SECONDS", 2700))
review_max_seconds: int = field(
default_factory=lambda: _envi("REVIEW_MAX_SECONDS", 7200))
# ---- behaviour ------------------------------------------------------ # ---- behaviour ------------------------------------------------------
git_sync: bool = _env("ASR_GIT_SYNC", "1") != "0" git_sync: bool = field(
notify: bool = _env("ASR_NOTIFY", "1") != "0" default_factory=lambda: _env("ASR_GIT_SYNC", "1") != "0")
cache_llm: bool = _env("ASR_CACHE", "0") != "0" # off in production notify: bool = field(
default_factory=lambda: _env("ASR_NOTIFY", "1") != "0")
cache_llm: bool = field(
default_factory=lambda: _env("ASR_CACHE", "0") != "0")
@property @property
def inbox(self) -> Path: def inbox(self) -> Path:
@@ -120,7 +180,6 @@ class Config:
@property @property
def pending_index(self) -> Path: def pending_index(self) -> Path:
# Legacy compatibility only; the Web queue replaced the Vault draft index.
return self.vault / "minutes" / "_drafts" / "_待确认.md" return self.vault / "minutes" / "_drafts" / "_待确认.md"
@property @property
@@ -150,15 +209,16 @@ class Config:
@property @property
def notifier(self) -> Path: def notifier(self) -> Path:
return self.obsidian / "bin" / "meeting-status-notify.py" return Path(_env("ASR_NOTIFIER", str(ROOT / "scripts" / "meeting-status-notify.py")))
@property @property
def env_file(self) -> Path: def env_file(self) -> Path:
"""600-perm credentials file, shared with the legacy systemd unit.""" """600-perm credentials file."""
return Path(_env("ASR_ENV_FILE", str(self.obsidian / "bin" / ".asr-refine.env"))) return Path(_env("ASR_ENV_FILE", str(ROOT / ".env")))
def api_key(self) -> str: def api_key(self) -> str:
for k in ("ZENMUX_API_KEY", "ANTHROPIC_AUTH_TOKEN"): for k in ("LITELLM_API_KEY", "ASR_LLM_API_KEY", "OPENAI_API_KEY",
"ANTHROPIC_AUTH_TOKEN"):
v = os.environ.get(k) v = os.environ.get(k)
if v: if v:
return v return v
@@ -166,13 +226,15 @@ class Config:
if p.exists(): if p.exists():
for line in p.read_text(errors="ignore").splitlines(): for line in p.read_text(errors="ignore").splitlines():
line = line.strip() line = line.strip()
if line.startswith("ANTHROPIC_AUTH_TOKEN="): for prefix in ("LITELLM_API_KEY=", "ASR_LLM_API_KEY=",
"OPENAI_API_KEY=", "ANTHROPIC_AUTH_TOKEN="):
if line.startswith(prefix):
val = line.split("=", 1)[1].strip().strip("'\"") val = line.split("=", 1)[1].strip().strip("'\"")
if val: if val:
return val return val
raise RuntimeError( raise RuntimeError(
"no API key: set ZENMUX_API_KEY/ANTHROPIC_AUTH_TOKEN or provide " "no API key: set LITELLM_API_KEY / ASR_LLM_API_KEY / OPENAI_API_KEY "
f"{p} with ANTHROPIC_AUTH_TOKEN=…") f"or provide {p}")
CFG = Config() CFG = Config()
+17 -22
View File
@@ -1,19 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""HTTPS client for the model provider. No agent CLI, no SDK. """HTTPS client for LiteLLM / OpenAI-compatible model gateways. No agent CLI, no SDK.
Deliberately built on urllib so the program has zero third-party HTTP Built on urllib so the program has zero third-party HTTP dependencies.
dependencies: it must keep working inside a bare systemd unit years from now.
Behaviours encoded from measurement rather than guesswork:
* concurrency 6 (HTTP 429 appears at ~12)
* HTTP 402 "Payment Required" is transient on ZenMux — credit seems reserved
against max_tokens, so a burst of large-budget calls can trip it and then
succeed moments later; it gets a much longer backoff than a rate limit
* `temperature` is rejected outright by claude-sonnet-5.5 / opus-5 but accepted by
haiku-4.5, sonnet-4.6, gemini and qwen, so it cannot be inferred from the
vendor prefix; the client self-heals on the 400 and remembers
* an empty completion (reasoning models burning the whole budget on hidden
thoughts) is retried rather than silently propagated as ""
""" """
from __future__ import annotations from __future__ import annotations
@@ -30,14 +18,22 @@ from .config import CFG
from .log import log from .log import log
PRICES = { PRICES = {
"aistudio/gemini-flash-lite-latest": (0.3, 2.5),
"vertex/gemini-flash-lite-latest": (0.3, 2.5),
"gemini-flash-lite-latest": (0.3, 2.5),
"google/gemini-3.5-flash-lite": (0.3, 2.5), "google/gemini-3.5-flash-lite": (0.3, 2.5),
"google/gemini-3.5-flash": (1.5, 9.0), "aistudio/gemini-flash-latest": (1.5, 9.0),
"vertex/gemini-flash-latest": (1.5, 9.0),
"aistudio/gemini-pro-latest": (2.5, 12.0),
"codex/gpt-sol-latest": (2.0, 8.0),
"openai/gpt-sol-latest": (2.0, 8.0),
"codex/gpt-luna-latest": (1.5, 6.0),
"xai/grok-latest": (2.0, 10.0),
"anthropic/claude-haiku-4.5": (1.0, 5.0), "anthropic/claude-haiku-4.5": (1.0, 5.0),
"anthropic/claude-sonnet-5": (2.0, 10.0), "anthropic/claude-sonnet-5": (2.0, 10.0),
"anthropic/claude-sonnet-5.5": (2.0, 10.0), "anthropic/claude-sonnet-5.5": (2.0, 10.0),
"anthropic/claude-sonnet-4.6": (3.0, 15.0), "anthropic/claude-sonnet-4.6": (3.0, 15.0),
"anthropic/claude-opus-5": (5.0, 25.0), "anthropic/claude-opus-5": (5.0, 25.0),
"qwen/qwen3.7-plus": (0.1373076, 0.5492304),
} }
_sem = threading.BoundedSemaphore(CFG.concurrency) _sem = threading.BoundedSemaphore(CFG.concurrency)
@@ -45,6 +41,8 @@ _NO_TEMPERATURE: set[str] = {
"anthropic/claude-sonnet-5", "anthropic/claude-sonnet-5",
"anthropic/claude-sonnet-5.5", "anthropic/claude-sonnet-5.5",
"anthropic/claude-opus-5", "anthropic/claude-opus-5",
"codex/gpt-sol-latest",
"codex/gpt-luna-latest",
} }
_lock = threading.Lock() _lock = threading.Lock()
_key: list[str] = [] _key: list[str] = []
@@ -143,7 +141,7 @@ def chat(model: str, prompt: str, *, system: str | None = None,
except Exception: except Exception:
pass pass
last = f"HTTP{e.code}: {detail[:200]}" last = f"HTTP{e.code}: {detail[:200]}"
if e.code == 400 and "temperature" in detail: if e.code == 400 and "temperature" in detail.lower():
with _lock: with _lock:
_NO_TEMPERATURE.add(model) _NO_TEMPERATURE.add(model)
payload = payload_of() payload = payload_of()
@@ -151,13 +149,12 @@ def chat(model: str, prompt: str, *, system: str | None = None,
cpath = _cache_path(model, payload) cpath = _cache_path(model, payload)
retries += 1 retries += 1
continue continue
if e.code not in (402, 408, 429, 500, 502, 503, 504): if e.code not in (408, 429, 500, 502, 503, 504):
if usage: if usage:
usage.failures += 1 usage.failures += 1
raise RuntimeError(f"{model} {last}") from e raise RuntimeError(f"{model} {last}") from e
retries += 1 retries += 1
delay = (min(20 * (attempt + 1), 120) if e.code == 402 delay = min(2 ** attempt + random.uniform(0, 1.5), 45)
else min(2 ** attempt + random.uniform(0, 1.5), 45))
log(f"llm retry {attempt+1}/{CFG.max_retries} {model} {last} " log(f"llm retry {attempt+1}/{CFG.max_retries} {model} {last} "
f"sleep {delay:.0f}s", level="WARN") f"sleep {delay:.0f}s", level="WARN")
time.sleep(delay) time.sleep(delay)
@@ -203,8 +200,6 @@ def chat_many(model: str, prompts: list[str], **kw) -> list[str]:
except BaseException as e: # noqa: BLE001 - recorded, decided by caller except BaseException as e: # noqa: BLE001 - recorded, decided by caller
errors.append((i, e)) errors.append((i, e))
if errors: if errors:
# Partial failure must not silently yield empty chunks: the caller falls
# back to the original text for these indices, which the gate then flags.
for i, e in errors: for i, e in errors:
log(f"chunk {i} failed: {e}", level="ERROR") log(f"chunk {i} failed: {e}", level="ERROR")
raise PartialFailure(out, [i for i, _ in errors]) raise PartialFailure(out, [i for i, _ in errors])
@@ -166,6 +166,20 @@ SPEAKER_NOTE = {
"- 不同 speaker_N 是不同真人(CAM++ 已分),但可能过度聚类(同一人被拆成多个 N)," "- 不同 speaker_N 是不同真人(CAM++ 已分),但可能过度聚类(同一人被拆成多个 N),"
"按内容合并同一真人。\n" "按内容合并同一真人。\n"
"- frontmatter 的 people 优先填**声纹命名的真人**。"), "- frontmatter 的 people 优先填**声纹命名的真人**。"),
"soniox": (
"转写来自 Soniox 线上分角色模型:\n"
"- `speaker_N` 为自动区分的说话人标签,按内容与背景推断身份并标「疑似」,"
"待人工在 Web 审核中确认。"),
"openai": (
"转写来自 OpenAI Transcribe 模型:\n"
"- 若含 `speaker_N` 标签,为单场自动区分的说话人,按内容推断身份并标「疑似」;"
"若无说话人标签则按内容推断发言归属。"),
"gemini": (
"转写来自 Gemini 3.5 Transcribe 模型:\n"
"- 按内容推断发言归属,不要臆造未出现的 speaker 标签。"),
"grok": (
"转写来自 Grok Voice Transcribe 2.0 分角色模型:\n"
"- `speaker_N` 为自动区分的说话人标签,按内容推断身份并标「疑似」。"),
"vibevoice": "转写带 S# 说话人标签,仅作软提示,块间编号不保证一致。", "vibevoice": "转写带 S# 说话人标签,仅作软提示,块间编号不保证一致。",
"qwen3-plain": "转写**无说话人信息**,据内容推断发言归属,不要臆造 speaker 标签。", "qwen3-plain": "转写**无说话人信息**,据内容推断发言归属,不要臆造 speaker 标签。",
} }
@@ -0,0 +1,844 @@
#!/usr/bin/env python3
"""Unified ASR provider abstraction for local GPU (qwen3cam) and online models
(Soniox, Gemini 3.5 Transcribe via LiteLLM, OpenAI Transcribe via LiteLLM, Grok).
"""
from __future__ import annotations
import json
import mimetypes
import os
import re
import shutil
import subprocess
import tempfile
import time
import urllib.error
import urllib.request
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from .config import CFG
from .log import log
PROVIDERS: dict[str, dict[str, str]] = {
"qwen3cam": {
"label": "内网 Qwen3-ASR 1.7B + CAM++ (分角色 + 声纹)",
"default_model": "qwen3-asr-1.7b-cam++",
"mode": "lan-diarized-voiceprint",
},
"soniox": {
"label": "Soniox Async v5 (线上分角色 + 词库热词注入)",
"default_model": "stt-async-v5",
"mode": "cloud-diarized",
},
"gemini": {
"label": "Gemini 3.5 Transcribe (LiteLLM 统一路由)",
"default_model": "aistudio/gemini-3.5-transcribe",
"mode": "litellm-segmented",
},
"openai": {
"label": "OpenAI GPT-4o Transcribe Diarize (LiteLLM 统一路由)",
"default_model": "openai/gpt-4o-transcribe-diarize",
"mode": "litellm-diarized",
},
"grok": {
"label": "Grok Voice Transcribe 2.0 (线上分角色)",
"default_model": "grok-voice-transcribe-2.0",
"mode": "cloud-diarized",
},
}
@dataclass
class TranscribeResult:
provider: str
model: str
transcript_text: str
raw_response: Any
diarized: bool = True
voiceprint_session: str = ""
duration_seconds: int = 0
meta: dict[str, Any] = field(default_factory=dict)
def normalize_provider_name(name: str | None) -> str:
raw = (name or CFG.asr_provider or "qwen3cam").strip().lower()
if raw in ("paraformer", "qwen3", "qwen3-cam"):
return "qwen3cam"
if raw in ("gemini-3.5-transcribe", "aistudio/gemini-3.5-transcribe",
"vertex/gemini-3.5-transcribe"):
return "gemini"
if raw in ("openai-diarize", "gpt-4o-transcribe-diarize", "gpt-4o-transcribe",
"gpt-transcribe"):
return "openai"
if raw in ("xai", "grok-voice-transcribe-2.0"):
return "grok"
return raw if raw in PROVIDERS else "qwen3cam"
def provider_status() -> list[dict[str, Any]]:
"""Return readiness metadata for each ASR provider without exposing secrets."""
has_litellm = False
try:
has_litellm = bool(CFG.api_key())
except Exception:
has_litellm = False
return [
{
"id": "qwen3cam",
"label": PROVIDERS["qwen3cam"]["label"],
"model": PROVIDERS["qwen3cam"]["default_model"],
"ready": bool(CFG.diarize_url),
"endpoint": CFG.diarize_url,
"via": "LAN GPU (:10099)",
},
{
"id": "soniox",
"label": PROVIDERS["soniox"]["label"],
"model": CFG.soniox_model,
"ready": bool(CFG.soniox_api_key),
"endpoint": CFG.soniox_api_base,
"via": "SONIOX_API_KEY 直连",
},
{
"id": "gemini",
"label": PROVIDERS["gemini"]["label"],
"model": CFG.gemini_asr_model,
"ready": has_litellm,
"endpoint": CFG.transcriptions_endpoint,
"via": "LiteLLM 统一网关",
},
{
"id": "openai",
"label": PROVIDERS["openai"]["label"],
"model": CFG.openai_asr_model,
"ready": has_litellm,
"endpoint": CFG.transcriptions_endpoint,
"via": "LiteLLM 统一网关",
},
{
"id": "grok",
"label": PROVIDERS["grok"]["label"],
"model": CFG.grok_asr_model,
"ready": bool(CFG.grok_api_key),
"endpoint": CFG.grok_api_base + "/stt",
"via": "XAI_API_KEY 直连",
},
]
def collect_hotwords(limit: int = 120) -> list[str]:
"""Collect canonical entity names and glossary terms for ASR context biasing."""
terms: list[str] = []
seen: set[str] = set()
def add(word: str) -> None:
w = word.strip()
if not w or len(w) < 2 or len(w) > 40 or w in seen:
return
if re.match(r"^\d{4}-\d{2}|^_|speaker_", w):
return
seen.add(w)
terms.append(w)
try:
from . import lexicon
con = lexicon.connect()
for row in con.execute(
"SELECT canonical FROM entity ORDER BY synced_at DESC LIMIT ?",
(limit,)).fetchall():
add(str(row["canonical"]))
for row in con.execute(
"SELECT DISTINCT canonical FROM alias WHERE confirmed=1 LIMIT ?",
(limit,)).fetchall():
add(str(row["canonical"]))
con.close()
except Exception:
pass
if CFG.glossary.exists():
for raw in CFG.glossary.read_text(errors="ignore").splitlines():
s = raw.strip()
if not s.startswith(("-", "*")) or re.search(r"[→⇒]|->", s):
continue
s = re.split(r"\s[—–-]\s|[::]",
re.sub(r"^[-*+\d\.\)\s]+", "", s), maxsplit=1)[0].strip()
s = re.sub(r"\[\[|\]\]", "", s).strip()
add(s)
if len(terms) >= limit:
break
return terms[:limit]
def _normalize_speaker_label(raw: Any, mapping: dict[str, str]) -> str:
s = str(raw if raw is not None else "").strip()
if not s or s == "?":
return "speaker_0"
if re.match(r"^[\u4e00-\u9fff]{2,8}$", s):
return s
if re.match(r"^speaker_\d+$", s):
return s
m = re.match(r"^[sS](?:peaker)?[_ -]?(\d+)$", s)
if m:
return f"speaker_{m.group(1)}"
if s.isdigit():
# Soniox often uses 1-based speaker numbers ("1", "2"); normalize to speaker_0..N-1
if s not in mapping:
mapping[s] = f"speaker_{len(mapping)}"
return mapping[s]
if s not in mapping:
mapping[s] = f"speaker_{len(mapping)}"
return mapping[s]
def format_diarized_transcript(
segments: list[dict[str, Any]],
*,
provider: str,
model: str,
header_title: str,
voiceprint_session: str = "",
matched_names: list[str] | None = None,
) -> str:
if not segments:
raise RuntimeError(f"ASR ({provider}) response contains no segments")
speaker_map: dict[str, str] = {}
turns: list[list[str]] = []
speakers_seen: set[str] = set()
for seg in segments:
raw_spk = seg.get("speaker")
text = str(seg.get("text") or "").strip()
if not text:
continue
spk = (_normalize_speaker_label(raw_spk, speaker_map)
if provider != "qwen3cam"
else str(raw_spk if raw_spk is not None else "speaker_0"))
speakers_seen.add(spk)
if turns and turns[-1][0] == spk:
turns[-1][1] += text
else:
turns.append([spk, text])
if not turns:
raise RuntimeError(f"ASR ({provider}) segments contained no text")
lines = [f"<!-- asr_provider: {provider} model: {model} -->"]
if voiceprint_session or provider == "qwen3cam":
lines.append(
f"<!-- voiceprint_session: {voiceprint_session} "
f"clusters: {len(speakers_seen)} -->")
hdr = f"# 转写({header_title})"
if matched_names:
hdr += " | 声纹命名: " + ", ".join(matched_names)
lines.append(hdr)
lines.append("")
lines.extend(f"【{spk}】{txt}\n" for spk, txt in turns)
return "\n".join(lines).strip() + "\n"
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")
vp = payload.get("voiceprint") or {}
sid = str(vp.get("session_id") or "")
matched = [
f"{m.get('speaker_local')}={m.get('person_name')}"
for m in vp.get("matches", [])
if m.get("status") == "matched"
]
return format_diarized_transcript(
segments,
provider="qwen3cam",
model="qwen3-asr-1.7b-cam++",
header_title="Qwen3-ASR 1.7B + CAM++ 分角色 + 声纹",
voiceprint_session=sid,
matched_names=matched,
)
def transcript_from_soniox_response(
payload: dict[str, Any],
*,
model: str = "stt-async-v5",
) -> str:
"""Convert Soniox token-level or segment-level diarized response into turns."""
tokens = payload.get("tokens")
segments: list[dict[str, Any]] = []
if isinstance(tokens, list) and tokens:
cur_speaker: Any = None
cur_pieces: list[str] = []
for tok in tokens:
txt = str(tok.get("text") or "")
if not txt:
continue
spk = tok.get("speaker")
if cur_speaker is not None and spk != cur_speaker and cur_pieces:
segments.append({"speaker": cur_speaker,
"text": "".join(cur_pieces).strip()})
cur_pieces = []
cur_speaker = spk if spk is not None else (cur_speaker or "1")
cur_pieces.append(txt)
if cur_pieces:
segments.append({"speaker": cur_speaker or "1",
"text": "".join(cur_pieces).strip()})
elif isinstance(payload.get("segments"), list) and payload["segments"]:
segments = payload["segments"]
elif isinstance(payload.get("text"), str) and payload["text"].strip():
segments = [{"speaker": "speaker_0", "text": payload["text"].strip()}]
return format_diarized_transcript(
segments,
provider="soniox",
model=model,
header_title=f"Soniox {model} 分角色",
)
def transcript_from_openai_response(
payload: dict[str, Any],
*,
model: str,
) -> str:
"""Convert OpenAI / LiteLLM transcription response (diarized or plain) into text."""
segments = payload.get("segments")
if isinstance(segments, list) and segments and any(
s.get("speaker") is not None for s in segments if isinstance(s, dict)):
return format_diarized_transcript(
segments,
provider="openai",
model=model,
header_title=f"OpenAI {model} 分角色",
)
text = str(payload.get("text") or payload.get("transcript") or "").strip()
if not text and isinstance(segments, list):
text = "".join(str(s.get("text") or "") for s in segments).strip()
if not text:
raise RuntimeError(f"OpenAI ASR ({model}) returned empty transcript")
if re.search(r"【[^】]+】", text):
return (
f"<!-- asr_provider: openai model: {model} -->\n"
f"# 转写(OpenAI {model})\n\n{text}\n"
)
return (
f"<!-- asr_provider: openai model: {model} -->\n"
f"--- 第1段 [00:00 - 结束] ---\n{text}\n"
)
def transcript_from_grok_response(
payload: dict[str, Any],
*,
model: str = "grok-voice-transcribe-2.0",
) -> str:
segments = payload.get("segments") or payload.get("utterances") or payload.get("turns")
if isinstance(segments, list) and segments:
norm_segs = []
for s in segments:
if not isinstance(s, dict):
continue
spk = s.get("speaker")
if spk is None:
spk = s.get("speaker_id")
if spk is None:
spk = "speaker_0"
txt = str(s.get("text") or s.get("transcript") or "").strip()
if txt:
norm_segs.append({"speaker": spk, "text": txt})
if norm_segs:
return format_diarized_transcript(
norm_segs,
provider="grok",
model=model,
header_title=f"Grok {model} 分角色",
)
words = payload.get("words")
if isinstance(words, list) and words and any(
isinstance(w, dict) and w.get("speaker") is not None for w in words):
norm_segs = []
cur_spk = None
buf: list[str] = []
for w in words:
if not isinstance(w, dict):
continue
txt = str(w.get("word") or w.get("text") or "")
spk = w.get("speaker")
if cur_spk is not None and spk != cur_spk and buf:
norm_segs.append({"speaker": cur_spk, "text": "".join(buf).strip()})
buf = []
cur_spk = spk if spk is not None else (cur_spk or "0")
buf.append(txt)
if buf:
norm_segs.append({"speaker": cur_spk or "0", "text": "".join(buf).strip()})
if norm_segs:
return format_diarized_transcript(
norm_segs,
provider="grok",
model=model,
header_title=f"Grok {model} 分角色",
)
text = str(payload.get("text") or payload.get("transcript") or "").strip()
if not text:
raise RuntimeError(f"Grok ASR ({model}) returned empty transcript")
return format_diarized_transcript(
[{"speaker": "speaker_0", "text": text}],
provider="grok",
model=model,
header_title=f"Grok {model}",
)
def probe_duration_seconds(audio: Path) -> int:
try:
out = subprocess.check_output([
"ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=nk=1:nw=1", str(audio),
], text=True, timeout=30).strip()
return max(1, int(float(out) + 0.999)) if out else 0
except Exception:
return 0
def _encode_multipart(
fields: dict[str, str],
file_field: str,
file_path: Path,
) -> tuple[bytes, str]:
boundary = f"----ASRBoundary{uuid.uuid4().hex}"
body = bytearray()
for name, val in fields.items():
body.extend(f"--{boundary}\r\n".encode())
body.extend(
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
body.extend(str(val).encode("utf-8"))
body.extend(b"\r\n")
mime = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
body.extend(f"--{boundary}\r\n".encode())
body.extend(
f'Content-Disposition: form-data; name="{file_field}"; '
f'filename="{file_path.name}"\r\n'.encode())
body.extend(f"Content-Type: {mime}\r\n\r\n".encode())
body.extend(file_path.read_bytes())
body.extend(f"\r\n--{boundary}--\r\n".encode())
return bytes(body), f"multipart/form-data; boundary={boundary}"
def _http_multipart(
url: str,
*,
fields: dict[str, str],
file_field: str,
file_path: Path,
headers: dict[str, str] | None = None,
timeout: int = 900,
) -> 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
try:
return json.loads(raw)
except ValueError as exc:
raise RuntimeError(f"ASR returned invalid JSON: {raw[:200]}") from exc
def _http_json(
method: str,
url: str,
*,
payload: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
timeout: int = 120,
) -> 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
if not raw.strip():
return {}
try:
return json.loads(raw)
except ValueError as exc:
raise RuntimeError(f"ASR returned invalid JSON: {raw[:200]}") from exc
def _compress_or_split_audio(
audio: Path,
tmpdir: Path,
*,
max_bytes: int = 23 * 1024 * 1024,
segment_seconds: int = 2400,
force_split: bool = False,
) -> list[tuple[Path, int, int]]:
"""Return [(segment_path, start_sec, end_sec), ...] suitable for upstream API."""
duration = probe_duration_seconds(audio)
if not force_split and audio.stat().st_size <= max_bytes and audio.suffix.lower() in {
".mp3", ".m4a", ".wav", ".ogg", ".webm", ".flac"}:
return [(audio, 0, duration)]
if not shutil.which("ffmpeg"):
if audio.stat().st_size <= max_bytes:
return [(audio, 0, duration)]
raise RuntimeError("audio exceeds size limit and ffmpeg is not installed")
if not force_split:
compressed = tmpdir / "compressed.mp3"
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 compressed.stat().st_size <= max_bytes:
return [(compressed, 0, duration)]
total = max(duration, 1)
step = max(60, segment_seconds)
n_segs = (total + step - 1) // step
out: list[tuple[Path, int, int]] = []
for i in range(n_segs):
start = i * step
end = min(total, start + step)
seg = tmpdir / f"segment_{i + 1:03d}.mp3"
subprocess.run([
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-ss", str(start), "-t", str(step), "-i", str(audio),
"-ar", "16000", "-ac", "1", "-b:a", "64k", str(seg),
], check=True, timeout=600)
out.append((seg, start, end))
return out
def transcribe_qwen3cam(audio: Path, *, model: str = "") -> TranscribeResult:
result = subprocess.run([
"curl", "-sS", "--fail-with-body", "--max-time", "900",
"-F", f"file=@{audio}", "-F", "language=Chinese",
"-F", "save_voiceprint_session=true", CFG.diarize_url,
], capture_output=True, timeout=930)
if result.returncode:
detail = result.stderr.decode("utf-8", errors="ignore")[:240]
raise RuntimeError(f"qwen3cam ASR HTTP failed ({result.returncode}): {detail}")
try:
payload = json.loads(result.stdout)
except ValueError as exc:
raise RuntimeError("qwen3cam returned invalid JSON") from exc
text = transcript_from_qwen3cam_response(payload)
vp = payload.get("voiceprint") or {}
return TranscribeResult(
provider="qwen3cam",
model=model or PROVIDERS["qwen3cam"]["default_model"],
transcript_text=text,
raw_response=payload,
diarized=True,
voiceprint_session=str(vp.get("session_id") or ""),
)
def build_soniox_context(title: str = "", user_context: str = "") -> dict[str, Any]:
ctx: dict[str, Any] = {}
general = [{"key": "domain", "value": "Biotech / Investment / Corporate Meeting"}]
if title.strip():
general.append({"key": "title", "value": title.strip()[:120]})
ctx["general"] = general
if user_context.strip():
ctx["text"] = user_context.strip()[:2000]
terms = collect_hotwords(120)
if terms:
ctx["terms"] = terms
return ctx
def transcribe_soniox(
audio: Path,
*,
model: str = "",
title: str = "",
user_context: str = "",
poll_interval: float = 3.0,
max_wait_seconds: float = 900.0,
) -> TranscribeResult:
key = CFG.soniox_api_key
if not key:
raise RuntimeError("SONIOX_API_KEY is not configured")
base = CFG.soniox_api_base
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,
)
file_id = str(file_resp.get("id") or "")
if not file_id:
raise RuntimeError(f"Soniox file upload returned no id: {file_resp}")
transcription_id = ""
try:
job_payload: dict[str, Any] = {
"model": chosen_model,
"file_id": file_id,
"enable_speaker_diarization": True,
"enable_language_identification": True,
"language_hints": ["zh", "en"],
"context": build_soniox_context(title, user_context),
}
job_resp = _http_json(
"POST", f"{base}/transcriptions",
payload=job_payload, headers=headers, timeout=60,
)
transcription_id = str(job_resp.get("id") or "")
if not transcription_id:
raise RuntimeError(f"Soniox transcription create returned no id: {job_resp}")
deadline = time.time() + max_wait_seconds
status_resp: dict[str, Any] = {}
while time.time() < deadline:
status_resp = _http_json(
"GET", f"{base}/transcriptions/{transcription_id}",
headers=headers, timeout=60,
)
st = str(status_resp.get("status") or "").lower()
if st == "completed":
break
if st in ("error", "failed"):
err = status_resp.get("error_message") or status_resp
raise RuntimeError(f"Soniox transcription failed: {err}")
time.sleep(poll_interval)
else:
raise RuntimeError("Soniox transcription timed out waiting for completion")
if "tokens" in status_resp or "segments" in status_resp:
transcript_payload = status_resp
else:
transcript_payload = _http_json(
"GET", f"{base}/transcriptions/{transcription_id}/transcript",
headers=headers, timeout=120,
)
text = transcript_from_soniox_response(
transcript_payload, model=chosen_model)
return TranscribeResult(
provider="soniox",
model=chosen_model,
transcript_text=text,
raw_response=transcript_payload,
diarized=True,
)
finally:
for cleanup_url in (
f"{base}/transcriptions/{transcription_id}" if transcription_id else "",
f"{base}/files/{file_id}" if file_id else "",
):
if cleanup_url:
try:
_http_json("DELETE", cleanup_url, headers=headers, timeout=15)
except Exception:
pass
def transcribe_gemini(
audio: Path,
*,
model: str = "",
title: str = "",
user_context: str = "",
) -> TranscribeResult:
chosen_model = model or CFG.gemini_asr_model or "aistudio/gemini-3.5-transcribe"
headers = {"Authorization": f"Bearer {CFG.api_key()}"}
with tempfile.TemporaryDirectory(prefix="asr-gemini-") as tmp:
segments = _compress_or_split_audio(
audio, Path(tmp),
max_bytes=18 * 1024 * 1024,
segment_seconds=CFG.segment_seconds,
force_split=probe_duration_seconds(audio) > CFG.segment_seconds + 60,
)
responses: list[dict[str, Any]] = []
parts: list[str] = [f"<!-- asr_provider: gemini model: {chosen_model} -->"]
for idx, (seg_path, start, end) in enumerate(segments, 1):
resp = _http_multipart(
CFG.transcriptions_endpoint,
fields={"model": chosen_model, "response_format": "json"},
file_field="file",
file_path=seg_path,
headers=headers,
timeout=600,
)
responses.append(resp)
seg_text = str(resp.get("text") or resp.get("transcript") or "").strip()
if not seg_text:
raise RuntimeError(f"Gemini segment {idx} returned empty text")
parts.append(
f"--- 第{idx}段 [{start // 60:02d}:{start % 60:02d} - "
f"{end // 60:02d}:{end % 60:02d}] ---\n{seg_text}\n")
return TranscribeResult(
provider="gemini",
model=chosen_model,
transcript_text="\n\n".join(parts).strip() + "\n",
raw_response={"segments": responses},
diarized=False,
)
def transcribe_openai(
audio: Path,
*,
model: str = "",
title: str = "",
user_context: str = "",
) -> TranscribeResult:
chosen_model = model or CFG.openai_asr_model or "openai/gpt-4o-transcribe-diarize"
headers = {"Authorization": f"Bearer {CFG.api_key()}"}
is_diarize = "diarize" in chosen_model.lower()
with tempfile.TemporaryDirectory(prefix="asr-openai-") as tmp:
segments = _compress_or_split_audio(
audio, Path(tmp),
max_bytes=23 * 1024 * 1024,
segment_seconds=2400,
force_split=False,
)
responses: list[dict[str, Any]] = []
all_diarized_segs: list[dict[str, Any]] = []
plain_parts: list[str] = [f"<!-- asr_provider: openai model: {chosen_model} -->"]
hotwords = collect_hotwords(80)
prompt = ""
if not is_diarize and hotwords:
prompt = "会议专有名词参考:" + "、".join(hotwords)[:800]
for idx, (seg_path, start, end) in enumerate(segments, 1):
fields: dict[str, str] = {
"model": chosen_model,
"language": "zh",
"response_format": "diarized_json" if is_diarize else "json",
}
if prompt:
fields["prompt"] = prompt
try:
resp = _http_multipart(
CFG.transcriptions_endpoint,
fields=fields,
file_field="file",
file_path=seg_path,
headers=headers,
timeout=600,
)
except RuntimeError:
if is_diarize:
# Fallback if gateway enforces response_format=json
fields["response_format"] = "json"
resp = _http_multipart(
CFG.transcriptions_endpoint,
fields=fields,
file_field="file",
file_path=seg_path,
headers=headers,
timeout=600,
)
else:
raise
responses.append(resp)
if isinstance(resp.get("segments"), list) and resp["segments"]:
all_diarized_segs.extend(resp["segments"])
else:
txt = str(resp.get("text") or resp.get("transcript") or "").strip()
if txt:
plain_parts.append(
f"--- 第{idx}段 [{start // 60:02d}:{start % 60:02d} - "
f"{end // 60:02d}:{end % 60:02d}] ---\n{txt}\n")
if all_diarized_segs:
text = transcript_from_openai_response(
{"segments": all_diarized_segs}, model=chosen_model)
diarized = True
elif len(responses) == 1:
text = transcript_from_openai_response(responses[0], model=chosen_model)
diarized = "【" in text
else:
if len(plain_parts) <= 1:
raise RuntimeError(f"OpenAI ASR ({chosen_model}) returned no text")
text = "\n\n".join(plain_parts).strip() + "\n"
diarized = False
return TranscribeResult(
provider="openai",
model=chosen_model,
transcript_text=text,
raw_response={"segments": responses} if len(responses) > 1 else responses[0],
diarized=diarized,
)
def transcribe_grok(
audio: Path,
*,
model: str = "",
title: str = "",
user_context: str = "",
) -> TranscribeResult:
key = CFG.grok_api_key
if not key:
raise RuntimeError("XAI_API_KEY is not configured")
chosen_model = model or CFG.grok_asr_model or "grok-voice-transcribe-2.0"
headers = {"Authorization": f"Bearer {key}"}
url = f"{CFG.grok_api_base}/stt"
with tempfile.TemporaryDirectory(prefix="asr-grok-") as tmp:
segments = _compress_or_split_audio(
audio, Path(tmp), max_bytes=23 * 1024 * 1024, segment_seconds=2400)
all_texts: list[str] = []
raw_list: list[dict[str, Any]] = []
for seg_path, _start, _end in segments:
resp = _http_multipart(
url,
fields={"model": chosen_model, "diarize": "true", "language": "zh"},
file_field="file",
file_path=seg_path,
headers=headers,
timeout=600,
)
raw_list.append(resp)
all_texts.append(transcript_from_grok_response(resp, model=chosen_model))
text = all_texts[0] if len(all_texts) == 1 else "\n\n".join(all_texts)
return TranscribeResult(
provider="grok",
model=chosen_model,
transcript_text=text,
raw_response=raw_list[0] if len(raw_list) == 1 else {"chunks": raw_list},
diarized=True,
)
def transcribe_audio(
audio: Path,
*,
provider: str | None = None,
model: str = "",
title: str = "",
user_context: str = "",
) -> TranscribeResult:
"""Dispatch transcription to the selected provider and return normalized result."""
prov = normalize_provider_name(provider)
log(f"transcribe start provider={prov} model={model or 'default'} file={audio.name}")
if prov == "qwen3cam":
return transcribe_qwen3cam(audio, model=model)
if prov == "soniox":
return transcribe_soniox(audio, model=model, title=title, user_context=user_context)
if prov == "gemini":
return transcribe_gemini(audio, model=model, title=title, user_context=user_context)
if prov == "openai":
return transcribe_openai(audio, model=model, title=title, user_context=user_context)
if prov == "grok":
return transcribe_grok(audio, model=model, title=title, user_context=user_context)
raise ValueError(f"unsupported ASR provider: {prov}")
@@ -28,6 +28,10 @@ from .log import log
SOURCE_DETAIL = { SOURCE_DETAIL = {
"qwen3cam": "Qwen3-ASR 1.7B + CAM++ 分角色 + 声纹自动命名", "qwen3cam": "Qwen3-ASR 1.7B + CAM++ 分角色 + 声纹自动命名",
"soniox": "Soniox stt-async-v5 线上分角色转写",
"gemini": "Gemini 3.5 Transcribe (LiteLLM)",
"openai": "OpenAI GPT-4o Transcribe (LiteLLM)",
"grok": "Grok Voice Transcribe 2.0",
"vibevoice": "VibeVoice 分角色", "vibevoice": "VibeVoice 分角色",
"qwen3-plain": "Qwen3-ASR 1.7B", "qwen3-plain": "Qwen3-ASR 1.7B",
} }
@@ -185,7 +185,8 @@ def _voiceprint_name(value: str) -> str:
def _confirm_voiceprint(draft_path: str, speaker_id: str, value: str) -> bool: def _confirm_voiceprint(draft_path: str, speaker_id: str, value: str) -> bool:
"""Persist an explicit speaker identity and immediately enroll its session.""" """Persist an explicit speaker identity and, when a voiceprint session exists,
enroll it against the acoustic voiceprint service."""
name = _voiceprint_name(value) name = _voiceprint_name(value)
if not name: if not name:
return False return False
@@ -196,8 +197,6 @@ def _confirm_voiceprint(draft_path: str, speaker_id: str, value: str) -> bool:
return False return False
text = path.read_text(encoding="utf-8", errors="ignore") text = path.read_text(encoding="utf-8", errors="ignore")
session_id = _frontmatter_value(text, "voiceprint_session") session_id = _frontmatter_value(text, "voiceprint_session")
if not session_id:
return False
fm = re.match(r"(?s)^---\n(.*?)\n---", text) fm = re.match(r"(?s)^---\n(.*?)\n---", text)
if not fm: if not fm:
return False return False
@@ -205,12 +204,17 @@ def _confirm_voiceprint(draft_path: str, speaker_id: str, value: str) -> bool:
speaker_line = rf"(?m)^(\s+{re.escape(speaker_id)}:)\s*.*$" speaker_line = rf"(?m)^(\s+{re.escape(speaker_id)}:)\s*.*$"
if re.search(speaker_line, block): if re.search(speaker_line, block):
block = re.sub(speaker_line, rf"\1 {name}", block) block = re.sub(speaker_line, rf"\1 {name}", block)
elif re.search(r"(?m)^voiceprint_speakers:\s*(?:\{\})?\s*$", block):
block = re.sub(
r"(?m)^voiceprint_speakers:\s*(?:\{\})?\s*$",
f"voiceprint_speakers:\n {speaker_id}: {name}", block, count=1)
elif re.search(r"(?m)^voiceprint_speakers:\s*$", block): elif re.search(r"(?m)^voiceprint_speakers:\s*$", block):
block = re.sub( block = re.sub(
r"(?m)^(voiceprint_speakers:\s*)$", r"(?m)^(voiceprint_speakers:\s*)$",
rf"\1\n {speaker_id}: {name}", block, count=1) rf"\1\n {speaker_id}: {name}", block, count=1)
else: else:
block += f"\nvoiceprint_speakers:\n {speaker_id}: {name}" block += f"\nvoiceprint_speakers:\n {speaker_id}: {name}"
if session_id:
if re.search(r"(?m)^voiceprint_enroll:", block): if re.search(r"(?m)^voiceprint_enroll:", block):
block = re.sub(r"(?m)^voiceprint_enroll:.*$", block = re.sub(r"(?m)^voiceprint_enroll:.*$",
"voiceprint_enroll: true", block) "voiceprint_enroll: true", block)
@@ -220,12 +224,14 @@ def _confirm_voiceprint(draft_path: str, speaker_id: str, value: str) -> bool:
from . import vault from . import vault
vault.write_draft_file(path, text) vault.write_draft_file(path, text)
if not session_id or not CFG.voiceprint_enroll_url:
return False
body = json.dumps({ body = json.dumps({
"session_id": session_id, "speaker_id": int(speaker_id.split("_")[1]), "session_id": session_id, "speaker_id": int(speaker_id.split("_")[1]),
"name": name, "name": name,
}).encode() }).encode()
req = urllib.request.Request( req = urllib.request.Request(
"http://192.168.50.100:10099/voiceprints/enroll", data=body, CFG.voiceprint_enroll_url, data=body,
headers={"Content-Type": "application/json"}) headers={"Content-Type": "application/json"})
try: try:
with urllib.request.urlopen(req, timeout=20) as response: with urllib.request.urlopen(req, timeout=20) as response:
@@ -29,6 +29,8 @@ def _url(token: str) -> str:
def request_start(job_id: str = "") -> bool: def request_start(job_id: str = "") -> bool:
"""Ensure control service is running and send a task-scoped review URL.""" """Ensure control service is running and send a task-scoped review URL."""
if os.environ.get("ASR_ROLE"):
return True
try: try:
r = subprocess.run(["systemctl", "--user", "start", UNIT], r = subprocess.run(["systemctl", "--user", "start", UNIT],
capture_output=True, text=True, timeout=15) capture_output=True, text=True, timeout=15)
@@ -22,6 +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*-->")
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)
@@ -95,6 +96,11 @@ class Chunk:
def detect_backend(raw: str) -> str: def detect_backend(raw: str) -> str:
head = raw[:500] head = raw[:500]
m_prov = PROVIDER_HDR.search(head)
if m_prov:
prov = m_prov.group(1).strip().lower()
if prov in {"qwen3cam", "soniox", "gemini", "openai", "grok", "vibevoice"}:
return prov
if "CAM++" in head or TURN_TAG.match(raw.strip().splitlines()[-1] if raw.strip() else ""): if "CAM++" in head or TURN_TAG.match(raw.strip().splitlines()[-1] if raw.strip() else ""):
return "qwen3cam" return "qwen3cam"
if re.search(r"^\[\d{1,2}:\d{2}\]\s*S\d+\s*[::]", raw, re.M): if re.search(r"^\[\d{1,2}:\d{2}\]\s*S\d+\s*[::]", raw, re.M):
@@ -108,7 +114,7 @@ def parse(raw: str) -> tuple[list[Turn], dict]:
"""Split a transcript into turns plus header metadata.""" """Split a transcript into turns plus header metadata."""
meta: dict = { meta: dict = {
"voiceprint_session": "", "voiceprint_named": {}, "banners": [], "voiceprint_session": "", "voiceprint_named": {}, "banners": [],
"intake": {}, "intake": {}, "asr_provider": "", "asr_model": "",
} }
lines = raw.splitlines() lines = raw.splitlines()
body_lines = [] body_lines = []
@@ -120,6 +126,11 @@ def parse(raw: str) -> tuple[list[Turn], dict]:
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
continue continue
m_prov = PROVIDER_HDR.match(ln)
if m_prov:
meta["asr_provider"] = m_prov.group(1) or ""
meta["asr_model"] = m_prov.group(2) or ""
continue
if VOICEPRINT_HDR.match(ln): if VOICEPRINT_HDR.match(ln):
m = re.search(r"voiceprint_session:\s*(\S+)", ln) m = re.search(r"voiceprint_session:\s*(\S+)", ln)
if m: if m:
@@ -1 +1 @@
"""Standalone ASR service for web, Agent API, and SSH drop-folder intake.""" """Standalone WebUI, REST API, and background worker service for Meeting ASR."""
@@ -3,23 +3,27 @@ from __future__ import annotations
import os import os
from pathlib import Path from pathlib import Path
from asrpipe.config import CFG
DATA = Path(os.getenv("ASR_DATA_DIR", "/data")) DATA = Path(os.getenv("ASR_DATA_DIR", "/data"))
INBOX = DATA / "inbox" INBOX = DATA / "inbox"
PROCESSING = DATA / "processing" PROCESSING = DATA / "processing"
PROCESSED = DATA / "processed" PROCESSED = DATA / "processed"
FAILED = DATA / "failed" FAILED = DATA / "failed"
TRANSCRIPTS = DATA / "transcripts" TRANSCRIPTS = DATA / "transcripts"
STATE = Path(os.getenv("ASR_STATE_DIR", "/state")) STATE = Path(os.getenv("ASR_STATE_DIR", str(CFG.state_dir)))
DRAFTS = DATA / "artifacts" / "drafts" DRAFTS = Path(os.getenv("ASR_DRAFTS_DIR", str(DATA / "artifacts" / "drafts")))
RESULTS = DATA / "artifacts" / "results" RESULTS = Path(os.getenv("ASR_RESULTS_DIR", str(DATA / "artifacts" / "results")))
LOGS = DATA / "logs" LOGS = DATA / "logs"
META = STATE / "uploads" META = STATE / "uploads"
REVISION_QUEUE = STATE / "revision-queue" REVISION_QUEUE = Path(os.getenv("ASR_REVISION_QUEUE_DIR", str(STATE / "revision-queue")))
DB = STATE / "intake.db" DB = STATE / "intake.db"
SUPPORTED = {".m4a", ".mp3", ".wav", ".mp4", ".aac", ".ogg", ".opus", ".webm", ".flac"} SUPPORTED = {".m4a", ".mp3", ".wav", ".mp4", ".aac", ".ogg", ".opus", ".webm", ".flac"}
PUBLIC_URL = os.getenv("ASR_PUBLIC_URL", "http://192.168.50.49:8788").rstrip("/") PUBLIC_URL = os.getenv("ASR_PUBLIC_URL", "http://192.168.50.49:8788").rstrip("/")
AGENT_TOKEN = os.getenv("ASR_AGENT_TOKEN", "") AGENT_TOKEN = os.getenv("ASR_AGENT_TOKEN", "")
DIARIZE_URL = os.getenv("ASR_DIARIZE_URL", "http://192.168.50.100:10099/asr") WEB_PASSWORD = os.getenv("ASR_WEB_PASSWORD", "")
DIARIZE_URL = os.getenv("ASR_DIARIZE_URL", CFG.diarize_url)
DEFAULT_PROVIDER = os.getenv("ASR_PROVIDER", CFG.asr_provider)
MAX_UPLOAD = int(os.getenv("ASR_UPLOAD_MAX_BYTES", str(4 * 1024**3))) MAX_UPLOAD = int(os.getenv("ASR_UPLOAD_MAX_BYTES", str(4 * 1024**3)))
POLL_SECONDS = max(1, int(os.getenv("ASR_POLL_SECONDS", "5"))) POLL_SECONDS = max(1, int(os.getenv("ASR_POLL_SECONDS", "5")))
STABLE_SECONDS = max(1, int(os.getenv("ASR_STABLE_SECONDS", "10"))) STABLE_SECONDS = max(1, int(os.getenv("ASR_STABLE_SECONDS", "10")))
@@ -27,8 +31,13 @@ MAX_ATTEMPTS = max(1, int(os.getenv("ASR_MAX_ATTEMPTS", "3")))
def ensure_dirs() -> None: def ensure_dirs() -> None:
for path in (INBOX, PROCESSING, PROCESSED, FAILED, TRANSCRIPTS, DRAFTS, # Keep asrpipe CFG aligned with container/service paths when running under asrservice
CFG.state_dir
for path in (
INBOX, PROCESSING, PROCESSED, FAILED, TRANSCRIPTS, DRAFTS,
RESULTS, STATE, LOGS, META, REVISION_QUEUE, DATA / "cache", RESULTS, STATE, LOGS, META, REVISION_QUEUE, DATA / "cache",
DATA / "artifacts" / "raw", DATA / "vault" / "minutes", DATA / "artifacts" / "raw", DATA / "artifacts" / "results",
DATA / "vault" / "People", DATA / "vault" / "Companies"): DATA / "vault" / "minutes", DATA / "vault" / "People",
DATA / "vault" / "Companies", DATA / "vault" / "Projects",
):
path.mkdir(parents=True, exist_ok=True) path.mkdir(parents=True, exist_ok=True)
+44 -17
View File
@@ -1,14 +1,15 @@
from __future__ import annotations from __future__ import annotations
import json
import sqlite3 import sqlite3
import time import time
from typing import Any from typing import Any
from .config import DB, ensure_dirs from .config import DB, DEFAULT_PROVIDER, ensure_dirs
STATUSES = {"uploading", "queued", "transcribing", "refining", "review", STATUSES = {
"publishing", "complete", "failed"} "uploading", "queued", "transcribing", "refining", "review",
"publishing", "complete", "failed",
}
def connect() -> sqlite3.Connection: def connect() -> sqlite3.Connection:
@@ -26,6 +27,8 @@ def connect() -> sqlite3.Connection:
draft TEXT NOT NULL DEFAULT '', result TEXT NOT NULL DEFAULT '', draft TEXT NOT NULL DEFAULT '', result TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT 'web', sha256 TEXT NOT NULL DEFAULT '', source TEXT NOT NULL DEFAULT 'web', sha256 TEXT NOT NULL DEFAULT '',
attempts INTEGER NOT NULL DEFAULT 0, error_code TEXT NOT NULL DEFAULT '', attempts INTEGER NOT NULL DEFAULT 0, error_code TEXT NOT NULL DEFAULT '',
asr_provider TEXT NOT NULL DEFAULT 'qwen3cam',
asr_model TEXT NOT NULL DEFAULT '',
created_at REAL NOT NULL, updated_at REAL NOT NULL created_at REAL NOT NULL, updated_at REAL NOT NULL
); );
CREATE TABLE IF NOT EXISTS job_event( CREATE TABLE IF NOT EXISTS job_event(
@@ -37,9 +40,14 @@ def connect() -> sqlite3.Connection:
""") """)
cols = {r[1] for r in con.execute("PRAGMA table_info(intake_job)")} cols = {r[1] for r in con.execute("PRAGMA table_info(intake_job)")}
additions = { additions = {
"draft": "TEXT NOT NULL DEFAULT ''", "result": "TEXT NOT NULL DEFAULT ''", "draft": "TEXT NOT NULL DEFAULT ''",
"source": "TEXT NOT NULL DEFAULT 'web'", "sha256": "TEXT NOT NULL DEFAULT ''", "result": "TEXT NOT NULL DEFAULT ''",
"attempts": "INTEGER NOT NULL DEFAULT 0", "error_code": "TEXT NOT NULL DEFAULT ''", "source": "TEXT NOT NULL DEFAULT 'web'",
"sha256": "TEXT NOT NULL DEFAULT ''",
"attempts": "INTEGER NOT NULL DEFAULT 0",
"error_code": "TEXT NOT NULL DEFAULT ''",
"asr_provider": f"TEXT NOT NULL DEFAULT '{DEFAULT_PROVIDER}'",
"asr_model": "TEXT NOT NULL DEFAULT ''",
} }
for name, ddl in additions.items(): for name, ddl in additions.items():
if name not in cols: if name not in cols:
@@ -48,23 +56,40 @@ def connect() -> sqlite3.Connection:
return con return con
def create_job(job_id: str, original_name: str, stored_name: str, *, title: str = "", def create_job(
context: str = "", size: int = 0, source: str = "web") -> dict[str, Any]: job_id: str,
original_name: str,
stored_name: str,
*,
title: str = "",
context: str = "",
size: int = 0,
source: str = "web",
asr_provider: str = "",
asr_model: str = "",
) -> dict[str, Any]:
now = time.time() now = time.time()
prov = (asr_provider or DEFAULT_PROVIDER or "qwen3cam").strip().lower()
with connect() as con: with connect() as con:
con.execute("INSERT INTO intake_job(id,original_name,stored_name,title,context,size," con.execute(
"status,source,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)", "INSERT INTO intake_job(id,original_name,stored_name,title,context,size,"
"status,source,asr_provider,asr_model,created_at,updated_at) "
"VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
(job_id, original_name, stored_name, title, context, size, (job_id, original_name, stored_name, title, context, size,
"queued", source, now, now)) "queued", source, prov, asr_model, now, now))
con.execute("INSERT INTO job_event(job_id,status,detail,created_at) VALUES(?,?,?,?)", con.execute(
(job_id, "queued", "录音已进入队列", now)) "INSERT INTO job_event(job_id,status,detail,created_at) VALUES(?,?,?,?)",
(job_id, "queued", f"录音已进入队列 (ASR: {prov})", now))
return get_job(job_id) or {} return get_job(job_id) or {}
def update_job(job_id: str, status: str, detail: str = "", **fields: Any) -> bool: def update_job(job_id: str, status: str, detail: str = "", **fields: Any) -> bool:
if status not in STATUSES: if status not in STATUSES:
raise ValueError(f"invalid status: {status}") raise ValueError(f"invalid status: {status}")
allowed = {"transcript", "draft", "result", "sha256", "attempts", "error_code"} allowed = {
"transcript", "draft", "result", "sha256", "attempts",
"error_code", "asr_provider", "asr_model",
}
changes = {k: v for k, v in fields.items() if k in allowed and v is not None} changes = {k: v for k, v in fields.items() if k in allowed and v is not None}
sets = ["status=?", "updated_at=?", "detail=?"] sets = ["status=?", "updated_at=?", "detail=?"]
values: list[Any] = [status, time.time(), detail[:1000]] values: list[Any] = [status, time.time(), detail[:1000]]
@@ -75,7 +100,8 @@ def update_job(job_id: str, status: str, detail: str = "", **fields: Any) -> boo
with connect() as con: with connect() as con:
cur = con.execute(f"UPDATE intake_job SET {','.join(sets)} WHERE id=?", values) cur = con.execute(f"UPDATE intake_job SET {','.join(sets)} WHERE id=?", values)
if cur.rowcount: if cur.rowcount:
con.execute("INSERT INTO job_event(job_id,status,detail,created_at) VALUES(?,?,?,?)", con.execute(
"INSERT INTO job_event(job_id,status,detail,created_at) VALUES(?,?,?,?)",
(job_id, status, detail[:1000], time.time())) (job_id, status, detail[:1000], time.time()))
return bool(cur.rowcount) return bool(cur.rowcount)
@@ -88,7 +114,8 @@ def get_job(job_id: str) -> dict[str, Any] | None:
def find_by_stored(stored_name: str) -> dict[str, Any] | None: def find_by_stored(stored_name: str) -> dict[str, Any] | None:
with connect() as con: with connect() as con:
row = con.execute("SELECT * FROM intake_job WHERE stored_name=? ORDER BY created_at DESC LIMIT 1", row = con.execute(
"SELECT * FROM intake_job WHERE stored_name=? ORDER BY created_at DESC LIMIT 1",
(stored_name,)).fetchone() (stored_name,)).fetchone()
return dict(row) if row else None return dict(row) if row else None
@@ -2,4 +2,6 @@ import time
from .config import STATE from .config import STATE
heartbeat = STATE / "worker.heartbeat" heartbeat = STATE / "worker.heartbeat"
raise SystemExit(0 if heartbeat.exists() and time.time() - heartbeat.stat().st_mtime < 90 else 1) raise SystemExit(
0 if heartbeat.exists() and time.time() - heartbeat.stat().st_mtime < 90 else 1
)
@@ -15,21 +15,28 @@ def send(status: str, filename: str = "", detail: str = "") -> bool:
chat = os.getenv("TELEGRAM_CHAT_ID", "") chat = os.getenv("TELEGRAM_CHAT_ID", "")
if not token or not chat: if not token or not chat:
return False return False
body = parse.urlencode({"chat_id": chat, body = parse.urlencode({
"text": "\n".join(x for x in (status, filename, detail) if x)}).encode() "chat_id": chat,
req = request.Request(f"https://api.telegram.org/bot{token}/sendMessage", "text": "\n".join(x for x in (status, filename, detail) if x),
}).encode()
req = request.Request(
f"https://api.telegram.org/bot{token}/sendMessage",
data=body, method="POST") data=body, method="POST")
elif backend == "hermes": elif backend == "hermes":
url = os.getenv("HERMES_WEBHOOK_URL", "") url = os.getenv("HERMES_WEBHOOK_URL", "")
secret = os.getenv("HERMES_WEBHOOK_SECRET", "") secret = os.getenv("HERMES_WEBHOOK_SECRET", "")
if not url or not secret: if not url or not secret:
return False return False
body = json.dumps({"status": status, "filename": filename, "detail": detail}, body = json.dumps(
{"status": status, "filename": filename, "detail": detail},
ensure_ascii=False).encode() ensure_ascii=False).encode()
sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() sig = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
req = request.Request(url, data=body, method="POST", req = request.Request(
headers={"Content-Type": "application/json", url, data=body, method="POST",
"X-Webhook-Signature": sig}) headers={
"Content-Type": "application/json",
"X-Webhook-Signature": sig,
})
else: else:
return False return False
with request.urlopen(req, timeout=10) as response: with request.urlopen(req, timeout=10) as response:
File diff suppressed because it is too large Load Diff
@@ -6,17 +6,17 @@ import json
import os import os
import shutil import shutil
import sqlite3 import sqlite3
import subprocess
import time import time
import uuid import uuid
from pathlib import Path from pathlib import Path
from asrpipe import refine, review, revise, vault from asrpipe import graph, lexicon, providers, refine, review, revise, vault
from . import db from . import db
from .config import (DIARIZE_URL, DRAFTS, FAILED, INBOX, MAX_ATTEMPTS, META, from .config import (
POLL_SECONDS, PROCESSED, PROCESSING, PUBLIC_URL, DATA, DEFAULT_PROVIDER, DRAFTS, FAILED, INBOX, LOGS, MAX_ATTEMPTS,
REVISION_QUEUE, STABLE_SECONDS, STATE, SUPPORTED, TRANSCRIPTS, LOGS, POLL_SECONDS, PROCESSED, PROCESSING, PUBLIC_URL, REVISION_QUEUE,
ensure_dirs) STABLE_SECONDS, STATE, SUPPORTED, TRANSCRIPTS, ensure_dirs,
)
from .notify import send from .notify import send
@@ -35,69 +35,83 @@ def sha256(path: Path) -> str:
def transcript_from_response(payload: dict) -> str: def transcript_from_response(payload: dict) -> str:
segments = payload.get("segments") """Backward-compatible helper for qwen3cam JSON responses."""
if not isinstance(segments, list) or not segments: return providers.transcript_from_qwen3cam_response(payload)
raise RuntimeError("ASR response contains no segments")
vp = payload.get("voiceprint") or {}
sid = str(vp.get("session_id") or "")
matched = [f"{m.get('speaker_local')}={m.get('person_name')}" for m in vp.get("matches", [])
if m.get("status") == "matched"]
speakers = {str(s.get("speaker")) for s in segments if s.get("speaker") is not None}
lines = [f"<!-- voiceprint_session: {sid} clusters: {len(speakers)} -->",
"# 转写(Qwen3-ASR 1.7B + CAM++ 分角色 + 声纹)"
+ (" | 声纹命名: " + ", ".join(matched) if matched else ""), ""]
turns: list[list[str]] = []
for segment in segments:
speaker, text = str(segment.get("speaker", "?")), str(segment.get("text") or "").strip()
if not text:
continue
if turns and turns[-1][0] == speaker:
turns[-1][1] += text
else:
turns.append([speaker, text])
lines.extend(f"【{speaker}】{text}\n" for speaker, text in turns)
return "\n".join(lines).strip() + "\n"
def transcribe(audio: Path, job_id: str) -> Path: def transcribe(
audio: Path,
job_id: str,
*,
provider: str = "",
model: str = "",
title: str = "",
user_context: str = "",
) -> tuple[Path, providers.TranscribeResult]:
res = providers.transcribe_audio(
audio,
provider=provider or DEFAULT_PROVIDER,
model=model,
title=title,
user_context=user_context,
)
response_path = TRANSCRIPTS / f"{job_id}.asr.json" response_path = TRANSCRIPTS / f"{job_id}.asr.json"
result = subprocess.run([
"curl", "-sS", "--fail-with-body", "--max-time", "900",
"-F", f"file=@{audio}", "-F", "language=Chinese",
"-F", "save_voiceprint_session=true", DIARIZE_URL,
], capture_output=True, timeout=930)
if result.returncode:
raise RuntimeError(f"ASR HTTP failed ({result.returncode})")
response_path.write_bytes(result.stdout)
try: try:
payload = json.loads(result.stdout) response_path.write_text(
except ValueError as exc: json.dumps(res.raw_response, ensure_ascii=False, indent=2),
raise RuntimeError("ASR returned invalid JSON") from exc encoding="utf-8",
)
except Exception:
pass
transcript = TRANSCRIPTS / f"{job_id}.txt" transcript = TRANSCRIPTS / f"{job_id}.txt"
transcript.write_text(transcript_from_response(payload), encoding="utf-8") transcript.write_text(res.transcript_text, encoding="utf-8")
return transcript return transcript, res
def process_audio(source: Path, job: dict) -> None: def process_audio(source: Path, job: dict) -> None:
job_id = job["id"] job_id = job["id"]
attempts = int(job.get("attempts") or 0) + 1 attempts = int(job.get("attempts") or 0) + 1
prov = providers.normalize_provider_name(job.get("asr_provider") or DEFAULT_PROVIDER)
model = str(job.get("asr_model") or "")
digest = sha256(source) digest = sha256(source)
db.update_job(job_id, "transcribing", "正在进行分角色转写", sha256=digest, prov_label = providers.PROVIDERS.get(prov, {}).get("label", prov)
attempts=attempts, error_code="") db.update_job(
transcript = transcribe(source, job_id) job_id, "transcribing", f"正在转写 ({prov_label})",
intake = {"job_id": job_id, "title": job.get("title", ""), sha256=digest, attempts=attempts, error_code="",
"context": job.get("context", ""), "source": job.get("source", "")} asr_provider=prov, asr_model=model,
)
transcript, res = transcribe(
source, job_id,
provider=prov, model=model,
title=str(job.get("title") or ""),
user_context=str(job.get("context") or ""),
)
intake = {
"job_id": job_id,
"title": job.get("title", ""),
"context": job.get("context", ""),
"source": job.get("source", ""),
"asr_provider": res.provider,
"asr_model": res.model,
}
raw = transcript.read_text(encoding="utf-8") raw = transcript.read_text(encoding="utf-8")
raw = f"<!-- intake: {json.dumps(intake, ensure_ascii=False)} -->\n" + raw raw = f"<!-- intake: {json.dumps(intake, ensure_ascii=False)} -->\n" + raw
transcript.write_text(raw, encoding="utf-8") transcript.write_text(raw, encoding="utf-8")
db.update_job(job_id, "refining", "转写完成,正在生成整理稿", transcript=str(transcript)) db.update_job(
result = refine.refine(raw, date=time.strftime("%Y-%m-%d"), job_id, "refining", f"转写完成 ({res.provider}:{res.model}),正在生成整理稿",
transcript_path=str(transcript), queue_review=True) transcript=str(transcript), asr_provider=res.provider, asr_model=res.model,
)
result = refine.refine(
raw, date=time.strftime("%Y-%m-%d"),
transcript_path=str(transcript), queue_review=True,
)
draft = DRAFTS / f"{job_id}.md" draft = DRAFTS / f"{job_id}.md"
vault.write_draft_file(draft, result.draft) vault.write_draft_file(draft, result.draft)
questions = review.enqueue_draft(str(transcript), draft) questions = review.enqueue_draft(str(transcript), draft)
db.update_job(job_id, "review", f"整理稿已生成,{questions} 个问题待确认", db.update_job(
transcript=str(transcript), draft=str(draft)) job_id, "review", f"整理稿已生成,{questions} 个问题待确认",
transcript=str(transcript), draft=str(draft),
)
target = PROCESSED / source.name target = PROCESSED / source.name
if target.exists(): if target.exists():
target = PROCESSED / f"{source.stem}-{job_id[:8]}{source.suffix}" target = PROCESSED / f"{source.stem}-{job_id[:8]}{source.suffix}"
@@ -110,8 +124,11 @@ def claim(path: Path) -> tuple[Path, dict]:
job = db.find_by_stored(path.name) job = db.find_by_stored(path.name)
if not job: if not job:
job_id = uuid.uuid4().hex[:24] job_id = uuid.uuid4().hex[:24]
job = db.create_job(job_id, path.name, path.name, title=path.stem, job = db.create_job(
size=path.stat().st_size, source="ssh") job_id, path.name, path.name, title=path.stem,
size=path.stat().st_size, source="ssh",
asr_provider=DEFAULT_PROVIDER,
)
target = PROCESSING / path.name target = PROCESSING / path.name
if target.exists(): if target.exists():
target = PROCESSING / f"{path.stem}-{job['id'][:8]}{path.suffix}" target = PROCESSING / f"{path.stem}-{job['id'][:8]}{path.suffix}"
@@ -132,10 +149,12 @@ def scan_inbox() -> None:
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 retry = attempts < MAX_ATTEMPTS
db.update_job(job["id"], "queued" if retry else "failed", db.update_job(
"处理失败,等待重试" if retry else "处理失败", job["id"], "queued" if retry else "failed",
error_code=type(exc).__name__) f"处理失败,等待重试 ({exc})" if retry else f"处理失败:{exc}",
log(f"job={job['id']} failed type={type(exc).__name__}") error_code=type(exc).__name__,
)
log(f"job={job['id']} failed type={type(exc).__name__} 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)
@@ -151,27 +170,41 @@ def process_revisions() -> None:
revise.revise(draft) revise.revise(draft)
current = db.get_job(job_id) or {} current = db.get_job(job_id) or {}
if current.get("status") == "complete": if current.get("status") == "complete":
send("ASR 正式记录已生成", current.get("title") or current.get("original_name", ""), send("ASR 正式记录已生成",
current.get("title") or current.get("original_name", ""),
f"{PUBLIC_URL}/jobs/{job_id}/") f"{PUBLIC_URL}/jobs/{job_id}/")
elif current.get("status") != "review": elif current.get("status") != "review":
db.update_job(job_id, "review", "定稿预览已生成,等待发布确认", draft=str(draft)) db.update_job(
job_id, "review", "定稿预览已生成,等待发布确认",
draft=str(draft),
)
marker.unlink(missing_ok=True) marker.unlink(missing_ok=True)
except Exception as exc: except Exception as exc:
log(f"revision marker={marker.name} failed type={type(exc).__name__}") log(f"revision marker={marker.name} failed type={type(exc).__name__} err={exc}")
def recover() -> None: def recover() -> None:
for job in db.pending_recovery(): for job in db.pending_recovery():
if job["status"] in {"transcribing", "refining"}: if job["status"] in {"transcribing", "refining"}:
candidate = next((p for p in PROCESSING.iterdir() if p.name == job["stored_name"]), None) candidate = next(
(p for p in PROCESSING.iterdir() if p.name == job["stored_name"]),
None,
)
if candidate: if candidate:
shutil.move(str(candidate), INBOX / candidate.name) shutil.move(str(candidate), INBOX / candidate.name)
db.update_job(job["id"], "queued", "worker 重启后重新排队") db.update_job(job["id"], "queued", "worker 重启后重新排队")
def sync_vault_knowledge() -> None:
try:
lexicon.sync_from_vault()
graph.sync()
except Exception as exc:
log(f"vault knowledge sync skipped: {exc}")
def backup_databases() -> None: def backup_databases() -> None:
# STATE is local disk; durable copies belong on the NFS data volume. backup_dir = DATA / "backups"
backup_dir = Path("/data/backups")
backup_dir.mkdir(parents=True, exist_ok=True) backup_dir.mkdir(parents=True, exist_ok=True)
for name in ("intake.db", "lexicon.db"): for name in ("intake.db", "lexicon.db"):
source = STATE / name source = STATE / name
@@ -184,22 +217,34 @@ def backup_databases() -> None:
temp.replace(target) temp.replace(target)
def main() -> None: def run_loop() -> None:
os.environ.setdefault("ASR_ROLE", "worker")
os.environ.setdefault("ASR_REVISION_QUEUE_DIR", str(REVISION_QUEUE))
ensure_dirs() ensure_dirs()
lock = (STATE / "worker.lock").open("w") lock = (STATE / "worker.lock").open("w")
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
recover() recover()
sync_vault_knowledge()
(STATE / "worker.heartbeat").write_text(str(time.time())) (STATE / "worker.heartbeat").write_text(str(time.time()))
last_backup = 0.0 last_backup = 0.0
last_vault_sync = time.time()
while True: while True:
scan_inbox() scan_inbox()
process_revisions() process_revisions()
if time.time() - last_backup >= 3600: now = time.time()
if now - last_vault_sync >= 900:
sync_vault_knowledge()
last_vault_sync = now
if now - last_backup >= 3600:
backup_databases() backup_databases()
last_backup = time.time() last_backup = now
(STATE / "worker.heartbeat").write_text(str(time.time())) (STATE / "worker.heartbeat").write_text(str(now))
time.sleep(POLL_SECONDS) time.sleep(POLL_SECONDS)
def main() -> None:
run_loop()
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+19 -6
View File
@@ -1,11 +1,24 @@
x-asr-environment: &asr-environment x-asr-environment: &asr-environment
ZENMUX_API_KEY: ${ZENMUX_API_KEY:-} ASR_PROVIDER: ${ASR_PROVIDER:-qwen3cam}
ASR_DRAFT_MODEL: ${ASR_DRAFT_MODEL:-anthropic/claude-sonnet-5.5}
ASR_AGENT_TOKEN: ${ASR_AGENT_TOKEN:-}
ASR_PUBLIC_URL: ${ASR_PUBLIC_URL:-http://192.168.50.49:8788}
ASR_DIARIZE_URL: ${ASR_DIARIZE_URL:-http://192.168.50.100:10099/asr} ASR_DIARIZE_URL: ${ASR_DIARIZE_URL:-http://192.168.50.100:10099/asr}
ASR_VOICEPRINT_ENROLL_URL: ${ASR_VOICEPRINT_ENROLL_URL:-http://192.168.50.100:10099/voiceprints/enroll}
LITELLM_BASE_URL: ${LITELLM_BASE_URL:-http://192.168.50.49:4000/v1}
LITELLM_API_KEY: ${LITELLM_API_KEY:-}
ASR_GEMINI_MODEL: ${ASR_GEMINI_MODEL:-aistudio/gemini-3.5-transcribe}
ASR_OPENAI_MODEL: ${ASR_OPENAI_MODEL:-openai/gpt-4o-transcribe-diarize}
SONIOX_API_KEY: ${SONIOX_API_KEY:-}
SONIOX_MODEL: ${SONIOX_MODEL:-stt-async-v5}
XAI_API_KEY: ${XAI_API_KEY:-}
ASR_GROK_MODEL: ${ASR_GROK_MODEL:-grok-voice-transcribe-2.0}
ASR_CLEAN_MODEL: ${ASR_CLEAN_MODEL:-aistudio/gemini-flash-lite-latest}
ASR_KEYWORD_MODEL: ${ASR_KEYWORD_MODEL:-aistudio/gemini-flash-lite-latest}
ASR_DRAFT_MODEL: ${ASR_DRAFT_MODEL:-codex/gpt-sol-latest}
ASR_AGENT_TOKEN: ${ASR_AGENT_TOKEN:-}
ASR_WEB_PASSWORD: ${ASR_WEB_PASSWORD:-}
ASR_PUBLIC_URL: ${ASR_PUBLIC_URL:-http://192.168.50.49:8788}
HINDSIGHT_API: ${HINDSIGHT_API:-http://192.168.50.47:8888} HINDSIGHT_API: ${HINDSIGHT_API:-http://192.168.50.47:8888}
HINDSIGHT_BANK: ${HINDSIGHT_BANK:-personal-context} HINDSIGHT_BANK: ${HINDSIGHT_BANK:-personal-context}
ASR_GIT_SYNC: ${ASR_GIT_SYNC:-0}
ASR_NOTIFY_BACKEND: ${ASR_NOTIFY_BACKEND:-none} ASR_NOTIFY_BACKEND: ${ASR_NOTIFY_BACKEND:-none}
HERMES_WEBHOOK_URL: ${HERMES_WEBHOOK_URL:-} HERMES_WEBHOOK_URL: ${HERMES_WEBHOOK_URL:-}
HERMES_WEBHOOK_SECRET: ${HERMES_WEBHOOK_SECRET:-} HERMES_WEBHOOK_SECRET: ${HERMES_WEBHOOK_SECRET:-}
@@ -14,7 +27,7 @@ x-asr-environment: &asr-environment
services: services:
asr-web: asr-web:
image: tools-asr-pipeline:2026-09-18 image: tools-asr-pipeline:2026-11-02
pull_policy: never pull_policy: never
build: build:
context: . context: .
@@ -39,7 +52,7 @@ services:
- no-new-privileges:true - no-new-privileges:true
asr-worker: asr-worker:
image: tools-asr-pipeline:2026-09-18 image: tools-asr-pipeline:2026-11-02
pull_policy: never pull_policy: never
container_name: tools-asr-worker container_name: tools-asr-worker
restart: unless-stopped restart: unless-stopped
@@ -1,3 +1,3 @@
pypinyin==0.55.0 pypinyin>=0.51,<1
rapidfuzz==3.14.1 rapidfuzz>=3,<4
numpy==2.3.3 numpy>=1.26,<3
@@ -1,51 +1,197 @@
from __future__ import annotations from __future__ import annotations
import importlib
import json import json
import os import os
import tempfile import tempfile
import threading
import unittest import unittest
import urllib.request
from http.server import ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from unittest import mock
TMP = tempfile.TemporaryDirectory()
os.environ["ASR_DATA_DIR"] = str(Path(TMP.name) / "data")
os.environ["ASR_STATE_DIR"] = str(Path(TMP.name) / "state")
os.environ["ASR_VAULT"] = str(Path(TMP.name) / "vault")
os.environ["ASR_RECORDINGS_ROOT"] = str(Path(TMP.name) / "recordings")
os.environ["ASR_AGENT_TOKEN"] = "test-agent-token"
os.environ["ASR_NOTIFY"] = "0"
os.environ["ASR_GIT_SYNC"] = "0"
from asrpipe import lexicon, providers, review, segment # noqa: E402
from asrservice import config as svc_cfg, db as svc_db, web as svc_web # noqa: E402
class ServiceTests(unittest.TestCase): class ProviderAndServiceTests(unittest.TestCase):
@classmethod def setUp(self):
def setUpClass(cls): self.tmp = tempfile.TemporaryDirectory()
cls.tmp = tempfile.TemporaryDirectory() self.root = Path(self.tmp.name)
os.environ["ASR_DATA_DIR"] = str(Path(cls.tmp.name) / "data") svc_cfg.ensure_dirs()
os.environ["ASR_STATE_DIR"] = str(Path(cls.tmp.name) / "state") svc_db.connect().close()
from asrservice import config, db, worker
cls.config, cls.db, cls.worker = config, db, worker
@classmethod def tearDown(self):
def tearDownClass(cls): self.tmp.cleanup()
cls.tmp.cleanup()
def test_job_events_follow_updates(self): def test_soniox_token_diarization_and_segment_parse(self):
row = self.db.create_job("a" * 24, "x.m4a", "x.m4a", source="ssh") payload = {
self.assertEqual(row["status"], "queued") "tokens": [
self.db.update_job(row["id"], "transcribing", "started", attempts=1) {"text": "大家", "speaker": "1"},
current = self.db.get_job(row["id"]) {"text": "好,", "speaker": "1"},
self.assertEqual(current["attempts"], 1) {"text": "今天", "speaker": "1"},
self.assertEqual([e["status"] for e in self.db.events(row["id"])], {"text": "讨论", "speaker": "1"},
["queued", "transcribing"]) {"text": "预算。", "speaker": "1"},
{"text": "收到,", "speaker": "2"},
{"text": "我来", "speaker": "2"},
{"text": "汇报。", "speaker": "2"},
]
}
text = providers.transcript_from_soniox_response(payload, model="stt-async-v5")
parsed_turns, meta = segment.parse(text)
self.assertEqual(meta["asr_provider"], "soniox")
self.assertEqual(meta["asr_model"], "stt-async-v5")
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, "收到,我来汇报。")
def test_transcript_contract(self): def test_soniox_context_includes_lexicon_and_intake(self):
text = self.worker.transcript_from_response({ lexicon.learn_alias("lite网关", "LiteLLM", note="test")
ctx = providers.build_soniox_context(
title="网关评审",
user_context="讨论 LiteLLM 与 Soniox 集成",
)
self.assertIn("terms", ctx)
self.assertIn("LiteLLM", ctx["terms"])
self.assertIn("讨论 LiteLLM", ctx.get("text", ""))
def test_openai_and_grok_diarized_transcriptions(self):
audio = self.root / "sample.mp3"
audio.write_bytes(b"ID3-fake-audio")
def fake_openai_multipart(url, *, fields, file_field, file_path, headers=None, timeout=900):
return {
"segments": [ "segments": [
{"speaker": "speaker_0", "text": "你好。"}, {"speaker": "A", "text": "第一句发言。"},
{"speaker": "speaker_0", "text": "继续。"}, {"speaker": "A", "text": "继续补充。"},
{"speaker": "speaker_1", "text": "收到。"}, {"speaker": "B", "text": "第二位发言。"},
], ]
"voiceprint": {"session_id": "s1", "matches": []}, }
})
self.assertIn("voiceprint_session: s1", text)
self.assertIn("【speaker_0】你好。继续。", text)
self.assertEqual(text.count("【speaker_0】"), 1)
def test_transcript_rejects_empty_segments(self): with mock.patch("asrpipe.providers._http_multipart", side_effect=fake_openai_multipart), \
with self.assertRaises(RuntimeError): mock.patch("asrpipe.providers.probe_duration_seconds", return_value=30), \
self.worker.transcript_from_response({"segments": []}) mock.patch.object(providers.CFG, "api_key", return_value="sk-test"):
res = providers.transcribe_openai(
audio,
title="OpenAI 测试",
model="openai/gpt-4o-transcribe-diarize",
)
turns, meta = segment.parse(res.transcript_text)
self.assertEqual(meta["asr_provider"], "openai")
self.assertEqual(len(turns), 2)
self.assertIn("第一句发言。继续补充。", turns[0].text)
self.assertEqual(turns[1].speaker, "speaker_1")
def fake_grok_multipart(url, *, fields, file_field, file_path, headers=None, timeout=900):
return {
"utterances": [
{"speaker": 0, "text": "Grok 说话人零。"},
{"speaker": 1, "text": "Grok 说话人一。"},
]
}
with mock.patch("asrpipe.providers._http_multipart", side_effect=fake_grok_multipart), \
mock.patch("asrpipe.providers.probe_duration_seconds", return_value=30), \
mock.patch.object(providers.CFG, "grok_api_key", "xai-test"):
gres = providers.transcribe_grok(
audio,
title="Grok 测试",
)
gturns, gmeta = segment.parse(gres.transcript_text)
self.assertEqual(gmeta["asr_provider"], "grok")
self.assertEqual(len(gturns), 2)
self.assertEqual(gturns[0].speaker, "speaker_0")
self.assertEqual(gturns[1].speaker, "speaker_1")
def test_online_speaker_confirmation_updates_draft_without_voiceprint_server(self):
drafts_dir = review.CFG.drafts
drafts_dir.mkdir(parents=True, exist_ok=True)
draft = drafts_dir / "online-draft.md"
draft.write_text(
"---\n"
"title: 线上模型会议\n"
"status: draft\n"
"asr_provider: soniox\n"
"voiceprint_session: ''\n"
"voiceprint_speakers: {}\n"
"---\n\n"
"【speaker_1】发言内容\n",
encoding="utf-8",
)
with mock.patch.object(review.CFG, "voiceprint_enroll_url", "http://127.0.0.1:9/unreachable"):
enrolled = review._confirm_voiceprint(str(draft), "speaker_1", "张三")
self.assertFalse(enrolled)
updated = draft.read_text(encoding="utf-8")
self.assertIn("speaker_1: 张三", updated)
def test_db_and_web_endpoints(self):
job_id = "0123456789abcdef01234567"
job = svc_db.create_job(
job_id=job_id,
original_name="meeting.m4a",
stored_name=f"20261102-{job_id}.m4a",
title="架构周会",
context="验证 Web API",
size=2048,
asr_provider="soniox",
asr_model="stt-async-v5",
)
self.assertEqual(job["asr_provider"], "soniox")
self.assertEqual(job["asr_model"], "stt-async-v5")
svc_web.AGENT_TOKEN = "test-agent-token"
server = ThreadingHTTPServer(("127.0.0.1", 0), svc_web.Handler)
port = server.server_address[1]
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
def fetch(path: str, headers: dict[str, str] | None = None) -> tuple[int, str]:
req = urllib.request.Request(
f"http://127.0.0.1:{port}{path}",
headers=headers or {},
)
with urllib.request.urlopen(req, timeout=5) as resp:
return resp.status, resp.read().decode("utf-8")
status, body = fetch("/health")
self.assertEqual(status, 200)
self.assertEqual(json.loads(body)["ok"], True)
status, body = fetch(
"/api/v1/providers",
headers={"Authorization": "Bearer test-agent-token"},
)
self.assertEqual(status, 200)
prov_data = json.loads(body)
prov_ids = [p["id"] for p in prov_data["providers"]]
self.assertEqual(prov_ids, ["qwen3cam", "soniox", "gemini", "openai", "grok"])
status, body = fetch(f"/jobs/{job_id}/")
self.assertEqual(status, 200)
self.assertIn("架构周会", body)
self.assertIn("soniox", body)
status, body = fetch("/lexicon")
self.assertEqual(status, 200)
self.assertIn("词库", body)
status, body = fetch("/settings")
self.assertEqual(status, 200)
self.assertIn("LiteLLM", body)
finally:
server.shutdown()
server.server_close()
if __name__ == "__main__": if __name__ == "__main__":
+23
View File
@@ -0,0 +1,23 @@
# Byparr
Private LAN browser-fetch service on `tools-pve` for pages that reject ordinary
HTTP clients.
- API docs: `http://192.168.50.49:8191/docs`
- FlareSolverr-compatible endpoint: `POST http://192.168.50.49:8191/v1`
- Authentication: none; the port is bound only to the host LAN address
- Persistent state: none
Example:
```bash
curl -sS http://192.168.50.49:8191/v1 \
-H 'Content-Type: application/json' \
-d '{"cmd":"request.get","url":"https://example.com","maxTimeout":120000}'
```
The upstream image is pinned to an immutable `linux/amd64` digest. Browser
scratch data and cache are RAM-backed because Byparr performs frequent writes.
The local TCP healthcheck deliberately avoids Byparr's `/health` endpoint,
which opens an external page on every check.
+27
View File
@@ -0,0 +1,27 @@
services:
byparr:
image: ghcr.io/thephaseless/byparr@sha256:51a629fa3dcde0cee1a780922e1a572c4a5be0b5a0cb825db73d4e63059275ff
container_name: tools-byparr
restart: unless-stopped
shm_size: 1gb
stdin_open: true
tty: true
environment:
LOG_LEVEL: INFO
BROWSER_LOCALE: en-US
BROWSER_TIMEZONE: America/Los_Angeles
ports:
- "192.168.50.49:8191:8191"
tmpfs:
- /tmp:exec,mode=1777,size=1G
- /cache:exec,mode=1777,size=2G
healthcheck:
test:
- CMD
- /app/.venv/bin/python
- -c
- import socket; s=socket.create_connection(('127.0.0.1',8191),5); s.close()
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
+14
View File
@@ -0,0 +1,14 @@
LISTEN_ADDRESS=192.168.50.49
LITELLM_PORT=4000
POSTGRES_PASSWORD=
LITELLM_MASTER_KEY=
LITELLM_SALT_KEY=
ZENMUX_SUBSCRIPTION_API_KEY=
ZENMUX_PAYG_API_KEY=
OPENROUTER_API_KEY=
BAILIAN_CN_API_KEY=
BAILIAN_CN_API_BASE=https://ws-v68kvjnmhkiejaxo.cn-beijing.maas.aliyuncs.com/api/v1
GOOGLE_AI_STUDIO_API_KEY=
VERTEXAI_ENABLED=false
VERTEXAI_PROJECT=
VERTEXAI_LOCATION=global
+3
View File
@@ -0,0 +1,3 @@
__pycache__/
*.pyc
.env
+312
View File
@@ -0,0 +1,312 @@
# tools-pve 统一模型代理
## OpenAI 官方 API 与订阅回退(2026-10-29)
官方 API key 由用户生成,经过明确授权,仅加入本 tools-pve 项目的 Arcane 私有
`OPENAI_API_KEY`。不写入 Git、不复制到 HK、不替换客户端 virtual key 或 OAuth。
官方上游固定为 `https://api.openai.com/v1`,不能因 LiteLLM provider 同为 openai
就误归入 ZenMux。未配置 key 时不生成这些路由和 fallback。
| 首选订阅路由 | 官方 API 备用路由 | 当前实际模型 |
| --- | --- | --- |
| `codex/gpt-luna-latest` | `openai/gpt-luna-latest` | `gpt-6-luna` |
| `codex/gpt-sol-latest` | `openai/gpt-sol-latest` | `gpt-6.1-sol` |
| `codex/gpt-astra-latest` | `openai/gpt-astra-latest` | `gpt-6-astra` |
| `codex/gpt-6-sol` | `openai/gpt-6-sol` | `gpt-6-sol` |
| `codex/gpt-5.5` | `openai/gpt-5.5` | `gpt-5.5` |
原不带provider的GPT兼容别名也保留同型号fallback。renderer从chatgpt表生成
对应的API别名,因此既有latest维护更新后,两侧不会各自残留不同型号。
`openai/...`显式路由只走官方API,不回到订阅,不改变Grok/Claude/Google/开放模型的主备。
订阅429、认证或暂时故障可以回退;不自动跨型号降级,失败冷却仍为60秒。
跨后端不共享prompt cache,也不承诺中途流断线或previous_response_id跨后端续接。
需要保留完整上下文以便调用方重试,不自动重放已经执行的工具。
API专有补充:`openai/gpt-terra-latest`(目前6.1 Terra);
`openai/gpt-transcribe`、`openai/gpt-4o-transcribe`、
`openai/gpt-4o-mini-transcribe`、`openai/gpt-4o-transcribe-diarize`;
`openai/text-embedding-3-small`、`openai/text-embedding-3-large`;
`openai/gpt-4o-mini-tts`;`openai/gpt-image-2.5-flare`、
`openai/gpt-image-2.5-sunburst`。图像路线可配置不代表做过昂贵出图验收,
各接口按实际测试结果记录。没有修改现有ASR热词、Hindsight embedding或客户端默认模型。
Terra与其他API专有模型暂按models.yaml显式维护,不擅自扩张既有Hermes自动维护授权。
现有lan-agents应用key增补这些API路线;Hindsight等受限key只增补其原先已允许的
GPT路线对应备用模型,不开放图像、语音等无关能力。Claude专用key不变。
赠金需绑定到此key所属API组织,并在该订阅账期末到期,不结转。赠金额度先消耗,
余额不足后若有已购额度可继续消费;没有任何额度则返回429 credit_balance_exhausted。
代理不能凭普通project key保证“只花赠金”,不替用户开启充值。这里提供的是
标准API请求的主备,不把官方赠金解释为可给Codex CLI的API-key模式增加额度。
依据:[OpenAI赠金说明](https://developers.openai.com/api/docs/guides/chatgpt-api-credits)、
[LiteLLM回退](https://docs.litellm.ai/docs/proxy/reliability)。
本轮验收:18项配置测试通过;官方Luna/Sol/Terra的Responses、Luna Chat、
Codex Luna Responses完整SSE通过(正常请求fallbacks=0);embedding small、
TTS与合成语音的gpt-transcribe/mini-transcribe通过。图像未实际生成,
diarize与embedding large只配置且模型目录可见,不能据此声称完成任务验收。
`verify_openai_fallback.py`在独立容器exec进程中对Chat与Responses各注入
429/401/503,六例均收到真实官方API的短回复;不是人为耗尽订阅,也不修改
生产worker的函数或真实OAuth token。复测命令(会有少量API用量):
```bash
ssh tools@192.168.50.49 'sudo -n docker exec -i tools-litellm python -' < verify_openai_fallback.py
```
Arcane Sync及Up终态success;PostgreSQL启动时间仍是
`2026-10-12T05:46:12.472887407Z`,数据库及OAuth卷未重建。
## 部署状态
2026-10-09:已通过 Arcane 上线,LiteLLM 1.104.2 与 PostgreSQL 均 healthy。
UI HTTP 200;应用 virtual key 的 OpenRouter MiMo 请求返回 OK。
ZenMux Sonnet 5.5 原生 Messages 正常;重复请求证实写入与读取
21,502 prompt-cache tokens。Codex/xAI 已独立授权,SDK 原生请求验证
Luna、Sol 6.1 和 Grok 4.7 返回 OK。用户更新后的 PAYG key 原生 Claude 请求 HTTP 200。
Google AI Studio key 模型目录 HTTP 200,使用上游提供的三个 latest 别名。
17 条路线已加载;原客户端 key 权限已更新,Luna/Sol 的 gateway Responses SSE、
Luna 非流式 Chat Completions、Grok/Gemini 三条 Chat 路线和 PAYG Messages 均实测 OK。
本项目对其他模型的自动 latest 维护尚未实现。
- Environment:`ff859844-8393-4f87-8caf-054df735a65d`。
- Project:`06052b47-f640-4e23-968d-bfc25a8a6e7a`,名称 `tools-litellm`。
- Git Sync:`49148e73-65ca-43a6-a859-efc9cc750b93`;Auto Sync 关闭。
- 宿主机项目目录:`/var/lib/docker/volumes/arcane-agent_agent-data/_data/projects/litellm`。
- 管理凭据:.145 的 `/home/claw/codex-workspace/server-management/.secrets/tools-litellm.env`。
- 普通客户端配置:同目录 `tools-litellm-client.env`;两者均为 0600,不提交 Git。
## 架构与入口
部署在 `192.168.50.49`,LiteLLM 与独立 PostgreSQL 17。
- OpenAI 兼容客户端:`http://192.168.50.49:4000/v1`。
- Anthropic SDK:`http://192.168.50.49:4000`,请求 `/v1/messages`。
- 管理界面:`http://192.168.50.49:4000/ui`。
- PostgreSQL 不发布主机端口。
- named volume:`tools-litellm-database`、`tools-litellm-oauth`。
只使用单个代理 worker,降低 OAuth refresh token 竞争的风险。
所有配置从 Git 生成,不允许 UI 中的模型配置成为另一份权威。
未提供 key 或未完成 OAuth 登录的路线不会进入运行配置。
OAuth 判定检查 auth.json 中 access_token,不仅检查文件存在性:
Codex 设备授权期间会先写入冷却占位文件,这不代表已经授权。
## Provider 与缓存
ZenMux Claude 使用 Anthropic Messages 上游:
`https://zenmux.ai/api/anthropic`。
LiteLLM 的 `anthropic/anthropic/claude-sonnet-5.5` 第一个前缀是
LiteLLM provider,第二个前缀是 ZenMux 的模型 ID,不是笔误。
订阅路线为 `claude-sonnet-latest`、`claude-opus-latest`、
`claude-haiku-latest`;PAYG 路线在同名末尾加 `-payg`。
不自动由订阅切换到 PAYG,也不设置跨模型 fallback。
Claude 客户端优先使用原生 `/v1/messages`,保留 `cache_control`、
稳定的 tools/system 前缀以及原样回传的 thinking/signature。
不能保证每次 cache hit;首次创建、最小缓存长度、TTL 和上游路由都会影响结果。
上线验收需重复发送足够长且完全相同的前缀,检查
`cache_creation_input_tokens` 和 `cache_read_input_tokens`,不能只看 HTTP 200。
不把 LiteLLM 的响应缓存当作 Anthropic prompt cache。
实测 ZenMux Sonnet 5.5 会拒绝强制 `tool_choice.type=tool/any`,
错误由上游返回;使用支持的 `auto` 模式,不在代理中静默改写调用者的要求。
开放模型已改为 ZenMux 订阅优先、OpenRouter 回退;`models.yaml` 来自实际 provider 目录。
除 Google 自带 latest 映射由其上游维护外,其他 `latest` 都是本项目手动维护的稳定别名。
Google AI Studio 使用独立 key 与模型表,已实际查询模型目录,
不把 Google OAuth/ADC 当成 AI Studio API key。
Google 三条模型为 gemini-flash-latest、gemini-flash-lite-latest、gemini-pro-latest。
用户新 provider key 的权威位置是 Arcane tools-litellm Project `.env`;
不要用 .145 首次生成的 tools-litellm.env 整体覆盖它,那份文件可能还含旧 provider 值。
该本机文件主要用于 master key 管理与数据库凭据恢复;提供商配置必须先从 Arcane 实查。
## 开源模型主备路由(2026-10-12)
| 客户端稳定别名 | 两侧具体模型 |
| --- | --- |
| `deepseek-flash-latest` | `deepseek/deepseek-v4.1-flash` |
| `deepseek-pro-latest` | `deepseek/deepseek-v4-pro` |
| `glm-latest` | `z-ai/glm-5.3-prime` |
| `kimi-latest` | `moonshotai/kimi-k3` |
| `mimo-flash-latest` | `xiaomi/mimo-v2.6-flash` |
这些原有别名先调用 ZenMux `/api/v1`,失败后由 Router 转向同名末尾加
`-openrouter` 的备用组;不是随机分流,不切到 PAYG,也不改变 Claude/OAuth。
DeepSeek Pro 原 OpenRouter 日期版 `v4-pro-0813` 改为双方共有的 `v4-pro`,
两侧返回模型 ID 已核验一致,但不能仅据 ID 保证供应商权重实现完全相同。
只配置现有五组,不把整个供应商目录自动开放。
缺 ZenMux key 时原别名仍直接走 OpenRouter;缺 OpenRouter key 时不生成悬空
fallback。普通 virtual key 需同时允许主组与备用组,现有 lan-agents key
通过 `/key/update` 增补五个备用别名,无需更换 key。
部署所用 SDK 的回环 mock 已验证 402、403、429、500 自动回退。
显式部署级异常策略让 429/500 在第一次失败后冷却 60 秒;402/403 仍会在
下一请求尝试主组,不宣称已按订阅窗口熔断。Router 默认最多一次重试,
不是耗尽后无限等待。两家共享订阅额度的跨模型熔断尚未实现。
请求开始前的失败已覆盖;流式输出中途断开不承诺无缝拼接或恰好一次执行工具。
ZenMux 恢复后重新优先使用它。OpenRouter 会产生其账户费用;缓存不跨
供应商共享。上游工具/推理参数兼容性仍按调用者实际用法验收,不能只凭
短文本成功宣称所有 Agent 工作流均兼容。
参考:[LiteLLM Fallbacks](https://docs.litellm.ai/docs/proxy/reliability)。
## Hermes 两个 profile 的集中接入
Hermes 使用三种协议入口:Claude 经 Anthropic Messages,Gemini/Grok/开源模型
经 Chat Completions,Codex 订阅经 Responses。不要为了单一 URL 把所有模型强行
桥接成同一种协议;`/v1/responses` 和 `/v1/messages` 均由同一代理提供。
补充路线 `gemini-flash-zenmux` 保留 Hermes 原 ZenMux Gemini 3.8 Flash 数据去向,
不自动换到 Google ADC。`gpt-work-sol`、`gpt-review-5.5`、`gpt-astra-latest` 分别
保留原 gpt-6-sol、gpt-5.5、gpt-6-astra;三者均在独立 Codex OAuth 实时目录中。
它们是兼容现有分工,不以过期型号或“最新总更好”为由静默更换。
`glm-flash-latest` 补齐原选择器中的 GLM 5.3 Flash,并使用同一 ZenMux/OpenRouter 主备模式。
用户同日确认的最终策略:default 用 `grok-latest`,work 用 `gpt-sol-latest`;
辅助全部用 `gpt-luna-latest`(一般 low、任务拆解 medium),审阅及后台审阅用
`gpt-sol-latest` high。当前分别映射 Grok 4.7、GPT-6 Luna、GPT-6.1 Sol。
原 Haiku/Flash/GPT-5.5 分工为迁移阶段,不再作为 Hermes 的当前策略。
旧兼容路线留给历史客户端,Hermes GPT 选择器只列 Luna/Sol/Astra latest。
MoA 仅按需启动,参考模型为 Sonnet 5.5/Grok/Luna,Astra 聚合。
work 的旧 Sonnet 5 参考槽统一到已有 Sonnet 5.5 路线。旧直连 provider 只保留
回滚兼容,新默认与任务槽不再引用;已打开 session 的 provider/model 覆盖不自动改写。
## 密钥
真实 `.env` 权限 `0600`,不提交 Git;模板见 `.env.example`。
ZenMux subscription/PAYG 的 key 需要明确确认用途,不能按外观推断。
数据库密码使用无特殊 URL 字符的随机值,避免 `DATABASE_URL` 编码问题。
`LITELLM_MASTER_KEY` 以 `sk-` 开头,salt 独立随机生成。
UI 使用数据库管理员 `kai@litellm.local`(只是登录标识,不发送邮件),
设置 `disable_env_credential_login: true`,禁用 UI_PASSWORD 以及 master key 的网页登录。
用户已修改首次临时密码,临时文件已删除。数据库保存密码哈希,
用户的新密码不写入环境变量,也不由 Agent 保存。
master key 仍能管理 API,用于恢复权限,不再充当网页登录密码。
不能直接用这份配置初始化空库后期待默认 admin 登录:必须预先通过
`/user/new` 建立管理员,再 `/user/update` 设置临时密码,随后启用限制。
日常客户端应使用管理界面创建的应用专属 virtual key,不共享 master key。
不要开启输出请求全文的 debug 日志。
## 独立 OAuth 登录
不复制 Codex CLI、Grok CLI 或 Hermes 的 refresh token。
在目标主机项目目录执行:
```bash
bash oauth-login.sh codex
```
打开终端显示的设备验证网址并输入验证码。
xAI 需要本机浏览器回调。先在浏览器所在电脑保持 SSH 隧道:
```bash
ssh -N -L 56121:127.0.0.1:56121 tools@192.168.50.49
```
然后在 tools-pve 的另一个终端运行:
```bash
bash oauth-login.sh xai
```
打开输出的授权 URL;若 56121 被占用,先关闭占用者再重试,
不要把临时改用的端口当作已有隧道可达。
登录 helper 使用 host network 仅供本地 OAuth 回调,生产代理仍使用独立 bridge。
确认账号实际支持的模型 ID 后,在 `models.yaml` 的 `chatgpt`/`xai` 表添加别名。
OAuth 映射为 gpt-luna-latest -> gpt-6-luna、gpt-sol-latest -> gpt-6.1-sol、
grok-latest -> grok-4.7,独立账号目录与 SDK 请求均已验证。
Codex 路线由 `chatgpt/` 访问,优先 `/v1/responses`;
原生 Responses 使用消息列表形式的 input 和 stream=true,按 SSE 读取到 response.completed。
常规 OpenAI SDK 客户端也可用 /v1/chat/completions,Luna 非流式桥接已实测 OK。
实测直接给字符串 input 会被 Codex backend 拒绝;不要把已授权误写成协议完全兼容。
xAI 路线使用 `xai/` 与 `use_xai_oauth: true`,不能设置 XAI API key 覆盖它。
新增 token 后需重启代理,运行时 refresh 会写回同一个持久卷。
## 部署及验收
正常走 Arcane Git Sync;Manager 已恢复,本项目已通过控制面部署。
如用户明确同意临时直接部署,将本目录放到 tools-pve,准备 `.env` 后执行:
```bash
docker compose config --quiet
docker compose up -d
docker compose ps
```
恢复 Arcane 后应纳管这套已有卷和项目,避免生成第二套数据库。
上线必须验证健康、带 virtual key 的模型列表、实际请求、stream 和工具调用;
OAuth 与 Claude cache hit 分别验收,未通过不能写成可用。
回滚只停止新增项目或回退配置/image pin,不删除 named volume。
定期 `pg_dump` 并保留独立备份,数据库卷不是备份。
参考:[LiteLLM ChatGPT OAuth](https://docs.litellm.ai/docs/providers/chatgpt)、
[xAI OAuth 官方源码](https://github.com/BerriAI/litellm/blob/main/litellm/llms/xai/oauth.py)、
[ZenMux prompt cache](https://zenmux.ai/docs/guide/advanced/prompt-cache.html)。
## Google Agent Platform 与检索模型
2026-10-09 用户确认 Pro 赠金已绑定结算账号后,接入项目
`gen-lang-client-0312436655`,区域 `global`。复用此前已授权的用户 ADC,
保存在既有 `tools-litellm-oauth` 卷的 `/tokens/google/adc.json`(0600),
不进 Git;容器通过 `GOOGLE_APPLICATION_CREDENTIALS` 读取并刷新凭据。
Arcane Project `.env` 设置 `VERTEXAI_ENABLED=true`、`VERTEXAI_PROJECT`、
`VERTEXAI_LOCATION`。默认关闭该路线,避免仅有项目名就自动发起付费调用。
| 对外路由 | 实际模型 | 验收 |
| --- | --- | --- |
| `gemini-flash-vertex` | `gemini-3.8-flash` | 原生及普通客户端 Chat 均成功 |
| `gemini-flash-lite-vertex` | `gemini-3.5-flash-lite` | 原生及普通客户端 Chat 均成功 |
| `gemini-pro-vertex` | `gemini-3.1-pro-preview` | 原生及普通客户端 Chat 均成功 |
| `gemini-embedding-2-studio` | AI Studio `gemini-embedding-2` | `/v1/embeddings` 两条输入返回两条 1024 维向量,index 正确 |
前三条不冒充 Google 厂商自动 latest 别名;保持与 AI Studio 既有路由分开。
Google 官方说明 AI Pro 月赠金可用于 Agent Platform,但本次没有从账单
实证某次请求扣的是赠金;兑换状态由用户确认。赠金耗尽后可能继续产生付费,
当前没有基于 Google 实时赠金余额的硬停机制,不设置自动跨 provider fallback。
AI Studio 当前账号目录含 `gemini-embedding-001`、`gemini-embedding-2-preview`、
`gemini-embedding-2`,没有独立 rerank 模型。优先用已经 GA 的 Embedding 2。
原生 Agent Platform `gemini-embedding-2:embedContent` 已实测返回 1024 维。
但 LiteLLM 1.104.2 的 `vertex_ai/gemini-embedding-2` 两条输入只返回一条向量,
故没有开放该代理路由供批量检索使用;不要把 API HTTP 200 当成批量验收。
Google rerank 是 Discovery Engine 的独立 Ranking API,不属于 AI Studio
embedding 接口。候选 `semantic-ranker-default@latest`、`semantic-ranker-fast@latest`;
005 为较新模型版本,官方页面 latest 指向说明与表格存在矛盾,不能仅凭别名
断言实际模型版本。当前项目两条路线均返回 403:Discovery Engine API 未启用。
尚未启用或纳入 LiteLLM,可到 Google Console 启用 `discoveryengine.googleapis.com`
后复测中文排序、权限、价格及延迟。当前 LiteLLM 已包含原生 Vertex rerank 适配。
Hindsight 0.10.1 当前生产仍用 unRaid TEI 的 `BAAI/bge-m3` embedding 和
`Alibaba-NLP/gte-multilingual-reranker-base` rerank,Cohere/rrf 是 fallback。
该版本原生 Google embedding provider 会把 Vertex Embedding 2 拆成逐条请求,
并验证输入与输出数量一致,因此后续可优先原生 Google ADC 路线,而非有问题
的 LiteLLM Vertex embedding 路线。不要直接改生产 provider:即使都选 1024 维,
BGE 与 Gemini 的向量空间不同,必须重嵌入并重建索引。rerank 切换不要求重建向量。
embedding 模型禁止使用自动追踪 latest 的别名,否则升级会混用旧向量。
参考:[Google Pro 赠金](https://developers.google.com/program/plans-and-pricing)、
[Gemini embedding](https://ai.google.dev/gemini-api/docs/embeddings)、
[Google Ranking API](https://docs.cloud.google.com/generative-ai-app-builder/docs/ranking)、
[Hindsight 配置](https://hindsight.vectorize.io/developer/configuration)。
# 个人语音输入热词路由(2026-10-25)
独立别名 `gemini-transcribe-personal` 使用 `gemini/gemini-3.5-transcribe`,经 Google AI Studio 凭据转发。客户端调用现有 `/v1/audio/transcriptions`,无需自行传词表。该别名附带 `personal_vocabulary: true`;启动适配器仅对明确标记的 Gemini 转写请求注入原生 `generation_config.transcription_config.custom_vocabulary` 和 `mode: smart`,普通路由不变。SMART 与 HK 保持相同的功能意图,但 AI Studio 使用小写字符串 `smart`,不是 Vertex 的大写 `SMART` 和 camelCase 字段。
当前个人词表仅包含 10 个人名、10 个公司名。按用户要求,从 Obsidian People/Companies 已确认卡片取规范名,以正式工作笔记的独立文档频次和 Hindsight 实体 mention_count 交叉排序;不加入产品、项目、文档标题、职务说明或错误别名。旧的 SQLite 100 词自动抽取已被替代,因为它把文档式长名称作为热词,出现了过度引导。
导出与私有频次审计使用 server-management 的 `scripts/export-entity-dictation-vocabulary.py`;具体名单与卡片证据不提交本配置仓库。私有文件仍为 tools-pve `/opt/litellm/personal-vocabulary.json`(0600)。每次请求读取文件;若通过原子替换文件更新,单文件 bind mount 需重启 LiteLLM 重新挂载。没有自动扩词任务,后续按实际误识别逐条复测。
语音输入使用 `response_format=json`,与 HK 的 Spokenly 设置一致;当前 text 请求虽 HTTP 200 仍返回 JSON 包装,不选用。不支持词级时间戳、字幕及说话人分离。Google 目前不允许 SMART/原生热词与说话人分离或词级时间戳组合。不要把这条路由用于需要 diarization 的会议流程。
LiteLLM 1.104.2 原生适配未映射热词/SMART,因此 `personal_transcription.py` 使用受路由开关约束的启动适配。升级 LiteLLM 后必须重跑 `scripts/test-personal-transcription-wire.py`,确认私有开关不泄漏上游、普通请求不变、Google 请求体中存在原生词表和 `mode: smart`。不要将 mock 验证等同于识别质量提升。
首次上线通过两次合成词 HTTP mock 验证,以及一秒合成静音的真实 Google HTTP 200 测试;未使用私人录音,未改会议配置。LAN 客户端 key 已单独增加本别名权限,其他受限 key 需按需授权。
同日启用 SMART 后,再次通过 mock 确认 native mode/词库和普通请求隔离;既有客户端 key 的静音及 Google 公开语音样本 JSON 转写均返回 200。只重启 tools-litellm,会议 worker/web 与 Postgres 启动时间未变。中文效果仍需真实使用评估。
@@ -0,0 +1,119 @@
"""Bridge OpenAI file uploads to Beijing Bailian's native ASR API."""
import base64
import io
import json
from pathlib import Path
from urllib.parse import urlsplit
import wave
from litellm.llms.base_llm.audio_transcription.transformation import AudioTranscriptionRequestData
from litellm.llms.openai.transcriptions.whisper_transformation import OpenAIWhisperAudioTranscriptionConfig
from litellm.litellm_core_utils.audio_utils.utils import process_audio_file
from litellm.types.utils import TranscriptionResponse, TranscriptionUsageDurationObject
MODEL = 'qwen-audio-3.1-asr-flash'
FORMATS = {'audio/wav':'wav','audio/x-wav':'wav','audio/wave':'wav',
'audio/mpeg':'mp3','audio/mp3':'mp3','audio/flac':'flac','audio/x-flac':'flac',
'audio/mp4':'m4a','audio/m4a':'m4a','audio/x-m4a':'m4a',
'audio/ogg':'ogg','audio/webm':'webm','video/webm':'webm','audio/aac':'aac'}
VOCABULARY_PATHS = frozenset({'/etc/litellm/personal-vocabulary.json',
'/credentials/personal-vocabulary.json'})
def vocabulary(path):
if path not in VOCABULARY_PATHS:
raise ValueError('Unexpected personal vocabulary path')
words = json.loads(Path(path).read_text())
if not isinstance(words,list) or not 1 <= len(words) <= 100:
raise ValueError('Personal vocabulary must contain 1-100 terms')
if any(not isinstance(w,str) or not w.strip() or len(w)>100 for w in words):
raise ValueError('Invalid personal vocabulary term')
return {w.strip():3 for w in words}
class BailianTranscriptionConfig(OpenAIWhisperAudioTranscriptionConfig):
@property
def has_native_transcription_endpoint(self):
return True
def get_supported_openai_params(self,model):
return ['language','response_format','prompt']
def map_openai_params(self,non_default_params,optional_params,model,drop_params):
result=super().map_openai_params(non_default_params,optional_params,model,drop_params)
if result.get('response_format') not in (None,'json','text'):
raise ValueError('Bailian dictation supports json/text without timestamps')
return result
def validate_environment(self,headers,model,messages,optional_params,litellm_params,
api_key=None,api_base=None):
if not api_key:raise ValueError('Beijing Bailian API key required')
return {**headers,'Authorization':'Bearer '+api_key,'Content-Type':'application/json',
'X-DashScope-SSE':'disable'}
def get_complete_url(self,api_base,api_key,model,optional_params,litellm_params,stream=None):
parsed=urlsplit(api_base or '')
if (model != MODEL or parsed.scheme != 'https' or
not (parsed.hostname or '').endswith('.cn-beijing.maas.aliyuncs.com') or
parsed.path.rstrip('/') != '/api/v1' or parsed.port not in (None,443) or
parsed.username or parsed.password or parsed.query or parsed.fragment):
raise ValueError('Bailian ASR restricted to Beijing workspace native endpoints')
if stream:raise ValueError('Streaming not enabled on this dictation route')
return api_base.rstrip('/')+'/services/aigc/multimodal-generation/generation'
def transform_audio_transcription_request(self,model,audio_file,optional_params,litellm_params):
if model != MODEL or optional_params.get('timestamp_granularities'):
raise ValueError('Unsupported Bailian dictation model or timestamps')
audio=process_audio_file(audio_file)
mime=audio.content_type.split(';',1)[0].lower()
if mime not in FORMATS:raise ValueError('Unsupported Bailian audio MIME type')
if len(audio.file_content)>10_000_000:raise ValueError('Bailian inline audio exceeds 10 MB')
parameters={'format':FORMATS[mime]}
if parameters['format']=='wav':
with wave.open(io.BytesIO(audio.file_content),'rb') as wav:
parameters['sample_rate']=str(wav.getframerate())
language=optional_params.get('language')
if language:parameters['language_hints']=[language]
# LiteLLM classifies DashScope as OpenAI-compatible and nests custom kwargs.
extra=optional_params.get('extra_body') or {}
path=optional_params.get('bailian_vocabulary_path') or extra.get('bailian_vocabulary_path')
if path:parameters['vocabulary']=vocabulary(path)
prompt=optional_params.get('prompt')
messages=[]
if prompt:
if not isinstance(prompt,str) or len(prompt)>400:
raise ValueError('Bailian context prompt must be at most 400 characters')
messages.append({'role':'user','content':[{'type':'input_text','text':prompt}]})
# Always preserve the original bytes; only WAV metadata supplies sample rate.
uri_mime='audio/mp3' if parameters['format']=='mp3' else mime
data_uri='data:'+uri_mime+';base64,'+base64.b64encode(audio.file_content).decode()
if len(data_uri)>10_000_000:
raise ValueError('Bailian inline audio exceeds 10 MB after Base64 encoding')
messages.append({'role':'user','content':[{'type':'input_audio','input_audio':{
'data':data_uri}}]})
return AudioTranscriptionRequestData(data={'model':MODEL,'input':{'messages':messages},
'parameters':parameters})
def transform_audio_transcription_response(self,raw_response):
body=raw_response.json()
text=body.get('output',{}).get('text',body.get('text'))
if not isinstance(text,str):raise ValueError('Bailian returned no transcript')
result=TranscriptionResponse(text=text)
duration=(body.get('usage') or {}).get('duration')
if duration is not None:
result.usage=TranscriptionUsageDurationObject(type='duration',seconds=duration)
result['duration']=duration
return result
def install():
import litellm
from litellm.utils import ProviderConfigManager
original=ProviderConfigManager.get_provider_audio_transcription_config
if getattr(original,'_bailian_asr_adapter',False):return
def select(model,provider):
if provider==litellm.LlmProviders.DASHSCOPE and model==MODEL:
return BailianTranscriptionConfig()
return original(model,provider)
select._bailian_asr_adapter=True
ProviderConfigManager.get_provider_audio_transcription_config=staticmethod(select)
+75
View File
@@ -0,0 +1,75 @@
name: tools-litellm
services:
postgres:
image: postgres:17-alpine@sha256:b0f9560a2de083e2cc7382e75f808c7381a32852a7ec49117deedb300e552b24
container_name: tools-litellm-postgres
restart: unless-stopped
environment:
POSTGRES_DB: litellm
POSTGRES_USER: litellm
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?required}
TZ: Asia/Shanghai
volumes:
- database:/var/lib/postgresql/data
healthcheck:
test: [CMD-SHELL, 'pg_isready -U litellm -d litellm']
interval: 10s
timeout: 5s
retries: 10
logging:
driver: json-file
options: {max-size: 10m, max-file: "3"}
litellm:
image: ghcr.io/berriai/litellm:main-stable@sha256:3d88bd757134a00e8c00237b85a021ab1f6d54abb9c1df750f411dbe7256a500
container_name: tools-litellm
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
ports:
- '${LISTEN_ADDRESS:-192.168.50.49}:${LITELLM_PORT:-4000}:4000'
environment:
TZ: Asia/Shanghai
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:-}
BAILIAN_CN_API_KEY: ${BAILIAN_CN_API_KEY:-}
BAILIAN_CN_API_BASE: ${BAILIAN_CN_API_BASE:-https://ws-v68kvjnmhkiejaxo.cn-beijing.maas.aliyuncs.com/api/v1}
GOOGLE_AI_STUDIO_API_KEY: ${GOOGLE_AI_STUDIO_API_KEY:-}
VERTEXAI_ENABLED: ${VERTEXAI_ENABLED:-false}
VERTEXAI_PROJECT: ${VERTEXAI_PROJECT:-}
VERTEXAI_LOCATION: ${VERTEXAI_LOCATION:-global}
GOOGLE_APPLICATION_CREDENTIALS: /tokens/google/adc.json
CHATGPT_TOKEN_DIR: /tokens/chatgpt
XAI_OAUTH_TOKEN_DIR: /tokens/xai
LITELLM_LOG: WARNING
volumes:
- ./models.yaml:/etc/litellm/models.yaml:ro
- ./render_config.py:/etc/litellm/render_config.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
- oauth:/tokens
entrypoint: [/bin/sh, -c]
command:
- 'python /etc/litellm/render_config.py && exec python /etc/litellm/personal_transcription.py --config /tmp/litellm.yaml --host 0.0.0.0 --port 4000 --num_workers 1'
healthcheck:
test: [CMD, python, -c, 'import urllib.request; urllib.request.urlopen("http://127.0.0.1:4000/health/liveliness", timeout=5)']
interval: 30s
timeout: 10s
retries: 6
start_period: 120s
logging:
driver: json-file
options: {max-size: 10m, max-file: "3"}
volumes:
database:
name: tools-litellm-database
oauth:
name: tools-litellm-oauth
+56
View File
@@ -0,0 +1,56 @@
# 除 Google 自带 latest 别名外,本项目映射不会自行追踪上游发布。
zenmux:
claude-sonnet-latest: anthropic/claude-sonnet-5.5
claude-opus-latest: anthropic/claude-opus-5.5
claude-haiku-latest: anthropic/claude-haiku-5.5
openrouter:
deepseek-flash-latest: deepseek/deepseek-v4.1-flash
deepseek-pro-latest: deepseek/deepseek-v4-pro
glm-latest: z-ai/glm-5.3-prime
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.
zenmux_open_models:
deepseek-flash-latest: deepseek/deepseek-v4.1-flash
deepseek-pro-latest: deepseek/deepseek-v4-pro
glm-latest: z-ai/glm-5.3-prime
glm-flash-latest: z-ai/glm-5.3-flash
kimi-latest: moonshotai/kimi-k3
mimo-flash-latest: xiaomi/mimo-v2.6-flash
zenmux_chat:
gemini-flash-zenmux: google/gemini-3.8-flash
google_ai_studio:
gemini-flash-latest: gemini-flash-latest
gemini-flash-lite-latest: gemini-flash-lite-latest
gemini-pro-latest: gemini-pro-latest
gemini-embedding-2-studio: gemini-embedding-2
google_vertex:
gemini-flash-vertex: gemini-3.8-flash
gemini-flash-lite-vertex: gemini-3.5-flash-lite
gemini-pro-vertex: gemini-3.1-pro-preview
google-rerank-default: semantic-ranker-default@latest
google-rerank-fast: semantic-ranker-fast@latest
# 已有 Hindsight 部署用过的候选模型;本代理仍需独立授权与请求验收。
# renderer 在实际 token 出现前排除这些路线。
chatgpt:
gpt-luna-latest: gpt-6-luna
gpt-sol-latest: gpt-6.1-sol
gpt-work-sol: gpt-6-sol
gpt-review-5.5: gpt-5.5
gpt-astra-latest: gpt-6-astra
xai:
grok-latest: grok-4.7
# Official API-only additions. OAuth model aliases above are mirrored by renderer
# so latest maintenance cannot silently leave the fallback on an older model.
openai:
gpt-terra-latest: gpt-6.1-terra
gpt-transcribe: gpt-transcribe
gpt-4o-transcribe: gpt-4o-transcribe
gpt-4o-mini-transcribe: gpt-4o-mini-transcribe
gpt-4o-transcribe-diarize: gpt-4o-transcribe-diarize
text-embedding-3-small: text-embedding-3-small
text-embedding-3-large: text-embedding-3-large
gpt-image-2.5-flare: gpt-image-2.5-flare
gpt-image-2.5-sunburst: gpt-image-2.5-sunburst
gpt-4o-mini-tts: gpt-4o-mini-tts
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
IMAGE='ghcr.io/berriai/litellm:main-stable@sha256:3d88bd757134a00e8c00237b85a021ab1f6d54abb9c1df750f411dbe7256a500'
case "${1:-}" in
codex)
CODE='from litellm.llms.chatgpt.authenticator import Authenticator; Authenticator().get_access_token(); print("Codex login complete")'
;;
xai)
CODE='from litellm.llms.xai.oauth import XAIOAuthAuthenticator; XAIOAuthAuthenticator().login(no_browser=True); print("xAI login complete")'
;;
*) printf 'Usage: bash oauth-login.sh codex|xai\n' >&2; exit 2 ;;
esac
# The helper shares only the proxy token volume, not its API keys or database.
sudo docker run --rm -it --network host \
--mount type=volume,src=tools-litellm-oauth,dst=/tokens \
-e CHATGPT_TOKEN_DIR=/tokens/chatgpt -e XAI_OAUTH_TOKEN_DIR=/tokens/xai \
--entrypoint python "$IMAGE" -c "$CODE"
printf 'Login persisted. Verify model IDs, then restart only the litellm service.\n'
@@ -0,0 +1,52 @@
"""Add native SMART mode and vocabulary only to opted-in dictation calls."""
import json
from pathlib import Path
VOCABULARY = Path('/etc/litellm/personal-vocabulary.json')
def vocabulary(path=VOCABULARY):
words = json.loads(path.read_text())
if not isinstance(words, list) or not 1 <= len(words) <= 100:
raise ValueError('Personal vocabulary must contain 1–100 terms')
if any(not isinstance(word, str) or not word.strip() or len(word) > 100 for word in words):
raise ValueError('Invalid personal vocabulary term')
return list(dict.fromkeys(words))
def install():
from litellm.llms.gemini.audio_transcription.transformation import GeminiAudioTranscriptionConfig
original = GeminiAudioTranscriptionConfig.transform_audio_transcription_request
if getattr(original, '_personal_vocabulary_patch', False):
return
def transform(self, model, audio_file, optional_params, litellm_params):
enabled = optional_params.get('personal_vocabulary') is True
if not enabled:
return original(self, model, audio_file, optional_params, litellm_params)
if model.removeprefix('gemini/') != 'gemini-3.5-transcribe':
raise ValueError('Personal vocabulary is restricted to Gemini 3.5 Transcribe')
if optional_params.get('timestamp_granularities') or optional_params.get('response_format') not in (None, 'json', 'text'):
raise ValueError('Personal dictation does not support timestamps or diarization')
clean = {key: value for key, value in optional_params.items() if key != 'personal_vocabulary'}
result = original(self, model, audio_file, clean, litellm_params)
config = result.data.setdefault('generation_config', {}).setdefault('transcription_config', {})
# Fail closed if a future LiteLLM version enables conflicting features by default.
if config.get('mode'):
raise ValueError('Unexpected Gemini transcription mode')
config['mode'] = 'smart'
config['custom_vocabulary'] = vocabulary()
return result
transform._personal_vocabulary_patch = True
GeminiAudioTranscriptionConfig.transform_audio_transcription_request = transform
if __name__ == '__main__':
import sys
from litellm import run_server
from bailian_transcription import install as install_bailian_asr
install_bailian_asr()
install()
sys.argv[0] = 'litellm'
sys.exit(run_server())
+210
View File
@@ -0,0 +1,210 @@
"""Render only credential-ready routes; credentials remain environment references."""
import os
import json
from copy import deepcopy
from pathlib import Path
import yaml
def oauth_ready(path):
try:
record = json.loads(path.read_text())
return isinstance(record, dict) and bool(record.get('access_token'))
except (OSError, ValueError):
return False
def namespace_config(config):
"""Add explicit provider names without removing existing client aliases."""
routes = config['model_list']
existing = {r['model_name']: r for r in routes}
fallbacks = config['router_settings']['fallbacks']
automatic = {name for group in fallbacks for name in group}
renamed = {}
def clone(route, alias):
if alias in existing:
if existing[alias]['litellm_params'] != route['litellm_params']:
raise ValueError('Conflicting namespace alias: ' + alias)
return
new = deepcopy(route)
new['model_name'] = alias
existing[alias] = new
routes.append(new)
for route in list(routes):
alias = route['model_name']
model = route['litellm_params']['model']
if alias in {'gemini-3.6-flash', 'gemini-3.5-flash-lite',
'google/gemini-embedding-2', 'google/gemini-3.8-flash'}:
continue
if alias.endswith('-openrouter'):
new = 'openrouter/' + alias.removesuffix('-openrouter')
elif alias in automatic:
new = 'auto/' + alias
clone(route, 'zenmux/' + alias)
elif model.startswith('anthropic/'):
new = ('zenmux-payg/' + alias.removesuffix('-payg')
if alias.endswith('-payg') else 'zenmux/' + alias)
elif model.startswith('openai/'):
name = model.removeprefix('openai/').split('/', 1)[-1]
new = 'zenmux/' + (name if alias.startswith('asr-') else
alias.replace('-zenmux', '-latest'))
elif model.startswith('openrouter/'):
new = 'openrouter/' + alias
elif model.startswith('gemini/'):
new = 'aistudio/' + alias.removesuffix('-studio')
elif model.startswith('vertex_ai/'):
name = {'google-rerank-default': 'rerank-default-latest',
'google-rerank-fast': 'rerank-fast-latest'}.get(alias,
alias.replace('-vertex', '-latest'))
new = 'vertex/' + name
elif model.startswith('chatgpt/'):
name = model.split('/', 1)[1] if alias in {'gpt-work-sol', 'gpt-review-5.5'} else alias
new = 'codex/' + name
elif model.startswith('xai/'):
new = 'xai/' + alias
else:
raise ValueError('Unrecognized namespace provider: ' + alias)
clone(route, new)
renamed[alias] = new
for group in list(fallbacks):
for source, targets in group.items():
fallbacks.append({renamed[source]: [renamed[target] for target in targets]})
return config
def add_bailian_routes(config, env, vocabulary_path):
if env.get('BAILIAN_CN_API_KEY'):
base = env.get('BAILIAN_CN_API_BASE')
if not base:
raise ValueError('BAILIAN_CN_API_BASE required for Beijing Bailian')
for personal in [False, True]:
params = {'model': 'dashscope/qwen-audio-3.1-asr-flash',
'api_base': base, 'api_key': 'os.environ/BAILIAN_CN_API_KEY',
'max_retries': 0}
if personal:
params['bailian_vocabulary_path'] = vocabulary_path
config['model_list'].append({'model_name': 'bailian-cn/qwen-audio-3.1-asr-flash' +
('-personal' if personal else ''), 'litellm_params': params})
return config
def add_openai_routes(config, catalog, env):
"""Official API is separate from OAuth and third-party OpenAI-compatible APIs."""
if not env.get('OPENAI_API_KEY'):
return config
models = dict(catalog.get('openai', {}))
for alias, model in catalog.get('chatgpt', {}).items():
name = alias if alias.endswith('-latest') else model
if name in models and models[name] != model:
raise ValueError('Official API/OAuth model mismatch: ' + name)
models[name] = model
by_model = {}
for alias, model in models.items():
name = 'openai/' + alias
config['model_list'].append({'model_name': name, 'litellm_params': {
'model': 'openai/' + model, 'api_key': 'os.environ/OPENAI_API_KEY',
'api_base': 'https://api.openai.com/v1', 'max_retries': 0}})
by_model[model] = name
for route in list(config['model_list']):
params = route['litellm_params']
if not params['model'].startswith('chatgpt/'):
continue
backup = by_model.get(params['model'].removeprefix('chatgpt/'))
if not backup:
raise ValueError('Missing same-model official API fallback')
params['max_retries'] = 0
route['model_info'] = {'cooldown_time': 60, 'allowed_fails_policy': {
'RateLimitErrorAllowedFails': 0, 'AuthenticationErrorAllowedFails': 0,
'InternalServerErrorAllowedFails': 0, 'ServiceUnavailableErrorAllowedFails': 0,
'TimeoutErrorAllowedFails': 0}}
config['router_settings']['fallbacks'].append({route['model_name']: [backup]})
return config
def build_config(catalog, env, exists, namespaced=True):
routes = []
fallbacks = []
def add(alias, model, key=None, **params):
if key:
params['api_key'] = 'os.environ/' + key
routes.append({'model_name': alias, 'litellm_params': {'model': model, **params}})
for key, suffix in [('ZENMUX_SUBSCRIPTION_API_KEY', ''), ('ZENMUX_PAYG_API_KEY', '-payg')]:
if env.get(key):
for alias, model in catalog['zenmux'].items():
# Strip only the LiteLLM provider prefix, retaining ZenMux's vendor prefix.
add(alias + suffix, 'anthropic/' + model, key,
api_base='https://zenmux.ai/api/anthropic')
if env.get('ZENMUX_SUBSCRIPTION_API_KEY'):
for alias, model in catalog.get('zenmux_chat', {}).items():
add(alias, 'openai/' + model, 'ZENMUX_SUBSCRIPTION_API_KEY',
api_base='https://zenmux.ai/api/v1')
if env.get('OPENROUTER_API_KEY'):
for alias, model in catalog['openrouter'].items():
primary = catalog.get('zenmux_open_models', {}).get(alias)
if primary and env.get('ZENMUX_SUBSCRIPTION_API_KEY'):
if primary != model:
raise ValueError('Cross-provider model mismatch: ' + alias)
add(alias, 'openai/' + primary, 'ZENMUX_SUBSCRIPTION_API_KEY',
api_base='https://zenmux.ai/api/v1', max_retries=0)
routes[-1]['model_info'] = {'cooldown_time': 60,
'allowed_fails_policy': {'RateLimitErrorAllowedFails': 0,
'AuthenticationErrorAllowedFails': 0,
'InternalServerErrorAllowedFails': 0,
'ServiceUnavailableErrorAllowedFails': 0,
'TimeoutErrorAllowedFails': 0}}
add(alias + '-openrouter', 'openrouter/' + model, 'OPENROUTER_API_KEY')
fallbacks.append({alias: [alias + '-openrouter']})
else:
add(alias, 'openrouter/' + model, 'OPENROUTER_API_KEY')
elif env.get('ZENMUX_SUBSCRIPTION_API_KEY'):
for alias, model in catalog.get('zenmux_open_models', {}).items():
add(alias, 'openai/' + model, 'ZENMUX_SUBSCRIPTION_API_KEY',
api_base='https://zenmux.ai/api/v1', max_retries=0)
if env.get('GOOGLE_AI_STUDIO_API_KEY'):
add('gemini-transcribe-personal', 'gemini/gemini-3.5-transcribe',
'GOOGLE_AI_STUDIO_API_KEY', personal_vocabulary=True)
for alias, model in catalog['google_ai_studio'].items():
add(alias, 'gemini/' + model, 'GOOGLE_AI_STUDIO_API_KEY')
if env.get('VERTEXAI_ENABLED', '').lower() == 'true' and env.get('VERTEXAI_PROJECT'):
for alias, model in catalog.get('google_vertex', {}).items():
add(alias, 'vertex_ai/' + model,
vertex_project='os.environ/VERTEXAI_PROJECT',
vertex_location='os.environ/VERTEXAI_LOCATION')
for provider, token_var, default_dir in [
('chatgpt', 'CHATGPT_TOKEN_DIR', '/tokens/chatgpt'),
('xai', 'XAI_OAUTH_TOKEN_DIR', '/tokens/xai'),
]:
if exists(Path(env.get(token_var, default_dir)) / 'auth.json'):
for alias, model in catalog[provider].items():
extra = {'use_xai_oauth': True} if provider == 'xai' else {}
add(alias, provider + '/' + model, **extra)
config = {
'model_list': routes,
'general_settings': {
'master_key': 'os.environ/LITELLM_MASTER_KEY',
'database_url': 'os.environ/DATABASE_URL',
'store_model_in_db': False,
'disable_env_credential_login': True,
},
'litellm_settings': {'drop_params': False, 'set_verbose': False},
# Official OpenAI fallbacks are appended after namespace expansion.
'router_settings': {'num_retries': 1, 'timeout': 180, 'fallbacks': fallbacks},
}
if namespaced:
config = add_bailian_routes(namespace_config(config), env,
'/etc/litellm/personal-vocabulary.json')
return add_openai_routes(config, catalog, env)
if __name__ == '__main__':
catalog = yaml.safe_load(Path('/etc/litellm/models.yaml').read_text())
config = build_config(catalog, os.environ, oauth_ready)
output = Path('/tmp/litellm.yaml')
output.write_text(yaml.safe_dump(config, allow_unicode=True, sort_keys=False))
output.chmod(0o600)
print('Configured routes:', ', '.join(route['model_name'] for route in config['model_list']))
+177
View File
@@ -0,0 +1,177 @@
import unittest
import yaml
from pathlib import Path
from tempfile import TemporaryDirectory
from render_config import build_config, oauth_ready
class ConfigTests(unittest.TestCase):
catalog = {
'zenmux': {'claude-sonnet-latest': 'anthropic/claude-sonnet-5.5'},
'openrouter': {'glm-latest': 'z-ai/glm-5.3-prime'},
'google_ai_studio': {'gemini-latest': 'gemini-test'},
'google_vertex': {'gemini-vertex': 'gemini-vertex-test'},
'chatgpt': {'codex-latest': 'gpt-test'},
'xai': {'grok-latest': 'grok-test'},
}
def test_missing_credentials_exclude_routes(self):
self.assertEqual(build_config(self.catalog, {}, lambda _: False)['model_list'], [])
def test_bailian_routes_are_native_and_opt_in(self):
env = {'BAILIAN_CN_API_KEY': 'synthetic', 'BAILIAN_CN_API_BASE':
'https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1'}
routes = build_config(self.catalog, env, lambda _: False)['model_list']
self.assertEqual(len(routes), 2)
self.assertEqual(routes[0]['model_name'], 'bailian-cn/qwen-audio-3.1-asr-flash')
self.assertNotIn('bailian_vocabulary_path', routes[0]['litellm_params'])
self.assertEqual(routes[1]['litellm_params']['bailian_vocabulary_path'],
'/etc/litellm/personal-vocabulary.json')
self.assertEqual(routes[0]['litellm_params']['model'], 'dashscope/qwen-audio-3.1-asr-flash')
self.assertNotIn('synthetic', str(routes))
def test_namespaces_preserve_every_legacy_route(self):
catalog = yaml.safe_load(Path(__file__).with_name('models.yaml').read_text())
env = {key: 'synthetic' for key in ['ZENMUX_SUBSCRIPTION_API_KEY',
'ZENMUX_PAYG_API_KEY', 'OPENROUTER_API_KEY', 'GOOGLE_AI_STUDIO_API_KEY',
'VERTEXAI_PROJECT']}
env['VERTEXAI_ENABLED'] = 'true'
old = build_config(catalog, env, lambda _: True, namespaced=False)
new = build_config(catalog, env, lambda _: True)
routes = {r['model_name']: r for r in new['model_list']}
self.assertEqual(len(routes), len(new['model_list']))
for route in old['model_list']:
self.assertEqual(route, routes[route['model_name']])
for old_name, new_name in {
'claude-sonnet-latest': 'zenmux/claude-sonnet-latest',
'claude-opus-latest-payg': 'zenmux-payg/claude-opus-latest',
'grok-latest': 'xai/grok-latest', 'gpt-sol-latest': 'codex/gpt-sol-latest',
'gpt-work-sol': 'codex/gpt-6-sol', 'gpt-review-5.5': 'codex/gpt-5.5',
'gemini-transcribe-personal': 'aistudio/gemini-transcribe-personal',
'gemini-embedding-2-studio': 'aistudio/gemini-embedding-2',
'google-rerank-fast': 'vertex/rerank-fast-latest',
}.items():
self.assertEqual(routes[old_name]['litellm_params'], routes[new_name]['litellm_params'])
def test_env_ui_login_disabled(self):
settings = build_config(self.catalog, {}, lambda _: False)['general_settings']
self.assertTrue(settings['disable_env_credential_login'])
def test_official_openai_missing_key_does_not_change_existing_config(self):
catalog = {**self.catalog, 'openai': {'transcribe': 'gpt-transcribe'}}
config = build_config(catalog, {}, lambda _: True)
self.assertFalse(any(r['model_name'].startswith('openai/') for r in config['model_list']))
self.assertEqual(config['router_settings']['fallbacks'], [])
def test_official_openai_is_not_misclassified_as_zenmux(self):
catalog = {**self.catalog, 'openai': {'transcribe': 'gpt-transcribe'}}
config = build_config(catalog, {'OPENAI_API_KEY': 'synthetic-secret'}, lambda _: False)
routes = {r['model_name']: r for r in config['model_list']}
self.assertIn('openai/transcribe', routes)
self.assertNotIn('zenmux/transcribe', routes)
self.assertEqual(routes['openai/transcribe']['litellm_params']['api_base'], 'https://api.openai.com/v1')
self.assertEqual(routes['openai/transcribe']['litellm_params']['api_key'], 'os.environ/OPENAI_API_KEY')
self.assertNotIn('synthetic-secret', str(config))
self.assertEqual(config['router_settings']['fallbacks'], [])
def test_oauth_fallbacks_cover_legacy_and_namespaced_routes(self):
config = build_config(self.catalog, {'OPENAI_API_KEY': 'synthetic'}, lambda _: True)
groups = {k: v for row in config['router_settings']['fallbacks'] for k, v in row.items()}
self.assertEqual(groups['codex-latest'], ['openai/codex-latest'])
self.assertEqual(groups['codex/codex-latest'], ['openai/codex-latest'])
self.assertNotIn('grok-latest', groups)
self.assertNotIn('openai/codex-latest', groups)
def test_actual_catalog_has_same_model_api_fallbacks_and_latest_tracks(self):
catalog = yaml.safe_load(Path(__file__).with_name('models.yaml').read_text())
catalog['chatgpt']['gpt-luna-latest'] = 'gpt-99-luna'
config = build_config(catalog, {'OPENAI_API_KEY': 'synthetic'}, lambda _: True)
routes = {r['model_name']: r for r in config['model_list']}
self.assertEqual(routes['openai/gpt-luna-latest']['litellm_params']['model'], 'openai/gpt-99-luna')
groups = {k: v for row in config['router_settings']['fallbacks'] for k, v in row.items()}
for name, route in routes.items():
if route['litellm_params']['model'].startswith('chatgpt/'):
backup = routes[groups[name][0]]
self.assertEqual(route['litellm_params']['model'].split('/', 1)[1],
backup['litellm_params']['model'].split('/', 1)[1])
self.assertEqual(groups['codex/gpt-6-sol'], ['openai/gpt-6-sol'])
self.assertEqual(groups['codex/gpt-5.5'], ['openai/gpt-5.5'])
def test_subscription_preserves_vendor_prefix(self):
routes = build_config(self.catalog, {'ZENMUX_SUBSCRIPTION_API_KEY': 'secret'}, lambda _: False)['model_list']
self.assertEqual(routes[0]['litellm_params']['model'], 'anthropic/anthropic/claude-sonnet-5.5')
self.assertNotIn('secret', str(routes))
def test_payg_separate_alias(self):
routes = build_config(self.catalog, {'ZENMUX_PAYG_API_KEY': 'secret'}, lambda _: False)['model_list']
self.assertEqual(routes[0]['model_name'], 'claude-sonnet-latest-payg')
def test_oauth_requires_persistent_file(self):
routes = build_config(self.catalog, {}, lambda path: str(path) == '/tokens/xai/auth.json')['model_list']
self.assertEqual(len(routes), 2)
self.assertTrue(routes[0]['litellm_params']['use_xai_oauth'])
self.assertNotIn('api_key', routes[0]['litellm_params'])
def test_pending_oauth_is_not_ready(self):
with TemporaryDirectory() as folder:
path = Path(folder) / 'auth.json'
self.assertFalse(oauth_ready(path))
path.write_text('{"device_code_requested_at": 1}')
self.assertFalse(oauth_ready(path))
path.write_text('{"access_token": "fixture-only"}')
self.assertTrue(oauth_ready(path))
def test_vertex_requires_explicit_enable_and_project(self):
for env in ({'VERTEXAI_PROJECT': 'test-project'}, {'VERTEXAI_ENABLED': 'true'}):
self.assertEqual(build_config(self.catalog, env, lambda _: False)['model_list'], [])
routes = build_config(self.catalog, {'VERTEXAI_ENABLED': 'true',
'VERTEXAI_PROJECT': 'test-project'}, lambda _: False)['model_list']
self.assertEqual(routes[0]['litellm_params']['model'], 'vertex_ai/gemini-vertex-test')
self.assertEqual(routes[0]['litellm_params']['vertex_project'], 'os.environ/VERTEXAI_PROJECT')
self.assertNotIn('api_key', routes[0]['litellm_params'])
def test_open_models_subscription_first(self):
catalog = {**self.catalog, 'zenmux_open_models': self.catalog['openrouter']}
config = build_config(catalog, {'ZENMUX_SUBSCRIPTION_API_KEY': 'a',
'OPENROUTER_API_KEY': 'b', 'ZENMUX_PAYG_API_KEY': 'c'}, lambda _: False)
routes = {r['model_name']: r for r in config['model_list']}
self.assertEqual(routes['glm-latest']['litellm_params']['model'], 'openai/z-ai/glm-5.3-prime')
self.assertEqual(routes['glm-latest-openrouter']['litellm_params']['model'], 'openrouter/z-ai/glm-5.3-prime')
self.assertEqual(config['router_settings']['fallbacks'], [
{'glm-latest': ['glm-latest-openrouter']},
{'auto/glm-latest': ['openrouter/glm-latest']}])
self.assertEqual(routes['auto/glm-latest']['litellm_params'], routes['glm-latest']['litellm_params'])
self.assertEqual(routes['zenmux/glm-latest']['litellm_params'], routes['glm-latest']['litellm_params'])
self.assertNotIn('zenmux/glm-latest', str(config['router_settings']['fallbacks']))
self.assertEqual(routes['glm-latest']['model_info']['cooldown_time'], 60)
self.assertNotIn('glm-latest-payg', routes)
def test_openrouter_only_remains_usable(self):
catalog = {**self.catalog, 'zenmux_open_models': self.catalog['openrouter']}
config = build_config(catalog, {'OPENROUTER_API_KEY': 'b'}, lambda _: False)
self.assertEqual(config['model_list'][0]['litellm_params']['model'], 'openrouter/z-ai/glm-5.3-prime')
self.assertEqual(config['router_settings']['fallbacks'], [])
def test_zenmux_only_no_broken_fallback(self):
catalog = {**self.catalog, 'zenmux_open_models': self.catalog['openrouter']}
config = build_config(catalog, {'ZENMUX_SUBSCRIPTION_API_KEY': 'a'}, lambda _: False)
self.assertIn('glm-latest', [r['model_name'] for r in config['model_list']])
self.assertEqual(config['router_settings']['fallbacks'], [])
def test_mismatched_model_rejected(self):
catalog = {**self.catalog, 'zenmux_open_models': {'glm-latest': 'different-model'}}
with self.assertRaises(ValueError):
build_config(catalog, {'ZENMUX_SUBSCRIPTION_API_KEY': 'a',
'OPENROUTER_API_KEY': 'b'}, lambda _: False)
def test_zenmux_chat_keeps_existing_data_destination(self):
catalog = {**self.catalog, 'zenmux_chat': {'gemini-flash-zenmux': 'google/gemini-3.8-flash'}}
config = build_config(catalog, {'ZENMUX_SUBSCRIPTION_API_KEY': 'a'}, lambda _: False)
route = next(r for r in config['model_list'] if r['model_name'] == 'gemini-flash-zenmux')
self.assertEqual(route['litellm_params']['model'], 'openai/google/gemini-3.8-flash')
self.assertEqual(route['litellm_params']['api_base'], 'https://zenmux.ai/api/v1')
self.assertNotIn('gemini-flash-zenmux-payg', [r['model_name'] for r in config['model_list']])
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Run inside a separate LiteLLM container exec: fake OAuth failures, real API backup."""
import asyncio
from copy import deepcopy
from functools import wraps
import json
import os
from pathlib import Path
from unittest.mock import patch
import litellm
import yaml
litellm.suppress_debug_info = True
async def main():
config = yaml.safe_load(Path('/tmp/litellm.yaml').read_text())
primary, backup = 'codex/gpt-luna-latest', 'openai/gpt-luna-latest'
routes = [deepcopy(r) for r in config['model_list'] if r['model_name'] in (primary, backup)]
assert len(routes) == 2
assert {primary: [backup]} in config['router_settings']['fallbacks']
for route in routes:
if route['model_name'] == backup:
route['litellm_params']['api_key'] = os.environ['OPENAI_API_KEY']
actual_chat, actual_responses = litellm.acompletion, litellm.aresponses
for protocol in ('chat', 'responses'):
for error_type, expected in ((litellm.RateLimitError, 429),
(litellm.AuthenticationError, 401),
(litellm.ServiceUnavailableError, 503)):
calls = []
async def invoke(real, *args, **kwargs):
model = kwargs.get('model', '')
if model.startswith('chatgpt/'):
calls.append('subscription-rejected')
raise error_type(message='Synthetic OAuth failure; isolated test only',
llm_provider='chatgpt', model=model)
assert model.startswith('openai/')
calls.append('official-api')
return await real(*args, **kwargs)
@wraps(actual_chat)
async def chat(*args, **kwargs):
return await invoke(actual_chat, *args, **kwargs)
@wraps(actual_responses)
async def responses(*args, **kwargs):
return await invoke(actual_responses, *args, **kwargs)
with patch.object(litellm, 'acompletion', chat), patch.object(litellm, 'aresponses', responses):
router = litellm.Router(model_list=deepcopy(routes), fallbacks=[{primary: [backup]}],
num_retries=0, timeout=60)
if protocol == 'chat':
result = await router.acompletion(model=primary, messages=[
{'role': 'user', 'content': 'Reply only OK. Synthetic test.'}], max_tokens=32)
assert result.choices and result.choices[0].message.content
else:
result = await router.aresponses(model=primary, input=[
{'role': 'user', 'content': 'Reply only OK. Synthetic test.'}],
max_output_tokens=32, store=False)
assert result.status == 'completed'
assert calls[0] == 'subscription-rejected' and calls[-1] == 'official-api', calls
print(json.dumps({'protocol': protocol, 'injected_error': expected,
'calls': calls, 'backup': backup, 'passed': True}), flush=True)
if __name__ == '__main__':
asyncio.run(main())
+24
View File
@@ -0,0 +1,24 @@
# 模型命名空间
2026-10-27 起新增客户端命名空间,保留旧名兼容。实际型号、协议、凭据、个人热词和 SMART 均不改变;latest 不代表自动跟踪发布。
| 前缀 | 含义 |
| --- | --- |
| zenmux/ | ZenMux 订阅直连,不跨 provider 回退 |
| zenmux-payg/ | ZenMux PAYG,仅显式请求 |
| openrouter/ | OpenRouter 直连 |
| auto/ | 开源模型 ZenMux 订阅优先,故障回退 OpenRouter |
| aistudio/ | Google AI Studio |
| vertex/ | Google Vertex |
| codex/ | Codex OAuth 订阅 |
| xai/ | xAI OAuth 订阅 |
常规旧别名加对应前缀。特殊转换:`gemini-flash-zenmux` → `zenmux/gemini-flash-latest`;`gemini-*-vertex` → `vertex/gemini-*-latest`;`gemini-embedding-2-studio` → `aistudio/gemini-embedding-2`;`google-rerank-default/fast` → `vertex/rerank-default/fast-latest`;`gpt-work-sol` → `codex/gpt-6-sol`;`gpt-review-5.5` → `codex/gpt-5.5`。PAYG 去掉旧 `-payg` 后缀,改加 `zenmux-payg/` 前缀。
六组开源模型 `deepseek-flash-latest`、`deepseek-pro-latest`、`glm-latest`、`glm-flash-latest`、`kimi-latest`、`mimo-flash-latest` 各提供 auto/、zenmux/、openrouter/ 三种选择。旧自动回退名和旧 -openrouter 名保留。
tools-pve 的个人语音名为 `aistudio/gemini-transcribe-personal`;HK 使用 `vertex/gemini-transcribe-personal`,不能互换两端 provider。HK 四款 ZenMux ASR 别名为 `zenmux/gpt-transcribe`、`zenmux/qwen-audio-3.1-asr-flash`、`zenmux/doubao-seed-asr-2.0`、`zenmux/mimo-v2.5-asr`。HK 的四个历史 Google 兼容名保留。
只有凭据已就绪的路线才生成。新旧别名的 litellm_params 必须完全相同,不能把客户端前缀当作上游 provider 参数。受模型白名单限制的 virtual key 需要另行授权新名;不扩大既有 key 权限。旧名暂不删除,待所有客户端完成迁移后单独处理。
修改 renderer 后,通过 Arcane Git Sync 同步,并重启对应 LiteLLM 进程使 /tmp/litellm.yaml 重建;仅同步代码不代表进程已加载。逐节点确认 /v1/models、新旧路由参数等价、fallback 配置及实际请求。
@@ -0,0 +1,24 @@
# 百炼北京原厂 ASR
2026-10-27 上线,仅北京,不配置新加坡或跨地域回退。客户端沿用本机 LiteLLM 的 /v1/audio/transcriptions、现有客户端 key、multipart 文件上传,推荐 response_format=json。
| 模型名 | 行为 |
| --- | --- |
| bailian-cn/qwen-audio-3.1-asr-flash | 原厂识别,无词表注入 |
| bailian-cn/qwen-audio-3.1-asr-flash-personal | 相同模型,原生 vocabulary 注入既有 20 个人名/公司名,每词权重 3 |
上游为业务空间专属域名 `https://ws-v68kvjnmhkiejaxo.cn-beijing.maas.aliyuncs.com/api/v1`,使用原生 `/services/aigc/multimodal-generation/generation`,不是 compatible-mode。已用 .145 现有 BAILIAN_API_KEY 实测该空间可用;部署后凭据权威是各 Arcane Project 的 BAILIAN_CN_API_KEY,原始 key 不进入 Git。
当前 LiteLLM 1.104.2 没有 DashScope 原生转写配置,启动适配器仅接管 dashscope provider 的 qwen-audio-3.1-asr-flash。普通 Gemini、Vertex、ZenMux 和 OpenAI 不变。不增加服务、端口或镜像,不重新部署会议管线。
文件原字节转为 Base64 Data URI。WAV 从文件头读取采样率,非 WAV 不伪造采样率;上游可选 prompt 作为 input_text 上下文,不覆盖、不做二次 LLM 改写,最多 400 字。生产个人词表从当前节点已有私有文件读取,不把词表放到 Git。DashScope 自定义参数在 SDK 中进入 extra_body,适配器需取出私有词表路径,再生成 parameters.vocabulary;私有路径不能泄漏上游。
不启用 streaming、时间戳或说话人分离,不承诺自动去口语化;原厂默认识别行为不额外覆盖。上传 Base64 Data URI 保守限制 10 MB,超限明确拒绝,不自动把文件上传公网。json 为已验证的推荐响应格式。
配置验证:先运行每目录 test_config.py,再在固定镜像中执行 scripts/test-bailian-asr-wire.py,检查同步/异步真实 SDK 的 HTTP 请求体、热词、原字节保持及其他 provider 不受影响。
原厂 A/B:2026-07-21 电话前 90 秒,无热词两处“洛奇”,指定临时热词“洛启、普华”(权重 3)后两处“洛启”,耗时分别 3.432/3.649 秒。该结果只证明该片段短词表有改善,不代表已有 20 词表一定同等改善或整体 CER/WER。
上线后必须验证:两节点普通/个人路由公开音频、现有客户端 key 权限、词表开关和 native vocabulary 请求体。新模型不因部署便自动获得 virtual key 权限;仅为用户批准的应用增加这两条路线,不扩大到全部模型。
官方文档:https://help.aliyun.com/zh/model-studio/fun-asr-flash-recorded-speech-recognition-http-api