9 Commits
Author SHA1 Message Date
ZacharyZhang-NY d3c9380307 release: v0.1.7
Release / build (aarch64-apple-darwin) (push) Waiting to run
Release / build (x86_64-apple-darwin) (push) Waiting to run
Release / build (aarch64-unknown-linux-gnu) (push) Waiting to run
Release / build (x86_64-pc-windows-msvc) (push) Waiting to run
Release / publish GitHub Release (push) Blocked by required conditions
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 7s
Since v0.1.6 — the cross-provider replay audit (Pi transform-messages
policy: every wire builder emits only items valid for its target):
- fix(responses): reasoning items without a native rs_* id dropped (the
  GPT/codex 400 Invalid 'input[N].id'); provenance gate drops foreign
  reasoning and demotes foreign backend tool calls on model switches
- fix(codex): bare rs_* reasoning references dropped (stateless backend)
- fix(wire): shared ASCII tool-call id sanitizer, symmetric call+result
  on both the Messages and Responses legs
- fix(messages): image-source whitelist (raster base64 only, http(s)
  urls only) and empty-user-content guard
- fix(conversation): char-boundary-safe code preview (CJK/emoji code
  panicked every request build)
- docs: AGENTS.md records the replay policy
2026-07-23 01:16:55 -04:00
ZacharyZhang-NY c950f8087c fix(messages): whitelist image sources and guard empty user content
Cross-provider audit M4/M5/M6 (Anthropic Messages builder):
- Non-base64 data: URIs rode as ImageSource::Url — url sources are
  http(s) only → 400; and the two image paths parsed data URIs
  differently (user path split on first comma, tool-result path on
  ';base64,').
- No media-type whitelist: image/svg+xml and param-carrying headers
  ('image/webp;name=x') reached the wire → 400.
- Empty user content shipped empty arrays/text blocks → 400.

One shared parse_base64_image_data_uri (raster whitelist: jpeg/png/gif/
webp) now serves both paths; rejected images degrade to a SHORT
'[unsupported image]' placeholder (never the multi-megabyte payload);
empty user turns get '[empty message]' (mirrors the assistant guard).

Part 6 of the cross-provider replay audit.

Verified: sampling-types 292 + downstream green, clippy clean.
2026-07-23 01:16:03 -04:00
ZacharyZhang-NY 40c71a8343 fix(responses): provenance-gate foreign turns (Pi transform-messages)
Cross-provider audit R5 + R2-residual: BackendToolCall items round-trip
as their typed shapes with provider-issued ids (grok x_search
CustomToolCall, web_search/code_interpreter calls) and Reasoning items
carry model-bound encrypted payloads — replaying either to a DIFFERENT
Responses target names undeclared tools / undecryptable material → 400.

New transform_items_for_responses pre-pass: each [Reasoning|
BackendToolCall]* Assistant run carries provenance in
AssistantItem.model_id; on confirmed mismatch with the request's target
model, Reasoning siblings are dropped and BackendToolCall demoted to the
same text summary the Messages/ChatCompletions builders already emit.
Same-model and unknown-provenance turns stay byte-verbatim (KV-cache
prefix stability preserved). The legacy-upgrade round-trip test now
models the same-model continuation it always described.

Part 5 of the cross-provider replay audit.

Verified: sampling-types 290 + downstream 5794 green, clippy clean.
2026-07-23 01:10:51 -04:00
ZacharyZhang-NY a3e3973453 docs(tests): restore doc comments displaced by the summary-test insertion
The new truncation test's doc landed between backend_tool_call_position_
stable's doc and its #[test] attribute (clippy: duplicated attribute).
Each test owns its own doc block again.
2026-07-23 01:02:19 -04:00
ZacharyZhang-NY 9cdc0ccfa3 fix(wire): shared ASCII tool-call id sanitizer, symmetric on both legs
Cross-provider audit R4+M3: the Responses leg passed tool-call ids
verbatim (call_id on both function_call and function_call_output) while
the Messages leg sanitized — and its closure used Unicode
is_alphanumeric, letting CJK ids through to Anthropic's ASCII-only
contract, with no empty-id fallback. Both providers enforce
[A-Za-z0-9_-]+ (the codex 400's own words). One module-scope
sanitize_tool_call_id now serves both builders, ASCII-only, empty → "_",
applied identically on call+result so pairing survives.

Part 4 of the cross-provider replay audit.

Verified: 6081 tests green across the four crates, clippy clean.
2026-07-23 01:00:56 -04:00
ZacharyZhang-NY 6f9f550308 fix(codex): drop bare rs_* reasoning references — stateless backend
Cross-provider audit R3: reasoning captured on stateful api.openai.com
sessions (no include requested) carries a server-issued rs_* id but NO
encrypted_content; replaying that bare reference to the stateless
(store:false) codex backend points at server state chatgpt.com does not
have. adapt_body_for_codex_backend step 3 drops such items (encrypted
ones pass through verbatim). Capture-side include for the API-key path
is deferred: the typed CreateResponse is shared with the xai Responses
leg and changing its bytes needs separate validation.

Part 3 of the cross-provider replay audit.

Verified: sampling-types+sampler+chat-state+shell all green.
2026-07-23 00:55:23 -04:00
ZacharyZhang-NY 2b43f54669 test(conversation): fix code-preview fixture to actually exceed the char cap
The multibyte-truncation test used 80 chars — below the 100-char cap, so
the truncation assertion failed (the previous commit's suite count was
misread; the FIX itself was correct and the panic repro held). 120 chars
now exercises both the boundary safety and the truncation.
2026-07-23 00:51:04 -04:00
ZacharyZhang-NY 6979407f22 fix(conversation): char-boundary-safe code preview in text_summary
The code-interpreter preview truncated at BYTE 100 — a guaranteed panic
on any CJK/emoji boundary in interpreted code. One poisoned history item
then crashed every subsequent request build on every backend (the
summary feeds both the Messages and ChatCompletions builders). Truncate
at 100 chars via char_indices instead. Panic pinned by test.

Part 2 of the cross-provider replay audit.

Verified: sampling-types 287 tests green.
2026-07-23 00:50:29 -04:00
ZacharyZhang-NY 1fa87566d9 fix(responses): drop reasoning items without a native rs_* id
Root cause of 400 Invalid 'input[N].id': '' on chatgpt.com/backend-api/
codex/responses: the Responses input builder replayed every stored
Reasoning item verbatim, and rs::ReasoningItem.id serializes
unconditionally — so foreign items (Messages-captured Anthropic
signatures, chat-completions-synthesized reasoning, stream-delta
fallbacks, legacy upgrades — all id '') reached the wire with an empty
id the API rejects. This also self-poisoned pure codex sessions whose
reasoning arrived only as deltas.

A native item always carries a server-issued rs_* id: empty id = foreign
= unusable by any Responses provider = dropped at the builder — the
exact mirror of the Messages builder's prune_replayed_thinking. The
encrypted-only fixture that pinned the poison shape now uses a native id
(the pass-through case it always meant to cover).

Part 1 of the cross-provider replay audit (Pi transform-messages
policy: builders emit only items valid for their target).

Verified: sampling-types 286 + sampler/chat-state/shell 5791 green.
2026-07-23 00:49:18 -04:00
6 changed files with 673 additions and 132 deletions
+20
View File
@@ -228,6 +228,26 @@ edges stay deterministic Rust. The harness appends a terminal
results); everything else — unsigned cross-backend history, `tco_*`
Responses blobs, stale-model blocks — is stripped, or the request
400s "Invalid `signature` in `thinking` block".
- CROSS-PROVIDER REPLAY POLICY (Pi `transform-messages` pattern): the
conversation history is provider-agnostic and sessions switch
models/backends mid-history, so EACH wire builder owns emitting only
items valid for its target — never patch downstream except in the
per-backend body adapters. Concretely: the Responses input drops
Reasoning items without a native `rs_*` id (foreign capture is id "")
and provenance-gates whole turns via `transform_items_for_responses`
(`AssistantItem.model_id` vs the request model: foreign Reasoning
dropped, foreign BackendToolCall demoted to its `text_summary`);
the codex adapter additionally drops bare `rs_*` references (stateless
backend); tool-call ids pass through ONE shared ASCII
`sanitize_tool_call_id` symmetrically on call+result on BOTH the
Messages and Responses legs; Messages image sources go through
`parse_base64_image_data_uri` (raster whitelist, no `data:` url
sources) and empty user turns get a placeholder. Dangling tool calls
are already repaired item-level by `repair_dangling_tool_calls` on the
actor's build path. When a provider wire bug surfaces, fix the CLASS
across all three builders in the same pass — three sequential
single-provider fixes (thinking signature → codex system role → codex
reasoning id) motivated this policy.
- `openai-codex` (ChatGPT Plus/Pro, `scope_key oauth/openai-codex`, port
1455 `/auth/callback`, FORM body, authorize+token host `auth.openai.com`,
client `app_EMoam…`, scope `openid profile email offline_access`, the 3
Generated
+62 -62
View File
@@ -5442,7 +5442,7 @@ dependencies = [
[[package]]
name = "kigi-acp-lib"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"agent-client-protocol",
"async-trait",
@@ -5456,7 +5456,7 @@ dependencies = [
[[package]]
name = "kigi-agent"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -5486,7 +5486,7 @@ dependencies = [
[[package]]
name = "kigi-agent-lifecycle"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"async-trait",
"tokio",
@@ -5495,7 +5495,7 @@ dependencies = [
[[package]]
name = "kigi-auth"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"async-trait",
"http 1.4.2",
@@ -5508,7 +5508,7 @@ dependencies = [
[[package]]
name = "kigi-bin"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"anyhow",
"clap",
@@ -5543,7 +5543,7 @@ dependencies = [
[[package]]
name = "kigi-chat-state"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"indexmap",
"kigi-compaction",
@@ -5560,7 +5560,7 @@ dependencies = [
[[package]]
name = "kigi-codebase-graph"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"ahash",
"clap",
@@ -5596,7 +5596,7 @@ dependencies = [
[[package]]
name = "kigi-compaction"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"anyhow",
"async-trait",
@@ -5609,7 +5609,7 @@ dependencies = [
[[package]]
name = "kigi-config"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"base64",
"blake3",
@@ -5632,7 +5632,7 @@ dependencies = [
[[package]]
name = "kigi-config-types"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"agent-client-protocol",
"indexmap",
@@ -5646,7 +5646,7 @@ dependencies = [
[[package]]
name = "kigi-crash-handler"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"backtrace",
"libc",
@@ -5657,7 +5657,7 @@ dependencies = [
[[package]]
name = "kigi-env"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"tracing",
"url",
@@ -5665,7 +5665,7 @@ dependencies = [
[[package]]
name = "kigi-fast-worktree"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"anyhow",
"bytes",
@@ -5697,7 +5697,7 @@ dependencies = [
[[package]]
name = "kigi-file-utils"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"anyhow",
"aws-config",
@@ -5721,7 +5721,7 @@ dependencies = [
[[package]]
name = "kigi-fsnotify"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"criterion",
"dunce",
@@ -5742,7 +5742,7 @@ dependencies = [
[[package]]
name = "kigi-gix-status"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"gix",
"kigi-test-utils",
@@ -5752,7 +5752,7 @@ dependencies = [
[[package]]
name = "kigi-hooks"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"fastrand",
"kigi-config",
@@ -5771,7 +5771,7 @@ dependencies = [
[[package]]
name = "kigi-hooks-plugins-types"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"serde",
"serde_json",
@@ -5779,7 +5779,7 @@ dependencies = [
[[package]]
name = "kigi-http"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"kigi-auth",
"kigi-log",
@@ -5794,7 +5794,7 @@ dependencies = [
[[package]]
name = "kigi-hunk-tracker"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"chrono",
"dunce",
@@ -5815,14 +5815,14 @@ dependencies = [
[[package]]
name = "kigi-interjection-core"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"serde",
]
[[package]]
name = "kigi-log"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"anyhow",
"chrono",
@@ -5840,7 +5840,7 @@ dependencies = [
[[package]]
name = "kigi-markdown"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"anstyle",
"anstyle-lossy",
@@ -5864,14 +5864,14 @@ dependencies = [
[[package]]
name = "kigi-markdown-core"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"pulldown-cmark",
]
[[package]]
name = "kigi-mcp"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"agent-client-protocol",
"async-trait",
@@ -5908,7 +5908,7 @@ dependencies = [
[[package]]
name = "kigi-memory"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"anyhow",
"arc-swap",
@@ -5942,7 +5942,7 @@ dependencies = [
[[package]]
name = "kigi-mermaid"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"fontdb",
"image",
@@ -5960,7 +5960,7 @@ dependencies = [
[[package]]
name = "kigi-models"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"kigi-env",
"serde",
@@ -5970,7 +5970,7 @@ dependencies = [
[[package]]
name = "kigi-pager-minimal"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"chrono",
"crossterm",
@@ -5987,7 +5987,7 @@ dependencies = [
[[package]]
name = "kigi-pager-pty-harness"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -6012,7 +6012,7 @@ dependencies = [
[[package]]
name = "kigi-pager-render"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"agent-client-protocol",
"anstyle",
@@ -6064,7 +6064,7 @@ dependencies = [
[[package]]
name = "kigi-paths"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"camino",
"serde",
@@ -6074,7 +6074,7 @@ dependencies = [
[[package]]
name = "kigi-prompt-queue"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"serde",
"serde_json",
@@ -6082,7 +6082,7 @@ dependencies = [
[[package]]
name = "kigi-proto-build"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"anyhow",
"pbjson-build",
@@ -6093,7 +6093,7 @@ dependencies = [
[[package]]
name = "kigi-ratatui-inline"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"ansi-width",
"anstyle-parse 0.2.7",
@@ -6110,7 +6110,7 @@ dependencies = [
[[package]]
name = "kigi-ratatui-textarea"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"arboard",
"chrono",
@@ -6131,7 +6131,7 @@ dependencies = [
[[package]]
name = "kigi-sampler"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"async-openai",
"async-stream",
@@ -6154,7 +6154,7 @@ dependencies = [
[[package]]
name = "kigi-sampling-types"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"assert_matches",
"async-openai",
@@ -6171,7 +6171,7 @@ dependencies = [
[[package]]
name = "kigi-sandbox"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"anyhow",
"chrono",
@@ -6192,7 +6192,7 @@ dependencies = [
[[package]]
name = "kigi-secrets"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"regex",
"serde_json",
@@ -6230,7 +6230,7 @@ dependencies = [
[[package]]
name = "kigi-shell"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6367,7 +6367,7 @@ dependencies = [
[[package]]
name = "kigi-shell-base"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"anyhow",
"chrono",
@@ -6392,7 +6392,7 @@ dependencies = [
[[package]]
name = "kigi-sqlite-journal"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"libc",
"rusqlite",
@@ -6403,7 +6403,7 @@ dependencies = [
[[package]]
name = "kigi-subagent-resolution"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"kigi-sampling-types",
"kigi-tool-types",
@@ -6418,7 +6418,7 @@ dependencies = [
[[package]]
name = "kigi-system-power"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"windows-sys 0.59.0",
"zbus",
@@ -6426,7 +6426,7 @@ dependencies = [
[[package]]
name = "kigi-test-support"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6448,7 +6448,7 @@ dependencies = [
[[package]]
name = "kigi-test-utils"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"runfiles",
"tracing",
@@ -6457,11 +6457,11 @@ dependencies = [
[[package]]
name = "kigi-token-estimation"
version = "0.1.6"
version = "0.1.7"
[[package]]
name = "kigi-tool-protocol"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"kigi-tool-types",
"serde",
@@ -6472,7 +6472,7 @@ dependencies = [
[[package]]
name = "kigi-tool-runtime"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"anyhow",
"async-trait",
@@ -6490,7 +6490,7 @@ dependencies = [
[[package]]
name = "kigi-tool-types"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"minijinja",
"schemars 1.2.1",
@@ -6500,7 +6500,7 @@ dependencies = [
[[package]]
name = "kigi-tools"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"anyhow",
"arc-swap",
@@ -6577,7 +6577,7 @@ dependencies = [
[[package]]
name = "kigi-tools-api"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"kigi-proto-build",
"kigi-tool-protocol",
@@ -6590,11 +6590,11 @@ dependencies = [
[[package]]
name = "kigi-tracing-macros"
version = "0.1.6"
version = "0.1.7"
[[package]]
name = "kigi-tty-utils"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"libc",
"nix 0.30.1",
@@ -6604,7 +6604,7 @@ dependencies = [
[[package]]
name = "kigi-tui"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"agent-client-protocol",
"ansi-to-tui",
@@ -6691,7 +6691,7 @@ dependencies = [
[[package]]
name = "kigi-update"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"anyhow",
"dunce",
@@ -6720,14 +6720,14 @@ dependencies = [
[[package]]
name = "kigi-version"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"semver",
]
[[package]]
name = "kigi-workspace"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6806,7 +6806,7 @@ dependencies = [
[[package]]
name = "kigi-workspace-types"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"base64",
"chrono",
@@ -8840,7 +8840,7 @@ dependencies = [
[[package]]
name = "ptyctl"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -8858,7 +8858,7 @@ dependencies = [
[[package]]
name = "ptyctl-cli"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"anyhow",
"axum",
+1 -1
View File
@@ -76,7 +76,7 @@ members = [
]
[workspace.package]
version = "0.1.6"
version = "0.1.7"
edition = "2024"
license = "Apache-2.0"
@@ -319,15 +319,15 @@ impl BackendToolCallItem {
format!("[backend x_search] {}({})", ct.name, ct.input)
}
BackendToolKind::CodeInterpreter(ci) => {
// Char-boundary-safe preview: a byte slice (`&c[..100]`)
// panicked on CJK/emoji code, crashing every subsequent
// request build on every backend.
let code_preview = ci
.code
.as_deref()
.map(|c| {
if c.len() > 100 {
format!("{}...", &c[..100])
} else {
c.to_string()
}
.map(|c| match c.char_indices().nth(100) {
Some((byte_idx, _)) => format!("{}...", &c[..byte_idx]),
None => c.to_string(),
})
.unwrap_or_default();
format!("[backend code_interpreter] {code_preview}")
@@ -2188,14 +2188,86 @@ impl From<&ConversationRequest> for rs::CreateResponse {
/// so they appear inline in the same order the model originally emitted —
/// which is what lets the server-side prefix KV-cache hit on repeat turns.
fn build_responses_input(req: &ConversationRequest) -> rs::InputParam {
let items: Vec<rs::InputItem> = req
.items
let transformed = transform_items_for_responses(&req.items, req.model.as_deref());
let items: Vec<rs::InputItem> = transformed
.iter()
.flat_map(conversation_item_to_input_items)
.collect();
rs::InputParam::Items(items)
}
/// Provenance gate for the Responses input (the Pi `transform-messages`
/// pattern): opaque provider-issued items replay verbatim only when the
/// turn that produced them ran on the SAME model this request targets.
///
/// Each `[Reasoning | BackendToolCall]* Assistant` run carries its
/// provenance in `AssistantItem::model_id`. On a confirmed mismatch
/// (both sides known, different — a mid-session `/model` switch or a
/// cross-backend history):
/// - `Reasoning` siblings are DROPPED — their encrypted payloads are
/// scoped to the issuing model (OpenAI documents encrypted content as
/// model-bound; foreign backends' blobs are undecryptable outright);
/// - `BackendToolCall` items are DEMOTED to a synthetic assistant text
/// summary — exactly the downgrade the Messages and ChatCompletions
/// builders already perform — because their typed shapes carry
/// provider-issued ids and tool names the target never declared
/// (e.g. a grok `x_search` CustomToolCall replayed to codex).
///
/// Same-model runs, unknown provenance (pre-provenance histories with
/// `model_id: None`), and trailing orphans pass through verbatim — that
/// byte-stability is what lets the server-side prefix KV-cache hit.
fn transform_items_for_responses(
items: &[ConversationItem],
target_model: Option<&str>,
) -> Vec<ConversationItem> {
let Some(target) = target_model else {
return items.to_vec();
};
let mut out: Vec<ConversationItem> = Vec::with_capacity(items.len());
// Pending run of opaque siblings awaiting their Assistant carrier.
let mut run_start: usize = 0;
for item in items {
match item {
ConversationItem::Reasoning(_) | ConversationItem::BackendToolCall(_) => {
out.push(item.clone());
}
ConversationItem::Assistant(a) => {
let foreign = a
.model_id
.as_deref()
.is_some_and(|producer| producer != target);
if foreign {
// Rewrite the pending run in place.
let mut rewritten: Vec<ConversationItem> = Vec::new();
for pending in out.drain(run_start..) {
match pending {
ConversationItem::Reasoning(_) => {}
ConversationItem::BackendToolCall(b) => {
rewritten.push(ConversationItem::Assistant(AssistantItem {
content: b.text_summary().into(),
tool_calls: vec![],
model_id: a.model_id.clone(),
model_fingerprint: None,
reasoning_effort: None,
}));
}
other => rewritten.push(other),
}
}
out.extend(rewritten);
}
out.push(item.clone());
run_start = out.len();
}
other => {
out.push(other.clone());
run_start = out.len();
}
}
}
out
}
/// Walk a serialized Responses API request body and inject the
/// `type: "reasoning_text"` discriminator that the API requires on
/// `reasoning.content[*]` items.
@@ -2251,9 +2323,23 @@ fn conversation_item_to_input_items(item: &ConversationItem) -> Vec<rs::InputIte
}
ConversationItem::Reasoning(r) => {
// Reasoning items round-trip back to the Responses API in their
// native typed form. `status` is output-only (the API rejects it
// on input), so strip it before emission; everything else
// (summary, content, encrypted_content, id) passes through.
// native typed form — but ONLY items the Responses API itself
// produced. A native item always carries a server-issued `rs_*`
// id; an EMPTY id marks a foreign item (Messages capture stores
// the Anthropic signature with id "", chat-completions and the
// stream-delta fallback synthesize with id "", legacy upgraders
// reconstruct with id ""), and the API rejects it outright:
// 400 "Invalid 'input[N].id': ''. Expected an ID that contains
// letters, numbers, underscores, or dashes". Foreign reasoning
// is unusable by a Responses provider anyway — drop it (the
// exact mirror of the Messages builder's
// `prune_replayed_thinking`).
if r.id.is_empty() {
return vec![];
}
// `status` is output-only (the API rejects it on input), so
// strip it before emission; everything else (summary, content,
// encrypted_content, id) passes through.
let mut r = r.clone();
r.status = None;
vec![rs::InputItem::Item(rs::Item::Reasoning(r))]
@@ -2274,12 +2360,15 @@ fn conversation_item_to_input_items(item: &ConversationItem) -> Vec<rs::InputIte
}));
}
// Add each tool call as a FunctionCall item
// Add each tool call as a FunctionCall item. The call_id is
// normalized to the Responses charset (foreign backends mint
// arbitrary ids); the ToolResult arm applies the SAME map so
// pairing survives.
for tc in &a.tool_calls {
let arguments = sanitize_tool_arguments(&tc.id, &tc.name, tc.arguments.clone());
items.push(rs::InputItem::Item(rs::Item::FunctionCall(
rs::FunctionToolCall {
call_id: tc.id.as_ref().to_owned(),
call_id: sanitize_tool_call_id(&tc.id),
name: tc.name.clone(),
arguments: arguments.as_ref().to_owned(),
id: None,
@@ -2313,7 +2402,9 @@ fn conversation_item_to_input_items(item: &ConversationItem) -> Vec<rs::InputIte
};
vec![rs::InputItem::Item(rs::Item::FunctionCallOutput(
rs::FunctionCallOutputItemParam {
call_id: t.tool_call_id.clone(),
// Same normalization as the FunctionCall arm — pairing
// survives because both sides map identically.
call_id: sanitize_tool_call_id(&t.tool_call_id),
output,
id: None,
status: None,
@@ -2983,6 +3074,53 @@ pub fn dedup_duplicate_tool_results(conversation: &mut Vec<ConversationItem>) ->
// ============================================================================
/// Convert a ConversationRequest to Anthropic MessagesRequest.
/// Normalize a tool-call id to the `[A-Za-z0-9_-]+` charset both Anthropic
/// Messages and the OpenAI Responses API enforce ("Expected an ID that
/// contains letters, numbers, underscores, or dashes"). Foreign backends
/// mint arbitrary ids (chat-completions providers, UUID synthesis), so the
/// wire builders apply this SYMMETRICALLY on the call and its result —
/// pairing survives because both sides map through the same function.
/// ASCII-only (the old closure used Unicode `is_alphanumeric`, letting
/// e.g. CJK ids through to Anthropic's ASCII contract); an empty id maps
/// to `"_"` so the mandatory field is never empty on the wire.
fn sanitize_tool_call_id(id: &str) -> String {
if id.is_empty() {
return "_".to_string();
}
id.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect()
}
/// The raster media types Anthropic accepts for base64 image sources.
const ANTHROPIC_IMAGE_MEDIA_TYPES: [&str; 4] =
["image/jpeg", "image/png", "image/gif", "image/webp"];
/// Parse a `data:` URI into an Anthropic base64 image source
/// `(media_type, data)`.
///
/// `None` for anything Anthropic would reject — non-base64 data URIs
/// (previously leaked as `ImageSource::Url` carrying a `data:` payload:
/// url sources must be http(s) → 400), media types outside the raster
/// whitelist (`image/svg+xml` → 400), and param-carrying headers
/// (`data:image/webp;name=x;base64,…` yields media type
/// `"image/webp;name=x"` → 400). Callers degrade to a short text
/// placeholder — never the raw URI, which for data URIs can be megabytes.
fn parse_base64_image_data_uri(url: &str) -> Option<(String, String)> {
let rest = url.strip_prefix("data:")?;
let (media_type, data) = rest.split_once(";base64,")?;
let media_type = media_type.to_ascii_lowercase();
ANTHROPIC_IMAGE_MEDIA_TYPES
.contains(&media_type.as_str())
.then(|| (media_type, data.to_string()))
}
pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::MessagesRequest {
use crate::messages::{
CacheControl, ContentBlock, ImageSource, Message, MessageContent, MessageRole,
@@ -2995,19 +3133,6 @@ pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::Mes
let mut pending_assistant: Vec<ContentBlock> = Vec::new();
let mut pending_tool_results: Vec<ContentBlock> = Vec::new();
// Helper to sanitize tool call IDs (replace [^a-zA-Z0-9_-] with _)
let sanitize_tool_call_id = |id: &str| -> String {
id.chars()
.map(|c| {
if c.is_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect()
};
// Helper to convert ContentPart to Anthropic ContentBlock
let content_parts_to_anthropic_blocks = |parts: &[ContentPart]| -> Vec<ContentBlock> {
parts
@@ -3018,28 +3143,9 @@ pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::Mes
cache_control: None,
},
ContentPart::Image { url } => {
// Parse data: URI vs HTTP(S) URL
if url.starts_with("data:") {
// data:image/png;base64,ABC123...
if let Some((header, data)) = url.split_once(',') {
// Extract media type from header: data:image/png;base64
let media_type = header
.strip_prefix("data:")
.and_then(|h| h.strip_suffix(";base64"))
.unwrap_or("image/png")
.to_string();
ContentBlock::Image {
source: ImageSource::Base64 {
media_type,
data: data.to_string(),
},
}
} else {
// Malformed data URI, treat as text
ContentBlock::Text {
text: format!("[invalid image: {}]", url),
cache_control: None,
}
if let Some((media_type, data)) = parse_base64_image_data_uri(url) {
ContentBlock::Image {
source: ImageSource::Base64 { media_type, data },
}
} else if url.starts_with("http://") || url.starts_with("https://") {
ContentBlock::Image {
@@ -3047,6 +3153,13 @@ pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::Mes
url: url.as_ref().to_owned(),
},
}
} else if url.starts_with("data:") {
// Rejected data URI (non-base64 / non-raster media
// type): short placeholder, NEVER the payload.
ContentBlock::Text {
text: "[unsupported image]".to_string(),
cache_control: None,
}
} else {
// Unknown format, treat as text
ContentBlock::Text {
@@ -3096,7 +3209,17 @@ pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::Mes
ConversationItem::User(u) => {
flush_assistant(&mut pending_assistant, &mut messages);
flush_tool_results(&mut pending_tool_results, &mut messages);
let blocks = content_parts_to_anthropic_blocks(&u.content);
// Anthropic rejects empty content: drop empty text parts and
// give an all-empty user turn a placeholder (mirrors the
// assistant arm's emptiness guard).
let mut blocks = content_parts_to_anthropic_blocks(&u.content);
blocks.retain(|b| !matches!(b, ContentBlock::Text { text, .. } if text.is_empty()));
if blocks.is_empty() {
blocks.push(ContentBlock::Text {
text: "[empty message]".to_string(),
cache_control: None,
});
}
messages.push(Message {
role: MessageRole::User,
content: MessageContent::Blocks(blocks),
@@ -3139,23 +3262,26 @@ pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::Mes
}];
for img in &t.images {
if let ContentPart::Image { url } = img {
let source = if let Some(rest) = url.strip_prefix("data:") {
if let Some((media_type, data)) = rest.split_once(";base64,") {
ImageSource::Base64 {
media_type: media_type.to_string(),
data: data.to_string(),
}
} else {
ImageSource::Url {
// Same whitelist parse as the user path; a
// rejected data URI must never ride as
// `ImageSource::Url` (url sources are http(s)
// only — Anthropic 400s a `data:` payload).
if let Some((media_type, data)) = parse_base64_image_data_uri(url) {
blocks.push(ContentBlock::Image {
source: ImageSource::Base64 { media_type, data },
});
} else if url.starts_with("http://") || url.starts_with("https://") {
blocks.push(ContentBlock::Image {
source: ImageSource::Url {
url: url.as_ref().to_owned(),
}
}
},
});
} else {
ImageSource::Url {
url: url.as_ref().to_owned(),
}
};
blocks.push(ContentBlock::Image { source });
blocks.push(ContentBlock::Text {
text: "[unsupported image]".to_string(),
cache_control: None,
});
}
}
}
ToolResultContent::Blocks(blocks)
@@ -4621,6 +4747,310 @@ mod tests {
}
}
/// Anthropic image sources: base64 only for whitelisted raster types;
/// url sources http(s) only. Previously a non-base64 `data:` URI rode
/// as `ImageSource::Url` (400), `image/svg+xml` passed the media type
/// through (400), and a param-carrying header produced
/// `"image/webp;name=x"` (400). Rejected images degrade to a SHORT
/// placeholder — never the multi-megabyte payload. Empty user turns
/// get a placeholder block (Anthropic rejects empty content).
#[test]
fn messages_request_guards_images_and_empty_user_content() {
let req = ConversationRequest::from_items(vec![
ConversationItem::user_with_parts(vec![
ContentPart::Text { text: "".into() },
ContentPart::Image {
url: "data:image/png;base64,AAAA".into(),
},
ContentPart::Image {
url: "data:image/svg+xml;base64,PHN2Zz4=".into(),
},
ContentPart::Image {
url: "data:text/plain,hello".into(),
},
]),
assistant_text("a1"),
// Empty user turn: must not ship an empty content array.
ConversationItem::user(""),
]);
let json = serde_json::to_value(build_messages_request(&req)).unwrap();
let messages = json["messages"].as_array().unwrap();
let first_user = &messages[0]["content"].as_array().unwrap();
// Valid png passes as base64.
assert!(
first_user
.iter()
.any(|b| b["type"] == "image" && b["source"]["media_type"] == "image/png"),
"{json:#}"
);
// svg + non-base64 rejected to short placeholders; never a
// data: payload in a url source, never a non-raster media type.
for m in messages {
if let Some(content) = m.get("content").and_then(|c| c.as_array()) {
assert!(!content.is_empty(), "empty content array: {json:#}");
for b in content {
if b["type"] == "image" {
let src = &b["source"];
if src["type"] == "url" {
let u = src["url"].as_str().unwrap();
assert!(
u.starts_with("http://") || u.starts_with("https://"),
"url source must be http(s): {u}"
);
} else {
let mt = src["media_type"].as_str().unwrap();
assert!(
ANTHROPIC_IMAGE_MEDIA_TYPES.contains(&mt),
"media type must be whitelisted: {mt}"
);
}
}
}
}
}
assert_eq!(
first_user
.iter()
.filter(|b| b["text"] == "[unsupported image]")
.count(),
2,
"both rejected images degrade to placeholders: {json:#}"
);
// The empty user turn carries the placeholder block.
let last_user = messages.last().unwrap();
assert_eq!(last_user["content"][0]["text"], "[empty message]");
}
/// Tool-result images take the same whitelist: a rejected data URI in
/// a tool result degrades to a text block, never an
/// `ImageSource::Url` carrying a `data:` payload.
#[test]
fn messages_request_guards_tool_result_images() {
let req = ConversationRequest::from_items(vec![
ConversationItem::user("q"),
ConversationItem::Assistant(AssistantItem {
content: "".into(),
tool_calls: vec![ToolCall {
id: std::sync::Arc::from("tc1"),
name: "read_file".to_string(),
arguments: std::sync::Arc::from("{}"),
}],
model_id: None,
model_fingerprint: None,
reasoning_effort: None,
}),
ConversationItem::tool_result_with_images(
"tc1",
"saw an image",
vec![ContentPart::Image {
url: "data:text/plain,hello".into(),
}],
),
]);
let json = serde_json::to_value(build_messages_request(&req)).unwrap();
let raw = json.to_string();
assert!(
!raw.contains("data:text/plain"),
"rejected data URI must not reach the wire: {json:#}"
);
assert!(raw.contains("[unsupported image]"), "{json:#}");
}
/// Provenance gate: a turn produced by a DIFFERENT model must not
/// replay its opaque items to this request's target — Reasoning
/// (model-bound encrypted payloads) is dropped, BackendToolCall
/// (provider-issued ids + undeclared tool shapes, e.g. grok x_search
/// → codex) is demoted to the same text summary the other builders
/// emit. Same-model and unknown-provenance turns stay byte-verbatim
/// (KV-cache stability).
#[test]
fn responses_input_gates_foreign_turns_by_provenance() {
let custom_call: rs::CustomToolCall = serde_json::from_value(serde_json::json!({
"call_id": "xs_1",
"id": "ct_1",
"input": "{\"q\":\"news\"}",
"name": "x_search",
}))
.expect("custom tool call fixture");
let x_search = ConversationItem::BackendToolCall(BackendToolCallItem {
kind: BackendToolKind::XSearch(custom_call),
});
let foreign_assistant = ConversationItem::Assistant(AssistantItem {
content: "grok says hi".into(),
tool_calls: vec![],
model_id: Some("grok-4".to_string()),
model_fingerprint: None,
reasoning_effort: None,
});
let native_assistant = ConversationItem::Assistant(AssistantItem {
content: "codex says hi".into(),
tool_calls: vec![],
model_id: Some("gpt-5.2-codex".to_string()),
model_fingerprint: None,
reasoning_effort: None,
});
let mut req = ConversationRequest::from_items(vec![
ConversationItem::user("q1"),
// Foreign turn: grok reasoning + x_search + assistant.
ConversationItem::Reasoning(rs::ReasoningItem {
id: "rs_grok_1".to_string(),
summary: vec![],
content: None,
encrypted_content: Some("grok-blob".to_string()),
status: None,
}),
x_search,
foreign_assistant,
ConversationItem::user("q2"),
// Native turn: same model as the request target.
ConversationItem::Reasoning(rs::ReasoningItem {
id: "rs_codex_1".to_string(),
summary: vec![],
content: None,
encrypted_content: Some("codex-blob".to_string()),
status: None,
}),
native_assistant,
ConversationItem::user("q3"),
]);
req.model = Some("gpt-5.2-codex".to_string());
let json = serde_json::to_value(rs::CreateResponse::from(&req)).unwrap();
let input = json["input"].as_array().unwrap();
// Foreign reasoning + x_search gone; the summary text survives.
assert!(
!input.iter().any(|i| i["id"] == "rs_grok_1"),
"foreign reasoning must be dropped:\n{json:#}"
);
assert!(
!input.iter().any(|i| i["type"] == "custom_tool_call"),
"foreign backend tool call must not replay typed:\n{json:#}"
);
assert!(
input.iter().any(|i| i["role"] == "assistant"
&& i["content"]
.as_str()
.is_some_and(|c| c.contains("[backend x_search]"))),
"foreign backend tool call demoted to text summary:\n{json:#}"
);
// Native reasoning verbatim.
assert!(
input
.iter()
.any(|i| i["id"] == "rs_codex_1" && i["encrypted_content"] == "codex-blob"),
"native reasoning must replay verbatim:\n{json:#}"
);
}
/// Both Anthropic Messages and the Responses API enforce
/// `[A-Za-z0-9_-]+` tool-call ids; foreign backends mint arbitrary
/// ones. The shared sanitizer must be ASCII-only (the old closure's
/// Unicode `is_alphanumeric` let CJK ids through), never emit an empty
/// id, and map call + result IDENTICALLY so pairing survives.
#[test]
fn tool_call_ids_sanitized_symmetrically_on_responses_leg() {
let weird_id = "调用#1 β";
let req = ConversationRequest::from_items(vec![
ConversationItem::user("q"),
ConversationItem::Assistant(AssistantItem {
content: "".into(),
tool_calls: vec![ToolCall {
id: std::sync::Arc::from(weird_id),
name: "read_file".to_string(),
arguments: std::sync::Arc::from("{}"),
}],
model_id: None,
model_fingerprint: None,
reasoning_effort: None,
}),
ConversationItem::tool_result(weird_id, "contents"),
]);
let json = serde_json::to_value(rs::CreateResponse::from(&req)).unwrap();
let input = json["input"].as_array().unwrap();
let call_id = input
.iter()
.find(|i| i["type"] == "function_call")
.map(|i| i["call_id"].as_str().unwrap().to_string())
.expect("function_call present");
let output_id = input
.iter()
.find(|i| i["type"] == "function_call_output")
.map(|i| i["call_id"].as_str().unwrap().to_string())
.expect("function_call_output present");
assert_eq!(call_id, output_id, "pairing must survive sanitization");
assert!(
call_id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
"sanitized id must satisfy the wire charset: {call_id:?}"
);
assert!(!call_id.is_empty());
// The sanitizer itself: ASCII passthrough, unicode replaced, empty
// never emitted.
assert_eq!(sanitize_tool_call_id("toolu_01AB-cd"), "toolu_01AB-cd");
assert_eq!(sanitize_tool_call_id("统A1"), "_A1");
assert_eq!(sanitize_tool_call_id(""), "_");
}
/// The Responses API requires a server-issued id on every replayed
/// reasoning input item — an empty one 400s with "Invalid
/// 'input[N].id': ''" (observed on the Codex backend after a
/// cross-backend session switched to a GPT model). Empty-id reasoning
/// can only be foreign: Messages-captured (Anthropic signature,
/// id "") or chat-completions-synthesized (id "", no encrypted
/// content). Neither is usable by a Responses provider — drop them;
/// native `rs_*` items pass through untouched.
#[test]
fn responses_input_drops_reasoning_without_native_id() {
let req = ConversationRequest::from_items(vec![
ConversationItem::user("q1"),
// Messages-captured: Anthropic signature, empty id.
reasoning("claude thinking", Some("anthropic-sig")),
assistant_text("a1"),
ConversationItem::user("q2"),
// Chat-completions synthesized: empty id, nothing encrypted.
ConversationItem::Reasoning(synthesized_reasoning_item("kimi thinking")),
assistant_text("a2"),
ConversationItem::user("q3"),
// Native Responses item: server-issued id.
ConversationItem::Reasoning(rs::ReasoningItem {
id: "rs_native_1".to_string(),
summary: vec![],
content: None,
encrypted_content: Some("gAAAA-native".to_string()),
status: None,
}),
assistant_text("a3"),
ConversationItem::user("q4"),
]);
let responses_req: rs::CreateResponse = (&req).into();
let json = serde_json::to_value(&responses_req).unwrap();
let reasoning_items: Vec<&serde_json::Value> = json["input"]
.as_array()
.unwrap()
.iter()
.filter(|i| i.get("type").and_then(|t| t.as_str()) == Some("reasoning"))
.collect();
assert_eq!(
reasoning_items.len(),
1,
"only the native rs_* item may be replayed:\n{json:#}"
);
assert_eq!(reasoning_items[0]["id"], "rs_native_1");
assert_eq!(reasoning_items[0]["encrypted_content"], "gAAAA-native");
assert!(
!json["input"]
.as_array()
.unwrap()
.iter()
.any(|i| i.get("id").and_then(|v| v.as_str()) == Some("")),
"no input item may carry an empty id:\n{json:#}"
);
}
#[test]
fn test_encrypted_reasoning_included_in_responses_api_request() {
// Test that when building a Responses API request, encrypted reasoning is included
@@ -4687,12 +5117,15 @@ mod tests {
#[test]
fn test_only_encrypted_reasoning_included_in_request() {
// Test that when there's only encrypted content (no visible summary),
// it's still included in the request
// Encrypted-only reasoning replays ONLY with a native (server-issued)
// id. An id-less encrypted blob is by construction FOREIGN (the
// Responses stream always captures the `rs_*` id; Messages capture
// stores the Anthropic signature with id "") and the API rejects
// empty ids — see `responses_input_drops_reasoning_without_native_id`.
let req = ConversationRequest::from_items(vec![
ConversationItem::user("Hello"),
ConversationItem::Reasoning(rs::ReasoningItem {
id: String::new(),
id: "rs_hidden_1".to_string(),
summary: vec![],
content: None,
encrypted_content: Some("enc_hidden_thoughts".to_string()),
@@ -4725,6 +5158,7 @@ mod tests {
assert_eq!(reasoning_items.len(), 1);
let reasoning = reasoning_items[0];
assert_eq!(reasoning.id, "rs_hidden_1");
// Encrypted content should be present
assert_eq!(
@@ -9540,6 +9974,40 @@ mod tests {
assert_prefix_stable(&req2, &req3);
}
/// `text_summary`'s code preview truncated at BYTE 100 (`&c[..100]`) —
/// a panic on any non-ASCII boundary (CJK/emoji in interpreted code).
/// One poisoned history item then crashed every subsequent request
/// build on every backend. Truncation must be char-boundary safe.
#[test]
fn code_interpreter_summary_truncates_multibyte_code_safely() {
let item = BackendToolCallItem {
kind: BackendToolKind::CodeInterpreter(rs::CodeInterpreterToolCall {
code: Some("统计".repeat(60)),
container_id: "cont_1".to_string(),
id: "ci_1".to_string(),
outputs: None,
status: rs::CodeInterpreterToolCallStatus::Completed,
}),
};
let summary = item.text_summary();
assert!(summary.starts_with("[backend code_interpreter] 统计"));
assert!(
summary.ends_with("..."),
"long code must truncate: {summary}"
);
// ASCII shorter than the cap stays whole.
let short = BackendToolCallItem {
kind: BackendToolKind::CodeInterpreter(rs::CodeInterpreterToolCall {
code: Some("print(1)".to_string()),
container_id: "cont_1".to_string(),
id: "ci_2".to_string(),
outputs: None,
status: rs::CodeInterpreterToolCallStatus::Completed,
}),
};
assert_eq!(short.text_summary(), "[backend code_interpreter] print(1)");
}
/// `BackendToolCall` items round-trip through the wire as their
/// typed Item shape; their serialized position must be stable across
/// turns. (This is the structural analogue of the old
@@ -1054,6 +1054,10 @@ pub fn patch_reasoning_effort(body: &mut Value, effort: Option<ReasoningEffort>)
/// 2. `store` is always `false` on this backend, so reasoning continuity
/// is stateless: `include: ["reasoning.encrypted_content"]` is required
/// for the response to carry replayable encrypted reasoning.
/// 3. For the same reason, a replayed reasoning item WITHOUT
/// `encrypted_content` (captured from a stateful api.openai.com session
/// that never requested the include) references server state
/// chatgpt.com does not have — drop it rather than 400.
///
/// openai-codex-GATED at the call sites — API-key `openai` Responses
/// bodies stay byte-identical.
@@ -1104,6 +1108,18 @@ pub fn adapt_body_for_codex_backend(body: &mut Value) {
entries.push(key);
}
}
// 3. Drop reasoning items with no encrypted payload: stateless codex
// cannot resolve a bare `rs_*` reference.
if let Some(input) = body.get_mut("input").and_then(|v| v.as_array_mut()) {
input.retain(|item| {
item.get("type").and_then(|t| t.as_str()) != Some("reasoning")
|| item
.get("encrypted_content")
.and_then(|v| v.as_str())
.is_some_and(|s| !s.is_empty())
});
}
}
/// Neutralize a `reasoning.effort` echo the typed `rs` enum cannot parse
@@ -1662,6 +1678,38 @@ mod tests {
assert_eq!(body, before);
}
/// Stateless codex cannot resolve a bare `rs_*` reference: reasoning
/// input items without an encrypted payload are dropped; items WITH
/// one pass through untouched.
#[test]
fn codex_adapter_drops_reasoning_without_encrypted_payload() {
let mut body = json!({
"model": "gpt-5.2-codex",
"input": [
{"type": "message", "role": "user", "content": "q"},
{"type": "reasoning", "id": "rs_bare", "summary": []},
{"type": "reasoning", "id": "rs_full", "summary": [],
"encrypted_content": "gAAAA-blob"},
{"type": "message", "role": "assistant", "content": "a"}
]
});
adapt_body_for_codex_backend(&mut body);
let input = body["input"].as_array().unwrap();
assert_eq!(input.len(), 3, "bare rs_* item dropped: {body:#}");
assert!(
input
.iter()
.any(|i| i.get("id").and_then(|v| v.as_str()) == Some("rs_full")),
"encrypted reasoning passes through: {body:#}"
);
assert!(
!input
.iter()
.any(|i| i.get("id").and_then(|v| v.as_str()) == Some("rs_bare")),
"{body:#}"
);
}
/// No system items and no prior instructions: input untouched, no
/// empty-string instructions invented, include still requested.
#[test]
@@ -472,7 +472,12 @@ async fn responses_upgrade_roundtrips_reconstructed_reasoning_as_typed_input() {
"\n",
r#"{"type":"user","content":[{"type":"text","text":"q1"}]}"#,
"\n",
r#"{"type":"assistant","content":"a1","reasoning":{"text":"legacy kigi reasoning","encrypted":"ENC_BLOB_xyz","id":"rs_kigibuild_legacy"},"model_id":"kigi"}"#,
// model_id matches the test client's request model: this test
// covers the SAME-MODEL continuation (the byte-stable
// SGLang-prefix path). A mismatched model_id is the provenance
// gate's territory (`transform_items_for_responses`) and drops
// the reasoning by design.
r#"{"type":"assistant","content":"a1","reasoning":{"text":"legacy kigi reasoning","encrypted":"ENC_BLOB_xyz","id":"rs_kigibuild_legacy"},"model_id":"test-model"}"#,
"\n",
),
)