utils/tool-schema-compat.tsno longer hoists a ROOT schema'stypeinto its combiner branches. OpenAI-compatible gateways reject a covered object-shaped root when normalization removes its requiredtype: "object", which is exactly how an Apitopia/Kimi turn died on 2026-08-04.normalizeNodenow takes anisRootflag so branch-level hoisting (still correct below the root) is unchanged. Plain and object-shaped roots receive or retain object typing, while scalar and mixed root unions remain unchanged instead of being mislabeled. RootallOfis protected from root type hoisting but is not flattened into a synthetic object.mergeRootObjectUnionmerges object-shaped rootanyOf/oneOfschemas without replacing the root's ownproperties/required. It previously returned{"properties":{},"type":"object"}for a root union that declared its properties at the root — silently sending a tool with zero parameters. Untyped constraint-only branches ({ required: [...] }over root properties) are accepted, andrequiredkeeps root entries plus only the names every branch shares.normalizeToolParametersForMoonshotnow reuses the same object-root normalization before annotation stripping, rather than maintaining a second, divergent root-merge path.api/anthropic-messages.tsresolves object-shaped rootanyOf/oneOfparameters through the sharedresolveRootObjectSchemabefore buildinginput_schema.convertToolsreads top-levelproperties/requiredonly, so covered root unions previously arrived as{"properties":{},"required":[]}. The conversion now merges their properties and required names while leaving ordinary object schemas unchanged; non-object unions and rootallOfremain outside this resolver's flattening boundary.utils/retry.tsclassifies five recognized malformed tool/function schema message forms as NON-retryable, andNON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERNis renamedNON_RETRYABLE_PROVIDER_ERROR_PATTERNbecause it no longer covers only limits. Gateways can wrap these deterministic rejections in retryable-looking 5xx envelopes, so generic status matching replayed an equivalent invalid request on the same model. Four matchers targettools./functions.request paths;invalid tool schemais intentionally broader. Eligible configured fallbacks rebuild their own provider-specific request rather than inheriting guaranteed identical bytes.
- Wire-payload schema normalization runs inside the provider adapter, after extension payload hooks, so no extension can repair the emitted tool schema. Retry classification is consumed by the agent session's hard-error routing, which lives below any extension seam.
- MEDIUM:
utils/tool-schema-compat.tsaround root handling andmergeRootObjectUnion. - MEDIUM:
utils/retry.tsin the non-retryable pattern list and its renamed constant. - LOW:
test/openai-completions-tool-schema-compat.test.ts,test/retry.test.ts.
utils/retry-hint.ts(new) owns the strict 429 retry-hint extractor:extract429RetryAfterMsplus canonical marker helpers. It parsesretry-after/retry-after-msheaders,x-ratelimit-reset*epoch headers, recursive JSONretryDelayfields (Google RPC style), body prose ("try again in N s", "resets at "), and SSEevent: errorpayloads, normalizing every shape to a millisecond delay or a sentinel for absent hint. Explicit-zero (retry immediately) is distinct from absent-hint (no guidance), so callers never conflate “server said now” with “server said nothing.”utils/provider-retry.tspropagates the extracted hint as a structuredProviderRetryDelayErrorcarrying the canonical marker, instead of leaving the delay embedded in an opaque error string. Non-429 retry-loop behavior (forced-eligibility, backoff) is intentionally preserved — the hint path only augments 429-class errors.api/anthropic-messages.tsandapi/openai-codex-responses.tsemit the canonical markers at both the HTTP-status boundary and the SSE in-streamevent: errorboundary, so hints survive regardless of whether the 429 arrives as a status response or a mid-stream error event.
- MEDIUM:
utils/provider-retry.tsaround the 429 hint propagation andProviderRetryDelayError. - MEDIUM:
api/anthropic-messages.tsandapi/openai-codex-responses.tsat the status/SSE error boundaries. - LOW:
utils/retry-hint.ts(new file) andpackage.json./utils/*export.
api/anthropic-tool-pairs.tsowns the browser-safe wire sanitizer for Anthropic clienttool_use/tool_resultadjacency, deduplication, orphan removal, and interrupted-result synthesis.api/anthropic-messages.tsapplies that sanitizer afteronPayloadand every built-in Anthropic request rewrite, immediately before request metadata extraction and SDK submission.- The final boundary no longer depends on extension-runner liveness or hook registration order. A reload, extension, or late payload transform can remove one result from a parallel tool-call turn without sending an invalid request to Anthropic.
test/anthropic-final-tool-pair-guard.test.tsdeterministically removes one result in the last payload hook and asserts that the SDK receives both immediate result blocks, including a synthetic error result.
- MEDIUM:
api/anthropic-messages.tsaround the final request-sanitization pipeline. - LOW:
api/anthropic-tool-pairs.tsif upstream adds equivalent Anthropic wire normalization.
- A transient pre-start Codex WebSocket failure no longer pins the session to SSE for the rest of the process lifetime. The fallback circuit now keeps immediate requests on SSE for 60 seconds, then lets the next fresh request probe WebSocket again.
- Recovery changes only a future request. The existing guard still propagates transport failures after the response stream starts, so no already-started or potentially billed response is retried through SSE.
- Production session cleanup now removes both live WebSocket resources and the session's fallback/debug state. Long-lived app-server processes no longer retain degraded routing after a session is closed.
- Fallback and debug-state ownership moved into
api/openai-codex-responses/fallback-state.ts, reducing the oversized adapter while keeping the public debug API stable.
../test/openai-codex-fallback-recovery.test.tsproves the immediate SSE cooldown boundary, post-cooldown WebSocket recovery, and immediate recovery after production cleanup.- Existing Codex stream tests retain the post-start no-fallback guard,
continuation recovery, connection-limit handling, and one-shot
cacheRetention: "none"behavior.
- MEDIUM: Codex WebSocket debug/fallback state and session cleanup.
- Issue #589's donated 25-hour session contained an 8.5-minute HTTP/SSE fallback burst where 18 requests reused only 22,016 cached tokens and resent roughly 175k-180k uncached tokens, interleaved with 10 normal roughly 196k-199k cache hits. No model, thinking-level, compaction, or custom-message transition occurred inside the burst.
- The session had previously recorded Codex WebSocket transport failures and
fallen back to SSE. Senpi's Codex adapter sent the stable session ID as
prompt_cache_key,session-id, andx-client-request-id, but omitted the official Codexthread-idaffinity header on both SSE and WebSocket. api/openai-prompt-cache.tsnow applies the complete stable affinity tuple, and both transports use it. Senpi has one durable conversation identifier at this layer, sosession-id,thread-id, andx-client-request-idall carry the clamped Senpi session ID whileprompt_cache_keyremains unchanged.cacheRetention: "none"keeps its existing no-affinity SSE behavior.- This fixes the client-controlled protocol divergence. Open upstream Codex reports show that the provider cache can still miss intermittently with byte-identical bodies and stable keys, so the change does not claim that a best-effort upstream cache becomes deterministic.
../test/openai-codex-cache-affinity.test.tsdrives the real SSE and WebSocket request builders, pins the complete header/body mapping, and preserves the disabled-cache boundary.
- LOW: additive prompt-cache header helper and the two Codex header builders.
- Unavailable Anthropic
tool_usehistory is still demoted to satisfy Anthropic's same-request tool-reference validation, but the assistant-role text now uses explicit<unavailable-tool-call>transcript records instead of an imitable[Called tool ... with input: ...]pseudo-action. - The first record for each missing tool name in a request explains that the call is historical and lists a capped, request-derived set of tools actually available now; later records for that name are terse self-closing elements. Tracking is request-local, so concurrent requests cannot interfere.
- Historical call inputs are omitted entirely, removing large replayed patch bodies. Tool-result text remains available in
<unavailable-tool-result>records; only literal closing-tag openers are narrowly neutralized so attacker-influenced output cannot escape the envelope. - XML attribute values are escaped for exotic tool names. The text builders live in the non-public
utils/surface rather than growing the already-large Anthropic adapter. - Coverage drives the real fake-client request path for first/later behavior, request-derived list capping, input omission, exotic-name escaping, result preservation, and closing-tag neutralization. The existing tool-reference integrity test remains unchanged.
- LOW: unavailable-tool rewriting inside
api/anthropic-messages.tsand its internal text helper import.
- OpenAI-compatible map-less
gpt-5.6-solmodels now exposexhighandmaxwithout requiring a generatedthinkingLevelMap. - Explicit maps remain authoritative: a missing level on an existing map stays unavailable, and
nullvetoes the heuristic.supportsXhighandsupportsMaxshare that precedence. supportsMaxis exported frommodels.tsso OpenAI Responses, Azure Responses, Codex Responses, and Completions sendmaxon the wire instead of clamping a UI-selected map-less Sol level tohigh.- Coverage pins capability, negative non-Sol boundaries, and captured request payloads without live tokens.
- Kimi-family streams now sanitize structural
think/response/messageXTML markers from final thinking content and promote text only when an explicit response-open boundary makes the split unambiguous. - Recovery uses the existing code mask, so XTML-looking examples inside inline or fenced code remain literal. Closing-marker-only payloads are sanitized but never exposed as visible chain-of-thought.
- Model recovery composition now applies Kimi response-channel recovery even when no tools are registered, while leaked text-tool-call reconstruction remains conditional on available tools.
- Coverage: coding-agent runtime-boundary tests pin no-tools recovery, split markers, conservative malformed handling, code literals, ordinary Kimi thinking, non-Kimi isolation, and existing tool-call recovery.
- New
providers/ollama.tsregistersollamaas an OpenAI-compatible builtin usingOLLAMA_API_KEYandhttps://ollama.com/v1. - The provider discovers the current Cloud catalog from
/api/tags, enriches each entry through/api/show, exposes only tool-capable models, and derives thinking, vision, and architecture-specific context metadata. - Per-model inspection uses bounded concurrency and retains a last-known tool model when that tag's inspection fails beside usable results; complete inspection failure and aborts fail the refresh without replacing the cache. A successful discovery with no usable tool models also preserves the last-known catalog instead of publishing or persisting an empty replacement.
- Catalog reads use the shared auth-aware
ModelsStorerefresh lifecycle, so only a non-empty successful result is persisted and failed or empty refreshes cannot replace the last-known list. Subscription usage has no stable per-token dollar rate, so discovered models report zero cost instead of fabricating prices. - Ollama's OpenAI-compatible endpoint does not accept OpenAI-only storage/developer/strict-tool fields; the model
compatibility projection uses
max_tokens, and Senpi'smaxreasoning level clamps to Ollama's supportedhighwire value.
- LOW: additive provider factory, provider registration,
KnownProvider, environment-key map entries, and the existing Ollama reasoning-level map inapi/openai-completions.ts. - LOW: additive provider documentation and deterministic catalog fixtures.
wrapStreamWithInvokeRecovery()accepts typed recovery options carrying both the parser factory and the protocol identity. The previous parser-only argument selected Kimi XTML correctly but lost that provenance in shared diagnostics and recovered tool-call IDs.- Successful Kimi recovery now reports
protocol: "kimi-xtml"and allocatesrecovered-kimi-xtml-*IDs. Invalid content/native event order and collision failures use the same protocol identity instead of always claimingantml. - The default and legacy parser-function call forms remain ANTML-compatible, preserving existing Claude/default recovery diagnostics and IDs.
- Coverage: the shared wrapper pins Kimi failure diagnostics, and the coding-agent runtime boundary pins successful Kimi diagnostics plus recovered IDs.
- MEDIUM: invoke-recovery wrapper, diagnostic, failure, and native projection constructor signatures.
api/openai-completions.tsnow closes the active thinking, text, or native tool-call block before starting the next block. The adapter previously accumulated every block and emitted all*_endevents only after the wire stream finished, producing overlapping canonical lifecycles such asthinking_start -> text_startandtext_start -> toolcall_start.- Providers that put text, reasoning, and parallel tool-call deltas in the same chunk keep their established single-block aggregation. The adapter defers that mixed chunk's content events and replays text, thinking, and each tool call as complete sequential lifecycles, avoiding duplicate text/thinking starts without restoring overlapping events.
- The invoke-recovery wrapper correctly rejects overlapping canonical content lifecycles. Kimi K3 exposed the
adapter bug when a normal response streamed reasoning, visible text, and native tool calls in sequence, causing
the user-facing terminal error
Invalid assistant content event order. - Coverage:
test/openai-completions-stream-lifecycle.test.tsdrives a real local SSE endpoint through reasoning, text, and a native tool call and pins the sequential start/delta/end event order.test/openai-completions-tool-choice.test.tspins mixed text/reasoning/parallel-tool aggregation and sequential event replay.
- LOW: the block lifecycle helpers inside
api/openai-completions.ts.
auth/headers.tsdefines the narrow, case-insensitive credential-header contract shared by auth discovery and request adapters. Standard authorization, API-key, API-token, auth-token, access-token, and client-secret header names count only when their effective value contains credential material; metadata such asUser-Agent, request ids, and trace tokens does not.api/openai-client-auth.tslets OpenAI-compatible adapters initialize from credential-bearing headers whenModelAuth.apiKeyis absent. Header-only clients suppress the SDK's defaultAuthorization: Bearer ...header unless an explicit Authorization or the existing Cloudflare AI Gateway authorization path owns that behavior.api/openai-completions.tsandapi/openai-responses.tsuse the shared client-auth resolver for HTTP and Responses WebSocket requests, sox-api-keyand equivalent static credentials work without an invented bearer token.
test/auth-headers.test.tscovers recognized names, metadata rejection, case-insensitive overrides, and empty authorization schemes.test/openai-header-auth.test.tsexercises real OpenAI-compatible request construction for Completions and Responses and proves metadata-only headers fail before any request is issued.
- LOW: additive auth/header helpers and root export.
- MEDIUM: the duplicated OpenAI client-auth setup removed from
api/openai-completions.tsandapi/openai-responses.ts.
utils/retry.ts:NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERNgainscredits_requiredandcredits are required, the Anthropic Console credit-exhaustion wording (a 429rate_limit_errorwhose details carryerror_code: credits_required). The account stays dead until the user buys credits or raises the spend limit, so same-model retries can never recover it. Callers now route the shape through the hard-error fallback branch, where coding-agent pins the billing fallback, instead of burning the same-model retry budget (1 + maxRetries dead requests) on every turn.- Coverage:
test/retry.test.tspins the verbatim incident message as non-retryable.
- LOW: two strings appended to the non-retryable pattern list in
utils/retry.ts.
utils/retry.tsexportsisProviderStreamStallError(): matches the agent-loop stream-watchdog failures ("Idle timeout waiting for provider stream after ms" and "Provider stream start timed out after ms") onstopReason: "error"messages. The class stays retryable (unchanged), but callers can now distinguish "the provider accepted the request and sent zero events for the whole idle budget" from fast transient failures. agent-session uses it to escalate a second consecutive stall to the fallback chain instead of replaying the identical payload for the rest of the same-model budget (evidence: donated session 019fa8da-43ad-70b7-b01b-8f34f4d907f2, records 1906/1919, where a hung gateway made every replay burn the full 300s idle budget).- Coverage:
test/retry.test.tspins the stall class against the idle-timeout message,Request timed out., and aborted stop reasons.
utils/retry.tsexportsisProviderStreamStallError()for the two anchored agent-loop watchdog messages andisProviderTimeoutError()for those stalls plus the exactRequest timed outtransport shape. The shared classifier accepts transport timeouts reported asabortedwhile rejecting incidental timeout text from commands, MCP servers, and extensions.../test/retry.test.tspins the observed positive shapes, negative lookalikes, and stop-reason policy.
- LOW: additive classifiers beside
isRetryableAssistantError()inutils/retry.ts; keepisProviderStreamStallError()aligned with PR #453 when the branches meet.
ToolCallFormatgains"kimi-xtml"(Kimi K3 native XTML channel syntax);getToolCallFormat()whitelist, protocol registry, compat docs, and middleware TESTING.md updated accordingly. Protocol implementation lives intool-call-middleware/protocols/kimi-xtml/(markers, parse, format, stream); details intool-call-middleware/changes.md.
api/anthropic-messages.tsgains a final payload pass,demoteUnavailableToolReferences(), applied aftersanitizeUnsupportedNativeTools()on every request. Anthropic rejects a request whose message history references a tool that is neither defined intoolsnor discovered through atool_referenceblock in the same request (400 invalid_request_error: Tool reference '<name>' not found in available tools). Sessions outlive their tools: an MCP server can be absent after asenpi --sessionresume, an extension can stop registering a tool, or anonPayloadhook can strip a definition while the history still carries the call.- The pass collects defined tool names and names discovered via
tool_referenceblocks (including replayed server-side tool-search results), then demotes offendingtool_useblocks to plain text, demotes theirtool_resultblocks in lockstep (preserving the original result text), and stripstool_referenceentries whose definition vanished — so neither the original 400 nor an orphan-pairing 400 can occur. ../test/anthropic-tool-reference-integrity.test.tsdrives the full request path offline through a fake Anthropic client: single and mixed-turn demotion, still-available tools kept intact, deferredtool_referencediscovery kept intact, and dangling-reference stripping after a payload hook removes a definition.
- LOW: the request-finalization chain inside
createRequest()inapi/anthropic-messages.ts. - LOW: new unexported helpers near the other payload sanitizers in
api/anthropic-messages.ts.
utils/provider-retry.tsnow prefetches the first SDK stream result inside the existing bounded, abortable provider retry policy. A retry creates a fresh request only when stream consumption fails before any wire chunk can reach the public event stream.api/openai-completions.tsuses that prefetch wrapper for OpenAI-compatible providers. Once the first chunk exists, the stream is replayed exactly once and any later failure remains terminal, preventing duplicated text or tool effects.- The exact property-less gateway error
Upstream error from DigitalOcean: stream failedis recognized as transient; arbitrary property-less errors remain non-retryable. ../test/openai-completions-retry.test.tscovers recovery, retry exhaustion, non-retryable failures, and the post-first-chunk no-retry boundary. The isolated mock-loop driver.agents/skills/senpi-qa/scripts/mock-loop-stream-retry.mjsproves the same behavior through the real source CLI.
- LOW: the request creation/retry block in
api/openai-completions.ts. - LOW: shared provider retry classification and stream-prefetch helper in
utils/provider-retry.ts.
scripts/generate-models.ts: newOPENAI_PRIORITY_TIER_MODEL_IDS(the OpenAI pricing page's Priority table: gpt-5.6-sol/terra/luna, gpt-5.5, gpt-5.4(+mini), gpt-5.2, gpt-5.1, gpt-5(+mini), gpt-4.1 family, gpt-4o family, o3, o4-mini) plus an emission pass that clones each eligibleopenaiprovider model into<id>-fastwithupstreamModelIdset to the base id andserviceTier: "priority". Emission runs after metadata application so variants clone fully processed base models, and is scoped to the direct OpenAI provider (Azure clones andopenai-codexare intentionally excluded).src/model.ts:Modelgains optionalupstreamModelIdandserviceTierso catalog entries can carry the alias/tier defaults that previously only models.json or extension model definitions could express. This removes the need to hand-maintain-fastpseudo-models in models.json for stock OpenAI models.- Variant
costrates intentionally equal the base model's:api/openai-responses.tsapplyServiceTierPricing()multiplies usage cost by the service-tier multiplier (2x, 2.5x for gpt-5.5) at request time, so raised catalog rates would double-count. The request path rewrites the wire id toupstreamModelId, preserving the multiplier'smodel.id === "gpt-5.5"branch. - Regenerated catalog: 18
openai-fastvariants added; other provider shards carry routine upstream models.dev/OpenRouter drift (e.g. nvidia +14/-2, fireworks +/-2) from regeneration. ../test/openai-fast-models.test.ts: pins variant presence/eligibility, cloned fields, base cost rates, non-recursion, and Azure/Codex exclusion.
- LOW: additive set + emission block in
scripts/generate-models.ts; additive optional fields onModelinsrc/model.ts; regeneratedsrc/providers/data/*shards (regenerate on conflict).
api/openai-codex-responses.tsbuildRequestBody()and the internalapi/openai-codex-responses/reasoning.tsnormalizer:reasoningSummary: nullnow omits thesummaryfield frombody.reasoninginstead of sending the literal string"off". The Codex backend'sReasoningSummaryParamaccepts onlyconcise,detailed, andauto, so every request carryingreasoningSummary: nullfailed with a 400invalid_enum_value. The coding-agent builtin compaction (summarizationReasoningOptions()) passes exactly that value to keep summarization turns cheap, which made compaction unusable on Codex models. The adapter now also preserves the shipped legacy union while normalizing"off"to omission and"on"to"auto". These semantics match the sibling adapters and the official OpenAI Codex CLI reference client, whoseReasoningSummary::Noneis encoded as an absentsummaryfield for both ordinary and compaction requests. Current upstream pi-mono instead maps null to"auto", so this fork intentionally follows the official Codex wire contract rather than claiming upstream parity.- An extension cannot fix this: the invalid value is produced inside the wire adapter's request builder, below every extension hook.
../test/openai-responses-thinking-matrix.test.ts: pins bothbuildRequestBody()branches — explicitreasoningEffortand the thinking-off fallback — across null, legacy"off"/"on", and"auto".
- LOW:
api/openai-codex-responses.tsbuildRequestBody()reasoning block and the internalapi/openai-codex-responses/reasoning.tsnormalizer. Upstream writessummary: options.reasoningSummary ?? "auto"without the null branch; a clean upstream touch of these two object literals should resolve by keeping the null-omit spread.
utils/retry.tsadds"522"to the retryable provider-error patterns. Cloudflare surfaces an origin that stopped responding asError: error code: 522(Connection timed out); the message matched no retryable pattern, so a transient gateway timeout dead-ended the turn instead of going through the existing bounded retry policy like the other 5xx statuses (500/502/503/504/524).
- LOW:
utils/retry.tsretryable provider-error status patterns.
oauth.tsnow also exportsloadAnthropicOAuthandregisterBundledOAuthFlowLoadersfromauth/oauth/load.ts(bundler-safe variable-specifier dynamic import preserved), so coding-agent extension providers can reuse the Anthropic PKCE machinery without reaching into package internals.
- Extracted
OpenAIResponsesCompatandSessionAffinityFormatfrom the oversizedtypes.tsintoopenai-responses-compat.tswhile preserving their public exports. - Added
supportsRemoteCompactionV2so verified OpenAI Responses proxies can explicitly advertise the nativecompaction_triggerrequest contract. Unknown custom proxies remain disabled by default.
api/azure-openai-responses.ts: requests withcacheRetention: "none"now omitprompt_cache_key, matching the OpenAI Responses adapter instead of silently enabling Azure prompt-cache affinity from the session id.../test/azure-openai-base-url.test.ts: pins both the existing 64-character cache-key clamp and the disabled-cache omission path.
- LOW:
api/azure-openai-responses.tsrequest payload construction.
utils/stop-details.ts:isClassifierRefusal()now accepts typed refusal/sensitive details on mixedtoolUsestops, matching Anthropic streams that finish with a policy block after emitting a tool call.- The same helper recognizes Anthropic's legacy policy-block error text when a gateway omits typed
stopDetails, while requiring the provider's full restrictions-and-Usage-Policy signature so ordinary policy documentation errors remain non-refusals. - This routes both shapes through the existing immediate pinned model-fallback path instead of executing the partial tool call or continuing on the refusing model.
api/openai-responses-shared.ts:convertResponsesMessages()andbackfillReasoningSignatures()now parse persisted reasoning signatures through a guardedparseReasoningSignature()that requires a JSON payload withtype === "reasoning". Foreign providers store non-JSON markers (Kimi's"reasoning_content") or opaque payloads (Anthropic thinking signatures) in the samethinkingSignaturefield; when such a block reaches the converter with same-model provenance (aliased/custom providers, corrupted session state), the previous unguardedJSON.parsethrew a client-sideSyntaxErroror leaked an invalid item to the API. Unparseable or non-reasoning signatures now demote to plain assistant text (empty text is dropped), mirroring the cross-model policy intransformMessages.utils/tool-call-id.ts,api/anthropic-messages.ts,api/bedrock-converse-stream.ts, andapi/google-shared.ts: the Anthropic-compatible adapters now share one collision-safe id normalizer. Over-long ids keep a readable prefix plus ashortHashof the full id instead of blind 64-char prefix truncation. OpenAI Responses tool ids run 450+ chars, and two distinct ids sharing a 64-char prefix previously collapsed into duplicate tool ids in Bedrock/Google even after the Anthropic Messages fix, corrupting tool-result pairing.api/anthropic-messages.tsbuildParams(): when a thinking-enabled request's final assistant turn containstool_usebut no leading thinking block — the normal outcome of replaying Kimi/OpenAI history, whose thinking demotes to text or drops — thinking is disabled for that request instead of failing with Anthropic's "final assistant message must start with a thinking block" 400 on every turn. Adaptive families that rejectthinking.type: "disabled"use the existing valid fallback (thinkingomitted plusoutput_config.effort: "low").../test/openai-responses-foreign-signature.test.ts,../test/anthropic-cross-model-history.test.ts,../test/bedrock-convert-messages.test.ts, and../test/google-shared-tool-call-id.test.ts: cover foreign signature demotion, genuine reasoning-item replay, cross-adapter collision freedom, and both legal thinking-degradation wire forms.
- MEDIUM:
api/openai-responses-shared.tsthinking/text branches ofconvertResponsesMessages()(text emission is now a sharedpushAssistantTextclosure) andbackfillReasoningSignatures(). - LOW:
utils/tool-call-id.ts, the three adapter imports/call sites, and the thinking-config block ofapi/anthropic-messages.tsbuildParams().
utils/retry.tsnow exportsisRetryableErrorMessage(errorMessage: string)andisRetryableAssistantErrordelegates to it. Callers that hold a thrownErrorinstead of anAssistantMessage(the compaction extension's blocking summarization path) need the same transient-vs-terminal classification to decide between degrading gracefully and surfacing loudly. No pattern changes; classification behavior is identical.
- LOW:
utils/retry.tsaroundisRetryableAssistantError.
utils/retry.tsclassifiesupstream_unavailableprovider errors as transient so the existing bounded retry policy retries Codex websocket proxy disconnects such asConnectionClosedOK.- The retry classifier and coding-agent event-contract tests pin the exact reported error through the existing retry lifecycle rather than introducing provider-specific retry behavior.
- LOW:
utils/retry.tstransient transport error patterns.
A session died permanently with a 400 invalid_request_error reading "web_search tool use with id
srvtoolu_... was found without a corresponding web_search_tool_result block". The assistant turn had persisted two
server_tool_use (web_search) provider-native blocks and no result blocks - the stream ended
between the search call and its result - and every later request replayed the unpairable halves, so
the session could never recover on its own.
Anthropic validates that each server_tool_use is followed, inside the same assistant message, by
its matching *_tool_result, and rejects the mirror case too (a result whose server_tool_use is
missing).
api/anthropic-messages.ts: assistant conversion now repairs the pairing across the whole conversation, not only inside the server-side-fallback boundary.collectProviderNativeToolPairingwalks the conversation in order, tracking which server-tool uses are still resumable: a use answered by a result in its own or the next assistant message replays (the deferred-continuation shape the API documents); a pending use survives only tool results, because user text, a tool result that registers deferred tool names (whose references serialize sibling text after the results), or another assistant turn all close the turn; and a blank user message closes nothing because it serializes to nothing. Only the unpairable halves are dropped — a closed use and a result whose use is nowhere. The predicate covers themcp_tool_useshape for when those blocks become replayable. Paired blocks,fallback, andcontainer_uploadreplay byte-for-byte as before, soencrypted_contentfidelity is untouched.utils/retry.ts: the pairing-error wording ("was found without a corresponding", anchored on the opening backtick of the result block name) joins the retryable provider-error patterns. The repaired history means the retried request is valid, so the session self-heals through the existing retry path; if it keeps failing, the error now also reaches the model-fallback chain instead of dead-ending the turn.test/anthropic-web-search-replay-encryption.test.ts: the byte-fidelity fixture gained theserver_tool_useits result belongs to. The assertion is unchanged - the fixture was simply not a shape Anthropic can accept.
api/openai-responses-shared.ts: custom Responses calls with no server item id now persist the sharedCUSTOM_TOOL_CALL_ITEM_ID_SENTINEL("custom") and recover theircustom_tool_call/custom_tool_call_outputwire types from that evidence. The recovery uses the existing freeform input serializer, preserving rawapply_patchtext during no-tool compaction and model/API replay. It never sends the sentinel as an itemid.- Active grammar metadata remains the higher-fidelity source when it is available: it continues to choose its named input property and retain real custom-call ids, while a sentinel still removes the invalid synthetic id.
- Focused AI and compaction wiremock tests pin raw-input round trips, matching custom result types, model-switch preservation, grammar precedence, and the no-invalid-id guard.
This deliberately diverges from upstream's #271 crash-only repair. That patch omitted the invalid sentinel id
but downgraded a historical freeform call to JSON function_call when the current request had no tool definitions.
Senpi's compaction path intentionally omits those definitions, so preserving the persisted freeform type is required
for type fidelity and byte-identical patch replay.
The persisted tool-call identity is decoded while constructing the provider request in packages/ai; extensions only
see the already-normalized context and cannot restore the Responses wire item type.
- HIGH: upstream owns
api/openai-responses-shared.ts'sconvertResponsesMessages()tool-call and tool-result branches and rewrote the same hunk in #271. Future upstream syncs will collide here; retain sentinel recovery, raw-input serialization, and the no-custom-id invariant when resolving.
Turning thinking off silently kept paid reasoning on for several model families, and several effort ladders degraded a requested level to a weaker wire value. Both are fixed adapter-side; the generated catalog only gained one compat fact.
Wire truth was established by probing the live Anthropic Messages endpoint before any edit
(7 families x thinking:{type:"disabled"}, plus pin/display controls, max_tokens: 16):
| probe | result |
|---|---|
thinking:{type:"disabled"} on opus-4-6 / 4-7 / 4-8 / 5, sonnet-4-6 / 5 |
200 - true disable works, kept as-is |
thinking:{type:"disabled"} on claude-fable-5 |
400 "thinking.type.disabled" is not supported for this model. Thinking defaults to adaptive mode when not specified |
no thinking + output_config:{effort:"low"} on fable-5 / opus-5 |
200 (with and without an effort beta header) |
thinking:{type:"adaptive",display:"summarized"} on opus-4-6 |
200 |
api/anthropic-messages.ts: the thinking-off branch no longer silently omits the thinking field for adaptive families that rejectdisabled. Families that acceptdisabledkeep sending it; families that cannot (encoded ascompat.supportsDisabledThinking: false) now send no thinking block plusoutput_config:{effort:"low"}, because the API defaults to adaptive thinking when the field is absent - previously "off" billed full reasoning.api/anthropic-messages.ts:ADAPTIVE_THINKING_MODEL_MARKERSgainedopus-4-8,opus-5,sonnet-5,fable-5, so models without theforceAdaptiveThinkingcompat pin (custommodels.jsonentries, third-party gateways) get adaptive effort control instead of a budget-token request.mapThinkingLevelToEffortnow floors the extended levels at the adaptive ladder's top tier viaNATIVE_XHIGH_EFFORT_MODEL_MARKERS:xhigh-> nativexhighwhere the family has it, otherwisemax;max->maxalways. It previously returnedhighfor everything except Opus 4.6/4.7, so a map-less Sonnet 4.6/5, Opus 4.8/5 or Fable 5 silently under-thought athigh.api/bedrock-converse-stream.ts:buildAdditionalModelRequestFieldsreturnedundefinedfor a thinking-off turn, which let every adaptive Claude family on Bedrock fall back to the adaptive default. It now sendsthinking:{type:"disabled"}, oroutput_config:{effort:"low"}for families that rejectdisabled; budget-based Claude still sends nothing (extended thinking is opt-in there). Its effort ladder got the samexhigh/maxfloor fix.api/anthropic-messages.ts: the "cannot disable thinking" fact is owned by code as well as the catalog (DISABLED_THINKING_REJECTING_MODEL_MARKERS+cannotDisableThinking()).models.jsonentries and third-party gateway rows carry no generated compat, so a custom Fable/Mythos model would otherwise take thedisabledbranch and get the probe-confirmed 400.api/bedrock-converse-stream.ts:supportsAdaptiveThinkingandsupportsNativeXhighEffortnow includeopus-5. Bedrock Opus 5 was classified as budget-based, so it sentthinking:{type:"enabled",budget_tokens}instead of adaptive +output_config.effort, and a thinking-off turn sent nothing at all and fell back to adaptive. It also gained the same family-marker check so application inference profiles and custom Fable rows never receivedisabled.models.tssupportsXhigh: recognizesgpt-5.6,opus-5,sonnet-5andfable-5.api/openai-completions.ts: added the missing no-map fallback ladders (Kimi K3low/high/max, DeepSeek and GLM 5.2high/max, OpenRouter DeepSeekhigh-only, MiMominimal->low/xhigh->high, Ollamalow/medium/high/max) and made an explicit catalognullsuppress the wire effort instead of forwarding the raw requested value. Applied consistently tostreamSimple, every value-bearingthinkingFormatbranch, and chat-template effort kwargs.api/openai-responses.ts,api/azure-openai-responses.ts,api/openai-codex-responses.ts: explicitmax: "max"is preserved for GPT-5.6 instead of being clamped, an explicitthinkingLevelMapnullwins for direct adapter options (including summary-default resolution), and Codex sends its catalog-directed off sentinel when agent-level off arrives as omitted reasoning.api/google-generative-ai.ts,api/google-vertex.ts: a runtime thinking-off request fell through to an enabled reasoning form (worst casethinkingBudget: 24576withincludeThoughts: trueon Gemini 2.5 Flash). BothstreamSimplepaths now route off to the adapter's disabled form.api/mistral-conversations.tswas audited and needed no change: off provably cannot reach the?? "high"fallback.scripts/generate-models.ts: Fable 5 onanthropic-messagesis now encoded ascompat.supportsDisabledThinking: falseinstead ofthinkingLevelMap.off: null. Both express "never sendthinking.type: disabled", but the compat form keepsoffa selectable level, so the UI can offer off and the provider pins the cheapest effort. Bedrock/Converse Fable rows keepoff: nullunchanged. Regenerated data therefore differs only in those fable-5 rows (plus one incidental OpenRouter price refresh).
For Fable 5 the API exposes no true off switch: thinking.type: "disabled" is rejected and an
absent thinking field means adaptive. off therefore maps to the cheapest adaptive effort rather
than zero reasoning. That is strictly better than the alternatives - before this change off was
hidden and the level clamped to the lowest selectable tier, which produced the same wire effort
while labelling it minimal. The level stays labelled off because it is the cheapest reasoning the
model can be asked for, and no other senpi surface can promise more.
The thinking-off wire shape, the effort ladder floors and the beta/compat gating all live inside the
provider request builders in packages/ai, below any extension-visible surface.
- New node-only subpath module
packages/ai/src/node/provider-scope.ts, exported as@earendil-works/pi-ai/node/provider-scope. It owns anAsyncLocalStorage<ProviderScope>plusrunWithProviderScopeandbindToProviderScope(fn)(explicit callback binding for EventEmitter/ watcher callbacks, because EventEmitter does not propagate ALS from registration time).ProviderScopecarriesactive|closedstate and a per-scope overlayMap. api-registry.tsstays browser-neutral: a synchronous scope-accessor install hook (default: none) lets the RPC host install a strict accessor. With no accessor installed, every classic path is byte-identical (browser smoke pins this). The faux fast path (getRegisteredFauxProvidershort-circuit atapi-registry.ts:78-82) consults the active scope first or is scope-keyed.- Scope-aware behavior for ALL registry operations:
getApiProvider,getApiProviders,registerApiProvider,unregisterApiProviders,clearApiProviders,resetApiProviders(compat.ts:143-147). In an active scope, resolution =session overlay → immutable builtin set— NEVER the mutable legacy global. Afterclose_sessionthe scope is closed and any lookup/mutation through it throws (no silent fallback). Reaching provider lookup in multi-session mode with NO active scope throws a diagnostic error (fail-loud, not fall-through). - The image-provider registry is scoped identically to the API-provider registry (same overlay → immutable-builtins-only resolution, same closed-scope throws semantics).
- Builtin identity semantics preserved:
getBuiltinProviderForModel(compat.ts:127-140,173) keeps reference-identity routing ingetBuiltinProviderForModel/builtinApiProviderInstanceswhile a scope holds unrelated overlay entries. - Browser-safety approach: the synchronous scope-accessor install hook keeps
packages/airoot and compat exports browser-neutral; the onlynode:async_hooksimport lives behind the node-only subpath. Root/compat stay browser-safe;npm run check:browser-smokestays green.
- Overlay → immutable-builtins-only resolution in an active scope; NEVER fall back to the mutable legacy global in multi-session mode.
- A closed scope must throw on any lookup/mutation (no silent fallback).
- Builtin identity semantics (
builtinApiProviderInstancesreference-identity routing ingetBuiltinProviderForModel) must keep working while a scope holds unrelated overlay entries. - Root/compat exports must stay browser-safe: no
node:async_hooks(or any node-only) import reachable from root or compat; the scope accessor ships only from the node-only subpath. - No new dependencies (
node:async_hooksis built-in).
- MEDIUM:
api-registry.tsscope-accessor install hook + the faux fast-path short-circuit. - LOW:
compat.tsbuiltin identity routing (additive guard only).
api/transform-messages.ts: the pairing pass now records the toolCall ids of every assistant it skips becausestopReason === "error" | "aborted"intodroppedCallIds(mirroring the existing skip condition), and the emit loop no longer emits a toolResult whosetoolCallIdis in that set — unless the id is also declared by a kept assistant (nextToolCallIndexById), which still pairs through the normal windows. Previously the errored assistant was dropped while its result (a real one, or a placeholder synthesized by the compaction pipeline'srepairOrphanedToolResults) survived, so the request carried arole:"tool"message whosetool_call_idno assistant declared; strict providers (apitopia/kimi openai-completions) reject it with400 tool_call_id ... is not found, permanently bricking compaction for the session. True orphans (id declared nowhere) and results of kept assistants are unchanged, and kept assistants' unanswered calls still get the synthetic "No result provided" result.utils/tool-pair-repair.ts:repairOrphanedToolResultsno longer synthesizes placeholder results for toolCalls declared by errored/aborted assistants (defense in depth; those assistants are dropped bytransformMessagesanyway). The coding-agent compaction copy received the identical guard; the two files remain verbatim copies.../test/transform-messages-errored-tool-results.test.ts: drop cases (errored + real result, aborted + synthesized placeholder), preservation cases (kept pair, "No result provided" synthesis, true orphan passthrough), and an id re-declared by a later kept assistant.../test/tool-pair-repair.test.ts: no synthesis for errored/aborted assistants, synthesis kept for a kept re-declaration.
- LOW:
api/transform-messages.tssecond-pass pairing loop and toolResult emit branch;utils/tool-pair-repair.tsdangling-call synthesis loop.
api/openai-responses-shared.ts: opaque output items now occupy the existing output-index slot map, soresponse.output_item.donereplaces the partialaddedpayload with the final provider item. OpenAI web-search actions commonly arrive only on the done frame; retaining the added placeholder lost the final query/action before session persistence and app-server projection.../test/openai-responses.provider-native.test.ts: covers an action-less added web-search item followed by the completed done item.
- LOW:
api/openai-responses-shared.tsoutput-slot creation andresponse.output_item.donefinalization.
api/openai-responses-shared.tsconvertResponsesMessages(): afunction_callinput item'sidis now emitted only when it begins with "fc" — the Responses API rejects anything else (Invalid 'input[N].id': 'custom'. Expected an ID that begins with 'fc'.). Custom tool calls are stored with the<call_id>|customsentinel (acustom_tool_calloutput carries no server-issued item id), so replaying them without their freeform tool registered — compaction summarization stripsfreeformfrom its tool list — previously sentid: "custom"and hard-failed the whole request, tripping the compaction circuit breaker. Omitting mirrors the existing different-model pairing-validation skip; server-issuedfc_…ids still replay unchanged.../test/openai-responses-custom-tools.test.ts: sentinel omission plus a pin that genuinefcids survive same-model replay.
- LOW:
convertResponsesMessagesfunction_call emission branch.
- Added optional typed refusal/sensitive stop details to assistant messages, preserving Anthropic classifier outcomes through streaming and faux provider errors.
- Exported
isClassifierRefusaland excluded classifier outcomes from generic same-model retry classification.
2026-07-20 - Live tool-result pairing by source position + Retry unsigned Anthropic thinking replay as text
api/transform-messages.ts: live history normalization now indexes tool results and replayable tool calls by source position. Each tool call consumes the earliest still-unconsumed matching result after its declaring assistant, emits that result adjacent to the assistant turn, or emits exactly one synthetic error result. A repeated ID establishes a new pairing window, so a delayed result cannot attach to an earlier call or be replayed twice across an intervening user turn. Aborted and errored assistant turns remain excluded.../test/transform-messages-copilot-openai-to-anthropic.test.ts: covers delayed normalized results across a user turn, partial multi-call results, reused IDs with prior orphaned results, trailing unresolved calls, and Anthropic-required tool-result adjacency.
AnthropicMessagesCompat.unsignedThinkingReplaynow explicitly controls replay of thinking blocks without a usable signature. The safe default is text replay for first-party/signing endpoints; the legacyallowEmptySignatureflag remains an alias for Kimi-compatible empty-signature replay.- When an endpoint rejects an empty replay signature with a pre-stream HTTP 400 containing
Invalid signature in thinking block, the Anthropic adapter rebuilds the request with unsigned thinking demoted to text and retries exactly once. That learned fallback is scoped to the session, base URL, and model ID, without mutating sharedModelmetadata. - Signed and redacted thinking replay remains byte-for-byte/native-state preserving. Non-signature 400s and errors after SSE content begins do not retry.
api/transform-messages.ts../test/transform-messages-copilot-openai-to-anthropic.test.tstypes.tsapi/anthropic-messages.ts../test/anthropic-unsigned-thinking-replay.test.ts
- LOW:
api/transform-messages.tssecond-pass tool-result normalization. - LOW:
AnthropicMessagesCompatreplay options and Anthropic request creation.
types.ts:Model.inputunion gains"video". No new message content type: video payloads ride the existingImageContentblock with avideo/*mimeType (helperisVideoMimeType()exported) to keep the message contract and the upstream merge surface unchanged.api/transform-messages.ts:downgradeUnsupportedImagesnow first replaces video-mime blocks with a placeholder for models without the"video"modality (user and toolResult content), then applies the existing image downgrade. Prevents cross-model replay from sending video blocks to providers that reject them.api/anthropic-messages.ts:convertContentBlocksand the user-message block mapping serialize video-mime blocks as{type:"video", source:{type:"base64", media_type, data}}— the wire shape the Kimi Anthropic-compatible endpoint accepts (verified against MoonshotAI/kimi-code kosong anthropic provider). The block is not in the official SDK union, so it is cast like the existingtool_referenceescape hatch.scripts/generate-models.ts+ regeneratedproviders/kimi-coding.models.ts: kimi-codingk3declaresinput: ["text", "image", "video"].
types.tsapi/transform-messages.tsapi/anthropic-messages.ts../scripts/generate-models.tsproviders/kimi-coding.models.ts(generated)../test/transform-messages-video.test.ts
- LOW:
types.tsModel.inputunion andImageContentcomment. - MEDIUM:
api/anthropic-messages.tsconvertContentBlocks/convertToolResultif upstream reworks content serialization. - LOW:
api/transform-messages.tsdowngradeUnsupportedImages.
- Added characterization + policy-table coverage for replaying mixed edit/apply_patch
history across every KnownApi: Responses targets serialize a historical apply_patch call
as
custom_tool_callwhen a freeform apply_patch is declared and asfunction_call(name preserved, JSON{input}args) otherwise; Completions/Anthropic/Google/Bedrock/ Mistral/pi-messages keep the stored name with native JSON-typed call entries. - No production change was required: existing converters already implement the name-preserving truth table. Tests pin both branches plus per-API shape assertions so a future regression cannot silently rename or drop historical patch calls.
- Truncated text-protocol tool calls were silently dropped, leaked as raw markup, or executed from a stale argument snapshot, with no public signal distinguishing a finalized (executable) call from one the parser could only partially recover. Consumers had no contract for "this tool call is incomplete; do not execute it; ask the model to retry."
ToolCallgains optionalincomplete?: trueanderrorMessage?: string, set by the text tool-call middleware when a truncated call could not be recovered. Carriers ofincompleteMUST NOT be executed; they are surfaced as a failed tool result so the model re-issues the call next turn.- The
toolcall_endmember ofAssistantMessageEventis redefined from an implicit "complete" to "finalized": atoolcall_endis executable iffincomplete !== true. Flagged ends still terminate the call (so the wrapper never holds a dangling partial) but are not executable. This is the release-note surface for the redefinition. ToolCallFormatgains"morph-xml"as the canonical id;"xml"is retained as a deprecated alias resolving to the same protocol, so existingmodels.jsonconfigs and compiled consumers ofgetProtocol("xml")keep working without a runtime normalization that rewrites stored config values.- Flagged dangling-call diagnostics always append
Re-issue the tool call with complete arguments.to parser-provided error messages without duplicating a final period. compat.tsnow publicly re-exportsgetToolCallFormat,getProtocol,transformContext, andwrapStreamWithToolCallMiddlewarefor composed providers that need the text tool-call middleware.
types.ts(ToolCall,AssistantMessageEvent.toolcall_end,OpenAICompletionsCompat.toolCallFormatdoc)tool-call-middleware/types.ts,tool-call-middleware/index.ts,tool-call-middleware/context-transformer.ts../test/tool-call-middleware/context-transformer.test.ts,../test/tool-call-middleware/stream-integration.test.ts
- The canonical
ToolCallshape, thetoolcall_endevent contract, and theToolCallFormatunion are all exported frompi-aiand consumed by standalonepi-aiclients before any coding-agent extension runs.
- LOW:
types.tsaround theToolCallandAssistantMessageEventdeclarations. - LOW:
tool-call-middleware/types.tsToolCallFormatunion andtoolcall_endvariant.
utils/tool-schema-compat.ts: Moonshot normalization now flattens a rootanyOf/oneOfof object parameter shapes into onetype: "object"schema. Properties are merged and only branch-common required fields remain. Kimi rejects a root combiner withouttype, but also rejects a sibling roottypebeside that combiner, so the union must be represented as a permissive object at the function-parameter boundary.../test/openai-completions-tool-schema-compat.test.ts: covers the realclick-style coordinate/index union and the final post-hook request payload.
- The provider adapter owns the final wire schema after payload hooks and is the only layer shared by direct Moonshot requests and custom Moonshot-compatible gateways.
- LOW:
utils/tool-schema-compat.tsif upstream expands its provider-specific schema normalizers.
api/openai-completions.ts: re-normalizes function tool parameter schemas afteronPayloadand immediately before the OpenAI SDK request. Payload hooks can replace or inject tools after the ordinaryconvertToolspass; those tools previously bypassed the Moonshot/MFJS compatibility transform and could retain a parenttypebesideanyOf, which Moonshot rejects with HTTP 400.../test/openai-completions-tool-schema-compat.test.ts: captures the real HTTP request and locks the post-hook wire shape.
before_provider_requestis exposed throughonPayload, so the provider adapter is the only layer that can validate the complete tool list after every hook has run.
- LOW:
api/openai-completions.tsaround theonPayloadcallback and final request submission.
types.ts: addedAnthropicMessagesCompat.supportsWebSearch. Default (resolved ingetAnthropicCompat): true only for the first-partyapi.anthropic.comendpoint; compatible providers and provider overrides can opt in per model viacompat.api/anthropic-messages.ts:sanitizeUnsupportedNativeToolsnow also strips hook-injected nativeweb_search_*tools when the resolved compat does not support them, mirroring the existing native computer tool guard and the OpenAI Responsesweb_search_previewcompat guard (2026-05-15). Anthropic-compatible endpoints such as kimi-coding execute the server-side search but reject the replayedserver_tool_use/web_search_tool_resultblocks on the next request (kimi-coding 400s withtool_call_id is not found), wedging the session. Namedtool_choiceis preserved when a same-name function fallback remains and removed only when the retained tool list no longer contains that choice.api/anthropic-messages.ts: same-model provider-native replay also drops web-search server-tool blocks (server_tool_usenamedweb_searchandweb_search_tool_result) when the endpoint lackssupportsWebSearch. Sessions that already recorded such blocks against an incompatible endpoint were permanently wedged — every request replayed the rejected blocks; dropping the pair loses the searched context but unwedges the session.api/anthropic-messages.ts: streaming now accumulatesinput_json_deltafor Anthropic's confirmed provider-native tool-use blocks (server_tool_useand betamcp_tool_use) and merges the parsed input into the stored raw block atcontent_block_stop(or in the abort/error finalizer for interrupted streams). Previously the block kept thecontent_block_startsnapshot (input: {}), so every same-model replay sent the server tool call with an empty input. Unknown and result-shaped blocks are never touched; their raw provider payload must remain verbatim.
types.tsapi/anthropic-messages.ts../test/anthropic-native-web-search-compat.test.ts../test/anthropic-provider-native-replay.test.ts../test/anthropic-web-search-replay-encryption.test.ts../test/anthropic.provider-native.test.ts- (see also
../../coding-agent/src/core/changes.mdfor the models.json compat schema entry)
- Extensions can inject native
web_search_*tools viabefore_provider_request; the final payload is only known after all hooks run, so the provider is the last reliable guard before SDK submission (same rationale as the OpenAI Responses guard). Provider-native block capture during streaming happens insidepi-aibefore any extension sees the message.
- MEDIUM:
api/anthropic-messages.tsaroundgetAnthropicCompat,sanitizeUnsupportedNativeTools, and thecontent_block_delta/content_block_stopstreaming handlers. - LOW:
types.tsAnthropicMessagesCompatif upstream adds more compat flags.
api/anthropic-messages.ts: same-model provider-native replay now preserves each nestedweb_search_resultitem'sencrypted_contentbyte-for-byte before sending prior server-side web search results back in the next Anthropic request. The existing same-provider/api/model boundary, fallback pruning, and cross-model dropping behavior remain unchanged.- Anthropic's current web-search contract requires
encrypted_contentto be passed back unmodified for multi-turn use. The July 8 stripping workaround was wrong under that contract: it discarded opaque provider-owned replay state after one observed 400, even though the raw session stored all seven encrypted fields and Senpi removed them during conversion.
api/anthropic-messages.ts../test/anthropic-provider-native-replay.test.ts../test/anthropic-web-search-replay-encryption.test.ts
- LOW:
api/anthropic-messages.tsaroundsanitizeReplayableAnthropicProviderNativeBlockand the provider-native replay path.
- The server-side fallback beta (
server-side-fallback-2026-06-01) emits afallbackcontent block mid-response when the serving model falls back (e.g. aclaude-fable-5refusal replaced by the fallback model). Three fixes (2026-07-02 → 2026-07-06) make replaying such turns conform to the beta's contract:fallbackwas added toREPLAYABLE_ANTHROPIC_PROVIDER_NATIVE_TYPES; dropping it on same-model replay mutated the latest assistant message's block sequence and the API rejected the next request of the turn with a 400thinking … cannot be modifiederror, wedging the session.- Blocks emitted before the final
fallbackmarker belong to the discarded attempt and are now omitted on replay; replaying them verbatim left pre-boundarytool_useblocks without matchingtool_results, rejected with 400tool_use ids were found without tool_result blocks. - An unpaired pre-boundary
server_tool_use(fallback interrupted the declined attempt before the server tool's result arrived) is also dropped; paired server-tool blocks and text still replay verbatim.
api/anthropic-messages.tstest/anthropic-provider-native-replay.test.ts
- Provider-native block replay filtering happens inside the Anthropic message transformer before any coding-agent extension can rewrite provider payloads.
- MEDIUM:
api/anthropic-messages.tsaroundREPLAYABLE_ANTHROPIC_PROVIDER_NATIVE_TYPESand the assistant-turn replay/filter path. - LOW:
test/anthropic-provider-native-replay.test.tsfixtures if upstream restructures replay tests.
api/openai-codex-responses.ts: accepted upstream zstd request-body compression for Codex Responses SSE while preserving the fork's senpi-branded Codex headers, stale response handling, service-tier support, and thinking support.utils/oauth/device-code.tsandutils/oauth/github-copilot.ts: accepted delayed GitHub Copilot device-code polling and related OAuth cleanup.- Provider model catalogs were refreshed for Copilot, Fireworks, OpenCode, Cloudflare AI Gateway, Bedrock, and related
providers while retaining fork-specific model capability metadata such as
supportsXhigh.
api/openai-codex-responses.tsproviders/amazon-bedrock.models.tsproviders/cloudflare-ai-gateway.models.tsproviders/fireworks.models.tsproviders/github-copilot.models.tsproviders/opencode-go.models.tsproviders/opencode.models.tsutils/oauth/device-code.tsutils/oauth/github-copilot.ts
- Codex SSE request compression, OAuth polling, and generated provider metadata all live inside
pi-aibefore coding-agent extensions can intercept a request or model catalog entry.
- MEDIUM:
api/openai-codex-responses.tsaround request body creation, zstd encoding, headers, and stream response handling. - LOW:
utils/oauth/device-code.tsaround polling cadence and error handling. - LOW: provider
*.models.tscatalogs when upstream regenerates model metadata.
providers/anthropic.ts: Cloudflare Anthropic routes now strip hook-injected nativecomputer_*tools afteronPayload, while preserving supported native tools such asbash_20250124andtext_editor_20250124.- Computer-use beta request headers are removed only for routes/models that reject the native computer tool.
- Added a regression matching the CF runtime error where
computer_20250124is not one of the accepted tool tags.
providers/anthropic.ts../test/anthropic-on-payload-headers.test.ts
- The failing payload can be introduced by
before_provider_request; the provider adapter is the final point that sees the complete Anthropic request before SDK submission.
- LOW: native-tool sanitization helpers near request metadata extraction.
providers/anthropic.ts: signed Anthropicthinkingreplay now forwards the stored text exactly as-is instead of running it through local surrogate sanitization. Anthropic treats signed and redacted thinking blocks as protected replay state; rewriting them can make the next tool-result request fail withthinking/redacted_thinkingmodification errors.providers/transform-messages.ts: same-model preserved provider-state blocks are now copied rather than shared, and redacted thinking remains same-model only. Cross-model transforms still drop opaque redacted thinking state.- Added regressions for signed thinking replay, redacted thinking replay, immutable same-model transforms, cross-model redacted thinking dropping, and retry context behavior after a failed assistant turn.
providers/anthropic.tsproviders/transform-messages.ts../test/anthropic-thinking-disable.test.ts../test/transform-messages-copilot-openai-to-anthropic.test.ts../../coding-agent/test/suite/regressions/0000-anthropic-partial-thinking-replay.test.ts
- Anthropic protected thinking is serialized inside
pi-ai's provider adapter after history transformation. Extensions and coding-agent retry logic cannot safely repair a signed block once the provider has normalized or shared it.
- LOW:
convertMessages()signed/redacted thinking block serialization inproviders/anthropic.ts. - LOW: same-model
preserveProviderStatebranches inproviders/transform-messages.ts.
providers/openai-responses.ts: afteronPayloadhooks run, custom OpenAI Responses endpoints now strip nativeweb_search_preview/web_search_preview_2025_03_11tools, the matchingtool_choice, andweb_search_call.action.sourcesincludes unlesscompat.supportsWebSearchPreviewexplicitly opts in. Officialapi.openai.comendpoints keep the existing default support.types.ts: addedOpenAIResponsesCompat.supportsWebSearchPreviewso custom providers can declare support when they really pass OpenAI-native Responses tools through.- Added regression coverage for hook-injected native web search on a custom Responses endpoint and the explicit opt-in path.
providers/openai-responses.tstypes.ts../test/openai-responses-web-search-compat.test.ts
- External or user extensions can add provider-native tools through
before_provider_request; the final OpenAI Responses payload is only known after all hooks have run. The provider is the last reliable guard before SDK submission.
- LOW:
streamOpenAIResponses()request construction immediately after theonPayloadcallback. - LOW:
OpenAIResponsesCompatif upstream adds more Responses compatibility flags.
providers/anthropic.ts: afteronPayloadhooks run, Opus 4.6 and 4.7 requests now strip Anthropic's legacy nativecomputer_20250124tool and removecomputer-use-2025-01-24from hook-addedanthropic-betarequest headers.- Added a regression to cover extension-style payload mutation where a native computer tool is injected alongside another supported native tool. The supported tool and remaining beta header survive; the Opus-rejected computer tool does not reach the SDK request body.
providers/anthropic.ts../test/anthropic-on-payload-headers.test.ts
- External or user extensions can add provider-native tools through
before_provider_request; the final provider payload is only known after all hooks have run. The Anthropic provider is the last reliable guard before SDK submission.
- LOW:
streamAnthropic()request construction immediately after theonPayloadcallback. - LOW: native-tool sanitization helpers near request metadata extraction.
providers/anthropic.ts: when anonPayloadhook returns request metadata fields (headers/extra_body), the provider now forwards string-valuedheadersthrough the Anthropic SDK request options and strips both metadata keys from the JSON request body.- Added a regression test for native computer-use extensions that inject
computer_20250124plusanthropic-beta: computer-use-2025-01-24frombefore_provider_request. Previously the tool reached Anthropic but the beta header did not, producing a 400 wherecomputer_20250124was not among the accepted tool tags.
providers/anthropic.ts../test/anthropic-on-payload-headers.test.ts
- Extensions can mutate the provider payload via
before_provider_request, but Anthropic SDK request headers are assembled insidepi-ai. The provider must explicitly lift hook-added header metadata into SDK request options afteronPayloadruns.
- LOW:
streamAnthropic()request construction around theonPayloadcallback and SDKmessages.create()options.
providers/openai-codex-responses.tsbuildBaseCodexHeaders(): changed the hardcodedoriginator: "pi"and theUser-Agent: "pi (…)"string to"senpi". Upstream chose"pi"as the Codex CLI identity; this fork's identity issenpi.utils/oauth/openai-codex.tscreateAuthorizationFlow(): changed the defaultoriginatorparameter from"pi"to"senpi"and updated the JSDoc onloginOpenAICodexaccordingly. Callers can still pass their own originator.
providers/openai-codex-responses.tsutils/oauth/openai-codex.ts
- The originator + User-Agent headers are built inside
pi-ai's Codex header constructor before the request leaves the library. Coding-agent extensions cannot intercept the header construction step.
- LOW:
buildBaseCodexHeaders()body (3 lines) and theoriginatordefault parameter / JSDoc increateAuthorizationFlow.
- Added
utils/tool-pair-repair.tsto centralize bidirectionaltool_use/tool_resultpairing repair inpi-ai. - This supports both coding-agent builtin extensions and external
pi-aiconsumers that do not load coding-agent extensions.
utils/tool-pair-repair.ts
- Extension code alone is not available to standalone
pi-aiconsumers, so this shared history repair logic must live inpi-ai.
- None expected; this is a new additive utility file.
- Added optional freeform grammar metadata to tool types.
- Updated OpenAI Responses request/history conversion to emit and preserve
custom/custom_tool_call/custom_tool_call_outputitems for freeform tools. This was required to match Codex GPTapply_patchbehavior instead of falling back to JSON function tools.
types.tsproviders/openai-responses-shared.ts
pi-aionly serialized tools as JSON function definitions for OpenAI Responses, so a builtin extension could not produce Codex-compatible freeform tools without core provider changes.
types.tstool modelproviders/openai-responses-shared.tsrequest/stream conversion paths
- Added
claude-opus-4-7to the Anthropic provider and its Bedrock cross-region profiles (anthropic.*,us.*,eu.*,global.*) so Opus 4.7 is available in the catalog and survives re-runs ofgenerate-models.ts. - Expanded
supportsXhigh()to includeopus-4-7/opus-4.7so the coding agent exposesxhighfor Opus 4.7 users. - Expanded Anthropic adaptive thinking support (
supportsAdaptiveThinking) and effort mapping (mapThinkingLevelToEffort) for Opus 4.7:xhighnow maps to the native"xhigh"effort on Opus 4.7 (Anthropic's newest tier).xhighstill maps to"max"on Opus 4.6 (Opus 4.6 doesn't support nativexhigh).- Added explicit
"max"to the effort type union for future use. - Cast through
{ output_config?: { effort: AnthropicEffort } }while the @anthropic-ai/sdk upstream types still reject"xhigh".
- Added
StreamOptions.extraBodyfor pass-through custom body fields (matches opencode's provideroptions). Wired it through every builtin provider's payload builder (anthropic,openai-responses,openai-completions,azure-openai-responses,openai-codex-responses,mistral,google,google-vertex,google-gemini-cli,amazon-bedrock). A sharedapplyExtraBodyhelper and per-provider reserved-key sets live inproviders/simple-options.tsto prevent users from overriding provider-managed fields (model id, messages, stream flag, etc.).
types.tsmodels.tsmodels.generated.tsproviders/simple-options.tsproviders/anthropic.tsproviders/openai-responses.tsproviders/openai-completions.tsproviders/azure-openai-responses.tsproviders/openai-codex-responses.tsproviders/mistral.tsproviders/google.tsproviders/google-vertex.tsproviders/google-gemini-cli.tsproviders/amazon-bedrock.tsscripts/generate-models.ts
- Extra-body pass-through has to be read inside each provider's payload builder (pre-
onPayloadhook), which is corepi-aiterritory; a coding-agent extension cannot reach intopi-aiprovider payload construction. - Opus 4.7 model metadata, xhigh capability detection, and adaptive thinking effort mapping all live in
pi-ai.supportsXhigh,supportsAdaptiveThinking, andmapThinkingLevelToEffortare internal to the provider. - Running
generate-models.tsregeneratesmodels.generated.tsfrom models.dev; the Opus 4.7 override block ensures the upstream regeneration keeps our entry.
scripts/generate-models.tsOpus override block (lines around the 4.6 additions).src/providers/anthropic.tssupportsAdaptiveThinking/mapThinkingLevelToEffort/AnthropicEffort.src/providers/simple-options.ts(new exports).src/models.tssupportsXhigh.src/types.tsStreamOptions.extraBody.
- Exposed Anthropic's native
"max"effort through the unifiedThinkingLevelsurface:StreamOptions.reasoning: "max"maps tomaxon Opus 4.6/4.7, clamps tohighon other adaptive models, and falls back to thehighbudget on budget-based Anthropic models. OpenAI-style providers clampmaxtoxhighon xhigh-capable models (GPT-5.2/5.3/5.4) and tohighotherwise via a newclampMaxForOpenAIhelper. - Extended the per-provider reserved-key sets so
extraBodycannot stomp library-managed fields. New reservations includemetadata,temperature,store,stream_options,provider,providerOptions,tool_stream,prompt_cache_key,prompt_cache_retention,service_tier,promptMode,requestMetadata. The Google reserved set now targets the innerconfigobject (which the @google/genai SDK serializes as the HTTP request body) withsystemInstruction/tools/toolConfig/generationConfig/thinkingConfig/responseMimeType/responseSchema/cachedContent/abortSignal/httpOptionsreserved. - Merged Google and Google Vertex
extraBodyintoparams.configinstead of the top-levelGenerateContentParametersso user-supplied fields actually reach the Gemini wire (the SDK does not serialize root-level unknown fields). - Updated
adjustMaxTokensForThinking/clampReasoningto accept the new"max"level without crashing on missing budget entries.
src/types.ts(ThinkingLevel adds"max")src/providers/simple-options.ts(addedclampMaxForOpenAI, tightened reserved sets, Google reservations targetconfig)src/providers/anthropic.ts(mapThinkingLevelToEffortnativemaxcase, JSDoc refresh, reserved keysmetadata+temperature)src/providers/openai-responses.ts,openai-completions.ts,openai-codex-responses.ts,azure-openai-responses.ts(useclampMaxForOpenAIon xhigh-capable models)src/providers/amazon-bedrock.ts(budget table addsmax, clampmaxon budget-based path)src/providers/google.ts,google-vertex.ts(merge extraBody intoconfig)
- The
ThinkingLevelunion, provider effort mapping, and reserved-key sets all live insidepi-ai. Exposing"max"to the coding agent requires widening the shared union and updating every provider's payload builder and option-derivation logic.
src/types.tsThinkingLevelunion.- Each provider's
streamSimple<Provider>reasoning mapping block. src/providers/simple-options.tsexported reserved-key sets.
ThinkingContentnow exposes optionalstartedAtandendedAtepoch-millisecond fields. The agent loop stamps these at provider stream-event receipt on a best-effort basis, allowing consumers to measure individual reasoning-block duration without changing provider event contracts.
- LOW:
src/types.tsThinkingContentinterface.
utils/server-fallback-receipt.ts: new module parsing Anthropic'sfallbackcontent block and thefallback_messageentry inusage.iterations, plus the refusal-shaped rewrite applied to an aborted turn.types.ts:StreamOptions.abortServerSideFallback(opt-in), inherited bySimpleStreamOptionsandAnthropicOptions;api/simple-options.tsforwards it throughbuildBaseOptions.api/anthropic-messages.ts: a provider-localAbortController, merged with the caller signal throughcombineAbortSignals, is passed to the request and the SSE iterator. A receipt block or afallback_messageusage entry aborts it and finalizes the turn as{stopReason:"error", stopDetails:{type:"refusal"}}with empty content plusserver_fallback_abortedandbilling_incomplete_after_client_abortdiagnostics. A caller abort is checked first and always wins.
Detection has to happen inside the Anthropic SSE loop while the stream is still open; nothing outside the provider can stop reading a response mid-flight.
- MEDIUM:
api/anthropic-messages.tsstreaming event loop and request-option construction. - LOW:
types.tsStreamOptions,api/simple-options.tsbuildBaseOptionsfield list,index.tsexport list.