Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2367c5eb4e | ||
|
|
d1790a05b0 | ||
|
|
3d44b74d8c | ||
|
|
df391cd41f | ||
|
|
81c475464e | ||
|
|
205bd3bbbc | ||
|
|
ed5f6514bb | ||
|
|
d583490bad | ||
|
|
daa907d029 | ||
|
|
6a88160e0f | ||
|
|
91daf426f2 | ||
|
|
45293b1b53 | ||
|
|
05e0800581 | ||
|
|
f9ccf7af2b | ||
|
|
443ed46bf9 | ||
|
|
71ccac6d72 | ||
|
|
ba0c198c0c | ||
|
|
641991043f | ||
|
|
7831d5f319 | ||
|
|
035daa177d | ||
|
|
8746be24ce | ||
|
|
94ffeca06e | ||
|
|
d5625cc610 | ||
|
|
7672ff4f6c | ||
|
|
c8a5fe3d03 | ||
|
|
a0e86bb47f | ||
|
|
c4a2182b5e | ||
|
|
8db1483345 | ||
|
|
cd50972093 | ||
|
|
9ebc213d71 | ||
|
|
a8c2a8d88f | ||
|
|
3f4b329018 | ||
|
|
6791addafd | ||
|
|
d3a487f86a | ||
|
|
db09881b2d | ||
|
|
9af7b98f17 | ||
|
|
6473297555 | ||
|
|
9a4020b7ec | ||
|
|
aaff6db668 | ||
|
|
8b5c1b9927 | ||
|
|
ac7508fdd9 | ||
|
|
15383c2dc9 | ||
|
|
3e4caaef91 | ||
|
|
f691817d64 |
@@ -0,0 +1 @@
|
|||||||
|
__pycache__/
|
||||||
@@ -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)
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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']))
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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']))
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 的
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
GRIMMORY_DB_PASSWORD=replace-with-a-random-password
|
||||||
|
GRIMMORY_DB_ROOT_PASSWORD=replace-with-a-different-random-password
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
.git
|
.git
|
||||||
.env
|
.venv
|
||||||
tests
|
|
||||||
README.md
|
|
||||||
__pycache__
|
__pycache__
|
||||||
*.pyc
|
*.pyc
|
||||||
seed
|
state/
|
||||||
|
cache/
|
||||||
|
dry-run/
|
||||||
|
logs/
|
||||||
|
.env
|
||||||
|
|||||||
@@ -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=
|
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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']))
|
||||||
@@ -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())
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user