Files
CC-Switch/src-tauri/src/proxy/providers/transform_codex_anthropic.rs
T
zayoka ff3bc242cc fix(Security): zip-slip on skill install, two credential leaks, and three panic paths (#5811)
* fix(security): harden GrokBuild credential handling and Codex/Anthropic transforms against malformed input

Three robustness/security fixes in the upstream v3.18.0 code, each with a
regression test.

1) grok_config: remove the unconditional XAI_API_KEY fallback in
   extract_credentials(). Credentials now come only from an explicit inline
   api_key or the process env var named by env_key. Silently substituting a
   different account's key (when the declared env_key var is unset) could
   leak that key to whatever base_url the config points at.

2) deeplink/provider: merge_grokbuild_config no longer resolves env vars
   into a plaintext api_key on import. A deeplink is untrusted input;
   resolving+inlining would persist the victim's environment secret into the
   imported provider's config.toml and ship it to the link's declared
   base_url. env_key now stays an indirection (name), not a resolved secret.

3) proxy transforms: stop panicking on malformed upstream data.
   - transform_codex_anthropic::anthropic_sse_to_message_value: only store a
     `message`/`content_block` when it is an object; otherwise treat it as
     empty, so the later `["content"]`/`["text"]`/`["signature"]` index
     assignments can't panic on a scalar/array Value.
   - streaming_codex_anthropic::responses_sse_events_from_anthropic_message:
     bail out with a failed-event when the buffered body is a top-level JSON
     array/scalar (a gateway that ignores stream:true), instead of
     index-assigning into a non-object.
   - mcp/grokbuild sync: normalize a non-table `mcp_servers` before
     inserting, avoiding a toml_edit IndexMut panic on a user-edited
     config.toml.

Panic findings verified with a minimal repro (serde_json index-assignment on
a non-object Value aborts). cargo test --lib: 2183 passed / 0 failed.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(mcp): normalize a non-table `mcp_servers` in Codex config before insert

Same class of bug as the GrokBuild MCP fix in the previous commit, but in the
much more widely used Codex path.

`sync_single_server_to_codex` guarded only with `contains_key("mcp_servers")`,
so a user-edited `~/.codex/config.toml` where the key exists but is *not* a
table (`mcp_servers = "x"` / `[]` / `42`) skipped the rebuild and then hit
`doc["mcp_servers"][id] = …`, which panics in toml_edit's `IndexMut`
(`.expect("index not found")`). The panic happens inside a Tauri command and
unwinds across the FFI boundary; in the provider-switch flow it also fires
after the DB/live write already committed, leaving a half-applied switch.

Extract the normalize-then-insert step into `upsert_mcp_server_table()` so the
previously panic-prone logic is unit-testable without touching the real
`~/.codex/config.toml`, and normalize any non-table value to an empty table
before inserting.

Audited the sibling MCP writers while here: claude.rs / gemini.rs /
hermes.rs / opencode.rs do not have this pattern (their `contains_key` uses are
read-only checks), and the other index-assignments in codex.rs operate on
freshly built `Table::new()` values, which are safe.

Tests: 2 new regression tests (malformed non-table values normalize and insert;
an existing valid table keeps its entries). cargo test --lib 2185 passed / 0
failed; cargo clippy --lib -D warnings clean.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(security): reject path-traversal entries when extracting a skill repo archive

`SkillService::download_and_extract` built the output path from the raw ZIP
entry name (`file.name()`), stripped only the leading `<repo>-<branch>/`
component, and then `dest.join(relative_path)` → `fs::File::create`. A crafted
entry such as `repo-main/../../../evil.sh` therefore escaped the destination
directory (zip-slip): arbitrary file write outside the temp extraction dir.

The archive is third-party controlled: it is downloaded from
`https://github.com/<owner>/<name>/archive/refs/heads/<branch>.zip`, and a
skill repo (`owner`/`name`) can be added through an untrusted `ccswitch://`
deeplink (`deeplink/skill.rs`), so the attacker fully controls the archive
contents.

Fix: resolve each entry through `zip::read::ZipFile::enclosed_name()`, which
rejects `..` components and absolute paths, before stripping the archive's
root directory and joining onto `dest`. Unsafe entries are skipped with a
warning. This matches what the two sibling extractors in this codebase already
did correctly (`extract_local_zip`, `webdav_sync/archive.rs`) — this call site
was the one that had been missed.

The extraction loop is split out into `extract_repo_archive()` so the guard is
testable without network access.

Verified the test actually catches the bug: with the guard reverted to the old
`file.name()` behaviour the new test fails ("zip-slip entry must not escape
dest (temp root)"); with the guard in place it passes.

cargo test --lib: 2186 passed / 0 failed; cargo clippy --lib -D warnings clean.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(security): strip all credentials from the shared Gemini common-config snippet

`extract_gemini_common_config` skipped only two hardcoded keys
(`GOOGLE_GEMINI_BASE_URL`, `GEMINI_API_KEY`) and copied every other `env`
entry into the shared snippet. But `GOOGLE_API_KEY` is a first-class Gemini
credential — `provider.rs` resolves it via
`first_non_empty(env, &["GEMINI_API_KEY", "GOOGLE_API_KEY"])` — so it was
never stripped.

That snippet is not inert: `apply_common_config_to_settings` (live.rs)
deep-merges it into the `env` of *every other* Gemini provider that uses the
common config, and the snippet is auto-extracted on startup, on import, and on
switch. Net effect: account A's key gets written into provider B and sent to
B's `GOOGLE_GEMINI_BASE_URL`, which may be a third-party relay. Anything else
the user put in `env` (`GOOGLE_APPLICATION_CREDENTIALS`, a proxy
`*_AUTH_TOKEN`, …) leaked the same way.

Fix: also skip `Self::is_sensitive_config_key(key)`, reusing the pattern
matcher the Claude extractor already relies on for exactly this reason (its
comment notes a fixed enumeration "will always miss the next `*_API_KEY`").
`GOOGLE_API_KEY` matches its `_API_KEY` suffix rule. Shareable non-secret
config (e.g. `GEMINI_TIMEOUT_MS`) is preserved.

Verified the test catches the bug: run against the old two-key filter it fails
with "credential GOOGLE_API_KEY must not leak into the shared Gemini snippet";
with the fix it passes.

cargo test --lib: 2187 passed / 0 failed; cargo clippy --lib -D warnings clean.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(security): validate skill directory from backups and sync-imported DB rows

The skill install pipeline sanitizes the install directory name, but two
entry points bypassed it entirely:

- restore_from_backup joined metadata.skill.directory (raw meta.json
  content) into the SSOT dir with no validation, allowing a crafted
  backup to copy attacker-controlled files outside the skills directory
  and to persist the poisoned value into the database.
- Sync import (WebDAV/S3) loads the remote database dump verbatim, so a
  malicious or compromised sync snapshot could plant a skills row whose
  directory contains path traversal. Every later raw join then operated
  on attacker-controlled paths — uninstall/remove_from_app would
  remove_dir_all outside the managed dirs (arbitrary directory deletion),
  and sync_to_app_dir would write/symlink outside them.

Add require_valid_directory() (built on the existing
sanitize_install_name) and enforce it at restore_from_backup,
sync_to_app_dir, remove_from_app, and uninstall. Regression tests cover
all three sinks plus the metadata path; each was verified against the
unguarded code first (disabling the guard makes the restore/uninstall
tests complete the traversal write/delete, failing the assertions).

Also fixes a pre-existing cargo fmt drift in mcp/grokbuild.rs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): reject path separators in sanitize_install_name on all platforms

The previous components()-based check was platform-dependent: on
Linux/macOS a backslash is not a path separator, so "a\b" parsed as a
single Normal component and was accepted as a valid install name — the
same value becomes a nested path when synced or restored onto Windows.
CI caught this on the Linux runner (the Windows runner passed).

Reject both '/' and '\' explicitly so the validation is
platform-independent; the components() check stays as the second layer
for dot segments, roots, and prefixes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): close the remaining skill-install attack chain

The zip-slip guard added earlier only covered half the problem, and the
repo coordinates that decide *which* archive gets downloaded were never
validated at all. Together those two gaps let an attacker choose both the
bytes and where they land.

1) zip-slip, second layer. `enclosed_name()` only guarantees the entry does
   not escape the *archive's own* root, and it does not normalise the path —
   `..` survives verbatim in the returned value. Stripping `root_name`
   afterwards spends one level of that depth budget, so `repo-main/../evil`
   still escaped `dest`. On Windows it is worse: `root_name` comes from
   `split('/')` and may contain backslashes, which Windows treats as
   separators, so one `strip_prefix` can eat N components. Re-check the
   *actual* relative path for `ParentDir` right before `dest.join()`.
   Verified by reverting the guard: the single-`..` case escapes without it.
   (The existing test used two `..`, which `enclosed_name()` rejects on its
   own — it covered the half that was already guarded.)

2) Repo coordinates are now validated. `download_repo` formats owner/name/
   branch straight into
   `https://github.com/{owner}/{name}/archive/refs/heads/{branch}.zip`,
   and `deeplink/skill.rs` applied no validation whatsoever. URL parsing
   resolves dot segments, so a branch of
   `../../../releases/download/v1/evil` retargets the request at a *release
   asset* — arbitrary attacker-uploaded bytes. That is what made (1)
   reachable in practice: git itself will not let a tree contain `..`.
   Note the trigger is below "install": repos are enabled by default, so
   merely opening the Skills panel downloads and extracts.

   `validate_repo_ref` whitelists all three fields. Branch names legally
   contain `/` (`feature/x`), so the check is per segment rather than a
   blanket separator ban; an empty branch keeps its existing "use the
   default branch" sentinel meaning, same as `HEAD`. The main guard sits in
   `download_repo` — the single convergence point for all four call paths —
   because both `skill_repos` and `skills` can be overwritten wholesale by a
   sync snapshot, which no insert-time check can prevent. Entry-point checks
   (deeplink, `add_skill_repo`, agents lock) exist so bad values surface
   immediately instead of silently failing on every later panel open.
   `build_skill_doc_url` is covered too: it feeds `readme_url`, which the
   frontend hands to `openExternal`.

3) Regressions from the previous commits in this branch, both fixed:
   - `uninstall` ran the directory guard *before* `db.delete_skill`. That
     method has exactly one call site and is not exposed as a command, so a
     row with a dirty `directory` (pre-v3.11.0 installs, or anything
     `import_from_apps` still creates today — it has no sanitiser) became
     permanently undeletable from the UI. Now the guard skips the filesystem
     work but still deletes the row.
   - `sync_to_app` propagated with `?`, so one bad row aborted the entire
     app's skill sync — and that runs on provider switch. Now it warns and
     continues per entry.
   - `require_valid_directory` returned `sanitize_install_name`'s normalised
     value, which trims. A DB value of `" foo "` would then join as `"foo"`
     and miss the real directory. It now validates without rewriting.

4) Guard applied to the sinks the earlier commits missed: `migrate_storage`
   (rename + remove_dir_all), `update_skill` (delete + write attacker-named
   paths), `import_from_apps` (both a sink and the source of dirty values —
   `selection.directory` arrives raw over IPC), `resolve_uninstall_backup_source`
   (copies any directory into the backup area, which the UI then lists),
   `check_updates`, and `migrate_skills_to_ssot`.

5) Extraction limits. The archive bytes were fully attacker-controlled via
   (2), and this path had no ceiling of any kind, unlike
   `webdav_sync/archive.rs`. Added entry count, total extracted bytes, a
   symlink-target cap (zip 2.4.2's `make_reader` does not truncate at the
   declared `uncompressed_size`, so a symlink-flagged entry that inflates to
   gigabytes was read straight into memory), per-directory charging (a
   directory-only archive writes no content bytes but still consumes inodes),
   and a download-body cap (`response.bytes()` buffered the whole archive
   before any limit applied). Budget is charged on bytes actually read and
   written, never on sizes declared in the archive header.

   Symlink materialisation is charged to the same budget, and a target that
   contains the link itself (`dir/link -> ..`) is now rejected: the existing
   "must stay inside base" check passes for it, and the recursive copy then
   re-copies its own output every level until PATH_MAX.

6) Temp directories are now RAII. `download_repo` and `extract_local_zip`
   called `TempDir::keep()` immediately and relied on every exit path
   remembering `remove_dir_all`. Several did not — including
   `fetch_repo_skills`, the highest-frequency path of all. Returning the
   guard makes the leak unrepresentable at the call sites.

* fix(config): make removals and edits survive user-authored config shapes

The previous commits hardened the *write* side of the MCP tables against a
non-table `mcp_servers`. The read/delete side kept using `as_table_mut`,
which returns None for an inline table (`mcp_servers = { foo = {...} }` —
valid TOML). Removal then silently did nothing: the UI reported success, the
entry stayed in the file, and Codex loaded it again on next start. That is
worse than the panic it mirrors, because users usually reach for the toggle
precisely when an MCP server is misbehaving. Both Codex and GrokBuild now use
`as_table_like_mut` on both sides.

Normalisation is no longer silent either. Replacing a user's hand-written
`mcp_servers = "x"` with an empty table destroys data, so all four sites that
do it now log a warning first.

`update_codex_toml_field` had the same asymmetry with a different symptom:
when `model_providers` or `[model_providers.<id>]` was an inline table,
`as_table_mut` returned None and execution fell through to the "write a
top-level field" fallback. The user's `base_url` edit landed at the wrong
level, Codex never read it, and nothing reported a problem.

`opencode_config` parses `~/.config/opencode/opencode.json` with json5 into a
bare Value and never checked the root's shape. A user file of `[]` or a
scalar made `set_provider`, `set_mcp_server` and `add_plugin` all panic on
index-assignment, inside a Tauri command, unwinding across the FFI boundary.
(A top-level `null` is fine — serde_json promotes it.) Rejecting a non-object
root at the single read site fixes all three. Rejecting rather than rebuilding
is deliberate: the file also holds the user's own `model` / `theme` settings,
so a silent rebuild would delete them. Same treatment for the `provider` and
`mcp` sections, whose non-object forms made writes silently no-op.

* fix(proxy): recover a malformed Anthropic content_block as text

Sanitising a non-object `content_block` to `{}` stops the panic but creates a
quieter failure: the final Responses conversion matches on the block's `type`
and silently drops anything it does not recognise, so a garbled block header
turned into a `completed` response with empty output. The client saw the model
say nothing, with no signal that data had been discarded.

The replacement now carries `type: "text"`. The deltas that follow a malformed
header are usually well-formed, so this recovers the common case; a tool-use
block still yields nothing, exactly as before. A warning is logged when the
substitution happens.

The regression test now asserts through `anthropic_response_to_responses`
rather than on the intermediate value — asserting on the intermediate alone
passes while the client still receives an empty response.

* fix(grokbuild): decouple base_url from credential resolution, reject env_key-only links

`resolve_usage_credentials` called `extract_credentials(...).unwrap_or_default()`,
so a missing credential blanked the base_url too — even though it is written
right there in the config. Removing the `XAI_API_KEY` fallback in the earlier
commit widened that considerably: a GUI process on macOS does not inherit the
shell environment, so an `env_key` that resolves fine in a terminal resolves to
nothing here. The result was a Base URL shown in the UI that differed from the
one actually used, `{{baseUrl}}` expanding to empty in usage scripts (turning
requests relative), and native balance queries reporting "API key is empty"
while hiding the real cause. The two values are now resolved independently, via
the existing `grok_config::extract_base_url`, matching how the Codex arm of the
same function already works.

A deeplink whose only credential is `env_key` is now rejected by name. It was
already unimportable — `build_grokbuild_settings` has no `env_key` slot — but it
failed with the generic "API key is required", which reads like a malformed link
and invites the obvious "just carry the name over" fix. Carrying it over is
exactly what must not happen: the forwarder and the usage query both resolve
`env_key` at request time, so the victim's environment secret would still reach
the link's `base_url`. Same leak, merely deferred. The message says so, and the
test sets the probe variable so that restoring the resolution turns it red.

* fix(security): scrub credentials already leaked into the Gemini shared snippet

Fixing the extractor only prevents new contamination. A Gemini snippet is never
re-extracted once it exists — startup auto-extract and post-import extraction
both require `snippet.is_none()`, and the on-switch rewrite only covers Claude
and Codex — so existing users keep injecting the leaked key into live config,
the proxy upstream, and the new-provider form, where another account's key is
plainly visible.

Removing it from the snippet alone would make things worse. Merge and strip
cancel out by *value equality*: on switch-away, `remove_common_config_from_settings`
deletes the injected keys using the snippet as its only record of what to look
for. Once the key is gone from the snippet, the residue left in live config is
backfilled verbatim into the victim provider's `settings_config`, turning a
transient leak into a permanent one. So the cleanup covers all four locations at
once, and step order is itself a safety property: every fallible step runs before
the irreversible one, and a failure returns an error so the next start retries
from an intact state.

Deletion is by key *and* value, never by key name alone, so a provider's own
same-named key with a different value survives. The `~/.gemini/.env` edit
preserves layout rather than round-tripping through
`parse_env_file`/`serialize_env_file`: that pair drops comments, blank lines and
unparseable lines, collapses duplicate keys and re-sorts the file. Acceptable
when re-projecting everything, destructive for a targeted removal the user never
asked for — in testing it reduced a six-line fixture to one line, taking the
user's own key with it.

An audit record is written before any provider row changes, listing key names and
affected provider ids but *no values*: `settings` is not in `SYNC_SKIP_TABLES`,
so it is uploaded by WebDAV/S3 sync, and these are precisely the credentials that
must be destroyed. Keeping values would trade one deletion for a plaintext copy
that spreads across devices, has no UI to reach it, and never expires. It is
written only when absent, so a partially-applied earlier run cannot overwrite the
original pre-cleanup state.

Consequence, intentional: some Gemini providers will now report a missing API key
and need one entered. That key was never theirs. (The victim's own value was
already overwritten at merge time and cannot be recovered — worth noting in the
release notes, along with a recommendation to rotate the leaked key.) The snippet
row is deleted rather than set to `{}` when nothing shareable remains, and the
`cleared` flag is deliberately not set — either would disable auto-extract
permanently and prevent the user's legitimate shared config from ever coming back.

The frontend snippet validator is aligned with the backend matcher. It had the
same two hardcoded key names, so a hand-edited snippet could put `GOOGLE_API_KEY`
straight back with no error.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Jason <farion1231@gmail.com>
2026-07-28 16:34:11 +08:00

3021 lines
123 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! OpenAI Responses ↔ Anthropic Messages format conversion module (used when the Codex upstream is an Anthropic gateway)
//!
//! Scenario: The Codex CLI only speaks the OpenAI Responses protocol, while the
//! upstream AI gateway only offers the native Anthropic Messages protocol
//! (`/v1/messages`). This module converts the Responses request sent by Codex
//! into an Anthropic request, then converts the Anthropic response back into a
//! Responses response.
//!
//! The direction is exactly the mirror of `transform_responses.rs`:
//! - `transform_responses.rs`: Anthropic request → Responses request, Responses response → Anthropic response
//! - this module: Responses request → Anthropic request, Anthropic response → Responses response
use super::transform_codex_chat::{
build_codex_tool_context_from_request, response_tool_call_item_from_chat_name,
response_tool_call_item_id_from_chat_name, CodexToolContext,
};
use super::transform_responses::{sanitize_anthropic_tool_use_input, TOOL_RESULT_ERROR_MARKER};
use crate::proxy::error::ProxyError;
use crate::proxy::json_canonical::canonical_json_string;
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 serde_json::{json, Value};
use std::collections::{BTreeMap, HashSet};
pub(crate) const ANTHROPIC_THINKING_ENCRYPTED_PREFIX: &str = "ccswitch-anthropic-thinking-v1:";
const TOOL_SEARCH_PROXY_NAME: &str = "tool_search";
/// Maps Codex's reasoning.effort to the token budget for Anthropic thinking.
///
/// Returning `None` indicates an unrecognized effort value—in that case extended
/// thinking should not be enabled (to avoid accidentally swallowing
/// temperature/top_p), keeping normal sampling.
pub(crate) fn effort_to_thinking_budget(effort: &str) -> Option<u64> {
match effort.trim().to_ascii_lowercase().as_str() {
"minimal" | "low" => Some(2048),
"medium" => Some(8192),
"high" => Some(16384),
"xhigh" | "max" => Some(24576),
_ => None,
}
}
fn codex_effort_to_anthropic(effort: &str) -> Option<&'static str> {
match effort.trim().to_ascii_lowercase().as_str() {
"minimal" | "low" => Some("low"),
"medium" => Some("medium"),
"high" => Some("high"),
"xhigh" | "max" => Some("max"),
_ => None,
}
}
fn reasoning_explicitly_disabled(effort: Option<&str>) -> bool {
matches!(
effort
.map(str::trim)
.map(str::to_ascii_lowercase)
.as_deref(),
Some("none" | "off" | "disabled")
)
}
/// Preserve an Anthropic signed thinking/redacted-thinking block inside the opaque
/// Responses `reasoning.encrypted_content` field so Codex replays it on the next
/// tool-result request. The prefix keeps unrelated providers' ciphertext isolated.
pub(crate) fn encode_anthropic_thinking_block(block: &Value) -> Option<String> {
match block.get("type").and_then(|value| value.as_str()) {
Some("thinking")
if block
.get("signature")
.and_then(Value::as_str)
.is_some_and(|value| !value.is_empty()) => {}
Some("redacted_thinking")
if block
.get("data")
.and_then(Value::as_str)
.is_some_and(|value| !value.is_empty()) => {}
_ => return None,
}
let bytes = serde_json::to_vec(block).ok()?;
Some(format!(
"{ANTHROPIC_THINKING_ENCRYPTED_PREFIX}{}",
URL_SAFE_NO_PAD.encode(bytes)
))
}
pub(crate) fn decode_anthropic_thinking_block(encrypted_content: &str) -> Option<Value> {
let encoded = encrypted_content.strip_prefix(ANTHROPIC_THINKING_ENCRYPTED_PREFIX)?;
let bytes = URL_SAFE_NO_PAD.decode(encoded).ok()?;
let block: Value = serde_json::from_slice(&bytes).ok()?;
// Reuse the encoder's validation so legacy/malformed bridge envelopes cannot
// replay an unsigned thinking block into an Anthropic tool turn.
encode_anthropic_thinking_block(&block).map(|_| block)
}
pub(crate) fn responses_reasoning_item_from_anthropic_block(
item_id: &str,
block: &Value,
) -> Option<Value> {
let encrypted_content = encode_anthropic_thinking_block(block)?;
let summary = block
.get("thinking")
.and_then(|value| value.as_str())
.filter(|text| !text.is_empty())
.map(|text| vec![json!({ "type": "summary_text", "text": text })])
.unwrap_or_default();
Some(json!({
"id": item_id,
"type": "reasoning",
"summary": summary,
"encrypted_content": encrypted_content
}))
}
/// Anthropic's stop_reason → Responses' (status, incomplete_details.reason)
pub(crate) fn map_anthropic_stop_reason_to_status(
stop_reason: Option<&str>,
) -> (&'static str, Option<&'static str>) {
match stop_reason {
Some("max_tokens") => ("incomplete", Some("max_output_tokens")),
// Safety refusal: report as incomplete to avoid Codex treating it as a normally-completed empty reply.
Some("refusal") => ("incomplete", Some("content_filter")),
Some("model_context_window_exceeded") => ("incomplete", Some("max_output_tokens")),
// pause_turn is unreachable on this path (Codex requests do not declare Anthropic server-side tools);
// if it does occur, log a warning and treat it as completed.
Some("pause_turn") => {
log::warn!("[Codex] Received unexpected Anthropic stop_reason=pause_turn, treating it as completed");
("completed", None)
}
_ => ("completed", None),
}
}
/// Builds Responses usage from Anthropic usage.
///
/// Anthropic's `input_tokens` is the cache-excluded fresh input. OpenAI Responses
/// reports total input and exposes cache reads/writes as subsets:
/// input_tokens = fresh + cache_read + cache_creation
/// input_tokens_details.cached_tokens = cache_read
/// input_tokens_details.cache_write_tokens = cache_creation
///
/// The internal billing parser subtracts both subsets before charging the normal
/// input rate, then prices cache reads and writes separately.
pub(crate) fn build_responses_usage_from_anthropic(usage: Option<&Value>) -> Value {
let u = match usage {
Some(v) if v.is_object() => v,
_ => {
return json!({
"input_tokens": 0,
"output_tokens": 0,
"total_tokens": 0,
"output_tokens_details": { "reasoning_tokens": 0 }
})
}
};
let fresh_input = u.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
let output = u.get("output_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
let reasoning = u
.pointer("/output_tokens_details/thinking_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let cache_read = u
.get("cache_read_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let cache_creation = u
.get("cache_creation_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let input_tokens = fresh_input
.saturating_add(cache_read)
.saturating_add(cache_creation);
let total_tokens = input_tokens.saturating_add(output);
let mut result = json!({
"input_tokens": input_tokens,
"output_tokens": output,
"total_tokens": total_tokens,
"output_tokens_details": { "reasoning_tokens": reasoning }
});
if cache_read > 0 || cache_creation > 0 {
result["input_tokens_details"] = json!({
"cached_tokens": cache_read,
"cache_write_tokens": cache_creation
});
}
// Keep the legacy top-level alias for one compatibility window. New code reads
// the official nested cache_write_tokens field first.
if cache_creation > 0 {
result["cache_creation_input_tokens"] = json!(cache_creation);
}
result
}
/// OpenAI Responses request → Anthropic Messages request
///
/// `default_max_tokens`: injected when the Responses body has no
/// `max_output_tokens` (Anthropic's `max_tokens` is required; missing it yields a 400).
fn responses_system_text(item: &Value) -> Vec<String> {
match item.get("content") {
Some(Value::String(text)) if is_meaningful_text(text) => {
vec![text.trim().to_string()]
}
Some(Value::Array(parts)) => parts
.iter()
.filter_map(|part| {
matches!(
part.get("type").and_then(Value::as_str),
Some("input_text" | "output_text" | "text")
)
.then(|| part.get("text").and_then(Value::as_str))
.flatten()
.filter(|text| is_meaningful_text(text))
.map(|text| text.trim().to_string())
})
.collect(),
_ => Vec::new(),
}
}
pub fn responses_request_to_anthropic(
body: Value,
default_max_tokens: u64,
) -> Result<Value, ProxyError> {
let mut result = json!({});
let tool_context = build_codex_tool_context_from_request(&body);
let model = body
.get("model")
.and_then(|value| value.as_str())
.unwrap_or("");
// Pass model through (the upstream model has already been applied by the forwarder)
if let Some(model) = body.get("model").and_then(|m| m.as_str()) {
result["model"] = json!(model);
}
// instructions and historical system/developer messages → Anthropic system.
// Anthropic messages only accept user/assistant roles; degrading these items to
// user silently changes instruction precedence.
let mut system_parts = Vec::new();
if let Some(instructions) = body.get("instructions").and_then(|v| v.as_str()) {
if is_meaningful_text(instructions) {
system_parts.push(instructions.trim().to_string());
}
}
if let Some(items) = body.get("input").and_then(Value::as_array) {
for item in items {
if matches!(
item.get("role").and_then(Value::as_str),
Some("system" | "developer")
) {
system_parts.extend(responses_system_text(item));
}
}
}
if !system_parts.is_empty() {
result["system"] = json!(system_parts.join("\n\n"));
}
// input → messages
let mut messages = match body.get("input") {
Some(Value::Array(items)) => convert_input_to_messages(items, &tool_context)?,
Some(Value::String(text)) if is_meaningful_text(text) => vec![json!({
"role": "user",
"content": [{ "type": "text", "text": text }]
})],
_ => Vec::new(),
};
// Anthropic /v1/messages requires messages to be non-empty and the first to be user.
// Normalize the history (compacted/resumed sessions may start with
// assistant/function_call, or input may be entirely reasoning and thus empty after being dropped).
// Drop incomplete tool turns first (they would otherwise 400), then guarantee a leading user.
drop_incomplete_tool_turns(&mut messages);
drop_empty_messages(&mut messages);
ensure_leading_user_message(&mut messages);
if messages.is_empty() {
return Err(ProxyError::InvalidRequest(
"cannot convert Codex request: empty messages".to_string(),
));
}
trim_trailing_assistant_text(&mut messages);
drop_empty_messages(&mut messages);
if messages.is_empty() {
return Err(ProxyError::InvalidRequest(
"cannot convert Codex request: empty messages".to_string(),
));
}
let thinking_history_is_valid = trailing_turn_supports_thinking(&messages);
result["messages"] = json!(messages);
let reasoning_effort = body
.pointer("/reasoning/effort")
.and_then(|value| value.as_str());
let adaptive_model = crate::proxy::thinking_optimizer::uses_adaptive_thinking(model);
let adaptive_by_default = crate::proxy::thinking_optimizer::adaptive_thinking_is_default(model);
let cannot_disable_thinking =
crate::proxy::thinking_optimizer::thinking_cannot_be_disabled(model);
// max_output_tokens → max_tokens (required)
let max_tokens = body
.get("max_output_tokens")
.and_then(|v| v.as_u64())
.filter(|v| *v > 0)
.unwrap_or(default_max_tokens);
let mut thinking_enabled = false;
let mut thinking_budget = reasoning_effort
.and_then(effort_to_thinking_budget)
.unwrap_or(0);
let explicitly_disabled = reasoning_explicitly_disabled(reasoning_effort);
let adaptive_should_think = adaptive_model
&& (adaptive_by_default
|| reasoning_effort
.and_then(codex_effort_to_anthropic)
.is_some());
if !thinking_history_is_valid {
if cannot_disable_thinking {
return Err(ProxyError::InvalidRequest(
"Anthropic model requires thinking, but the tool history has no signed thinking block to replay"
.to_string(),
));
}
if adaptive_should_think {
result["thinking"] = json!({ "type": "disabled" });
}
} else if adaptive_should_think && (!explicitly_disabled || cannot_disable_thinking) {
thinking_enabled = true;
result["thinking"] = json!({ "type": "adaptive" });
if let Some(effort) = reasoning_effort.and_then(codex_effort_to_anthropic) {
result["output_config"] = json!({ "effort": effort });
} else if explicitly_disabled && cannot_disable_thinking {
// Fable/Mythos cannot turn thinking off. `low` is the closest safe
// representation of Codex's explicit `none` request.
result["output_config"] = json!({ "effort": "low" });
}
} else if explicitly_disabled {
result["thinking"] = json!({ "type": "disabled" });
} else if thinking_budget > 0 {
thinking_enabled = true;
// Anthropic requires max_tokens > budget_tokens and budget >= 1024. Reserve
// headroom for the visible answer: cap the thinking budget at half of max_tokens
// so a large derived budget (e.g. 24576 for xhigh) can't consume nearly all of a
// modest max_tokens and leave ~1 output token (an effectively empty completion).
// Do not raise the caller's max_tokens (it may exceed the model's output ceiling
// and 400). If the remaining budget is below Anthropic's 1024 floor, disable
// thinking and restore normal sampling.
let ceiling = max_tokens / 2;
thinking_budget = thinking_budget.min(ceiling);
if thinking_budget < 1024 {
thinking_enabled = false;
}
}
result["max_tokens"] = json!(max_tokens);
if thinking_enabled && !adaptive_model {
result["thinking"] = json!({
"type": "enabled",
"budget_tokens": thinking_budget
});
}
if !thinking_enabled {
if let Some(v) = body.get("temperature") {
result["temperature"] = v.clone();
}
if let Some(v) = body.get("top_p") {
result["top_p"] = v.clone();
}
}
if let Some(v) = body.get("stream") {
result["stream"] = v.clone();
}
// Reuse the Codex tool context so function, namespace, custom, tool_search, and
// dynamically loaded tools all receive stable flat names upstream.
let anth_tools: Vec<Value> = tool_context
.chat_tools()
.iter()
.filter_map(chat_tool_to_anthropic_tool)
.collect();
let has_tools = !anth_tools.is_empty();
if has_tools {
result["tools"] = json!(anth_tools);
}
// Only forward tool_choice when tools survived the filter. Anthropic 400s on a
// tool_choice with no tools ("tool_choice may only be specified while providing
// tools"), and that 400 is non-retryable — so a request whose only tools were
// unsupported hosted tools (for example web_search) must drop tool_choice too.
if has_tools {
if let Some(tc) = body.get("tool_choice") {
let mapped = map_tool_choice_to_anthropic(tc, &tool_context);
let forced = matches!(
mapped.get("type").and_then(|value| value.as_str()),
Some("any" | "tool")
);
if thinking_enabled && forced {
if cannot_disable_thinking {
return Err(ProxyError::InvalidRequest(
"Anthropic model requires adaptive thinking and cannot honor a forced tool_choice"
.to_string(),
));
}
// Anthropic rejects forced tools while thinking is enabled. Preserve
// the caller's explicit tool constraint and disable thinking for this
// request instead of silently weakening `required`/named selection.
result["thinking"] = json!({ "type": "disabled" });
result.as_object_mut().unwrap().remove("output_config");
if let Some(value) = body.get("temperature") {
result["temperature"] = value.clone();
}
if let Some(value) = body.get("top_p") {
result["top_p"] = value.clone();
}
}
result["tool_choice"] = mapped;
}
if body.get("parallel_tool_calls").and_then(Value::as_bool) == Some(false) {
if result.get("tool_choice").is_none() {
result["tool_choice"] = json!({ "type": "auto" });
}
if let Some(tool_choice) = result.get_mut("tool_choice").and_then(Value::as_object_mut)
{
tool_choice.insert("disable_parallel_tool_use".to_string(), json!(true));
}
}
}
Ok(result)
}
fn chat_tool_to_anthropic_tool(chat_tool: &Value) -> Option<Value> {
let function = chat_tool.get("function")?;
let name = function
.get("name")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())?;
let mut input_schema = function
.get("parameters")
.cloned()
.filter(|value| value.as_object().is_some_and(|object| !object.is_empty()))
.unwrap_or_else(|| json!({ "type": "object", "properties": {} }));
if let Some(schema) = input_schema.as_object_mut() {
if schema.get("type").and_then(Value::as_str) != Some("object") {
schema.insert("type".to_string(), json!("object"));
}
}
let mut tool = json!({ "name": name, "input_schema": input_schema });
if let Some(description) = function.get("description").and_then(|value| value.as_str()) {
tool["description"] = json!(description);
}
if let Some(strict) = function.get("strict").and_then(|value| value.as_bool()) {
tool["strict"] = json!(strict);
}
Some(tool)
}
/// tool_choice: Responses → Anthropic (the reverse of `map_tool_choice_to_responses`)
fn map_tool_choice_to_anthropic(tool_choice: &Value, tool_context: &CodexToolContext) -> Value {
match tool_choice {
Value::String(s) => match s.as_str() {
"required" => json!({ "type": "any" }),
"auto" => json!({ "type": "auto" }),
"none" => json!({ "type": "none" }),
_ => json!({ "type": "auto" }),
},
Value::Object(obj) => match obj.get("type").and_then(|t| t.as_str()) {
Some("function") => {
let name = obj.get("name").and_then(|n| n.as_str()).unwrap_or("");
let namespace = obj.get("namespace").and_then(|value| value.as_str());
let upstream_name = tool_context.chat_name_for_response_function(name, namespace);
json!({ "type": "tool", "name": upstream_name })
}
Some("custom") => json!({
"type": "tool",
"name": obj.get("name").and_then(|value| value.as_str()).unwrap_or("")
}),
Some("tool_search") => {
json!({ "type": "tool", "name": TOOL_SEARCH_PROXY_NAME })
}
// Other object shapes (allowed_tools / hosted-tool selectors, etc.) are
// not recognized by Anthropic; downgrade to auto to avoid passing OpenAI's
// raw structure through and causing a 400.
_ => json!({ "type": "auto" }),
},
_ => json!({ "type": "auto" }),
}
}
/// Re-nests the flat Responses input[] back into Anthropic messages.
///
/// - input_text/output_text → text block of the corresponding role
/// - input_image → image block
/// - function_call → assistant's tool_use block (merged with the preceding assistant text into the same message)
/// - function_call_output → user's tool_result block (consecutive ones merged into the same user message)
/// - Anthropic-origin reasoning.encrypted_content → restored signed thinking block
fn convert_input_to_messages(
items: &[Value],
tool_context: &CodexToolContext,
) -> Result<Vec<Value>, ProxyError> {
let mut messages: Vec<Value> = Vec::new();
for item in items {
let item_type = item.get("type").and_then(|t| t.as_str());
if matches!(
item_type,
Some("function_call" | "custom_tool_call" | "tool_search_call")
) && item.get("status").and_then(Value::as_str) == Some("incomplete")
{
log::warn!(
"[Codex/Anthropic] Dropping incomplete historical tool call: type={}, call_id={}",
item_type.unwrap_or("unknown"),
item.get("call_id")
.and_then(Value::as_str)
.or_else(|| item.get("id").and_then(Value::as_str))
.unwrap_or("")
);
continue;
}
match item_type {
Some("function_call") => {
let call_id = item
.get("call_id")
.and_then(|v| v.as_str())
.or_else(|| item.get("id").and_then(|v| v.as_str()))
.unwrap_or("");
let name = item.get("name").and_then(|v| v.as_str()).unwrap_or("");
let namespace = item.get("namespace").and_then(|value| value.as_str());
let upstream_name = tool_context.chat_name_for_response_function(name, namespace);
let args_str = item.get("arguments").and_then(|v| v.as_str()).unwrap_or("");
let input: Value = if args_str.trim().is_empty() {
json!({})
} else {
serde_json::from_str(args_str).map_err(|error| {
ProxyError::InvalidRequest(format!(
"Invalid function_call arguments for '{name}': {error}"
))
})?
};
if !input.is_object() {
return Err(ProxyError::InvalidRequest(format!(
"Function call arguments for '{name}' must be a JSON object"
)));
}
let input = sanitize_anthropic_tool_use_input(name, input);
push_block(
&mut messages,
"assistant",
json!({
"type": "tool_use",
"id": call_id,
"name": upstream_name,
"input": input
}),
);
}
Some("custom_tool_call") => {
let call_id = item
.get("call_id")
.and_then(|value| value.as_str())
.or_else(|| item.get("id").and_then(|value| value.as_str()))
.unwrap_or("");
let name = item
.get("name")
.and_then(|value| value.as_str())
.unwrap_or("");
let input = item.get("input").cloned().unwrap_or_else(|| json!(""));
push_block(
&mut messages,
"assistant",
json!({
"type": "tool_use",
"id": call_id,
"name": name,
"input": { "input": input }
}),
);
}
Some("tool_search_call") => {
let call_id = item
.get("call_id")
.and_then(|value| value.as_str())
.or_else(|| item.get("id").and_then(|value| value.as_str()))
.unwrap_or("");
let input = item
.get("arguments")
.cloned()
.filter(Value::is_object)
.unwrap_or_else(|| json!({}));
push_block(
&mut messages,
"assistant",
json!({
"type": "tool_use",
"id": call_id,
"name": TOOL_SEARCH_PROXY_NAME,
"input": input
}),
);
}
Some("function_call_output" | "custom_tool_call_output" | "tool_search_output") => {
let call_id = item.get("call_id").and_then(|v| v.as_str()).unwrap_or("");
let output = tool_result_content_from_responses_item(item);
let mut block = json!({
"type": "tool_result",
"tool_use_id": call_id,
"content": output.content
});
if output.is_error {
block["is_error"] = json!(true);
}
push_tool_result_block(&mut messages, block);
}
Some("input_text") => {
if let Some(text) = item
.get("text")
.and_then(Value::as_str)
.filter(|text| is_meaningful_text(text))
{
push_block(
&mut messages,
"user",
json!({ "type": "text", "text": text }),
);
}
}
Some("input_image") => {
if let Some(block) = image_block_from_input_image(item) {
push_block(&mut messages, "user", block);
}
}
Some("reasoning") => {
if let Some(block) = item
.get("encrypted_content")
.and_then(|value| value.as_str())
.and_then(decode_anthropic_thinking_block)
{
push_assistant_thinking_block(&mut messages, block);
}
}
// message item or an item carrying a role
_ => {
let role = item.get("role").and_then(|r| r.as_str()).unwrap_or("user");
if matches!(role, "system" | "developer") {
continue;
}
let anth_role = if role == "assistant" {
"assistant"
} else {
"user"
};
match item.get("content") {
Some(Value::String(text)) if is_meaningful_text(text) => {
push_block(
&mut messages,
anth_role,
json!({ "type": "text", "text": text }),
);
}
Some(Value::Array(parts)) => {
for part in parts {
let part_type = part.get("type").and_then(|t| t.as_str()).unwrap_or("");
match part_type {
"input_text" | "output_text" => {
if let Some(text) = part
.get("text")
.and_then(|t| t.as_str())
.filter(|t| is_meaningful_text(t))
{
push_block(
&mut messages,
anth_role,
json!({ "type": "text", "text": text }),
);
}
}
"refusal" => {
if let Some(text) = part
.get("refusal")
.and_then(|t| t.as_str())
.filter(|t| is_meaningful_text(t))
{
push_block(
&mut messages,
anth_role,
json!({ "type": "text", "text": text }),
);
}
}
"input_image" => {
if let Some(block) = image_block_from_input_image(part) {
push_block(&mut messages, anth_role, block);
}
}
"input_file" => {
if let Some(block) = document_block_from_input_file(part) {
push_block(&mut messages, anth_role, block);
}
}
_ => {}
}
}
}
_ => {}
}
}
}
}
Ok(messages)
}
struct ToolResultContent {
content: Value,
is_error: bool,
}
fn tool_result_content_from_responses_item(item: &Value) -> ToolResultContent {
match item.get("output") {
Some(text @ Value::String(_)) => {
alternate_image_tool_result_content(text).unwrap_or_else(|| ToolResultContent {
content: text.clone(),
is_error: false,
})
}
Some(Value::Array(parts)) => {
let mut content = Vec::new();
let mut is_error = false;
for part in parts {
match part.get("type").and_then(Value::as_str) {
Some("input_text" | "output_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}));
}
}
}
Some("input_image") => {
if let Some(image) = image_block_from_input_image(part) {
content.push(image);
} else {
content.push(json!({
"type":"text",
"text":canonical_json_string(part)
}));
}
}
Some("input_file") => {
if let Some(document) = document_block_from_input_file(part) {
content.push(document);
} else {
content.push(json!({
"type":"text",
"text":canonical_json_string(part)
}));
}
}
_ => {
if let Some(alternate) = alternate_image_tool_result_content(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 {
content: Value::Array(content),
is_error,
}
}
Some(value) => {
alternate_image_tool_result_content(value).unwrap_or_else(|| ToolResultContent {
content: json!(canonical_json_string(value)),
is_error: false,
})
}
None => ToolResultContent {
content: json!(canonical_json_string(item)),
is_error: false,
},
}
}
/// 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
/// 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).
fn ensure_leading_user_message(messages: &mut Vec<Value>) {
let leads_with_user = messages
.first()
.and_then(|m| m.get("role"))
.and_then(|r| r.as_str())
== Some("user");
if !messages.is_empty() && !leads_with_user {
messages.insert(
0,
json!({
"role": "user",
"content": [{ "type": "text", "text": "(continuing the conversation)" }]
}),
);
}
}
/// Removes compacted/resumed tool turns that no longer form a complete adjacent
/// assistant `tool_use` → user `tool_result` pair. Anthropic requires every tool
/// call in an assistant turn to be answered together in the immediately following
/// user turn. Dropping the whole incomplete assistant turn also avoids modifying a
/// subset of a signed thinking/tool-use response.
fn drop_incomplete_tool_turns(messages: &mut Vec<Value>) {
let original = std::mem::take(messages);
let mut sanitized = Vec::with_capacity(original.len());
let mut index = 0;
while index < original.len() {
let message = &original[index];
let is_assistant = message.get("role").and_then(Value::as_str) == Some("assistant");
let tool_use_ids = if is_assistant {
message_block_ids(message, "tool_use", "id")
} else {
Vec::new()
};
if !tool_use_ids.is_empty() {
let paired_user = original
.get(index + 1)
.filter(|next| next.get("role").and_then(Value::as_str) == Some("user"));
let tool_result_ids = paired_user
.map(|user| message_block_ids(user, "tool_result", "tool_use_id"))
.unwrap_or_default();
let unique_tool_uses: HashSet<&str> = tool_use_ids.iter().copied().collect();
let unique_tool_results: HashSet<&str> = tool_result_ids.iter().copied().collect();
let complete = tool_use_ids.iter().all(|id| !id.is_empty())
&& tool_result_ids.iter().all(|id| !id.is_empty())
&& unique_tool_uses.len() == tool_use_ids.len()
&& unique_tool_results.len() == tool_result_ids.len()
&& unique_tool_uses == unique_tool_results;
if complete {
sanitized.push(message.clone());
sanitized.push(paired_user.unwrap().clone());
} else if let Some(user) = paired_user {
let mut user = user.clone();
drop_tool_result_blocks(&mut user);
if message_has_content(&user) {
sanitized.push(user);
}
}
index += if paired_user.is_some() { 2 } else { 1 };
continue;
}
let mut message = message.clone();
if message.get("role").and_then(Value::as_str) == Some("user") {
// A user message not consumed as the adjacent half of a complete tool
// pair cannot legally retain any tool_result blocks.
drop_tool_result_blocks(&mut message);
}
if message_has_content(&message) {
sanitized.push(message);
}
index += 1;
}
*messages = sanitized;
}
fn message_block_ids<'a>(message: &'a Value, block_type: &str, id_field: &str) -> Vec<&'a str> {
message
.get("content")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter(|block| block.get("type").and_then(Value::as_str) == Some(block_type))
.map(|block| block.get(id_field).and_then(Value::as_str).unwrap_or(""))
.collect()
}
fn drop_tool_result_blocks(message: &mut Value) {
if let Some(content) = message.get_mut("content").and_then(Value::as_array_mut) {
content.retain(|block| block.get("type").and_then(Value::as_str) != Some("tool_result"));
}
}
fn message_has_content(message: &Value) -> bool {
message
.get("content")
.and_then(Value::as_array)
.map(|content| !content.is_empty())
.unwrap_or(true)
}
/// A fresh user prompt can start a new thinking turn. A tool-result continuation
/// may keep thinking enabled only when the preceding assistant turn includes the
/// signed thinking/redacted-thinking block that Anthropic requires callers to replay.
fn trailing_turn_supports_thinking(messages: &[Value]) -> bool {
let Some(last) = messages.last() else {
return false;
};
if last.get("role").and_then(Value::as_str) != Some("user") {
return false;
}
let mut tool_result_ids = Vec::new();
if let Some(blocks) = last.get("content").and_then(Value::as_array) {
for block in blocks {
if block.get("type").and_then(Value::as_str) != Some("tool_result") {
continue;
}
let Some(id) = block
.get("tool_use_id")
.and_then(Value::as_str)
.filter(|id| !id.is_empty())
else {
return false;
};
tool_result_ids.push(id);
}
}
if tool_result_ids.is_empty() {
return true;
}
// A tool-result turn answers the immediately preceding assistant tool-use turn.
// Looking any farther back can pick up an unrelated signed thinking block and
// incorrectly re-enable thinking for an unsigned tool call.
let Some(paired_assistant) = messages.get(messages.len().saturating_sub(2)) else {
return false;
};
if paired_assistant.get("role").and_then(Value::as_str) != Some("assistant") {
return false;
}
let Some(blocks) = paired_assistant.get("content").and_then(Value::as_array) else {
return false;
};
let has_signed_thinking = blocks.iter().any(|block| {
matches!(
block.get("type").and_then(Value::as_str),
Some("thinking" | "redacted_thinking")
)
});
if !has_signed_thinking {
return false;
}
let paired_tool_use_ids: HashSet<&str> = blocks
.iter()
.filter(|block| block.get("type").and_then(Value::as_str) == Some("tool_use"))
.filter_map(|block| block.get("id").and_then(Value::as_str))
.collect();
tool_result_ids
.iter()
.all(|id| paired_tool_use_ids.contains(id))
}
/// Removes whitespace-only assistant prefills and trims trailing whitespace from a
/// real prefill. Anthropic rejects an assistant prefill whose final text ends in
/// whitespace, and Codex may replay an empty assistant text beside a tool call.
fn trim_trailing_assistant_text(messages: &mut [Value]) {
let Some(last) = messages.last_mut() else {
return;
};
if last.get("role").and_then(Value::as_str) != Some("assistant") {
return;
}
let Some(blocks) = last.get_mut("content").and_then(Value::as_array_mut) else {
return;
};
let Some(block) = blocks.last_mut() else {
return;
};
if block.get("type").and_then(Value::as_str) != Some("text") {
return;
}
let Some(text) = block.get("text").and_then(Value::as_str) else {
return;
};
let trimmed = text.trim_end();
if trimmed.is_empty() {
blocks.pop();
} else if trimmed.len() != text.len() {
block["text"] = json!(trimmed);
}
}
/// Anthropic 400s on a text content block whose text is empty or whitespace-only.
/// Such blocks arise when a prior Responses turn recorded an empty
/// input_text/output_text (e.g. an empty assistant text emitted alongside a
/// tool_use); replaying it verbatim would fail the next follow-up request.
fn is_meaningful_text(text: &str) -> bool {
!text.trim().is_empty()
}
/// Removes messages whose content array ended up empty (e.g. a turn that carried
/// only empty text that was filtered out). Anthropic 400s on empty content.
fn drop_empty_messages(messages: &mut Vec<Value>) {
messages.retain(|msg| {
msg.get("content")
.and_then(|c| c.as_array())
.map(|arr| !arr.is_empty())
.unwrap_or(true)
});
}
/// Appends a content block to messages: merge if the last message has the same role, otherwise create a new message.
fn push_block(messages: &mut Vec<Value>, role: &str, block: Value) {
if let Some(last) = messages.last_mut() {
if last.get("role").and_then(|r| r.as_str()) == Some(role) {
if let Some(arr) = last.get_mut("content").and_then(|c| c.as_array_mut()) {
arr.push(block);
return;
}
}
}
messages.push(json!({
"role": role,
"content": [block]
}));
}
/// Appends a tool result to a user turn while preserving Anthropic's required
/// ordering: every tool_result block must precede any text or image blocks.
fn push_tool_result_block(messages: &mut Vec<Value>, block: Value) {
if let Some(last) = messages.last_mut() {
if last.get("role").and_then(Value::as_str) == Some("user") {
if let Some(content) = last.get_mut("content").and_then(Value::as_array_mut) {
let insert_at = content
.iter()
.position(|item| {
item.get("type").and_then(Value::as_str) != Some("tool_result")
})
.unwrap_or(content.len());
content.insert(insert_at, block);
return;
}
}
}
messages.push(json!({
"role": "user",
"content": [block]
}));
}
fn push_assistant_thinking_block(messages: &mut Vec<Value>, block: Value) {
if let Some(last) = messages.last_mut() {
if last.get("role").and_then(Value::as_str) == Some("assistant") {
if let Some(content) = last.get_mut("content").and_then(Value::as_array_mut) {
let index = content
.iter()
.take_while(|item| {
matches!(
item.get("type").and_then(Value::as_str),
Some("thinking" | "redacted_thinking")
)
})
.count();
content.insert(index, block);
return;
}
}
}
push_block(messages, "assistant", block);
}
/// Responses' input_image → Anthropic image block.
fn image_block_from_input_image(part: &Value) -> Option<Value> {
let url = part.get("image_url").and_then(|v| {
v.as_str()
.map(str::to_string)
.or_else(|| v.get("url").and_then(|u| u.as_str()).map(str::to_string))
})?;
if url
.get(..5)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("data:"))
{
// data:<media_type>;base64,<data>
let rest = &url[5..];
let (meta, data) = rest.split_once(',')?;
let media_type = meta.split(';').next().unwrap_or("image/png");
Some(json!({
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": data
}
}))
} 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!({
"type": "image",
"source": { "type": "url", "url": url }
}))
} else {
None
}
}
/// Responses' input_file → Anthropic document block.
fn document_block_from_input_file(part: &Value) -> Option<Value> {
let filename = part
.get("filename")
.and_then(Value::as_str)
.filter(|value| !value.is_empty());
let mut block = if let Some(file_url) = part
.get("file_url")
.and_then(Value::as_str)
.filter(|url| url.starts_with("http://") || url.starts_with("https://"))
{
json!({
"type":"document",
"source":{"type":"url","url":file_url}
})
} else {
let file_data = part.get("file_data").and_then(Value::as_str)?;
let rest = file_data.strip_prefix("data:")?;
let (meta, data) = rest.split_once(',')?;
if data.is_empty() {
return None;
}
let media_type = meta.split(';').next().unwrap_or("application/pdf");
json!({
"type":"document",
"source":{
"type":"base64",
"media_type":media_type,
"data":data
}
})
};
if let Some(filename) = filename {
block["title"] = json!(filename);
}
Some(block)
}
/// Anthropic Messages response → OpenAI Responses response (non-streaming)
#[allow(dead_code)]
pub fn anthropic_response_to_responses(body: Value) -> Result<Value, ProxyError> {
anthropic_response_to_responses_with_context(body, &CodexToolContext::default())
}
pub(crate) fn anthropic_response_to_responses_with_context(
body: Value,
tool_context: &CodexToolContext,
) -> Result<Value, ProxyError> {
if body.get("type").and_then(Value::as_str) == Some("error") || body.get("error").is_some() {
let error = body.get("error").unwrap_or(&body);
let message = error
.get("message")
.and_then(Value::as_str)
.or_else(|| error.as_str())
.unwrap_or("Anthropic upstream returned an error envelope");
let error_type = error.get("type").and_then(Value::as_str).unwrap_or("error");
return Err(ProxyError::TransformError(format!(
"Anthropic upstream {error_type}: {message}"
)));
}
let id = body.get("id").and_then(|i| i.as_str()).unwrap_or("");
let response_id = if id.is_empty() {
"resp_ccswitch".to_string()
} else if id.starts_with("resp_") {
id.to_string()
} else {
format!("resp_{id}")
};
let model = body.get("model").and_then(|m| m.as_str()).unwrap_or("");
let mut output: Vec<Value> = Vec::new();
let mut text_parts: Vec<Value> = Vec::new();
let flush_text = |output: &mut Vec<Value>, text_parts: &mut Vec<Value>| {
if !text_parts.is_empty() {
let idx = output.len();
output.push(json!({
"id": format!("{response_id}_msg_{idx}"),
"type": "message",
"status": "completed",
"role": "assistant",
"content": std::mem::take(text_parts)
}));
}
};
if let Some(blocks) = body.get("content").and_then(|c| c.as_array()) {
for block in blocks {
match block.get("type").and_then(|t| t.as_str()).unwrap_or("") {
"text" => {
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
text_parts.push(json!({
"type": "output_text",
"text": text,
"annotations": []
}));
}
}
"tool_use" => {
flush_text(&mut output, &mut text_parts);
let call_id = block.get("id").and_then(|v| v.as_str()).unwrap_or("");
let name = block.get("name").and_then(|v| v.as_str()).unwrap_or("");
let input = block.get("input").cloned().unwrap_or(json!({}));
let input = sanitize_anthropic_tool_use_input(name, input);
let item_id =
response_tool_call_item_id_from_chat_name(call_id, name, tool_context);
output.push(response_tool_call_item_from_chat_name(
&item_id,
"completed",
call_id,
name,
&canonical_json_string(&input),
None,
tool_context,
));
}
"thinking" | "redacted_thinking" => {
flush_text(&mut output, &mut text_parts);
let idx = output.len();
if let Some(item) = responses_reasoning_item_from_anthropic_block(
&format!("rs_{response_id}_{idx}"),
block,
) {
output.push(item);
}
}
_ => {}
}
}
}
flush_text(&mut output, &mut text_parts);
let (status, incomplete_reason) =
map_anthropic_stop_reason_to_status(body.get("stop_reason").and_then(|s| s.as_str()));
let usage = build_responses_usage_from_anthropic(body.get("usage"));
let mut result = json!({
"id": response_id,
"object": "response",
"created_at": 0,
"status": status,
"model": model,
"output": output,
"usage": usage
});
if let Some(reason) = incomplete_reason {
result["incomplete_details"] = json!({ "reason": reason });
}
Ok(result)
}
/// Aggregates an Anthropic Messages **SSE stream** (with no Content-Type marker)
/// back into a single Anthropic non-streaming message JSON.
///
/// Used as a fallback: the upstream returned an SSE body for a `stream:false`
/// request but without the `text/event-stream` header (symmetric to the
/// `body_looks_like_sse` fallback on the chat / claude side, see #2234). The
/// aggregated message can be handed directly to [`anthropic_response_to_responses`].
///
/// It also tolerates the last event missing a trailing blank line (truncated
/// stream): after looping over complete event blocks, it processes the residual
/// buffer as the last event.
pub fn anthropic_sse_to_message_value(body: &str) -> Result<Value, ProxyError> {
let mut message: Option<Value> = None;
// Collect blocks by content index along with the partial_json accumulator for their tool_use.
let mut blocks: BTreeMap<u64, Value> = BTreeMap::new();
let mut json_accum: BTreeMap<u64, String> = BTreeMap::new();
let mut stop_reason: Option<String> = None;
let mut delta_output_tokens: Option<u64> = None;
let mut saw_message_stop = false;
let mut buffer = body.to_string();
let process_block = |block: &str,
message: &mut Option<Value>,
blocks: &mut BTreeMap<u64, Value>,
json_accum: &mut BTreeMap<u64, String>,
stop_reason: &mut Option<String>,
delta_output_tokens: &mut Option<u64>,
saw_message_stop: &mut bool|
-> Result<(), ProxyError> {
let mut data = String::new();
for line in block.lines() {
if let Some(chunk) = strip_sse_field(line, "data") {
if !data.is_empty() {
data.push('\n');
}
data.push_str(chunk);
}
}
if data.trim().is_empty() || data.trim() == "[DONE]" {
return Ok(());
}
let value: Value = match serde_json::from_str(data.trim()) {
Ok(v) => v,
Err(_) => return Ok(()), // Skip events that cannot be parsed (ping, etc.)
};
match value.get("type").and_then(|t| t.as_str()).unwrap_or("") {
"message_start" => {
// Only accept an object message; a malformed upstream could send a
// scalar/array here, and the later `message["content"] = …` index
// assignment would panic on a non-object Value.
if let Some(msg) = value.get("message").filter(|m| m.is_object()) {
*message = Some(msg.clone());
}
}
"content_block_start" => {
if let Some(index) = value.get("index").and_then(|v| v.as_u64()) {
// Sanitize to an object: any later index-assignment (`["text"]`,
// `["signature"]`, `["input"]`) requires a JSON object, so a
// malformed non-object block from the upstream cannot be stored
// verbatim (it would panic on the next delta).
//
// The replacement carries `type: "text"` rather than being empty:
// the deltas that follow are usually well-formed, and a block with
// no `type` is silently dropped by the final Responses conversion,
// which turns a garbled block header into a `completed` response
// with empty output — the client sees the model saying nothing and
// has no way to tell that data was discarded. A text block recovers
// the common case; a tool-use block still yields nothing, exactly as
// it did before.
let block = match value.get("content_block") {
Some(block) if block.is_object() => block.clone(),
malformed => {
if malformed.is_some() {
log::warn!(
"Anthropic upstream sent a non-object content_block at index {index}; recovering it as a text block"
);
}
json!({ "type": "text" })
}
};
blocks.insert(index, block);
json_accum.entry(index).or_default();
}
}
"content_block_delta" => {
if let Some(index) = value.get("index").and_then(|v| v.as_u64()) {
let delta = value.get("delta").cloned().unwrap_or(json!({}));
match delta.get("type").and_then(|t| t.as_str()).unwrap_or("") {
"text_delta" => {
if let Some(text) = delta.get("text").and_then(|t| t.as_str()) {
append_str_field(
blocks.entry(index).or_insert(json!({})),
"text",
text,
);
}
}
"thinking_delta" => {
if let Some(text) = delta.get("thinking").and_then(|t| t.as_str()) {
append_str_field(
blocks.entry(index).or_insert(json!({})),
"thinking",
text,
);
}
}
"signature_delta" => {
if let Some(sig) = delta.get("signature").and_then(|t| t.as_str()) {
blocks.entry(index).or_insert(json!({}))["signature"] = json!(sig);
}
}
"input_json_delta" => {
if let Some(partial) =
delta.get("partial_json").and_then(|t| t.as_str())
{
json_accum.entry(index).or_default().push_str(partial);
}
}
_ => {}
}
}
}
"content_block_stop" => {
if let Some(index) = value.get("index").and_then(|v| v.as_u64()) {
if let Some(accum) = json_accum.get(&index) {
if !accum.trim().is_empty() {
let parsed: Value =
serde_json::from_str(accum).unwrap_or_else(|_| json!({}));
if let Some(block) = blocks.get_mut(&index) {
block["input"] = parsed;
}
}
}
}
}
"message_delta" => {
if let Some(reason) = value.pointer("/delta/stop_reason").and_then(|v| v.as_str()) {
*stop_reason = Some(reason.to_string());
}
if let Some(output) = value
.pointer("/usage/output_tokens")
.and_then(|v| v.as_u64())
{
*delta_output_tokens = Some(output);
}
}
"message_stop" => *saw_message_stop = true,
"error" => {
let msg = value
.pointer("/error/message")
.and_then(|v| v.as_str())
.unwrap_or("upstream anthropic SSE error");
return Err(ProxyError::TransformError(format!(
"anthropic SSE error event: {msg}"
)));
}
_ => {}
}
Ok(())
};
while let Some(block) = take_sse_block(&mut buffer) {
process_block(
&block,
&mut message,
&mut blocks,
&mut json_accum,
&mut stop_reason,
&mut delta_output_tokens,
&mut saw_message_stop,
)?;
}
// Tolerate the last event missing a trailing blank line (truncated stream).
if !buffer.trim().is_empty() {
process_block(
&buffer.clone(),
&mut message,
&mut blocks,
&mut json_accum,
&mut stop_reason,
&mut delta_output_tokens,
&mut saw_message_stop,
)?;
}
let mut message = message.ok_or_else(|| {
ProxyError::TransformError(
"anthropic SSE aggregation: missing message_start event".to_string(),
)
})?;
if !saw_message_stop && stop_reason.is_none() {
if blocks.is_empty() {
return Err(ProxyError::TransformError(
"anthropic SSE aggregation: stream ended before message_stop".to_string(),
));
}
// Preserve partial content but make the truncation visible to Codex instead
// of returning a normal completed response.
stop_reason = Some("max_tokens".to_string());
}
// Merge in the content blocks (ordered by index), stop_reason, and the cumulative output_tokens.
let content: Vec<Value> = blocks.into_values().collect();
message["content"] = json!(content);
if let Some(reason) = stop_reason {
message["stop_reason"] = json!(reason);
}
if let Some(output) = delta_output_tokens {
// message_delta's usage.output_tokens is a cumulative value, overriding the 0 from message_start.
if let Some(usage) = message.get_mut("usage").and_then(|u| u.as_object_mut()) {
usage.insert("output_tokens".to_string(), json!(output));
}
}
Ok(message)
}
/// Appends content to a string field of a JSON object (creating it if absent).
fn append_str_field(block: &mut Value, field: &str, text: &str) {
let existing = block
.get(field)
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
block[field] = json!(format!("{existing}{text}"));
}
#[cfg(test)]
mod tests {
use super::*;
// ==================== Request: Responses → Anthropic ====================
#[test]
fn test_request_simple_text() {
let input = json!({
"model": "claude-3-5-sonnet",
"max_output_tokens": 1024,
"input": [
{ "role": "user", "content": [{ "type": "input_text", "text": "Hello" }] }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert_eq!(result["model"], "claude-3-5-sonnet");
assert_eq!(result["max_tokens"], 1024);
assert_eq!(result["messages"][0]["role"], "user");
assert_eq!(result["messages"][0]["content"][0]["type"], "text");
assert_eq!(result["messages"][0]["content"][0]["text"], "Hello");
}
#[test]
fn test_request_missing_max_output_tokens_injects_default() {
let input = json!({
"model": "claude",
"input": [{ "role": "user", "content": "Hi" }]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert_eq!(result["max_tokens"], 4096);
}
#[test]
fn test_request_instructions_to_system() {
let input = json!({
"model": "claude",
"max_output_tokens": 100,
"instructions": "You are helpful.",
"input": [{ "role": "user", "content": "hi" }]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert_eq!(result["system"], "You are helpful.");
}
#[test]
fn test_request_system_and_developer_history_are_hoisted() {
let input = json!({
"model":"claude",
"instructions":"base",
"input":[
{"role":"system","content":"system history"},
{"role":"developer","content":[{"type":"input_text","text":"developer history"}]},
{"role":"user","content":"hi"}
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert_eq!(
result["system"],
"base\n\nsystem history\n\ndeveloper history"
);
assert_eq!(result["messages"].as_array().unwrap().len(), 1);
assert_eq!(result["messages"][0]["role"], "user");
}
#[test]
fn test_request_no_instructions_no_system() {
let input = json!({
"model": "claude",
"max_output_tokens": 100,
"input": [{ "role": "user", "content": "hi" }]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert!(result.get("system").is_none());
}
#[test]
fn test_request_tools_and_filtering() {
let input = json!({
"model": "claude",
"max_output_tokens": 100,
"input": [{ "role": "user", "content": "hi" }],
"tools": [
{ "type": "function", "name": "get_weather", "description": "d", "parameters": {"type": "object"} },
{ "type": "web_search" },
{ "type": "custom", "name": "apply_patch" }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let tools = result["tools"].as_array().unwrap();
assert_eq!(tools.len(), 2);
assert_eq!(tools[0]["name"], "get_weather");
assert_eq!(tools[0]["input_schema"]["type"], "object");
assert!(tools[0].get("parameters").is_none());
assert_eq!(tools[1]["name"], "apply_patch");
}
#[test]
fn test_request_tool_search_output_schema_defaults_root_type_to_object() {
let input = json!({
"model": "claude",
"max_output_tokens": 100,
"input": [
{ "role": "user", "content": "hi" },
{
"type": "tool_search_output",
"call_id": "tool_demo123",
"tools": [{
"type": "function",
"name": "demo_union_tool",
"parameters": {
"oneOf": [
{ "type": "object", "properties": { "mode": { "type": "string" } } },
{ "type": "object", "properties": { "name": { "type": "string" } } }
]
}
}]
}
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let input_schema = &result["tools"][0]["input_schema"];
assert_eq!(input_schema["type"], "object");
assert_eq!(
input_schema["oneOf"][0]["properties"]["mode"]["type"],
"string"
);
assert_eq!(
input_schema["oneOf"][1]["properties"]["name"]["type"],
"string"
);
}
#[test]
fn test_request_tool_choice_mapping() {
// A function tool must be present, else tool_choice is (correctly) dropped.
let base = |tc: Value| {
json!({
"model": "c", "max_output_tokens": 100,
"input": [{ "role": "user", "content": "hi" }],
"tools": [{ "type": "function", "name": "x", "parameters": {"type": "object"} }],
"tool_choice": tc
})
};
assert_eq!(
responses_request_to_anthropic(base(json!("required")), 4096).unwrap()["tool_choice"],
json!({"type": "any"})
);
assert_eq!(
responses_request_to_anthropic(base(json!("auto")), 4096).unwrap()["tool_choice"],
json!({"type": "auto"})
);
assert_eq!(
responses_request_to_anthropic(base(json!({"type": "function", "name": "x"})), 4096)
.unwrap()["tool_choice"],
json!({"type": "tool", "name": "x"})
);
}
#[test]
fn test_request_function_call_renests_into_assistant_tool_use() {
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [
{ "role": "assistant", "content": [{ "type": "output_text", "text": "Let me check" }] },
{ "type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": "{\"city\":\"Tokyo\"}" },
{ "type": "function_call_output", "call_id": "call_1", "output": "sunny" }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let messages = result["messages"].as_array().unwrap();
// The assistant-first history is normalized: a synthetic user message is
// prepended, and the complete assistant tool turn remains intact.
assert_eq!(messages.len(), 3);
assert_eq!(messages[0]["role"], "user");
assert_eq!(messages[1]["role"], "assistant");
assert_eq!(messages[1]["content"][0]["type"], "text");
assert_eq!(messages[1]["content"][1]["type"], "tool_use");
assert_eq!(messages[1]["content"][1]["id"], "call_1");
assert_eq!(messages[1]["content"][1]["input"]["city"], "Tokyo");
}
#[test]
fn test_request_function_call_outputs_merge_into_one_user_message() {
// Consecutive function_call_output items (each paired with a preceding
// function_call) merge into a single user message of tool_result blocks.
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [
{ "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" },
{ "type": "function_call", "call_id": "c2", "name": "t", "arguments": "{}" },
{ "type": "function_call_output", "call_id": "c1", "output": "A" },
{ "type": "function_call_output", "call_id": "c2", "output": "B" }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let messages = result["messages"].as_array().unwrap();
// Leading assistant history is normalized with a synthetic leading user.
let last = messages.last().unwrap();
assert_eq!(last["role"], "user");
let content = last["content"].as_array().unwrap();
assert_eq!(content.len(), 2);
assert_eq!(content[0]["type"], "tool_result");
assert_eq!(content[0]["tool_use_id"], "c1");
assert_eq!(content[0]["content"], "A");
assert_eq!(content[1]["tool_use_id"], "c2");
}
#[test]
fn test_request_unanswered_trailing_tool_use_is_dropped() {
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [
{ "role": "user", "content": "run it" },
{ "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let messages = result["messages"].as_array().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0]["role"], "user");
assert_eq!(messages[0]["content"][0]["text"], "run it");
}
#[test]
fn test_request_partial_parallel_tool_turn_is_dropped_as_a_unit() {
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [
{ "role": "user", "content": "run both" },
{ "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" },
{ "type": "function_call", "call_id": "c2", "name": "t", "arguments": "{}" },
{ "type": "function_call_output", "call_id": "c1", "output": "one" },
{ "role": "user", "content": [{ "type": "input_text", "text": "continue" }] }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let messages = result["messages"].as_array().unwrap();
assert!(messages.iter().all(|message| {
message["content"].as_array().unwrap().iter().all(|block| {
!matches!(
block.get("type").and_then(Value::as_str),
Some("tool_use" | "tool_result")
)
})
}));
assert_eq!(messages.last().unwrap()["content"][0]["text"], "continue");
}
#[test]
fn test_request_tool_results_precede_user_text() {
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [
{ "role": "user", "content": "run it" },
{ "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" },
{ "role": "user", "content": [{ "type": "input_text", "text": "then explain" }] },
{ "type": "function_call_output", "call_id": "c1", "output": "ok" }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let content = result["messages"].as_array().unwrap().last().unwrap()["content"]
.as_array()
.unwrap();
assert_eq!(content[0]["type"], "tool_result");
assert_eq!(content[0]["tool_use_id"], "c1");
assert_eq!(content[1]["type"], "text");
assert_eq!(content[1]["text"], "then explain");
}
#[test]
fn test_request_orphan_tool_result_dropped() {
// A function_call_output whose matching function_call was dropped (e.g. by
// compaction) becomes an orphan tool_result; it must be removed so Anthropic
// does not 400, while the rest of the turn is preserved.
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [
{ "type": "function_call_output", "call_id": "ghost", "output": "X" },
{ "role": "user", "content": [{ "type": "input_text", "text": "hello" }] }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let messages = result["messages"].as_array().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0]["role"], "user");
let content = messages[0]["content"].as_array().unwrap();
// Only the text survives; the orphan tool_result is gone.
assert_eq!(content.len(), 1);
assert_eq!(content[0]["type"], "text");
assert_eq!(content[0]["text"], "hello");
}
#[test]
fn test_request_empty_text_blocks_dropped() {
// An empty/whitespace-only assistant text emitted alongside a tool_use must be
// filtered out (Anthropic 400s on empty text blocks), keeping the tool_use, and
// a user turn made up solely of empty text must not leave an empty message.
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [
{ "role": "user", "content": [{ "type": "input_text", "text": "hi" }] },
{ "role": "assistant", "content": [{ "type": "output_text", "text": "" }] },
{ "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" },
{ "type": "function_call_output", "call_id": "c1", "output": "ok" },
{ "role": "user", "content": [{ "type": "input_text", "text": " " }] }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let messages = result["messages"].as_array().unwrap();
// Empty assistant text is gone; no message carries an empty text block.
for msg in messages {
for block in msg["content"].as_array().unwrap() {
if block["type"] == "text" {
assert!(!block["text"].as_str().unwrap().trim().is_empty());
}
}
assert!(!msg["content"].as_array().unwrap().is_empty());
}
// The whitespace-only trailing user turn collapsed into the tool_result user message.
let last = messages.last().unwrap();
assert_eq!(last["role"], "user");
assert_eq!(last["content"][0]["type"], "tool_result");
}
#[test]
fn test_request_all_orphan_tool_results_error() {
// If the entire input is orphan tool_results, dropping them empties the
// message list and conversion errors (nothing valid to send to Anthropic).
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [
{ "type": "function_call_output", "call_id": "c1", "output": "A" },
{ "type": "function_call_output", "call_id": "c2", "output": "B" }
]
});
assert!(responses_request_to_anthropic(input, 4096).is_err());
}
#[test]
fn test_request_paired_tool_result_kept() {
// A tool_result whose function_call is present survives the orphan guard.
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [
{ "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" },
{ "type": "function_call_output", "call_id": "c1", "output": "ok" }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let messages = result["messages"].as_array().unwrap();
let last = messages.last().unwrap();
assert_eq!(last["role"], "user");
assert_eq!(last["content"][0]["type"], "tool_result");
assert_eq!(last["content"][0]["tool_use_id"], "c1");
}
#[test]
fn test_request_empty_arguments_parses_to_empty_object() {
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [
{ "type": "function_call", "call_id": "c1", "name": "t", "arguments": "" },
{ "type": "function_call_output", "call_id": "c1", "output": "ok" }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
// function_call at the head → a synthetic user is prepended, tool_use is in the second assistant message.
assert_eq!(result["messages"][1]["content"][0]["input"], json!({}));
}
#[test]
fn test_request_invalid_or_non_object_arguments_error() {
for arguments in ["{broken", "[1,2]"] {
let input = json!({
"model":"c",
"input":[
{"type":"function_call","call_id":"c1","name":"t","arguments":arguments},
{"type":"function_call_output","call_id":"c1","output":"ok"}
]
});
assert!(matches!(
responses_request_to_anthropic(input, 4096),
Err(ProxyError::InvalidRequest(_))
));
}
}
#[test]
fn test_request_drops_incomplete_tool_call_and_orphaned_output() {
let input = json!({
"model":"c",
"input":[
{"role":"user","content":[{"type":"input_text","text":"run it"}]},
{
"type":"function_call",
"call_id":"c1",
"name":"exec",
"arguments":"{\"cmd\":",
"status":"incomplete"
},
{"type":"function_call_output","call_id":"c1","output":"never ran"}
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let serialized = result["messages"].to_string();
assert!(!serialized.contains("tool_use"));
assert!(!serialized.contains("tool_result"));
assert!(serialized.contains("run it"));
}
#[test]
fn test_request_image_data_url() {
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [{
"role": "user",
"content": [
{ "type": "input_text", "text": "what?" },
{ "type": "input_image", "image_url": "data:image/png;base64,abc123" }
]
}]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let content = result["messages"][0]["content"].as_array().unwrap();
assert_eq!(content[1]["type"], "image");
assert_eq!(content[1]["source"]["type"], "base64");
assert_eq!(content[1]["source"]["media_type"], "image/png");
assert_eq!(content[1]["source"]["data"], "abc123");
}
#[test]
fn test_request_top_level_content_items() {
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [
{ "type": "input_text", "text": "what?" },
{ "type": "input_image", "image_url": "data:image/png;base64,abc123" }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let messages = result["messages"].as_array().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0]["role"], "user");
let content = messages[0]["content"].as_array().unwrap();
assert_eq!(content[0]["type"], "text");
assert_eq!(content[0]["text"], "what?");
assert_eq!(content[1]["type"], "image");
assert_eq!(content[1]["source"]["type"], "base64");
assert_eq!(content[1]["source"]["data"], "abc123");
}
#[test]
fn test_request_image_http_url() {
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [{
"role": "user",
"content": [{ "type": "input_image", "image_url": "https://x/y.png" }]
}]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let block = &result["messages"][0]["content"][0];
assert_eq!(block["type"], "image");
assert_eq!(block["source"]["type"], "url");
assert_eq!(block["source"]["url"], "https://x/y.png");
}
#[test]
fn test_request_effort_to_thinking_and_drops_temperature() {
let input = json!({
"model": "c",
// Well above 2× the high-effort budget so the output-headroom cap
// (max_tokens/2) does not clamp it — this test covers effort mapping.
"max_output_tokens": 40000,
"temperature": 0.7,
"top_p": 0.9,
"reasoning": { "effort": "high" },
"input": [{ "role": "user", "content": "hi" }]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert_eq!(result["thinking"]["type"], "enabled");
assert_eq!(result["thinking"]["budget_tokens"], 16384);
assert!(result.get("temperature").is_none());
assert!(result.get("top_p").is_none());
}
#[test]
fn test_adaptive_thinking_respects_model_defaults() {
let request = |model: &str| {
json!({
"model": model,
"max_output_tokens": 4096,
"input": [{"role": "user", "content": "hi"}]
})
};
let sonnet = responses_request_to_anthropic(request("claude-sonnet-5"), 4096).unwrap();
assert_eq!(sonnet["thinking"]["type"], "adaptive");
let opus = responses_request_to_anthropic(request("claude-opus-4.8"), 4096).unwrap();
assert!(opus.get("thinking").is_none());
}
#[test]
fn test_request_unknown_effort_keeps_sampling_params() {
// An unrecognized effort should not enable thinking, nor swallow temperature/top_p.
let input = json!({
"model": "c",
"max_output_tokens": 20000,
"temperature": 0.7,
"top_p": 0.9,
"reasoning": { "effort": "turbo" },
"input": [{ "role": "user", "content": "hi" }]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert!(result.get("thinking").is_none());
assert_eq!(result["temperature"], 0.7);
assert_eq!(result["top_p"], 0.9);
}
#[test]
fn test_request_tool_history_disables_thinking() {
// When tool history is present (function_call_output), do not inject thinking
// even if effort is set, and fall back to passing temperature/top_p through,
// to avoid the Anthropic 400 caused by a missing thinking block.
let input = json!({
"model": "c",
"max_output_tokens": 20000,
"temperature": 0.5,
"reasoning": { "effort": "high" },
"input": [
{ "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" },
{ "type": "function_call_output", "call_id": "c1", "output": "ok" }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert!(result.get("thinking").is_none());
assert_eq!(result["temperature"], 0.5);
}
#[test]
fn test_older_signed_turn_does_not_enable_thinking_for_unsigned_tool_call() {
let encrypted = encode_anthropic_thinking_block(&json!({
"type": "thinking",
"thinking": "old reasoning",
"signature": "sig_old"
}))
.unwrap();
let input = json!({
"model": "claude-sonnet-5",
"max_output_tokens": 4096,
"reasoning": { "effort": "high" },
"input": [
{ "role": "user", "content": "first question" },
{ "type": "reasoning", "encrypted_content": encrypted },
{ "role": "assistant", "content": [{ "type": "output_text", "text": "first answer" }] },
{ "role": "user", "content": "call the tool" },
{ "type": "function_call", "call_id": "c2", "name": "Read", "arguments": "{}" },
{ "type": "function_call_output", "call_id": "c2", "output": "ok" }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert_eq!(result["thinking"]["type"], "disabled");
let messages = result["messages"].as_array().unwrap();
let paired_assistant = &messages[messages.len() - 2];
assert_eq!(paired_assistant["role"], "assistant");
assert_eq!(paired_assistant["content"][0]["type"], "tool_use");
}
#[test]
fn test_thinking_requires_all_parallel_tool_results_to_match_paired_turn() {
let paired = json!([
{"role": "assistant", "content": [
{"type": "thinking", "thinking": "reason", "signature": "sig"},
{"type": "tool_use", "id": "c1", "name": "a", "input": {}},
{"type": "tool_use", "id": "c2", "name": "b", "input": {}}
]},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "c1", "content": "one"},
{"type": "tool_result", "tool_use_id": "c2", "content": "two"}
]}
]);
assert!(trailing_turn_supports_thinking(paired.as_array().unwrap()));
let mismatched = json!([
paired[0].clone(),
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "c1", "content": "one"},
{"type": "tool_result", "tool_use_id": "c3", "content": "three"}
]}
]);
assert!(!trailing_turn_supports_thinking(
mismatched.as_array().unwrap()
));
}
#[test]
fn test_request_completed_tool_round_reenables_thinking() {
// A *completed* tool round (assistant answered after the tool_result) followed
// by a fresh user question: the trailing turn is text-only, so thinking must be
// re-enabled — unlike a whole-history scan, which would stay off forever.
let input = json!({
"model": "c",
"max_output_tokens": 20000,
"reasoning": { "effort": "high" },
"input": [
{ "role": "user", "content": [{ "type": "input_text", "text": "hi" }] },
{ "type": "function_call", "call_id": "c1", "name": "t", "arguments": "{}" },
{ "type": "function_call_output", "call_id": "c1", "output": "ok" },
{ "role": "assistant", "content": [{ "type": "output_text", "text": "done" }] },
{ "role": "user", "content": [{ "type": "input_text", "text": "next question" }] }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
// The last message is a text-only user turn → not mid tool-cycle → thinking on.
let messages = result["messages"].as_array().unwrap();
let last = messages.last().unwrap();
assert_eq!(last["role"], "user");
assert_eq!(last["content"][0]["type"], "text");
assert_eq!(result["thinking"]["type"], "enabled");
}
#[test]
fn test_request_custom_tool_survives_with_required_choice() {
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [{ "role": "user", "content": "hi" }],
"tools": [
{ "type": "web_search" },
{ "type": "custom", "name": "apply_patch" }
],
"tool_choice": "required"
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert_eq!(result["tools"].as_array().unwrap().len(), 1);
assert_eq!(result["tools"][0]["name"], "apply_patch");
assert_eq!(result["tool_choice"], json!({ "type": "any" }));
}
#[test]
fn test_request_forced_tool_choice_disables_thinking_instead_of_downgrading() {
let input = json!({
"model": "c",
"max_output_tokens": 20000,
"reasoning": { "effort": "high" },
"tools": [{ "type": "function", "name": "x", "parameters": {"type": "object"} }],
"tool_choice": "required",
"input": [
{ "role": "user", "content": [{ "type": "input_text", "text": "hi" }] }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert_eq!(result["thinking"]["type"], "disabled");
assert_eq!(result["tool_choice"], json!({ "type": "any" }));
}
#[test]
fn test_request_forced_tool_choice_kept_without_thinking() {
// With no effort (thinking off), a forced tool_choice is kept as-is.
let input = json!({
"model": "c",
"max_output_tokens": 100,
"tools": [{ "type": "function", "name": "x", "parameters": {"type": "object"} }],
"tool_choice": "required",
"input": [{ "role": "user", "content": "hi" }]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert_eq!(result["tool_choice"], json!({ "type": "any" }));
}
#[test]
fn test_request_small_max_tokens_disables_thinking() {
// The chosen effort budget is clamped below max_tokens; after clamping it is
// < 1024 → disable thinking, fall back to sampling, and do not raise the
// caller's max_tokens (to avoid exceeding the model's output ceiling and 400).
let input = json!({
"model": "c",
"max_output_tokens": 1000,
"temperature": 0.7,
"reasoning": { "effort": "high" },
"input": [{ "role": "user", "content": "hi" }]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert!(result.get("thinking").is_none());
assert_eq!(result["max_tokens"], 1000);
assert_eq!(result["temperature"], 0.7);
}
#[test]
fn test_request_thinking_budget_clamped_below_max_tokens() {
// The chosen effort budget exceeds half of max_tokens, so it is capped at
// max_tokens/2 (reserving the other half for the visible answer) while staying
// >= 1024, so thinking stays enabled.
let input = json!({
"model": "c",
"max_output_tokens": 5000,
"reasoning": { "effort": "high" }, // budget 16384
"input": [{ "role": "user", "content": "hi" }]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert_eq!(result["thinking"]["type"], "enabled");
assert_eq!(result["thinking"]["budget_tokens"], 2500);
assert_eq!(result["max_tokens"], 5000);
}
#[test]
fn test_request_default_max_tokens_leaves_output_headroom() {
// Regression: on the no-max_output_tokens fallback path, a large derived thinking
// budget must not consume nearly all of the default max_tokens. With default 8192
// and high effort (16384), the budget is capped at 8192/2 = 4096, leaving 4096 for
// the visible answer (previously it clamped to 8191, leaving ~1 output token).
let input = json!({
"model": "c",
"reasoning": { "effort": "high" },
"input": [{ "role": "user", "content": "hi" }]
});
let result = responses_request_to_anthropic(input, 8192).unwrap();
assert_eq!(result["thinking"]["type"], "enabled");
assert_eq!(result["thinking"]["budget_tokens"], 4096);
assert_eq!(result["max_tokens"], 8192);
assert!(
result["max_tokens"].as_u64().unwrap()
- result["thinking"]["budget_tokens"].as_u64().unwrap()
>= 4096,
"at least half of max_tokens must remain for the visible answer"
);
}
#[test]
fn test_request_unpaired_tool_turn_is_dropped_before_thinking_gate() {
// A resumed/compacted history ending in an unpaired assistant tool_use is
// removed, leaving the original user prompt as a fresh thinking turn.
let input = json!({
"model": "c",
"max_output_tokens": 40000,
"reasoning": { "effort": "high" },
"input": [
{ "role": "user", "content": [{ "type": "input_text", "text": "run it" }] },
{ "type": "function_call", "call_id": "c1", "name": "sh", "arguments": "{}" }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let messages = result["messages"].as_array().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0]["role"], "user");
assert_eq!(result["thinking"]["type"], "enabled");
}
#[test]
fn test_request_reasoning_item_dropped() {
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [
{ "type": "reasoning", "id": "rs_1", "encrypted_content": "xxx" },
{ "role": "user", "content": [{ "type": "input_text", "text": "hi" }] }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let messages = result["messages"].as_array().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0]["role"], "user");
}
#[test]
fn test_request_drops_openai_only_fields() {
let input = json!({
"model": "c",
"max_output_tokens": 100,
"store": false,
"include": ["reasoning.encrypted_content"],
"service_tier": "priority",
"parallel_tool_calls": true,
"input": [{ "role": "user", "content": "hi" }]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert!(result.get("store").is_none());
assert!(result.get("include").is_none());
assert!(result.get("service_tier").is_none());
assert!(result.get("parallel_tool_calls").is_none());
}
// ==================== Response: Anthropic → Responses ====================
#[test]
fn test_response_text_end_turn() {
let input = json!({
"id": "msg_1",
"type": "message",
"role": "assistant",
"model": "claude",
"content": [{ "type": "text", "text": "Hello!" }],
"stop_reason": "end_turn",
"usage": { "input_tokens": 10, "output_tokens": 5 }
});
let result = anthropic_response_to_responses(input).unwrap();
assert_eq!(result["id"], "resp_msg_1");
assert_eq!(result["status"], "completed");
assert_eq!(result["output"][0]["type"], "message");
assert_eq!(result["output"][0]["content"][0]["type"], "output_text");
assert_eq!(result["output"][0]["content"][0]["text"], "Hello!");
assert_eq!(result["usage"]["input_tokens"], 10);
assert_eq!(result["usage"]["output_tokens"], 5);
assert_eq!(result["usage"]["total_tokens"], 15);
}
#[test]
fn test_response_tool_use() {
let input = json!({
"id": "msg_1",
"content": [{
"type": "tool_use",
"id": "call_1",
"name": "get_weather",
"input": { "city": "Tokyo" }
}],
"stop_reason": "tool_use",
"usage": { "input_tokens": 10, "output_tokens": 15 }
});
let result = anthropic_response_to_responses(input).unwrap();
assert_eq!(result["status"], "completed");
assert_eq!(result["output"][0]["type"], "function_call");
assert_eq!(result["output"][0]["call_id"], "call_1");
assert_eq!(result["output"][0]["name"], "get_weather");
assert_eq!(result["output"][0]["arguments"], "{\"city\":\"Tokyo\"}");
}
#[test]
fn test_response_thinking_becomes_reasoning() {
let input = json!({
"id": "msg_1",
"content": [
{ "type": "thinking", "thinking": "Let me think", "signature": "sig" },
{ "type": "text", "text": "answer" }
],
"stop_reason": "end_turn",
"usage": { "input_tokens": 1, "output_tokens": 2 }
});
let result = anthropic_response_to_responses(input).unwrap();
assert_eq!(result["output"][0]["type"], "reasoning");
assert_eq!(result["output"][0]["summary"][0]["text"], "Let me think");
assert_eq!(result["output"][1]["type"], "message");
}
#[test]
fn test_unsigned_thinking_is_not_replayed_as_encrypted_reasoning() {
let input = json!({
"id":"msg_unsigned",
"content":[
{"type":"thinking","thinking":"unsigned"},
{"type":"text","text":"answer"}
],
"stop_reason":"end_turn"
});
let result = anthropic_response_to_responses(input).unwrap();
assert_eq!(result["output"].as_array().unwrap().len(), 1);
assert_eq!(result["output"][0]["type"], "message");
}
#[test]
fn test_response_max_tokens_incomplete() {
let input = json!({
"id": "msg_1",
"content": [{ "type": "text", "text": "partial" }],
"stop_reason": "max_tokens",
"usage": { "input_tokens": 1, "output_tokens": 2 }
});
let result = anthropic_response_to_responses(input).unwrap();
assert_eq!(result["status"], "incomplete");
assert_eq!(result["incomplete_details"]["reason"], "max_output_tokens");
}
#[test]
fn test_response_usage_cache_no_double_count() {
let input = json!({
"id": "msg_1",
"content": [{ "type": "text", "text": "x" }],
"stop_reason": "end_turn",
"usage": {
"input_tokens": 20,
"output_tokens": 5,
"output_tokens_details": {"thinking_tokens": 3},
"cache_read_input_tokens": 60,
"cache_creation_input_tokens": 20
}
});
let result = anthropic_response_to_responses(input).unwrap();
// Responses input_tokens is the inclusive total: fresh + read + write.
assert_eq!(result["usage"]["input_tokens"], 100);
assert_eq!(result["usage"]["output_tokens"], 5);
assert_eq!(
result["usage"]["output_tokens_details"]["reasoning_tokens"],
3
);
// total includes input total + output exactly once.
assert_eq!(result["usage"]["total_tokens"], 105);
assert_eq!(result["usage"]["input_tokens_details"]["cached_tokens"], 60);
assert_eq!(
result["usage"]["input_tokens_details"]["cache_write_tokens"],
20
);
// Aggregate cache creation is exposed for downstream billing attribution (counted only once).
assert_eq!(result["usage"]["cache_creation_input_tokens"], 20);
}
#[test]
fn test_response_read_tool_drops_empty_pages() {
let input = json!({
"id": "msg_1",
"content": [{
"type": "tool_use",
"id": "call_1",
"name": "Read",
"input": { "file_path": "/tmp/x", "pages": "" }
}],
"stop_reason": "tool_use"
});
let result = anthropic_response_to_responses(input).unwrap();
let args = result["output"][0]["arguments"].as_str().unwrap();
assert!(args.contains("/tmp/x"));
assert!(!args.contains("pages"));
}
#[test]
fn test_response_refusal_is_incomplete_content_filter() {
let input = json!({
"id": "msg_1",
"content": [{ "type": "text", "text": "" }],
"stop_reason": "refusal",
"usage": { "input_tokens": 1, "output_tokens": 0 }
});
let result = anthropic_response_to_responses(input).unwrap();
assert_eq!(result["status"], "incomplete");
assert_eq!(result["incomplete_details"]["reason"], "content_filter");
}
#[test]
fn test_signed_thinking_round_trips_through_responses_tool_loop() {
let converted = anthropic_response_to_responses(json!({
"id": "msg_signed",
"model": "claude-sonnet-5",
"stop_reason": "tool_use",
"content": [
{"type": "thinking", "thinking": "check", "signature": "sig_123"},
{"type": "tool_use", "id": "call_1", "name": "Read", "input": {"path": "/tmp/a"}}
]
}))
.unwrap();
let reasoning = converted["output"][0].clone();
assert!(reasoning["encrypted_content"]
.as_str()
.unwrap()
.starts_with(ANTHROPIC_THINKING_ENCRYPTED_PREFIX));
let replay = responses_request_to_anthropic(
json!({
"model": "claude-sonnet-5",
"max_output_tokens": 4096,
"reasoning": {"effort": "high"},
"input": [
reasoning,
converted["output"][1].clone(),
{"type": "function_call_output", "call_id": "call_1", "output": "ok"}
]
}),
4096,
)
.unwrap();
assert_eq!(replay["thinking"]["type"], "adaptive");
assert_eq!(replay["messages"][1]["content"][0]["type"], "thinking");
assert_eq!(replay["messages"][1]["content"][0]["signature"], "sig_123");
}
#[test]
fn test_redacted_thinking_is_preserved_without_visible_summary() {
let response = anthropic_response_to_responses(json!({
"id": "msg_redacted",
"content": [{"type": "redacted_thinking", "data": "opaque"}]
}))
.unwrap();
assert_eq!(response["output"][0]["summary"], json!([]));
assert!(response["output"][0]["encrypted_content"].is_string());
}
#[test]
fn test_namespace_tool_response_restores_namespace() {
let context = build_codex_tool_context_from_request(&json!({
"tools": [{
"type": "namespace",
"name": "mcp_files",
"tools": [{"type": "function", "name": "read", "parameters": {"type": "object"}}]
}]
}));
let response = anthropic_response_to_responses_with_context(
json!({
"id": "msg_ns",
"content": [{"type": "tool_use", "id": "call_1", "name": "mcp_files__read", "input": {}}]
}),
&context,
)
.unwrap();
assert_eq!(response["output"][0]["type"], "function_call");
assert_eq!(response["output"][0]["name"], "read");
assert_eq!(response["output"][0]["namespace"], "mcp_files");
}
#[test]
fn test_success_status_error_envelope_is_not_completed() {
let error = anthropic_response_to_responses(json!({
"type": "error",
"error": {"type": "overloaded_error", "message": "busy"}
}))
.unwrap_err();
assert!(error.to_string().contains("overloaded_error"));
}
#[test]
fn test_context_window_stop_reason_is_incomplete() {
let response = anthropic_response_to_responses(json!({
"id": "msg_ctx",
"stop_reason": "model_context_window_exceeded",
"content": [{"type": "text", "text": "partial"}]
}))
.unwrap();
assert_eq!(response["status"], "incomplete");
assert_eq!(
response["incomplete_details"]["reason"],
"max_output_tokens"
);
}
#[test]
fn test_request_parallel_tool_calls_false_disables_parallel_use() {
let response = responses_request_to_anthropic(
json!({
"model": "c",
"input": [{"role": "user", "content": "hi"}],
"tools": [{"type": "function", "name": "x", "parameters": {"type": "object"}}],
"parallel_tool_calls": false
}),
4096,
)
.unwrap();
assert_eq!(response["tool_choice"]["type"], "auto");
assert_eq!(response["tool_choice"]["disable_parallel_tool_use"], true);
}
#[test]
fn test_request_trims_trailing_assistant_whitespace() {
let response = responses_request_to_anthropic(
json!({
"model": "c",
"input": [
{"role": "user", "content": "continue"},
{"role": "assistant", "content": [{"type": "output_text", "text": "prefix \n"}]}
]
}),
4096,
)
.unwrap();
assert_eq!(response["messages"][1]["content"][0]["text"], "prefix");
}
#[test]
fn test_structured_tool_output_preserves_text_and_image_blocks() {
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": "output_text", "text": "result"},
{"type": "input_image", "image_url": "data:image/png;base64,abc"}
]}
]
}),
4096,
)
.unwrap();
let content = &response["messages"][2]["content"][0]["content"];
assert_eq!(content[0]["type"], "text");
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]
fn test_structured_tool_output_restores_error_file_and_unknown_parts() {
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":"input_text","text":TOOL_RESULT_ERROR_MARKER},
{"type":"input_text","text":"failed"},
{"type":"input_file","file_url":"https://example.com/log.pdf","filename":"log.pdf"},
{"type":"future_part","payload":{"x":1}}
]}
]
}),
4096,
)
.unwrap();
let tool_result = &response["messages"][2]["content"][0];
assert_eq!(tool_result["is_error"], true);
assert_eq!(
tool_result["content"][0],
json!({"type":"text","text":"failed"})
);
assert_eq!(tool_result["content"][1]["type"], "document");
assert_eq!(tool_result["content"][1]["source"]["type"], "url");
assert_eq!(tool_result["content"][2]["type"], "text");
assert!(tool_result["content"][2]["text"]
.as_str()
.unwrap()
.contains("future_part"));
}
// ==================== Request normalization: non-empty & first is user ====================
#[test]
fn test_request_empty_messages_errors() {
// input is entirely reasoning (dropped) → messages is empty → error explicitly rather than leaving it to the upstream 400.
let input = json!({
"model": "c",
"max_output_tokens": 100,
"instructions": "sys",
"input": [
{ "type": "reasoning", "id": "rs_1", "encrypted_content": "xxx" }
]
});
assert!(responses_request_to_anthropic(input, 4096).is_err());
}
#[test]
fn test_request_assistant_first_gets_leading_user() {
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [
{ "role": "assistant", "content": [{ "type": "output_text", "text": "hi" }] }
]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let messages = result["messages"].as_array().unwrap();
assert_eq!(messages[0]["role"], "user");
assert_eq!(messages[1]["role"], "assistant");
}
// ==================== tools / tool_choice edge cases ====================
#[test]
fn test_request_tool_without_description_or_params() {
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [{ "role": "user", "content": "hi" }],
"tools": [ { "type": "function", "name": "noop" } ]
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
let tool = &result["tools"][0];
// Do not emit an explicit null description; input_schema falls back to a valid object schema.
assert!(tool.get("description").is_none());
assert_eq!(tool["input_schema"]["type"], "object");
}
#[test]
fn test_request_unknown_object_tool_choice_degrades_to_auto() {
let input = json!({
"model": "c",
"max_output_tokens": 100,
"input": [{ "role": "user", "content": "hi" }],
"tools": [{ "type": "function", "name": "x", "parameters": {"type": "object"} }],
"tool_choice": { "type": "allowed_tools", "tools": [] }
});
let result = responses_request_to_anthropic(input, 4096).unwrap();
assert_eq!(result["tool_choice"], json!({ "type": "auto" }));
}
// ==================== SSE aggregation fallback ====================
#[test]
fn test_anthropic_sse_aggregation_text_and_usage() {
let sse = "event: message_start\n\
data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude\",\"content\":[],\"stop_reason\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":0}}}\n\n\
event: content_block_start\n\
data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n\
event: content_block_delta\n\
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n\n\
event: content_block_delta\n\
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" world\"}}\n\n\
event: content_block_stop\n\
data: {\"type\":\"content_block_stop\",\"index\":0}\n\n\
event: message_delta\n\
data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":7}}\n\n\
event: message_stop\n\
data: {\"type\":\"message_stop\"}\n\n";
let msg = anthropic_sse_to_message_value(sse).unwrap();
assert_eq!(msg["content"][0]["type"], "text");
assert_eq!(msg["content"][0]["text"], "Hello world");
assert_eq!(msg["stop_reason"], "end_turn");
assert_eq!(msg["usage"]["input_tokens"], 10);
assert_eq!(msg["usage"]["output_tokens"], 7);
// The aggregated result can be converted directly into Responses.
let resp = anthropic_response_to_responses(msg).unwrap();
assert_eq!(resp["status"], "completed");
assert_eq!(resp["output"][0]["content"][0]["text"], "Hello world");
}
#[test]
fn test_anthropic_sse_aggregation_tool_use_partial_json() {
let sse = "data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"c\",\"content\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\n\
data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"get_weather\",\"input\":{}}}\n\n\
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\":\"}}\n\n\
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"Tokyo\\\"}\"}}\n\n\
data: {\"type\":\"content_block_stop\",\"index\":0}\n\n\
data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"output_tokens\":3}}\n\n";
let msg = anthropic_sse_to_message_value(sse).unwrap();
assert_eq!(msg["content"][0]["type"], "tool_use");
assert_eq!(msg["content"][0]["name"], "get_weather");
assert_eq!(msg["content"][0]["input"]["city"], "Tokyo");
assert_eq!(msg["stop_reason"], "tool_use");
}
#[test]
fn test_anthropic_sse_aggregation_tool_use_input_only_in_start() {
// Parity guard with the live streaming emitter's
// `test_tool_use_input_only_in_start_event`: a gateway that carries the full tool
// `input` on content_block_start and emits NO input_json_delta must still resolve
// the same arguments (the empty-accum fallback keeps the start-carried input).
let sse = "data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"c\",\"content\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\n\
data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"get_weather\",\"input\":{\"city\":\"Tokyo\"}}}\n\n\
data: {\"type\":\"content_block_stop\",\"index\":0}\n\n\
data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"output_tokens\":3}}\n\n";
let msg = anthropic_sse_to_message_value(sse).unwrap();
assert_eq!(msg["content"][0]["type"], "tool_use");
assert_eq!(msg["content"][0]["name"], "get_weather");
// Identical to the deltas-only case above — neither path may drop start input.
assert_eq!(msg["content"][0]["input"]["city"], "Tokyo");
assert_eq!(msg["stop_reason"], "tool_use");
}
#[test]
fn test_anthropic_sse_aggregation_missing_message_start_errors() {
let sse = "data: {\"type\":\"content_block_stop\",\"index\":0}\n\n";
assert!(anthropic_sse_to_message_value(sse).is_err());
}
#[test]
fn test_anthropic_sse_aggregation_error_event_errors() {
let sse = "data: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"overloaded\"}}\n\n";
assert!(anthropic_sse_to_message_value(sse).is_err());
}
#[test]
fn test_anthropic_sse_aggregation_tolerates_missing_trailing_blank_line() {
// The last event missing a trailing blank line (truncated stream) should still be processed.
let sse = "data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"c\",\"content\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\n\
data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"hi\"}}\n\n\
data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":2}}";
let msg = anthropic_sse_to_message_value(sse).unwrap();
assert_eq!(msg["stop_reason"], "end_turn");
assert_eq!(msg["usage"]["output_tokens"], 2);
}
#[test]
fn test_anthropic_sse_aggregation_truncated_output_is_incomplete() {
let sse = "data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"content\":[]}}\n\n\
data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"partial\"}}\n\n";
let message = anthropic_sse_to_message_value(sse).unwrap();
let response = anthropic_response_to_responses(message).unwrap();
assert_eq!(response["status"], "incomplete");
assert_eq!(
response["incomplete_details"]["reason"],
"max_output_tokens"
);
}
#[test]
fn test_anthropic_sse_aggregation_truncated_without_output_errors() {
let sse =
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"content\":[]}}\n\n";
assert!(anthropic_sse_to_message_value(sse).is_err());
}
#[test]
fn test_anthropic_sse_aggregation_non_object_content_block_does_not_panic() {
// A malformed upstream can send a non-object `content_block`; the index
// assignment on the next delta would have panicked before the shape guard.
let sse = concat!(
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"content\":[]}}\n\n",
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":[1]}\n\n",
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"x\"}}\n\n",
"data: {\"type\":\"message_stop\"}\n\n",
);
let msg = anthropic_sse_to_message_value(sse)
.expect("aggregation must not panic on a non-object content_block");
assert_eq!(msg["content"][0]["text"], json!("x"));
// Not panicking is only half of it: the sanitized block must still carry a
// `type`, because the final conversion matches on it and silently drops
// anything it does not recognise. Asserting only on the intermediate value
// would pass while the client receives a `completed` response with empty
// output and no indication that the text was thrown away.
let response = anthropic_response_to_responses(msg).expect("final conversion must succeed");
assert_eq!(
response["output"][0]["content"][0]["text"],
json!("x"),
"text recovered from a malformed block must survive to the Responses output: {response}"
);
}
#[test]
fn test_anthropic_sse_aggregation_non_object_message_errors_not_panic() {
// A malformed upstream can send a scalar `message`; the later
// `message["content"] = …` would have panicked before the shape guard.
let sse = concat!(
"data: {\"type\":\"message_start\",\"message\":\"oops\"}\n\n",
"data: {\"type\":\"message_stop\"}\n\n",
);
assert!(anthropic_sse_to_message_value(sse).is_err());
}
}