Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
1 line
242 KiB
JSON
1 line
242 KiB
JSON
{"repo": "alibaba/open-code-review", "files": {"pages/src/content/docs/ja/configuration.md": {"5829539379ecc3c2e43c36061b73469f7a261f8d": {"t": 1785674661.4065151, "n": 5770, "h": "fedaf895861fa764", "c": "---\ntitle: 設定\nsidebar:\n order: 5\n---\n\n設定ファイルは `~/.opencodereview/config.json` にあります。編集方法は 3 つあります。\n\n- **インタラクティブ TUI** —— `ocr config provider` / `ocr config model`。ガイド付きメニューが表示されます。\n- **コマンドライン** —— `ocr config set <key> <value>`。スクリプトや CI に適しています。\n- **手動編集(非推奨)** —— この JSON ファイルを直接編集(次回の `ocr config set` 書き込み時に再フォーマットされます)。\n\n## モデルを設定する\n\n### 推奨:インタラクティブ設定\n\n```bash\nocr config provider\n```\n\n組み込みまたはカスタムの provider を選択し、API key を入力し、model を選び、すべてを設定ファイルに保存したうえで、`ocr llm test` を 1 回実行してエンドポイントを検証します。あとで model を切り替えるには:\n\n```bash\nocr config model\n```\n\n### 非インタラクティブ設定(CI / TUI なし環境)\n\n`ocr config set` で同じ設定に書き込みます。\n\n```bash\nocr config set provider anthropic\nocr config set model claude-opus-4-6\nocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx\n```\n\n### 組み込み provider\n\n以下の provider が OCR に同梱されており、Base URL とプロトコルがプリセット\nされています——選択後は API key を入力するだけです。`providers.<name>.api_key`\nが未設定の場合は、対応する環境変数に自動的にフォールバックします。\n\n| 名称 | プロトコル | Base URL | API key 環境変数 |\n|---|---|---|---|\n| `anthropic` | anthropic | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` |\n| `openai` | openai | `https://api.openai.com/v1` | `OPENAI_API_KEY` |\n| `dashscope` | openai | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_API_KEY` |\n| `dashscope-tokenplan` | openai | `https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_TOKENPLAN_KEY` |\n| `volcengine` | openai | `https://ark.cn-beijing.volces.com/api/v3` | `ARK_API_KEY` |\n| `deepseek` | openai | `https://api.deepseek.com` | `DEEPSEEK_API_KEY` |\n| `tencent-tokenhub` | openai | `https://tokenhub.tencentmaas.com/v1` | `TENCENT_TOKENHUB_API_KEY` |\n| `hy-tokenplan` | openai | `https://api.lkeap.cloud.tencent.com/plan/v3` | `TENCENT_HUNYUAN_TOKENPLAN_KEY` |\n| `iflytek` | openai | `https://spark-api-open.xf-yun.com/v1` | `SPARK_API_KEY` |\n| `kimi` | openai | `https://api.moonshot.cn/v1` | `MOONSHOT_API_KEY` |\n| `z-ai` | openai | `https://open.bigmodel.cn/api/paas/v4` | `Z_AI_API_KEY` |\n| `mimo` | openai | `https://api.xiaomimimo.com/v1` | `MIMO_API_KEY` |\n| `minimax` | openai | `https://api.minimaxi.com/v1` | `MINIMAX_API_KEY` |\n| `baidu-qianfan` | openai | `https://qianfan.baidubce.com/v2` | `QIANFAN_API_KEY` |\n\n### カスタム provider\n\n上記の表にない provider 名はすべてカスタムとみなされ、少なくとも `url` と\n`protocol` を指定する必要があります(`protocol` は `anthropic`、`openai`、\nまたは `openai-responses`)。\n\n```bash\nocr config set provider my-gateway\nocr config set custom_providers.my-gateway.url https://gateway.internal.com/v1\nocr config set custom_providers.my-gateway.protocol openai\nocr config set custom_providers.my-gateway.model llama-3-70b\nocr config set custom_providers.my-gateway.api_key \"$MY_API_KEY\"\n```\n\nprovider またはモデルが OpenAI Responses API(`/v1/responses`)を必要とする場合は、\n`openai-responses` プロトコルを使用します。\n\n```bash\nocr config set provider openai-responses-gateway\nocr config set custom_providers.openai-responses-gateway.url https://api.openai.com/v1\nocr config set custom_providers.openai-responses-gateway.protocol openai-responses\nocr config set custom_providers.openai-responses-gateway.model gpt-5\nocr config set custom_providers.openai-responses-gateway.api_key \"$OPENAI_API_KEY\"\n```\n\n`url` には API の Base URL または完全な `/responses` エンドポイントのどちらを指定してもよく、OCR がどちらの形式も正規化します。\n\nOllama で動かすローカルモデルは、ローカルの OpenAI 互換エンドポイントを\n指すカスタム provider にすぎません。\n\n```bash\nocr config set provider ollama\nocr config set custom_providers.ollama.url http://127.0.0.1:11434/v1\nocr config set custom_providers.ollama.protocol openai\nocr config set custom_providers.ollama.model qwen3:32b\nocr config set custom_providers.ollama.api_key ollama\n```\n\nOllama は API key を無視しますが、カスタム provider は空でない `api_key` を\n必要とします(カスタム provider には環境変数のフォールバックがありません)。\nそのため任意のプレースホルダー値を設定してください。モデル自体はネイティブな\nツール呼び出しをサポートしている必要があります——選ぶ前に FAQ の\n[\"No tool calls parsed\"(ローカルモデル / Ollama)](../faq/#no-tool-calls-parsed-ollama)を\n参照してください。\n\n### タイムアウト(Timeouts)\n\n各 LLM リクエストには HTTP タイムアウトがあり、デフォルトは **300 秒**です。\n遅いローカルモデル(あるいは大きなファイル)では、それ以上の時間が必要になることがあります。\nスコープの狭い順に、3 つの設定があります。\n\n- `providers.<name>.timeout_sec` / `custom_providers.<name>.timeout_sec`\n ——provider ごと、秒単位。\n- `llm.timeout_sec`——レガシーな `llm` セクション用、秒単位。\n- `OCR_LLM_TIMEOUT` 環境変数——整数(秒単位)。すべての解決パスで設定ファイルの\n 値を上書きします。\n\n`timeout_sec` key は `ocr config set` ではサポートされていません——\n`~/.opencodereview/config.json` を直接編集してください。\n\n```json\n{\n \"custom_providers\": {\n \"ollama\": { \"url\": \"http://127.0.0.1:11434/v1\", \"protocol\": \"openai\", \"timeout_sec\": 900 }\n }\n}\n```\n\n### 接続性を検証する\n\n```bash\nocr llm test\n```\n\n### 既存の環境変数を再利用する\n\nClaude Code の `ANTHROPIC_*` や OCR 独自の `OCR_LLM_*` 環境変数をすでに\n設定している場合、OCR はそれらを自動的に認識するため、設定ファイルを書く\n必要はありません。\n\n### CC-Switch を使う\n\n[CC-Switch](https://github.com/farion1231/cc-switch) を\n[ルーティングサービス](https://www.ccswitch.io/en/docs?section=proxy&item=service)\n有効で使用している場合、プロバイダーの `url` をローカルプロキシに向けるだけで、\n追加設定なしで利用できます:\n\n```bash\n# Claude(Anthropic 互換)\nocr config set providers.anthropic.url http://127.0.0.1:15721\n\n# Codex / OpenAI 互換 — そのプロバイダーの url キーを設定\nocr config set providers.<name>.url http://127.0.0.1:15721/v1\n```\n\n`api_key` は任意の値で構いません。`extra_body`(およびその他のプロバイダー固有フィールド)は\n引き続き有効です。\n\n### ベンダー固有のフィールドを送信する\n\n一部の provider は非標準のリクエストフィールド(Bedrock 風の `thinking` など)を\n必要とします。`extra_body`(各リクエストにマージされます)を使えば、ソースコードを\n変更せずにそれらを送信できます。\n\n```bash\nocr config set providers.anthropic.extra_body '{\"thinking\":{\"type\":\"disabled\"}}'\n```\n\n## レビュー言語を設定する\n\n`language` はレビューコメントをどの言語で出力するかを決めます。未設定の場合は\nデフォルトで英語になります。\n\n```bash\nocr config set language 中文\nocr config set language English\n```\n\n## 関連項目\n\n- [クイックスタート](../quickstart/)——最小限のセットアップと初回のレビュー。\n- [CLI リファレンス](../cli-reference/)——review コマンドが受け入れる各引数。\n"}, "0f3c920fc8ff091cbd1ad9e0632bf54bba56e46b": {"t": 1785809839.01042, "n": 5770, "h": "fedaf895861fa764", "c": "---\ntitle: 設定\nsidebar:\n order: 5\n---\n\n設定ファイルは `~/.opencodereview/config.json` にあります。編集方法は 3 つあります。\n\n- **インタラクティブ TUI** —— `ocr config provider` / `ocr config model`。ガイド付きメニューが表示されます。\n- **コマンドライン** —— `ocr config set <key> <value>`。スクリプトや CI に適しています。\n- **手動編集(非推奨)** —— この JSON ファイルを直接編集(次回の `ocr config set` 書き込み時に再フォーマットされます)。\n\n## モデルを設定する\n\n### 推奨:インタラクティブ設定\n\n```bash\nocr config provider\n```\n\n組み込みまたはカスタムの provider を選択し、API key を入力し、model を選び、すべてを設定ファイルに保存したうえで、`ocr llm test` を 1 回実行してエンドポイントを検証します。あとで model を切り替えるには:\n\n```bash\nocr config model\n```\n\n### 非インタラクティブ設定(CI / TUI なし環境)\n\n`ocr config set` で同じ設定に書き込みます。\n\n```bash\nocr config set provider anthropic\nocr config set model claude-opus-4-6\nocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx\n```\n\n### 組み込み provider\n\n以下の provider が OCR に同梱されており、Base URL とプロトコルがプリセット\nされています——選択後は API key を入力するだけです。`providers.<name>.api_key`\nが未設定の場合は、対応する環境変数に自動的にフォールバックします。\n\n| 名称 | プロトコル | Base URL | API key 環境変数 |\n|---|---|---|---|\n| `anthropic` | anthropic | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` |\n| `openai` | openai | `https://api.openai.com/v1` | `OPENAI_API_KEY` |\n| `dashscope` | openai | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_API_KEY` |\n| `dashscope-tokenplan` | openai | `https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_TOKENPLAN_KEY` |\n| `volcengine` | openai | `https://ark.cn-beijing.volces.com/api/v3` | `ARK_API_KEY` |\n| `deepseek` | openai | `https://api.deepseek.com` | `DEEPSEEK_API_KEY` |\n| `tencent-tokenhub` | openai | `https://tokenhub.tencentmaas.com/v1` | `TENCENT_TOKENHUB_API_KEY` |\n| `hy-tokenplan` | openai | `https://api.lkeap.cloud.tencent.com/plan/v3` | `TENCENT_HUNYUAN_TOKENPLAN_KEY` |\n| `iflytek` | openai | `https://spark-api-open.xf-yun.com/v1` | `SPARK_API_KEY` |\n| `kimi` | openai | `https://api.moonshot.cn/v1` | `MOONSHOT_API_KEY` |\n| `z-ai` | openai | `https://open.bigmodel.cn/api/paas/v4` | `Z_AI_API_KEY` |\n| `mimo` | openai | `https://api.xiaomimimo.com/v1` | `MIMO_API_KEY` |\n| `minimax` | openai | `https://api.minimaxi.com/v1` | `MINIMAX_API_KEY` |\n| `baidu-qianfan` | openai | `https://qianfan.baidubce.com/v2` | `QIANFAN_API_KEY` |\n\n### カスタム provider\n\n上記の表にない provider 名はすべてカスタムとみなされ、少なくとも `url` と\n`protocol` を指定する必要があります(`protocol` は `anthropic`、`openai`、\nまたは `openai-responses`)。\n\n```bash\nocr config set provider my-gateway\nocr config set custom_providers.my-gateway.url https://gateway.internal.com/v1\nocr config set custom_providers.my-gateway.protocol openai\nocr config set custom_providers.my-gateway.model llama-3-70b\nocr config set custom_providers.my-gateway.api_key \"$MY_API_KEY\"\n```\n\nprovider またはモデルが OpenAI Responses API(`/v1/responses`)を必要とする場合は、\n`openai-responses` プロトコルを使用します。\n\n```bash\nocr config set provider openai-responses-gateway\nocr config set custom_providers.openai-responses-gateway.url https://api.openai.com/v1\nocr config set custom_providers.openai-responses-gateway.protocol openai-responses\nocr config set custom_providers.openai-responses-gateway.model gpt-5\nocr config set custom_providers.openai-responses-gateway.api_key \"$OPENAI_API_KEY\"\n```\n\n`url` には API の Base URL または完全な `/responses` エンドポイントのどちらを指定してもよく、OCR がどちらの形式も正規化します。\n\nOllama で動かすローカルモデルは、ローカルの OpenAI 互換エンドポイントを\n指すカスタム provider にすぎません。\n\n```bash\nocr config set provider ollama\nocr config set custom_providers.ollama.url http://127.0.0.1:11434/v1\nocr config set custom_providers.ollama.protocol openai\nocr config set custom_providers.ollama.model qwen3:32b\nocr config set custom_providers.ollama.api_key ollama\n```\n\nOllama は API key を無視しますが、カスタム provider は空でない `api_key` を\n必要とします(カスタム provider には環境変数のフォールバックがありません)。\nそのため任意のプレースホルダー値を設定してください。モデル自体はネイティブな\nツール呼び出しをサポートしている必要があります——選ぶ前に FAQ の\n[\"No tool calls parsed\"(ローカルモデル / Ollama)](../faq/#no-tool-calls-parsed-ollama)を\n参照してください。\n\n### タイムアウト(Timeouts)\n\n各 LLM リクエストには HTTP タイムアウトがあり、デフォルトは **300 秒**です。\n遅いローカルモデル(あるいは大きなファイル)では、それ以上の時間が必要になることがあります。\nスコープの狭い順に、3 つの設定があります。\n\n- `providers.<name>.timeout_sec` / `custom_providers.<name>.timeout_sec`\n ——provider ごと、秒単位。\n- `llm.timeout_sec`——レガシーな `llm` セクション用、秒単位。\n- `OCR_LLM_TIMEOUT` 環境変数——整数(秒単位)。すべての解決パスで設定ファイルの\n 値を上書きします。\n\n`timeout_sec` key は `ocr config set` ではサポートされていません——\n`~/.opencodereview/config.json` を直接編集してください。\n\n```json\n{\n \"custom_providers\": {\n \"ollama\": { \"url\": \"http://127.0.0.1:11434/v1\", \"protocol\": \"openai\", \"timeout_sec\": 900 }\n }\n}\n```\n\n### 接続性を検証する\n\n```bash\nocr llm test\n```\n\n### 既存の環境変数を再利用する\n\nClaude Code の `ANTHROPIC_*` や OCR 独自の `OCR_LLM_*` 環境変数をすでに\n設定している場合、OCR はそれらを自動的に認識するため、設定ファイルを書く\n必要はありません。\n\n### CC-Switch を使う\n\n[CC-Switch](https://github.com/farion1231/cc-switch) を\n[ルーティングサービス](https://www.ccswitch.io/en/docs?section=proxy&item=service)\n有効で使用している場合、プロバイダーの `url` をローカルプロキシに向けるだけで、\n追加設定なしで利用できます:\n\n```bash\n# Claude(Anthropic 互換)\nocr config set providers.anthropic.url http://127.0.0.1:15721\n\n# Codex / OpenAI 互換 — そのプロバイダーの url キーを設定\nocr config set providers.<name>.url http://127.0.0.1:15721/v1\n```\n\n`api_key` は任意の値で構いません。`extra_body`(およびその他のプロバイダー固有フィールド)は\n引き続き有効です。\n\n### ベンダー固有のフィールドを送信する\n\n一部の provider は非標準のリクエストフィールド(Bedrock 風の `thinking` など)を\n必要とします。`extra_body`(各リクエストにマージされます)を使えば、ソースコードを\n変更せずにそれらを送信できます。\n\n```bash\nocr config set providers.anthropic.extra_body '{\"thinking\":{\"type\":\"disabled\"}}'\n```\n\n## レビュー言語を設定する\n\n`language` はレビューコメントをどの言語で出力するかを決めます。未設定の場合は\nデフォルトで英語になります。\n\n```bash\nocr config set language 中文\nocr config set language English\n```\n\n## 関連項目\n\n- [クイックスタート](../quickstart/)——最小限のセットアップと初回のレビュー。\n- [CLI リファレンス](../cli-reference/)——review コマンドが受け入れる各引数。\n"}, "e6895dd15481e2612bf65114fab728fabd32451e": {"t": 1785846923.272841, "n": 5770, "h": "fedaf895861fa764", "c": "---\ntitle: 設定\nsidebar:\n order: 5\n---\n\n設定ファイルは `~/.opencodereview/config.json` にあります。編集方法は 3 つあります。\n\n- **インタラクティブ TUI** —— `ocr config provider` / `ocr config model`。ガイド付きメニューが表示されます。\n- **コマンドライン** —— `ocr config set <key> <value>`。スクリプトや CI に適しています。\n- **手動編集(非推奨)** —— この JSON ファイルを直接編集(次回の `ocr config set` 書き込み時に再フォーマットされます)。\n\n## モデルを設定する\n\n### 推奨:インタラクティブ設定\n\n```bash\nocr config provider\n```\n\n組み込みまたはカスタムの provider を選択し、API key を入力し、model を選び、すべてを設定ファイルに保存したうえで、`ocr llm test` を 1 回実行してエンドポイントを検証します。あとで model を切り替えるには:\n\n```bash\nocr config model\n```\n\n### 非インタラクティブ設定(CI / TUI なし環境)\n\n`ocr config set` で同じ設定に書き込みます。\n\n```bash\nocr config set provider anthropic\nocr config set model claude-opus-4-6\nocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx\n```\n\n### 組み込み provider\n\n以下の provider が OCR に同梱されており、Base URL とプロトコルがプリセット\nされています——選択後は API key を入力するだけです。`providers.<name>.api_key`\nが未設定の場合は、対応する環境変数に自動的にフォールバックします。\n\n| 名称 | プロトコル | Base URL | API key 環境変数 |\n|---|---|---|---|\n| `anthropic` | anthropic | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` |\n| `openai` | openai | `https://api.openai.com/v1` | `OPENAI_API_KEY` |\n| `dashscope` | openai | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_API_KEY` |\n| `dashscope-tokenplan` | openai | `https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_TOKENPLAN_KEY` |\n| `volcengine` | openai | `https://ark.cn-beijing.volces.com/api/v3` | `ARK_API_KEY` |\n| `deepseek` | openai | `https://api.deepseek.com` | `DEEPSEEK_API_KEY` |\n| `tencent-tokenhub` | openai | `https://tokenhub.tencentmaas.com/v1` | `TENCENT_TOKENHUB_API_KEY` |\n| `hy-tokenplan` | openai | `https://api.lkeap.cloud.tencent.com/plan/v3` | `TENCENT_HUNYUAN_TOKENPLAN_KEY` |\n| `iflytek` | openai | `https://spark-api-open.xf-yun.com/v1` | `SPARK_API_KEY` |\n| `kimi` | openai | `https://api.moonshot.cn/v1` | `MOONSHOT_API_KEY` |\n| `z-ai` | openai | `https://open.bigmodel.cn/api/paas/v4` | `Z_AI_API_KEY` |\n| `mimo` | openai | `https://api.xiaomimimo.com/v1` | `MIMO_API_KEY` |\n| `minimax` | openai | `https://api.minimaxi.com/v1` | `MINIMAX_API_KEY` |\n| `baidu-qianfan` | openai | `https://qianfan.baidubce.com/v2` | `QIANFAN_API_KEY` |\n\n### カスタム provider\n\n上記の表にない provider 名はすべてカスタムとみなされ、少なくとも `url` と\n`protocol` を指定する必要があります(`protocol` は `anthropic`、`openai`、\nまたは `openai-responses`)。\n\n```bash\nocr config set provider my-gateway\nocr config set custom_providers.my-gateway.url https://gateway.internal.com/v1\nocr config set custom_providers.my-gateway.protocol openai\nocr config set custom_providers.my-gateway.model llama-3-70b\nocr config set custom_providers.my-gateway.api_key \"$MY_API_KEY\"\n```\n\nprovider またはモデルが OpenAI Responses API(`/v1/responses`)を必要とする場合は、\n`openai-responses` プロトコルを使用します。\n\n```bash\nocr config set provider openai-responses-gateway\nocr config set custom_providers.openai-responses-gateway.url https://api.openai.com/v1\nocr config set custom_providers.openai-responses-gateway.protocol openai-responses\nocr config set custom_providers.openai-responses-gateway.model gpt-5\nocr config set custom_providers.openai-responses-gateway.api_key \"$OPENAI_API_KEY\"\n```\n\n`url` には API の Base URL または完全な `/responses` エンドポイントのどちらを指定してもよく、OCR がどちらの形式も正規化します。\n\nOllama で動かすローカルモデルは、ローカルの OpenAI 互換エンドポイントを\n指すカスタム provider にすぎません。\n\n```bash\nocr config set provider ollama\nocr config set custom_providers.ollama.url http://127.0.0.1:11434/v1\nocr config set custom_providers.ollama.protocol openai\nocr config set custom_providers.ollama.model qwen3:32b\nocr config set custom_providers.ollama.api_key ollama\n```\n\nOllama は API key を無視しますが、カスタム provider は空でない `api_key` を\n必要とします(カスタム provider には環境変数のフォールバックがありません)。\nそのため任意のプレースホルダー値を設定してください。モデル自体はネイティブな\nツール呼び出しをサポートしている必要があります——選ぶ前に FAQ の\n[\"No tool calls parsed\"(ローカルモデル / Ollama)](../faq/#no-tool-calls-parsed-ollama)を\n参照してください。\n\n### タイムアウト(Timeouts)\n\n各 LLM リクエストには HTTP タイムアウトがあり、デフォルトは **300 秒**です。\n遅いローカルモデル(あるいは大きなファイル)では、それ以上の時間が必要になることがあります。\nスコープの狭い順に、3 つの設定があります。\n\n- `providers.<name>.timeout_sec` / `custom_providers.<name>.timeout_sec`\n ——provider ごと、秒単位。\n- `llm.timeout_sec`——レガシーな `llm` セクション用、秒単位。\n- `OCR_LLM_TIMEOUT` 環境変数——整数(秒単位)。すべての解決パスで設定ファイルの\n 値を上書きします。\n\n`timeout_sec` key は `ocr config set` ではサポートされていません——\n`~/.opencodereview/config.json` を直接編集してください。\n\n```json\n{\n \"custom_providers\": {\n \"ollama\": { \"url\": \"http://127.0.0.1:11434/v1\", \"protocol\": \"openai\", \"timeout_sec\": 900 }\n }\n}\n```\n\n### 接続性を検証する\n\n```bash\nocr llm test\n```\n\n### 既存の環境変数を再利用する\n\nClaude Code の `ANTHROPIC_*` や OCR 独自の `OCR_LLM_*` 環境変数をすでに\n設定している場合、OCR はそれらを自動的に認識するため、設定ファイルを書く\n必要はありません。\n\n### CC-Switch を使う\n\n[CC-Switch](https://github.com/farion1231/cc-switch) を\n[ルーティングサービス](https://www.ccswitch.io/en/docs?section=proxy&item=service)\n有効で使用している場合、プロバイダーの `url` をローカルプロキシに向けるだけで、\n追加設定なしで利用できます:\n\n```bash\n# Claude(Anthropic 互換)\nocr config set providers.anthropic.url http://127.0.0.1:15721\n\n# Codex / OpenAI 互換 — そのプロバイダーの url キーを設定\nocr config set providers.<name>.url http://127.0.0.1:15721/v1\n```\n\n`api_key` は任意の値で構いません。`extra_body`(およびその他のプロバイダー固有フィールド)は\n引き続き有効です。\n\n### ベンダー固有のフィールドを送信する\n\n一部の provider は非標準のリクエストフィールド(Bedrock 風の `thinking` など)を\n必要とします。`extra_body`(各リクエストにマージされます)を使えば、ソースコードを\n変更せずにそれらを送信できます。\n\n```bash\nocr config set providers.anthropic.extra_body '{\"thinking\":{\"type\":\"disabled\"}}'\n```\n\n## レビュー言語を設定する\n\n`language` はレビューコメントをどの言語で出力するかを決めます。未設定の場合は\nデフォルトで英語になります。\n\n```bash\nocr config set language 中文\nocr config set language English\n```\n\n## 関連項目\n\n- [クイックスタート](../quickstart/)——最小限のセットアップと初回のレビュー。\n- [CLI リファレンス](../cli-reference/)——review コマンドが受け入れる各引数。\n"}, "e9d4db51ec1d06fef1123d5a7158a7b311043650": {"t": 1785846959.6413512, "n": 5770, "h": "fedaf895861fa764", "c": "---\ntitle: 設定\nsidebar:\n order: 5\n---\n\n設定ファイルは `~/.opencodereview/config.json` にあります。編集方法は 3 つあります。\n\n- **インタラクティブ TUI** —— `ocr config provider` / `ocr config model`。ガイド付きメニューが表示されます。\n- **コマンドライン** —— `ocr config set <key> <value>`。スクリプトや CI に適しています。\n- **手動編集(非推奨)** —— この JSON ファイルを直接編集(次回の `ocr config set` 書き込み時に再フォーマットされます)。\n\n## モデルを設定する\n\n### 推奨:インタラクティブ設定\n\n```bash\nocr config provider\n```\n\n組み込みまたはカスタムの provider を選択し、API key を入力し、model を選び、すべてを設定ファイルに保存したうえで、`ocr llm test` を 1 回実行してエンドポイントを検証します。あとで model を切り替えるには:\n\n```bash\nocr config model\n```\n\n### 非インタラクティブ設定(CI / TUI なし環境)\n\n`ocr config set` で同じ設定に書き込みます。\n\n```bash\nocr config set provider anthropic\nocr config set model claude-opus-4-6\nocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx\n```\n\n### 組み込み provider\n\n以下の provider が OCR に同梱されており、Base URL とプロトコルがプリセット\nされています——選択後は API key を入力するだけです。`providers.<name>.api_key`\nが未設定の場合は、対応する環境変数に自動的にフォールバックします。\n\n| 名称 | プロトコル | Base URL | API key 環境変数 |\n|---|---|---|---|\n| `anthropic` | anthropic | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` |\n| `openai` | openai | `https://api.openai.com/v1` | `OPENAI_API_KEY` |\n| `dashscope` | openai | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_API_KEY` |\n| `dashscope-tokenplan` | openai | `https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_TOKENPLAN_KEY` |\n| `volcengine` | openai | `https://ark.cn-beijing.volces.com/api/v3` | `ARK_API_KEY` |\n| `deepseek` | openai | `https://api.deepseek.com` | `DEEPSEEK_API_KEY` |\n| `tencent-tokenhub` | openai | `https://tokenhub.tencentmaas.com/v1` | `TENCENT_TOKENHUB_API_KEY` |\n| `hy-tokenplan` | openai | `https://api.lkeap.cloud.tencent.com/plan/v3` | `TENCENT_HUNYUAN_TOKENPLAN_KEY` |\n| `iflytek` | openai | `https://spark-api-open.xf-yun.com/v1` | `SPARK_API_KEY` |\n| `kimi` | openai | `https://api.moonshot.cn/v1` | `MOONSHOT_API_KEY` |\n| `z-ai` | openai | `https://open.bigmodel.cn/api/paas/v4` | `Z_AI_API_KEY` |\n| `mimo` | openai | `https://api.xiaomimimo.com/v1` | `MIMO_API_KEY` |\n| `minimax` | openai | `https://api.minimaxi.com/v1` | `MINIMAX_API_KEY` |\n| `baidu-qianfan` | openai | `https://qianfan.baidubce.com/v2` | `QIANFAN_API_KEY` |\n\n### カスタム provider\n\n上記の表にない provider 名はすべてカスタムとみなされ、少なくとも `url` と\n`protocol` を指定する必要があります(`protocol` は `anthropic`、`openai`、\nまたは `openai-responses`)。\n\n```bash\nocr config set provider my-gateway\nocr config set custom_providers.my-gateway.url https://gateway.internal.com/v1\nocr config set custom_providers.my-gateway.protocol openai\nocr config set custom_providers.my-gateway.model llama-3-70b\nocr config set custom_providers.my-gateway.api_key \"$MY_API_KEY\"\n```\n\nprovider またはモデルが OpenAI Responses API(`/v1/responses`)を必要とする場合は、\n`openai-responses` プロトコルを使用します。\n\n```bash\nocr config set provider openai-responses-gateway\nocr config set custom_providers.openai-responses-gateway.url https://api.openai.com/v1\nocr config set custom_providers.openai-responses-gateway.protocol openai-responses\nocr config set custom_providers.openai-responses-gateway.model gpt-5\nocr config set custom_providers.openai-responses-gateway.api_key \"$OPENAI_API_KEY\"\n```\n\n`url` には API の Base URL または完全な `/responses` エンドポイントのどちらを指定してもよく、OCR がどちらの形式も正規化します。\n\nOllama で動かすローカルモデルは、ローカルの OpenAI 互換エンドポイントを\n指すカスタム provider にすぎません。\n\n```bash\nocr config set provider ollama\nocr config set custom_providers.ollama.url http://127.0.0.1:11434/v1\nocr config set custom_providers.ollama.protocol openai\nocr config set custom_providers.ollama.model qwen3:32b\nocr config set custom_providers.ollama.api_key ollama\n```\n\nOllama は API key を無視しますが、カスタム provider は空でない `api_key` を\n必要とします(カスタム provider には環境変数のフォールバックがありません)。\nそのため任意のプレースホルダー値を設定してください。モデル自体はネイティブな\nツール呼び出しをサポートしている必要があります——選ぶ前に FAQ の\n[\"No tool calls parsed\"(ローカルモデル / Ollama)](../faq/#no-tool-calls-parsed-ollama)を\n参照してください。\n\n### タイムアウト(Timeouts)\n\n各 LLM リクエストには HTTP タイムアウトがあり、デフォルトは **300 秒**です。\n遅いローカルモデル(あるいは大きなファイル)では、それ以上の時間が必要になることがあります。\nスコープの狭い順に、3 つの設定があります。\n\n- `providers.<name>.timeout_sec` / `custom_providers.<name>.timeout_sec`\n ——provider ごと、秒単位。\n- `llm.timeout_sec`——レガシーな `llm` セクション用、秒単位。\n- `OCR_LLM_TIMEOUT` 環境変数——整数(秒単位)。すべての解決パスで設定ファイルの\n 値を上書きします。\n\n`timeout_sec` key は `ocr config set` ではサポートされていません——\n`~/.opencodereview/config.json` を直接編集してください。\n\n```json\n{\n \"custom_providers\": {\n \"ollama\": { \"url\": \"http://127.0.0.1:11434/v1\", \"protocol\": \"openai\", \"timeout_sec\": 900 }\n }\n}\n```\n\n### 接続性を検証する\n\n```bash\nocr llm test\n```\n\n### 既存の環境変数を再利用する\n\nClaude Code の `ANTHROPIC_*` や OCR 独自の `OCR_LLM_*` 環境変数をすでに\n設定している場合、OCR はそれらを自動的に認識するため、設定ファイルを書く\n必要はありません。\n\n### CC-Switch を使う\n\n[CC-Switch](https://github.com/farion1231/cc-switch) を\n[ルーティングサービス](https://www.ccswitch.io/en/docs?section=proxy&item=service)\n有効で使用している場合、プロバイダーの `url` をローカルプロキシに向けるだけで、\n追加設定なしで利用できます:\n\n```bash\n# Claude(Anthropic 互換)\nocr config set providers.anthropic.url http://127.0.0.1:15721\n\n# Codex / OpenAI 互換 — そのプロバイダーの url キーを設定\nocr config set providers.<name>.url http://127.0.0.1:15721/v1\n```\n\n`api_key` は任意の値で構いません。`extra_body`(およびその他のプロバイダー固有フィールド)は\n引き続き有効です。\n\n### ベンダー固有のフィールドを送信する\n\n一部の provider は非標準のリクエストフィールド(Bedrock 風の `thinking` など)を\n必要とします。`extra_body`(各リクエストにマージされます)を使えば、ソースコードを\n変更せずにそれらを送信できます。\n\n```bash\nocr config set providers.anthropic.extra_body '{\"thinking\":{\"type\":\"disabled\"}}'\n```\n\n## レビュー言語を設定する\n\n`language` はレビューコメントをどの言語で出力するかを決めます。未設定の場合は\nデフォルトで英語になります。\n\n```bash\nocr config set language 中文\nocr config set language English\n```\n\n## 関連項目\n\n- [クイックスタート](../quickstart/)——最小限のセットアップと初回のレビュー。\n- [CLI リファレンス](../cli-reference/)——review コマンドが受け入れる各引数。\n"}}, "pages/src/content/docs/en/configuration.md": {"5829539379ecc3c2e43c36061b73469f7a261f8d": {"t": 1785674662.415503, "n": 6790, "h": "0ce4b8721bb9f682", "c": "---\ntitle: Configuration\nsidebar:\n order: 5\n---\n\nThe config file lives at `~/.opencodereview/config.json`. You have three ways\nto edit it:\n\n- **Interactive TUI** — `ocr config provider` / `ocr config model`, with guided menus.\n- **Command line** — `ocr config set <key> <value>`, ideal for scripts and CI.\n- **Manual edit (not recommended)** — the JSON file directly (it gets reformatted on the next `ocr config set` write).\n\n## Configuring a model\n\n### Recommended: interactive setup\n\n```bash\nocr config provider\n```\n\nIt lets you pick a built-in or custom provider, enter an API key, choose a model, saves everything to the config file, and then runs `ocr llm test` once to verify the endpoint. To switch models later:\n\n```bash\nocr config model\n```\n\n### Non-interactive setup (CI / no-TUI environments)\n\nWrite to the same config with `ocr config set`:\n\n```bash\nocr config set provider anthropic\nocr config set model claude-opus-4-6\nocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx\n```\n\n### Built-in providers\n\nThe following providers ship with OCR, with the Base URL and protocol\npreset — once selected, you only need to fill in the API key. If\n`providers.<name>.api_key` is unset, OCR falls back to the corresponding\nenvironment variable.\n\n| Name | Protocol | Base URL | API key env var |\n|---|---|---|---|\n| `anthropic` | anthropic | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` |\n| `openai` | openai | `https://api.openai.com/v1` | `OPENAI_API_KEY` |\n| `dashscope` | openai | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_API_KEY` |\n| `dashscope-tokenplan` | openai | `https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_TOKENPLAN_KEY` |\n| `volcengine` | openai | `https://ark.cn-beijing.volces.com/api/v3` | `ARK_API_KEY` |\n| `deepseek` | openai | `https://api.deepseek.com` | `DEEPSEEK_API_KEY` |\n| `tencent-tokenhub` | openai | `https://tokenhub.tencentmaas.com/v1` | `TENCENT_TOKENHUB_API_KEY` |\n| `hy-tokenplan` | openai | `https://api.lkeap.cloud.tencent.com/plan/v3` | `TENCENT_HUNYUAN_TOKENPLAN_KEY` |\n| `iflytek` | openai | `https://spark-api-open.xf-yun.com/v1` | `SPARK_API_KEY` |\n| `kimi` | openai | `https://api.moonshot.cn/v1` | `MOONSHOT_API_KEY` |\n| `z-ai` | openai | `https://open.bigmodel.cn/api/paas/v4` | `Z_AI_API_KEY` |\n| `mimo` | openai | `https://api.xiaomimimo.com/v1` | `MIMO_API_KEY` |\n| `minimax` | openai | `https://api.minimaxi.com/v1` | `MINIMAX_API_KEY` |\n| `baidu-qianfan` | openai | `https://qianfan.baidubce.com/v2` | `QIANFAN_API_KEY` |\n\n### Custom providers\n\nAny provider name not in the table above is treated as custom and must\nsupply at least `url` and `protocol` (`protocol` is `anthropic`,\n`openai`, or `openai-responses`):\n\n```bash\nocr config set provider my-gateway\nocr config set custom_providers.my-gateway.url https://gateway.internal.com/v1\nocr config set custom_providers.my-gateway.protocol openai\nocr config set custom_providers.my-gateway.model llama-3-70b\nocr config set custom_providers.my-gateway.api_key \"$MY_API_KEY\"\n```\n\nUse `openai-responses` when a provider or model requires the OpenAI\nResponses API (`/v1/responses`):\n\n```bash\nocr config set provider openai-responses-gateway\nocr config set custom_providers.openai-responses-gateway.url https://api.openai.com/v1\nocr config set custom_providers.openai-responses-gateway.protocol openai-responses\nocr config set custom_providers.openai-responses-gateway.model gpt-5\nocr config set custom_providers.openai-responses-gateway.api_key \"$OPENAI_API_KEY\"\n```\n\nThe `url` can be either the API base URL or the full `/responses` endpoint — OCR normalizes it either way.\n\nA local model served by Ollama is just a custom provider pointing at the\nlocal OpenAI-compatible endpoint:\n\n```bash\nocr config set provider ollama\nocr config set custom_providers.ollama.url http://127.0.0.1:11434/v1\nocr config set custom_providers.ollama.protocol openai\nocr config set custom_providers.ollama.model qwen3:32b\nocr config set custom_providers.ollama.api_key ollama\n```\n\nOllama ignores the API key, but custom providers require a non-empty\n`api_key` (there is no environment-variable fallback for them), so set\nany placeholder value. The model itself must support native tool\ncalling — see\n[\"No tool calls parsed\" (local models / Ollama)](../faq/#no-tool-calls-parsed-local-models-ollama)\nin the FAQ before picking one.\n\n### Timeouts\n\nEach LLM request has an HTTP timeout, defaulting to **300 seconds**.\nSlow local models (or large files) can need more. Three knobs, in\nincreasing scope:\n\n- `providers.<name>.timeout_sec` / `custom_providers.<name>.timeout_sec`\n — per-provider, in seconds.\n- `llm.timeout_sec` — for the legacy `llm` section, in seconds.\n- `OCR_LLM_TIMEOUT` environment variable — integer seconds; overrides\n the config-file value for every resolution path.\n\nThe `timeout_sec` keys are not supported by `ocr config set` — edit\n`~/.opencodereview/config.json` directly:\n\n```json\n{\n \"custom_providers\": {\n \"ollama\": { \"url\": \"http://127.0.0.1:11434/v1\", \"protocol\": \"openai\", \"timeout_sec\": 900 }\n }\n}\n```\n\n### Verify connectivity\n\n```bash\nocr llm test\n```\n\n### Reuse existing environment variables\n\nIf you already have Claude Code's `ANTHROPIC_*` or OCR's own `OCR_LLM_*`\nenvironment variables configured, OCR picks them up automatically — no\nconfig file needed.\n\n### Using CC-Switch\n\nIf you use [CC-Switch](https://github.com/farion1231/cc-switch) with its\n[routing service](https://www.ccswitch.io/en/docs?section=proxy&item=service)\nenabled, point the provider `url` at the local proxy — no other setup is\nrequired:\n\n```bash\n# Claude (Anthropic-compatible)\nocr config set providers.anthropic.url http://127.0.0.1:15721\n\n# Codex / OpenAI-compatible — set that provider's url key instead\nocr config set providers.<name>.url http://127.0.0.1:15721/v1\n```\n\n`api_key` can be any value. `extra_body` (and other per-provider fields)\nstill apply as usual.\n\n### Send vendor-specific fields\n\nSome providers require non-standard request fields (such as Bedrock-style\n`thinking`). Use `extra_body` (merged into every request) to send them\nwithout patching the source:\n\n```bash\nocr config set providers.anthropic.extra_body '{\"thinking\":{\"type\":\"disabled\"}}'\n```\n\n## Configuring the review language\n\n`language` determines which language review comments are written in;\nit defaults to English when unset:\n\n```bash\nocr config set language 中文\nocr config set language English\n```\n\n## See Also\n\n- [QuickStart](../quickstart/) — minimal setup and first review.\n- [CLI Reference](../cli-reference/) — every flag the review command accepts.\n"}, "0f3c920fc8ff091cbd1ad9e0632bf54bba56e46b": {"t": 1785809839.0882032, "n": 6790, "h": "0ce4b8721bb9f682", "c": "---\ntitle: Configuration\nsidebar:\n order: 5\n---\n\nThe config file lives at `~/.opencodereview/config.json`. You have three ways\nto edit it:\n\n- **Interactive TUI** — `ocr config provider` / `ocr config model`, with guided menus.\n- **Command line** — `ocr config set <key> <value>`, ideal for scripts and CI.\n- **Manual edit (not recommended)** — the JSON file directly (it gets reformatted on the next `ocr config set` write).\n\n## Configuring a model\n\n### Recommended: interactive setup\n\n```bash\nocr config provider\n```\n\nIt lets you pick a built-in or custom provider, enter an API key, choose a model, saves everything to the config file, and then runs `ocr llm test` once to verify the endpoint. To switch models later:\n\n```bash\nocr config model\n```\n\n### Non-interactive setup (CI / no-TUI environments)\n\nWrite to the same config with `ocr config set`:\n\n```bash\nocr config set provider anthropic\nocr config set model claude-opus-4-6\nocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx\n```\n\n### Built-in providers\n\nThe following providers ship with OCR, with the Base URL and protocol\npreset — once selected, you only need to fill in the API key. If\n`providers.<name>.api_key` is unset, OCR falls back to the corresponding\nenvironment variable.\n\n| Name | Protocol | Base URL | API key env var |\n|---|---|---|---|\n| `anthropic` | anthropic | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` |\n| `openai` | openai | `https://api.openai.com/v1` | `OPENAI_API_KEY` |\n| `dashscope` | openai | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_API_KEY` |\n| `dashscope-tokenplan` | openai | `https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_TOKENPLAN_KEY` |\n| `volcengine` | openai | `https://ark.cn-beijing.volces.com/api/v3` | `ARK_API_KEY` |\n| `deepseek` | openai | `https://api.deepseek.com` | `DEEPSEEK_API_KEY` |\n| `tencent-tokenhub` | openai | `https://tokenhub.tencentmaas.com/v1` | `TENCENT_TOKENHUB_API_KEY` |\n| `hy-tokenplan` | openai | `https://api.lkeap.cloud.tencent.com/plan/v3` | `TENCENT_HUNYUAN_TOKENPLAN_KEY` |\n| `iflytek` | openai | `https://spark-api-open.xf-yun.com/v1` | `SPARK_API_KEY` |\n| `kimi` | openai | `https://api.moonshot.cn/v1` | `MOONSHOT_API_KEY` |\n| `z-ai` | openai | `https://open.bigmodel.cn/api/paas/v4` | `Z_AI_API_KEY` |\n| `mimo` | openai | `https://api.xiaomimimo.com/v1` | `MIMO_API_KEY` |\n| `minimax` | openai | `https://api.minimaxi.com/v1` | `MINIMAX_API_KEY` |\n| `baidu-qianfan` | openai | `https://qianfan.baidubce.com/v2` | `QIANFAN_API_KEY` |\n\n### Custom providers\n\nAny provider name not in the table above is treated as custom and must\nsupply at least `url` and `protocol` (`protocol` is `anthropic`,\n`openai`, or `openai-responses`):\n\n```bash\nocr config set provider my-gateway\nocr config set custom_providers.my-gateway.url https://gateway.internal.com/v1\nocr config set custom_providers.my-gateway.protocol openai\nocr config set custom_providers.my-gateway.model llama-3-70b\nocr config set custom_providers.my-gateway.api_key \"$MY_API_KEY\"\n```\n\nUse `openai-responses` when a provider or model requires the OpenAI\nResponses API (`/v1/responses`):\n\n```bash\nocr config set provider openai-responses-gateway\nocr config set custom_providers.openai-responses-gateway.url https://api.openai.com/v1\nocr config set custom_providers.openai-responses-gateway.protocol openai-responses\nocr config set custom_providers.openai-responses-gateway.model gpt-5\nocr config set custom_providers.openai-responses-gateway.api_key \"$OPENAI_API_KEY\"\n```\n\nThe `url` can be either the API base URL or the full `/responses` endpoint — OCR normalizes it either way.\n\nA local model served by Ollama is just a custom provider pointing at the\nlocal OpenAI-compatible endpoint:\n\n```bash\nocr config set provider ollama\nocr config set custom_providers.ollama.url http://127.0.0.1:11434/v1\nocr config set custom_providers.ollama.protocol openai\nocr config set custom_providers.ollama.model qwen3:32b\nocr config set custom_providers.ollama.api_key ollama\n```\n\nOllama ignores the API key, but custom providers require a non-empty\n`api_key` (there is no environment-variable fallback for them), so set\nany placeholder value. The model itself must support native tool\ncalling — see\n[\"No tool calls parsed\" (local models / Ollama)](../faq/#no-tool-calls-parsed-local-models-ollama)\nin the FAQ before picking one.\n\n### Timeouts\n\nEach LLM request has an HTTP timeout, defaulting to **300 seconds**.\nSlow local models (or large files) can need more. Three knobs, in\nincreasing scope:\n\n- `providers.<name>.timeout_sec` / `custom_providers.<name>.timeout_sec`\n — per-provider, in seconds.\n- `llm.timeout_sec` — for the legacy `llm` section, in seconds.\n- `OCR_LLM_TIMEOUT` environment variable — integer seconds; overrides\n the config-file value for every resolution path.\n\nThe `timeout_sec` keys are not supported by `ocr config set` — edit\n`~/.opencodereview/config.json` directly:\n\n```json\n{\n \"custom_providers\": {\n \"ollama\": { \"url\": \"http://127.0.0.1:11434/v1\", \"protocol\": \"openai\", \"timeout_sec\": 900 }\n }\n}\n```\n\n### Verify connectivity\n\n```bash\nocr llm test\n```\n\n### Reuse existing environment variables\n\nIf you already have Claude Code's `ANTHROPIC_*` or OCR's own `OCR_LLM_*`\nenvironment variables configured, OCR picks them up automatically — no\nconfig file needed.\n\n### Using CC-Switch\n\nIf you use [CC-Switch](https://github.com/farion1231/cc-switch) with its\n[routing service](https://www.ccswitch.io/en/docs?section=proxy&item=service)\nenabled, point the provider `url` at the local proxy — no other setup is\nrequired:\n\n```bash\n# Claude (Anthropic-compatible)\nocr config set providers.anthropic.url http://127.0.0.1:15721\n\n# Codex / OpenAI-compatible — set that provider's url key instead\nocr config set providers.<name>.url http://127.0.0.1:15721/v1\n```\n\n`api_key` can be any value. `extra_body` (and other per-provider fields)\nstill apply as usual.\n\n### Send vendor-specific fields\n\nSome providers require non-standard request fields (such as Bedrock-style\n`thinking`). Use `extra_body` (merged into every request) to send them\nwithout patching the source:\n\n```bash\nocr config set providers.anthropic.extra_body '{\"thinking\":{\"type\":\"disabled\"}}'\n```\n\n## Configuring the review language\n\n`language` determines which language review comments are written in;\nit defaults to English when unset:\n\n```bash\nocr config set language 中文\nocr config set language English\n```\n\n## See Also\n\n- [QuickStart](../quickstart/) — minimal setup and first review.\n- [CLI Reference](../cli-reference/) — every flag the review command accepts.\n"}, "e6895dd15481e2612bf65114fab728fabd32451e": {"t": 1785846922.7873585, "n": 6790, "h": "0ce4b8721bb9f682", "c": "---\ntitle: Configuration\nsidebar:\n order: 5\n---\n\nThe config file lives at `~/.opencodereview/config.json`. You have three ways\nto edit it:\n\n- **Interactive TUI** — `ocr config provider` / `ocr config model`, with guided menus.\n- **Command line** — `ocr config set <key> <value>`, ideal for scripts and CI.\n- **Manual edit (not recommended)** — the JSON file directly (it gets reformatted on the next `ocr config set` write).\n\n## Configuring a model\n\n### Recommended: interactive setup\n\n```bash\nocr config provider\n```\n\nIt lets you pick a built-in or custom provider, enter an API key, choose a model, saves everything to the config file, and then runs `ocr llm test` once to verify the endpoint. To switch models later:\n\n```bash\nocr config model\n```\n\n### Non-interactive setup (CI / no-TUI environments)\n\nWrite to the same config with `ocr config set`:\n\n```bash\nocr config set provider anthropic\nocr config set model claude-opus-4-6\nocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx\n```\n\n### Built-in providers\n\nThe following providers ship with OCR, with the Base URL and protocol\npreset — once selected, you only need to fill in the API key. If\n`providers.<name>.api_key` is unset, OCR falls back to the corresponding\nenvironment variable.\n\n| Name | Protocol | Base URL | API key env var |\n|---|---|---|---|\n| `anthropic` | anthropic | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` |\n| `openai` | openai | `https://api.openai.com/v1` | `OPENAI_API_KEY` |\n| `dashscope` | openai | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_API_KEY` |\n| `dashscope-tokenplan` | openai | `https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_TOKENPLAN_KEY` |\n| `volcengine` | openai | `https://ark.cn-beijing.volces.com/api/v3` | `ARK_API_KEY` |\n| `deepseek` | openai | `https://api.deepseek.com` | `DEEPSEEK_API_KEY` |\n| `tencent-tokenhub` | openai | `https://tokenhub.tencentmaas.com/v1` | `TENCENT_TOKENHUB_API_KEY` |\n| `hy-tokenplan` | openai | `https://api.lkeap.cloud.tencent.com/plan/v3` | `TENCENT_HUNYUAN_TOKENPLAN_KEY` |\n| `iflytek` | openai | `https://spark-api-open.xf-yun.com/v1` | `SPARK_API_KEY` |\n| `kimi` | openai | `https://api.moonshot.cn/v1` | `MOONSHOT_API_KEY` |\n| `z-ai` | openai | `https://open.bigmodel.cn/api/paas/v4` | `Z_AI_API_KEY` |\n| `mimo` | openai | `https://api.xiaomimimo.com/v1` | `MIMO_API_KEY` |\n| `minimax` | openai | `https://api.minimaxi.com/v1` | `MINIMAX_API_KEY` |\n| `baidu-qianfan` | openai | `https://qianfan.baidubce.com/v2` | `QIANFAN_API_KEY` |\n\n### Custom providers\n\nAny provider name not in the table above is treated as custom and must\nsupply at least `url` and `protocol` (`protocol` is `anthropic`,\n`openai`, or `openai-responses`):\n\n```bash\nocr config set provider my-gateway\nocr config set custom_providers.my-gateway.url https://gateway.internal.com/v1\nocr config set custom_providers.my-gateway.protocol openai\nocr config set custom_providers.my-gateway.model llama-3-70b\nocr config set custom_providers.my-gateway.api_key \"$MY_API_KEY\"\n```\n\nUse `openai-responses` when a provider or model requires the OpenAI\nResponses API (`/v1/responses`):\n\n```bash\nocr config set provider openai-responses-gateway\nocr config set custom_providers.openai-responses-gateway.url https://api.openai.com/v1\nocr config set custom_providers.openai-responses-gateway.protocol openai-responses\nocr config set custom_providers.openai-responses-gateway.model gpt-5\nocr config set custom_providers.openai-responses-gateway.api_key \"$OPENAI_API_KEY\"\n```\n\nThe `url` can be either the API base URL or the full `/responses` endpoint — OCR normalizes it either way.\n\nA local model served by Ollama is just a custom provider pointing at the\nlocal OpenAI-compatible endpoint:\n\n```bash\nocr config set provider ollama\nocr config set custom_providers.ollama.url http://127.0.0.1:11434/v1\nocr config set custom_providers.ollama.protocol openai\nocr config set custom_providers.ollama.model qwen3:32b\nocr config set custom_providers.ollama.api_key ollama\n```\n\nOllama ignores the API key, but custom providers require a non-empty\n`api_key` (there is no environment-variable fallback for them), so set\nany placeholder value. The model itself must support native tool\ncalling — see\n[\"No tool calls parsed\" (local models / Ollama)](../faq/#no-tool-calls-parsed-local-models-ollama)\nin the FAQ before picking one.\n\n### Timeouts\n\nEach LLM request has an HTTP timeout, defaulting to **300 seconds**.\nSlow local models (or large files) can need more. Three knobs, in\nincreasing scope:\n\n- `providers.<name>.timeout_sec` / `custom_providers.<name>.timeout_sec`\n — per-provider, in seconds.\n- `llm.timeout_sec` — for the legacy `llm` section, in seconds.\n- `OCR_LLM_TIMEOUT` environment variable — integer seconds; overrides\n the config-file value for every resolution path.\n\nThe `timeout_sec` keys are not supported by `ocr config set` — edit\n`~/.opencodereview/config.json` directly:\n\n```json\n{\n \"custom_providers\": {\n \"ollama\": { \"url\": \"http://127.0.0.1:11434/v1\", \"protocol\": \"openai\", \"timeout_sec\": 900 }\n }\n}\n```\n\n### Verify connectivity\n\n```bash\nocr llm test\n```\n\n### Reuse existing environment variables\n\nIf you already have Claude Code's `ANTHROPIC_*` or OCR's own `OCR_LLM_*`\nenvironment variables configured, OCR picks them up automatically — no\nconfig file needed.\n\n### Using CC-Switch\n\nIf you use [CC-Switch](https://github.com/farion1231/cc-switch) with its\n[routing service](https://www.ccswitch.io/en/docs?section=proxy&item=service)\nenabled, point the provider `url` at the local proxy — no other setup is\nrequired:\n\n```bash\n# Claude (Anthropic-compatible)\nocr config set providers.anthropic.url http://127.0.0.1:15721\n\n# Codex / OpenAI-compatible — set that provider's url key instead\nocr config set providers.<name>.url http://127.0.0.1:15721/v1\n```\n\n`api_key` can be any value. `extra_body` (and other per-provider fields)\nstill apply as usual.\n\n### Send vendor-specific fields\n\nSome providers require non-standard request fields (such as Bedrock-style\n`thinking`). Use `extra_body` (merged into every request) to send them\nwithout patching the source:\n\n```bash\nocr config set providers.anthropic.extra_body '{\"thinking\":{\"type\":\"disabled\"}}'\n```\n\n## Configuring the review language\n\n`language` determines which language review comments are written in;\nit defaults to English when unset:\n\n```bash\nocr config set language 中文\nocr config set language English\n```\n\n## See Also\n\n- [QuickStart](../quickstart/) — minimal setup and first review.\n- [CLI Reference](../cli-reference/) — every flag the review command accepts.\n"}, "e9d4db51ec1d06fef1123d5a7158a7b311043650": {"t": 1785846959.726488, "n": 6790, "h": "0ce4b8721bb9f682", "c": "---\ntitle: Configuration\nsidebar:\n order: 5\n---\n\nThe config file lives at `~/.opencodereview/config.json`. You have three ways\nto edit it:\n\n- **Interactive TUI** — `ocr config provider` / `ocr config model`, with guided menus.\n- **Command line** — `ocr config set <key> <value>`, ideal for scripts and CI.\n- **Manual edit (not recommended)** — the JSON file directly (it gets reformatted on the next `ocr config set` write).\n\n## Configuring a model\n\n### Recommended: interactive setup\n\n```bash\nocr config provider\n```\n\nIt lets you pick a built-in or custom provider, enter an API key, choose a model, saves everything to the config file, and then runs `ocr llm test` once to verify the endpoint. To switch models later:\n\n```bash\nocr config model\n```\n\n### Non-interactive setup (CI / no-TUI environments)\n\nWrite to the same config with `ocr config set`:\n\n```bash\nocr config set provider anthropic\nocr config set model claude-opus-4-6\nocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx\n```\n\n### Built-in providers\n\nThe following providers ship with OCR, with the Base URL and protocol\npreset — once selected, you only need to fill in the API key. If\n`providers.<name>.api_key` is unset, OCR falls back to the corresponding\nenvironment variable.\n\n| Name | Protocol | Base URL | API key env var |\n|---|---|---|---|\n| `anthropic` | anthropic | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` |\n| `openai` | openai | `https://api.openai.com/v1` | `OPENAI_API_KEY` |\n| `dashscope` | openai | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_API_KEY` |\n| `dashscope-tokenplan` | openai | `https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_TOKENPLAN_KEY` |\n| `volcengine` | openai | `https://ark.cn-beijing.volces.com/api/v3` | `ARK_API_KEY` |\n| `deepseek` | openai | `https://api.deepseek.com` | `DEEPSEEK_API_KEY` |\n| `tencent-tokenhub` | openai | `https://tokenhub.tencentmaas.com/v1` | `TENCENT_TOKENHUB_API_KEY` |\n| `hy-tokenplan` | openai | `https://api.lkeap.cloud.tencent.com/plan/v3` | `TENCENT_HUNYUAN_TOKENPLAN_KEY` |\n| `iflytek` | openai | `https://spark-api-open.xf-yun.com/v1` | `SPARK_API_KEY` |\n| `kimi` | openai | `https://api.moonshot.cn/v1` | `MOONSHOT_API_KEY` |\n| `z-ai` | openai | `https://open.bigmodel.cn/api/paas/v4` | `Z_AI_API_KEY` |\n| `mimo` | openai | `https://api.xiaomimimo.com/v1` | `MIMO_API_KEY` |\n| `minimax` | openai | `https://api.minimaxi.com/v1` | `MINIMAX_API_KEY` |\n| `baidu-qianfan` | openai | `https://qianfan.baidubce.com/v2` | `QIANFAN_API_KEY` |\n\n### Custom providers\n\nAny provider name not in the table above is treated as custom and must\nsupply at least `url` and `protocol` (`protocol` is `anthropic`,\n`openai`, or `openai-responses`):\n\n```bash\nocr config set provider my-gateway\nocr config set custom_providers.my-gateway.url https://gateway.internal.com/v1\nocr config set custom_providers.my-gateway.protocol openai\nocr config set custom_providers.my-gateway.model llama-3-70b\nocr config set custom_providers.my-gateway.api_key \"$MY_API_KEY\"\n```\n\nUse `openai-responses` when a provider or model requires the OpenAI\nResponses API (`/v1/responses`):\n\n```bash\nocr config set provider openai-responses-gateway\nocr config set custom_providers.openai-responses-gateway.url https://api.openai.com/v1\nocr config set custom_providers.openai-responses-gateway.protocol openai-responses\nocr config set custom_providers.openai-responses-gateway.model gpt-5\nocr config set custom_providers.openai-responses-gateway.api_key \"$OPENAI_API_KEY\"\n```\n\nThe `url` can be either the API base URL or the full `/responses` endpoint — OCR normalizes it either way.\n\nA local model served by Ollama is just a custom provider pointing at the\nlocal OpenAI-compatible endpoint:\n\n```bash\nocr config set provider ollama\nocr config set custom_providers.ollama.url http://127.0.0.1:11434/v1\nocr config set custom_providers.ollama.protocol openai\nocr config set custom_providers.ollama.model qwen3:32b\nocr config set custom_providers.ollama.api_key ollama\n```\n\nOllama ignores the API key, but custom providers require a non-empty\n`api_key` (there is no environment-variable fallback for them), so set\nany placeholder value. The model itself must support native tool\ncalling — see\n[\"No tool calls parsed\" (local models / Ollama)](../faq/#no-tool-calls-parsed-local-models-ollama)\nin the FAQ before picking one.\n\n### Timeouts\n\nEach LLM request has an HTTP timeout, defaulting to **300 seconds**.\nSlow local models (or large files) can need more. Three knobs, in\nincreasing scope:\n\n- `providers.<name>.timeout_sec` / `custom_providers.<name>.timeout_sec`\n — per-provider, in seconds.\n- `llm.timeout_sec` — for the legacy `llm` section, in seconds.\n- `OCR_LLM_TIMEOUT` environment variable — integer seconds; overrides\n the config-file value for every resolution path.\n\nThe `timeout_sec` keys are not supported by `ocr config set` — edit\n`~/.opencodereview/config.json` directly:\n\n```json\n{\n \"custom_providers\": {\n \"ollama\": { \"url\": \"http://127.0.0.1:11434/v1\", \"protocol\": \"openai\", \"timeout_sec\": 900 }\n }\n}\n```\n\n### Verify connectivity\n\n```bash\nocr llm test\n```\n\n### Reuse existing environment variables\n\nIf you already have Claude Code's `ANTHROPIC_*` or OCR's own `OCR_LLM_*`\nenvironment variables configured, OCR picks them up automatically — no\nconfig file needed.\n\n### Using CC-Switch\n\nIf you use [CC-Switch](https://github.com/farion1231/cc-switch) with its\n[routing service](https://www.ccswitch.io/en/docs?section=proxy&item=service)\nenabled, point the provider `url` at the local proxy — no other setup is\nrequired:\n\n```bash\n# Claude (Anthropic-compatible)\nocr config set providers.anthropic.url http://127.0.0.1:15721\n\n# Codex / OpenAI-compatible — set that provider's url key instead\nocr config set providers.<name>.url http://127.0.0.1:15721/v1\n```\n\n`api_key` can be any value. `extra_body` (and other per-provider fields)\nstill apply as usual.\n\n### Send vendor-specific fields\n\nSome providers require non-standard request fields (such as Bedrock-style\n`thinking`). Use `extra_body` (merged into every request) to send them\nwithout patching the source:\n\n```bash\nocr config set providers.anthropic.extra_body '{\"thinking\":{\"type\":\"disabled\"}}'\n```\n\n## Configuring the review language\n\n`language` determines which language review comments are written in;\nit defaults to English when unset:\n\n```bash\nocr config set language 中文\nocr config set language English\n```\n\n## See Also\n\n- [QuickStart](../quickstart/) — minimal setup and first review.\n- [CLI Reference](../cli-reference/) — every flag the review command accepts.\n"}}, "pages/src/content/docs/zh/configuration.md": {"5829539379ecc3c2e43c36061b73469f7a261f8d": {"t": 1785674662.5233676, "n": 5203, "h": "d038372cc8ea9f26", "c": "---\ntitle: 配置\nsidebar:\n order: 5\n---\n\n配置文件在 `~/.opencodereview/config.json`,你有三种方式编辑它:\n\n- **交互式 TUI** —— `ocr config provider` / `ocr config model`,带引导菜单。\n- **命令行** —— `ocr config set <key> <value>`,适合脚本与 CI。\n- **手动编辑(不推荐)** —— 该 JSON 文件(下次 `ocr config set` 写入时会重新格式化)。\n\n## 配置模型\n\n### 推荐:交互式设置\n\n```bash\nocr config provider\n```\n\n它会让你选择一个内置或自定义 provider、填入 API key、挑选 model,保存到配置文件后自动运行一次 `ocr llm test` 验证端点。之后想换模型:\n\n```bash\nocr config model\n```\n\n### 非交互设置(CI / 无 TUI 环境)\n\n用 `ocr config set` 写入同一份配置:\n\n```bash\nocr config set provider anthropic\nocr config set model claude-opus-4-6\nocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx\n```\n\n### 内置 provider\n\n下列 provider 随 OCR 发布,已预置 Base URL 与协议,选中后只需填 API key。\n若 `providers.<name>.api_key` 未设置,会自动回退到对应的环境变量。\n\n| 名称 | 协议 | Base URL | API key 环境变量 |\n|---|---|---|---|\n| `anthropic` | anthropic | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` |\n| `openai` | openai | `https://api.openai.com/v1` | `OPENAI_API_KEY` |\n| `dashscope` | openai | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_API_KEY` |\n| `dashscope-tokenplan` | openai | `https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_TOKENPLAN_KEY` |\n| `volcengine` | openai | `https://ark.cn-beijing.volces.com/api/v3` | `ARK_API_KEY` |\n| `deepseek` | openai | `https://api.deepseek.com` | `DEEPSEEK_API_KEY` |\n| `tencent-tokenhub` | openai | `https://tokenhub.tencentmaas.com/v1` | `TENCENT_TOKENHUB_API_KEY` |\n| `hy-tokenplan` | openai | `https://api.lkeap.cloud.tencent.com/plan/v3` | `TENCENT_HUNYUAN_TOKENPLAN_KEY` |\n| `iflytek` | openai | `https://spark-api-open.xf-yun.com/v1` | `SPARK_API_KEY` |\n| `kimi` | openai | `https://api.moonshot.cn/v1` | `MOONSHOT_API_KEY` |\n| `z-ai` | openai | `https://open.bigmodel.cn/api/paas/v4` | `Z_AI_API_KEY` |\n| `mimo` | openai | `https://api.xiaomimimo.com/v1` | `MIMO_API_KEY` |\n| `minimax` | openai | `https://api.minimaxi.com/v1` | `MINIMAX_API_KEY` |\n| `baidu-qianfan` | openai | `https://qianfan.baidubce.com/v2` | `QIANFAN_API_KEY` |\n\n### 自定义 provider\n\n任何不在上表中的 provider 名都视为自定义,至少要提供 `url` 和 `protocol`\n(`protocol` 取 `anthropic`、`openai` 或 `openai-responses`):\n\n```bash\nocr config set provider my-gateway\nocr config set custom_providers.my-gateway.url https://gateway.internal.com/v1\nocr config set custom_providers.my-gateway.protocol openai\nocr config set custom_providers.my-gateway.model llama-3-70b\nocr config set custom_providers.my-gateway.api_key \"$MY_API_KEY\"\n```\n\n当 provider 或模型要求使用 OpenAI Responses API(`/v1/responses`)时,使用\n`openai-responses` 协议:\n\n```bash\nocr config set provider openai-responses-gateway\nocr config set custom_providers.openai-responses-gateway.url https://api.openai.com/v1\nocr config set custom_providers.openai-responses-gateway.protocol openai-responses\nocr config set custom_providers.openai-responses-gateway.model gpt-5\nocr config set custom_providers.openai-responses-gateway.api_key \"$OPENAI_API_KEY\"\n```\n\n`url` 既可以填 API 的 Base URL,也可以填完整的 `/responses` 端点,OCR 会自动归一化处理。\n\n用 Ollama 跑本地模型,就是一个指向本地 OpenAI 兼容端点的自定义 provider:\n\n```bash\nocr config set provider ollama\nocr config set custom_providers.ollama.url http://127.0.0.1:11434/v1\nocr config set custom_providers.ollama.protocol openai\nocr config set custom_providers.ollama.model qwen3:32b\nocr config set custom_providers.ollama.api_key ollama\n```\n\nOllama 会忽略 API key,但自定义 provider 要求非空的 `api_key`(自定义\nprovider 没有环境变量回退),所以设任意占位值即可。模型本身必须支持原生\n工具调用——选型前请先看 FAQ 中的\n[\"No tool calls parsed\"(本地模型 / Ollama)](../faq/#no-tool-calls-parsed-本地模型-ollama)。\n\n### 超时\n\n每个 LLM 请求都有 HTTP 超时,默认 **300 秒**。慢的本地模型(或大文件)可能\n需要更长的时间。三个配置项,作用域递增:\n\n- `providers.<name>.timeout_sec` / `custom_providers.<name>.timeout_sec`\n ——per-provider,单位秒。\n- `llm.timeout_sec`——用于旧版 `llm` 配置段,单位秒。\n- `OCR_LLM_TIMEOUT` 环境变量——整数秒;对每条解析路径都覆盖配置文件里\n 的值。\n\n`ocr config set` 不支持 `timeout_sec` key——直接编辑\n`~/.opencodereview/config.json`:\n\n```json\n{\n \"custom_providers\": {\n \"ollama\": { \"url\": \"http://127.0.0.1:11434/v1\", \"protocol\": \"openai\", \"timeout_sec\": 900 }\n }\n}\n```\n\n### 验证连通性\n\n```bash\nocr llm test\n```\n\n### 复用已有的环境变量\n\n如果你已经配好了 Claude Code 的 `ANTHROPIC_*`,或 OCR 自己的 `OCR_LLM_*`环境变量,OCR 会自动识别,无需再写配置文件。\n\n### 使用 CC-Switch\n\n如果你使用 [CC-Switch](https://github.com/farion1231/cc-switch) 并开启了\n[路由服务](https://www.ccswitch.io/zh/docs?section=proxy&item=service),\n可以将供应商的 `url` 配置成 CC-Switch 启动的代理地址,无需额外配置:\n\n```bash\n# Claude(Anthropic 兼容)\nocr config set providers.anthropic.url http://127.0.0.1:15721\n\n# Codex / OpenAI 兼容 — 将该供应商的 url 键设为代理地址\nocr config set providers.<name>.url http://127.0.0.1:15721/v1\n```\n\n`api_key` 可设置为任意值。`extra_body`(及其他按供应商字段)依然生效。\n\n### 发送厂商专属字段\n\n某些 provider 需要非标准的请求字段(如 Bedrock 风格的 `thinking`)。用`extra_body`(合并进每次请求)即可发送,无需改源码:\n\n```bash\nocr config set providers.anthropic.extra_body '{\"thinking\":{\"type\":\"disabled\"}}'\n```\n\n## 配置评审语言\n\n`language` 决定评审评论用哪种语言输出,未设置时默认英文:\n\n```bash\nocr config set language 中文\nocr config set language English\n```\n\n## 另见\n\n- [快速开始](../quickstart/)——最小化设置与首次评审。\n- [CLI 参考](../cli-reference/)——review 命令接受的每个参数。\n"}, "0f3c920fc8ff091cbd1ad9e0632bf54bba56e46b": {"t": 1785809838.9480562, "n": 5203, "h": "d038372cc8ea9f26", "c": "---\ntitle: 配置\nsidebar:\n order: 5\n---\n\n配置文件在 `~/.opencodereview/config.json`,你有三种方式编辑它:\n\n- **交互式 TUI** —— `ocr config provider` / `ocr config model`,带引导菜单。\n- **命令行** —— `ocr config set <key> <value>`,适合脚本与 CI。\n- **手动编辑(不推荐)** —— 该 JSON 文件(下次 `ocr config set` 写入时会重新格式化)。\n\n## 配置模型\n\n### 推荐:交互式设置\n\n```bash\nocr config provider\n```\n\n它会让你选择一个内置或自定义 provider、填入 API key、挑选 model,保存到配置文件后自动运行一次 `ocr llm test` 验证端点。之后想换模型:\n\n```bash\nocr config model\n```\n\n### 非交互设置(CI / 无 TUI 环境)\n\n用 `ocr config set` 写入同一份配置:\n\n```bash\nocr config set provider anthropic\nocr config set model claude-opus-4-6\nocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx\n```\n\n### 内置 provider\n\n下列 provider 随 OCR 发布,已预置 Base URL 与协议,选中后只需填 API key。\n若 `providers.<name>.api_key` 未设置,会自动回退到对应的环境变量。\n\n| 名称 | 协议 | Base URL | API key 环境变量 |\n|---|---|---|---|\n| `anthropic` | anthropic | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` |\n| `openai` | openai | `https://api.openai.com/v1` | `OPENAI_API_KEY` |\n| `dashscope` | openai | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_API_KEY` |\n| `dashscope-tokenplan` | openai | `https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_TOKENPLAN_KEY` |\n| `volcengine` | openai | `https://ark.cn-beijing.volces.com/api/v3` | `ARK_API_KEY` |\n| `deepseek` | openai | `https://api.deepseek.com` | `DEEPSEEK_API_KEY` |\n| `tencent-tokenhub` | openai | `https://tokenhub.tencentmaas.com/v1` | `TENCENT_TOKENHUB_API_KEY` |\n| `hy-tokenplan` | openai | `https://api.lkeap.cloud.tencent.com/plan/v3` | `TENCENT_HUNYUAN_TOKENPLAN_KEY` |\n| `iflytek` | openai | `https://spark-api-open.xf-yun.com/v1` | `SPARK_API_KEY` |\n| `kimi` | openai | `https://api.moonshot.cn/v1` | `MOONSHOT_API_KEY` |\n| `z-ai` | openai | `https://open.bigmodel.cn/api/paas/v4` | `Z_AI_API_KEY` |\n| `mimo` | openai | `https://api.xiaomimimo.com/v1` | `MIMO_API_KEY` |\n| `minimax` | openai | `https://api.minimaxi.com/v1` | `MINIMAX_API_KEY` |\n| `baidu-qianfan` | openai | `https://qianfan.baidubce.com/v2` | `QIANFAN_API_KEY` |\n\n### 自定义 provider\n\n任何不在上表中的 provider 名都视为自定义,至少要提供 `url` 和 `protocol`\n(`protocol` 取 `anthropic`、`openai` 或 `openai-responses`):\n\n```bash\nocr config set provider my-gateway\nocr config set custom_providers.my-gateway.url https://gateway.internal.com/v1\nocr config set custom_providers.my-gateway.protocol openai\nocr config set custom_providers.my-gateway.model llama-3-70b\nocr config set custom_providers.my-gateway.api_key \"$MY_API_KEY\"\n```\n\n当 provider 或模型要求使用 OpenAI Responses API(`/v1/responses`)时,使用\n`openai-responses` 协议:\n\n```bash\nocr config set provider openai-responses-gateway\nocr config set custom_providers.openai-responses-gateway.url https://api.openai.com/v1\nocr config set custom_providers.openai-responses-gateway.protocol openai-responses\nocr config set custom_providers.openai-responses-gateway.model gpt-5\nocr config set custom_providers.openai-responses-gateway.api_key \"$OPENAI_API_KEY\"\n```\n\n`url` 既可以填 API 的 Base URL,也可以填完整的 `/responses` 端点,OCR 会自动归一化处理。\n\n用 Ollama 跑本地模型,就是一个指向本地 OpenAI 兼容端点的自定义 provider:\n\n```bash\nocr config set provider ollama\nocr config set custom_providers.ollama.url http://127.0.0.1:11434/v1\nocr config set custom_providers.ollama.protocol openai\nocr config set custom_providers.ollama.model qwen3:32b\nocr config set custom_providers.ollama.api_key ollama\n```\n\nOllama 会忽略 API key,但自定义 provider 要求非空的 `api_key`(自定义\nprovider 没有环境变量回退),所以设任意占位值即可。模型本身必须支持原生\n工具调用——选型前请先看 FAQ 中的\n[\"No tool calls parsed\"(本地模型 / Ollama)](../faq/#no-tool-calls-parsed-本地模型-ollama)。\n\n### 超时\n\n每个 LLM 请求都有 HTTP 超时,默认 **300 秒**。慢的本地模型(或大文件)可能\n需要更长的时间。三个配置项,作用域递增:\n\n- `providers.<name>.timeout_sec` / `custom_providers.<name>.timeout_sec`\n ——per-provider,单位秒。\n- `llm.timeout_sec`——用于旧版 `llm` 配置段,单位秒。\n- `OCR_LLM_TIMEOUT` 环境变量——整数秒;对每条解析路径都覆盖配置文件里\n 的值。\n\n`ocr config set` 不支持 `timeout_sec` key——直接编辑\n`~/.opencodereview/config.json`:\n\n```json\n{\n \"custom_providers\": {\n \"ollama\": { \"url\": \"http://127.0.0.1:11434/v1\", \"protocol\": \"openai\", \"timeout_sec\": 900 }\n }\n}\n```\n\n### 验证连通性\n\n```bash\nocr llm test\n```\n\n### 复用已有的环境变量\n\n如果你已经配好了 Claude Code 的 `ANTHROPIC_*`,或 OCR 自己的 `OCR_LLM_*`环境变量,OCR 会自动识别,无需再写配置文件。\n\n### 使用 CC-Switch\n\n如果你使用 [CC-Switch](https://github.com/farion1231/cc-switch) 并开启了\n[路由服务](https://www.ccswitch.io/zh/docs?section=proxy&item=service),\n可以将供应商的 `url` 配置成 CC-Switch 启动的代理地址,无需额外配置:\n\n```bash\n# Claude(Anthropic 兼容)\nocr config set providers.anthropic.url http://127.0.0.1:15721\n\n# Codex / OpenAI 兼容 — 将该供应商的 url 键设为代理地址\nocr config set providers.<name>.url http://127.0.0.1:15721/v1\n```\n\n`api_key` 可设置为任意值。`extra_body`(及其他按供应商字段)依然生效。\n\n### 发送厂商专属字段\n\n某些 provider 需要非标准的请求字段(如 Bedrock 风格的 `thinking`)。用`extra_body`(合并进每次请求)即可发送,无需改源码:\n\n```bash\nocr config set providers.anthropic.extra_body '{\"thinking\":{\"type\":\"disabled\"}}'\n```\n\n## 配置评审语言\n\n`language` 决定评审评论用哪种语言输出,未设置时默认英文:\n\n```bash\nocr config set language 中文\nocr config set language English\n```\n\n## 另见\n\n- [快速开始](../quickstart/)——最小化设置与首次评审。\n- [CLI 参考](../cli-reference/)——review 命令接受的每个参数。\n"}, "e6895dd15481e2612bf65114fab728fabd32451e": {"t": 1785846922.796352, "n": 5203, "h": "d038372cc8ea9f26", "c": "---\ntitle: 配置\nsidebar:\n order: 5\n---\n\n配置文件在 `~/.opencodereview/config.json`,你有三种方式编辑它:\n\n- **交互式 TUI** —— `ocr config provider` / `ocr config model`,带引导菜单。\n- **命令行** —— `ocr config set <key> <value>`,适合脚本与 CI。\n- **手动编辑(不推荐)** —— 该 JSON 文件(下次 `ocr config set` 写入时会重新格式化)。\n\n## 配置模型\n\n### 推荐:交互式设置\n\n```bash\nocr config provider\n```\n\n它会让你选择一个内置或自定义 provider、填入 API key、挑选 model,保存到配置文件后自动运行一次 `ocr llm test` 验证端点。之后想换模型:\n\n```bash\nocr config model\n```\n\n### 非交互设置(CI / 无 TUI 环境)\n\n用 `ocr config set` 写入同一份配置:\n\n```bash\nocr config set provider anthropic\nocr config set model claude-opus-4-6\nocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx\n```\n\n### 内置 provider\n\n下列 provider 随 OCR 发布,已预置 Base URL 与协议,选中后只需填 API key。\n若 `providers.<name>.api_key` 未设置,会自动回退到对应的环境变量。\n\n| 名称 | 协议 | Base URL | API key 环境变量 |\n|---|---|---|---|\n| `anthropic` | anthropic | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` |\n| `openai` | openai | `https://api.openai.com/v1` | `OPENAI_API_KEY` |\n| `dashscope` | openai | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_API_KEY` |\n| `dashscope-tokenplan` | openai | `https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_TOKENPLAN_KEY` |\n| `volcengine` | openai | `https://ark.cn-beijing.volces.com/api/v3` | `ARK_API_KEY` |\n| `deepseek` | openai | `https://api.deepseek.com` | `DEEPSEEK_API_KEY` |\n| `tencent-tokenhub` | openai | `https://tokenhub.tencentmaas.com/v1` | `TENCENT_TOKENHUB_API_KEY` |\n| `hy-tokenplan` | openai | `https://api.lkeap.cloud.tencent.com/plan/v3` | `TENCENT_HUNYUAN_TOKENPLAN_KEY` |\n| `iflytek` | openai | `https://spark-api-open.xf-yun.com/v1` | `SPARK_API_KEY` |\n| `kimi` | openai | `https://api.moonshot.cn/v1` | `MOONSHOT_API_KEY` |\n| `z-ai` | openai | `https://open.bigmodel.cn/api/paas/v4` | `Z_AI_API_KEY` |\n| `mimo` | openai | `https://api.xiaomimimo.com/v1` | `MIMO_API_KEY` |\n| `minimax` | openai | `https://api.minimaxi.com/v1` | `MINIMAX_API_KEY` |\n| `baidu-qianfan` | openai | `https://qianfan.baidubce.com/v2` | `QIANFAN_API_KEY` |\n\n### 自定义 provider\n\n任何不在上表中的 provider 名都视为自定义,至少要提供 `url` 和 `protocol`\n(`protocol` 取 `anthropic`、`openai` 或 `openai-responses`):\n\n```bash\nocr config set provider my-gateway\nocr config set custom_providers.my-gateway.url https://gateway.internal.com/v1\nocr config set custom_providers.my-gateway.protocol openai\nocr config set custom_providers.my-gateway.model llama-3-70b\nocr config set custom_providers.my-gateway.api_key \"$MY_API_KEY\"\n```\n\n当 provider 或模型要求使用 OpenAI Responses API(`/v1/responses`)时,使用\n`openai-responses` 协议:\n\n```bash\nocr config set provider openai-responses-gateway\nocr config set custom_providers.openai-responses-gateway.url https://api.openai.com/v1\nocr config set custom_providers.openai-responses-gateway.protocol openai-responses\nocr config set custom_providers.openai-responses-gateway.model gpt-5\nocr config set custom_providers.openai-responses-gateway.api_key \"$OPENAI_API_KEY\"\n```\n\n`url` 既可以填 API 的 Base URL,也可以填完整的 `/responses` 端点,OCR 会自动归一化处理。\n\n用 Ollama 跑本地模型,就是一个指向本地 OpenAI 兼容端点的自定义 provider:\n\n```bash\nocr config set provider ollama\nocr config set custom_providers.ollama.url http://127.0.0.1:11434/v1\nocr config set custom_providers.ollama.protocol openai\nocr config set custom_providers.ollama.model qwen3:32b\nocr config set custom_providers.ollama.api_key ollama\n```\n\nOllama 会忽略 API key,但自定义 provider 要求非空的 `api_key`(自定义\nprovider 没有环境变量回退),所以设任意占位值即可。模型本身必须支持原生\n工具调用——选型前请先看 FAQ 中的\n[\"No tool calls parsed\"(本地模型 / Ollama)](../faq/#no-tool-calls-parsed-本地模型-ollama)。\n\n### 超时\n\n每个 LLM 请求都有 HTTP 超时,默认 **300 秒**。慢的本地模型(或大文件)可能\n需要更长的时间。三个配置项,作用域递增:\n\n- `providers.<name>.timeout_sec` / `custom_providers.<name>.timeout_sec`\n ——per-provider,单位秒。\n- `llm.timeout_sec`——用于旧版 `llm` 配置段,单位秒。\n- `OCR_LLM_TIMEOUT` 环境变量——整数秒;对每条解析路径都覆盖配置文件里\n 的值。\n\n`ocr config set` 不支持 `timeout_sec` key——直接编辑\n`~/.opencodereview/config.json`:\n\n```json\n{\n \"custom_providers\": {\n \"ollama\": { \"url\": \"http://127.0.0.1:11434/v1\", \"protocol\": \"openai\", \"timeout_sec\": 900 }\n }\n}\n```\n\n### 验证连通性\n\n```bash\nocr llm test\n```\n\n### 复用已有的环境变量\n\n如果你已经配好了 Claude Code 的 `ANTHROPIC_*`,或 OCR 自己的 `OCR_LLM_*`环境变量,OCR 会自动识别,无需再写配置文件。\n\n### 使用 CC-Switch\n\n如果你使用 [CC-Switch](https://github.com/farion1231/cc-switch) 并开启了\n[路由服务](https://www.ccswitch.io/zh/docs?section=proxy&item=service),\n可以将供应商的 `url` 配置成 CC-Switch 启动的代理地址,无需额外配置:\n\n```bash\n# Claude(Anthropic 兼容)\nocr config set providers.anthropic.url http://127.0.0.1:15721\n\n# Codex / OpenAI 兼容 — 将该供应商的 url 键设为代理地址\nocr config set providers.<name>.url http://127.0.0.1:15721/v1\n```\n\n`api_key` 可设置为任意值。`extra_body`(及其他按供应商字段)依然生效。\n\n### 发送厂商专属字段\n\n某些 provider 需要非标准的请求字段(如 Bedrock 风格的 `thinking`)。用`extra_body`(合并进每次请求)即可发送,无需改源码:\n\n```bash\nocr config set providers.anthropic.extra_body '{\"thinking\":{\"type\":\"disabled\"}}'\n```\n\n## 配置评审语言\n\n`language` 决定评审评论用哪种语言输出,未设置时默认英文:\n\n```bash\nocr config set language 中文\nocr config set language English\n```\n\n## 另见\n\n- [快速开始](../quickstart/)——最小化设置与首次评审。\n- [CLI 参考](../cli-reference/)——review 命令接受的每个参数。\n"}, "e9d4db51ec1d06fef1123d5a7158a7b311043650": {"t": 1785846960.4230182, "n": 5203, "h": "d038372cc8ea9f26", "c": "---\ntitle: 配置\nsidebar:\n order: 5\n---\n\n配置文件在 `~/.opencodereview/config.json`,你有三种方式编辑它:\n\n- **交互式 TUI** —— `ocr config provider` / `ocr config model`,带引导菜单。\n- **命令行** —— `ocr config set <key> <value>`,适合脚本与 CI。\n- **手动编辑(不推荐)** —— 该 JSON 文件(下次 `ocr config set` 写入时会重新格式化)。\n\n## 配置模型\n\n### 推荐:交互式设置\n\n```bash\nocr config provider\n```\n\n它会让你选择一个内置或自定义 provider、填入 API key、挑选 model,保存到配置文件后自动运行一次 `ocr llm test` 验证端点。之后想换模型:\n\n```bash\nocr config model\n```\n\n### 非交互设置(CI / 无 TUI 环境)\n\n用 `ocr config set` 写入同一份配置:\n\n```bash\nocr config set provider anthropic\nocr config set model claude-opus-4-6\nocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx\n```\n\n### 内置 provider\n\n下列 provider 随 OCR 发布,已预置 Base URL 与协议,选中后只需填 API key。\n若 `providers.<name>.api_key` 未设置,会自动回退到对应的环境变量。\n\n| 名称 | 协议 | Base URL | API key 环境变量 |\n|---|---|---|---|\n| `anthropic` | anthropic | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` |\n| `openai` | openai | `https://api.openai.com/v1` | `OPENAI_API_KEY` |\n| `dashscope` | openai | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_API_KEY` |\n| `dashscope-tokenplan` | openai | `https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_TOKENPLAN_KEY` |\n| `volcengine` | openai | `https://ark.cn-beijing.volces.com/api/v3` | `ARK_API_KEY` |\n| `deepseek` | openai | `https://api.deepseek.com` | `DEEPSEEK_API_KEY` |\n| `tencent-tokenhub` | openai | `https://tokenhub.tencentmaas.com/v1` | `TENCENT_TOKENHUB_API_KEY` |\n| `hy-tokenplan` | openai | `https://api.lkeap.cloud.tencent.com/plan/v3` | `TENCENT_HUNYUAN_TOKENPLAN_KEY` |\n| `iflytek` | openai | `https://spark-api-open.xf-yun.com/v1` | `SPARK_API_KEY` |\n| `kimi` | openai | `https://api.moonshot.cn/v1` | `MOONSHOT_API_KEY` |\n| `z-ai` | openai | `https://open.bigmodel.cn/api/paas/v4` | `Z_AI_API_KEY` |\n| `mimo` | openai | `https://api.xiaomimimo.com/v1` | `MIMO_API_KEY` |\n| `minimax` | openai | `https://api.minimaxi.com/v1` | `MINIMAX_API_KEY` |\n| `baidu-qianfan` | openai | `https://qianfan.baidubce.com/v2` | `QIANFAN_API_KEY` |\n\n### 自定义 provider\n\n任何不在上表中的 provider 名都视为自定义,至少要提供 `url` 和 `protocol`\n(`protocol` 取 `anthropic`、`openai` 或 `openai-responses`):\n\n```bash\nocr config set provider my-gateway\nocr config set custom_providers.my-gateway.url https://gateway.internal.com/v1\nocr config set custom_providers.my-gateway.protocol openai\nocr config set custom_providers.my-gateway.model llama-3-70b\nocr config set custom_providers.my-gateway.api_key \"$MY_API_KEY\"\n```\n\n当 provider 或模型要求使用 OpenAI Responses API(`/v1/responses`)时,使用\n`openai-responses` 协议:\n\n```bash\nocr config set provider openai-responses-gateway\nocr config set custom_providers.openai-responses-gateway.url https://api.openai.com/v1\nocr config set custom_providers.openai-responses-gateway.protocol openai-responses\nocr config set custom_providers.openai-responses-gateway.model gpt-5\nocr config set custom_providers.openai-responses-gateway.api_key \"$OPENAI_API_KEY\"\n```\n\n`url` 既可以填 API 的 Base URL,也可以填完整的 `/responses` 端点,OCR 会自动归一化处理。\n\n用 Ollama 跑本地模型,就是一个指向本地 OpenAI 兼容端点的自定义 provider:\n\n```bash\nocr config set provider ollama\nocr config set custom_providers.ollama.url http://127.0.0.1:11434/v1\nocr config set custom_providers.ollama.protocol openai\nocr config set custom_providers.ollama.model qwen3:32b\nocr config set custom_providers.ollama.api_key ollama\n```\n\nOllama 会忽略 API key,但自定义 provider 要求非空的 `api_key`(自定义\nprovider 没有环境变量回退),所以设任意占位值即可。模型本身必须支持原生\n工具调用——选型前请先看 FAQ 中的\n[\"No tool calls parsed\"(本地模型 / Ollama)](../faq/#no-tool-calls-parsed-本地模型-ollama)。\n\n### 超时\n\n每个 LLM 请求都有 HTTP 超时,默认 **300 秒**。慢的本地模型(或大文件)可能\n需要更长的时间。三个配置项,作用域递增:\n\n- `providers.<name>.timeout_sec` / `custom_providers.<name>.timeout_sec`\n ——per-provider,单位秒。\n- `llm.timeout_sec`——用于旧版 `llm` 配置段,单位秒。\n- `OCR_LLM_TIMEOUT` 环境变量——整数秒;对每条解析路径都覆盖配置文件里\n 的值。\n\n`ocr config set` 不支持 `timeout_sec` key——直接编辑\n`~/.opencodereview/config.json`:\n\n```json\n{\n \"custom_providers\": {\n \"ollama\": { \"url\": \"http://127.0.0.1:11434/v1\", \"protocol\": \"openai\", \"timeout_sec\": 900 }\n }\n}\n```\n\n### 验证连通性\n\n```bash\nocr llm test\n```\n\n### 复用已有的环境变量\n\n如果你已经配好了 Claude Code 的 `ANTHROPIC_*`,或 OCR 自己的 `OCR_LLM_*`环境变量,OCR 会自动识别,无需再写配置文件。\n\n### 使用 CC-Switch\n\n如果你使用 [CC-Switch](https://github.com/farion1231/cc-switch) 并开启了\n[路由服务](https://www.ccswitch.io/zh/docs?section=proxy&item=service),\n可以将供应商的 `url` 配置成 CC-Switch 启动的代理地址,无需额外配置:\n\n```bash\n# Claude(Anthropic 兼容)\nocr config set providers.anthropic.url http://127.0.0.1:15721\n\n# Codex / OpenAI 兼容 — 将该供应商的 url 键设为代理地址\nocr config set providers.<name>.url http://127.0.0.1:15721/v1\n```\n\n`api_key` 可设置为任意值。`extra_body`(及其他按供应商字段)依然生效。\n\n### 发送厂商专属字段\n\n某些 provider 需要非标准的请求字段(如 Bedrock 风格的 `thinking`)。用`extra_body`(合并进每次请求)即可发送,无需改源码:\n\n```bash\nocr config set providers.anthropic.extra_body '{\"thinking\":{\"type\":\"disabled\"}}'\n```\n\n## 配置评审语言\n\n`language` 决定评审评论用哪种语言输出,未设置时默认英文:\n\n```bash\nocr config set language 中文\nocr config set language English\n```\n\n## 另见\n\n- [快速开始](../quickstart/)——最小化设置与首次评审。\n- [CLI 参考](../cli-reference/)——review 命令接受的每个参数。\n"}}, "internal/llm/providers.go": {"0f3c920fc8ff091cbd1ad9e0632bf54bba56e46b": {"t": 1785809838.7835782, "n": 8753, "h": "5dea4ba68c13d35a", "c": "package llm\n\nimport (\n\t\"sort\"\n\t\"strings\"\n)\n\n// Provider holds the preset configuration for a known LLM provider.\n//\n// Protocol uses the canonical names defined in protocol.go:\n// - ProtocolAnthropic (\"anthropic\")\n// - ProtocolOpenAIChatCompletions (\"openai\")\n// - ProtocolOpenAIResponses (\"openai-responses\")\n//\n// To add a built-in provider that speaks a different protocol, set Protocol\n// accordingly and ensure NewLLMClient has a matching case.\ntype Provider struct {\n\tName string\n\tDisplayName string\n\tProtocol string\n\tBaseURL string\n\tAuthHeader string // Anthropic-only; empty for OpenAI-compatible\n\tEnvVar string // environment variable name for API key fallback\n\tModels []string\n}\n\nvar registry = []Provider{\n\t{\n\t\tName: \"anthropic\",\n\t\tDisplayName: \"Anthropic Claude API\",\n\t\tProtocol: ProtocolAnthropic,\n\t\tBaseURL: \"https://api.anthropic.com\",\n\t\tAuthHeader: \"x-api-key\",\n\t\tEnvVar: \"ANTHROPIC_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"claude-opus-5\",\n\t\t\t\"claude-sonnet-5\",\n\t\t\t\"claude-opus-4-8\",\n\t\t\t\"claude-opus-4-7\",\n\t\t\t\"claude-opus-4-6\",\n\t\t\t\"claude-sonnet-4-6\",\n\t\t},\n\t},\n\t{\n\t\tName: \"openai\",\n\t\tDisplayName: \"OpenAI API\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://api.openai.com/v1\",\n\t\tEnvVar: \"OPENAI_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"gpt-5.6-sol\",\n\t\t\t\"gpt-5.6-terra\",\n\t\t\t\"gpt-5.6-luna\",\n\t\t\t\"gpt-5.5\",\n\t\t\t\"gpt-5.4\",\n\t\t\t\"gpt-5.4-mini\",\n\t\t},\n\t},\n\t{\n\t\tName: \"edenai\",\n\t\tDisplayName: \"Eden AI\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://api.edenai.run/v3\",\n\t\tEnvVar: \"EDENAI_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"anthropic/claude-opus-4-5\",\n\t\t\t\"anthropic/claude-sonnet-4-5\",\n\t\t\t\"anthropic/claude-haiku-4-5\",\n\t\t\t\"openai/gpt-5.1\",\n\t\t\t\"openai/gpt-5.1-codex\",\n\t\t\t\"google/gemini-3.1-pro-preview\",\n\t\t\t\"mistral/devstral-medium-latest\",\n\t\t\t\"mistral/codestral-latest\",\n\t\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\t\"xai/grok-4\",\n\t\t},\n\t},\n\t{\n\t\tName: \"dashscope\",\n\t\tDisplayName: \"Alibaba DashScope API\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n\t\tEnvVar: \"DASHSCOPE_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"qwen3.8-max\",\n\t\t\t\"qwen3.7-max\",\n\t\t\t\"qwen3.7-plus\",\n\t\t\t\"qwen3.6-plus\",\n\t\t\t\"qwen3.6-flash\",\n\t\t\t\"deepseek-v4-pro\",\n\t\t\t\"deepseek-v4-flash\",\n\t\t\t\"kimi-k2.7-code\",\n\t\t\t\"glm-5.2\",\n\t\t\t\"MiniMax-M2.5\",\n\t\t},\n\t},\n\t{\n\t\tName: \"dashscope-tokenplan\",\n\t\tDisplayName: \"Alibaba DashScope Token Plan API\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1\",\n\t\tEnvVar: \"DASHSCOPE_TOKENPLAN_KEY\",\n\t\tModels: []string{\n\t\t\t\"qwen3.8-max\",\n\t\t\t\"qwen3.7-max\",\n\t\t\t\"qwen3.7-plus\",\n\t\t\t\"qwen3.6-plus\",\n\t\t\t\"qwen3.6-flash\",\n\t\t\t\"deepseek-v4-pro\",\n\t\t\t\"deepseek-v4-flash\",\n\t\t\t\"kimi-k2.7-code\",\n\t\t\t\"kimi-k2.6\",\n\t\t\t\"kimi-k2.5\",\n\t\t\t\"glm-5.2\",\n\t\t\t\"glm-5.1\",\n\t\t\t\"glm-5\",\n\t\t\t\"MiniMax-M2.5\",\n\t\t},\n\t},\n\t{\n\t\tName: \"volcengine\",\n\t\tDisplayName: \"Volcano Engine Ark API\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://ark.cn-beijing.volces.com/api/v3\",\n\t\tEnvVar: \"ARK_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"doubao-seed-evolving\",\n\t\t\t\"doubao-seed-2-1-pro-260628\",\n\t\t\t\"doubao-seed-2-1-turbo-260628\",\n\t\t\t\"doubao-seed-2-0-lite-260428\",\n\t\t\t\"doubao-seed-2-0-mini-260428\",\n\t\t\t\"doubao-seed-2-0-pro-260215\",\n\t\t},\n\t},\n\t{\n\t\tName: \"deepseek\",\n\t\tDisplayName: \"DeepSeek API\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tEnvVar: \"DEEPSEEK_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"deepseek-v4-pro\",\n\t\t\t\"deepseek-v4-flash\",\n\t\t},\n\t},\n\t{\n\t\tName: \"tencent-tokenhub\",\n\t\tDisplayName: \"Tencent TokenHub API\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://tokenhub.tencentmaas.com/v1\",\n\t\tEnvVar: \"TENCENT_TOKENHUB_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"deepseek-v4-pro\",\n\t\t\t\"deepseek-v4-flash\",\n\t\t\t\"glm-5.2\",\n\t\t\t\"glm-5.1\",\n\t\t\t\"glm-5\",\n\t\t\t\"glm-5-turbo\",\n\t\t\t\"kimi-k2.7-code\",\n\t\t\t\"kimi-k2.7-code-highspeed\",\n\t\t\t\"kimi-k2.6\",\n\t\t\t\"minimax-m3\",\n\t\t\t\"minimax-m2.7\",\n\t\t},\n\t},\n\t{\n\t\tName: \"hy-tokenplan\",\n\t\tDisplayName: \"Tencent Hunyuan Token Plan API\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://api.lkeap.cloud.tencent.com/plan/v3\",\n\t\tEnvVar: \"TENCENT_HUNYUAN_TOKENPLAN_KEY\",\n\t\tModels: []string{\n\t\t\t\"hy3\",\n\t\t},\n\t},\n\t{\n\t\tName: \"iflytek\",\n\t\tDisplayName: \"iFlytek Spark API\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://spark-api-open.xf-yun.com/v1\",\n\t\tEnvVar: \"SPARK_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"4.0Ultra\",\n\t\t\t\"generalv3.5\",\n\t\t\t\"max-32k\",\n\t\t\t\"generalv3\",\n\t\t\t\"pro-128k\",\n\t\t\t\"lite\",\n\t\t},\n\t},\n\t{\n\t\tName: \"kimi\",\n\t\tDisplayName: \"Kimi Moonshot API\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://api.moonshot.cn/v1\",\n\t\tEnvVar: \"MOONSHOT_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"kimi-k3\",\n\t\t\t\"kimi-k2.7-code\",\n\t\t\t\"kimi-k2.7-code-highspeed\",\n\t\t\t\"kimi-k2.6\",\n\t\t\t\"kimi-k2.5\",\n\t\t},\n\t},\n\t{\n\t\tName: \"z-ai\",\n\t\tDisplayName: \"Z.AI API\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://open.bigmodel.cn/api/paas/v4\",\n\t\tEnvVar: \"Z_AI_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"glm-5.2\",\n\t\t\t\"glm-5.1\",\n\t\t\t\"glm-5\",\n\t\t\t\"glm-5-turbo\",\n\t\t\t\"glm-4.7\",\n\t\t},\n\t},\n\t{\n\t\tName: \"z-ai-coding\",\n\t\tDisplayName: \"Z.AI Coding Plan API\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://open.bigmodel.cn/api/coding/paas/v4\",\n\t\tEnvVar: \"Z_AI_CODING_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"glm-5.2\",\n\t\t\t\"glm-5.1\",\n\t\t\t\"glm-5-turbo\",\n\t\t\t\"glm-4.7\",\n\t\t},\n\t},\n\t{\n\t\tName: \"mimo\",\n\t\tDisplayName: \"Xiaomi MiMo API\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\tEnvVar: \"MIMO_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"mimo-v2.5-pro\",\n\t\t\t\"mimo-v2.5\",\n\t\t},\n\t},\n\t{\n\t\tName: \"minimax\",\n\t\tDisplayName: \"MiniMax API\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://api.minimaxi.com/v1\",\n\t\tEnvVar: \"MINIMAX_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"MiniMax-M3\",\n\t\t\t\"MiniMax-M2.7\",\n\t\t\t\"MiniMax-M2.7-highspeed\",\n\t\t\t\"MiniMax-M2.5\",\n\t\t\t\"MiniMax-M2.5-highspeed\",\n\t\t},\n\t},\n\t{\n\t\tName: \"baidu-qianfan\",\n\t\tDisplayName: \"Baidu Qianfan API\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://qianfan.baidubce.com/v2\",\n\t\tEnvVar: \"QIANFAN_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"ernie-5.1\",\n\t\t\t\"ernie-5.0\",\n\t\t\t\"ernie-x1.1\",\n\t\t\t\"ernie-x1-turbo-32k-preview\",\n\t\t\t\"deepseek-v4-pro\",\n\t\t\t\"deepseek-v4-flash\",\n\t\t\t\"glm-5.2\",\n\t\t\t\"glm-5.1\",\n\t\t\t\"glm-5\",\n\t\t\t\"kimi-k2.6\",\n\t\t},\n\t},\n\t{\n\t\tName: \"ollama-cloud\",\n\t\tDisplayName: \"Ollama Cloud API\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://ollama.com/v1\",\n\t\tEnvVar: \"OLLAMA_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"deepseek-v4-flash\",\n\t\t\t\"deepseek-v4-pro\",\n\t\t\t\"gemma4:31b\",\n\t\t\t\"glm-5.1\",\n\t\t\t\"glm-5.2\",\n\t\t\t\"gpt-oss:120b\",\n\t\t\t\"gpt-oss:20b\",\n\t\t\t\"kimi-k2.5\",\n\t\t\t\"kimi-k2.6\",\n\t\t\t\"kimi-k2.7-code\",\n\t\t\t\"minimax-m2.5\",\n\t\t\t\"minimax-m2.7\",\n\t\t\t\"minimax-m3\",\n\t\t\t\"mistral-large-3:675b\",\n\t\t\t\"nemotron-3-nano:30b\",\n\t\t\t\"nemotron-3-super\",\n\t\t\t\"nemotron-3-ultra\",\n\t\t\t\"qwen3.5:397b\",\n\t\t},\n\t},\n\t{\n\t\tName: \"litellm\",\n\t\tDisplayName: \"LiteLLM AI Gateway\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"http://localhost:4000/v1\",\n\t\tEnvVar: \"LITELLM_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"anthropic/claude-sonnet-4-6\",\n\t\t\t\"anthropic/claude-opus-4-6\",\n\t\t\t\"anthropic/claude-haiku-4-5\",\n\t\t\t\"openai/gpt-4o\",\n\t\t\t\"openai/gpt-5.4\",\n\t\t\t\"openai/o3\",\n\t\t\t\"vertex_ai/gemini-2.5-flash\",\n\t\t\t\"vertex_ai/gemini-2.5-pro\",\n\t\t\t\"bedrock/anthropic.claude-sonnet-4-6-v1\",\n\t\t\t\"groq/llama-4-scout-17b-16e-instruct\",\n\t\t\t\"mistral/mistral-large-latest\",\n\t\t\t\"deepseek/deepseek-chat\",\n\t\t},\n\t},\n}\n\nvar registryMap map[string]Provider\n\nfunc init() {\n\tregistryMap = make(map[string]Provider, len(registry))\n\tfor _, p := range registry {\n\t\tregistryMap[strings.ToLower(p.Name)] = p\n\t}\n}\n\n// LookupProvider returns the preset provider by name.\n// The returned Provider has its own copy of the Models slice.\nfunc LookupProvider(name string) (Provider, bool) {\n\tp, ok := registryMap[strings.ToLower(strings.TrimSpace(name))]\n\tif ok {\n\t\tp = copyProvider(p)\n\t}\n\treturn p, ok\n}\n\n// ListProviders returns all built-in providers sorted by provider name.\n// Each returned Provider has its own copy of the Models slice in registry order.\nfunc ListProviders() []Provider {\n\tout := make([]Provider, len(registry))\n\tfor i, p := range registry {\n\t\tout[i] = copyProvider(p)\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\treturn out[i].Name < out[j].Name\n\t})\n\treturn out\n}\n\nfunc copyProvider(p Provider) Provider {\n\tif p.Models != nil {\n\t\tmodels := make([]string, len(p.Models))\n\t\tcopy(models, p.Models)\n\t\tp.Models = models\n\t}\n\treturn p\n}\n"}, "e9d4db51ec1d06fef1123d5a7158a7b311043650": {"t": 1785846960.52166, "n": 8753, "h": "5dea4ba68c13d35a", "c": "package llm\n\nimport (\n\t\"sort\"\n\t\"strings\"\n)\n\n// Provider holds the preset configuration for a known LLM provider.\n//\n// Protocol uses the canonical names defined in protocol.go:\n// - ProtocolAnthropic (\"anthropic\")\n// - ProtocolOpenAIChatCompletions (\"openai\")\n// - ProtocolOpenAIResponses (\"openai-responses\")\n//\n// To add a built-in provider that speaks a different protocol, set Protocol\n// accordingly and ensure NewLLMClient has a matching case.\ntype Provider struct {\n\tName string\n\tDisplayName string\n\tProtocol string\n\tBaseURL string\n\tAuthHeader string // Anthropic-only; empty for OpenAI-compatible\n\tEnvVar string // environment variable name for API key fallback\n\tModels []string\n}\n\nvar registry = []Provider{\n\t{\n\t\tName: \"anthropic\",\n\t\tDisplayName: \"Anthropic Claude API\",\n\t\tProtocol: ProtocolAnthropic,\n\t\tBaseURL: \"https://api.anthropic.com\",\n\t\tAuthHeader: \"x-api-key\",\n\t\tEnvVar: \"ANTHROPIC_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"claude-opus-5\",\n\t\t\t\"claude-sonnet-5\",\n\t\t\t\"claude-opus-4-8\",\n\t\t\t\"claude-opus-4-7\",\n\t\t\t\"claude-opus-4-6\",\n\t\t\t\"claude-sonnet-4-6\",\n\t\t},\n\t},\n\t{\n\t\tName: \"openai\",\n\t\tDisplayName: \"OpenAI API\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://api.openai.com/v1\",\n\t\tEnvVar: \"OPENAI_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"gpt-5.6-sol\",\n\t\t\t\"gpt-5.6-terra\",\n\t\t\t\"gpt-5.6-luna\",\n\t\t\t\"gpt-5.5\",\n\t\t\t\"gpt-5.4\",\n\t\t\t\"gpt-5.4-mini\",\n\t\t},\n\t},\n\t{\n\t\tName: \"edenai\",\n\t\tDisplayName: \"Eden AI\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://api.edenai.run/v3\",\n\t\tEnvVar: \"EDENAI_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"anthropic/claude-opus-4-5\",\n\t\t\t\"anthropic/claude-sonnet-4-5\",\n\t\t\t\"anthropic/claude-haiku-4-5\",\n\t\t\t\"openai/gpt-5.1\",\n\t\t\t\"openai/gpt-5.1-codex\",\n\t\t\t\"google/gemini-3.1-pro-preview\",\n\t\t\t\"mistral/devstral-medium-latest\",\n\t\t\t\"mistral/codestral-latest\",\n\t\t\t\"deepseek/deepseek-v4-pro\",\n\t\t\t\"xai/grok-4\",\n\t\t},\n\t},\n\t{\n\t\tName: \"dashscope\",\n\t\tDisplayName: \"Alibaba DashScope API\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n\t\tEnvVar: \"DASHSCOPE_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"qwen3.8-max\",\n\t\t\t\"qwen3.7-max\",\n\t\t\t\"qwen3.7-plus\",\n\t\t\t\"qwen3.6-plus\",\n\t\t\t\"qwen3.6-flash\",\n\t\t\t\"deepseek-v4-pro\",\n\t\t\t\"deepseek-v4-flash\",\n\t\t\t\"kimi-k2.7-code\",\n\t\t\t\"glm-5.2\",\n\t\t\t\"MiniMax-M2.5\",\n\t\t},\n\t},\n\t{\n\t\tName: \"dashscope-tokenplan\",\n\t\tDisplayName: \"Alibaba DashScope Token Plan API\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1\",\n\t\tEnvVar: \"DASHSCOPE_TOKENPLAN_KEY\",\n\t\tModels: []string{\n\t\t\t\"qwen3.8-max\",\n\t\t\t\"qwen3.7-max\",\n\t\t\t\"qwen3.7-plus\",\n\t\t\t\"qwen3.6-plus\",\n\t\t\t\"qwen3.6-flash\",\n\t\t\t\"deepseek-v4-pro\",\n\t\t\t\"deepseek-v4-flash\",\n\t\t\t\"kimi-k2.7-code\",\n\t\t\t\"kimi-k2.6\",\n\t\t\t\"kimi-k2.5\",\n\t\t\t\"glm-5.2\",\n\t\t\t\"glm-5.1\",\n\t\t\t\"glm-5\",\n\t\t\t\"MiniMax-M2.5\",\n\t\t},\n\t},\n\t{\n\t\tName: \"volcengine\",\n\t\tDisplayName: \"Volcano Engine Ark API\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://ark.cn-beijing.volces.com/api/v3\",\n\t\tEnvVar: \"ARK_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"doubao-seed-evolving\",\n\t\t\t\"doubao-seed-2-1-pro-260628\",\n\t\t\t\"doubao-seed-2-1-turbo-260628\",\n\t\t\t\"doubao-seed-2-0-lite-260428\",\n\t\t\t\"doubao-seed-2-0-mini-260428\",\n\t\t\t\"doubao-seed-2-0-pro-260215\",\n\t\t},\n\t},\n\t{\n\t\tName: \"deepseek\",\n\t\tDisplayName: \"DeepSeek API\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://api.deepseek.com\",\n\t\tEnvVar: \"DEEPSEEK_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"deepseek-v4-pro\",\n\t\t\t\"deepseek-v4-flash\",\n\t\t},\n\t},\n\t{\n\t\tName: \"tencent-tokenhub\",\n\t\tDisplayName: \"Tencent TokenHub API\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://tokenhub.tencentmaas.com/v1\",\n\t\tEnvVar: \"TENCENT_TOKENHUB_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"deepseek-v4-pro\",\n\t\t\t\"deepseek-v4-flash\",\n\t\t\t\"glm-5.2\",\n\t\t\t\"glm-5.1\",\n\t\t\t\"glm-5\",\n\t\t\t\"glm-5-turbo\",\n\t\t\t\"kimi-k2.7-code\",\n\t\t\t\"kimi-k2.7-code-highspeed\",\n\t\t\t\"kimi-k2.6\",\n\t\t\t\"minimax-m3\",\n\t\t\t\"minimax-m2.7\",\n\t\t},\n\t},\n\t{\n\t\tName: \"hy-tokenplan\",\n\t\tDisplayName: \"Tencent Hunyuan Token Plan API\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://api.lkeap.cloud.tencent.com/plan/v3\",\n\t\tEnvVar: \"TENCENT_HUNYUAN_TOKENPLAN_KEY\",\n\t\tModels: []string{\n\t\t\t\"hy3\",\n\t\t},\n\t},\n\t{\n\t\tName: \"iflytek\",\n\t\tDisplayName: \"iFlytek Spark API\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://spark-api-open.xf-yun.com/v1\",\n\t\tEnvVar: \"SPARK_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"4.0Ultra\",\n\t\t\t\"generalv3.5\",\n\t\t\t\"max-32k\",\n\t\t\t\"generalv3\",\n\t\t\t\"pro-128k\",\n\t\t\t\"lite\",\n\t\t},\n\t},\n\t{\n\t\tName: \"kimi\",\n\t\tDisplayName: \"Kimi Moonshot API\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://api.moonshot.cn/v1\",\n\t\tEnvVar: \"MOONSHOT_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"kimi-k3\",\n\t\t\t\"kimi-k2.7-code\",\n\t\t\t\"kimi-k2.7-code-highspeed\",\n\t\t\t\"kimi-k2.6\",\n\t\t\t\"kimi-k2.5\",\n\t\t},\n\t},\n\t{\n\t\tName: \"z-ai\",\n\t\tDisplayName: \"Z.AI API\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://open.bigmodel.cn/api/paas/v4\",\n\t\tEnvVar: \"Z_AI_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"glm-5.2\",\n\t\t\t\"glm-5.1\",\n\t\t\t\"glm-5\",\n\t\t\t\"glm-5-turbo\",\n\t\t\t\"glm-4.7\",\n\t\t},\n\t},\n\t{\n\t\tName: \"z-ai-coding\",\n\t\tDisplayName: \"Z.AI Coding Plan API\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://open.bigmodel.cn/api/coding/paas/v4\",\n\t\tEnvVar: \"Z_AI_CODING_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"glm-5.2\",\n\t\t\t\"glm-5.1\",\n\t\t\t\"glm-5-turbo\",\n\t\t\t\"glm-4.7\",\n\t\t},\n\t},\n\t{\n\t\tName: \"mimo\",\n\t\tDisplayName: \"Xiaomi MiMo API\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\tEnvVar: \"MIMO_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"mimo-v2.5-pro\",\n\t\t\t\"mimo-v2.5\",\n\t\t},\n\t},\n\t{\n\t\tName: \"minimax\",\n\t\tDisplayName: \"MiniMax API\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://api.minimaxi.com/v1\",\n\t\tEnvVar: \"MINIMAX_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"MiniMax-M3\",\n\t\t\t\"MiniMax-M2.7\",\n\t\t\t\"MiniMax-M2.7-highspeed\",\n\t\t\t\"MiniMax-M2.5\",\n\t\t\t\"MiniMax-M2.5-highspeed\",\n\t\t},\n\t},\n\t{\n\t\tName: \"baidu-qianfan\",\n\t\tDisplayName: \"Baidu Qianfan API\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://qianfan.baidubce.com/v2\",\n\t\tEnvVar: \"QIANFAN_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"ernie-5.1\",\n\t\t\t\"ernie-5.0\",\n\t\t\t\"ernie-x1.1\",\n\t\t\t\"ernie-x1-turbo-32k-preview\",\n\t\t\t\"deepseek-v4-pro\",\n\t\t\t\"deepseek-v4-flash\",\n\t\t\t\"glm-5.2\",\n\t\t\t\"glm-5.1\",\n\t\t\t\"glm-5\",\n\t\t\t\"kimi-k2.6\",\n\t\t},\n\t},\n\t{\n\t\tName: \"ollama-cloud\",\n\t\tDisplayName: \"Ollama Cloud API\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"https://ollama.com/v1\",\n\t\tEnvVar: \"OLLAMA_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"deepseek-v4-flash\",\n\t\t\t\"deepseek-v4-pro\",\n\t\t\t\"gemma4:31b\",\n\t\t\t\"glm-5.1\",\n\t\t\t\"glm-5.2\",\n\t\t\t\"gpt-oss:120b\",\n\t\t\t\"gpt-oss:20b\",\n\t\t\t\"kimi-k2.5\",\n\t\t\t\"kimi-k2.6\",\n\t\t\t\"kimi-k2.7-code\",\n\t\t\t\"minimax-m2.5\",\n\t\t\t\"minimax-m2.7\",\n\t\t\t\"minimax-m3\",\n\t\t\t\"mistral-large-3:675b\",\n\t\t\t\"nemotron-3-nano:30b\",\n\t\t\t\"nemotron-3-super\",\n\t\t\t\"nemotron-3-ultra\",\n\t\t\t\"qwen3.5:397b\",\n\t\t},\n\t},\n\t{\n\t\tName: \"litellm\",\n\t\tDisplayName: \"LiteLLM AI Gateway\",\n\t\tProtocol: ProtocolOpenAIChatCompletions,\n\t\tBaseURL: \"http://localhost:4000/v1\",\n\t\tEnvVar: \"LITELLM_API_KEY\",\n\t\tModels: []string{\n\t\t\t\"anthropic/claude-sonnet-4-6\",\n\t\t\t\"anthropic/claude-opus-4-6\",\n\t\t\t\"anthropic/claude-haiku-4-5\",\n\t\t\t\"openai/gpt-4o\",\n\t\t\t\"openai/gpt-5.4\",\n\t\t\t\"openai/o3\",\n\t\t\t\"vertex_ai/gemini-2.5-flash\",\n\t\t\t\"vertex_ai/gemini-2.5-pro\",\n\t\t\t\"bedrock/anthropic.claude-sonnet-4-6-v1\",\n\t\t\t\"groq/llama-4-scout-17b-16e-instruct\",\n\t\t\t\"mistral/mistral-large-latest\",\n\t\t\t\"deepseek/deepseek-chat\",\n\t\t},\n\t},\n}\n\nvar registryMap map[string]Provider\n\nfunc init() {\n\tregistryMap = make(map[string]Provider, len(registry))\n\tfor _, p := range registry {\n\t\tregistryMap[strings.ToLower(p.Name)] = p\n\t}\n}\n\n// LookupProvider returns the preset provider by name.\n// The returned Provider has its own copy of the Models slice.\nfunc LookupProvider(name string) (Provider, bool) {\n\tp, ok := registryMap[strings.ToLower(strings.TrimSpace(name))]\n\tif ok {\n\t\tp = copyProvider(p)\n\t}\n\treturn p, ok\n}\n\n// ListProviders returns all built-in providers sorted by provider name.\n// Each returned Provider has its own copy of the Models slice in registry order.\nfunc ListProviders() []Provider {\n\tout := make([]Provider, len(registry))\n\tfor i, p := range registry {\n\t\tout[i] = copyProvider(p)\n\t}\n\tsort.Slice(out, func(i, j int) bool {\n\t\treturn out[i].Name < out[j].Name\n\t})\n\treturn out\n}\n\nfunc copyProvider(p Provider) Provider {\n\tif p.Models != nil {\n\t\tmodels := make([]string, len(p.Models))\n\t\tcopy(models, p.Models)\n\t\tp.Models = models\n\t}\n\treturn p\n}\n"}}, "extensions/vscode/src/shared/providers.ts": {"0f3c920fc8ff091cbd1ad9e0632bf54bba56e46b": {"t": 1785809838.9441366, "n": 4707, "h": "67dfac2398eee691", "c": "export interface OcrProviderPreset {\n name: string;\n displayName: string;\n protocol: 'anthropic' | 'openai';\n baseUrl: string;\n authHeader?: string;\n envVar: string;\n models: string[];\n}\n\n/** 与 internal/llm/providers.go 内置 registry 对齐 */\nexport const PROVIDER_PRESETS: OcrProviderPreset[] = [\n {\n name: 'anthropic',\n displayName: 'Anthropic Claude API',\n protocol: 'anthropic',\n baseUrl: 'https://api.anthropic.com',\n authHeader: 'x-api-key',\n envVar: 'ANTHROPIC_API_KEY',\n models: ['claude-opus-4-8', 'claude-opus-4-7', 'claude-opus-4-6', 'claude-sonnet-4-6'],\n },\n {\n name: 'openai',\n displayName: 'OpenAI API',\n protocol: 'openai',\n baseUrl: 'https://api.openai.com/v1',\n envVar: 'OPENAI_API_KEY',\n models: ['gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini'],\n },\n {\n name: 'dashscope',\n displayName: 'Alibaba DashScope API',\n protocol: 'openai',\n baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n envVar: 'DASHSCOPE_API_KEY',\n models: ['qwen3.7-max', 'qwen3.7-plus', 'qwen3.6-plus', 'qwen3.6-flash'],\n },\n {\n name: 'dashscope-tokenplan',\n displayName: 'Alibaba DashScope Token Plan API',\n protocol: 'openai',\n baseUrl: 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1',\n envVar: 'DASHSCOPE_TOKENPLAN_KEY',\n models: [\n 'qwen3.7-max', 'qwen3.7-plus', 'qwen3.6-plus', 'qwen3.6-flash',\n 'deepseek-v4-pro', 'deepseek-v4-flash', 'kimi-k2.6', 'kimi-k2.5',\n 'glm-5.2', 'glm-5.1', 'glm-5', 'MiniMax-M2.5',\n ],\n },\n {\n name: 'volcengine',\n displayName: 'Volcano Engine Ark API',\n protocol: 'openai',\n baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',\n envVar: 'ARK_API_KEY',\n models: ['doubao-seed-2-0-lite-260428', 'doubao-seed-2-0-mini-260428', 'doubao-seed-2-0-pro-260215'],\n },\n {\n name: 'deepseek',\n displayName: 'DeepSeek API',\n protocol: 'openai',\n baseUrl: 'https://api.deepseek.com',\n envVar: 'DEEPSEEK_API_KEY',\n models: ['deepseek-v4-pro', 'deepseek-v4-flash'],\n },\n {\n name: 'tencent-tokenhub',\n displayName: 'Tencent TokenHub API',\n protocol: 'openai',\n baseUrl: 'https://tokenhub.tencentmaas.com/v1',\n envVar: 'TENCENT_TOKENHUB_API_KEY',\n models: ['hy3-preview'],\n },\n {\n name: 'hy-tokenplan',\n displayName: 'Tencent Hunyuan Token Plan API',\n protocol: 'openai',\n baseUrl: 'https://api.lkeap.cloud.tencent.com/plan/v3',\n envVar: 'TENCENT_HUNYUAN_TOKENPLAN_KEY',\n models: ['hy3-preview'],\n },\n {\n name: 'kimi',\n displayName: 'Kimi Moonshot API',\n protocol: 'openai',\n baseUrl: 'https://api.moonshot.cn/v1',\n envVar: 'MOONSHOT_API_KEY',\n models: ['kimi-k2.7-code', 'kimi-k2.6', 'kimi-k2.5'],\n },\n {\n name: 'z-ai',\n displayName: 'Z.AI API',\n protocol: 'openai',\n baseUrl: 'https://open.bigmodel.cn/api/paas/v4',\n envVar: 'Z_AI_API_KEY',\n models: ['glm-5.2', 'glm-5.1', 'glm-5-turbo', 'glm-4.7'],\n },\n {\n name: 'z-ai-coding',\n displayName: 'Z.AI Coding Plan API',\n protocol: 'openai',\n baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',\n envVar: 'Z_AI_CODING_API_KEY',\n models: ['glm-5.2', 'glm-5.1', 'glm-5-turbo', 'glm-4.7'],\n },\n {\n name: 'mimo',\n displayName: 'Xiaomi MiMo API',\n protocol: 'openai',\n baseUrl: 'https://api.xiaomimimo.com/v1',\n envVar: 'MIMO_API_KEY',\n models: ['mimo-v2.5-pro', 'mimo-v2.5', 'mimo-v2-pro', 'mimo-v2-omni', 'mimo-v2-flash'],\n },\n {\n name: 'minimax',\n displayName: 'MiniMax API',\n protocol: 'openai',\n baseUrl: 'https://api.minimaxi.com/v1',\n envVar: 'MINIMAX_API_KEY',\n models: ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed'],\n },\n {\n name: 'baidu-qianfan',\n displayName: 'Baidu Qianfan API',\n protocol: 'openai',\n baseUrl: 'https://qianfan.baidubce.com/v2',\n envVar: 'QIANFAN_API_KEY',\n models: ['ernie-5.1', 'ernie-5.0', 'ernie-x1.1', 'ernie-x1-turbo-32k-preview', 'deepseek-v4-pro', 'deepseek-v4-flash'],\n },\n];\n\nconst presetMap = new Map(PROVIDER_PRESETS.map((p) => [p.name.toLowerCase(), p]));\n\nexport function lookupPreset(name: string): OcrProviderPreset | undefined {\n return presetMap.get(name.trim().toLowerCase());\n}\n\nexport function isPresetProvider(name: string): boolean {\n return presetMap.has(name.trim().toLowerCase());\n}\n\nexport function mergeModelLists(...lists: string[][]): string[] {\n const seen = new Set<string>();\n const out: string[] = [];\n for (const list of lists) {\n for (const raw of list) {\n const model = raw.trim();\n if (!model || seen.has(model)) continue;\n seen.add(model);\n out.push(model);\n }\n }\n return out;\n}\n"}, "e9d4db51ec1d06fef1123d5a7158a7b311043650": {"t": 1785846959.8967772, "n": 4707, "h": "67dfac2398eee691", "c": "export interface OcrProviderPreset {\n name: string;\n displayName: string;\n protocol: 'anthropic' | 'openai';\n baseUrl: string;\n authHeader?: string;\n envVar: string;\n models: string[];\n}\n\n/** 与 internal/llm/providers.go 内置 registry 对齐 */\nexport const PROVIDER_PRESETS: OcrProviderPreset[] = [\n {\n name: 'anthropic',\n displayName: 'Anthropic Claude API',\n protocol: 'anthropic',\n baseUrl: 'https://api.anthropic.com',\n authHeader: 'x-api-key',\n envVar: 'ANTHROPIC_API_KEY',\n models: ['claude-opus-4-8', 'claude-opus-4-7', 'claude-opus-4-6', 'claude-sonnet-4-6'],\n },\n {\n name: 'openai',\n displayName: 'OpenAI API',\n protocol: 'openai',\n baseUrl: 'https://api.openai.com/v1',\n envVar: 'OPENAI_API_KEY',\n models: ['gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini'],\n },\n {\n name: 'dashscope',\n displayName: 'Alibaba DashScope API',\n protocol: 'openai',\n baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',\n envVar: 'DASHSCOPE_API_KEY',\n models: ['qwen3.7-max', 'qwen3.7-plus', 'qwen3.6-plus', 'qwen3.6-flash'],\n },\n {\n name: 'dashscope-tokenplan',\n displayName: 'Alibaba DashScope Token Plan API',\n protocol: 'openai',\n baseUrl: 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1',\n envVar: 'DASHSCOPE_TOKENPLAN_KEY',\n models: [\n 'qwen3.7-max', 'qwen3.7-plus', 'qwen3.6-plus', 'qwen3.6-flash',\n 'deepseek-v4-pro', 'deepseek-v4-flash', 'kimi-k2.6', 'kimi-k2.5',\n 'glm-5.2', 'glm-5.1', 'glm-5', 'MiniMax-M2.5',\n ],\n },\n {\n name: 'volcengine',\n displayName: 'Volcano Engine Ark API',\n protocol: 'openai',\n baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',\n envVar: 'ARK_API_KEY',\n models: ['doubao-seed-2-0-lite-260428', 'doubao-seed-2-0-mini-260428', 'doubao-seed-2-0-pro-260215'],\n },\n {\n name: 'deepseek',\n displayName: 'DeepSeek API',\n protocol: 'openai',\n baseUrl: 'https://api.deepseek.com',\n envVar: 'DEEPSEEK_API_KEY',\n models: ['deepseek-v4-pro', 'deepseek-v4-flash'],\n },\n {\n name: 'tencent-tokenhub',\n displayName: 'Tencent TokenHub API',\n protocol: 'openai',\n baseUrl: 'https://tokenhub.tencentmaas.com/v1',\n envVar: 'TENCENT_TOKENHUB_API_KEY',\n models: ['hy3-preview'],\n },\n {\n name: 'hy-tokenplan',\n displayName: 'Tencent Hunyuan Token Plan API',\n protocol: 'openai',\n baseUrl: 'https://api.lkeap.cloud.tencent.com/plan/v3',\n envVar: 'TENCENT_HUNYUAN_TOKENPLAN_KEY',\n models: ['hy3-preview'],\n },\n {\n name: 'kimi',\n displayName: 'Kimi Moonshot API',\n protocol: 'openai',\n baseUrl: 'https://api.moonshot.cn/v1',\n envVar: 'MOONSHOT_API_KEY',\n models: ['kimi-k2.7-code', 'kimi-k2.6', 'kimi-k2.5'],\n },\n {\n name: 'z-ai',\n displayName: 'Z.AI API',\n protocol: 'openai',\n baseUrl: 'https://open.bigmodel.cn/api/paas/v4',\n envVar: 'Z_AI_API_KEY',\n models: ['glm-5.2', 'glm-5.1', 'glm-5-turbo', 'glm-4.7'],\n },\n {\n name: 'z-ai-coding',\n displayName: 'Z.AI Coding Plan API',\n protocol: 'openai',\n baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',\n envVar: 'Z_AI_CODING_API_KEY',\n models: ['glm-5.2', 'glm-5.1', 'glm-5-turbo', 'glm-4.7'],\n },\n {\n name: 'mimo',\n displayName: 'Xiaomi MiMo API',\n protocol: 'openai',\n baseUrl: 'https://api.xiaomimimo.com/v1',\n envVar: 'MIMO_API_KEY',\n models: ['mimo-v2.5-pro', 'mimo-v2.5', 'mimo-v2-pro', 'mimo-v2-omni', 'mimo-v2-flash'],\n },\n {\n name: 'minimax',\n displayName: 'MiniMax API',\n protocol: 'openai',\n baseUrl: 'https://api.minimaxi.com/v1',\n envVar: 'MINIMAX_API_KEY',\n models: ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed'],\n },\n {\n name: 'baidu-qianfan',\n displayName: 'Baidu Qianfan API',\n protocol: 'openai',\n baseUrl: 'https://qianfan.baidubce.com/v2',\n envVar: 'QIANFAN_API_KEY',\n models: ['ernie-5.1', 'ernie-5.0', 'ernie-x1.1', 'ernie-x1-turbo-32k-preview', 'deepseek-v4-pro', 'deepseek-v4-flash'],\n },\n];\n\nconst presetMap = new Map(PROVIDER_PRESETS.map((p) => [p.name.toLowerCase(), p]));\n\nexport function lookupPreset(name: string): OcrProviderPreset | undefined {\n return presetMap.get(name.trim().toLowerCase());\n}\n\nexport function isPresetProvider(name: string): boolean {\n return presetMap.has(name.trim().toLowerCase());\n}\n\nexport function mergeModelLists(...lists: string[][]): string[] {\n const seen = new Set<string>();\n const out: string[] = [];\n for (const list of lists) {\n for (const raw of list) {\n const model = raw.trim();\n if (!model || seen.has(model)) continue;\n seen.add(model);\n out.push(model);\n }\n }\n return out;\n}\n"}}, "pages/src/content/docs/ru/configuration.md": {"0f3c920fc8ff091cbd1ad9e0632bf54bba56e46b": {"t": 1785809839.023949, "n": 7466, "h": "5211e19ced921ec0", "c": "---\ntitle: Конфигурация\nsidebar:\n order: 5\n---\n\nФайл конфигурации находится по пути `~/.opencodereview/config.json`. Изменить\nего можно тремя способами:\n\n- **Интерактивный TUI** — `ocr config provider` / `ocr config model` с пошаговыми меню.\n- **Командная строка** — `ocr config set <key> <value>`, удобно для скриптов и CI.\n- **Редактирование вручную (не рекомендуется)** — напрямую изменить JSON-файл (при следующей записи через `ocr config set` его форматирование изменится).\n\n## Настройка модели\n\n### Рекомендуемый способ: интерактивная настройка\n\n```bash\nocr config provider\n```\n\nКоманда позволяет выбрать встроенного или пользовательского провайдера,\nввести API-ключ и выбрать модель, сохраняет всё в файл конфигурации, а затем\nодин раз запускает `ocr llm test` для проверки эндпоинта. Чтобы позже сменить\nмодель:\n\n```bash\nocr config model\n```\n\n### Неинтерактивная настройка (CI / среды без TUI)\n\nЗапишите параметры в тот же файл конфигурации с помощью `ocr config set`:\n\n```bash\nocr config set provider anthropic\nocr config set model claude-opus-4-6\nocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx\n```\n\n### Встроенные провайдеры\n\nПеречисленные ниже провайдеры поставляются вместе с OCR; для них заранее\nзаданы базовый URL и протокол, поэтому после выбора достаточно указать\nAPI-ключ. Если `providers.<name>.api_key` не задан, OCR использует\nсоответствующую переменную окружения.\n\n| Имя | Протокол | Базовый URL | Переменная окружения для API-ключа |\n|---|---|---|---|\n| `anthropic` | anthropic | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` |\n| `openai` | openai | `https://api.openai.com/v1` | `OPENAI_API_KEY` |\n| `dashscope` | openai | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_API_KEY` |\n| `dashscope-tokenplan` | openai | `https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_TOKENPLAN_KEY` |\n| `volcengine` | openai | `https://ark.cn-beijing.volces.com/api/v3` | `ARK_API_KEY` |\n| `deepseek` | openai | `https://api.deepseek.com` | `DEEPSEEK_API_KEY` |\n| `tencent-tokenhub` | openai | `https://tokenhub.tencentmaas.com/v1` | `TENCENT_TOKENHUB_API_KEY` |\n| `hy-tokenplan` | openai | `https://api.lkeap.cloud.tencent.com/plan/v3` | `TENCENT_HUNYUAN_TOKENPLAN_KEY` |\n| `iflytek` | openai | `https://spark-api-open.xf-yun.com/v1` | `SPARK_API_KEY` |\n| `kimi` | openai | `https://api.moonshot.cn/v1` | `MOONSHOT_API_KEY` |\n| `z-ai` | openai | `https://open.bigmodel.cn/api/paas/v4` | `Z_AI_API_KEY` |\n| `mimo` | openai | `https://api.xiaomimimo.com/v1` | `MIMO_API_KEY` |\n| `minimax` | openai | `https://api.minimaxi.com/v1` | `MINIMAX_API_KEY` |\n| `baidu-qianfan` | openai | `https://qianfan.baidubce.com/v2` | `QIANFAN_API_KEY` |\n\n### Пользовательские провайдеры\n\nЛюбое имя провайдера, которого нет в таблице выше, считается\nпользовательским. Для него необходимо задать как минимум `url` и `protocol`\n(`protocol` может принимать значения `anthropic`, `openai` или\n`openai-responses`):\n\n```bash\nocr config set provider my-gateway\nocr config set custom_providers.my-gateway.url https://gateway.internal.com/v1\nocr config set custom_providers.my-gateway.protocol openai\nocr config set custom_providers.my-gateway.model llama-3-70b\nocr config set custom_providers.my-gateway.api_key \"$MY_API_KEY\"\n```\n\nИспользуйте `openai-responses`, если провайдеру или модели требуется OpenAI\nResponses API (`/v1/responses`):\n\n```bash\nocr config set provider openai-responses-gateway\nocr config set custom_providers.openai-responses-gateway.url https://api.openai.com/v1\nocr config set custom_providers.openai-responses-gateway.protocol openai-responses\nocr config set custom_providers.openai-responses-gateway.model gpt-5\nocr config set custom_providers.openai-responses-gateway.api_key \"$OPENAI_API_KEY\"\n```\n\nВ качестве `url` можно указать как базовый URL API, так и полный эндпоинт\n`/responses` — OCR нормализует оба варианта.\n\nЛокальная модель, запущенная через Ollama, — это обычный пользовательский\nпровайдер, указывающий на локальный OpenAI-совместимый эндпоинт:\n\n```bash\nocr config set provider ollama\nocr config set custom_providers.ollama.url http://127.0.0.1:11434/v1\nocr config set custom_providers.ollama.protocol openai\nocr config set custom_providers.ollama.model qwen3:32b\nocr config set custom_providers.ollama.api_key ollama\n```\n\nOllama игнорирует API-ключ, однако для пользовательских провайдеров требуется\nнепустое значение `api_key` (для них нет резервного варианта с переменной\nокружения), поэтому укажите любое значение-заполнитель. Сама модель должна\nподдерживать встроенные вызовы инструментов — перед выбором модели прочитайте\nраздел [«Не удалось разобрать вызовы инструментов» (локальные модели / Ollama)](../faq/#no-tool-calls-parsed-local-models-ollama)\nв FAQ.\n\n### Тайм-ауты\n\nДля каждого HTTP-запроса к LLM действует тайм-аут, по умолчанию **300 секунд**.\nМедленным локальным моделям (или большим файлам) может потребоваться больше\nвремени. Доступны три настройки с возрастающей областью действия:\n\n- `providers.<name>.timeout_sec` / `custom_providers.<name>.timeout_sec`\n — для отдельного провайдера, в секундах.\n- `llm.timeout_sec` — для устаревшего раздела `llm`, в секундах.\n- Переменная окружения `OCR_LLM_TIMEOUT` — целое число секунд; переопределяет\n значение из файла конфигурации для всех вариантов разрешения настроек.\n\nКлючи `timeout_sec` не поддерживаются командой `ocr config set` — измените\n`~/.opencodereview/config.json` напрямую:\n\n```json\n{\n \"custom_providers\": {\n \"ollama\": { \"url\": \"http://127.0.0.1:11434/v1\", \"protocol\": \"openai\", \"timeout_sec\": 900 }\n }\n}\n```\n\n### Проверка подключения\n\n```bash\nocr llm test\n```\n\n### Использование существующих переменных окружения\n\nЕсли у вас уже настроены переменные Claude Code `ANTHROPIC_*` или собственные\nпеременные OCR `OCR_LLM_*`, OCR обнаружит их автоматически — файл конфигурации\nне нужен.\n\n### Использование CC-Switch\n\nЕсли вы используете [CC-Switch](https://github.com/farion1231/cc-switch) с\nвключённой [службой маршрутизации](https://www.ccswitch.io/en/docs?section=proxy&item=service),\nукажите локальный прокси в качестве `url` провайдера — дополнительная настройка\nне требуется:\n\n```bash\n# Claude (Anthropic-compatible)\nocr config set providers.anthropic.url http://127.0.0.1:15721\n\n# Codex / OpenAI-compatible — set that provider's url key instead\nocr config set providers.<name>.url http://127.0.0.1:15721/v1\n```\n\nВ `api_key` можно указать любое значение. `extra_body` (как и другие поля\nконкретного провайдера) продолжает применяться обычным образом.\n\n### Отправка полей, специфичных для поставщика\n\nНекоторым провайдерам требуются нестандартные поля запроса (например,\n`thinking` в стиле Bedrock). Используйте `extra_body` (его содержимое\nдобавляется к каждому запросу), чтобы отправлять их без изменения исходного\nкода:\n\n```bash\nocr config set providers.anthropic.extra_body '{\"thinking\":{\"type\":\"disabled\"}}'\n```\n\n## Настройка языка ревью\n\nПараметр `language` определяет, на каком языке будут написаны комментарии\nревью; если он не задан, используется английский:\n\n```bash\nocr config set language 中文\nocr config set language English\n```\n\n## См. также\n\n- [Быстрый старт](../quickstart/) — минимальная настройка и первое ревью.\n- [Справочник CLI](../cli-reference/) — все флаги, принимаемые командой ревью.\n"}, "e6895dd15481e2612bf65114fab728fabd32451e": {"t": 1785846922.8521082, "n": 7466, "h": "5211e19ced921ec0", "c": "---\ntitle: Конфигурация\nsidebar:\n order: 5\n---\n\nФайл конфигурации находится по пути `~/.opencodereview/config.json`. Изменить\nего можно тремя способами:\n\n- **Интерактивный TUI** — `ocr config provider` / `ocr config model` с пошаговыми меню.\n- **Командная строка** — `ocr config set <key> <value>`, удобно для скриптов и CI.\n- **Редактирование вручную (не рекомендуется)** — напрямую изменить JSON-файл (при следующей записи через `ocr config set` его форматирование изменится).\n\n## Настройка модели\n\n### Рекомендуемый способ: интерактивная настройка\n\n```bash\nocr config provider\n```\n\nКоманда позволяет выбрать встроенного или пользовательского провайдера,\nввести API-ключ и выбрать модель, сохраняет всё в файл конфигурации, а затем\nодин раз запускает `ocr llm test` для проверки эндпоинта. Чтобы позже сменить\nмодель:\n\n```bash\nocr config model\n```\n\n### Неинтерактивная настройка (CI / среды без TUI)\n\nЗапишите параметры в тот же файл конфигурации с помощью `ocr config set`:\n\n```bash\nocr config set provider anthropic\nocr config set model claude-opus-4-6\nocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx\n```\n\n### Встроенные провайдеры\n\nПеречисленные ниже провайдеры поставляются вместе с OCR; для них заранее\nзаданы базовый URL и протокол, поэтому после выбора достаточно указать\nAPI-ключ. Если `providers.<name>.api_key` не задан, OCR использует\nсоответствующую переменную окружения.\n\n| Имя | Протокол | Базовый URL | Переменная окружения для API-ключа |\n|---|---|---|---|\n| `anthropic` | anthropic | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` |\n| `openai` | openai | `https://api.openai.com/v1` | `OPENAI_API_KEY` |\n| `dashscope` | openai | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_API_KEY` |\n| `dashscope-tokenplan` | openai | `https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_TOKENPLAN_KEY` |\n| `volcengine` | openai | `https://ark.cn-beijing.volces.com/api/v3` | `ARK_API_KEY` |\n| `deepseek` | openai | `https://api.deepseek.com` | `DEEPSEEK_API_KEY` |\n| `tencent-tokenhub` | openai | `https://tokenhub.tencentmaas.com/v1` | `TENCENT_TOKENHUB_API_KEY` |\n| `hy-tokenplan` | openai | `https://api.lkeap.cloud.tencent.com/plan/v3` | `TENCENT_HUNYUAN_TOKENPLAN_KEY` |\n| `iflytek` | openai | `https://spark-api-open.xf-yun.com/v1` | `SPARK_API_KEY` |\n| `kimi` | openai | `https://api.moonshot.cn/v1` | `MOONSHOT_API_KEY` |\n| `z-ai` | openai | `https://open.bigmodel.cn/api/paas/v4` | `Z_AI_API_KEY` |\n| `mimo` | openai | `https://api.xiaomimimo.com/v1` | `MIMO_API_KEY` |\n| `minimax` | openai | `https://api.minimaxi.com/v1` | `MINIMAX_API_KEY` |\n| `baidu-qianfan` | openai | `https://qianfan.baidubce.com/v2` | `QIANFAN_API_KEY` |\n\n### Пользовательские провайдеры\n\nЛюбое имя провайдера, которого нет в таблице выше, считается\nпользовательским. Для него необходимо задать как минимум `url` и `protocol`\n(`protocol` может принимать значения `anthropic`, `openai` или\n`openai-responses`):\n\n```bash\nocr config set provider my-gateway\nocr config set custom_providers.my-gateway.url https://gateway.internal.com/v1\nocr config set custom_providers.my-gateway.protocol openai\nocr config set custom_providers.my-gateway.model llama-3-70b\nocr config set custom_providers.my-gateway.api_key \"$MY_API_KEY\"\n```\n\nИспользуйте `openai-responses`, если провайдеру или модели требуется OpenAI\nResponses API (`/v1/responses`):\n\n```bash\nocr config set provider openai-responses-gateway\nocr config set custom_providers.openai-responses-gateway.url https://api.openai.com/v1\nocr config set custom_providers.openai-responses-gateway.protocol openai-responses\nocr config set custom_providers.openai-responses-gateway.model gpt-5\nocr config set custom_providers.openai-responses-gateway.api_key \"$OPENAI_API_KEY\"\n```\n\nВ качестве `url` можно указать как базовый URL API, так и полный эндпоинт\n`/responses` — OCR нормализует оба варианта.\n\nЛокальная модель, запущенная через Ollama, — это обычный пользовательский\nпровайдер, указывающий на локальный OpenAI-совместимый эндпоинт:\n\n```bash\nocr config set provider ollama\nocr config set custom_providers.ollama.url http://127.0.0.1:11434/v1\nocr config set custom_providers.ollama.protocol openai\nocr config set custom_providers.ollama.model qwen3:32b\nocr config set custom_providers.ollama.api_key ollama\n```\n\nOllama игнорирует API-ключ, однако для пользовательских провайдеров требуется\nнепустое значение `api_key` (для них нет резервного варианта с переменной\nокружения), поэтому укажите любое значение-заполнитель. Сама модель должна\nподдерживать встроенные вызовы инструментов — перед выбором модели прочитайте\nраздел [«Не удалось разобрать вызовы инструментов» (локальные модели / Ollama)](../faq/#no-tool-calls-parsed-local-models-ollama)\nв FAQ.\n\n### Тайм-ауты\n\nДля каждого HTTP-запроса к LLM действует тайм-аут, по умолчанию **300 секунд**.\nМедленным локальным моделям (или большим файлам) может потребоваться больше\nвремени. Доступны три настройки с возрастающей областью действия:\n\n- `providers.<name>.timeout_sec` / `custom_providers.<name>.timeout_sec`\n — для отдельного провайдера, в секундах.\n- `llm.timeout_sec` — для устаревшего раздела `llm`, в секундах.\n- Переменная окружения `OCR_LLM_TIMEOUT` — целое число секунд; переопределяет\n значение из файла конфигурации для всех вариантов разрешения настроек.\n\nКлючи `timeout_sec` не поддерживаются командой `ocr config set` — измените\n`~/.opencodereview/config.json` напрямую:\n\n```json\n{\n \"custom_providers\": {\n \"ollama\": { \"url\": \"http://127.0.0.1:11434/v1\", \"protocol\": \"openai\", \"timeout_sec\": 900 }\n }\n}\n```\n\n### Проверка подключения\n\n```bash\nocr llm test\n```\n\n### Использование существующих переменных окружения\n\nЕсли у вас уже настроены переменные Claude Code `ANTHROPIC_*` или собственные\nпеременные OCR `OCR_LLM_*`, OCR обнаружит их автоматически — файл конфигурации\nне нужен.\n\n### Использование CC-Switch\n\nЕсли вы используете [CC-Switch](https://github.com/farion1231/cc-switch) с\nвключённой [службой маршрутизации](https://www.ccswitch.io/en/docs?section=proxy&item=service),\nукажите локальный прокси в качестве `url` провайдера — дополнительная настройка\nне требуется:\n\n```bash\n# Claude (Anthropic-compatible)\nocr config set providers.anthropic.url http://127.0.0.1:15721\n\n# Codex / OpenAI-compatible — set that provider's url key instead\nocr config set providers.<name>.url http://127.0.0.1:15721/v1\n```\n\nВ `api_key` можно указать любое значение. `extra_body` (как и другие поля\nконкретного провайдера) продолжает применяться обычным образом.\n\n### Отправка полей, специфичных для поставщика\n\nНекоторым провайдерам требуются нестандартные поля запроса (например,\n`thinking` в стиле Bedrock). Используйте `extra_body` (его содержимое\nдобавляется к каждому запросу), чтобы отправлять их без изменения исходного\nкода:\n\n```bash\nocr config set providers.anthropic.extra_body '{\"thinking\":{\"type\":\"disabled\"}}'\n```\n\n## Настройка языка ревью\n\nПараметр `language` определяет, на каком языке будут написаны комментарии\nревью; если он не задан, используется английский:\n\n```bash\nocr config set language 中文\nocr config set language English\n```\n\n## См. также\n\n- [Быстрый старт](../quickstart/) — минимальная настройка и первое ревью.\n- [Справочник CLI](../cli-reference/) — все флаги, принимаемые командой ревью.\n"}, "e9d4db51ec1d06fef1123d5a7158a7b311043650": {"t": 1785846960.2779853, "n": 7466, "h": "5211e19ced921ec0", "c": "---\ntitle: Конфигурация\nsidebar:\n order: 5\n---\n\nФайл конфигурации находится по пути `~/.opencodereview/config.json`. Изменить\nего можно тремя способами:\n\n- **Интерактивный TUI** — `ocr config provider` / `ocr config model` с пошаговыми меню.\n- **Командная строка** — `ocr config set <key> <value>`, удобно для скриптов и CI.\n- **Редактирование вручную (не рекомендуется)** — напрямую изменить JSON-файл (при следующей записи через `ocr config set` его форматирование изменится).\n\n## Настройка модели\n\n### Рекомендуемый способ: интерактивная настройка\n\n```bash\nocr config provider\n```\n\nКоманда позволяет выбрать встроенного или пользовательского провайдера,\nввести API-ключ и выбрать модель, сохраняет всё в файл конфигурации, а затем\nодин раз запускает `ocr llm test` для проверки эндпоинта. Чтобы позже сменить\nмодель:\n\n```bash\nocr config model\n```\n\n### Неинтерактивная настройка (CI / среды без TUI)\n\nЗапишите параметры в тот же файл конфигурации с помощью `ocr config set`:\n\n```bash\nocr config set provider anthropic\nocr config set model claude-opus-4-6\nocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx\n```\n\n### Встроенные провайдеры\n\nПеречисленные ниже провайдеры поставляются вместе с OCR; для них заранее\nзаданы базовый URL и протокол, поэтому после выбора достаточно указать\nAPI-ключ. Если `providers.<name>.api_key` не задан, OCR использует\nсоответствующую переменную окружения.\n\n| Имя | Протокол | Базовый URL | Переменная окружения для API-ключа |\n|---|---|---|---|\n| `anthropic` | anthropic | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` |\n| `openai` | openai | `https://api.openai.com/v1` | `OPENAI_API_KEY` |\n| `dashscope` | openai | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_API_KEY` |\n| `dashscope-tokenplan` | openai | `https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_TOKENPLAN_KEY` |\n| `volcengine` | openai | `https://ark.cn-beijing.volces.com/api/v3` | `ARK_API_KEY` |\n| `deepseek` | openai | `https://api.deepseek.com` | `DEEPSEEK_API_KEY` |\n| `tencent-tokenhub` | openai | `https://tokenhub.tencentmaas.com/v1` | `TENCENT_TOKENHUB_API_KEY` |\n| `hy-tokenplan` | openai | `https://api.lkeap.cloud.tencent.com/plan/v3` | `TENCENT_HUNYUAN_TOKENPLAN_KEY` |\n| `iflytek` | openai | `https://spark-api-open.xf-yun.com/v1` | `SPARK_API_KEY` |\n| `kimi` | openai | `https://api.moonshot.cn/v1` | `MOONSHOT_API_KEY` |\n| `z-ai` | openai | `https://open.bigmodel.cn/api/paas/v4` | `Z_AI_API_KEY` |\n| `mimo` | openai | `https://api.xiaomimimo.com/v1` | `MIMO_API_KEY` |\n| `minimax` | openai | `https://api.minimaxi.com/v1` | `MINIMAX_API_KEY` |\n| `baidu-qianfan` | openai | `https://qianfan.baidubce.com/v2` | `QIANFAN_API_KEY` |\n\n### Пользовательские провайдеры\n\nЛюбое имя провайдера, которого нет в таблице выше, считается\nпользовательским. Для него необходимо задать как минимум `url` и `protocol`\n(`protocol` может принимать значения `anthropic`, `openai` или\n`openai-responses`):\n\n```bash\nocr config set provider my-gateway\nocr config set custom_providers.my-gateway.url https://gateway.internal.com/v1\nocr config set custom_providers.my-gateway.protocol openai\nocr config set custom_providers.my-gateway.model llama-3-70b\nocr config set custom_providers.my-gateway.api_key \"$MY_API_KEY\"\n```\n\nИспользуйте `openai-responses`, если провайдеру или модели требуется OpenAI\nResponses API (`/v1/responses`):\n\n```bash\nocr config set provider openai-responses-gateway\nocr config set custom_providers.openai-responses-gateway.url https://api.openai.com/v1\nocr config set custom_providers.openai-responses-gateway.protocol openai-responses\nocr config set custom_providers.openai-responses-gateway.model gpt-5\nocr config set custom_providers.openai-responses-gateway.api_key \"$OPENAI_API_KEY\"\n```\n\nВ качестве `url` можно указать как базовый URL API, так и полный эндпоинт\n`/responses` — OCR нормализует оба варианта.\n\nЛокальная модель, запущенная через Ollama, — это обычный пользовательский\nпровайдер, указывающий на локальный OpenAI-совместимый эндпоинт:\n\n```bash\nocr config set provider ollama\nocr config set custom_providers.ollama.url http://127.0.0.1:11434/v1\nocr config set custom_providers.ollama.protocol openai\nocr config set custom_providers.ollama.model qwen3:32b\nocr config set custom_providers.ollama.api_key ollama\n```\n\nOllama игнорирует API-ключ, однако для пользовательских провайдеров требуется\nнепустое значение `api_key` (для них нет резервного варианта с переменной\nокружения), поэтому укажите любое значение-заполнитель. Сама модель должна\nподдерживать встроенные вызовы инструментов — перед выбором модели прочитайте\nраздел [«Не удалось разобрать вызовы инструментов» (локальные модели / Ollama)](../faq/#no-tool-calls-parsed-local-models-ollama)\nв FAQ.\n\n### Тайм-ауты\n\nДля каждого HTTP-запроса к LLM действует тайм-аут, по умолчанию **300 секунд**.\nМедленным локальным моделям (или большим файлам) может потребоваться больше\nвремени. Доступны три настройки с возрастающей областью действия:\n\n- `providers.<name>.timeout_sec` / `custom_providers.<name>.timeout_sec`\n — для отдельного провайдера, в секундах.\n- `llm.timeout_sec` — для устаревшего раздела `llm`, в секундах.\n- Переменная окружения `OCR_LLM_TIMEOUT` — целое число секунд; переопределяет\n значение из файла конфигурации для всех вариантов разрешения настроек.\n\nКлючи `timeout_sec` не поддерживаются командой `ocr config set` — измените\n`~/.opencodereview/config.json` напрямую:\n\n```json\n{\n \"custom_providers\": {\n \"ollama\": { \"url\": \"http://127.0.0.1:11434/v1\", \"protocol\": \"openai\", \"timeout_sec\": 900 }\n }\n}\n```\n\n### Проверка подключения\n\n```bash\nocr llm test\n```\n\n### Использование существующих переменных окружения\n\nЕсли у вас уже настроены переменные Claude Code `ANTHROPIC_*` или собственные\nпеременные OCR `OCR_LLM_*`, OCR обнаружит их автоматически — файл конфигурации\nне нужен.\n\n### Использование CC-Switch\n\nЕсли вы используете [CC-Switch](https://github.com/farion1231/cc-switch) с\nвключённой [службой маршрутизации](https://www.ccswitch.io/en/docs?section=proxy&item=service),\nукажите локальный прокси в качестве `url` провайдера — дополнительная настройка\nне требуется:\n\n```bash\n# Claude (Anthropic-compatible)\nocr config set providers.anthropic.url http://127.0.0.1:15721\n\n# Codex / OpenAI-compatible — set that provider's url key instead\nocr config set providers.<name>.url http://127.0.0.1:15721/v1\n```\n\nВ `api_key` можно указать любое значение. `extra_body` (как и другие поля\nконкретного провайдера) продолжает применяться обычным образом.\n\n### Отправка полей, специфичных для поставщика\n\nНекоторым провайдерам требуются нестандартные поля запроса (например,\n`thinking` в стиле Bedrock). Используйте `extra_body` (его содержимое\nдобавляется к каждому запросу), чтобы отправлять их без изменения исходного\nкода:\n\n```bash\nocr config set providers.anthropic.extra_body '{\"thinking\":{\"type\":\"disabled\"}}'\n```\n\n## Настройка языка ревью\n\nПараметр `language` определяет, на каком языке будут написаны комментарии\nревью; если он не задан, используется английский:\n\n```bash\nocr config set language 中文\nocr config set language English\n```\n\n## См. также\n\n- [Быстрый старт](../quickstart/) — минимальная настройка и первое ревью.\n- [Справочник CLI](../cli-reference/) — все флаги, принимаемые командой ревью.\n"}}, "cmd/opencodereview/provider_tui_test.go": {"4bcc95acecd629e64ea984c962cfd08dd892a38e": {"t": 1786068299.602233, "n": 85632, "h": "f69b80fb5a317666", "c": "// SPDX-License-Identifier: Apache-2.0\n// Copyright 2026 alibaba/open-code-review Contributors\n\npackage main\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strings\"\n\t\"testing\"\n\n\ttea \"charm.land/bubbletea/v2\"\n\n\t\"github.com/alibaba/open-code-review/internal/llm\"\n)\n\nfunc escKey() tea.KeyPressMsg {\n\treturn tea.KeyPressMsg{Code: tea.KeyEscape}\n}\n\nfunc enterKey() tea.KeyPressMsg {\n\treturn tea.KeyPressMsg{Code: tea.KeyEnter}\n}\n\nfunc leftKey() tea.KeyPressMsg {\n\treturn tea.KeyPressMsg{Code: tea.KeyLeft}\n}\n\nfunc rightKey() tea.KeyPressMsg {\n\treturn tea.KeyPressMsg{Code: tea.KeyRight}\n}\n\nfunc downKey() tea.KeyPressMsg {\n\treturn tea.KeyPressMsg{Code: tea.KeyDown}\n}\n\nfunc tabKeyMsg() tea.KeyPressMsg {\n\treturn tea.KeyPressMsg{Code: tea.KeyTab}\n}\n\nfunc charKey(c rune) tea.KeyPressMsg {\n\treturn tea.KeyPressMsg{Code: c, Text: string(c)}\n}\n\n// --- Tab switching tests ---\n\nfunc TestProviderTUI_TabSwitchRight(t *testing.T) {\n\tm := newProviderTUI(&Config{}, \"\")\n\tif m.activeTab != tabOfficial {\n\t\tt.Fatalf(\"initial tab = %d, want %d\", m.activeTab, tabOfficial)\n\t}\n\n\tresult, _ := m.Update(rightKey())\n\tm2 := result.(providerTUIModel)\n\tif m2.activeTab != tabCustom {\n\t\tt.Errorf(\"after right, tab = %d, want %d\", m2.activeTab, tabCustom)\n\t}\n\n\tresult, _ = m2.Update(rightKey())\n\tm3 := result.(providerTUIModel)\n\tif m3.activeTab != tabManual {\n\t\tt.Errorf(\"after 2x right, tab = %d, want %d\", m3.activeTab, tabManual)\n\t}\n\n\t// Should not go past last tab\n\tresult, _ = m3.Update(rightKey())\n\tm4 := result.(providerTUIModel)\n\tif m4.activeTab != tabManual {\n\t\tt.Errorf(\"after 3x right, tab = %d, want %d (should clamp)\", m4.activeTab, tabManual)\n\t}\n}\n\nfunc TestProviderTUI_TabSwitchLeft(t *testing.T) {\n\tm := newProviderTUI(&Config{}, \"\")\n\n\t// Go to manual tab first\n\tresult, _ := m.Update(rightKey())\n\tm2 := result.(providerTUIModel)\n\tresult, _ = m2.Update(rightKey())\n\tm3 := result.(providerTUIModel)\n\tif m3.activeTab != tabManual {\n\t\tt.Fatalf(\"setup: tab = %d, want %d\", m3.activeTab, tabManual)\n\t}\n\n\tresult, _ = m3.Update(leftKey())\n\tm4 := result.(providerTUIModel)\n\tif m4.activeTab != tabCustom {\n\t\tt.Errorf(\"after left, tab = %d, want %d\", m4.activeTab, tabCustom)\n\t}\n\n\tresult, _ = m4.Update(leftKey())\n\tm5 := result.(providerTUIModel)\n\tif m5.activeTab != tabOfficial {\n\t\tt.Errorf(\"after 2x left, tab = %d, want %d\", m5.activeTab, tabOfficial)\n\t}\n\n\t// Should not go past first tab\n\tresult, _ = m5.Update(leftKey())\n\tm6 := result.(providerTUIModel)\n\tif m6.activeTab != tabOfficial {\n\t\tt.Errorf(\"after 3x left, tab = %d, want %d (should clamp)\", m6.activeTab, tabOfficial)\n\t}\n}\n\nfunc TestProviderTUI_TabKeyCycles(t *testing.T) {\n\tm := newProviderTUI(&Config{}, \"\")\n\n\tresult, _ := m.Update(tabKeyMsg())\n\tm2 := result.(providerTUIModel)\n\tif m2.activeTab != tabCustom {\n\t\tt.Errorf(\"after tab, tab = %d, want %d\", m2.activeTab, tabCustom)\n\t}\n\n\tresult, _ = m2.Update(tabKeyMsg())\n\tm3 := result.(providerTUIModel)\n\tif m3.activeTab != tabManual {\n\t\tt.Errorf(\"after 2x tab, tab = %d, want %d\", m3.activeTab, tabManual)\n\t}\n\n\tresult, _ = m3.Update(tabKeyMsg())\n\tm4 := result.(providerTUIModel)\n\tif m4.activeTab != tabOfficial {\n\t\tt.Errorf(\"after 3x tab, tab = %d, want %d (should wrap)\", m4.activeTab, tabOfficial)\n\t}\n}\n\nfunc TestProviderTUI_TabSwitchOnlyOnStepProvider(t *testing.T) {\n\tm := newProviderTUI(&Config{}, \"\")\n\n\t// Advance to stepModel\n\tresult, _ := m.Update(enterKey())\n\tm2 := result.(providerTUIModel)\n\tif m2.step != stepModel {\n\t\tt.Fatalf(\"step = %d, want %d\", m2.step, stepModel)\n\t}\n\n\t// Tab keys should not change tab\n\tresult, _ = m2.Update(rightKey())\n\tm3 := result.(providerTUIModel)\n\tif m3.activeTab != tabOfficial {\n\t\tt.Errorf(\"right on stepModel should not change tab: got %d\", m3.activeTab)\n\t}\n}\n\n// --- Official tab tests (updated from original) ---\n\nfunc TestProviderTUI_OfficialProvidersSortedByDisplayName(t *testing.T) {\n\tm := newProviderTUI(&Config{}, \"\")\n\n\tdisplayNames := make([]string, len(m.providers))\n\tnormalized := make([]string, len(m.providers))\n\tfor i, p := range m.providers {\n\t\tdisplayNames[i] = p.DisplayName\n\t\tnormalized[i] = strings.ToLower(p.DisplayName)\n\t}\n\n\tif !sort.StringsAreSorted(normalized) {\n\t\tt.Errorf(\"provider display names are not sorted: %v\", displayNames)\n\t}\n}\n\nfunc TestProviderTUI_EscFromModelGoesBackToProvider(t *testing.T) {\n\tm := newProviderTUI(&Config{}, \"\")\n\n\tresult, _ := m.Update(enterKey())\n\tm2 := result.(providerTUIModel)\n\tif m2.step != stepModel {\n\t\tt.Fatalf(\"after Enter, step = %d, want %d (stepModel)\", m2.step, stepModel)\n\t}\n\n\tresult, _ = m2.Update(escKey())\n\tm3 := result.(providerTUIModel)\n\tif m3.step != stepProvider {\n\t\tt.Errorf(\"after Esc on stepModel, step = %d, want %d (stepProvider)\", m3.step, stepProvider)\n\t}\n\tif m3.cancelled {\n\t\tt.Error(\"should not be cancelled when going back from stepModel\")\n\t}\n}\n\nfunc TestProviderTUI_EscFromAPIKeyGoesBackToModel(t *testing.T) {\n\tm := newProviderTUI(&Config{}, \"\")\n\n\tresult, _ := m.Update(enterKey())\n\tm2 := result.(providerTUIModel)\n\n\tresult, _ = m2.Update(enterKey())\n\tm3 := result.(providerTUIModel)\n\tif m3.step != stepAPIKey {\n\t\tt.Fatalf(\"after 2x Enter, step = %d, want %d (stepAPIKey)\", m3.step, stepAPIKey)\n\t}\n\n\tresult, _ = m3.Update(escKey())\n\tm4 := result.(providerTUIModel)\n\tif m4.step != stepModel {\n\t\tt.Errorf(\"after Esc on stepAPIKey, step = %d, want %d (stepModel)\", m4.step, stepModel)\n\t}\n}\n\nfunc TestProviderTUI_EscFromProviderCancels(t *testing.T) {\n\tm := newProviderTUI(&Config{}, \"\")\n\n\tresult, cmd := m.Update(escKey())\n\tm2 := result.(providerTUIModel)\n\tif !m2.cancelled {\n\t\tt.Error(\"Esc on stepProvider should set cancelled = true\")\n\t}\n\tif cmd == nil {\n\t\tt.Error(\"Esc on stepProvider should return tea.Quit\")\n\t}\n}\n\nfunc TestProviderTUI_EscKeyString(t *testing.T) {\n\tesc := escKey()\n\tif s := esc.String(); s != \"esc\" {\n\t\tt.Errorf(\"escape key String() = %q, want %q\", s, \"esc\")\n\t}\n}\n\n// --- Manual tab tests ---\n\nfunc TestProviderTUI_ManualTabEnterStartsForm(t *testing.T) {\n\tm := newProviderTUI(&Config{}, \"\")\n\n\t// Switch to manual tab\n\tresult, _ := m.Update(rightKey())\n\tm2 := result.(providerTUIModel)\n\tresult, _ = m2.Update(rightKey())\n\tm3 := result.(providerTUIModel)\n\tif m3.activeTab != tabManual {\n\t\tt.Fatalf(\"tab = %d, want %d\", m3.activeTab, tabManual)\n\t}\n\n\t// Press Enter to start form\n\tresult, _ = m3.Update(enterKey())\n\tm4 := result.(providerTUIModel)\n\tif !m4.inManualForm {\n\t\tt.Error(\"Enter on manual tab should set inManualForm = true\")\n\t}\n\tif m4.manualStep != manualStepURL {\n\t\tt.Errorf(\"manualStep = %d, want %d\", m4.manualStep, manualStepURL)\n\t}\n}\n\nfunc TestProviderTUI_ManualFormEscFromURLExitsForm(t *testing.T) {\n\tm := newProviderTUI(&Config{}, \"\")\n\n\t// Switch to manual tab and enter form\n\tresult, _ := m.Update(rightKey())\n\tm2 := result.(providerTUIModel)\n\tresult, _ = m2.Update(rightKey())\n\tm3 := result.(providerTUIModel)\n\tresult, _ = m3.Update(enterKey())\n\tm4 := result.(providerTUIModel)\n\tif !m4.inManualForm {\n\t\tt.Fatalf(\"should be in manual form\")\n\t}\n\n\t// Esc should exit form, not cancel\n\tresult, _ = m4.Update(escKey())\n\tm5 := result.(providerTUIModel)\n\tif m5.inManualForm {\n\t\tt.Error(\"Esc from URL step should exit form\")\n\t}\n\tif m5.cancelled {\n\t\tt.Error(\"should not be cancelled when exiting form\")\n\t}\n}\n\nfunc TestProviderTUI_ManualFormEscRefocusesPreviousInput(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tfromStep manualStep\n\t\twantStep manualStep\n\t\tfocused func(m providerTUIModel) bool\n\t\tvalue func(m providerTUIModel) string\n\t}{\n\t\t{\n\t\t\tname: \"protocol back to url\",\n\t\t\tfromStep: manualStepProtocol,\n\t\t\twantStep: manualStepURL,\n\t\t\tfocused: func(m providerTUIModel) bool { return m.manualURLInput.Focused() },\n\t\t\tvalue: func(m providerTUIModel) string { return m.manualURLInput.Value() },\n\t\t},\n\t\t{\n\t\t\tname: \"auth token back to model\",\n\t\t\tfromStep: manualStepAuthToken,\n\t\t\twantStep: manualStepModel,\n\t\t\tfocused: func(m providerTUIModel) bool { return m.manualModelInput.Focused() },\n\t\t\tvalue: func(m providerTUIModel) string { return m.manualModelInput.Value() },\n\t\t},\n\t\t{\n\t\t\tname: \"auth header back to auth token\",\n\t\t\tfromStep: manualStepAuthHeader,\n\t\t\twantStep: manualStepAuthToken,\n\t\t\tfocused: func(m providerTUIModel) bool { return m.manualTokenInput.Focused() },\n\t\t\tvalue: func(m providerTUIModel) string { return m.manualTokenInput.Value() },\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tm := newProviderTUI(&Config{}, \"\")\n\t\t\tm.activeTab = tabManual\n\t\t\tm.inManualForm = true\n\t\t\tm.manualStep = tt.fromStep\n\n\t\t\tresult, _ := m.Update(escKey())\n\t\t\tm2 := result.(providerTUIModel)\n\t\t\tif m2.manualStep != tt.wantStep {\n\t\t\t\tt.Fatalf(\"manualStep = %d, want %d\", m2.manualStep, tt.wantStep)\n\t\t\t}\n\t\t\tif !tt.focused(m2) {\n\t\t\t\tt.Fatal(\"previous step input should be focused after Esc\")\n\t\t\t}\n\n\t\t\tresult, _ = m2.Update(charKey('x'))\n\t\t\tm3 := result.(providerTUIModel)\n\t\t\tif got := tt.value(m3); !strings.Contains(got, \"x\") {\n\t\t\t\tt.Errorf(\"input should accept typing after Esc: value = %q\", got)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestProviderTUI_ManualFormEscRestoresOriginalValues(t *testing.T) {\n\tcfg := &Config{\n\t\tLlm: LlmConfig{\n\t\t\tURL: \"https://example.com/v1\",\n\t\t\tModel: \"test-model\",\n\t\t\tAuthToken: \"token-123\",\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\n\t// Enter the form\n\tresult, _ := m.Update(enterKey())\n\tm2 := result.(providerTUIModel)\n\tif !m2.inManualForm {\n\t\tt.Fatalf(\"should be in manual form\")\n\t}\n\n\t// Simulate editing by directly modifying the input value\n\tm2.manualURLInput.SetValue(\"https://modified.example.com\")\n\n\t// Esc should restore original values\n\tresult, _ = m2.Update(escKey())\n\tm3 := result.(providerTUIModel)\n\tif m3.inManualForm {\n\t\tt.Error(\"should have exited form\")\n\t}\n\tif m3.manualURLInput.Value() != \"https://example.com/v1\" {\n\t\tt.Errorf(\"URL not restored: got %q, want %q\", m3.manualURLInput.Value(), \"https://example.com/v1\")\n\t}\n\tif m3.manualModelInput.Value() != \"test-model\" {\n\t\tt.Errorf(\"Model not restored: got %q, want %q\", m3.manualModelInput.Value(), \"test-model\")\n\t}\n\tif !m3.manualTokenMasked {\n\t\tt.Error(\"Token should be masked after Esc restore\")\n\t}\n\tif m3.manualTokenOriginal != \"token-123\" {\n\t\tt.Errorf(\"Token original not restored: got %q, want %q\", m3.manualTokenOriginal, \"token-123\")\n\t}\n}\n\nfunc TestProviderTUI_ManualFormPrefilledValues(t *testing.T) {\n\tcfg := &Config{\n\t\tLlm: LlmConfig{\n\t\t\tURL: \"https://example.com/v1\",\n\t\t\tModel: \"test-model\",\n\t\t\tAuthToken: \"token-123\",\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\n\tif m.activeTab != tabManual {\n\t\tt.Fatalf(\"should auto-select manual tab when Llm.URL is set, got %d\", m.activeTab)\n\t}\n\tif m.manualURLInput.Value() != \"https://example.com/v1\" {\n\t\tt.Errorf(\"URL not prefilled: got %q\", m.manualURLInput.Value())\n\t}\n\tif m.manualModelInput.Value() != \"test-model\" {\n\t\tt.Errorf(\"Model not prefilled: got %q\", m.manualModelInput.Value())\n\t}\n\tif !m.manualTokenMasked {\n\t\tt.Error(\"Token should be masked when prefilled\")\n\t}\n\tif m.manualTokenOriginal != \"token-123\" {\n\t\tt.Errorf(\"Token original not prefilled: got %q, want %q\", m.manualTokenOriginal, \"token-123\")\n\t}\n\tif m.manualTokenInput.Value() != strings.Repeat(\"*\", 20) {\n\t\tt.Errorf(\"Token input not masked display: got %q\", m.manualTokenInput.Value())\n\t}\n}\n\nfunc TestProviderTUI_ManualResult(t *testing.T) {\n\tcfg := &Config{\n\t\tLlm: LlmConfig{\n\t\t\tURL: \"https://example.com/v1\",\n\t\t\tModel: \"test-model\",\n\t\t\tAuthToken: \"token-123\",\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\n\t// Enter the form\n\tresult, _ := m.Update(enterKey())\n\tm2 := result.(providerTUIModel)\n\tm2.confirmed = true\n\n\tr := m2.result()\n\tif !r.isManual {\n\t\tt.Error(\"result should have isManual = true\")\n\t}\n\tif r.url != \"https://example.com/v1\" {\n\t\tt.Errorf(\"result url = %q, want %q\", r.url, \"https://example.com/v1\")\n\t}\n\tif r.model != \"test-model\" {\n\t\tt.Errorf(\"result model = %q, want %q\", r.model, \"test-model\")\n\t}\n}\n\nfunc TestProviderTUI_ManualFormPrefilledWhenProviderSet(t *testing.T) {\n\tcfg := &Config{\n\t\tProvider: \"my-gateway\",\n\t\tCustomProviders: map[string]ProviderEntry{\n\t\t\t\"my-gateway\": {URL: \"https://gw.example.com/v1\", Protocol: \"openai\", Model: \"llama-3\"},\n\t\t},\n\t\tLlm: LlmConfig{\n\t\t\tURL: \"https://manual.example.com/v1\",\n\t\t\tModel: \"manual-model\",\n\t\t\tAuthToken: \"manual-token\",\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\n\tif m.activeTab != tabCustom {\n\t\tt.Fatalf(\"should auto-select custom tab, got %d\", m.activeTab)\n\t}\n\tif m.manualURLInput.Value() != \"https://manual.example.com/v1\" {\n\t\tt.Errorf(\"URL not prefilled: got %q\", m.manualURLInput.Value())\n\t}\n\tif m.manualModelInput.Value() != \"manual-model\" {\n\t\tt.Errorf(\"Model not prefilled: got %q\", m.manualModelInput.Value())\n\t}\n\tif !m.manualTokenMasked {\n\t\tt.Error(\"Token should be masked when prefilled\")\n\t}\n\tif m.manualTokenOriginal != \"manual-token\" {\n\t\tt.Errorf(\"Token original not prefilled: got %q, want %q\", m.manualTokenOriginal, \"manual-token\")\n\t}\n}\n\nfunc TestProviderTUI_ManualFormPrefillsAuthHeader(t *testing.T) {\n\tcfg := &Config{\n\t\tLlm: LlmConfig{\n\t\t\tURL: \"https://manual.example.com/v1\",\n\t\t\tModel: \"manual-model\",\n\t\t\tAuthToken: \"manual-token\",\n\t\t\tAuthHeader: \"X-Custom-Auth\",\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\n\tif got := m.manualAuthHeaderInput.Value(); got != \"X-Custom-Auth\" {\n\t\tt.Errorf(\"manualAuthHeaderInput not prefilled: got %q, want %q\", got, \"X-Custom-Auth\")\n\t}\n}\n\nfunc TestProviderTUI_ManualFormSkipsEmptyTokenWhenOriginalExists(t *testing.T) {\n\tcfg := &Config{\n\t\tLlm: LlmConfig{\n\t\t\tURL: \"https://example.com/v1\",\n\t\t\tModel: \"test-model\",\n\t\t\tAuthToken: \"token-123\",\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\tm.inManualForm = true\n\tm.manualStep = manualStepAuthToken\n\tm.manualTokenOriginal = \"token-123\"\n\tm.manualTokenMasked = false\n\tm.manualTokenInput.SetValue(\"\")\n\tm.manualTokenInput.Focus()\n\n\tresult, _ := m.Update(enterKey())\n\tm2 := result.(providerTUIModel)\n\tif m2.manualStep != manualStepAuthHeader {\n\t\tt.Errorf(\"manualStep = %d, want %d\", m2.manualStep, manualStepAuthHeader)\n\t}\n\n\tm2.confirmed = true\n\tr := m2.result()\n\tif r.apiKey != \"token-123\" {\n\t\tt.Errorf(\"result apiKey = %q, want %q\", r.apiKey, \"token-123\")\n\t}\n}\n\nfunc TestProviderTUI_ManualFormRequiresTokenOnFirstSetup(t *testing.T) {\n\tm := newProviderTUI(&Config{}, \"\")\n\tm.inManualForm = true\n\tm.manualStep = manualStepAuthToken\n\tm.manualTokenInput.SetValue(\"\")\n\tm.manualTokenInput.Focus()\n\n\tresult, cmd := m.Update(enterKey())\n\tm2 := result.(providerTUIModel)\n\tif m2.manualStep != manualStepAuthToken {\n\t\tt.Errorf(\"should stay on auth token step, got %d\", m2.manualStep)\n\t}\n\tif m2.formError != manualAuthTokenRequiredError {\n\t\tt.Errorf(\"formError = %q, want %q\", m2.formError, manualAuthTokenRequiredError)\n\t}\n\tif cmd != nil {\n\t\tt.Error(\"Enter with empty token should not quit\")\n\t}\n}\n\nfunc TestProviderTUI_ManualFormRejectsWhitespaceOnlyToken(t *testing.T) {\n\tm := newProviderTUI(&Config{}, \"\")\n\tm.inManualForm = true\n\tm.manualStep = manualStepAuthToken\n\tm.manualTokenInput.SetValue(\" \")\n\tm.manualTokenInput.Focus()\n\n\tresult, cmd := m.Update(enterKey())\n\tm2 := result.(providerTUIModel)\n\tif m2.manualStep != manualStepAuthToken {\n\t\tt.Errorf(\"should stay on auth token step, got %d\", m2.manualStep)\n\t}\n\tif m2.formError != manualAuthTokenRequiredError {\n\t\tt.Errorf(\"formError = %q, want %q\", m2.formError, manualAuthTokenRequiredError)\n\t}\n\tif cmd != nil {\n\t\tt.Error(\"Enter with whitespace-only token should not quit\")\n\t}\n}\n\nfunc TestProviderTUI_SessionModelPickSurvivesOfficialProviderSwitch(t *testing.T) {\n\tcfg := &Config{\n\t\tProvider: \"deepseek\",\n\t\tModel: \"deepseek-v4-flash\",\n\t\tProviders: map[string]ProviderEntry{\n\t\t\t\"deepseek\": {Model: \"deepseek-v4-flash\"},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\tm.activeTab = tabOfficial\n\tfor i, p := range m.providers {\n\t\tif p.Name == \"baidu-qianfan\" {\n\t\t\tm.officialIdx = i\n\t\t\tbreak\n\t\t}\n\t}\n\n\tresult, _ := m.Update(enterKey())\n\tm2 := result.(providerTUIModel)\n\tm2.modelIdx = modelIdxForName(t, m2, \"glm-5\")\n\n\tresult, _ = m2.Update(enterKey())\n\tm3 := result.(providerTUIModel)\n\tif got := m3.sessionModelPick[\"baidu-qianfan\"]; got != \"glm-5\" {\n\t\tt.Errorf(\"sessionModelPick = %q, want glm-5\", got)\n\t}\n\n\tresult, _ = m3.Update(escKey())\n\tm4 := result.(providerTUIModel)\n\tresult, _ = m4.Update(escKey())\n\tm5 := result.(providerTUIModel)\n\n\tresult, _ = m5.Update(enterKey())\n\tm6 := result.(providerTUIModel)\n\tif got := m6.models()[m6.modelIdx]; got != \"glm-5\" {\n\t\tt.Errorf(\"model cursor = %q, want glm-5\", got)\n\t}\n}\n\n// --- Custom tab tests ---\n\nfunc TestProviderTUI_CustomTabShowsAddOption(t *testing.T) {\n\tm := newProviderTUI(&Config{}, \"\")\n\n\t// Switch to custom tab\n\tresult, _ := m.Update(rightKey())\n\tm2 := result.(providerTUIModel)\n\tif m2.activeTab != tabCustom {\n\t\tt.Fatalf(\"tab = %d, want %d\", m2.activeTab, tabCustom)\n\t}\n\n\t// With no custom providers, only \"Add\" option exists at index 0\n\tif m2.customListCount() != 1 {\n\t\tt.Errorf(\"customListCount() = %d, want 1 (only add option)\", m2.customListCount())\n\t}\n}\n\nfunc TestProviderTUI_CustomTabSelectAddStartsForm(t *testing.T) {\n\tm := newProviderTUI(&Config{}, \"\")\n\n\t// Switch to custom tab\n\tresult, _ := m.Update(rightKey())\n\tm2 := result.(providerTUIModel)\n\n\t// Enter on \"Add\" option\n\tresult, _ = m2.Update(enterKey())\n\tm3 := result.(providerTUIModel)\n\tif !m3.creatingCustom {\n\t\tt.Error(\"Enter on add option should set creatingCustom = true\")\n\t}\n\tif m3.cpStep != cpStepName {\n\t\tt.Errorf(\"cpStep = %d, want %d\", m3.cpStep, cpStepName)\n\t}\n}\n\nfunc TestProviderTUI_CustomFormEscFromNameExitsForm(t *testing.T) {\n\tm := newProviderTUI(&Config{}, \"\")\n\n\t// Switch to custom tab and start form\n\tresult, _ := m.Update(rightKey())\n\tm2 := result.(providerTUIModel)\n\tresult, _ = m2.Update(enterKey())\n\tm3 := result.(providerTUIModel)\n\tif !m3.creatingCustom {\n\t\tt.Fatalf(\"should be creating custom\")\n\t}\n\n\t// Esc from name step should exit form\n\tresult, _ = m3.Update(escKey())\n\tm4 := result.(providerTUIModel)\n\tif m4.creatingCustom {\n\t\tt.Error(\"Esc from name step should exit custom form\")\n\t}\n\tif m4.cancelled {\n\t\tt.Error(\"should not be cancelled\")\n\t}\n}\n\nfunc TestProviderTUI_CustomFormRejectsDuplicateName(t *testing.T) {\n\tcfg := &Config{\n\t\tProvider: \"stepfun\",\n\t\tCustomProviders: map[string]ProviderEntry{\n\t\t\t\"stepfun\": {Model: \"xxx\"},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\n\tresult, _ := m.Update(downKey())\n\tm2 := result.(providerTUIModel)\n\n\tresult, _ = m2.Update(enterKey())\n\tm3 := result.(providerTUIModel)\n\tif !m3.creatingCustom {\n\t\tt.Fatal(\"should be creating custom\")\n\t}\n\n\tm3.cpNameInput.SetValue(\"stepfun\")\n\tresult, _ = m3.Update(enterKey())\n\tm4 := result.(providerTUIModel)\n\tif m4.cpStep != cpStepName {\n\t\tt.Errorf(\"cpStep = %d, want %d\", m4.cpStep, cpStepName)\n\t}\n\tif m4.formError == \"\" {\n\t\tt.Error(\"expected formError for duplicate name\")\n\t}\n\tif !strings.Contains(m4.formError, \"stepfun\") {\n\t\tt.Errorf(\"formError = %q, want to mention stepfun\", m4.formError)\n\t}\n\n\tresult, _ = m4.Update(charKey('x'))\n\tm4b := result.(providerTUIModel)\n\tif m4b.formError != \"\" {\n\t\tt.Errorf(\"formError should clear on keystroke, got %q\", m4b.formError)\n\t}\n\n\tm4b.cpNameInput.SetValue(\"stepfun2\")\n\tresult, _ = m4b.Update(enterKey())\n\tm5 := result.(providerTUIModel)\n\tif m5.cpStep != cpStepProtocol {\n\t\tt.Errorf(\"cpStep = %d, want %d\", m5.cpStep, cpStepProtocol)\n\t}\n\tif m5.formError != \"\" {\n\t\tt.Errorf(\"formError = %q, want empty after valid name\", m5.formError)\n\t}\n}\n\nfunc TestProviderTUI_CustomFormRejectsInvalidAuthHeader(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tcfg := &Config{}\n\tm := newProviderTUI(cfg, configPath)\n\n\tresult, _ := m.Update(rightKey())\n\tm2 := result.(providerTUIModel)\n\tresult, _ = m2.Update(enterKey())\n\tm3 := result.(providerTUIModel)\n\n\tm3.cpNameInput.SetValue(\"my-new\")\n\tresult, _ = m3.Update(enterKey())\n\tm4 := result.(providerTUIModel)\n\tresult, _ = m4.Update(enterKey())\n\tm5 := result.(providerTUIModel)\n\tm5.cpURLInput.SetValue(\"https://api.example.com\")\n\tresult, _ = m5.Update(enterKey())\n\tm6 := result.(providerTUIModel)\n\tresult, _ = m6.Update(enterKey())\n\tm7 := result.(providerTUIModel)\n\tif m7.cpStep != cpStepAuthHeader {\n\t\tt.Fatalf(\"cpStep = %d, want %d\", m7.cpStep, cpStepAuthHeader)\n\t}\n\n\tfor _, c := range \"bad-header\" {\n\t\tresult, _ = m7.Update(charKey(c))\n\t\tm7 = result.(providerTUIModel)\n\t}\n\tresult, _ = m7.Update(enterKey())\n\tm8 := result.(providerTUIModel)\n\n\tif m8.cpStep != cpStepAuthHeader {\n\t\tt.Errorf(\"cpStep = %d, want %d\", m8.cpStep, cpStepAuthHeader)\n\t}\n\tif m8.formError == \"\" {\n\t\tt.Error(\"expected formError for invalid auth header\")\n\t}\n\tif !strings.Contains(m8.formError, \"Unsupported Auth Header\") {\n\t\tt.Errorf(\"formError = %q, want unsupported auth header message\", m8.formError)\n\t}\n\tif !m8.creatingCustom {\n\t\tt.Error(\"creatingCustom should remain true when validation fails\")\n\t}\n\tif _, err := os.Stat(configPath); err == nil {\n\t\tt.Error(\"config should not be saved for invalid auth header\")\n\t}\n}\n\nfunc TestProviderTUI_CustomFormEditRejectsInvalidAuthHeader(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tcfg := &Config{\n\t\tCustomProviders: map[string]ProviderEntry{\n\t\t\t\"stepfun\": {\n\t\t\t\tURL: \"https://api.example.com\",\n\t\t\t\tProtocol: \"anthropic\",\n\t\t\t\tAuthHeader: \"authorization\",\n\t\t\t},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, configPath)\n\tm.activeTab = tabCustom\n\tm.customIdx = 0\n\tm.enterEditCustomProvider()\n\tm.cpStep = cpStepAuthHeader\n\tm.cpAuthInput.SetValue(\"bad-header\")\n\tm.cpAuthInput.Focus()\n\n\tresult, _ := m.Update(enterKey())\n\tm2 := result.(providerTUIModel)\n\n\tif m2.cpStep != cpStepAuthHeader {\n\t\tt.Errorf(\"cpStep = %d, want %d\", m2.cpStep, cpStepAuthHeader)\n\t}\n\tif m2.formError == \"\" {\n\t\tt.Error(\"expected formError for invalid auth header\")\n\t}\n\tif !m2.editingCustom {\n\t\tt.Error(\"editingCustom should remain true when validation fails\")\n\t}\n\tif got := cfg.CustomProviders[\"stepfun\"].AuthHeader; got != \"authorization\" {\n\t\tt.Errorf(\"AuthHeader = %q, want unchanged %q\", got, \"authorization\")\n\t}\n}\n\nfunc TestProviderTUI_EditCustomProviderSaveRejectsDuplicateRename(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tcfg := &Config{\n\t\tCustomProviders: map[string]ProviderEntry{\n\t\t\t\"stepfun\": {\n\t\t\t\tURL: \"https://stepfun.example.com\",\n\t\t\t\tProtocol: \"anthropic\",\n\t\t\t},\n\t\t\t\"other\": {\n\t\t\t\tURL: \"https://other.example.com\",\n\t\t\t\tProtocol: \"openai\",\n\t\t\t},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, configPath)\n\tm.activeTab = tabCustom\n\tm.editingCustom = true\n\tm.editTargetName = \"other\"\n\tm.cpProtocolIdx = 1 // openai\n\tm.cpNameInput.SetValue(\"stepfun\")\n\tm.cpURLInput.SetValue(\"https://other.example.com\")\n\n\terr := m.applyEditCustomProviderSave()\n\tif err == nil {\n\t\tt.Fatal(\"expected error when renaming to existing provider name\")\n\t}\n\tif !strings.Contains(m.formError, \"stepfun\") {\n\t\tt.Errorf(\"formError = %q, want to mention stepfun\", m.formError)\n\t}\n\tif _, ok := cfg.CustomProviders[\"other\"]; !ok {\n\t\tt.Error(\"original provider 'other' should still exist\")\n\t}\n\tif cfg.CustomProviders[\"other\"].URL != \"https://other.example.com\" {\n\t\tt.Errorf(\"provider 'other' URL = %q, want unchanged\", cfg.CustomProviders[\"other\"].URL)\n\t}\n}\n\nfunc TestApplyEditCustomProviderSave_ClearsAPIKeyWhenEditedEmpty(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tcfg := &Config{\n\t\tCustomProviders: map[string]ProviderEntry{\n\t\t\t\"aaa\": {\n\t\t\t\tURL: \"https://example.com/v1\",\n\t\t\t\tProtocol: \"anthropic\",\n\t\t\t\tAPIKey: \"old-saved-key\",\n\t\t\t\tModel: \"test\",\n\t\t\t\tModels: []string{\"test\"},\n\t\t\t},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, configPath)\n\tm.activeTab = tabCustom\n\tm.editingCustom = true\n\tm.editTargetName = \"aaa\"\n\tm.cpProtocolIdx = 0\n\tm.cpNameInput.SetValue(\"aaa\")\n\tm.cpURLInput.SetValue(\"https://example.com/v1\")\n\tm.beginAPIKeyReplace()\n\n\tif err := m.applyEditCustomProviderSave(); err != nil {\n\t\tt.Fatalf(\"applyEditCustomProviderSave: %v\", err)\n\t}\n\tif got := cfg.CustomProviders[\"aaa\"].APIKey; got != \"\" {\n\t\tt.Errorf(\"APIKey = %q, want empty\", got)\n\t}\n\tdiskCfg, err := loadOrCreateConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"load config: %v\", err)\n\t}\n\tif got := diskCfg.CustomProviders[\"aaa\"].APIKey; got != \"\" {\n\t\tt.Errorf(\"persisted APIKey = %q, want empty\", got)\n\t}\n}\n\nfunc TestApplyEditCustomProviderSave_PreservesAPIKeyWhenMasked(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tcfg := &Config{\n\t\tCustomProviders: map[string]ProviderEntry{\n\t\t\t\"aaa\": {\n\t\t\t\tURL: \"https://example.com/v1\",\n\t\t\t\tProtocol: \"anthropic\",\n\t\t\t\tAPIKey: \"keep-me\",\n\t\t\t},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, configPath)\n\tm.activeTab = tabCustom\n\tm.customIdx = 0\n\tm.enterEditCustomProvider()\n\n\tif err := m.applyEditCustomProviderSave(); err != nil {\n\t\tt.Fatalf(\"applyEditCustomProviderSave: %v\", err)\n\t}\n\tif got := cfg.CustomProviders[\"aaa\"].APIKey; got != \"keep-me\" {\n\t\tt.Errorf(\"APIKey = %q, want keep-me\", got)\n\t}\n}\n\nfunc TestProviderTUI_EditCustomClearKey_NoMaskedOnStepAPIKey(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tcfg := &Config{\n\t\tCustomProviders: map[string]ProviderEntry{\n\t\t\t\"aaa\": {\n\t\t\t\tURL: \"https://example.com/v1\",\n\t\t\t\tProtocol: \"anthropic\",\n\t\t\t\tAPIKey: \"old-saved-key\",\n\t\t\t\tModel: \"test\",\n\t\t\t\tModels: []string{\"test\", \"aaa\"},\n\t\t\t},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, configPath)\n\tm.activeTab = tabCustom\n\tfor i, cp := range m.customProviders {\n\t\tif cp.name == \"aaa\" {\n\t\t\tm.customIdx = i\n\t\t\tbreak\n\t\t}\n\t}\n\tm.enterEditCustomProvider()\n\tm.cpStep = cpStepAPIKey\n\tm.beginAPIKeyReplace()\n\tm.cpStep = cpStepAuthHeader\n\tm.cpAuthInput.SetValue(\"\")\n\tm.cpAuthInput.Focus()\n\n\tresult, _ := m.Update(enterKey())\n\tm2 := result.(providerTUIModel)\n\tif m2.step != stepModel {\n\t\tt.Fatalf(\"step = %d, want stepModel\", m2.step)\n\t}\n\tif got := m2.customProviders[m2.customIdx].entry.APIKey; got != \"\" {\n\t\tt.Errorf(\"saved APIKey = %q, want empty\", got)\n\t}\n\n\tm2.modelIdx = modelIdxForName(t, m2, \"test\")\n\tresult, _ = m2.Update(enterKey())\n\tm3 := result.(providerTUIModel)\n\tif m3.step != stepAPIKey {\n\t\tt.Fatalf(\"step = %d, want stepAPIKey\", m3.step)\n\t}\n\tif m3.apiKeyMasked {\n\t\tt.Error(\"apiKeyMasked should be false after clearing key in edit\")\n\t}\n\tgot := stripANSI(m3.View().Content)\n\tif strings.Contains(got, \"Type or paste to replace the saved key\") {\n\t\tt.Errorf(\"view should not show replace hint; got:\\n%s\", got)\n\t}\n}\n\nfunc TestProviderTUI_ReenterEditAfterClearKey_ShowsEmpty(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tcfg := &Config{\n\t\tCustomProviders: map[string]ProviderEntry{\n\t\t\t\"aaa\": {\n\t\t\t\tURL: \"https://example.com/v1\",\n\t\t\t\tProtocol: \"anthropic\",\n\t\t\t\tAPIKey: \"old-saved-key\",\n\t\t\t},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, configPath)\n\tm.activeTab = tabCustom\n\tm.customIdx = 0\n\tm.editingCustom = true\n\tm.editTargetName = \"aaa\"\n\tm.cpProtocolIdx = 0\n\tm.cpNameInput.SetValue(\"aaa\")\n\tm.cpURLInput.SetValue(\"https://example.com/v1\")\n\tm.beginAPIKeyReplace()\n\tif err := m.applyEditCustomProviderSave(); err != nil {\n\t\tt.Fatalf(\"applyEditCustomProviderSave: %v\", err)\n\t}\n\n\tm.enterEditCustomProvider()\n\tif m.apiKeyMasked {\n\t\tt.Error(\"apiKeyMasked should be false when key was cleared\")\n\t}\n\tif got := m.apiKeyInput.Value(); got != \"\" {\n\t\tt.Errorf(\"apiKeyInput = %q, want empty\", got)\n\t}\n}\n\nfunc TestCustomAPIKeyForSave(t *testing.T) {\n\tm := providerTUIModel{\n\t\tapiKeyMasked: true,\n\t\tapiKeyOriginal: \"keep-me\",\n\t}\n\tkey, edited := m.customAPIKeyForSave()\n\tif edited {\n\t\tt.Fatal(\"masked key should not count as edited\")\n\t}\n\tif key != \"keep-me\" {\n\t\tt.Errorf(\"key = %q, want keep-me\", key)\n\t}\n\n\tm.apiKeyMasked = false\n\tm.apiKeyInput.SetValue(\" \")\n\tkey, edited = m.customAPIKeyForSave()\n\tif !edited {\n\t\tt.Fatal(\"cleared field should count as edited\")\n\t}\n\tif key != \"\" {\n\t\tt.Errorf(\"key = %q, want empty\", key)\n\t}\n}\n\nfunc TestProviderTUI_CustomFormCreateReturnsToModelList(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tcfg := &Config{}\n\tm := newProviderTUI(cfg, configPath)\n\n\tresult, _ := m.Update(rightKey())\n\tm2 := result.(providerTUIModel)\n\tresult, _ = m2.Update(enterKey())\n\tm3 := result.(providerTUIModel)\n\n\tm3.cpNameInput.SetValue(\"my-new\")\n\tresult, _ = m3.Update(enterKey()) // name -> protocol\n\tm4 := result.(providerTUIModel)\n\tresult, _ = m4.Update(enterKey()) // protocol -> URL\n\tm5 := result.(providerTUIModel)\n\tm5.cpURLInput.SetValue(\"https://api.example.com\")\n\tresult, _ = m5.Update(enterKey()) // URL -> API key\n\tm6 := result.(providerTUIModel)\n\tm6.apiKeyInput.SetValue(\"key-123\")\n\tresult, _ = m6.Update(enterKey()) // API key -> auth header\n\tm7 := result.(providerTUIModel)\n\tresult, cmd := m7.Update(enterKey()) // auth header -> save\n\tm8 := result.(providerTUIModel)\n\n\tif cmd != nil {\n\t\tt.Error(\"create should not quit TUI\")\n\t}\n\tif m8.creatingCustom {\n\t\tt.Error(\"creatingCustom should be false after create\")\n\t}\n\t// Create should drop the user into the model selection step for the new\n\t// provider so they can pick/add a model right away.\n\tif m8.step != stepModel {\n\t\tt.Errorf(\"step = %d, want stepModel\", m8.step)\n\t}\n\tif len(m8.customProviders) != 1 {\n\t\tt.Fatalf(\"expected 1 custom provider, got %d\", len(m8.customProviders))\n\t}\n\tif m8.customProviders[0].name != \"my-new\" {\n\t\tt.Errorf(\"provider name = %q, want %q\", m8.customProviders[0].name, \"my-new\")\n\t}\n\tif cfg.Provider != \"\" {\n\t\tt.Error(\"active provider should not be set when only creating\")\n\t}\n\tif !m8.savedInSession {\n\t\tt.Error(\"savedInSession should be true after create\")\n\t}\n\tif _, err := os.Stat(configPath); err != nil {\n\t\tt.Fatalf(\"config should be saved: %v\", err)\n\t}\n}\n\nfunc TestProviderTUI_CustomProviderExistsInList(t *testing.T) {\n\tcfg := &Config{\n\t\tProvider: \"my-llm\",\n\t\tCustomProviders: map[string]ProviderEntry{\n\t\t\t\"my-llm\": {\n\t\t\t\tURL: \"https://custom.api/v1\",\n\t\t\t\tProtocol: \"openai\",\n\t\t\t\tModel: \"custom-model\",\n\t\t\t\tAPIKey: \"key-123\",\n\t\t\t},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\n\tif m.activeTab != tabCustom {\n\t\tt.Fatalf(\"should auto-select custom tab, got %d\", m.activeTab)\n\t}\n\tif len(m.customProviders) != 1 {\n\t\tt.Fatalf(\"expected 1 custom provider, got %d\", len(m.customProviders))\n\t}\n\tif m.customProviders[0].name != \"my-llm\" {\n\t\tt.Errorf(\"custom provider name = %q, want %q\", m.customProviders[0].name, \"my-llm\")\n\t}\n}\n\nfunc TestProviderTUI_SelectExistingCustomGoesToModel(t *testing.T) {\n\tcfg := &Config{\n\t\tProvider: \"my-llm\",\n\t\tCustomProviders: map[string]ProviderEntry{\n\t\t\t\"my-llm\": {\n\t\t\t\tURL: \"https://custom.api/v1\",\n\t\t\t\tProtocol: \"openai\",\n\t\t\t\tModel: \"custom-model\",\n\t\t\t\tModels: []string{\"custom-model\", \"custom-fast\"},\n\t\t\t\tAPIKey: \"key-123\",\n\t\t\t},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\n\t// Enter on existing custom provider should go to model selection first.\n\tresult, _ := m.Update(enterKey())\n\tm2 := result.(providerTUIModel)\n\tif m2.step != stepModel {\n\t\tt.Errorf(\"step = %d, want %d (stepModel)\", m2.step, stepModel)\n\t}\n\tgotModels := m2.models()\n\tif len(gotModels) != 2 || gotModels[0] != \"custom-model\" || gotModels[1] != \"custom-fast\" {\n\t\tt.Errorf(\"models = %v, want [custom-model custom-fast] (config order)\", gotModels)\n\t}\n}\n\n// --- collectCustomProviders tests ---\n\nfunc TestCollectCustomProviders_NilConfig(t *testing.T) {\n\tresult := collectCustomProviders(nil)\n\tif result != nil {\n\t\tt.Errorf(\"expected nil, got %v\", result)\n\t}\n}\n\nfunc TestCollectCustomProviders_ReadsCustomProviders(t *testing.T) {\n\tcfg := &Config{\n\t\tProviders: map[string]ProviderEntry{\n\t\t\t\"anthropic\": {APIKey: \"key1\"},\n\t\t\t\"openai\": {APIKey: \"key2\"},\n\t\t},\n\t\tCustomProviders: map[string]ProviderEntry{\n\t\t\t\"my-custom\": {URL: \"https://example.com\", Protocol: \"openai\"},\n\t\t},\n\t}\n\tresult := collectCustomProviders(cfg)\n\tif len(result) != 1 {\n\t\tt.Fatalf(\"expected 1 custom provider, got %d\", len(result))\n\t}\n\tif result[0].name != \"my-custom\" {\n\t\tt.Errorf(\"name = %q, want %q\", result[0].name, \"my-custom\")\n\t}\n}\n\nfunc TestCollectCustomProviders_SortedByName(t *testing.T) {\n\tcfg := &Config{\n\t\tCustomProviders: map[string]ProviderEntry{\n\t\t\t\"zzz-provider\": {URL: \"https://z.example.com\"},\n\t\t\t\"aaa-provider\": {URL: \"https://a.example.com\"},\n\t\t},\n\t}\n\tresult := collectCustomProviders(cfg)\n\tif len(result) != 2 {\n\t\tt.Fatalf(\"expected 2, got %d\", len(result))\n\t}\n\tif result[0].name != \"aaa-provider\" {\n\t\tt.Errorf(\"first = %q, want %q\", result[0].name, \"aaa-provider\")\n\t}\n\tif result[1].name != \"zzz-provider\" {\n\t\tt.Errorf(\"second = %q, want %q\", result[1].name, \"zzz-provider\")\n\t}\n}\n\n// --- Delete custom provider tests ---\n\nfunc dKey() tea.KeyPressMsg {\n\treturn tea.KeyPressMsg{Code: 'd'}\n}\n\nfunc yKey() tea.KeyPressMsg {\n\treturn tea.KeyPressMsg{Code: 'y'}\n}\n\nfunc nKey() tea.KeyPressMsg {\n\treturn tea.KeyPressMsg{Code: 'n'}\n}\n\nfunc TestProviderTUI_DeleteCustomProvider(t *testing.T) {\n\tcfg := &Config{\n\t\tProvider: \"anthropic\",\n\t\tCustomProviders: map[string]ProviderEntry{\n\t\t\t\"my-llm\": {URL: \"https://custom.api/v1\", Protocol: \"openai\", Model: \"custom-model\"},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\n\t// Switch to custom tab\n\tresult, _ := m.Update(rightKey())\n\tm2 := result.(providerTUIModel)\n\tif m2.activeTab != tabCustom {\n\t\tt.Fatalf(\"tab = %d, want %d\", m2.activeTab, tabCustom)\n\t}\n\n\t// Select the existing provider (index 0), press d\n\tm2.customIdx = 0\n\tresult, _ = m2.Update(dKey())\n\tm3 := result.(providerTUIModel)\n\tif !m3.confirmingDelete {\n\t\tt.Fatal(\"pressing d should set confirmingDelete = true\")\n\t}\n\tif m3.deleteTargetName != \"my-llm\" {\n\t\tt.Errorf(\"deleteTargetName = %q, want %q\", m3.deleteTargetName, \"my-llm\")\n\t}\n\n\t// Confirm with y\n\tresult, _ = m3.Update(yKey())\n\tm4 := result.(providerTUIModel)\n\tif m4.confirmingDelete {\n\t\tt.Error(\"confirmingDelete should be false after y\")\n\t}\n\tif len(m4.deletedProviders) != 1 || m4.deletedProviders[0] != \"my-llm\" {\n\t\tt.Errorf(\"deletedProviders = %v, want [my-llm]\", m4.deletedProviders)\n\t}\n\tif len(m4.customProviders) != 0 {\n\t\tt.Errorf(\"customProviders length = %d, want 0\", len(m4.customProviders))\n\t}\n}\n\nfunc TestProviderTUI_DeleteCustomProviderCancel(t *testing.T) {\n\tcfg := &Config{\n\t\tCustomProviders: map[string]ProviderEntry{\n\t\t\t\"my-llm\": {URL: \"https://custom.api/v1\", Protocol: \"openai\", Model: \"custom-model\"},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\n\t// Force custom tab so this test is independent of init-time tab routing.\n\t// Switch to custom tab, select provider, press d\n\tresult, _ := m.Update(rightKey())\n\tm2 := result.(providerTUIModel)\n\tm2.customIdx = 0\n\tresult, _ = m2.Update(dKey())\n\tm3 := result.(providerTUIModel)\n\tif !m3.confirmingDelete {\n\t\tt.Fatal(\"should be confirming delete\")\n\t}\n\n\t// Cancel with n\n\tresult, _ = m3.Update(nKey())\n\tm4 := result.(providerTUIModel)\n\tif m4.confirmingDelete {\n\t\tt.Error(\"confirmingDelete should be false after n\")\n\t}\n\tif len(m4.deletedProviders) != 0 {\n\t\tt.Error(\"deletedProviders should be empty after cancel\")\n\t}\n\tif len(m4.customProviders) != 1 {\n\t\tt.Error(\"customProviders should still have 1 entry after cancel\")\n\t}\n}\n\nfunc TestProviderTUI_DeleteOnAddOptionIgnored(t *testing.T) {\n\tcfg := &Config{\n\t\tCustomProviders: map[string]ProviderEntry{\n\t\t\t\"my-llm\": {URL: \"https://custom.api/v1\", Protocol: \"openai\"},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\n\t// Switch to custom tab\n\tresult, _ := m.Update(rightKey())\n\tm2 := result.(providerTUIModel)\n\n\t// Move to \"Add\" option (index 1, since there's 1 provider)\n\tm2.customIdx = len(m2.customProviders)\n\tresult, _ = m2.Update(dKey())\n\tm3 := result.(providerTUIModel)\n\tif m3.confirmingDelete {\n\t\tt.Error(\"pressing d on Add option should not trigger delete confirmation\")\n\t}\n}\n\nfunc TestProviderTUI_DeleteActiveCustomProvider(t *testing.T) {\n\tcfg := &Config{\n\t\tProvider: \"my-llm\",\n\t\tCustomProviders: map[string]ProviderEntry{\n\t\t\t\"my-llm\": {URL: \"https://custom.api/v1\", Protocol: \"openai\", Model: \"custom-model\"},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\n\t// Should auto-select custom tab with active provider\n\tif m.activeTab != tabCustom {\n\t\tt.Fatalf(\"should auto-select custom tab, got %d\", m.activeTab)\n\t}\n\n\t// Press d on the active provider\n\tm.customIdx = 0\n\tresult, _ := m.Update(dKey())\n\tm2 := result.(providerTUIModel)\n\tif !m2.confirmingDelete {\n\t\tt.Fatal(\"should be confirming delete\")\n\t}\n\n\t// Confirm\n\tresult, _ = m2.Update(yKey())\n\tm3 := result.(providerTUIModel)\n\tif len(m3.deletedProviders) != 1 || m3.deletedProviders[0] != \"my-llm\" {\n\t\tt.Errorf(\"deletedProviders = %v, want [my-llm]\", m3.deletedProviders)\n\t}\n}\n\nfunc TestProviderTUI_DeleteEscCancels(t *testing.T) {\n\tcfg := &Config{\n\t\tCustomProviders: map[string]ProviderEntry{\n\t\t\t\"my-llm\": {URL: \"https://custom.api/v1\", Protocol: \"openai\"},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\n\tresult, _ := m.Update(rightKey())\n\tm2 := result.(providerTUIModel)\n\tm2.customIdx = 0\n\tresult, _ = m2.Update(dKey())\n\tm3 := result.(providerTUIModel)\n\n\t// Esc should cancel confirmation\n\tresult, _ = m3.Update(escKey())\n\tm4 := result.(providerTUIModel)\n\tif m4.confirmingDelete {\n\t\tt.Error(\"Esc should cancel delete confirmation\")\n\t}\n\tif len(m4.deletedProviders) != 0 {\n\t\tt.Error(\"no providers should be deleted after Esc\")\n\t}\n}\n\nfunc TestActiveModelForProvider_PrefersEntryModel(t *testing.T) {\n\tcfg := &Config{Provider: \"stepfun\", Model: \"step-3.7-flash\"}\n\tentry := ProviderEntry{Model: \"step-3.5-flash\"}\n\tgot := activeModelForProvider(cfg, \"stepfun\", entry)\n\tif got != \"step-3.5-flash\" {\n\t\tt.Errorf(\"got %q, want step-3.5-flash\", got)\n\t}\n}\n\nfunc TestActiveModelForProvider_FallsBackToCfgModel(t *testing.T) {\n\tcfg := &Config{Provider: \"stepfun\", Model: \"step-3.5-flash\"}\n\tentry := ProviderEntry{}\n\tgot := activeModelForProvider(cfg, \"stepfun\", entry)\n\tif got != \"step-3.5-flash\" {\n\t\tt.Errorf(\"got %q, want step-3.5-flash\", got)\n\t}\n}\n\nfunc TestProviderTUI_CustomModelInput_AddsSingleName(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tcfg := &Config{\n\t\tProvider: \"stepfun\",\n\t\tModel: \"step-3.5-flash\",\n\t\tCustomProviders: map[string]ProviderEntry{\n\t\t\t\"stepfun\": {\n\t\t\t\tURL: \"https://api.stepfun.com/v1\",\n\t\t\t\tModel: \"step-3.5-flash\",\n\t\t\t\tModels: []string{\"step-3.5-flash\"},\n\t\t\t},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, configPath)\n\tm.activeTab = tabCustom\n\tm.customIdx = 0\n\tm.step = stepModel\n\tm.modelIdx = len(m.models()) // land on \"Enter custom model name...\"\n\tm.customModel = true\n\tm.modelInput.SetValue(\"newmodel\")\n\tm.modelInput.Focus()\n\n\tresult, _ := m.Update(enterKey())\n\tm2 := result.(providerTUIModel)\n\n\tif m2.customModel {\n\t\tt.Error(\"customModel should be cleared after Enter\")\n\t}\n\tif m2.formError != \"\" {\n\t\tt.Errorf(\"formError = %q, want empty\", m2.formError)\n\t}\n\tgot := m2.existingCfg.CustomProviders[\"stepfun\"].Models\n\twant := []string{\"step-3.5-flash\", \"newmodel\"}\n\tif len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {\n\t\tt.Errorf(\"Models = %v, want %v\", got, want)\n\t}\n\tif !m2.savedInSession {\n\t\tt.Error(\"savedInSession should be true after add\")\n\t}\n\n\tdiskCfg, err := loadOrCreateConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"load disk config: %v\", err)\n\t}\n\tdiskModels := diskCfg.CustomProviders[\"stepfun\"].Models\n\tif len(diskModels) != 2 || diskModels[1] != \"newmodel\" {\n\t\tt.Errorf(\"disk Models = %v, want last=step-3.5-flash,newmodel\", diskModels)\n\t}\n}\n\nfunc TestProviderTUI_CustomModelInput_RejectsDuplicate(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tcfg := &Config{\n\t\tProvider: \"stepfun\",\n\t\tModel: \"step-3.5-flash\",\n\t\tCustomProviders: map[string]ProviderEntry{\n\t\t\t\"stepfun\": {\n\t\t\t\tURL: \"https://api.stepfun.com/v1\",\n\t\t\t\tModel: \"step-3.5-flash\",\n\t\t\t\tModels: []string{\"step-3.5-flash\"},\n\t\t\t},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, configPath)\n\tm.activeTab = tabCustom\n\tm.customIdx = 0\n\tm.step = stepModel\n\tm.modelIdx = len(m.models())\n\tm.customModel = true\n\tm.modelInput.SetValue(\"step-3.5-flash\")\n\tm.modelInput.Focus()\n\n\tresult, _ := m.Update(enterKey())\n\tm2 := result.(providerTUIModel)\n\n\tif !m2.customModel {\n\t\tt.Error(\"customModel should stay true after duplicate reject\")\n\t}\n\tif m2.formError != \"Already in list: step-3.5-flash\" {\n\t\tt.Errorf(\"formError = %q, want %q\", m2.formError, \"Already in list: step-3.5-flash\")\n\t}\n\tif m2.modelInput.Value() != \"step-3.5-flash\" {\n\t\tt.Errorf(\"input should be preserved on dup; got %q\", m2.modelInput.Value())\n\t}\n\tif len(m2.existingCfg.CustomProviders[\"stepfun\"].Models) != 1 {\n\t\tt.Errorf(\"Models mutated: %v\", m2.existingCfg.CustomProviders[\"stepfun\"].Models)\n\t}\n\tif _, err := os.Stat(configPath); err == nil {\n\t\tt.Errorf(\"disk file should not exist; duplicate did not persist\")\n\t}\n\tif m2.savedInSession {\n\t\tt.Error(\"savedInSession should be false after rejected duplicate\")\n\t}\n}\n\nfunc TestProviderTUI_OfficialTab_CustomModelInput_PersistsName(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tcfg := &Config{\n\t\tProvider: \"dashscope\",\n\t\tModel: \"qwen3.7-max\",\n\t\tProviders: map[string]ProviderEntry{\n\t\t\t\"dashscope\": {\n\t\t\t\tModel: \"qwen3.7-max\",\n\t\t\t\tModels: []string{\"qwen3.7-max\"},\n\t\t\t},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, configPath)\n\tm.activeTab = tabOfficial\n\n\t// Land the cursor on the official provider we configured.\n\tfor i, p := range m.providers {\n\t\tif p.Name == \"dashscope\" {\n\t\t\tm.officialIdx = i\n\t\t\tbreak\n\t\t}\n\t}\n\tm.step = stepModel\n\tm.modelIdx = len(m.models()) // \"Enter custom model name...\"\n\tm.customModel = true\n\tm.modelInput.SetValue(\"my-custom-model\")\n\tm.modelInput.Focus()\n\n\tresult, _ := m.Update(enterKey())\n\tm2 := result.(providerTUIModel)\n\n\tif m2.customModel {\n\t\tt.Error(\"customModel should be cleared after Enter\")\n\t}\n\tif m2.formError != \"\" {\n\t\tt.Errorf(\"formError = %q, want empty\", m2.formError)\n\t}\n\tif !m2.savedInSession {\n\t\tt.Error(\"savedInSession should be true after successful add\")\n\t}\n\tgot := m2.existingCfg.Providers[\"dashscope\"].Models\n\twant := []string{\"qwen3.7-max\", \"my-custom-model\"}\n\tif len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {\n\t\tt.Errorf(\"official Models = %v, want %v\", got, want)\n\t}\n\n\tdiskCfg, err := loadOrCreateConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"load disk config: %v\", err)\n\t}\n\tdiskModels := diskCfg.Providers[\"dashscope\"].Models\n\tif len(diskModels) != 2 || diskModels[1] != \"my-custom-model\" {\n\t\tt.Errorf(\"disk Models = %v, want [qwen3.7-max my-custom-model]\", diskModels)\n\t}\n}\n\nfunc TestProviderTUI_OfficialTab_CustomModelInput_RejectsDuplicate(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tcfg := &Config{\n\t\tProvider: \"dashscope\",\n\t\tModel: \"qwen3.7-max\",\n\t\tProviders: map[string]ProviderEntry{\n\t\t\t\"dashscope\": {\n\t\t\t\tModel: \"qwen3.7-max\",\n\t\t\t\tModels: []string{\"qwen3.7-max\"},\n\t\t\t},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, configPath)\n\tm.activeTab = tabOfficial\n\tfor i, p := range m.providers {\n\t\tif p.Name == \"dashscope\" {\n\t\t\tm.officialIdx = i\n\t\t\tbreak\n\t\t}\n\t}\n\tm.step = stepModel\n\tm.modelIdx = len(m.models())\n\tm.customModel = true\n\tm.modelInput.SetValue(\"qwen3.7-max\")\n\tm.modelInput.Focus()\n\n\tresult, _ := m.Update(enterKey())\n\tm2 := result.(providerTUIModel)\n\n\tif !m2.customModel {\n\t\tt.Error(\"customModel should stay true after duplicate reject\")\n\t}\n\tif m2.formError != \"Already in list: qwen3.7-max\" {\n\t\tt.Errorf(\"formError = %q, want %q\", m2.formError, \"Already in list: qwen3.7-max\")\n\t}\n\tif _, err := os.Stat(configPath); err == nil {\n\t\tt.Errorf(\"disk file should not exist; duplicate did not persist\")\n\t}\n}\n\nfunc officialDashscopeModelTUI(t *testing.T, configPath string, extraModels []string) providerTUIModel {\n\tt.Helper()\n\tmodels := []string{\"qwen3.7-max\"}\n\tmodels = append(models, extraModels...)\n\tcfg := &Config{\n\t\tProvider: \"dashscope\",\n\t\tModel: \"qwen3.7-max\",\n\t\tProviders: map[string]ProviderEntry{\n\t\t\t\"dashscope\": {\n\t\t\t\tModel: \"qwen3.7-max\",\n\t\t\t\tModels: models,\n\t\t\t},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, configPath)\n\tm.activeTab = tabOfficial\n\tfor i, p := range m.providers {\n\t\tif p.Name == \"dashscope\" {\n\t\t\tm.officialIdx = i\n\t\t\tbreak\n\t\t}\n\t}\n\tm.step = stepModel\n\treturn m\n}\n\nfunc customStepfunModelTUI(t *testing.T, configPath string, models []string) providerTUIModel {\n\tt.Helper()\n\tif len(models) == 0 {\n\t\tmodels = []string{\"step-3.5-flash\"}\n\t}\n\tcfg := &Config{\n\t\tProvider: \"stepfun\",\n\t\tModel: models[0],\n\t\tCustomProviders: map[string]ProviderEntry{\n\t\t\t\"stepfun\": {\n\t\t\t\tURL: \"https://api.stepfun.com/v1\",\n\t\t\t\tProtocol: \"openai\",\n\t\t\t\tModel: models[0],\n\t\t\t\tModels: models,\n\t\t\t},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, configPath)\n\tm.activeTab = tabCustom\n\tfor i, cp := range m.customProviders {\n\t\tif cp.name == \"stepfun\" {\n\t\t\tm.customIdx = i\n\t\t\tbreak\n\t\t}\n\t}\n\tm.step = stepModel\n\treturn m\n}\n\nfunc modelIdxForName(t *testing.T, m providerTUIModel, name string) int {\n\tt.Helper()\n\tfor i, model := range m.models() {\n\t\tif model == name {\n\t\t\treturn i\n\t\t}\n\t}\n\tt.Fatalf(\"model %q not found in %v\", name, m.models())\n\treturn -1\n}\n\nfunc TestProviderTUI_OfficialTab_DeleteUserAddedModel(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tm := officialDashscopeModelTUI(t, configPath, []string{\"my-custom-model\"})\n\tm.modelIdx = modelIdxForName(t, m, \"my-custom-model\")\n\n\tresult, _ := m.Update(dKey())\n\tm2 := result.(providerTUIModel)\n\tif !m2.confirmingDeleteModel {\n\t\tt.Fatal(\"pressing d on user-added model should set confirmingDeleteModel = true\")\n\t}\n\tif m2.deleteModelName != \"my-custom-model\" {\n\t\tt.Errorf(\"deleteModelName = %q, want my-custom-model\", m2.deleteModelName)\n\t}\n\n\tresult, _ = m2.Update(yKey())\n\tm3 := result.(providerTUIModel)\n\tif m3.confirmingDeleteModel {\n\t\tt.Error(\"confirmingDeleteModel should be false after y\")\n\t}\n\tgot := m3.existingCfg.Providers[\"dashscope\"].Models\n\tif len(got) != 1 || got[0] != \"qwen3.7-max\" {\n\t\tt.Errorf(\"Models = %v, want [qwen3.7-max]\", got)\n\t}\n\tif !m3.savedInSession {\n\t\tt.Error(\"savedInSession should be true after delete\")\n\t}\n\n\tdiskCfg, err := loadOrCreateConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"load disk config: %v\", err)\n\t}\n\tif len(diskCfg.Providers[\"dashscope\"].Models) != 1 {\n\t\tt.Errorf(\"disk Models = %v, want [qwen3.7-max]\", diskCfg.Providers[\"dashscope\"].Models)\n\t}\n}\n\nfunc TestProviderTUI_OfficialTab_DeleteBuiltInModelIgnored(t *testing.T) {\n\tm := officialDashscopeModelTUI(t, \"\", []string{\"my-custom-model\"})\n\tm.modelIdx = modelIdxForName(t, m, \"qwen3.7-max\")\n\n\tresult, _ := m.Update(dKey())\n\tm2 := result.(providerTUIModel)\n\tif m2.confirmingDeleteModel {\n\t\tt.Error(\"pressing d on built-in model should not trigger delete confirmation\")\n\t}\n}\n\nfunc TestProviderTUI_OfficialTab_RegistryModelNotDeletable(t *testing.T) {\n\tm := officialDashscopeModelTUI(t, \"\", []string{\"my-custom-model\"})\n\tm.modelIdx = modelIdxForName(t, m, \"qwen3.7-max\")\n\n\tif m.isUserAddedOfficialModel(\"qwen3.7-max\") {\n\t\tt.Error(\"qwen3.7-max should not be user-added when it is in the registry\")\n\t}\n\n\tresult, _ := m.Update(dKey())\n\tm2 := result.(providerTUIModel)\n\tif m2.confirmingDeleteModel {\n\t\tt.Error(\"pressing d on registry model should not trigger delete confirmation\")\n\t}\n}\n\nfunc TestProviderTUI_OfficialTab_DeleteOnCustomModelInputIgnored(t *testing.T) {\n\tm := officialDashscopeModelTUI(t, \"\", []string{\"my-custom-model\"})\n\tm.modelIdx = len(m.models())\n\n\tresult, _ := m.Update(dKey())\n\tm2 := result.(providerTUIModel)\n\tif m2.confirmingDeleteModel {\n\t\tt.Error(\"pressing d on Enter custom model name... should not trigger delete confirmation\")\n\t}\n}\n\nfunc TestProviderTUI_CustomTab_DeleteOnCustomModelInputIgnored(t *testing.T) {\n\tm := customStepfunModelTUI(t, \"\", []string{\"step-3.5-flash\", \"aaa\"})\n\tm.modelIdx = len(m.models())\n\n\tresult, _ := m.Update(dKey())\n\tm2 := result.(providerTUIModel)\n\tif m2.confirmingDeleteModel {\n\t\tt.Error(\"pressing d on Enter custom model name... should not trigger delete confirmation\")\n\t}\n}\n\nfunc TestProviderTUI_CustomTab_ModelShowsDeleteHint(t *testing.T) {\n\tm := customStepfunModelTUI(t, \"\", []string{\"step-3.5-flash\", \"aaa\"})\n\n\tm.modelIdx = len(m.models())\n\tgot := stripANSI(m.View().Content)\n\tif strings.Contains(got, \"d Delete\") {\n\t\tt.Errorf(\"custom input row should not show d Delete hint; got:\\n%s\", got)\n\t}\n\n\tm.modelIdx = modelIdxForName(t, m, \"aaa\")\n\tgot = stripANSI(m.View().Content)\n\tif !strings.Contains(got, \"d Delete\") {\n\t\tt.Errorf(\"custom model row should show d Delete hint; got:\\n%s\", got)\n\t}\n}\n\nfunc TestProviderTUI_OfficialTab_UserAddedModelShowsDeleteHint(t *testing.T) {\n\tm := officialDashscopeModelTUI(t, \"\", []string{\"my-custom-model\"})\n\n\tm.modelIdx = modelIdxForName(t, m, \"qwen3.7-max\")\n\tgot := stripANSI(m.View().Content)\n\tif strings.Contains(got, \"d Delete\") {\n\t\tt.Errorf(\"built-in model should not show d Delete hint; got:\\n%s\", got)\n\t}\n\n\tm.modelIdx = modelIdxForName(t, m, \"my-custom-model\")\n\tgot = stripANSI(m.View().Content)\n\tif !strings.Contains(got, \"d Delete\") {\n\t\tt.Errorf(\"user-added model should show d Delete hint; got:\\n%s\", got)\n\t}\n}\n\nfunc TestProviderTUI_OfficialTab_DeleteModelPreservesActiveModel(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tm := officialDashscopeModelTUI(t, configPath, []string{\"my-custom-model\"})\n\tm.existingCfg.Model = \"qwen3.7-max\"\n\tm.existingCfg.Providers[\"dashscope\"] = ProviderEntry{\n\t\tModel: \"qwen3.7-max\",\n\t\tModels: []string{\"qwen3.7-max\", \"my-custom-model\"},\n\t}\n\tm.modelIdx = modelIdxForName(t, m, \"my-custom-model\")\n\n\tresult, _ := m.Update(dKey())\n\tm2 := result.(providerTUIModel)\n\tresult, _ = m2.Update(yKey())\n\tm3 := result.(providerTUIModel)\n\n\tif m3.existingCfg.Providers[\"dashscope\"].Model != \"qwen3.7-max\" {\n\t\tt.Errorf(\"entry.Model = %q, want qwen3.7-max\", m3.existingCfg.Providers[\"dashscope\"].Model)\n\t}\n\tif m3.existingCfg.Model != \"qwen3.7-max\" {\n\t\tt.Errorf(\"cfg.Model = %q, want qwen3.7-max\", m3.existingCfg.Model)\n\t}\n}\n\nfunc TestProviderTUI_OfficialTab_DeleteUserAddedModelCancel(t *testing.T) {\n\tcancelKeys := []struct {\n\t\tname string\n\t\tkey tea.KeyPressMsg\n\t}{\n\t\t{\"n\", nKey()},\n\t\t{\"esc\", escKey()},\n\t}\n\tfor _, tc := range cancelKeys {\n\t\tt.Run(tc.name, func(t *testing.T) {\n\t\t\tm := officialDashscopeModelTUI(t, \"\", []string{\"my-custom-model\"})\n\t\t\tm.modelIdx = modelIdxForName(t, m, \"my-custom-model\")\n\n\t\t\tresult, _ := m.Update(dKey())\n\t\t\tm2 := result.(providerTUIModel)\n\t\t\tif !m2.confirmingDeleteModel {\n\t\t\t\tt.Fatal(\"expected confirmingDeleteModel after d\")\n\t\t\t}\n\n\t\t\tresult, _ = m2.Update(tc.key)\n\t\t\tm3 := result.(providerTUIModel)\n\t\t\tif m3.confirmingDeleteModel {\n\t\t\t\tt.Error(\"confirmingDeleteModel should be false after cancel\")\n\t\t\t}\n\t\t\tgot := m3.existingCfg.Providers[\"dashscope\"].Models\n\t\t\tif len(got) != 2 || got[1] != \"my-custom-model\" {\n\t\t\t\tt.Errorf(\"Models = %v, want model unchanged\", got)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestProviderTUI_OfficialTab_DeleteActiveUserModelClearsCfg(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tm := officialDashscopeModelTUI(t, configPath, []string{\"my-custom-model\"})\n\tm.existingCfg.Model = \"my-custom-model\"\n\tm.existingCfg.Providers[\"dashscope\"] = ProviderEntry{\n\t\tModel: \"my-custom-model\",\n\t\tModels: []string{\"qwen3.7-max\", \"my-custom-model\"},\n\t}\n\tm.modelIdx = modelIdxForName(t, m, \"my-custom-model\")\n\n\tresult, _ := m.Update(dKey())\n\tm2 := result.(providerTUIModel)\n\tresult, _ = m2.Update(yKey())\n\tm3 := result.(providerTUIModel)\n\n\tif m3.existingCfg.Providers[\"dashscope\"].Model != \"\" {\n\t\tt.Errorf(\"entry.Model = %q, want empty\", m3.existingCfg.Providers[\"dashscope\"].Model)\n\t}\n\tif m3.existingCfg.Model != \"\" {\n\t\tt.Errorf(\"cfg.Model = %q, want empty\", m3.existingCfg.Model)\n\t}\n}\n\nfunc TestOfficialSelectedModelUsesRegistryNotStaleMerge(t *testing.T) {\n\tregistry := []string{\"built-in\"}\n\tdeletedCustom := \"my-custom\"\n\tstaleMerged := mergeModelLists(registry, []string{deletedCustom})\n\n\tif llm.ModelListContains(registry, deletedCustom) {\n\t\tt.Fatal(\"test setup: custom name should not be in registry\")\n\t}\n\tif !llm.ModelListContains(staleMerged, deletedCustom) {\n\t\tt.Fatal(\"test setup: stale merge should still list deleted custom name\")\n\t}\n\t// runConfigModel must use registryModels, not staleMerged, or re-selected custom\n\t// names would skip ensureModelInList when still present in the pre-TUI merge.\n\tif llm.ModelListContains(staleMerged, deletedCustom) && llm.ModelListContains(registry, deletedCustom) {\n\t\tt.Error(\"would skip persisting re-selected custom model\")\n\t}\n\tif llm.ModelListContains(registry, deletedCustom) {\n\t\tt.Error(\"registry-only check must not treat custom model as built-in\")\n\t}\n}\n\nfunc TestProviderTUI_CustomTab_DeleteModelSkipsSavedInSessionWhenNoModelDeleted(t *testing.T) {\n\tm := customStepfunModelTUI(t, \"\", []string{\"step-3.5-flash\", \"aaa\"})\n\tm.modelIdx = len(m.models()) // out of range — no model row selected\n\tm.deleteModelName = \"aaa\"\n\tm.confirmingDeleteModel = true\n\n\tresult, _ := m.confirmDeleteCustomModel()\n\tm2 := result.(providerTUIModel)\n\tif m2.savedInSession {\n\t\tt.Error(\"savedInSession should be false when modelIdx is out of range\")\n\t}\n}\n\nfunc TestProviderTUI_CustomTab_DeleteModelViaDKey(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tcfg := &Config{\n\t\tProvider: \"stepfun\",\n\t\tModel: \"step-3.5-flash\",\n\t\tCustomProviders: map[string]ProviderEntry{\n\t\t\t\"stepfun\": {\n\t\t\t\tURL: \"https://api.stepfun.com/v1\",\n\t\t\t\tProtocol: \"openai\",\n\t\t\t\tModel: \"step-3.5-flash\",\n\t\t\t\tModels: []string{\"step-3.5-flash\", \"aaa\"},\n\t\t\t},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, configPath)\n\tm.activeTab = tabCustom\n\tfor i, cp := range m.customProviders {\n\t\tif cp.name == \"stepfun\" {\n\t\t\tm.customIdx = i\n\t\t\tbreak\n\t\t}\n\t}\n\tm.step = stepModel\n\tm.modelIdx = modelIdxForName(t, m, \"aaa\")\n\n\tresult, _ := m.Update(dKey())\n\tm2 := result.(providerTUIModel)\n\tif !m2.confirmingDeleteModel || m2.deleteModelName != \"aaa\" {\n\t\tt.Fatalf(\"after d: confirming=%v deleteModelName=%q\", m2.confirmingDeleteModel, m2.deleteModelName)\n\t}\n\n\tresult, _ = m2.Update(yKey())\n\tm3 := result.(providerTUIModel)\n\tgot := m3.existingCfg.CustomProviders[\"stepfun\"].Models\n\tif len(got) != 1 || got[0] != \"step-3.5-flash\" {\n\t\tt.Errorf(\"Models = %v, want [step-3.5-flash]\", got)\n\t}\n\n\tdiskCfg, err := loadOrCreateConfig(configPath)\n\tif err != nil {\n\t\tt.Fatalf(\"load disk config: %v\", err)\n\t}\n\tif len(diskCfg.CustomProviders[\"stepfun\"].Models) != 1 {\n\t\tt.Errorf(\"disk Models = %v, want [step-3.5-flash]\", diskCfg.CustomProviders[\"stepfun\"].Models)\n\t}\n}\n\nfunc TestProviderTUI_PersistCustomModelName_SaveFailureRollsBack(t *testing.T) {\n\tblockPath := filepath.Join(t.TempDir(), \"blocked\")\n\tif err := os.Mkdir(blockPath, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tm := officialDashscopeModelTUI(t, blockPath, nil)\n\tbefore := append([]string(nil), m.existingCfg.Providers[\"dashscope\"].Models...)\n\tm.modelIdx = len(m.models())\n\tm.customModel = true\n\tm.modelInput.SetValue(\"failed-model\")\n\tm.modelInput.Focus()\n\n\tresult, _ := m.Update(enterKey())\n\tm2 := result.(providerTUIModel)\n\tif m2.formError == \"\" {\n\t\tt.Fatal(\"expected formError on save failure\")\n\t}\n\tif !strings.Contains(m2.formError, \"failed to save\") {\n\t\tt.Errorf(\"formError = %q, want save failure message\", m2.formError)\n\t}\n\tgot := m2.existingCfg.Providers[\"dashscope\"].Models\n\tif len(got) != len(before) {\n\t\tt.Errorf(\"Models = %v, want unchanged %v\", got, before)\n\t}\n\tif m2.savedInSession {\n\t\tt.Error(\"savedInSession should be false after failed persist\")\n\t}\n}\n\nfunc TestProviderTUI_ManualFormPassesKToAuthHeaderInput(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tcfg := &Config{Llm: LlmConfig{URL: \"https://example.com/v1\", Model: \"m\", AuthToken: \"k\"}}\n\tm := newProviderTUI(cfg, configPath)\n\tm.activeTab = tabManual\n\tm.inManualForm = true\n\tm.manualStep = manualStepAuthHeader\n\tm.manualAuthHeaderInput.Focus()\n\n\tresult, _ := m.Update(charKey('x'))\n\tm2 := result.(providerTUIModel)\n\tresult, _ = m2.Update(charKey('-'))\n\tm3 := result.(providerTUIModel)\n\tresult, _ = m3.Update(charKey('a'))\n\tm4 := result.(providerTUIModel)\n\tresult, _ = m4.Update(charKey('p'))\n\tm5 := result.(providerTUIModel)\n\tresult, _ = m5.Update(charKey('i'))\n\tm6 := result.(providerTUIModel)\n\tresult, _ = m6.Update(charKey('-'))\n\tm7 := result.(providerTUIModel)\n\tresult, _ = m7.Update(charKey('k'))\n\tm8 := result.(providerTUIModel)\n\tresult, _ = m8.Update(charKey('e'))\n\tm9 := result.(providerTUIModel)\n\tresult, _ = m9.Update(charKey('y'))\n\tm10 := result.(providerTUIModel)\n\n\tif got := m10.manualAuthHeaderInput.Value(); got != \"x-api-key\" {\n\t\tt.Errorf(\"manualAuthHeaderInput.Value() = %q, want %q\", got, \"x-api-key\")\n\t}\n}\n\nfunc TestProviderTUI_CustomFormPassesKToAuthHeaderInput(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tcfg := &Config{}\n\tm := newProviderTUI(cfg, configPath)\n\tm.creatingCustom = true\n\tm.cpStep = cpStepAuthHeader\n\tm.cpAuthInput.Focus()\n\n\tresult, _ := m.Update(charKey('k'))\n\tm2 := result.(providerTUIModel)\n\tresult, _ = m2.Update(charKey('e'))\n\tm3 := result.(providerTUIModel)\n\tresult, _ = m3.Update(charKey('y'))\n\tm4 := result.(providerTUIModel)\n\n\tif got := m4.cpAuthInput.Value(); got != \"key\" {\n\t\tt.Errorf(\"cpAuthInput.Value() = %q, want %q\", got, \"key\")\n\t}\n}\n\nfunc TestProviderTUI_ViewAPIKey_MaskedShowsReplaceHintAndLastFour(t *testing.T) {\n\tcfg := &Config{\n\t\tProvider: \"dashscope\",\n\t\tModel: \"qwen3.7-max\",\n\t\tProviders: map[string]ProviderEntry{\n\t\t\t\"dashscope\": {APIKey: \"sk-secret-1234567890abcd\"},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\tm.activeTab = tabOfficial\n\tfor i, p := range m.providers {\n\t\tif p.Name == \"dashscope\" {\n\t\t\tm.officialIdx = i\n\t\t\tbreak\n\t\t}\n\t}\n\tm.step = stepAPIKey\n\tm.loadExistingAPIKey()\n\tm.apiKeyInput.Focus()\n\n\tif !m.apiKeyMasked {\n\t\tt.Fatal(\"apiKeyMasked should be true when an existing key is loaded\")\n\t}\n\n\tgot := stripANSI(m.View().Content)\n\tif !strings.Contains(got, \"Type or paste to replace the saved key\") {\n\t\tt.Errorf(\"view missing replace hint; got:\\n%s\", got)\n\t}\n\tif !strings.Contains(got, \"(saved: sk-sec...abcd)\") {\n\t\tt.Errorf(\"view missing prefix+suffix fingerprint; got:\\n%s\", got)\n\t}\n}\n\nfunc TestProviderTUI_ViewAPIKey_ShortKeyOmitsFingerprint(t *testing.T) {\n\tcfg := &Config{\n\t\tProvider: \"dashscope\",\n\t\tModel: \"qwen3.7-max\",\n\t\tProviders: map[string]ProviderEntry{\n\t\t\t\"dashscope\": {APIKey: \"12345678901234\"}, // 14 runes — below min length 15\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\tm.activeTab = tabOfficial\n\tfor i, p := range m.providers {\n\t\tif p.Name == \"dashscope\" {\n\t\t\tm.officialIdx = i\n\t\t\tbreak\n\t\t}\n\t}\n\tm.step = stepAPIKey\n\tm.loadExistingAPIKey()\n\tm.apiKeyInput.Focus()\n\n\tgot := stripANSI(m.View().Content)\n\tif !strings.Contains(got, \"Type or paste to replace the saved key\") {\n\t\tt.Errorf(\"view missing replace hint; got:\\n%s\", got)\n\t}\n\tif strings.Contains(got, \"(saved:\") {\n\t\tt.Errorf(\"view should omit fingerprint for keys shorter than 15 runes; got:\\n%s\", got)\n\t}\n}\n\nfunc TestProviderTUI_ViewAPIKey_MinLenKeyShowsFingerprint(t *testing.T) {\n\tcfg := &Config{\n\t\tProvider: \"dashscope\",\n\t\tModel: \"qwen3.7-max\",\n\t\tProviders: map[string]ProviderEntry{\n\t\t\t\"dashscope\": {APIKey: \"123456789012345\"}, // 15 runes — at min length\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\tm.activeTab = tabOfficial\n\tfor i, p := range m.providers {\n\t\tif p.Name == \"dashscope\" {\n\t\t\tm.officialIdx = i\n\t\t\tbreak\n\t\t}\n\t}\n\tm.step = stepAPIKey\n\tm.loadExistingAPIKey()\n\tm.apiKeyInput.Focus()\n\n\tgot := stripANSI(m.View().Content)\n\tif !strings.Contains(got, \"(saved: 123456...2345)\") {\n\t\tt.Errorf(\"view should show fingerprint at min length 15; got:\\n%s\", got)\n\t}\n}\n\nfunc TestSavedSecretFingerprint_TrimsLeadingWhitespace(t *testing.T) {\n\tconst key = \"sk-secret-1234567890abcd\"\n\tgot := savedSecretFingerprint(\" \" + key)\n\twant := \"sk-sec...abcd\"\n\tif got != want {\n\t\tt.Errorf(\"savedSecretFingerprint(%q) = %q, want %q\", \" \"+key, got, want)\n\t}\n}\n\nfunc TestProviderTUI_ViewAPIKey_FreshHidesReplaceHint(t *testing.T) {\n\tcfg := &Config{\n\t\tProvider: \"dashscope\",\n\t\tModel: \"qwen3.7-max\",\n\t\tProviders: map[string]ProviderEntry{\n\t\t\t\"dashscope\": {},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\tm.activeTab = tabOfficial\n\tfor i, p := range m.providers {\n\t\tif p.Name == \"dashscope\" {\n\t\t\tm.officialIdx = i\n\t\t\tbreak\n\t\t}\n\t}\n\tm.step = stepAPIKey\n\tm.loadExistingAPIKey()\n\n\tif m.apiKeyMasked {\n\t\tt.Fatal(\"apiKeyMasked should be false when no key is loaded\")\n\t}\n\n\tgot := stripANSI(m.View().Content)\n\tif strings.Contains(got, \"Type or paste to replace the saved key\") {\n\t\tt.Errorf(\"view should not show replace hint when fresh; got:\\n%s\", got)\n\t}\n}\n\nfunc TestOfficialAPIKeyEnvSetHint(t *testing.T) {\n\tconst envVar = \"DEEPSEEK_API_KEY\"\n\tif got := officialAPIKeyEnvSetHint(envVar, false); got != \"$DEEPSEEK_API_KEY is set. Leave empty to use it; enter a key here to override.\" {\n\t\tt.Errorf(\"no saved key hint = %q\", got)\n\t}\n\tif got := officialAPIKeyEnvSetHint(envVar, true); got != \"$DEEPSEEK_API_KEY is set; used only when no key is saved here.\" {\n\t\tt.Errorf(\"saved key hint = %q\", got)\n\t}\n}\n\nfunc TestProviderTUI_ViewAPIKey_EnvSetNoSavedKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-from-env\")\n\tcfg := &Config{\n\t\tProvider: \"deepseek\",\n\t\tModel: \"deepseek-v4-flash\",\n\t\tProviders: map[string]ProviderEntry{\n\t\t\t\"deepseek\": {Model: \"deepseek-v4-flash\"},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\tm.activeTab = tabOfficial\n\tfor i, p := range m.providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tm.officialIdx = i\n\t\t\tbreak\n\t\t}\n\t}\n\tm.step = stepAPIKey\n\tm.loadExistingAPIKey()\n\tm.apiKeyInput.Focus()\n\n\tgot := stripANSI(m.View().Content)\n\twant := \"$DEEPSEEK_API_KEY is set. Leave empty to use it; enter a key here to override.\"\n\tif !strings.Contains(got, want) {\n\t\tt.Errorf(\"view missing env hint; want %q; got:\\n%s\", want, got)\n\t}\n}\n\nfunc TestProviderTUI_ViewAPIKey_EnvSetWithSavedKey(t *testing.T) {\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"sk-from-env\")\n\tcfg := &Config{\n\t\tProvider: \"deepseek\",\n\t\tModel: \"deepseek-v4-flash\",\n\t\tProviders: map[string]ProviderEntry{\n\t\t\t\"deepseek\": {APIKey: \"sk-secret-1234567890abcd\", Model: \"deepseek-v4-flash\"},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\tm.activeTab = tabOfficial\n\tfor i, p := range m.providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tm.officialIdx = i\n\t\t\tbreak\n\t\t}\n\t}\n\tm.step = stepAPIKey\n\tm.loadExistingAPIKey()\n\tm.apiKeyInput.Focus()\n\n\tgot := stripANSI(m.View().Content)\n\tif !strings.Contains(got, \"Type or paste to replace the saved key\") {\n\t\tt.Errorf(\"view missing replace hint; got:\\n%s\", got)\n\t}\n\twant := \"$DEEPSEEK_API_KEY is set; used only when no key is saved here.\"\n\tif !strings.Contains(got, want) {\n\t\tt.Errorf(\"view missing env hint; want %q; got:\\n%s\", want, got)\n\t}\n\tif strings.Contains(got, \"Leave empty to use it\") {\n\t\tt.Errorf(\"saved-key view should not show empty-env hint; got:\\n%s\", got)\n\t}\n}\n\nfunc TestProviderTUI_ApiKeyPasteReplacesMaskedKey(t *testing.T) {\n\tcfg := &Config{\n\t\tProvider: \"stepfun\",\n\t\tModel: \"step-3.5-flash\",\n\t\tCustomProviders: map[string]ProviderEntry{\n\t\t\t\"stepfun\": {\n\t\t\t\tURL: \"https://api.stepfun.com/v1\",\n\t\t\t\tAPIKey: \"old-key-ssss\",\n\t\t\t\tModel: \"step-3.5-flash\",\n\t\t\t\tModels: []string{\"step-3.5-flash\"},\n\t\t\t},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\tm.activeTab = tabCustom\n\tm.customIdx = 0\n\tm.step = stepAPIKey\n\tm.loadExistingAPIKey()\n\tm.apiKeyInput.Focus()\n\n\tif !m.apiKeyMasked {\n\t\tt.Fatal(\"expected masked key on load\")\n\t}\n\tif m.apiKeyInput.Value() != maskedSecretPlaceholder() {\n\t\tt.Fatalf(\"placeholder = %q, want fixed %d asterisks\", m.apiKeyInput.Value(), maskedSecretDisplayLen)\n\t}\n\n\tresult, _ := m.Update(tea.PasteMsg{Content: \"sk-new-pasted-key\"})\n\tm2 := result.(providerTUIModel)\n\n\tif m2.apiKeyMasked {\n\t\tt.Fatal(\"paste should unmask the field\")\n\t}\n\tif got := m2.apiKeyInput.Value(); got != \"sk-new-pasted-key\" {\n\t\tt.Errorf(\"input value = %q, want pasted key\", got)\n\t}\n\tif r := m2.result(); r.apiKey != \"sk-new-pasted-key\" {\n\t\tt.Errorf(\"result().apiKey = %q, want pasted key\", r.apiKey)\n\t}\n}\n\nfunc TestProviderTUI_ManualTokenPasteReplacesMaskedToken(t *testing.T) {\n\tcfg := &Config{\n\t\tLlm: LlmConfig{\n\t\t\tURL: \"https://example.com/v1\",\n\t\t\tModel: \"m\",\n\t\t\tAuthToken: \"old-token-secret\",\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\tm.activeTab = tabManual\n\tm.inManualForm = true\n\tm.manualStep = manualStepAuthToken\n\tm.manualTokenMasked = true\n\tm.manualTokenOriginal = \"old-token-secret\"\n\tm.manualTokenInput.SetValue(maskedSecretPlaceholder())\n\tm.manualTokenInput.Focus()\n\n\tif !m.manualTokenMasked {\n\t\tt.Fatal(\"expected masked token on load\")\n\t}\n\n\tresult, _ := m.Update(tea.PasteMsg{Content: \"new-pasted-token\"})\n\tm2 := result.(providerTUIModel)\n\n\tif m2.manualTokenMasked {\n\t\tt.Fatal(\"paste should unmask the token field\")\n\t}\n\tif got := m2.manualTokenInput.Value(); got != \"new-pasted-token\" {\n\t\tt.Errorf(\"input value = %q, want pasted token\", got)\n\t}\n\tif r := m2.result(); r.apiKey != \"new-pasted-token\" {\n\t\tt.Errorf(\"result().apiKey = %q, want pasted token\", r.apiKey)\n\t}\n}\n\nfunc TestProviderTUI_ApiKeyTypingShowsOneStarPerChar(t *testing.T) {\n\tcfg := &Config{\n\t\tProvider: \"stepfun\",\n\t\tModel: \"step-3.5-flash\",\n\t\tCustomProviders: map[string]ProviderEntry{\n\t\t\t\"stepfun\": {APIKey: \"old-key-ssss\", Model: \"step-3.5-flash\"},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\tm.activeTab = tabCustom\n\tm.customIdx = 0\n\tm.step = stepAPIKey\n\tm.loadExistingAPIKey()\n\tm.apiKeyInput.Focus()\n\n\tfor _, ch := range \"abc\" {\n\t\tresult, _ := m.Update(charKey(ch))\n\t\tm = result.(providerTUIModel)\n\t}\n\tif m.apiKeyInput.Value() != \"abc\" {\n\t\tt.Errorf(\"value = %q, want abc\", m.apiKeyInput.Value())\n\t}\n\t// EchoPassword renders one '*' per character in the input view.\n\tmasked := stripANSI(m.apiKeyInput.View())\n\tstarCount := strings.Count(masked, \"*\")\n\tif starCount != 3 {\n\t\tt.Errorf(\"masked view has %d asterisks, want 3 (one * per char)\", starCount)\n\t}\n}\n\nfunc TestProviderTUI_ApiKeyEnterWithoutEditKeepsOriginal(t *testing.T) {\n\tcfg := &Config{\n\t\tProvider: \"stepfun\",\n\t\tCustomProviders: map[string]ProviderEntry{\n\t\t\t\"stepfun\": {APIKey: \"keep-me\"},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\tm.activeTab = tabCustom\n\tm.customIdx = 0\n\tm.step = stepAPIKey\n\tm.loadExistingAPIKey()\n\tm.apiKeyInput.Focus()\n\n\tif r := m.result(); r.apiKey != \"keep-me\" {\n\t\tt.Fatalf(\"before edit result().apiKey = %q\", r.apiKey)\n\t}\n\n\tresult, cmd := m.Update(enterKey())\n\tm2 := result.(providerTUIModel)\n\tif !m2.confirmed {\n\t\tt.Error(\"Enter without edit should confirm\")\n\t}\n\tif cmd == nil {\n\t\tt.Error(\"Enter without edit should quit\")\n\t}\n\tif r := m2.result(); r.apiKey != \"keep-me\" {\n\t\tt.Errorf(\"after Enter result().apiKey = %q, want keep-me\", r.apiKey)\n\t}\n}\n\nfunc TestProviderTUI_ApiKeyClearSavedKeyReturnsEmpty(t *testing.T) {\n\tcfg := &Config{\n\t\tProvider: \"deepseek\",\n\t\tModel: \"deepseek-v4-flash\",\n\t\tProviders: map[string]ProviderEntry{\n\t\t\t\"deepseek\": {APIKey: \"old-saved-key\", Model: \"deepseek-v4-flash\"},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\tm.activeTab = tabOfficial\n\tfor i, p := range m.providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tm.officialIdx = i\n\t\t\tbreak\n\t\t}\n\t}\n\tm.step = stepAPIKey\n\tm.loadExistingAPIKey()\n\tm.beginAPIKeyReplace()\n\n\tif r := m.result(); r.apiKey != \"\" {\n\t\tt.Errorf(\"result().apiKey = %q, want empty after clearing saved key\", r.apiKey)\n\t}\n}\n\nfunc TestProviderTUI_ApiKeyResultTrimSpace(t *testing.T) {\n\tcfg := &Config{\n\t\tProvider: \"deepseek\",\n\t\tModel: \"deepseek-v4-flash\",\n\t\tProviders: map[string]ProviderEntry{\n\t\t\t\"deepseek\": {Model: \"deepseek-v4-flash\"},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\tm.activeTab = tabOfficial\n\tfor i, p := range m.providers {\n\t\tif p.Name == \"deepseek\" {\n\t\t\tm.officialIdx = i\n\t\t\tbreak\n\t\t}\n\t}\n\tm.step = stepAPIKey\n\tm.apiKeyInput.SetValue(\" \")\n\n\tif r := m.result(); r.apiKey != \"\" {\n\t\tt.Errorf(\"result().apiKey = %q, want empty for whitespace-only input\", r.apiKey)\n\t}\n}\n\nfunc TestProviderTUI_OfficialApiKeyEmptyWithoutEnvBlocksEnter(t *testing.T) {\n\tcfg := &Config{\n\t\tProvider: \"dashscope\",\n\t\tModel: \"qwen3.7-max\",\n\t\tProviders: map[string]ProviderEntry{\n\t\t\t\"dashscope\": {},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\tm.activeTab = tabOfficial\n\tfor i, p := range m.providers {\n\t\tif p.Name == \"dashscope\" {\n\t\t\tm.officialIdx = i\n\t\t\tbreak\n\t\t}\n\t}\n\tm.step = stepAPIKey\n\tm.loadExistingAPIKey()\n\tm.apiKeyInput.Focus()\n\n\tt.Setenv(\"DASHSCOPE_API_KEY\", \"\")\n\n\tresult, cmd := m.Update(enterKey())\n\tm2 := result.(providerTUIModel)\n\tif m2.step != stepAPIKey {\n\t\tt.Errorf(\"step = %d, want stepAPIKey\", m2.step)\n\t}\n\tif m2.formError != \"API key is required (or set $DASHSCOPE_API_KEY)\" {\n\t\tt.Errorf(\"formError = %q\", m2.formError)\n\t}\n\tif cmd != nil {\n\t\tt.Error(\"Enter without key or env should not quit\")\n\t}\n}\n\nfunc TestProviderTUI_OfficialApiKeyEmptyWithEnvAllowsEnter(t *testing.T) {\n\tcfg := &Config{\n\t\tProvider: \"dashscope\",\n\t\tModel: \"qwen3.7-max\",\n\t\tProviders: map[string]ProviderEntry{\n\t\t\t\"dashscope\": {},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\tm.activeTab = tabOfficial\n\tfor i, p := range m.providers {\n\t\tif p.Name == \"dashscope\" {\n\t\t\tm.officialIdx = i\n\t\t\tbreak\n\t\t}\n\t}\n\tm.step = stepAPIKey\n\tm.loadExistingAPIKey()\n\tm.apiKeyInput.Focus()\n\n\tt.Setenv(\"DASHSCOPE_API_KEY\", \"sk-from-env\")\n\n\tresult, cmd := m.Update(enterKey())\n\tm2 := result.(providerTUIModel)\n\tif !m2.confirmed {\n\t\tt.Error(\"Enter with env set should confirm\")\n\t}\n\tif cmd == nil {\n\t\tt.Error(\"Enter with env set should quit\")\n\t}\n\tif m2.formError != \"\" {\n\t\tt.Errorf(\"formError = %q, want empty\", m2.formError)\n\t}\n}\n\nfunc TestProviderTUI_CustomExistingApiKeyEmptyBlocksEnter(t *testing.T) {\n\tcfg := &Config{\n\t\tProvider: \"stepfun\",\n\t\tCustomProviders: map[string]ProviderEntry{\n\t\t\t\"stepfun\": {APIKey: \"old-key\"},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\tm.activeTab = tabCustom\n\tm.customIdx = 0\n\tm.step = stepAPIKey\n\tm.loadExistingAPIKey()\n\tm.apiKeyInput.Focus()\n\tm.beginAPIKeyReplace()\n\n\tresult, cmd := m.Update(enterKey())\n\tm2 := result.(providerTUIModel)\n\tif m2.step != stepAPIKey {\n\t\tt.Errorf(\"step = %d, want stepAPIKey\", m2.step)\n\t}\n\tif m2.formError != \"API key is required\" {\n\t\tt.Errorf(\"formError = %q, want %q\", m2.formError, \"API key is required\")\n\t}\n\tif cmd != nil {\n\t\tt.Error(\"Enter with cleared key should not quit\")\n\t}\n}\n\nfunc TestProviderTUI_CustomCreateApiKeyOptional(t *testing.T) {\n\tm := newProviderTUI(&Config{}, \"\")\n\tm.activeTab = tabCustom\n\tm.creatingCustom = true\n\tm.cpStep = cpStepAPIKey\n\tm.apiKeyInput.SetValue(\"\")\n\tm.apiKeyInput.Focus()\n\n\tresult, _ := m.Update(enterKey())\n\tm2 := result.(providerTUIModel)\n\tif m2.cpStep != cpStepAuthHeader {\n\t\tt.Errorf(\"cpStep = %d, want cpStepAuthHeader\", m2.cpStep)\n\t}\n\tif m2.formError != \"\" {\n\t\tt.Errorf(\"formError = %q, want empty for optional API key\", m2.formError)\n\t}\n}\n\nfunc TestProviderTUI_ViewAPIKey_ShowsFormError(t *testing.T) {\n\tcfg := &Config{\n\t\tProvider: \"dashscope\",\n\t\tModel: \"qwen3.7-max\",\n\t\tProviders: map[string]ProviderEntry{\n\t\t\t\"dashscope\": {},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\tm.activeTab = tabOfficial\n\tfor i, p := range m.providers {\n\t\tif p.Name == \"dashscope\" {\n\t\t\tm.officialIdx = i\n\t\t\tbreak\n\t\t}\n\t}\n\tm.step = stepAPIKey\n\tm.loadExistingAPIKey()\n\tm.formError = \"API key is required (or set $DASHSCOPE_API_KEY)\"\n\tm.apiKeyInput.Focus()\n\n\tgot := stripANSI(m.View().Content)\n\tif !strings.Contains(got, \"API key is required (or set $DASHSCOPE_API_KEY)\") {\n\t\tt.Errorf(\"view missing formError; got:\\n%s\", got)\n\t}\n}\n\nfunc TestProviderTUI_CancelIncompleteOfficialProviderSwitch_NoPersistedChanges(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tcfg := &Config{\n\t\tProvider: \"deepseek\",\n\t\tModel: \"deepseek-v4-flash\",\n\t\tProviders: map[string]ProviderEntry{\n\t\t\t\"deepseek\": {Model: \"deepseek-v4-flash\"},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, configPath)\n\tm.activeTab = tabOfficial\n\tfor i, p := range m.providers {\n\t\tif p.Name == \"baidu-qianfan\" {\n\t\t\tm.officialIdx = i\n\t\t\tbreak\n\t\t}\n\t}\n\tm.step = stepModel\n\tm.modelIdx = modelIdxForName(t, m, \"glm-5\")\n\n\tresult, _ := m.Update(enterKey())\n\tm2 := result.(providerTUIModel)\n\tif m2.savedInSession {\n\t\tt.Error(\"savedInSession should be false for cross-provider navigation\")\n\t}\n\tif m2.step != stepAPIKey {\n\t\tt.Fatalf(\"step = %d, want stepAPIKey\", m2.step)\n\t}\n\n\tresult, _ = m2.Update(escKey())\n\tm3 := result.(providerTUIModel)\n\tresult, _ = m3.Update(escKey())\n\tm4 := result.(providerTUIModel)\n\tresult, cmd := m4.Update(escKey())\n\tm5 := result.(providerTUIModel)\n\tif !m5.cancelled {\n\t\tt.Error(\"expected cancelled = true\")\n\t}\n\tif m5.savedInSession {\n\t\tt.Error(\"savedInSession should remain false after cancel\")\n\t}\n\tif cmd == nil {\n\t\tt.Error(\"expected tea.Quit on final Esc\")\n\t}\n\tif _, err := os.Stat(configPath); err == nil {\n\t\tdiskCfg, err := loadOrCreateConfig(configPath)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"load config: %v\", err)\n\t\t}\n\t\tif diskCfg.Provider != \"deepseek\" {\n\t\t\tt.Errorf(\"Provider = %q, want deepseek\", diskCfg.Provider)\n\t\t}\n\t\tif entry, ok := diskCfg.Providers[\"baidu-qianfan\"]; ok && entry.Model != \"\" {\n\t\t\tt.Errorf(\"baidu-qianfan model = %q, want no cross-provider draft persisted\", entry.Model)\n\t\t}\n\t}\n}\n\nfunc TestProviderTUI_SameOfficialProviderModelChange_DefersPersistUntilConfirm(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tcfg := &Config{\n\t\tProvider: \"deepseek\",\n\t\tModel: \"deepseek-v4-flash\",\n\t\tProviders: map[string]ProviderEntry{\n\t\t\t\"deepseek\": {Model: \"deepseek-v4-flash\"},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, configPath)\n\tm.activeTab = tabOfficial\n\tm.step = stepModel\n\tm.modelIdx = modelIdxForName(t, m, \"deepseek-v4-pro\")\n\n\tresult, _ := m.Update(enterKey())\n\tm2 := result.(providerTUIModel)\n\tif m2.savedInSession {\n\t\tt.Error(\"savedInSession should be false before API key confirm\")\n\t}\n\tif m2.step != stepAPIKey {\n\t\tt.Fatalf(\"step = %d, want stepAPIKey\", m2.step)\n\t}\n\tif _, err := os.Stat(configPath); err == nil {\n\t\tt.Fatal(\"config should not be written before wizard confirm\")\n\t}\n\tif cfg.Model != \"deepseek-v4-flash\" {\n\t\tt.Errorf(\"cfg.Model = %q, want deepseek-v4-flash\", cfg.Model)\n\t}\n}\n\nfunc TestProviderTUI_OfficialModelChangeBlockedAtAPIKey_KeepsGlobalModel(t *testing.T) {\n\tt.Setenv(\"ANTHROPIC_API_KEY\", \"\")\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tcfg := &Config{\n\t\tProvider: \"anthropic\",\n\t\tModel: \"claude-opus-4-8\",\n\t\tProviders: map[string]ProviderEntry{\n\t\t\t\"anthropic\": {\n\t\t\t\tModel: \"claude-opus-4-8\",\n\t\t\t\tAPIKey: \"sk-test-key\",\n\t\t\t},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, configPath)\n\tm.activeTab = tabOfficial\n\tfor i, p := range m.providers {\n\t\tif p.Name == \"anthropic\" {\n\t\t\tm.officialIdx = i\n\t\t\tbreak\n\t\t}\n\t}\n\tm.step = stepModel\n\tm.modelIdx = modelIdxForName(t, m, \"claude-opus-4-7\")\n\n\tresult, _ := m.Update(enterKey())\n\tm2 := result.(providerTUIModel)\n\tm2.beginAPIKeyReplace()\n\n\tresult, cmd := m2.Update(enterKey())\n\tm3 := result.(providerTUIModel)\n\tif cmd != nil {\n\t\tt.Error(\"Enter without key or env should not quit\")\n\t}\n\tif m3.step != stepAPIKey {\n\t\tt.Fatalf(\"step = %d, want stepAPIKey\", m3.step)\n\t}\n\tif cfg.Model != \"claude-opus-4-8\" {\n\t\tt.Errorf(\"cfg.Model = %q, want claude-opus-4-8\", cfg.Model)\n\t}\n\tif got := cfg.Providers[\"anthropic\"].Model; got != \"claude-opus-4-8\" {\n\t\tt.Errorf(\"providers.anthropic.Model = %q, want claude-opus-4-8\", got)\n\t}\n\tif _, err := os.Stat(configPath); err == nil {\n\t\tt.Fatal(\"config should not be written when API key validation fails\")\n\t}\n}\n\n// stripANSI removes ANSI escape sequences from a string so tests can assert\n// against plain text content.\nfunc stripANSI(s string) string {\n\tvar b strings.Builder\n\tinEscape := false\n\tfor _, r := range s {\n\t\tif r == 0x1b {\n\t\t\tinEscape = true\n\t\t\tcontinue\n\t\t}\n\t\tif inEscape {\n\t\t\tif r == 'm' {\n\t\t\t\tinEscape = false\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tb.WriteRune(r)\n\t}\n\treturn b.String()\n}\n\nfunc TestProviderTUI_DeleteModelPreservesActiveModel(t *testing.T) {\n\tcfg := &Config{\n\t\tProvider: \"stepfun\",\n\t\tModel: \"step-3.5-flash\",\n\t\tCustomProviders: map[string]ProviderEntry{\n\t\t\t\"stepfun\": {\n\t\t\t\tModel: \"step-3.5-flash\",\n\t\t\t\tModels: []string{\"step-3.5-flash\", \"aaa\"},\n\t\t\t},\n\t\t},\n\t}\n\tm := newProviderTUI(cfg, \"\")\n\tm.activeTab = tabCustom\n\tm.customIdx = 0\n\tm.step = stepModel\n\tm.modelIdx = 1 // aaa\n\n\tm.confirmingDeleteModel = true\n\tm.deleteModelName = \"aaa\"\n\tresult, _ := m.Update(yKey())\n\tm2 := result.(providerTUIModel)\n\n\tif m2.existingCfg.CustomProviders[\"stepfun\"].Model != \"step-3.5-flash\" {\n\t\tt.Errorf(\"entry.Model = %q, want step-3.5-flash\", m2.existingCfg.CustomProviders[\"stepfun\"].Model)\n\t}\n\tif m2.existingCfg.Model != \"step-3.5-flash\" {\n\t\tt.Errorf(\"cfg.Model = %q, want step-3.5-flash\", m2.existingCfg.Model)\n\t}\n\tif !m2.savedInSession {\n\t\tt.Error(\"savedInSession should be true after deleting a model\")\n\t}\n}\n\nfunc TestApplyCustomProviderConfigPreservesModelOrder(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tmodels := []string{\"test-model\", \"test-model-2\", \"bbb\", \"aaa\", \"test-model-3\"}\n\tcfg := &Config{\n\t\tProvider: \"test-provider\",\n\t\tModel: \"test-model-2\",\n\t\tCustomProviders: map[string]ProviderEntry{\n\t\t\t\"test-provider\": {\n\t\t\t\tModel: \"test-model-2\",\n\t\t\t\tModels: append([]string(nil), models...),\n\t\t\t},\n\t\t},\n\t}\n\tif err := saveConfig(configPath, cfg); err != nil {\n\t\tt.Fatalf(\"saveConfig: %v\", err)\n\t}\n\n\tresult := providerTUIResult{\n\t\tprovider: \"test-provider\",\n\t\tmodel: \"test-model-3\",\n\t\tmodels: append([]string(nil), models...),\n\t\tisCustom: true,\n\t\tisEdit: true,\n\t}\n\tif err := applyCustomProviderConfig(configPath, cfg, result); err != nil {\n\t\tt.Fatalf(\"applyCustomProviderConfig: %v\", err)\n\t}\n\n\tgot := cfg.CustomProviders[\"test-provider\"].Models\n\tif len(got) != len(models) {\n\t\tt.Fatalf(\"Models length = %d, want %d: %v\", len(got), len(models), got)\n\t}\n\tfor i := range models {\n\t\tif got[i] != models[i] {\n\t\t\tt.Errorf(\"Models[%d] = %q, want %q\", i, got[i], models[i])\n\t\t}\n\t}\n\tif cfg.CustomProviders[\"test-provider\"].Model != \"test-model-3\" {\n\t\tt.Errorf(\"entry.Model = %q, want test-model-3\", cfg.CustomProviders[\"test-provider\"].Model)\n\t}\n}\n\nfunc TestApplyManualConfigNormalizesAuthHeader(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tcfg := &Config{}\n\n\tresult := providerTUIResult{\n\t\tisManual: true,\n\t\turl: \"https://example.com/v1\",\n\t\tmodel: \"test-model\",\n\t\tapiKey: \"token\",\n\t\tprotocol: \"anthropic\",\n\t\tauthHeader: \"X-Api-Key\",\n\t}\n\tif err := applyManualConfig(configPath, cfg, result); err != nil {\n\t\tt.Fatalf(\"applyManualConfig: %v\", err)\n\t}\n\tif got := cfg.Llm.AuthHeader; got != \"x-api-key\" {\n\t\tt.Errorf(\"Llm.AuthHeader = %q, want %q\", got, \"x-api-key\")\n\t}\n\tuseAnthropic := true\n\tif cfg.Llm.UseAnthropic == nil || *cfg.Llm.UseAnthropic != useAnthropic {\n\t\tt.Errorf(\"UseAnthropic = %v, want %v\", cfg.Llm.UseAnthropic, useAnthropic)\n\t}\n}\n\nfunc TestApplyCustomProviderConfigNormalizesAuthHeader(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\tcfg := &Config{\n\t\tCustomProviders: map[string]ProviderEntry{\n\t\t\t\"test-provider\": {URL: \"https://example.com\", Model: \"m\"},\n\t\t},\n\t}\n\n\tresult := providerTUIResult{\n\t\tprovider: \"test-provider\",\n\t\tmodel: \"m\",\n\t\turl: \"https://example.com\",\n\t\tprotocol: \"anthropic\",\n\t\tauthHeader: \"Authorization\",\n\t\tisCustom: true,\n\t\tisEdit: true,\n\t}\n\tif err := applyCustomProviderConfig(configPath, cfg, result); err != nil {\n\t\tt.Fatalf(\"applyCustomProviderConfig: %v\", err)\n\t}\n\tif got := cfg.CustomProviders[\"test-provider\"].AuthHeader; got != \"authorization\" {\n\t\tt.Errorf(\"AuthHeader = %q, want %q\", got, \"authorization\")\n\t}\n}\n\n// --- protocol normalization / openai-responses support ---\n\nfunc TestCpProtocols_ContainsAllCanonicalNames(t *testing.T) {\n\t// The slice drives both Custom and Manual forms — every canonical protocol\n\t// must appear, in canonical order, so the TUI result() picks up the right\n\t// string for each index.\n\twant := []string{\n\t\tllm.ProtocolAnthropic,\n\t\tllm.ProtocolOpenAIChatCompletions,\n\t\tllm.ProtocolOpenAIResponses,\n\t}\n\tif len(cpProtocols) != len(want) {\n\t\tt.Fatalf(\"cpProtocols has %d entries, want %d\", len(cpProtocols), len(want))\n\t}\n\tfor i, p := range want {\n\t\tif cpProtocols[i] != p {\n\t\t\tt.Errorf(\"cpProtocols[%d] = %q, want %q\", i, cpProtocols[i], p)\n\t\t}\n\t}\n}\n\nfunc TestCpProtocolIndex(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tprotocol string\n\t\twant int\n\t}{\n\t\t{\"canonical anthropic\", llm.ProtocolAnthropic, 0},\n\t\t{\"canonical chat-completions\", llm.ProtocolOpenAIChatCompletions, 1},\n\t\t{\"canonical responses\", llm.ProtocolOpenAIResponses, 2},\n\t\t{\"alias openai normalizes to chat-completions\", \"openai\", 1},\n\t\t{\"alias OPENAI case-insensitive\", \"OPENAI\", 1},\n\t\t{\"empty defaults to chat-completions\", \"\", 1},\n\t\t{\"unknown defaults to chat-completions\", \"grpc\", 1},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif got := cpProtocolIndex(tt.protocol); got != tt.want {\n\t\t\t\tt.Errorf(\"cpProtocolIndex(%q) = %d, want %d\", tt.protocol, got, tt.want)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestNewProviderTUI_ManualProtocolFromConfig(t *testing.T) {\n\tt.Run(\"cfg.Llm.Protocol drives manualProtocolIdx (responses)\", func(t *testing.T) {\n\t\tcfg := &Config{\n\t\t\tLlm: LlmConfig{\n\t\t\t\tURL: \"https://api.openai.com/v1\",\n\t\t\t\tProtocol: llm.ProtocolOpenAIResponses,\n\t\t\t\tModel: \"gpt-5.4\",\n\t\t\t},\n\t\t}\n\t\tm := newProviderTUI(cfg, \"\")\n\t\tif m.manualProtocolIdx != 2 {\n\t\t\tt.Errorf(\"manualProtocolIdx = %d, want 2 (openai-responses)\", m.manualProtocolIdx)\n\t\t}\n\t})\n\n\tt.Run(\"cfg.Llm.Protocol alias openai normalizes\", func(t *testing.T) {\n\t\tcfg := &Config{\n\t\t\tLlm: LlmConfig{\n\t\t\t\tURL: \"https://api.openai.com/v1\",\n\t\t\t\tProtocol: \"openai\",\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t}\n\t\tm := newProviderTUI(cfg, \"\")\n\t\tif m.manualProtocolIdx != 1 {\n\t\t\tt.Errorf(\"manualProtocolIdx = %d, want 1 (openai)\", m.manualProtocolIdx)\n\t\t}\n\t})\n\n\tt.Run(\"falls back to use_anthropic when Protocol empty\", func(t *testing.T) {\n\t\tf := false\n\t\tcfg := &Config{\n\t\t\tLlm: LlmConfig{\n\t\t\t\tURL: \"https://api.openai.com/v1\",\n\t\t\t\tUseAnthropic: &f,\n\t\t\t\tModel: \"gpt-4o\",\n\t\t\t},\n\t\t}\n\t\tm := newProviderTUI(cfg, \"\")\n\t\tif m.manualProtocolIdx != 1 {\n\t\t\tt.Errorf(\"manualProtocolIdx = %d, want 1 (openai from use_anthropic=false)\", m.manualProtocolIdx)\n\t\t}\n\t})\n}\n\nfunc TestEnterEditCustomProvider_ProtocolIndex(t *testing.T) {\n\ttests := []struct {\n\t\tname string\n\t\tprotocol string\n\t\twantIdx int\n\t}{\n\t\t{\"anthropic\", llm.ProtocolAnthropic, 0},\n\t\t{\"openai\", llm.ProtocolOpenAIChatCompletions, 1},\n\t\t{\"openai-responses\", llm.ProtocolOpenAIResponses, 2},\n\t\t{\"legacy alias openai-chat-completions\", \"openai-chat-completions\", 1},\n\t}\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tcfg := &Config{\n\t\t\t\tCustomProviders: map[string]ProviderEntry{\n\t\t\t\t\t\"cp\": {URL: \"https://x.example.com\", Protocol: tt.protocol},\n\t\t\t\t},\n\t\t\t}\n\t\t\tm := newProviderTUI(cfg, \"\")\n\t\t\tm.activeTab = tabCustom\n\t\t\tm.customIdx = 0\n\t\t\tm.enterEditCustomProvider()\n\t\t\tif m.cpProtocolIdx != tt.wantIdx {\n\t\t\t\tt.Errorf(\"cpProtocolIdx = %d, want %d\", m.cpProtocolIdx, tt.wantIdx)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestApplyManualConfig_DoubleWritesProtocolAndUseAnthropic verifies the dual\n// write: cfg.Llm.Protocol always gets the canonical name, and UseAnthropic is\n// mirrored for the two protocols that have a boolean equivalent so older\n// binaries can still read the config.\nfunc TestApplyManualConfig_DoubleWritesProtocolAndUseAnthropic(t *testing.T) {\n\tdir := t.TempDir()\n\tconfigPath := filepath.Join(dir, \"config.json\")\n\n\tt.Run(\"responses writes Protocol AND use_anthropic=false (openai-family fallback)\", func(t *testing.T) {\n\t\tcfg := &Config{}\n\t\tresult := providerTUIResult{\n\t\t\tisManual: true,\n\t\t\turl: \"https://example.com/v1\",\n\t\t\tmodel: \"gpt-5.4\",\n\t\t\tapiKey: \"tok\",\n\t\t\tprotocol: llm.ProtocolOpenAIResponses,\n\t\t}\n\t\tif err := applyManualConfig(configPath, cfg, result); err != nil {\n\t\t\tt.Fatalf(\"applyManualConfig: %v\", err)\n\t\t}\n\t\tif cfg.Llm.Protocol != llm.ProtocolOpenAIResponses {\n\t\t\tt.Errorf(\"Protocol = %q, want %q\", cfg.Llm.Protocol, llm.ProtocolOpenAIResponses)\n\t\t}\n\t\t// UseAnthropic falls back to false so older binaries use the OpenAI\n\t\t// family instead of wrongly defaulting to anthropic.\n\t\tif cfg.Llm.UseAnthropic == nil || *cfg.Llm.UseAnthropic {\n\t\t\tt.Error(\"UseAnthropic should be false for responses protocol\")\n\t\t}\n\t})\n\n\tt.Run(\"responses overwrites stale use_anthropic from prior protocol\", func(t *testing.T) {\n\t\tstale := true\n\t\tcfg := &Config{}\n\t\tcfg.Llm.UseAnthropic = &stale // simulate switching from anthropic\n\t\tresult := providerTUIResult{\n\t\t\tisManual: true,\n\t\t\turl: \"https://example.com/v1\",\n\t\t\tmodel: \"gpt-5.4\",\n\t\t\tapiKey: \"tok\",\n\t\t\tprotocol: llm.ProtocolOpenAIResponses,\n\t\t}\n\t\tif err := applyManualConfig(configPath, cfg, result); err != nil {\n\t\t\tt.Fatalf(\"applyManualConfig: %v\", err)\n\t\t}\n\t\tif cfg.Llm.UseAnthropic == nil || *cfg.Llm.UseAnthropic {\n\t\t\tt.Error(\"UseAnthropic should be false (overwriting stale true)\")\n\t\t}\n\t})\n\n\tt.Run(\"anthropic writes Protocol AND use_anthropic=true (back-compat)\", func(t *testing.T) {\n\t\tcfg := &Config{}\n\t\tresult := providerTUIResult{\n\t\t\tisManual: true,\n\t\t\turl: \"https://example.com/v1\",\n\t\t\tmodel: \"claude\",\n\t\t\tapiKey: \"tok\",\n\t\t\tprotocol: llm.ProtocolAnthropic,\n\t\t}\n\t\tif err := applyManualConfig(configPath, cfg, result); err != nil {\n\t\t\tt.Fatalf(\"applyManualConfig: %v\", err)\n\t\t}\n\t\tif cfg.Llm.Protocol != llm.ProtocolAnthropic {\n\t\t\tt.Errorf(\"Protocol = %q, want %q\", cfg.Llm.Protocol, llm.ProtocolAnthropic)\n\t\t}\n\t\tif cfg.Llm.UseAnthropic == nil || !*cfg.Llm.UseAnthropic {\n\t\t\tt.Error(\"UseAnthropic should be mirrored to true for anthropic\")\n\t\t}\n\t})\n\n\tt.Run(\"chat-completions writes Protocol AND use_anthropic=false (back-compat)\", func(t *testing.T) {\n\t\tcfg := &Config{}\n\t\tresult := providerTUIResult{\n\t\t\tisManual: true,\n\t\t\turl: \"https://example.com/v1\",\n\t\t\tmodel: \"gpt-4o\",\n\t\t\tapiKey: \"tok\",\n\t\t\tprotocol: llm.ProtocolOpenAIChatCompletions,\n\t\t}\n\t\tif err := applyManualConfig(configPath, cfg, result); err != nil {\n\t\t\tt.Fatalf(\"applyManualConfig: %v\", err)\n\t\t}\n\t\tif cfg.Llm.Protocol != llm.ProtocolOpenAIChatCompletions {\n\t\t\tt.Errorf(\"Protocol = %q, want %q\", cfg.Llm.Protocol, llm.ProtocolOpenAIChatCompletions)\n\t\t}\n\t\tif cfg.Llm.UseAnthropic == nil || *cfg.Llm.UseAnthropic {\n\t\t\tt.Error(\"UseAnthropic should be mirrored to false for chat-completions\")\n\t\t}\n\t})\n}\n\n// TestProviderTUIResult_ManualProtocolIsCanonical makes sure result() for the\n// Manual tab returns the canonical protocol name picked from cpProtocols.\nfunc TestProviderTUIResult_ManualProtocolIsCanonical(t *testing.T) {\n\tcfg := &Config{}\n\tm := newProviderTUI(cfg, \"\")\n\tm.activeTab = tabManual\n\tm.inManualForm = true\n\tm.manualURLInput.SetValue(\"https://example.com/v1\")\n\tm.manualModelInput.SetValue(\"m\")\n\tm.manualTokenInput.SetValue(\"t\")\n\n\tfor i, want := range cpProtocols {\n\t\tm.manualProtocolIdx = i\n\t\tr := m.result()\n\t\tif r.protocol != want {\n\t\t\tt.Errorf(\"manualProtocolIdx=%d: result.protocol = %q, want %q\", i, r.protocol, want)\n\t\t}\n\t}\n}\n"}}}} |