Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff0fb56c67 | ||
|
|
2524c33a5b | ||
|
|
301eb61de4 | ||
|
|
d3c9380307 | ||
|
|
c950f8087c | ||
|
|
40c71a8343 | ||
|
|
a3e3973453 | ||
|
|
9cdc0ccfa3 | ||
|
|
6f9f550308 | ||
|
|
2b43f54669 | ||
|
|
6979407f22 | ||
|
|
1fa87566d9 |
@@ -228,6 +228,37 @@ edges stay deterministic Rust. The harness appends a terminal
|
|||||||
results); everything else — unsigned cross-backend history, `tco_*`
|
results); everything else — unsigned cross-backend history, `tco_*`
|
||||||
Responses blobs, stale-model blocks — is stripped, or the request
|
Responses blobs, stale-model blocks — is stripped, or the request
|
||||||
400s "Invalid `signature` in `thinking` block".
|
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.
|
||||||
|
- ChatCompletions dialect selection: registry platforms declare
|
||||||
|
`chat_compat` explicitly; BYOK/custom entries default to `Passthrough`
|
||||||
|
(vanilla OpenAI semantics) EXCEPT entries pointed at the house/Kimi
|
||||||
|
coding endpoint, which keep the `Kimi` dialect (base-url detection —
|
||||||
|
Pi-style quirk sniffing). `ChatCompat::Mistral` = StrictOpenAi plus the
|
||||||
|
exactly-nine-`[a-zA-Z0-9]` tool-call id normalizer
|
||||||
|
(`normalize_mistral_tool_call_ids`, deterministic FNV-1a→base36, one
|
||||||
|
map for call+result; persisted `mistral` values resolve here). Chat
|
||||||
|
tool messages are TEXT-ONLY: tool-result images batch into one
|
||||||
|
synthetic user message after the consecutive tool-result run
|
||||||
|
(`conversation_to_chat_messages`).
|
||||||
- `openai-codex` (ChatGPT Plus/Pro, `scope_key oauth/openai-codex`, port
|
- `openai-codex` (ChatGPT Plus/Pro, `scope_key oauth/openai-codex`, port
|
||||||
1455 `/auth/callback`, FORM body, authorize+token host `auth.openai.com`,
|
1455 `/auth/callback`, FORM body, authorize+token host `auth.openai.com`,
|
||||||
client `app_EMoam…`, scope `openid profile email offline_access`, the 3
|
client `app_EMoam…`, scope `openid profile email offline_access`, the 3
|
||||||
|
|||||||
Generated
+62
-62
@@ -5442,7 +5442,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-acp-lib"
|
name = "kigi-acp-lib"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -5456,7 +5456,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-agent"
|
name = "kigi-agent"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs 6.0.0",
|
"dirs 6.0.0",
|
||||||
@@ -5486,7 +5486,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-agent-lifecycle"
|
name = "kigi-agent-lifecycle"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -5495,7 +5495,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-auth"
|
name = "kigi-auth"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"http 1.4.2",
|
"http 1.4.2",
|
||||||
@@ -5508,7 +5508,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-bin"
|
name = "kigi-bin"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -5543,7 +5543,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-chat-state"
|
name = "kigi-chat-state"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"kigi-compaction",
|
"kigi-compaction",
|
||||||
@@ -5560,7 +5560,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-codebase-graph"
|
name = "kigi-codebase-graph"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -5596,7 +5596,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-compaction"
|
name = "kigi-compaction"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -5609,7 +5609,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-config"
|
name = "kigi-config"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"blake3",
|
"blake3",
|
||||||
@@ -5632,7 +5632,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-config-types"
|
name = "kigi-config-types"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
@@ -5646,7 +5646,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-crash-handler"
|
name = "kigi-crash-handler"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"backtrace",
|
"backtrace",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -5657,7 +5657,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-env"
|
name = "kigi-env"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
@@ -5665,7 +5665,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-fast-worktree"
|
name = "kigi-fast-worktree"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -5697,7 +5697,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-file-utils"
|
name = "kigi-file-utils"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
@@ -5721,7 +5721,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-fsnotify"
|
name = "kigi-fsnotify"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"criterion",
|
"criterion",
|
||||||
"dunce",
|
"dunce",
|
||||||
@@ -5742,7 +5742,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-gix-status"
|
name = "kigi-gix-status"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gix",
|
"gix",
|
||||||
"kigi-test-utils",
|
"kigi-test-utils",
|
||||||
@@ -5752,7 +5752,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-hooks"
|
name = "kigi-hooks"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"kigi-config",
|
"kigi-config",
|
||||||
@@ -5771,7 +5771,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-hooks-plugins-types"
|
name = "kigi-hooks-plugins-types"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -5779,7 +5779,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-http"
|
name = "kigi-http"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-auth",
|
"kigi-auth",
|
||||||
"kigi-log",
|
"kigi-log",
|
||||||
@@ -5794,7 +5794,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-hunk-tracker"
|
name = "kigi-hunk-tracker"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dunce",
|
"dunce",
|
||||||
@@ -5815,14 +5815,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-interjection-core"
|
name = "kigi-interjection-core"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-log"
|
name = "kigi-log"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -5840,7 +5840,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-markdown"
|
name = "kigi-markdown"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"anstyle-lossy",
|
"anstyle-lossy",
|
||||||
@@ -5864,14 +5864,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-markdown-core"
|
name = "kigi-markdown-core"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-mcp"
|
name = "kigi-mcp"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -5908,7 +5908,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-memory"
|
name = "kigi-memory"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
@@ -5942,7 +5942,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-mermaid"
|
name = "kigi-mermaid"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fontdb",
|
"fontdb",
|
||||||
"image",
|
"image",
|
||||||
@@ -5960,7 +5960,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-models"
|
name = "kigi-models"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-env",
|
"kigi-env",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -5970,7 +5970,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-pager-minimal"
|
name = "kigi-pager-minimal"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
@@ -5987,7 +5987,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-pager-pty-harness"
|
name = "kigi-pager-pty-harness"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alacritty_terminal",
|
"alacritty_terminal",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -6012,7 +6012,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-pager-render"
|
name = "kigi-pager-render"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
@@ -6064,7 +6064,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-paths"
|
name = "kigi-paths"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"camino",
|
"camino",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -6074,7 +6074,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-prompt-queue"
|
name = "kigi-prompt-queue"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -6082,7 +6082,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-proto-build"
|
name = "kigi-proto-build"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"pbjson-build",
|
"pbjson-build",
|
||||||
@@ -6093,7 +6093,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-ratatui-inline"
|
name = "kigi-ratatui-inline"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ansi-width",
|
"ansi-width",
|
||||||
"anstyle-parse 0.2.7",
|
"anstyle-parse 0.2.7",
|
||||||
@@ -6110,7 +6110,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-ratatui-textarea"
|
name = "kigi-ratatui-textarea"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arboard",
|
"arboard",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -6131,7 +6131,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-sampler"
|
name = "kigi-sampler"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-openai",
|
"async-openai",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@@ -6154,7 +6154,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-sampling-types"
|
name = "kigi-sampling-types"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"assert_matches",
|
"assert_matches",
|
||||||
"async-openai",
|
"async-openai",
|
||||||
@@ -6171,7 +6171,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-sandbox"
|
name = "kigi-sandbox"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -6192,7 +6192,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-secrets"
|
name = "kigi-secrets"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"regex",
|
"regex",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -6230,7 +6230,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-shell"
|
name = "kigi-shell"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -6367,7 +6367,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-shell-base"
|
name = "kigi-shell-base"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -6392,7 +6392,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-sqlite-journal"
|
name = "kigi-sqlite-journal"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
@@ -6403,7 +6403,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-subagent-resolution"
|
name = "kigi-subagent-resolution"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-sampling-types",
|
"kigi-sampling-types",
|
||||||
"kigi-tool-types",
|
"kigi-tool-types",
|
||||||
@@ -6418,7 +6418,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-system-power"
|
name = "kigi-system-power"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
"zbus",
|
"zbus",
|
||||||
@@ -6426,7 +6426,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-test-support"
|
name = "kigi-test-support"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -6448,7 +6448,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-test-utils"
|
name = "kigi-test-utils"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"runfiles",
|
"runfiles",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -6457,11 +6457,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-token-estimation"
|
name = "kigi-token-estimation"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tool-protocol"
|
name = "kigi-tool-protocol"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-tool-types",
|
"kigi-tool-types",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -6472,7 +6472,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tool-runtime"
|
name = "kigi-tool-runtime"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -6490,7 +6490,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tool-types"
|
name = "kigi-tool-types"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"minijinja",
|
"minijinja",
|
||||||
"schemars 1.2.1",
|
"schemars 1.2.1",
|
||||||
@@ -6500,7 +6500,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tools"
|
name = "kigi-tools"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
@@ -6577,7 +6577,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tools-api"
|
name = "kigi-tools-api"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kigi-proto-build",
|
"kigi-proto-build",
|
||||||
"kigi-tool-protocol",
|
"kigi-tool-protocol",
|
||||||
@@ -6590,11 +6590,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tracing-macros"
|
name = "kigi-tracing-macros"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tty-utils"
|
name = "kigi-tty-utils"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"nix 0.30.1",
|
"nix 0.30.1",
|
||||||
@@ -6604,7 +6604,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-tui"
|
name = "kigi-tui"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"ansi-to-tui",
|
"ansi-to-tui",
|
||||||
@@ -6691,7 +6691,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-update"
|
name = "kigi-update"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dunce",
|
"dunce",
|
||||||
@@ -6720,14 +6720,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-version"
|
name = "kigi-version"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-workspace"
|
name = "kigi-workspace"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -6806,7 +6806,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kigi-workspace-types"
|
name = "kigi-workspace-types"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -8840,7 +8840,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ptyctl"
|
name = "ptyctl"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alacritty_terminal",
|
"alacritty_terminal",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -8858,7 +8858,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ptyctl-cli"
|
name = "ptyctl-cli"
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
|||||||
+1
-1
@@ -76,7 +76,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.6"
|
version = "0.1.8"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|
||||||
|
|||||||
@@ -70,9 +70,12 @@ pub enum PlatformChatCompat {
|
|||||||
Kimi,
|
Kimi,
|
||||||
DeepSeek,
|
DeepSeek,
|
||||||
Passthrough,
|
Passthrough,
|
||||||
/// Strict OpenAI-compatible validator (Mistral, Cerebras) — strips
|
/// Strict OpenAI-compatible validator (Cerebras, NVIDIA) — strips
|
||||||
/// `stream_options` and private fields.
|
/// `stream_options` and private fields.
|
||||||
StrictOpenAi,
|
StrictOpenAi,
|
||||||
|
/// Mistral: StrictOpenAi plus its exactly-9-alphanumeric tool-call id
|
||||||
|
/// contract (foreign/OpenAI-style ids are deterministically remapped).
|
||||||
|
Mistral,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How a platform's API key rides requests (listing, validation, inference).
|
/// How a platform's API key rides requests (listing, validation, inference).
|
||||||
@@ -599,10 +602,12 @@ const MISTRAL_SPEC: PlatformSpec = PlatformSpec {
|
|||||||
wire_serves_metadata: false,
|
wire_serves_metadata: false,
|
||||||
wire_api: PlatformWireApi::ChatCompletions,
|
wire_api: PlatformWireApi::ChatCompletions,
|
||||||
listing: ListingDialect::OpenAi,
|
listing: ListingDialect::OpenAi,
|
||||||
// Mistral's strict validator 422s on `stream_options`, and its reasoning
|
// Mistral's strict validator 422s on `stream_options`, its reasoning
|
||||||
// models return array content — the StrictOpenAi dialect strips
|
// models return array content, and tool-call ids must be EXACTLY nine
|
||||||
// stream_options; the response deserializer handles arrays universally.
|
// `[a-zA-Z0-9]` chars — the Mistral dialect strips stream_options and
|
||||||
chat_compat: PlatformChatCompat::StrictOpenAi,
|
// deterministically remaps non-conforming (foreign/OpenAI-style) ids;
|
||||||
|
// the response deserializer handles arrays universally.
|
||||||
|
chat_compat: PlatformChatCompat::Mistral,
|
||||||
key_header: PlatformKeyHeader::Bearer,
|
key_header: PlatformKeyHeader::Bearer,
|
||||||
// The listing carries embed/moderation/OCR entries; keep tool-calling
|
// The listing carries embed/moderation/OCR entries; keep tool-calling
|
||||||
// chat models only.
|
// chat models only.
|
||||||
|
|||||||
@@ -51,6 +51,91 @@ pub(crate) fn adapt_chat_completions_body_for(
|
|||||||
strip_kigi_private_message_fields(body);
|
strip_kigi_private_message_fields(body);
|
||||||
strip_stream_options(body);
|
strip_stream_options(body);
|
||||||
}
|
}
|
||||||
|
kigi_sampling_types::ChatCompat::Mistral => {
|
||||||
|
strip_kigi_private_message_fields(body);
|
||||||
|
strip_stream_options(body);
|
||||||
|
normalize_mistral_tool_call_ids(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mistral's validator requires tool-call ids of EXACTLY nine
|
||||||
|
/// `[a-zA-Z0-9]` characters. Foreign backends mint arbitrary ids
|
||||||
|
/// (OpenAI `call_…`, UUIDs, Anthropic `toolu_…`), so non-conforming ids
|
||||||
|
/// are remapped deterministically — ported from Pi's
|
||||||
|
/// `mistral-conversations.ts` normalizer: strip non-alphanumerics, keep
|
||||||
|
/// the id when the result is already exactly nine chars, otherwise hash
|
||||||
|
/// (FNV-1a → base36) down to nine, retrying with an attempt suffix on
|
||||||
|
/// collision. ONE map serves `tool_calls[].id` and `tool_call_id` alike,
|
||||||
|
/// so call/result pairing survives.
|
||||||
|
fn normalize_mistral_tool_call_ids(body: &mut Value) {
|
||||||
|
const LEN: usize = 9;
|
||||||
|
|
||||||
|
fn derive(id: &str, attempt: u32) -> String {
|
||||||
|
let normalized: String = id.chars().filter(char::is_ascii_alphanumeric).collect();
|
||||||
|
if attempt == 0 && normalized.len() == LEN {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
let seed_base = if normalized.is_empty() {
|
||||||
|
id
|
||||||
|
} else {
|
||||||
|
&normalized
|
||||||
|
};
|
||||||
|
let seed = if attempt == 0 {
|
||||||
|
seed_base.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{seed_base}:{attempt}")
|
||||||
|
};
|
||||||
|
// FNV-1a (stable across builds, unlike std's DefaultHasher) → base36.
|
||||||
|
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
||||||
|
for b in seed.bytes() {
|
||||||
|
hash ^= u64::from(b);
|
||||||
|
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||||
|
}
|
||||||
|
let mut out = String::with_capacity(LEN);
|
||||||
|
let digits = b"0123456789abcdefghijklmnopqrstuvwxyz";
|
||||||
|
let mut h = hash;
|
||||||
|
while out.len() < LEN {
|
||||||
|
out.push(digits[(h % 36) as usize] as char);
|
||||||
|
h = h / 36 + 1; // +1 keeps the stream from collapsing to zeros
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(messages) = body.get_mut("messages").and_then(|m| m.as_array_mut()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut forward: std::collections::HashMap<String, String> = std::collections::HashMap::new();
|
||||||
|
let mut taken: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||||
|
let mut normalize = |id: &str| -> String {
|
||||||
|
if let Some(mapped) = forward.get(id) {
|
||||||
|
return mapped.clone();
|
||||||
|
}
|
||||||
|
let mut attempt = 0;
|
||||||
|
loop {
|
||||||
|
let candidate = derive(id, attempt);
|
||||||
|
if taken.insert(candidate.clone()) {
|
||||||
|
forward.insert(id.to_string(), candidate.clone());
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
attempt += 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for message in messages.iter_mut() {
|
||||||
|
if let Some(tool_calls) = message.get_mut("tool_calls").and_then(|t| t.as_array_mut()) {
|
||||||
|
for tc in tool_calls {
|
||||||
|
if let Some(id) = tc.get("id").and_then(|v| v.as_str()).map(str::to_owned) {
|
||||||
|
tc["id"] = Value::String(normalize(&id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(id) = message
|
||||||
|
.get("tool_call_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(str::to_owned)
|
||||||
|
{
|
||||||
|
message["tool_call_id"] = Value::String(normalize(&id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -666,4 +751,56 @@ mod tests {
|
|||||||
assert_eq!(props["num"]["type"], json!("number"));
|
assert_eq!(props["num"]["type"], json!("number"));
|
||||||
assert_eq!(props["free"]["type"], json!("string"));
|
assert_eq!(props["free"]["type"], json!("string"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mistral dialect: exactly-nine `[a-zA-Z0-9]` tool-call ids. A
|
||||||
|
/// conforming id survives; foreign ids (OpenAI `call_…`, UUIDs) remap
|
||||||
|
/// deterministically; the SAME map serves `tool_calls[].id` and
|
||||||
|
/// `tool_call_id`, so pairing survives; distinct inputs never collide.
|
||||||
|
#[test]
|
||||||
|
fn mistral_dialect_normalizes_tool_call_ids_symmetrically() {
|
||||||
|
let mut body = serde_json::json!({
|
||||||
|
"messages": [
|
||||||
|
{"role": "assistant", "tool_calls": [
|
||||||
|
{"id": "abc123XYZ", "type": "function", "function": {"name": "a", "arguments": "{}"}},
|
||||||
|
{"id": "call_0123456789abcdef", "type": "function", "function": {"name": "b", "arguments": "{}"}}
|
||||||
|
]},
|
||||||
|
{"role": "tool", "tool_call_id": "abc123XYZ", "content": "r1"},
|
||||||
|
{"role": "tool", "tool_call_id": "call_0123456789abcdef", "content": "r2"},
|
||||||
|
],
|
||||||
|
"stream_options": {"include_usage": true}
|
||||||
|
});
|
||||||
|
adapt_chat_completions_body_for(kigi_sampling_types::ChatCompat::Mistral, &mut body);
|
||||||
|
|
||||||
|
let msgs = body["messages"].as_array().unwrap();
|
||||||
|
let ids: Vec<String> = msgs[0]["tool_calls"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|tc| tc["id"].as_str().unwrap().to_string())
|
||||||
|
.collect();
|
||||||
|
// Conforming id kept verbatim.
|
||||||
|
assert_eq!(ids[0], "abc123XYZ");
|
||||||
|
// Foreign id remapped to exactly nine alphanumerics.
|
||||||
|
assert_eq!(ids[1].len(), 9, "{ids:?}");
|
||||||
|
assert!(ids[1].chars().all(|ch| ch.is_ascii_alphanumeric()));
|
||||||
|
assert_ne!(ids[0], ids[1], "distinct inputs must not collide");
|
||||||
|
// Results carry the SAME mapped ids.
|
||||||
|
assert_eq!(msgs[1]["tool_call_id"].as_str().unwrap(), ids[0]);
|
||||||
|
assert_eq!(msgs[2]["tool_call_id"].as_str().unwrap(), ids[1]);
|
||||||
|
// StrictOpenAi base behavior rides along.
|
||||||
|
assert!(body.get("stream_options").is_none());
|
||||||
|
|
||||||
|
// Determinism: the same foreign id maps identically in a fresh body.
|
||||||
|
let mut body2 = serde_json::json!({
|
||||||
|
"messages": [
|
||||||
|
{"role": "tool", "tool_call_id": "call_0123456789abcdef", "content": "r"}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
adapt_chat_completions_body_for(kigi_sampling_types::ChatCompat::Mistral, &mut body2);
|
||||||
|
assert_eq!(
|
||||||
|
body2["messages"][0]["tool_call_id"].as_str().unwrap(),
|
||||||
|
ids[1],
|
||||||
|
"remap must be deterministic across requests (prefix-cache stability)"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -319,15 +319,15 @@ impl BackendToolCallItem {
|
|||||||
format!("[backend x_search] {}({})", ct.name, ct.input)
|
format!("[backend x_search] {}({})", ct.name, ct.input)
|
||||||
}
|
}
|
||||||
BackendToolKind::CodeInterpreter(ci) => {
|
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
|
let code_preview = ci
|
||||||
.code
|
.code
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|c| {
|
.map(|c| match c.char_indices().nth(100) {
|
||||||
if c.len() > 100 {
|
Some((byte_idx, _)) => format!("{}...", &c[..byte_idx]),
|
||||||
format!("{}...", &c[..100])
|
None => c.to_string(),
|
||||||
} else {
|
|
||||||
c.to_string()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
format!("[backend code_interpreter] {code_preview}")
|
format!("[backend code_interpreter] {code_preview}")
|
||||||
@@ -1773,31 +1773,18 @@ pub fn conversation_item_to_chat_message(item: ConversationItem) -> ChatRequestM
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ConversationItem::ToolResult(t) => {
|
ConversationItem::ToolResult(t) => {
|
||||||
if t.images.is_empty() {
|
// Tool messages are TEXT-ONLY on the OpenAI chat wire (the spec
|
||||||
ChatRequestMessage::tool(t.tool_call_id, t.content.as_ref().to_owned())
|
// allows string/text-parts; image parts 400 on strict
|
||||||
|
// validators). Images ride a synthetic user message appended
|
||||||
|
// after the consecutive tool-result run by
|
||||||
|
// `conversation_to_chat_messages` — the Pi `openai-completions`
|
||||||
|
// relocation. An image-only result gets a pointer placeholder.
|
||||||
|
let text = if t.content.is_empty() && !t.images.is_empty() {
|
||||||
|
"(see attached image)".to_string()
|
||||||
} else {
|
} else {
|
||||||
let mut blocks = vec![ChatContentBlock::Text {
|
t.content.as_ref().to_owned()
|
||||||
text: t.content.as_ref().to_owned(),
|
};
|
||||||
}];
|
ChatRequestMessage::tool(t.tool_call_id, text)
|
||||||
for img in t.images {
|
|
||||||
if let ContentPart::Image { url } = img {
|
|
||||||
blocks.push(ChatContentBlock::ImageUrl {
|
|
||||||
image_url: ImageUrl {
|
|
||||||
url: url.as_ref().to_owned(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ChatRequestMessage {
|
|
||||||
role: Role::Tool,
|
|
||||||
content: MessageContent::Blocks(blocks),
|
|
||||||
name: None,
|
|
||||||
tool_calls: Vec::new(),
|
|
||||||
tool_call_id: Some(t.tool_call_id),
|
|
||||||
model_id: None,
|
|
||||||
reasoning_content: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Backend tool calls have no Chat Completions equivalent.
|
// Backend tool calls have no Chat Completions equivalent.
|
||||||
// Emit a synthetic assistant message so the model sees context
|
// Emit a synthetic assistant message so the model sees context
|
||||||
@@ -1839,16 +1826,44 @@ pub fn conversation_item_to_chat_message(item: ConversationItem) -> ChatRequestM
|
|||||||
pub fn conversation_to_chat_messages(items: Vec<ConversationItem>) -> Vec<ChatRequestMessage> {
|
pub fn conversation_to_chat_messages(items: Vec<ConversationItem>) -> Vec<ChatRequestMessage> {
|
||||||
let mut out: Vec<ChatRequestMessage> = Vec::with_capacity(items.len());
|
let mut out: Vec<ChatRequestMessage> = Vec::with_capacity(items.len());
|
||||||
let mut pending_reasoning: Vec<String> = Vec::new();
|
let mut pending_reasoning: Vec<String> = Vec::new();
|
||||||
|
// Images from the current consecutive tool-result run. Tool messages
|
||||||
|
// are text-only on the OpenAI chat wire, and a user message may not
|
||||||
|
// interrupt the run (tool messages must directly follow their
|
||||||
|
// assistant's tool_calls), so images batch here and flush as ONE
|
||||||
|
// synthetic user message after the run — Pi's `openai-completions`
|
||||||
|
// relocation pattern.
|
||||||
|
let mut pending_tool_images: Vec<ChatContentBlock> = Vec::new();
|
||||||
|
let flush_tool_images = |pending: &mut Vec<ChatContentBlock>,
|
||||||
|
out: &mut Vec<ChatRequestMessage>| {
|
||||||
|
if pending.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut blocks = vec![ChatContentBlock::Text {
|
||||||
|
text: "Attached image(s) from tool result:".to_string(),
|
||||||
|
}];
|
||||||
|
blocks.append(pending);
|
||||||
|
out.push(ChatRequestMessage {
|
||||||
|
role: Role::User,
|
||||||
|
content: MessageContent::Blocks(blocks),
|
||||||
|
name: None,
|
||||||
|
tool_calls: Vec::new(),
|
||||||
|
tool_call_id: None,
|
||||||
|
model_id: None,
|
||||||
|
reasoning_content: None,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
for item in items {
|
for item in items {
|
||||||
match item {
|
match item {
|
||||||
ConversationItem::Reasoning(r) => {
|
ConversationItem::Reasoning(r) => {
|
||||||
|
flush_tool_images(&mut pending_tool_images, &mut out);
|
||||||
let text = reasoning_item_text(&r);
|
let text = reasoning_item_text(&r);
|
||||||
if !text.is_empty() {
|
if !text.is_empty() {
|
||||||
pending_reasoning.push(text);
|
pending_reasoning.push(text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ConversationItem::Assistant(_) => {
|
ConversationItem::Assistant(_) => {
|
||||||
|
flush_tool_images(&mut pending_tool_images, &mut out);
|
||||||
let mut msg = conversation_item_to_chat_message(item);
|
let mut msg = conversation_item_to_chat_message(item);
|
||||||
if !pending_reasoning.is_empty() {
|
if !pending_reasoning.is_empty() {
|
||||||
msg.reasoning_content = Some(pending_reasoning.join("\n"));
|
msg.reasoning_content = Some(pending_reasoning.join("\n"));
|
||||||
@@ -1864,6 +1879,21 @@ pub fn conversation_to_chat_messages(items: Vec<ConversationItem>) -> Vec<ChatRe
|
|||||||
// folds onto the following assistant — matching the Responses
|
// folds onto the following assistant — matching the Responses
|
||||||
// API path, which preserves reasoning across backend tool
|
// API path, which preserves reasoning across backend tool
|
||||||
// calls.
|
// calls.
|
||||||
|
flush_tool_images(&mut pending_tool_images, &mut out);
|
||||||
|
out.push(conversation_item_to_chat_message(item));
|
||||||
|
}
|
||||||
|
ConversationItem::ToolResult(ref t) => {
|
||||||
|
// Collect the run's images before the text-only conversion.
|
||||||
|
for img in &t.images {
|
||||||
|
if let ContentPart::Image { url } = img {
|
||||||
|
pending_tool_images.push(ChatContentBlock::ImageUrl {
|
||||||
|
image_url: ImageUrl {
|
||||||
|
url: url.as_ref().to_owned(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pending_reasoning.clear();
|
||||||
out.push(conversation_item_to_chat_message(item));
|
out.push(conversation_item_to_chat_message(item));
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
@@ -1871,11 +1901,13 @@ pub fn conversation_to_chat_messages(items: Vec<ConversationItem>) -> Vec<ChatRe
|
|||||||
// intervening user/tool messages clear it, matching the
|
// intervening user/tool messages clear it, matching the
|
||||||
// pre-refactor behavior where reasoning lived on the
|
// pre-refactor behavior where reasoning lived on the
|
||||||
// immediately-following assistant turn only.
|
// immediately-following assistant turn only.
|
||||||
|
flush_tool_images(&mut pending_tool_images, &mut out);
|
||||||
pending_reasoning.clear();
|
pending_reasoning.clear();
|
||||||
out.push(conversation_item_to_chat_message(other));
|
out.push(conversation_item_to_chat_message(other));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
flush_tool_images(&mut pending_tool_images, &mut out);
|
||||||
|
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
@@ -2188,14 +2220,86 @@ impl From<&ConversationRequest> for rs::CreateResponse {
|
|||||||
/// so they appear inline in the same order the model originally emitted —
|
/// 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.
|
/// which is what lets the server-side prefix KV-cache hit on repeat turns.
|
||||||
fn build_responses_input(req: &ConversationRequest) -> rs::InputParam {
|
fn build_responses_input(req: &ConversationRequest) -> rs::InputParam {
|
||||||
let items: Vec<rs::InputItem> = req
|
let transformed = transform_items_for_responses(&req.items, req.model.as_deref());
|
||||||
.items
|
let items: Vec<rs::InputItem> = transformed
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(conversation_item_to_input_items)
|
.flat_map(conversation_item_to_input_items)
|
||||||
.collect();
|
.collect();
|
||||||
rs::InputParam::Items(items)
|
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
|
/// Walk a serialized Responses API request body and inject the
|
||||||
/// `type: "reasoning_text"` discriminator that the API requires on
|
/// `type: "reasoning_text"` discriminator that the API requires on
|
||||||
/// `reasoning.content[*]` items.
|
/// `reasoning.content[*]` items.
|
||||||
@@ -2251,9 +2355,23 @@ fn conversation_item_to_input_items(item: &ConversationItem) -> Vec<rs::InputIte
|
|||||||
}
|
}
|
||||||
ConversationItem::Reasoning(r) => {
|
ConversationItem::Reasoning(r) => {
|
||||||
// Reasoning items round-trip back to the Responses API in their
|
// Reasoning items round-trip back to the Responses API in their
|
||||||
// native typed form. `status` is output-only (the API rejects it
|
// native typed form — but ONLY items the Responses API itself
|
||||||
// on input), so strip it before emission; everything else
|
// produced. A native item always carries a server-issued `rs_*`
|
||||||
// (summary, content, encrypted_content, id) passes through.
|
// 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();
|
let mut r = r.clone();
|
||||||
r.status = None;
|
r.status = None;
|
||||||
vec![rs::InputItem::Item(rs::Item::Reasoning(r))]
|
vec![rs::InputItem::Item(rs::Item::Reasoning(r))]
|
||||||
@@ -2274,12 +2392,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 {
|
for tc in &a.tool_calls {
|
||||||
let arguments = sanitize_tool_arguments(&tc.id, &tc.name, tc.arguments.clone());
|
let arguments = sanitize_tool_arguments(&tc.id, &tc.name, tc.arguments.clone());
|
||||||
items.push(rs::InputItem::Item(rs::Item::FunctionCall(
|
items.push(rs::InputItem::Item(rs::Item::FunctionCall(
|
||||||
rs::FunctionToolCall {
|
rs::FunctionToolCall {
|
||||||
call_id: tc.id.as_ref().to_owned(),
|
call_id: sanitize_tool_call_id(&tc.id),
|
||||||
name: tc.name.clone(),
|
name: tc.name.clone(),
|
||||||
arguments: arguments.as_ref().to_owned(),
|
arguments: arguments.as_ref().to_owned(),
|
||||||
id: None,
|
id: None,
|
||||||
@@ -2313,7 +2434,9 @@ fn conversation_item_to_input_items(item: &ConversationItem) -> Vec<rs::InputIte
|
|||||||
};
|
};
|
||||||
vec![rs::InputItem::Item(rs::Item::FunctionCallOutput(
|
vec![rs::InputItem::Item(rs::Item::FunctionCallOutput(
|
||||||
rs::FunctionCallOutputItemParam {
|
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,
|
output,
|
||||||
id: None,
|
id: None,
|
||||||
status: None,
|
status: None,
|
||||||
@@ -2983,6 +3106,53 @@ pub fn dedup_duplicate_tool_results(conversation: &mut Vec<ConversationItem>) ->
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/// Convert a ConversationRequest to Anthropic MessagesRequest.
|
/// 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 {
|
pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::MessagesRequest {
|
||||||
use crate::messages::{
|
use crate::messages::{
|
||||||
CacheControl, ContentBlock, ImageSource, Message, MessageContent, MessageRole,
|
CacheControl, ContentBlock, ImageSource, Message, MessageContent, MessageRole,
|
||||||
@@ -2995,19 +3165,6 @@ pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::Mes
|
|||||||
let mut pending_assistant: Vec<ContentBlock> = Vec::new();
|
let mut pending_assistant: Vec<ContentBlock> = Vec::new();
|
||||||
let mut pending_tool_results: 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
|
// Helper to convert ContentPart to Anthropic ContentBlock
|
||||||
let content_parts_to_anthropic_blocks = |parts: &[ContentPart]| -> Vec<ContentBlock> {
|
let content_parts_to_anthropic_blocks = |parts: &[ContentPart]| -> Vec<ContentBlock> {
|
||||||
parts
|
parts
|
||||||
@@ -3018,28 +3175,9 @@ pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::Mes
|
|||||||
cache_control: None,
|
cache_control: None,
|
||||||
},
|
},
|
||||||
ContentPart::Image { url } => {
|
ContentPart::Image { url } => {
|
||||||
// Parse data: URI vs HTTP(S) URL
|
if let Some((media_type, data)) = parse_base64_image_data_uri(url) {
|
||||||
if url.starts_with("data:") {
|
ContentBlock::Image {
|
||||||
// data:image/png;base64,ABC123...
|
source: ImageSource::Base64 { media_type, data },
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if url.starts_with("http://") || url.starts_with("https://") {
|
} else if url.starts_with("http://") || url.starts_with("https://") {
|
||||||
ContentBlock::Image {
|
ContentBlock::Image {
|
||||||
@@ -3047,6 +3185,13 @@ pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::Mes
|
|||||||
url: url.as_ref().to_owned(),
|
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 {
|
} else {
|
||||||
// Unknown format, treat as text
|
// Unknown format, treat as text
|
||||||
ContentBlock::Text {
|
ContentBlock::Text {
|
||||||
@@ -3096,7 +3241,17 @@ pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::Mes
|
|||||||
ConversationItem::User(u) => {
|
ConversationItem::User(u) => {
|
||||||
flush_assistant(&mut pending_assistant, &mut messages);
|
flush_assistant(&mut pending_assistant, &mut messages);
|
||||||
flush_tool_results(&mut pending_tool_results, &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 {
|
messages.push(Message {
|
||||||
role: MessageRole::User,
|
role: MessageRole::User,
|
||||||
content: MessageContent::Blocks(blocks),
|
content: MessageContent::Blocks(blocks),
|
||||||
@@ -3139,23 +3294,26 @@ pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::Mes
|
|||||||
}];
|
}];
|
||||||
for img in &t.images {
|
for img in &t.images {
|
||||||
if let ContentPart::Image { url } = img {
|
if let ContentPart::Image { url } = img {
|
||||||
let source = if let Some(rest) = url.strip_prefix("data:") {
|
// Same whitelist parse as the user path; a
|
||||||
if let Some((media_type, data)) = rest.split_once(";base64,") {
|
// rejected data URI must never ride as
|
||||||
ImageSource::Base64 {
|
// `ImageSource::Url` (url sources are http(s)
|
||||||
media_type: media_type.to_string(),
|
// only — Anthropic 400s a `data:` payload).
|
||||||
data: data.to_string(),
|
if let Some((media_type, data)) = parse_base64_image_data_uri(url) {
|
||||||
}
|
blocks.push(ContentBlock::Image {
|
||||||
} else {
|
source: ImageSource::Base64 { media_type, data },
|
||||||
ImageSource::Url {
|
});
|
||||||
|
} else if url.starts_with("http://") || url.starts_with("https://") {
|
||||||
|
blocks.push(ContentBlock::Image {
|
||||||
|
source: ImageSource::Url {
|
||||||
url: url.as_ref().to_owned(),
|
url: url.as_ref().to_owned(),
|
||||||
}
|
},
|
||||||
}
|
});
|
||||||
} else {
|
} else {
|
||||||
ImageSource::Url {
|
blocks.push(ContentBlock::Text {
|
||||||
url: url.as_ref().to_owned(),
|
text: "[unsupported image]".to_string(),
|
||||||
}
|
cache_control: None,
|
||||||
};
|
});
|
||||||
blocks.push(ContentBlock::Image { source });
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ToolResultContent::Blocks(blocks)
|
ToolResultContent::Blocks(blocks)
|
||||||
@@ -4621,6 +4779,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]
|
#[test]
|
||||||
fn test_encrypted_reasoning_included_in_responses_api_request() {
|
fn test_encrypted_reasoning_included_in_responses_api_request() {
|
||||||
// Test that when building a Responses API request, encrypted reasoning is included
|
// Test that when building a Responses API request, encrypted reasoning is included
|
||||||
@@ -4687,12 +5149,15 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_only_encrypted_reasoning_included_in_request() {
|
fn test_only_encrypted_reasoning_included_in_request() {
|
||||||
// Test that when there's only encrypted content (no visible summary),
|
// Encrypted-only reasoning replays ONLY with a native (server-issued)
|
||||||
// it's still included in the request
|
// 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![
|
let req = ConversationRequest::from_items(vec![
|
||||||
ConversationItem::user("Hello"),
|
ConversationItem::user("Hello"),
|
||||||
ConversationItem::Reasoning(rs::ReasoningItem {
|
ConversationItem::Reasoning(rs::ReasoningItem {
|
||||||
id: String::new(),
|
id: "rs_hidden_1".to_string(),
|
||||||
summary: vec![],
|
summary: vec![],
|
||||||
content: None,
|
content: None,
|
||||||
encrypted_content: Some("enc_hidden_thoughts".to_string()),
|
encrypted_content: Some("enc_hidden_thoughts".to_string()),
|
||||||
@@ -4725,6 +5190,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(reasoning_items.len(), 1);
|
assert_eq!(reasoning_items.len(), 1);
|
||||||
let reasoning = reasoning_items[0];
|
let reasoning = reasoning_items[0];
|
||||||
|
assert_eq!(reasoning.id, "rs_hidden_1");
|
||||||
|
|
||||||
// Encrypted content should be present
|
// Encrypted content should be present
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -7917,6 +8383,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_tool_result_with_images_to_chat_completions() {
|
fn test_tool_result_with_images_to_chat_completions() {
|
||||||
|
// Tool messages are TEXT-ONLY on the OpenAI chat wire (image parts
|
||||||
|
// 400 on strict validators); the images relocate to a batched user
|
||||||
|
// message in `conversation_to_chat_messages` — see
|
||||||
|
// `chat_tool_result_images_relocate_to_batched_user_message`.
|
||||||
let item = ConversationItem::tool_result_with_images(
|
let item = ConversationItem::tool_result_with_images(
|
||||||
"call_1",
|
"call_1",
|
||||||
"Read image file: photo.png",
|
"Read image file: photo.png",
|
||||||
@@ -7928,20 +8398,10 @@ mod tests {
|
|||||||
let msg = conversation_item_to_chat_message(item);
|
let msg = conversation_item_to_chat_message(item);
|
||||||
assert_eq!(msg.role, Role::Tool);
|
assert_eq!(msg.role, Role::Tool);
|
||||||
assert_eq!(msg.tool_call_id, Some("call_1".to_string()));
|
assert_eq!(msg.tool_call_id, Some("call_1".to_string()));
|
||||||
|
|
||||||
// Should be Blocks, not Text
|
|
||||||
let MessageContent::Blocks(blocks) = &msg.content else {
|
|
||||||
panic!(
|
|
||||||
"Expected Blocks content for image tool result, got {:?}",
|
|
||||||
msg.content
|
|
||||||
);
|
|
||||||
};
|
|
||||||
assert_eq!(blocks.len(), 2);
|
|
||||||
assert!(
|
assert!(
|
||||||
matches!(&blocks[0], ChatContentBlock::Text { text } if text == "Read image file: photo.png")
|
matches!(&msg.content, MessageContent::Text(t) if t == "Read image file: photo.png"),
|
||||||
);
|
"tool message must be text-only, got {:?}",
|
||||||
assert!(
|
msg.content
|
||||||
matches!(&blocks[1], ChatContentBlock::ImageUrl { image_url } if image_url.url == "data:image/png;base64,iVBOR")
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8715,6 +9175,79 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chat_tool_result_images_relocate_to_batched_user_message() {
|
||||||
|
// Tool messages are text-only on the OpenAI chat wire; images from
|
||||||
|
// a CONSECUTIVE tool-result run must batch into ONE user message
|
||||||
|
// AFTER the run (a user message may not interrupt tool responses
|
||||||
|
// answering the same assistant's tool_calls) — Pi's
|
||||||
|
// openai-completions relocation. An image-only result gets a
|
||||||
|
// pointer placeholder.
|
||||||
|
let assistant_with_calls = |ids: &[&str]| {
|
||||||
|
ConversationItem::Assistant(AssistantItem {
|
||||||
|
content: "".into(),
|
||||||
|
tool_calls: ids
|
||||||
|
.iter()
|
||||||
|
.map(|id| ToolCall {
|
||||||
|
id: std::sync::Arc::from(*id),
|
||||||
|
name: "read_file".to_string(),
|
||||||
|
arguments: std::sync::Arc::from("{}"),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
model_id: None,
|
||||||
|
model_fingerprint: None,
|
||||||
|
reasoning_effort: None,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let img = |data: &str| ContentPart::Image {
|
||||||
|
url: format!("data:image/png;base64,{data}").into(),
|
||||||
|
};
|
||||||
|
let msgs = conversation_to_chat_messages(vec![
|
||||||
|
ConversationItem::user("q"),
|
||||||
|
assistant_with_calls(&["tc1", "tc2"]),
|
||||||
|
ConversationItem::tool_result_with_images("tc1", "", vec![img("AAA")]),
|
||||||
|
ConversationItem::tool_result_with_images("tc2", "text out", vec![img("BBB")]),
|
||||||
|
ConversationItem::assistant("done"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let roles: Vec<Role> = msgs.iter().map(|m| m.role).collect();
|
||||||
|
assert_eq!(
|
||||||
|
roles,
|
||||||
|
vec![
|
||||||
|
Role::User,
|
||||||
|
Role::Assistant,
|
||||||
|
Role::Tool,
|
||||||
|
Role::Tool,
|
||||||
|
Role::User,
|
||||||
|
Role::Assistant
|
||||||
|
],
|
||||||
|
"images flush as ONE user message after the tool run: {msgs:#?}"
|
||||||
|
);
|
||||||
|
// Tool messages are text-only; the image-only result carries the
|
||||||
|
// pointer placeholder.
|
||||||
|
assert!(matches!(&msgs[2].content, MessageContent::Text(t) if t == "(see attached image)"));
|
||||||
|
assert!(matches!(&msgs[3].content, MessageContent::Text(t) if t == "text out"));
|
||||||
|
// The batched user message carries the lead text + BOTH images.
|
||||||
|
let MessageContent::Blocks(blocks) = &msgs[4].content else {
|
||||||
|
panic!("image carrier must be a blocks message: {msgs:#?}");
|
||||||
|
};
|
||||||
|
assert_eq!(blocks.len(), 3);
|
||||||
|
assert!(
|
||||||
|
matches!(&blocks[0], ChatContentBlock::Text { text } if text.starts_with("Attached image"))
|
||||||
|
);
|
||||||
|
let urls: Vec<&str> = blocks[1..]
|
||||||
|
.iter()
|
||||||
|
.filter_map(|b| match b {
|
||||||
|
ChatContentBlock::ImageUrl { image_url } => Some(image_url.url.as_str()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
urls,
|
||||||
|
vec!["data:image/png;base64,AAA", "data:image/png;base64,BBB"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn conversation_to_chat_messages_folds_reasoning_into_following_assistant() {
|
fn conversation_to_chat_messages_folds_reasoning_into_following_assistant() {
|
||||||
let items = vec![
|
let items = vec![
|
||||||
@@ -9540,6 +10073,40 @@ mod tests {
|
|||||||
assert_prefix_stable(&req2, &req3);
|
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
|
/// `BackendToolCall` items round-trip through the wire as their
|
||||||
/// typed Item shape; their serialized position must be stable across
|
/// typed Item shape; their serialized position must be stable across
|
||||||
/// turns. (This is the structural analogue of the old
|
/// 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
|
/// 2. `store` is always `false` on this backend, so reasoning continuity
|
||||||
/// is stateless: `include: ["reasoning.encrypted_content"]` is required
|
/// is stateless: `include: ["reasoning.encrypted_content"]` is required
|
||||||
/// for the response to carry replayable encrypted reasoning.
|
/// 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
|
/// openai-codex-GATED at the call sites — API-key `openai` Responses
|
||||||
/// bodies stay byte-identical.
|
/// bodies stay byte-identical.
|
||||||
@@ -1104,6 +1108,18 @@ pub fn adapt_body_for_codex_backend(body: &mut Value) {
|
|||||||
entries.push(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
|
/// Neutralize a `reasoning.effort` echo the typed `rs` enum cannot parse
|
||||||
@@ -1244,15 +1260,21 @@ pub enum ChatCompat {
|
|||||||
DeepSeek,
|
DeepSeek,
|
||||||
/// Leave the body as-is (OpenAI-style `reasoning_effort` passes through).
|
/// Leave the body as-is (OpenAI-style `reasoning_effort` passes through).
|
||||||
Passthrough,
|
Passthrough,
|
||||||
/// Strict OpenAI-compatible validators (Mistral, Cerebras) reject any
|
/// Strict OpenAI-compatible validators (Cerebras, NVIDIA) reject any
|
||||||
/// out-of-schema request field with a 4xx (`additionalProperties:false`).
|
/// out-of-schema request field with a 4xx (`additionalProperties:false`).
|
||||||
/// kigi injects `stream_options.include_usage` on every streaming
|
/// kigi injects `stream_options.include_usage` on every streaming
|
||||||
/// request, which such validators reject, so it is stripped (streaming
|
/// request, which such validators reject, so it is stripped (streaming
|
||||||
/// usage falls back to token estimation). `reasoning_effort` passes
|
/// usage falls back to token estimation). `reasoning_effort` passes
|
||||||
/// through; private message fields are stripped like Passthrough.
|
/// through; private message fields are stripped like Passthrough.
|
||||||
/// (Serde alias `mistral` keeps sessions persisted before the rename.)
|
|
||||||
#[serde(alias = "mistral")]
|
|
||||||
StrictOpenAi,
|
StrictOpenAi,
|
||||||
|
/// Mistral: [`Self::StrictOpenAi`] behavior plus its exactly-nine
|
||||||
|
/// `[a-zA-Z0-9]` tool-call id contract — foreign/OpenAI-style ids are
|
||||||
|
/// deterministically remapped on call+result in one shared map (the
|
||||||
|
/// Pi `mistral-conversations` normalizer). Serializes as `mistral`, so
|
||||||
|
/// sessions persisted before the StrictOpenAi rename (which carried
|
||||||
|
/// the `mistral` alias) resolve here — correct, they were Mistral
|
||||||
|
/// sessions.
|
||||||
|
Mistral,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const REASONING_EFFORT_META_KEY: &str = "reasoningEffort";
|
pub const REASONING_EFFORT_META_KEY: &str = "reasoningEffort";
|
||||||
@@ -1662,6 +1684,38 @@ mod tests {
|
|||||||
assert_eq!(body, before);
|
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
|
/// No system items and no prior instructions: input untouched, no
|
||||||
/// empty-string instructions invented, include still requested.
|
/// empty-string instructions invented, include still requested.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1683,9 +1737,15 @@ mod tests {
|
|||||||
/// persisted before the rename still deserialize.
|
/// persisted before the rename still deserialize.
|
||||||
#[test]
|
#[test]
|
||||||
fn chat_compat_mistral_alias_deserializes_to_strict_openai() {
|
fn chat_compat_mistral_alias_deserializes_to_strict_openai() {
|
||||||
|
// `mistral` resolves to the dedicated Mistral dialect — including
|
||||||
|
// sessions persisted before the StrictOpenAi rename (they were
|
||||||
|
// Mistral sessions and now get the 9-char id contract too).
|
||||||
let v: ChatCompat = serde_json::from_str("\"mistral\"").unwrap();
|
let v: ChatCompat = serde_json::from_str("\"mistral\"").unwrap();
|
||||||
assert_eq!(v, ChatCompat::StrictOpenAi);
|
assert_eq!(v, ChatCompat::Mistral);
|
||||||
// New value round-trips as strict_open_ai.
|
assert_eq!(
|
||||||
|
serde_json::to_string(&ChatCompat::Mistral).unwrap(),
|
||||||
|
"\"mistral\""
|
||||||
|
);
|
||||||
let v: ChatCompat = serde_json::from_str("\"strict_open_ai\"").unwrap();
|
let v: ChatCompat = serde_json::from_str("\"strict_open_ai\"").unwrap();
|
||||||
assert_eq!(v, ChatCompat::StrictOpenAi);
|
assert_eq!(v, ChatCompat::StrictOpenAi);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -4099,8 +4099,12 @@ pub fn sampling_config_for_model(
|
|||||||
&credentials.base_url,
|
&credentials.base_url,
|
||||||
);
|
);
|
||||||
let api_backend = info.api_backend.clone();
|
let api_backend = info.api_backend.clone();
|
||||||
// Managed platform entries speak their registry dialect; BYOK/custom
|
// Managed platform entries speak their registry dialect. BYOK/custom
|
||||||
// entries keep the historical Kimi body adaptation.
|
// entries default to Passthrough (vanilla OpenAI semantics — the
|
||||||
|
// Kimi-specific body mutations `thinking:{…}` + replayed
|
||||||
|
// `reasoning_content` 400 on third-party OpenAI-compatible servers),
|
||||||
|
// EXCEPT entries pointed at the house/Kimi coding endpoint, which keep
|
||||||
|
// the historical Kimi dialect (mirrors Pi's base-url quirk sniffing).
|
||||||
let chat_compat = info
|
let chat_compat = info
|
||||||
.id
|
.id
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -4114,8 +4118,15 @@ pub fn sampling_config_for_model(
|
|||||||
kigi_models::PlatformChatCompat::StrictOpenAi => {
|
kigi_models::PlatformChatCompat::StrictOpenAi => {
|
||||||
kigi_sampling_types::ChatCompat::StrictOpenAi
|
kigi_sampling_types::ChatCompat::StrictOpenAi
|
||||||
}
|
}
|
||||||
|
kigi_models::PlatformChatCompat::Mistral => kigi_sampling_types::ChatCompat::Mistral,
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_else(|| {
|
||||||
|
if crate::util::is_effective_coding_endpoint_url(&credentials.base_url) {
|
||||||
|
kigi_sampling_types::ChatCompat::Kimi
|
||||||
|
} else {
|
||||||
|
kigi_sampling_types::ChatCompat::Passthrough
|
||||||
|
}
|
||||||
|
});
|
||||||
// Claude Pro/Max OAuth Messages adaptation: a managed key whose platform is
|
// Claude Pro/Max OAuth Messages adaptation: a managed key whose platform is
|
||||||
// a generic-OAuth Messages provider (claude-pro-max) drives the OAuth
|
// a generic-OAuth Messages provider (claude-pro-max) drives the OAuth
|
||||||
// identity headers + "You are Claude Code" system prefix in the sampler.
|
// identity headers + "You are Claude Code" system prefix in the sampler.
|
||||||
@@ -5963,6 +5974,72 @@ reasoning_effort = "low"
|
|||||||
"agentType should always be in meta, defaulting to DEFAULT_AGENT_TYPE"
|
"agentType should always be in meta, defaulting to DEFAULT_AGENT_TYPE"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
/// BYOK/custom entries (no managed platform key) default to the
|
||||||
|
/// Passthrough dialect — the historical Kimi default leaked
|
||||||
|
/// Kimi-specific body mutations (`thinking:{…}`, replayed
|
||||||
|
/// `reasoning_content`) to third-party OpenAI-compatible servers.
|
||||||
|
/// The one exception: entries pointed at the house/Kimi coding
|
||||||
|
/// endpoint keep the Kimi dialect (base-url detection, mirroring
|
||||||
|
/// Pi's quirk sniffing).
|
||||||
|
#[test]
|
||||||
|
fn byok_custom_entries_default_to_passthrough_except_house_endpoint() {
|
||||||
|
let make_cfg = |base_url: &str| {
|
||||||
|
let entry_cfg = ModelEntryConfig {
|
||||||
|
id: None, // BYOK: no managed platform key
|
||||||
|
model: "my-custom-model".to_string(),
|
||||||
|
base_url: base_url.to_string(),
|
||||||
|
name: None,
|
||||||
|
description: None,
|
||||||
|
max_completion_tokens: None,
|
||||||
|
temperature: None,
|
||||||
|
top_p: None,
|
||||||
|
api_key: None,
|
||||||
|
env_key: None,
|
||||||
|
api_backend: ApiBackend::default(),
|
||||||
|
auth_scheme: None,
|
||||||
|
extra_headers: IndexMap::new(),
|
||||||
|
context_window: NonZeroU64::new(200_000).unwrap(),
|
||||||
|
auto_compact_threshold_percent: None,
|
||||||
|
system_prompt_label: None,
|
||||||
|
api_base_url: None,
|
||||||
|
use_concise: true,
|
||||||
|
agent_type: default_agent_type(),
|
||||||
|
inference_idle_timeout_secs: None,
|
||||||
|
max_retries: None,
|
||||||
|
hidden: false,
|
||||||
|
supported_in_api: true,
|
||||||
|
reasoning_effort: None,
|
||||||
|
supports_reasoning_effort: false,
|
||||||
|
reasoning_efforts: Vec::new(),
|
||||||
|
capabilities: Vec::new(),
|
||||||
|
supports_backend_search: false,
|
||||||
|
compactions_remaining: None,
|
||||||
|
compaction_at_tokens: None,
|
||||||
|
show_model_fingerprint: false,
|
||||||
|
stream_tool_calls: None,
|
||||||
|
laziness_detector: LazinessDetectorPerModelConfig::default(),
|
||||||
|
};
|
||||||
|
let entry = ModelEntry::from_config_entry(&entry_cfg);
|
||||||
|
let creds = ResolvedCredentials {
|
||||||
|
api_key: Some("sk-byok".into()),
|
||||||
|
base_url: base_url.to_string(),
|
||||||
|
auth_type: kigi_chat_state::AuthType::ApiKey,
|
||||||
|
auth_scheme: Default::default(),
|
||||||
|
};
|
||||||
|
sampling_config_for_model(&entry, creds, None)
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
make_cfg("https://api.third-party.example/v1").chat_compat,
|
||||||
|
kigi_sampling_types::ChatCompat::Passthrough,
|
||||||
|
"third-party BYOK must get vanilla OpenAI semantics"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
make_cfg("https://api.kimi.com/coding/v1").chat_compat,
|
||||||
|
kigi_sampling_types::ChatCompat::Kimi,
|
||||||
|
"the house coding endpoint keeps the Kimi dialect"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Managed `{platform}/{model}` entries stamp `meta.provider` with the
|
/// Managed `{platform}/{model}` entries stamp `meta.provider` with the
|
||||||
/// platform's display name so the client's model picker can say which
|
/// platform's display name so the client's model picker can say which
|
||||||
/// connected provider each model belongs to. User-defined `[model.*]`
|
/// connected provider each model belongs to. User-defined `[model.*]`
|
||||||
|
|||||||
@@ -1841,8 +1841,9 @@ mod tests {
|
|||||||
let cfg = crate::agent::config::sampling_config_for_model(&model_entry, creds, None);
|
let cfg = crate::agent::config::sampling_config_for_model(&model_entry, creds, None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.chat_compat,
|
cfg.chat_compat,
|
||||||
kigi_sampling_types::ChatCompat::StrictOpenAi,
|
kigi_sampling_types::ChatCompat::Mistral,
|
||||||
"mistral entries use the StrictOpenAi dialect (stream_options strip)"
|
"mistral entries use the Mistral dialect (StrictOpenAi behavior \
|
||||||
|
plus the exactly-nine-alphanumeric tool-call id contract)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -472,7 +472,12 @@ async fn responses_upgrade_roundtrips_reconstructed_reasoning_as_typed_input() {
|
|||||||
"\n",
|
"\n",
|
||||||
r#"{"type":"user","content":[{"type":"text","text":"q1"}]}"#,
|
r#"{"type":"user","content":[{"type":"text","text":"q1"}]}"#,
|
||||||
"\n",
|
"\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",
|
"\n",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user