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

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

Part 6 of the cross-provider replay audit.

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

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

Part 5 of the cross-provider replay audit.

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

Part 4 of the cross-provider replay audit.

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

Part 3 of the cross-provider replay audit.

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

Part 2 of the cross-provider replay audit.

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

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

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

Verified: sampling-types 286 + sampler/chat-state/shell 5791 green.
2026-07-23 00:49:18 -04:00
ZacharyZhang-NY d6e49bcc7d release: v0.1.6
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.5:
- fix(messages): Claude models no longer 400 with 'Invalid signature in
  thinking block' — thinking is replayed only for the active tool loop
  and only when genuinely signed (cross-backend/cross-model histories
  are stripped)
- fix(codex): ChatGPT Codex no longer 400s with 'System messages are
  not allowed' — system prompts ride the instructions field and
  encrypted reasoning is requested for stateless replay
- docs: AGENTS.md records both wire contracts
2026-07-22 23:36:22 -04:00
ZacharyZhang-NY d2358b037c fix(codex): adapt the Responses body to the ChatGPT/Codex backend contract
Root cause of 400 {"detail":"System messages are not allowed"} at
chatgpt.com/backend-api/codex/responses (both platforms): the codex
adaptation covered only IDENTITY HEADERS (originator/OpenAI-Beta/UA/
chatgpt-account-id) — the BODY still carried the system prompt as
role:system input items, which the codex backend rejects outright. Its
system channel is the top-level  field, and stateless
(store:false) reasoning replay requires
include:["reasoning.encrypted_content"] — both per the same reference
the headers were ported from (official Codex CLI + Pi's
api/openai-codex-responses.ts).

New adapt_body_for_codex_backend (kigi-sampling-types): hoists every
system input item into  (order preserved, appended to any
existing instructions; string and parts content shapes) and requests
encrypted reasoning. Idempotent. Applied at both Responses send paths,
openai_codex-GATED — the API-key  path stays byte-identical
(pinned by a control wire test).

Tests: adapter unit tests (hoist+include, no-system no-op, idempotence)
plus two mock-server wire tests (codex body has no system role,
instructions + include present; plain Responses body unchanged).

Verified: sampling-types + sampler + chat-state + shell 6076 tests
green, clippy clean.
2026-07-22 23:35:31 -04:00
ZacharyZhang-NY 6ba24db019 fix(messages): replay thinking blocks only for the active tool loop
Root cause of 'Invalid signature in thinking block' (400 at
messages.1.content.0, Claude models): build_messages_request replayed
EVERY stored Reasoning item as a thinking block with no origin check —
history synthesized by other backends (encrypted_content: None → the
mandatory signature field serialized as ""), Responses-API tco_* blobs
(signature bytes, no text), and blocks signed by a DIFFERENT model after
a mid-session /model switch. Anthropic validates every replayed
signature (model-bound), so such histories 400 deterministically. The
platform split was circumstantial: Windows sessions started on the
default model and switched to Claude; macOS sessions were Claude-native
from turn 1. Adversarially verified — no platform-divergent byte path
exists in capture, storage, or replay.

New prune_replayed_thinking pass (Pi/Claude Code replay policy): keep
exactly the final assistant message's thinking when its tool loop is
still open (request ends on the tool results — an open loop can never
span a model switch) and the block is genuinely signed; strip every
other thinking block (the API ignores valid prior-turn thinking and
rejects invalid). Assistant messages emptied by the strip (thinking-only
aborted turns) are removed — empty content arrays are rejected too.

Tests: three unit tests pin strip-outside-loop (unsigned, tco_*, stale
signed), keep-in-active-loop (verbatim text+signature at content.0), and
emptied-message removal; the legacy-upgrade integration test now proves
both wire fidelity in the active loop AND stripping once the loop
closes.

Verified: sampling-types + sampler + chat-state + shell 6072 tests
green, clippy clean.
2026-07-22 23:26:49 -04:00
ZacharyZhang-NY 10149f50dd install: stop persisting KIGI_GRAPH — the binary default is the product default
The README always shipped graph engineering enabled; the enablement was
delegated to installer env plumbing that diverged per platform:
install.sh exported KIGI_GRAPH=1 into shell rc (worked), install.ps1
wrote the User registry variable — which running Windows terminals (and
new tabs of an open Windows Terminal) never pick up, so /graph was
'missing on Windows' despite a successful install.

With resolve_graph() defaulting true in the binary (e53a66d), the env
writes are redundant complexity: drop them from both installers, keep
KIGI_GRAPH=0 as the documented opt-out (env still beats the default),
and correct the flag comment to tell this story instead of a
'gray release' one. Both scripts syntax-checked (sh -n / pwsh parser).

Installers are served from main (raw.githubusercontent), so this takes
effect for all new installs immediately — no retag needed; the running
v0.1.5 build already carries the binary-default fix.
2026-07-22 21:22:16 -04:00
13 changed files with 1576 additions and 226 deletions
+48 -4
View File
@@ -220,7 +220,45 @@ edges stay deterministic Rust. The harness appends a terminal
system prefix — gated on `SamplerConfig.anthropic_oauth` (claude-pro-max
only), so API-key `anthropic`/`minimax` Messages requests stay
byte-identical. Its `/v1/models` listing rides the same Bearer +
oauth-beta headers.
oauth-beta headers. THINKING REPLAY (`prune_replayed_thinking`,
all Messages requests): Anthropic validates every replayed
`thinking` block (signature model-bound, non-empty required), so
only the final assistant message's signed thinking is replayed and
only while its tool loop is open (request ends on the tool
results); everything else — unsigned cross-backend history, `tco_*`
Responses blobs, stale-model blocks — is stripped, or the request
400s "Invalid `signature` in `thinking` block".
- CROSS-PROVIDER REPLAY POLICY (Pi `transform-messages` pattern): the
conversation history is provider-agnostic and sessions switch
models/backends mid-history, so EACH wire builder owns emitting only
items valid for its target — never patch downstream except in the
per-backend body adapters. Concretely: the Responses input drops
Reasoning items without a native `rs_*` id (foreign capture is id "")
and provenance-gates whole turns via `transform_items_for_responses`
(`AssistantItem.model_id` vs the request model: foreign Reasoning
dropped, foreign BackendToolCall demoted to its `text_summary`);
the codex adapter additionally drops bare `rs_*` references (stateless
backend); tool-call ids pass through ONE shared ASCII
`sanitize_tool_call_id` symmetrically on call+result on BOTH the
Messages and Responses legs; Messages image sources go through
`parse_base64_image_data_uri` (raster whitelist, no `data:` url
sources) and empty user turns get a placeholder. Dangling tool calls
are already repaired item-level by `repair_dangling_tool_calls` on the
actor's build path. When a provider wire bug surfaces, fix the CLASS
across all three builders in the same pass — three sequential
single-provider fixes (thinking signature → codex system role → codex
reasoning id) motivated this policy.
- 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
1455 `/auth/callback`, FORM body, authorize+token host `auth.openai.com`,
client `app_EMoam…`, scope `openid profile email offline_access`, the 3
@@ -237,9 +275,15 @@ edges stay deterministic Rust. The harness appends a terminal
`PlatformId::sends_codex_responses_headers()`): headers
`chatgpt-account-id` (per-request from the JWT), `originator codex_cli_rs`,
`OpenAI-Beta responses=experimental`, a codex `User-Agent`; `store:false`
is the shared Responses default. API-key `openai` Responses requests carry
NONE of this (byte-identical). `reasoning.effort` carries the thinking
level (incl. the codex-only `ultra`). NO websocket, NO base_instructions.
is the shared Responses default. BODY adaptation
(`adapt_body_for_codex_backend`, same gate): the backend 400s
`role:system` input ("System messages are not allowed") — system items
are hoisted into the top-level `instructions` field — and stateless
reasoning replay requires `include:["reasoning.encrypted_content"]`.
API-key `openai` Responses requests carry NONE of this
(byte-identical, pinned by a control wire test). `reasoning.effort`
carries the thinking level (incl. the codex-only `ultra`). NO
websocket, NO base_instructions.
CATALOG is HARDCODED (`PlatformId::hardcoded_catalog` →
`openai_codex_wire_models`, mapped through the SAME
`platform_wire_model_to_entry` output): exactly the 4 `visibility=list` &&
Generated
+62 -62
View File
@@ -5442,7 +5442,7 @@ dependencies = [
[[package]]
name = "kigi-acp-lib"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"agent-client-protocol",
"async-trait",
@@ -5456,7 +5456,7 @@ dependencies = [
[[package]]
name = "kigi-agent"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -5486,7 +5486,7 @@ dependencies = [
[[package]]
name = "kigi-agent-lifecycle"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"async-trait",
"tokio",
@@ -5495,7 +5495,7 @@ dependencies = [
[[package]]
name = "kigi-auth"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"async-trait",
"http 1.4.2",
@@ -5508,7 +5508,7 @@ dependencies = [
[[package]]
name = "kigi-bin"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"anyhow",
"clap",
@@ -5543,7 +5543,7 @@ dependencies = [
[[package]]
name = "kigi-chat-state"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"indexmap",
"kigi-compaction",
@@ -5560,7 +5560,7 @@ dependencies = [
[[package]]
name = "kigi-codebase-graph"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"ahash",
"clap",
@@ -5596,7 +5596,7 @@ dependencies = [
[[package]]
name = "kigi-compaction"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"anyhow",
"async-trait",
@@ -5609,7 +5609,7 @@ dependencies = [
[[package]]
name = "kigi-config"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"base64",
"blake3",
@@ -5632,7 +5632,7 @@ dependencies = [
[[package]]
name = "kigi-config-types"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"agent-client-protocol",
"indexmap",
@@ -5646,7 +5646,7 @@ dependencies = [
[[package]]
name = "kigi-crash-handler"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"backtrace",
"libc",
@@ -5657,7 +5657,7 @@ dependencies = [
[[package]]
name = "kigi-env"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"tracing",
"url",
@@ -5665,7 +5665,7 @@ dependencies = [
[[package]]
name = "kigi-fast-worktree"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"anyhow",
"bytes",
@@ -5697,7 +5697,7 @@ dependencies = [
[[package]]
name = "kigi-file-utils"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"anyhow",
"aws-config",
@@ -5721,7 +5721,7 @@ dependencies = [
[[package]]
name = "kigi-fsnotify"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"criterion",
"dunce",
@@ -5742,7 +5742,7 @@ dependencies = [
[[package]]
name = "kigi-gix-status"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"gix",
"kigi-test-utils",
@@ -5752,7 +5752,7 @@ dependencies = [
[[package]]
name = "kigi-hooks"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"fastrand",
"kigi-config",
@@ -5771,7 +5771,7 @@ dependencies = [
[[package]]
name = "kigi-hooks-plugins-types"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"serde",
"serde_json",
@@ -5779,7 +5779,7 @@ dependencies = [
[[package]]
name = "kigi-http"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"kigi-auth",
"kigi-log",
@@ -5794,7 +5794,7 @@ dependencies = [
[[package]]
name = "kigi-hunk-tracker"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"chrono",
"dunce",
@@ -5815,14 +5815,14 @@ dependencies = [
[[package]]
name = "kigi-interjection-core"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"serde",
]
[[package]]
name = "kigi-log"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"anyhow",
"chrono",
@@ -5840,7 +5840,7 @@ dependencies = [
[[package]]
name = "kigi-markdown"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"anstyle",
"anstyle-lossy",
@@ -5864,14 +5864,14 @@ dependencies = [
[[package]]
name = "kigi-markdown-core"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"pulldown-cmark",
]
[[package]]
name = "kigi-mcp"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"agent-client-protocol",
"async-trait",
@@ -5908,7 +5908,7 @@ dependencies = [
[[package]]
name = "kigi-memory"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"anyhow",
"arc-swap",
@@ -5942,7 +5942,7 @@ dependencies = [
[[package]]
name = "kigi-mermaid"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"fontdb",
"image",
@@ -5960,7 +5960,7 @@ dependencies = [
[[package]]
name = "kigi-models"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"kigi-env",
"serde",
@@ -5970,7 +5970,7 @@ dependencies = [
[[package]]
name = "kigi-pager-minimal"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"chrono",
"crossterm",
@@ -5987,7 +5987,7 @@ dependencies = [
[[package]]
name = "kigi-pager-pty-harness"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -6012,7 +6012,7 @@ dependencies = [
[[package]]
name = "kigi-pager-render"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"agent-client-protocol",
"anstyle",
@@ -6064,7 +6064,7 @@ dependencies = [
[[package]]
name = "kigi-paths"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"camino",
"serde",
@@ -6074,7 +6074,7 @@ dependencies = [
[[package]]
name = "kigi-prompt-queue"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"serde",
"serde_json",
@@ -6082,7 +6082,7 @@ dependencies = [
[[package]]
name = "kigi-proto-build"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"anyhow",
"pbjson-build",
@@ -6093,7 +6093,7 @@ dependencies = [
[[package]]
name = "kigi-ratatui-inline"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"ansi-width",
"anstyle-parse 0.2.7",
@@ -6110,7 +6110,7 @@ dependencies = [
[[package]]
name = "kigi-ratatui-textarea"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"arboard",
"chrono",
@@ -6131,7 +6131,7 @@ dependencies = [
[[package]]
name = "kigi-sampler"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"async-openai",
"async-stream",
@@ -6154,7 +6154,7 @@ dependencies = [
[[package]]
name = "kigi-sampling-types"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"assert_matches",
"async-openai",
@@ -6171,7 +6171,7 @@ dependencies = [
[[package]]
name = "kigi-sandbox"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"anyhow",
"chrono",
@@ -6192,7 +6192,7 @@ dependencies = [
[[package]]
name = "kigi-secrets"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"regex",
"serde_json",
@@ -6230,7 +6230,7 @@ dependencies = [
[[package]]
name = "kigi-shell"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6367,7 +6367,7 @@ dependencies = [
[[package]]
name = "kigi-shell-base"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"anyhow",
"chrono",
@@ -6392,7 +6392,7 @@ dependencies = [
[[package]]
name = "kigi-sqlite-journal"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"libc",
"rusqlite",
@@ -6403,7 +6403,7 @@ dependencies = [
[[package]]
name = "kigi-subagent-resolution"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"kigi-sampling-types",
"kigi-tool-types",
@@ -6418,7 +6418,7 @@ dependencies = [
[[package]]
name = "kigi-system-power"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"windows-sys 0.59.0",
"zbus",
@@ -6426,7 +6426,7 @@ dependencies = [
[[package]]
name = "kigi-test-support"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6448,7 +6448,7 @@ dependencies = [
[[package]]
name = "kigi-test-utils"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"runfiles",
"tracing",
@@ -6457,11 +6457,11 @@ dependencies = [
[[package]]
name = "kigi-token-estimation"
version = "0.1.5"
version = "0.1.8"
[[package]]
name = "kigi-tool-protocol"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"kigi-tool-types",
"serde",
@@ -6472,7 +6472,7 @@ dependencies = [
[[package]]
name = "kigi-tool-runtime"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"anyhow",
"async-trait",
@@ -6490,7 +6490,7 @@ dependencies = [
[[package]]
name = "kigi-tool-types"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"minijinja",
"schemars 1.2.1",
@@ -6500,7 +6500,7 @@ dependencies = [
[[package]]
name = "kigi-tools"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"anyhow",
"arc-swap",
@@ -6577,7 +6577,7 @@ dependencies = [
[[package]]
name = "kigi-tools-api"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"kigi-proto-build",
"kigi-tool-protocol",
@@ -6590,11 +6590,11 @@ dependencies = [
[[package]]
name = "kigi-tracing-macros"
version = "0.1.5"
version = "0.1.8"
[[package]]
name = "kigi-tty-utils"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"libc",
"nix 0.30.1",
@@ -6604,7 +6604,7 @@ dependencies = [
[[package]]
name = "kigi-tui"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"agent-client-protocol",
"ansi-to-tui",
@@ -6691,7 +6691,7 @@ dependencies = [
[[package]]
name = "kigi-update"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"anyhow",
"dunce",
@@ -6720,14 +6720,14 @@ dependencies = [
[[package]]
name = "kigi-version"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"semver",
]
[[package]]
name = "kigi-workspace"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6806,7 +6806,7 @@ dependencies = [
[[package]]
name = "kigi-workspace-types"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"base64",
"chrono",
@@ -8840,7 +8840,7 @@ dependencies = [
[[package]]
name = "ptyctl"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -8858,7 +8858,7 @@ dependencies = [
[[package]]
name = "ptyctl-cli"
version = "0.1.5"
version = "0.1.8"
dependencies = [
"anyhow",
"axum",
+1 -1
View File
@@ -76,7 +76,7 @@ members = [
]
[workspace.package]
version = "0.1.5"
version = "0.1.8"
edition = "2024"
license = "Apache-2.0"
+10 -5
View File
@@ -70,9 +70,12 @@ pub enum PlatformChatCompat {
Kimi,
DeepSeek,
Passthrough,
/// Strict OpenAI-compatible validator (Mistral, Cerebras) — strips
/// Strict OpenAI-compatible validator (Cerebras, NVIDIA) — strips
/// `stream_options` and private fields.
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).
@@ -599,10 +602,12 @@ const MISTRAL_SPEC: PlatformSpec = PlatformSpec {
wire_serves_metadata: false,
wire_api: PlatformWireApi::ChatCompletions,
listing: ListingDialect::OpenAi,
// Mistral's strict validator 422s on `stream_options`, and its reasoning
// models return array content — the StrictOpenAi dialect strips
// stream_options; the response deserializer handles arrays universally.
chat_compat: PlatformChatCompat::StrictOpenAi,
// Mistral's strict validator 422s on `stream_options`, its reasoning
// models return array content, and tool-call ids must be EXACTLY nine
// `[a-zA-Z0-9]` chars — the Mistral dialect strips stream_options and
// deterministically remaps non-conforming (foreign/OpenAI-style) ids;
// the response deserializer handles arrays universally.
chat_compat: PlatformChatCompat::Mistral,
key_header: PlatformKeyHeader::Bearer,
// The listing carries embed/moderation/OCR entries; keep tool-calling
// chat models only.
@@ -1183,6 +1183,9 @@ impl SamplingClient {
// old raw_output machinery.
kigi_sampling_types::patch_reasoning_text_types(&mut request_body);
kigi_sampling_types::patch_reasoning_effort(&mut request_body, request.reasoning_effort);
if self.defaults.openai_codex {
kigi_sampling_types::adapt_body_for_codex_backend(&mut request_body);
}
let http_request = self.post(self.endpoint("responses")).json(&request_body);
let response = http_request.send().await.map_err(|e| {
@@ -1321,6 +1324,9 @@ impl SamplingClient {
}
kigi_sampling_types::patch_reasoning_text_types(&mut request_body);
kigi_sampling_types::patch_reasoning_effort(&mut request_body, request.reasoning_effort);
if self.defaults.openai_codex {
kigi_sampling_types::adapt_body_for_codex_backend(&mut request_body);
}
// Fresh per attempt so signals never leak across retries; `None`
// (check disabled) sends no header and does no peek work per event.
let doom_loop = self
@@ -51,6 +51,91 @@ pub(crate) fn adapt_chat_completions_body_for(
strip_kigi_private_message_fields(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["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)"
);
}
}
File diff suppressed because it is too large Load Diff
+190 -5
View File
@@ -1041,6 +1041,87 @@ pub fn patch_reasoning_effort(body: &mut Value, effort: Option<ReasoningEffort>)
}
}
/// Adapt a serialized Responses request body to the ChatGPT/Codex backend
/// contract (`chatgpt.com/backend-api/codex/responses`) — ported from the
/// same reference as the identity headers (official Codex CLI + Pi's
/// `api/openai-codex-responses.ts`):
///
/// 1. The backend rejects `role: system` input items outright
/// (400 `{"detail":"System messages are not allowed"}`); its system
/// channel is the top-level `instructions` field. Hoist every system
/// input message there (order preserved, blank-line joined, appended to
/// any existing instructions) and remove them from `input`.
/// 2. `store` is always `false` on this backend, so reasoning continuity
/// is stateless: `include: ["reasoning.encrypted_content"]` is required
/// for the response to carry replayable encrypted reasoning.
/// 3. For the same reason, a replayed reasoning item WITHOUT
/// `encrypted_content` (captured from a stateful api.openai.com session
/// that never requested the include) references server state
/// chatgpt.com does not have — drop it rather than 400.
///
/// openai-codex-GATED at the call sites — API-key `openai` Responses
/// bodies stay byte-identical.
pub fn adapt_body_for_codex_backend(body: &mut Value) {
// 1. Hoist system messages into `instructions`.
let mut hoisted: Vec<String> = Vec::new();
if let Some(input) = body.get_mut("input").and_then(|v| v.as_array_mut()) {
input.retain(|item| {
let is_system = item.get("role").and_then(|r| r.as_str()) == Some("system");
if is_system {
match item.get("content") {
Some(Value::String(s)) => hoisted.push(s.clone()),
Some(Value::Array(parts)) => {
for p in parts {
if let Some(t) = p.get("text").and_then(|t| t.as_str()) {
hoisted.push(t.to_string());
}
}
}
_ => {}
}
}
!is_system
});
}
if !hoisted.is_empty() {
let mut instructions = body
.get("instructions")
.and_then(|v| v.as_str())
.map(str::to_owned)
.unwrap_or_default();
for part in hoisted {
if !instructions.is_empty() {
instructions.push_str("\n\n");
}
instructions.push_str(&part);
}
body["instructions"] = Value::String(instructions);
}
// 2. Request replayable encrypted reasoning.
let include = body
.as_object_mut()
.map(|obj| obj.entry("include").or_insert_with(|| Value::Array(vec![])));
if let Some(Value::Array(entries)) = include {
let key = Value::String("reasoning.encrypted_content".to_string());
if !entries.contains(&key) {
entries.push(key);
}
}
// 3. Drop reasoning items with no encrypted payload: stateless codex
// cannot resolve a bare `rs_*` reference.
if let Some(input) = body.get_mut("input").and_then(|v| v.as_array_mut()) {
input.retain(|item| {
item.get("type").and_then(|t| t.as_str()) != Some("reasoning")
|| item
.get("encrypted_content")
.and_then(|v| v.as_str())
.is_some_and(|s| !s.is_empty())
});
}
}
/// Neutralize a `reasoning.effort` echo the typed `rs` enum cannot parse
/// (`max`): remove it so response deserialization succeeds. The turn's
/// canonical effort lives in the session sampling config regardless; only
@@ -1179,15 +1260,21 @@ pub enum ChatCompat {
DeepSeek,
/// Leave the body as-is (OpenAI-style `reasoning_effort` passes through).
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`).
/// kigi injects `stream_options.include_usage` on every streaming
/// request, which such validators reject, so it is stripped (streaming
/// usage falls back to token estimation). `reasoning_effort` passes
/// through; private message fields are stripped like Passthrough.
/// (Serde alias `mistral` keeps sessions persisted before the rename.)
#[serde(alias = "mistral")]
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";
@@ -1551,6 +1638,98 @@ mod tests {
use super::*;
use serde_json::json;
/// The Codex backend rejects `role: system` input outright
/// (400 `{"detail":"System messages are not allowed"}`) — its system
/// channel is the top-level `instructions` field, and stateless
/// (`store: false`) reasoning replay needs
/// `include: ["reasoning.encrypted_content"]`. The adapter must hoist
/// every system item (string or parts content, order preserved),
/// append to existing instructions, and leave the rest of the input
/// untouched.
#[test]
fn codex_adapter_hoists_system_messages_and_requests_encrypted_reasoning() {
let mut body = json!({
"model": "gpt-5.2-codex",
"instructions": "base",
"input": [
{"type": "message", "role": "system", "content": "sys head"},
{"type": "message", "role": "user", "content": "hello"},
{"type": "message", "role": "system", "content": [
{"type": "input_text", "text": "memory reminder"}
]},
{"type": "message", "role": "assistant", "content": "hi"}
]
});
adapt_body_for_codex_backend(&mut body);
let input = body["input"].as_array().unwrap();
assert_eq!(input.len(), 2, "system items removed from input: {body:#}");
assert!(
input.iter().all(|i| i["role"] != "system"),
"no system role may remain: {body:#}"
);
assert_eq!(
body["instructions"], "base\n\nsys head\n\nmemory reminder",
"system content hoisted into instructions, order preserved"
);
assert_eq!(
body["include"],
json!(["reasoning.encrypted_content"]),
"stateless reasoning replay requires the include"
);
// Idempotent: a second pass changes nothing.
let before = body.clone();
adapt_body_for_codex_backend(&mut body);
assert_eq!(body, before);
}
/// Stateless codex cannot resolve a bare `rs_*` reference: reasoning
/// input items without an encrypted payload are dropped; items WITH
/// one pass through untouched.
#[test]
fn codex_adapter_drops_reasoning_without_encrypted_payload() {
let mut body = json!({
"model": "gpt-5.2-codex",
"input": [
{"type": "message", "role": "user", "content": "q"},
{"type": "reasoning", "id": "rs_bare", "summary": []},
{"type": "reasoning", "id": "rs_full", "summary": [],
"encrypted_content": "gAAAA-blob"},
{"type": "message", "role": "assistant", "content": "a"}
]
});
adapt_body_for_codex_backend(&mut body);
let input = body["input"].as_array().unwrap();
assert_eq!(input.len(), 3, "bare rs_* item dropped: {body:#}");
assert!(
input
.iter()
.any(|i| i.get("id").and_then(|v| v.as_str()) == Some("rs_full")),
"encrypted reasoning passes through: {body:#}"
);
assert!(
!input
.iter()
.any(|i| i.get("id").and_then(|v| v.as_str()) == Some("rs_bare")),
"{body:#}"
);
}
/// No system items and no prior instructions: input untouched, no
/// empty-string instructions invented, include still requested.
#[test]
fn codex_adapter_without_system_messages_only_adds_include() {
let mut body = json!({
"model": "gpt-5.2-codex",
"input": [{"type": "message", "role": "user", "content": "q"}]
});
adapt_body_for_codex_backend(&mut body);
assert!(body.get("instructions").is_none(), "{body:#}");
assert_eq!(body["input"].as_array().unwrap().len(), 1);
assert_eq!(body["include"], json!(["reasoning.encrypted_content"]));
}
/// String content (the only shape non-Mistral providers send) stays the
/// answer verbatim with no thinking — byte-identical to the pre-change
/// deserialization.
@@ -1558,9 +1737,15 @@ mod tests {
/// persisted before the rename still deserialize.
#[test]
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();
assert_eq!(v, ChatCompat::StrictOpenAi);
// New value round-trips as strict_open_ai.
assert_eq!(v, ChatCompat::Mistral);
assert_eq!(
serde_json::to_string(&ChatCompat::Mistral).unwrap(),
"\"mistral\""
);
let v: ChatCompat = serde_json::from_str("\"strict_open_ai\"").unwrap();
assert_eq!(v, ChatCompat::StrictOpenAi);
assert_eq!(
+89 -9
View File
@@ -1881,12 +1881,15 @@ impl Config {
.default(true)
.resolve()
}
/// Graph mode (`/graph`) master switch. Default ON the gray release
/// (plan.md G0, `KIGI_GRAPH=1` only) is over; every install gets the
/// same commands (its absence on non-dev machines read as a platform
/// bug). `KIGI_GRAPH=0` remains the off-switch. Graph mode additionally
/// requires the goal harness (nodes execute as goals), enforced at
/// availability time, not here.
/// Graph mode (`/graph`) master switch. Default ON in the binary: the
/// README ships graph engineering enabled for every install, but the
/// old `default(false)` delegated enablement to installer env plumbing
/// (`install.sh` shell-rc export vs `install.ps1` registry write) — and
/// Windows terminals don't pick up freshly-written registry env, so
/// `/graph` went "missing on Windows". The product default lives HERE,
/// not in installers. `KIGI_GRAPH=0` is the off-switch. Graph mode
/// additionally requires the goal harness (nodes execute as goals),
/// enforced at availability time, not here.
pub(crate) fn resolve_graph(&self) -> Resolved<bool> {
BoolFlag::env("KIGI_GRAPH").default(true).resolve()
}
@@ -4096,8 +4099,12 @@ pub fn sampling_config_for_model(
&credentials.base_url,
);
let api_backend = info.api_backend.clone();
// Managed platform entries speak their registry dialect; BYOK/custom
// entries keep the historical Kimi body adaptation.
// Managed platform entries speak their registry dialect. BYOK/custom
// 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
.id
.as_deref()
@@ -4111,8 +4118,15 @@ pub fn sampling_config_for_model(
kigi_models::PlatformChatCompat::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
// a generic-OAuth Messages provider (claude-pro-max) drives the OAuth
// identity headers + "You are Claude Code" system prefix in the sampler.
@@ -5960,6 +5974,72 @@ reasoning_effort = "low"
"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
/// platform's display name so the client's model picker can say which
/// 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);
assert_eq!(
cfg.chat_compat,
kigi_sampling_types::ChatCompat::StrictOpenAi,
"mistral entries use the StrictOpenAi dialect (stream_options strip)"
kigi_sampling_types::ChatCompat::Mistral,
"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",
r#"{"type":"user","content":[{"type":"text","text":"q1"}]}"#,
"\n",
r#"{"type":"assistant","content":"a1","reasoning":{"text":"legacy kigi reasoning","encrypted":"ENC_BLOB_xyz","id":"rs_kigibuild_legacy"},"model_id":"kigi"}"#,
// model_id matches the test client's request model: this test
// covers the SAME-MODEL continuation (the byte-stable
// SGLang-prefix path). A mismatched model_id is the provenance
// gate's territory (`transform_items_for_responses`) and drops
// the reasoning by design.
r#"{"type":"assistant","content":"a1","reasoning":{"text":"legacy kigi reasoning","encrypted":"ENC_BLOB_xyz","id":"rs_kigibuild_legacy"},"model_id":"test-model"}"#,
"\n",
),
)
@@ -535,13 +540,20 @@ async fn responses_upgrade_roundtrips_reconstructed_reasoning_as_typed_input() {
/// Upgrade path, Anthropic Messages API: a legacy session whose assistant
/// carries inline `reasoning: {text, encrypted, id}` (text = thinking,
/// encrypted = signature) must, on load, reconstruct a sibling Reasoning
/// item that emits a Anthropic Messages `thinking` content block (with `thinking`
/// + `signature`) on the outgoing `/v1/messages` request.
/// item — and when that turn is the ACTIVE tool-use continuation, its
/// `thinking` block (text + signature) must reach the outgoing
/// `/v1/messages` request verbatim.
///
/// Outside an active tool loop the block must be STRIPPED: Anthropic
/// validates every replayed signature (model-bound), so replaying stale
/// thinking is exactly what 400'd with "Invalid `signature` in `thinking`
/// block" after cross-model histories (see `prune_replayed_thinking`).
#[tokio::test]
async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
// 1. Seed a legacy Anthropic Messages-origin chat_history.jsonl. Anthropic Messages
// thinking blocks never carried an id (stream/messages.rs sets
// id=""), and the signature lives in `encrypted`.
async fn messages_upgrade_replays_reconstructed_thinking_only_in_active_tool_loop() {
// 1. Seed a legacy Anthropic Messages-origin chat_history.jsonl whose
// assistant turn issued a tool call (thinking blocks never carried an
// id — stream/messages.rs sets id="" and the signature lives in
// `encrypted`). The pending tool_result makes this the active loop.
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("chat_history.jsonl"),
@@ -550,7 +562,9 @@ async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
"\n",
r#"{"type":"user","content":[{"type":"text","text":"q1"}]}"#,
"\n",
r#"{"type":"assistant","content":"a1","reasoning":{"text":"legacy anthropic thinking","encrypted":"SIGNATURE_abc","id":""},"model_id":"kigi-4.5"}"#,
r#"{"type":"assistant","content":"a1","reasoning":{"text":"legacy anthropic thinking","encrypted":"SIGNATURE_abc","id":""},"model_id":"kigi-4.5","tool_calls":[{"id":"tc1","name":"read_file","arguments":"{}"}]}"#,
"\n",
r#"{"type":"tool_result","tool_call_id":"tc1","content":"file contents"}"#,
"\n",
),
)
@@ -558,7 +572,7 @@ async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
// 2. Load + upgrade.
let adapter = JsonlStorageAdapter::with_root(dir.path().to_path_buf());
let mut items = adapter.load_chat_history_from_dir(dir.path()).unwrap();
let items = adapter.load_chat_history_from_dir(dir.path()).unwrap();
assert!(
items
.iter()
@@ -566,20 +580,18 @@ async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
"legacy inline reasoning must be reconstructed as a sibling on load, got {items:?}"
);
// 3. Continue and send over the Messages API, capturing the body.
items.push(ConversationItem::user("q2"));
// 3. Send the tool-loop continuation over the Messages API.
let server = MockInferenceServer::start().await.unwrap();
server.set_response("ok");
let client = create_test_client(&server.url(), ApiBackend::Messages);
let _ = client
.conversation_collect(ConversationRequest::from_items(items))
.conversation_collect(ConversationRequest::from_items(items.clone()))
.await
.unwrap();
// 4. The reconstructed reasoning must emit a Anthropic Messages `thinking`
// content block carrying the thinking text + signature.
// 4. The active loop's reconstructed reasoning must emit an Anthropic
// `thinking` content block carrying the thinking text + signature.
let body = server.request_bodies().pop().unwrap();
let messages = body.get("messages").unwrap().as_array().unwrap();
let thinking_block = messages
@@ -593,7 +605,7 @@ async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
})
.find(|b| b.get("type").and_then(Value::as_str) == Some("thinking"))
.unwrap_or_else(|| {
panic!("reconstructed reasoning must emit an Anthropic thinking block; messages: {messages:#?}")
panic!("active-loop reasoning must emit an Anthropic thinking block; messages: {messages:#?}")
});
assert_eq!(
thinking_block.get("thinking").and_then(Value::as_str),
@@ -605,6 +617,25 @@ async fn messages_upgrade_emits_reconstructed_reasoning_as_thinking_block() {
Some("SIGNATURE_abc"),
"signature (encrypted) preserved — required to reuse the thought server-side"
);
// 5. A follow-up user turn CLOSES the loop: the same history plus a new
// user message must replay NO thinking block at all.
let mut closed = items;
closed.push(ConversationItem::user("q2"));
let _ = client
.conversation_collect(ConversationRequest::from_items(closed))
.await
.unwrap();
let body = server.request_bodies().pop().unwrap();
let any_thinking = body["messages"].as_array().unwrap().iter().any(|m| {
m.get("content")
.and_then(Value::as_array)
.is_some_and(|c| c.iter().any(|b| b["type"] == "thinking"))
});
assert!(
!any_thinking,
"stale thinking must be stripped outside the active tool loop; body: {body:#?}"
);
}
// ============================================================================
@@ -1443,3 +1474,79 @@ async fn test_chat_completions_backend_hits_chat_endpoint_not_responses() {
"Should NOT have called /v1/responses"
);
}
/// ChatGPT/Codex backend body contract (`openai_codex = true`): the
/// `/codex/responses` endpoint rejects `role: system` input outright
/// (400 {"detail":"System messages are not allowed"}) — system content
/// must ride the top-level `instructions` field, and stateless reasoning
/// replay needs `include: ["reasoning.encrypted_content"]`. Ported from
/// the official Codex CLI + Pi's api/openai-codex-responses.ts, like the
/// identity headers.
#[tokio::test]
async fn codex_responses_body_hoists_system_into_instructions() {
let server = MockInferenceServer::start().await.unwrap();
server.set_response("ok");
let mut config = common::test_sampler_config(&server.url(), ApiBackend::Responses, &[]);
config.openai_codex = true;
let client = Client::new(config).unwrap();
let _ = client
.conversation_collect(ConversationRequest::from_items(vec![
ConversationItem::system("You are Kigi."),
ConversationItem::user("test"),
]))
.await
.unwrap();
let body = server.request_bodies().pop().unwrap();
let input = body["input"].as_array().unwrap();
assert!(
input
.iter()
.all(|i| i.get("role").and_then(Value::as_str) != Some("system")),
"codex backend must never receive system-role input: {body:#?}"
);
assert_eq!(
body["instructions"].as_str(),
Some("You are Kigi."),
"system prompt must ride the instructions field: {body:#?}"
);
assert_eq!(
body["include"],
serde_json::json!(["reasoning.encrypted_content"]),
"stateless reasoning replay requires the include: {body:#?}"
);
}
/// Control: the API-key `openai` Responses path (`openai_codex = false`)
/// stays byte-compatible — system-role input preserved, no codex fields.
#[tokio::test]
async fn plain_responses_body_keeps_system_role_input() {
let server = MockInferenceServer::start().await.unwrap();
server.set_response("ok");
let client = create_test_client(&server.url(), ApiBackend::Responses);
let _ = client
.conversation_collect(ConversationRequest::from_items(vec![
ConversationItem::system("You are Kigi."),
ConversationItem::user("test"),
]))
.await
.unwrap();
let body = server.request_bodies().pop().unwrap();
assert!(
body["input"]
.as_array()
.unwrap()
.iter()
.any(|i| i.get("role").and_then(Value::as_str) == Some("system")),
"api-key openai keeps system-role input: {body:#?}"
);
assert!(
body.get("instructions")
.map(|v| v.is_null())
.unwrap_or(true),
"no instructions hoist outside codex: {body:#?}"
);
}
+7 -9
View File
@@ -149,15 +149,13 @@ try {
Write-Host "Run 'kigi' to get started."
}
# Graph engineering ships enabled by default. Respect an explicit
# user choice: only set the variable when it is not already defined
# (so a persisted opt-out of "0" survives reinstalls).
$Graph = [Environment]::GetEnvironmentVariable("KIGI_GRAPH", "User")
if ($null -eq $Graph -or $Graph -eq "") {
[Environment]::SetEnvironmentVariable("KIGI_GRAPH", "1", "User")
Write-Host "Enabled graph engineering (KIGI_GRAPH=1)."
Write-Host "Disable: [Environment]::SetEnvironmentVariable('KIGI_GRAPH','0','User')"
}
# Graph engineering is enabled by default IN THE BINARY (resolve_graph
# defaults true) — no environment plumbing needed. The installer used
# to persist KIGI_GRAPH=1 into the User registry env, but running
# terminals (and new tabs of an open Windows Terminal) never pick up
# freshly-written registry variables, which made /graph "missing on
# Windows" while the shell-rc path worked on macOS/Linux. Opt out any
# time with: [Environment]::SetEnvironmentVariable('KIGI_GRAPH','0','User')
} finally {
Remove-Item -Path $TmpDir -Recurse -Force -ErrorAction SilentlyContinue
}
+4 -8
View File
@@ -209,7 +209,6 @@ case "${SHELL:-}" in
*/zsh)
RC_FILE="${ZDOTDIR:-$HOME}/.zshrc"
PATH_LINE="export PATH=\"$BIN_DIR:\$PATH\""
GRAPH_LINE="export KIGI_GRAPH=1"
;;
*/bash)
# macOS login shells read ~/.bash_profile; Linux reads ~/.bashrc.
@@ -219,7 +218,6 @@ case "${SHELL:-}" in
RC_FILE="$HOME/.bashrc"
fi
PATH_LINE="export PATH=\"$BIN_DIR:\$PATH\""
GRAPH_LINE="export KIGI_GRAPH=1"
;;
*/fish)
# fish_add_path in config.fish is fish's own idempotent way
@@ -228,12 +226,10 @@ case "${SHELL:-}" in
mkdir -p "$FISH_CONF_DIR"
RC_FILE="$FISH_CONF_DIR/config.fish"
PATH_LINE="fish_add_path $BIN_DIR"
GRAPH_LINE="set -gx KIGI_GRAPH 1"
;;
*)
RC_FILE="$HOME/.profile"
PATH_LINE="export PATH=\"$BIN_DIR:\$PATH\""
GRAPH_LINE="export KIGI_GRAPH=1"
;;
esac
@@ -247,8 +243,8 @@ case ":$PATH:" in
;;
esac
# Graph engineering ships enabled by default. The KIGI_GRAPH guard makes
# this idempotent AND respects an explicit user opt-out (an existing
# `export KIGI_GRAPH=0` line is left untouched). Disable any time with:
# Graph engineering is enabled by default IN THE BINARY (resolve_graph
# defaults true) — the installer no longer writes KIGI_GRAPH=1 into shell
# rc files (per-shell env plumbing was fragile and diverged per platform).
# Disable any time with:
# echo 'export KIGI_GRAPH=0' >> <your shell rc>
persist_line "$RC_FILE" "$GRAPH_LINE" "KIGI_GRAPH" "graph engineering (KIGI_GRAPH=1)"