diff --git a/docs-site/src/content/docs/fr/reference/adapters.md b/docs-site/src/content/docs/fr/reference/adapters.md index 04ff6ccd44..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 -`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. +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 f1276511b1..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` と `claude-opus-5` はネイティブ effort をサポートし、リクエストフィールド名が異なります。 -`low` / `medium` / `high` / `xhigh` / `max` は、前者では -`additionalModelRequestFields.reasoning.effort`、後者では `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 83aeaf2dd3..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`과 `claude-opus-5`는 네이티브 effort를 지원하며 요청 필드 이름이 다릅니다. -`low` / `medium` / `high` / `xhigh` / `max` 값은 각각 -`additionalModelRequestFields.reasoning.effort`와 `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 715555f847..70180417f1 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -364,13 +364,13 @@ 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 -`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 d2d1f53de9..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-sol` и `claude-opus-5` поддерживают нативный effort, но называют поле запроса по-разному. -Значения `low` / `medium` / `high` / `xhigh` / `max` отправляются как -`additionalModelRequestFields.reasoning.effort` и `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/tr/reference/adapters.md b/docs-site/src/content/docs/tr/reference/adapters.md index 6167bfe6df..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ı -`gpt-5.6-sol` 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 -`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 e111743887..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` 和 `claude-opus-5` 支持原生 effort,且请求字段名不同。`low` / `medium` / `high` / -`xhigh` / `max` 分别通过 `additionalModelRequestFields.reasoning.effort` 和 -`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 c155c8ae51..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` 和 `claude-opus-5` 支援原生 effort,且請求欄位名不同。`low` / `medium` / `high` / -`xhigh` / `max` 分別透過 `additionalModelRequestFields.reasoning.effort` 和 -`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-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..1fbd3bcbfd 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) } + : {}), }, } : { @@ -447,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 c218bf1233..d12c95654c 100644 --- a/src/adapters/kiro/reasoning.ts +++ b/src/adapters/kiro/reasoning.ts @@ -4,21 +4,46 @@ 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", }; export const KIRO_NATIVE_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; -export function kiroNativeEffortField(modelId: string): "reasoning" | "output_config" | undefined { - return KIRO_NATIVE_EFFORT_FIELDS[normalizeKiroModelId(modelId)]; +// The newly enabled models have evidence for these rungs only. Keep the previous +// emulation for xhigh, and never widen their native wire when the shared ladder grows. +const KIRO_LUNA_TERRA_NATIVE_EFFORTS = new Set(["low", "medium", "high", "max"]); + +export function kiroNativeEffortField( + modelId: string, + effort?: string, +): "reasoning" | "output_config" | undefined { + const model = normalizeKiroModelId(modelId); + if ((model === "gpt-5.6-luna" || model === "gpt-5.6-terra") + && effort !== undefined && !KIRO_LUNA_TERRA_NATIVE_EFFORTS.has(effort)) return undefined; + return KIRO_NATIVE_EFFORT_FIELDS[model]; } -export function kiroReasoningMode(modelId: string): KiroReasoningMode { - return kiroNativeEffortField(modelId) ? "native" : "emulated"; +export function kiroReasoningMode(modelId: string, effort?: string): KiroReasoningMode { + return kiroNativeEffortField(modelId, effort) ? "native" : "emulated"; } export function kiroThinkingBudget(parsed: OcxParsedRequest): number | undefined { @@ -38,7 +63,7 @@ export function kiroThinkingBudget(parsed: OcxParsedRequest): number | undefined } export function injectKiroThinkingTags(content: string, parsed: OcxParsedRequest): string { - if (kiroReasoningMode(parsed.modelId) !== "emulated") return content; + if (kiroReasoningMode(parsed.modelId, parsed.options.reasoning) !== "emulated") return content; const budget = kiroThinkingBudget(parsed); if (!budget) return content; const instruction = [ @@ -54,3 +79,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..296e226392 100644 --- a/structure/providers/kiro.md +++ b/structure/providers/kiro.md @@ -31,26 +31,43 @@ 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. + +Native effort admission is narrower than model eligibility: luna and terra send only +`low`, `medium`, `high`, and `max` on the native field. Their `xhigh` requests retain the +previous emulated thinking tags because that native rung is unverified. A future shared +effort rung does not expand this allowlist. Sol and Opus keep their existing native ladder. + +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..7eb45b4077 100644 --- a/tests/providers/kiro/kiro-reasoning-roundtrip.test.ts +++ b/tests/providers/kiro/kiro-reasoning-roundtrip.test.ts @@ -1,9 +1,15 @@ import { describe, expect, test } from "bun:test"; +import { buildKiroPayload } from "../../../src/adapters/kiro/payload"; +import { kiroNativeEffortField } from "../../../src/adapters/kiro/reasoning"; 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 +141,162 @@ 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, but luna/terra keep xhigh emulated until verified. +describe("kiro native reasoning effort — the GPT-5.6 family", () => { + function wireBody(modelId: string, effort = "max"): Record { + const parsed = { + modelId, + stream: true, + options: { reasoning: effort, 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"]) { + 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).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" } }); + }); +});