Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3c9380307 | ||
|
|
c950f8087c | ||
|
|
40c71a8343 | ||
|
|
a3e3973453 | ||
|
|
9cdc0ccfa3 | ||
|
|
6f9f550308 | ||
|
|
2b43f54669 | ||
|
|
6979407f22 | ||
|
|
1fa87566d9 | ||
|
|
d6e49bcc7d | ||
|
|
d2358b037c | ||
|
|
6ba24db019 | ||
|
|
10149f50dd |
@@ -220,7 +220,34 @@ edges stay deterministic Rust. The harness appends a terminal
|
||||
system prefix — gated on `SamplerConfig.anthropic_oauth` (claude-pro-max
|
||||
only), so API-key `anthropic`/`minimax` Messages requests stay
|
||||
byte-identical. Its `/v1/models` listing rides the same Bearer +
|
||||
oauth-beta headers.
|
||||
oauth-beta headers. THINKING REPLAY (`prune_replayed_thinking`,
|
||||
all Messages requests): Anthropic validates every replayed
|
||||
`thinking` block (signature model-bound, non-empty required), so
|
||||
only the final assistant message's signed thinking is replayed and
|
||||
only while its tool loop is open (request ends on the tool
|
||||
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
|
||||
@@ -237,9 +264,15 @@ edges stay deterministic Rust. The harness appends a terminal
|
||||
`PlatformId::sends_codex_responses_headers()`): headers
|
||||
`chatgpt-account-id` (per-request from the JWT), `originator codex_cli_rs`,
|
||||
`OpenAI-Beta responses=experimental`, a codex `User-Agent`; `store:false`
|
||||
is the shared Responses default. API-key `openai` Responses requests carry
|
||||
NONE of this (byte-identical). `reasoning.effort` carries the thinking
|
||||
level (incl. the codex-only `ultra`). NO websocket, NO base_instructions.
|
||||
is the shared Responses default. BODY adaptation
|
||||
(`adapt_body_for_codex_backend`, same gate): the backend 400s
|
||||
`role:system` input ("System messages are not allowed") — system items
|
||||
are hoisted into the top-level `instructions` field — and stateless
|
||||
reasoning replay requires `include:["reasoning.encrypted_content"]`.
|
||||
API-key `openai` Responses requests carry NONE of this
|
||||
(byte-identical, pinned by a control wire test). `reasoning.effort`
|
||||
carries the thinking level (incl. the codex-only `ultra`). NO
|
||||
websocket, NO base_instructions.
|
||||
CATALOG is HARDCODED (`PlatformId::hardcoded_catalog` →
|
||||
`openai_codex_wire_models`, mapped through the SAME
|
||||
`platform_wire_model_to_entry` output): exactly the 4 `visibility=list` &&
|
||||
|
||||
Generated
+62
-62
@@ -5442,7 +5442,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-acp-lib"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"async-trait",
|
||||
@@ -5456,7 +5456,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-agent"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 6.0.0",
|
||||
@@ -5486,7 +5486,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-agent-lifecycle"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"tokio",
|
||||
@@ -5495,7 +5495,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-auth"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"http 1.4.2",
|
||||
@@ -5508,7 +5508,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-bin"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -5543,7 +5543,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-chat-state"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"kigi-compaction",
|
||||
@@ -5560,7 +5560,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-codebase-graph"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"clap",
|
||||
@@ -5596,7 +5596,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-compaction"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -5609,7 +5609,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-config"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"blake3",
|
||||
@@ -5632,7 +5632,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-config-types"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"indexmap",
|
||||
@@ -5646,7 +5646,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-crash-handler"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"libc",
|
||||
@@ -5657,7 +5657,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-env"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"tracing",
|
||||
"url",
|
||||
@@ -5665,7 +5665,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-fast-worktree"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
@@ -5697,7 +5697,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-file-utils"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aws-config",
|
||||
@@ -5721,7 +5721,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-fsnotify"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"dunce",
|
||||
@@ -5742,7 +5742,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-gix-status"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"gix",
|
||||
"kigi-test-utils",
|
||||
@@ -5752,7 +5752,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-hooks"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"kigi-config",
|
||||
@@ -5771,7 +5771,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-hooks-plugins-types"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5779,7 +5779,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-http"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"kigi-auth",
|
||||
"kigi-log",
|
||||
@@ -5794,7 +5794,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-hunk-tracker"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -5815,14 +5815,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-interjection-core"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kigi-log"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -5840,7 +5840,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-markdown"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-lossy",
|
||||
@@ -5864,14 +5864,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-markdown-core"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"pulldown-cmark",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kigi-mcp"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"async-trait",
|
||||
@@ -5908,7 +5908,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-memory"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -5942,7 +5942,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-mermaid"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"fontdb",
|
||||
"image",
|
||||
@@ -5960,7 +5960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-models"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"kigi-env",
|
||||
"serde",
|
||||
@@ -5970,7 +5970,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-pager-minimal"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"crossterm",
|
||||
@@ -5987,7 +5987,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-pager-pty-harness"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
@@ -6012,7 +6012,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-pager-render"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anstyle",
|
||||
@@ -6064,7 +6064,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-paths"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"camino",
|
||||
"serde",
|
||||
@@ -6074,7 +6074,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-prompt-queue"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -6082,7 +6082,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-proto-build"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"pbjson-build",
|
||||
@@ -6093,7 +6093,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-ratatui-inline"
|
||||
version = "0.1.5"
|
||||
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.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -6131,7 +6131,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-sampler"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"async-openai",
|
||||
"async-stream",
|
||||
@@ -6154,7 +6154,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-sampling-types"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"assert_matches",
|
||||
"async-openai",
|
||||
@@ -6171,7 +6171,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-sandbox"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -6192,7 +6192,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-secrets"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde_json",
|
||||
@@ -6230,7 +6230,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-shell"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anyhow",
|
||||
@@ -6367,7 +6367,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-shell-base"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -6392,7 +6392,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-sqlite-journal"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rusqlite",
|
||||
@@ -6403,7 +6403,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-subagent-resolution"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"kigi-sampling-types",
|
||||
"kigi-tool-types",
|
||||
@@ -6418,7 +6418,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-system-power"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"zbus",
|
||||
@@ -6426,7 +6426,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-test-support"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anyhow",
|
||||
@@ -6448,7 +6448,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-test-utils"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"runfiles",
|
||||
"tracing",
|
||||
@@ -6457,11 +6457,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-token-estimation"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tool-protocol"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"kigi-tool-types",
|
||||
"serde",
|
||||
@@ -6472,7 +6472,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tool-runtime"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -6490,7 +6490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tool-types"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"minijinja",
|
||||
"schemars 1.2.1",
|
||||
@@ -6500,7 +6500,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tools"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -6577,7 +6577,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tools-api"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"kigi-proto-build",
|
||||
"kigi-tool-protocol",
|
||||
@@ -6590,11 +6590,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tracing-macros"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tty-utils"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"nix 0.30.1",
|
||||
@@ -6604,7 +6604,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-tui"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"ansi-to-tui",
|
||||
@@ -6691,7 +6691,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-update"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
@@ -6720,14 +6720,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-version"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kigi-workspace"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"agent-client-protocol",
|
||||
"anyhow",
|
||||
@@ -6806,7 +6806,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kigi-workspace-types"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"chrono",
|
||||
@@ -8840,7 +8840,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ptyctl"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
@@ -8858,7 +8858,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ptyctl-cli"
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.5"
|
||||
version = "0.1.7"
|
||||
edition = "2024"
|
||||
license = "Apache-2.0"
|
||||
|
||||
|
||||
@@ -1183,6 +1183,9 @@ impl SamplingClient {
|
||||
// old raw_output machinery.
|
||||
kigi_sampling_types::patch_reasoning_text_types(&mut request_body);
|
||||
kigi_sampling_types::patch_reasoning_effort(&mut request_body, request.reasoning_effort);
|
||||
if self.defaults.openai_codex {
|
||||
kigi_sampling_types::adapt_body_for_codex_backend(&mut request_body);
|
||||
}
|
||||
let http_request = self.post(self.endpoint("responses")).json(&request_body);
|
||||
|
||||
let response = http_request.send().await.map_err(|e| {
|
||||
@@ -1321,6 +1324,9 @@ impl SamplingClient {
|
||||
}
|
||||
kigi_sampling_types::patch_reasoning_text_types(&mut request_body);
|
||||
kigi_sampling_types::patch_reasoning_effort(&mut request_body, request.reasoning_effort);
|
||||
if self.defaults.openai_codex {
|
||||
kigi_sampling_types::adapt_body_for_codex_backend(&mut request_body);
|
||||
}
|
||||
// Fresh per attempt so signals never leak across retries; `None`
|
||||
// (check disabled) sends no header and does no peek work per event.
|
||||
let doom_loop = self
|
||||
|
||||
@@ -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)
|
||||
@@ -3201,6 +3327,8 @@ pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::Mes
|
||||
flush_assistant(&mut pending_assistant, &mut messages);
|
||||
flush_tool_results(&mut pending_tool_results, &mut messages);
|
||||
|
||||
prune_replayed_thinking(&mut messages);
|
||||
|
||||
// Attach cache_control: {type: "ephemeral"} to last system block
|
||||
if let Some(last) = system_blocks.last_mut() {
|
||||
last.cache_control = Some(CacheControl {
|
||||
@@ -3290,6 +3418,74 @@ pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::Mes
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip replayed `thinking` blocks the Anthropic Messages API would
|
||||
/// reject — keep exactly the one it requires.
|
||||
///
|
||||
/// Anthropic validates EVERY `thinking` block in the request: the
|
||||
/// signature is bound to the emitting model and must be non-empty, so
|
||||
/// history from another backend (`encrypted_content: None` replays as
|
||||
/// `signature: ""`), a Responses-API `tco_*` blob (signature bytes with no
|
||||
/// text), or a block signed by a DIFFERENT model after a mid-session
|
||||
/// `/model` switch 400s the whole request with
|
||||
/// "messages.N.content.0: Invalid `signature` in `thinking` block".
|
||||
///
|
||||
/// The API only NEEDS thinking for the ACTIVE tool-use continuation: the
|
||||
/// final assistant message whose tool_use results follow must carry its
|
||||
/// signed thinking back verbatim. Prior turns' thinking is ignored even
|
||||
/// when valid (Pi/Claude Code replay exactly this way). So: keep the
|
||||
/// final assistant message's thinking when the loop is open and the block
|
||||
/// is genuinely signed (non-empty text AND signature — an open loop can
|
||||
/// never span a model switch, so that signature is always the current
|
||||
/// model's); strip every other thinking block. An assistant message left
|
||||
/// EMPTY by the strip (a thinking-only aborted turn) is removed — the API
|
||||
/// rejects empty content arrays.
|
||||
fn prune_replayed_thinking(messages: &mut Vec<crate::messages::Message>) {
|
||||
use crate::messages::{ContentBlock, MessageContent, MessageRole};
|
||||
let last_assistant = messages
|
||||
.iter()
|
||||
.rposition(|m| matches!(m.role, MessageRole::Assistant));
|
||||
let active_tool_loop = last_assistant.is_some_and(|i| {
|
||||
let has_tool_use = matches!(
|
||||
&messages[i].content,
|
||||
MessageContent::Blocks(blocks)
|
||||
if blocks.iter().any(|b| matches!(b, ContentBlock::ToolUse { .. }))
|
||||
);
|
||||
// The loop is OPEN only while the request ends on the tool results:
|
||||
// a later plain user turn closes it (the results answered, the model
|
||||
// replied — its thinking is history the API ignores or rejects).
|
||||
let continuation = &messages[i + 1..];
|
||||
let ends_on_results = !continuation.is_empty()
|
||||
&& continuation.iter().all(|m| {
|
||||
matches!(
|
||||
&m.content,
|
||||
MessageContent::Blocks(blocks)
|
||||
if blocks.iter().any(|b| matches!(b, ContentBlock::ToolResult { .. }))
|
||||
)
|
||||
});
|
||||
has_tool_use && ends_on_results
|
||||
});
|
||||
let mut index = 0;
|
||||
messages.retain_mut(|m| {
|
||||
let i = index;
|
||||
index += 1;
|
||||
if !matches!(m.role, MessageRole::Assistant) {
|
||||
return true;
|
||||
}
|
||||
let MessageContent::Blocks(blocks) = &mut m.content else {
|
||||
return true;
|
||||
};
|
||||
let keep_thinking = active_tool_loop && Some(i) == last_assistant;
|
||||
blocks.retain(|b| match b {
|
||||
ContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
} => keep_thinking && !thinking.is_empty() && !signature.is_empty(),
|
||||
_ => true,
|
||||
});
|
||||
!blocks.is_empty()
|
||||
});
|
||||
}
|
||||
|
||||
/// Convert a MessagesResponse to a single Assistant `ConversationItem`.
|
||||
///
|
||||
/// Note: Anthropic `Thinking` blocks are dropped here because this `From`
|
||||
@@ -4551,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
|
||||
@@ -4617,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()),
|
||||
@@ -4655,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!(
|
||||
@@ -5332,6 +5836,160 @@ mod tests {
|
||||
/// messages while setting top-level `thinking: null` — the Messages API
|
||||
/// rejects this with a 400. Verify that stripped reasoning produces a
|
||||
/// valid request with no thinking blocks in messages.
|
||||
/// Collect `(message_index, thinking, signature)` for every thinking
|
||||
/// block in a built Messages request.
|
||||
fn thinking_blocks(json: &serde_json::Value) -> Vec<(usize, String, String)> {
|
||||
let mut out = Vec::new();
|
||||
for (i, m) in json["messages"].as_array().unwrap().iter().enumerate() {
|
||||
if let Some(content) = m.get("content").and_then(|c| c.as_array()) {
|
||||
for b in content {
|
||||
if b.get("type").and_then(|t| t.as_str()) == Some("thinking") {
|
||||
out.push((
|
||||
i,
|
||||
b["thinking"].as_str().unwrap_or_default().to_string(),
|
||||
b["signature"].as_str().unwrap_or_default().to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn reasoning(text: &str, encrypted: Option<&str>) -> ConversationItem {
|
||||
ConversationItem::Reasoning(rs::ReasoningItem {
|
||||
id: String::new(),
|
||||
summary: if text.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
vec![rs::SummaryPart::SummaryText(rs::SummaryTextContent {
|
||||
text: text.to_string(),
|
||||
})]
|
||||
},
|
||||
content: None,
|
||||
encrypted_content: encrypted.map(str::to_owned),
|
||||
status: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn assistant_text(text: &str) -> ConversationItem {
|
||||
ConversationItem::Assistant(AssistantItem {
|
||||
content: text.into(),
|
||||
tool_calls: vec![],
|
||||
model_id: None,
|
||||
model_fingerprint: None,
|
||||
reasoning_effort: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Anthropic validates EVERY replayed `thinking` block: an unsigned one
|
||||
/// (history synthesized by another backend replays as `signature: ""`),
|
||||
/// a Responses `tco_*` blob (signature with no text), or a block signed
|
||||
/// by a different model after a mid-session `/model` switch 400s the
|
||||
/// whole request — "messages.N.content.0: Invalid `signature` in
|
||||
/// `thinking` block". The API only NEEDS thinking for the active
|
||||
/// tool-use continuation, so outside one the builder must replay NO
|
||||
/// thinking blocks at all.
|
||||
#[test]
|
||||
fn messages_request_strips_thinking_outside_active_tool_loop() {
|
||||
let req = ConversationRequest::from_items(vec![
|
||||
ConversationItem::system("sys"),
|
||||
ConversationItem::user("q1"),
|
||||
// Cross-backend history: unsigned reasoning (the Windows repro —
|
||||
// session started on another model, then switched to Claude).
|
||||
reasoning("some thinking", None),
|
||||
assistant_text("a1"),
|
||||
ConversationItem::user("q2"),
|
||||
// Responses-API blob: signature-shaped bytes, no text.
|
||||
reasoning("", Some("tco_blob")),
|
||||
assistant_text("a2"),
|
||||
ConversationItem::user("q3"),
|
||||
// Genuinely signed — but its tool loop (none) is closed, so the
|
||||
// API ignores it when valid and 400s it after a model switch.
|
||||
reasoning("signed thinking", Some("sig-real")),
|
||||
assistant_text("a3"),
|
||||
ConversationItem::user("q4"),
|
||||
]);
|
||||
let json = serde_json::to_value(build_messages_request(&req)).unwrap();
|
||||
assert_eq!(
|
||||
thinking_blocks(&json),
|
||||
vec![],
|
||||
"no thinking block may be replayed outside an active tool loop:\n{json:#}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The active tool-use continuation is the one place Anthropic REQUIRES
|
||||
/// the signed thinking block back: the final assistant message issued
|
||||
/// tool_use and its results follow. Exactly that block is kept; a
|
||||
/// prior turn's signed thinking is still stripped.
|
||||
#[test]
|
||||
fn messages_request_keeps_signed_thinking_for_active_tool_loop() {
|
||||
let req = ConversationRequest::from_items(vec![
|
||||
ConversationItem::user("q0"),
|
||||
reasoning("old turn", Some("sig-old")),
|
||||
assistant_text("a0"),
|
||||
ConversationItem::user("q1"),
|
||||
reasoning("current turn", Some("sig-current")),
|
||||
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("tc1", "file contents"),
|
||||
]);
|
||||
let json = serde_json::to_value(build_messages_request(&req)).unwrap();
|
||||
let blocks = thinking_blocks(&json);
|
||||
assert_eq!(
|
||||
blocks.len(),
|
||||
1,
|
||||
"exactly the active loop's thinking survives:\n{json:#}"
|
||||
);
|
||||
let (msg_idx, thinking, signature) = &blocks[0];
|
||||
assert_eq!(thinking, "current turn");
|
||||
assert_eq!(signature, "sig-current");
|
||||
// It sits at content.0 of the final assistant message.
|
||||
let msg = &json["messages"].as_array().unwrap()[*msg_idx];
|
||||
assert_eq!(msg["role"], "assistant");
|
||||
assert_eq!(msg["content"][0]["type"], "thinking");
|
||||
assert!(
|
||||
msg["content"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|b| b["type"] == "tool_use"),
|
||||
"the kept thinking belongs to the tool_use turn"
|
||||
);
|
||||
}
|
||||
|
||||
/// A thinking-only assistant turn (aborted before any text/tool output)
|
||||
/// must not survive as an EMPTY assistant message after the strip —
|
||||
/// Anthropic rejects empty content arrays.
|
||||
#[test]
|
||||
fn messages_request_drops_assistant_message_emptied_by_thinking_strip() {
|
||||
let req = ConversationRequest::from_items(vec![
|
||||
ConversationItem::user("q"),
|
||||
reasoning("aborted turn thinking", Some("sig")),
|
||||
assistant_text(""),
|
||||
ConversationItem::user("follow-up"),
|
||||
]);
|
||||
let json = serde_json::to_value(build_messages_request(&req)).unwrap();
|
||||
for m in json["messages"].as_array().unwrap() {
|
||||
if let Some(content) = m.get("content").and_then(|c| c.as_array()) {
|
||||
assert!(
|
||||
!content.is_empty(),
|
||||
"no message may ship an empty content array:\n{json:#}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(thinking_blocks(&json), vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_btw_stripped_reasoning_produces_no_thinking_blocks() {
|
||||
// Simulate a conversation where the model responded with thinking.
|
||||
@@ -9316,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
|
||||
|
||||
@@ -1041,6 +1041,87 @@ pub fn patch_reasoning_effort(body: &mut Value, effort: Option<ReasoningEffort>)
|
||||
}
|
||||
}
|
||||
|
||||
/// Adapt a serialized Responses request body to the ChatGPT/Codex backend
|
||||
/// contract (`chatgpt.com/backend-api/codex/responses`) — ported from the
|
||||
/// same reference as the identity headers (official Codex CLI + Pi's
|
||||
/// `api/openai-codex-responses.ts`):
|
||||
///
|
||||
/// 1. The backend rejects `role: system` input items outright
|
||||
/// (400 `{"detail":"System messages are not allowed"}`); its system
|
||||
/// channel is the top-level `instructions` field. Hoist every system
|
||||
/// input message there (order preserved, blank-line joined, appended to
|
||||
/// any existing instructions) and remove them from `input`.
|
||||
/// 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.
|
||||
pub fn adapt_body_for_codex_backend(body: &mut Value) {
|
||||
// 1. Hoist system messages into `instructions`.
|
||||
let mut hoisted: Vec<String> = Vec::new();
|
||||
if let Some(input) = body.get_mut("input").and_then(|v| v.as_array_mut()) {
|
||||
input.retain(|item| {
|
||||
let is_system = item.get("role").and_then(|r| r.as_str()) == Some("system");
|
||||
if is_system {
|
||||
match item.get("content") {
|
||||
Some(Value::String(s)) => hoisted.push(s.clone()),
|
||||
Some(Value::Array(parts)) => {
|
||||
for p in parts {
|
||||
if let Some(t) = p.get("text").and_then(|t| t.as_str()) {
|
||||
hoisted.push(t.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
!is_system
|
||||
});
|
||||
}
|
||||
if !hoisted.is_empty() {
|
||||
let mut instructions = body
|
||||
.get("instructions")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_default();
|
||||
for part in hoisted {
|
||||
if !instructions.is_empty() {
|
||||
instructions.push_str("\n\n");
|
||||
}
|
||||
instructions.push_str(&part);
|
||||
}
|
||||
body["instructions"] = Value::String(instructions);
|
||||
}
|
||||
|
||||
// 2. Request replayable encrypted reasoning.
|
||||
let include = body
|
||||
.as_object_mut()
|
||||
.map(|obj| obj.entry("include").or_insert_with(|| Value::Array(vec![])));
|
||||
if let Some(Value::Array(entries)) = include {
|
||||
let key = Value::String("reasoning.encrypted_content".to_string());
|
||||
if !entries.contains(&key) {
|
||||
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
|
||||
/// (`max`): remove it so response deserialization succeeds. The turn's
|
||||
/// canonical effort lives in the session sampling config regardless; only
|
||||
@@ -1551,6 +1632,98 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// The Codex backend rejects `role: system` input outright
|
||||
/// (400 `{"detail":"System messages are not allowed"}`) — its system
|
||||
/// channel is the top-level `instructions` field, and stateless
|
||||
/// (`store: false`) reasoning replay needs
|
||||
/// `include: ["reasoning.encrypted_content"]`. The adapter must hoist
|
||||
/// every system item (string or parts content, order preserved),
|
||||
/// append to existing instructions, and leave the rest of the input
|
||||
/// untouched.
|
||||
#[test]
|
||||
fn codex_adapter_hoists_system_messages_and_requests_encrypted_reasoning() {
|
||||
let mut body = json!({
|
||||
"model": "gpt-5.2-codex",
|
||||
"instructions": "base",
|
||||
"input": [
|
||||
{"type": "message", "role": "system", "content": "sys head"},
|
||||
{"type": "message", "role": "user", "content": "hello"},
|
||||
{"type": "message", "role": "system", "content": [
|
||||
{"type": "input_text", "text": "memory reminder"}
|
||||
]},
|
||||
{"type": "message", "role": "assistant", "content": "hi"}
|
||||
]
|
||||
});
|
||||
adapt_body_for_codex_backend(&mut body);
|
||||
|
||||
let input = body["input"].as_array().unwrap();
|
||||
assert_eq!(input.len(), 2, "system items removed from input: {body:#}");
|
||||
assert!(
|
||||
input.iter().all(|i| i["role"] != "system"),
|
||||
"no system role may remain: {body:#}"
|
||||
);
|
||||
assert_eq!(
|
||||
body["instructions"], "base\n\nsys head\n\nmemory reminder",
|
||||
"system content hoisted into instructions, order preserved"
|
||||
);
|
||||
assert_eq!(
|
||||
body["include"],
|
||||
json!(["reasoning.encrypted_content"]),
|
||||
"stateless reasoning replay requires the include"
|
||||
);
|
||||
|
||||
// Idempotent: a second pass changes nothing.
|
||||
let before = body.clone();
|
||||
adapt_body_for_codex_backend(&mut body);
|
||||
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]
|
||||
fn codex_adapter_without_system_messages_only_adds_include() {
|
||||
let mut body = json!({
|
||||
"model": "gpt-5.2-codex",
|
||||
"input": [{"type": "message", "role": "user", "content": "q"}]
|
||||
});
|
||||
adapt_body_for_codex_backend(&mut body);
|
||||
assert!(body.get("instructions").is_none(), "{body:#}");
|
||||
assert_eq!(body["input"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(body["include"], json!(["reasoning.encrypted_content"]));
|
||||
}
|
||||
|
||||
/// String content (the only shape non-Mistral providers send) stays the
|
||||
/// answer verbatim with no thinking — byte-identical to the pre-change
|
||||
/// deserialization.
|
||||
|
||||
@@ -1881,12 +1881,15 @@ impl Config {
|
||||
.default(true)
|
||||
.resolve()
|
||||
}
|
||||
/// Graph mode (`/graph`) master switch. Default ON — the gray release
|
||||
/// (plan.md G0, `KIGI_GRAPH=1` only) is over; every install gets the
|
||||
/// same commands (its absence on non-dev machines read as a platform
|
||||
/// bug). `KIGI_GRAPH=0` remains the off-switch. Graph mode additionally
|
||||
/// requires the goal harness (nodes execute as goals), enforced at
|
||||
/// availability time, not here.
|
||||
/// Graph mode (`/graph`) master switch. Default ON in the binary: the
|
||||
/// README ships graph engineering enabled for every install, but the
|
||||
/// old `default(false)` delegated enablement to installer env plumbing
|
||||
/// (`install.sh` shell-rc export vs `install.ps1` registry write) — and
|
||||
/// Windows terminals don't pick up freshly-written registry env, so
|
||||
/// `/graph` went "missing on Windows". The product default lives HERE,
|
||||
/// not in installers. `KIGI_GRAPH=0` is the off-switch. Graph mode
|
||||
/// additionally requires the goal harness (nodes execute as goals),
|
||||
/// enforced at availability time, not here.
|
||||
pub(crate) fn resolve_graph(&self) -> Resolved<bool> {
|
||||
BoolFlag::env("KIGI_GRAPH").default(true).resolve()
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
@@ -535,13 +540,20 @@ async fn responses_upgrade_roundtrips_reconstructed_reasoning_as_typed_input() {
|
||||
/// Upgrade path, Anthropic Messages API: a legacy session whose assistant
|
||||
/// carries inline `reasoning: {text, encrypted, id}` (text = thinking,
|
||||
/// encrypted = signature) must, on load, reconstruct a sibling Reasoning
|
||||
/// item that emits a Anthropic Messages `thinking` content block (with `thinking`
|
||||
/// + `signature`) on the outgoing `/v1/messages` request.
|
||||
/// item — and when that turn is the ACTIVE tool-use continuation, its
|
||||
/// `thinking` block (text + signature) must reach the outgoing
|
||||
/// `/v1/messages` request verbatim.
|
||||
///
|
||||
/// Outside an active tool loop the block must be STRIPPED: Anthropic
|
||||
/// validates every replayed signature (model-bound), so replaying stale
|
||||
/// thinking is exactly what 400'd with "Invalid `signature` in `thinking`
|
||||
/// block" after cross-model histories (see `prune_replayed_thinking`).
|
||||
#[tokio::test]
|
||||
async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
|
||||
// 1. Seed a legacy Anthropic Messages-origin chat_history.jsonl. Anthropic Messages
|
||||
// thinking blocks never carried an id (stream/messages.rs sets
|
||||
// id=""), and the signature lives in `encrypted`.
|
||||
async fn messages_upgrade_replays_reconstructed_thinking_only_in_active_tool_loop() {
|
||||
// 1. Seed a legacy Anthropic Messages-origin chat_history.jsonl whose
|
||||
// assistant turn issued a tool call (thinking blocks never carried an
|
||||
// id — stream/messages.rs sets id="" — and the signature lives in
|
||||
// `encrypted`). The pending tool_result makes this the active loop.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("chat_history.jsonl"),
|
||||
@@ -550,7 +562,9 @@ async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
|
||||
"\n",
|
||||
r#"{"type":"user","content":[{"type":"text","text":"q1"}]}"#,
|
||||
"\n",
|
||||
r#"{"type":"assistant","content":"a1","reasoning":{"text":"legacy anthropic thinking","encrypted":"SIGNATURE_abc","id":""},"model_id":"kigi-4.5"}"#,
|
||||
r#"{"type":"assistant","content":"a1","reasoning":{"text":"legacy anthropic thinking","encrypted":"SIGNATURE_abc","id":""},"model_id":"kigi-4.5","tool_calls":[{"id":"tc1","name":"read_file","arguments":"{}"}]}"#,
|
||||
"\n",
|
||||
r#"{"type":"tool_result","tool_call_id":"tc1","content":"file contents"}"#,
|
||||
"\n",
|
||||
),
|
||||
)
|
||||
@@ -558,7 +572,7 @@ async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
|
||||
|
||||
// 2. Load + upgrade.
|
||||
let adapter = JsonlStorageAdapter::with_root(dir.path().to_path_buf());
|
||||
let mut items = adapter.load_chat_history_from_dir(dir.path()).unwrap();
|
||||
let items = adapter.load_chat_history_from_dir(dir.path()).unwrap();
|
||||
assert!(
|
||||
items
|
||||
.iter()
|
||||
@@ -566,20 +580,18 @@ async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
|
||||
"legacy inline reasoning must be reconstructed as a sibling on load, got {items:?}"
|
||||
);
|
||||
|
||||
// 3. Continue and send over the Messages API, capturing the body.
|
||||
items.push(ConversationItem::user("q2"));
|
||||
|
||||
// 3. Send the tool-loop continuation over the Messages API.
|
||||
let server = MockInferenceServer::start().await.unwrap();
|
||||
server.set_response("ok");
|
||||
let client = create_test_client(&server.url(), ApiBackend::Messages);
|
||||
|
||||
let _ = client
|
||||
.conversation_collect(ConversationRequest::from_items(items))
|
||||
.conversation_collect(ConversationRequest::from_items(items.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 4. The reconstructed reasoning must emit a Anthropic Messages `thinking`
|
||||
// content block carrying the thinking text + signature.
|
||||
// 4. The active loop's reconstructed reasoning must emit an Anthropic
|
||||
// `thinking` content block carrying the thinking text + signature.
|
||||
let body = server.request_bodies().pop().unwrap();
|
||||
let messages = body.get("messages").unwrap().as_array().unwrap();
|
||||
let thinking_block = messages
|
||||
@@ -593,7 +605,7 @@ async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
|
||||
})
|
||||
.find(|b| b.get("type").and_then(Value::as_str) == Some("thinking"))
|
||||
.unwrap_or_else(|| {
|
||||
panic!("reconstructed reasoning must emit an Anthropic thinking block; messages: {messages:#?}")
|
||||
panic!("active-loop reasoning must emit an Anthropic thinking block; messages: {messages:#?}")
|
||||
});
|
||||
assert_eq!(
|
||||
thinking_block.get("thinking").and_then(Value::as_str),
|
||||
@@ -605,6 +617,25 @@ async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
|
||||
Some("SIGNATURE_abc"),
|
||||
"signature (encrypted) preserved — required to reuse the thought server-side"
|
||||
);
|
||||
|
||||
// 5. A follow-up user turn CLOSES the loop: the same history plus a new
|
||||
// user message must replay NO thinking block at all.
|
||||
let mut closed = items;
|
||||
closed.push(ConversationItem::user("q2"));
|
||||
let _ = client
|
||||
.conversation_collect(ConversationRequest::from_items(closed))
|
||||
.await
|
||||
.unwrap();
|
||||
let body = server.request_bodies().pop().unwrap();
|
||||
let any_thinking = body["messages"].as_array().unwrap().iter().any(|m| {
|
||||
m.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|c| c.iter().any(|b| b["type"] == "thinking"))
|
||||
});
|
||||
assert!(
|
||||
!any_thinking,
|
||||
"stale thinking must be stripped outside the active tool loop; body: {body:#?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -1443,3 +1474,79 @@ async fn test_chat_completions_backend_hits_chat_endpoint_not_responses() {
|
||||
"Should NOT have called /v1/responses"
|
||||
);
|
||||
}
|
||||
|
||||
/// ChatGPT/Codex backend body contract (`openai_codex = true`): the
|
||||
/// `/codex/responses` endpoint rejects `role: system` input outright
|
||||
/// (400 {"detail":"System messages are not allowed"}) — system content
|
||||
/// must ride the top-level `instructions` field, and stateless reasoning
|
||||
/// replay needs `include: ["reasoning.encrypted_content"]`. Ported from
|
||||
/// the official Codex CLI + Pi's api/openai-codex-responses.ts, like the
|
||||
/// identity headers.
|
||||
#[tokio::test]
|
||||
async fn codex_responses_body_hoists_system_into_instructions() {
|
||||
let server = MockInferenceServer::start().await.unwrap();
|
||||
server.set_response("ok");
|
||||
let mut config = common::test_sampler_config(&server.url(), ApiBackend::Responses, &[]);
|
||||
config.openai_codex = true;
|
||||
let client = Client::new(config).unwrap();
|
||||
|
||||
let _ = client
|
||||
.conversation_collect(ConversationRequest::from_items(vec![
|
||||
ConversationItem::system("You are Kigi."),
|
||||
ConversationItem::user("test"),
|
||||
]))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = server.request_bodies().pop().unwrap();
|
||||
let input = body["input"].as_array().unwrap();
|
||||
assert!(
|
||||
input
|
||||
.iter()
|
||||
.all(|i| i.get("role").and_then(Value::as_str) != Some("system")),
|
||||
"codex backend must never receive system-role input: {body:#?}"
|
||||
);
|
||||
assert_eq!(
|
||||
body["instructions"].as_str(),
|
||||
Some("You are Kigi."),
|
||||
"system prompt must ride the instructions field: {body:#?}"
|
||||
);
|
||||
assert_eq!(
|
||||
body["include"],
|
||||
serde_json::json!(["reasoning.encrypted_content"]),
|
||||
"stateless reasoning replay requires the include: {body:#?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Control: the API-key `openai` Responses path (`openai_codex = false`)
|
||||
/// stays byte-compatible — system-role input preserved, no codex fields.
|
||||
#[tokio::test]
|
||||
async fn plain_responses_body_keeps_system_role_input() {
|
||||
let server = MockInferenceServer::start().await.unwrap();
|
||||
server.set_response("ok");
|
||||
let client = create_test_client(&server.url(), ApiBackend::Responses);
|
||||
|
||||
let _ = client
|
||||
.conversation_collect(ConversationRequest::from_items(vec![
|
||||
ConversationItem::system("You are Kigi."),
|
||||
ConversationItem::user("test"),
|
||||
]))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = server.request_bodies().pop().unwrap();
|
||||
assert!(
|
||||
body["input"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|i| i.get("role").and_then(Value::as_str) == Some("system")),
|
||||
"api-key openai keeps system-role input: {body:#?}"
|
||||
);
|
||||
assert!(
|
||||
body.get("instructions")
|
||||
.map(|v| v.is_null())
|
||||
.unwrap_or(true),
|
||||
"no instructions hoist outside codex: {body:#?}"
|
||||
);
|
||||
}
|
||||
|
||||
+7
-9
@@ -149,15 +149,13 @@ try {
|
||||
Write-Host "Run 'kigi' to get started."
|
||||
}
|
||||
|
||||
# Graph engineering ships enabled by default. Respect an explicit
|
||||
# user choice: only set the variable when it is not already defined
|
||||
# (so a persisted opt-out of "0" survives reinstalls).
|
||||
$Graph = [Environment]::GetEnvironmentVariable("KIGI_GRAPH", "User")
|
||||
if ($null -eq $Graph -or $Graph -eq "") {
|
||||
[Environment]::SetEnvironmentVariable("KIGI_GRAPH", "1", "User")
|
||||
Write-Host "Enabled graph engineering (KIGI_GRAPH=1)."
|
||||
Write-Host "Disable: [Environment]::SetEnvironmentVariable('KIGI_GRAPH','0','User')"
|
||||
}
|
||||
# Graph engineering is enabled by default IN THE BINARY (resolve_graph
|
||||
# defaults true) — no environment plumbing needed. The installer used
|
||||
# to persist KIGI_GRAPH=1 into the User registry env, but running
|
||||
# terminals (and new tabs of an open Windows Terminal) never pick up
|
||||
# freshly-written registry variables, which made /graph "missing on
|
||||
# Windows" while the shell-rc path worked on macOS/Linux. Opt out any
|
||||
# time with: [Environment]::SetEnvironmentVariable('KIGI_GRAPH','0','User')
|
||||
} finally {
|
||||
Remove-Item -Path $TmpDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
+4
-8
@@ -209,7 +209,6 @@ case "${SHELL:-}" in
|
||||
*/zsh)
|
||||
RC_FILE="${ZDOTDIR:-$HOME}/.zshrc"
|
||||
PATH_LINE="export PATH=\"$BIN_DIR:\$PATH\""
|
||||
GRAPH_LINE="export KIGI_GRAPH=1"
|
||||
;;
|
||||
*/bash)
|
||||
# macOS login shells read ~/.bash_profile; Linux reads ~/.bashrc.
|
||||
@@ -219,7 +218,6 @@ case "${SHELL:-}" in
|
||||
RC_FILE="$HOME/.bashrc"
|
||||
fi
|
||||
PATH_LINE="export PATH=\"$BIN_DIR:\$PATH\""
|
||||
GRAPH_LINE="export KIGI_GRAPH=1"
|
||||
;;
|
||||
*/fish)
|
||||
# fish_add_path in config.fish is fish's own idempotent way
|
||||
@@ -228,12 +226,10 @@ case "${SHELL:-}" in
|
||||
mkdir -p "$FISH_CONF_DIR"
|
||||
RC_FILE="$FISH_CONF_DIR/config.fish"
|
||||
PATH_LINE="fish_add_path $BIN_DIR"
|
||||
GRAPH_LINE="set -gx KIGI_GRAPH 1"
|
||||
;;
|
||||
*)
|
||||
RC_FILE="$HOME/.profile"
|
||||
PATH_LINE="export PATH=\"$BIN_DIR:\$PATH\""
|
||||
GRAPH_LINE="export KIGI_GRAPH=1"
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -247,8 +243,8 @@ case ":$PATH:" in
|
||||
;;
|
||||
esac
|
||||
|
||||
# Graph engineering ships enabled by default. The KIGI_GRAPH guard makes
|
||||
# this idempotent AND respects an explicit user opt-out (an existing
|
||||
# `export KIGI_GRAPH=0` line is left untouched). Disable any time with:
|
||||
# Graph engineering is enabled by default IN THE BINARY (resolve_graph
|
||||
# defaults true) — the installer no longer writes KIGI_GRAPH=1 into shell
|
||||
# rc files (per-shell env plumbing was fragile and diverged per platform).
|
||||
# Disable any time with:
|
||||
# echo 'export KIGI_GRAPH=0' >> <your shell rc>
|
||||
persist_line "$RC_FILE" "$GRAPH_LINE" "KIGI_GRAPH" "graph engineering (KIGI_GRAPH=1)"
|
||||
|
||||
Reference in New Issue
Block a user