Files
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
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.
2026-08-07 12:53:47 +08:00

1 line
242 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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"}}}}