3 Commits
Author SHA1 Message Date
ZacharyZhang-NY ff0fb56c67 release: v0.1.8
Release / build (aarch64-apple-darwin) (push) Waiting to run
Release / build (x86_64-apple-darwin) (push) Waiting to run
Release / build (aarch64-unknown-linux-gnu) (push) Waiting to run
Release / build (x86_64-pc-windows-msvc) (push) Waiting to run
Release / publish GitHub Release (push) Blocked by required conditions
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 8s
Since v0.1.7 — chat-completions dialect correctness (Pi-referenced):
- fix(chat): BYOK/custom endpoints default to Passthrough (vanilla
  OpenAI semantics; Kimi body mutations no longer leak to third-party
  servers); house/Kimi coding endpoints keep the Kimi dialect via
  base-url detection
- fix(chat): dedicated Mistral dialect — exactly-nine-alphanumeric
  tool-call id normalizer (deterministic, call/result-symmetric)
- fix(chat): tool-result images relocate to a batched user message
  (tool messages are text-only on the OpenAI chat wire)
- docs: AGENTS.md records the dialect-selection contract
2026-07-23 10:39:18 -04:00
ZacharyZhang-NY 2524c33a5b fix(chat): relocate tool-result images to a batched user message
C3 (Pi openai-completions relocation): tool messages are text-only on
the OpenAI chat wire — image parts inside role:tool content 400 on
strict validators. Images from a consecutive tool-result run now batch
into ONE synthetic user message appended after the run (a user message
may not interrupt tool responses answering the same assistant's
tool_calls); an image-only result carries a '(see attached image)'
pointer placeholder. Applied at the typed conversion layer, so every
chat dialect gets the universally-valid shape.

Verified: models+sampling-types+sampler+chat-state+shell all green,
clippy clean.
2026-07-23 10:38:37 -04:00
ZacharyZhang-NY 301eb61de4 fix(chat): BYOK dialect defaults to Passthrough; dedicated Mistral dialect
C1 (decision): custom/BYOK ChatCompletions entries defaulted to the Kimi
dialect, leaking Kimi-specific body mutations (thinking:{…} control,
replayed reasoning_content, schema rewrites) to arbitrary third-party
OpenAI-compatible servers. The default is now Passthrough (vanilla
OpenAI semantics), with ONE exception mirroring Pi's base-url quirk
sniffing: entries pointed at the house/Kimi coding endpoint keep the
Kimi dialect. Registry platforms are unaffected (all declare
explicitly).

C2 (Pi mistral-conversations normalizer): Mistral requires tool-call ids
of EXACTLY nine [a-zA-Z0-9] chars; even same-session synthesized UUIDs
violate it. New ChatCompat::Mistral = StrictOpenAi behavior + the
normalizer — strip non-alphanumerics, keep exact-9 ids, else FNV-1a →
base36 (build-stable, deterministic across requests for prefix-cache
stability) with collision retry; ONE map covers tool_calls[].id and
tool_call_id so pairing survives. The mistral registry row and the
persisted 'mistral' serde value both resolve to it (pre-rename Mistral
sessions gain the contract automatically); Cerebras/NVIDIA stay on
StrictOpenAi untouched.

Verified: models+sampling-types+sampler+chat-state+shell all green,
clippy clean.
2026-07-23 10:31:45 -04:00
9 changed files with 457 additions and 115 deletions
+11
View File
@@ -248,6 +248,17 @@ edges stay deterministic Rust. The harness appends a terminal
across all three builders in the same pass — three sequential across all three builders in the same pass — three sequential
single-provider fixes (thinking signature → codex system role → codex single-provider fixes (thinking signature → codex system role → codex
reasoning id) motivated this policy. 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
View File
@@ -5442,7 +5442,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-acp-lib" name = "kigi-acp-lib"
version = "0.1.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" version = "0.1.8"
dependencies = [ dependencies = [
"serde", "serde",
] ]
[[package]] [[package]]
name = "kigi-log" name = "kigi-log"
version = "0.1.7" 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.7" 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.7" version = "0.1.8"
dependencies = [ dependencies = [
"pulldown-cmark", "pulldown-cmark",
] ]
[[package]] [[package]]
name = "kigi-mcp" name = "kigi-mcp"
version = "0.1.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" 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.7" version = "0.1.8"
[[package]] [[package]]
name = "kigi-tool-protocol" name = "kigi-tool-protocol"
version = "0.1.7" 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.7" 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.7" 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.7" 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.7" 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.7" version = "0.1.8"
[[package]] [[package]]
name = "kigi-tty-utils" name = "kigi-tty-utils"
version = "0.1.7" 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.7" 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.7" 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.7" version = "0.1.8"
dependencies = [ dependencies = [
"semver", "semver",
] ]
[[package]] [[package]]
name = "kigi-workspace" name = "kigi-workspace"
version = "0.1.7" 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.7" version = "0.1.8"
dependencies = [ dependencies = [
"base64", "base64",
"chrono", "chrono",
@@ -8840,7 +8840,7 @@ dependencies = [
[[package]] [[package]]
name = "ptyctl" name = "ptyctl"
version = "0.1.7" 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.7" version = "0.1.8"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
+1 -1
View File
@@ -76,7 +76,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.1.7" version = "0.1.8"
edition = "2024" edition = "2024"
license = "Apache-2.0" license = "Apache-2.0"
+10 -5
View File
@@ -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)"
);
}
} }
@@ -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
} }
@@ -8351,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",
@@ -8362,21 +8398,11 @@ 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()));
assert!(
// Should be Blocks, not Text matches!(&msg.content, MessageContent::Text(t) if t == "Read image file: photo.png"),
let MessageContent::Blocks(blocks) = &msg.content else { "tool message must be text-only, got {:?}",
panic!(
"Expected Blocks content for image tool result, got {:?}",
msg.content msg.content
); );
};
assert_eq!(blocks.len(), 2);
assert!(
matches!(&blocks[0], ChatContentBlock::Text { text } if text == "Read image file: photo.png")
);
assert!(
matches!(&blocks[1], ChatContentBlock::ImageUrl { image_url } if image_url.url == "data:image/png;base64,iVBOR")
);
} }
#[test] #[test]
@@ -9149,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![
@@ -1260,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";
@@ -1731,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!(
+80 -3
View File
@@ -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)"
); );
} }