@bitkyc08/opencodex 2.7.33 → 2.7.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.ja.md +1 -1
  2. package/README.ko.md +1 -1
  3. package/README.md +21 -10
  4. package/README.ru.md +1 -1
  5. package/README.zh-CN.md +1 -1
  6. package/gui/dist/assets/index-BkmJJgg6.js +52 -0
  7. package/gui/dist/assets/index-Sg-7L_oZ.css +1 -0
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic.ts +13 -6
  11. package/src/adapters/cursor/discovery.ts +39 -4
  12. package/src/adapters/cursor/exec-policy.ts +11 -13
  13. package/src/adapters/cursor/live-transport.ts +22 -4
  14. package/src/adapters/cursor/protobuf-events.ts +140 -8
  15. package/src/adapters/cursor/protobuf-request.ts +15 -0
  16. package/src/adapters/cursor/request-builder.ts +10 -5
  17. package/src/adapters/cursor/transport.ts +3 -2
  18. package/src/adapters/cursor/types.ts +14 -0
  19. package/src/adapters/kiro-constants.ts +12 -0
  20. package/src/adapters/kiro-errors.ts +111 -2
  21. package/src/adapters/kiro-events.ts +154 -35
  22. package/src/adapters/kiro-retry.ts +116 -32
  23. package/src/adapters/kiro-tools.ts +30 -20
  24. package/src/adapters/kiro-wire.ts +47 -6
  25. package/src/adapters/kiro.ts +891 -228
  26. package/src/adapters/openai-chat.ts +12 -5
  27. package/src/adapters/openai-responses.ts +7 -2
  28. package/src/bridge.ts +109 -26
  29. package/src/claude/outbound.ts +27 -4
  30. package/src/cli/index.ts +1 -1
  31. package/src/codex/catalog.ts +375 -33
  32. package/src/combos/index.ts +3 -0
  33. package/src/combos/request.ts +4 -4
  34. package/src/combos/resolve.ts +2 -2
  35. package/src/combos/types.ts +104 -2
  36. package/src/config.ts +70 -1
  37. package/src/lib/eventstream-decoder.ts +9 -0
  38. package/src/oauth/index.ts +3 -1
  39. package/src/oauth/kiro-credentials.ts +48 -20
  40. package/src/oauth/login-cli.ts +2 -0
  41. package/src/providers/derive.ts +8 -0
  42. package/src/providers/kiro-models.ts +2 -2
  43. package/src/providers/openai-sidecar.ts +28 -1
  44. package/src/providers/registry.ts +39 -2
  45. package/src/responses/parser.ts +22 -10
  46. package/src/responses/schema.ts +1 -0
  47. package/src/responses/state.ts +50 -10
  48. package/src/router.ts +15 -3
  49. package/src/server/auth-cors.ts +7 -0
  50. package/src/server/claude-messages.ts +6 -0
  51. package/src/server/index.ts +6 -3
  52. package/src/server/management-api.ts +187 -43
  53. package/src/server/ports.ts +4 -2
  54. package/src/server/request-log.ts +3 -2
  55. package/src/server/responses-item-id-repair.ts +281 -0
  56. package/src/server/responses.ts +274 -73
  57. package/src/types.ts +109 -16
  58. package/src/update/job.ts +81 -1
  59. package/src/vision/describe.ts +2 -1
  60. package/src/web-search/executor.ts +2 -1
  61. package/src/web-search/loop.ts +9 -1
  62. package/src/web-search/progress-stream.ts +12 -10
  63. package/gui/dist/assets/index-D6Fcl4yM.css +0 -1
  64. package/gui/dist/assets/index-d63HMU0x.js +0 -52
@@ -22,6 +22,8 @@ import {
22
22
  McpToolResultSchema,
23
23
  McpToolsSchema,
24
24
  ModelDetailsSchema,
25
+ RequestedModelSchema,
26
+ RequestedModel_ModelParameterbytesSchema,
25
27
  ResumeActionSchema,
26
28
  RequestContextSchema,
27
29
  RequestContextEnvSchema,
@@ -43,6 +45,9 @@ import {
43
45
 
44
46
  const encoder = new TextEncoder();
45
47
 
48
+ /** Parameter id advertised by Cursor's `default` model for its Cost/Balance/Intelligence control. */
49
+ export const CURSOR_ROUTING_LEVEL_PARAMETER_ID = "optimization";
50
+
46
51
  /** Runtime timezone for protobuf RequestContextEnv (dynamic, never hardcoded). */
47
52
  function runtimeTimeZone(): string {
48
53
  try {
@@ -364,6 +369,16 @@ export function encodeCursorRunRequest(request: CursorRunRequest): Uint8Array {
364
369
  displayNameShort: request.modelId,
365
370
  aliases: [],
366
371
  }),
372
+ ...(request.routingLevel ? {
373
+ requestedModel: create(RequestedModelSchema, {
374
+ modelId: request.modelId,
375
+ maxMode: false,
376
+ parameters: [create(RequestedModel_ModelParameterbytesSchema, {
377
+ id: CURSOR_ROUTING_LEVEL_PARAMETER_ID,
378
+ value: request.routingLevel,
379
+ })],
380
+ }),
381
+ } : {}),
367
382
  // Mirror the client (Responses) tool definitions into the top-level AgentRunRequest.mcp_tools
368
383
  // channel. Advertising them ONLY via native-exec `requestContextArgs` (RequestContext.tools) is
369
384
  // insufficient: cursor models report those tools as unavailable and fall back to native tools.
@@ -8,7 +8,7 @@ import type {
8
8
  } from "../../types";
9
9
  import { isAllowedToolChoice, namespacedToolName, toolChoiceAliases, type OcxTool, type OcxToolChoice } from "../../types";
10
10
  import type { CursorRequestMessage, CursorRunRequest } from "./types";
11
- import { cursorCodexToWireModelId } from "./discovery";
11
+ import { cursorWireModelSelection, type CursorRoutingLevel } from "./discovery";
12
12
  import { cursorEffortSuffix } from "./effort-map";
13
13
  import {
14
14
  cursorMcpToolEncodedSize,
@@ -97,10 +97,11 @@ function catalogLimitNote(kept: readonly OcxTool[], omitted: readonly OcxTool[])
97
97
  * `undefined` for non-reasoning models like `composer-2.5`. A fully-qualified id (one that isn't a
98
98
  * known effort base) passes through unchanged.
99
99
  */
100
- function normalizeCursorModelId(modelId: string, reasoning?: string): string {
101
- const id = cursorCodexToWireModelId(modelId);
100
+ function normalizeCursorModelId(modelId: string, reasoning?: string): { modelId: string; routingLevel?: CursorRoutingLevel } {
101
+ const selection = cursorWireModelSelection(modelId);
102
+ const id = selection.modelId;
102
103
  const suffix = cursorEffortSuffix(id, reasoning);
103
- return suffix ? `${id}-${suffix}` : id;
104
+ return { ...selection, modelId: suffix ? `${id}-${suffix}` : id };
104
105
  }
105
106
 
106
107
  function contentPartToText(part: OcxContentPart | OcxAssistantContentPart): string | undefined {
@@ -166,8 +167,10 @@ export function createCursorRequest(parsed: OcxParsedRequest): CursorRunRequest
166
167
  const visibleTools = cursorToolsForActivePrompt(parsed.context.tools, activeText, parsed.options.toolChoice);
167
168
  const budget = applyCursorToolBudget(visibleTools, parsed.options.toolChoice);
168
169
  const limitNote = catalogLimitNote(budget.tools, budget.omitted);
170
+ const model = normalizeCursorModelId(parsed.modelId, parsed.options.reasoning);
169
171
  return {
170
- modelId: normalizeCursorModelId(parsed.modelId, parsed.options.reasoning),
172
+ modelId: model.modelId,
173
+ ...(model.routingLevel ? { routingLevel: model.routingLevel } : {}),
171
174
  // The Cursor conversation id comes ONLY from remembered state (_cursorConversationId). Do NOT fall
172
175
  // back to the OpenAI Responses previous_response_id (resp_*): that is a Responses-chain id in a
173
176
  // different namespace and would start an unrelated Cursor conversation, breaking tool-result
@@ -176,6 +179,8 @@ export function createCursorRequest(parsed: OcxParsedRequest): CursorRunRequest
176
179
  system: [...(parsed.context.systemPrompt ?? []), ...(limitNote ? [limitNote] : [])],
177
180
  messages,
178
181
  rawMessages: parsed.context.messages,
182
+ ...(parsed._compactionRequest === true || parsed._contextCompactionBoundary === true ? { contextUsageReset: true } : {}),
183
+ ...(parsed._compactionRequest === true ? { contextUsageStoreCheckpoints: false } : {}),
179
184
  ...(budget.tools.length ? { tools: budget.tools } : {}),
180
185
  ...(parsed.options.toolChoice ? { toolChoice: parsed.options.toolChoice } : {}),
181
186
  ...(parsed.options.parallelToolCalls !== undefined ? { parallelToolCalls: parsed.options.parallelToolCalls } : {}),
@@ -24,8 +24,9 @@ export interface CursorTransportFactoryInput {
24
24
  */
25
25
  clientToolFinalizeGraceMs?: number;
26
26
  /**
27
- * True when the inbound request's system/developer text declares the Codex full-access
28
- * sandbox; consumed by nativeLocalExec:"codex-sandbox" policy (exec-policy.ts).
27
+ * True when inbound request text carries the legacy Codex full-access sandbox marker.
28
+ * This is retained as diagnostic/context only; exec-policy.ts does not trust it as
29
+ * native local exec authorization because the text is caller-controlled.
29
30
  */
30
31
  requestDeclaresFullAccess?: boolean;
31
32
  }
@@ -1,8 +1,11 @@
1
1
  import type { OcxUsage } from "../../types";
2
2
  import type { OcxMessage, OcxRequestOptions, OcxTool } from "../../types";
3
+ import type { CursorRoutingLevel } from "./discovery";
3
4
 
4
5
  export interface CursorRunRequest {
5
6
  modelId: string;
7
+ /** Cursor Router optimization parameter; valid only while modelId is the `default` wire model. */
8
+ routingLevel?: CursorRoutingLevel;
6
9
  conversationId: string;
7
10
  system: string[];
8
11
  messages: CursorRequestMessage[];
@@ -10,6 +13,17 @@ export interface CursorRunRequest {
10
13
  tools?: OcxTool[];
11
14
  toolChoice?: OcxRequestOptions["toolChoice"];
12
15
  parallelToolCalls?: boolean;
16
+ /**
17
+ * Clear provider-private context-usage carry-forward before this run. Used when Codex starts a
18
+ * newly observed compacted context epoch, so pre-compaction totals are not over-reported while
19
+ * historical previous_response_id replay remains idempotent.
20
+ */
21
+ contextUsageReset?: boolean;
22
+ /**
23
+ * Defaults to true. Set false for compaction summarizer turns: their checkpoints describe the
24
+ * pre-compaction history being summarized and must not become the next turn's carry-forward total.
25
+ */
26
+ contextUsageStoreCheckpoints?: boolean;
13
27
  }
14
28
 
15
29
  export interface CursorRequestMessage {
@@ -0,0 +1,12 @@
1
+ export const KIRO_COMPLETION_TOOL_NAME = "codex_kiro_final_answer";
2
+ export const KIRO_CONTINUATION_MESSAGE = "[system: conversation continues]";
3
+ export const KIRO_COMPLETION_RETRY_MESSAGE =
4
+ `[system: The preceding assistant output did not explicitly complete the turn. If the task is complete, call ${KIRO_COMPLETION_TOOL_NAME} now with the complete final answer. Otherwise issue the next real tool call now. Do not ask the user for another task or emit another progress-only message.]`;
5
+
6
+ export const KIRO_COMPLETION_INSTRUCTIONS =
7
+ `When tools are available, ordinary assistant text is mid-task commentary and does not end the turn. Continue using tools after progress updates. When the task is fully complete and no more tool calls are needed, call ${KIRO_COMPLETION_TOOL_NAME} exactly once with the complete user-facing final answer in \`answer\`. Do not provide the final answer as ordinary assistant text.`;
8
+
9
+ export type KiroCompletionMode = "disabled" | "required" | "text_fallback";
10
+
11
+ /** Bound proxy-authored prompt additions independently of caller-owned instructions/history. */
12
+ export const MAX_KIRO_INJECTED_INSTRUCTION_CHARS = 16_384;
@@ -1,5 +1,13 @@
1
1
  import { parseUpstreamJsonPayload, safeUpstreamErrorString, sanitizeUpstreamErrorText } from "./upstream-http-error";
2
- const DETAIL_KEYS = ["__type", "code", "error", "name", "message", "Message", "errorMessage"];
2
+ const DETAIL_KEYS = ["__type", "code", "error", "name", "reason", "message", "Message", "errorMessage"];
3
+
4
+ export interface KiroErrorClassification {
5
+ message: string;
6
+ status: number;
7
+ errorType: string;
8
+ code: string;
9
+ retryable: boolean;
10
+ }
3
11
 
4
12
  function headerValue(headers: Headers | Record<string, unknown>, name: string): string | undefined {
5
13
  if (headers instanceof Headers) return name.startsWith(":") ? undefined : safeUpstreamErrorString(headers.get(name));
@@ -79,10 +87,111 @@ function normalizedKiroErrorMessage(headers: Headers | Record<string, unknown>,
79
87
  return detail ? `${prefix}: ${detail}` : prefix;
80
88
  }
81
89
 
90
+ function isContentLengthError(text: string): boolean {
91
+ const lower = text.toLowerCase();
92
+ return lower.includes("content_length_exceeds_threshold") || lower.includes("content length exceeds");
93
+ }
94
+
95
+ function classifyKiroFailure(
96
+ headers: Headers | Record<string, unknown>,
97
+ payloadText: string,
98
+ status?: number,
99
+ ): KiroErrorClassification {
100
+ const message = normalizedKiroErrorMessage(headers, payloadText, status);
101
+ const headerType = headerValue(headers, ":exception-type") || headerValue(headers, ":error-type") || "";
102
+ const evidence = [headerType, ...payloadDetails(payloadText), message].join(" ").toLowerCase();
103
+ if (isContentLengthError(evidence)) {
104
+ return {
105
+ message: "Kiro rejected the request because the conversation exceeds the model's context window. Compact or reduce the history, or start a new session.",
106
+ status: 400,
107
+ errorType: "invalid_request_error",
108
+ code: "context_length_exceeded",
109
+ retryable: false,
110
+ };
111
+ }
112
+ if (
113
+ evidence.includes("insufficient_quota")
114
+ || evidence.includes("quota exhausted")
115
+ || evidence.includes("quota exceeded")
116
+ ) {
117
+ return { message, status: 429, errorType: "insufficient_quota", code: "insufficient_quota", retryable: false };
118
+ }
119
+ if (
120
+ status === 429
121
+ || evidence.includes("throttlingexception")
122
+ || evidence.includes("too many requests")
123
+ || evidence.includes("rate limit")
124
+ ) {
125
+ return { message, status: 429, errorType: "rate_limit_error", code: "rate_limit_exceeded", retryable: true };
126
+ }
127
+ if (
128
+ status === 401
129
+ || status === 403
130
+ || evidence.includes("accessdenied")
131
+ || evidence.includes("unauthorized")
132
+ || evidence.includes("unrecognizedclient")
133
+ || evidence.includes("expiredtoken")
134
+ || evidence.includes("expired token")
135
+ || evidence.includes("invalid token")
136
+ || evidence.includes("authentication")
137
+ ) {
138
+ return { message, status: status === 403 ? 403 : 401, errorType: status === 403 ? "permission_error" : "authentication_error", code: status === 403 ? "permission_denied" : "invalid_api_key", retryable: false };
139
+ }
140
+ if (
141
+ status === 400
142
+ || evidence.includes("validationexception")
143
+ || evidence.includes("invalid request")
144
+ || evidence.includes("model unavailable")
145
+ || evidence.includes("model not found")
146
+ || evidence.includes("unsupported model")
147
+ || evidence.includes("profile arn")
148
+ || evidence.includes("malformed")
149
+ ) {
150
+ return { message, status: 400, errorType: "invalid_request_error", code: "invalid_request_error", retryable: false };
151
+ }
152
+ if (
153
+ status === 503
154
+ || evidence.includes("overloaded")
155
+ || evidence.includes("server is busy")
156
+ || evidence.includes("temporarily unavailable")
157
+ ) {
158
+ return { message, status: 503, errorType: "server_error", code: "server_is_overloaded", retryable: true };
159
+ }
160
+ return {
161
+ message,
162
+ status: status && status >= 500 ? status : 502,
163
+ errorType: "server_error",
164
+ code: "upstream_server_error",
165
+ retryable: true,
166
+ };
167
+ }
168
+
82
169
  export function safeKiroErrorMessage(headers: Record<string, unknown>, payloadText: string): string {
83
170
  return normalizedKiroErrorMessage(headers, payloadText);
84
171
  }
85
172
 
173
+ export function classifyKiroStreamError(
174
+ headers: Record<string, unknown>,
175
+ payloadText: string,
176
+ ): KiroErrorClassification {
177
+ return classifyKiroFailure(headers, payloadText);
178
+ }
179
+
180
+ export function classifyKiroHttpError(
181
+ status: number,
182
+ headers: Headers | Record<string, unknown>,
183
+ payloadText: string,
184
+ ): KiroErrorClassification {
185
+ return classifyKiroFailure(headers, payloadText, status);
186
+ }
187
+
188
+ export function classifyKiroEventError(reason: string | undefined, message: string | undefined): KiroErrorClassification {
189
+ const safeReason = reason ? sanitizeUpstreamErrorText(reason).slice(0, 160) : "";
190
+ const safeMessage = message ? sanitizeUpstreamErrorText(message).slice(0, 500) : "Kiro request failed";
191
+ const payload = JSON.stringify({ reason: safeReason, message: safeMessage });
192
+ return classifyKiroFailure({}, payload);
193
+ }
194
+
86
195
  export function safeKiroHttpErrorMessage(status: number, headers: Headers | Record<string, unknown>, payloadText: string): string {
87
- return normalizedKiroErrorMessage(headers, payloadText, status);
196
+ return classifyKiroFailure(headers, payloadText, status).message;
88
197
  }
@@ -1,48 +1,167 @@
1
+ import type { OcxUsage } from "../types";
1
2
  import { kiroTruncationReason } from "./kiro-truncation";
2
3
 
3
- export interface ParsedKiroEvent {
4
- type: "content" | "tool_start" | "tool_input" | "tool_stop" | "truncation" | "usage" | "context_usage";
5
- data?: string;
6
- contextUsagePercentage?: number;
7
- usage?: unknown;
8
- name?: string;
9
- toolUseId?: string;
10
- input?: string;
4
+ export type ParsedKiroEvent =
5
+ | { type: "content"; data?: string; modelId?: string }
6
+ | { type: "reasoning"; data?: string }
7
+ | { type: "tool"; name?: string; toolUseId?: string; input?: string; stop?: boolean }
8
+ | { type: "truncation"; data: string }
9
+ | { type: "metadata"; usage?: OcxUsage; contextUsagePercentage?: number }
10
+ | { type: "message_metadata"; conversationId?: string }
11
+ | { type: "invalid_state"; message?: string }
12
+ | { type: "error"; reason?: string; message?: string };
13
+
14
+ const KNOWN_EVENT_TYPES = new Set([
15
+ "assistantResponseEvent",
16
+ "reasoningContentEvent",
17
+ "toolUseEvent",
18
+ "messageMetadataEvent",
19
+ "metadataEvent",
20
+ "invalidStateEvent",
21
+ "error",
22
+ ]);
23
+
24
+ function malformed(eventType: string, detail: string): never {
25
+ throw new Error(`invalid Kiro ${eventType} payload: ${detail}`);
11
26
  }
12
27
 
13
- export function parseKiroEvent(payload: Uint8Array): ParsedKiroEvent | null {
14
- let text: string;
28
+ function parseObject(eventType: string, payload: Uint8Array): Record<string, unknown> {
29
+ let value: unknown;
15
30
  try {
16
- text = new TextDecoder().decode(payload).trim();
31
+ value = JSON.parse(new TextDecoder().decode(payload));
17
32
  } catch {
18
- return null;
33
+ return malformed(eventType, "expected valid JSON");
19
34
  }
20
- if (!text.startsWith("{")) return null;
21
- let parsed: Record<string, unknown>;
22
- try {
23
- parsed = JSON.parse(text);
24
- } catch {
25
- return null;
35
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
36
+ return malformed(eventType, "expected an object");
37
+ }
38
+ return value as Record<string, unknown>;
39
+ }
40
+
41
+ function optionalString(eventType: string, obj: Record<string, unknown>, key: string): string | undefined {
42
+ const value = obj[key];
43
+ if (value === undefined || value === null) return undefined;
44
+ if (typeof value !== "string") return malformed(eventType, `${key} must be a string`);
45
+ return value;
46
+ }
47
+
48
+ function optionalBoolean(eventType: string, obj: Record<string, unknown>, key: string): boolean | undefined {
49
+ const value = obj[key];
50
+ if (value === undefined || value === null) return undefined;
51
+ if (typeof value !== "boolean") return malformed(eventType, `${key} must be a boolean`);
52
+ return value;
53
+ }
54
+
55
+ function tokenCount(eventType: string, obj: Record<string, unknown>, key: string, required: boolean): number {
56
+ const value = obj[key];
57
+ if (value === undefined && !required) return 0;
58
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
59
+ return malformed(eventType, `${key} must be a non-negative safe integer`);
60
+ }
61
+ return value;
62
+ }
63
+
64
+ function parseTokenUsage(eventType: string, value: unknown): OcxUsage | undefined {
65
+ if (value === undefined || value === null) return undefined;
66
+ if (typeof value !== "object" || Array.isArray(value)) {
67
+ return malformed(eventType, "tokenUsage must be an object");
26
68
  }
69
+ const usage = value as Record<string, unknown>;
70
+ const uncached = tokenCount(eventType, usage, "uncachedInputTokens", true);
71
+ const cacheRead = tokenCount(eventType, usage, "cacheReadInputTokens", false);
72
+ const cacheWrite = tokenCount(eventType, usage, "cacheWriteInputTokens", false);
73
+ const outputTokens = tokenCount(eventType, usage, "outputTokens", true);
74
+ const totalTokens = tokenCount(eventType, usage, "totalTokens", true);
75
+ const inputTokens = uncached + cacheRead + cacheWrite;
76
+ if (!Number.isSafeInteger(inputTokens)) return malformed(eventType, "input token usage overflowed");
77
+ return {
78
+ inputTokens,
79
+ outputTokens,
80
+ totalTokens,
81
+ cachedInputTokens: cacheRead,
82
+ cacheReadInputTokens: cacheRead,
83
+ cacheCreationInputTokens: cacheWrite,
84
+ };
85
+ }
86
+
87
+ /** Decode a known Kiro event using its Smithy `:event-type` header. */
88
+ export function parseKiroEvent(eventType: string, payload: Uint8Array): ParsedKiroEvent | null {
89
+ // Unknown event types are intentionally ignored without parsing or logging their payload.
90
+ if (!KNOWN_EVENT_TYPES.has(eventType)) return null;
91
+ const parsed = parseObject(eventType, payload);
27
92
  const truncationReason = kiroTruncationReason(parsed);
28
93
  if (truncationReason) return { type: "truncation", data: truncationReason };
29
- if ("usage" in parsed) return { type: "usage", usage: parsed.usage };
30
- if (typeof parsed.contextUsagePercentage === "number" && Number.isFinite(parsed.contextUsagePercentage)) {
31
- return { type: "context_usage", contextUsagePercentage: parsed.contextUsagePercentage };
32
- }
33
- if ("content" in parsed && typeof parsed.content === "string") return { type: "content", data: parsed.content };
34
- const toolUseId = typeof parsed.toolUseId === "string" ? parsed.toolUseId : undefined;
35
- const name = typeof parsed.name === "string" ? parsed.name : undefined;
36
- if (parsed.stop === true) return { type: "tool_stop", toolUseId };
37
- if ("input" in parsed) {
38
- const input =
39
- typeof parsed.input === "object" && parsed.input !== null
40
- ? JSON.stringify(parsed.input)
41
- : typeof parsed.input === "string"
42
- ? parsed.input
43
- : "";
44
- return { type: "tool_input", input, name, toolUseId };
94
+
95
+ switch (eventType) {
96
+ case "assistantResponseEvent":
97
+ return {
98
+ type: "content",
99
+ ...(optionalString(eventType, parsed, "content") !== undefined
100
+ ? { data: optionalString(eventType, parsed, "content") }
101
+ : {}),
102
+ ...(optionalString(eventType, parsed, "modelId") !== undefined
103
+ ? { modelId: optionalString(eventType, parsed, "modelId") }
104
+ : {}),
105
+ };
106
+ case "reasoningContentEvent":
107
+ return {
108
+ type: "reasoning",
109
+ ...(optionalString(eventType, parsed, "text") !== undefined
110
+ ? { data: optionalString(eventType, parsed, "text") }
111
+ : {}),
112
+ };
113
+ case "toolUseEvent":
114
+ return {
115
+ type: "tool",
116
+ ...(optionalString(eventType, parsed, "name") !== undefined
117
+ ? { name: optionalString(eventType, parsed, "name") }
118
+ : {}),
119
+ ...(optionalString(eventType, parsed, "toolUseId") !== undefined
120
+ ? { toolUseId: optionalString(eventType, parsed, "toolUseId") }
121
+ : {}),
122
+ ...(optionalString(eventType, parsed, "input") !== undefined
123
+ ? { input: optionalString(eventType, parsed, "input") }
124
+ : {}),
125
+ ...(optionalBoolean(eventType, parsed, "stop") !== undefined
126
+ ? { stop: optionalBoolean(eventType, parsed, "stop") }
127
+ : {}),
128
+ };
129
+ case "messageMetadataEvent":
130
+ return {
131
+ type: "message_metadata",
132
+ conversationId:
133
+ optionalString(eventType, parsed, "conversationId")
134
+ ?? optionalString(eventType, parsed, "utteranceId"),
135
+ };
136
+ case "metadataEvent": {
137
+ const contextUsagePercentage = parsed.contextUsagePercentage;
138
+ if (
139
+ contextUsagePercentage !== undefined
140
+ && (typeof contextUsagePercentage !== "number" || !Number.isFinite(contextUsagePercentage))
141
+ ) {
142
+ return malformed(eventType, "contextUsagePercentage must be a finite number");
143
+ }
144
+ return {
145
+ type: "metadata",
146
+ ...(parseTokenUsage(eventType, parsed.tokenUsage) !== undefined
147
+ ? { usage: parseTokenUsage(eventType, parsed.tokenUsage) }
148
+ : {}),
149
+ ...(typeof contextUsagePercentage === "number" ? { contextUsagePercentage } : {}),
150
+ };
151
+ }
152
+ case "invalidStateEvent":
153
+ return { type: "invalid_state", message: optionalString(eventType, parsed, "message") };
154
+ case "error":
155
+ return {
156
+ type: "error",
157
+ reason:
158
+ optionalString(eventType, parsed, "reason")
159
+ ?? optionalString(eventType, parsed, "type")
160
+ ?? optionalString(eventType, parsed, "__type"),
161
+ message:
162
+ optionalString(eventType, parsed, "message")
163
+ ?? optionalString(eventType, parsed, "Message"),
164
+ };
45
165
  }
46
- if (name !== undefined) return { type: "tool_start", name, toolUseId };
47
166
  return null;
48
167
  }
@@ -1,6 +1,7 @@
1
1
  import type { AdapterFetchContext, AdapterRequest } from "./base";
2
2
  import { safeKiroHttpErrorMessage } from "./kiro-errors";
3
3
  import { normalizeUpstreamHttpErrorResponse } from "./upstream-http-error";
4
+ import { readBoundedResponseBody } from "../lib/bounded-body";
4
5
  import {
5
6
  abortError,
6
7
  cancelResponseBodyBestEffort,
@@ -10,54 +11,137 @@ import {
10
11
  sleepWithAbort,
11
12
  } from "../lib/upstream-retry";
12
13
 
13
- const KIRO_RETRY_ATTEMPTS = 3;
14
- const KIRO_RETRY_BASE_MS = 250;
15
- const KIRO_RETRY_MAX_MS = 2_000;
14
+ const RESET_ATTEMPTS = 3;
15
+ const RESET_RETRY_BASE_MS = 150;
16
+ const RESET_RETRY_MAX_MS = 1_000;
17
+ const CANONICAL_RUNTIME_HOST = /^runtime\.([a-z]{2}(?:-[a-z]+)+-\d)\.kiro\.dev$/i;
18
+ const ENDPOINT_ERROR_MARKERS = [
19
+ "unknownoperation",
20
+ "unknown operation",
21
+ "invalidsignature",
22
+ "invalid signature",
23
+ "endpoint not found",
24
+ "unsupported endpoint",
25
+ ];
26
+ const CONNECT_ERROR_CODES = new Set(["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED", "ENETUNREACH", "EHOSTUNREACH"]);
16
27
 
17
- function retryableKiroStatus(status: number): boolean {
18
- return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
28
+ function errorChain(error: unknown): Error[] {
29
+ const chain: Error[] = [];
30
+ let current = error;
31
+ const seen = new Set<unknown>();
32
+ while (current instanceof Error && !seen.has(current)) {
33
+ seen.add(current);
34
+ chain.push(current);
35
+ current = (current as Error & { cause?: unknown }).cause;
36
+ }
37
+ return chain;
19
38
  }
20
39
 
21
- function retryableKiroFetchError(err: unknown): boolean {
22
- return isConnectionResetError(err) || (err instanceof Error && err.name === "TimeoutError");
40
+ function endpointConnectFailure(error: unknown): boolean {
41
+ return errorChain(error).some(item => {
42
+ if (item.name === "AbortError" || item.name === "TimeoutError") return false;
43
+ const code = (item as Error & { code?: unknown }).code;
44
+ if (typeof code === "string" && CONNECT_ERROR_CODES.has(code)) return true;
45
+ const message = item.message.toLowerCase();
46
+ return message.includes("dns")
47
+ || message.includes("name resolution")
48
+ || message.includes("failed to lookup address")
49
+ || message.includes("connection refused")
50
+ || message.includes("failed to connect")
51
+ || message.includes("connect error");
52
+ });
23
53
  }
24
54
 
25
- async function normalizeFinalKiroHttpError(res: Response, signal?: AbortSignal): Promise<Response> {
26
- return normalizeUpstreamHttpErrorResponse(res, {
27
- signal,
28
- formatMessage: payloadText => safeKiroHttpErrorMessage(res.status, res.headers, payloadText),
29
- });
55
+ function legacyUrl(requestUrl: string): string | undefined {
56
+ let url: URL;
57
+ try { url = new URL(requestUrl); } catch { return undefined; }
58
+ const match = CANONICAL_RUNTIME_HOST.exec(url.hostname);
59
+ if (!match || url.pathname !== "/" || url.search || url.hash) return undefined;
60
+ url.hostname = `q.${match[1]}.amazonaws.com`;
61
+ return url.toString();
30
62
  }
31
63
 
32
- export async function fetchKiroWithRetry(request: AdapterRequest, ctx: AdapterFetchContext = {}): Promise<Response> {
33
- const timeoutMs = ctx.timeoutMs ?? 200_000;
64
+ async function fetchWithResetRecovery(
65
+ request: AdapterRequest,
66
+ url: string,
67
+ ctx: AdapterFetchContext,
68
+ timeoutMs: number,
69
+ ): Promise<Response> {
34
70
  let lastError: unknown;
35
- for (let attempt = 0; attempt < KIRO_RETRY_ATTEMPTS; attempt++) {
71
+ for (let attempt = 0; attempt < RESET_ATTEMPTS; attempt++) {
36
72
  if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal);
37
73
  try {
38
- const res = await fetchWithAttemptDeadline(request.url, {
74
+ const headers = new Headers(request.headers);
75
+ const recovered = attempt > 0;
76
+ if (recovered) headers.set("connection", "close");
77
+ return await fetchWithAttemptDeadline(url, {
39
78
  method: request.method,
40
- headers: request.headers,
79
+ headers,
41
80
  body: request.body,
81
+ ...(recovered ? { keepalive: false } : {}),
42
82
  }, timeoutMs, ctx.abortSignal, ctx.stream);
43
- if (!retryableKiroStatus(res.status) || attempt === KIRO_RETRY_ATTEMPTS - 1) {
44
- return ctx.returnRawErrors ? res : normalizeFinalKiroHttpError(res, ctx.abortSignal);
45
- }
46
- cancelResponseBodyBestEffort(res);
47
- await sleepWithAbort(retryBackoffDelayMs(attempt, {
48
- baseDelayMs: KIRO_RETRY_BASE_MS,
49
- maxDelayMs: KIRO_RETRY_MAX_MS,
50
- headers: res.headers,
51
- }), ctx.abortSignal);
52
- } catch (err) {
53
- if (ctx.abortSignal?.aborted) throw err;
54
- if (!retryableKiroFetchError(err) || attempt === KIRO_RETRY_ATTEMPTS - 1) throw err;
55
- lastError = err;
83
+ } catch (error) {
84
+ if (ctx.abortSignal?.aborted || !isConnectionResetError(error) || attempt === RESET_ATTEMPTS - 1) throw error;
85
+ lastError = error;
56
86
  await sleepWithAbort(retryBackoffDelayMs(attempt, {
57
- baseDelayMs: KIRO_RETRY_BASE_MS,
58
- maxDelayMs: KIRO_RETRY_MAX_MS,
87
+ baseDelayMs: RESET_RETRY_BASE_MS,
88
+ maxDelayMs: RESET_RETRY_MAX_MS,
59
89
  }), ctx.abortSignal);
60
90
  }
61
91
  }
62
92
  throw lastError ?? new Error("Kiro fetch failed");
63
93
  }
94
+
95
+ async function inspectEndpointHttpFailure(
96
+ response: Response,
97
+ signal?: AbortSignal,
98
+ ): Promise<{ response: Response; fallback: boolean }> {
99
+ if (response.status === 404 || response.status === 405) return { response, fallback: true };
100
+ if (response.status !== 400 && response.status !== 403) return { response, fallback: false };
101
+
102
+ const body = await readBoundedResponseBody(response, { signal });
103
+ const headers = new Headers(response.headers);
104
+ headers.delete("content-encoding");
105
+ headers.delete("content-length");
106
+ const rebuilt = new Response(body.displaySafe ? body.text : "", {
107
+ status: response.status,
108
+ statusText: response.statusText,
109
+ headers,
110
+ });
111
+ const text = body.displaySafe ? body.text.toLowerCase() : "";
112
+ return { response: rebuilt, fallback: ENDPOINT_ERROR_MARKERS.some(marker => text.includes(marker)) };
113
+ }
114
+
115
+ async function normalizeFinalKiroHttpError(res: Response, signal?: AbortSignal): Promise<Response> {
116
+ return normalizeUpstreamHttpErrorResponse(res, {
117
+ signal,
118
+ formatMessage: payloadText => safeKiroHttpErrorMessage(res.status, res.headers, payloadText),
119
+ });
120
+ }
121
+
122
+ /**
123
+ * Kiro owns only replay-safe pre-header reset recovery and one canonical-to-legacy endpoint fallback.
124
+ * Client-level retry policy owns throttling, timeouts, and ordinary service failures.
125
+ */
126
+ export async function fetchKiroWithRetry(request: AdapterRequest, ctx: AdapterFetchContext = {}): Promise<Response> {
127
+ const timeoutMs = ctx.timeoutMs ?? 200_000;
128
+ const legacy = legacyUrl(request.url);
129
+ let response: Response;
130
+ try {
131
+ response = await fetchWithResetRecovery(request, request.url, ctx, timeoutMs);
132
+ } catch (error) {
133
+ if (!legacy || !endpointConnectFailure(error)) throw error;
134
+ response = await fetchWithResetRecovery(request, legacy, ctx, timeoutMs);
135
+ return ctx.returnRawErrors ? response : normalizeFinalKiroHttpError(response, ctx.abortSignal);
136
+ }
137
+
138
+ if (legacy && !response.ok) {
139
+ const inspected = await inspectEndpointHttpFailure(response, ctx.abortSignal);
140
+ response = inspected.response;
141
+ if (inspected.fallback) {
142
+ cancelResponseBodyBestEffort(response);
143
+ response = await fetchWithResetRecovery(request, legacy, ctx, timeoutMs);
144
+ }
145
+ }
146
+ return ctx.returnRawErrors ? response : normalizeFinalKiroHttpError(response, ctx.abortSignal);
147
+ }