From fc260e9ce5d5dc1a1134b76e575c3aa4526d2a3e Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:53:57 +0900 Subject: [PATCH 01/14] fix(cursor): normalize localized native-shell routing claims --- src/adapters/cursor/envelope-echo.ts | 4 +- structure/adapters/registry.md | 2 +- structure/data-planes/inbound-compat.md | 2 +- structure/providers/chat-compat.md | 2 +- structure/providers/cursor.md | 2 + structure/runtime.md | 2 +- structure/transports/byte-accounting.md | 2 +- structure/transports/inventory.md | 2 +- structure/transports/responses.md | 2 +- .../cursor/cursor-envelope-echo-retry.test.ts | 47 ++++++++++++++++--- 10 files changed, 51 insertions(+), 16 deletions(-) diff --git a/src/adapters/cursor/envelope-echo.ts b/src/adapters/cursor/envelope-echo.ts index ffaf3df193..6848106a0b 100644 --- a/src/adapters/cursor/envelope-echo.ts +++ b/src/adapters/cursor/envelope-echo.ts @@ -217,7 +217,7 @@ export type RoutingCommentaryDecision = | { kind: "flush" } | { kind: "hallucination" }; -const ROUTING_NATIVE_TOOL_NAME = /\b(shell|read|grep|list|bash)\b/giu; +const ROUTING_NATIVE_TOOL_NAME = /\b(shell|read|grep|list|bash)\b|네이티브\s*(?:셸|쉘)/giu; const ROUTING_TOOL_HINT = /(?:\b(?:shell|read|grep|list|bash)\b|exec_command|shell_command|브리지|네이티브\s*(?:셸|쉘))/iu; const ROUTING_FAILURE_CLAIM = @@ -276,7 +276,7 @@ export class CursorRoutingCommentarySniffer { private matchesHallucination(): boolean { if (!ROUTING_FAILURE_CLAIM.test(this.buffered)) return false; const nativeTools = new Set( - [...this.buffered.matchAll(ROUTING_NATIVE_TOOL_NAME)].map(match => match[1]?.toLowerCase()), + [...this.buffered.matchAll(ROUTING_NATIVE_TOOL_NAME)].map(match => match[1]?.toLowerCase() ?? "shell"), ); if (nativeTools.size === 0) return false; return ROUTING_REDIRECT_CLAIM.test(this.buffered) || nativeTools.size >= 2; diff --git a/structure/adapters/registry.md b/structure/adapters/registry.md index 908633f265..a607422084 100644 --- a/structure/adapters/registry.md +++ b/structure/adapters/registry.md @@ -1,7 +1,7 @@ # Adapter Registry Authority The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Cursor's localized native-shell names follow the [routing-commentary guard contract](../providers/cursor.md#cursor-native-exec). Shared parsing and streaming follow the [request-copy](../transports/byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](../transports/byte-accounting.md#stream-buffer-accounting) contracts. diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index aa9aa15f52..9fe6ae2919 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -1,7 +1,7 @@ # Inbound Compatibility Surfaces The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Cursor's localized native-shell names follow the [routing-commentary guard contract](../providers/cursor.md#cursor-native-exec). ## Standalone file transcription diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 5ee17ed807..ea9630cb3b 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -1,7 +1,7 @@ # Chat Provider Compatibility The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Cursor's localized native-shell names follow the [routing-commentary guard contract](cursor.md#cursor-native-exec). Native Codex Spark-specific request exceptions are absent. General Lite and namespace repair remain shared [Responses compatibility](../transports/responses.md#responses-httpsse), including diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index 5ae38028d8..b95af45a94 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -28,6 +28,8 @@ that survives the transport budget: unified Desktop `exec` as well as the legacy unified `exec` keeps its own schema and is surfaced back to Codex as a client tool. It must never fall through to the separate native-local-exec dispatcher. +In external Cursor turns using code mode or shell aliases, the bounded leading-commentary guard in `src/adapters/cursor/envelope-echo.ts` counts `Shell`, `네이티브 셸`, and `네이티브 쉘` as one `shell` identity, including spacing variants and names split across text deltas. Rejection still requires a failure claim plus either an explicit redirect or at least two distinct native-tool identities; repeated aliases alone do not count as multiple tools. + > Decision record: [ADR-0048](../decisions/ADR-0048-cursor-native-exec.md) ## Cursor parameterized models diff --git a/structure/runtime.md b/structure/runtime.md index e4a50ba3f8..c882249a5c 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -1,7 +1,7 @@ # Runtime The configuration-only [plaintext V2 contract](subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Cursor's localized native-shell names follow the [routing-commentary guard contract](providers/cursor.md#cursor-native-exec). Chat request serialization owns the destination-scoped [OpenCode Go instruction ordering](providers/chat-compat.md#opencode-go-chronological-instructions); diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index e758afeaf2..67c63745be 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -2,7 +2,7 @@ How opencodex measures request and stream bytes without allocating copies solely to count them. These contracts are shared by request parsing, SSE rewriting, the provider adapters and -the translator budget, which is why so many documents link here rather than restating them. +the translator budget, which is why so many documents link here rather than restating them. Cursor's localized native-shell names follow the [routing-commentary guard contract](../providers/cursor.md#cursor-native-exec). ## Request-copy accounting diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 8c04f7b633..7366843bf9 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -1,7 +1,7 @@ # Transport Inventory The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Cursor's localized native-shell names follow the [routing-commentary guard contract](../providers/cursor.md#cursor-native-exec). The Chat adapter's [OpenCode Go instruction ordering](../providers/chat-compat.md#opencode-go-chronological-instructions) changes translated message placement only; endpoint selection and transport stay with their existing owners. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 1bc2b4de0d..d56b52d168 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -1,7 +1,7 @@ # Responses Transport The configuration-only [plaintext V2 contract](../subagents.md#plaintext-v2-agent-messages) -is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. +is scoped to canonical ChatGPT Responses forwarding; other source-area behavior described here is unchanged. Cursor's localized native-shell names follow the [routing-commentary guard contract](../providers/cursor.md#cursor-native-exec). Plaintext collaboration restoration treats a null namespace as absent, rejects non-string namespace types, and restores the native namespace/name pair before HTTP/WS delivery and continuation publication. diff --git a/tests/providers/cursor/cursor-envelope-echo-retry.test.ts b/tests/providers/cursor/cursor-envelope-echo-retry.test.ts index 0470eee8c6..112cf84ad6 100644 --- a/tests/providers/cursor/cursor-envelope-echo-retry.test.ts +++ b/tests/providers/cursor/cursor-envelope-echo-retry.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { createCursorAdapter as createCursorAdapterProduction } from "../../../src/adapters/cursor"; import { CURSOR_ECHO_RETRY_CONTINUATION_TEXT, + CURSOR_ROUTING_COMMENTARY_RETRY_TEXT, CursorEnvelopeEchoSniffer, CursorMidstreamEchoObserver, CursorRoutingCommentarySniffer, @@ -210,6 +211,34 @@ describe("cursor external output quarantine + corrective retry (devlog 260826 ga expect(contextFree.finish().kind).toBe("flush"); }); + test.each(["네이티브 셸", "네이티브 쉘", "네이티브셸", "네이티브\t쉘"])( + "localized shell requires a redirect or a second distinct tool (%s)", nativeShell => { + const redirect = new CursorRoutingCommentarySniffer(); + expect(redirect.feed(`${nativeShell}이 차단되어 exec_command로 전환합니다.`).kind).toBe("hallucination"); + const distinct = new CursorRoutingCommentarySniffer(); + expect(distinct.feed(`${nativeShell}과 Read가 모두 unavailable 상태입니다.`).kind).toBe("hallucination"); + }, + ); + + test("localized shell detection spans native-name and redirect delta boundaries", () => { + const sniffer = new CursorRoutingCommentarySniffer(); + for (const fragment of ["네이", "티브 ", "쉘이 차단되어 ", "exec_"]) { + expect(sniffer.feed(fragment).kind).toBe("hold"); + } + expect(sniffer.feed("command로 전환합니다.").kind).toBe("hallucination"); + }); + + test.each([ + "네이티브 셸이 unavailable 상태입니다.", + "네이티브 셸과 네이티브 쉘이 모두 blocked 상태입니다.", + "Shell과 네이티브 셸이 모두 blocked 상태입니다.", + "SHELL과 네이티브쉘, 네이티브 셸이 모두 unavailable 상태입니다.", + ])("shell aliases alone do not fabricate two distinct tools (%s)", text => { + const sniffer = new CursorRoutingCommentarySniffer(); + expect(sniffer.feed(text).kind).toBe("hold"); + expect(sniffer.finish().kind).toBe("flush"); + }); + test("external tool-result echo retries once with the corrective action text and no leaked envelope", async () => { const { factory, runRequests, attempts } = echoingThenHealthyTransportFactory(); const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { createTransport: factory as never }); @@ -318,7 +347,11 @@ describe("cursor external output quarantine + corrective retry (devlog 260826 ga expect(runRequests[1]?.echoRetryContinuationText).toBeDefined(); }); - test("code-mode routing commentary that invents a blocked native Shell is quarantined and retried", async () => { + test.each([ + { fragments: ["`Shell` 경로는 차단됐으니 exec_command 경로로 읽겠습니다."] }, + { fragments: ["네이", "티브 셸은 차단됐으니 ", "exec_command 경로로 읽겠습니다."] }, + { fragments: ["네이티브", "쉘은 차단됐으니 ", "exec_command 경로로 읽겠습니다."] }, + ])("code-mode routing commentary is quarantined and retried once (%j)", async ({ fragments }) => { let attempt = 0; const runRequests: CursorRunRequest[] = []; const factory = () => ({ @@ -326,10 +359,9 @@ describe("cursor external output quarantine + corrective retry (devlog 260826 ga runRequests.push(request); attempt += 1; if (attempt === 1) { - yield { - type: "text", - text: "`Shell` 경로는 또 같은 문구로 차단됐으니, 통과가 확인된 `exec_command` 경로로 읽겠습니다.", - } satisfies CursorServerMessage; + for (const text of fragments) { + yield { type: "text", text } satisfies CursorServerMessage; + } } else { yield { type: "text", text: "READ_OK" } satisfies CursorServerMessage; } @@ -359,7 +391,8 @@ describe("cursor external output quarantine + corrective retry (devlog 260826 ga const text = events.filter(e => e.type === "text_delta").map(e => (e as { text: string }).text).join(""); expect(attempt).toBe(2); expect(text).toBe("READ_OK"); - expect(text).not.toContain("Shell"); - expect(runRequests[1]?.echoRetryContinuationText).toBeDefined(); + expect(text).not.toContain(fragments.join("")); + expect(runRequests).toHaveLength(2); + expect(runRequests[1]?.echoRetryContinuationText).toBe(CURSOR_ROUTING_COMMENTARY_RETRY_TEXT); }); }); From 588c33395d088ca258343ab21e365e41882fdd60 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:05:25 +0900 Subject: [PATCH 02/14] fix(cursor): bound Korean shell aliases at token boundaries --- src/adapters/cursor/envelope-echo.ts | 2 +- structure/providers/cursor.md | 2 +- .../cursor/cursor-envelope-echo-retry.test.ts | 10 +++++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/adapters/cursor/envelope-echo.ts b/src/adapters/cursor/envelope-echo.ts index 6848106a0b..962d93e170 100644 --- a/src/adapters/cursor/envelope-echo.ts +++ b/src/adapters/cursor/envelope-echo.ts @@ -217,7 +217,7 @@ export type RoutingCommentaryDecision = | { kind: "flush" } | { kind: "hallucination" }; -const ROUTING_NATIVE_TOOL_NAME = /\b(shell|read|grep|list|bash)\b|네이티브\s*(?:셸|쉘)/giu; +const ROUTING_NATIVE_TOOL_NAME = /\b(shell|read|grep|list|bash)\b|(? Decision record: [ADR-0048](../decisions/ADR-0048-cursor-native-exec.md) diff --git a/tests/providers/cursor/cursor-envelope-echo-retry.test.ts b/tests/providers/cursor/cursor-envelope-echo-retry.test.ts index 112cf84ad6..abb6e9fde4 100644 --- a/tests/providers/cursor/cursor-envelope-echo-retry.test.ts +++ b/tests/providers/cursor/cursor-envelope-echo-retry.test.ts @@ -211,7 +211,7 @@ describe("cursor external output quarantine + corrective retry (devlog 260826 ga expect(contextFree.finish().kind).toBe("flush"); }); - test.each(["네이티브 셸", "네이티브 쉘", "네이티브셸", "네이티브\t쉘"])( + test.each(["네이티브 셸", "네이티브 쉘", "네이티브셸", "네이티브\t쉘", "“네이티브 셸”", "(네이티브쉘)"])( "localized shell requires a redirect or a second distinct tool (%s)", nativeShell => { const redirect = new CursorRoutingCommentarySniffer(); expect(redirect.feed(`${nativeShell}이 차단되어 exec_command로 전환합니다.`).kind).toBe("hallucination"); @@ -220,6 +220,14 @@ describe("cursor external output quarantine + corrective retry (devlog 260826 ga }, ); + test.each(["비네이티브 셸", "비네이티브쉘", "x네이티브 셸", "_네이티브쉘", "1네이티브 셸", "a\u0301네이티브 셸"])( + "embedded Korean shell wording does not fabricate a second tool (%s)", nativeShell => { + const sniffer = new CursorRoutingCommentarySniffer(); + expect(sniffer.feed(`${nativeShell} 관련 Read가 unavailable 상태입니다.`).kind).toBe("hold"); + expect(sniffer.finish().kind).toBe("flush"); + }, + ); + test("localized shell detection spans native-name and redirect delta boundaries", () => { const sniffer = new CursorRoutingCommentarySniffer(); for (const fragment of ["네이", "티브 ", "쉘이 차단되어 ", "exec_"]) { From 11e343e5f877b21aa6f0b2ce52048baf0e34d788 Mon Sep 17 00:00:00 2001 From: agentHits <140916359+agentHits@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:59:59 -0400 Subject: [PATCH 03/14] fix(google): allow structured output for Gemini models on Cloud Code Assist - Lift blanket rejection on Cloud Code Assist for Gemini models (modelId starting with gemini-) - Route structured output into generationConfig.responseMimeType and responseJsonSchema inside envelope.request - Retain explicit fail-closed rejection for non-Gemini models (such as Claude) served through Cloud Code Assist - Keep existing refusals for image-capable models and schemaless json_schema - Update structure/providers/google.md and tests/adapters/google/google-structured-output.test.ts --- src/adapters/google.ts | 14 ++++----- structure/providers/google.md | 9 +++--- .../google/google-structured-output.test.ts | 29 +++++++++++++++++-- 3 files changed, 38 insertions(+), 14 deletions(-) diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 9617c1ac10..8829dc0784 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -796,14 +796,14 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte // body, URL or credential. const requestedTextFormat = parsed.options.textFormat; if (requestedTextFormat) { - if (provider.googleMode === "cloud-code-assist") { - // Not implemented or verified by opencodex for the Cloud Code Assist envelope, - // including Claude models served through it. This is not a claim that the - // upstream cannot do it — silence would return unconstrained prose as success, - // which is the failure this fix exists to remove. + if (provider.googleMode === "cloud-code-assist" && !parsed.modelId.startsWith("gemini-")) { + // Not implemented by opencodex for non-Gemini models (including Claude) + // served through the Cloud Code Assist envelope. This is not a claim that + // the upstream cannot do it — silence would return unconstrained prose as success, + // which is the failure this refusal exists to prevent. throw new Error( - "google cloud-code-assist structured output is not implemented by opencodex — " - + "remove response_format or route this model through AI Studio or Vertex", + "google cloud-code-assist structured output is not implemented by opencodex for non-Gemini models — " + + "remove response_format or route this model through a direct provider", ); } if (isImageCapableModel(parsed.modelId)) { diff --git a/structure/providers/google.md b/structure/providers/google.md index a403777ee3..21d22fc15c 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -62,12 +62,13 @@ The schema is carried verbatim. `sanitizeGeminiToolParameters` narrows a schema the function-declaration subset and must never be applied to a caller-authored output schema. `compileGenerationConfig` in `google-wire-compiler.ts` is a whitelist, so both keys are listed there as well; setting them in the adapter alone would drop them -before the wire. +before the wire. On Cloud Code Assist, Gemini models carry these same keys inside +`envelope.request.generationConfig`. Three cases refuse explicitly rather than dropping the constraint silently: -cloud-code-assist, which opencodex does not implement or verify for this field -(including Claude models served through that envelope — this is not a claim about -what the upstream can do); an image-capable model, whose `responseModalities` +non-Gemini models on Cloud Code Assist (such as Claude models served through that +envelope), which opencodex does not implement or verify for this field (this is not +a claim about what the upstream can do); an image-capable model, whose `responseModalities` configuration contradicts JSON-constrained text; and a `json_schema` format carrying no schema, which would otherwise downgrade to bare JSON mode. An image-capable model with no structured-output request keeps its existing `responseModalities` behavior. diff --git a/tests/adapters/google/google-structured-output.test.ts b/tests/adapters/google/google-structured-output.test.ts index f569b0adc3..c2b845085f 100644 --- a/tests/adapters/google/google-structured-output.test.ts +++ b/tests/adapters/google/google-structured-output.test.ts @@ -17,7 +17,7 @@ import type { OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; const aiStudio = { adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", apiKey: "key" } as unknown as OcxProviderConfig; const vertex = { adapter: "google", googleMode: "vertex", baseUrl: "https://aiplatform.googleapis.com", apiKey: "key" } as unknown as OcxProviderConfig; -const cca = { adapter: "google", googleMode: "cloud-code-assist", baseUrl: "https://cloudcode-pa.googleapis.com", apiKey: "token" } as unknown as OcxProviderConfig; +const cca = { adapter: "google", googleMode: "cloud-code-assist", baseUrl: "https://cloudcode-pa.googleapis.com", apiKey: "token", project: "test-project" } as unknown as OcxProviderConfig; const SCHEMA = { type: "object", @@ -57,6 +57,27 @@ describe("F3 Google structured output reaches the generateContent wire", () => { expect(config.responseJsonSchema).toEqual(SCHEMA); }); + test("Gemini-on-CCA carries responseMimeType and responseJsonSchema inside envelope.request", async () => { + const { body } = await createGoogleAdapter(cca).buildRequest( + parsed({ type: "json_schema", name: "answer", schema: SCHEMA, strict: true }), + ); + const envelope = JSON.parse(typeof body === "string" ? body : JSON.stringify(body)) as Record; + + expect(envelope.generationConfig).toBeUndefined(); + expect(envelope.request?.generationConfig?.responseMimeType).toBe("application/json"); + expect(envelope.request?.generationConfig?.responseJsonSchema).toEqual(SCHEMA); + expect(envelope.request?.generationConfig?.responseSchema).toBeUndefined(); + }); + + test("json_object on Cloud Code Assist sets only responseMimeType in envelope.request", async () => { + const { body } = await createGoogleAdapter(cca).buildRequest(parsed({ type: "json_object" })); + const envelope = JSON.parse(typeof body === "string" ? body : JSON.stringify(body)) as Record; + + expect(envelope.generationConfig).toBeUndefined(); + expect(envelope.request?.generationConfig?.responseMimeType).toBe("application/json"); + expect(envelope.request?.generationConfig?.responseJsonSchema).toBeUndefined(); + }); + test("the schema survives compilation byte-for-byte, unsanitized", async () => { const nested = { type: "object", @@ -86,8 +107,10 @@ describe("F3 Google structured output reaches the generateContent wire", () => { }); describe("F3 unsupported modes refuse explicitly instead of dropping the schema", () => { - test("cloud-code-assist reports that opencodex does not implement it", async () => { - const promise = createGoogleAdapter(cca).buildRequest(parsed({ type: "json_schema", schema: SCHEMA })); + test("Claude-on-CCA with textFormat reports that opencodex does not implement it", async () => { + const promise = createGoogleAdapter(cca).buildRequest( + parsed({ type: "json_schema", schema: SCHEMA }, "claude-3-7-sonnet"), + ); await expect(promise).rejects.toThrow(/not implemented by opencodex/); }); From 4931e858031ee7696cd3340bc9cbd56aeac5b6a7 Mon Sep 17 00:00:00 2001 From: "wentao.ma2" Date: Tue, 15 Sep 2026 11:28:35 +0800 Subject: [PATCH 04/14] fix(kiro): send native reasoning effort for the GPT-5.6 family and replay its blob on the right field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gpt-5.6-luna` and `gpt-5.6-terra` were missing from `KIRO_NATIVE_EFFORT_FIELDS`, so a request asking for `low`/`medium`/`high`/`max` reached Kiro with the emulated `` prompt and no `additionalModelRequestFields.reasoning.effort` at all. Both models accept the native field on the live runtime. The encrypted reasoning blob those models return also arrives on `reasoningContentEvent.signature`, not `redactedContent`, and its `.KTR~~…` value is not base64. The adapter read `redactedContent` only — a member none of the thirteen captures sent (all thirteen carried `{signature, text}`) — so the blob was dropped and the next turn had no previous reasoning to replay; sending that value on `redactedContent` instead comes back HTTP 400 `REQUEST_BODY_INVALID` ("Improperly formed request"). The blob now carries the field it arrived on (a `signature:` tag) from the adapter event through the `ocxr1:` envelope to `assistantResponseMessage.reasoningContent`, and is replayed verbatim on that member. Provider data cannot forge the tag: the other member is base64, whose alphabet has no colon. Measured on the live runtime against one fixed hard prompt, HTTP 200 throughout: - luna's reasoning blob 5,130 chars at native `low`, 16,686 at `medium`, 30,670 at `high` and 48,594 at `max`; a bare prompt with no effort signal returned 13,118, and `gpt-5.6-sol`'s native `max` cross-checked at 30,498. - The emulated tag channel that used to serve these models: 21,202 (`low`) and 28,302 (`max`) — between native `medium` and `high`, never reaching native `max`. - terra, two repetitions each: 11,758 / 17,598 bare against 34,590 / 38,106 at native `max`. - Replay A/B on one captured luna blob: `{signature: …}` 200, `{redactedContent: …}` 400 `com.amazon.kiro.runtimeservice#ValidationException / REQUEST_BODY_INVALID`. The new assertions live in `tests/providers/kiro/kiro-reasoning-roundtrip.test.ts`, next to the round-trip they belong to, because `kiro-adapter.test.ts` and `kiro-stream.test.ts` both sit at their file-size-ratchet cap and a baselined file may not grow by a single line (`tests/fixtures/file-size-baseline.json`). `kiro-adapter.test.ts` still extends its existing unsupported-effort loop to luna and terra, which rewrites one line and leaves the cap intact. Verification: - `bun run typecheck` - `bun test tests/providers/kiro` — 439 pass / 0 fail - `bun test tests/ci-workflows/file-size-ratchet.test.ts` — 6 pass / 0 fail - `bun run structure:check`, `bun run privacy:scan` --- .../src/content/docs/fr/reference/adapters.md | 2 +- .../src/content/docs/ja/reference/adapters.md | 6 +- .../src/content/docs/ko/reference/adapters.md | 6 +- .../src/content/docs/reference/adapters.md | 7 +- .../src/content/docs/ru/reference/adapters.md | 4 +- .../src/content/docs/tr/reference/adapters.md | 4 +- .../content/docs/zh-cn/reference/adapters.md | 6 +- .../content/docs/zh-tw/reference/adapters.md | 6 +- src/adapters/kiro-events.ts | 34 +++-- src/adapters/kiro/payload.ts | 13 +- src/adapters/kiro/reasoning.ts | 57 ++++++- src/adapters/kiro/stream.ts | 10 +- src/adapters/kiro/wire.ts | 3 +- src/providers/kiro-models.ts | 7 +- src/responses/reasoning-envelope.ts | 9 +- src/types/request.ts | 13 +- structure/providers/kiro.md | 36 +++-- tests/providers/kiro/kiro-adapter.test.ts | 2 +- .../kiro/kiro-reasoning-roundtrip.test.ts | 142 ++++++++++++++++++ 19 files changed, 303 insertions(+), 64 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/adapters.md b/docs-site/src/content/docs/fr/reference/adapters.md index 04ff6ccd44..dc28832ca7 100644 --- a/docs-site/src/content/docs/fr/reference/adapters.md +++ b/docs-site/src/content/docs/fr/reference/adapters.md @@ -131,7 +131,7 @@ Si Kiro s’arrête sans appeler l’outil d’achèvement, l’adaptateur effec ### Effort de raisonnement -`gpt-5.6-sol` et `claude-opus-5` prennent en charge nativement un niveau d’effort vérifié, mais chaque famille de modèles nomme différemment le champ de la requête. La valeur sélectionnée `low`, `medium`, `high`, `xhigh` ou `max` est envoyée dans `additionalModelRequestFields.reasoning.effort` pour `gpt-5.6-sol`, et dans `additionalModelRequestFields.output_config.effort` pour `claude-opus-5`. Les autres modèles Kiro utilisent actuellement un raisonnement émulé : opencodex convertit le niveau choisi en instructions de réflexion bornées dans le contenu utilisateur, car leur champ d’effort natif n’a pas été vérifié. La présence d’un contrôle d’effort annoncé sur ces modèles ne prouve donc pas la prise en charge native du raisonnement en amont. +La famille GPT-5.6 de Kiro (`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`) et `claude-opus-5` prennent en charge nativement un niveau d’effort vérifié, mais chaque famille de modèles nomme différemment le champ de la requête. La valeur sélectionnée `low`, `medium`, `high`, `xhigh` ou `max` est envoyée dans `additionalModelRequestFields.reasoning.effort` pour les modèles GPT-5.6, et dans `additionalModelRequestFields.output_config.effort` pour `claude-opus-5`. Les autres modèles Kiro utilisent actuellement un raisonnement émulé : opencodex convertit le niveau choisi en instructions de réflexion bornées dans le contenu utilisateur, car leur champ d’effort natif n’a pas été vérifié. La présence d’un contrôle d’effort annoncé sur ces modèles ne prouve donc pas la prise en charge native du raisonnement en amont. ## `cursor` diff --git a/docs-site/src/content/docs/ja/reference/adapters.md b/docs-site/src/content/docs/ja/reference/adapters.md index f1276511b1..2191cd41dd 100644 --- a/docs-site/src/content/docs/ja/reference/adapters.md +++ b/docs-site/src/content/docs/ja/reference/adapters.md @@ -154,9 +154,9 @@ filtered incomplete になります。実際のツール呼び出しを伴わな ### Reasoning effort -`gpt-5.6-sol` と `claude-opus-5` はネイティブ effort をサポートし、リクエストフィールド名が異なります。 -`low` / `medium` / `high` / `xhigh` / `max` は、前者では -`additionalModelRequestFields.reasoning.effort`、後者では `output_config.effort` として送信されます。 +`gpt-5.6-sol` / `gpt-5.6-terra` / `gpt-5.6-luna` と `claude-opus-5` はネイティブ effort をサポートし、リクエストフィールド名が異なります。 +`low` / `medium` / `high` / `xhigh` / `max` は、GPT-5.6 系では +`additionalModelRequestFields.reasoning.effort`、`claude-opus-5` では `output_config.effort` として送信されます。 ## `cursor` diff --git a/docs-site/src/content/docs/ko/reference/adapters.md b/docs-site/src/content/docs/ko/reference/adapters.md index 83aeaf2dd3..4ff2cc0b37 100644 --- a/docs-site/src/content/docs/ko/reference/adapters.md +++ b/docs-site/src/content/docs/ko/reference/adapters.md @@ -167,9 +167,9 @@ commentary로 유지하고 비공개 완료 툴을 한 번 검증합니다. ### Reasoning effort -`gpt-5.6-sol`과 `claude-opus-5`는 네이티브 effort를 지원하며 요청 필드 이름이 다릅니다. -`low` / `medium` / `high` / `xhigh` / `max` 값은 각각 -`additionalModelRequestFields.reasoning.effort`와 `output_config.effort`로 전송됩니다. +`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`와 `claude-opus-5`는 네이티브 effort를 지원하며 요청 필드 이름이 다릅니다. +`low` / `medium` / `high` / `xhigh` / `max` 값은 GPT-5.6 계열에서는 +`additionalModelRequestFields.reasoning.effort`, `claude-opus-5`에서는 `output_config.effort`로 전송됩니다. ## `cursor` diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 715555f847..f5e2310624 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -364,9 +364,10 @@ important than cosmetic de-duplication. Tool-free requests retain normal text co ### Reasoning effort -`gpt-5.6-sol` and `claude-opus-5` have verified native effort support, and each model family names -the request field differently. A selected `low`, `medium`, `high`, `xhigh`, or `max` value is sent -as `additionalModelRequestFields.reasoning.effort` for `gpt-5.6-sol` and as +The Kiro GPT-5.6 family (`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`) and `claude-opus-5` have +verified native effort support, and each model family names the request field differently. A +selected `low`, `medium`, `high`, `xhigh`, or `max` value is sent as +`additionalModelRequestFields.reasoning.effort` for the GPT-5.6 models and as `additionalModelRequestFields.output_config.effort` for `claude-opus-5`. Other Kiro models currently use emulated reasoning: opencodex converts the selected level into bounded thinking instructions in the user content because their native effort field has not been verified. Do not interpret an diff --git a/docs-site/src/content/docs/ru/reference/adapters.md b/docs-site/src/content/docs/ru/reference/adapters.md index d2d1f53de9..9961210917 100644 --- a/docs-site/src/content/docs/ru/reference/adapters.md +++ b/docs-site/src/content/docs/ru/reference/adapters.md @@ -189,9 +189,9 @@ incomplete. `TOOL_USE` без фактического вызова инстру ### Reasoning effort -`gpt-5.6-sol` и `claude-opus-5` поддерживают нативный effort, но называют поле запроса по-разному. +Модели семейства GPT-5.6 (`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`) и `claude-opus-5` поддерживают нативный effort, но называют поле запроса по-разному. Значения `low` / `medium` / `high` / `xhigh` / `max` отправляются как -`additionalModelRequestFields.reasoning.effort` и `output_config.effort` соответственно. +`additionalModelRequestFields.reasoning.effort` для моделей GPT-5.6 и `output_config.effort` для `claude-opus-5`. ## `cursor` diff --git a/docs-site/src/content/docs/tr/reference/adapters.md b/docs-site/src/content/docs/tr/reference/adapters.md index 6167bfe6df..876d050b04 100644 --- a/docs-site/src/content/docs/tr/reference/adapters.md +++ b/docs-site/src/content/docs/tr/reference/adapters.md @@ -268,9 +268,9 @@ tam olarak tekrarlasa bile, çünkü aşama doğruluğu kozmetik tekilleştirmed ### Akıl yürütme çabası -`gpt-5.6-sol` ve `claude-opus-5` doğrulanmış yerel çaba desteğine sahiptir ve +Kiro'nun GPT-5.6 ailesi (`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`) ve `claude-opus-5` doğrulanmış yerel çaba desteğine sahiptir ve her model ailesi istek alanını farklı şekilde adlandırır. Seçilen `low`, -`medium`, `high`, `xhigh` veya `max` değeri `gpt-5.6-sol` için +`medium`, `high`, `xhigh` veya `max` değeri GPT-5.6 modelleri için `additionalModelRequestFields.reasoning.effort` olarak ve `claude-opus-5` için `additionalModelRequestFields.output_config.effort` olarak gönderilir. Diğer Kiro modelleri şu anda öykünülmüş akıl yürütme kullanır: opencodex yerel çaba diff --git a/docs-site/src/content/docs/zh-cn/reference/adapters.md b/docs-site/src/content/docs/zh-cn/reference/adapters.md index e111743887..6c68bf3526 100644 --- a/docs-site/src/content/docs/zh-cn/reference/adapters.md +++ b/docs-site/src/content/docs/zh-cn/reference/adapters.md @@ -154,9 +154,9 @@ Kiro 的 assistant 文本本身没有可靠的回合结束标记,但终止的 ### Reasoning effort -`gpt-5.6-sol` 和 `claude-opus-5` 支持原生 effort,且请求字段名不同。`low` / `medium` / `high` / -`xhigh` / `max` 分别通过 `additionalModelRequestFields.reasoning.effort` 和 -`output_config.effort` 发送。 +`gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna` 和 `claude-opus-5` 支持原生 effort,且请求字段名不同。`low` / `medium` / `high` / +`xhigh` / `max` 在 GPT-5.6 系列中通过 `additionalModelRequestFields.reasoning.effort` 发送, +在 `claude-opus-5` 上通过 `output_config.effort` 发送。 ## `cursor` diff --git a/docs-site/src/content/docs/zh-tw/reference/adapters.md b/docs-site/src/content/docs/zh-tw/reference/adapters.md index c155c8ae51..90cb9f8a3f 100644 --- a/docs-site/src/content/docs/zh-tw/reference/adapters.md +++ b/docs-site/src/content/docs/zh-tw/reference/adapters.md @@ -145,9 +145,9 @@ Kiro 的 assistant 文字本身沒有可靠的回合結束標記,但終止的 ### Reasoning effort -`gpt-5.6-sol` 和 `claude-opus-5` 支援原生 effort,且請求欄位名不同。`low` / `medium` / `high` / -`xhigh` / `max` 分別透過 `additionalModelRequestFields.reasoning.effort` 和 -`output_config.effort` 傳送。 +`gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna` 和 `claude-opus-5` 支援原生 effort,且請求欄位名不同。`low` / `medium` / `high` / +`xhigh` / `max` 在 GPT-5.6 系列中透過 `additionalModelRequestFields.reasoning.effort` 傳送, +在 `claude-opus-5` 上透過 `output_config.effort` 傳送。 ## `cursor` diff --git a/src/adapters/kiro-events.ts b/src/adapters/kiro-events.ts index 3662d8caaa..730ad2a2c4 100644 --- a/src/adapters/kiro-events.ts +++ b/src/adapters/kiro-events.ts @@ -3,7 +3,7 @@ import { kiroTruncationReason } from "./kiro-truncation"; export type ParsedKiroEvent = | { type: "content"; data?: string; modelId?: string } - | { type: "reasoning"; data?: string; redactedContent?: string } + | { type: "reasoning"; data?: string; signature?: string; redactedContent?: string } | { type: "context_usage"; contextUsagePercentage: number } | { type: "tool"; name?: string; toolUseId?: string; input?: string; stop?: boolean } | { type: "truncation"; data: string } @@ -138,18 +138,26 @@ export function parseKiroEvent(eventType: string, payload: Uint8Array): ParsedKi : {}), }; case "reasoningContentEvent": - // `text` is plaintext reasoning; `redactedContent` is the encrypted blob the GPT-5.6 family - // (sol/terra/luna) actually returns — they never send `text`. Keyed off the wire field, not - // the model id. Both may be absent on a bare event. - return { - type: "reasoning", - ...(optionalString(eventType, parsed, "text") !== undefined - ? { data: optionalString(eventType, parsed, "text") } - : {}), - ...(optionalString(eventType, parsed, "redactedContent") !== undefined - ? { redactedContent: optionalString(eventType, parsed, "redactedContent") } - : {}), - }; + // `text` is plaintext reasoning; the GPT-5.6 family (sol/terra/luna) instead returns an + // encrypted blob, and the field it arrives on has to be replayed unchanged (see + // kiro/reasoning.ts): `signature` carries the `.KTR~~…` value verbatim and is what every + // capture of those models sent, while `redactedContent` — the base64 shape a capture has + // never shown — stays accepted for any model that sends it. Keyed off the wire field, not the + // model id. Any of the three may be absent on a bare event. + { + const text = optionalString(eventType, parsed, "text"); + const signature = optionalString(eventType, parsed, "signature"); + const redacted = optionalString(eventType, parsed, "redactedContent"); + return { + type: "reasoning", + ...(text !== undefined ? { data: text } : {}), + ...(signature !== undefined + ? { signature } + : redacted !== undefined + ? { redactedContent: redacted } + : {}), + }; + } case "toolUseEvent": return { type: "tool", diff --git a/src/adapters/kiro/payload.ts b/src/adapters/kiro/payload.ts index 4da79a9bcc..338f525d2e 100644 --- a/src/adapters/kiro/payload.ts +++ b/src/adapters/kiro/payload.ts @@ -38,7 +38,12 @@ import { validateKiroConversationState, type KiroTurn, } from "./conversation"; -import { injectKiroThinkingTags, kiroNativeEffortField, KIRO_NATIVE_EFFORTS } from "./reasoning"; +import { + injectKiroThinkingTags, + kiroNativeEffortField, + kiroReasoningContent, + KIRO_NATIVE_EFFORTS, +} from "./reasoning"; import { kiroPayloadMessages, userContentText } from "./usage"; import { kiroToolWireNames, @@ -388,7 +393,11 @@ export function buildKiroPayload( assistantResponseMessage: { content: turn.content, ...(turn.toolUses.length > 0 ? { toolUses: turn.toolUses } : {}), - ...(turn.redactedReasoning ? { reasoningContent: { redactedContent: turn.redactedReasoning } } : {}), + // Replayed on the field it was received on: the GPT-5.6 signature is not base64 and is + // rejected when sent as `redactedContent`. + ...(turn.redactedReasoning + ? { reasoningContent: kiroReasoningContent(turn.redactedReasoning) } + : {}), }, } : { diff --git a/src/adapters/kiro/reasoning.ts b/src/adapters/kiro/reasoning.ts index c218bf1233..0441986f10 100644 --- a/src/adapters/kiro/reasoning.ts +++ b/src/adapters/kiro/reasoning.ts @@ -4,10 +4,25 @@ import type { OcxParsedRequest } from "../../types"; export type KiroReasoningMode = "native" | "emulated"; // Kiro takes a verified native effort field for these models, and each model family names it -// differently: the Sol-only `reasoning.effort` versus the Claude-specific `output_config.effort`. -// Models absent from this table fall back to emulated thinking instructions. +// differently: the GPT-5.6 family's `reasoning.effort` versus the Claude-specific +// `output_config.effort`. Models absent from this table fall back to emulated thinking +// instructions. +// +// The GPT-5.6 entries are measured against the live runtime rather than inferred from the vendor +// schema: the field is accepted (HTTP 200) and the encrypted reasoning blob that comes back grows +// with the effort. On one fixed hard prompt — a primality search plus a 20-bit recurrence count — +// luna's blob measured 5,130 chars at `low`, 16,686 at `medium`, 30,670 at `high` and 48,594 at +// `max`, against 13,118 with no effort signal at all; terra's measured 34,590 and 38,106 at native +// `max` against 11,758 and 17,598 bare, two repetitions each. The channel this replaces — the +// emulated `` tag block, which was all those models used to receive — measured +// 21,202 (`low`) and 28,302 (`max`) for luna, i.e. between that model's native `medium` and +// `high`, never reaching native `max`. `gpt-5.6-sol`'s native `max` cross-checked at 30,498 on the +// same prompt. Terra's absence from this table was therefore an omission rather than a capability +// difference: what the earlier Sol-only scope recorded was not reproducible here. export const KIRO_NATIVE_EFFORT_FIELDS: Record = { "gpt-5.6-sol": "reasoning", + "gpt-5.6-terra": "reasoning", + "gpt-5.6-luna": "reasoning", "claude-opus-5": "output_config", }; @@ -54,3 +69,41 @@ export function injectKiroThinkingTags(content: string, parsed: OcxParsedRequest content, ].join("\n"); } + +/** + * The blob from a Kiro `reasoningContentEvent` has two possible homes on a replayed assistant + * turn, and the wire validates the SHAPE of each rather than its content: `signature` takes the + * emitted string verbatim, while `redactedContent` is a base64 member. The `.KTR~~…` value every + * GPT-5.6 capture returns is NOT valid base64, which is exactly why replaying it as + * `redactedContent` — what this proxy did before the field was measured — came back as + * REQUEST_BODY_INVALID ("Improperly formed request"). + * + * The blob travels as ONE opaque string: adapter event, `ocxr1:` reasoning envelope, then + * `OcxAssistantMessage.kiroRedactedReasoning`. The field it arrived on therefore rides that same + * string, instead of a second parallel value that could drift from it. Provider data cannot forge + * the tag: the other channel is base64, whose alphabet has no colon. + */ +export const KIRO_REASONING_SIGNATURE_TAG = "signature:"; + +export function tagKiroReasoningBlob(field: "signature" | "redactedContent", data: string): string { + return field === "signature" ? KIRO_REASONING_SIGNATURE_TAG + data : data; +} + +/** The wire field a stored blob arrived on, and its untagged value. */ +export function splitKiroReasoningBlob(value: string): { field: "signature" | "redactedContent"; data: string } { + return value.startsWith(KIRO_REASONING_SIGNATURE_TAG) + ? { field: "signature", data: value.slice(KIRO_REASONING_SIGNATURE_TAG.length) } + : { field: "redactedContent", data: value }; +} + +/** + * The `reasoningContent` object on an `assistantResponseMessage`. Exactly one member is set: the + * wire validates the shape, so the two cannot be substituted for each other. + */ +export type KiroReasoningContent = { signature: string } | { redactedContent: string }; + +/** `reasoningContent` for a replayed `assistantResponseMessage`, carrying the blob verbatim. */ +export function kiroReasoningContent(value: string): KiroReasoningContent { + const { field, data } = splitKiroReasoningBlob(value); + return field === "signature" ? { signature: data } : { redactedContent: data }; +} diff --git a/src/adapters/kiro/stream.ts b/src/adapters/kiro/stream.ts index 1740cf8d64..d10ab1105c 100644 --- a/src/adapters/kiro/stream.ts +++ b/src/adapters/kiro/stream.ts @@ -19,6 +19,7 @@ import { noteKiroTransientThrottle } from "../kiro-retry"; import { KiroThinkingParser } from "../kiro-thinking"; import { isCompleteKiroToolInput, kiroTruncationErrorMessage } from "../kiro-truncation"; import { isValidKiroConversationId } from "../kiro-wire"; +import { tagKiroReasoningBlob } from "./reasoning"; import { estimateKiroTokens, kiroUpstreamContextWindow } from "./usage"; // Stream parsing (shared by parseStream + parseResponse) @@ -633,8 +634,13 @@ async function* parseKiroAttemptEvents( if (ev.data) { yield* emitRetained(stage({ type: "reasoning_raw_delta", text: ev.data })); } - if (ev.redactedContent) { - yield* emitRetained(stage({ type: "kiro_redacted_reasoning", data: ev.redactedContent })); + // The blob is replayed on the field it arrived on, so remember that field here — this is + // the only place that still knows it. See kiro/reasoning.ts for why the distinction is + // load-bearing rather than cosmetic. + if (ev.signature) { + yield* emitRetained(stage({ type: "kiro_redacted_reasoning", data: tagKiroReasoningBlob("signature", ev.signature) })); + } else if (ev.redactedContent) { + yield* emitRetained(stage({ type: "kiro_redacted_reasoning", data: tagKiroReasoningBlob("redactedContent", ev.redactedContent) })); } break; case "context_usage": diff --git a/src/adapters/kiro/wire.ts b/src/adapters/kiro/wire.ts index ec8c32272d..7bf91d9db5 100644 --- a/src/adapters/kiro/wire.ts +++ b/src/adapters/kiro/wire.ts @@ -1,5 +1,6 @@ import type { OcxProviderConfig } from "../../types"; import type { KiroImage } from "../kiro-images"; +import type { KiroReasoningContent } from "./reasoning"; export const AMZ_TARGET = "AmazonCodeWhispererStreamingService.GenerateAssistantResponse"; export const SDK_VERSION = "1.0.27"; @@ -51,7 +52,7 @@ export interface KiroHistoryEntry { assistantResponseMessage?: { content: string; toolUses?: KiroToolUse[]; - reasoningContent?: { redactedContent: string }; + reasoningContent?: KiroReasoningContent; }; } diff --git a/src/providers/kiro-models.ts b/src/providers/kiro-models.ts index 72063fde9b..2f8e015976 100644 --- a/src/providers/kiro-models.ts +++ b/src/providers/kiro-models.ts @@ -47,9 +47,10 @@ export const KIRO_MODEL_CONTEXT_WINDOWS: Record = { const KIRO_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; -// gpt-5.6-sol and claude-opus-5 send these values through Kiro's verified native effort fields -// (`reasoning.effort` and `output_config.effort` respectively). Other models map them to bounded -// thinking instructions until their native effort support is verified. +// The GPT-5.6 family (sol/terra/luna) and claude-opus-5 send these values through Kiro's verified +// native effort fields (`reasoning.effort` for the GPT-5.6 models, `output_config.effort` for +// claude-opus-5). Other models map them to bounded thinking instructions until their native +// effort support is verified. export const KIRO_MODEL_REASONING_EFFORTS: Record = Object.fromEntries( KIRO_MODELS.map(id => [id, KIRO_REASONING_EFFORTS]), ); diff --git a/src/responses/reasoning-envelope.ts b/src/responses/reasoning-envelope.ts index ba20e800ed..9b97dd060a 100644 --- a/src/responses/reasoning-envelope.ts +++ b/src/responses/reasoning-envelope.ts @@ -28,9 +28,12 @@ export interface ReasoningEnvelope { */ txt?: string; /** - * Kiro `reasoningContentEvent.redactedContent`: a KMS-encrypted reasoning blob that is opaque to - * the proxy. Kiro's own CLI replays it on the matching `assistantResponseMessage` to preserve - * model reasoning across turns, so it round-trips here the same way a signature does. + * Kiro's reasoning blob from `reasoningContentEvent`: a KMS-encrypted value that is opaque to the + * proxy (the GPT-5.6 family sends it as `signature`, other models as the base64 + * `redactedContent`, and the value carries a tag naming which one — see + * src/adapters/kiro/reasoning.ts). Kiro's own CLI replays it on the matching + * `assistantResponseMessage` to preserve model reasoning across turns, so it round-trips here the + * same way a signature does. */ krc?: string; } diff --git a/src/types/request.ts b/src/types/request.ts index cec8294a50..3ac5e2cbde 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -154,9 +154,11 @@ export interface OcxAssistantMessage { model?: string; timestamp: number; /** - * Kiro `reasoningContent.redactedContent` for THIS assistant turn — an opaque encrypted blob - * Kiro replays to preserve model reasoning across turns. Provider-specific and unrenderable, so - * it rides the message rather than a content part: any other adapter simply ignores it. + * Kiro's encrypted reasoning blob for THIS assistant turn — the opaque value from the turn's + * `reasoningContentEvent` (`signature` for the GPT-5.6 family, `redactedContent` for the base64 + * shape), tagged with the wire field it must be replayed on (see kiro/reasoning.ts). Kiro + * replays it to preserve model reasoning across turns. Provider-specific and unrenderable, so it + * rides the message rather than a content part: any other adapter simply ignores it. */ kiroRedactedReasoning?: string; } @@ -317,8 +319,9 @@ export type AdapterEvent = // opaque redacted_thinking blocks. Both must be replayed verbatim or tool-use turns 400. | { type: "thinking_signature"; signature: string } | { type: "redacted_thinking"; data: string } - // Kiro reasoning round-trip: the encrypted `redactedContent` blob for the CURRENT assistant turn. - // Never rendered — it only rides the reasoning item's envelope so the next request can replay it. + // Kiro reasoning round-trip: the encrypted reasoning blob for the CURRENT assistant turn, tagged + // with the wire field it arrived on. Never rendered — it only rides the reasoning item's envelope + // so the next request can replay it verbatim. | { type: "kiro_redacted_reasoning"; data: string } | { type: "reasoning_raw_delta"; text: string } | { type: "tool_call_start"; id: string; name: string; providerMetadata?: OcxProviderOpaqueToolCallMetadata } diff --git a/structure/providers/kiro.md b/structure/providers/kiro.md index 80f765adae..39494ed80e 100644 --- a/structure/providers/kiro.md +++ b/structure/providers/kiro.md @@ -31,26 +31,38 @@ raw body. > Decision record: [ADR-0061](../decisions/ADR-0061-kiro-responses-text-controls.md) -## Kiro reasoning round-trip (`redactedContent`) +## Kiro reasoning round-trip (`signature`) Kiro never returns plaintext reasoning for its **GPT-5.6 family** (`gpt-5.6-sol`, `-terra`, -`-luna`): `reasoningContentEvent` carries a KMS-encrypted `redactedContent` blob, never `text`. -Their `additionalModelRequestFieldsSchema` (`ListAvailableModels`) accepts only `reasoning.effort` -with `additionalProperties: false` — there is no display/summary opt-in, so this is the only -reasoning these models can return. Kiro's own CLI replays the blob on the matching -`assistantResponseMessage.reasoningContent` to preserve model reasoning across turns; dropping it -makes every turn restart without the previous turn's reasoning. Verified on kiro-cli 2.14.1 and -2.16.0, all three models. +`-luna`): `reasoningContentEvent` carries a KMS-encrypted blob, never `text`. It arrives on +`signature`, holding the `.KTR~~…` value verbatim, which is what every capture of those models +sent. Their `additionalModelRequestFieldsSchema` (`ListAvailableModels`) accepts only +`reasoning.effort` with `additionalProperties: false` — there is no display/summary opt-in, so this +is the only reasoning these models can return, and all three select that native field +(`KIRO_NATIVE_EFFORT_FIELDS` in `src/adapters/kiro/reasoning.ts`). Kiro's own CLI replays the blob +on the matching `assistantResponseMessage.reasoningContent` to preserve model reasoning across +turns; dropping it makes every turn restart without the previous turn's reasoning. Verified on +kiro-cli 2.14.1 and 2.16.0, all three models. + +The two members of `reasoningContent` are not interchangeable. The wire validates the shape of the +member rather than its content, and the signature is not base64 — its alphabet contains `.` and +`~` — so a blob replayed as `redactedContent` is rejected with `REQUEST_BODY_INVALID` +("Improperly formed request"). `signature` therefore takes the verbatim value and +`redactedContent` remains the home for the base64 shape another model may send. Which field a blob +arrived on is carried by the blob itself, one opaque string with a `signature:` tag, rather than by +a second value that could drift from it; provider data cannot forge the tag, because base64 has no +colon. The Claude 4.6+/5 entries advertise a different, richer contract (`thinking.type` adaptive/disabled, `thinking.display` summarized/omitted, `output_config.effort`, `max_tokens`) and are not covered by that measurement; older Claude, deepseek, minimax, glm, and qwen entries advertise no additional fields at all. The handling below keys off the wire field, not the model id, so any model that -sends `redactedContent` round-trips. +sends either member round-trips. -- The blob rides the existing `ocxr1:` envelope as `krc` (`src/responses/reasoning-envelope.ts`) on - an envelope-only reasoning item — `summary: []`, no text deltas — so it stays invisible in the - Codex app while round-tripping, exactly like the hidden-thinking path. +- The tagged blob rides the existing `ocxr1:` envelope as `krc` + (`src/responses/reasoning-envelope.ts`) on an envelope-only reasoning item — `summary: []`, no + text deltas — so it stays invisible in the Codex app while round-tripping, exactly like the + hidden-thinking path. - **Pairing is backwards.** Kiro emits `reasoningContentEvent` at the END of an assistant turn, after content AND tool calls. A `krc`-only item therefore belongs to the turn that already closed, so the parser attaches it to the PRECEDING assistant message rather than folding it into diff --git a/tests/providers/kiro/kiro-adapter.test.ts b/tests/providers/kiro/kiro-adapter.test.ts index 947d6ad740..d068f905b2 100644 --- a/tests/providers/kiro/kiro-adapter.test.ts +++ b/tests/providers/kiro/kiro-adapter.test.ts @@ -1741,7 +1741,7 @@ describe("kiro adapter — native and emulated reasoning effort", () => { }); test("native-effort models reject efforts Kiro does not accept", async () => { - for (const modelId of ["gpt-5.6-sol", "claude-opus-5"]) { + for (const modelId of ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "claude-opus-5"]) { await expect(createKiroAdapter(provider).buildRequest({ ...parsedWith([{ role: "user", content: "solve" }], undefined, modelId), options: { reasoning: "minimal" }, diff --git a/tests/providers/kiro/kiro-reasoning-roundtrip.test.ts b/tests/providers/kiro/kiro-reasoning-roundtrip.test.ts index 9cf598f372..8c1dc5cd37 100644 --- a/tests/providers/kiro/kiro-reasoning-roundtrip.test.ts +++ b/tests/providers/kiro/kiro-reasoning-roundtrip.test.ts @@ -1,9 +1,14 @@ import { describe, expect, test } from "bun:test"; +import { buildKiroPayload } from "../../../src/adapters/kiro/payload"; import { bridgeToResponsesSSE, buildResponseJSON } from "../../../src/bridge"; import { parseRequest } from "../../../src/responses/parser"; import { decodeReasoningEnvelope } from "../../../src/responses/reasoning-envelope"; import type { AdapterEvent } from "../../../src/types"; +import type { OcxProviderConfig } from "../../../src/types"; +import { createKiroAdapter as createKiroAdapterProduction } from "../../../src/adapters/kiro"; +import { encodeMessage } from "../../../src/lib/eventstream-decoder"; import { createTranslatorBudget } from "../../../src/lib/translator-budget"; +import { withTestTranslatorBudget } from "../../helpers/translator-budget"; const BLOB = "LktUUn5+ZXlKbGJtTnllWEIwYVc5dVVtVm5hVzl1SWpvaQ=="; @@ -135,3 +140,140 @@ describe("kiro redacted-reasoning round-trip (bridge → parse)", () => { expect((assistant as { kiroRedactedReasoning?: string }).kiroRedactedReasoning).toBe(BLOB); }); }); + +// The blob has two possible homes on a replayed `assistantResponseMessage`, and the wire validates +// the SHAPE of each: `signature` takes the emitted string verbatim, while `redactedContent` is a +// base64 member. The ".KTR~~…" value the GPT-5.6 family returns is not base64, which is why +// replaying it as `redactedContent` — what the proxy did before the field was measured — came back +// as REQUEST_BODY_INVALID. Which field a blob arrived on therefore has to survive the whole +// round-trip, not just the parse. +describe("kiro reasoning blob — the wire field it replays on", () => { + const SIGNATURE = ".KTR~~eyJlbmNyeXB0aW9uUmVnaW9uIjoidXMtZWFzdC0xIiwic2xvdHMiOltdfQ=="; + + interface HistoryEntry { + assistantResponseMessage?: { reasoningContent?: unknown }; + } + + /** Round-trip one blob the way Codex does — bridge, history replay, then the next Kiro body. */ + function replayedReasoningContent(blob: string): unknown { + const response = buildResponseJSON([ + { type: "text_delta", text: "the answer" }, + { type: "kiro_redacted_reasoning", data: blob }, + { type: "done", usage: { inputTokens: 1, outputTokens: 2 }, endTurn: true }, + ], "kiro/gpt-5.6-luna"); + const items = (response.output as Record[]).map(({ status: _status, ...item }) => item); + // Kiro requires the request to end with a user turn, so the replayed turn is followed by one. + const parsed = parseRequest({ + model: "kiro/gpt-5.6-luna", + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, + ...items, + { type: "message", role: "user", content: [{ type: "input_text", text: "again" }] }, + ], + }); + const { payload } = buildKiroPayload(parsed, undefined, "disabled", "ide"); + const history = (payload.conversationState as { history?: HistoryEntry[] }).history ?? []; + return history.find(entry => entry.assistantResponseMessage?.reasoningContent) + ?.assistantResponseMessage?.reasoningContent; + } + + test("a signature blob is replayed verbatim on `signature`", () => { + expect(replayedReasoningContent(`signature:${SIGNATURE}`)).toEqual({ signature: SIGNATURE }); + }); + + test("an untagged blob keeps the base64 `redactedContent` shape", () => { + expect(replayedReasoningContent(BLOB)).toEqual({ redactedContent: BLOB }); + }); + + test("the tag never reaches the wire as part of the blob", () => { + const replayed = replayedReasoningContent(`signature:${SIGNATURE}`) as { signature?: string }; + expect(replayed.signature).toBe(SIGNATURE); + expect(JSON.stringify(replayed)).not.toContain("signature:"); + }); +}); + +// The parse side is where the tag is minted, so it is pinned here rather than in +// tests/providers/kiro/kiro-stream.test.ts: that file sits at its file-size-ratchet cap +// (tests/fixtures/file-size-baseline.json), and a baselined file may not grow by one line. +// An event carrying only the signature still has to emit the blob — the GPT-5.6 family can finish +// a turn with the encrypted blob and no assistant text at all. +describe("kiro reasoning blob — the stream records the field it arrived on", () => { + const provider = { + adapter: "kiro", + baseUrl: "https://runtime.us-east-1.kiro.dev", + authMode: "oauth", + apiKey: "tok-123", + } as unknown as OcxProviderConfig; + const enc = new TextEncoder(); + const signatureFrame = (obj: unknown) => encodeMessage( + { ":message-type": "event", ":event-type": "reasoningContentEvent" }, + enc.encode(JSON.stringify(obj)), + ); + + function streamOf(...frames: Uint8Array[]): ReadableStream { + let i = 0; + return new ReadableStream({ + pull(c) { + if (i < frames.length) c.enqueue(frames[i++]); + else c.close(); + }, + }); + } + + async function parse(frame: Uint8Array): Promise { + const adapter = withTestTranslatorBudget(createKiroAdapterProduction(provider)); + const out: AdapterEvent[] = []; + for await (const event of adapter.parseStream(new Response(streamOf(frame)))) out.push(event); + return out; + } + + test("a signature blob is tagged with the field it must be replayed on", async () => { + // Every capture of the GPT-5.6 family put the blob on `signature` and left `text` as a "..." + // placeholder. That value starts with ".KTR~~", which is NOT base64, so replaying it as + // `redactedContent` — what the proxy used to send — is rejected as REQUEST_BODY_INVALID. A + // `redactedContent` event stays untagged; the untagged shape is covered above. + const signature = ".KTR~~eyJ2IjoxfQ=="; + expect(await parse(signatureFrame({ signature, text: "..." }))).toEqual([ + { type: "reasoning_raw_delta", text: "..." }, + { type: "kiro_redacted_reasoning", data: `signature:${signature}` }, + expect.objectContaining({ type: "done" }), + ]); + }); + + test("a signature-only event still yields the tagged blob", async () => { + // No assistant text means no terminal either: the blob is the whole turn, which is why the tag + // must not be conditioned on `text`. + expect((await parse(signatureFrame({ signature: ".KTR~~only" })))[0]).toEqual( + { type: "kiro_redacted_reasoning", data: "signature:.KTR~~only" }, + ); + }); +}); + +// The request side of the same story. luna and terra used to fall through to the emulated +// block, a strictly weaker signal: on one fixed hard prompt that channel landed +// between the model's native medium and high (21,202 / 28,302 chars) and never reached native max +// (48,594), while the native ladder itself ran 5,130 -> 48,594 from low to max. The whole GPT-5.6 +// family shares the field name, so all three are native now. +describe("kiro native reasoning effort — the GPT-5.6 family", () => { + function wireBody(modelId: string): Record { + const parsed = { + modelId, + stream: true, + options: { reasoning: "max", maxOutputTokens: 1000 }, + context: { messages: [{ role: "user", content: "solve" }] }, + } as unknown as Parameters[0]; + return buildKiroPayload(parsed, undefined, "disabled", "ide").payload; + } + + test("luna and terra send the native reasoning field instead of thinking tags", () => { + for (const modelId of ["gpt-5.6-luna", "gpt-5.6-terra"]) { + const body = wireBody(modelId); + expect(body.additionalModelRequestFields).toEqual({ reasoning: { effort: "max" } }); + // Native effort replaces the emulated thinking-tag prompt entirely. + const current = (body.conversationState as { + currentMessage: { userInputMessage: { content: string } }; + }).currentMessage.userInputMessage.content; + expect(current).toBe("solve"); + } + }); +}); From c68682d0c2ed89ed4e79c39b725211ebb8e95215 Mon Sep 17 00:00:00 2001 From: "wentao.ma2" Date: Tue, 15 Sep 2026 15:28:33 +0800 Subject: [PATCH 05/14] docs(kiro): spell the full claude-opus-5 effort field in translated pages CodeRabbit flagged the ja/ko/ru adapter pages for dropping the `additionalModelRequestFields` prefix on the claude-opus-5 effort field, which documents a different request shape than the English source. zh-cn and zh-tw carried the same truncation, so all five locales now name `additionalModelRequestFields.output_config.effort` exactly as the canonical page does. tr and fr were already complete. --- docs-site/src/content/docs/ja/reference/adapters.md | 2 +- docs-site/src/content/docs/ko/reference/adapters.md | 2 +- docs-site/src/content/docs/ru/reference/adapters.md | 2 +- docs-site/src/content/docs/zh-cn/reference/adapters.md | 2 +- docs-site/src/content/docs/zh-tw/reference/adapters.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/ja/reference/adapters.md b/docs-site/src/content/docs/ja/reference/adapters.md index 2191cd41dd..d9dd6fdd0b 100644 --- a/docs-site/src/content/docs/ja/reference/adapters.md +++ b/docs-site/src/content/docs/ja/reference/adapters.md @@ -156,7 +156,7 @@ filtered incomplete になります。実際のツール呼び出しを伴わな `gpt-5.6-sol` / `gpt-5.6-terra` / `gpt-5.6-luna` と `claude-opus-5` はネイティブ effort をサポートし、リクエストフィールド名が異なります。 `low` / `medium` / `high` / `xhigh` / `max` は、GPT-5.6 系では -`additionalModelRequestFields.reasoning.effort`、`claude-opus-5` では `output_config.effort` として送信されます。 +`additionalModelRequestFields.reasoning.effort`、`claude-opus-5` では `additionalModelRequestFields.output_config.effort` として送信されます。 ## `cursor` diff --git a/docs-site/src/content/docs/ko/reference/adapters.md b/docs-site/src/content/docs/ko/reference/adapters.md index 4ff2cc0b37..548977d38c 100644 --- a/docs-site/src/content/docs/ko/reference/adapters.md +++ b/docs-site/src/content/docs/ko/reference/adapters.md @@ -169,7 +169,7 @@ commentary로 유지하고 비공개 완료 툴을 한 번 검증합니다. `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`와 `claude-opus-5`는 네이티브 effort를 지원하며 요청 필드 이름이 다릅니다. `low` / `medium` / `high` / `xhigh` / `max` 값은 GPT-5.6 계열에서는 -`additionalModelRequestFields.reasoning.effort`, `claude-opus-5`에서는 `output_config.effort`로 전송됩니다. +`additionalModelRequestFields.reasoning.effort`, `claude-opus-5`에서는 `additionalModelRequestFields.output_config.effort`로 전송됩니다. ## `cursor` diff --git a/docs-site/src/content/docs/ru/reference/adapters.md b/docs-site/src/content/docs/ru/reference/adapters.md index 9961210917..62416d5671 100644 --- a/docs-site/src/content/docs/ru/reference/adapters.md +++ b/docs-site/src/content/docs/ru/reference/adapters.md @@ -191,7 +191,7 @@ incomplete. `TOOL_USE` без фактического вызова инстру Модели семейства GPT-5.6 (`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`) и `claude-opus-5` поддерживают нативный effort, но называют поле запроса по-разному. Значения `low` / `medium` / `high` / `xhigh` / `max` отправляются как -`additionalModelRequestFields.reasoning.effort` для моделей GPT-5.6 и `output_config.effort` для `claude-opus-5`. +`additionalModelRequestFields.reasoning.effort` для моделей GPT-5.6 и `additionalModelRequestFields.output_config.effort` для `claude-opus-5`. ## `cursor` diff --git a/docs-site/src/content/docs/zh-cn/reference/adapters.md b/docs-site/src/content/docs/zh-cn/reference/adapters.md index 6c68bf3526..e7f96a93e4 100644 --- a/docs-site/src/content/docs/zh-cn/reference/adapters.md +++ b/docs-site/src/content/docs/zh-cn/reference/adapters.md @@ -156,7 +156,7 @@ Kiro 的 assistant 文本本身没有可靠的回合结束标记,但终止的 `gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna` 和 `claude-opus-5` 支持原生 effort,且请求字段名不同。`low` / `medium` / `high` / `xhigh` / `max` 在 GPT-5.6 系列中通过 `additionalModelRequestFields.reasoning.effort` 发送, -在 `claude-opus-5` 上通过 `output_config.effort` 发送。 +在 `claude-opus-5` 上通过 `additionalModelRequestFields.output_config.effort` 发送。 ## `cursor` diff --git a/docs-site/src/content/docs/zh-tw/reference/adapters.md b/docs-site/src/content/docs/zh-tw/reference/adapters.md index 90cb9f8a3f..4708e2e6b1 100644 --- a/docs-site/src/content/docs/zh-tw/reference/adapters.md +++ b/docs-site/src/content/docs/zh-tw/reference/adapters.md @@ -147,7 +147,7 @@ Kiro 的 assistant 文字本身沒有可靠的回合結束標記,但終止的 `gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna` 和 `claude-opus-5` 支援原生 effort,且請求欄位名不同。`low` / `medium` / `high` / `xhigh` / `max` 在 GPT-5.6 系列中透過 `additionalModelRequestFields.reasoning.effort` 傳送, -在 `claude-opus-5` 上透過 `output_config.effort` 傳送。 +在 `claude-opus-5` 上透過 `additionalModelRequestFields.output_config.effort` 傳送。 ## `cursor` From 6c8f5d181b8aeaca47d0769f95fa61111fddccb5 Mon Sep 17 00:00:00 2001 From: agentHits <140916359+agentHits@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:59:59 -0400 Subject: [PATCH 06/14] fix(google): allow structured output for Gemini models on Cloud Code Assist - Lift blanket rejection on Cloud Code Assist for Gemini models (modelId starting with gemini-) - Route structured output into generationConfig.responseMimeType and responseJsonSchema inside envelope.request - Retain explicit fail-closed rejection for non-Gemini models (such as Claude) served through Cloud Code Assist - Keep existing refusals for image-capable models and schemaless json_schema - Update structure/providers/google.md and tests/adapters/google/google-structured-output.test.ts --- src/adapters/google.ts | 14 ++++----- structure/providers/google.md | 9 +++--- .../google/google-structured-output.test.ts | 29 +++++++++++++++++-- 3 files changed, 38 insertions(+), 14 deletions(-) diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 9617c1ac10..8829dc0784 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -796,14 +796,14 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte // body, URL or credential. const requestedTextFormat = parsed.options.textFormat; if (requestedTextFormat) { - if (provider.googleMode === "cloud-code-assist") { - // Not implemented or verified by opencodex for the Cloud Code Assist envelope, - // including Claude models served through it. This is not a claim that the - // upstream cannot do it — silence would return unconstrained prose as success, - // which is the failure this fix exists to remove. + if (provider.googleMode === "cloud-code-assist" && !parsed.modelId.startsWith("gemini-")) { + // Not implemented by opencodex for non-Gemini models (including Claude) + // served through the Cloud Code Assist envelope. This is not a claim that + // the upstream cannot do it — silence would return unconstrained prose as success, + // which is the failure this refusal exists to prevent. throw new Error( - "google cloud-code-assist structured output is not implemented by opencodex — " - + "remove response_format or route this model through AI Studio or Vertex", + "google cloud-code-assist structured output is not implemented by opencodex for non-Gemini models — " + + "remove response_format or route this model through a direct provider", ); } if (isImageCapableModel(parsed.modelId)) { diff --git a/structure/providers/google.md b/structure/providers/google.md index a403777ee3..21d22fc15c 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -62,12 +62,13 @@ The schema is carried verbatim. `sanitizeGeminiToolParameters` narrows a schema the function-declaration subset and must never be applied to a caller-authored output schema. `compileGenerationConfig` in `google-wire-compiler.ts` is a whitelist, so both keys are listed there as well; setting them in the adapter alone would drop them -before the wire. +before the wire. On Cloud Code Assist, Gemini models carry these same keys inside +`envelope.request.generationConfig`. Three cases refuse explicitly rather than dropping the constraint silently: -cloud-code-assist, which opencodex does not implement or verify for this field -(including Claude models served through that envelope — this is not a claim about -what the upstream can do); an image-capable model, whose `responseModalities` +non-Gemini models on Cloud Code Assist (such as Claude models served through that +envelope), which opencodex does not implement or verify for this field (this is not +a claim about what the upstream can do); an image-capable model, whose `responseModalities` configuration contradicts JSON-constrained text; and a `json_schema` format carrying no schema, which would otherwise downgrade to bare JSON mode. An image-capable model with no structured-output request keeps its existing `responseModalities` behavior. diff --git a/tests/adapters/google/google-structured-output.test.ts b/tests/adapters/google/google-structured-output.test.ts index f569b0adc3..c2b845085f 100644 --- a/tests/adapters/google/google-structured-output.test.ts +++ b/tests/adapters/google/google-structured-output.test.ts @@ -17,7 +17,7 @@ import type { OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; const aiStudio = { adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", apiKey: "key" } as unknown as OcxProviderConfig; const vertex = { adapter: "google", googleMode: "vertex", baseUrl: "https://aiplatform.googleapis.com", apiKey: "key" } as unknown as OcxProviderConfig; -const cca = { adapter: "google", googleMode: "cloud-code-assist", baseUrl: "https://cloudcode-pa.googleapis.com", apiKey: "token" } as unknown as OcxProviderConfig; +const cca = { adapter: "google", googleMode: "cloud-code-assist", baseUrl: "https://cloudcode-pa.googleapis.com", apiKey: "token", project: "test-project" } as unknown as OcxProviderConfig; const SCHEMA = { type: "object", @@ -57,6 +57,27 @@ describe("F3 Google structured output reaches the generateContent wire", () => { expect(config.responseJsonSchema).toEqual(SCHEMA); }); + test("Gemini-on-CCA carries responseMimeType and responseJsonSchema inside envelope.request", async () => { + const { body } = await createGoogleAdapter(cca).buildRequest( + parsed({ type: "json_schema", name: "answer", schema: SCHEMA, strict: true }), + ); + const envelope = JSON.parse(typeof body === "string" ? body : JSON.stringify(body)) as Record; + + expect(envelope.generationConfig).toBeUndefined(); + expect(envelope.request?.generationConfig?.responseMimeType).toBe("application/json"); + expect(envelope.request?.generationConfig?.responseJsonSchema).toEqual(SCHEMA); + expect(envelope.request?.generationConfig?.responseSchema).toBeUndefined(); + }); + + test("json_object on Cloud Code Assist sets only responseMimeType in envelope.request", async () => { + const { body } = await createGoogleAdapter(cca).buildRequest(parsed({ type: "json_object" })); + const envelope = JSON.parse(typeof body === "string" ? body : JSON.stringify(body)) as Record; + + expect(envelope.generationConfig).toBeUndefined(); + expect(envelope.request?.generationConfig?.responseMimeType).toBe("application/json"); + expect(envelope.request?.generationConfig?.responseJsonSchema).toBeUndefined(); + }); + test("the schema survives compilation byte-for-byte, unsanitized", async () => { const nested = { type: "object", @@ -86,8 +107,10 @@ describe("F3 Google structured output reaches the generateContent wire", () => { }); describe("F3 unsupported modes refuse explicitly instead of dropping the schema", () => { - test("cloud-code-assist reports that opencodex does not implement it", async () => { - const promise = createGoogleAdapter(cca).buildRequest(parsed({ type: "json_schema", schema: SCHEMA })); + test("Claude-on-CCA with textFormat reports that opencodex does not implement it", async () => { + const promise = createGoogleAdapter(cca).buildRequest( + parsed({ type: "json_schema", schema: SCHEMA }, "claude-3-7-sonnet"), + ); await expect(promise).rejects.toThrow(/not implemented by opencodex/); }); From ed9f0b9fefec7630337956e2a7507d717ef2c2ca Mon Sep 17 00:00:00 2001 From: Theo / Taeyoon Kang Date: Tue, 15 Sep 2026 09:00:59 +0900 Subject: [PATCH 07/14] fix(providers): declare Anthropic image input capabilities Missing modelInputModalities on both Anthropic registry entries caused Aside, Pi and GJC exports to fall back to text-only input. Seed the known Claude models once and preserve explicit operator overrides through existing enrichment. Add registry and production catalog-to-client regression coverage for both auth flows. Update provider documentation and mapped architecture notes. Refs #4667 --- .../src/content/docs/guides/providers.md | 8 +++++ src/providers/registry/entries-core.ts | 3 ++ src/providers/registry/model-seeds.ts | 4 +++ structure/providers/xai-grok.md | 2 ++ structure/runtime.md | 6 ++++ structure/subagents.md | 2 ++ structure/transports/inventory.md | 2 ++ .../provider-registry-parity.test.ts | 21 +++++++++++ .../management-client-config-route.test.ts | 35 +++++++++++++++++++ 9 files changed, 83 insertions(+) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index f45f653c39..7883fc4b59 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -64,6 +64,14 @@ Shipped v1 configs migrate automatically to marker 2 and one option-aware row. T is retained once at `~/.opencodex/config.json.pre-openai-tiers-v2.bak`; restore it with `cp ~/.opencodex/config.json.pre-openai-tiers-v2.bak ~/.opencodex/config.json`. +## Anthropic image input + +The built-in Claude model seeds advertise text and image input for both `anthropic` (OAuth) and +`anthropic-apikey`, consistent with [Anthropic's model overview](https://platform.claude.com/docs/en/models/overview). +Explicit per-model input-modality overrides remain authoritative; unknown models are not assumed +image-capable. After updating opencodex, regenerate or refresh the client configuration managed by +opencodex so clients receive the updated image capability metadata. + ## Auth modes Provider configs accept three `authMode` values (`key` is the default). The built-in registry also diff --git a/src/providers/registry/entries-core.ts b/src/providers/registry/entries-core.ts index 32e5cc2d95..07689dfdcb 100644 --- a/src/providers/registry/entries-core.ts +++ b/src/providers/registry/entries-core.ts @@ -17,6 +17,7 @@ import type { ProviderRegistryEntry } from "./types"; import { ANTHROPIC_MODELS, ANTHROPIC_MODEL_CONTEXT_WINDOWS, + ANTHROPIC_MODEL_INPUT_MODALITIES, ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, ANTHROPIC_MODEL_REASONING_EFFORTS, ZAI_GLM_52_REASONING_EFFORTS, @@ -383,6 +384,7 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ note: "Log in with your Claude account", models: [...ANTHROPIC_MODELS], modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS }, + modelInputModalities: { ...ANTHROPIC_MODEL_INPUT_MODALITIES }, modelReasoningEfforts: { ...ANTHROPIC_MODEL_REASONING_EFFORTS }, // Codex omits max_output_tokens; without a provider budget the Anthropic adapter // falls back to 8192, which truncates long answers with stop_reason=max_tokens. @@ -403,6 +405,7 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ models: [...ANTHROPIC_MODELS], liveModels: true, modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS }, + modelInputModalities: { ...ANTHROPIC_MODEL_INPUT_MODALITIES }, modelReasoningEfforts: { ...ANTHROPIC_MODEL_REASONING_EFFORTS }, defaultMaxOutputTokens: ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, defaultModel: "claude-sonnet-5", diff --git a/src/providers/registry/model-seeds.ts b/src/providers/registry/model-seeds.ts index bcc0ae6932..94e34b803a 100644 --- a/src/providers/registry/model-seeds.ts +++ b/src/providers/registry/model-seeds.ts @@ -8,6 +8,10 @@ import type { ProviderModelDiscoverySpec } from "./types"; // always on, per the official models overview and pricing page (platform.claude.com). export const ANTHROPIC_MODELS = ["claude-fable-5-1", "claude-fable-5", "claude-sonnet-5", "claude-opus-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"]; export const ANTHROPIC_MODEL_CONTEXT_WINDOWS: Record = { "claude-fable-5-1": 1_000_000, "claude-sonnet-5": 1_000_000, "claude-fable-5": 1_000_000, "claude-opus-5": 1_000_000, "claude-opus-4-8": 1_000_000, "claude-opus-4-7": 1_000_000, "claude-opus-4-6": 1_000_000, "claude-sonnet-4-6": 1_000_000, "claude-haiku-4-5": 200_000 }; +// All seeded Claude models support vision: https://platform.claude.com/docs/en/models/overview +export const ANTHROPIC_MODEL_INPUT_MODALITIES: Record = Object.fromEntries( + ANTHROPIC_MODELS.map(id => [id, ["text", "image"]]), +); // Every current Claude family accepts at least 64k output tokens (Haiku 4.5 / Sonnet 4.x // through Opus 5 and Fable 5). Anthropic caps max_tokens per model server-side, so a // larger request never over-allocates; it only stops the 8192 truncation. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index dc982c5639..2d98e7860d 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -96,6 +96,8 @@ privately to final dispatch; preliminary route selection does not inject Go-only Devin CLI credential path composition in `src/oauth/devin/cli-import.ts` follows the selected platform: Windows uses Win32 APPDATA paths, other platforms use POSIX XDG-data paths. The explicit absolute override remains verbatim; credential parsing and login behavior are unchanged. +[Anthropic seed image metadata](../runtime.md#capability-aware-image-admission) is provider-scoped; xAI model metadata and transport behavior remain unchanged. + Provider-scoped catalog hints remain isolated by provider in `src/providers/registry/entries-core.ts`. The OpenCode Go `deepseek-v4.1-flash` 1,048,576-token context hint does not change xAI model metadata or transport behavior. diff --git a/structure/runtime.md b/structure/runtime.md index bd9ebbd561..f1aa3aeae9 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -408,6 +408,12 @@ The [explicit model-capability contract](config.md#explicit-per-model-capability ## Capability-aware image admission +The `anthropic` OAuth and `anthropic-apikey` presets in `src/providers/registry/entries-core.ts` +declare `modelInputModalities: ["text", "image"]` per model for the nine Claude seeds in +`src/providers/registry/model-seeds.ts`. Existing enrichment fills missing entries while preserving +explicit operator overrides; unknown models receive no new declaration. Client eligibility filters +and Anthropic image wire handling remain unchanged. + `src/vision/plan.ts` prevents raw image bytes from reaching any target whose effective capability is positively known to exclude image input. Evidence from the resolved runtime provider and explicit operator declarations takes precedence, followed by backend-specific/registry/vendor metadata. A proven text-only target is preprocessed through the configured Vision Sidecar; a positively image-capable target receives the image directly. Genuinely unknown custom models retain the existing compatibility path rather than being guessed text-only. Canonical ChatGPT Codex forwarding uses the generated `openai-codex` capability bundle rather than the public `openai` bundle. This matters when the two backends differ: for example, the vendored metadata records `gpt-5.3-codex-spark` as text-only on `openai-codex` while the public OpenAI row lists image input. The native Chat fast path and web-search image verbalization consume the same effective-capability decision. diff --git a/structure/subagents.md b/structure/subagents.md index c0c92891f7..fa5b540acd 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -371,6 +371,8 @@ The [explicit model-capability contract](config.md#explicit-per-model-capability Exact [model input declarations](config.md#explicit-per-model-capability-declarations) now feed text-only eligibility and catalog hints; existing image-description/omission handling consumes them before the main upstream send. +[Anthropic seed image metadata](runtime.md#capability-aware-image-admission) supplies missing capability evidence; subagent selection and eligibility rules remain unchanged. + Provider-scoped approval reviewer settings are projected by the [catalog owner](catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. Renamed fixed-key providers receive [missing reasoning metadata](catalog.md#renamed-destination-reasoning-metadata) during derivation; explicit per-model entries and provider defaults retain precedence. diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index ca80373a8e..e2294daed6 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -11,6 +11,8 @@ changes translated message placement only; endpoint selection and transport stay Shared parsing and streaming follow the [request-copy](byte-accounting.md#request-copy-accounting) and [stream-buffer accounting](byte-accounting.md#stream-buffer-accounting) contracts. Response-attached WebSocket telemetry follows the [stage record identity contract](responses.md#passthrough-sse-stream-shapes-314). +[Anthropic seed image metadata](../runtime.md#capability-aware-image-admission) supplies missing capability evidence; transport selection and image wire handling remain unchanged. + ## Transport inventory The sections above cover the transports with load-bearing invariants. The rest of the transport diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index 46579009ce..0582bd5072 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -752,6 +752,27 @@ describe("provider registry parity", () => { expect(KEY_LOGIN_PROVIDERS["anthropic-apikey"].modelContextWindows).toEqual(anthropicOauth?.modelContextWindows); }); + test("Anthropic providers seed image input while preserving explicit model overrides", () => { + for (const id of ["anthropic", "anthropic-apikey"]) { + const entry = PROVIDER_REGISTRY.find(entry => entry.id === id)!; + const seed = providerConfigSeed(entry); + expect(entry.models!.length).toBeGreaterThan(0); + for (const model of entry.models!) { + expect(seed.modelInputModalities?.[model]).toEqual(["text", "image"]); + } + + const provider: OcxProviderConfig = { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + modelInputModalities: { "claude-sonnet-5": ["text"] }, + }; + enrichProviderFromRegistry(id, provider); + expect(provider.modelInputModalities?.["claude-sonnet-5"]).toEqual(["text"]); + expect(provider.modelInputModalities?.["claude-fable-5-1"]).toEqual(["text", "image"]); + expect(provider.modelInputModalities?.["unknown-model"]).toBeUndefined(); + } + }); + test("Anthropic providers advertise an effort ladder for every model on both auth flows", () => { const anthropicOauth = PROVIDER_REGISTRY.find(entry => entry.id === "anthropic"); const apiKey = KEY_LOGIN_PROVIDERS["anthropic-apikey"]; diff --git a/tests/server/management-client-config-route.test.ts b/tests/server/management-client-config-route.test.ts index 45514c9754..9c024a02a7 100644 --- a/tests/server/management-client-config-route.test.ts +++ b/tests/server/management-client-config-route.test.ts @@ -162,6 +162,41 @@ function toExportModel(row: ModelRow): ExportModel { } +describe("native Anthropic image input reaches client documents", () => { + for (const client of ["aside", "pi", "gajae"] as const) { + test.each(["anthropic", "anthropic-apikey"])(`${client} advertises image input for %s`, async (provider) => { + const config = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: provider, + providers: { + [provider]: { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + authMode: provider === "anthropic" ? "oauth" : "key", + liveModels: false, + }, + }, + } as unknown as OcxConfig; + + const models = await loadExportModels(config); + const document = buildClientConfig(client, { + baseUrl: "http://127.0.0.1:10100/v1", + config, + models, + }) as PiGeneratedConfig; + const rows = document.providers[OPENCODE_PROVIDER_ID]!.models + .filter(model => model.id.startsWith(`${provider}/claude-`)); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + expect({ id: row.id, input: row.input }).toEqual({ + id: row.id, input: ["text", "image"], + }); + } + }); + } +}); + describe("native Anthropic effort ladder reaches the Aside document", () => { /** * The end-to-end guard for the defect: native Anthropic rows used to reach Aside with no From 179aa98b090ba86ff8ee88e08c58edeef1d54b4a Mon Sep 17 00:00:00 2001 From: RHODIZ IT Date: Mon, 14 Sep 2026 13:25:27 -0500 Subject: [PATCH 08/14] fix(claude): forward done-only tool arguments Co-authored-by: RHODIZ IT --- src/claude/outbound.ts | 18 +++++++++++ .../claude-outbound.test.ts | 30 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/claude/outbound.ts b/src/claude/outbound.ts index d4e7758ee0..f281b632b7 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -212,6 +212,8 @@ interface OpenBlock { argsBuf?: string; argsBufBytes?: number; webSearchArgsEmitted?: boolean; + /** True once ordinary function-call arguments were emitted to Anthropic SSE. */ + toolArgsEmitted?: boolean; callId?: string; /** Last fixed-size reasoning identity (item + summary/content index) seen by this block. */ reasoningPartKey?: string; @@ -488,6 +490,7 @@ export function responsesSseToAnthropicSse( argsBuf: "", argsBufBytes: 0, webSearchArgsEmitted: false, + toolArgsEmitted: false, }; break; } @@ -518,6 +521,14 @@ export function responsesSseToAnthropicSse( type: "content_block_delta", index: open.index, delta: { type: "input_json_delta", partial_json: data.delta }, }); + open.toolArgsEmitted = true; + break; + } + case "response.function_call_arguments.done": { + if (!open || open.kind !== "tool_use" || open.bufferWebSearchArgs || open.toolArgsEmitted) break; + if (typeof data.arguments !== "string" || data.arguments.length === 0) break; + emit("content_block_delta", { type: "content_block_delta", index: open.index, delta: { type: "input_json_delta", partial_json: data.arguments } }); + open.toolArgsEmitted = true; break; } case "response.output_item.done": { @@ -565,6 +576,13 @@ export function responsesSseToAnthropicSse( delta: { type: "input_json_delta", partial_json: JSON.stringify(sanitizeWebSearchInput(parsed)) }, }); open.webSearchArgsEmitted = true; + } else if (!open.bufferWebSearchArgs && !open.toolArgsEmitted + && typeof item.arguments === "string" && item.arguments.length > 0) { + emit("content_block_delta", { + type: "content_block_delta", index: open.index, + delta: { type: "input_json_delta", partial_json: item.arguments }, + }); + open.toolArgsEmitted = true; } closeOpenBlock(); } diff --git a/tests/claude-integration/claude-outbound.test.ts b/tests/claude-integration/claude-outbound.test.ts index 72f7a22bdf..375fac9155 100644 --- a/tests/claude-integration/claude-outbound.test.ts +++ b/tests/claude-integration/claude-outbound.test.ts @@ -197,6 +197,21 @@ describe("claude outbound SSE", () => { expect(unspacedBudget.snapshot().currentBytes).toBe(spacedBudget.snapshot().currentBytes); }); + test("done-only function arguments reach Claude tool input", async () => { + const upstream = [ + sse("response.created", { response: { id: "resp_done", status: "in_progress" } }), + sse("response.output_item.added", { output_index: 0, item: { type: "function_call", id: "fc_done", call_id: "toolu_done", name: "Bash", arguments: "", status: "in_progress" } }), + sse("response.function_call_arguments.done", { item_id: "fc_done", output_index: 0, arguments: "{\"command\":\"printf RHODIZ_TOOL_OK\"}" }), + sse("response.output_item.done", { output_index: 0, item: { type: "function_call", id: "fc_done", call_id: "toolu_done", name: "Bash", arguments: "{\"command\":\"printf RHODIZ_TOOL_OK\"}" } }), + sse("response.completed", { response: { status: "completed", usage: { input_tokens: 10, output_tokens: 5 } } }), + ].join(""); + const events = await collectEvents(responsesSseToAnthropicSse(streamFrom(upstream), "claude-ocx-test")); + const argDeltas = events.filter(e => e.name === "content_block_delta" && e.data.delta?.type === "input_json_delta"); + expect(argDeltas).toHaveLength(1); + expect(argDeltas[0].data.delta.partial_json).toBe("{\"command\":\"printf RHODIZ_TOOL_OK\"}"); + expect(events.find(e => e.name === "content_block_start")?.data.content_block).toMatchObject({ type: "tool_use", name: "Bash", input: {} }); + }); + test("text + thinking + tool call + completed w/ usage -> exact Anthropic sequence", async () => { const upstream = [ sse("response.created", { response: { id: "resp_1", status: "in_progress" } }), @@ -255,6 +270,21 @@ describe("claude outbound SSE", () => { expect(startIndexes).toEqual([0, 1, 2]); }); + test("done-only function-call arguments reach Claude tool input", async () => { + const args = JSON.stringify({ command: "printf RHODIZ_TOOL_OK" }); + const upstream = [ + sse("response.created", { response: { id: "resp_done_args", status: "in_progress" } }), + sse("response.output_item.added", { output_index: 0, item: { type: "function_call", id: "fc_done", call_id: "toolu_done", name: "Bash", arguments: "", status: "in_progress" } }), + sse("response.function_call_arguments.done", { item_id: "fc_done", output_index: 0, arguments: args }), + sse("response.output_item.done", { output_index: 0, item: { type: "function_call", id: "fc_done", call_id: "toolu_done", name: "Bash", arguments: args, status: "completed" } }), + sse("response.completed", { response: { status: "completed", usage: { input_tokens: 1, output_tokens: 1 } } }), + ].join(""); + const events = await collectEvents(responsesSseToAnthropicSse(streamFrom(upstream), "claude-ocx-test")); + const deltas = events.filter(e => e.name === "content_block_delta" && e.data.delta?.type === "input_json_delta"); + expect(deltas).toHaveLength(1); + expect(deltas[0].data.delta.partial_json).toBe(args); + }); + test("multi-part reasoning summaries keep the JSON path's part separator", async () => { const upstream = [ sse("response.created", { response: { id: "resp_1", status: "in_progress" } }), From 1a2b4154fc9bbfcefe4133b19159658bb3f4364e Mon Sep 17 00:00:00 2001 From: AD PAO Date: Fri, 11 Sep 2026 01:22:46 +0700 Subject: [PATCH 09/14] fix(claude-desktop): expand synthetic date alias slots from 365 to 3652 Co-authored-by: AD PAO --- src/claude/desktop-profile.ts | 75 +++++++++++++++++++++++---- tests/clients/desktop-3p.test.ts | 2 +- tests/clients/desktop-profile.test.ts | 21 ++++++-- 3 files changed, 83 insertions(+), 15 deletions(-) diff --git a/src/claude/desktop-profile.ts b/src/claude/desktop-profile.ts index 20652cf34b..e35b74cb46 100644 --- a/src/claude/desktop-profile.ts +++ b/src/claude/desktop-profile.ts @@ -22,8 +22,34 @@ export interface RenderedDesktopModel extends DesktopProfileModel { supports1m: boolean; } -const DATE_ALIAS = /^claude-opus-4-8-(2026\d{4})$/; -const DAY_COUNT_2026 = 365; +// Managed-namespace date aliases run 2026-2035. The original 2026-only +// (365 slots) design failed with "all 365 encoded date slots are occupied" +// once a catalog exceeded 365 routes (stale assignments are retained by +// design, so the set only grows). Years before 2026 stay rejected: dated +// ids like `claude-opus-4-8-20250201` are real model snapshot ids, not +// managed aliases, and the inbound decoder relies on that distinction. +// Every emitted suffix stays 8 digits so modelMap date-stripping keeps +// working. +const DATE_ALIAS = /^claude-opus-4-8-(202[6-9]\d{4}|203[0-5]\d{4})$/; +const LEGACY_YEAR = 2026; +const LEGACY_DAY_COUNT = 365; +const ALIAS_FIRST_YEAR = 2026; +const ALIAS_LAST_YEAR = 2035; + +function isLeapYear(year: number): boolean { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); +} + +function daysInAliasYear(year: number): number { + return isLeapYear(year) ? 366 : 365; +} + +export const TOTAL_ALIAS_SLOTS = (() => { + let total = 0; + for (let year = ALIAS_FIRST_YEAR; year <= ALIAS_LAST_YEAR; year += 1) total += daysInAliasYear(year); + return total; +})(); +export const ALIAS_YEAR_RANGE = { first: ALIAS_FIRST_YEAR, last: ALIAS_LAST_YEAR } as const; export class DesktopProfileError extends Error { constructor(message: string, readonly path = "profile") { @@ -119,7 +145,7 @@ export function parseDesktopProfile(value: unknown): DesktopProfile { if (isRealAnthropicRoute(route)) { if (raw.alias !== routeModelId(route)) throw new DesktopProfileError("real Anthropic routes must keep their exact model id", `profile.assignments.${route}.alias`); } else if (!validDateAlias(raw.alias)) { - throw new DesktopProfileError("must be a valid claude-opus-4-8-2026MMDD alias", `profile.assignments.${route}.alias`); + throw new DesktopProfileError("must be a valid claude-opus-4-8-YYYYMMDD alias", `profile.assignments.${route}.alias`); } if (aliases.has(raw.alias)) throw new DesktopProfileError(`duplicate alias "${raw.alias}"`, `profile.assignments.${route}.alias`); aliases.add(raw.alias); @@ -144,26 +170,57 @@ export function parseDesktopProfile(value: unknown): DesktopProfile { return { version: 1, assignments, defaults, ...appliedMarkers(value) }; } -function dayOfYearAlias(dayIndex: number): string { - const date = new Date(Date.UTC(2026, 0, dayIndex + 1)); +function formatSlotDate(year: number, dayOfYear: number): string { + const date = new Date(Date.UTC(year, 0, dayOfYear)); const y = date.getUTCFullYear(); const m = String(date.getUTCMonth() + 1).padStart(2, "0"); const d = String(date.getUTCDate()).padStart(2, "0"); return `claude-opus-4-8-${y}${m}${d}`; } +// Legacy 2026 ring, byte-identical to the original allocator: the same route +// must keep resolving to the same 2026 alias it always had, and a probe over +// a nearly-full 2026 set must land on the same free date as before. +function legacyDayAlias(dayIndex: number): string { + return formatSlotDate(LEGACY_YEAR, dayIndex + 1); +} + +// Overflow ring for catalogs past 365 routes (2027-2035). Probed only after +// every legacy slot is taken, so existing profiles never shift into it. +const OVERFLOW_FIRST_YEAR = LEGACY_YEAR + 1; +const OVERFLOW_SLOT_COUNT = TOTAL_ALIAS_SLOTS - LEGACY_DAY_COUNT; + +function overflowSlotAlias(slotIndex: number): string { + let remaining = ((slotIndex % OVERFLOW_SLOT_COUNT) + OVERFLOW_SLOT_COUNT) % OVERFLOW_SLOT_COUNT; + for (let year = OVERFLOW_FIRST_YEAR; year <= ALIAS_LAST_YEAR; year += 1) { + const days = daysInAliasYear(year); + if (remaining < days) return formatSlotDate(year, remaining + 1); + remaining -= days; + } + throw new DesktopProfileError("slot index out of range", "profile.assignments"); +} + function routeStartDay(route: string): number { - return createHash("sha256").update(route).digest().readUInt32BE(0) % DAY_COUNT_2026; + return createHash("sha256").update(route).digest().readUInt32BE(0) % LEGACY_DAY_COUNT; +} + +function routeOverflowStart(route: string): number { + return createHash("sha256").update(route).digest().readUInt32BE(4) % OVERFLOW_SLOT_COUNT; } function allocateAlias(route: string, used: Set): string { if (isRealAnthropicRoute(route)) return routeModelId(route); const start = routeStartDay(route); - for (let offset = 0; offset < DAY_COUNT_2026; offset += 1) { - const alias = dayOfYearAlias((start + offset) % DAY_COUNT_2026); + for (let offset = 0; offset < LEGACY_DAY_COUNT; offset += 1) { + const alias = legacyDayAlias((start + offset) % LEGACY_DAY_COUNT); + if (!used.has(alias)) return alias; + } + const overflowStart = routeOverflowStart(route); + for (let offset = 0; offset < OVERFLOW_SLOT_COUNT; offset += 1) { + const alias = overflowSlotAlias((overflowStart + offset) % OVERFLOW_SLOT_COUNT); if (!used.has(alias)) return alias; } - throw new DesktopProfileError("all 365 encoded date slots are occupied", `profile.assignments.${route}.alias`); + throw new DesktopProfileError(`all ${TOTAL_ALIAS_SLOTS} encoded date slots are occupied`, `profile.assignments.${route}.alias`); } export function reconcileDesktopProfile( diff --git a/tests/clients/desktop-3p.test.ts b/tests/clients/desktop-3p.test.ts index cc116cb837..411d44b805 100644 --- a/tests/clients/desktop-3p.test.ts +++ b/tests/clients/desktop-3p.test.ts @@ -380,7 +380,7 @@ describe("Claude Desktop 3P models", () => { const models = generateDesktop3pModels(["gpt-5.6-sol"], routed, profile); const luna = models.find(model => model.labelOverride.includes("Luna")); expect(luna).toMatchObject({ anthropicFamilyTier: "haiku", isFamilyDefault: true, supports1m: true }); - expect(luna?.name).toMatch(/^claude-opus-4-8-2026\d{4}$/); + expect(luna?.name).toMatch(/^claude-opus-4-8-20\d{6}$/); expect(resolveDesktop3pAlias(luna!.name)).toBe("cursor/gpt-5.6-luna"); }); diff --git a/tests/clients/desktop-profile.test.ts b/tests/clients/desktop-profile.test.ts index 0aae60dc93..7aa7cdda7e 100644 --- a/tests/clients/desktop-profile.test.ts +++ b/tests/clients/desktop-profile.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { DesktopProfileError, + TOTAL_ALIAS_SLOTS, emptyDesktopProfile, moveDesktopRoute, parseDesktopProfile, @@ -49,7 +50,7 @@ describe("Claude Desktop profile", () => { expect(second).toEqual(first); expect(first.defaults.opus).toBe("anthropic/claude-fable-5"); expect(first.assignments["anthropic/claude-fable-5"]?.alias).toBe("claude-fable-5"); - expect(first.assignments["native/gpt-5.6-sol"]?.alias).toMatch(/^claude-opus-4-8-2026\d{4}$/); + expect(first.assignments["native/gpt-5.6-sol"]?.alias).toMatch(/^claude-opus-4-8-20\d{6}$/); expect(new Set(Object.values(first.assignments).map(value => value.alias)).size).toBe(3); }); @@ -96,18 +97,28 @@ describe("Claude Desktop profile", () => { expect(() => parseDesktopProfile(wrongDefault)).toThrow("empty family"); }); - test("fills all 365 encoded slots then fails without mutating the saved profile", () => { - const encoded = Array.from({ length: 365 }, (_, index) => ({ + test("fills all encoded slots then fails without mutating the saved profile", () => { + const encoded = Array.from({ length: TOTAL_ALIAS_SLOTS }, (_, index) => ({ route: `test/model-${index}`, label: `Model ${index}`, })); const full = reconcileDesktopProfile(emptyDesktopProfile(), encoded); const snapshot = structuredClone(full); - expect(Object.keys(full.assignments)).toHaveLength(365); - expect(() => reconcileDesktopProfile(full, [...encoded, { route: "test/overflow", label: "Overflow" }])).toThrow("365 encoded date slots"); + expect(Object.keys(full.assignments)).toHaveLength(TOTAL_ALIAS_SLOTS); + expect(() => reconcileDesktopProfile(full, [...encoded, { route: "test/overflow", label: "Overflow" }])).toThrow("encoded date slots"); expect(full).toEqual(snapshot); }); + test("a 366-route catalog no longer exhausts the first-year slots (regression: 365 overflow)", () => { + const encoded = Array.from({ length: 366 }, (_, index) => ({ + route: `test/model-${index}`, + label: `Model ${index}`, + })); + const profile = reconcileDesktopProfile(emptyDesktopProfile(), encoded); + expect(Object.keys(profile.assignments)).toHaveLength(366); + expect(new Set(Object.values(profile.assignments).map(value => value.alias)).size).toBe(366); + }); + // The apply route writes `appliedFingerprint`/`appliedAt` back onto the stored profile so the // GUI can show applied-vs-saved state. Every rebuild in this module must accept AND carry them: // rejecting them broke the Desktop tab outright after the first apply, and silently dropping From 24adf125bd9132c155df4e6d869a3bf0e31ba345 Mon Sep 17 00:00:00 2001 From: Theo / Taeyoon Kang Date: Tue, 15 Sep 2026 09:15:46 +0900 Subject: [PATCH 10/14] fix(clients): propagate image capabilities across integrations Preserve declared OpenClaw input modalities and emit Kimi image_in capabilities instead of losing catalog-backed vision support. Keep unknown-model defaults and schemas without an established capability field unchanged. Cover all 14 capability-aware config exporters and the Codex/Claude catalog surfaces from both Anthropic auth-provider seeds. Record the complete integration scope in the docs. Refs #4667 Co-authored-by: Theo / Taeyoon Kang --- .../src/content/docs/guides/providers.md | 8 +- src/clients/config-export.ts | 10 ++- structure/clients/integrations.md | 18 ++++ .../claude-model-info.test.ts | 27 +++++- .../catalog-input-modality-enum.test.ts | 23 +++++ .../client-config-export-new-clients.test.ts | 35 +++++++- .../config/client-config-new-clients.test.ts | 7 +- .../management-client-config-route.test.ts | 86 ++++++++++++------- 8 files changed, 171 insertions(+), 43 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 7883fc4b59..901c75b242 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -69,8 +69,12 @@ is retained once at `~/.opencodex/config.json.pre-openai-tiers-v2.bak`; restore The built-in Claude model seeds advertise text and image input for both `anthropic` (OAuth) and `anthropic-apikey`, consistent with [Anthropic's model overview](https://platform.claude.com/docs/en/models/overview). Explicit per-model input-modality overrides remain authoritative; unknown models are not assumed -image-capable. After updating opencodex, regenerate or refresh the client configuration managed by -opencodex so clients receive the updated image capability metadata. +image-capable. This applies across integrations wherever the client's configuration supports image +capability metadata: OpenClaw exports a declared `input` array, and Kimi Code exports +`capabilities: ["image_in"]` only for image-capable models. OpenClaw omits `input` when no supported +modalities are declared; Kimi omits `capabilities` for unknown or text-only models. Clients without +a supported capability field keep their existing configuration shape. After updating opencodex, +regenerate or refresh the client configuration managed by opencodex to receive the updated metadata. ## Auth modes diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index 21f0858179..0f6c756a25 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -812,6 +812,7 @@ export interface OpenclawModelEntry { id: string; name: string; contextWindow?: number; + input?: string[]; } export interface OpenclawProviderBlock { @@ -839,15 +840,15 @@ export interface KimiProviderBlock { /** * `max_context_size` is mandatory and must be positive, so a model with no * authoritative context window is omitted from the document entirely rather - * than guessed at. `capabilities` is never emitted: our catalog does not - * assert them, and Kimi's own inference works off OpenAI-style name prefixes - * that a routed selector will not match. + * than guessed at. Catalog image input becomes `image_in`; other capabilities + * are not inferred from routed model names. */ export interface KimiModelBlock { provider: string; model: string; max_context_size: number; display_name?: string; + capabilities?: ["image_in"]; } export interface KimiGeneratedConfig { @@ -974,10 +975,12 @@ function buildHermesClientConfig(ctx: ExportContext): HermesGeneratedConfig { function buildOpenclawClientConfig(ctx: ExportContext): OpenclawGeneratedConfig { const models: OpenclawModelEntry[] = normalizeExportModels(ctx.models).map(model => { const context = authoritativeContextWindow(model.contextWindow); + const input = [...new Set(model.inputModalities?.filter(value => ["text", "image", "video", "audio"].includes(value)))]; return { id: model.namespaced, name: exportModelLabel(model), ...(context !== undefined ? { contextWindow: context } : {}), + ...(input.length > 0 ? { input } : {}), }; }); const headers = proxyAdmissionHeaders(ctx.config, OPENCLAW_API_KEY_ENV_REF); @@ -1015,6 +1018,7 @@ function buildKimiClientConfig(ctx: ExportContext): KimiGeneratedConfig { model: model.namespaced, max_context_size: context, ...(model.displayName ? { display_name: model.displayName } : {}), + ...(model.inputModalities?.includes("image") ? { capabilities: ["image_in"] as ["image_in"] } : {}), }; } return { diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index eb4ace5975..3f2d3bf103 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -68,6 +68,24 @@ selector, preserving the underlying provider, model ID, modalities, limits, and False or missing metadata never causes local inference, so old or disabled remote hubs remain authoritative. Existing client configs receive the entries on export or managed refresh. +## Model input capability exports + +All registered integrations consume the shared catalog, including [Anthropic seed image metadata](../runtime.md#capability-aware-image-admission), through their existing schema-specific exports: + +| Client | Per-model output | +| --- | --- | +| OpenCode | `attachment`, `modalities.input` | +| Pi, OMP, Prime, Aside, omo, Gajae, DSH | `input` (text/image only) | +| ZCode | `modalities.input` (text/image only) | +| Cline | `modalities.input`, `supportsVision` | +| Hermes | `supports_vision` (see below) | +| OpenClaw | `input`, filtered to declared text/image/video/audio; omitted when none remain | +| Kimi Code | `capabilities: ["image_in"]` only for declared image input; omitted for unknown/text-only models | +| MiniMax Code | No per-model image capability field emitted | +| Raycast | `abilities.vision.supported` | + +No exporter infers image support from a model name. Existing client eligibility filters and ownership/refresh rules remain unchanged; exports do not add fields to schemas without a supported mapping. + ## Hermes Model Capabilities Hermes cannot infer custom-provider capabilities from its built-in registry. The OpenCodex diff --git a/tests/claude-integration/claude-model-info.test.ts b/tests/claude-integration/claude-model-info.test.ts index abff0b4db2..7794a73029 100644 --- a/tests/claude-integration/claude-model-info.test.ts +++ b/tests/claude-integration/claude-model-info.test.ts @@ -1,8 +1,33 @@ import { describe, expect, test } from "bun:test"; import { buildAnthropicModelInfos, nativeEffectiveLadder } from "../../src/claude/model-info"; -import { nativeEffortClamp } from "../../src/codex/catalog"; +import { gatherRoutedModels, nativeEffortClamp } from "../../src/codex/catalog"; describe("anthropic-flavor ModelInfo discovery entries (devlog 130 B4b)", () => { + test.each(["anthropic", "anthropic-apikey"])("%s registry image inputs reach Claude discovery aliases", async (provider) => { + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: provider, + providers: { + [provider]: { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + authMode: provider === "anthropic" ? "oauth" : "key", + liveModels: false, + }, + }, + }); + const routed = models.filter(model => model.provider === provider); + expect(routed.length).toBeGreaterThan(0); + for (const idStyle of ["readable", "desktop3p"] as const) { + const infos = buildAnthropicModelInfos([], routed, undefined, idStyle); + expect(infos.length).toBeGreaterThanOrEqual(routed.length); + expect(infos.some(info => info.id.endsWith("[1m]"))).toBe(true); + for (const info of infos) { + expect(info.capabilities.image_input.supported).toBe(true); + } + } + }); + test("routed model with adapter-reported ladder advertises exactly those rungs", () => { const [info] = buildAnthropicModelInfos([], [{ provider: "cursor", id: "gpt-5.6-luna", diff --git a/tests/codex-integration/catalog-input-modality-enum.test.ts b/tests/codex-integration/catalog-input-modality-enum.test.ts index 4d9342eba4..532f77af6b 100644 --- a/tests/codex-integration/catalog-input-modality-enum.test.ts +++ b/tests/codex-integration/catalog-input-modality-enum.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, test } from "bun:test"; import { ensureStrictCatalogFields } from "../../src/codex/catalog/parsing"; import { catalogHintsFromModelsApiItem } from "../../src/codex/catalog/provider-fetch"; +import { buildCatalogEntries, gatherRoutedModels } from "../../src/codex/catalog"; import type { OcxConfig } from "../../src/types"; /** @@ -12,6 +13,28 @@ import type { OcxConfig } from "../../src/types"; * verbatim and the Codex app reported `unknown variant 'video'` while showing zero apps. */ describe("catalog input_modalities stay inside the enum Codex accepts", () => { + test.each(["anthropic", "anthropic-apikey"])("%s registry image inputs reach the Codex catalog", async (provider) => { + const models = await gatherRoutedModels({ + port: 10100, + defaultProvider: provider, + providers: { + [provider]: { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + authMode: provider === "anthropic" ? "oauth" : "key", + liveModels: false, + }, + }, + }); + const routed = models.filter(model => model.provider === provider); + expect(routed.length).toBeGreaterThan(0); + const entries = buildCatalogEntries(null, [], routed); + expect(entries).toHaveLength(routed.length); + for (const entry of entries) { + expect(entry.input_modalities).toEqual(["text", "image"]); + } + }); + test("an out-of-enum modality is dropped rather than written through", () => { const entry = ensureStrictCatalogFields( { slug: "zenmux/meta-muse-spark-1.1", input_modalities: ["text", "image", "audio", "video"] }, diff --git a/tests/config/client-config-export-new-clients.test.ts b/tests/config/client-config-export-new-clients.test.ts index 3a9c171238..2721520de1 100644 --- a/tests/config/client-config-export-new-clients.test.ts +++ b/tests/config/client-config-export-new-clients.test.ts @@ -133,6 +133,29 @@ describe("hermes", () => { }); describe("openclaw", () => { + test("declares image input only from catalog capabilities", () => { + const doc = buildClientConfig("openclaw", ctx()) as OpenclawGeneratedConfig; + const models = doc.models.providers[OPENCODE_PROVIDER_ID]!.models; + expect(models.find(model => model.id === "anthropic/claude-opus-4-8")).toHaveProperty("input", ["text", "image"]); + expect(models.find(model => model.id === "gpt-5.5")).toHaveProperty("input", ["text"]); + expect(models.find(model => model.id === "local/no-window")).not.toHaveProperty("input"); + }); + + test("filters unsupported modalities without inventing image input", () => { + const doc = buildClientConfig("openclaw", { + ...ctx(), + models: [ + { namespaced: "p/mixed", provider: "p", id: "mixed", inputModalities: ["text", "image", "image", "audio", "video", "pdf"] }, + { namespaced: "p/unknown", provider: "p", id: "unknown", inputModalities: [] }, + { namespaced: "p/foreign", provider: "p", id: "foreign", inputModalities: ["pdf"] }, + ], + }) as OpenclawGeneratedConfig; + const models = doc.models.providers[OPENCODE_PROVIDER_ID]!.models; + expect(models.find(model => model.id === "p/mixed")?.input).toEqual(["text", "image", "audio", "video"]); + expect(models.find(model => model.id === "p/unknown")).not.toHaveProperty("input"); + expect(models.find(model => model.id === "p/foreign")).not.toHaveProperty("input"); + }); + test("merges with the bundled catalog and omits a window it cannot assert", () => { const doc = buildClientConfig("openclaw", ctx()) as OpenclawGeneratedConfig; expect(doc.models.mode).toBe("merge"); @@ -252,9 +275,15 @@ describe("kimi", () => { expect(doc.providers[OPENCODE_PROVIDER_ID]!.api_key).toBe(LOOPBACK_API_KEY_PLACEHOLDER); }); - test("never emits capabilities it cannot assert", () => { - const { text } = buildClientConfigText("kimi", ctx()); - expect(text).not.toContain("capabilities"); + test("declares image_in only for catalog-backed image models", () => { + const doc = buildClientConfig("kimi", ctx()) as KimiGeneratedConfig; + expect(doc.models[`${OPENCODE_PROVIDER_ID}/anthropic/claude-opus-4-8`]) + .toHaveProperty("capabilities", ["image_in"]); + expect(doc.models[`${OPENCODE_PROVIDER_ID}/gpt-5.5`]).not.toHaveProperty("capabilities"); + const unknown = buildClientConfig("kimi", { + ...ctx(), models: [{ namespaced: "local/unknown", provider: "local", id: "unknown", contextWindow: 32_000 }], + }) as KimiGeneratedConfig; + expect(unknown.models[`${OPENCODE_PROVIDER_ID}/local/unknown`]).not.toHaveProperty("capabilities"); }); test("KIMI_CODE_HOME wins over the default", () => { diff --git a/tests/config/client-config-new-clients.test.ts b/tests/config/client-config-new-clients.test.ts index 7deb7fdb36..9e0ec710b2 100644 --- a/tests/config/client-config-new-clients.test.ts +++ b/tests/config/client-config-new-clients.test.ts @@ -121,11 +121,10 @@ describe("kimi", () => { expect(doc.providers[OPENCODE_PROVIDER_ID]!.type).toBe("openai"); }); - test("never asserts capabilities it cannot know", () => { + test("asserts image input only when the catalog declares it", () => { const doc = buildClientConfig("kimi", ctx()) as KimiGeneratedConfig; - for (const model of Object.values(doc.models)) { - expect(model).not.toHaveProperty("capabilities"); - } + expect(doc.models[kimiModelAlias("anthropic/claude-opus-4-8")]?.capabilities).toEqual(["image_in"]); + expect(doc.models[kimiModelAlias("gpt-5.5")]).not.toHaveProperty("capabilities"); }); test("its document round-trips through the TOML parser", () => { diff --git a/tests/server/management-client-config-route.test.ts b/tests/server/management-client-config-route.test.ts index 9c024a02a7..21912afd0d 100644 --- a/tests/server/management-client-config-route.test.ts +++ b/tests/server/management-client-config-route.test.ts @@ -21,10 +21,14 @@ import { type ExportModel, type HermesGeneratedConfig, type McodeGeneratedConfig, + type KimiGeneratedConfig, + type OpenclawGeneratedConfig, type OpencodeGeneratedConfig, type PiGeneratedConfig, type RaycastGeneratedConfig, + type ZcodeGeneratedConfig, } from "../../src/clients/config-export"; +import type { ClineGeneratedConfig } from "../../src/clients/config-export/cline"; import type { OcxConfig } from "../../src/types"; import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -163,38 +167,60 @@ function toExportModel(row: ModelRow): ExportModel { describe("native Anthropic image input reaches client documents", () => { - for (const client of ["aside", "pi", "gajae"] as const) { - test.each(["anthropic", "anthropic-apikey"])(`${client} advertises image input for %s`, async (provider) => { - const config = { - port: 10100, - hostname: "127.0.0.1", - defaultProvider: provider, - providers: { - [provider]: { - adapter: "anthropic", - baseUrl: "https://api.anthropic.com", - authMode: provider === "anthropic" ? "oauth" : "key", - liveModels: false, - }, + test.each(["anthropic", "anthropic-apikey"])("all capability-aware exports advertise image input for %s", async (provider) => { + const config = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: provider, + providers: { + [provider]: { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + authMode: provider === "anthropic" ? "oauth" : "key", + liveModels: false, }, - } as unknown as OcxConfig; - - const models = await loadExportModels(config); - const document = buildClientConfig(client, { - baseUrl: "http://127.0.0.1:10100/v1", - config, - models, - }) as PiGeneratedConfig; - const rows = document.providers[OPENCODE_PROVIDER_ID]!.models - .filter(model => model.id.startsWith(`${provider}/claude-`)); - expect(rows.length).toBeGreaterThan(0); - for (const row of rows) { - expect({ id: row.id, input: row.input }).toEqual({ - id: row.id, input: ["text", "image"], - }); + }, + } as unknown as OcxConfig; + // Use the production catalog, not hand-authored ExportModels that would conceal missing seeds. + const models = (await loadExportModels(config)) + .filter(model => model.provider === provider); + expect(models.length).toBeGreaterThan(0); + const context = { baseUrl: "http://127.0.0.1:10100/v1", config, models }; + const expectedInputs = models.map(model => ({ id: model.namespaced, input: ["text", "image"] })); + + for (const client of ["aside", "pi", "gajae", "prime", "omo", "omp"] as const) { + const document = buildClientConfig(client, context) as PiGeneratedConfig; + const rows = document.providers[OPENCODE_PROVIDER_ID]!.models; + expect({ client, inputs: rows.map(({ id, input }) => ({ id, input })) }) + .toEqual({ client, inputs: expectedInputs }); + } + const dsh = buildClientConfig("dsh", context) as DshGeneratedConfig; + expect(dsh["llm-pi-ai"].providers[OPENCODE_PROVIDER_ID]!.models.map(({ id, input }) => ({ id, input }))) + .toEqual(expectedInputs); + + const openclaw = buildClientConfig("openclaw", context) as OpenclawGeneratedConfig; + expect(openclaw.models.providers[OPENCODE_PROVIDER_ID]!.models.map(({ id, input }) => ({ id, input }))) + .toEqual(expectedInputs); + const kimi = buildClientConfig("kimi", context) as KimiGeneratedConfig; + const opencode = buildClientConfig("opencode", context) as OpencodeGeneratedConfig; + const zcode = buildClientConfig("zcode", context) as ZcodeGeneratedConfig; + const cline = buildClientConfig("cline", context) as ClineGeneratedConfig; + const hermes = buildClientConfig("hermes", context) as HermesGeneratedConfig; + const raycast = buildClientConfig("raycast", context) as RaycastGeneratedConfig; + for (const model of models) { + expect(kimi.models[`${OPENCODE_PROVIDER_ID}/${model.namespaced}`]?.capabilities).toEqual(["image_in"]); + for (const block of [opencode.provider, opencode.providers]) { + expect(block[OPENCODE_PROVIDER_ID]!.models[model.namespaced]?.modalities?.input).toEqual(["text", "image"]); + expect(block[OPENCODE_PROVIDER_ID]!.models[model.namespaced]?.attachment).toBe(true); } - }); - } + expect(zcode.provider[OPENCODE_PROVIDER_ID]!.models[model.namespaced]?.modalities.input).toEqual(["text", "image"]); + const clineModel = cline.catalog.providers[OPENCODE_PROVIDER_ID]!.models[model.namespaced]; + expect(clineModel?.modalities?.input).toEqual(["text", "image"]); + expect(clineModel?.supportsVision).toBe(true); + expect(hermes.providers[OPENCODE_PROVIDER_ID]!.models[model.namespaced]?.supports_vision).toBe(true); + expect(raycast.providers[0]!.models.find(row => row.id === model.namespaced)?.abilities.vision.supported).toBe(true); + } + }); }); describe("native Anthropic effort ladder reaches the Aside document", () => { From 22965dc2d3303217a6da25042997c41ca026c8c3 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 15 Sep 2026 19:47:41 +0900 Subject: [PATCH 11/14] test(google): pin CCA image refusal and typed schema envelope Add the branch-specific image-output refusal regression and replace the two untyped envelope casts. Static inspection only; product tests and CI remain operator-owned. Co-authored-by: agentHits <140916359+agentHits@users.noreply.github.com> --- .../google/google-structured-output.test.ts | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/tests/adapters/google/google-structured-output.test.ts b/tests/adapters/google/google-structured-output.test.ts index c2b845085f..b23bbcabc2 100644 --- a/tests/adapters/google/google-structured-output.test.ts +++ b/tests/adapters/google/google-structured-output.test.ts @@ -19,6 +19,17 @@ const aiStudio = { adapter: "google", baseUrl: "https://generativelanguage.googl const vertex = { adapter: "google", googleMode: "vertex", baseUrl: "https://aiplatform.googleapis.com", apiKey: "key" } as unknown as OcxProviderConfig; const cca = { adapter: "google", googleMode: "cloud-code-assist", baseUrl: "https://cloudcode-pa.googleapis.com", apiKey: "token", project: "test-project" } as unknown as OcxProviderConfig; +type CloudCodeAssistEnvelope = { + generationConfig?: unknown; + request?: { + generationConfig?: { + responseMimeType?: unknown; + responseJsonSchema?: unknown; + responseSchema?: unknown; + }; + }; +}; + const SCHEMA = { type: "object", properties: { answer: { type: "string" } }, @@ -61,7 +72,7 @@ describe("F3 Google structured output reaches the generateContent wire", () => { const { body } = await createGoogleAdapter(cca).buildRequest( parsed({ type: "json_schema", name: "answer", schema: SCHEMA, strict: true }), ); - const envelope = JSON.parse(typeof body === "string" ? body : JSON.stringify(body)) as Record; + const envelope = JSON.parse(typeof body === "string" ? body : JSON.stringify(body)) as CloudCodeAssistEnvelope; expect(envelope.generationConfig).toBeUndefined(); expect(envelope.request?.generationConfig?.responseMimeType).toBe("application/json"); @@ -71,7 +82,7 @@ describe("F3 Google structured output reaches the generateContent wire", () => { test("json_object on Cloud Code Assist sets only responseMimeType in envelope.request", async () => { const { body } = await createGoogleAdapter(cca).buildRequest(parsed({ type: "json_object" })); - const envelope = JSON.parse(typeof body === "string" ? body : JSON.stringify(body)) as Record; + const envelope = JSON.parse(typeof body === "string" ? body : JSON.stringify(body)) as CloudCodeAssistEnvelope; expect(envelope.generationConfig).toBeUndefined(); expect(envelope.request?.generationConfig?.responseMimeType).toBe("application/json"); @@ -121,6 +132,13 @@ describe("F3 unsupported modes refuse explicitly instead of dropping the schema" await expect(promise).rejects.toThrow(/cannot combine image output with structured output/); }); + test("an image-capable Cloud Code Assist model refuses the structured-output conflict", async () => { + const promise = createGoogleAdapter(cca).buildRequest( + parsed({ type: "json_schema", schema: SCHEMA }, "gemini-3-pro-image-preview"), + ); + await expect(promise).rejects.toThrow("cannot combine image output with structured output"); + }); + test("an image-capable model with NO schema keeps its image behavior", async () => { const config = await generationConfig(aiStudio, parsed(undefined, "gemini-3-pro-image-preview")); From 46e5f55ca70dc8cab0433a05f07229d48435e2a8 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 15 Sep 2026 19:51:32 +0900 Subject: [PATCH 12/14] fix(kiro): keep unverified luna and terra effort rungs emulated Use the proven native effort allowlist for newly enabled models, retain existing Sol/Opus behavior, and add boundary fixtures. No live provider requests or product tests were run on the connected machine. Co-authored-by: wentao.ma2 --- .../src/content/docs/fr/reference/adapters.md | 7 +++- .../src/content/docs/ja/reference/adapters.md | 10 +++-- .../src/content/docs/ko/reference/adapters.md | 10 +++-- .../src/content/docs/reference/adapters.md | 15 ++++---- .../src/content/docs/ru/reference/adapters.md | 10 +++-- .../src/content/docs/tr/reference/adapters.md | 15 +++----- .../content/docs/zh-cn/reference/adapters.md | 10 +++-- .../content/docs/zh-tw/reference/adapters.md | 10 +++-- src/adapters/kiro/payload.ts | 7 +++- src/adapters/kiro/reasoning.ts | 20 +++++++--- structure/providers/kiro.md | 5 +++ .../kiro/kiro-reasoning-roundtrip.test.ts | 37 +++++++++++++++---- 12 files changed, 105 insertions(+), 51 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/adapters.md b/docs-site/src/content/docs/fr/reference/adapters.md index dc28832ca7..9535fe02f4 100644 --- a/docs-site/src/content/docs/fr/reference/adapters.md +++ b/docs-site/src/content/docs/fr/reference/adapters.md @@ -131,7 +131,12 @@ Si Kiro s’arrête sans appeler l’outil d’achèvement, l’adaptateur effec ### Effort de raisonnement -La famille GPT-5.6 de Kiro (`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`) et `claude-opus-5` prennent en charge nativement un niveau d’effort vérifié, mais chaque famille de modèles nomme différemment le champ de la requête. La valeur sélectionnée `low`, `medium`, `high`, `xhigh` ou `max` est envoyée dans `additionalModelRequestFields.reasoning.effort` pour les modèles GPT-5.6, et dans `additionalModelRequestFields.output_config.effort` pour `claude-opus-5`. Les autres modèles Kiro utilisent actuellement un raisonnement émulé : opencodex convertit le niveau choisi en instructions de réflexion bornées dans le contenu utilisateur, car leur champ d’effort natif n’a pas été vérifié. La présence d’un contrôle d’effort annoncé sur ces modèles ne prouve donc pas la prise en charge native du raisonnement en amont. +Les modèles GPT-5.6 utilisent `additionalModelRequestFields.reasoning.effort`, et `claude-opus-5` +utilise `additionalModelRequestFields.output_config.effort`. Pour `gpt-5.6-luna` et `gpt-5.6-terra`, +seuls `low`, `medium`, `high` et `max` empruntent le chemin natif vérifié. Leur niveau `xhigh` +conserve les instructions de réflexion bornées existantes, car ce niveau natif n’a pas été vérifié. +`gpt-5.6-sol` et `claude-opus-5` conservent leurs niveaux natifs existants : `low`, `medium`, `high`, +`xhigh` et `max`. Les autres modèles Kiro utilisent une émulation ; un réglage d’effort ne prouve pas une prise en charge native. ## `cursor` diff --git a/docs-site/src/content/docs/ja/reference/adapters.md b/docs-site/src/content/docs/ja/reference/adapters.md index d9dd6fdd0b..1565770c95 100644 --- a/docs-site/src/content/docs/ja/reference/adapters.md +++ b/docs-site/src/content/docs/ja/reference/adapters.md @@ -154,10 +154,12 @@ filtered incomplete になります。実際のツール呼び出しを伴わな ### Reasoning effort -`gpt-5.6-sol` / `gpt-5.6-terra` / `gpt-5.6-luna` と `claude-opus-5` はネイティブ effort をサポートし、リクエストフィールド名が異なります。 -`low` / `medium` / `high` / `xhigh` / `max` は、GPT-5.6 系では -`additionalModelRequestFields.reasoning.effort`、`claude-opus-5` では `additionalModelRequestFields.output_config.effort` として送信されます。 - +GPT-5.6 系は `additionalModelRequestFields.reasoning.effort`、`claude-opus-5` は +`additionalModelRequestFields.output_config.effort` を使用します。`gpt-5.6-luna` と +`gpt-5.6-terra` では、検証済みの `low`、`medium`、`high`、`max` だけをネイティブフィールドで送信します。 +両モデルの `xhigh` は未検証のため、従来の上限付き thinking 指示によるエミュレーションを維持します。 +`gpt-5.6-sol` と `claude-opus-5` の既存のネイティブ段階(`low`、`medium`、`high`、`xhigh`、`max`)は変更しません。 +その他の Kiro モデルはエミュレーションを使用し、effort の選択肢だけではネイティブ対応を意味しません。 ## `cursor` diff --git a/docs-site/src/content/docs/ko/reference/adapters.md b/docs-site/src/content/docs/ko/reference/adapters.md index 548977d38c..64193b4232 100644 --- a/docs-site/src/content/docs/ko/reference/adapters.md +++ b/docs-site/src/content/docs/ko/reference/adapters.md @@ -167,10 +167,12 @@ commentary로 유지하고 비공개 완료 툴을 한 번 검증합니다. ### Reasoning effort -`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`와 `claude-opus-5`는 네이티브 effort를 지원하며 요청 필드 이름이 다릅니다. -`low` / `medium` / `high` / `xhigh` / `max` 값은 GPT-5.6 계열에서는 -`additionalModelRequestFields.reasoning.effort`, `claude-opus-5`에서는 `additionalModelRequestFields.output_config.effort`로 전송됩니다. - +GPT-5.6 계열은 `additionalModelRequestFields.reasoning.effort`를, `claude-opus-5`는 +`additionalModelRequestFields.output_config.effort`를 사용합니다. `gpt-5.6-luna`와 +`gpt-5.6-terra`는 검증된 `low`, `medium`, `high`, `max`만 네이티브 필드로 전송합니다. +두 모델의 `xhigh`는 네이티브 동작이 검증되지 않아 기존의 제한된 thinking 지시문 방식을 유지합니다. +`gpt-5.6-sol`과 `claude-opus-5`의 기존 네이티브 단계(`low`, `medium`, `high`, `xhigh`, `max`)는 +바뀌지 않습니다. 다른 Kiro 모델의 effort는 에뮬레이션이며, 조절 항목이 있다고 네이티브 지원을 뜻하지는 않습니다. ## `cursor` diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index f5e2310624..70180417f1 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -364,14 +364,13 @@ important than cosmetic de-duplication. Tool-free requests retain normal text co ### Reasoning effort -The Kiro GPT-5.6 family (`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`) and `claude-opus-5` have -verified native effort support, and each model family names the request field differently. A -selected `low`, `medium`, `high`, `xhigh`, or `max` value is sent as -`additionalModelRequestFields.reasoning.effort` for the GPT-5.6 models and as -`additionalModelRequestFields.output_config.effort` for `claude-opus-5`. Other Kiro models currently -use emulated reasoning: opencodex converts the selected level into bounded thinking instructions in -the user content because their native effort field has not been verified. Do not interpret an -advertised effort control on those models as proof of upstream-native reasoning support. +The GPT-5.6 family uses `additionalModelRequestFields.reasoning.effort`; `claude-opus-5` +uses `additionalModelRequestFields.output_config.effort`. For `gpt-5.6-luna` and +`gpt-5.6-terra`, only `low`, `medium`, `high`, and `max` use the verified native path. +Their `xhigh` selection retains the previous bounded thinking instructions in user content +because that native rung has not been verified. `gpt-5.6-sol` and `claude-opus-5` keep +their existing native `low`, `medium`, `high`, `xhigh`, and `max` behavior. Other Kiro +models use emulated reasoning; an advertised effort control is not proof of native support. ## `cursor` diff --git a/docs-site/src/content/docs/ru/reference/adapters.md b/docs-site/src/content/docs/ru/reference/adapters.md index 62416d5671..1d81b039e2 100644 --- a/docs-site/src/content/docs/ru/reference/adapters.md +++ b/docs-site/src/content/docs/ru/reference/adapters.md @@ -189,10 +189,12 @@ incomplete. `TOOL_USE` без фактического вызова инстру ### Reasoning effort -Модели семейства GPT-5.6 (`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`) и `claude-opus-5` поддерживают нативный effort, но называют поле запроса по-разному. -Значения `low` / `medium` / `high` / `xhigh` / `max` отправляются как -`additionalModelRequestFields.reasoning.effort` для моделей GPT-5.6 и `additionalModelRequestFields.output_config.effort` для `claude-opus-5`. - +Семейство GPT-5.6 использует `additionalModelRequestFields.reasoning.effort`, а `claude-opus-5` — +`additionalModelRequestFields.output_config.effort`. Для `gpt-5.6-luna` и `gpt-5.6-terra` нативный +путь проверен только для `low`, `medium`, `high` и `max`. Их `xhigh` сохраняет прежнюю эмуляцию +через ограниченные инструкции thinking, поскольку нативный уровень не проверен. +Существующие нативные уровни `gpt-5.6-sol` и `claude-opus-5` (`low`, `medium`, `high`, `xhigh`, `max`) +не меняются. Остальные модели Kiro используют эмуляцию; наличие настройки effort не доказывает нативную поддержку. ## `cursor` diff --git a/docs-site/src/content/docs/tr/reference/adapters.md b/docs-site/src/content/docs/tr/reference/adapters.md index 876d050b04..090be99061 100644 --- a/docs-site/src/content/docs/tr/reference/adapters.md +++ b/docs-site/src/content/docs/tr/reference/adapters.md @@ -268,15 +268,12 @@ tam olarak tekrarlasa bile, çünkü aşama doğruluğu kozmetik tekilleştirmed ### Akıl yürütme çabası -Kiro'nun GPT-5.6 ailesi (`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`) ve `claude-opus-5` doğrulanmış yerel çaba desteğine sahiptir ve -her model ailesi istek alanını farklı şekilde adlandırır. Seçilen `low`, -`medium`, `high`, `xhigh` veya `max` değeri GPT-5.6 modelleri için -`additionalModelRequestFields.reasoning.effort` olarak ve `claude-opus-5` için -`additionalModelRequestFields.output_config.effort` olarak gönderilir. Diğer -Kiro modelleri şu anda öykünülmüş akıl yürütme kullanır: opencodex yerel çaba -alanları doğrulanmadığı için seçilen seviyeyi kullanıcı içeriğinde sınırlı -düşünme talimatlarına dönüştürür. Bu modellerde bildirilen bir çaba denetimini -yukarı akış yerel akıl yürütme desteğinin kanıtı olarak yorumlamayın. +GPT-5.6 ailesi `additionalModelRequestFields.reasoning.effort`, `claude-opus-5` ise +`additionalModelRequestFields.output_config.effort` alanını kullanır. `gpt-5.6-luna` ve +`gpt-5.6-terra` için yalnızca doğrulanmış `low`, `medium`, `high` ve `max` seviyeleri yerel alandan +gönderilir. Bu iki modelin yerel `xhigh` seviyesi doğrulanmadığı için mevcut sınırlı düşünme +talimatlarıyla öykünme korunur. `gpt-5.6-sol` ve `claude-opus-5` için mevcut yerel `low`, `medium`, +`high`, `xhigh` ve `max` davranışı değişmez. Diğer Kiro modelleri öykünme kullanır; çaba seçeneği yerel desteğin kanıtı değildir. ## `cursor` diff --git a/docs-site/src/content/docs/zh-cn/reference/adapters.md b/docs-site/src/content/docs/zh-cn/reference/adapters.md index e7f96a93e4..a970fd1d6c 100644 --- a/docs-site/src/content/docs/zh-cn/reference/adapters.md +++ b/docs-site/src/content/docs/zh-cn/reference/adapters.md @@ -154,10 +154,12 @@ Kiro 的 assistant 文本本身没有可靠的回合结束标记,但终止的 ### Reasoning effort -`gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna` 和 `claude-opus-5` 支持原生 effort,且请求字段名不同。`low` / `medium` / `high` / -`xhigh` / `max` 在 GPT-5.6 系列中通过 `additionalModelRequestFields.reasoning.effort` 发送, -在 `claude-opus-5` 上通过 `additionalModelRequestFields.output_config.effort` 发送。 - +GPT-5.6 系列使用 `additionalModelRequestFields.reasoning.effort`,`claude-opus-5` 使用 +`additionalModelRequestFields.output_config.effort`。`gpt-5.6-luna` 和 `gpt-5.6-terra` +仅通过原生字段发送已验证的 `low`、`medium`、`high` 和 `max`。 +这两个模型的原生 `xhigh` 尚未验证,因此仍使用原有的有界 thinking 指令模拟。 +`gpt-5.6-sol` 和 `claude-opus-5` 保留现有原生档位(`low`、`medium`、`high`、`xhigh`、`max`)。 +其他 Kiro 模型使用模拟推理;提供 effort 选项并不代表原生支持。 ## `cursor` diff --git a/docs-site/src/content/docs/zh-tw/reference/adapters.md b/docs-site/src/content/docs/zh-tw/reference/adapters.md index 4708e2e6b1..2b314cd299 100644 --- a/docs-site/src/content/docs/zh-tw/reference/adapters.md +++ b/docs-site/src/content/docs/zh-tw/reference/adapters.md @@ -145,10 +145,12 @@ Kiro 的 assistant 文字本身沒有可靠的回合結束標記,但終止的 ### Reasoning effort -`gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna` 和 `claude-opus-5` 支援原生 effort,且請求欄位名不同。`low` / `medium` / `high` / -`xhigh` / `max` 在 GPT-5.6 系列中透過 `additionalModelRequestFields.reasoning.effort` 傳送, -在 `claude-opus-5` 上透過 `additionalModelRequestFields.output_config.effort` 傳送。 - +GPT-5.6 系列使用 `additionalModelRequestFields.reasoning.effort`,`claude-opus-5` 使用 +`additionalModelRequestFields.output_config.effort`。`gpt-5.6-luna` 和 `gpt-5.6-terra` +只透過原生欄位傳送已驗證的 `low`、`medium`、`high` 和 `max`。 +這兩個模型的原生 `xhigh` 尚未驗證,因此仍使用原有的有界 thinking 指令模擬。 +`gpt-5.6-sol` 和 `claude-opus-5` 保留現有原生檔位(`low`、`medium`、`high`、`xhigh`、`max`)。 +其他 Kiro 模型使用模擬推理;提供 effort 選項不代表原生支援。 ## `cursor` diff --git a/src/adapters/kiro/payload.ts b/src/adapters/kiro/payload.ts index 338f525d2e..1fbd3bcbfd 100644 --- a/src/adapters/kiro/payload.ts +++ b/src/adapters/kiro/payload.ts @@ -456,7 +456,12 @@ export function buildKiroPayload( if (!KIRO_NATIVE_EFFORTS.includes(effort)) { throw new Error(`Kiro ${normalizeKiroModelId(parsed.modelId)} does not support reasoning effort ${JSON.stringify(effort)}`); } - payload.additionalModelRequestFields = { [effortField]: { effort } }; + // Model eligibility still owns unsupported-effort validation above; wire eligibility + // is narrower for luna/terra, whose unverified rungs retain the thinking-tag path. + const verifiedEffortField = kiroNativeEffortField(parsed.modelId, effort); + if (verifiedEffortField) { + payload.additionalModelRequestFields = { [verifiedEffortField]: { effort } }; + } } if (profileArn) payload.profileArn = profileArn; return { payload, nameMap, conversationId, completionMode }; diff --git a/src/adapters/kiro/reasoning.ts b/src/adapters/kiro/reasoning.ts index 0441986f10..d12c95654c 100644 --- a/src/adapters/kiro/reasoning.ts +++ b/src/adapters/kiro/reasoning.ts @@ -28,12 +28,22 @@ export const KIRO_NATIVE_EFFORT_FIELDS: Record block, a strictly weaker signal: on one fixed hard prompt that channel landed // between the model's native medium and high (21,202 / 28,302 chars) and never reached native max // (48,594), while the native ladder itself ran 5,130 -> 48,594 from low to max. The whole GPT-5.6 -// family shares the field name, so all three are native now. +// family shares the field name, but luna/terra keep xhigh emulated until verified. describe("kiro native reasoning effort — the GPT-5.6 family", () => { - function wireBody(modelId: string): Record { + function wireBody(modelId: string, effort = "max"): Record { const parsed = { modelId, stream: true, - options: { reasoning: "max", maxOutputTokens: 1000 }, + options: { reasoning: effort, maxOutputTokens: 1000 }, context: { messages: [{ role: "user", content: "solve" }] }, } as unknown as Parameters[0]; return buildKiroPayload(parsed, undefined, "disabled", "ide").payload; @@ -267,13 +268,35 @@ describe("kiro native reasoning effort — the GPT-5.6 family", () => { test("luna and terra send the native reasoning field instead of thinking tags", () => { for (const modelId of ["gpt-5.6-luna", "gpt-5.6-terra"]) { - const body = wireBody(modelId); - expect(body.additionalModelRequestFields).toEqual({ reasoning: { effort: "max" } }); - // Native effort replaces the emulated thinking-tag prompt entirely. + for (const effort of ["low", "medium", "high", "max"]) { + const body = wireBody(modelId, effort); + expect(body.additionalModelRequestFields).toEqual({ reasoning: { effort } }); + // Native effort replaces the emulated thinking-tag prompt entirely. + const current = (body.conversationState as { + currentMessage: { userInputMessage: { content: string } }; + }).currentMessage.userInputMessage.content; + expect(current).toBe("solve"); + } + } + }); + + test("luna and terra keep unverified xhigh on the emulated path", () => { + for (const modelId of ["gpt-5.6-luna", "gpt-5.6-terra"]) { + const body = wireBody(modelId, "xhigh"); + expect(body.additionalModelRequestFields).toBeUndefined(); const current = (body.conversationState as { currentMessage: { userInputMessage: { content: string } }; }).currentMessage.userInputMessage.content; - expect(current).toBe("solve"); + expect(current).toContain("enabled"); + expect(current).toContain("900"); + expect(kiroNativeEffortField(modelId, "future-effort")).toBeUndefined(); } }); + + test("existing Sol and Opus native xhigh fields stay unchanged", () => { + expect(wireBody("gpt-5.6-sol", "xhigh").additionalModelRequestFields) + .toEqual({ reasoning: { effort: "xhigh" } }); + expect(wireBody("claude-opus-5", "xhigh").additionalModelRequestFields) + .toEqual({ output_config: { effort: "xhigh" } }); + }); }); From bf07b72f73b371b8366d7f019f191aeacbe7e01d Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 16 Sep 2026 11:22:46 +0900 Subject: [PATCH 13/14] docs(lanes): correct the Kiro reasoning-text claim and the Desktop alias range Two review findings on this lane were accurate. They are fixed on top of the lane rather than by rewriting a member's commit, so every member's ancestry and authorship stay intact. structure/providers/kiro.md said reasoningContentEvent carries the encrypted blob and "never text". The round-trip test #4682 added shows otherwise: every captured GPT-5.6 frame leaves a literal "..." placeholder on text and the adapter forwards it as a reasoning_raw_delta (tests/providers/kiro/kiro-reasoning-roundtrip.test.ts, "a signature blob is tagged with the field it must be replayed on"). The field is present; what it never carries is model reasoning. The wording now says that, which keeps the doc from contradicting its own binding test. #4224 widened the managed Desktop date aliases from 2026 alone (365 slots) to 2026-2035 (3652), but every user-facing description of that namespace still said claude-opus-4-8-2026MMDD: the ocx claude help text, the Claude Code guide in four locales, and structure/clients/claude-desktop.md did not mention the range at all. A user reading any of them would conclude a 2027 alias is not one of ours. All four surfaces now give the real range and record that 2026 is allocated first, so existing assignments keep their ids. No runtime behaviour changes here. The only src/ edit is the help string in src/cli/registry.ts. --- docs-site/src/content/docs/fr/guides/claude-code.md | 5 +++-- docs-site/src/content/docs/guides/claude-code.md | 7 +++++-- docs-site/src/content/docs/tr/guides/claude-code.md | 8 +++++--- docs-site/src/content/docs/zh-tw/guides/claude-code.md | 5 +++-- src/cli/registry.ts | 3 ++- structure/clients/claude-desktop.md | 9 +++++++++ structure/providers/kiro.md | 10 +++++++--- 7 files changed, 34 insertions(+), 13 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md index de614880e7..43e718d2da 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -159,8 +159,9 @@ Support/Claude/configLibrary` sur macOS, `%APPDATA%\Claude\configLibrary` sur Wi `CLAUDE_USER_DATA_DIR` pour utiliser une autre racine de données Claude Desktop. L'ancien répertoire `Claude-3p` n'est ni lu ni supprimé automatiquement. -Les routes non Anthropic reçoivent des alias stables comme `claude-opus-4-8-2026MMDD`. La partie qui ressemble à une date -est un emplacement synthétique de route, et non la date de publication du modèle. Les véritables routes Anthropic Claude conservent +Les routes non Anthropic reçoivent des alias stables comme `claude-opus-4-8-YYYYMMDD`, dont l'année va de 2026 à 2035. La partie qui ressemble à une date +est un emplacement synthétique de route, et non la date de publication du modèle. Les emplacements de 2026 sont attribués en premier, de sorte que les alias +existants conservent leur identifiant ; les années suivantes ne sont utilisées qu'une fois 2026 saturée. Les véritables routes Anthropic Claude conservent leur identité. Les nouvelles routes appartiennent par défaut à la famille Opus, mais déplacer une route ne change ni le fournisseur ni le modèle qu'elle appelle. Les anciens indicateurs `--static`, `--hybrid` et `--discovery-only` restent disponibles pour les scripts existants. diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index a44867bd09..6d87b8a2ce 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -181,8 +181,11 @@ Support/Claude/configLibrary` on macOS, `%APPDATA%\Claude\configLibrary` on Wind `CLAUDE_USER_DATA_DIR` for an alternate Desktop user-data root. The legacy `Claude-3p` directory is not read or deleted automatically. -Non-Anthropic routes receive stable aliases such as `claude-opus-4-8-2026MMDD`. The date-looking -part is a synthetic route slot, not the model's release date. Real Anthropic Claude routes keep +Non-Anthropic routes receive stable aliases such as `claude-opus-4-8-YYYYMMDD`, where the year runs +from 2026 to 2035. The date-looking +part is a synthetic route slot, not the model's release date. 2026 slots are allocated first, so +existing aliases keep their ids; the later years are reached only once 2026 fills. +Real Anthropic Claude routes keep their real ids. New routes default to the Opus family, but moving a route does not change the provider or model it calls. The legacy apply flags `--static`, `--hybrid`, and `--discovery-only` remain available for existing scripts. diff --git a/docs-site/src/content/docs/tr/guides/claude-code.md b/docs-site/src/content/docs/tr/guides/claude-code.md index 510a7818a7..034f7505b5 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -189,9 +189,11 @@ alternatif bir Desktop kullanıcı verisi kökü için `CLAUDE_USER_DATA_DIR` değerini ayarlayın. Eski `Claude-3p` dizini otomatik olarak okunmaz veya silinmez. -Anthropic harici rotalar, `claude-opus-4-8-2026MMDD` gibi kararlı takma adlar -alır. Tarih benzeri kısım, modelin çıkış tarihi değil, sentetik bir rota -yuvasıdır. Gerçek Anthropic Claude rotaları kendi gerçek kimliklerini korur. +Anthropic harici rotalar, `claude-opus-4-8-YYYYMMDD` gibi kararlı takma adlar +alır; yıl 2026 ile 2035 arasındadır. Tarih benzeri kısım, modelin çıkış tarihi +değil, sentetik bir rota yuvasıdır. Önce 2026 yuvaları atanır, bu nedenle mevcut +takma adlar kimliklerini korur; sonraki yıllara ancak 2026 dolduktan sonra +geçilir. Gerçek Anthropic Claude rotaları kendi gerçek kimliklerini korur. Yeni rotalar varsayılan olarak Opus ailesine gider, ancak bir rotayı taşımak çağırdığı sağlayıcıyı veya modeli değiştirmez. Eski uygulama bayrakları `--static`, `--hybrid` ve `--discovery-only` mevcut betikler için kullanılabilir diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index 2c1a995cc3..338b19c7ee 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -129,8 +129,9 @@ ocx claude desktop import [--apply] 檔案,因此無效檔案不會改動目前設定檔。加上 `--apply` 可在匯入有效設定檔後立即寫入 Desktop。 `none` 僅適用於空系列;每個非空系列都必須保留一個預設。 -非 Anthropic 路由會得到穩定別名,例如 `claude-opus-4-8-2026MMDD`。看起來像日期的部分是合成的 -路由槽位,不是模型釋出日期。真正的 Anthropic Claude 路由保留真實 id。新路由預設落在 Opus +非 Anthropic 路由會得到穩定別名,例如 `claude-opus-4-8-YYYYMMDD`,年份範圍為 2026 至 2035。看起來像日期的部分是合成的 +路由槽位,不是模型釋出日期。系統會先配置 2026 的槽位,因此既有別名的 id 不變;2026 用盡後才會用到後續年份。 +真正的 Anthropic Claude 路由保留真實 id。新路由預設落在 Opus 系列,但移動路由不會改變它所呼叫的供應商或模型。舊版 apply 旗標 `--static`、`--hybrid` 與 `--discovery-only` 仍可供既有腳本使用。 diff --git a/src/cli/registry.ts b/src/cli/registry.ts index bfbe845c5b..d716ccffef 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -407,7 +407,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ "Ensures the proxy is running, then execs `claude` with ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN,", "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 and model slots from config.claudeCode.", "When Claude routing is explicitly disabled, it launches natively after removing proven OpenCodex-owned proxy state.", - "Routed models appear in the native /model picker with stable claude-opus-4-8-2026MMDD slot aliases (Claude Code >= 2.1.129).", + "Routed models appear in the native /model picker with stable claude-opus-4-8-YYYYMMDD slot aliases,", + "where the year runs 2026-2035 and 2026 slots are allocated first (Claude Code >= 2.1.129).", "Older versions: pick models via ANTHROPIC_MODEL or /model directly (any string passes through).", "User-exported ANTHROPIC_* variables take precedence for routed launches; native fallback removes only proven OpenCodex-owned proxy values.", "", diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 01a583c182..47497cc3f8 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -38,6 +38,15 @@ writes the resulting local Desktop configuration. No admin token, hub-profile up alias regeneration is part of this flow. Unsupported old hubs, invalid snapshots and unavailable Desktop models fail apply without a local-catalog or loopback fallback. +Managed-namespace date aliases occupy `claude-opus-4-8-YYYYMMDD` slots across 2026-2035, not 2026 +alone. The original 2026-only design held 365 slots and failed with "all 365 encoded date slots are +occupied" once a catalog exceeded 365 routes, because stale assignments are retained by design and +the set only grows. 2026 is still allocated first, so existing assignments keep their ids, and +2027-2035 are reached only after it fills. Years before 2026 stay rejected: dated ids such as +`claude-opus-4-8-20250201` are real Anthropic snapshot ids and the inbound decoder relies on that +distinction. Every emitted suffix stays eight digits so `modelMap` date-stripping keeps working. +`src/claude/desktop-profile.ts` owns this range. + Date-shaped Desktop IDs can overlap genuine native model IDs. When available discovery and mapping evidence cannot resolve one, Messages and count-tokens return HTTP 503 with the fixed `desktop_model_mapping_unavailable` error rather than classifying it as invalid. Unknown legacy hash aliases diff --git a/structure/providers/kiro.md b/structure/providers/kiro.md index 296e226392..5132d9c550 100644 --- a/structure/providers/kiro.md +++ b/structure/providers/kiro.md @@ -34,9 +34,13 @@ raw body. ## Kiro reasoning round-trip (`signature`) Kiro never returns plaintext reasoning for its **GPT-5.6 family** (`gpt-5.6-sol`, `-terra`, -`-luna`): `reasoningContentEvent` carries a KMS-encrypted blob, never `text`. It arrives on -`signature`, holding the `.KTR~~…` value verbatim, which is what every capture of those models -sent. Their `additionalModelRequestFieldsSchema` (`ListAvailableModels`) accepts only +`-luna`): `reasoningContentEvent` carries a KMS-encrypted blob rather than readable reasoning. It +arrives on `signature`, holding the `.KTR~~…` value verbatim, which is what every capture of those +models sent. The event's `text` field is not absent — every captured GPT-5.6 frame left a literal +`"..."` placeholder there, which the adapter forwards as a `reasoning_raw_delta` — but it never +carries model reasoning, so `signature` is the only field worth replaying +(`tests/providers/kiro/kiro-reasoning-roundtrip.test.ts`). +Their `additionalModelRequestFieldsSchema` (`ListAvailableModels`) accepts only `reasoning.effort` with `additionalProperties: false` — there is no display/summary opt-in, so this is the only reasoning these models can return, and all three select that native field (`KIRO_NATIVE_EFFORT_FIELDS` in `src/adapters/kiro/reasoning.ts`). Kiro's own CLI replays the blob From 4f61edc49c823080a0b61294512c148e4b0114a4 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 16 Sep 2026 11:38:28 +0900 Subject: [PATCH 14/14] docs(cursor): record why the localized native-shell match has no right boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A review flagged that ROUTING_NATIVE_TOOL_NAME guards the left side of the Korean alternative and not the right, so it matches inside a longer compound. That reading is correct, and the symmetric fix is still wrong: Korean attaches particles straight onto the noun, so the sentences this detector exists to catch are "네이티브 셸이 차단되어..." and "네이티브 셸과 Read가...". A mirrored (?![\p{L}\p{M}\p{N}_]) lookahead treats the 이 and 과 particles as letters and stops matching all six parameterised positive cases, which is exactly why the existing negative cases only probe the left side. The one compound named in review, 네이티브 셸스크립트 paired with a failure claim and a redirect claim, is itself the hallucination shape being quarantined, so excluding it would narrow coverage rather than sharpen it. Nothing changes but the comment. It exists because the review thread will disappear and the next reader will otherwise see an obvious missing boundary and disable the check by fixing it. --- src/adapters/cursor/envelope-echo.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/adapters/cursor/envelope-echo.ts b/src/adapters/cursor/envelope-echo.ts index 962d93e170..336a276793 100644 --- a/src/adapters/cursor/envelope-echo.ts +++ b/src/adapters/cursor/envelope-echo.ts @@ -217,6 +217,12 @@ export type RoutingCommentaryDecision = | { kind: "flush" } | { kind: "hallucination" }; +// The Korean alternative is deliberately asymmetric: it has a left boundary and no right one. +// Korean attaches particles directly to the noun, so the real sentences this detector exists to +// catch read "네이티브 셸이 차단되어..." and "네이티브 셸과 Read가...". A mirrored +// (?![\p{L}\p{M}\p{N}_]) lookahead would see the 이/과 particle as a letter and stop matching +// every one of them, which is why the negative cases below only probe the left side. Adding the +// right boundary looks like an obvious fix and disables the check; do not. const ROUTING_NATIVE_TOOL_NAME = /\b(shell|read|grep|list|bash)\b|(?