Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f9217e2
fix(kiro): emulate structured output instead of refusing it
mushikingh Nov 1, 2026
0b35294
fix(kiro): retry structured output only after a clean stop
mushikingh Nov 1, 2026
9e0a839
fix(kiro): end a malformed structured retry without a replay
mushikingh Nov 1, 2026
ebbbe72
fix(kiro): retry a truncated structured answer like an invalid one
mushikingh Nov 1, 2026
3824d20
fix(kiro): retry a structured call left open at end of stream
mushikingh Nov 1, 2026
5e8aee0
docs(kiro): document the functions the structured-output change touches
mushikingh Nov 1, 2026
bb69c1e
Merge remote-tracking branch 'origin/dev' into lane/pr-8071
lidge-jun Nov 3, 2026
5eb0abf
fix(kiro): validate strict structured output, keep wrapped refs and s…
lidge-jun Nov 3, 2026
81ed353
fix(kiro): bound strict structured-output validation to an exact subset
lidge-jun Nov 3, 2026
fad563b
fix(kiro): refuse strict numeric const/enum, multipleOf and regular e…
lidge-jun Nov 3, 2026
adce3cb
Merge remote-tracking branch 'origin/dev' into lane-d/8071
lidge-jun Nov 3, 2026
3daa6a1
test(kiro): use a string const in the strict rich-schema fixture
lidge-jun Nov 3, 2026
7819b7a
fix(kiro): bound strict schema preparation and literal checks, keep d…
lidge-jun Nov 3, 2026
c4feea7
fix(kiro): charge every per-node loop, decode $ref before matching, r…
lidge-jun Nov 3, 2026
7a99d31
fix(kiro): charge number checks, refuse repeated types and schema-les…
lidge-jun Nov 3, 2026
051c9c4
fix(kiro): linear decimal normalization, decoded wrapping pointers, n…
lidge-jun Nov 3, 2026
0addb35
Merge remote-tracking branch 'origin/dev' into lane-d/8071
lidge-jun Nov 3, 2026
96d4801
docs(kiro): an empty clean first response takes the bounded retry
lidge-jun Nov 3, 2026
918eb14
fix(kiro): hold Messages schemas to full conformance, bound exponent …
lidge-jun Nov 3, 2026
670ddeb
Merge remote-tracking branch 'origin/dev' into lane-d/8071
lidge-jun Nov 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions docs-site/src/content/docs/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -507,9 +507,45 @@ MiMo model Command Code serves.
solely because the client permits parallel calls.
- Accepts Responses `text` controls that are not structured output — `text.verbosity` and
`text.format: {"type":"text"}` — without forwarding them. Kiro has no wire field for either, so
they are ignored rather than rejected. Structured output (`text.format` of type `json_schema`
or `json_object`) is still refused: the Kiro wire cannot constrain the response shape, and a
caller expecting JSON would otherwise receive prose.
they are ignored rather than rejected.
- Emulates structured output (Responses `text.format` of type `json_schema` or `json_object`, and
Messages `output_config.format`). The Kiro wire cannot constrain the response shape, so the
adapter advertises a private output tool whose input schema is the requested schema and returns
that tool's input as the response text. A plain-text reply that is already the requested JSON is
accepted as is. Any other reply, including an empty one, gets one bounded retry when the model stopped cleanly (a reply cut by the output-token limit, a content filter or a full context window ends with that stop's own error instead), and if the retry also fails the request ends
with an `invalid_kiro_structured_output` error. A caller expecting JSON never receives prose.
Without `strict: true` the check covers JSON syntax, the root type and the root's required
properties. With `strict: true` the whole value is checked exactly against the schema, and a
strict schema outside this subset is refused before the request is sent:
- `type`, `properties`, `required`, `additionalProperties`, single-schema `items`, `enum`,
`const` and `anyOf`. A `const` or `enum` value that contains a number is refused: the proxy
receives the request already parsed, so a number such as `1.0000000000000001` arrives as `1`
and an exact match could accept a value the caller excluded.
- `$ref` only to `#/$defs/<name>` or `#/definitions/<name>` at the schema root, with `~0` and
`~1` as the only JSON Pointer escapes. References such
as `#`, `#/properties/...` or `#/default`, nested `$defs`, and reference cycles that never
reach a property or item are refused, and so is a `$ref` with an assertion keyword beside it
(drafts disagree on whether such siblings apply), a `$id` below the root, and `$anchor`.
- `minLength`, `maxLength`, `minItems` and `maxItems` as non-negative integers.
- `minimum`, `maximum`, `exclusiveMinimum` and `exclusiveMaximum` as safe integers or decimals
with at most 15 significant digits. The model's numbers are compared exactly as written. This is
a heuristic for the bound itself: a bound written with more digits than a double holds can still
arrive rounded, so a value within about one unit in the last place of the bound may be judged on
the wrong side.
- `multipleOf`, `pattern` and `patternProperties` are refused in strict mode. Without
`strict: true` they are accepted and left to the light checks above.

Validation work is capped per answer (10,000 schema checks plus 16 per JSON value, 128 schema
levels deep), including `const`/`enum` comparisons and every pass over `required` lists, object
entries, long strings and long numbers. A strict format without a JSON object schema is refused,
and so is a `type` list that repeats a type. An answer that needs more counts as not
matching the format. A strict schema with more than 10,000 nodes, or more than 10,000 values in
its `const`/`enum` literals, is refused. Error messages name the failed check only, never a
property name or path from the answer or the schema.
A Messages `output_config.format` schema always gets the `strict: true` checks, even when it
has optional properties and is therefore forwarded to OpenAI-compatible destinations without
`strict`: Anthropic promises an answer that conforms to the schema. A Messages schema outside
this subset is refused before the request is sent.
- Decodes `application/vnd.amazon.eventstream`, reconstructs text/thinking/tool events, detects
truncated tool JSON, and estimates usage because the upstream does not return token counts.
Decoder teardown initiates cancellation without waiting for transport cleanup to settle, so a
Expand Down
4 changes: 2 additions & 2 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1099,7 +1099,7 @@
"kiro-review-regressions.test.ts": "providers/kiro",
"kiro-metering-events.test.ts": "providers/kiro",
"kiro-metering-usage.test.ts": "providers/kiro",
"kiro-single-final.test.ts": "providers/kiro",
"kiro-single-final.test.ts": "providers/kiro", "kiro-structured-output.test.ts": "providers/kiro", "kiro-structured-output-strict.test.ts": "providers/kiro",
"kiro-stream.test.ts": "providers/kiro",
"kiro-transport-parity.test.ts": "providers/kiro",
"kiro-usage-quota.test.ts": "providers/kiro",
Expand Down Expand Up @@ -1671,7 +1671,7 @@
"server-images.test.ts": "server",
"server-key-failover-e2e.test.ts": "server", "distributed-key-401.test.ts": "server",
"server-jev-combo-e2e.test.ts": "server", "combo-requested-effort.test.ts": "server",
"server-kiro-completion-e2e.test.ts": "server",
"server-kiro-completion-e2e.test.ts": "server", "server-kiro-structured-output-e2e.test.ts": "server",
"server-kiro-device-login.test.ts": "server", "server-kiro-oauth-401-replay.test.ts": "server",
"server-kiro-refusal-e2e.test.ts": "server", "server-live-existing-call.test.ts": "server",
"server-live-frame-log.test.ts": "server",
Expand Down
9 changes: 9 additions & 0 deletions src/adapters/kiro-constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
export const KIRO_COMPLETION_TOOL_NAME = "codex_kiro_final_answer";
/**
* Private terminal channel for Responses structured output (`text.format` json_schema/json_object).
*
* The CodeWhisperer wire has no schema-constrained response mode, but it does carry tool input
* schemas, and a tool call's input is JSON by construction. A turn that asks for structured output
* therefore advertises this tool INSTEAD of `KIRO_COMPLETION_TOOL_NAME` and relays its input as the
* assistant's text. Reserved in the same collision domain so a client tool can never claim it.
*/
export const KIRO_STRUCTURED_OUTPUT_TOOL_NAME = "codex_kiro_structured_output";

/**
* Request-scoped CodeWhisperer service profile for AWS Builder ID accounts.
Expand Down
23 changes: 14 additions & 9 deletions src/adapters/kiro-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,25 +60,30 @@ const KIRO_REJECTED_SCHEMA_KEYS = new Set([
// "format"/"pattern" would be deleted. We recurse into the value schemas but keep every name intact.
const SCHEMA_MAP_KEYS = new Set(["properties", "$defs", "definitions"]);

// Keys whose values are JSON data rather than schemas. Recursing into them would strip
// keyword-named keys from the data itself and turn `const: {"format":"pdf"}` into `const: {}`,
// silently changing the value a tool argument must equal.
const SCHEMA_LITERAL_KEYS = new Set(["const", "enum", "default", "examples"]);

function sanitizeSchemaMap(value: unknown): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) return sanitizeKiroSchema(value);
const out: Record<string, unknown> = {};
for (const [name, child] of Object.entries(value as Record<string, unknown>)) {
out[name] = sanitizeKiroSchema(child);
}
return out;
// fromEntries defines own properties: a property named "__proto__" stays a property instead of
// hitting the prototype setter and vanishing from the advertised schema.
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([name, child]) => [name, sanitizeKiroSchema(child)]));
}

function sanitizeKiroSchema(value: unknown): unknown {
/** Strip the keywords Kiro's tool-spec validator rejects, everywhere in a schema tree. */
export function sanitizeKiroSchema(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sanitizeKiroSchema);
if (!value || typeof value !== "object") return value;
const out: Record<string, unknown> = {};
const out: Array<[string, unknown]> = [];
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
if (KIRO_REJECTED_SCHEMA_KEYS.has(key)) continue;
if (key === "required" && Array.isArray(child) && child.length === 0) continue;
out[key] = SCHEMA_MAP_KEYS.has(key) ? sanitizeSchemaMap(child) : sanitizeKiroSchema(child);
if (SCHEMA_LITERAL_KEYS.has(key)) out.push([key, child]);
else out.push([key, SCHEMA_MAP_KEYS.has(key) ? sanitizeSchemaMap(child) : sanitizeKiroSchema(child)]);
}
return out;
return Object.fromEntries(out);
}

function ensureRootObjectType(schema: unknown): Record<string, unknown> {
Expand Down
14 changes: 9 additions & 5 deletions src/adapters/kiro-wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
import { hostname, userInfo } from "node:os";
import { normalizeKiroModelId } from "../providers/kiro-models";
import type { OcxParsedRequest } from "../types";
import { KIRO_COMPLETION_TOOL_NAME } from "./kiro-constants";
import { KIRO_COMPLETION_TOOL_NAME, KIRO_STRUCTURED_OUTPUT_TOOL_NAME } from "./kiro-constants";

let cachedFp: string | undefined;

Expand Down Expand Up @@ -77,15 +77,19 @@ export interface KiroToolNameRegistry {
readonly nameMap: Map<string, string>;
}

/** One collision domain for advertised tools, replayed calls, and the private completion tool. */
/** Proxy-owned terminal tools; neither may be claimed by a client tool or a replayed call. */
const KIRO_RESERVED_TOOL_NAMES: readonly string[] = [KIRO_COMPLETION_TOOL_NAME, KIRO_STRUCTURED_OUTPUT_TOOL_NAME];

/** One collision domain for advertised tools, replayed calls, and the private terminal tools. */
export function createKiroToolNameRegistry(): KiroToolNameRegistry {
const used = new Set<string>([KIRO_COMPLETION_TOOL_NAME]);
const used = new Set<string>(KIRO_RESERVED_TOOL_NAMES);
const wireToKiro = new Map<string, string>();
const nameMap = new Map<string, string>();
return {
/** Return the Kiro-safe alias for a wire tool name, registering it on first use; reserved names are refused. */
alias(wireName: string): string {
if (wireName === KIRO_COMPLETION_TOOL_NAME) {
throw new Error(`Kiro reserves the tool name ${JSON.stringify(KIRO_COMPLETION_TOOL_NAME)}`);
if (KIRO_RESERVED_TOOL_NAMES.includes(wireName)) {
throw new Error(`Kiro reserves the tool name ${JSON.stringify(wireName)}`);
}
const existing = wireToKiro.get(wireName);
if (existing) return existing;
Expand Down
21 changes: 18 additions & 3 deletions src/adapters/kiro/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { fetchKiroWithRetry } from "../kiro-retry";
import { fingerprint, invocationId, osTag } from "../kiro-wire";
import { hasTrailingDeliveredFinalAnswer } from "./conversation";
import { buildKiroPayload } from "./payload";
import type { KiroStructuredOutputSpec } from "./structured-output";
import {
jsonStringSerializedUtf8Bytes,
parseKiroStream,
Expand Down Expand Up @@ -60,6 +61,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
let toolNameMap: Map<string, string> | undefined;
let conversationId: string | undefined;
let completionMode: KiroCompletionMode = "disabled";
let structuredOutput: KiroStructuredOutputSpec | undefined;
let requestSnapshot: OcxParsedRequest | undefined;
let firstRequestBodyBytes = 0;
let requestAbortSignal: AbortSignal | undefined;
Expand Down Expand Up @@ -88,6 +90,10 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
requestOnPhysicalSend?.({ ordinal, ...(recovery ? { recovery } : {}) });
};

/**
* Build the Kiro request for a parsed turn, optionally forcing the completion mode (the bounded
* retry passes `text_fallback`), and capture the state the stream parser needs.
*/
const build = async (
parsed: OcxParsedRequest,
forcedCompletionMode?: KiroCompletionMode,
Expand All @@ -96,6 +102,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
nameMap: Map<string, string>;
conversationId: string;
completionMode: KiroCompletionMode;
structuredOutput?: KiroStructuredOutputSpec;
inputTokens: number;
contextInputEstimate: number;
}> => {
Expand Down Expand Up @@ -154,6 +161,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
region,
requestedModel: parsed.modelId,
completionMode: built.completionMode,
structuredOutput: built.structuredOutput !== undefined,
bodyBytes: new TextEncoder().encode(body).length,
messageCount: kiroPayloadMessages(parsed).length,
toolCount: parsed.context.tools?.length ?? 0,
Expand All @@ -173,6 +181,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
nameMap: built.nameMap,
conversationId: built.conversationId,
completionMode: built.completionMode,
...(built.structuredOutput ? { structuredOutput: built.structuredOutput } : {}),
inputTokens: estimateKiroInputTokens(parsed),
contextInputEstimate,
};
Expand Down Expand Up @@ -297,6 +306,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
toolNameMap = built.nameMap;
conversationId = built.conversationId;
completionMode = built.completionMode;
structuredOutput = built.structuredOutput;
// Runtime callbacks belong to the original request, not the retry payload.
requestSnapshot = structuredClone(Object.fromEntries(
Object.entries(parsed).filter(([, value]) => typeof value !== "function"),
Expand All @@ -306,6 +316,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
return built.request;
},

/** Parse a streaming Kiro response, with the bounded retry wired to this request. */
parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator<AdapterEvent> {
return parseKiroStream(
response,
Expand All @@ -318,6 +329,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
completionMode,
completionMode === "required" ? fallbackFactory : undefined,
contextInputEstimate,
structuredOutput,
);
},

Expand Down Expand Up @@ -345,9 +357,11 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
return safeKiroHttpErrorMessage(status, headers, payloadText);
},

// Kiro always returns an event stream, including for non-streaming Responses requests. Drain
// the decoder into a budget-owned batch so an upstream stream cannot grow this array without
// bound while the caller waits for the complete JSON response.
/**
* Kiro always returns an event stream, including for non-streaming Responses requests. Drain
* the decoder into a budget-owned batch so an upstream stream cannot grow this array without
* bound while the caller waits for the complete JSON response.
*/
async parseResponse(response: Response, budget: TranslatorBudget): Promise<AdapterEvent[]> {
const events: AdapterEvent[] = [];
try {
Expand All @@ -362,6 +376,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
completionMode,
completionMode === "required" ? fallbackFactory : undefined,
contextInputEstimate,
structuredOutput,
)) {
retainTranslatedEvent(e, budget, events.at(-1));
events.push(e);
Expand Down
16 changes: 8 additions & 8 deletions src/adapters/kiro/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
import type { KiroImage } from "../kiro-images";
import type { KiroHistoryEntry, KiroToolResult, KiroToolUse } from "./wire";

/** Refuse request controls the Kiro wire cannot honour (forced tool choice, service tiers) before a request is built. */
export function validateKiroCapabilities(parsed: OcxParsedRequest): void {
const choice = parsed.options.toolChoice;
if (choice !== undefined && choice !== "auto" && choice !== "none") {
Expand All @@ -15,12 +16,14 @@ export function validateKiroCapabilities(parsed: OcxParsedRequest): void {
if (parsed.options.serviceTier !== undefined) {
throw new Error("Kiro does not support service tiers");
}
// Structured output is a real contract Kiro cannot honour: the wire has no
// schema-constrained response mode, so a caller expecting parseable JSON would receive
// prose and fail downstream. Refuse it.
// Structured output is a real contract the wire has no response mode for, so it is not
// validated here: buildKiroPayload emulates it through the private structured-output tool
// (structured-output.ts), and the stream relays only JSON that tool or the bounded retry
// produced, or fails the turn. It used to be refused at this point, which kept the
// "no prose for a JSON caller" promise by failing every such turn instead.
//
// The rest of the Responses `text` object is not that. `text.verbosity` is a length
// preference and `text.format: {type:"text"}` is ordinary prose — the default output
// The rest of the Responses `text` object is not a contract at all. `text.verbosity` is a
// length preference and `text.format: {type:"text"}` is ordinary prose — the default output
// mode, which no capability flag governs and every correct client may send. Testing
// `_rawBody.text !== undefined` refused those turns for the mere PRESENCE of the key,
// the same mistake db040e70f removed one condition earlier where a permissive
Expand All @@ -30,9 +33,6 @@ export function validateKiroCapabilities(parsed: OcxParsedRequest): void {
// buildKiroPayload composes conversationState field by field from `parsed` and never
// spreads `_rawBody`, so a tolerated control is dropped by construction. The test
// asserts that absence so it stays true.
if (parsed._structuredOutput) {
throw new Error("Kiro does not support Responses structured output");
}
}

export type KiroTurn =
Expand Down
Loading
Loading