diff --git a/docs-site/src/content/docs/getting-started/quickstart.md b/docs-site/src/content/docs/getting-started/quickstart.md index 6dbd3135ca..9db9cd1102 100644 --- a/docs-site/src/content/docs/getting-started/quickstart.md +++ b/docs-site/src/content/docs/getting-started/quickstart.md @@ -13,7 +13,7 @@ ocx init `ocx init` walks you through: -1. **Pick a provider** — choose one of the 66 built-in registry presets or `custom` to type a base +1. **Pick a provider** — choose one of the 68 built-in registry presets or `custom` to type a base URL and adapter. 2. **API key** — paste a key, or reference an environment variable like `${ANTHROPIC_API_KEY}`. 3. **Default model** — for key, local, and custom providers, accept the preset or enter a model id. diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 75d11eaff1..597c31e9c7 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -190,15 +190,30 @@ selectors, then retry. Signing in from a machine with no existing `kiro-cli` ses ## 3. API-key catalog -opencodex ships 66 built-in presets: 55 key-based, seven OAuth, three local, and the default +opencodex ships 68 built-in presets: 57 key-based, seven OAuth, three local, and the default ChatGPT-forward preset. The dashboard's **Add provider** picker opens a key provider's dashboard, validates the key, and stores it. Notable entries: +**ClinePass** uses a Cline API key with the [official subscription catalog](https://docs.cline.bot/getting-started/clinepass) +and [Chat Completions endpoint](https://docs.cline.bot/api/chat-completions), operated by Cline Bot Inc. under +[Cline's terms](https://cline.bot/tos). A routed id such as `cline-pass/cline-pass/kimi-k3` is +intentional: the first segment selects the opencodex provider, while `cline-pass/kimi-k3` is the +full model slug sent upstream. ClinePass quota is shared by the account across rolling 5-hour, +weekly, and monthly limits. opencodex currently advertises the live-verified `low` reasoning tier; +higher requested tiers clamp to `low` until the gateway publishes or verifies a wider ladder. + +**Cline** is the same API key and endpoint on pay-as-you-go usage billing across 100+ models +(OpenRouter-style ids like `anthropic/claude-sonnet-4-6`). Cline's promotional free models are only +available in the Cline IDE/CLI, not through the API; `minimax/minimax-m2.5` is the documented API +free-experimentation model. + | Provider | Base URL | | --- | --- | | **OpenAI (API key)** | `https://api.openai.com/v1` | | **Anthropic (API key)** | `https://api.anthropic.com` | | **OpenRouter** | `https://openrouter.ai/api/v1` | +| **Cline** | `https://api.cline.bot/api/v1` | +| **ClinePass** | `https://api.cline.bot/api/v1` | | **Ollama Cloud** | `https://ollama.com/v1` | | Google Gemini · Google Vertex AI | `https://generativelanguage.googleapis.com` · `https://aiplatform.googleapis.com` | | Azure OpenAI | `https://{resource}.openai.azure.com/openai` | diff --git a/docs-site/src/content/docs/ja/getting-started/quickstart.md b/docs-site/src/content/docs/ja/getting-started/quickstart.md index fee4fbd264..f129296e95 100644 --- a/docs-site/src/content/docs/ja/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ja/getting-started/quickstart.md @@ -13,7 +13,7 @@ ocx init `ocx init` では次の手順を説明します。 -1. **プロバイダーを選択してください** — 66 の組み込みレジストリ プリセットのいずれか、または `custom` を選択してベースを入力します +1. **プロバイダーを選択してください** — 68 の組み込みレジストリ プリセットのいずれか、または `custom` を選択してベースを入力します URLとアダプター。 2. **API キー** — キーを貼り付けるか、`${ANTHROPIC_API_KEY}` のような環境変数を参照します。 3. **デフォルト モデル** — キー、ローカル、カスタム プロバイダーの場合は、プリセットを受け入れるか、モデル ID を入力します。 diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 2005aa3296..6acd78b84e 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -119,15 +119,30 @@ Kiro のログインには Kiro CLI が必要です。Unix では `curl -fsSL ht ## 3. API キーカタログ -opencodex には組み込みプリセットが 66 個含まれています。キー方式 55、OAuth 7、ローカル 3、 +opencodex には組み込みプリセットが 68 個含まれています。キー方式 57、OAuth 7、ローカル 3、 デフォルト ChatGPT 転送プリセット 1 です。ダッシュボードの **Add provider** ピッカーはキー発行ページを開き、 入力したキーを検証した後保存します。主な項目は以下のとおりです: +**ClinePass** は Cline API キーで[公式サブスクリプションカタログ](https://docs.cline.bot/getting-started/clinepass)と +[Chat Completions エンドポイント](https://docs.cline.bot/api/chat-completions)に接続します。運営主体は +[Cline の利用規約](https://cline.bot/tos)に記載された Cline Bot Inc. です。`cline-pass/cline-pass/kimi-k3` のようなルーティング ID は +意図した形式です。先頭は opencodex のプロバイダー、残りの `cline-pass/kimi-k3` は upstream に送信する +完全なモデル slug です。使用量はアカウントのローリング 5 時間、週次、月次の各上限で共有されます。 +現在 opencodex が公開する reasoning tier は実機検証済みの `low` のみで、より高い要求は公式範囲が +公開または検証されるまで `low` にクランプされます。 + +**Cline** は同じ API キー・エンドポイントを従量課金で使い、100 以上のモデルにアクセスできます +(OpenRouter 形式の ID、例: `anthropic/claude-sonnet-4-6`)。Cline の期間限定無料モデルは +Cline IDE/CLI のみで API からは使えません。`minimax/minimax-m2.5` は API で利用できる +無料試用モデルとして文書化されています。 + | プロバイダー | ベース URL | --- | --- | | **OpenAI (API キー)** | `https://api.openai.com/v1` | | **Anthropic (API キー)** | `https://api.anthropic.com` | | **OpenRouter** | `https://openrouter.ai/api/v1` | +| **Cline** | `https://api.cline.bot/api/v1` | +| **ClinePass** | `https://api.cline.bot/api/v1` | | **Ollama Cloud** | `https://ollama.com/v1` | | Google Gemini · Google Vertex AI | `https://generativelanguage.googleapis.com` · `https://aiplatform.googleapis.com` | | Azure OpenAI | `https://{resource}.openai.azure.com/openai` | diff --git a/docs-site/src/content/docs/ja/reference/adapters.md b/docs-site/src/content/docs/ja/reference/adapters.md index 7b1fbc1c41..0e15cc2dac 100644 --- a/docs-site/src/content/docs/ja/reference/adapters.md +++ b/docs-site/src/content/docs/ja/reference/adapters.md @@ -32,6 +32,11 @@ interface ProviderAdapter { プロバイダーが明示的に alias を設定しない限り、`xhigh` と `max` は異なるラベルのまま保ちます。`provider.noReasoningModels` に含まれる id には値を **一切送りません**。 - `delta.content`(テキスト)、`delta.reasoning_content`(thinking)、`delta.tool_calls[]` を ストリーミングし、`usage` を収集します。 +- ClinePass は、ライブ検証済みのゲートウェイ形式 `reasoning: { enabled: true, effort: "low" }` + (reasoning を無効にする場合は `{ enabled: false }`)を使用します。公開 API ドキュメントには + 現在このリクエスト形式が明記されていません。アダプターは他の effort リクエストを検証済みの + `low` に調整し、`delta.reasoning_content` または `delta.reasoning` を reasoning delta として扱い、 + `stream_options.include_usage` でストリーム usage を要求し、非ストリームのレスポンス envelope からも usage を読み取ります。 ## `openai-responses` diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 46c6ccb83e..38067d5226 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -70,6 +70,7 @@ description: プロバイダー エントリ、認証、エンドポイント、 | `modelAdapters?` | `Record` | 混合配線ゲートウェイのモデルごとの `openai-chat` または `openai-responses` 配線オーバーライド。明示的なエントリはレジストリのデフォルトを破ります。DeepSeek のプリセットは `deepseek-v4-flash` のネイティブ Responses を選択でき、GitHub Copilot は GPT-5 ファミリー (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`) を Responses 専用デフォルトとして宣言します。これらのモデルはエージェント トラフィックで `/chat/completions` を拒否するためです。`gpt-5.4-nano` のようなビルトイン デフォルトのないモデルはここでオプトインできます。単線アップストリーム ピンと正規の ChatGPT 転送はオーバーライドを拒否します。 | | `reasoningEffortMap?` | `Record` |ラベルを推論するためのプロバイダー全体のワイヤ エイリアス。 | | `modelReasoningEffortMap?` | `Record>` |推論ラベルのモデルごとのワイヤ エイリアス。 | +| `reasoningWireFormat?` | `"gateway-object"` | `reasoning_effort` ではなく `reasoning: { enabled, effort }` を受け取る OpenAI 互換ゲートウェイ用です。ClinePass プリセットが自動設定します。 | | `noReasoningModels?` | `string[]` |推論/思考パラメーターを拒否するモデル。 | | `noTemperatureModels?` | `string[]` |発信者指定の`temperature`を拒否するモデル。 | | `noTopPModels?` | `string[]` |発信者指定の`top_p`を拒否するモデル。 | diff --git a/docs-site/src/content/docs/ko/getting-started/quickstart.md b/docs-site/src/content/docs/ko/getting-started/quickstart.md index 0784c85a13..64b5e44ebc 100644 --- a/docs-site/src/content/docs/ko/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ko/getting-started/quickstart.md @@ -13,7 +13,7 @@ ocx init `ocx init`은 다음 과정을 안내합니다: -1. **프로바이더 선택** — 내장 레지스트리 프리셋 66개 중 하나를 고르거나 `custom`을 선택해 base URL과 adapter를 직접 입력합니다. +1. **프로바이더 선택** — 내장 레지스트리 프리셋 68개 중 하나를 고르거나 `custom`을 선택해 base URL과 adapter를 직접 입력합니다. 2. **API 키** — 키를 붙여넣거나 `${ANTHROPIC_API_KEY}` 같은 환경 변수를 참조합니다. 3. **기본 모델** — 키, 로컬, custom 프로바이더에서는 프리셋을 그대로 쓰거나 모델 ID를 직접 입력합니다. 4. **프록시 포트** — 기본값은 `10100`입니다. diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index f7cf96e29f..09d81ce800 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -119,15 +119,31 @@ Kiro 로그인에는 Kiro CLI가 필요합니다. Unix에서는 `curl -fsSL http ## 3. API 키 카탈로그 -opencodex에는 빌트인 프리셋이 66개 들어 있습니다. 키 방식 55개, OAuth 7개, 로컬 3개, +opencodex에는 빌트인 프리셋이 68개 들어 있습니다. 키 방식 57개, OAuth 7개, 로컬 3개, 기본 ChatGPT 포워드 프리셋 1개입니다. 대시보드의 **Add provider** 선택기는 키 발급 페이지를 열고, 입력한 키를 검증한 뒤 저장합니다. 주요 항목은 다음과 같습니다: +**ClinePass**는 Cline API 키로 [공식 구독 카탈로그](https://docs.cline.bot/getting-started/clinepass)와 +[Chat Completions 엔드포인트](https://docs.cline.bot/api/chat-completions)에 연결합니다. 운영 주체는 +[Cline 약관](https://cline.bot/tos)에 명시된 Cline Bot Inc.입니다. `cline-pass/cline-pass/kimi-k3` +같은 라우팅 ID는 정상입니다. +앞의 `cline-pass`는 opencodex 프로바이더이고, 뒤의 `cline-pass/kimi-k3`는 upstream에 보내는 +전체 모델 slug입니다. ClinePass 사용량은 계정의 5시간 롤링·주간·월간 한도를 함께 사용합니다. +현재 opencodex는 실측된 `low` reasoning 단계만 광고하며, 더 높은 요청은 공식 지원 범위가 +게시되거나 검증될 때까지 `low`로 제한합니다. + +**Cline**은 동일한 API 키·엔드포인트를 종량제로 사용하며 100개 이상의 모델에 접근합니다 +(OpenRouter 형식 ID, 예: `anthropic/claude-sonnet-4-6`). Cline의 프로모션 무료 모델은 +Cline IDE/CLI에서만 제공되며 API로는 사용할 수 없습니다. `minimax/minimax-m2.5`는 API에서 +사용할 수 있는 무료 체험 모델로 문서화되어 있습니다. + | 프로바이더 | 베이스 URL | | --- | --- | | **OpenAI (API key)** | `https://api.openai.com/v1` | | **Anthropic (API key)** | `https://api.anthropic.com` | | **OpenRouter** | `https://openrouter.ai/api/v1` | +| **Cline** | `https://api.cline.bot/api/v1` | +| **ClinePass** | `https://api.cline.bot/api/v1` | | **Ollama Cloud** | `https://ollama.com/v1` | | Google Gemini · Google Vertex AI | `https://generativelanguage.googleapis.com` · `https://aiplatform.googleapis.com` | | Azure OpenAI | `https://{resource}.openai.azure.com/openai` | diff --git a/docs-site/src/content/docs/ko/reference/adapters.md b/docs-site/src/content/docs/ko/reference/adapters.md index 85134c9edd..6d92d5b374 100644 --- a/docs-site/src/content/docs/ko/reference/adapters.md +++ b/docs-site/src/content/docs/ko/reference/adapters.md @@ -38,6 +38,11 @@ interface ProviderAdapter { 유지합니다. `provider.noReasoningModels`에 든 id에는 값을 **아예 보내지 않습니다**. - `delta.content`(텍스트), `delta.reasoning_content`(thinking), `delta.tool_calls[]`를 스트리밍하고 `usage`를 수집합니다. +- ClinePass는 라이브로 검증된 게이트웨이 형식 `reasoning: { enabled: true, effort: "low" }`을 + 사용하며, reasoning을 끌 때는 `{ enabled: false }`를 사용합니다. 공개 API 문서에는 현재 이 요청 + 형식이 명시되어 있지 않습니다. 어댑터는 다른 effort 요청을 검증된 `low`로 조정하고, + `delta.reasoning_content` 또는 `delta.reasoning`을 reasoning delta로 처리하며, + `stream_options.include_usage`로 스트림 usage를 요청하고 비스트림 응답 envelope에서도 usage를 읽습니다. ## `openai-responses` diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 60aa509f34..83ffabc15c 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -70,6 +70,7 @@ description: 공급자 항목, 인증, 엔드포인트, 모델 카탈로그, 할 | `modelAdapters?` | `Record` | 혼합 와이어 게이트웨이를 위한 모델별 `openai-chat` 또는 `openai-responses` 와이어 재정의입니다. 명시적 항목이 레지스트리 기본값보다 우선합니다. DeepSeek 프리셋은 `deepseek-v4-flash`에 네이티브 Responses를 선택할 수 있고, GitHub Copilot은 GPT-5 계열(`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`)을 Responses 전용 기본값으로 선언합니다. 이 모델들은 에이전트 트래픽에서 `/chat/completions`를 거부하기 때문입니다. `gpt-5.4-nano`처럼 기본값이 없는 모델은 여기서 직접 옵트인할 수 있습니다. 단일 와이어 상위 항목과 정식 ChatGPT forward는 재정의를 거부합니다. | | `reasoningEffortMap?` | `Record` | reasoning 레이블의 공급자 전반 와이어 별칭입니다. | | `modelReasoningEffortMap?` | `Record>` | reasoning 레이블의 모델별 와이어 별칭입니다. | +| `reasoningWireFormat?` | `"gateway-object"` | `reasoning_effort` 대신 `reasoning: { enabled, effort }`를 받는 OpenAI 호환 게이트웨이용입니다. ClinePass 프리셋이 자동 설정합니다. | | `noReasoningModels?` | `string[]` | reasoning/thinking 매개변수를 거부하는 모델입니다. | | `noTemperatureModels?` | `string[]` | 호출자가 지정한 `temperature`를 거부하는 모델입니다. | | `noTopPModels?` | `string[]` | 호출자가 지정한 `top_p`를 거부하는 모델입니다. | diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 2a6d997da2..a4afd8c38d 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -38,6 +38,11 @@ provider — xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama (local & cloud), adapter **omits it entirely** for ids in `provider.noReasoningModels`. - Streams `delta.content` (text), `delta.reasoning_content` (thinking), and `delta.tool_calls[]`; collects `usage`. +- ClinePass uses the live-verified gateway format `reasoning: { enabled: true, effort: "low" }` + (or `{ enabled: false }` when reasoning is disabled); its public API docs do not currently specify + this request shape. The adapter clamps other effort requests to the verified `low` tier, accepts + reasoning deltas from either `delta.reasoning_content` or `delta.reasoning`, requests streamed + usage with `stream_options.include_usage`, and reads usage from non-stream response envelopes. ## `openai-responses` diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index b4c8072577..209a26d25f 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -81,6 +81,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `modelAdapters?` | `Record` | Per-model `openai-chat` or `openai-responses` wire override for mixed-wire gateways. Explicit entries beat registry defaults; DeepSeek's preset can select native Responses for `deepseek-v4-flash`, and GitHub Copilot declares Responses-only defaults for its GPT-5 family (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`) because those models reject `/chat/completions` for agent traffic. Models without a built-in default (for example `gpt-5.4-nano`) can be opted in here. Single-wire upstream pins and canonical ChatGPT forward reject overrides. | | `reasoningEffortMap?` | `Record` | Provider-wide wire aliases for reasoning labels. | | `modelReasoningEffortMap?` | `Record>` | Per-model wire aliases for reasoning labels. | +| `reasoningWireFormat?` | `"gateway-object"` | For OpenAI-compatible gateways that accept `reasoning: { enabled, effort }` instead of `reasoning_effort`. The ClinePass preset sets this automatically. | | `noReasoningModels?` | `string[]` | Models that reject reasoning/thinking parameters. | | `noTemperatureModels?` | `string[]` | Models that reject caller-specified `temperature`. | | `noTopPModels?` | `string[]` | Models that reject caller-specified `top_p`. | diff --git a/docs-site/src/content/docs/ru/getting-started/quickstart.md b/docs-site/src/content/docs/ru/getting-started/quickstart.md index 33a7e41ee1..7f1d20abeb 100644 --- a/docs-site/src/content/docs/ru/getting-started/quickstart.md +++ b/docs-site/src/content/docs/ru/getting-started/quickstart.md @@ -13,7 +13,7 @@ ocx init `ocx init` проведёт вас по следующим шагам: -1. **Выбор провайдера** — выберите один из 66 встроенных пресетов реестра или `custom`, чтобы +1. **Выбор провайдера** — выберите один из 68 встроенных пресетов реестра или `custom`, чтобы ввести базовый URL и адаптер вручную. 2. **API-ключ** — вставьте ключ или сошлитесь на переменную окружения вида `${ANTHROPIC_API_KEY}`. 3. **Модель по умолчанию** — для провайдеров с ключом, локальных и `custom` примите значение из diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 1ed4d5cbcb..8f882b30fa 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -126,15 +126,30 @@ OAuth-провайдеры, чьи учётные данные содержат ## 3. Каталог API-ключей -opencodex поставляется с 66 встроенными пресетами: 55 на основе ключей, семь OAuth, три локальных и +opencodex поставляется с 68 встроенными пресетами: 57 на основе ключей, семь OAuth, три локальных и пресет ChatGPT-форварда по умолчанию. Селектор **Add provider** в дашборде открывает страницу выдачи ключей провайдера, проверяет ключ и сохраняет его. Наиболее заметные записи: +**ClinePass** подключается с помощью Cline API key к [официальному каталогу подписки](https://docs.cline.bot/getting-started/clinepass) +и [Chat Completions endpoint](https://docs.cline.bot/api/chat-completions). Оператор — Cline Bot Inc., указанный в +[условиях Cline](https://cline.bot/tos). Маршрут вида `cline-pass/cline-pass/kimi-k3` +намеренный: первая часть выбирает провайдера opencodex, а полный slug `cline-pass/kimi-k3` +отправляется upstream. Использование учитывается в общих для аккаунта скользящем 5-часовом, +недельном и месячном лимитах. Сейчас opencodex публикует только проверенный на живом API reasoning tier +`low`; более высокие запросы ограничиваются до `low`, пока шлюз не опубликует или не подтвердит более широкий диапазон. + +**Cline** использует тот же ключ и эндпоинт с оплатой по мере использования и доступом к 100+ моделям +(ID в формате OpenRouter, например `anthropic/claude-sonnet-4-6`). Промо-бесплатные модели Cline +доступны только в IDE/CLI Cline, а не через API; `minimax/minimax-m2.5` документирован как +бесплатная модель для экспериментов через API. + | Провайдер | Базовый URL | | --- | --- | | **OpenAI (API key)** | `https://api.openai.com/v1` | | **Anthropic (API key)** | `https://api.anthropic.com` | | **OpenRouter** | `https://openrouter.ai/api/v1` | +| **Cline** | `https://api.cline.bot/api/v1` | +| **ClinePass** | `https://api.cline.bot/api/v1` | | **Ollama Cloud** | `https://ollama.com/v1` | | Google Gemini · Google Vertex AI | `https://generativelanguage.googleapis.com` · `https://aiplatform.googleapis.com` | | Azure OpenAI | `https://{resource}.openai.azure.com/openai` | diff --git a/docs-site/src/content/docs/ru/reference/adapters.md b/docs-site/src/content/docs/ru/reference/adapters.md index 6637163140..bd1a3d1ea1 100644 --- a/docs-site/src/content/docs/ru/reference/adapters.md +++ b/docs-site/src/content/docs/ru/reference/adapters.md @@ -41,6 +41,12 @@ interface ProviderAdapter { id из `provider.noReasoningModels` адаптер **полностью опускает** этот параметр. - Стримит `delta.content` (текст), `delta.reasoning_content` (thinking) и `delta.tool_calls[]`; собирает `usage`. +- ClinePass использует проверенный на живом API формат шлюза + `reasoning: { enabled: true, effort: "low" }` (или `{ enabled: false }`, когда reasoning отключён); + в публичной документации API этот формат запроса пока не указан. Адаптер прижимает другие + уровни effort к проверенному `low`, принимает reasoning delta из `delta.reasoning_content` или + `delta.reasoning`, запрашивает usage потока через `stream_options.include_usage` и читает usage + из envelope нестримингового ответа. ## `openai-responses` diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 0b2462e65c..6a9957d0b6 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -86,6 +86,7 @@ cross-route credential fallback не существует. Строки API GPT- | `modelAdapters?` | `Record` | Wire-override по модели для `openai-chat` или `openai-responses` в gateway с несколькими wire-форматами. Явные записи имеют приоритет над default'ами registry; preset DeepSeek может выбирать native Responses для `deepseek-v4-flash`, а GitHub Copilot объявляет Responses-only default'ы для семейства GPT-5 (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`), потому что эти модели отклоняют `/chat/completions` для агентного трафика. Модели без встроенного default'а (например, `gpt-5.4-nano`) можно включить здесь. Single-wire upstream pin'ы и canonical ChatGPT forward override не принимают. | | `reasoningEffortMap?` | `Record` | Provider-wide wire-alias'ы для reasoning-label'ов. | | `modelReasoningEffortMap?` | `Record>` | Wire-alias'ы для reasoning-label'ов по отдельным моделям. | +| `reasoningWireFormat?` | `"gateway-object"` | Для OpenAI-совместимых шлюзов, принимающих `reasoning: { enabled, effort }` вместо `reasoning_effort`. Пресет ClinePass задаёт это автоматически. | | `noReasoningModels?` | `string[]` | Модели, отвергающие параметры reasoning/thinking. | | `noTemperatureModels?` | `string[]` | Модели, отвергающие переданный вызывающей стороной `temperature`. | | `noTopPModels?` | `string[]` | Модели, отвергающие переданный вызывающей стороной `top_p`. | diff --git a/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md b/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md index bcbfebbdfb..02178fd285 100644 --- a/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md +++ b/docs-site/src/content/docs/zh-cn/getting-started/quickstart.md @@ -13,7 +13,7 @@ ocx init `ocx init` 会引导你完成: -1. **选择 provider** — 从内置 registry 的 66 个预设中选择一个,或选择 `custom` 手动输入 base URL 和 adapter。 +1. **选择 provider** — 从内置 registry 的 68 个预设中选择一个,或选择 `custom` 手动输入 base URL 和 adapter。 2. **API key** — 粘贴一个 key,或引用一个环境变量,例如 `${ANTHROPIC_API_KEY}`。 3. **默认模型** — 对于 key、本地和 custom provider,接受预设值或输入模型 id。 4. **代理端口** — 默认为 `10100`。 diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index 6e6e4796f1..b5706f7654 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -111,15 +111,30 @@ Kiro 登录需要 Kiro CLI:Unix 使用 `curl -fsSL https://cli.kiro.dev/instal ## 3. API 密钥目录 -opencodex 内置 66 个预设:55 个密钥预设、7 个 OAuth 预设、3 个本地预设,以及默认的 +opencodex 内置 68 个预设:57 个密钥预设、7 个 OAuth 预设、3 个本地预设,以及默认的 ChatGPT 转发预设。仪表盘的 **Add provider** 选择器会打开密钥提供商的控制台,验证并保存密钥。 主要条目包括: +**ClinePass** 使用 Cline API 密钥连接[官方订阅目录](https://docs.cline.bot/getting-started/clinepass)和 +[Chat Completions 端点](https://docs.cline.bot/api/chat-completions)。运营主体是 +[Cline 条款](https://cline.bot/tos)所列的 Cline Bot Inc.。 +`cline-pass/cline-pass/kimi-k3` 这样的路由 ID 是预期格式:第一段选择 opencodex 提供商, +其余的 `cline-pass/kimi-k3` 是发送到上游的完整模型 slug。用量由账户的滚动 5 小时、每周和 +每月限额共同管理。当前 opencodex 仅公开经过实测的 `low` reasoning 档位;在网关公布或验证更宽 +档位之前,更高请求会被限制为 `low`。 + +**Cline** 使用相同的 API 密钥和端点,按用量计费,可访问 100 多个模型 +(OpenRouter 风格 ID,如 `anthropic/claude-sonnet-4-6`)。Cline 的促销免费模型仅在 +Cline IDE/CLI 中提供,不能通过 API 使用;`minimax/minimax-m2.5` 是文档中通过 API +免费试用的模型。 + | 提供商 | 基础 URL | | --- | --- | | **OpenAI (API key)** | `https://api.openai.com/v1` | | **Anthropic (API key)** | `https://api.anthropic.com` | | **OpenRouter** | `https://openrouter.ai/api/v1` | +| **Cline** | `https://api.cline.bot/api/v1` | +| **ClinePass** | `https://api.cline.bot/api/v1` | | **Ollama Cloud** | `https://ollama.com/v1` | | Google Gemini · Google Vertex AI | `https://generativelanguage.googleapis.com` · `https://aiplatform.googleapis.com` | | Azure OpenAI | `https://{resource}.openai.azure.com/openai` | diff --git a/docs-site/src/content/docs/zh-cn/reference/adapters.md b/docs-site/src/content/docs/zh-cn/reference/adapters.md index e0b5caa6f4..be3d4a3f77 100644 --- a/docs-site/src/content/docs/zh-cn/reference/adapters.md +++ b/docs-site/src/content/docs/zh-cn/reference/adapters.md @@ -36,6 +36,11 @@ interface ProviderAdapter { 省略**该参数。 - 流式输出 `delta.content`(文本)、`delta.reasoning_content`(thinking)和 `delta.tool_calls[]`,并收集 `usage`。 +- ClinePass 使用经实时验证的网关格式 `reasoning: { enabled: true, effort: "low" }`;关闭 + reasoning 时使用 `{ enabled: false }`。其公开 API 文档目前没有说明这一请求格式。adapter 会把 + 其他 effort 请求限制到已验证的 `low`,把 `delta.reasoning_content` 或 `delta.reasoning` + 作为 reasoning delta,通过 `stream_options.include_usage` 请求流式 usage,并从非流式响应 + envelope 中读取 usage。 ## `openai-responses` diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index c462f907bb..139afb6769 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -70,6 +70,7 @@ description: 提供者条目、身份验证、端点、模型目录、配额、 | `modelAdapters?` | `Record` | 按模型设置的 `openai-chat` 或 `openai-responses` 线协议覆盖项,用于混合线协议网关。显式条目优先于注册表默认值;DeepSeek 预设可以为 `deepseek-v4-flash` 选择原生 Responses,GitHub Copilot 则为 GPT-5 系列(`gpt-5.3-codex`、`gpt-5.4`、`gpt-5.4-mini`、`gpt-5.5`、`gpt-5.6-luna`、`gpt-5.6-sol`、`gpt-5.6-terra`)声明了 Responses 专用默认值,因为这些模型在代理流量下会拒绝 `/chat/completions`。没有内置默认值的模型(例如 `gpt-5.4-nano`)可以在此手动启用。单一线协议上游固定项和规范 ChatGPT forward 会拒绝覆盖。 | | `reasoningEffortMap?` | `Record` | 提供者级、用于推理标签的线协议别名。 | | `modelReasoningEffortMap?` | `Record>` | 按模型设置的推理标签线协议别名。 | +| `reasoningWireFormat?` | `"gateway-object"` | 用于接受 `reasoning: { enabled, effort }` 而非 `reasoning_effort` 的 OpenAI 兼容 gateway。ClinePass preset 会自动设置。 | | `noReasoningModels?` | `string[]` | 会拒绝推理/思考参数的模型。 | | `noTemperatureModels?` | `string[]` | 会拒绝调用方指定 `temperature` 的模型。 | | `noTopPModels?` | `string[]` | 会拒绝调用方指定 `top_p` 的模型。 | diff --git a/gui/public/provider-icons/cline-color.svg b/gui/public/provider-icons/cline-color.svg new file mode 100644 index 0000000000..317f01c5f5 --- /dev/null +++ b/gui/public/provider-icons/cline-color.svg @@ -0,0 +1,16 @@ + + + Cline + + + + + + + + + + + + + diff --git a/gui/src/hooks/useJsonConfigEditor.ts b/gui/src/hooks/useJsonConfigEditor.ts index 628913677c..b2f53a3b4a 100644 --- a/gui/src/hooks/useJsonConfigEditor.ts +++ b/gui/src/hooks/useJsonConfigEditor.ts @@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; export interface Config { port: number; defaultProvider: string; - providers: Record; + providers: Record; } export function useJsonConfigEditor(deps: { diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index 21a402eb67..69b1316fd4 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -101,7 +101,7 @@ interface LogAttempt { requestedEffort?: string; effectiveEffort?: string; reasoningWireField?: string; - reasoningWireValue?: string | number; + reasoningWireValue?: string | number | boolean; displayMetrics?: LogDisplayMetrics; } @@ -115,7 +115,7 @@ export interface LogEntry { requestedEffort?: string; effectiveEffort?: string; reasoningWireField?: string; - reasoningWireValue?: string | number; + reasoningWireValue?: string | number | boolean; requestedServiceTier?: string; requestedSpeedLabel?: string; configuredServiceTier?: string; @@ -221,7 +221,7 @@ interface ReasoningLogFields { requestedEffort?: string; effectiveEffort?: string; reasoningWireField?: string; - reasoningWireValue?: string | number; + reasoningWireValue?: string | number | boolean; } function effortLabel(log: ReasoningLogFields): string { diff --git a/gui/src/pages/providers-shared.ts b/gui/src/pages/providers-shared.ts index 34ebbc825f..fb64a56a86 100644 --- a/gui/src/pages/providers-shared.ts +++ b/gui/src/pages/providers-shared.ts @@ -9,6 +9,7 @@ export interface ProvidersConfig { defaultModel?: string; models?: string[]; liveModels?: boolean; + reasoningWireFormat?: "gateway-object"; authMode?: string; keyOptional?: boolean; disabled?: boolean; diff --git a/gui/src/provider-icons.ts b/gui/src/provider-icons.ts index 96de07735e..c0e1d0677c 100644 --- a/gui/src/provider-icons.ts +++ b/gui/src/provider-icons.ts @@ -7,6 +7,8 @@ const PROVIDER_ICON_ALIASES: Record = { chatgpt: "openai.svg", "cloudflare-ai-gateway": "cloudflare-ai-gateway-color.svg", "cloudflare-workers-ai": "cloudflare-ai-gateway-color.svg", + cline: "cline-color.svg", + "cline-pass": "cline-color.svg", cursor: "cursor-color.svg", deepseek: "deepseek-color.svg", firepass: "firepass-color.svg", @@ -59,6 +61,8 @@ const PROVIDER_DISPLAY_NAMES: Record = { "azure-openai": "Azure OpenAI", "cloudflare-ai-gateway": "Cloudflare AI Gateway", "cloudflare-workers-ai": "Cloudflare Workers AI", + cline: "Cline", + "cline-pass": "ClinePass", nvidia: "NVIDIA NIM", ollama: "Ollama", "ollama-cloud": "Ollama Cloud", diff --git a/src/adapters/base.ts b/src/adapters/base.ts index 58ab9b3d5c..e71e0db42c 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -49,11 +49,22 @@ export interface AdapterRequest { /** Releases observation of a serialized request body after its final fetch attempt settles. */ releaseBodyObservation?: () => void; /** Exact reasoning parameter emitted by the adapter, for request-log diagnostics only. */ - reasoningLog?: { - effectiveEffort: string; - wireField: "reasoning_effort" | "thinking_budget" | "thinking.type"; - wireValue: string | number; - }; + reasoningLog?: + | { + effectiveEffort: string; + wireField: "reasoning.enabled"; + wireValue: boolean; + } + | { + effectiveEffort: string; + wireField: "thinking_budget"; + wireValue: number; + } + | { + effectiveEffort: string; + wireField: "reasoning_effort" | "reasoning.effort" | "thinking.type"; + wireValue: string; + }; usageLog?: { inputTokens?: number; estimated?: boolean; diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 2848790103..863bbe4905 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -70,6 +70,103 @@ function extractErrorDetail(parsed: unknown): string | undefined { return undefined; } +// ClinePass live responses observed 2026-08-02 wrap non-stream Chat Completions in +// `{ success, error, data }`; its public Chat Completions docs do not currently describe that +// envelope. Keep ordinary OpenAI-shaped responses on the direct path. +function unwrapChatCompletionPayload(json: Record): Record { + if ((json.error !== undefined && json.error !== null) || Array.isArray(json.choices)) return json; + const data = json.data; + return data !== null && typeof data === "object" && !Array.isArray(data) + ? data as Record + : json; +} + +interface OpenAIChatError { + message?: unknown; + code?: unknown; + type?: unknown; + status?: unknown; + metadata?: unknown; +} + +function safeUpstreamRequestId(metadata: unknown): string | undefined { + if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) return undefined; + const record = metadata as Record; + const value = record.request_id ?? record.requestId; + if (typeof value !== "string") return undefined; + const requestId = value.trim(); + return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(requestId) + && redactSecretString(requestId) === requestId + ? requestId + : undefined; +} + +function upstreamErrorEvent( + error: unknown, + usage?: OcxUsage, +): Extract { + const details = error !== null && typeof error === "object" && !Array.isArray(error) + ? error as OpenAIChatError + : undefined; + const rawMessage = typeof error === "string" + ? error.trim() || "upstream error" + : typeof details?.message === "string" ? details.message : "upstream error"; + const safeMessage = redactSecretString(rawMessage); + const requestId = safeUpstreamRequestId(details?.metadata); + const message = requestId !== undefined && !safeMessage.includes(requestId) + ? `${safeMessage} (request ID: ${requestId})` + : safeMessage; + const code = typeof details?.code === "string" + ? details.code + : typeof details?.code === "number" && Number.isFinite(details.code) && Number.isInteger(details.code) + ? String(details.code) + : undefined; + const errorType = typeof details?.type === "string" ? details.type : undefined; + const codeStatus = typeof details?.code === "number" + && Number.isInteger(details.code) + && details.code >= 100 + && details.code <= 599 + ? details.code + : undefined; + const status = isCyberPolicyCode(code) + ? 400 + : typeof details?.status === "number" && Number.isInteger(details.status) + ? details.status + : codeStatus; + return { + type: "error", + message, + ...(usage !== undefined ? { usage } : {}), + ...(code !== undefined ? { code } : {}), + ...(errorType !== undefined ? { errorType } : {}), + ...(status !== undefined ? { status } : {}), + }; +} + +function stopReasonFor(finishReason: unknown): "max_tokens" | "content_filter" | undefined { + return finishReason === "length" + ? "max_tokens" + : finishReason === "content_filter" + ? "content_filter" + : undefined; +} + +function reasoningTextFrom(record: Record): string | undefined { + return typeof record.reasoning_content === "string" && record.reasoning_content.length > 0 + ? record.reasoning_content + : typeof record.reasoning === "string" && record.reasoning.length > 0 + ? record.reasoning + : undefined; +} + +function invalidChoicesEvent(usage?: OcxUsage): Extract { + return { + type: "error", + message: "upstream response contained invalid choices", + ...(usage !== undefined ? { usage } : {}), + }; +} + function developerSystemText(message: OcxMessage): string | undefined { if (message.role !== "developer") return undefined; if (typeof message.content === "string") return message.content; @@ -591,10 +688,27 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd body.top_p = parsed.options.topP; } if (parsed.options.stopSequences !== undefined) body.stop = parsed.options.stopSequences; + const reasoningDisabled = modelInList(provider.noReasoningModels, parsed.modelId); const reasoningEffort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning); let reasoningLog: AdapterRequest["reasoningLog"]; - if (reasoningEffort !== undefined) { - if (modelInList(provider.thinkingBudgetModels, parsed.modelId)) { + // ClinePass live requests observed 2026-08-02 require this gateway-specific object; the + // public API docs do not currently specify its request shape. + if (!reasoningDisabled && provider.reasoningWireFormat === "gateway-object" && parsed.options.reasoning === "none") { + body.reasoning = { enabled: false }; + reasoningLog = { + effectiveEffort: "none", + wireField: "reasoning.enabled", + wireValue: false, + }; + } else if (reasoningEffort !== undefined) { + if (provider.reasoningWireFormat === "gateway-object") { + body.reasoning = { enabled: true, effort: reasoningEffort }; + reasoningLog = { + effectiveEffort: reasoningEffort, + wireField: "reasoning.effort", + wireValue: reasoningEffort, + }; + } else if (modelInList(provider.thinkingBudgetModels, parsed.modelId)) { const budget = thinkingBudgetForEffort(parsed, reasoningEffort, maxTokens); if (budget !== undefined) { body.thinking_budget = budget; @@ -707,17 +821,28 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd interface PendingToolCall { key: string; id: string; name: string; args: string; argsBytes: number } const pendingToolCalls: PendingToolCall[] = []; let toolCallSeq = 0; + const closeToolCalls = (): PendingToolCall[] => { + const calls = [...pendingToolCalls]; + for (const call of calls) budget.closeCall(call.key); + pendingToolCalls.length = 0; + return calls; + }; const flushToolCalls = function* (): Generator { // Do not treat flushed tool calls as user-facing output for the finish-less EOF // fallback — incomplete tool args must stay on the truncation path. - for (const call of pendingToolCalls) { + for (const call of closeToolCalls()) { if (!call.id) call.id = `call_${++toolCallSeq}`; yield { type: "tool_call_start", id: call.id, name: call.name }; if (call.args.length > 0) yield { type: "tool_call_delta", arguments: call.args }; yield { type: "tool_call_end" }; - budget.closeCall(call.key); } - pendingToolCalls.length = 0; + }; + const terminateWithError = function* ( + event: Extract, + ): Generator { + closeToolCalls(); + yield event; + return "terminate"; }; let pendingUsage: OcxUsage | undefined; // Track terminal signals so a socket EOF without any terminator can fail closed instead of @@ -738,11 +863,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd const payload = line.slice(6).trim(); if (payload === "[DONE]") { yield* flushToolCalls(); - const stopReason = finishReason === "length" - ? "max_tokens" - : finishReason === "content_filter" - ? "content_filter" - : undefined; + const stopReason = stopReasonFor(finishReason); yield { type: "done", usage: pendingUsage, ...(stopReason ? { stopReason } : {}) }; return "terminate"; } @@ -758,23 +879,10 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd // A 200/OK chat-completions stream may carry an inline provider error envelope // instead of a clean [DONE]. Surface it as a terminal error so the bridge emits a // classified response.failed (bridge case "error") — never a truncated completion. - if (chunk.error) { - const err = chunk.error as { message?: string; code?: string; type?: string; status?: number } | undefined; - const message = err?.message ?? "upstream error"; - debugProviderDiagnostic("openai-chat", "stream-error", { message }); - yield* flushToolCalls(); - yield { - type: "error", - message, - ...(typeof err?.code === "string" ? { code: err.code } : {}), - ...(typeof err?.type === "string" ? { errorType: err.type } : {}), - ...(isCyberPolicyCode(err?.code) - ? { status: 400 } - : typeof err?.status === "number" && Number.isInteger(err.status) - ? { status: err.status } - : {}), - }; - return "terminate"; + if (chunk.error !== undefined && chunk.error !== null) { + const event = upstreamErrorEvent(chunk.error, pendingUsage); + debugProviderDiagnostic("openai-chat", "stream-error", { message: event.message }); + return yield* terminateWithError(event); } if (chunk.usage) { @@ -784,17 +892,36 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd pendingUsage = usageFromOpenAIChat(chunk.usage as Record); } - const choices = chunk.choices as { delta?: Record; finish_reason?: string }[] | undefined; - if (!choices || choices.length === 0) return "continue"; + const choices = chunk.choices; + if (choices === undefined) return "continue"; + if (!Array.isArray(choices)) { + return yield* terminateWithError(invalidChoicesEvent(pendingUsage)); + } + if (choices.length === 0) return "continue"; + const rawChoice = choices[0]; + if (rawChoice === null || typeof rawChoice !== "object" || Array.isArray(rawChoice)) { + return yield* terminateWithError(invalidChoicesEvent(pendingUsage)); + } + const choice = rawChoice as { + delta?: Record; + finish_reason?: string; + error?: unknown; + }; + if (choice.finish_reason === "error") { + const event = upstreamErrorEvent(choice.error, pendingUsage); + debugProviderDiagnostic("openai-chat", "stream-error", { message: event.message }); + return yield* terminateWithError(event); + } // Observe the terminator BEFORE the delta guard: a finish-only chunk (finish_reason set, // no delta) is a graceful close and must record finishReason even though we skip it below. - if (typeof choices[0].finish_reason === "string" && choices[0].finish_reason) { - finishReason = choices[0].finish_reason; + if (typeof choice.finish_reason === "string" && choice.finish_reason) { + finishReason = choice.finish_reason; } - const delta = choices[0].delta; + const delta = choice.delta; if (delta) { - if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) { - yield { type: "reasoning_raw_delta", text: delta.reasoning_content }; + const reasoningText = reasoningTextFrom(delta); + if (reasoningText !== undefined) { + yield { type: "reasoning_raw_delta", text: reasoningText }; } if (typeof delta.content === "string" && delta.content.length > 0) { sawUserFacingOutput = true; @@ -842,7 +969,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd // Any non-empty finish_reason ends the generation: flush assembled tool calls as // atomic sequences (covers "tool_calls" AND providers that close tool turns with "stop"). - if (typeof choices[0].finish_reason === "string" && choices[0].finish_reason) { + if (typeof choice.finish_reason === "string" && choice.finish_reason) { yield* flushToolCalls(); } return "continue"; @@ -921,11 +1048,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd } yield* flushToolCalls(); // Graceful close that omitted [DONE] but delivered finish_reason and/or answer text. - const stopReason = finishReason === "length" - ? "max_tokens" - : finishReason === "content_filter" - ? "content_filter" - : undefined; + const stopReason = stopReasonFor(finishReason); yield { type: "done", usage: pendingUsage, ...(stopReason ? { stopReason } : {}) }; } catch (error) { if (isTranslatorBudgetExceededError(error) @@ -943,7 +1066,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd throw error; } finally { budget.releaseRetained(bufferBytes, { kind: "live_transient" }); - for (const call of pendingToolCalls) budget.closeCall(call.key); + closeToolCalls(); reader.releaseLock(); } }, @@ -953,34 +1076,40 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd const responseBytes = new TextEncoder().encode(JSON.stringify(json)).byteLength; budget.chargeRetained(responseBytes, { kind: "retained_collectors" }); try { - if (json.error) { - const upstreamError = json.error as { message?: unknown; code?: unknown; type?: unknown; status?: unknown }; - const message = typeof upstreamError.message === "string" ? upstreamError.message : "upstream error"; - const code = typeof upstreamError.code === "string" ? upstreamError.code : undefined; - const errorType = typeof upstreamError.type === "string" ? upstreamError.type : undefined; - const status = isCyberPolicyCode(code) - ? 400 - : typeof upstreamError.status === "number" && Number.isInteger(upstreamError.status) - ? upstreamError.status - : undefined; + const payload = unwrapChatCompletionPayload(json); + const usage = usageFromOpenAIChat(payload.usage as Record | undefined); + if (json.success === false && payload.error === undefined) { return [{ type: "error", - message, - ...(code !== undefined ? { code } : {}), - ...(errorType !== undefined ? { errorType } : {}), - ...(status !== undefined ? { status } : {}), + message: "upstream reported failure without an error payload", + ...(usage ? { usage } : {}), }]; } + if (payload.error !== undefined && payload.error !== null) { + return [upstreamErrorEvent(payload.error, usage)]; + } const events: AdapterEvent[] = []; - const choices = json.choices as { message?: Record }[] | undefined; - if (!Array.isArray(choices) || choices.length === 0 || !choices[0].message) { - return [{ type: "error", message: "upstream response contained no choices" }]; + const choices = payload.choices as { + message?: Record; + finish_reason?: unknown; + error?: OpenAIChatError; + }[] | undefined; + if (!Array.isArray(choices) || choices.length === 0) { + return [{ type: "error", message: "upstream response contained no choices", ...(usage ? { usage } : {}) }]; + } + const rawChoice = choices[0]; + if (rawChoice === null || typeof rawChoice !== "object" || Array.isArray(rawChoice)) { + return [invalidChoicesEvent(usage)]; } + const choice = rawChoice; + if (choice.finish_reason === "error") return [upstreamErrorEvent(choice.error, usage)]; + if (!choice.message) return [{ type: "error", message: "upstream response contained no choices", ...(usage ? { usage } : {}) }]; - const msg = choices[0].message; - if (typeof msg.reasoning_content === "string" && msg.reasoning_content.length > 0) { - events.push({ type: "reasoning_raw_delta", text: msg.reasoning_content }); + const msg = choice.message; + const reasoningText = reasoningTextFrom(msg); + if (reasoningText !== undefined) { + events.push({ type: "reasoning_raw_delta", text: reasoningText }); } if (typeof msg.content === "string") { events.push({ type: "text_delta", text: msg.content }); @@ -993,10 +1122,11 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd events.push({ type: "tool_call_end" }); } } - const usage = json.usage as Record | undefined; + const stopReason = stopReasonFor(choice.finish_reason); events.push({ type: "done", - usage: usageFromOpenAIChat(usage), + usage, + ...(stopReason ? { stopReason } : {}), }); retainTranslatedEventBatch(events, budget); return events; diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 4aeb2aa469..7f2da82fc6 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -22,6 +22,7 @@ export interface DerivedKeyLoginProvider { modelDefaultReasoningEfforts?: Record; reasoningEffortMap?: Record; modelReasoningEffortMap?: Record>; + reasoningWireFormat?: OcxProviderConfig["reasoningWireFormat"]; noVisionModels?: string[]; noReasoningModels?: string[]; noTemperatureModels?: string[]; @@ -128,6 +129,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon ...(entry.modelDefaultReasoningEfforts ? { modelDefaultReasoningEfforts: { ...entry.modelDefaultReasoningEfforts } } : {}), ...(entry.reasoningEffortMap ? { reasoningEffortMap: { ...entry.reasoningEffortMap } } : {}), ...(entry.modelReasoningEffortMap ? { modelReasoningEffortMap: cloneNestedRecord(entry.modelReasoningEffortMap) } : {}), + ...(entry.reasoningWireFormat ? { reasoningWireFormat: entry.reasoningWireFormat } : {}), ...(entry.noVisionModels ? { noVisionModels: [...entry.noVisionModels] } : {}), ...(entry.noReasoningModels ? { noReasoningModels: [...entry.noReasoningModels] } : {}), ...(entry.noTemperatureModels ? { noTemperatureModels: [...entry.noTemperatureModels] } : {}), @@ -175,6 +177,7 @@ export function deriveKeyLoginMap(): Record { ...(entry.modelDefaultReasoningEfforts ? { modelDefaultReasoningEfforts: { ...entry.modelDefaultReasoningEfforts } } : {}), ...(entry.reasoningEffortMap ? { reasoningEffortMap: { ...entry.reasoningEffortMap } } : {}), ...(entry.modelReasoningEffortMap ? { modelReasoningEffortMap: cloneNestedRecord(entry.modelReasoningEffortMap) } : {}), + ...(entry.reasoningWireFormat ? { reasoningWireFormat: entry.reasoningWireFormat } : {}), ...(entry.noVisionModels ? { noVisionModels: [...entry.noVisionModels] } : {}), ...(entry.noReasoningModels ? { noReasoningModels: [...entry.noReasoningModels] } : {}), ...(entry.noTemperatureModels ? { noTemperatureModels: [...entry.noTemperatureModels] } : {}), @@ -248,6 +251,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig if (!prov.modelDefaultReasoningEfforts && seed.modelDefaultReasoningEfforts) prov.modelDefaultReasoningEfforts = { ...seed.modelDefaultReasoningEfforts }; if (!prov.reasoningEffortMap && seed.reasoningEffortMap) prov.reasoningEffortMap = { ...seed.reasoningEffortMap }; if (!prov.modelReasoningEffortMap && seed.modelReasoningEffortMap) prov.modelReasoningEffortMap = cloneNestedRecord(seed.modelReasoningEffortMap); + if (prov.reasoningWireFormat === undefined && seed.reasoningWireFormat !== undefined) prov.reasoningWireFormat = seed.reasoningWireFormat; if (!prov.noVisionModels && seed.noVisionModels) prov.noVisionModels = [...seed.noVisionModels]; if (!prov.noReasoningModels && seed.noReasoningModels) prov.noReasoningModels = [...seed.noReasoningModels]; if (!prov.noTemperatureModels && seed.noTemperatureModels) prov.noTemperatureModels = [...seed.noTemperatureModels]; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index b528860887..5dff08a156 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -190,6 +190,7 @@ export interface ProviderRegistryEntry { modelDefaultReasoningEfforts?: Record; reasoningEffortMap?: Record; modelReasoningEffortMap?: Record>; + reasoningWireFormat?: OcxProviderConfig["reasoningWireFormat"]; noVisionModels?: string[]; noReasoningModels?: string[]; noTemperatureModels?: string[]; @@ -221,7 +222,7 @@ export type ProviderConfigSeed = Pick< "adapter" | "baseUrl" | "apiKeyTransport" | "responsesPath" | "authMode" | "keyOptional" | "freeTier" | "modelSuffixBracketStrip" | "defaultModel" | "models" | "liveModels" | "contextWindow" | "modelContextWindows" | "modelInputModalities" | "modelMaxInputTokens" | "defaultMaxOutputTokens" | "modelMaxOutputTokens" - | "reasoningEfforts" | "modelReasoningEfforts" | "modelDefaultReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap" + | "reasoningEfforts" | "modelReasoningEfforts" | "modelDefaultReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap" | "reasoningWireFormat" | "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noPenaltyModels" | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "reasoningSplitModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames" | "googleMode" | "project" | "location" | "headers" @@ -584,6 +585,44 @@ const UMANS_MODEL_CONTEXT_WINDOWS: Record = { const UMANS_MODEL_INPUT_MODALITIES: Record = Object.fromEntries( UMANS_MODELS.map(id => [id, UMANS_TEXT_ONLY_MODELS.includes(id) ? ["text"] : ["text", "image"]]), ); +const CLINE_PASS_MODELS = [ + "cline-pass/glm-5.2", + "cline-pass/kimi-k3", + "cline-pass/kimi-k2.7-code", + "cline-pass/kimi-k2.6", + "cline-pass/deepseek-v4-pro", + "cline-pass/deepseek-v4-flash", + "cline-pass/mimo-v2.5", + "cline-pass/mimo-v2.5-pro", + "cline-pass/minimax-m3", + "cline-pass/qwen3.7-max", + "cline-pass/qwen3.7-plus", +]; +const CLINE_PASS_MODEL_CONTEXT_WINDOWS: Record = { + "cline-pass/glm-5.2": 1_048_576, + "cline-pass/kimi-k3": 1_048_576, + "cline-pass/kimi-k2.7-code": 262_144, + "cline-pass/kimi-k2.6": 262_144, + "cline-pass/deepseek-v4-pro": 1_048_576, + "cline-pass/deepseek-v4-flash": 1_048_576, + "cline-pass/mimo-v2.5": 1_050_000, + "cline-pass/mimo-v2.5-pro": 1_050_000, + "cline-pass/minimax-m3": 1_048_576, + "cline-pass/qwen3.7-max": 1_000_000, + "cline-pass/qwen3.7-plus": 1_000_000, +}; +const CLINE_PASS_IMAGE_MODELS = new Set([ + "cline-pass/kimi-k3", + "cline-pass/kimi-k2.7-code", + "cline-pass/kimi-k2.6", + "cline-pass/mimo-v2.5", + "cline-pass/minimax-m3", + "cline-pass/qwen3.7-plus", +]); +const CLINE_PASS_TEXT_ONLY_MODELS = CLINE_PASS_MODELS.filter(id => !CLINE_PASS_IMAGE_MODELS.has(id)); +const CLINE_PASS_MODEL_INPUT_MODALITIES: Record = Object.fromEntries( + CLINE_PASS_MODELS.map(id => [id, CLINE_PASS_IMAGE_MODELS.has(id) ? ["text", "image"] : ["text"]]), +); export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ { @@ -889,6 +928,56 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ preserveReasoningContentModels: NEURALWATT_REASONING_HISTORY_MODELS, }, { id: "openrouter", label: "OpenRouter", adapter: "openai-chat", baseUrl: "https://openrouter.ai/api/v1", authKind: "key", featured: true, dashboardUrl: "https://openrouter.ai/keys", jawcodeBundle: "openrouter", models: ["anthropic/claude-sonnet-5", ...OPENROUTER_GPT56_MODELS], modelContextWindows: { "anthropic/claude-sonnet-5": 1_000_000, ...OPENROUTER_GPT56_CONTEXT_WINDOWS } }, + { + // Primary sources checked 2026-08-02: + // - docs.cline.bot/getting-started/clinepass publishes this exact catalog and explicitly + // authorizes using the full slugs through Cline's external API. + // - docs.cline.bot/api/chat-completions and /api/errors define the endpoint, reasoning delta, + // and choice-scoped mid-stream error contract. + // - Cline's official catalog source resolves per-model capabilities through OpenRouter data; + // the static context/modality snapshot below was cross-checked against that catalog. + // - cline.bot/tos identifies Cline Bot Inc. as the operator. Maintenance owner: @lidge-jun. + id: "cline-pass", + label: "ClinePass", + adapter: "openai-chat", + baseUrl: "https://api.cline.bot/api/v1", + authKind: "key", + dashboardUrl: "https://app.cline.bot", + defaultModel: "cline-pass/kimi-k3", + models: CLINE_PASS_MODELS, + modelContextWindows: CLINE_PASS_MODEL_CONTEXT_WINDOWS, + modelInputModalities: CLINE_PASS_MODEL_INPUT_MODALITIES, + noVisionModels: CLINE_PASS_TEXT_ONLY_MODELS, + // Only low and the `reasoning: { enabled, effort }` request shape have been accepted by a live + // ClinePass request. Neither wire detail is currently documented, so clamp higher Codex + // requests to the verified tier until the gateway documents or is live-probed more broadly. + reasoningEfforts: ["low"], + reasoningWireFormat: "gateway-object", + preserveCustomDestination: true, + note: "ClinePass subscription API. Uses a Cline API key and the full cline-pass/ upstream slug; quota is shared across the account's rolling 5-hour, weekly, and monthly limits.", + }, + // Cline API (usage-billing): OpenAI-compatible Chat Completions. Model IDs follow the + // OpenRouter-style `provider/model` convention. Live /models discovery is key-gated (401 + // without auth), so the static seed is the cold-start fallback. Evidence: docs.cline.bot/api/*. + { + id: "cline", + label: "Cline", + adapter: "openai-chat", + baseUrl: "https://api.cline.bot/api/v1", + authKind: "key", + dashboardUrl: "https://app.cline.bot", + liveModels: true, + defaultModel: "anthropic/claude-sonnet-4-6", + models: [ + "anthropic/claude-sonnet-4-6", + "openai/gpt-4o", + "google/gemini-2.5-pro", + "deepseek/deepseek-chat", + "minimax/minimax-m2.5", + ], + preserveCustomDestination: true, + note: "Cline usage-billing API: one key, 100+ models, OpenRouter-style ids. Promotional free models are IDE/CLI-only per Cline docs; minimax/minimax-m2.5 is the documented API free experimentation model.", + }, { // OrcaRouter: OpenAI-compatible adaptive router (api.orcarouter.ai). Model ids are // vendor-namespaced (`/`) and pass through to the upstream as-is. diff --git a/src/router.ts b/src/router.ts index 733fc7bb55..0991ffe7d7 100644 --- a/src/router.ts +++ b/src/router.ts @@ -282,6 +282,9 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig) // opt-in, while an explicit user `false` keeps overriding registry `true`. ...(provider.parallelToolCalls === undefined && registryEntry.parallelToolCalls !== undefined ? { parallelToolCalls: registryEntry.parallelToolCalls } : {}), ...(provider.promptCacheKey === undefined && registryEntry.promptCacheKey !== undefined ? { promptCacheKey: registryEntry.promptCacheKey } : {}), + ...(provider.reasoningWireFormat === undefined && registryEntry.reasoningWireFormat !== undefined + ? { reasoningWireFormat: registryEntry.reasoningWireFormat } + : {}), ...(provider.defaultMaxOutputTokens === undefined && registryEntry.defaultMaxOutputTokens !== undefined ? { defaultMaxOutputTokens: registryEntry.defaultMaxOutputTokens } : {}), diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index aedf4a932e..d6baef6cfa 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -509,6 +509,7 @@ export function safeConfigDTO(config: OcxConfig): unknown { "modelOpenRouterRouting", "reasoningEfforts", "modelReasoningEfforts", + "reasoningWireFormat", "noVisionModels", "noReasoningModels", "noTemperatureModels", diff --git a/src/server/request-log.ts b/src/server/request-log.ts index 845e3c4d55..b9982e64b4 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -15,6 +15,7 @@ import { isKnownAdmissionKind, isKnownInboundProtocol, isKnownUsageSurface, + isValidReasoningWireValue, readRecentUsageEntries, usageForFinalLog, usageStatusForFinalLog, @@ -58,7 +59,7 @@ export interface RequestLogContext { requestedEffort?: string; effectiveEffort?: string; reasoningWireField?: string; - reasoningWireValue?: string | number; + reasoningWireValue?: string | number | boolean; requestedServiceTier?: string; requestedSpeedLabel?: string; configuredServiceTier?: string; @@ -121,7 +122,7 @@ export interface RequestLogEntry { requestedEffort?: string; effectiveEffort?: string; reasoningWireField?: string; - reasoningWireValue?: string | number; + reasoningWireValue?: string | number | boolean; requestedServiceTier?: string; requestedSpeedLabel?: string; configuredServiceTier?: string; @@ -399,12 +400,11 @@ export function recordAdapterReasoning( const reasoning = raw as Record; if (typeof reasoning.effectiveEffort !== "string" || !reasoning.effectiveEffort || (reasoning.wireField !== "reasoning_effort" + && reasoning.wireField !== "reasoning.enabled" + && reasoning.wireField !== "reasoning.effort" && reasoning.wireField !== "thinking_budget" && reasoning.wireField !== "thinking.type") - || (!(typeof reasoning.wireValue === "string" && reasoning.wireValue) - && !(typeof reasoning.wireValue === "number" - && Number.isFinite(reasoning.wireValue) - && reasoning.wireValue >= 0))) { + || !isValidReasoningWireValue(reasoning.wireField, reasoning.wireValue)) { return; } diff --git a/src/types.ts b/src/types.ts index dd4eda3d94..bd5f0ae1ef 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1069,6 +1069,8 @@ export interface OcxProviderConfig { reasoningEffortMap?: Record; /** Model-specific mapping from Codex effort labels to upstream `reasoning_effort` values. */ modelReasoningEffortMap?: Record>; + /** OpenAI-compatible gateway reasoning wire shape. Default sends `reasoning_effort`. */ + reasoningWireFormat?: "gateway-object"; /** * Model ids that do NOT support a reasoning/thinking parameter. The openai-chat adapter drops * reasoning_effort for these even when Codex selects a reasoning level (e.g. xAI grok-build-0.1). diff --git a/src/usage/log.ts b/src/usage/log.ts index 3c588c758d..7e846bc821 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -35,7 +35,7 @@ export interface PersistedUsageAttempt { requestedEffort?: string; effectiveEffort?: string; reasoningWireField?: string; - reasoningWireValue?: string | number; + reasoningWireValue?: string | number | boolean; } export interface PersistedUsageEntry { @@ -59,7 +59,7 @@ export interface PersistedUsageEntry { /** Adapter-normalized tier and exact upstream parameter emitted for this request. */ effectiveEffort?: string; reasoningWireField?: string; - reasoningWireValue?: string | number; + reasoningWireValue?: string | number | boolean; requestedServiceTier?: string; requestedSpeedLabel?: string; configuredServiceTier?: string; @@ -268,14 +268,29 @@ function normalizeUsageAttempt(raw: unknown): PersistedUsageAttempt | null { ...(typeof attempt.reasoningWireField === "string" && attempt.reasoningWireField ? { reasoningWireField: capMetadataString(attempt.reasoningWireField) } : {}), - ...(typeof attempt.reasoningWireValue === "string" && attempt.reasoningWireValue - ? { reasoningWireValue: capMetadataString(attempt.reasoningWireValue) } - : isNonNegativeFiniteNumber(attempt.reasoningWireValue) - ? { reasoningWireValue: attempt.reasoningWireValue } - : {}), + ...(isValidReasoningWireValue(attempt.reasoningWireField, attempt.reasoningWireValue) + ? typeof attempt.reasoningWireValue === "string" + ? { reasoningWireValue: capMetadataString(attempt.reasoningWireValue) } + : { reasoningWireValue: attempt.reasoningWireValue } + : {}), }; } +/** + * Pairing rule for reasoning diagnostics, shared with the live request-log capture path: + * a non-empty string, a non-negative finite number, or a boolean only for + * `reasoning.enabled`. The field name itself is validated separately at capture time; + * persisted rows may carry legacy field names, so this checks only the value shape. + */ +export function isValidReasoningWireValue( + wireField: unknown, + wireValue: unknown, +): wireValue is string | number | boolean { + return (typeof wireValue === "string" && wireValue.length > 0) + || (typeof wireValue === "number" && Number.isFinite(wireValue) && wireValue >= 0) + || (wireField === "reasoning.enabled" && typeof wireValue === "boolean"); +} + function normalizedAttempts(raw: unknown): PersistedUsageAttempt[] { if (!Array.isArray(raw)) return []; return raw.map(normalizeUsageAttempt) @@ -323,11 +338,11 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry { ...(typeof entry.reasoningWireField === "string" && entry.reasoningWireField ? { reasoningWireField: capMetadataString(entry.reasoningWireField) } : {}), - ...(typeof entry.reasoningWireValue === "string" && entry.reasoningWireValue - ? { reasoningWireValue: capMetadataString(entry.reasoningWireValue) } - : isNonNegativeFiniteNumber(entry.reasoningWireValue) - ? { reasoningWireValue: entry.reasoningWireValue } - : {}), + ...(isValidReasoningWireValue(entry.reasoningWireField, entry.reasoningWireValue) + ? typeof entry.reasoningWireValue === "string" + ? { reasoningWireValue: capMetadataString(entry.reasoningWireValue) } + : { reasoningWireValue: entry.reasoningWireValue } + : {}), ...(typeof entry.requestedServiceTier === "string" && entry.requestedServiceTier ? { requestedServiceTier: capMetadataString(entry.requestedServiceTier) } : {}), diff --git a/tests/cline-pass-provider.test.ts b/tests/cline-pass-provider.test.ts new file mode 100644 index 0000000000..11d88ef41e --- /dev/null +++ b/tests/cline-pass-provider.test.ts @@ -0,0 +1,309 @@ +import { describe, expect, test } from "bun:test"; +import { createOpenAIChatAdapter } from "../src/adapters/openai-chat"; +import { createTranslatorBudget } from "../src/lib/translator-budget"; +import { KEY_LOGIN_PROVIDERS } from "../src/oauth/key-providers"; +import { providerConfigSeed } from "../src/providers/derive"; +import { PROVIDER_REGISTRY } from "../src/providers/registry"; +import { routeModel } from "../src/router"; +import type { OcxConfig, OcxParsedRequest } from "../src/types"; + +const OFFICIAL_CLINE_PASS_MODELS = [ + "cline-pass/glm-5.2", + "cline-pass/kimi-k3", + "cline-pass/kimi-k2.7-code", + "cline-pass/kimi-k2.6", + "cline-pass/deepseek-v4-pro", + "cline-pass/deepseek-v4-flash", + "cline-pass/mimo-v2.5", + "cline-pass/mimo-v2.5-pro", + "cline-pass/minimax-m3", + "cline-pass/qwen3.7-max", + "cline-pass/qwen3.7-plus", +]; + +function parsed(modelId: string, reasoning: string): OcxParsedRequest { + return { + modelId, + context: { messages: [{ role: "user", content: "hello", timestamp: 0 }] }, + stream: false, + options: { reasoning }, + }; +} + +function registryEntry() { + const entry = PROVIDER_REGISTRY.find(provider => provider.id === "cline-pass"); + if (!entry) throw new Error("missing ClinePass registry entry"); + return entry; +} + +async function parseNonStream(body: Record) { + const provider = providerConfigSeed(registryEntry()); + const adapter = createOpenAIChatAdapter({ ...provider, apiKey: "cline-test-key" }); + const budget = createTranslatorBudget(); + try { + return await adapter.parseResponse!(new Response(JSON.stringify(body)), budget); + } finally { + budget.dispose(); + } +} + +describe("ClinePass provider", () => { + test("registry exposes the official API-key transport and static subscription catalog", () => { + const entry = registryEntry(); + + expect(entry).toMatchObject({ + label: "ClinePass", + adapter: "openai-chat", + baseUrl: "https://api.cline.bot/api/v1", + authKind: "key", + dashboardUrl: "https://app.cline.bot", + defaultModel: "cline-pass/kimi-k3", + preserveCustomDestination: true, + reasoningWireFormat: "gateway-object", + }); + expect(entry?.models).toEqual(OFFICIAL_CLINE_PASS_MODELS); + expect(entry?.models).toContain(entry?.defaultModel); + expect(entry?.liveModels).toBeUndefined(); + expect(entry?.reasoningEfforts).toEqual(["low"]); + expect(entry?.modelMaxInputTokens).toBeUndefined(); + expect(entry?.noVisionModels).toEqual([ + "cline-pass/glm-5.2", + "cline-pass/deepseek-v4-pro", + "cline-pass/deepseek-v4-flash", + "cline-pass/mimo-v2.5-pro", + "cline-pass/qwen3.7-max", + ]); + expect(entry?.modelInputModalities?.["cline-pass/kimi-k3"]).toEqual(["text", "image"]); + expect(entry?.modelInputModalities?.["cline-pass/glm-5.2"]).toEqual(["text"]); + expect(KEY_LOGIN_PROVIDERS["cline-pass"]?.models).toEqual(OFFICIAL_CLINE_PASS_MODELS); + expect(KEY_LOGIN_PROVIDERS["cline-pass"]?.noVisionModels).toEqual(entry.noVisionModels); + const seed = providerConfigSeed(entry); + expect(seed).toMatchObject({ reasoningWireFormat: "gateway-object" }); + expect(seed).not.toHaveProperty("preserveCustomDestination"); + }); + + test("routing keeps the full upstream model slug and emits the Cline gateway reasoning object", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "cline-pass", + providers: { + "cline-pass": { + adapter: "openai-chat", + baseUrl: "https://api.cline.bot/api/v1", + apiKey: "cline-test-key", + authMode: "key", + }, + }, + }; + const route = routeModel(config, "cline-pass/cline-pass/kimi-k3"); + const request = createOpenAIChatAdapter(route.provider).buildRequest(parsed(route.modelId, "high")); + const body = JSON.parse(request.body) as Record; + + expect(route.modelId).toBe("cline-pass/kimi-k3"); + expect(route.provider).toMatchObject({ reasoningWireFormat: "gateway-object" }); + expect(body.model).toBe("cline-pass/kimi-k3"); + expect(body.reasoning).toEqual({ enabled: true, effort: "low" }); + expect(body).not.toHaveProperty("reasoning_effort"); + expect(request.reasoningLog).toEqual({ + effectiveEffort: "low", + wireField: "reasoning.effort", + wireValue: "low", + }); + }); + + test("explicit reasoning disable uses the gateway object instead of leaking reasoning_effort", () => { + const provider = providerConfigSeed(PROVIDER_REGISTRY.find(entry => entry.id === "cline-pass")!); + const request = createOpenAIChatAdapter({ ...provider, apiKey: "cline-test-key" }) + .buildRequest(parsed("cline-pass/glm-5.2", "none")); + const body = JSON.parse(request.body) as Record; + + expect(body.reasoning).toEqual({ enabled: false }); + expect(body).not.toHaveProperty("reasoning_effort"); + expect(request.reasoningLog).toEqual({ + effectiveEffort: "none", + wireField: "reasoning.enabled", + wireValue: false, + }); + }); + + test("noReasoningModels suppresses the gateway object for explicit disable", () => { + const provider = providerConfigSeed(registryEntry()); + const request = createOpenAIChatAdapter({ + ...provider, + apiKey: "cline-test-key", + noReasoningModels: ["cline-pass/glm-5.2"], + }).buildRequest(parsed("cline-pass/glm-5.2", "none")); + const body = JSON.parse(request.body) as Record; + + expect(body).not.toHaveProperty("reasoning"); + expect(body).not.toHaveProperty("reasoning_effort"); + expect(request.reasoningLog).toBeUndefined(); + }); + + test("routing preserves an explicitly configured ClinePass reasoning wire format", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "cline-pass", + providers: { + "cline-pass": { + adapter: "openai-chat", + baseUrl: "https://api.cline.bot/api/v1", + apiKey: "cline-test-key", + authMode: "key", + reasoningWireFormat: "gateway-object", + }, + }, + }; + + const route = routeModel(config, "cline-pass/cline-pass/kimi-k3"); + + expect(route.provider.reasoningWireFormat).toBe("gateway-object"); + }); + + test("same-named custom provider keeps its own destination and credential boundary", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "cline-pass", + providers: { + "cline-pass": { + adapter: "openai-chat", + baseUrl: "https://custom.example/v1", + apiKey: "custom-key", + authMode: "key", + }, + }, + }; + const route = routeModel(config, "cline-pass/custom-model"); + const request = createOpenAIChatAdapter(route.provider).buildRequest(parsed(route.modelId, "none")); + + expect(route.provider.baseUrl).toBe("https://custom.example/v1"); + expect(route.provider.apiKey).toBe("custom-key"); + expect(route.provider.reasoningWireFormat).toBeUndefined(); + expect(request.url).toBe("https://custom.example/v1/chat/completions"); + expect(request.headers.Authorization).toBe("Bearer custom-key"); + }); + + test("unwraps the Cline API non-stream response envelope", async () => { + const events = await parseNonStream({ + success: true, + error: null, + data: { + choices: [{ + finish_reason: "stop", + index: 0, + message: { content: "OK", reasoning: "thinking", role: "assistant" }, + }], + usage: { + completion_tokens: 3, + completion_tokens_details: { reasoning_tokens: 2 }, + prompt_tokens: 4, + total_tokens: 7, + }, + }, + }); + + expect(events).toContainEqual({ type: "reasoning_raw_delta", text: "thinking" }); + expect(events).toContainEqual({ type: "text_delta", text: "OK" }); + expect(events.at(-1)).toMatchObject({ + type: "done", + usage: { inputTokens: 4, outputTokens: 3, reasoningOutputTokens: 2 }, + }); + }); + + test("non-stream finish reasons preserve truncation, filtering, and choice-scoped errors", async () => { + const length = await parseNonStream({ + choices: [{ finish_reason: "length", message: { content: "partial" } }], + }); + expect(length.at(-1)).toEqual({ + type: "done", + usage: undefined, + stopReason: "max_tokens", + }); + + const filtered = await parseNonStream({ + success: true, + data: { choices: [{ finish_reason: "content_filter", message: { content: "partial" } }] }, + }); + expect(filtered.at(-1)).toEqual({ + type: "done", + usage: undefined, + stopReason: "content_filter", + }); + + const failed = await parseNonStream({ + choices: [{ + finish_reason: "error", + error: { code: "rate_limit", message: "ClinePass limit reached" }, + }], + }); + expect(failed).toEqual([{ type: "error", code: "rate_limit", message: "ClinePass limit reached" }]); + + const stringError = await parseNonStream({ + success: false, + error: "ClinePass quota exhausted", + }); + expect(stringError).toEqual([{ type: "error", message: "ClinePass quota exhausted" }]); + + const secretErrors = await Promise.all([ + parseNonStream({ error: "Denied Bearer cline-secret-token" }), + parseNonStream({ error: { message: "Rejected api_key=sk-cline-secret" } }), + ]); + expect(secretErrors).toEqual([ + [{ type: "error", message: "Denied Bearer [REDACTED]" }], + [{ type: "error", message: "Rejected api_key=[REDACTED]" }], + ]); + + const numericCode = await parseNonStream({ + error: { + code: 429, + message: "ClinePass limit reached", + metadata: { request_id: "req_cline_429" }, + }, + }); + expect(numericCode).toEqual([{ + type: "error", + code: "429", + status: 429, + message: "ClinePass limit reached (request ID: req_cline_429)", + }]); + + const secretShapedMetadata = await parseNonStream({ + error: { + code: 429, + message: "ClinePass limit reached", + metadata: { request_id: ["sk", "test", "secret-shaped-request-id"].join("-") }, + }, + }); + expect(secretShapedMetadata).toEqual([{ + type: "error", + code: "429", + status: 429, + message: "ClinePass limit reached", + }]); + }); + + test("rejects contradictory or malformed failure envelopes", async () => { + const contradictory = await parseNonStream({ + success: false, + data: { + choices: [{ finish_reason: "stop", message: { content: "must not pass" } }], + usage: { prompt_tokens: 7, completion_tokens: 2 }, + }, + }); + expect(contradictory).toEqual([{ + type: "error", + message: "upstream reported failure without an error payload", + usage: { inputTokens: 7, outputTokens: 2 }, + }]); + + const nestedError = await parseNonStream({ + success: false, + data: { error: { code: "server_error", message: "nested failure" } }, + }); + expect(nestedError).toEqual([{ + type: "error", + code: "server_error", + message: "nested failure", + }]); + }); +}); diff --git a/tests/cline-provider.test.ts b/tests/cline-provider.test.ts new file mode 100644 index 0000000000..3e912f98e1 --- /dev/null +++ b/tests/cline-provider.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test"; +import { PROVIDER_REGISTRY } from "../src/providers/registry"; +import { providerConfigSeed, deriveKeyLoginMap, deriveProviderPresets } from "../src/providers/derive"; +import { createOpenAIChatAdapter } from "../src/adapters/openai-chat"; +import type { OcxParsedRequest, OcxProviderConfig } from "../src/types"; + +function minimalRequest(model: string): OcxParsedRequest { + return { + modelId: model, + stream: false, + context: { messages: [{ role: "user", content: "hi" }], tools: [] }, + options: {}, + }; +} + +describe("cline provider", () => { + const entry = PROVIDER_REGISTRY.find(e => e.id === "cline"); + + test("registry entry exists with correct shape", () => { + expect(entry).toBeDefined(); + expect(entry?.adapter).toBe("openai-chat"); + expect(entry?.baseUrl).toBe("https://api.cline.bot/api/v1"); + expect(entry?.authKind).toBe("key"); + expect(entry?.dashboardUrl).toBe("https://app.cline.bot"); + expect(entry?.liveModels).toBe(true); + expect(entry?.preserveCustomDestination).toBe(true); + expect(entry?.defaultModel).toBe("anthropic/claude-sonnet-4-6"); + expect(entry?.models).toContain("anthropic/claude-sonnet-4-6"); + expect(entry?.models).toContain("minimax/minimax-m2.5"); + expect(entry?.note).toMatch(/usage-billing/i); + }); + + test("is included in the key-login map", () => { + const keyMap = deriveKeyLoginMap(); + expect(keyMap.cline).toMatchObject({ + adapter: "openai-chat", + baseUrl: "https://api.cline.bot/api/v1", + dashboardUrl: "https://app.cline.bot", + }); + }); + + test("appears in the GUI preset picker", () => { + const presets = deriveProviderPresets(); + const preset = presets.find((p: { id: string }) => p.id === "cline"); + expect(preset).toBeDefined(); + expect(preset?.baseUrl).toBe("https://api.cline.bot/api/v1"); + expect(preset?.defaultModel).toBe("anthropic/claude-sonnet-4-6"); + expect(preset?.note).toBeDefined(); + }); + + test("adapter targets the Cline chat completions endpoint with bearer auth", () => { + const provider: OcxProviderConfig = { + ...providerConfigSeed(entry!), + apiKey: "ck-test", + }; + const req = createOpenAIChatAdapter(provider).buildRequest(minimalRequest("anthropic/claude-sonnet-4-6")); + const headers = req.headers as Record; + expect(req.url).toBe("https://api.cline.bot/api/v1/chat/completions"); + expect(headers["Authorization"]).toBe("Bearer ck-test"); + }); +}); + +describe("cline-pass provider integration", () => { + test("sits alongside cline on the same endpoint with distinct curated models", () => { + const pass = PROVIDER_REGISTRY.find(e => e.id === "cline-pass"); + const cline = PROVIDER_REGISTRY.find(e => e.id === "cline"); + expect(pass?.baseUrl).toBe(cline?.baseUrl); + expect(pass?.models).not.toEqual(cline?.models); + expect(pass?.models?.every(id => id.startsWith("cline-pass/"))).toBe(true); + }); +}); diff --git a/tests/openai-chat-eof.test.ts b/tests/openai-chat-eof.test.ts index 06fdba53a2..4031b1e0a7 100644 --- a/tests/openai-chat-eof.test.ts +++ b/tests/openai-chat-eof.test.ts @@ -86,6 +86,56 @@ describe("openai-chat stream EOF fail-closed", () => { expect(events.find(e => e.type === "error")).toMatchObject({ message: "Rate limit reached for model" }); }); + test("choice-scoped finish_reason error yields a terminal error", async () => { + const response = new Response([ + 'data: {"choices":[{"delta":{"content":"partial"}}]}\n\n', + 'data: {"choices":[{"finish_reason":"error","error":{"code":"rate_limit","message":"ClinePass limit reached"}}]}\n\n', + ].join("")); + const events = await collect(createOpenAIChatAdapter(provider).parseStream(response)); + + expect(events.at(-1)).toMatchObject({ + type: "error", + code: "rate_limit", + message: "ClinePass limit reached", + }); + expect(events.some(e => e.type === "done")).toBe(false); + }); + + test("terminal errors discard a pending tool call instead of completing it", async () => { + for (const terminal of [ + 'data: {"choices":[{"finish_reason":"error","error":{"code":"server_error","message":"upstream failed"}}]}\n\n', + 'data: {"error":{"code":"server_error","message":"upstream failed"}}\n\n', + ]) { + const body = [ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"shell","arguments":"{\\"cmd\\":\\"l"}}]}}]}\n\n', + terminal, + ].join(""); + const adapter = createOpenAIChatAdapter(provider); + const events = await collect(adapter.parseStream(new Response(body))); + + expect(events).toEqual([{ type: "error", code: "server_error", message: "upstream failed" }]); + + const bridged = await new Response(bridgeToResponsesSSE( + adapter.parseStream(new Response(body)), + "openai-chat/test-model", + )).text(); + expect(bridged).toContain("event: response.failed"); + expect(bridged).not.toContain("response.function_call_arguments.done"); + expect(bridged).not.toContain('"status":"completed"'); + } + }); + + test("Cline-compatible delta.reasoning is preserved as reasoning output", async () => { + const response = new Response([ + 'data: {"choices":[{"delta":{"reasoning":"considering"}}]}\n\n', + 'data: {"choices":[{"delta":{"content":"answer"},"finish_reason":"stop"}]}\n\n', + ].join("")); + const events = await collect(createOpenAIChatAdapter(provider).parseStream(response)); + + expect(events).toContainEqual({ type: "reasoning_raw_delta", text: "considering" }); + expect(events.at(-1)?.type).toBe("done"); + }); + test("finish-only chunk with no delta (provider omits [DONE]) is accepted as done", async () => { const response = new Response([ 'data: {"choices":[{"delta":{"content":"hi"}}]}\n\n', diff --git a/tests/openai-chat-hardening.test.ts b/tests/openai-chat-hardening.test.ts index 1f5846ace9..3e6f726c5e 100644 --- a/tests/openai-chat-hardening.test.ts +++ b/tests/openai-chat-hardening.test.ts @@ -62,6 +62,14 @@ describe("openai-chat non-stream response hardening", () => { }]); }); + test("treats falsey upstream error payloads as errors", async () => { + const adapter = createOpenAIChatAdapter(provider()); + for (const error of [0, ""]) { + const events = await adapter.parseResponse!(new Response(JSON.stringify({ error }))); + expect(events).toEqual([{ type: "error", message: "upstream error" }]); + } + }); + test("rejects an empty choices array", async () => { const adapter = createOpenAIChatAdapter(provider()); const events = await adapter.parseResponse!(new Response(JSON.stringify({ choices: [] }))); @@ -69,15 +77,76 @@ describe("openai-chat non-stream response hardening", () => { expect(events).toEqual([{ type: "error", message: "upstream response contained no choices" }]); }); + test("preserves usage when an upstream response has no choices", async () => { + const adapter = createOpenAIChatAdapter(provider()); + const events = await adapter.parseResponse!(new Response(JSON.stringify({ + choices: [], + usage: { prompt_tokens: 7, completion_tokens: 2 }, + }))); + + expect(events).toEqual([{ + type: "error", + message: "upstream response contained no choices", + usage: { inputTokens: 7, outputTokens: 2 }, + }]); + }); + + test("keeps ordinary and data-wrapped responses compatible for non-Cline providers", async () => { + const adapter = createOpenAIChatAdapter(provider()); + for (const body of [ + { choices: [{ message: { content: "plain" } }] }, + { success: true, data: { choices: [{ message: { content: "wrapped" } }] } }, + ]) { + const events = await adapter.parseResponse!(new Response(JSON.stringify(body))); + expect(events.find(event => event.type === "error")).toBeUndefined(); + expect(events.at(-1)?.type).toBe("done"); + } + }); + test("rejects a choice with no message", async () => { const adapter = createOpenAIChatAdapter(provider()); const events = await adapter.parseResponse!(new Response(JSON.stringify({ choices: [{}] }))); expect(events).toEqual([{ type: "error", message: "upstream response contained no choices" }]); }); + + test("rejects a null choice without throwing", async () => { + const adapter = createOpenAIChatAdapter(provider()); + const events = await adapter.parseResponse!(new Response(JSON.stringify({ choices: [null] }))); + + expect(events).toEqual([{ type: "error", message: "upstream response contained invalid choices" }]); + }); }); describe("openai-chat stream response hardening", () => { + test("treats falsey upstream error payloads as terminal errors", async () => { + const adapter = createOpenAIChatAdapter(provider()); + for (const error of [0, ""]) { + const response = new Response([ + `data: ${JSON.stringify({ error })}\n\n`, + "data: [DONE]\n\n", + ].join("")); + + const events = await collect(adapter.parseStream(response)); + expect(events).toEqual([{ type: "error", message: "upstream error" }]); + } + }); + + test("rejects a non-array choices payload without throwing", async () => { + const adapter = createOpenAIChatAdapter(provider()); + const response = new Response([ + 'data: {"choices":{},"usage":{"prompt_tokens":7,"completion_tokens":2}}\n\n', + "data: [DONE]\n\n", + ].join("")); + + const events = await collect(adapter.parseStream(response)); + expect(events).toEqual([{ + type: "error", + message: "upstream response contained invalid choices", + usage: { inputTokens: 7, outputTokens: 2 }, + }]); + }); + test("malformed SSE data is terminal even when followed by [DONE]", async () => { const adapter = createOpenAIChatAdapter(provider()); const response = new Response([ diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index a4aaefbd30..e9e80cf330 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -30,7 +30,7 @@ function nativeTemplate(): Record { } const EXPECTED_KEY_PROVIDER_IDS = [ - "anthropic-apikey", "openai-apikey", "umans", "opencode-go", "neuralwatt", "openrouter", "orcarouter", "bizrouter", "groq", "google", "google-vertex", "azure-openai", + "anthropic-apikey", "openai-apikey", "umans", "opencode-go", "neuralwatt", "openrouter", "cline-pass", "cline", "orcarouter", "bizrouter", "groq", "google", "google-vertex", "azure-openai", "deepseek", "cerebras", "deepinfra", "hyperbolic", "baseten", "together", "fireworks", "firepass", "moonshot", "huggingface", "nvidia", "venice", "zai", "zhipu-bigmodel", "nanogpt", "synthetic", "siliconflow", "qwen-cloud", "tencent-coding-plan", "volcengine", "volcengine-coding-plan", "volcengine-agent-plan", "qianfan", "alibaba", "alibaba-token-plan", "alibaba-token-plan-intl", "parallel", "zenmux", "litellm", "ollama-cloud", "mistral", diff --git a/tests/request-log.test.ts b/tests/request-log.test.ts index 562cb004d9..50042794d7 100644 --- a/tests/request-log.test.ts +++ b/tests/request-log.test.ts @@ -152,6 +152,23 @@ describe("request log metadata", () => { } }); + test("records a gateway reasoning disable as a boolean", () => { + const logCtx: RequestLogContext = { model: "m", provider: "cline-pass" }; + recordAdapterReasoning(logCtx, { + url: "https://api.cline.bot/api/v1/chat/completions", + method: "POST", + headers: {}, + body: "{}", + reasoningLog: { + effectiveEffort: "none", + wireField: "reasoning.enabled", + wireValue: false, + }, + }); + + expect(logCtx.reasoningWireValue).toBe(false); + }); + test("recordFirstOutput is one-shot for request and active attempt (WP4 TTFT)", () => { const attempt = beginRequestAttempt(1, "a", "m1", "openai-chat"); const logCtx: RequestLogContext = { diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index d67f9ea72f..74923bc636 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -350,6 +350,7 @@ describe("server local API auth", () => { apiKeyPool: [{ id: "pool-id", key: "pool-secret", label: "private-pool-label" }], modelMaxInputTokens: { "gpt-test": 1000 }, codexAccountMode: "pool", + reasoningWireFormat: "gateway-object", virtualModels: { "gpt-test-pro": { wireModelId: "gpt-test", reasoningMode: "pro" } }, codexAuthContext: { accessToken: "runtime-token" }, selectedForwardHeaders: { authorization: "Bearer runtime-token" }, @@ -376,6 +377,7 @@ describe("server local API auth", () => { hasApiKey: true, hasHeaders: true, codexAccountMode: "pool", + reasoningWireFormat: "gateway-object", }); expect(dto.providers.openai).not.toHaveProperty("apiKey"); expect(dto.providers.openai).not.toHaveProperty("headers"); diff --git a/tests/usage-log.test.ts b/tests/usage-log.test.ts index f9a5b103b9..499c2e4907 100644 --- a/tests/usage-log.test.ts +++ b/tests/usage-log.test.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { appendUsageEntry, currentUsageLogRevision, + normalizeUsageEntryForTest, readRecentUsageEntries, readUsageEntries, readUsageEntriesForManagement, @@ -311,6 +312,54 @@ describe("usage log", () => { expect(attempt).not.toHaveProperty("reasoningWireValue"); }); + test("keeps boolean reasoning values only for reasoning.enabled", () => { + const base = { + requestId: "ocx-boolean-reasoning", + timestamp: 1, + provider: "combo", + model: "combo/free", + status: 200, + durationMs: 4, + usageStatus: "unreported", + attempts: [{ + ordinal: 1, + provider: "a", + model: "m1", + adapter: "openai-chat", + status: 200, + durationMs: 3, + sendCount: 1, + recoveryKinds: [], + usageStatus: "unreported", + }], + } as const; + const mismatched = normalizeUsageEntryForTest({ + ...base, + reasoningWireField: "reasoning_effort", + reasoningWireValue: true, + attempts: [{ + ...base.attempts[0], + reasoningWireField: "reasoning_effort", + reasoningWireValue: true, + }], + }); + const valid = normalizeUsageEntryForTest({ + ...base, + reasoningWireField: "reasoning.enabled", + reasoningWireValue: false, + attempts: [{ + ...base.attempts[0], + reasoningWireField: "reasoning.enabled", + reasoningWireValue: false, + }], + }); + + expect(mismatched).not.toHaveProperty("reasoningWireValue"); + expect(mismatched.attempts?.[0]).not.toHaveProperty("reasoningWireValue"); + expect(valid.reasoningWireValue).toBe(false); + expect(valid.attempts?.[0]?.reasoningWireValue).toBe(false); + }); + test("drops only malformed persisted attempts while preserving valid siblings", () => { const valid = (ordinal: number) => ({ ordinal,