mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-26 14:35:22 +08:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 878c26f31e | |||
| 6c9d444c8a | |||
| 34cbb375f0 | |||
| cd161f4401 | |||
| 3cf84ca362 | |||
| 15d5dbe065 | |||
| a377d79303 | |||
| 846fbdd1c0 | |||
| 3a9fb13a0b |
@@ -0,0 +1,141 @@
|
|||||||
|
# Using GPT Models in Claude Code with CC Switch
|
||||||
|
|
||||||
|
> Applies to CC Switch 3.17.0 and later. (Both integration methods in this guide existed in earlier versions, but the gpt-5.6 preset and the client-identity fix landed in 3.17.0; on older versions, requesting new models like `gpt-5.6-luna` falsely returns 404.) This guide is compiled from the repository's documentation and code, and all sample data has been de-identified.
|
||||||
|
|
||||||
|
## Why local routing is needed
|
||||||
|
|
||||||
|
Claude Code targets the Anthropic Messages protocol — that is, `/v1/messages` — whereas the upstreams for Codex-family models, whether the OpenAI Responses API exposed by a third-party gateway or the Codex service behind a ChatGPT subscription, all speak the Responses protocol. The two protocols use completely different request bodies, streaming events, and response structures, so putting such an endpoint directly into Claude Code's config leaves the upstream receiving a `/v1/messages` request it doesn't recognize — which can only fail.
|
||||||
|
|
||||||
|
CC Switch's approach is to keep Claude Code always connected to the local route and still sending requests as Anthropic Messages; once the route detects that the active provider is Responses-format, it converts the request into Responses for the upstream, then converts the response back into the Messages shape it returns to Claude Code — tool calls, images, PDFs, and thinking configuration are all within the conversion scope.
|
||||||
|
|
||||||
|
This guide covers both integration methods:
|
||||||
|
|
||||||
|
- **Method 1 (API Key)**: you have a gateway endpoint and key compatible with the OpenAI Responses API, and you want to run the GPT-family models behind it inside Claude Code.
|
||||||
|
- **Method 2 (ChatGPT subscription)**: you have a ChatGPT Plus/Pro subscription and use its quota directly by signing in through Codex OAuth — no API key needed at any point.
|
||||||
|
|
||||||
|
The chain has four main steps:
|
||||||
|
|
||||||
|
1. When Claude Code is taken over, `ANTHROPIC_BASE_URL` in `~/.claude/settings.json` is written as the local route address (default `http://127.0.0.1:15721`), the auth entry keeps only a placeholder, and real credentials never enter the live config.
|
||||||
|
2. The provider's `API Format` is set to OpenAI Responses, telling the route that the real upstream speaks the Responses protocol.
|
||||||
|
3. The route converts the `/v1/messages` request into a Responses request body for the upstream; Method 2 additionally carries the OAuth token and the official client identity to reach ChatGPT's Codex service.
|
||||||
|
4. After the upstream responds, the route converts the Responses JSON/SSE back into the Messages shape Claude Code understands.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- CC Switch installed and able to start (3.17.0 or later; see the version note at the top for why).
|
||||||
|
- Claude Code installed and run at least once.
|
||||||
|
- For Method 1: a service endpoint compatible with the OpenAI Responses API and its API Key; follow the gateway's documentation for the endpoint and model names. Note it's the **Responses API**, not Chat Completions; a gateway that only offers the Chat format still works — see the `API Format` note in Step 1.
|
||||||
|
- For Method 2: a ChatGPT Plus/Pro subscription account.
|
||||||
|
|
||||||
|
## Step 1: Add a provider
|
||||||
|
|
||||||
|
### Method 1: Third-party Responses gateway (API Key)
|
||||||
|
|
||||||
|
Open CC Switch, switch to the top-level `Claude Code` tab, click the plus button in the upper-right corner to add a provider, keep the default `Custom Configuration`, then fill in:
|
||||||
|
|
||||||
|
- **Provider Name**: anything you like, e.g. `GPT Gateway`.
|
||||||
|
- **API Key**: your gateway key. The real key is stored only in CC Switch and injected by the local route when forwarding.
|
||||||
|
- **API Endpoint**: just the gateway's service root, e.g. `https://gpt-gateway.example.com`, without a trailing slash — the route sends requests to the gateway's Responses endpoint (`/v1/responses`) automatically. When the gateway path is unusual, turn on the `Full URL` toggle next to it and paste the complete endpoint verbatim.
|
||||||
|
|
||||||
|
Then expand `Advanced Options`:
|
||||||
|
|
||||||
|
- **API Format**: change from the default `Anthropic Messages (Native)` to **`OpenAI Responses API (Requires routing)`**. If the gateway only offers the Chat Completions protocol, choose `OpenAI Chat Completions (Requires routing)` here instead; every other step is identical.
|
||||||
|
- **Auth Field**: keep the default `ANTHROPIC_AUTH_TOKEN (Default)`; the route sends `Authorization: Bearer <key>` to the upstream — exactly the auth header an OpenAI-compatible gateway expects. Unless the gateway's documentation explicitly requires `x-api-key`, don't switch to `ANTHROPIC_API_KEY`; the wrong choice typically shows up as 401/403.
|
||||||
|
- **Model Mapping**: map Claude Code's model roles to the real models the gateway recognizes. **At minimum, fill in the `Default fallback model`** (e.g. `gpt-5.6`, per the gateway's documentation) — if left empty, unmatched requests pass through to the upstream under the original Claude model name and error out, while roles you haven't configured individually fall back to it. For finer control, specify per row: put your main model in `Sonnet`/`Opus`, and a cheap, fast model in `Haiku` (Claude Code's background sub-tasks use this tier). The `Display name` only affects what shows in the `/model` menu; leave it empty to show the real model name directly.
|
||||||
|
- **Declare 1M**: the `1M` checkbox on each model-mapping row declares to Claude Code that the tier supports a 1M context. Check it only when the gateway truly serves that model with a window of one million tokens or more (e.g. a gateway offering gpt-5.6 at its API spec); otherwise long conversations will error out at the upstream's real ceiling.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
After saving, a `Needs Routing` marker appears on the card — providers like this only work while local routing is running.
|
||||||
|
|
||||||
|
### Method 2: ChatGPT subscription (Codex OAuth)
|
||||||
|
|
||||||
|
Again on the `Claude Code` tab, click the plus button and pick the **`Codex`** preset with the OpenAI icon from the preset list — it appearing under the Claude Code tab is not a mistake; this preset is built precisely for "using a ChatGPT subscription inside Claude Code":
|
||||||
|
|
||||||
|
- **No API Key and no address needed** — requests always go to ChatGPT's Codex service, so the address field in the form needs no changes.
|
||||||
|
- Click **`Sign in with ChatGPT`**. This is a device-code flow: CC Switch opens the browser automatically and copies the verification code to the clipboard; paste the code on the browser page to complete authorization, while the app shows `Waiting for authorization...`.
|
||||||
|
- After a successful sign-in, `Auth status` shows the signed-in account (email). Multiple accounts are supported: you can `Add another account`, `Set as default`, or pin a specific account to this provider; day-to-day management can also go through `Settings` → `OAuth Authentication Center`.
|
||||||
|
- **FAST mode**: an optional toggle; when on, requests carry `service_tier="priority"` for lower latency but consume ChatGPT quota at a higher rate. Keep it off by default.
|
||||||
|
- The model tiers are pre-filled: `Sonnet`/`Opus` map to `gpt-5.6`, and `Haiku` maps to `gpt-5.6-luna` (used for background sub-tasks — faster and lighter on quota).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The login credentials are stored in `~/.cc-switch/codex_oauth_auth.json` (not `~/.codex/`), independent of the Codex CLI's own login; the token refreshes automatically before it expires.
|
||||||
|
|
||||||
|
## Step 2: Enable local routing and take over Claude Code
|
||||||
|
|
||||||
|
Go to the `Routing` page in Settings, expand `Local Routing`, and complete two toggles:
|
||||||
|
|
||||||
|
1. Turn on the `Routing Master Switch` to start the local service (the first time you enable it, an explanatory confirmation dialog appears). The default address is `127.0.0.1:15721`.
|
||||||
|
2. Turn on `Claude Code` under `Routing Enabled`. If you only want Claude Code to use routing, leave the other apps off.
|
||||||
|
|
||||||
|
After takeover, CC Switch points Claude Code's live config at the local route, with only a placeholder in the auth entry; both Method 1's gateway key and Method 2's OAuth token are injected by the local route on forward.
|
||||||
|
|
||||||
|
> **Note**: the live config is read when the Claude Code process starts. After you first enable takeover (or disable it to restore a direct connection), if Claude Code is already running, open a new terminal session. Afterward, switching providers in routing mode is a hot switch and needs no further restart.
|
||||||
|
|
||||||
|
## Step 3: Switch providers and verify
|
||||||
|
|
||||||
|
Return to the Claude Code provider list and click `Enable` on the target provider. If routing isn't running, CC Switch shows "This provider uses OpenAI Responses API format, requires the routing service to work properly. Start routing first." — this notice doesn't block the switch, but with routing off the request is bound to fail, so go back to Step 2 and turn it on.
|
||||||
|
|
||||||
|
Inside Claude Code you can verify step by step:
|
||||||
|
|
||||||
|
- Open a new session and use `/model` to view the model menu: each tier shows the display name from the model mapping (by default, Method 2 shows `gpt-5.6` and `gpt-5.6-luna`). A few spots in the UI may still show Claude-family model names — those are the internal role aliases the routing takeover uses; this is normal, and the `/model` menu and usage dashboard are authoritative.
|
||||||
|
- Send a small question and watch the `Current Provider` on the Settings → Routing page change to your provider and `Total Requests` start to climb.
|
||||||
|
- In the usage dashboard, these requests show under the upstream's real models: a tier mapped to `gpt-5.6` resolves to the Sol tier and displays as `GPT-5.6 Sol`, while `gpt-5.6-luna` displays as `GPT-5.6 Luna`; you can filter by provider to reconcile token usage.
|
||||||
|
- The Method 2 provider card also shows subscription quota: utilization and reset countdowns for the 5-hour and 7-day windows, drawn from the ChatGPT account itself and shared with the official Codex client.
|
||||||
|
|
||||||
|
## Capabilities and known limitations
|
||||||
|
|
||||||
|
- **Prompt caching is automatic**: the route injects a stable `prompt_cache_key` per session, and together with OpenAI's automatic prefix caching, long conversations don't resend everything at full price each turn — no configuration needed.
|
||||||
|
- **Thinking is mapped to reasoning effort**: Claude Code's thinking toggle and thinking level are mapped to GPT's `reasoning.effort` (low/medium/high); GPT's reasoning content round-trips across turns intact in encrypted form, so multi-turn reasoning coherence is unaffected by the conversion. Method 2 also accesses in stateless mode (`store:false`), leaving no conversation stored on OpenAI's servers.
|
||||||
|
- **Tools and multimodal are fully converted**: multi-turn tool calls, image inputs, and PDF inputs are all fully converted.
|
||||||
|
- **Context is managed against a 200K window**: Claude Code auto-compacts routed providers against a default 200K window. When the upstream's real window is larger (e.g. gpt-5.6 on ChatGPT's Codex service is 372K), anything beyond 200K currently goes unused — compaction triggers early, which is conservative but safe. The only switch to push past 200K today is the `1M` checkbox in the model mapping (a strict 1M declaration), for use only with Method 1 and only when the upstream truly serves the model at 1M or more; Method 2's upstream ceiling is 372K, short of 1M, so checking it would instead make long conversations error out at the upstream's real ceiling — keep it at the default.
|
||||||
|
- **Output ceiling**: for Method 2, the output ceiling is controlled by the ChatGPT server (the `max_tokens` in Claude Code's request is not sent downstream); for Method 1, Claude Code's `max_tokens` is passed through as-is — no configuration needed.
|
||||||
|
- **Web search is unavailable**: Claude Code's WebSearch relies on Anthropic's servers to run, which the GPT upstream can't take on, so for tasks involving web search, switch back to a Claude-family provider. Locally executed WebFetch is unaffected.
|
||||||
|
- **Dashboard dollar amounts are for reference**: token counts are accurate, but the dollar figures are estimates converted at public API prices — Method 2's subscription traffic is estimated at GPT-5.6's public price, and Method 1's third-party gateways bill at their own rates, so both may differ from what you're actually charged and serve only for comparison. For Method 2, treat the window utilization on the provider card as authoritative for quota consumption.
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
**The upstream returns 401 or 403 (Method 1)**
|
||||||
|
|
||||||
|
First confirm the `Auth Field` in Advanced Options is the default `ANTHROPIC_AUTH_TOKEN (Default)` — switching to `ANTHROPIC_API_KEY` sends `x-api-key`, which the vast majority of OpenAI-compatible gateways don't accept. Then confirm the key itself is valid and has balance.
|
||||||
|
|
||||||
|
**Requesting new models like `gpt-5.6-luna` returns 404 Model not found (Method 2)**
|
||||||
|
|
||||||
|
Upgrade to CC Switch 3.17.0 or later. On older versions the client identity wasn't aligned with the official Codex client, so the ChatGPT server resolves new models to a nonexistent engine.
|
||||||
|
|
||||||
|
**The switch didn't take effect, or the `/model` menu still shows old names**
|
||||||
|
|
||||||
|
Both the model menu and the route address are read when Claude Code starts: after first enabling takeover you must open a new terminal session; switching between providers is a hot switch, but the display names in the menu only refresh in a new session.
|
||||||
|
|
||||||
|
**Claude Code reports "Codex OAuth authentication failed", or the card shows "Session expired" (Method 2)**
|
||||||
|
|
||||||
|
The login credentials have expired. Go back to the provider form or `Settings` → `OAuth Authentication Center` and run through `Sign in with ChatGPT` again; no command-line steps are needed.
|
||||||
|
|
||||||
|
**The conversation auto-compacts partway through**
|
||||||
|
|
||||||
|
See "Capabilities and known limitations": routed providers are managed against a 200K window, so the compaction threshold comes early — this is expected behavior.
|
||||||
|
|
||||||
|
**Restoring the official Claude setup**
|
||||||
|
|
||||||
|
Switch back to an official provider, or turn off the `Claude Code` routing toggle on the Routing page — CC Switch restores the pre-takeover live config, and the official login credentials are unaffected throughout. After restoring, you'll again need to open a new terminal session.
|
||||||
|
|
||||||
|
**Should you enable FAST mode? (Method 2)**
|
||||||
|
|
||||||
|
Leaving it off is fine. Turn it on only if you're especially latency-sensitive and willing to accept faster quota consumption; if the ChatGPT server rejects the parameter, turning the toggle off restores things.
|
||||||
|
|
||||||
|
## Compliance note
|
||||||
|
|
||||||
|
Method 2 uses ChatGPT subscription quota outside the official Codex client, and this isn't a gray-area hack: Thibault Sottiaux (@thsottiaux), the OpenAI Codex lead, has publicly demonstrated and encouraged pointing Claude Code (the "orange crab", as he jokingly calls it) at GPT-5.6 Sol — using exactly a "local proxy + model alias" approach, the same category as Method 2 here. As the lead of the Codex product line, his active encouragement to use their own model inside a competitor's client shows that running GPT-family models in Claude Code on a subscription is a use the vendor welcomes and encourages people to try.
|
||||||
|
|
||||||
|
Two practical reminders are still worth noting: first, this traffic is counted against the same subscription quota as the official Codex client, so heavy use hits the cap sooner; second, CC Switch's authentication center keeps a compliance notice out of caution ("Use your other subscriptions in Claude Code — please be mindful of compliance risks."), and whether this fits the terms that apply to your account is for you to check. When using a third-party gateway in Method 1, separately read the target gateway's terms on billing, compliance, and data retention.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [CC Switch User Manual: Add a Provider (incl. Codex OAuth reverse proxy and API formats)](../user-manual/en/2-providers/2.1-add.md)
|
||||||
|
- [CC Switch User Manual: Proxy Service](../user-manual/en/4-proxy/4.1-service.md)
|
||||||
|
- [CC Switch User Manual: App Routing](../user-manual/en/4-proxy/4.2-routing.md)
|
||||||
|
- [CC Switch v3.17.0 Release Notes](../release-notes/v3.17.0-en.md)
|
||||||
|
- Reverse guide: [Using Claude Models in Codex](./codex-claude-routing-guide-en.md)
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# CC Switch を使って Claude Code で GPT モデルを利用する
|
||||||
|
|
||||||
|
> 対象バージョン: CC Switch 3.17.0 以降(本記事の 2 つの接続方法自体はより古いバージョンにもありますが、gpt-5.6 プリセットとクライアントアイデンティティの修正は 3.17.0 から導入されたため、古いバージョンで `gpt-5.6-luna` のような新しいモデルをリクエストすると 404 が誤って返ることがあります)。本記事はリポジトリ内のドキュメントとコードをもとに整理し、サンプルデータはすべて匿名化しています。
|
||||||
|
|
||||||
|
## ローカルルーティングが必要な理由
|
||||||
|
|
||||||
|
Claude Code が前提としているのは Anthropic Messages プロトコル、つまり `/v1/messages` です。一方で Codex 系モデルの上流——サードパーティゲートウェイが公開している OpenAI Responses API であれ、ChatGPT サブスクリプションの背後にある Codex サービスであれ——が話すのはすべて Responses プロトコルです。この 2 つのプロトコルは、リクエストボディ、ストリーミングイベント、レスポンス構造がまったく異なります。この種のアドレスをそのまま Claude Code の設定に入れても、上流が受け取るのは自分の知らない `/v1/messages` リクエストであり、結果は失敗にしかなりません。
|
||||||
|
|
||||||
|
CC Switch では、Claude Code が常にローカルルートへ接続し、Anthropic Messages のままリクエストを送るようにします。ルートは現在のプロバイダーが Responses 形式だと判定すると、リクエストを Responses に変換して上流へ送り、最後にレスポンスを Messages 形式へ戻して Claude Code に返します——ツール呼び出し、画像、PDF、思考設定もすべて変換の対象です。
|
||||||
|
|
||||||
|
対応する 2 つの接続方法を、本記事はどちらもカバーします:
|
||||||
|
|
||||||
|
- **方式 1(API Key)**: OpenAI Responses API 互換のゲートウェイエンドポイントと Key を持っていて、その背後の GPT 系モデルを Claude Code で動かしたい場合。
|
||||||
|
- **方式 2(ChatGPT サブスクリプション)**: ChatGPT Plus/Pro のサブスクリプションを持っていて、Codex OAuth ログインでサブスクリプション枠を直接使いたい場合。全工程で API Key は不要です。
|
||||||
|
|
||||||
|
この経路は主に 4 つのステップに分かれます:
|
||||||
|
|
||||||
|
1. Claude Code を引き継ぐと、`~/.claude/settings.json` の `ANTHROPIC_BASE_URL` がローカルルートのアドレス(デフォルトは `http://127.0.0.1:15721`)に書き換えられ、認証項目にはプレースホルダーだけが残り、実際の認証情報は live 設定には入りません。
|
||||||
|
2. プロバイダーの `API フォーマット` を OpenAI Responses に設定し、実際の上流は Responses プロトコルだとルートに伝えます。
|
||||||
|
3. ルートは `/v1/messages` リクエストを Responses のリクエストボディへ変換して上流へ送ります。方式 2 ではさらに OAuth token と公式クライアントのアイデンティティを付けて ChatGPT の Codex サービスにアクセスします。
|
||||||
|
4. 上流から返ってきた後、ルートは Responses の JSON/SSE を Claude Code が理解できる Messages 形式へ変換して返します。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 事前準備
|
||||||
|
|
||||||
|
- インストール済みで起動できる CC Switch(3.17.0 以降、理由は冒頭のバージョン説明を参照)。
|
||||||
|
- インストール済みで、少なくとも 1 回は実行したことのある Claude Code。
|
||||||
|
- 方式 1 に必要なもの: OpenAI Responses API 互換のサービスエンドポイントと対応する API Key。エンドポイントアドレスとモデル名はゲートウェイのドキュメントに従ってください。注意すべきは **Responses API** であって Chat Completions ではないという点です。ゲートウェイが Chat 形式しか提供していない場合でも動作します。Step 1 の「API フォーマット」の説明を参照してください。
|
||||||
|
- 方式 2 に必要なもの: ChatGPT Plus/Pro のサブスクリプションアカウント。
|
||||||
|
|
||||||
|
## Step 1: プロバイダーを追加する
|
||||||
|
|
||||||
|
### 方式 1: サードパーティ Responses ゲートウェイ(API Key)
|
||||||
|
|
||||||
|
CC Switch を開き、上部の `Claude Code` タブへ切り替え、右上のプラスボタンからプロバイダーを追加します。デフォルトの `カスタム設定` のまま、次の項目を入力します:
|
||||||
|
|
||||||
|
- **プロバイダー名**: 任意です。たとえば `GPT Gateway`。
|
||||||
|
- **API Key**: あなたのゲートウェイの Key。実際の Key は CC Switch 内にのみ保存され、ローカルルートが転送時に注入します。
|
||||||
|
- **API エンドポイント**: ゲートウェイのサービスルートアドレスを入力すれば十分です。たとえば `https://gpt-gateway.example.com`。末尾にスラッシュを付けないでください。ルートが自動的にそのゲートウェイの Responses エンドポイント(`/v1/responses`)へリクエストを送ります。ゲートウェイのパスが特殊な場合は、隣の `フル URL` スイッチをオンにして、完全なエンドポイントをそのまま貼り付けてください。
|
||||||
|
|
||||||
|
続いて `高級オプション` を展開します:
|
||||||
|
|
||||||
|
- **API フォーマット**: デフォルトの `Anthropic Messages(ネイティブ)` から **`OpenAI Responses API(ルーティングが必要)`** に変更します。ゲートウェイが Chat Completions プロトコルしか提供していない場合は、ここで `OpenAI Chat Completions(ルーティングが必要)` を選びます。その他のステップはまったく同じです。
|
||||||
|
- **認証フィールド**: デフォルトの `ANTHROPIC_AUTH_TOKEN(デフォルト)` のままにします。ルートは `Authorization: Bearer <key>` を上流へ送ります——これはまさに OpenAI 互換ゲートウェイが期待する認証ヘッダーです。ゲートウェイのドキュメントが明確に `x-api-key` を要求している場合を除き、`ANTHROPIC_API_KEY` に変更しないでください。誤って変更した場合の典型的な症状は 401/403 です。
|
||||||
|
- **モデルマッピング**: Claude Code のモデル役割を、ゲートウェイが認識する実際のモデルへマッピングします。**少なくとも `既定フォールバックモデル` は入力してください**(たとえば `gpt-5.6`。ゲートウェイのドキュメントに従ってください)——空欄のままだと、一致しなかったリクエストは元の Claude モデル名のまま上流へ転送されてエラーになります。個別に設定していない役割はこのフォールバックへ回帰します。より細かく設定するには、行ごとに指定します:`Sonnet`/`Opus` に主力モデル、`Haiku` に安価で高速なモデル(Claude Code のバックグラウンド小タスクがこの区分を使います)。`表示名` は `/model` メニューでの表示にのみ影響し、空欄なら実際のモデル名がそのまま表示されます。
|
||||||
|
- **1M 対応を宣言**: モデルマッピングの各行にある `1M` チェックボックスは、その区分が 1M コンテキストに対応していることを Claude Code に宣言します。ゲートウェイが実際に 100 万トークン以上のウィンドウでそのモデルを提供している場合(たとえば API 仕様どおりに gpt-5.6 を提供するゲートウェイ)にのみチェックしてください。そうでないと、長い対話が上流の実際の上限で直接エラーになります。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
保存すると、カードに `ルーティングが必要` のマークが表示されます——この種のプロバイダーは、ローカルルーティングが実行中でなければ正しく動作しません。
|
||||||
|
|
||||||
|
### 方式 2: ChatGPT サブスクリプション(Codex OAuth)
|
||||||
|
|
||||||
|
同じく `Claude Code` タブでプラスボタンを押し、プリセット一覧で OpenAI アイコンの付いた **`Codex`** プリセットを選びます——これが Claude Code タブの下に出ていても選択ミスではありません。このプリセットはまさに「Claude Code で ChatGPT サブスクリプションを使う」ために用意されたものです:
|
||||||
|
|
||||||
|
- **API Key もアドレスの入力も不要です**——リクエストは常に ChatGPT の Codex サービスへ送られるため、フォームのアドレス項目は変更する必要がありません。
|
||||||
|
- **`ChatGPT でログイン`** をクリックします。これはデバイスコードフローです:CC Switch が自動的にブラウザを開き、認証コードをクリップボードにコピーします。ブラウザのページで認証コードを貼り付けて認可を完了させてください。その間、アプリ内には `認証を待機中...` と表示されます。
|
||||||
|
- ログインに成功すると、`認証状態` にログイン済みアカウント(メールアドレス)が表示されます。複数アカウントに対応しています:`別のアカウントを追加`、`デフォルトに設定`、またはこのプロバイダーで特定のアカウントを指定できます。日常的な管理は `設定` → `OAuth 認証センター` からも行えます。
|
||||||
|
- **FAST モード**: 任意のスイッチです。オンにするとリクエストが `service_tier="priority"` を伴い、より低い遅延と引き換えに、より高いレートで ChatGPT のクォータを消費します。デフォルトはオフのままにしてください。
|
||||||
|
- モデル区分はあらかじめ入力済みです:`Sonnet`/`Opus` が `gpt-5.6` に、`Haiku` が `gpt-5.6-luna` に対応します(バックグラウンドの小タスクがこれを使い、より速く、より枠を節約します)。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
ログイン認証情報は `~/.cc-switch/codex_oauth_auth.json`(`~/.codex/` ではありません)に保存され、Codex CLI 自身のログインとは互いに影響しません。token は有効期限前に自動的に更新されます。
|
||||||
|
|
||||||
|
## Step 2: ローカルルーティングを有効にして Claude Code をルーティングする
|
||||||
|
|
||||||
|
設定の `ルーティング` ページに入り、`ローカルルーティング` を展開して、2 つのスイッチを設定します:
|
||||||
|
|
||||||
|
1. `ルーティング総スイッチ` をオンにしてローカルサービスを起動します(初回起動時は説明の確認ダイアログが表示されます)。デフォルトアドレスは `127.0.0.1:15721` です。
|
||||||
|
2. `ルーティング有効` で `Claude Code` をオンにします。Claude Code だけをルーティングしたい場合は、その他のアプリはオフのままで構いません。
|
||||||
|
|
||||||
|
引き継ぎ後、CC Switch は Claude Code の live 設定をローカルルートへ向け、認証項目にはプレースホルダーだけが入ります。方式 1 のゲートウェイ Key も方式 2 の OAuth token も、いずれもローカルルートが転送時に注入します。
|
||||||
|
|
||||||
|
> **注意**: live 設定は Claude Code プロセスの起動時に読み込まれます。初回の引き継ぎを有効にした後(または引き継ぎをオフにして直結に戻した後)、Claude Code が実行中の場合は、ターミナルセッションを開き直してください。その後、ルーティングモードでのプロバイダー切り替えはホットスワップになり、再起動は不要です。
|
||||||
|
|
||||||
|
## Step 3: プロバイダーを切り替えて確認する
|
||||||
|
|
||||||
|
Claude Code のプロバイダー一覧に戻り、目的のプロバイダーの `有効化` をクリックします。ルーティングが実行されていない場合、CC Switch は「このプロバイダーは OpenAI Responses API フォーマットを使用しており、ルーティングサービスが必要です。先にルーティングを起動してください」と表示します——この表示は切り替えを妨げませんが、ルーティングが未起動のままではリクエストは必ず失敗します。Step 2 に戻ってオンにすれば解決します。
|
||||||
|
|
||||||
|
Claude Code に入ったら、段階的に確認できます:
|
||||||
|
|
||||||
|
- 新しいセッションを開き、`/model` でモデルメニューを確認します:各区分にはモデルマッピングの表示名が表示されます(方式 2 のデフォルトは `gpt-5.6`、`gpt-5.6-luna`)。画面の一部の箇所には Claude 系のモデル名が現れることがあります——それはルーティング引き継ぎが使う内部の役割エイリアスであり、正常な現象です。`/model` メニューと使用量ダッシュボードを基準にしてください。
|
||||||
|
- 小さな質問を 1 つ送り、設定 → ルーティングページの「現在のプロバイダー」があなたのプロバイダーに変わり、「総リクエスト数」が増え始めるのを確認します。
|
||||||
|
- 使用量ダッシュボードでは、これらのリクエストが上流の実際のモデルで表示されます:`gpt-5.6` にマッピングされた区分は Sol 区分として解析され `GPT-5.6 Sol` と表示され、`gpt-5.6-luna` は `GPT-5.6 Luna` と表示されます。プロバイダーで絞り込んで token 使用量を照合できます。
|
||||||
|
- 方式 2 のプロバイダーカードには、さらにサブスクリプション枠が表示されます:5 時間ウィンドウと 7 日ウィンドウの利用率とリセットまでのカウントダウンです。データは ChatGPT アカウント自体から取得され、公式 Codex クライアントと同じ枠を共有します。
|
||||||
|
|
||||||
|
## 機能の範囲と既知の制限
|
||||||
|
|
||||||
|
- **プロンプトキャッシュが自動で有効**: ルートは各セッションに安定した `prompt_cache_key` を注入し、OpenAI 側の自動プレフィックスキャッシュと組み合わせます。長い対話でも毎回全額で再送されることはなく、設定は不要です。
|
||||||
|
- **思考を reasoning effort に換算**: Claude Code の思考スイッチと思考レベルは GPT の `reasoning.effort`(low/medium/high)へ換算されます。GPT の推論内容は暗号化された形態でターンをまたいで完全に再生され、複数ターンの推論の一貫性は変換の影響を受けません。方式 2 は同時にステルスモード(`store:false`)でアクセスし、OpenAI サーバー側にはセッションを残しません。
|
||||||
|
- **ツールとマルチモーダルを完全変換**: 複数ターンのツール呼び出し、画像、PDF 入力はすべて完全に変換されます。
|
||||||
|
- **コンテキストは 200K ウィンドウで管理**: Claude Code はルーティングプロバイダーに対して、デフォルトの 200K ウィンドウで自動圧縮を行います。上流の実際のウィンドウがより大きい場合(たとえば ChatGPT Codex サービスの gpt-5.6 は 372K)でも、200K を超える部分は現状では使われません——圧縮が早めにトリガーされ、保守的ですが安全です。200K を突破したい場合、現在唯一のスイッチはモデルマッピングの `1M` チェックボックス(厳密に 1M を宣言)で、方式 1 に限り、かつ上流が実際に 1M 以上でそのモデルを提供している場合にのみ使用します。方式 2 の上流上限は 372K で 1M には届かないため、チェックするとかえって長い対話が上流の実際の上限でエラーになります。デフォルトのままにしてください。
|
||||||
|
- **出力上限**: 方式 2 の出力上限は ChatGPT サーバー側が制御します(Claude Code のリクエスト内の `max_tokens` は下流に送られません)。方式 1 は Claude Code の `max_tokens` をそのまま透過するため、設定は不要です。
|
||||||
|
- **Web 検索は利用不可**: Claude Code の WebSearch は Anthropic サーバー側での実行に依存しており、GPT 上流では引き受けられません。Web 検索を伴うタスクは Claude 系プロバイダーへ切り替えることをおすすめします。ローカルで実行される WebFetch は影響を受けません。
|
||||||
|
- **使用量ダッシュボードの金額は参考値**: token カウントは正確ですが、ドル金額は公開 API 価格に基づく概算です——方式 2 のサブスクリプショントラフィックは GPT-5.6 の公開価格で換算され、方式 1 のサードパーティゲートウェイはそれぞれのレートで課金されます。どちらも実際の請求額と一致しない可能性があり、比較用途に留めてください。方式 2 の枠の消費は、プロバイダーカード上のウィンドウ利用率を基準にしてください。
|
||||||
|
|
||||||
|
## よくある質問
|
||||||
|
|
||||||
|
**上流が 401 または 403 を返す(方式 1)**
|
||||||
|
|
||||||
|
まず高級オプションの `認証フィールド` がデフォルトの `ANTHROPIC_AUTH_TOKEN(デフォルト)` になっているか確認してください——`ANTHROPIC_API_KEY` に変更すると `x-api-key` で送信され、大多数の OpenAI 互換ゲートウェイはこれを受け付けません。あわせて、Key 自体が有効で残高があることも確認してください。
|
||||||
|
|
||||||
|
**`gpt-5.6-luna` などの新しいモデルをリクエストすると 404 Model not found が出る(方式 2)**
|
||||||
|
|
||||||
|
CC Switch 3.17.0 以降にアップグレードしてください。古いバージョンではクライアントアイデンティティが公式 Codex クライアントに揃っておらず、ChatGPT サーバー側が新しいモデルを存在しないエンジンへ解決してしまいます。
|
||||||
|
|
||||||
|
**切り替えても反映されない、または `/model` メニューが古い名前のまま**
|
||||||
|
|
||||||
|
モデルメニューもルーティングアドレスも、Claude Code の起動時に読み込まれます:初回の引き継ぎを有効にした後は必ずターミナルセッションを開き直してください。プロバイダー間の切り替えはホットスワップですが、メニューの表示名は新しいセッションでなければ更新されません。
|
||||||
|
|
||||||
|
**Claude Code に「Codex OAuth 認証に失敗しました」と出る、またはカードに「セッションが期限切れです」と表示される(方式 2)**
|
||||||
|
|
||||||
|
ログイン認証情報が失効しています。プロバイダーフォーム、または `設定` → `OAuth 認証センター` に戻り、`ChatGPT でログイン` をもう一度実行してください。コマンドライン操作は一切不要です。
|
||||||
|
|
||||||
|
**対話の途中で自動的に圧縮されてしまう**
|
||||||
|
|
||||||
|
「機能の範囲」を参照してください:ルーティングプロバイダーは 200K ウィンドウで管理され、圧縮のしきい値もそれに合わせて早まります。想定どおりの動作です。
|
||||||
|
|
||||||
|
**公式の Claude の使い方に戻したい**
|
||||||
|
|
||||||
|
公式プロバイダーに切り替えるか、ルーティングページで `Claude Code` のルーティングスイッチをオフにしてください——CC Switch は引き継ぎ前の live 設定に戻し、公式ログインの認証情報は全工程を通じて影響を受けません。戻した後も同様にターミナルセッションを開き直す必要があります。
|
||||||
|
|
||||||
|
**FAST モードをオンにすべきか(方式 2)**
|
||||||
|
|
||||||
|
デフォルトのオフのままで構いません。遅延に特に敏感で、かつクォータのより速い消費を受け入れられる場合にのみオンにしてください。ChatGPT サーバー側がこのパラメータを拒否する場合は、スイッチをオフにすれば復旧します。
|
||||||
|
|
||||||
|
## コンプライアンスに関する注意
|
||||||
|
|
||||||
|
方式 2 は ChatGPT サブスクリプション枠を公式 Codex クライアント以外で使うものですが、これはグレーな手法ではありません:OpenAI Codex の責任者である Thibault Sottiaux(@thsottiaux)自身が、Claude Code(彼が「orange crab」と呼ぶもの)を GPT-5.6 Sol に向けて使うことを公開でデモし、推奨しています——用いているのはまさに「ローカルプロキシ + モデルエイリアス」であり、本記事の方式 2 と同じ類の手法です。Codex という製品ラインの責任者が、競合クライアントで自社モデルを使うことを自ら奨励しているのですから、サブスクリプションで Claude Code に GPT 系モデルを動かすことは、公式が歓迎し、試すことを推奨している使い方だと分かります。
|
||||||
|
|
||||||
|
とはいえ、実務上の 2 点は依然として留意する価値があります:1 つは、この部分のトラフィックが公式 Codex クライアントと同じサブスクリプション枠に合算されるため、ヘビーユースではより速く上限に達すること。もう 1 つは、CC Switch の認証センターが念のためコンプライアンス上の注意(「Claude Code で他のサブスクリプションをご利用いただけます。コンプライアンスリスクにご注意ください。」)を残していることです。あなたのアカウントに適用される規約に沿うかどうかは、各自でご確認ください。方式 1 でサードパーティゲートウェイを使う場合は、対象ゲートウェイの課金・コンプライアンス・データ保持に関する規約を別途お読みください。
|
||||||
|
|
||||||
|
## 参考リンク
|
||||||
|
|
||||||
|
- [CC Switch ユーザーマニュアル: プロバイダーを追加(Codex OAuth リバースプロキシと API フォーマットを含む)](../user-manual/ja/2-providers/2.1-add.md)
|
||||||
|
- [CC Switch ユーザーマニュアル: プロキシサービス](../user-manual/ja/4-proxy/4.1-service.md)
|
||||||
|
- [CC Switch ユーザーマニュアル: アプリケーションルーティング](../user-manual/ja/4-proxy/4.2-routing.md)
|
||||||
|
- [CC Switch v3.17.0 リリースノート](../release-notes/v3.17.0-ja.md)
|
||||||
|
- 逆方向のガイド: [Codex で Claude モデルを使う](./codex-claude-routing-guide-ja.md)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# 在 Claude Code 中用 Codex:CC Switch 本地路由攻略
|
# 通过 CC Switch 在 Claude Code 中使用 GPT 模型
|
||||||
|
|
||||||
> 适用版本:CC Switch 3.17.0 及以上(更早版本已具备本文两种接入方式,但 gpt-5.6 预设与客户端身份修复自 3.17.0 落地,低版本请求 `gpt-5.6-luna` 这类新模型会误报 404)。本文根据仓库内文档与代码整理,示例数据均已去敏。
|
> 适用版本:CC Switch 3.17.0 及以上(更早版本已具备本文两种接入方式,但 gpt-5.6 预设与客户端身份修复自 3.17.0 落地,低版本请求 `gpt-5.6-luna` 这类新模型会误报 404)。本文根据仓库内文档与代码整理,示例数据均已去敏。
|
||||||
|
|
||||||
@@ -75,8 +75,6 @@ CC Switch 的做法是让 Claude Code 始终连本机路由,仍以 Anthropic M
|
|||||||
|
|
||||||
> **注意**:live 配置是 Claude Code 进程启动时读取的。首次开启接管(或关闭接管恢复直连)后,如果 Claude Code 正在运行,请重开一个终端会话。之后在路由模式下切换供应商就是热切换,无需再重启。
|
> **注意**:live 配置是 Claude Code 进程启动时读取的。首次开启接管(或关闭接管恢复直连)后,如果 Claude Code 正在运行,请重开一个终端会话。之后在路由模式下切换供应商就是热切换,无需再重启。
|
||||||
|
|
||||||
<!-- TODO 截图 04:本地路由页面中启用 Claude Code 接管(docs/images/claude-codex-routing/04-local-route-claude-takeover.png)。此图需在「Claude Code 路由已开启」状态下截取,而开启接管会把本机 settings.json 的 base_url 改写为 127.0.0.1,需在不依赖该配置的环境中补拍。 -->
|
|
||||||
|
|
||||||
## 第三步:切换供应商并验证
|
## 第三步:切换供应商并验证
|
||||||
|
|
||||||
回到 Claude Code 供应商列表,点击目标供应商的 `启用`。如果路由没有在运行,CC Switch 会提示「此供应商使用 OpenAI Responses 接口格式,需要路由服务才能正常使用,请先启动路由」——这个提示不会拦截切换,但路由未开时请求必然失败,回到第二步打开即可。
|
回到 Claude Code 供应商列表,点击目标供应商的 `启用`。如果路由没有在运行,CC Switch 会提示「此供应商使用 OpenAI Responses 接口格式,需要路由服务才能正常使用,请先启动路由」——这个提示不会拦截切换,但路由未开时请求必然失败,回到第二步打开即可。
|
||||||
@@ -140,4 +138,4 @@ CC Switch 的做法是让 Claude Code 始终连本机路由,仍以 Anthropic M
|
|||||||
- [CC Switch 用户手册:代理服务](../user-manual/zh/4-proxy/4.1-service.md)
|
- [CC Switch 用户手册:代理服务](../user-manual/zh/4-proxy/4.1-service.md)
|
||||||
- [CC Switch 用户手册:应用路由](../user-manual/zh/4-proxy/4.2-routing.md)
|
- [CC Switch 用户手册:应用路由](../user-manual/zh/4-proxy/4.2-routing.md)
|
||||||
- [CC Switch v3.17.0 发布说明](../release-notes/v3.17.0-zh.md)
|
- [CC Switch v3.17.0 发布说明](../release-notes/v3.17.0-zh.md)
|
||||||
- 反方向攻略:[在 Codex 中用 Claude](./codex-claude-routing-guide-zh.md)
|
- 反方向攻略:[在 Codex 中使用 Claude 模型](./codex-claude-routing-guide-zh.md)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Using Claude in Codex: CC Switch Local Routing Guide
|
# Using Claude Models in Codex with CC Switch
|
||||||
|
|
||||||
> Applies to CC Switch 3.17.0 and later (the Anthropic Messages upstream was introduced in 3.17.0). This guide is based on the repository documentation and code, and uses a Claude-family relay gateway as the example. Screenshots are generated from the current frontend UI with de-identified sample data to avoid exposing a real API key.
|
> Applies to CC Switch 3.17.0 and later (the Anthropic Messages upstream was introduced in 3.17.0). This guide is based on the repository documentation and code, and uses a Claude-family relay gateway as the example. Screenshots are generated from the current frontend UI with de-identified sample data to avoid exposing a real API key.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Codex で Claude を使う: CC Switch ローカルルーティングガイド
|
# CC Switch を使って Codex で Claude モデルを利用する
|
||||||
|
|
||||||
> 対象バージョン: CC Switch 3.17.0 以降(「Anthropic Messages 上流」は 3.17.0 から導入)。本記事はリポジトリ内のドキュメントとコードをもとに整理し、Claude 系中継ゲートウェイを例に説明します。スクリーンショットは現在のフロントエンド UI から、実際の API Key が漏れないよう匿名化したサンプルデータで生成しています。
|
> 対象バージョン: CC Switch 3.17.0 以降(「Anthropic Messages 上流」は 3.17.0 から導入)。本記事はリポジトリ内のドキュメントとコードをもとに整理し、Claude 系中継ゲートウェイを例に説明します。スクリーンショットは現在のフロントエンド UI から、実際の API Key が漏れないよう匿名化したサンプルデータで生成しています。
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 在 Codex 中用 Claude:CC Switch 本地路由攻略
|
# 通过 CC Switch 在 Codex 中使用 Claude 模型
|
||||||
|
|
||||||
> 适用版本:CC Switch 3.17.0 及以上(「Anthropic Messages 上游」自 3.17.0 引入)。本文根据仓库内文档与代码整理,以 Claude 系中转网关为例演示。截图来自当前前端界面,使用去敏示例数据生成,避免泄露真实 API Key。
|
> 适用版本:CC Switch 3.17.0 及以上(「Anthropic Messages 上游」自 3.17.0 引入)。本文根据仓库内文档与代码整理,以 Claude 系中转网关为例演示。截图来自当前前端界面,使用去敏示例数据生成,避免泄露真实 API Key。
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,8 @@
|
|||||||
The new capabilities in this release land mainly in the provider presets, Settings → OAuth Auth Center, and the usage dashboard. The following docs are worth reading alongside it:
|
The new capabilities in this release land mainly in the provider presets, Settings → OAuth Auth Center, and the usage dashboard. The following docs are worth reading alongside it:
|
||||||
|
|
||||||
- **[xAI Grok Account Sign-In (Settings → OAuth Auth Center)](../user-manual/en/1-getting-started/1.5-settings.md)**: the device-code login flow, multi-account management, and the integration's boundaries; please read the client-identity disclosure under "Risk Notice" below before use.
|
- **[xAI Grok Account Sign-In (Settings → OAuth Auth Center)](../user-manual/en/1-getting-started/1.5-settings.md)**: the device-code login flow, multi-account management, and the integration's boundaries; please read the client-identity disclosure under "Risk Notice" below before use.
|
||||||
- **[Using Codex-Style Providers in Claude Code (local routing guide)](../guides/claude-codex-routing-guide-zh.md)** (currently Chinese only): a new step-by-step guide added in this release. Claude Code always speaks Anthropic Messages to the local `/v1/messages` route, and the local proxy converts each request to the upstream's Responses protocol — a gateway API key, a native Responses endpoint like xAI, or a ChatGPT subscription's Codex service all fit.
|
- **[Using GPT Models in Claude Code (local routing guide)](../guides/claude-codex-routing-guide-en.md)** (now in Chinese / English / Japanese): a new step-by-step guide added in this release. Claude Code always speaks Anthropic Messages to the local `/v1/messages` route, and the local proxy converts each request to the upstream's Responses protocol — a gateway API key, a native Responses endpoint like xAI, or a ChatGPT subscription's Codex service all fit.
|
||||||
- **[Using Claude in Codex (local routing guide)](../guides/codex-claude-routing-guide-en.md)**: a new step-by-step guide in three languages, pairing with v3.17.0's native Anthropic Messages upstream to connect Codex to any Claude-family gateway that only offers `/v1/messages`.
|
- **[Using Claude Models in Codex (local routing guide)](../guides/codex-claude-routing-guide-en.md)**: a new step-by-step guide in three languages, pairing with v3.17.0's native Anthropic Messages upstream to connect Codex to any Claude-family gateway that only offers `/v1/messages`.
|
||||||
- **[Usage Statistics](../user-manual/en/4-proxy/4.4-usage.md)**: understand the Usage Dashboard's data sources and how the statistics are counted. This release fixes the usage double count and adds the "Rebuild Codex Usage" maintenance action.
|
- **[Usage Statistics](../user-manual/en/4-proxy/4.4-usage.md)**: understand the Usage Dashboard's data sources and how the statistics are counted. This release fixes the usage double count and adds the "Rebuild Codex Usage" maintenance action.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -82,7 +82,7 @@ Settings → OAuth Auth Center gains an xAI section: device-code login (user cod
|
|||||||
|
|
||||||
The integration's boundaries are pinned shut: no matter what the endpoint / format fields in the form say, the upstream is always `https://api.x.ai/v1/responses` (Responses format); OAuth endpoints are resolved via OIDC discovery but strictly validated to `auth.x.ai` over https; refresh tokens live in `~/.cc-switch/xai_oauth_auth.json` (`0600` on Unix; access tokens are memory-only); and OAuth error bodies never enter error messages or logs. Pricing for `grok-4.5` ($2 input / $6 output / $0.50 cache read per million tokens) is seeded in sync so its usage no longer records $0, with existing databases picking the row up automatically on next launch. Four-locale copy included. Please read the client-identity disclosure under "Risk Notice" before use.
|
The integration's boundaries are pinned shut: no matter what the endpoint / format fields in the form say, the upstream is always `https://api.x.ai/v1/responses` (Responses format); OAuth endpoints are resolved via OIDC discovery but strictly validated to `auth.x.ai` over https; refresh tokens live in `~/.cc-switch/xai_oauth_auth.json` (`0600` on Unix; access tokens are memory-only); and OAuth error bodies never enter error messages or logs. Pricing for `grok-4.5` ($2 input / $6 output / $0.50 cache read per million tokens) is seeded in sync so its usage no longer records $0, with existing databases picking the row up automatically on next launch. Four-locale copy included. Please read the client-identity disclosure under "Risk Notice" before use.
|
||||||
|
|
||||||
Not using OAuth and only have a pay-as-you-go xAI API key? You can still connect it to Claude Code: xAI's API endpoint is standard Responses protocol, so add it as an ordinary Responses provider — a custom provider with `https://api.x.ai/v1` and your API key, upstream format set to Responses, converted between Anthropic Messages ↔ Responses by local routing; it's the same recipe as the [Using Codex-Style Providers in Claude Code](../guides/claude-codex-routing-guide-zh.md) guide. On the Codex side there's a ready-made API-key preset — see the next section.
|
Not using OAuth and only have a pay-as-you-go xAI API key? You can still connect it to Claude Code: xAI's API endpoint is standard Responses protocol, so add it as an ordinary Responses provider — a custom provider with `https://api.x.ai/v1` and your API key, upstream format set to Responses, converted between Anthropic Messages ↔ Responses by local routing; it's the same recipe as the [Using GPT Models in Claude Code](../guides/claude-codex-routing-guide-en.md) guide. On the Codex side there's a ready-made API-key preset — see the next section.
|
||||||
|
|
||||||
### Codex Straight to xAI: Managed OAuth and Native API-Key Presets
|
### Codex Straight to xAI: Managed OAuth and Native API-Key Presets
|
||||||
|
|
||||||
@@ -204,8 +204,8 @@ Fifteen OpenClaw preset entries carried cost values in the wrong unit or unconve
|
|||||||
|
|
||||||
Two new guides complete the pair — "Claude models inside the Codex client" and "Responses providers inside the Claude Code client":
|
Two new guides complete the pair — "Claude models inside the Codex client" and "Responses providers inside the Claude Code client":
|
||||||
|
|
||||||
- **[Using Claude in Codex](../guides/codex-claude-routing-guide-en.md)** (Chinese / English / Japanese, with screenshots): pairs with v3.17.0's native Anthropic Messages upstream to connect Codex to a Claude-family `/v1/messages` gateway; the v3.17.0 release notes now link to it.
|
- **[Using Claude Models in Codex](../guides/codex-claude-routing-guide-en.md)** (Chinese / English / Japanese, with screenshots): pairs with v3.17.0's native Anthropic Messages upstream to connect Codex to a Claude-family `/v1/messages` gateway; the v3.17.0 release notes now link to it.
|
||||||
- **[Using Codex-Style Providers in Claude Code](../guides/claude-codex-routing-guide-zh.md)** (Chinese, with screenshots): drive Claude Code with Responses-speaking providers (a gateway API key, or a ChatGPT subscription's Codex service) — Claude Code always speaks Anthropic Messages to the local `/v1/messages` route, and the proxy converts each request to the upstream's Responses protocol.
|
- **[Using GPT Models in Claude Code](../guides/claude-codex-routing-guide-en.md)** (Chinese / English / Japanese, with screenshots): drive Claude Code with Responses-speaking providers (a gateway API key, or a ChatGPT subscription's Codex service) — Claude Code always speaks Anthropic Messages to the local `/v1/messages` route, and the proxy converts each request to the upstream's Responses protocol.
|
||||||
|
|
||||||
### README Sponsor Updates
|
### README Sponsor Updates
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,8 @@
|
|||||||
本リリースの新機能は、主にプロバイダープリセット、「設定 → OAuth 認証センター」、使用量ダッシュボードにあります。以下のドキュメントもあわせてご覧ください:
|
本リリースの新機能は、主にプロバイダープリセット、「設定 → OAuth 認証センター」、使用量ダッシュボードにあります。以下のドキュメントもあわせてご覧ください:
|
||||||
|
|
||||||
- **[xAI Grok アカウントサインイン(設定 → OAuth 認証センター)](../user-manual/ja/1-getting-started/1.5-settings.md)**:デバイスコードログインの流れ、複数アカウント管理、連携の境界について説明します。利用前に下記「リスク通知」のクライアント識別情報の開示を必ずお読みください。
|
- **[xAI Grok アカウントサインイン(設定 → OAuth 認証センター)](../user-manual/ja/1-getting-started/1.5-settings.md)**:デバイスコードログインの流れ、複数アカウント管理、連携の境界について説明します。利用前に下記「リスク通知」のクライアント識別情報の開示を必ずお読みください。
|
||||||
- **[Claude Code で Codex 系プロバイダーを使う(ローカルルーティングガイド)](../guides/claude-codex-routing-guide-zh.md)**(現時点では中国語のみ):本リリース追加のステップバイステップガイドです。Claude Code は常にローカルの `/v1/messages` ルートに対して Anthropic Messages を話し、ローカルプロキシが各リクエストを上流の Responses プロトコルへ変換します——ゲートウェイの API key、xAI のようなネイティブ Responses endpoint、ChatGPT サブスクリプションの Codex サービスのいずれにも使えます。
|
- **[Claude Code で GPT モデルを使う(ローカルルーティングガイド)](../guides/claude-codex-routing-guide-ja.md)**(中国語 / 英語 / 日本語):本リリース追加のステップバイステップガイドです。Claude Code は常にローカルの `/v1/messages` ルートに対して Anthropic Messages を話し、ローカルプロキシが各リクエストを上流の Responses プロトコルへ変換します——ゲートウェイの API key、xAI のようなネイティブ Responses endpoint、ChatGPT サブスクリプションの Codex サービスのいずれにも使えます。
|
||||||
- **[Codex で Claude を使う(ローカルルーティングガイド)](../guides/codex-claude-routing-guide-ja.md)**:本リリース追加の 3 言語ガイド。v3.17.0 の「ネイティブ Anthropic Messages 上流」と組み合わせ、`/v1/messages` のみを提供する Claude 系ゲートウェイへ Codex を接続します。
|
- **[Codex で Claude モデルを使う(ローカルルーティングガイド)](../guides/codex-claude-routing-guide-ja.md)**:本リリース追加の 3 言語ガイド。v3.17.0 の「ネイティブ Anthropic Messages 上流」と組み合わせ、`/v1/messages` のみを提供する Claude 系ゲートウェイへ Codex を接続します。
|
||||||
- **[使用量統計](../user-manual/ja/4-proxy/4.4-usage.md)**:使用量ダッシュボードのデータソースと集計方法を確認できます。本リリースでは二重計上を修正し、「Codex 使用量を再構築」メンテナンス操作を追加しました。
|
- **[使用量統計](../user-manual/ja/4-proxy/4.4-usage.md)**:使用量ダッシュボードのデータソースと集計方法を確認できます。本リリースでは二重計上を修正し、「Codex 使用量を再構築」メンテナンス操作を追加しました。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -82,7 +82,7 @@ Claude Code と Claude Desktop に「xAI (Grok)」プリセットが加わり、
|
|||||||
|
|
||||||
連携の境界は固定されています。フォームの endpoint / 形式フィールドをどう変えても上流は常に `https://api.x.ai/v1/responses`(Responses 形式)。OAuth endpoint は OIDC discovery で解決しますが https の `auth.x.ai` のみに厳格検証。refresh token は `~/.cc-switch/xai_oauth_auth.json`(Unix ではパーミッション `0600`、access token はメモリのみ)。OAuth のエラーボディはエラーメッセージにもログにも決して入りません。`grok-4.5` の価格(100 万 token あたり入力 $2 / 出力 $6 / キャッシュ読み取り $0.50)も登録され、使用量が $0 と記録されなくなります。既存データベースは次回起動時に自動で行を取り込みます。4 言語対応。利用前に「リスク通知」のクライアント識別情報の開示をお読みください。
|
連携の境界は固定されています。フォームの endpoint / 形式フィールドをどう変えても上流は常に `https://api.x.ai/v1/responses`(Responses 形式)。OAuth endpoint は OIDC discovery で解決しますが https の `auth.x.ai` のみに厳格検証。refresh token は `~/.cc-switch/xai_oauth_auth.json`(Unix ではパーミッション `0600`、access token はメモリのみ)。OAuth のエラーボディはエラーメッセージにもログにも決して入りません。`grok-4.5` の価格(100 万 token あたり入力 $2 / 出力 $6 / キャッシュ読み取り $0.50)も登録され、使用量が $0 と記録されなくなります。既存データベースは次回起動時に自動で行を取り込みます。4 言語対応。利用前に「リスク通知」のクライアント識別情報の開示をお読みください。
|
||||||
|
|
||||||
OAuth を使わず、従量課金の xAI API key しかない場合でも、Claude Code へ接続できます。xAI の API endpoint は標準の Responses プロトコルなので、通常の Responses プロバイダーとして追加してください——カスタムプロバイダーに `https://api.x.ai/v1` と API key を設定し、上流形式に Responses を選ぶと、ローカルルーティングが Anthropic Messages ↔ Responses を変換します。[Claude Code で Codex 系プロバイダーを使う](../guides/claude-codex-routing-guide-zh.md)ガイドと同じ手順です。Codex 側には既製の API key プリセットがあります(次節参照)。
|
OAuth を使わず、従量課金の xAI API key しかない場合でも、Claude Code へ接続できます。xAI の API endpoint は標準の Responses プロトコルなので、通常の Responses プロバイダーとして追加してください——カスタムプロバイダーに `https://api.x.ai/v1` と API key を設定し、上流形式に Responses を選ぶと、ローカルルーティングが Anthropic Messages ↔ Responses を変換します。[Claude Code で GPT モデルを使う](../guides/claude-codex-routing-guide-ja.md)ガイドと同じ手順です。Codex 側には既製の API key プリセットがあります(次節参照)。
|
||||||
|
|
||||||
### Codex から xAI へ直結:管理下 OAuth と API Key ネイティブの 2 プリセット
|
### Codex から xAI へ直結:管理下 OAuth と API Key ネイティブの 2 プリセット
|
||||||
|
|
||||||
@@ -204,8 +204,8 @@ OpenClaw プリセットの 15 エントリが、誤った単位や未換算の
|
|||||||
|
|
||||||
「Codex クライアントで Claude モデルを使う」「Claude Code クライアントで Responses プロバイダーを使う」の両方向が、2 つの新ガイドで揃いました:
|
「Codex クライアントで Claude モデルを使う」「Claude Code クライアントで Responses プロバイダーを使う」の両方向が、2 つの新ガイドで揃いました:
|
||||||
|
|
||||||
- **[Codex で Claude を使う](../guides/codex-claude-routing-guide-ja.md)**(中 / 英 / 日 3 言語、スクリーンショット付き):v3.17.0 のネイティブ Anthropic Messages 上流と組み合わせ、Claude 系の `/v1/messages` ゲートウェイへ Codex を接続します。v3.17.0 のリリースノートからもリンクされています。
|
- **[Codex で Claude モデルを使う](../guides/codex-claude-routing-guide-ja.md)**(中 / 英 / 日 3 言語、スクリーンショット付き):v3.17.0 のネイティブ Anthropic Messages 上流と組み合わせ、Claude 系の `/v1/messages` ゲートウェイへ Codex を接続します。v3.17.0 のリリースノートからもリンクされています。
|
||||||
- **[Claude Code で Codex 系プロバイダーを使う](../guides/claude-codex-routing-guide-zh.md)**(中国語、スクリーンショット付き):Responses を話すプロバイダー(ゲートウェイの API key、または ChatGPT サブスクリプションの Codex サービス)で Claude Code を動かします——Claude Code は常にローカルの `/v1/messages` ルートへ Anthropic Messages を話し、プロキシが各リクエストを上流の Responses プロトコルへ変換します。
|
- **[Claude Code で GPT モデルを使う](../guides/claude-codex-routing-guide-ja.md)**(中 / 英 / 日 3 言語、スクリーンショット付き):Responses を話すプロバイダー(ゲートウェイの API key、または ChatGPT サブスクリプションの Codex サービス)で Claude Code を動かします——Claude Code は常にローカルの `/v1/messages` ルートへ Anthropic Messages を話し、プロキシが各リクエストを上流の Responses プロトコルへ変換します。
|
||||||
|
|
||||||
### README のスポンサー欄を更新
|
### README のスポンサー欄を更新
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,8 @@
|
|||||||
本版新能力主要落在供应商预设、「设置 → OAuth 授权中心」与用量看板里,建议结合以下文档了解:
|
本版新能力主要落在供应商预设、「设置 → OAuth 授权中心」与用量看板里,建议结合以下文档了解:
|
||||||
|
|
||||||
- **[xAI Grok 账号登录(设置 → OAuth 授权中心)](../user-manual/zh/1-getting-started/1.5-settings.md)**:设备码登录流程、多账号管理与集成边界说明;使用前请先阅读下方「风险提示」中的客户端身份披露。
|
- **[xAI Grok 账号登录(设置 → OAuth 授权中心)](../user-manual/zh/1-getting-started/1.5-settings.md)**:设备码登录流程、多账号管理与集成边界说明;使用前请先阅读下方「风险提示」中的客户端身份披露。
|
||||||
- **[在 Claude Code 中使用 Codex 类供应商(本地路由攻略)](../guides/claude-codex-routing-guide-zh.md)**:本版新增的中文分步攻略。Claude Code 始终对本地 `/v1/messages` 路由说 Anthropic Messages 协议,由本地代理把每个请求转换成上游的 Responses 协议——网关 API Key、xAI 这类原生 Responses 端点,或 ChatGPT 订阅的 Codex 服务都适用。
|
- **[在 Claude Code 中使用 GPT 模型(本地路由攻略)](../guides/claude-codex-routing-guide-zh.md)**:本版新增的分步攻略,现已中 / 英 / 日三语齐全。Claude Code 始终对本地 `/v1/messages` 路由说 Anthropic Messages 协议,由本地代理把每个请求转换成上游的 Responses 协议——网关 API Key、xAI 这类原生 Responses 端点,或 ChatGPT 订阅的 Codex 服务都适用。
|
||||||
- **[在 Codex 中用 Claude(本地路由攻略)](../guides/codex-claude-routing-guide-zh.md)**:本版新增的三语分步攻略,配合 v3.17.0 的「原生 Anthropic Messages 上游」功能,把 Codex 接到任何只提供 `/v1/messages` 的 Claude 系网关。
|
- **[在 Codex 中使用 Claude 模型(本地路由攻略)](../guides/codex-claude-routing-guide-zh.md)**:本版新增的三语分步攻略,配合 v3.17.0 的「原生 Anthropic Messages 上游」功能,把 Codex 接到任何只提供 `/v1/messages` 的 Claude 系网关。
|
||||||
- **[用量统计](../user-manual/zh/4-proxy/4.4-usage.md)**:了解用量看板的数据来源与统计口径。本版修复用量双计并新增「重建 Codex 用量」维护操作。
|
- **[用量统计](../user-manual/zh/4-proxy/4.4-usage.md)**:了解用量看板的数据来源与统计口径。本版修复用量双计并新增「重建 Codex 用量」维护操作。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -82,7 +82,7 @@ Claude Code 与 Claude Desktop 新增「xAI (Grok)」预设,用 OAuth 设备
|
|||||||
|
|
||||||
集成边界是钉死的:无论表单里的端点 / 格式字段怎么改,上游始终是 `https://api.x.ai/v1/responses`(Responses 格式);OAuth 端点经 OIDC 发现解析,但强制校验为 https 的 `auth.x.ai`;刷新令牌存于 `~/.cc-switch/xai_oauth_auth.json`(Unix 上 `0600`;访问令牌只存内存);OAuth 错误响应体绝不进入错误信息或日志。`grok-4.5` 定价($2 输入 / $6 输出 / $0.50 缓存读,每百万 token)同步入库,用量不再记 $0,存量数据库下次启动自动补行。四语文案同步。使用前请阅读「风险提示」中的客户端身份披露。
|
集成边界是钉死的:无论表单里的端点 / 格式字段怎么改,上游始终是 `https://api.x.ai/v1/responses`(Responses 格式);OAuth 端点经 OIDC 发现解析,但强制校验为 https 的 `auth.x.ai`;刷新令牌存于 `~/.cc-switch/xai_oauth_auth.json`(Unix 上 `0600`;访问令牌只存内存);OAuth 错误响应体绝不进入错误信息或日志。`grok-4.5` 定价($2 输入 / $6 输出 / $0.50 缓存读,每百万 token)同步入库,用量不再记 $0,存量数据库下次启动自动补行。四语文案同步。使用前请阅读「风险提示」中的客户端身份披露。
|
||||||
|
|
||||||
不用 OAuth、只有按量付费的 xAI API Key?同样能接进 Claude Code:xAI 的 API 端点就是标准 Responses 协议,把它当作一个普通的 Responses 供应商添加——自定义供应商填 `https://api.x.ai/v1` 与 API Key、上游格式选 Responses,经本地路由完成 Anthropic Messages ↔ Responses 转换,与〈[在 Claude Code 中使用 Codex 类供应商](../guides/claude-codex-routing-guide-zh.md)〉攻略是同一套玩法。Codex 侧则有现成的 API Key 预设,见下一节。
|
不用 OAuth、只有按量付费的 xAI API Key?同样能接进 Claude Code:xAI 的 API 端点就是标准 Responses 协议,把它当作一个普通的 Responses 供应商添加——自定义供应商填 `https://api.x.ai/v1` 与 API Key、上游格式选 Responses,经本地路由完成 Anthropic Messages ↔ Responses 转换,与〈[在 Claude Code 中使用 GPT 模型](../guides/claude-codex-routing-guide-zh.md)〉攻略是同一套玩法。Codex 侧则有现成的 API Key 预设,见下一节。
|
||||||
|
|
||||||
### Codex 直连 xAI:OAuth 受管与 API Key 原生双预设
|
### Codex 直连 xAI:OAuth 受管与 API Key 原生双预设
|
||||||
|
|
||||||
@@ -204,8 +204,8 @@ Chat→Responses 流式桥的两个 bug 会弄坏「身份分散在多个 chunk
|
|||||||
|
|
||||||
两篇新攻略把「Codex 客户端用 Claude 模型」「Claude Code 客户端用 Responses 供应商」补成了双向:
|
两篇新攻略把「Codex 客户端用 Claude 模型」「Claude Code 客户端用 Responses 供应商」补成了双向:
|
||||||
|
|
||||||
- **[在 Codex 中用 Claude](../guides/codex-claude-routing-guide-zh.md)**(中 / 英 / 日三语,含截图):配合 v3.17.0 的原生 Anthropic Messages 上游,把 Codex 接到 Claude 系 `/v1/messages` 网关;v3.17.0 的 release notes 已回链本攻略。
|
- **[在 Codex 中使用 Claude 模型](../guides/codex-claude-routing-guide-zh.md)**(中 / 英 / 日三语,含截图):配合 v3.17.0 的原生 Anthropic Messages 上游,把 Codex 接到 Claude 系 `/v1/messages` 网关;v3.17.0 的 release notes 已回链本攻略。
|
||||||
- **[在 Claude Code 中使用 Codex 类供应商](../guides/claude-codex-routing-guide-zh.md)**(中文,含截图):用 Responses 协议的供应商(网关 API Key,或 ChatGPT 订阅的 Codex 服务)驱动 Claude Code——Claude Code 始终对本地 `/v1/messages` 路由说 Anthropic Messages,由代理把每个请求转换成上游的 Responses 协议。
|
- **[在 Claude Code 中使用 GPT 模型](../guides/claude-codex-routing-guide-zh.md)**(中 / 英 / 日三语,含截图):用 Responses 协议的供应商(网关 API Key,或 ChatGPT 订阅的 Codex 服务)驱动 Claude Code——Claude Code 始终对本地 `/v1/messages` 路由说 Anthropic Messages,由代理把每个请求转换成上游的 Responses 协议。
|
||||||
|
|
||||||
### README 赞助商更新
|
### README 赞助商更新
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use tauri::{Emitter, Manager, State};
|
|||||||
|
|
||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use crate::commands::copilot::CopilotAuthState;
|
use crate::commands::copilot::CopilotAuthState;
|
||||||
|
use crate::commands::xai_oauth::XaiOAuthState;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::provider::{ClaudeDesktopMode, Provider};
|
use crate::provider::{ClaudeDesktopMode, Provider};
|
||||||
use crate::services::{
|
use crate::services::{
|
||||||
@@ -442,6 +443,7 @@ pub async fn queryProviderUsage(
|
|||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
copilot_state: State<'_, CopilotAuthState>,
|
copilot_state: State<'_, CopilotAuthState>,
|
||||||
|
xai_state: State<'_, XaiOAuthState>,
|
||||||
#[allow(non_snake_case)] providerId: String, // 使用 camelCase 匹配前端
|
#[allow(non_snake_case)] providerId: String, // 使用 camelCase 匹配前端
|
||||||
app: String,
|
app: String,
|
||||||
) -> Result<crate::provider::UsageResult, String> {
|
) -> Result<crate::provider::UsageResult, String> {
|
||||||
@@ -454,8 +456,14 @@ pub async fn queryProviderUsage(
|
|||||||
// 不写失败快照、不 emit:保留上一份托盘快照,与前端 react-query reject
|
// 不写失败快照、不 emit:保留上一份托盘快照,与前端 react-query reject
|
||||||
// 保留上次 data 的语义一致;否则失败快照会经 useUsageCacheBridge 盲写
|
// 保留上次 data 的语义一致;否则失败快照会经 useUsageCacheBridge 盲写
|
||||||
// 回 query 缓存,抹掉 reject 本该保留的旧值。
|
// 回 query 缓存,抹掉 reject 本该保留的旧值。
|
||||||
let inner =
|
let inner = query_provider_usage_inner(
|
||||||
query_provider_usage_inner(&state, &copilot_state, app_type.clone(), &providerId).await;
|
&state,
|
||||||
|
&copilot_state,
|
||||||
|
&xai_state,
|
||||||
|
app_type.clone(),
|
||||||
|
&providerId,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
if let Ok(snapshot) = &inner {
|
if let Ok(snapshot) = &inner {
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"kind": "script",
|
"kind": "script",
|
||||||
@@ -521,6 +529,7 @@ fn resolve_coding_plan_credentials(
|
|||||||
async fn query_provider_usage_inner(
|
async fn query_provider_usage_inner(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
copilot_state: &CopilotAuthState,
|
copilot_state: &CopilotAuthState,
|
||||||
|
xai_state: &XaiOAuthState,
|
||||||
app_type: AppType,
|
app_type: AppType,
|
||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
) -> Result<crate::provider::UsageResult, String> {
|
) -> Result<crate::provider::UsageResult, String> {
|
||||||
@@ -689,9 +698,19 @@ async fn query_provider_usage_inner(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let quota = crate::services::subscription::get_subscription_quota(app_type.as_str())
|
// xAI OAuth 托管供应商的额度属绑定的 SuperGrok 账号,而非所在 app 的
|
||||||
.await
|
// CLI 凭据(对 codex/claude 而言 CLI 凭据是 ChatGPT/Claude 订阅,跨了
|
||||||
.map_err(|e| format!("Failed to query subscription quota: {e}"))?;
|
// 订阅体系,查出来的数字张冠李戴)。
|
||||||
|
let quota = if provider.map(Provider::is_xai_oauth).unwrap_or(false) {
|
||||||
|
let account_id = provider
|
||||||
|
.and_then(|p| p.meta.as_ref())
|
||||||
|
.and_then(|m| m.managed_account_id_for("xai_oauth"));
|
||||||
|
crate::commands::xai_oauth::query_xai_oauth_quota_for(xai_state, account_id).await?
|
||||||
|
} else {
|
||||||
|
crate::services::subscription::get_subscription_quota(app_type.as_str())
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to query subscription quota: {e}"))?
|
||||||
|
};
|
||||||
|
|
||||||
if !quota.success {
|
if !quota.success {
|
||||||
return Ok(crate::provider::UsageResult {
|
return Ok(crate::provider::UsageResult {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
use crate::proxy::providers::xai_oauth_auth::XaiOAuthManager;
|
use crate::proxy::providers::xai_oauth_auth::XaiOAuthManager;
|
||||||
use crate::proxy::providers::XAI_API_BASE_URL;
|
use crate::proxy::providers::XAI_API_BASE_URL;
|
||||||
use crate::services::model_fetch::FetchedModel;
|
use crate::services::model_fetch::FetchedModel;
|
||||||
|
use crate::services::subscription::{CredentialStatus, SubscriptionQuota};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -11,6 +12,68 @@ use tokio::sync::RwLock;
|
|||||||
|
|
||||||
pub struct XaiOAuthState(pub Arc<RwLock<XaiOAuthManager>>);
|
pub struct XaiOAuthState(pub Arc<RwLock<XaiOAuthManager>>);
|
||||||
|
|
||||||
|
/// 查询 xAI OAuth (SuperGrok 反代) 订阅额度的共享核心
|
||||||
|
///
|
||||||
|
/// 与 `get_codex_oauth_quota` 平行:数据走 cc-switch 自管的 xAI OAuth token,
|
||||||
|
/// 而非 Grok CLI 的 ~/.grok/auth.json。两者是同一个 OAuth client
|
||||||
|
/// (client_id 与 Grok CLI 一致),token 对 grok.com 账单端点等效,因此
|
||||||
|
/// 复用 `subscription_grok::query_grok_quota`,协议与 Grok CLI 路径完全一致。
|
||||||
|
///
|
||||||
|
/// 供两处调用:`get_xai_oauth_quota` 命令(前端 footer)与
|
||||||
|
/// `commands::provider` 的 official_subscription 分支(用量脚本/托盘路径,
|
||||||
|
/// xai_oauth 供应商的额度属绑定的 SuperGrok 账号而非所在 app 的 CLI 凭据)。
|
||||||
|
///
|
||||||
|
/// - `account_id` 未指定时回退到 `XaiOAuthManager` 的默认账号
|
||||||
|
/// - 没有任何账号时返回 `not_found`,前端 `SubscriptionQuotaView` 会静默不渲染
|
||||||
|
/// - 瞬时传输失败以 `Err` 传播(前端 reject → retry + 保留上次成功值)
|
||||||
|
pub(crate) async fn query_xai_oauth_quota_for(
|
||||||
|
state: &XaiOAuthState,
|
||||||
|
account_id: Option<String>,
|
||||||
|
) -> Result<SubscriptionQuota, String> {
|
||||||
|
let manager = state.0.read().await;
|
||||||
|
|
||||||
|
// 解析最终使用的账号 ID:显式 > 默认账号 > 无账号 (not_found)
|
||||||
|
let resolved = match account_id
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|id| !id.is_empty())
|
||||||
|
{
|
||||||
|
Some(id) => Some(id.to_string()),
|
||||||
|
None => manager.default_account_id().await,
|
||||||
|
};
|
||||||
|
let Some(id) = resolved else {
|
||||||
|
return Ok(SubscriptionQuota::not_found("xai_oauth"));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取(必要时自动刷新)access_token
|
||||||
|
let token = match manager.get_valid_token_for_account(&id).await {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
return Ok(SubscriptionQuota::error(
|
||||||
|
"xai_oauth",
|
||||||
|
CredentialStatus::Expired,
|
||||||
|
format!("xAI OAuth token unavailable: {e}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
crate::services::subscription_grok::query_grok_quota(
|
||||||
|
&token,
|
||||||
|
"xai_oauth",
|
||||||
|
"Please re-login via cc-switch.",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 查询 xAI OAuth (SuperGrok 反代) 订阅额度
|
||||||
|
#[tauri::command(rename_all = "camelCase")]
|
||||||
|
pub async fn get_xai_oauth_quota(
|
||||||
|
account_id: Option<String>,
|
||||||
|
state: State<'_, XaiOAuthState>,
|
||||||
|
) -> Result<SubscriptionQuota, String> {
|
||||||
|
query_xai_oauth_quota_for(&state, account_id).await
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct ModelsResponse {
|
struct ModelsResponse {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|||||||
@@ -2246,6 +2246,10 @@ impl Database {
|
|||||||
("qwen3-32b", "Qwen3 32B", "0.16", "0.64", "0", "0"),
|
("qwen3-32b", "Qwen3 32B", "0.16", "0.64", "0", "0"),
|
||||||
// Grok 系列 (xAI)
|
// Grok 系列 (xAI)
|
||||||
("grok-4.5", "Grok 4.5", "2", "6", "0.50", "0"),
|
("grok-4.5", "Grok 4.5", "2", "6", "0.50", "0"),
|
||||||
|
// Grok CLI 官方 OAuth 态 modelUsage 上报的内部别名。定价由
|
||||||
|
// costUsdTicks(1 tick = 1e-10 USD)双轮实测反推:input/output 与
|
||||||
|
// grok-4.5 同为 2/6,cache read 实际按 0.30 计(非 API 挂牌的 0.50)
|
||||||
|
("grok-4.5-build", "Grok 4.5 Build", "2", "6", "0.30", "0"),
|
||||||
("grok-4.3", "Grok 4.3", "1.25", "2.50", "0.20", "0"),
|
("grok-4.3", "Grok 4.3", "1.25", "2.50", "0.20", "0"),
|
||||||
(
|
(
|
||||||
"grok-4.20-0309-reasoning",
|
"grok-4.20-0309-reasoning",
|
||||||
|
|||||||
@@ -1375,6 +1375,7 @@ pub fn run() {
|
|||||||
commands::get_codex_oauth_quota,
|
commands::get_codex_oauth_quota,
|
||||||
commands::get_codex_oauth_models,
|
commands::get_codex_oauth_models,
|
||||||
commands::get_xai_oauth_models,
|
commands::get_xai_oauth_models,
|
||||||
|
commands::get_xai_oauth_quota,
|
||||||
commands::get_coding_plan_quota,
|
commands::get_coding_plan_quota,
|
||||||
commands::get_balance,
|
commands::get_balance,
|
||||||
// New MCP via config.json (SSOT)
|
// New MCP via config.json (SSOT)
|
||||||
|
|||||||
@@ -4755,6 +4755,60 @@ mod tests {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn body_with_codex_tool_output_image(stringified: bool) -> Value {
|
||||||
|
let output = json!({
|
||||||
|
"content": [{
|
||||||
|
"type": "input_image",
|
||||||
|
"image_url": "data:image/png;base64,TOOL_OUTPUT_SENTINEL"
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
json!({
|
||||||
|
"model": "any-model",
|
||||||
|
"input": [{
|
||||||
|
"type": "function_call_output",
|
||||||
|
"call_id": "call_1",
|
||||||
|
"output": if stringified {
|
||||||
|
Value::String(output.to_string())
|
||||||
|
} else {
|
||||||
|
output
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn body_with_stringified_chat_tool_image() -> Value {
|
||||||
|
let content = json!({
|
||||||
|
"content": [{
|
||||||
|
"type": "image",
|
||||||
|
"mimeType": "image/png",
|
||||||
|
"data": "CHAT_TOOL_SENTINEL"
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
json!({
|
||||||
|
"model": "any-model",
|
||||||
|
"messages": [{
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": "call_1",
|
||||||
|
"content": content
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn body_with_gemini_image() -> Value {
|
||||||
|
json!({
|
||||||
|
"contents": [{
|
||||||
|
"role": "user",
|
||||||
|
"parts": [{
|
||||||
|
"inlineData": {
|
||||||
|
"mimeType": "image/png",
|
||||||
|
"data": "GEMINI_SENTINEL"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn image_unsupported_error() -> ProxyError {
|
fn image_unsupported_error() -> ProxyError {
|
||||||
ProxyError::UpstreamError {
|
ProxyError::UpstreamError {
|
||||||
status: 400,
|
status: 400,
|
||||||
@@ -4857,6 +4911,49 @@ mod tests {
|
|||||||
assert!(fwd.media_retry_should_trigger("Codex", false, &body, &error));
|
assert!(fwd.media_retry_should_trigger("Codex", false, &body, &error));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reactive_triggers_for_structured_and_stringified_codex_tool_images() {
|
||||||
|
let fwd = forwarder_with_rectifier(RectifierConfig::default());
|
||||||
|
|
||||||
|
for stringified in [false, true] {
|
||||||
|
let body = body_with_codex_tool_output_image(stringified);
|
||||||
|
assert!(
|
||||||
|
fwd.media_retry_should_trigger("Codex", false, &body, &image_unsupported_error()),
|
||||||
|
"tool-output image should trigger retry (stringified={stringified})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reactive_triggers_for_chat_tool_and_gemini_images() {
|
||||||
|
let fwd = forwarder_with_rectifier(RectifierConfig::default());
|
||||||
|
|
||||||
|
assert!(fwd.media_retry_should_trigger(
|
||||||
|
"Claude",
|
||||||
|
false,
|
||||||
|
&body_with_stringified_chat_tool_image(),
|
||||||
|
&image_unsupported_error()
|
||||||
|
));
|
||||||
|
assert!(fwd.media_retry_should_trigger(
|
||||||
|
"Claude",
|
||||||
|
false,
|
||||||
|
&body_with_gemini_image(),
|
||||||
|
&image_unsupported_error()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reactive_does_not_treat_context_limit_as_image_rejection() {
|
||||||
|
let fwd = forwarder_with_rectifier(RectifierConfig::default());
|
||||||
|
let body = body_with_codex_tool_output_image(false);
|
||||||
|
let context_error = ProxyError::UpstreamError {
|
||||||
|
status: 400,
|
||||||
|
body: Some(r#"{"error":{"message":"maximum context length exceeded"}}"#.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(!fwd.media_retry_should_trigger("Codex", false, &body, &context_error));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reactive_skipped_when_media_fallback_off() {
|
fn reactive_skipped_when_media_fallback_off() {
|
||||||
// 关闭 request_media_fallback:上游报图片错误也不触发兜底重试。
|
// 关闭 request_media_fallback:上游报图片错误也不触发兜底重试。
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ use crate::model_capabilities::is_confirmed_text_only_model as confirmed_text_on
|
|||||||
use crate::model_capabilities::{image_input_capability_from_settings, ImageInputCapability};
|
use crate::model_capabilities::{image_input_capability_from_settings, ImageInputCapability};
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
use crate::proxy::error::ProxyError;
|
use crate::proxy::error::ProxyError;
|
||||||
|
use crate::proxy::tool_media::{
|
||||||
|
strip_media_from_tool_value, tool_output_contains_media, ToolMediaScope,
|
||||||
|
};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
pub const UNSUPPORTED_IMAGE_MARKER: &str = "[Unsupported Image]";
|
pub const UNSUPPORTED_IMAGE_MARKER: &str = "[Unsupported Image]";
|
||||||
@@ -40,7 +43,9 @@ pub fn replace_images_for_text_only_model(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn contains_image_blocks(body: &Value) -> bool {
|
pub fn contains_image_blocks(body: &Value) -> bool {
|
||||||
messages_have_image_blocks(body) || responses_input_has_image_blocks(body.get("input"))
|
messages_have_image_blocks(body)
|
||||||
|
|| responses_input_has_image_blocks(body.get("input"))
|
||||||
|
|| gemini_contents_have_image_blocks(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn replace_image_blocks_with_marker(body: &mut Value) -> usize {
|
pub fn replace_image_blocks_with_marker(body: &mut Value) -> usize {
|
||||||
@@ -119,7 +124,11 @@ fn content_has_image_blocks(content: &Value) -> bool {
|
|||||||
|
|
||||||
blocks.iter().any(|block| {
|
blocks.iter().any(|block| {
|
||||||
is_image_block_type(block.get("type").and_then(Value::as_str))
|
is_image_block_type(block.get("type").and_then(Value::as_str))
|
||||||
|| block.get("content").is_some_and(content_has_image_blocks)
|
|| block.get("content").is_some_and(|nested| {
|
||||||
|
content_has_image_blocks(nested)
|
||||||
|
|| (block.get("type").and_then(Value::as_str) == Some("tool_result")
|
||||||
|
&& tool_output_contains_media(nested, ToolMediaScope::ImagesOnly))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,13 +136,7 @@ fn replace_images_in_body(body: &mut Value) -> usize {
|
|||||||
let message_replacements = body
|
let message_replacements = body
|
||||||
.get_mut("messages")
|
.get_mut("messages")
|
||||||
.and_then(Value::as_array_mut)
|
.and_then(Value::as_array_mut)
|
||||||
.map(|messages| {
|
.map(|messages| messages.iter_mut().map(replace_images_in_message).sum())
|
||||||
messages
|
|
||||||
.iter_mut()
|
|
||||||
.filter_map(|message| message.get_mut("content"))
|
|
||||||
.map(replace_images_in_content)
|
|
||||||
.sum()
|
|
||||||
})
|
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
message_replacements
|
message_replacements
|
||||||
@@ -141,6 +144,37 @@ fn replace_images_in_body(body: &mut Value) -> usize {
|
|||||||
.get_mut("input")
|
.get_mut("input")
|
||||||
.map(replace_images_in_responses_input)
|
.map(replace_images_in_responses_input)
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
|
+ replace_images_in_gemini_contents(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_images_in_message(message: &mut Value) -> usize {
|
||||||
|
let is_tool_message = message.get("role").and_then(Value::as_str) == Some("tool");
|
||||||
|
let Some(content) = message.get_mut("content") else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_tool_message {
|
||||||
|
// Preserve the legacy typed-image replacement semantics first,
|
||||||
|
// including Anthropic cache_control on the replacement text block.
|
||||||
|
// The shared traversal then handles JSON strings, MCP wrappers, and
|
||||||
|
// loose data-URL shapes that the legacy recursion does not recognize.
|
||||||
|
let mut replaced = replace_images_in_content(content);
|
||||||
|
let replacement_block = json!({
|
||||||
|
"type":"text",
|
||||||
|
"text":UNSUPPORTED_IMAGE_MARKER
|
||||||
|
});
|
||||||
|
let mut discarded_media = Vec::new();
|
||||||
|
replaced += strip_media_from_tool_value(
|
||||||
|
content,
|
||||||
|
&mut discarded_media,
|
||||||
|
ToolMediaScope::ImagesOnly,
|
||||||
|
&replacement_block,
|
||||||
|
UNSUPPORTED_IMAGE_MARKER,
|
||||||
|
);
|
||||||
|
replaced
|
||||||
|
} else {
|
||||||
|
replace_images_in_content(content)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn replace_images_in_content(content: &mut Value) -> usize {
|
fn replace_images_in_content(content: &mut Value) -> usize {
|
||||||
@@ -154,14 +188,36 @@ fn replace_images_in_content_with_text_type(content: &mut Value, text_type: &str
|
|||||||
|
|
||||||
let mut replaced = 0usize;
|
let mut replaced = 0usize;
|
||||||
for block in blocks {
|
for block in blocks {
|
||||||
if is_image_block_type(block.get("type").and_then(Value::as_str)) {
|
let block_type = block.get("type").and_then(Value::as_str);
|
||||||
|
if is_image_block_type(block_type) {
|
||||||
replace_image_block_with_text_marker(block, text_type);
|
replace_image_block_with_text_marker(block, text_type);
|
||||||
replaced += 1;
|
replaced += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let is_tool_result = block_type == Some("tool_result");
|
||||||
if let Some(nested_content) = block.get_mut("content") {
|
if let Some(nested_content) = block.get_mut("content") {
|
||||||
replaced += replace_images_in_content_with_text_type(nested_content, text_type);
|
if is_tool_result {
|
||||||
|
// Run the legacy typed-block replacement before the shared
|
||||||
|
// payload-aware traversal. This makes replacement a superset
|
||||||
|
// of detection and preserves cache_control on Anthropic image
|
||||||
|
// blocks, while the second pass covers alternate tool shapes.
|
||||||
|
replaced += replace_images_in_content_with_text_type(nested_content, text_type);
|
||||||
|
let replacement_block = json!({
|
||||||
|
"type":text_type,
|
||||||
|
"text":UNSUPPORTED_IMAGE_MARKER
|
||||||
|
});
|
||||||
|
let mut discarded_media = Vec::new();
|
||||||
|
replaced += strip_media_from_tool_value(
|
||||||
|
nested_content,
|
||||||
|
&mut discarded_media,
|
||||||
|
ToolMediaScope::ImagesOnly,
|
||||||
|
&replacement_block,
|
||||||
|
UNSUPPORTED_IMAGE_MARKER,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
replaced += replace_images_in_content_with_text_type(nested_content, text_type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,13 +228,110 @@ fn messages_have_image_blocks(body: &Value) -> bool {
|
|||||||
body.get("messages")
|
body.get("messages")
|
||||||
.and_then(Value::as_array)
|
.and_then(Value::as_array)
|
||||||
.is_some_and(|messages| {
|
.is_some_and(|messages| {
|
||||||
messages
|
messages.iter().any(|message| {
|
||||||
.iter()
|
let Some(content) = message.get("content") else {
|
||||||
.filter_map(|message| message.get("content"))
|
return false;
|
||||||
.any(content_has_image_blocks)
|
};
|
||||||
|
content_has_image_blocks(content)
|
||||||
|
|| (message.get("role").and_then(Value::as_str) == Some("tool")
|
||||||
|
&& tool_output_contains_media(content, ToolMediaScope::ImagesOnly))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn gemini_contents_have_image_blocks(body: &Value) -> bool {
|
||||||
|
body.get("contents")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.is_some_and(|contents| {
|
||||||
|
contents.iter().any(|content| {
|
||||||
|
content
|
||||||
|
.get("parts")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.is_some_and(|parts| parts.iter().any(gemini_part_has_image))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gemini_part_has_image(part: &Value) -> bool {
|
||||||
|
gemini_media_payload_is_image(part.get("inlineData").or_else(|| part.get("inline_data")))
|
||||||
|
|| gemini_media_payload_is_image(part.get("fileData").or_else(|| part.get("file_data")))
|
||||||
|
|| part
|
||||||
|
.get("functionResponse")
|
||||||
|
.or_else(|| part.get("function_response"))
|
||||||
|
.and_then(|response| response.get("parts"))
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.is_some_and(|parts| parts.iter().any(gemini_part_has_image))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gemini_media_payload_is_image(payload: Option<&Value>) -> bool {
|
||||||
|
payload
|
||||||
|
.and_then(|payload| payload.get("mimeType").or_else(|| payload.get("mime_type")))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|mime_type| {
|
||||||
|
mime_type
|
||||||
|
.get(..6)
|
||||||
|
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("image/"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_images_in_gemini_contents(body: &mut Value) -> usize {
|
||||||
|
body.get_mut("contents")
|
||||||
|
.and_then(Value::as_array_mut)
|
||||||
|
.map(|contents| {
|
||||||
|
contents
|
||||||
|
.iter_mut()
|
||||||
|
.filter_map(|content| content.get_mut("parts").and_then(Value::as_array_mut))
|
||||||
|
.map(|parts| {
|
||||||
|
parts
|
||||||
|
.iter_mut()
|
||||||
|
.map(replace_images_in_gemini_part)
|
||||||
|
.sum::<usize>()
|
||||||
|
})
|
||||||
|
.sum()
|
||||||
|
})
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_images_in_gemini_part(part: &mut Value) -> usize {
|
||||||
|
if gemini_media_payload_is_image(part.get("inlineData").or_else(|| part.get("inline_data")))
|
||||||
|
|| gemini_media_payload_is_image(part.get("fileData").or_else(|| part.get("file_data")))
|
||||||
|
{
|
||||||
|
*part = json!({"text":UNSUPPORTED_IMAGE_MARKER});
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response_key = if part.get("functionResponse").is_some() {
|
||||||
|
"functionResponse"
|
||||||
|
} else {
|
||||||
|
"function_response"
|
||||||
|
};
|
||||||
|
let Some(function_response) = part.get_mut(response_key) else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
let Some(media_parts) = function_response
|
||||||
|
.get_mut("parts")
|
||||||
|
.and_then(Value::as_array_mut)
|
||||||
|
else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
let before = media_parts.len();
|
||||||
|
media_parts.retain(|media_part| !gemini_part_has_image(media_part));
|
||||||
|
let replaced = before.saturating_sub(media_parts.len());
|
||||||
|
if replaced > 0 {
|
||||||
|
if let Some(response) = function_response
|
||||||
|
.get_mut("response")
|
||||||
|
.and_then(Value::as_object_mut)
|
||||||
|
{
|
||||||
|
response.insert(
|
||||||
|
"cc_switch_media".to_string(),
|
||||||
|
Value::String(UNSUPPORTED_IMAGE_MARKER.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
replaced
|
||||||
|
}
|
||||||
|
|
||||||
fn responses_input_has_image_blocks(input: Option<&Value>) -> bool {
|
fn responses_input_has_image_blocks(input: Option<&Value>) -> bool {
|
||||||
match input {
|
match input {
|
||||||
Some(Value::Array(items)) => items.iter().any(responses_input_item_has_image_blocks),
|
Some(Value::Array(items)) => items.iter().any(responses_input_item_has_image_blocks),
|
||||||
@@ -193,6 +346,9 @@ fn responses_input_item_has_image_blocks(item: &Value) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
item.get("content").is_some_and(content_has_image_blocks)
|
item.get("content").is_some_and(content_has_image_blocks)
|
||||||
|
|| item
|
||||||
|
.get("output")
|
||||||
|
.is_some_and(|output| tool_output_contains_media(output, ToolMediaScope::ImagesOnly))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn replace_images_in_responses_input(input: &mut Value) -> usize {
|
fn replace_images_in_responses_input(input: &mut Value) -> usize {
|
||||||
@@ -218,6 +374,23 @@ fn replace_images_in_responses_input_item(item: &mut Value) -> usize {
|
|||||||
replaced += replace_images_in_content_with_text_type(content, "input_text");
|
replaced += replace_images_in_content_with_text_type(content, "input_text");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(output) = item.get_mut("output") {
|
||||||
|
// The image-capability fallback deliberately strips images only.
|
||||||
|
// Tool-output files/audio remain a known unsupported-modality gap.
|
||||||
|
let replacement_block = json!({
|
||||||
|
"type": "input_text",
|
||||||
|
"text": UNSUPPORTED_IMAGE_MARKER
|
||||||
|
});
|
||||||
|
let mut discarded_media = Vec::new();
|
||||||
|
replaced += strip_media_from_tool_value(
|
||||||
|
output,
|
||||||
|
&mut discarded_media,
|
||||||
|
ToolMediaScope::ImagesOnly,
|
||||||
|
&replacement_block,
|
||||||
|
UNSUPPORTED_IMAGE_MARKER,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
replaced
|
replaced
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,6 +456,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn large_tool_data_url() -> String {
|
||||||
|
format!(
|
||||||
|
"data:image/png;base64,{}",
|
||||||
|
"SANITIZER_TOOL_MEDIA_SENTINEL".repeat(400)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keeps_images_when_model_capability_is_unknown() {
|
fn keeps_images_when_model_capability_is_unknown() {
|
||||||
let provider = provider(json!({}));
|
let provider = provider(json!({}));
|
||||||
@@ -603,6 +783,379 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replaces_file_backed_tool_result_image_and_preserves_cache_control() {
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "deepseek-v4-pro",
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": "toolu_file",
|
||||||
|
"content": [{
|
||||||
|
"type": "image",
|
||||||
|
"source": {
|
||||||
|
"type": "file",
|
||||||
|
"file_id": "file_123"
|
||||||
|
},
|
||||||
|
"cache_control": {"type": "ephemeral"}
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(contains_image_blocks(&body));
|
||||||
|
let count = replace_image_blocks_with_marker(&mut body);
|
||||||
|
let replacement = &body["messages"][0]["content"][0]["content"][0];
|
||||||
|
|
||||||
|
assert_eq!(count, 1);
|
||||||
|
assert_eq!(replacement["type"], "text");
|
||||||
|
assert_eq!(replacement["text"], UNSUPPORTED_IMAGE_MARKER);
|
||||||
|
assert_eq!(replacement["cache_control"]["type"], "ephemeral");
|
||||||
|
assert!(!body.to_string().contains("file_123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replaces_stringified_anthropic_tool_result_image_blocks() {
|
||||||
|
let content = json!({
|
||||||
|
"content": [{
|
||||||
|
"type": "image",
|
||||||
|
"mimeType": "image/png",
|
||||||
|
"data": "ANTHROPIC_STRING_TOOL_SENTINEL"
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "deepseek-v4-pro",
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": "toolu_1",
|
||||||
|
"content": content
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(contains_image_blocks(&body));
|
||||||
|
let count = replace_image_blocks_with_marker(&mut body);
|
||||||
|
let rewritten = body["messages"][0]["content"][0]["content"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(count, 1);
|
||||||
|
assert!(rewritten.contains(UNSUPPORTED_IMAGE_MARKER));
|
||||||
|
assert!(!rewritten.contains("ANTHROPIC_STRING_TOOL_SENTINEL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_and_replaces_responses_function_output_images() {
|
||||||
|
let data_url = large_tool_data_url();
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "text-only",
|
||||||
|
"input": [{
|
||||||
|
"type": "function_call_output",
|
||||||
|
"call_id": "call_1",
|
||||||
|
"output": {
|
||||||
|
"content": [
|
||||||
|
{"type": "input_text", "text": "caption"},
|
||||||
|
{"type": "input_image", "image_url": data_url.clone()},
|
||||||
|
{"type": "image", "mimeType": "image/webp", "data": "MCP_SENTINEL"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(contains_image_blocks(&body));
|
||||||
|
let replaced = replace_image_blocks_with_marker(&mut body);
|
||||||
|
|
||||||
|
assert_eq!(replaced, 2);
|
||||||
|
assert_eq!(
|
||||||
|
body["input"][0]["output"]["content"][1],
|
||||||
|
json!({"type": "input_text", "text": UNSUPPORTED_IMAGE_MARKER})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
body["input"][0]["output"]["content"][2],
|
||||||
|
json!({"type": "input_text", "text": UNSUPPORTED_IMAGE_MARKER})
|
||||||
|
);
|
||||||
|
assert!(!body.to_string().contains(&data_url));
|
||||||
|
assert!(!body.to_string().contains("MCP_SENTINEL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn proactive_text_only_sanitizer_covers_responses_tool_outputs() {
|
||||||
|
let provider = provider(json!({
|
||||||
|
"models": [{"id": "text-model", "input": ["text"]}]
|
||||||
|
}));
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "text-model",
|
||||||
|
"input": [{
|
||||||
|
"type": "function_call_output",
|
||||||
|
"call_id": "call_1",
|
||||||
|
"output": [{
|
||||||
|
"type": "input_image",
|
||||||
|
"image_url": "data:image/png;base64,PROACTIVE_SENTINEL"
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let replaced = replace_images_for_text_only_model(&mut body, &provider, true);
|
||||||
|
|
||||||
|
assert_eq!(replaced, 1);
|
||||||
|
assert_eq!(body["input"][0]["output"][0]["type"], "input_text");
|
||||||
|
assert!(!body.to_string().contains("PROACTIVE_SENTINEL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_and_replaces_json_string_tool_output_symmetrically() {
|
||||||
|
let data_url = large_tool_data_url();
|
||||||
|
let output = json!({
|
||||||
|
"content": [{
|
||||||
|
"type": "input_image",
|
||||||
|
"image_url": data_url.clone()
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let mut body = json!({
|
||||||
|
"input": [{
|
||||||
|
"type": "function_call_output",
|
||||||
|
"call_id": "call_string",
|
||||||
|
"output": output
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(contains_image_blocks(&body));
|
||||||
|
let replaced = replace_image_blocks_with_marker(&mut body);
|
||||||
|
|
||||||
|
assert_eq!(replaced, 1);
|
||||||
|
let rewritten = body["input"][0]["output"].as_str().unwrap();
|
||||||
|
assert!(rewritten.contains(UNSUPPORTED_IMAGE_MARKER));
|
||||||
|
assert!(!rewritten.contains(&data_url));
|
||||||
|
let parsed: Value = serde_json::from_str(rewritten).unwrap();
|
||||||
|
assert_eq!(parsed["content"][0]["type"], "input_text");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_and_replaces_whole_string_tool_image_data_url() {
|
||||||
|
let data_url = large_tool_data_url();
|
||||||
|
let mut body = json!({
|
||||||
|
"input": [{
|
||||||
|
"type": "function_call_output",
|
||||||
|
"call_id": "call_raw",
|
||||||
|
"output": data_url.clone()
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(contains_image_blocks(&body));
|
||||||
|
let replaced = replace_image_blocks_with_marker(&mut body);
|
||||||
|
|
||||||
|
assert_eq!(replaced, 1);
|
||||||
|
assert_eq!(
|
||||||
|
body["input"][0]["output"],
|
||||||
|
Value::String(UNSUPPORTED_IMAGE_MARKER.to_string())
|
||||||
|
);
|
||||||
|
assert!(!body.to_string().contains(&data_url));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_and_replaces_custom_tool_output_images() {
|
||||||
|
let mut body = json!({
|
||||||
|
"input": [{
|
||||||
|
"type": "custom_tool_call_output",
|
||||||
|
"call_id": "call_custom",
|
||||||
|
"status": "completed",
|
||||||
|
"output": [{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": "https://example.com/render.png"}
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(contains_image_blocks(&body));
|
||||||
|
let replaced = replace_image_blocks_with_marker(&mut body);
|
||||||
|
|
||||||
|
assert_eq!(replaced, 1);
|
||||||
|
assert_eq!(body["input"][0]["status"], "completed");
|
||||||
|
assert_eq!(body["input"][0]["output"][0]["type"], "input_text");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_no_media_and_untyped_remote_tool_outputs() {
|
||||||
|
let mut body = json!({
|
||||||
|
"input": [
|
||||||
|
{
|
||||||
|
"type": "function_call_output",
|
||||||
|
"call_id": "call_text",
|
||||||
|
"output": {"content": [{"type": "text", "text": "ordinary result"}]}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tool_search_output",
|
||||||
|
"call_id": "call_search",
|
||||||
|
"output": {
|
||||||
|
"image_url": {"url": "https://example.com/search-thumbnail.png"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
let original = body.clone();
|
||||||
|
|
||||||
|
assert!(!contains_image_blocks(&body));
|
||||||
|
assert_eq!(replace_image_blocks_with_marker(&mut body), 0);
|
||||||
|
assert_eq!(body, original);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn image_retry_scope_intentionally_ignores_tool_files_and_audio() {
|
||||||
|
let mut body = json!({
|
||||||
|
"input": [{
|
||||||
|
"type": "function_call_output",
|
||||||
|
"call_id": "call_modalities",
|
||||||
|
"output": {
|
||||||
|
"content": [
|
||||||
|
{"type": "input_file", "file_id": "file_1"},
|
||||||
|
{
|
||||||
|
"type": "input_audio",
|
||||||
|
"input_audio": {"data": "AUDIO", "format": "wav"}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
let original = body.clone();
|
||||||
|
|
||||||
|
assert!(!contains_image_blocks(&body));
|
||||||
|
assert_eq!(replace_image_blocks_with_marker(&mut body), 0);
|
||||||
|
assert_eq!(body, original);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replaces_synthetic_user_and_tool_role_chat_image_parts() {
|
||||||
|
let mut body = json!({
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "tool media"},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": "data:image/png;base64,USER_SENTINEL"}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": "call_1",
|
||||||
|
"content": [{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": "https://example.com/tool.png"}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(contains_image_blocks(&body));
|
||||||
|
let replaced = replace_image_blocks_with_marker(&mut body);
|
||||||
|
|
||||||
|
assert_eq!(replaced, 2);
|
||||||
|
assert_eq!(body["messages"][0]["content"][1]["type"], "text");
|
||||||
|
assert_eq!(body["messages"][1]["content"][0]["type"], "text");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_and_replaces_stringified_chat_tool_image() {
|
||||||
|
let content = json!({
|
||||||
|
"content": [{
|
||||||
|
"type": "image",
|
||||||
|
"mimeType": "image/png",
|
||||||
|
"data": "STRINGIFIED_CHAT_TOOL_SENTINEL"
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let mut body = json!({
|
||||||
|
"messages": [{
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": "call_1",
|
||||||
|
"content": content
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(contains_image_blocks(&body));
|
||||||
|
let replaced = replace_image_blocks_with_marker(&mut body);
|
||||||
|
let rewritten = body["messages"][0]["content"].as_str().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(replaced, 1);
|
||||||
|
assert!(rewritten.contains(UNSUPPORTED_IMAGE_MARKER));
|
||||||
|
assert!(!rewritten.contains("STRINGIFIED_CHAT_TOOL_SENTINEL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_and_replaces_gemini_native_image_parts() {
|
||||||
|
let mut body = json!({
|
||||||
|
"contents": [{
|
||||||
|
"role": "user",
|
||||||
|
"parts": [
|
||||||
|
{
|
||||||
|
"functionResponse": {
|
||||||
|
"name": "inspect",
|
||||||
|
"response": {"content": "done"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"inlineData": {
|
||||||
|
"mimeType": "image/png",
|
||||||
|
"data": "GEMINI_INLINE_SENTINEL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(contains_image_blocks(&body));
|
||||||
|
let replaced = replace_image_blocks_with_marker(&mut body);
|
||||||
|
|
||||||
|
assert_eq!(replaced, 1);
|
||||||
|
assert_eq!(
|
||||||
|
body["contents"][0]["parts"][1]["text"],
|
||||||
|
UNSUPPORTED_IMAGE_MARKER
|
||||||
|
);
|
||||||
|
assert!(!body.to_string().contains("GEMINI_INLINE_SENTINEL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_and_removes_nested_gemini_function_response_media() {
|
||||||
|
let mut body = json!({
|
||||||
|
"contents": [{
|
||||||
|
"role": "user",
|
||||||
|
"parts": [{
|
||||||
|
"functionResponse": {
|
||||||
|
"name": "inspect",
|
||||||
|
"response": {"content": "done"},
|
||||||
|
"parts": [{
|
||||||
|
"inlineData": {
|
||||||
|
"mimeType": "image/webp",
|
||||||
|
"data": "GEMINI_FUNCTION_SENTINEL"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(contains_image_blocks(&body));
|
||||||
|
let replaced = replace_image_blocks_with_marker(&mut body);
|
||||||
|
|
||||||
|
assert_eq!(replaced, 1);
|
||||||
|
assert!(body["contents"][0]["parts"][0]["functionResponse"]["parts"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
body["contents"][0]["parts"][0]["functionResponse"]["response"]["cc_switch_media"],
|
||||||
|
UNSUPPORTED_IMAGE_MARKER
|
||||||
|
);
|
||||||
|
assert!(!body.to_string().contains("GEMINI_FUNCTION_SENTINEL"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detects_unsupported_image_errors() {
|
fn detects_unsupported_image_errors() {
|
||||||
let error = ProxyError::UpstreamError {
|
let error = ProxyError::UpstreamError {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ pub(crate) mod switch_lock;
|
|||||||
pub mod thinking_budget_rectifier;
|
pub mod thinking_budget_rectifier;
|
||||||
pub mod thinking_optimizer;
|
pub mod thinking_optimizer;
|
||||||
pub mod thinking_rectifier;
|
pub mod thinking_rectifier;
|
||||||
|
pub(crate) mod tool_media;
|
||||||
pub(crate) mod types;
|
pub(crate) mod types;
|
||||||
pub mod usage;
|
pub mod usage;
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,14 @@
|
|||||||
//! 实现 Anthropic ↔ OpenAI 格式转换,用于 OpenRouter 支持
|
//! 实现 Anthropic ↔ OpenAI 格式转换,用于 OpenRouter 支持
|
||||||
//! 参考: anthropic-proxy-rs
|
//! 参考: anthropic-proxy-rs
|
||||||
|
|
||||||
use crate::proxy::{error::ProxyError, json_canonical::canonical_json_string};
|
use crate::proxy::{
|
||||||
|
error::ProxyError,
|
||||||
|
json_canonical::canonical_json_string,
|
||||||
|
tool_media::{
|
||||||
|
chat_media_part_from_tool_part, flush_pending_chat_tool_media, plan_chat_tool_output_media,
|
||||||
|
queue_chat_tool_output_media, ToolMediaScope,
|
||||||
|
},
|
||||||
|
};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
const ANTHROPIC_BILLING_HEADER_PREFIX: &str = "x-anthropic-billing-header:";
|
const ANTHROPIC_BILLING_HEADER_PREFIX: &str = "x-anthropic-billing-header:";
|
||||||
@@ -375,6 +382,7 @@ fn convert_message_to_openai(
|
|||||||
if let Some(blocks) = content.as_array() {
|
if let Some(blocks) = content.as_array() {
|
||||||
let mut content_parts = Vec::new();
|
let mut content_parts = Vec::new();
|
||||||
let mut tool_calls = Vec::new();
|
let mut tool_calls = Vec::new();
|
||||||
|
let mut pending_tool_media = Vec::new();
|
||||||
// reasoning_parts: 仅在兼容 Moonshot/Kimi/DeepSeek thinking tool-call 路径时
|
// reasoning_parts: 仅在兼容 Moonshot/Kimi/DeepSeek thinking tool-call 路径时
|
||||||
// 生成 reasoning_content,通用 OpenAI-compatible 路径不发送该非标准字段。
|
// 生成 reasoning_content,通用 OpenAI-compatible 路径不发送该非标准字段。
|
||||||
let mut reasoning_parts = Vec::new();
|
let mut reasoning_parts = Vec::new();
|
||||||
@@ -389,16 +397,10 @@ fn convert_message_to_openai(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"image" => {
|
"image" => {
|
||||||
if let Some(source) = block.get("source") {
|
if let Some(image) =
|
||||||
let media_type = source
|
chat_media_part_from_tool_part(block, ToolMediaScope::ImagesOnly)
|
||||||
.get("media_type")
|
{
|
||||||
.and_then(|m| m.as_str())
|
content_parts.push(image);
|
||||||
.unwrap_or("image/png");
|
|
||||||
let data = source.get("data").and_then(|d| d.as_str()).unwrap_or("");
|
|
||||||
content_parts.push(json!({
|
|
||||||
"type": "image_url",
|
|
||||||
"image_url": {"url": format!("data:{};base64,{}", media_type, data)}
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"tool_use" => {
|
"tool_use" => {
|
||||||
@@ -421,10 +423,22 @@ fn convert_message_to_openai(
|
|||||||
.and_then(|i| i.as_str())
|
.and_then(|i| i.as_str())
|
||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
let content_val = block.get("content");
|
let content_val = block.get("content");
|
||||||
let content_str = match content_val {
|
let media_plan = content_val.cloned().and_then(plan_chat_tool_output_media);
|
||||||
Some(Value::String(s)) => s.clone(),
|
let content_str = if let Some(media_plan) = media_plan {
|
||||||
Some(v) => canonical_json_string(v),
|
queue_chat_tool_output_media(
|
||||||
None => String::new(),
|
&mut pending_tool_media,
|
||||||
|
tool_use_id,
|
||||||
|
media_plan.media_parts,
|
||||||
|
);
|
||||||
|
media_plan.tool_content
|
||||||
|
} else {
|
||||||
|
// Keep the no-media representation exactly equal to
|
||||||
|
// the legacy converter for prompt-cache stability.
|
||||||
|
match content_val {
|
||||||
|
Some(Value::String(s)) => s.clone(),
|
||||||
|
Some(v) => canonical_json_string(v),
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
};
|
};
|
||||||
result.push(json!({
|
result.push(json!({
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
@@ -452,6 +466,11 @@ fn convert_message_to_openai(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chat tool messages cannot carry image parts. Keep parallel tool
|
||||||
|
// results adjacent, then present all extracted media in one user turn
|
||||||
|
// before any ordinary message content from the same Anthropic turn.
|
||||||
|
flush_pending_chat_tool_media(&mut result, &mut pending_tool_media);
|
||||||
|
|
||||||
// 添加带内容和/或工具调用的消息
|
// 添加带内容和/或工具调用的消息
|
||||||
if !content_parts.is_empty() || !tool_calls.is_empty() {
|
if !content_parts.is_empty() || !tool_calls.is_empty() {
|
||||||
let mut msg = json!({"role": role});
|
let mut msg = json!({"role": role});
|
||||||
@@ -1133,6 +1152,164 @@ mod tests {
|
|||||||
assert_eq!(msg["content"], "Sunny, 25°C");
|
assert_eq!(msg["content"], "Sunny, 25°C");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_anthropic_to_openai_no_media_tool_results_keep_legacy_representation() {
|
||||||
|
let raw_json_string = "{ \"status\": \"ok\", \"count\": 2 }";
|
||||||
|
let input = json!({
|
||||||
|
"model": "claude-3-opus",
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": "call_string",
|
||||||
|
"content": raw_json_string
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": "call_array",
|
||||||
|
"content": [{"type": "text", "text": "plain"}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = anthropic_to_openai(input).unwrap();
|
||||||
|
let messages = result["messages"].as_array().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(messages.len(), 2);
|
||||||
|
assert_eq!(messages[0]["content"], raw_json_string);
|
||||||
|
assert_eq!(
|
||||||
|
messages[1]["content"],
|
||||||
|
canonical_json_string(&json!([{"type": "text", "text": "plain"}]))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_anthropic_to_openai_moves_tool_result_image_to_user_message() {
|
||||||
|
let input = json!({
|
||||||
|
"model": "claude-3-opus",
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": "call_image",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "caption"},
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"source": {
|
||||||
|
"type": "base64",
|
||||||
|
"media_type": "image/png",
|
||||||
|
"data": "CLAUDE_CHAT_IMAGE_SENTINEL"
|
||||||
|
},
|
||||||
|
"cache_control": {"type": "ephemeral"},
|
||||||
|
"prompt_cache_breakpoint": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = anthropic_to_openai(input).unwrap();
|
||||||
|
let messages = result["messages"].as_array().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(messages.len(), 2);
|
||||||
|
assert_eq!(messages[0]["role"], "tool");
|
||||||
|
assert_eq!(messages[0]["tool_call_id"], "call_image");
|
||||||
|
assert!(messages[0]["content"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("tool result media moved"));
|
||||||
|
assert!(!messages[0]["content"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("CLAUDE_CHAT_IMAGE_SENTINEL"));
|
||||||
|
assert_eq!(messages[1]["role"], "user");
|
||||||
|
assert_eq!(
|
||||||
|
messages[1]["content"][0]["text"],
|
||||||
|
"[cc-switch: media output of tool call call_image]"
|
||||||
|
);
|
||||||
|
assert_eq!(messages[1]["content"][1]["type"], "image_url");
|
||||||
|
assert!(messages[1]["content"][1].get("cache_control").is_none());
|
||||||
|
assert!(messages[1]["content"][1]
|
||||||
|
.get("prompt_cache_breakpoint")
|
||||||
|
.is_none());
|
||||||
|
assert_eq!(
|
||||||
|
messages[1]["content"][1]["image_url"]["url"],
|
||||||
|
"data:image/png;base64,CLAUDE_CHAT_IMAGE_SENTINEL"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_anthropic_to_openai_batches_parallel_tool_result_media() {
|
||||||
|
let input = json!({
|
||||||
|
"model": "claude-3-opus",
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": "call_1",
|
||||||
|
"content": [{
|
||||||
|
"type": "image",
|
||||||
|
"source": {"type": "base64", "media_type": "image/png", "data": "ONE"}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": "call_2",
|
||||||
|
"content": [{
|
||||||
|
"type": "image",
|
||||||
|
"source": {"type": "base64", "media_type": "image/jpeg", "data": "TWO"}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = anthropic_to_openai(input).unwrap();
|
||||||
|
let messages = result["messages"].as_array().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(messages.len(), 3);
|
||||||
|
assert_eq!(messages[0]["role"], "tool");
|
||||||
|
assert_eq!(messages[1]["role"], "tool");
|
||||||
|
assert_eq!(messages[2]["role"], "user");
|
||||||
|
assert_eq!(messages[2]["content"].as_array().unwrap().len(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_anthropic_to_openai_maps_remote_image_source() {
|
||||||
|
let input = json!({
|
||||||
|
"model": "claude-3-opus",
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{
|
||||||
|
"type": "image",
|
||||||
|
"source": {
|
||||||
|
"type": "url",
|
||||||
|
"url": "https://example.com/image.png"
|
||||||
|
},
|
||||||
|
"cache_control": {"type": "ephemeral"},
|
||||||
|
"prompt_cache_breakpoint": true
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = anthropic_to_openai(input).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result["messages"][0]["content"][0]["image_url"]["url"],
|
||||||
|
"https://example.com/image.png"
|
||||||
|
);
|
||||||
|
assert!(result["messages"][0]["content"][0]
|
||||||
|
.get("cache_control")
|
||||||
|
.is_none());
|
||||||
|
assert!(result["messages"][0]["content"][0]
|
||||||
|
.get("prompt_cache_breakpoint")
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_openai_to_anthropic_simple() {
|
fn test_openai_to_anthropic_simple() {
|
||||||
let input = json!({
|
let input = json!({
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ use super::transform_responses::{sanitize_anthropic_tool_use_input, TOOL_RESULT_
|
|||||||
use crate::proxy::error::ProxyError;
|
use crate::proxy::error::ProxyError;
|
||||||
use crate::proxy::json_canonical::canonical_json_string;
|
use crate::proxy::json_canonical::canonical_json_string;
|
||||||
use crate::proxy::sse::{strip_sse_field, take_sse_block};
|
use crate::proxy::sse::{strip_sse_field, take_sse_block};
|
||||||
|
use crate::proxy::tool_media::{
|
||||||
|
strip_and_clamp_media_from_tool_value, ToolMediaScope, TOOL_RESULT_MEDIA_ATTACHED_MARKER,
|
||||||
|
};
|
||||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::collections::{BTreeMap, HashSet};
|
use std::collections::{BTreeMap, HashSet};
|
||||||
@@ -723,10 +726,12 @@ struct ToolResultContent {
|
|||||||
|
|
||||||
fn tool_result_content_from_responses_item(item: &Value) -> ToolResultContent {
|
fn tool_result_content_from_responses_item(item: &Value) -> ToolResultContent {
|
||||||
match item.get("output") {
|
match item.get("output") {
|
||||||
Some(Value::String(text)) => ToolResultContent {
|
Some(text @ Value::String(_)) => {
|
||||||
content: json!(text),
|
alternate_image_tool_result_content(text).unwrap_or_else(|| ToolResultContent {
|
||||||
is_error: false,
|
content: text.clone(),
|
||||||
},
|
is_error: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
Some(Value::Array(parts)) => {
|
Some(Value::Array(parts)) => {
|
||||||
let mut content = Vec::new();
|
let mut content = Vec::new();
|
||||||
let mut is_error = false;
|
let mut is_error = false;
|
||||||
@@ -761,10 +766,26 @@ fn tool_result_content_from_responses_item(item: &Value) -> ToolResultContent {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => content.push(json!({
|
_ => {
|
||||||
"type":"text",
|
if let Some(alternate) = alternate_image_tool_result_content(part) {
|
||||||
"text":canonical_json_string(part)
|
is_error |= alternate.is_error;
|
||||||
})),
|
match alternate.content {
|
||||||
|
Value::Array(mut blocks) => content.append(&mut blocks),
|
||||||
|
Value::String(text) => {
|
||||||
|
content.push(json!({"type":"text","text":text}))
|
||||||
|
}
|
||||||
|
other => content.push(json!({
|
||||||
|
"type":"text",
|
||||||
|
"text":canonical_json_string(&other)
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
content.push(json!({
|
||||||
|
"type":"text",
|
||||||
|
"text":canonical_json_string(part)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ToolResultContent {
|
ToolResultContent {
|
||||||
@@ -772,10 +793,12 @@ fn tool_result_content_from_responses_item(item: &Value) -> ToolResultContent {
|
|||||||
is_error,
|
is_error,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(value) => ToolResultContent {
|
Some(value) => {
|
||||||
content: json!(canonical_json_string(value)),
|
alternate_image_tool_result_content(value).unwrap_or_else(|| ToolResultContent {
|
||||||
is_error: false,
|
content: json!(canonical_json_string(value)),
|
||||||
},
|
is_error: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
None => ToolResultContent {
|
None => ToolResultContent {
|
||||||
content: json!(canonical_json_string(item)),
|
content: json!(canonical_json_string(item)),
|
||||||
is_error: false,
|
is_error: false,
|
||||||
@@ -783,6 +806,96 @@ fn tool_result_content_from_responses_item(item: &Value) -> ToolResultContent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert image-bearing tool-output variants that are not native Responses
|
||||||
|
/// content blocks. The shared traversal recognizes JSON strings, MCP image
|
||||||
|
/// blocks, Anthropic image blocks, Chat image_url blocks, nested `content`
|
||||||
|
/// wrappers, and whole image data URLs.
|
||||||
|
fn alternate_image_tool_result_content(value: &Value) -> Option<ToolResultContent> {
|
||||||
|
let mut cleaned = value.clone();
|
||||||
|
let replacement_block = json!({
|
||||||
|
"type":"input_text",
|
||||||
|
"text":TOOL_RESULT_MEDIA_ATTACHED_MARKER
|
||||||
|
});
|
||||||
|
let mut chat_media_parts = Vec::new();
|
||||||
|
let replaced = strip_and_clamp_media_from_tool_value(
|
||||||
|
&mut cleaned,
|
||||||
|
&mut chat_media_parts,
|
||||||
|
ToolMediaScope::ImagesOnly,
|
||||||
|
&replacement_block,
|
||||||
|
TOOL_RESULT_MEDIA_ATTACHED_MARKER,
|
||||||
|
);
|
||||||
|
if replaced == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut content = Vec::new();
|
||||||
|
let mut is_error = false;
|
||||||
|
append_sanitized_tool_result_value(&cleaned, &mut content, &mut is_error);
|
||||||
|
content.extend(
|
||||||
|
chat_media_parts
|
||||||
|
.iter()
|
||||||
|
.filter_map(image_block_from_input_image),
|
||||||
|
);
|
||||||
|
|
||||||
|
Some(ToolResultContent {
|
||||||
|
content: Value::Array(content),
|
||||||
|
is_error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_sanitized_tool_result_value(
|
||||||
|
value: &Value,
|
||||||
|
content: &mut Vec<Value>,
|
||||||
|
is_error: &mut bool,
|
||||||
|
) {
|
||||||
|
match value {
|
||||||
|
Value::String(text) => {
|
||||||
|
if text == TOOL_RESULT_ERROR_MARKER {
|
||||||
|
*is_error = true;
|
||||||
|
} else if !text.is_empty() {
|
||||||
|
content.push(json!({"type":"text","text":text}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Array(parts) => {
|
||||||
|
for part in parts {
|
||||||
|
match part.get("type").and_then(Value::as_str) {
|
||||||
|
Some("input_text" | "output_text" | "text") => {
|
||||||
|
if let Some(text) = part.get("text").and_then(Value::as_str) {
|
||||||
|
if text == TOOL_RESULT_ERROR_MARKER {
|
||||||
|
*is_error = true;
|
||||||
|
} else {
|
||||||
|
content.push(json!({"type":"text","text":text}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => content.push(json!({
|
||||||
|
"type":"text",
|
||||||
|
"text":canonical_json_string(part)
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Object(object)
|
||||||
|
if matches!(
|
||||||
|
object.get("type").and_then(Value::as_str),
|
||||||
|
Some("input_text" | "output_text" | "text")
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
if let Some(text) = object.get("text").and_then(Value::as_str) {
|
||||||
|
if text == TOOL_RESULT_ERROR_MARKER {
|
||||||
|
*is_error = true;
|
||||||
|
} else {
|
||||||
|
content.push(json!({"type":"text","text":text}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => content.push(json!({
|
||||||
|
"type":"text",
|
||||||
|
"text":canonical_json_string(other)
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Ensures the first message is a user: compacted/resumed sessions may start with
|
/// Ensures the first message is a user: compacted/resumed sessions may start with
|
||||||
/// assistant or function_call, but Anthropic requires the first to be user, else 400.
|
/// assistant or function_call, but Anthropic requires the first to be user, else 400.
|
||||||
/// An empty array is not handled (the caller decides whether to error).
|
/// An empty array is not handled (the caller decides whether to error).
|
||||||
@@ -1071,8 +1184,12 @@ fn image_block_from_input_image(part: &Value) -> Option<Value> {
|
|||||||
.or_else(|| v.get("url").and_then(|u| u.as_str()).map(str::to_string))
|
.or_else(|| v.get("url").and_then(|u| u.as_str()).map(str::to_string))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if let Some(rest) = url.strip_prefix("data:") {
|
if url
|
||||||
|
.get(..5)
|
||||||
|
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("data:"))
|
||||||
|
{
|
||||||
// data:<media_type>;base64,<data>
|
// data:<media_type>;base64,<data>
|
||||||
|
let rest = &url[5..];
|
||||||
let (meta, data) = rest.split_once(',')?;
|
let (meta, data) = rest.split_once(',')?;
|
||||||
let media_type = meta.split(';').next().unwrap_or("image/png");
|
let media_type = meta.split(';').next().unwrap_or("image/png");
|
||||||
Some(json!({
|
Some(json!({
|
||||||
@@ -1083,7 +1200,13 @@ fn image_block_from_input_image(part: &Value) -> Option<Value> {
|
|||||||
"data": data
|
"data": data
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
} else if url.starts_with("http://") || url.starts_with("https://") {
|
} else if url
|
||||||
|
.get(..7)
|
||||||
|
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("http://"))
|
||||||
|
|| url
|
||||||
|
.get(..8)
|
||||||
|
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://"))
|
||||||
|
{
|
||||||
Some(json!({
|
Some(json!({
|
||||||
"type": "image",
|
"type": "image",
|
||||||
"source": { "type": "url", "url": url }
|
"source": { "type": "url", "url": url }
|
||||||
@@ -2555,6 +2678,80 @@ mod tests {
|
|||||||
assert_eq!(content[1]["type"], "image");
|
assert_eq!(content[1]["type"], "image");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_alternate_mcp_tool_image_is_not_stringified_for_anthropic() {
|
||||||
|
let response = responses_request_to_anthropic(
|
||||||
|
json!({
|
||||||
|
"model": "c",
|
||||||
|
"input": [
|
||||||
|
{"type": "function_call", "call_id": "c1", "name": "inspect", "arguments": "{}"},
|
||||||
|
{"type": "function_call_output", "call_id": "c1", "output": [{
|
||||||
|
"type": "image",
|
||||||
|
"mimeType": "image/webp",
|
||||||
|
"data": "MCP_ANTHROPIC_IMAGE_SENTINEL"
|
||||||
|
}]}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
4096,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let content = &response["messages"][2]["content"][0]["content"];
|
||||||
|
|
||||||
|
assert_eq!(content[0]["type"], "text");
|
||||||
|
assert!(!content[0]["text"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("MCP_ANTHROPIC_IMAGE_SENTINEL"));
|
||||||
|
assert_eq!(content[1]["type"], "image");
|
||||||
|
assert_eq!(content[1]["source"]["media_type"], "image/webp");
|
||||||
|
assert_eq!(content[1]["source"]["data"], "MCP_ANTHROPIC_IMAGE_SENTINEL");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_json_string_nested_tool_image_is_not_text_for_anthropic() {
|
||||||
|
let residual_base64 = "A".repeat(20_000);
|
||||||
|
let encoded_output = json!({
|
||||||
|
"content": [
|
||||||
|
{"type": "input_text", "text": "caption"},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": "data:image/png;base64,STRING_IMAGE_SENTINEL"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{"type": "video", "data": residual_base64}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let response = responses_request_to_anthropic(
|
||||||
|
json!({
|
||||||
|
"model": "c",
|
||||||
|
"input": [
|
||||||
|
{"type": "function_call", "call_id": "c1", "name": "inspect", "arguments": "{}"},
|
||||||
|
{"type": "function_call_output", "call_id": "c1", "output": encoded_output}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
4096,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let content = response["messages"][2]["content"][0]["content"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap();
|
||||||
|
let image = content
|
||||||
|
.iter()
|
||||||
|
.find(|block| block["type"] == "image")
|
||||||
|
.expect("stringified tool image should become an Anthropic image block");
|
||||||
|
|
||||||
|
assert_eq!(image["source"]["data"], "STRING_IMAGE_SENTINEL");
|
||||||
|
assert!(content
|
||||||
|
.iter()
|
||||||
|
.filter_map(|block| block.get("text").and_then(Value::as_str))
|
||||||
|
.all(|text| !text.contains("STRING_IMAGE_SENTINEL")));
|
||||||
|
let serialized = response.to_string();
|
||||||
|
assert!(serialized.contains("[cc-switch: omitted 20000 bytes]"));
|
||||||
|
assert!(!serialized.contains(&"A".repeat(64)));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_structured_tool_output_restores_error_file_and_unknown_parts() {
|
fn test_structured_tool_output_restores_error_file_and_unknown_parts() {
|
||||||
let response = responses_request_to_anthropic(
|
let response = responses_request_to_anthropic(
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ use crate::proxy::{
|
|||||||
canonical_json_string, canonicalize_json_string_if_parseable, canonicalize_tool_arguments,
|
canonical_json_string, canonicalize_json_string_if_parseable, canonicalize_tool_arguments,
|
||||||
short_sha256_hex,
|
short_sha256_hex,
|
||||||
},
|
},
|
||||||
|
tool_media::{
|
||||||
|
chat_file_from_input_file, flush_pending_chat_tool_media, plan_chat_tool_output_media,
|
||||||
|
queue_chat_tool_output_media, strip_and_clamp_media_from_tool_value, ToolMediaScope,
|
||||||
|
TOOL_RESULT_MEDIA_MOVED_MARKER,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
@@ -42,7 +47,6 @@ const CUSTOM_TOOL_INPUT_FIELD: &str = "input";
|
|||||||
const CHAT_TOOL_NAME_MAX_LEN: usize = 64;
|
const CHAT_TOOL_NAME_MAX_LEN: usize = 64;
|
||||||
const CUSTOM_TOOL_INPUT_DESCRIPTION: &str = "Raw string input for the original custom tool. Preserve formatting exactly and follow the original tool definition embedded in the description.";
|
const CUSTOM_TOOL_INPUT_DESCRIPTION: &str = "Raw string input for the original custom tool. Preserve formatting exactly and follow the original tool definition embedded in the description.";
|
||||||
const CUSTOM_TOOL_PRESERVED_METADATA_HEADING: &str = "Original tool definition:";
|
const CUSTOM_TOOL_PRESERVED_METADATA_HEADING: &str = "Original tool definition:";
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub(crate) enum CodexToolKind {
|
pub(crate) enum CodexToolKind {
|
||||||
Function,
|
Function,
|
||||||
@@ -545,6 +549,7 @@ fn append_responses_input_as_chat_messages(
|
|||||||
tool_context: &CodexToolContext,
|
tool_context: &CodexToolContext,
|
||||||
) -> Result<(), ProxyError> {
|
) -> Result<(), ProxyError> {
|
||||||
let mut pending_tool_calls = Vec::new();
|
let mut pending_tool_calls = Vec::new();
|
||||||
|
let mut pending_media = Vec::new();
|
||||||
let mut pending_reasoning: Option<String> = None;
|
let mut pending_reasoning: Option<String> = None;
|
||||||
let mut last_assistant_index: Option<usize> = None;
|
let mut last_assistant_index: Option<usize> = None;
|
||||||
|
|
||||||
@@ -561,6 +566,7 @@ fn append_responses_input_as_chat_messages(
|
|||||||
item,
|
item,
|
||||||
messages,
|
messages,
|
||||||
&mut pending_tool_calls,
|
&mut pending_tool_calls,
|
||||||
|
&mut pending_media,
|
||||||
&mut pending_reasoning,
|
&mut pending_reasoning,
|
||||||
&mut last_assistant_index,
|
&mut last_assistant_index,
|
||||||
tool_context,
|
tool_context,
|
||||||
@@ -572,6 +578,7 @@ fn append_responses_input_as_chat_messages(
|
|||||||
input,
|
input,
|
||||||
messages,
|
messages,
|
||||||
&mut pending_tool_calls,
|
&mut pending_tool_calls,
|
||||||
|
&mut pending_media,
|
||||||
&mut pending_reasoning,
|
&mut pending_reasoning,
|
||||||
&mut last_assistant_index,
|
&mut last_assistant_index,
|
||||||
tool_context,
|
tool_context,
|
||||||
@@ -580,9 +587,14 @@ fn append_responses_input_as_chat_messages(
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If a later assistant tool-call batch was accumulated after an earlier
|
||||||
|
// media-bearing result, the synthetic user media belongs before that next
|
||||||
|
// assistant turn.
|
||||||
|
flush_pending_chat_tool_media(messages, &mut pending_media);
|
||||||
flush_pending_tool_calls(
|
flush_pending_tool_calls(
|
||||||
messages,
|
messages,
|
||||||
&mut pending_tool_calls,
|
&mut pending_tool_calls,
|
||||||
|
&mut pending_media,
|
||||||
&mut pending_reasoning,
|
&mut pending_reasoning,
|
||||||
&mut last_assistant_index,
|
&mut last_assistant_index,
|
||||||
);
|
);
|
||||||
@@ -603,6 +615,7 @@ fn append_responses_item_as_chat_message(
|
|||||||
item: &Value,
|
item: &Value,
|
||||||
messages: &mut Vec<Value>,
|
messages: &mut Vec<Value>,
|
||||||
pending_tool_calls: &mut Vec<Value>,
|
pending_tool_calls: &mut Vec<Value>,
|
||||||
|
pending_media: &mut Vec<Value>,
|
||||||
pending_reasoning: &mut Option<String>,
|
pending_reasoning: &mut Option<String>,
|
||||||
last_assistant_index: &mut Option<usize>,
|
last_assistant_index: &mut Option<usize>,
|
||||||
tool_context: &CodexToolContext,
|
tool_context: &CodexToolContext,
|
||||||
@@ -628,14 +641,26 @@ fn append_responses_item_as_chat_message(
|
|||||||
flush_pending_tool_calls(
|
flush_pending_tool_calls(
|
||||||
messages,
|
messages,
|
||||||
pending_tool_calls,
|
pending_tool_calls,
|
||||||
|
pending_media,
|
||||||
pending_reasoning,
|
pending_reasoning,
|
||||||
last_assistant_index,
|
last_assistant_index,
|
||||||
);
|
);
|
||||||
let call_id = item.get("call_id").and_then(|v| v.as_str()).unwrap_or("");
|
let call_id = item.get("call_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
let output = match item.get("output") {
|
let media_plan = item
|
||||||
Some(Value::String(s)) => canonicalize_json_string_if_parseable(s),
|
.get("output")
|
||||||
Some(v) => canonical_json_string(v),
|
.cloned()
|
||||||
None => String::new(),
|
.and_then(plan_chat_tool_output_media);
|
||||||
|
let output = if let Some(media_plan) = media_plan {
|
||||||
|
queue_chat_tool_output_media(pending_media, call_id, media_plan.media_parts);
|
||||||
|
media_plan.tool_content
|
||||||
|
} else {
|
||||||
|
// Cache-sensitive no-media fallback: keep these expressions
|
||||||
|
// byte-for-byte equivalent to the pre-fix conversion.
|
||||||
|
match item.get("output") {
|
||||||
|
Some(Value::String(s)) => canonicalize_json_string_if_parseable(s),
|
||||||
|
Some(v) => canonical_json_string(v),
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
};
|
};
|
||||||
messages.push(json!({
|
messages.push(json!({
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
@@ -647,11 +672,36 @@ fn append_responses_item_as_chat_message(
|
|||||||
flush_pending_tool_calls(
|
flush_pending_tool_calls(
|
||||||
messages,
|
messages,
|
||||||
pending_tool_calls,
|
pending_tool_calls,
|
||||||
|
pending_media,
|
||||||
pending_reasoning,
|
pending_reasoning,
|
||||||
last_assistant_index,
|
last_assistant_index,
|
||||||
);
|
);
|
||||||
let call_id = item.get("call_id").and_then(|v| v.as_str()).unwrap_or("");
|
let call_id = item.get("call_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
let output = canonical_json_string(item);
|
let mut transformed_item = item.clone();
|
||||||
|
let replacement_block = json!({
|
||||||
|
"type": "text",
|
||||||
|
"text": TOOL_RESULT_MEDIA_MOVED_MARKER
|
||||||
|
});
|
||||||
|
let mut media_parts = Vec::new();
|
||||||
|
let replaced = transformed_item
|
||||||
|
.get_mut("output")
|
||||||
|
.map(|output| {
|
||||||
|
strip_and_clamp_media_from_tool_value(
|
||||||
|
output,
|
||||||
|
&mut media_parts,
|
||||||
|
ToolMediaScope::AllSupported,
|
||||||
|
&replacement_block,
|
||||||
|
TOOL_RESULT_MEDIA_MOVED_MARKER,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
let output = if replaced > 0 {
|
||||||
|
queue_chat_tool_output_media(pending_media, call_id, media_parts);
|
||||||
|
canonical_json_string(&transformed_item)
|
||||||
|
} else {
|
||||||
|
// Preserve the legacy whole-item representation exactly.
|
||||||
|
canonical_json_string(item)
|
||||||
|
};
|
||||||
messages.push(json!({
|
messages.push(json!({
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
"tool_call_id": call_id,
|
"tool_call_id": call_id,
|
||||||
@@ -672,9 +722,14 @@ fn append_responses_item_as_chat_message(
|
|||||||
flush_pending_tool_calls(
|
flush_pending_tool_calls(
|
||||||
messages,
|
messages,
|
||||||
pending_tool_calls,
|
pending_tool_calls,
|
||||||
|
pending_media,
|
||||||
pending_reasoning,
|
pending_reasoning,
|
||||||
last_assistant_index,
|
last_assistant_index,
|
||||||
);
|
);
|
||||||
|
// `flush_pending_tool_calls` intentionally returns early when
|
||||||
|
// there is no new assistant batch. A previous tool result may
|
||||||
|
// still have media waiting, so flush it before this new message.
|
||||||
|
flush_pending_chat_tool_media(messages, pending_media);
|
||||||
let role = item
|
let role = item
|
||||||
.get("role")
|
.get("role")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -705,13 +760,15 @@ fn append_responses_item_as_chat_message(
|
|||||||
messages.push(message);
|
messages.push(message);
|
||||||
}
|
}
|
||||||
Some("message") | None => {
|
Some("message") | None => {
|
||||||
flush_pending_tool_calls(
|
|
||||||
messages,
|
|
||||||
pending_tool_calls,
|
|
||||||
pending_reasoning,
|
|
||||||
last_assistant_index,
|
|
||||||
);
|
|
||||||
if item.get("role").is_some() || item.get("content").is_some() {
|
if item.get("role").is_some() || item.get("content").is_some() {
|
||||||
|
flush_pending_tool_calls(
|
||||||
|
messages,
|
||||||
|
pending_tool_calls,
|
||||||
|
pending_media,
|
||||||
|
pending_reasoning,
|
||||||
|
last_assistant_index,
|
||||||
|
);
|
||||||
|
flush_pending_chat_tool_media(messages, pending_media);
|
||||||
let message = responses_message_item_to_chat_message(
|
let message = responses_message_item_to_chat_message(
|
||||||
item,
|
item,
|
||||||
pending_reasoning,
|
pending_reasoning,
|
||||||
@@ -720,16 +777,28 @@ fn append_responses_item_as_chat_message(
|
|||||||
);
|
);
|
||||||
update_last_assistant_index(messages, &message, last_assistant_index);
|
update_last_assistant_index(messages, &message, last_assistant_index);
|
||||||
messages.push(message);
|
messages.push(message);
|
||||||
|
} else if pending_media.is_empty() {
|
||||||
|
// Preserve legacy no-media ordering: inert message-like items
|
||||||
|
// used to close a pending tool-call batch.
|
||||||
|
flush_pending_tool_calls(
|
||||||
|
messages,
|
||||||
|
pending_tool_calls,
|
||||||
|
pending_media,
|
||||||
|
pending_reasoning,
|
||||||
|
last_assistant_index,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
flush_pending_tool_calls(
|
|
||||||
messages,
|
|
||||||
pending_tool_calls,
|
|
||||||
pending_reasoning,
|
|
||||||
last_assistant_index,
|
|
||||||
);
|
|
||||||
if item.get("role").is_some() || item.get("content").is_some() {
|
if item.get("role").is_some() || item.get("content").is_some() {
|
||||||
|
flush_pending_tool_calls(
|
||||||
|
messages,
|
||||||
|
pending_tool_calls,
|
||||||
|
pending_media,
|
||||||
|
pending_reasoning,
|
||||||
|
last_assistant_index,
|
||||||
|
);
|
||||||
|
flush_pending_chat_tool_media(messages, pending_media);
|
||||||
let message = responses_message_item_to_chat_message(
|
let message = responses_message_item_to_chat_message(
|
||||||
item,
|
item,
|
||||||
pending_reasoning,
|
pending_reasoning,
|
||||||
@@ -738,6 +807,16 @@ fn append_responses_item_as_chat_message(
|
|||||||
);
|
);
|
||||||
update_last_assistant_index(messages, &message, last_assistant_index);
|
update_last_assistant_index(messages, &message, last_assistant_index);
|
||||||
messages.push(message);
|
messages.push(message);
|
||||||
|
} else if pending_media.is_empty() {
|
||||||
|
// Preserve legacy no-media ordering without letting an inert
|
||||||
|
// unknown item flush a media-bearing result batch.
|
||||||
|
flush_pending_tool_calls(
|
||||||
|
messages,
|
||||||
|
pending_tool_calls,
|
||||||
|
pending_media,
|
||||||
|
pending_reasoning,
|
||||||
|
last_assistant_index,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -748,6 +827,7 @@ fn append_responses_item_as_chat_message(
|
|||||||
fn flush_pending_tool_calls(
|
fn flush_pending_tool_calls(
|
||||||
messages: &mut Vec<Value>,
|
messages: &mut Vec<Value>,
|
||||||
pending_tool_calls: &mut Vec<Value>,
|
pending_tool_calls: &mut Vec<Value>,
|
||||||
|
pending_media: &mut Vec<Value>,
|
||||||
pending_reasoning: &mut Option<String>,
|
pending_reasoning: &mut Option<String>,
|
||||||
last_assistant_index: &mut Option<usize>,
|
last_assistant_index: &mut Option<usize>,
|
||||||
) {
|
) {
|
||||||
@@ -755,6 +835,10 @@ fn flush_pending_tool_calls(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Media from the preceding tool-result batch must be presented before a
|
||||||
|
// new assistant tool-call turn. Consecutive outputs do not enter here
|
||||||
|
// because `pending_tool_calls` is empty after the first output.
|
||||||
|
flush_pending_chat_tool_media(messages, pending_media);
|
||||||
let mut message = json!({
|
let mut message = json!({
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": null,
|
"content": null,
|
||||||
@@ -1057,18 +1141,7 @@ fn responses_content_to_chat_content(_role: &str, content: &Value) -> Value {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn responses_input_file_to_chat_file(part: &Value) -> Option<Value> {
|
fn responses_input_file_to_chat_file(part: &Value) -> Option<Value> {
|
||||||
let mut file = serde_json::Map::new();
|
chat_file_from_input_file(part)
|
||||||
let has_supported_file_ref = part.get("file_id").is_some() || part.get("file_data").is_some();
|
|
||||||
if !has_supported_file_ref {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
for key in ["file_id", "file_data", "filename"] {
|
|
||||||
if let Some(value) = part.get(key) {
|
|
||||||
file.insert(key.to_string(), value.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(Value::Object(file))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collect_tool_search_output_tools(value: &Value, context: &mut CodexToolContext) {
|
fn collect_tool_search_output_tools(value: &Value, context: &mut CodexToolContext) {
|
||||||
@@ -1834,6 +1907,50 @@ pub fn chat_error_to_response_error(body: Option<&Value>) -> Value {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||||
|
|
||||||
|
fn large_test_image_data_url() -> String {
|
||||||
|
let bytes = b"CC_SWITCH_TOOL_MEDIA_SENTINEL".repeat(400);
|
||||||
|
format!("data:image/png;base64,{}", STANDARD.encode(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn message_roles(result: &Value) -> Vec<&str> {
|
||||||
|
result["messages"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|message| message.get("role").and_then(Value::as_str))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_function_call(call_id: &str) -> Value {
|
||||||
|
json!({
|
||||||
|
"type": "function_call",
|
||||||
|
"call_id": call_id,
|
||||||
|
"name": "view_image",
|
||||||
|
"arguments": "{}"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_function_output(call_id: &str, output: Value) -> Value {
|
||||||
|
json!({
|
||||||
|
"type": "function_call_output",
|
||||||
|
"call_id": call_id,
|
||||||
|
"output": output
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert_test_input(items: Vec<Value>) -> Value {
|
||||||
|
responses_to_chat_completions(json!({
|
||||||
|
"model": "kimi-k3",
|
||||||
|
"input": items
|
||||||
|
}))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn result_messages(result: &Value) -> &[Value] {
|
||||||
|
result["messages"].as_array().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn responses_request_with_stream_injects_include_usage() {
|
fn responses_request_with_stream_injects_include_usage() {
|
||||||
@@ -3061,6 +3178,577 @@ mod tests {
|
|||||||
assert_eq!(messages[1]["content"], "plain text result");
|
assert_eq!(messages[1]["content"], "plain text result");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn responses_request_to_chat_moves_tool_image_to_synthetic_user_message() {
|
||||||
|
let data_url = large_test_image_data_url();
|
||||||
|
let result = convert_test_input(vec![
|
||||||
|
test_function_call("call_image"),
|
||||||
|
test_function_output(
|
||||||
|
"call_image",
|
||||||
|
json!([
|
||||||
|
{"type": "input_text", "text": "screenshot follows"},
|
||||||
|
{"type": "input_image", "image_url": data_url.clone()}
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
let messages = result_messages(&result);
|
||||||
|
|
||||||
|
assert_eq!(message_roles(&result), vec!["assistant", "tool", "user"]);
|
||||||
|
assert!(messages[1]["content"].is_string());
|
||||||
|
let tool_content: Value =
|
||||||
|
serde_json::from_str(messages[1]["content"].as_str().unwrap()).unwrap();
|
||||||
|
assert_eq!(tool_content[0]["text"], "screenshot follows");
|
||||||
|
assert_eq!(tool_content[1]["type"], "text");
|
||||||
|
assert_eq!(tool_content[1]["text"], TOOL_RESULT_MEDIA_MOVED_MARKER);
|
||||||
|
assert!(!messages[1]["content"].as_str().unwrap().contains(&data_url));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
messages[2]["content"][0]["text"],
|
||||||
|
"[cc-switch: media output of tool call call_image]"
|
||||||
|
);
|
||||||
|
assert_eq!(messages[2]["content"][1]["type"], "image_url");
|
||||||
|
assert_eq!(messages[2]["content"][1]["image_url"]["url"], data_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn responses_request_to_chat_groups_parallel_media_after_all_tool_outputs() {
|
||||||
|
let first_url = large_test_image_data_url();
|
||||||
|
let second_payload = "MCP_TOOL_MEDIA_SENTINEL";
|
||||||
|
let result = convert_test_input(vec![
|
||||||
|
test_function_call("call_1"),
|
||||||
|
test_function_call("call_2"),
|
||||||
|
test_function_output(
|
||||||
|
"call_1",
|
||||||
|
json!({"type": "input_image", "image_url": first_url.clone()}),
|
||||||
|
),
|
||||||
|
json!({
|
||||||
|
"type": "reasoning",
|
||||||
|
"summary": [{"type": "summary_text", "text": "keep outputs adjacent"}]
|
||||||
|
}),
|
||||||
|
test_function_output(
|
||||||
|
"call_2",
|
||||||
|
json!({
|
||||||
|
"type": "image",
|
||||||
|
"mimeType": "image/webp",
|
||||||
|
"data": second_payload
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
let messages = result_messages(&result);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
message_roles(&result),
|
||||||
|
vec!["assistant", "tool", "tool", "user"]
|
||||||
|
);
|
||||||
|
assert_eq!(messages[0]["reasoning_content"], "keep outputs adjacent");
|
||||||
|
assert_ne!(messages[0]["reasoning_content"], "tool call");
|
||||||
|
assert_eq!(messages[1]["tool_call_id"], "call_1");
|
||||||
|
assert_eq!(messages[2]["tool_call_id"], "call_2");
|
||||||
|
assert!(messages[1]["content"].is_string());
|
||||||
|
assert!(messages[2]["content"].is_string());
|
||||||
|
assert!(!messages[1]["content"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains(&first_url));
|
||||||
|
assert!(!messages[2]["content"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains(second_payload));
|
||||||
|
assert_eq!(messages[3]["content"].as_array().unwrap().len(), 4);
|
||||||
|
assert_eq!(
|
||||||
|
messages[3]["content"][3]["image_url"]["url"],
|
||||||
|
format!("data:image/webp;base64,{second_payload}")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn responses_request_to_chat_flushes_media_before_next_tool_call_batch() {
|
||||||
|
let result = convert_test_input(vec![
|
||||||
|
test_function_call("call_1"),
|
||||||
|
test_function_output(
|
||||||
|
"call_1",
|
||||||
|
json!({
|
||||||
|
"type": "input_image",
|
||||||
|
"image_url": large_test_image_data_url()
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
test_function_call("call_2"),
|
||||||
|
test_function_output("call_2", json!("second result")),
|
||||||
|
]);
|
||||||
|
let messages = result_messages(&result);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
message_roles(&result),
|
||||||
|
vec!["assistant", "tool", "user", "assistant", "tool"]
|
||||||
|
);
|
||||||
|
assert_eq!(messages[0]["tool_calls"][0]["id"], "call_1");
|
||||||
|
assert_eq!(messages[3]["tool_calls"][0]["id"], "call_2");
|
||||||
|
assert_eq!(messages[4]["tool_call_id"], "call_2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn responses_request_to_chat_flushes_media_before_real_user_messages() {
|
||||||
|
let boundaries = [
|
||||||
|
json!({"type": "input_text", "text": "continue"}),
|
||||||
|
json!({"type": "future_message", "role": "user", "content": "continue"}),
|
||||||
|
];
|
||||||
|
|
||||||
|
for boundary in boundaries {
|
||||||
|
let result = convert_test_input(vec![
|
||||||
|
test_function_call("call_1"),
|
||||||
|
test_function_output(
|
||||||
|
"call_1",
|
||||||
|
json!({
|
||||||
|
"type": "input_image",
|
||||||
|
"image_url": large_test_image_data_url()
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
boundary,
|
||||||
|
]);
|
||||||
|
let messages = result_messages(&result);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
message_roles(&result),
|
||||||
|
vec!["assistant", "tool", "user", "user"]
|
||||||
|
);
|
||||||
|
assert!(messages[2]["content"].is_array());
|
||||||
|
assert_eq!(messages[3]["role"], "user");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn responses_request_to_chat_handles_raw_data_url_thresholds() {
|
||||||
|
let large = large_test_image_data_url();
|
||||||
|
let large_result = convert_test_input(vec![
|
||||||
|
test_function_call("call_large"),
|
||||||
|
test_function_output("call_large", Value::String(large.clone())),
|
||||||
|
]);
|
||||||
|
let large_messages = result_messages(&large_result);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
message_roles(&large_result),
|
||||||
|
vec!["assistant", "tool", "user"]
|
||||||
|
);
|
||||||
|
assert_eq!(large_messages[1]["content"], TOOL_RESULT_MEDIA_MOVED_MARKER);
|
||||||
|
assert_eq!(large_messages[2]["content"][1]["image_url"]["url"], large);
|
||||||
|
|
||||||
|
let small = "data:image/png;base64,YWJj";
|
||||||
|
let small_result = convert_test_input(vec![
|
||||||
|
test_function_call("call_small"),
|
||||||
|
test_function_output("call_small", json!(small)),
|
||||||
|
]);
|
||||||
|
assert_eq!(message_roles(&small_result), vec!["assistant", "tool"]);
|
||||||
|
assert_eq!(small_result["messages"][1]["content"], small);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn responses_request_to_chat_maps_supported_structured_image_shapes() {
|
||||||
|
let cases = vec![
|
||||||
|
(
|
||||||
|
json!({
|
||||||
|
"type": "input_image",
|
||||||
|
"image_url": {"url": "https://example.com/input.png"},
|
||||||
|
"detail": "high"
|
||||||
|
}),
|
||||||
|
"https://example.com/input.png",
|
||||||
|
Some("high"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!({
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": "https://example.com/chat-string.png"
|
||||||
|
}),
|
||||||
|
"https://example.com/chat-string.png",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!({
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": "https://example.com/chat-object.png",
|
||||||
|
"detail": "low"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
"https://example.com/chat-object.png",
|
||||||
|
Some("low"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!({"image_url": "data:image/gif;base64,LOOSE_SENTINEL"}),
|
||||||
|
"data:image/gif;base64,LOOSE_SENTINEL",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!({
|
||||||
|
"type": "image",
|
||||||
|
"source": {
|
||||||
|
"media_type": "image/jpeg",
|
||||||
|
"data": "ANTHROPIC_SENTINEL"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
"data:image/jpeg;base64,ANTHROPIC_SENTINEL",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!({
|
||||||
|
"type": "image",
|
||||||
|
"mimeType": "image/webp",
|
||||||
|
"data": "MCP_SENTINEL"
|
||||||
|
}),
|
||||||
|
"data:image/webp;base64,MCP_SENTINEL",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (index, (output, expected_url, expected_detail)) in cases.into_iter().enumerate() {
|
||||||
|
let call_id = format!("call_shape_{index}");
|
||||||
|
let result = convert_test_input(vec![
|
||||||
|
test_function_call(&call_id),
|
||||||
|
test_function_output(&call_id, output),
|
||||||
|
]);
|
||||||
|
let image = &result["messages"][2]["content"][1];
|
||||||
|
|
||||||
|
assert_eq!(message_roles(&result), vec!["assistant", "tool", "user"]);
|
||||||
|
assert_eq!(image["type"], "image_url");
|
||||||
|
assert_eq!(image["image_url"]["url"], expected_url);
|
||||||
|
match expected_detail {
|
||||||
|
Some(detail) => assert_eq!(image["image_url"]["detail"], detail),
|
||||||
|
None => assert!(image["image_url"].get("detail").is_none()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn responses_request_to_chat_extracts_media_from_json_string_and_content_wrapper() {
|
||||||
|
let output = json!({
|
||||||
|
"content": [
|
||||||
|
{"type": "input_text", "text": "MCP response"},
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"mimeType": "image/png",
|
||||||
|
"data": "STRING_MCP_SENTINEL"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let result = convert_test_input(vec![
|
||||||
|
test_function_call("call_string"),
|
||||||
|
test_function_output("call_string", Value::String(output)),
|
||||||
|
]);
|
||||||
|
let messages = result_messages(&result);
|
||||||
|
|
||||||
|
assert_eq!(message_roles(&result), vec!["assistant", "tool", "user"]);
|
||||||
|
let tool_content: Value =
|
||||||
|
serde_json::from_str(messages[1]["content"].as_str().unwrap()).unwrap();
|
||||||
|
assert_eq!(tool_content["content"][0]["text"], "MCP response");
|
||||||
|
assert_eq!(
|
||||||
|
tool_content["content"][1]["text"],
|
||||||
|
TOOL_RESULT_MEDIA_MOVED_MARKER
|
||||||
|
);
|
||||||
|
assert!(!messages[1]["content"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("STRING_MCP_SENTINEL"));
|
||||||
|
assert_eq!(
|
||||||
|
messages[2]["content"][1]["image_url"]["url"],
|
||||||
|
"data:image/png;base64,STRING_MCP_SENTINEL"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn responses_request_to_chat_extracts_tool_files_and_audio() {
|
||||||
|
let result = convert_test_input(vec![
|
||||||
|
test_function_call("call_media"),
|
||||||
|
test_function_output(
|
||||||
|
"call_media",
|
||||||
|
json!({
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "input_file",
|
||||||
|
"file_id": "file_123",
|
||||||
|
"filename": "report.pdf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "input_audio",
|
||||||
|
"input_audio": {"data": "AUDIO_SENTINEL", "format": "wav"}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
let messages = result_messages(&result);
|
||||||
|
|
||||||
|
assert_eq!(message_roles(&result), vec!["assistant", "tool", "user"]);
|
||||||
|
assert_eq!(messages[2]["content"][1]["type"], "file");
|
||||||
|
assert_eq!(messages[2]["content"][1]["file"]["file_id"], "file_123");
|
||||||
|
assert_eq!(messages[2]["content"][2]["type"], "input_audio");
|
||||||
|
assert_eq!(
|
||||||
|
messages[2]["content"][2]["input_audio"]["data"],
|
||||||
|
"AUDIO_SENTINEL"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn responses_request_to_chat_extracts_custom_and_tool_search_output_media() {
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
json!({
|
||||||
|
"type": "custom_tool_call",
|
||||||
|
"call_id": "call_custom",
|
||||||
|
"name": "render",
|
||||||
|
"input": "draw"
|
||||||
|
}),
|
||||||
|
json!({
|
||||||
|
"type": "custom_tool_call_output",
|
||||||
|
"call_id": "call_custom",
|
||||||
|
"status": "completed",
|
||||||
|
"output": {
|
||||||
|
"content": [{
|
||||||
|
"type": "input_image",
|
||||||
|
"image_url": "data:image/png;base64,CUSTOM_SENTINEL"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!({
|
||||||
|
"type": "tool_search_call",
|
||||||
|
"call_id": "call_search",
|
||||||
|
"arguments": {"query": "image tool"}
|
||||||
|
}),
|
||||||
|
json!({
|
||||||
|
"type": "tool_search_output",
|
||||||
|
"call_id": "call_search",
|
||||||
|
"status": "completed",
|
||||||
|
"output": {
|
||||||
|
"content": [{
|
||||||
|
"type": "image",
|
||||||
|
"mimeType": "image/png",
|
||||||
|
"data": "SEARCH_SENTINEL"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (call, output) in cases {
|
||||||
|
let expected_type = output["type"].as_str().unwrap().to_string();
|
||||||
|
let result = convert_test_input(vec![call, output]);
|
||||||
|
let messages = result_messages(&result);
|
||||||
|
let tool_content: Value =
|
||||||
|
serde_json::from_str(messages[1]["content"].as_str().unwrap()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(message_roles(&result), vec!["assistant", "tool", "user"]);
|
||||||
|
assert_eq!(tool_content["type"], expected_type);
|
||||||
|
assert_eq!(tool_content["status"], "completed");
|
||||||
|
assert_eq!(
|
||||||
|
tool_content["output"]["content"][0]["text"],
|
||||||
|
TOOL_RESULT_MEDIA_MOVED_MARKER
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn responses_request_to_chat_clamps_stringified_custom_output_residual_base64() {
|
||||||
|
let encoded_output = json!({
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "input_image",
|
||||||
|
"image_url": "data:image/png;base64,CUSTOM_STRING_IMAGE_SENTINEL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "video",
|
||||||
|
"data": "A".repeat(20_000)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let result = convert_test_input(vec![
|
||||||
|
json!({
|
||||||
|
"type": "custom_tool_call",
|
||||||
|
"call_id": "call_custom_string",
|
||||||
|
"name": "render",
|
||||||
|
"input": "draw"
|
||||||
|
}),
|
||||||
|
json!({
|
||||||
|
"type": "custom_tool_call_output",
|
||||||
|
"call_id": "call_custom_string",
|
||||||
|
"status": "completed",
|
||||||
|
"output": encoded_output
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
let messages = result_messages(&result);
|
||||||
|
let tool_item: Value =
|
||||||
|
serde_json::from_str(messages[1]["content"].as_str().unwrap()).unwrap();
|
||||||
|
let rewritten = tool_item["output"].as_str().unwrap();
|
||||||
|
|
||||||
|
assert!(rewritten.contains("[cc-switch: omitted 20000 bytes]"));
|
||||||
|
assert!(!rewritten.contains(&"A".repeat(64)));
|
||||||
|
assert!(!rewritten.contains("CUSTOM_STRING_IMAGE_SENTINEL"));
|
||||||
|
assert_eq!(messages[2]["content"][1]["type"], "image_url");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn responses_request_to_chat_rejects_false_positive_media_shapes() {
|
||||||
|
let outputs = [
|
||||||
|
json!({"type": "image", "name": "business metadata"}),
|
||||||
|
json!({
|
||||||
|
"type": "image",
|
||||||
|
"mimeType": "text/plain",
|
||||||
|
"data": "NOT_AN_IMAGE"
|
||||||
|
}),
|
||||||
|
json!({
|
||||||
|
"image_url": {
|
||||||
|
"url": "https://example.com/search-thumbnail.png"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (index, output) in outputs.into_iter().enumerate() {
|
||||||
|
let call_id = format!("call_false_positive_{index}");
|
||||||
|
let expected = canonical_json_string(&output);
|
||||||
|
let result = convert_test_input(vec![
|
||||||
|
test_function_call(&call_id),
|
||||||
|
test_function_output(&call_id, output),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(message_roles(&result), vec!["assistant", "tool"]);
|
||||||
|
assert_eq!(result["messages"][1]["content"], expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn responses_request_to_chat_keeps_no_media_tool_output_bytes_stable() {
|
||||||
|
let cases = [
|
||||||
|
(Some(json!("plain text")), "plain text".to_string()),
|
||||||
|
(
|
||||||
|
Some(json!("{ \"z\": true, \"a\": [2, 1] }")),
|
||||||
|
r#"{"a":[2,1],"z":true}"#.to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Some(json!(["main.rs", "lib.rs"])),
|
||||||
|
r#"["main.rs","lib.rs"]"#.to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Some(json!({"z": true, "a": [2, 1]})),
|
||||||
|
r#"{"a":[2,1],"z":true}"#.to_string(),
|
||||||
|
),
|
||||||
|
(Some(json!([])), "[]".to_string()),
|
||||||
|
(None, String::new()),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (index, (output, expected)) in cases.into_iter().enumerate() {
|
||||||
|
let call_id = format!("call_stable_{index}");
|
||||||
|
let mut item = json!({
|
||||||
|
"type": "function_call_output",
|
||||||
|
"call_id": call_id
|
||||||
|
});
|
||||||
|
if let Some(output) = output {
|
||||||
|
item["output"] = output;
|
||||||
|
}
|
||||||
|
let result = convert_test_input(vec![test_function_call(&call_id), item]);
|
||||||
|
|
||||||
|
assert_eq!(message_roles(&result), vec!["assistant", "tool"]);
|
||||||
|
assert_eq!(result["messages"][1]["content"], expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
for item in [
|
||||||
|
json!({
|
||||||
|
"type": "custom_tool_call_output",
|
||||||
|
"call_id": "call_custom",
|
||||||
|
"status": "completed",
|
||||||
|
"output": {"text": "unchanged"}
|
||||||
|
}),
|
||||||
|
json!({
|
||||||
|
"type": "tool_search_output",
|
||||||
|
"call_id": "call_search",
|
||||||
|
"status": "completed",
|
||||||
|
"output": []
|
||||||
|
}),
|
||||||
|
] {
|
||||||
|
let expected = canonical_json_string(&item);
|
||||||
|
let result = convert_test_input(vec![item]);
|
||||||
|
assert_eq!(result["messages"][0]["content"], expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn responses_request_to_chat_preserves_legacy_unknown_item_batch_boundary_without_media() {
|
||||||
|
let result = convert_test_input(vec![
|
||||||
|
test_function_call("call_1"),
|
||||||
|
json!({"type": "future_metadata", "value": 1}),
|
||||||
|
json!({
|
||||||
|
"type": "reasoning",
|
||||||
|
"summary": [{"type": "summary_text", "text": "second batch reasoning"}]
|
||||||
|
}),
|
||||||
|
test_function_call("call_2"),
|
||||||
|
test_function_output("call_1", json!("first result")),
|
||||||
|
test_function_output("call_2", json!("second result")),
|
||||||
|
]);
|
||||||
|
let messages = result_messages(&result);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
message_roles(&result),
|
||||||
|
vec!["assistant", "assistant", "tool", "tool"]
|
||||||
|
);
|
||||||
|
assert_eq!(messages[0]["tool_calls"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(messages[0]["tool_calls"][0]["id"], "call_1");
|
||||||
|
assert_eq!(messages[0]["reasoning_content"], "tool call");
|
||||||
|
assert_eq!(messages[1]["tool_calls"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(messages[1]["tool_calls"][0]["id"], "call_2");
|
||||||
|
assert_eq!(messages[1]["reasoning_content"], "second batch reasoning");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn responses_request_to_chat_clamps_only_residual_base64ish_strings() {
|
||||||
|
let data_url = large_test_image_data_url();
|
||||||
|
let long_text = format!("{}end", "ordinary OCR text with spaces. ".repeat(3_500));
|
||||||
|
let residual_base64 = "A".repeat(20_000);
|
||||||
|
let encoded_output = json!([
|
||||||
|
{"type": "input_image", "image_url": data_url.clone()},
|
||||||
|
{"type": "text", "text": long_text.clone()},
|
||||||
|
{"type": "video", "data": residual_base64}
|
||||||
|
])
|
||||||
|
.to_string();
|
||||||
|
let result = convert_test_input(vec![
|
||||||
|
test_function_call("call_clamp"),
|
||||||
|
test_function_output("call_clamp", json!(encoded_output)),
|
||||||
|
]);
|
||||||
|
let tool_content_text = result["messages"][1]["content"].as_str().unwrap();
|
||||||
|
let tool_content: Value = serde_json::from_str(tool_content_text).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(tool_content[1]["text"], long_text);
|
||||||
|
assert!(tool_content[2]["data"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("[cc-switch: omitted 20000 bytes]"));
|
||||||
|
assert!(!tool_content_text.contains(&data_url));
|
||||||
|
assert!(!tool_content_text.contains("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn responses_request_to_chat_media_conversion_is_deterministic() {
|
||||||
|
let input = json!({
|
||||||
|
"model": "kimi-k3",
|
||||||
|
"input": [
|
||||||
|
test_function_call("call_repeat"),
|
||||||
|
test_function_output(
|
||||||
|
"call_repeat",
|
||||||
|
json!({
|
||||||
|
"content": [{
|
||||||
|
"type": "input_image",
|
||||||
|
"image_url": large_test_image_data_url()
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
let first = responses_to_chat_completions(input.clone()).unwrap();
|
||||||
|
let second = responses_to_chat_completions(input).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(first, second);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn chat_response_to_responses_maps_text_tool_calls_and_usage() {
|
fn chat_response_to_responses_maps_text_tool_calls_and_usage() {
|
||||||
let input = json!({
|
let input = json!({
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
use super::gemini_schema::build_gemini_function_declaration;
|
use super::gemini_schema::build_gemini_function_declaration;
|
||||||
use super::gemini_shadow::{GeminiAssistantTurn, GeminiShadowStore, GeminiToolCallMeta};
|
use super::gemini_shadow::{GeminiAssistantTurn, GeminiShadowStore, GeminiToolCallMeta};
|
||||||
use crate::proxy::error::ProxyError;
|
use crate::proxy::error::ProxyError;
|
||||||
|
use crate::proxy::tool_media::{
|
||||||
|
strip_and_clamp_media_from_tool_value, ToolMediaScope, TOOL_RESULT_MEDIA_ATTACHED_MARKER,
|
||||||
|
};
|
||||||
use serde_json::{json, Map, Value};
|
use serde_json::{json, Map, Value};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
@@ -61,6 +64,10 @@ pub fn anthropic_to_gemini_with_shadow(
|
|||||||
.and_then(|((store, provider_id), session_id)| store.get_session(provider_id, session_id))
|
.and_then(|((store, provider_id), session_id)| store.get_session(provider_id, session_id))
|
||||||
.map(|snapshot| snapshot.turns)
|
.map(|snapshot| snapshot.turns)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
let supports_multimodal_function_response = body
|
||||||
|
.get("model")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(is_gemini_3_series);
|
||||||
|
|
||||||
let messages = body.get("messages").and_then(|value| value.as_array());
|
let messages = body.get("messages").and_then(|value| value.as_array());
|
||||||
|
|
||||||
@@ -73,7 +80,11 @@ pub fn anthropic_to_gemini_with_shadow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(messages) = messages {
|
if let Some(messages) = messages {
|
||||||
result["contents"] = json!(convert_messages_to_contents(messages, &shadow_turns)?);
|
result["contents"] = json!(convert_messages_to_contents(
|
||||||
|
messages,
|
||||||
|
&shadow_turns,
|
||||||
|
supports_multimodal_function_response,
|
||||||
|
)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(generation_config) = build_generation_config(&body) {
|
if let Some(generation_config) = build_generation_config(&body) {
|
||||||
@@ -361,6 +372,7 @@ fn build_generation_config(body: &Value) -> Option<Value> {
|
|||||||
fn convert_messages_to_contents(
|
fn convert_messages_to_contents(
|
||||||
messages: &[Value],
|
messages: &[Value],
|
||||||
shadow_turns: &[GeminiAssistantTurn],
|
shadow_turns: &[GeminiAssistantTurn],
|
||||||
|
supports_multimodal_function_response: bool,
|
||||||
) -> Result<Vec<Value>, ProxyError> {
|
) -> Result<Vec<Value>, ProxyError> {
|
||||||
let mut contents = Vec::new();
|
let mut contents = Vec::new();
|
||||||
let mut used_shadow_indices = HashSet::new();
|
let mut used_shadow_indices = HashSet::new();
|
||||||
@@ -446,6 +458,7 @@ fn convert_messages_to_contents(
|
|||||||
role,
|
role,
|
||||||
&mut tool_name_by_id,
|
&mut tool_name_by_id,
|
||||||
&thought_signature_by_id,
|
&thought_signature_by_id,
|
||||||
|
supports_multimodal_function_response,
|
||||||
)?
|
)?
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -454,6 +467,7 @@ fn convert_messages_to_contents(
|
|||||||
role,
|
role,
|
||||||
&mut tool_name_by_id,
|
&mut tool_name_by_id,
|
||||||
&thought_signature_by_id,
|
&thought_signature_by_id,
|
||||||
|
supports_multimodal_function_response,
|
||||||
)?
|
)?
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -462,6 +476,7 @@ fn convert_messages_to_contents(
|
|||||||
role,
|
role,
|
||||||
&mut tool_name_by_id,
|
&mut tool_name_by_id,
|
||||||
&thought_signature_by_id,
|
&thought_signature_by_id,
|
||||||
|
supports_multimodal_function_response,
|
||||||
)?
|
)?
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -560,6 +575,7 @@ fn convert_message_content_to_parts(
|
|||||||
role: &str,
|
role: &str,
|
||||||
tool_name_by_id: &mut std::collections::HashMap<String, String>,
|
tool_name_by_id: &mut std::collections::HashMap<String, String>,
|
||||||
thought_signature_by_id: &std::collections::HashMap<String, String>,
|
thought_signature_by_id: &std::collections::HashMap<String, String>,
|
||||||
|
supports_multimodal_function_response: bool,
|
||||||
) -> Result<Vec<Value>, ProxyError> {
|
) -> Result<Vec<Value>, ProxyError> {
|
||||||
let Some(content) = content else {
|
let Some(content) = content else {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
@@ -705,16 +721,31 @@ fn convert_message_content_to_parts(
|
|||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
let (response, media_parts) = plan_gemini_tool_result(block.get("content"));
|
||||||
|
|
||||||
// See `tool_use` above: synthesized ids must not leak upstream.
|
// See `tool_use` above: synthesized ids must not leak upstream.
|
||||||
let mut function_response = json!({
|
let mut function_response = json!({
|
||||||
"name": name,
|
"name": name,
|
||||||
"response": normalize_tool_result_response(block.get("content"))
|
"response": response
|
||||||
});
|
});
|
||||||
if !tool_use_id.is_empty() && !is_synthesized_tool_call_id(tool_use_id) {
|
if !tool_use_id.is_empty() && !is_synthesized_tool_call_id(tool_use_id) {
|
||||||
function_response["id"] = json!(tool_use_id);
|
function_response["id"] = json!(tool_use_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
parts.push(json!({ "functionResponse": function_response }));
|
if supports_multimodal_function_response && !media_parts.is_empty() {
|
||||||
|
function_response["parts"] = Value::Array(media_parts);
|
||||||
|
parts.push(json!({ "functionResponse": function_response }));
|
||||||
|
} else {
|
||||||
|
parts.push(json!({ "functionResponse": function_response }));
|
||||||
|
if !media_parts.is_empty() {
|
||||||
|
parts.push(json!({
|
||||||
|
"text": format!(
|
||||||
|
"[cc-switch: media output of tool call {tool_use_id}]"
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
parts.extend(media_parts);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"thinking" | "redacted_thinking" => {}
|
"thinking" | "redacted_thinking" => {}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -745,6 +776,74 @@ fn normalize_tool_result_response(content: Option<&Value>) -> Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn plan_gemini_tool_result(content: Option<&Value>) -> (Value, Vec<Value>) {
|
||||||
|
let Some(content) = content else {
|
||||||
|
return (normalize_tool_result_response(None), Vec::new());
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut cleaned = content.clone();
|
||||||
|
let replacement_block = json!({
|
||||||
|
"type":"text",
|
||||||
|
"text":TOOL_RESULT_MEDIA_ATTACHED_MARKER
|
||||||
|
});
|
||||||
|
let mut chat_media_parts = Vec::new();
|
||||||
|
let replaced = strip_and_clamp_media_from_tool_value(
|
||||||
|
&mut cleaned,
|
||||||
|
&mut chat_media_parts,
|
||||||
|
ToolMediaScope::InlineImagesOnly,
|
||||||
|
&replacement_block,
|
||||||
|
TOOL_RESULT_MEDIA_ATTACHED_MARKER,
|
||||||
|
);
|
||||||
|
if replaced == 0 {
|
||||||
|
return (normalize_tool_result_response(Some(content)), Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut gemini_parts = Vec::new();
|
||||||
|
gemini_parts.extend(
|
||||||
|
chat_media_parts
|
||||||
|
.iter()
|
||||||
|
.filter_map(gemini_part_from_chat_image),
|
||||||
|
);
|
||||||
|
|
||||||
|
(normalize_tool_result_response(Some(&cleaned)), gemini_parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_gemini_3_series(model: &str) -> bool {
|
||||||
|
let normalized = model.trim().to_ascii_lowercase();
|
||||||
|
normalized.starts_with("gemini-3")
|
||||||
|
|| normalized
|
||||||
|
.rsplit('/')
|
||||||
|
.next()
|
||||||
|
.is_some_and(|tail| tail.starts_with("gemini-3"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gemini_part_from_chat_image(part: &Value) -> Option<Value> {
|
||||||
|
let image_url = part
|
||||||
|
.pointer("/image_url/url")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|url| !url.trim().is_empty())?;
|
||||||
|
|
||||||
|
if image_url
|
||||||
|
.get(..5)
|
||||||
|
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("data:"))
|
||||||
|
{
|
||||||
|
let rest = &image_url[5..];
|
||||||
|
let (meta, data) = rest.split_once(',')?;
|
||||||
|
if data.is_empty() || !meta.to_ascii_lowercase().contains(";base64") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mime_type = meta.split(';').next().unwrap_or("image/png");
|
||||||
|
return Some(json!({
|
||||||
|
"inlineData": {
|
||||||
|
"mimeType": mime_type,
|
||||||
|
"data": data
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
fn shadow_parts(content: &Value) -> Option<Vec<Value>> {
|
fn shadow_parts(content: &Value) -> Option<Vec<Value>> {
|
||||||
let mut parts = content
|
let mut parts = content
|
||||||
.get("parts")
|
.get("parts")
|
||||||
@@ -1266,6 +1365,290 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anthropic_to_gemini_moves_mixed_tool_result_image_to_native_part() {
|
||||||
|
let input = json!({
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": "call_image",
|
||||||
|
"name": "inspect",
|
||||||
|
"input": {}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": "call_image",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "caption"},
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"source": {
|
||||||
|
"type": "base64",
|
||||||
|
"media_type": "image/png",
|
||||||
|
"data": "GEMINI_TOOL_IMAGE_SENTINEL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = anthropic_to_gemini(input).unwrap();
|
||||||
|
let parts = result["contents"][1]["parts"].as_array().unwrap();
|
||||||
|
let response = &parts[0]["functionResponse"]["response"]["content"];
|
||||||
|
|
||||||
|
assert!(response.as_str().unwrap().contains("caption"));
|
||||||
|
assert!(response
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("tool result media attached"));
|
||||||
|
assert!(!response
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("GEMINI_TOOL_IMAGE_SENTINEL"));
|
||||||
|
assert_eq!(
|
||||||
|
parts[1]["text"],
|
||||||
|
"[cc-switch: media output of tool call call_image]"
|
||||||
|
);
|
||||||
|
assert_eq!(parts[2]["inlineData"]["mimeType"], "image/png");
|
||||||
|
assert_eq!(parts[2]["inlineData"]["data"], "GEMINI_TOOL_IMAGE_SENTINEL");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anthropic_to_gemini_clamps_json_string_residual_base64_before_serializing() {
|
||||||
|
let residual_base64 = "A".repeat(20_000);
|
||||||
|
let encoded = json!({
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": "data:image/png;base64,GEMINI_STRING_IMAGE_SENTINEL"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{"type": "video", "data": residual_base64}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let input = json!({
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": "call_string",
|
||||||
|
"name": "inspect",
|
||||||
|
"input": {}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": "call_string",
|
||||||
|
"content": encoded
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = anthropic_to_gemini(input).unwrap();
|
||||||
|
let serialized = result.to_string();
|
||||||
|
|
||||||
|
assert!(serialized.contains("[cc-switch: omitted 20000 bytes]"));
|
||||||
|
assert!(!serialized.contains(&"A".repeat(64)));
|
||||||
|
assert_eq!(
|
||||||
|
result["contents"][1]["parts"][2]["inlineData"]["data"],
|
||||||
|
"GEMINI_STRING_IMAGE_SENTINEL"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anthropic_to_gemini_keeps_remote_tool_image_in_legacy_response() {
|
||||||
|
let remote_image = json!({
|
||||||
|
"type": "image",
|
||||||
|
"source": {
|
||||||
|
"type": "url",
|
||||||
|
"url": "https://example.com/tool-image.png"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let input = json!({
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": "call_remote",
|
||||||
|
"name": "inspect",
|
||||||
|
"input": {}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": "call_remote",
|
||||||
|
"content": [remote_image.clone()]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = anthropic_to_gemini(input).unwrap();
|
||||||
|
let parts = result["contents"][1]["parts"].as_array().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(parts.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
parts[0]["functionResponse"]["response"]["content"][0],
|
||||||
|
remote_image
|
||||||
|
);
|
||||||
|
assert!(!result.to_string().contains("fileData"));
|
||||||
|
assert!(!result
|
||||||
|
.to_string()
|
||||||
|
.contains(TOOL_RESULT_MEDIA_ATTACHED_MARKER));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anthropic_to_gemini_does_not_strip_unconvertible_tool_data_url() {
|
||||||
|
let malformed_image = json!({
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": "data:image/png,NOT_BASE64"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let input = json!({
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": "call_malformed",
|
||||||
|
"name": "inspect",
|
||||||
|
"input": {}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": "call_malformed",
|
||||||
|
"content": [malformed_image.clone()]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = anthropic_to_gemini(input).unwrap();
|
||||||
|
let parts = result["contents"][1]["parts"].as_array().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(parts.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
parts[0]["functionResponse"]["response"]["content"][0],
|
||||||
|
malformed_image
|
||||||
|
);
|
||||||
|
assert!(!result.to_string().contains("inlineData"));
|
||||||
|
assert!(!result
|
||||||
|
.to_string()
|
||||||
|
.contains(TOOL_RESULT_MEDIA_ATTACHED_MARKER));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anthropic_to_gemini_does_not_embed_image_only_tool_result_as_json() {
|
||||||
|
let input = json!({
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": "call_image",
|
||||||
|
"name": "inspect",
|
||||||
|
"input": {}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": "call_image",
|
||||||
|
"content": [{
|
||||||
|
"type": "image",
|
||||||
|
"source": {
|
||||||
|
"type": "base64",
|
||||||
|
"media_type": "image/webp",
|
||||||
|
"data": "IMAGE_ONLY_SENTINEL"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = anthropic_to_gemini(input).unwrap();
|
||||||
|
let parts = result["contents"][1]["parts"].as_array().unwrap();
|
||||||
|
let response = &parts[0]["functionResponse"]["response"]["content"];
|
||||||
|
|
||||||
|
assert!(response.is_string());
|
||||||
|
assert!(!response.as_str().unwrap().contains("IMAGE_ONLY_SENTINEL"));
|
||||||
|
assert_eq!(parts[2]["inlineData"]["mimeType"], "image/webp");
|
||||||
|
assert_eq!(parts[2]["inlineData"]["data"], "IMAGE_ONLY_SENTINEL");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anthropic_to_gemini_3_uses_multimodal_function_response_parts() {
|
||||||
|
let input = json!({
|
||||||
|
"model": "gemini-3-pro-preview",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": "call_image",
|
||||||
|
"name": "inspect",
|
||||||
|
"input": {}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": "call_image",
|
||||||
|
"content": [{
|
||||||
|
"type": "image",
|
||||||
|
"source": {
|
||||||
|
"type": "base64",
|
||||||
|
"media_type": "image/jpeg",
|
||||||
|
"data": "GEMINI_3_IMAGE_SENTINEL"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = anthropic_to_gemini(input).unwrap();
|
||||||
|
let parts = result["contents"][1]["parts"].as_array().unwrap();
|
||||||
|
let function_response = &parts[0]["functionResponse"];
|
||||||
|
|
||||||
|
assert_eq!(parts.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
function_response["parts"][0]["inlineData"]["mimeType"],
|
||||||
|
"image/jpeg"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
function_response["parts"][0]["inlineData"]["data"],
|
||||||
|
"GEMINI_3_IMAGE_SENTINEL"
|
||||||
|
);
|
||||||
|
assert!(!function_response["response"]["content"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("GEMINI_3_IMAGE_SENTINEL"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn anthropic_to_gemini_resolves_tool_result_name_from_shadow_content() {
|
fn anthropic_to_gemini_resolves_tool_result_name_from_shadow_content() {
|
||||||
let store = GeminiShadowStore::with_limits(8, 4);
|
let store = GeminiShadowStore::with_limits(8, 4);
|
||||||
|
|||||||
@@ -8,7 +8,13 @@
|
|||||||
//! - system prompt 使用 `instructions` 字段而非 system role message
|
//! - system prompt 使用 `instructions` 字段而非 system role message
|
||||||
//! - usage 字段命名与 Anthropic 一致 (input_tokens/output_tokens)
|
//! - usage 字段命名与 Anthropic 一致 (input_tokens/output_tokens)
|
||||||
|
|
||||||
use crate::proxy::{error::ProxyError, json_canonical::canonical_json_string};
|
use crate::proxy::{
|
||||||
|
error::ProxyError,
|
||||||
|
json_canonical::canonical_json_string,
|
||||||
|
tool_media::{
|
||||||
|
strip_and_clamp_media_from_tool_value, ToolMediaScope, TOOL_RESULT_MEDIA_ATTACHED_MARKER,
|
||||||
|
},
|
||||||
|
};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use super::reasoning_bridge::{
|
use super::reasoning_bridge::{
|
||||||
@@ -80,8 +86,11 @@ fn anthropic_tool_result_to_responses_output(block: &Value) -> Value {
|
|||||||
let content = block.get("content");
|
let content = block.get("content");
|
||||||
|
|
||||||
if !is_error {
|
if !is_error {
|
||||||
if let Some(Value::String(text)) = content {
|
if let Some(text @ Value::String(_)) = content {
|
||||||
return json!(text);
|
if let Some(output) = alternate_image_tool_result_to_responses(text) {
|
||||||
|
return Value::Array(output);
|
||||||
|
}
|
||||||
|
return text.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +101,13 @@ fn anthropic_tool_result_to_responses_output(block: &Value) -> Value {
|
|||||||
|
|
||||||
match content {
|
match content {
|
||||||
Some(Value::String(text)) => {
|
Some(Value::String(text)) => {
|
||||||
output.push(json!({"type":"input_text","text":text}));
|
if let Some(mut alternate) =
|
||||||
|
alternate_image_tool_result_to_responses(&Value::String(text.clone()))
|
||||||
|
{
|
||||||
|
output.append(&mut alternate);
|
||||||
|
} else {
|
||||||
|
output.push(json!({"type":"input_text","text":text}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Some(Value::Array(blocks)) => {
|
Some(Value::Array(blocks)) => {
|
||||||
for part in blocks {
|
for part in blocks {
|
||||||
@@ -105,6 +120,10 @@ fn anthropic_tool_result_to_responses_output(block: &Value) -> Value {
|
|||||||
Some("image") => {
|
Some("image") => {
|
||||||
if let Some(image) = anthropic_image_to_responses_part(part) {
|
if let Some(image) = anthropic_image_to_responses_part(part) {
|
||||||
output.push(image);
|
output.push(image);
|
||||||
|
} else if let Some(mut alternate) =
|
||||||
|
alternate_image_tool_result_to_responses(part)
|
||||||
|
{
|
||||||
|
output.append(&mut alternate);
|
||||||
} else {
|
} else {
|
||||||
output.push(json!({
|
output.push(json!({
|
||||||
"type":"input_text",
|
"type":"input_text",
|
||||||
@@ -122,6 +141,77 @@ fn anthropic_tool_result_to_responses_output(block: &Value) -> Value {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_ => {
|
||||||
|
if let Some(mut alternate) = alternate_image_tool_result_to_responses(part)
|
||||||
|
{
|
||||||
|
output.append(&mut alternate);
|
||||||
|
} else {
|
||||||
|
output.push(json!({
|
||||||
|
"type":"input_text",
|
||||||
|
"text":canonical_json_string(part)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(value) => {
|
||||||
|
if let Some(mut alternate) = alternate_image_tool_result_to_responses(value) {
|
||||||
|
output.append(&mut alternate);
|
||||||
|
} else {
|
||||||
|
output.push(json!({
|
||||||
|
"type":"input_text",
|
||||||
|
"text":canonical_json_string(value)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Value::Array(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn alternate_image_tool_result_to_responses(value: &Value) -> Option<Vec<Value>> {
|
||||||
|
let mut cleaned = value.clone();
|
||||||
|
let replacement_block = json!({
|
||||||
|
"type":"input_text",
|
||||||
|
"text":TOOL_RESULT_MEDIA_ATTACHED_MARKER
|
||||||
|
});
|
||||||
|
let mut chat_media_parts = Vec::new();
|
||||||
|
let replaced = strip_and_clamp_media_from_tool_value(
|
||||||
|
&mut cleaned,
|
||||||
|
&mut chat_media_parts,
|
||||||
|
ToolMediaScope::ImagesOnly,
|
||||||
|
&replacement_block,
|
||||||
|
TOOL_RESULT_MEDIA_ATTACHED_MARKER,
|
||||||
|
);
|
||||||
|
if replaced == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut output = Vec::new();
|
||||||
|
append_sanitized_responses_tool_value(&cleaned, &mut output);
|
||||||
|
output.extend(
|
||||||
|
chat_media_parts
|
||||||
|
.iter()
|
||||||
|
.filter_map(responses_image_from_chat_media),
|
||||||
|
);
|
||||||
|
Some(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_sanitized_responses_tool_value(value: &Value, output: &mut Vec<Value>) {
|
||||||
|
match value {
|
||||||
|
Value::String(text) if !text.is_empty() => {
|
||||||
|
output.push(json!({"type":"input_text","text":text}));
|
||||||
|
}
|
||||||
|
Value::Array(parts) => {
|
||||||
|
for part in parts {
|
||||||
|
match part.get("type").and_then(Value::as_str) {
|
||||||
|
Some("input_text" | "output_text" | "text") => {
|
||||||
|
if let Some(text) = part.get("text").and_then(Value::as_str) {
|
||||||
|
output.push(json!({"type":"input_text","text":text}));
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => output.push(json!({
|
_ => output.push(json!({
|
||||||
"type":"input_text",
|
"type":"input_text",
|
||||||
"text":canonical_json_string(part)
|
"text":canonical_json_string(part)
|
||||||
@@ -129,14 +219,37 @@ fn anthropic_tool_result_to_responses_output(block: &Value) -> Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(value) => output.push(json!({
|
Value::Object(object)
|
||||||
|
if matches!(
|
||||||
|
object.get("type").and_then(Value::as_str),
|
||||||
|
Some("input_text" | "output_text" | "text")
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
if let Some(text) = object.get("text").and_then(Value::as_str) {
|
||||||
|
output.push(json!({"type":"input_text","text":text}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Null | Value::String(_) => {}
|
||||||
|
other => output.push(json!({
|
||||||
"type":"input_text",
|
"type":"input_text",
|
||||||
"text":canonical_json_string(value)
|
"text":canonical_json_string(other)
|
||||||
})),
|
})),
|
||||||
None => {}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Value::Array(output)
|
fn responses_image_from_chat_media(part: &Value) -> Option<Value> {
|
||||||
|
let image_url = part
|
||||||
|
.pointer("/image_url/url")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|url| !url.trim().is_empty())?;
|
||||||
|
let mut image = json!({
|
||||||
|
"type":"input_image",
|
||||||
|
"image_url":image_url
|
||||||
|
});
|
||||||
|
if let Some(detail) = part.pointer("/image_url/detail") {
|
||||||
|
image["detail"] = detail.clone();
|
||||||
|
}
|
||||||
|
Some(image)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn sanitize_anthropic_tool_use_input(name: &str, input: Value) -> Value {
|
pub(crate) fn sanitize_anthropic_tool_use_input(name: &str, input: Value) -> Value {
|
||||||
@@ -1157,6 +1270,78 @@ mod tests {
|
|||||||
assert_eq!(output[3]["filename"], "trace.pdf");
|
assert_eq!(output[3]["filename"], "trace.pdf");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_anthropic_to_responses_converts_mcp_tool_image() {
|
||||||
|
let input = json!({
|
||||||
|
"model":"gpt-5",
|
||||||
|
"messages":[{"role":"user","content":[{
|
||||||
|
"type":"tool_result",
|
||||||
|
"tool_use_id":"call_1",
|
||||||
|
"content":[{
|
||||||
|
"type":"image",
|
||||||
|
"mimeType":"image/webp",
|
||||||
|
"data":"MCP_RESPONSES_IMAGE_SENTINEL"
|
||||||
|
}]
|
||||||
|
}]}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||||
|
let output = result["input"][0]["output"].as_array().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(output[0]["type"], "input_text");
|
||||||
|
assert!(!output[0]["text"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("MCP_RESPONSES_IMAGE_SENTINEL"));
|
||||||
|
assert_eq!(output[1]["type"], "input_image");
|
||||||
|
assert_eq!(
|
||||||
|
output[1]["image_url"],
|
||||||
|
"data:image/webp;base64,MCP_RESPONSES_IMAGE_SENTINEL"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_anthropic_to_responses_converts_json_string_tool_image() {
|
||||||
|
let residual_base64 = "A".repeat(20_000);
|
||||||
|
let encoded = json!({
|
||||||
|
"content":[
|
||||||
|
{
|
||||||
|
"type":"image_url",
|
||||||
|
"image_url":{"url":"data:image/png;base64,STRING_RESPONSES_SENTINEL"}
|
||||||
|
},
|
||||||
|
{"type":"video","data":residual_base64}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let input = json!({
|
||||||
|
"model":"gpt-5",
|
||||||
|
"messages":[{"role":"user","content":[{
|
||||||
|
"type":"tool_result",
|
||||||
|
"tool_use_id":"call_1",
|
||||||
|
"content":encoded
|
||||||
|
}]}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||||
|
let output = result["input"][0]["output"].as_array().unwrap();
|
||||||
|
let image = output
|
||||||
|
.iter()
|
||||||
|
.find(|part| part["type"] == "input_image")
|
||||||
|
.expect("stringified image must stay a Responses image");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
image["image_url"],
|
||||||
|
"data:image/png;base64,STRING_RESPONSES_SENTINEL"
|
||||||
|
);
|
||||||
|
assert!(output
|
||||||
|
.iter()
|
||||||
|
.filter_map(|part| part.get("text").and_then(Value::as_str))
|
||||||
|
.all(|text| !text.contains("STRING_RESPONSES_SENTINEL")));
|
||||||
|
let serialized = result.to_string();
|
||||||
|
assert!(serialized.contains("[cc-switch: omitted 20000 bytes]"));
|
||||||
|
assert!(!serialized.contains(&"A".repeat(64)));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_anthropic_to_responses_thinking_discarded() {
|
fn test_anthropic_to_responses_thinking_discarded() {
|
||||||
let input = json!({
|
let input = json!({
|
||||||
|
|||||||
@@ -0,0 +1,991 @@
|
|||||||
|
//! Shared media handling for tool outputs.
|
||||||
|
//!
|
||||||
|
//! Responses and Anthropic tool outputs may carry structured media blocks.
|
||||||
|
//! Chat Completions tool messages are text-only, so protocol bridges extract
|
||||||
|
//! those blocks and re-emit them in a synthetic user message. The media
|
||||||
|
//! sanitizer reuses the same recognition and traversal rules when it needs to
|
||||||
|
//! remove images for a text-only upstream.
|
||||||
|
|
||||||
|
use crate::proxy::json_canonical::canonical_json_string;
|
||||||
|
use serde_json::{json, Map, Value};
|
||||||
|
|
||||||
|
pub(crate) const WHOLE_DATA_URL_MIN_BYTES: usize = 8 * 1024;
|
||||||
|
pub(crate) const TOOL_RESULT_MEDIA_MOVED_MARKER: &str =
|
||||||
|
"[cc-switch: tool result media moved to the following user message]";
|
||||||
|
pub(crate) const TOOL_RESULT_MEDIA_ATTACHED_MARKER: &str =
|
||||||
|
"[cc-switch: tool result media attached as native media]";
|
||||||
|
const BASE64ISH_MIN_BYTES: usize = 16 * 1024;
|
||||||
|
const MAX_MEDIA_TRAVERSAL_DEPTH: usize = 32;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum ToolMediaScope {
|
||||||
|
/// Used by the existing image-capability sanitizer and its retry path.
|
||||||
|
ImagesOnly,
|
||||||
|
/// Used by Gemini Native `generateContent`, whose existing bridge only
|
||||||
|
/// promises inline base64 image input. Remote URLs and malformed data URLs
|
||||||
|
/// must stay in the legacy tool-result representation.
|
||||||
|
InlineImagesOnly,
|
||||||
|
/// Used by Chat conversion bridges, where user messages can carry all
|
||||||
|
/// currently mapped Chat input modalities.
|
||||||
|
AllSupported,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum ToolMediaKind {
|
||||||
|
Image,
|
||||||
|
File,
|
||||||
|
Audio,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct ChatToolOutputMediaPlan {
|
||||||
|
pub(crate) tool_content: String,
|
||||||
|
pub(crate) media_parts: Vec<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolMediaScope {
|
||||||
|
fn allows(self, kind: ToolMediaKind) -> bool {
|
||||||
|
matches!(kind, ToolMediaKind::Image) || matches!(self, Self::AllSupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accepts_chat_part(self, part: &Value) -> bool {
|
||||||
|
!matches!(self, Self::InlineImagesOnly) || chat_image_part_has_inline_data(part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a Chat-compatible tool-output plan without changing no-media output.
|
||||||
|
///
|
||||||
|
/// Scalar strings remain scalar strings after replacement. This matters for a
|
||||||
|
/// raw image data URL and for JSON encoded inside a tool-output string: adding
|
||||||
|
/// another layer of JSON string quotes would change what the model sees.
|
||||||
|
pub(crate) fn plan_chat_tool_output_media(mut output: Value) -> Option<ChatToolOutputMediaPlan> {
|
||||||
|
let output_was_string = output.is_string();
|
||||||
|
let replacement_block = json!({
|
||||||
|
"type": "text",
|
||||||
|
"text": TOOL_RESULT_MEDIA_MOVED_MARKER
|
||||||
|
});
|
||||||
|
let mut media_parts = Vec::new();
|
||||||
|
let replaced = strip_and_clamp_media_from_tool_value(
|
||||||
|
&mut output,
|
||||||
|
&mut media_parts,
|
||||||
|
ToolMediaScope::AllSupported,
|
||||||
|
&replacement_block,
|
||||||
|
TOOL_RESULT_MEDIA_MOVED_MARKER,
|
||||||
|
);
|
||||||
|
if replaced == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tool_content = if output_was_string {
|
||||||
|
output.as_str().unwrap_or_default().to_string()
|
||||||
|
} else {
|
||||||
|
canonical_json_string(&output)
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(ChatToolOutputMediaPlan {
|
||||||
|
tool_content,
|
||||||
|
media_parts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn queue_chat_tool_output_media(
|
||||||
|
pending_media: &mut Vec<Value>,
|
||||||
|
call_id: &str,
|
||||||
|
media_parts: Vec<Value>,
|
||||||
|
) {
|
||||||
|
if media_parts.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pending_media.push(json!({
|
||||||
|
"type": "text",
|
||||||
|
"text": format!("[cc-switch: media output of tool call {call_id}]")
|
||||||
|
}));
|
||||||
|
pending_media.extend(media_parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn flush_pending_chat_tool_media(
|
||||||
|
messages: &mut Vec<Value>,
|
||||||
|
pending_media: &mut Vec<Value>,
|
||||||
|
) {
|
||||||
|
if pending_media.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push(json!({
|
||||||
|
"role": "user",
|
||||||
|
"content": std::mem::take(pending_media)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert one recognized tool media block to a Chat user content part. This
|
||||||
|
/// is the single shape-recognition entry point used by extraction.
|
||||||
|
pub(crate) fn chat_media_part_from_tool_part(part: &Value, scope: ToolMediaScope) -> Option<Value> {
|
||||||
|
let kind = tool_media_kind(part)?;
|
||||||
|
if !scope.allows(kind) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let chat_part = match kind {
|
||||||
|
ToolMediaKind::Image => chat_image_part(part),
|
||||||
|
ToolMediaKind::File => chat_file_from_input_file(part).map(|file| {
|
||||||
|
json!({
|
||||||
|
"type": "file",
|
||||||
|
"file": file
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
ToolMediaKind::Audio => part.get("input_audio").map(|input_audio| {
|
||||||
|
json!({
|
||||||
|
"type": "input_audio",
|
||||||
|
"input_audio": input_audio.clone()
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
}?;
|
||||||
|
|
||||||
|
scope.accepts_chat_part(&chat_part).then_some(chat_part)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a Responses `input_file` block to the Chat file payload. Kept here so
|
||||||
|
/// top-level content and tool-output extraction share the exact same rules.
|
||||||
|
pub(crate) fn chat_file_from_input_file(part: &Value) -> Option<Value> {
|
||||||
|
let mut file = Map::new();
|
||||||
|
let has_supported_file_ref = part.get("file_id").is_some() || part.get("file_data").is_some();
|
||||||
|
if !has_supported_file_ref {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
for key in ["file_id", "file_data", "filename"] {
|
||||||
|
if let Some(value) = part.get(key) {
|
||||||
|
file.insert(key.to_string(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Value::Object(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recognize a complete image data URL stored as a scalar string.
|
||||||
|
///
|
||||||
|
/// Only whole-string matches are accepted. Embedded data URLs in HTML/CSS/SVG
|
||||||
|
/// source are deliberately left alone. Small values remain text as well, which
|
||||||
|
/// preserves workflows that intentionally inspect tiny inline icons.
|
||||||
|
pub(crate) fn whole_string_image_data_url(value: &str) -> Option<Value> {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.len() < WHOLE_DATA_URL_MIN_BYTES || !is_image_base64_data_url(trimmed) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(json!({
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": trimmed
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only media detection using the same shape classifier and recursive
|
||||||
|
/// boundaries as [`strip_media_from_tool_value`].
|
||||||
|
pub(crate) fn tool_output_contains_media(value: &Value, scope: ToolMediaScope) -> bool {
|
||||||
|
tool_output_contains_media_at_depth(value, scope, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract recognized media blocks and replace them in-place.
|
||||||
|
///
|
||||||
|
/// `replacement_block` is used for structured array/object parts. A scalar
|
||||||
|
/// string that is itself a complete image data URL uses `replacement_text`, so
|
||||||
|
/// an originally plain string stays a plain string. Parseable JSON strings are
|
||||||
|
/// recursively transformed and canonicalized back into a string only when a
|
||||||
|
/// replacement actually occurred.
|
||||||
|
pub(crate) fn strip_media_from_tool_value(
|
||||||
|
value: &mut Value,
|
||||||
|
media_parts: &mut Vec<Value>,
|
||||||
|
scope: ToolMediaScope,
|
||||||
|
replacement_block: &Value,
|
||||||
|
replacement_text: &str,
|
||||||
|
) -> usize {
|
||||||
|
strip_media_from_tool_value_at_depth(
|
||||||
|
value,
|
||||||
|
media_parts,
|
||||||
|
scope,
|
||||||
|
replacement_block,
|
||||||
|
replacement_text,
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract media and clamp residual large data/base64 scalars on media-bearing
|
||||||
|
/// outputs. Parseable JSON strings are clamped while still represented as a
|
||||||
|
/// JSON tree, before they are canonicalized back into their original string
|
||||||
|
/// container.
|
||||||
|
pub(crate) fn strip_and_clamp_media_from_tool_value(
|
||||||
|
value: &mut Value,
|
||||||
|
media_parts: &mut Vec<Value>,
|
||||||
|
scope: ToolMediaScope,
|
||||||
|
replacement_block: &Value,
|
||||||
|
replacement_text: &str,
|
||||||
|
) -> usize {
|
||||||
|
let replaced = strip_media_from_tool_value_at_depth(
|
||||||
|
value,
|
||||||
|
media_parts,
|
||||||
|
scope,
|
||||||
|
replacement_block,
|
||||||
|
replacement_text,
|
||||||
|
true,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
if replaced > 0 {
|
||||||
|
clamp_base64ish_strings(value);
|
||||||
|
}
|
||||||
|
replaced
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove residual data/base64 payloads only after a tool output has already
|
||||||
|
/// been positively identified as media-bearing. Ordinary long text is kept.
|
||||||
|
pub(crate) fn clamp_base64ish_strings(value: &mut Value) {
|
||||||
|
match value {
|
||||||
|
Value::String(text) => {
|
||||||
|
let trimmed = text.trim();
|
||||||
|
let should_omit = (trimmed.len() >= WHOLE_DATA_URL_MIN_BYTES
|
||||||
|
&& trimmed
|
||||||
|
.get(..5)
|
||||||
|
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("data:")))
|
||||||
|
|| looks_like_base64_payload(trimmed);
|
||||||
|
if should_omit {
|
||||||
|
let byte_len = text.len();
|
||||||
|
*text = format!("[cc-switch: omitted {byte_len} bytes]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Array(items) => {
|
||||||
|
for item in items {
|
||||||
|
clamp_base64ish_strings(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Object(object) => {
|
||||||
|
for nested in object.values_mut() {
|
||||||
|
clamp_base64ish_strings(nested);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_output_contains_media_at_depth(value: &Value, scope: ToolMediaScope, depth: usize) -> bool {
|
||||||
|
if depth > MAX_MEDIA_TRAVERSAL_DEPTH {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
match value {
|
||||||
|
Value::String(text) => {
|
||||||
|
if scope.allows(ToolMediaKind::Image) && whole_string_image_data_url(text).is_some() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmed = text.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
serde_json::from_str::<Value>(trimmed)
|
||||||
|
.ok()
|
||||||
|
.is_some_and(|parsed| {
|
||||||
|
tool_output_contains_media_at_depth(&parsed, scope, depth + 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Value::Array(items) => items
|
||||||
|
.iter()
|
||||||
|
.any(|item| tool_output_contains_media_at_depth(item, scope, depth + 1)),
|
||||||
|
Value::Object(object) => {
|
||||||
|
if chat_media_part_from_tool_part(value, scope).is_some() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
object.get("content").is_some_and(|content| {
|
||||||
|
tool_output_contains_media_at_depth(content, scope, depth + 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_media_from_tool_value_at_depth(
|
||||||
|
value: &mut Value,
|
||||||
|
media_parts: &mut Vec<Value>,
|
||||||
|
scope: ToolMediaScope,
|
||||||
|
replacement_block: &Value,
|
||||||
|
replacement_text: &str,
|
||||||
|
clamp_parsed_strings: bool,
|
||||||
|
depth: usize,
|
||||||
|
) -> usize {
|
||||||
|
if depth > MAX_MEDIA_TRAVERSAL_DEPTH {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
match value {
|
||||||
|
Value::String(text) => {
|
||||||
|
if scope.allows(ToolMediaKind::Image) {
|
||||||
|
if let Some(media_part) = whole_string_image_data_url(text) {
|
||||||
|
media_parts.push(media_part);
|
||||||
|
*text = replacement_text.to_string();
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmed = text.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let Ok(mut parsed) = serde_json::from_str::<Value>(trimmed) else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
let replaced = strip_media_from_tool_value_at_depth(
|
||||||
|
&mut parsed,
|
||||||
|
media_parts,
|
||||||
|
scope,
|
||||||
|
replacement_block,
|
||||||
|
replacement_text,
|
||||||
|
clamp_parsed_strings,
|
||||||
|
depth + 1,
|
||||||
|
);
|
||||||
|
if replaced > 0 {
|
||||||
|
if clamp_parsed_strings {
|
||||||
|
clamp_base64ish_strings(&mut parsed);
|
||||||
|
}
|
||||||
|
*text = canonical_json_string(&parsed);
|
||||||
|
}
|
||||||
|
replaced
|
||||||
|
}
|
||||||
|
Value::Array(items) => items
|
||||||
|
.iter_mut()
|
||||||
|
.map(|item| {
|
||||||
|
strip_media_from_tool_value_at_depth(
|
||||||
|
item,
|
||||||
|
media_parts,
|
||||||
|
scope,
|
||||||
|
replacement_block,
|
||||||
|
replacement_text,
|
||||||
|
clamp_parsed_strings,
|
||||||
|
depth + 1,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.sum(),
|
||||||
|
Value::Object(_) => {
|
||||||
|
if let Some(media_part) = chat_media_part_from_tool_part(value, scope) {
|
||||||
|
media_parts.push(media_part);
|
||||||
|
*value = replacement_block.clone();
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
value
|
||||||
|
.as_object_mut()
|
||||||
|
.expect("object match arm must remain an object")
|
||||||
|
.get_mut("content")
|
||||||
|
.map(|content| {
|
||||||
|
strip_media_from_tool_value_at_depth(
|
||||||
|
content,
|
||||||
|
media_parts,
|
||||||
|
scope,
|
||||||
|
replacement_block,
|
||||||
|
replacement_text,
|
||||||
|
clamp_parsed_strings,
|
||||||
|
depth + 1,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_media_kind(part: &Value) -> Option<ToolMediaKind> {
|
||||||
|
let object = part.as_object()?;
|
||||||
|
let part_type = object.get("type").and_then(Value::as_str);
|
||||||
|
|
||||||
|
match part_type {
|
||||||
|
Some("input_image" | "image_url") if normalized_image_url(part).is_some() => {
|
||||||
|
Some(ToolMediaKind::Image)
|
||||||
|
}
|
||||||
|
Some("input_file") if part.get("file_id").is_some() || part.get("file_data").is_some() => {
|
||||||
|
Some(ToolMediaKind::File)
|
||||||
|
}
|
||||||
|
Some("input_audio") if part.get("input_audio").is_some_and(Value::is_object) => {
|
||||||
|
Some(ToolMediaKind::Audio)
|
||||||
|
}
|
||||||
|
Some("image") if typed_image_has_payload(part) => Some(ToolMediaKind::Image),
|
||||||
|
None if loose_data_image_url(part).is_some() => Some(ToolMediaKind::Image),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn chat_image_part(part: &Value) -> Option<Value> {
|
||||||
|
match part.get("type").and_then(Value::as_str) {
|
||||||
|
Some("input_image" | "image_url") => normalized_image_url(part).map(image_url_content_part),
|
||||||
|
Some("image") => typed_image_url(part).map(image_url_content_part),
|
||||||
|
None => loose_data_image_url(part).map(image_url_content_part),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized_image_url(part: &Value) -> Option<Value> {
|
||||||
|
let image_url = part.get("image_url")?;
|
||||||
|
let mut object = match image_url {
|
||||||
|
Value::String(url) if !url.trim().is_empty() => {
|
||||||
|
let mut object = Map::new();
|
||||||
|
object.insert("url".to_string(), Value::String(url.clone()));
|
||||||
|
object
|
||||||
|
}
|
||||||
|
Value::Object(object)
|
||||||
|
if object
|
||||||
|
.get("url")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|url| !url.trim().is_empty()) =>
|
||||||
|
{
|
||||||
|
object.clone()
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
merge_top_level_detail(part, &mut object);
|
||||||
|
Some(Value::Object(object))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn loose_data_image_url(part: &Value) -> Option<Value> {
|
||||||
|
if part.get("type").is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let normalized = normalized_image_url(part)?;
|
||||||
|
let url = normalized.get("url").and_then(Value::as_str)?;
|
||||||
|
if !url
|
||||||
|
.get(..5)
|
||||||
|
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("data:"))
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn typed_image_has_payload(part: &Value) -> bool {
|
||||||
|
let Some(object) = part.as_object() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(source) = object.get("source").and_then(Value::as_object) {
|
||||||
|
if source_media_type_is_image(source) {
|
||||||
|
let has_url = source
|
||||||
|
.get("url")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|url| !url.trim().is_empty());
|
||||||
|
let has_data = source
|
||||||
|
.get("data")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|data| !data.is_empty());
|
||||||
|
if has_url || has_data {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object
|
||||||
|
.get("data")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|data| !data.is_empty())
|
||||||
|
&& object
|
||||||
|
.get("mimeType")
|
||||||
|
.or_else(|| object.get("mime_type"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(is_image_mime_type)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn typed_image_url(part: &Value) -> Option<Value> {
|
||||||
|
let object = part.as_object()?;
|
||||||
|
|
||||||
|
if let Some(source) = object.get("source").and_then(Value::as_object) {
|
||||||
|
if !source_media_type_is_image(source) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(url) = source
|
||||||
|
.get("url")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|url| !url.trim().is_empty())
|
||||||
|
{
|
||||||
|
let mut image_url = Map::new();
|
||||||
|
image_url.insert("url".to_string(), Value::String(url.to_string()));
|
||||||
|
merge_top_level_detail(part, &mut image_url);
|
||||||
|
return Some(Value::Object(image_url));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(data) = source
|
||||||
|
.get("data")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|data| !data.is_empty())
|
||||||
|
{
|
||||||
|
let media_type = source
|
||||||
|
.get("media_type")
|
||||||
|
.or_else(|| source.get("mime_type"))
|
||||||
|
.or_else(|| source.get("mimeType"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("image/png");
|
||||||
|
let url = if data
|
||||||
|
.get(..11)
|
||||||
|
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("data:image/"))
|
||||||
|
{
|
||||||
|
data.to_string()
|
||||||
|
} else {
|
||||||
|
format!("data:{media_type};base64,{data}")
|
||||||
|
};
|
||||||
|
let mut image_url = Map::new();
|
||||||
|
image_url.insert("url".to_string(), Value::String(url));
|
||||||
|
merge_top_level_detail(part, &mut image_url);
|
||||||
|
return Some(Value::Object(image_url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = object
|
||||||
|
.get("data")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|data| !data.is_empty())?;
|
||||||
|
let media_type = object
|
||||||
|
.get("mimeType")
|
||||||
|
.or_else(|| object.get("mime_type"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|media_type| is_image_mime_type(media_type))?;
|
||||||
|
let mut image_url = Map::new();
|
||||||
|
image_url.insert(
|
||||||
|
"url".to_string(),
|
||||||
|
Value::String(format!("data:{media_type};base64,{data}")),
|
||||||
|
);
|
||||||
|
merge_top_level_detail(part, &mut image_url);
|
||||||
|
Some(Value::Object(image_url))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_url_content_part(image_url: Value) -> Value {
|
||||||
|
let mut content_part = Map::new();
|
||||||
|
content_part.insert("type".to_string(), Value::String("image_url".to_string()));
|
||||||
|
content_part.insert("image_url".to_string(), image_url);
|
||||||
|
Value::Object(content_part)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn chat_image_part_has_inline_data(part: &Value) -> bool {
|
||||||
|
part.pointer("/image_url/url")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|url| {
|
||||||
|
let trimmed = url.trim();
|
||||||
|
let Some(comma_index) = trimmed.find(',') else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
comma_index + 1 < trimmed.len() && is_image_base64_data_url(trimmed)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_top_level_detail(part: &Value, image_url: &mut Map<String, Value>) {
|
||||||
|
if image_url.get("detail").is_none() {
|
||||||
|
if let Some(detail) = part.get("detail") {
|
||||||
|
image_url.insert("detail".to_string(), detail.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_media_type_is_image(source: &Map<String, Value>) -> bool {
|
||||||
|
source
|
||||||
|
.get("media_type")
|
||||||
|
.or_else(|| source.get("mime_type"))
|
||||||
|
.or_else(|| source.get("mimeType"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_none_or(is_image_mime_type)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_image_mime_type(value: &str) -> bool {
|
||||||
|
value
|
||||||
|
.get(..6)
|
||||||
|
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("image/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_image_base64_data_url(value: &str) -> bool {
|
||||||
|
let Some(comma_index) = value.find(',') else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let header = &value[..comma_index];
|
||||||
|
let header = header.to_ascii_lowercase();
|
||||||
|
header.starts_with("data:image/") && header.ends_with(";base64")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn looks_like_base64_payload(value: &str) -> bool {
|
||||||
|
if value.len() < BASE64ISH_MIN_BYTES {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
value
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'+' | b'/' | b'='))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||||
|
|
||||||
|
fn large_image_data_url() -> String {
|
||||||
|
format!(
|
||||||
|
"data:image/png;base64,{}",
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAE".repeat(400)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_input_image_and_merges_top_level_detail() {
|
||||||
|
let part = json!({
|
||||||
|
"type": "input_image",
|
||||||
|
"image_url": "https://example.com/image.png",
|
||||||
|
"detail": "high"
|
||||||
|
});
|
||||||
|
|
||||||
|
let mapped = chat_media_part_from_tool_part(&part, ToolMediaScope::AllSupported).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(mapped["type"], "image_url");
|
||||||
|
assert_eq!(mapped["image_url"]["url"], "https://example.com/image.png");
|
||||||
|
assert_eq!(mapped["image_url"]["detail"], "high");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_already_chat_shaped_image_url() {
|
||||||
|
let part = json!({
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": "https://example.com/image.png",
|
||||||
|
"detail": "low"
|
||||||
|
},
|
||||||
|
"cache_control": {"type": "ephemeral"},
|
||||||
|
"prompt_cache_breakpoint": true
|
||||||
|
});
|
||||||
|
|
||||||
|
let mapped = chat_media_part_from_tool_part(&part, ToolMediaScope::AllSupported).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
mapped,
|
||||||
|
json!({
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": "https://example.com/image.png",
|
||||||
|
"detail": "low"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_anthropic_and_mcp_image_shapes() {
|
||||||
|
let anthropic = json!({
|
||||||
|
"type": "image",
|
||||||
|
"source": {
|
||||||
|
"type": "base64",
|
||||||
|
"media_type": "image/jpeg",
|
||||||
|
"data": "YWJj"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let mcp = json!({
|
||||||
|
"type": "image",
|
||||||
|
"mimeType": "image/webp",
|
||||||
|
"data": "ZGVm"
|
||||||
|
});
|
||||||
|
let anthropic_url = json!({
|
||||||
|
"type": "image",
|
||||||
|
"source": {
|
||||||
|
"url": "https://example.com/anthropic.png"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let anthropic =
|
||||||
|
chat_media_part_from_tool_part(&anthropic, ToolMediaScope::AllSupported).unwrap();
|
||||||
|
let mcp = chat_media_part_from_tool_part(&mcp, ToolMediaScope::AllSupported).unwrap();
|
||||||
|
let anthropic_url =
|
||||||
|
chat_media_part_from_tool_part(&anthropic_url, ToolMediaScope::AllSupported).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(anthropic["image_url"]["url"], "data:image/jpeg;base64,YWJj");
|
||||||
|
assert_eq!(mcp["image_url"]["url"], "data:image/webp;base64,ZGVm");
|
||||||
|
assert_eq!(
|
||||||
|
anthropic_url["image_url"]["url"],
|
||||||
|
"https://example.com/anthropic.png"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_anthropic_source_data_when_optional_url_is_empty() {
|
||||||
|
let part = json!({
|
||||||
|
"type": "image",
|
||||||
|
"source": {
|
||||||
|
"url": "",
|
||||||
|
"media_type": "image/png",
|
||||||
|
"data": "YWJj"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mapped = chat_media_part_from_tool_part(&part, ToolMediaScope::AllSupported).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(mapped["image_url"]["url"], "data:image/png;base64,YWJj");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_image_metadata_and_non_image_mcp_payloads() {
|
||||||
|
let metadata = json!({"type": "image", "name": "cover"});
|
||||||
|
let non_image = json!({
|
||||||
|
"type": "image",
|
||||||
|
"mimeType": "text/plain",
|
||||||
|
"data": "aGVsbG8="
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(chat_media_part_from_tool_part(&metadata, ToolMediaScope::AllSupported).is_none());
|
||||||
|
assert!(chat_media_part_from_tool_part(&non_image, ToolMediaScope::AllSupported).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loose_data_image_url_is_media_but_loose_remote_url_is_not() {
|
||||||
|
let data = json!({
|
||||||
|
"image_url": {
|
||||||
|
"url": "data:application/octet-stream;base64,YWJj"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let remote = json!({
|
||||||
|
"image_url": {
|
||||||
|
"url": "https://example.com/search-thumbnail.png"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(tool_output_contains_media(
|
||||||
|
&data,
|
||||||
|
ToolMediaScope::ImagesOnly
|
||||||
|
));
|
||||||
|
assert!(!tool_output_contains_media(
|
||||||
|
&remote,
|
||||||
|
ToolMediaScope::ImagesOnly
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inline_image_scope_rejects_remote_and_malformed_data_urls() {
|
||||||
|
let inline = json!({
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": "data:image/png;base64,YWJj"}
|
||||||
|
});
|
||||||
|
let remote = json!({
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": "https://example.com/image.png"}
|
||||||
|
});
|
||||||
|
let missing_base64 = json!({
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": "data:image/png,YWJj"}
|
||||||
|
});
|
||||||
|
let empty_data = json!({
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {"url": "data:image/png;base64,"}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(tool_output_contains_media(
|
||||||
|
&inline,
|
||||||
|
ToolMediaScope::InlineImagesOnly
|
||||||
|
));
|
||||||
|
assert!(!tool_output_contains_media(
|
||||||
|
&remote,
|
||||||
|
ToolMediaScope::InlineImagesOnly
|
||||||
|
));
|
||||||
|
assert!(!tool_output_contains_media(
|
||||||
|
&missing_base64,
|
||||||
|
ToolMediaScope::InlineImagesOnly
|
||||||
|
));
|
||||||
|
assert!(!tool_output_contains_media(
|
||||||
|
&empty_data,
|
||||||
|
ToolMediaScope::InlineImagesOnly
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn does_not_scan_embedded_data_urls_inside_plain_text() {
|
||||||
|
let data_url = large_image_data_url();
|
||||||
|
let mut value = json!(format!("<html><img src=\"{data_url}\"></html>"));
|
||||||
|
let original = value.clone();
|
||||||
|
let replacement = json!({"type": "text", "text": "moved"});
|
||||||
|
let mut media = Vec::new();
|
||||||
|
|
||||||
|
assert!(!tool_output_contains_media(
|
||||||
|
&value,
|
||||||
|
ToolMediaScope::AllSupported
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
strip_media_from_tool_value(
|
||||||
|
&mut value,
|
||||||
|
&mut media,
|
||||||
|
ToolMediaScope::AllSupported,
|
||||||
|
&replacement,
|
||||||
|
"moved",
|
||||||
|
),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert!(media.is_empty());
|
||||||
|
assert_eq!(value, original);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn whole_string_data_url_respects_threshold() {
|
||||||
|
let large = large_image_data_url();
|
||||||
|
let small = "data:image/png;base64,YWJj";
|
||||||
|
|
||||||
|
assert!(whole_string_image_data_url(&large).is_some());
|
||||||
|
assert!(whole_string_image_data_url(small).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strips_media_from_json_string_and_nested_content() {
|
||||||
|
let data_url = large_image_data_url();
|
||||||
|
let mut value = Value::String(
|
||||||
|
json!({
|
||||||
|
"content": [
|
||||||
|
{"type": "input_text", "text": "caption"},
|
||||||
|
{"type": "input_image", "image_url": data_url}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
let replacement = json!({
|
||||||
|
"type": "text",
|
||||||
|
"text": "moved"
|
||||||
|
});
|
||||||
|
let mut media = Vec::new();
|
||||||
|
|
||||||
|
let replaced = strip_media_from_tool_value(
|
||||||
|
&mut value,
|
||||||
|
&mut media,
|
||||||
|
ToolMediaScope::AllSupported,
|
||||||
|
&replacement,
|
||||||
|
"moved",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(replaced, 1);
|
||||||
|
assert_eq!(media.len(), 1);
|
||||||
|
let serialized = value.as_str().unwrap();
|
||||||
|
assert!(serialized.contains("\"text\":\"moved\""));
|
||||||
|
assert!(!serialized.contains("iVBORw0KGgo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chat_plan_keeps_scalar_tool_strings_unquoted() {
|
||||||
|
let raw_data_url = large_image_data_url();
|
||||||
|
let raw_plan = plan_chat_tool_output_media(Value::String(raw_data_url.clone())).unwrap();
|
||||||
|
assert_eq!(raw_plan.tool_content, TOOL_RESULT_MEDIA_MOVED_MARKER);
|
||||||
|
assert_eq!(raw_plan.media_parts[0]["image_url"]["url"], raw_data_url);
|
||||||
|
|
||||||
|
let encoded = json!({
|
||||||
|
"content": [{
|
||||||
|
"type": "input_image",
|
||||||
|
"image_url": raw_data_url
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let encoded_plan = plan_chat_tool_output_media(Value::String(encoded)).unwrap();
|
||||||
|
assert!(encoded_plan.tool_content.starts_with('{'));
|
||||||
|
assert!(encoded_plan
|
||||||
|
.tool_content
|
||||||
|
.contains(TOOL_RESULT_MEDIA_MOVED_MARKER));
|
||||||
|
assert!(!encoded_plan.tool_content.starts_with('"'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chat_plan_clamps_residual_base64_inside_json_string_before_serializing() {
|
||||||
|
let residual_base64 = "A".repeat(20_000);
|
||||||
|
let encoded = json!({
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "input_image",
|
||||||
|
"image_url": "data:image/png;base64,IMAGE_SENTINEL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "video",
|
||||||
|
"data": residual_base64
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let plan = plan_chat_tool_output_media(Value::String(encoded)).unwrap();
|
||||||
|
|
||||||
|
assert!(plan
|
||||||
|
.tool_content
|
||||||
|
.contains("[cc-switch: omitted 20000 bytes]"));
|
||||||
|
assert!(!plan.tool_content.contains(&"A".repeat(64)));
|
||||||
|
assert!(!plan.tool_content.contains("IMAGE_SENTINEL"));
|
||||||
|
assert_eq!(plan.media_parts.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn image_only_scope_ignores_file_and_audio() {
|
||||||
|
let file = json!({"type": "input_file", "file_id": "file_1"});
|
||||||
|
let audio = json!({
|
||||||
|
"type": "input_audio",
|
||||||
|
"input_audio": {"data": "YWJj", "format": "wav"}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(!tool_output_contains_media(
|
||||||
|
&file,
|
||||||
|
ToolMediaScope::ImagesOnly
|
||||||
|
));
|
||||||
|
assert!(!tool_output_contains_media(
|
||||||
|
&audio,
|
||||||
|
ToolMediaScope::ImagesOnly
|
||||||
|
));
|
||||||
|
assert!(tool_output_contains_media(
|
||||||
|
&file,
|
||||||
|
ToolMediaScope::AllSupported
|
||||||
|
));
|
||||||
|
assert!(tool_output_contains_media(
|
||||||
|
&audio,
|
||||||
|
ToolMediaScope::AllSupported
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clamp_preserves_long_text_but_removes_data_and_base64_payloads() {
|
||||||
|
let long_text = format!(
|
||||||
|
"{} with spaces and punctuation!",
|
||||||
|
"ordinary text ".repeat(9000)
|
||||||
|
);
|
||||||
|
let data_url = large_image_data_url();
|
||||||
|
let bytes = (0_u8..=255).cycle().take(18_000).collect::<Vec<_>>();
|
||||||
|
let base64 = STANDARD.encode(bytes);
|
||||||
|
let mut value = json!({
|
||||||
|
"text": long_text,
|
||||||
|
"data_url": data_url,
|
||||||
|
"raw": base64
|
||||||
|
});
|
||||||
|
|
||||||
|
clamp_base64ish_strings(&mut value);
|
||||||
|
|
||||||
|
assert_eq!(value["text"], long_text);
|
||||||
|
assert!(value["data_url"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("[cc-switch: omitted "));
|
||||||
|
assert!(value["raw"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("[cc-switch: omitted "));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_media_strip_is_byte_stable() {
|
||||||
|
let mut value = json!({
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "hello"},
|
||||||
|
{"type": "image", "name": "business metadata"}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
let before = canonical_json_string(&value);
|
||||||
|
let replacement = json!({"type": "text", "text": "moved"});
|
||||||
|
let mut media = Vec::new();
|
||||||
|
|
||||||
|
let replaced = strip_media_from_tool_value(
|
||||||
|
&mut value,
|
||||||
|
&mut media,
|
||||||
|
ToolMediaScope::AllSupported,
|
||||||
|
&replacement,
|
||||||
|
"moved",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(replaced, 0);
|
||||||
|
assert!(media.is_empty());
|
||||||
|
assert_eq!(canonical_json_string(&value), before);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,7 +59,8 @@ impl CostCalculator {
|
|||||||
pricing: &ModelPricing,
|
pricing: &ModelPricing,
|
||||||
cost_multiplier: Decimal,
|
cost_multiplier: Decimal,
|
||||||
) -> CostBreakdown {
|
) -> CostBreakdown {
|
||||||
let input_includes_cache_read = matches!(app_type, "codex" | "gemini" | "grokbuild");
|
let input_includes_cache_read =
|
||||||
|
crate::services::sql_helpers::is_cache_inclusive_app(app_type);
|
||||||
Self::calculate_with_cache_semantics(
|
Self::calculate_with_cache_semantics(
|
||||||
usage,
|
usage,
|
||||||
pricing,
|
pricing,
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ impl<'a> UsageLogger<'a> {
|
|||||||
|
|
||||||
let created_at = chrono::Utc::now().timestamp();
|
let created_at = chrono::Utc::now().timestamp();
|
||||||
let input_token_semantics =
|
let input_token_semantics =
|
||||||
if matches!(log.app_type.as_str(), "codex" | "gemini" | "grokbuild") {
|
if crate::services::sql_helpers::is_cache_inclusive_app(log.app_type.as_str()) {
|
||||||
INPUT_TOKEN_SEMANTICS_TOTAL
|
INPUT_TOKEN_SEMANTICS_TOTAL
|
||||||
} else {
|
} else {
|
||||||
INPUT_TOKEN_SEMANTICS_FRESH
|
INPUT_TOKEN_SEMANTICS_FRESH
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ pub mod s3_sync;
|
|||||||
pub mod session_usage;
|
pub mod session_usage;
|
||||||
pub mod session_usage_codex;
|
pub mod session_usage_codex;
|
||||||
pub mod session_usage_gemini;
|
pub mod session_usage_gemini;
|
||||||
|
pub mod session_usage_grokbuild;
|
||||||
pub mod session_usage_opencode;
|
pub mod session_usage_opencode;
|
||||||
pub mod skill;
|
pub mod skill;
|
||||||
pub mod speedtest;
|
pub mod speedtest;
|
||||||
pub mod sql_helpers;
|
pub mod sql_helpers;
|
||||||
pub mod stream_check;
|
pub mod stream_check;
|
||||||
pub mod subscription;
|
pub mod subscription;
|
||||||
|
pub mod subscription_grok;
|
||||||
pub mod sync_protocol;
|
pub mod sync_protocol;
|
||||||
pub mod usage_cache;
|
pub mod usage_cache;
|
||||||
pub mod usage_stats;
|
pub mod usage_stats;
|
||||||
|
|||||||
@@ -1505,8 +1505,11 @@ impl ProxyService {
|
|||||||
/// Grok Build live 是否具备可接管的自定义模型表。
|
/// Grok Build live 是否具备可接管的自定义模型表。
|
||||||
///
|
///
|
||||||
/// 官方态 live(Grok CLI 自带 OAuth 登录、无 `[model.*]` 表)没有注入
|
/// 官方态 live(Grok CLI 自带 OAuth 登录、无 `[model.*]` 表)没有注入
|
||||||
/// 占位符的落点,且官方供应商本就禁止经代理接管(封号风险),调用方
|
/// 占位符的落点:Grok CLI 以「config 是否为空」区分官方 OAuth / 自定义
|
||||||
/// 应跳过接管或直接报错。
|
/// 供应商两种模式,表达不出「官方 OAuth + 自定义 base_url」。官方供应商
|
||||||
|
/// 的接管能力门见 `official_provider_supports_proxy_takeover`(按应用逐个
|
||||||
|
/// 开,目前仅 Codex),调用方应跳过接管或直接报错。官方态的用量统计由
|
||||||
|
/// `session_usage_grokbuild` 从会话日志导入,不依赖代理。
|
||||||
fn grok_live_config_supports_takeover(config: &Value) -> bool {
|
fn grok_live_config_supports_takeover(config: &Value) -> bool {
|
||||||
config
|
config
|
||||||
.get("config")
|
.get("config")
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ pub fn sync_all_unlocked(db: &Database) -> SessionSyncResult {
|
|||||||
"OpenCode",
|
"OpenCode",
|
||||||
crate::services::session_usage_opencode::sync_opencode_usage(db),
|
crate::services::session_usage_opencode::sync_opencode_usage(db),
|
||||||
);
|
);
|
||||||
|
merge_sync_step(
|
||||||
|
&mut result,
|
||||||
|
"Grok Build",
|
||||||
|
crate::services::session_usage_grokbuild::sync_grokbuild_usage(db),
|
||||||
|
);
|
||||||
notify_sync_result(&result);
|
notify_sync_result(&result);
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,16 @@
|
|||||||
/// style provider not added here) shows up loudly as a too-low cache hit
|
/// style provider not added here) shows up loudly as a too-low cache hit
|
||||||
/// rate, which is easier to catch than the silent over-deduction that
|
/// rate, which is easier to catch than the silent over-deduction that
|
||||||
/// would happen with the opposite default.
|
/// would happen with the opposite default.
|
||||||
const CACHE_INCLUSIVE_APP_TYPES: &[&str] = &["codex", "gemini", "grokbuild"];
|
/// 单一语义集(SSOT):写入侧(proxy logger/calculator)、回填侧
|
||||||
|
/// (usage_stats 成本重算)与展示侧(本文件的 SQL 归一)都必须引用这里,
|
||||||
|
/// 防止同一语义散落多处后新增 app 时漏改(grokbuild 曾在回填侧漏掉)。
|
||||||
|
/// 前端 `src/types/usage.ts` 的同名常量是跨语言的对应物,改动须同步。
|
||||||
|
pub(crate) const CACHE_INCLUSIVE_APP_TYPES: &[&str] = &["codex", "gemini", "grokbuild"];
|
||||||
|
|
||||||
|
/// `app_type` 的存储 `input_tokens` 是否已包含 cache read/write。
|
||||||
|
pub(crate) fn is_cache_inclusive_app(app_type: &str) -> bool {
|
||||||
|
CACHE_INCLUSIVE_APP_TYPES.contains(&app_type)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) const INPUT_TOKEN_SEMANTICS_LEGACY: i64 = 0;
|
pub(crate) const INPUT_TOKEN_SEMANTICS_LEGACY: i64 = 0;
|
||||||
pub(crate) const INPUT_TOKEN_SEMANTICS_TOTAL: i64 = 1;
|
pub(crate) const INPUT_TOKEN_SEMANTICS_TOTAL: i64 = 1;
|
||||||
|
|||||||
@@ -318,6 +318,12 @@ pub const TIER_MONTHLY: &str = "monthly";
|
|||||||
/// 三处共用同一标识。见 #3651。
|
/// 三处共用同一标识。见 #3651。
|
||||||
pub const TIER_THIRTY_DAY: &str = "30_day";
|
pub const TIER_THIRTY_DAY: &str = "30_day";
|
||||||
|
|
||||||
|
/// Grok credit 额度窗口的兜底 tier 名。Grok 账单接口只返回一个 credit 用量
|
||||||
|
/// 窗口,`subscription_grok::tier_name_for_reset` 按重置距离优先映射到
|
||||||
|
/// `weekly_limit` / `monthly`,两者都不匹配时用此标识;前端 `TIER_I18N_KEYS`
|
||||||
|
/// 映射到 `subscription.credits`,tray 归入 "c" 分组。
|
||||||
|
pub const TIER_CREDITS: &str = "credits";
|
||||||
|
|
||||||
/// Gemini 用量分组名称(按模型而非时间窗口)。`classify_gemini_model` 输出。
|
/// Gemini 用量分组名称(按模型而非时间窗口)。`classify_gemini_model` 输出。
|
||||||
pub const TIER_GEMINI_PRO: &str = "gemini_pro";
|
pub const TIER_GEMINI_PRO: &str = "gemini_pro";
|
||||||
pub const TIER_GEMINI_FLASH: &str = "gemini_flash";
|
pub const TIER_GEMINI_FLASH: &str = "gemini_flash";
|
||||||
@@ -1340,6 +1346,7 @@ pub async fn get_subscription_quota(tool: &str) -> Result<SubscriptionQuota, Str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"grokbuild" => crate::services::subscription_grok::get_grok_subscription_quota().await,
|
||||||
_ => Ok(SubscriptionQuota::not_found(tool)),
|
_ => Ok(SubscriptionQuota::not_found(tool)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,961 @@
|
|||||||
|
//! Grok (xAI) 官方订阅额度查询
|
||||||
|
//!
|
||||||
|
//! 读取 Grok CLI 的 OAuth 凭据(~/.grok/auth.json),调用 grok.com 的
|
||||||
|
//! gRPC-web billing 端点查询 SuperGrok 订阅的 credit 用量。
|
||||||
|
//!
|
||||||
|
//! 实现移植自 CodexBar(steipete/CodexBar)的 Grok provider:
|
||||||
|
//! - 凭据:`GrokAuth.swift` —— auth.json 是以 OIDC scope URL 为 key 的 map,
|
||||||
|
//! 优先 SuperGrok 的 `https://auth.x.ai::<client-id>` 条目,回退 legacy
|
||||||
|
//! session 条目;`key` 字段即 Bearer token。
|
||||||
|
//! - 查询:`GrokWebBillingFetcher.swift` —— POST 空 gRPC-web 帧到
|
||||||
|
//! `GetGrokCreditsConfig`,响应无公开 .proto,用通用 protobuf 扫描按
|
||||||
|
//! 字段路径启发式提取已用百分比与重置时间。
|
||||||
|
//! - token 刷新由 Grok CLI 自己负责(约 7 天过期),本模块只读不刷新,
|
||||||
|
//! 过期时引导用户重新 `grok login`。
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use crate::services::subscription::{
|
||||||
|
CredentialStatus, QuotaTier, SubscriptionQuota, TIER_CREDITS, TIER_MONTHLY, TIER_WEEKLY_LIMIT,
|
||||||
|
};
|
||||||
|
|
||||||
|
const GROK_BILLING_ENDPOINT: &str =
|
||||||
|
"https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig";
|
||||||
|
|
||||||
|
/// SuperGrok(OIDC)条目的 scope 前缀
|
||||||
|
const OIDC_SCOPE_PREFIX: &str = "https://auth.x.ai::";
|
||||||
|
/// 旧版 `grok login` 的 session scope
|
||||||
|
const LEGACY_SESSION_SCOPE: &str = "https://accounts.x.ai/sign-in";
|
||||||
|
|
||||||
|
const RELOGIN_HINT: &str = "Please re-login with `grok login`.";
|
||||||
|
|
||||||
|
// ── 凭据读取 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// (access_token, status, message)
|
||||||
|
type GrokCredentials = (Option<String>, CredentialStatus, Option<String>);
|
||||||
|
|
||||||
|
/// 读取 Grok CLI 的 OAuth 凭据(~/.grok/auth.json,目录可被设置覆盖)
|
||||||
|
fn read_grok_credentials() -> GrokCredentials {
|
||||||
|
let auth_path = crate::grok_config::get_grok_config_dir().join("auth.json");
|
||||||
|
|
||||||
|
if !auth_path.exists() {
|
||||||
|
return (None, CredentialStatus::NotFound, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = match std::fs::read_to_string(&auth_path) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
return (
|
||||||
|
None,
|
||||||
|
CredentialStatus::ParseError,
|
||||||
|
Some(format!("Failed to read Grok auth file: {e}")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
parse_grok_auth_json(&content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析 auth.json:顶层是 scope → 条目的 map,选出首选条目并检查过期
|
||||||
|
fn parse_grok_auth_json(content: &str) -> GrokCredentials {
|
||||||
|
let parsed: serde_json::Value = match serde_json::from_str(content) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
return (
|
||||||
|
None,
|
||||||
|
CredentialStatus::ParseError,
|
||||||
|
Some(format!("Failed to parse Grok auth JSON: {e}")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let root = match parsed.as_object() {
|
||||||
|
Some(o) => o,
|
||||||
|
None => {
|
||||||
|
return (
|
||||||
|
None,
|
||||||
|
CredentialStatus::ParseError,
|
||||||
|
Some("Grok auth.json root is not an object".to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let entry = match select_preferred_entry(root) {
|
||||||
|
Some(e) => e,
|
||||||
|
None => {
|
||||||
|
return (
|
||||||
|
None,
|
||||||
|
CredentialStatus::ParseError,
|
||||||
|
Some("Grok auth.json contains no usable access token".to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// select_preferred_entry 已保证 key 非空
|
||||||
|
let access_token = entry
|
||||||
|
.get("key")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
if let Some(expires_at) = entry.get("expires_at").and_then(|v| v.as_str()) {
|
||||||
|
if is_iso_expired(expires_at) {
|
||||||
|
return (
|
||||||
|
Some(access_token),
|
||||||
|
CredentialStatus::Expired,
|
||||||
|
Some("Grok OAuth token has expired".to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(Some(access_token), CredentialStatus::Valid, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 选择首选凭据条目:OIDC(SuperGrok)优先,legacy session 兜底。
|
||||||
|
///
|
||||||
|
/// 只接受 `key` 非空的条目——残缺的 OIDC 记录不能遮蔽健康的 legacy 条目
|
||||||
|
/// (与 CodexBar `selectPreferredEntry` 一致)。
|
||||||
|
fn select_preferred_entry(
|
||||||
|
root: &serde_json::Map<String, serde_json::Value>,
|
||||||
|
) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
||||||
|
let mut oidc_candidate = None;
|
||||||
|
let mut legacy_candidate = None;
|
||||||
|
|
||||||
|
for (scope, value) in root {
|
||||||
|
let entry = match value.as_object() {
|
||||||
|
Some(e) => e,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
let has_key = entry
|
||||||
|
.get("key")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.is_some_and(|k| !k.is_empty());
|
||||||
|
if !has_key {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if scope.starts_with(OIDC_SCOPE_PREFIX) {
|
||||||
|
oidc_candidate = Some(entry);
|
||||||
|
} else if scope == LEGACY_SESSION_SCOPE || scope.contains("/sign-in") {
|
||||||
|
legacy_candidate = Some(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
oidc_candidate.or(legacy_candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断 ISO 8601 时间串是否已过期;无法解析时不视为过期
|
||||||
|
fn is_iso_expired(iso: &str) -> bool {
|
||||||
|
let now_secs = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs() as i64;
|
||||||
|
|
||||||
|
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(iso) {
|
||||||
|
dt.timestamp() < now_secs
|
||||||
|
} else if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(iso, "%Y-%m-%dT%H:%M:%S%.f") {
|
||||||
|
dt.and_utc().timestamp() < now_secs
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── gRPC-web 帧与 protobuf 解析 ──────────────────────────
|
||||||
|
|
||||||
|
/// protobuf 扫描收集到的字段(路径 = 从根到该字段的 field number 链)
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ProtobufScan {
|
||||||
|
/// (path, float 值, 出现顺序)
|
||||||
|
fixed32_fields: Vec<(Vec<u64>, f32, usize)>,
|
||||||
|
/// (path, varint 值)
|
||||||
|
varint_fields: Vec<(Vec<u64>, u64)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_varint(bytes: &[u8], index: &mut usize) -> Option<u64> {
|
||||||
|
let mut value: u64 = 0;
|
||||||
|
let mut shift: u32 = 0;
|
||||||
|
while *index < bytes.len() && shift < 64 {
|
||||||
|
let byte = bytes[*index];
|
||||||
|
*index += 1;
|
||||||
|
value |= u64::from(byte & 0x7F) << shift;
|
||||||
|
if byte & 0x80 == 0 {
|
||||||
|
return Some(value);
|
||||||
|
}
|
||||||
|
shift += 7;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 递归扫描 protobuf 消息,收集 varint 与 fixed32 字段。
|
||||||
|
///
|
||||||
|
/// 无 .proto 定义,length-delimited 字段一律当嵌套消息试扫(深度 ≤4);
|
||||||
|
/// 无法解析的字节从字段起点 +1 重新同步。返回下一个 fixed32 序号。
|
||||||
|
fn scan_protobuf(
|
||||||
|
bytes: &[u8],
|
||||||
|
depth: usize,
|
||||||
|
path: &[u64],
|
||||||
|
order: usize,
|
||||||
|
scan: &mut ProtobufScan,
|
||||||
|
) -> usize {
|
||||||
|
let mut index = 0;
|
||||||
|
let mut next_order = order;
|
||||||
|
|
||||||
|
while index < bytes.len() {
|
||||||
|
let field_start = index;
|
||||||
|
let key = match read_varint(bytes, &mut index) {
|
||||||
|
Some(k) if k != 0 => k,
|
||||||
|
_ => {
|
||||||
|
index = field_start + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let field_number = key >> 3;
|
||||||
|
let wire_type = key & 0x07;
|
||||||
|
let mut field_path = path.to_vec();
|
||||||
|
field_path.push(field_number);
|
||||||
|
|
||||||
|
match wire_type {
|
||||||
|
0 => match read_varint(bytes, &mut index) {
|
||||||
|
Some(value) => scan.varint_fields.push((field_path, value)),
|
||||||
|
None => index = field_start + 1,
|
||||||
|
},
|
||||||
|
1 => {
|
||||||
|
if index + 8 > bytes.len() {
|
||||||
|
return next_order;
|
||||||
|
}
|
||||||
|
index += 8;
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
let length = match read_varint(bytes, &mut index) {
|
||||||
|
Some(l) if l <= (bytes.len() - index) as u64 => l as usize,
|
||||||
|
_ => {
|
||||||
|
index = field_start + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let end = index + length;
|
||||||
|
if depth < 4 {
|
||||||
|
next_order =
|
||||||
|
scan_protobuf(&bytes[index..end], depth + 1, &field_path, next_order, scan);
|
||||||
|
}
|
||||||
|
index = end;
|
||||||
|
}
|
||||||
|
5 => {
|
||||||
|
if index + 4 > bytes.len() {
|
||||||
|
return next_order;
|
||||||
|
}
|
||||||
|
let bits = u32::from_le_bytes([
|
||||||
|
bytes[index],
|
||||||
|
bytes[index + 1],
|
||||||
|
bytes[index + 2],
|
||||||
|
bytes[index + 3],
|
||||||
|
]);
|
||||||
|
scan.fixed32_fields
|
||||||
|
.push((field_path, f32::from_bits(bits), next_order));
|
||||||
|
next_order += 1;
|
||||||
|
index += 4;
|
||||||
|
}
|
||||||
|
_ => index = field_start + 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next_order
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拆出 gRPC-web data 帧(flags 高位 0x80 的 trailer 帧跳过)。
|
||||||
|
/// 任一帧长度非法时返回空——调用方再按裸 protobuf 兜底。
|
||||||
|
fn grpc_web_data_frames(data: &[u8]) -> Vec<&[u8]> {
|
||||||
|
let mut frames = Vec::new();
|
||||||
|
let mut index = 0;
|
||||||
|
while index < data.len() {
|
||||||
|
if index + 5 > data.len() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let flags = data[index];
|
||||||
|
let length = u32::from_be_bytes([
|
||||||
|
data[index + 1],
|
||||||
|
data[index + 2],
|
||||||
|
data[index + 3],
|
||||||
|
data[index + 4],
|
||||||
|
]) as usize;
|
||||||
|
let start = index + 5;
|
||||||
|
let end = start + length;
|
||||||
|
if end > data.len() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
if flags & 0x80 == 0 {
|
||||||
|
frames.push(&data[start..end]);
|
||||||
|
}
|
||||||
|
index = end;
|
||||||
|
}
|
||||||
|
frames
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 响应体没有帧头时,看首字节是否像合法 protobuf tag(某些成功请求直接返回裸 protobuf)
|
||||||
|
fn looks_like_protobuf_payload(data: &[u8]) -> bool {
|
||||||
|
match data.first() {
|
||||||
|
Some(&first) => {
|
||||||
|
let field_number = first >> 3;
|
||||||
|
let wire_type = first & 0x07;
|
||||||
|
field_number > 0 && matches!(wire_type, 0 | 1 | 2 | 5)
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 trailer 帧(flags & 0x80)解析 `grpc-status` / `grpc-message` 等字段
|
||||||
|
fn grpc_web_trailer_fields(data: &[u8]) -> HashMap<String, String> {
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
let mut index = 0;
|
||||||
|
while index + 5 <= data.len() {
|
||||||
|
let flags = data[index];
|
||||||
|
let length = u32::from_be_bytes([
|
||||||
|
data[index + 1],
|
||||||
|
data[index + 2],
|
||||||
|
data[index + 3],
|
||||||
|
data[index + 4],
|
||||||
|
]) as usize;
|
||||||
|
let start = index + 5;
|
||||||
|
let end = start + length;
|
||||||
|
if end > data.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if flags & 0x80 != 0 {
|
||||||
|
if let Ok(text) = std::str::from_utf8(&data[start..end]) {
|
||||||
|
for line in text.lines().filter(|l| !l.is_empty()) {
|
||||||
|
if let Some((key, value)) = line.split_once(':') {
|
||||||
|
fields.insert(key.trim().to_lowercase(), percent_decode(value.trim()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index = end;
|
||||||
|
}
|
||||||
|
fields
|
||||||
|
}
|
||||||
|
|
||||||
|
/// gRPC message 使用 percent-encoding;解码失败的序列原样保留
|
||||||
|
fn percent_decode(input: &str) -> String {
|
||||||
|
let bytes = input.as_bytes();
|
||||||
|
let mut out = Vec::with_capacity(bytes.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||||
|
// 只切字节切片再校验 UTF-8:对 &str 按字节切片会在多字节字符
|
||||||
|
// 边界内 panic(trailer 内容由服务端控制,可含任意 UTF-8)
|
||||||
|
if let Ok(hex) = std::str::from_utf8(&bytes[i + 1..i + 3]) {
|
||||||
|
if let Ok(b) = u8::from_str_radix(hex, 16) {
|
||||||
|
out.push(b);
|
||||||
|
i += 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(bytes[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&out).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析出的账单快照
|
||||||
|
struct GrokBillingSnapshot {
|
||||||
|
used_percent: f64,
|
||||||
|
/// Unix 秒
|
||||||
|
resets_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从响应体提取已用百分比与重置时间(CodexBar `parseGRPCWebResponse` 的移植)。
|
||||||
|
///
|
||||||
|
/// 启发式:
|
||||||
|
/// - 百分比:wire-type 5 (float) 中路径末段为 1、值域 [0,100] 的字段,
|
||||||
|
/// 取路径最浅、出现最早的那个;
|
||||||
|
/// - 重置时间:varint 中值落在合理 Unix 秒区间且晚于当前时刻的字段,
|
||||||
|
/// 优先精确路径 [1,5,1],否则取最近的未来时间;
|
||||||
|
/// - 零用量特判:proto3 会省略值为 0 的 percent 字段,此时若存在重置时间
|
||||||
|
/// 和用量周期标记(路径 [1,6,*] 或 [1,8,1]=1/2),按 0% 处理。
|
||||||
|
fn parse_billing_payload(data: &[u8], now_secs: i64) -> Result<GrokBillingSnapshot, String> {
|
||||||
|
let mut payloads = grpc_web_data_frames(data);
|
||||||
|
if payloads.is_empty() && looks_like_protobuf_payload(data) {
|
||||||
|
payloads = vec![data];
|
||||||
|
}
|
||||||
|
if payloads.is_empty() {
|
||||||
|
return Err("Grok billing response contained no protobuf payload".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut scan = ProtobufScan::default();
|
||||||
|
for payload in payloads {
|
||||||
|
// 与 CodexBar 一致:fixed32 序号在每个顶层 data 帧内独立从 0 计数
|
||||||
|
scan_protobuf(payload, 0, &[], 0, &mut scan);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed_percent = scan
|
||||||
|
.fixed32_fields
|
||||||
|
.iter()
|
||||||
|
.filter(|(path, value, _)| {
|
||||||
|
path.last() == Some(&1) && value.is_finite() && *value >= 0.0 && *value <= 100.0
|
||||||
|
})
|
||||||
|
.min_by_key(|(path, _, order)| (path.len(), *order))
|
||||||
|
.map(|(_, value, _)| f64::from(*value));
|
||||||
|
|
||||||
|
let reset_candidates: Vec<(&[u64], i64)> = scan
|
||||||
|
.varint_fields
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, value)| (1_700_000_000..=2_100_000_000).contains(value))
|
||||||
|
.map(|(path, value)| (path.as_slice(), *value as i64))
|
||||||
|
.filter(|(_, ts)| *ts > now_secs)
|
||||||
|
.collect();
|
||||||
|
let reset = reset_candidates
|
||||||
|
.iter()
|
||||||
|
.filter(|(path, _)| *path == [1, 5, 1])
|
||||||
|
.map(|(_, ts)| *ts)
|
||||||
|
.min()
|
||||||
|
.or_else(|| reset_candidates.iter().map(|(_, ts)| *ts).min());
|
||||||
|
|
||||||
|
let has_usage_period = scan.varint_fields.iter().any(|(path, value)| {
|
||||||
|
path.starts_with(&[1, 6]) || (path.as_slice() == [1, 8, 1] && (*value == 1 || *value == 2))
|
||||||
|
});
|
||||||
|
let no_usage_yet = parsed_percent.is_none()
|
||||||
|
&& scan.fixed32_fields.is_empty()
|
||||||
|
&& reset.is_some()
|
||||||
|
&& has_usage_period;
|
||||||
|
|
||||||
|
let used_percent = match parsed_percent.or(if no_usage_yet { Some(0.0) } else { None }) {
|
||||||
|
Some(p) => p,
|
||||||
|
None => return Err("Could not locate usage percent in Grok billing response".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(GrokBillingSnapshot {
|
||||||
|
used_percent,
|
||||||
|
resets_at: reset,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API 查询 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 认证类失败(token 无效/过期)的 gRPC 状态判定,
|
||||||
|
/// 移植自 CodexBar `GrokWebBillingError.isAuthenticationFailure`
|
||||||
|
fn is_grpc_auth_failure(status: i64, message: &str) -> bool {
|
||||||
|
if status == 16 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if status != 7 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let lower = message.to_lowercase();
|
||||||
|
lower.contains("bad-credentials")
|
||||||
|
|| lower.contains("unauthenticated")
|
||||||
|
|| (lower.contains("oauth2") && lower.contains("could not be validated"))
|
||||||
|
|| (lower.contains("access token")
|
||||||
|
&& (lower.contains("invalid")
|
||||||
|
|| lower.contains("expired")
|
||||||
|
|| lower.contains("could not be validated")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// xAI 尚未提供团队主体的用量接口,识别其专属失败以给出可读提示
|
||||||
|
fn is_team_billing_unavailable(status: i64, message: &str) -> bool {
|
||||||
|
status == 9
|
||||||
|
&& matches!(
|
||||||
|
message.trim().to_lowercase().as_str(),
|
||||||
|
"no personal team" | "no personal team."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 瞬时性 gRPC 状态:DEADLINE_EXCEEDED(4) / UNAVAILABLE(14),以及带超时
|
||||||
|
/// 文案的 CANCELLED(1)。语义上等价 HTTP 504/503,对齐 CodexBar `shouldRetry`
|
||||||
|
/// 的 rpcFailed 分支
|
||||||
|
fn is_transient_grpc_status(status: i64, message: &str) -> bool {
|
||||||
|
match status {
|
||||||
|
4 | 14 => true,
|
||||||
|
1 => {
|
||||||
|
let lower = message.to_lowercase();
|
||||||
|
lower.contains("timeout") || lower.contains("deadline") || lower.contains("expired")
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将非 0 的 gRPC 状态映射为失败。
|
||||||
|
///
|
||||||
|
/// 瞬时状态(超时/不可用)→ `Err`:前端 react-query retry + keep-last-good
|
||||||
|
/// 保留上次成功值,托盘保留旧快照;折叠成 `Ok(success:false)` 会因错误文案
|
||||||
|
/// 匹配不到前端 `isTransientUsageError` 的任何瞬时模式而被当确定性失败,
|
||||||
|
/// 一次服务端抖动就清掉展示值与 lastGood 快照。其余状态 → 确定性失败快照。
|
||||||
|
/// header(trailers-only 响应)与 body trailer 两条路径都必须走这里。
|
||||||
|
fn grpc_status_failure(
|
||||||
|
status: i64,
|
||||||
|
message: &str,
|
||||||
|
tool_label: &str,
|
||||||
|
relogin_hint: &str,
|
||||||
|
) -> Result<SubscriptionQuota, String> {
|
||||||
|
if is_transient_grpc_status(status, message) {
|
||||||
|
return Err(format!(
|
||||||
|
"Transient gRPC failure (grpc-status {status}): {message}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(grpc_status_error(status, message, tool_label, relogin_hint))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将非 0 的 gRPC 状态映射为确定性失败快照
|
||||||
|
fn grpc_status_error(
|
||||||
|
status: i64,
|
||||||
|
message: &str,
|
||||||
|
tool_label: &str,
|
||||||
|
relogin_hint: &str,
|
||||||
|
) -> SubscriptionQuota {
|
||||||
|
if is_grpc_auth_failure(status, message) {
|
||||||
|
return SubscriptionQuota::error(
|
||||||
|
tool_label,
|
||||||
|
CredentialStatus::Expired,
|
||||||
|
format!("Grok credentials were rejected (grpc-status {status}). {relogin_hint}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if is_team_billing_unavailable(status, message) {
|
||||||
|
return SubscriptionQuota::error(
|
||||||
|
tool_label,
|
||||||
|
CredentialStatus::Valid,
|
||||||
|
"Grok team usage is not available from the billing API yet".to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
SubscriptionQuota::error(
|
||||||
|
tool_label,
|
||||||
|
CredentialStatus::Valid,
|
||||||
|
format!("Grok billing RPC failed (grpc-status {status}): {message}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按重置时间距今的天数推断窗口 tier 名(CodexBar `primaryLabel` 的阈值):
|
||||||
|
/// 4–12 天 → 周窗口,20–45 天 → 月窗口,其余 → 通用 credit 额度
|
||||||
|
fn tier_name_for_reset(resets_at: Option<i64>, now_secs: i64) -> &'static str {
|
||||||
|
if let Some(ts) = resets_at {
|
||||||
|
let days = ((ts - now_secs) as f64 / 86400.0).round() as i64;
|
||||||
|
if (4..=12).contains(&days) {
|
||||||
|
return TIER_WEEKLY_LIMIT;
|
||||||
|
}
|
||||||
|
if (20..=45).contains(&days) {
|
||||||
|
return TIER_MONTHLY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TIER_CREDITS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 查询 Grok 官方订阅额度
|
||||||
|
///
|
||||||
|
/// 与 claude/codex/gemini 同一约定:瞬时传输失败返回 `Err`(前端 retry +
|
||||||
|
/// 保留上次成功值),确定性失败返回 `Ok(success:false)`。
|
||||||
|
///
|
||||||
|
/// 参数化 `tool_label` / `relogin_hint` 让该函数可被两个调用点共用(与
|
||||||
|
/// `query_codex_quota` 的双调用点设计一致):
|
||||||
|
/// - `"grokbuild"` + "grok login"(Grok CLI 凭据路径)
|
||||||
|
/// - `"xai_oauth"` + "re-login via cc-switch"(cc-switch 自管 xAI OAuth 路径,
|
||||||
|
/// 见 `commands::xai_oauth::get_xai_oauth_quota`;两者是同一个 OAuth client,
|
||||||
|
/// token 对 grok.com 账单端点等效)
|
||||||
|
pub(crate) async fn query_grok_quota(
|
||||||
|
access_token: &str,
|
||||||
|
tool_label: &str,
|
||||||
|
relogin_hint: &str,
|
||||||
|
) -> Result<SubscriptionQuota, String> {
|
||||||
|
let client = crate::proxy::http_client::get();
|
||||||
|
|
||||||
|
// 空 gRPC-web 帧:1 字节 flags + 4 字节大端长度 0
|
||||||
|
let resp = client
|
||||||
|
.post(GROK_BILLING_ENDPOINT)
|
||||||
|
.header("Authorization", format!("Bearer {access_token}"))
|
||||||
|
.header("Origin", "https://grok.com")
|
||||||
|
.header("Referer", "https://grok.com/?_s=usage")
|
||||||
|
.header("Accept", "*/*")
|
||||||
|
.header("Content-Type", "application/grpc-web+proto")
|
||||||
|
.header("x-grpc-web", "1")
|
||||||
|
.header("x-user-agent", "connect-es/2.1.1")
|
||||||
|
.header("User-Agent", "cc-switch")
|
||||||
|
.body(vec![0u8; 5])
|
||||||
|
.timeout(std::time::Duration::from_secs(15))
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let resp = match resp {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => return Err(format!("Network error: {e}")),
|
||||||
|
};
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
|
||||||
|
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||||
|
return Ok(SubscriptionQuota::error(
|
||||||
|
tool_label,
|
||||||
|
CredentialStatus::Expired,
|
||||||
|
format!("Authentication failed (HTTP {status}). {relogin_hint}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP 408 与 grpc-status 4 同为服务端超时,以 Err 传播(前端 retry +
|
||||||
|
// keep-last-good);折叠进下方通用分支会因前端 isTransientUsageError 只认
|
||||||
|
// 5xx/429 为瞬时而清掉 lastGood。CodexBar 的 shouldRetry 同样重试 408,
|
||||||
|
// 其余的 502/503/504 前端已按 5xx 识别为瞬时,维持 Ok(success:false)。
|
||||||
|
if status == reqwest::StatusCode::REQUEST_TIMEOUT {
|
||||||
|
return Err(format!("Transient HTTP failure (HTTP {status})"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// gRPC 错误可能在 HTTP 头里携带(trailers-only 响应),先于响应体检查
|
||||||
|
let header_status = resp
|
||||||
|
.headers()
|
||||||
|
.get("grpc-status")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.parse::<i64>().ok());
|
||||||
|
let header_message = resp
|
||||||
|
.headers()
|
||||||
|
.get("grpc-message")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(percent_decode)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
let body: String = body.chars().take(400).collect();
|
||||||
|
return Ok(SubscriptionQuota::error(
|
||||||
|
tool_label,
|
||||||
|
CredentialStatus::Valid,
|
||||||
|
format!("API error (HTTP {status}): {body}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(code) = header_status {
|
||||||
|
if code != 0 {
|
||||||
|
return grpc_status_failure(code, &header_message, tool_label, relogin_hint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw = match resp.bytes().await {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => return Err(format!("Failed to read API response: {e}")),
|
||||||
|
};
|
||||||
|
|
||||||
|
let trailers = grpc_web_trailer_fields(&raw);
|
||||||
|
if let Some(code) = trailers
|
||||||
|
.get("grpc-status")
|
||||||
|
.and_then(|v| v.parse::<i64>().ok())
|
||||||
|
{
|
||||||
|
if code != 0 {
|
||||||
|
let message = trailers
|
||||||
|
.get("grpc-message")
|
||||||
|
.map(String::as_str)
|
||||||
|
.unwrap_or("");
|
||||||
|
return grpc_status_failure(code, message, tool_label, relogin_hint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let now_secs = now_secs();
|
||||||
|
let snapshot = match parse_billing_payload(&raw, now_secs) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
return Ok(SubscriptionQuota::error(
|
||||||
|
tool_label,
|
||||||
|
CredentialStatus::Valid,
|
||||||
|
format!("Failed to parse API response: {e}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let tier = QuotaTier {
|
||||||
|
name: tier_name_for_reset(snapshot.resets_at, now_secs).to_string(),
|
||||||
|
utilization: snapshot.used_percent.clamp(0.0, 100.0),
|
||||||
|
resets_at: snapshot
|
||||||
|
.resets_at
|
||||||
|
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0))
|
||||||
|
.map(|dt| dt.to_rfc3339()),
|
||||||
|
used_value_usd: None,
|
||||||
|
max_value_usd: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(SubscriptionQuota {
|
||||||
|
tool: tool_label.to_string(),
|
||||||
|
credential_status: CredentialStatus::Valid,
|
||||||
|
credential_message: None,
|
||||||
|
success: true,
|
||||||
|
tiers: vec![tier],
|
||||||
|
extra_usage: None,
|
||||||
|
error: None,
|
||||||
|
queried_at: Some(now_millis()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// grokbuild 的订阅额度入口(由 `subscription::get_subscription_quota` 分发)
|
||||||
|
pub(crate) async fn get_grok_subscription_quota() -> Result<SubscriptionQuota, String> {
|
||||||
|
let (token, status, message) = read_grok_credentials();
|
||||||
|
|
||||||
|
match status {
|
||||||
|
CredentialStatus::NotFound => Ok(SubscriptionQuota::not_found("grokbuild")),
|
||||||
|
CredentialStatus::ParseError => Ok(SubscriptionQuota::error(
|
||||||
|
"grokbuild",
|
||||||
|
CredentialStatus::ParseError,
|
||||||
|
message.unwrap_or_else(|| "Failed to parse Grok credentials".to_string()),
|
||||||
|
)),
|
||||||
|
CredentialStatus::Expired => {
|
||||||
|
// 即使过期也尝试调用 API(时钟偏差时 token 可能仍有效)
|
||||||
|
if let Some(ref token) = token {
|
||||||
|
let result = query_grok_quota(token, "grokbuild", RELOGIN_HINT).await?;
|
||||||
|
if result.success {
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(SubscriptionQuota::error(
|
||||||
|
"grokbuild",
|
||||||
|
CredentialStatus::Expired,
|
||||||
|
format!(
|
||||||
|
"{} {RELOGIN_HINT}",
|
||||||
|
message.unwrap_or_else(|| "Grok OAuth token has expired.".to_string())
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
CredentialStatus::Valid => {
|
||||||
|
let token = token.expect("token must be Some when status is Valid");
|
||||||
|
query_grok_quota(&token, "grokbuild", RELOGIN_HINT).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 辅助函数 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn now_secs() -> i64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs() as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_millis() -> i64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ── protobuf 构造辅助 ──
|
||||||
|
|
||||||
|
fn varint(mut value: u64) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
loop {
|
||||||
|
let byte = (value & 0x7F) as u8;
|
||||||
|
value >>= 7;
|
||||||
|
if value == 0 {
|
||||||
|
out.push(byte);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out.push(byte | 0x80);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field_varint(number: u64, value: u64) -> Vec<u8> {
|
||||||
|
let mut out = varint(number << 3);
|
||||||
|
out.extend(varint(value));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field_float(number: u64, value: f32) -> Vec<u8> {
|
||||||
|
let mut out = varint((number << 3) | 5);
|
||||||
|
out.extend(value.to_bits().to_le_bytes());
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field_message(number: u64, payload: &[u8]) -> Vec<u8> {
|
||||||
|
let mut out = varint((number << 3) | 2);
|
||||||
|
out.extend(varint(payload.len() as u64));
|
||||||
|
out.extend(payload);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn grpc_web_frame(flags: u8, payload: &[u8]) -> Vec<u8> {
|
||||||
|
let mut out = vec![flags];
|
||||||
|
out.extend((payload.len() as u32).to_be_bytes());
|
||||||
|
out.extend(payload);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOW: i64 = 1_750_000_000;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_percent_and_reset_from_framed_payload() {
|
||||||
|
// message { 1: { 1: 37.5f, 5: { 1: reset_ts } } }
|
||||||
|
let reset_ts = (NOW + 30 * 86400) as u64;
|
||||||
|
let inner = [
|
||||||
|
field_float(1, 37.5),
|
||||||
|
field_message(5, &field_varint(1, reset_ts)),
|
||||||
|
]
|
||||||
|
.concat();
|
||||||
|
let payload = field_message(1, &inner);
|
||||||
|
let data = grpc_web_frame(0, &payload);
|
||||||
|
|
||||||
|
let snapshot = parse_billing_payload(&data, NOW).expect("parse ok");
|
||||||
|
assert_eq!(snapshot.used_percent, 37.5);
|
||||||
|
assert_eq!(snapshot.resets_at, Some(reset_ts as i64));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_bare_protobuf_without_frame_header() {
|
||||||
|
let payload = field_message(1, &field_float(1, 12.0));
|
||||||
|
let snapshot = parse_billing_payload(&payload, NOW).expect("parse ok");
|
||||||
|
assert_eq!(snapshot.used_percent, 12.0);
|
||||||
|
assert_eq!(snapshot.resets_at, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prefers_shallowest_percent_candidate() {
|
||||||
|
// 深层 [1,2,1]=99.0 不应盖过浅层 [1,1]=25.0
|
||||||
|
let inner = [
|
||||||
|
field_message(2, &field_float(1, 99.0)),
|
||||||
|
field_float(1, 25.0),
|
||||||
|
]
|
||||||
|
.concat();
|
||||||
|
let payload = field_message(1, &inner);
|
||||||
|
let data = grpc_web_frame(0, &payload);
|
||||||
|
|
||||||
|
let snapshot = parse_billing_payload(&data, NOW).expect("parse ok");
|
||||||
|
assert_eq!(snapshot.used_percent, 25.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_usage_period_without_percent_field_reads_as_zero() {
|
||||||
|
// proto3 省略 0 值 percent:仅有 [1,5,1] 重置时间 + [1,6,1] 周期标记
|
||||||
|
let reset_ts = (NOW + 7 * 86400) as u64;
|
||||||
|
let inner = [
|
||||||
|
field_message(5, &field_varint(1, reset_ts)),
|
||||||
|
field_message(6, &field_varint(1, 3)),
|
||||||
|
]
|
||||||
|
.concat();
|
||||||
|
let payload = field_message(1, &inner);
|
||||||
|
let data = grpc_web_frame(0, &payload);
|
||||||
|
|
||||||
|
let snapshot = parse_billing_payload(&data, NOW).expect("parse ok");
|
||||||
|
assert_eq!(snapshot.used_percent, 0.0);
|
||||||
|
assert_eq!(snapshot.resets_at, Some(reset_ts as i64));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_percent_without_period_marker_is_parse_error() {
|
||||||
|
let payload = field_message(1, &field_varint(7, 42));
|
||||||
|
let data = grpc_web_frame(0, &payload);
|
||||||
|
assert!(parse_billing_payload(&data, NOW).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trailer_frames_are_excluded_from_payload_and_expose_status() {
|
||||||
|
let payload = field_message(1, &field_float(1, 50.0));
|
||||||
|
let mut data = grpc_web_frame(0, &payload);
|
||||||
|
data.extend(grpc_web_frame(0x80, b"grpc-status: 0\r\ngrpc-message: ok"));
|
||||||
|
|
||||||
|
let snapshot = parse_billing_payload(&data, NOW).expect("parse ok");
|
||||||
|
assert_eq!(snapshot.used_percent, 50.0);
|
||||||
|
|
||||||
|
let trailers = grpc_web_trailer_fields(&data);
|
||||||
|
assert_eq!(trailers.get("grpc-status").map(String::as_str), Some("0"));
|
||||||
|
assert_eq!(trailers.get("grpc-message").map(String::as_str), Some("ok"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn percent_decode_unescapes_grpc_message() {
|
||||||
|
assert_eq!(percent_decode("no%20personal%20team"), "no personal team");
|
||||||
|
assert_eq!(percent_decode("plain"), "plain");
|
||||||
|
// 非法序列原样保留
|
||||||
|
assert_eq!(percent_decode("50%ZZ"), "50%ZZ");
|
||||||
|
// '%' + ASCII + 多字节字符:不得在字符边界内切片 panic
|
||||||
|
assert_eq!(percent_decode("bad%1é"), "bad%1é");
|
||||||
|
assert_eq!(percent_decode("%1é"), "%1é");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_json_prefers_oidc_entry_over_legacy() {
|
||||||
|
let content = r#"{
|
||||||
|
"https://accounts.x.ai/sign-in": {"key": "legacy-token"},
|
||||||
|
"https://auth.x.ai::client-id": {"key": "oidc-token"}
|
||||||
|
}"#;
|
||||||
|
let (token, status, _) = parse_grok_auth_json(content);
|
||||||
|
assert_eq!(token.as_deref(), Some("oidc-token"));
|
||||||
|
assert!(matches!(status, CredentialStatus::Valid));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_json_empty_oidc_key_falls_back_to_legacy() {
|
||||||
|
// 残缺 OIDC 记录不遮蔽健康的 legacy 条目
|
||||||
|
let content = r#"{
|
||||||
|
"https://auth.x.ai::client-id": {"key": ""},
|
||||||
|
"https://accounts.x.ai/sign-in": {"key": "legacy-token"}
|
||||||
|
}"#;
|
||||||
|
let (token, status, _) = parse_grok_auth_json(content);
|
||||||
|
assert_eq!(token.as_deref(), Some("legacy-token"));
|
||||||
|
assert!(matches!(status, CredentialStatus::Valid));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_json_expired_entry_reports_expired() {
|
||||||
|
let content = r#"{
|
||||||
|
"https://auth.x.ai::client-id": {
|
||||||
|
"key": "token",
|
||||||
|
"expires_at": "2020-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
let (token, status, message) = parse_grok_auth_json(content);
|
||||||
|
assert_eq!(token.as_deref(), Some("token"));
|
||||||
|
assert!(matches!(status, CredentialStatus::Expired));
|
||||||
|
assert!(message.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_json_without_usable_entry_is_parse_error() {
|
||||||
|
let (token, status, _) = parse_grok_auth_json(r#"{"other-scope": {"key": "x"}}"#);
|
||||||
|
assert!(token.is_none());
|
||||||
|
assert!(matches!(status, CredentialStatus::ParseError));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tier_name_follows_reset_distance() {
|
||||||
|
assert_eq!(
|
||||||
|
tier_name_for_reset(Some(NOW + 7 * 86400), NOW),
|
||||||
|
TIER_WEEKLY_LIMIT
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
tier_name_for_reset(Some(NOW + 30 * 86400), NOW),
|
||||||
|
TIER_MONTHLY
|
||||||
|
);
|
||||||
|
assert_eq!(tier_name_for_reset(Some(NOW + 86400), NOW), TIER_CREDITS);
|
||||||
|
assert_eq!(tier_name_for_reset(None, NOW), TIER_CREDITS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grpc_auth_and_team_failures_classify_correctly() {
|
||||||
|
assert!(is_grpc_auth_failure(16, ""));
|
||||||
|
assert!(is_grpc_auth_failure(7, "Bad-Credentials: token rejected"));
|
||||||
|
assert!(!is_grpc_auth_failure(7, "quota exceeded"));
|
||||||
|
assert!(is_team_billing_unavailable(9, " No Personal Team "));
|
||||||
|
assert!(!is_team_billing_unavailable(9, "other precondition"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transient_grpc_statuses_propagate_as_err() {
|
||||||
|
// DEADLINE_EXCEEDED / UNAVAILABLE 无条件瞬时
|
||||||
|
assert!(is_transient_grpc_status(4, ""));
|
||||||
|
assert!(is_transient_grpc_status(14, ""));
|
||||||
|
// CANCELLED 仅在带超时文案时瞬时
|
||||||
|
assert!(is_transient_grpc_status(1, "context deadline exceeded"));
|
||||||
|
assert!(!is_transient_grpc_status(1, "cancelled by user"));
|
||||||
|
// 鉴权/团队/其他状态不属瞬时
|
||||||
|
assert!(!is_transient_grpc_status(16, ""));
|
||||||
|
assert!(!is_transient_grpc_status(9, "no personal team"));
|
||||||
|
assert!(!is_transient_grpc_status(13, "internal"));
|
||||||
|
|
||||||
|
// 瞬时 → Err(前端 retry + keep-last-good),确定性 → Ok(success:false)
|
||||||
|
assert!(grpc_status_failure(4, "deadline exceeded", "grokbuild", RELOGIN_HINT).is_err());
|
||||||
|
assert!(grpc_status_failure(14, "unavailable", "grokbuild", RELOGIN_HINT).is_err());
|
||||||
|
let determinate = grpc_status_failure(13, "internal", "grokbuild", RELOGIN_HINT)
|
||||||
|
.expect("determinate is Ok");
|
||||||
|
assert!(!determinate.success);
|
||||||
|
// tool_label 参数化:两条链路(CLI / cc-switch 自管 OAuth)标签正确落到快照
|
||||||
|
let auth =
|
||||||
|
grpc_status_failure(16, "", "xai_oauth", "re-login").expect("auth failure is Ok");
|
||||||
|
assert!(matches!(auth.credential_status, CredentialStatus::Expired));
|
||||||
|
assert_eq!(auth.tool, "xai_oauth");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -214,6 +214,7 @@ fn provider_name_coalesce(log_alias: &str, provider_alias: &str) -> String {
|
|||||||
WHEN '_codex_session' THEN 'Codex (Session)' \
|
WHEN '_codex_session' THEN 'Codex (Session)' \
|
||||||
WHEN '_gemini_session' THEN 'Gemini (Session)' \
|
WHEN '_gemini_session' THEN 'Gemini (Session)' \
|
||||||
WHEN '_opencode_session' THEN 'OpenCode (Session)' \
|
WHEN '_opencode_session' THEN 'OpenCode (Session)' \
|
||||||
|
WHEN '_grok_session' THEN 'Grok Build (Session)' \
|
||||||
ELSE {log_alias}.provider_id END)"
|
ELSE {log_alias}.provider_id END)"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -416,6 +417,40 @@ pub(crate) fn has_matching_proxy_usage_log(
|
|||||||
.map_err(|e| AppError::Database(format!("查询重复代理用量日志失败: {e}")))
|
.map_err(|e| AppError::Database(format!("查询重复代理用量日志失败: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// grokbuild 会话导入的接管活动守卫:给定时刻 ±窗口内存在任何 grokbuild
|
||||||
|
/// 代理直录行,即认为当时处于代理接管态,会话事件应整体跳过——同一请求
|
||||||
|
/// 已由代理逐请求记账,会话侧再入账必双算。
|
||||||
|
///
|
||||||
|
/// 不复用 [`has_matching_proxy_usage_log`] 的指纹匹配:Grok 会话事件是
|
||||||
|
/// 逐轮聚合值,与代理逐请求行的 token 值结构性不相等,指纹永不命中。
|
||||||
|
/// 这里按"接管态检测"而非"行匹配"设计,故不过滤 status_code——失败的
|
||||||
|
/// 代理请求同样证明流量正走代理。
|
||||||
|
///
|
||||||
|
/// 已知局限(有意取舍,方向保守只漏不双):窗口不含 session 维度,任一
|
||||||
|
/// grokbuild 代理行会给 ±窗口内的全部会话事件投下阴影——接管/官方两态在
|
||||||
|
/// 十分钟内交替或并行使用时,官方侧轮次会被跳过(漏记而非双算)。
|
||||||
|
pub(crate) fn has_recent_grokbuild_proxy_activity(
|
||||||
|
conn: &Connection,
|
||||||
|
created_at: i64,
|
||||||
|
) -> Result<bool, AppError> {
|
||||||
|
let l_data_source = data_source_expr("l");
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM proxy_request_logs l
|
||||||
|
WHERE {l_data_source} = 'proxy'
|
||||||
|
AND l.app_type = 'grokbuild'
|
||||||
|
AND l.created_at BETWEEN ?1 - ?2 AND ?1 + ?2
|
||||||
|
)"
|
||||||
|
);
|
||||||
|
conn.query_row(
|
||||||
|
&sql,
|
||||||
|
params![created_at, SESSION_PROXY_DEDUP_WINDOW_SECONDS],
|
||||||
|
|row| row.get::<_, bool>(0),
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(format!("查询 Grok 接管活动失败: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn has_suspected_codex_session_duplicate(
|
pub(crate) fn has_suspected_codex_session_duplicate(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
request_id: &str,
|
request_id: &str,
|
||||||
@@ -1850,10 +1885,11 @@ impl Database {
|
|||||||
let million = rust_decimal::Decimal::from(1_000_000u64);
|
let million = rust_decimal::Decimal::from(1_000_000u64);
|
||||||
|
|
||||||
// 与 CostCalculator::calculate_for_app 保持一致的计算逻辑:
|
// 与 CostCalculator::calculate_for_app 保持一致的计算逻辑:
|
||||||
// 1. 历史 Codex/Gemini 行只包含 cache read;新 total 行还包含 cache write。
|
// 1. 历史 cache-inclusive 行只包含 cache read;新 total 行还包含 cache write。
|
||||||
// 2. Claude/Anthropic 的 input_tokens 已经是 fresh input,不能再次扣减
|
// 2. Claude/Anthropic 的 input_tokens 已经是 fresh input,不能再次扣减
|
||||||
// 3. 各项成本是基础成本(不含倍率),倍率只作用于最终总价
|
// 3. 各项成本是基础成本(不含倍率),倍率只作用于最终总价
|
||||||
let cache_inclusive_app = matches!(log.app_type.as_str(), "codex" | "gemini");
|
let cache_inclusive_app =
|
||||||
|
crate::services::sql_helpers::is_cache_inclusive_app(log.app_type.as_str());
|
||||||
let billable_input_tokens =
|
let billable_input_tokens =
|
||||||
if !cache_inclusive_app || log.input_token_semantics == INPUT_TOKEN_SEMANTICS_FRESH {
|
if !cache_inclusive_app || log.input_token_semantics == INPUT_TOKEN_SEMANTICS_FRESH {
|
||||||
log.input_tokens as u64
|
log.input_tokens as u64
|
||||||
@@ -2613,6 +2649,53 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_backfill_deducts_cache_read_for_grokbuild_total_rows() -> Result<(), AppError> {
|
||||||
|
// 回归:回填侧的 cache-inclusive 判定曾硬编码 codex|gemini 漏掉
|
||||||
|
// grokbuild,导致 TOTAL 行按全量 input 计价、cache_read 双算。
|
||||||
|
// 判定收敛到 sql_helpers::is_cache_inclusive_app 后按 450 fresh 计价。
|
||||||
|
let db = Database::memory()?;
|
||||||
|
{
|
||||||
|
let conn = lock_conn!(db.conn);
|
||||||
|
insert_usage_log(
|
||||||
|
&conn,
|
||||||
|
"grokbuild-total-backfill",
|
||||||
|
"grokbuild",
|
||||||
|
"_grok_session",
|
||||||
|
"grok-4.5",
|
||||||
|
"grok_session",
|
||||||
|
1000,
|
||||||
|
700,
|
||||||
|
100,
|
||||||
|
250,
|
||||||
|
0,
|
||||||
|
200,
|
||||||
|
"0",
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE proxy_request_logs
|
||||||
|
SET input_token_semantics = ?1
|
||||||
|
WHERE request_id = 'grokbuild-total-backfill'",
|
||||||
|
[INPUT_TOKEN_SEMANTICS_TOTAL],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(db.backfill_missing_usage_costs()?, 1);
|
||||||
|
|
||||||
|
let conn = lock_conn!(db.conn);
|
||||||
|
let (input_cost, cache_read_cost, total_cost): (String, String, String) = conn.query_row(
|
||||||
|
"SELECT input_cost_usd, cache_read_cost_usd, total_cost_usd
|
||||||
|
FROM proxy_request_logs WHERE request_id = 'grokbuild-total-backfill'",
|
||||||
|
[],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||||
|
)?;
|
||||||
|
// grok-4.5 定价 2/6/0.50:input = (700-250)×2/1M,cache_read = 250×0.5/1M
|
||||||
|
assert_eq!(input_cost, "0.000900");
|
||||||
|
assert_eq!(cache_read_cost, "0.000125");
|
||||||
|
assert_eq!(total_cost, "0.001625");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_backfill_missing_usage_costs_uses_stored_multiplier() -> Result<(), AppError> {
|
fn test_backfill_missing_usage_costs_uses_stored_multiplier() -> Result<(), AppError> {
|
||||||
let db = Database::memory()?;
|
let db = Database::memory()?;
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ const M_TIER_NAMES: &[&str] = &[
|
|||||||
crate::services::subscription::TIER_MONTHLY,
|
crate::services::subscription::TIER_MONTHLY,
|
||||||
crate::services::subscription::TIER_THIRTY_DAY,
|
crate::services::subscription::TIER_THIRTY_DAY,
|
||||||
];
|
];
|
||||||
|
// Grok credit 额度的兜底窗口(重置距离能识别为周/月时归入 w/m 组)
|
||||||
|
const CREDITS_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_CREDITS];
|
||||||
const GEMINI_PRO_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_GEMINI_PRO];
|
const GEMINI_PRO_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_GEMINI_PRO];
|
||||||
const GEMINI_FLASH_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_GEMINI_FLASH];
|
const GEMINI_FLASH_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_GEMINI_FLASH];
|
||||||
const GEMINI_FLASH_LITE_TIER_NAMES: &[&str] =
|
const GEMINI_FLASH_LITE_TIER_NAMES: &[&str] =
|
||||||
@@ -34,6 +36,7 @@ const TIER_LABEL_GROUPS: &[(&str, &[&str])] = &[
|
|||||||
("h", H_TIER_NAMES),
|
("h", H_TIER_NAMES),
|
||||||
("w", W_TIER_NAMES),
|
("w", W_TIER_NAMES),
|
||||||
("m", M_TIER_NAMES),
|
("m", M_TIER_NAMES),
|
||||||
|
("c", CREDITS_TIER_NAMES),
|
||||||
("p", GEMINI_PRO_TIER_NAMES),
|
("p", GEMINI_PRO_TIER_NAMES),
|
||||||
("f", GEMINI_FLASH_TIER_NAMES),
|
("f", GEMINI_FLASH_TIER_NAMES),
|
||||||
("l", GEMINI_FLASH_LITE_TIER_NAMES),
|
("l", GEMINI_FLASH_LITE_TIER_NAMES),
|
||||||
@@ -1097,6 +1100,7 @@ pub(crate) async fn refresh_all_usage_in_tray(app: &tauri::AppHandle) {
|
|||||||
let app_clone = app.clone();
|
let app_clone = app.clone();
|
||||||
let state = app.state::<AppState>();
|
let state = app.state::<AppState>();
|
||||||
let copilot_state = app.state::<CopilotAuthState>();
|
let copilot_state = app.state::<CopilotAuthState>();
|
||||||
|
let xai_state = app.state::<crate::commands::XaiOAuthState>();
|
||||||
let provider_id = current_id.clone();
|
let provider_id = current_id.clone();
|
||||||
let app_str = app_type_str.to_string();
|
let app_str = app_type_str.to_string();
|
||||||
script_futures.push(async move {
|
script_futures.push(async move {
|
||||||
@@ -1104,6 +1108,7 @@ pub(crate) async fn refresh_all_usage_in_tray(app: &tauri::AppHandle) {
|
|||||||
app_clone,
|
app_clone,
|
||||||
state,
|
state,
|
||||||
copilot_state,
|
copilot_state,
|
||||||
|
xai_state,
|
||||||
provider_id.clone(),
|
provider_id.clone(),
|
||||||
app_str,
|
app_str,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export const TIER_I18N_KEYS: Record<string, string> = {
|
|||||||
weekly_limit: "subscription.sevenDay",
|
weekly_limit: "subscription.sevenDay",
|
||||||
// 火山方舟 Agent Plan / Coding Plan 的月窗口
|
// 火山方舟 Agent Plan / Coding Plan 的月窗口
|
||||||
monthly: "subscription.monthly",
|
monthly: "subscription.monthly",
|
||||||
|
// Grok credit 额度的兜底窗口(重置距离可识别时归入 weekly_limit/monthly)
|
||||||
|
credits: "subscription.credits",
|
||||||
// GitHub Copilot
|
// GitHub Copilot
|
||||||
premium: "subscription.copilotPremium",
|
premium: "subscription.copilotPremium",
|
||||||
};
|
};
|
||||||
@@ -422,7 +424,8 @@ const SubscriptionQuotaFooter: React.FC<SubscriptionQuotaFooterProps> = ({
|
|||||||
quota={quota}
|
quota={quota}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
refetch={refetch}
|
refetch={refetch}
|
||||||
appIdForExpiredHint={appId}
|
// expiredHint 里的 {tool} 是 CLI 命令名:Grok 的命令是 `grok` 而非 appId
|
||||||
|
appIdForExpiredHint={appId === "grokbuild" ? "grok" : appId}
|
||||||
inline={inline}
|
inline={inline}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ function detectBalanceProvider(baseUrl: string | undefined): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isOfficialSubscriptionProvider(provider: Provider, appId: AppId) {
|
function isOfficialSubscriptionProvider(provider: Provider, appId: AppId) {
|
||||||
if (!["claude", "codex", "gemini"].includes(appId)) return false;
|
if (!["claude", "codex", "gemini", "grokbuild"].includes(appId)) return false;
|
||||||
if (provider.category === "official") return true;
|
if (provider.category === "official") return true;
|
||||||
|
|
||||||
const config = provider.settingsConfig as Record<string, any>;
|
const config = provider.settingsConfig as Record<string, any>;
|
||||||
@@ -188,6 +188,12 @@ function isOfficialSubscriptionProvider(provider: Provider, appId: AppId) {
|
|||||||
(!baseUrl || (typeof baseUrl === "string" && baseUrl.trim() === ""))
|
(!baseUrl || (typeof baseUrl === "string" && baseUrl.trim() === ""))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// grokbuild 不做配置启发式,只认上方的 category === "official":官方态判定
|
||||||
|
// 在后端是 TOML 解析(grok_config::is_official_live_config),正则无法忠实
|
||||||
|
// 镜像(引号键/inline table/非法 TOML 均会误判为官方),误判会让本组件的
|
||||||
|
// state 初始化丢弃已保存的非官方脚本。claude/codex/gemini 的启发式建立在
|
||||||
|
// 已解析的 JSON 字段上是精确的,不受此限。官方判定以 category 为 SSOT 的
|
||||||
|
// 理由见 ProviderCard 中的注释。
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import React from "react";
|
||||||
|
import type { ProviderMeta } from "@/types";
|
||||||
|
import { useXaiOauthQuota } from "@/lib/query/subscription";
|
||||||
|
import { SubscriptionQuotaView } from "@/components/SubscriptionQuotaFooter";
|
||||||
|
|
||||||
|
interface XaiOauthQuotaFooterProps {
|
||||||
|
meta?: ProviderMeta;
|
||||||
|
inline?: boolean;
|
||||||
|
/** 是否为当前激活的供应商 */
|
||||||
|
isCurrent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* xAI OAuth (SuperGrok 反代) 订阅额度 footer
|
||||||
|
*
|
||||||
|
* 复用 SubscriptionQuotaView 的全部渲染逻辑(5 状态 × inline/expanded)。
|
||||||
|
* 数据源为 cc-switch 自管的 xAI OAuth token,与 Grok Build 分区读 Grok CLI
|
||||||
|
* 凭据的路径查同一个 grok.com 账单端点,展示同一份订阅额度。
|
||||||
|
*/
|
||||||
|
const XaiOauthQuotaFooter: React.FC<XaiOauthQuotaFooterProps> = ({
|
||||||
|
meta,
|
||||||
|
inline = false,
|
||||||
|
isCurrent = false,
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
data: quota,
|
||||||
|
isFetching: loading,
|
||||||
|
refetch,
|
||||||
|
} = useXaiOauthQuota(meta, { enabled: true, autoQuery: isCurrent });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SubscriptionQuotaView
|
||||||
|
quota={quota}
|
||||||
|
loading={loading}
|
||||||
|
refetch={refetch}
|
||||||
|
appIdForExpiredHint="xai_oauth"
|
||||||
|
inline={inline}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default XaiOauthQuotaFooter;
|
||||||
@@ -14,6 +14,7 @@ import UsageFooter from "@/components/UsageFooter";
|
|||||||
import SubscriptionQuotaFooter from "@/components/SubscriptionQuotaFooter";
|
import SubscriptionQuotaFooter from "@/components/SubscriptionQuotaFooter";
|
||||||
import CopilotQuotaFooter from "@/components/CopilotQuotaFooter";
|
import CopilotQuotaFooter from "@/components/CopilotQuotaFooter";
|
||||||
import CodexOauthQuotaFooter from "@/components/CodexOauthQuotaFooter";
|
import CodexOauthQuotaFooter from "@/components/CodexOauthQuotaFooter";
|
||||||
|
import XaiOauthQuotaFooter from "@/components/XaiOauthQuotaFooter";
|
||||||
import { PROVIDER_TYPES, TEMPLATE_TYPES } from "@/config/constants";
|
import { PROVIDER_TYPES, TEMPLATE_TYPES } from "@/config/constants";
|
||||||
import { isHermesReadOnlyProvider } from "@/config/hermesProviderPresets";
|
import { isHermesReadOnlyProvider } from "@/config/hermesProviderPresets";
|
||||||
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
|
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
|
||||||
@@ -196,7 +197,7 @@ export function ProviderCard({
|
|||||||
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
|
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
|
||||||
const isOfficial = isOfficialProvider(provider, appId);
|
const isOfficial = isOfficialProvider(provider, appId);
|
||||||
const supportsOfficialSubscription =
|
const supportsOfficialSubscription =
|
||||||
isOfficial && ["claude", "codex", "gemini"].includes(appId);
|
isOfficial && ["claude", "codex", "gemini", "grokbuild"].includes(appId);
|
||||||
const isOfficialSubscriptionUsage =
|
const isOfficialSubscriptionUsage =
|
||||||
provider.meta?.usage_script?.templateType ===
|
provider.meta?.usage_script?.templateType ===
|
||||||
TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION;
|
TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION;
|
||||||
@@ -227,6 +228,8 @@ export function ProviderCard({
|
|||||||
appId === "hermes" && isHermesReadOnlyProvider(provider.settingsConfig);
|
appId === "hermes" && isHermesReadOnlyProvider(provider.settingsConfig);
|
||||||
const isCodexOauth =
|
const isCodexOauth =
|
||||||
provider.meta?.providerType === PROVIDER_TYPES.CODEX_OAUTH;
|
provider.meta?.providerType === PROVIDER_TYPES.CODEX_OAUTH;
|
||||||
|
// xAI OAuth (SuperGrok 反代):额度经自管 OAuth token 自动显示,与 codex_oauth 同构
|
||||||
|
const isXaiOauth = provider.meta?.providerType === PROVIDER_TYPES.XAI_OAUTH;
|
||||||
// 统一权威谓词(详见 providerNeedsRouting):以 providerType 为准,不受
|
// 统一权威谓词(详见 providerNeedsRouting):以 providerType 为准,不受
|
||||||
// apiFormat 被改动/缺省影响。此 badge 仅在 Codex 视图渲染,故加 appId 守卫。
|
// apiFormat 被改动/缺省影响。此 badge 仅在 Codex 视图渲染,故加 appId 守卫。
|
||||||
const codexNeedsRouting =
|
const codexNeedsRouting =
|
||||||
@@ -493,6 +496,12 @@ export function ProviderCard({
|
|||||||
inline={true}
|
inline={true}
|
||||||
isCurrent={isCurrent}
|
isCurrent={isCurrent}
|
||||||
/>
|
/>
|
||||||
|
) : isXaiOauth ? (
|
||||||
|
<XaiOauthQuotaFooter
|
||||||
|
meta={provider.meta}
|
||||||
|
inline={true}
|
||||||
|
isCurrent={isCurrent}
|
||||||
|
/>
|
||||||
) : isOfficial ? (
|
) : isOfficial ? (
|
||||||
officialSubscriptionEnabled ? (
|
officialSubscriptionEnabled ? (
|
||||||
<SubscriptionQuotaFooter
|
<SubscriptionQuotaFooter
|
||||||
@@ -573,7 +582,8 @@ export function ProviderCard({
|
|||||||
onConfigureUsage={
|
onConfigureUsage={
|
||||||
(isOfficial && !supportsOfficialSubscription) ||
|
(isOfficial && !supportsOfficialSubscription) ||
|
||||||
isCopilot ||
|
isCopilot ||
|
||||||
isCodexOauth
|
isCodexOauth ||
|
||||||
|
isXaiOauth
|
||||||
? undefined
|
? undefined
|
||||||
: () => onConfigureUsage(provider)
|
: () => onConfigureUsage(provider)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const DATA_SOURCE_ICONS: Record<string, React.ReactNode> = {
|
|||||||
codex_session: <FileText className="h-3.5 w-3.5" />,
|
codex_session: <FileText className="h-3.5 w-3.5" />,
|
||||||
gemini_session: <FileText className="h-3.5 w-3.5" />,
|
gemini_session: <FileText className="h-3.5 w-3.5" />,
|
||||||
opencode_session: <FileText className="h-3.5 w-3.5" />,
|
opencode_session: <FileText className="h-3.5 w-3.5" />,
|
||||||
|
grok_session: <FileText className="h-3.5 w-3.5" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DataSourceBar({ refreshIntervalMs }: DataSourceBarProps) {
|
export function DataSourceBar({ refreshIntervalMs }: DataSourceBarProps) {
|
||||||
|
|||||||
@@ -1501,7 +1501,8 @@
|
|||||||
"claude": "Claude Code",
|
"claude": "Claude Code",
|
||||||
"codex": "Codex",
|
"codex": "Codex",
|
||||||
"gemini": "Gemini",
|
"gemini": "Gemini",
|
||||||
"opencode": "OpenCode"
|
"opencode": "OpenCode",
|
||||||
|
"grokbuild": "Grok Build"
|
||||||
},
|
},
|
||||||
"rawInputLabel": "Raw",
|
"rawInputLabel": "Raw",
|
||||||
"dataSources": "Data Sources",
|
"dataSources": "Data Sources",
|
||||||
@@ -1511,7 +1512,8 @@
|
|||||||
"codex_db": "Codex DB",
|
"codex_db": "Codex DB",
|
||||||
"codex_session": "Codex Session",
|
"codex_session": "Codex Session",
|
||||||
"gemini_session": "Gemini Session",
|
"gemini_session": "Gemini Session",
|
||||||
"opencode_session": "OpenCode Session"
|
"opencode_session": "OpenCode Session",
|
||||||
|
"grok_session": "Grok Build Session"
|
||||||
},
|
},
|
||||||
"sessionSync": {
|
"sessionSync": {
|
||||||
"trigger": "Sync session logs",
|
"trigger": "Sync session logs",
|
||||||
@@ -3006,6 +3008,7 @@
|
|||||||
"geminiFlashLite": "Flash Lite",
|
"geminiFlashLite": "Flash Lite",
|
||||||
"weeklyLimit": "Weekly",
|
"weeklyLimit": "Weekly",
|
||||||
"monthly": "Monthly",
|
"monthly": "Monthly",
|
||||||
|
"credits": "Credits",
|
||||||
"copilotPremium": "Premium",
|
"copilotPremium": "Premium",
|
||||||
"utilization": "{{value}}%",
|
"utilization": "{{value}}%",
|
||||||
"resetsIn": "Resets in {{time}}",
|
"resetsIn": "Resets in {{time}}",
|
||||||
|
|||||||
@@ -1501,7 +1501,8 @@
|
|||||||
"claude": "Claude Code",
|
"claude": "Claude Code",
|
||||||
"codex": "Codex",
|
"codex": "Codex",
|
||||||
"gemini": "Gemini",
|
"gemini": "Gemini",
|
||||||
"opencode": "OpenCode"
|
"opencode": "OpenCode",
|
||||||
|
"grokbuild": "Grok Build"
|
||||||
},
|
},
|
||||||
"rawInputLabel": "原始",
|
"rawInputLabel": "原始",
|
||||||
"dataSources": "データソース",
|
"dataSources": "データソース",
|
||||||
@@ -1511,7 +1512,8 @@
|
|||||||
"codex_db": "Codex DB",
|
"codex_db": "Codex DB",
|
||||||
"codex_session": "Codex セッション",
|
"codex_session": "Codex セッション",
|
||||||
"gemini_session": "Gemini セッション",
|
"gemini_session": "Gemini セッション",
|
||||||
"opencode_session": "OpenCode セッション"
|
"opencode_session": "OpenCode セッション",
|
||||||
|
"grok_session": "Grok Build セッション"
|
||||||
},
|
},
|
||||||
"sessionSync": {
|
"sessionSync": {
|
||||||
"trigger": "セッションログを同期",
|
"trigger": "セッションログを同期",
|
||||||
@@ -3006,6 +3008,7 @@
|
|||||||
"geminiFlashLite": "Flash Lite",
|
"geminiFlashLite": "Flash Lite",
|
||||||
"weeklyLimit": "週間",
|
"weeklyLimit": "週間",
|
||||||
"monthly": "月間",
|
"monthly": "月間",
|
||||||
|
"credits": "クレジット",
|
||||||
"copilotPremium": "プレミアム",
|
"copilotPremium": "プレミアム",
|
||||||
"utilization": "{{value}}%",
|
"utilization": "{{value}}%",
|
||||||
"resetsIn": "{{time}}後にリセット",
|
"resetsIn": "{{time}}後にリセット",
|
||||||
|
|||||||
@@ -1473,7 +1473,8 @@
|
|||||||
"claude": "Claude Code",
|
"claude": "Claude Code",
|
||||||
"codex": "Codex",
|
"codex": "Codex",
|
||||||
"gemini": "Gemini",
|
"gemini": "Gemini",
|
||||||
"opencode": "OpenCode"
|
"opencode": "OpenCode",
|
||||||
|
"grokbuild": "Grok Build"
|
||||||
},
|
},
|
||||||
"rawInputLabel": "原始",
|
"rawInputLabel": "原始",
|
||||||
"dataSources": "資料來源",
|
"dataSources": "資料來源",
|
||||||
@@ -1483,7 +1484,8 @@
|
|||||||
"codex_db": "Codex 資料庫",
|
"codex_db": "Codex 資料庫",
|
||||||
"codex_session": "Codex 工作階段日誌",
|
"codex_session": "Codex 工作階段日誌",
|
||||||
"gemini_session": "Gemini 工作階段日誌",
|
"gemini_session": "Gemini 工作階段日誌",
|
||||||
"opencode_session": "OpenCode 工作階段日誌"
|
"opencode_session": "OpenCode 工作階段日誌",
|
||||||
|
"grok_session": "Grok Build 工作階段日誌"
|
||||||
},
|
},
|
||||||
"sessionSync": {
|
"sessionSync": {
|
||||||
"trigger": "同步工作階段日誌",
|
"trigger": "同步工作階段日誌",
|
||||||
@@ -2978,6 +2980,7 @@
|
|||||||
"geminiFlashLite": "Flash Lite",
|
"geminiFlashLite": "Flash Lite",
|
||||||
"weeklyLimit": "每週",
|
"weeklyLimit": "每週",
|
||||||
"monthly": "每月",
|
"monthly": "每月",
|
||||||
|
"credits": "額度",
|
||||||
"copilotPremium": "進階請求",
|
"copilotPremium": "進階請求",
|
||||||
"utilization": "{{value}}%",
|
"utilization": "{{value}}%",
|
||||||
"resetsIn": "{{time}} 後重設",
|
"resetsIn": "{{time}} 後重設",
|
||||||
|
|||||||
@@ -1501,7 +1501,8 @@
|
|||||||
"claude": "Claude Code",
|
"claude": "Claude Code",
|
||||||
"codex": "Codex",
|
"codex": "Codex",
|
||||||
"gemini": "Gemini",
|
"gemini": "Gemini",
|
||||||
"opencode": "OpenCode"
|
"opencode": "OpenCode",
|
||||||
|
"grokbuild": "Grok Build"
|
||||||
},
|
},
|
||||||
"rawInputLabel": "原始",
|
"rawInputLabel": "原始",
|
||||||
"dataSources": "数据来源",
|
"dataSources": "数据来源",
|
||||||
@@ -1511,7 +1512,8 @@
|
|||||||
"codex_db": "Codex 数据库",
|
"codex_db": "Codex 数据库",
|
||||||
"codex_session": "Codex 会话日志",
|
"codex_session": "Codex 会话日志",
|
||||||
"gemini_session": "Gemini 会话日志",
|
"gemini_session": "Gemini 会话日志",
|
||||||
"opencode_session": "OpenCode 会话日志"
|
"opencode_session": "OpenCode 会话日志",
|
||||||
|
"grok_session": "Grok Build 会话日志"
|
||||||
},
|
},
|
||||||
"sessionSync": {
|
"sessionSync": {
|
||||||
"trigger": "同步会话日志",
|
"trigger": "同步会话日志",
|
||||||
@@ -3006,6 +3008,7 @@
|
|||||||
"geminiFlashLite": "Flash Lite",
|
"geminiFlashLite": "Flash Lite",
|
||||||
"weeklyLimit": "每周",
|
"weeklyLimit": "每周",
|
||||||
"monthly": "每月",
|
"monthly": "每月",
|
||||||
|
"credits": "额度",
|
||||||
"copilotPremium": "高级请求",
|
"copilotPremium": "高级请求",
|
||||||
"utilization": "{{value}}%",
|
"utilization": "{{value}}%",
|
||||||
"resetsIn": "{{time}}后重置",
|
"resetsIn": "{{time}}后重置",
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export const subscriptionApi = {
|
|||||||
invoke("get_subscription_quota", { tool }),
|
invoke("get_subscription_quota", { tool }),
|
||||||
getCodexOauthQuota: (accountId: string | null): Promise<SubscriptionQuota> =>
|
getCodexOauthQuota: (accountId: string | null): Promise<SubscriptionQuota> =>
|
||||||
invoke("get_codex_oauth_quota", { accountId }),
|
invoke("get_codex_oauth_quota", { accountId }),
|
||||||
|
getXaiOauthQuota: (accountId: string | null): Promise<SubscriptionQuota> =>
|
||||||
|
invoke("get_xai_oauth_quota", { accountId }),
|
||||||
getCodingPlanQuota: (
|
getCodingPlanQuota: (
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
|
|||||||
@@ -90,7 +90,8 @@ export function useSubscriptionQuota(
|
|||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: subscriptionKeys.quota(appId),
|
queryKey: subscriptionKeys.quota(appId),
|
||||||
queryFn: () => subscriptionApi.getQuota(appId),
|
queryFn: () => subscriptionApi.getQuota(appId),
|
||||||
enabled: enabled && ["claude", "codex", "gemini"].includes(appId),
|
enabled:
|
||||||
|
enabled && ["claude", "codex", "gemini", "grokbuild"].includes(appId),
|
||||||
refetchInterval,
|
refetchInterval,
|
||||||
refetchIntervalInBackground: Boolean(refetchInterval),
|
refetchIntervalInBackground: Boolean(refetchInterval),
|
||||||
refetchOnWindowFocus: Boolean(refetchInterval),
|
refetchOnWindowFocus: Boolean(refetchInterval),
|
||||||
@@ -138,3 +139,30 @@ export function useCodexOauthQuota(
|
|||||||
|
|
||||||
return useQuotaKeepLastGood(query, accountId ?? "default");
|
return useQuotaKeepLastGood(query, accountId ?? "default");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* xAI OAuth (SuperGrok 反代) 订阅额度查询 hook
|
||||||
|
*
|
||||||
|
* 与 `useCodexOauthQuota` 平行:数据走 cc-switch 自管的 xAI OAuth token,
|
||||||
|
* 而不是 Grok CLI 的 ~/.grok/auth.json;后端复用同一个 grok.com 账单端点,
|
||||||
|
* 因此与 Grok Build 分区的官方订阅显示同一份额度。
|
||||||
|
*/
|
||||||
|
export function useXaiOauthQuota(
|
||||||
|
meta: ProviderMeta | undefined,
|
||||||
|
options: UseCodexOauthQuotaOptions = {},
|
||||||
|
) {
|
||||||
|
const { enabled = true, autoQuery = false } = options;
|
||||||
|
const accountId = resolveManagedAccountId(meta, PROVIDER_TYPES.XAI_OAUTH);
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["xai_oauth", "quota", accountId ?? "default"],
|
||||||
|
queryFn: () => subscriptionApi.getXaiOauthQuota(accountId),
|
||||||
|
enabled,
|
||||||
|
refetchInterval: autoQuery ? REFETCH_INTERVAL : false,
|
||||||
|
refetchIntervalInBackground: autoQuery,
|
||||||
|
refetchOnWindowFocus: autoQuery,
|
||||||
|
staleTime: REFETCH_INTERVAL,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
return useQuotaKeepLastGood(query, accountId ?? "default");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user