From 71ccac6d729fd1c3b7ad88410b5b357238604d2c Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 25 Oct 2026 00:59:39 -0700 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E9=A6=99=E6=B8=AF=20Vertex?= =?UTF-8?q?=20SMART=20=E4=B8=AA=E4=BA=BA=E8=AF=AD=E9=9F=B3=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E4=B8=8E=E7=83=AD=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- servers/bwh-hk-cmi/litellm/README.md | 16 +++++- servers/bwh-hk-cmi/litellm/compose.yaml | 6 ++- servers/bwh-hk-cmi/litellm/render_config.py | 7 ++- servers/bwh-hk-cmi/litellm/test_config.py | 4 +- .../bwh-hk-cmi/litellm/vertex_dictation.py | 50 +++++++++++++++++++ 5 files changed, 78 insertions(+), 5 deletions(-) create mode 100644 servers/bwh-hk-cmi/litellm/vertex_dictation.py diff --git a/servers/bwh-hk-cmi/litellm/README.md b/servers/bwh-hk-cmi/litellm/README.md index 7f63cbf..5ea3a53 100644 --- a/servers/bwh-hk-cmi/litellm/README.md +++ b/servers/bwh-hk-cmi/litellm/README.md @@ -9,10 +9,22 @@ `google/gemini-embedding-2` 和 `google/gemini-3.8-flash`;后两者仍通过 Vertex AI ADC 路由。 - 2026-10-25 同步 tools-pve 已验证的 LiteLLM 1.104.2 固定镜像,以及 `models.yaml` 中的提供商映射。`latest` 是维护的别名,不是自动发现所有厂商的新模型。 -- `provider_routes.py` 是同日 tools-pve 的路由生成器快照;更新提供商逻辑时应同步比对两端。HK 的 `render_config.py` 删除数据库管理、OAuth 和个人词库路线,保留现有主密钥认证。 +- `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 凭据,也不发布其对话、embedding 或转写路线。tools-pve 上原有 AI Studio 功能不受影响。个人词库、OAuth 会话和会议处理程序没有复制至 HK。 +- 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` 或 `text`;不请求时间戳、说话人分离或 `verbose_json`。额外 prompt 留空;去口语化由 SMART 负责,不靠提示词。 +- 仅显式带 `personal_dictation: true` 的这条路由经 `vertex_dictation.py` 添加 `generationConfig.audioTranscriptionConfig.mode=SMART` 和 `customVocabulary`。其他转写路线、会议录音以及 tools-pve 配置不改。 +- 个人词库从既有 ASR SQLite 中导出 100 个已确认 canonical 目标词;不把误识别别名或整个公共术语库发给 Google。用户已批准该个人词库用于 HK Vertex 语音输入。 +- `PERSONAL_VOCABULARY_B64` 只存 Arcane Project 环境(Base64 不是加密);init 将其解码至现有私有凭据卷 `/credentials/personal-vocabulary.json`,umask 077,LiteLLM 只读挂载,不进入 Git。 +- 更新词库时使用项目 `scripts/export-personal-asr-vocabulary.py`,替换 Arcane 中上述变量并重建 init;单改环境变量不会更新已生成文件。升级 LiteLLM 前重跑 `scripts/test-vertex-dictation-wire.py`,确认原生 SMART/热词和普通路线隔离。 diff --git a/servers/bwh-hk-cmi/litellm/compose.yaml b/servers/bwh-hk-cmi/litellm/compose.yaml index cbb7e28..e9c2635 100644 --- a/servers/bwh-hk-cmi/litellm/compose.yaml +++ b/servers/bwh-hk-cmi/litellm/compose.yaml @@ -4,14 +4,17 @@ services: 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 @@ -36,10 +39,11 @@ services: - ./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 - google-adc:/credentials:ro entrypoint: ["/bin/sh", "-c"] command: - - python /etc/litellm/render_config.py && exec litellm --config /tmp/litellm.yaml --host 0.0.0.0 --port ${LITELLM_PORT:-1049} --num_workers 1 + - 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: diff --git a/servers/bwh-hk-cmi/litellm/render_config.py b/servers/bwh-hk-cmi/litellm/render_config.py index 387a748..2a7b481 100644 --- a/servers/bwh-hk-cmi/litellm/render_config.py +++ b/servers/bwh-hk-cmi/litellm/render_config.py @@ -1,4 +1,4 @@ -"""HK reuses tools-pve provider routing without OAuth, database, or private vocabulary.""" +"""HK provider routes and separately opted-in personal Vertex dictation.""" import os from pathlib import Path @@ -17,6 +17,11 @@ def build_hk_config(catalog, env): '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'], diff --git a/servers/bwh-hk-cmi/litellm/test_config.py b/servers/bwh-hk-cmi/litellm/test_config.py index 46e9180..4a4430e 100644 --- a/servers/bwh-hk-cmi/litellm/test_config.py +++ b/servers/bwh-hk-cmi/litellm/test_config.py @@ -16,7 +16,9 @@ class RoutingTests(unittest.TestCase): 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.assertNotIn('gemini-transcribe-personal', routes) + 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['gemini-transcribe']['model'], 'gemini/gemini-3.5-transcribe') diff --git a/servers/bwh-hk-cmi/litellm/vertex_dictation.py b/servers/bwh-hk-cmi/litellm/vertex_dictation.py new file mode 100644 index 0000000..6210f88 --- /dev/null +++ b/servers/bwh-hk-cmi/litellm/vertex_dictation.py @@ -0,0 +1,50 @@ +"""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 + install() + sys.argv[0] = 'litellm' + sys.exit(run_server())