@bitkyc08/opencodex 2.33.0 → 2.34.0

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 (159) hide show
  1. package/README.md +3 -3
  2. package/gui/dist/assets/index-C4TMRloX.js +112 -0
  3. package/gui/dist/assets/index-DQ-Ie18T.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/anthropic.ts +79 -2
  7. package/src/adapters/command-code.ts +40 -3
  8. package/src/adapters/cursor/call-id.ts +44 -0
  9. package/src/adapters/cursor/checkpoint-store.ts +15 -10
  10. package/src/adapters/cursor/discovery.ts +60 -2
  11. package/src/adapters/cursor/effort-map.ts +79 -1
  12. package/src/adapters/cursor/envelope-echo.ts +162 -0
  13. package/src/adapters/cursor/live-models.ts +7 -2
  14. package/src/adapters/cursor/live-transport.ts +14 -0
  15. package/src/adapters/cursor/message-mapper.ts +4 -1
  16. package/src/adapters/cursor/native-exec-desktop.ts +23 -0
  17. package/src/adapters/cursor/native-exec-fs.ts +2 -2
  18. package/src/adapters/cursor/native-exec-network.ts +1 -1
  19. package/src/adapters/cursor/native-exec-shell.ts +5 -2
  20. package/src/adapters/cursor/native-exec.ts +20 -0
  21. package/src/adapters/cursor/protobuf-request.ts +68 -14
  22. package/src/adapters/cursor/request-builder.ts +29 -6
  23. package/src/adapters/cursor/tool-definitions.ts +11 -2
  24. package/src/adapters/cursor/tool-result-normalize.ts +32 -1
  25. package/src/adapters/cursor/types.ts +20 -0
  26. package/src/adapters/cursor.ts +189 -37
  27. package/src/adapters/google-antigravity-replay.ts +102 -23
  28. package/src/adapters/google-errors.ts +44 -12
  29. package/src/adapters/google.ts +115 -16
  30. package/src/adapters/image.ts +1 -1
  31. package/src/adapters/kiro-constants.ts +15 -0
  32. package/src/adapters/kiro-tools.ts +43 -15
  33. package/src/adapters/kiro.ts +54 -9
  34. package/src/adapters/openai-chat.ts +286 -242
  35. package/src/adapters/openai-responses.ts +334 -24
  36. package/src/adapters/tool-catalog-nudge.ts +2 -2
  37. package/src/adapters/xai-tool-schema.ts +436 -0
  38. package/src/bridge.ts +67 -24
  39. package/src/chat/inbound.ts +14 -1
  40. package/src/chat/outbound.ts +15 -7
  41. package/src/claude/agents-inject.ts +8 -1
  42. package/src/claude/outbound.ts +10 -8
  43. package/src/cli/account-api.ts +32 -4
  44. package/src/cli/account-extended.ts +1 -1
  45. package/src/cli/account.ts +29 -5
  46. package/src/cli/alias.ts +66 -0
  47. package/src/cli/claude.ts +26 -1
  48. package/src/cli/dispatch.ts +13 -1
  49. package/src/cli/help.ts +1 -0
  50. package/src/cli/init.ts +1 -0
  51. package/src/cli/models-runtime.ts +95 -0
  52. package/src/cli/models.ts +13 -7
  53. package/src/cli/provider-runtime.ts +16 -2
  54. package/src/cli/registry.ts +6 -1
  55. package/src/cli/v2.ts +34 -10
  56. package/src/codex/account-pause.ts +2 -1
  57. package/src/codex/account-priority.ts +3 -2
  58. package/src/codex/app-server-processes.ts +80 -6
  59. package/src/codex/auth-api.ts +46 -8
  60. package/src/codex/auth-context.ts +21 -18
  61. package/src/codex/catalog/aggregation.ts +6 -0
  62. package/src/codex/catalog/native-models.ts +5 -2
  63. package/src/codex/catalog/parsing.ts +16 -0
  64. package/src/codex/catalog/provider-fetch.ts +20 -3
  65. package/src/codex/catalog/sync.ts +127 -2
  66. package/src/codex/catalog.ts +1 -1
  67. package/src/codex/codex-write-lock.ts +3 -1
  68. package/src/codex/convergence-types.ts +1 -1
  69. package/src/codex/convergence.ts +22 -2
  70. package/src/codex/desired-state.ts +2 -2
  71. package/src/codex/desktop-app-restart.ts +18 -5
  72. package/src/codex/inject-coordination.ts +83 -0
  73. package/src/codex/inject.ts +14 -1
  74. package/src/codex/log-guard/inspect.ts +22 -4
  75. package/src/codex/model-entitlements.ts +9 -2
  76. package/src/codex/prompt-layers.ts +371 -25
  77. package/src/codex/prompt-text-probe.ts +238 -0
  78. package/src/codex/quota.ts +91 -10
  79. package/src/codex/routing.ts +9 -0
  80. package/src/codex/subagent-model-fallback.ts +104 -25
  81. package/src/codex/transition-state.ts +107 -8
  82. package/src/combos/types.ts +10 -0
  83. package/src/compatibility/openai-responses.ts +33 -1
  84. package/src/config/rebase-provenance.ts +68 -0
  85. package/src/config.ts +107 -9
  86. package/src/generated/compatibility-version.json +191 -139
  87. package/src/images/loop.ts +5 -4
  88. package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
  89. package/src/lab/fabric/producer-child.ts +1 -1
  90. package/src/lib/config-ownership.ts +20 -0
  91. package/src/lib/errors.ts +11 -2
  92. package/src/lib/package-tree-integrity.ts +101 -0
  93. package/src/oauth/generic-account-failover.ts +231 -0
  94. package/src/oauth/index.ts +57 -5
  95. package/src/oauth/key-providers.ts +10 -1
  96. package/src/oauth/kiro.ts +45 -0
  97. package/src/oauth/types.ts +15 -0
  98. package/src/providers/command-code-efforts.ts +38 -6
  99. package/src/providers/context-cap.ts +4 -3
  100. package/src/providers/default-aliases.ts +65 -0
  101. package/src/providers/derive.ts +28 -0
  102. package/src/providers/fastwire.ts +7 -1
  103. package/src/providers/model-presets.ts +119 -0
  104. package/src/providers/new-model-policy.ts +146 -0
  105. package/src/providers/provider-id-rewrite.ts +2 -1
  106. package/src/providers/quota.ts +65 -7
  107. package/src/providers/registry.ts +160 -68
  108. package/src/providers/slug-codec.ts +52 -0
  109. package/src/responses/code-mode-helper-compat.ts +50 -0
  110. package/src/responses/custom-tool-compat.ts +34 -10
  111. package/src/responses/parser.ts +4 -0
  112. package/src/responses/schema.ts +5 -1
  113. package/src/responses/thought-signature-replay.ts +17 -0
  114. package/src/router.ts +40 -2
  115. package/src/routing/analytics.ts +1 -0
  116. package/src/routing/quota.ts +10 -0
  117. package/src/server/auth-cors.ts +4 -0
  118. package/src/server/chat-completions.ts +24 -16
  119. package/src/server/chat-native-sse.ts +3 -3
  120. package/src/server/chat-native.ts +30 -11
  121. package/src/server/claude-messages.ts +1 -1
  122. package/src/server/index.ts +77 -11
  123. package/src/server/lifecycle.ts +52 -1
  124. package/src/server/management/agent-settings-routes.ts +31 -15
  125. package/src/server/management/codex-prompt-routes.ts +570 -0
  126. package/src/server/management/combo-routes.ts +2 -1
  127. package/src/server/management/config-routes.ts +27 -9
  128. package/src/server/management/context.ts +9 -0
  129. package/src/server/management/model-routes.ts +266 -0
  130. package/src/server/management/provider-routes.ts +6 -0
  131. package/src/server/management/routing-profile-routes.ts +2 -2
  132. package/src/server/management-api.ts +2 -0
  133. package/src/server/relay-eager.ts +147 -20
  134. package/src/server/relay.ts +251 -19
  135. package/src/server/request-log-conversation.ts +21 -0
  136. package/src/server/request-log.ts +41 -15
  137. package/src/server/responses/collaboration.ts +37 -3
  138. package/src/server/responses/combo-stream-preflight.ts +10 -3
  139. package/src/server/responses/core.ts +494 -114
  140. package/src/server/responses/empty-completion-guard.ts +35 -0
  141. package/src/server/responses/fetch-helpers.ts +2 -1
  142. package/src/server/responses/input-admission.ts +3 -1
  143. package/src/server/responses/passthrough-error.ts +33 -9
  144. package/src/server/responses/responses-field-backfill.ts +105 -13
  145. package/src/server/responses-custom-tool-repair.ts +52 -7
  146. package/src/server/responses-terminal-repair.ts +25 -4
  147. package/src/server/sse-frame-buffer.ts +31 -4
  148. package/src/server/ws-bridge.ts +14 -2
  149. package/src/types/config.ts +53 -0
  150. package/src/types/provider.ts +64 -0
  151. package/src/types/request.ts +11 -3
  152. package/src/types/tools.ts +7 -5
  153. package/src/types.ts +1 -0
  154. package/src/usage/expected-prices.ts +43 -24
  155. package/src/usage/log.ts +3 -0
  156. package/src/web-search/exa-executor.ts +40 -9
  157. package/src/web-search/loop.ts +5 -4
  158. package/gui/dist/assets/index-23-Lf7jR.js +0 -102
  159. package/gui/dist/assets/index-DxJMDyOr.css +0 -1
@@ -26,7 +26,7 @@ import { identifyRoutedModel } from "./identity";
26
26
  import { antigravityUsesReplayCache, applyAntigravityReplay, clearAntigravityReplay, observeAntigravityReplay } from "./google-antigravity-replay";
27
27
  import { resolveAntigravityEffortWireModel } from "../providers/antigravity-models";
28
28
  import { googleVertexLocationConfigError } from "../providers/google-vertex-location";
29
- import { lookupReplayThoughtSignature } from "../responses/thought-signature-replay";
29
+ import { forgetThoughtSignatureForReplay, lookupReplayThoughtSignature } from "../responses/thought-signature-replay";
30
30
  import {
31
31
  isTranslatorBudgetExceededError,
32
32
  retainTranslatedEventBatch,
@@ -48,6 +48,43 @@ const GOOGLE_BREVITY_INSTRUCTION = [
48
48
  "- This applies only to intermediate progress text. Your final answer after the work is done is exempt: write it in full and at whatever length the task requires.",
49
49
  ].join("\n");
50
50
 
51
+ /**
52
+ * Documented output ceiling for a Google-surface model, or `undefined` when the id is not
53
+ * recognized.
54
+ *
55
+ * Unknown ids return `undefined` deliberately. An earlier revision returned a 16,384 floor for
56
+ * anything unmatched, which silently truncated aliases, gateway ids, and any model added after
57
+ * this table was written — the operator asked for N tokens and got 16,384 with no signal. A cap
58
+ * we cannot justify is worse than no cap: `structure/02_config-and-codex-home.md` is explicit
59
+ * that an explicit request value wins, so an unrecognized model passes through untouched and the
60
+ * upstream remains the authority on its own limit.
61
+ *
62
+ * Matching is prefix/family based rather than substring based for the same reason: `includes("pro")`
63
+ * matched any id containing "pro" (`my-prototype-model`), and `includes("oss")` matched any id
64
+ * containing "oss" (`crossover-v2`).
65
+ */
66
+ export function maxOutputTokensForGoogleModel(modelId: string): number | undefined {
67
+ const lower = modelId.toLowerCase().trim();
68
+ if (lower.startsWith("gemini")) {
69
+ // Pro tops out one token below the flash/other Gemini ceiling; both are documented values.
70
+ return /(^|[-.])pro([-.]|$)/.test(lower) ? 65535 : 65536;
71
+ }
72
+ if (lower.startsWith("claude")) return 64000;
73
+ if (lower.startsWith("gpt-oss")) return 32768;
74
+ return undefined;
75
+ }
76
+
77
+ export function clampGoogleMaxOutputTokens(
78
+ modelId: string,
79
+ requestedTokens?: number,
80
+ ): number | undefined {
81
+ if (requestedTokens === undefined || requestedTokens <= 0) return undefined;
82
+ const modelMax = maxOutputTokensForGoogleModel(modelId);
83
+ // Unknown model: honour the request as-is rather than inventing a ceiling for it.
84
+ if (modelMax === undefined) return requestedTokens;
85
+ return Math.min(requestedTokens, modelMax);
86
+ }
87
+
51
88
  /**
52
89
  * Some Google direct deployments expose current Gemini Flash generations with a `-tiered`
53
90
  * wire suffix (`gemini-3.7-flash` -> `gemini-3.7-flash-tiered`). Keep the picker-visible id
@@ -141,7 +178,7 @@ function geminiTextPart(text: unknown): { text: string } | undefined {
141
178
  */
142
179
  function geminiToolResultText(content: string | OcxContentPart[]): string {
143
180
  if (typeof content === "string") return content || GEMINI_EMPTY_TOOL_OUTPUT_PLACEHOLDER;
144
- const hasContent = content.some(p => p.type === "image" || (typeof p.text === "string" && p.text.length > 0));
181
+ const hasContent = content.some(p => p.type !== "text" || p.text.length > 0);
145
182
  return hasContent ? contentPartsToText(content) : GEMINI_EMPTY_TOOL_OUTPUT_PLACEHOLDER;
146
183
  }
147
184
 
@@ -184,7 +221,7 @@ function geminiOrphanToolResultParts(msg: OcxToolResultMessage): unknown[] {
184
221
  function messagesToGeminiFormat(
185
222
  parsed: OcxParsedRequest,
186
223
  identityModelId: string,
187
- ): { systemInstruction?: unknown; contents: unknown[] } {
224
+ ): { systemInstruction?: unknown; contents: unknown[]; replayedCallIds: string[] } {
188
225
  // Neutralize Codex's GPT-5 identity line (Gemini/Antigravity share this path) so a routed model
189
226
  // never misreports as GPT-5/OpenAI, and never leaks the proxy identity upstream.
190
227
  const toolCatalogNudge = buildNonOpenAIToolCatalogNudgeForTools(parsed.context.tools, parsed.options.toolChoice);
@@ -196,6 +233,7 @@ function messagesToGeminiFormat(
196
233
  const systemInstruction = { parts: [{ text: systemText }] };
197
234
 
198
235
  const contents: unknown[] = [];
236
+ const replayedCallIds: string[] = [];
199
237
 
200
238
  const callIds = createToolCallIdAllocator();
201
239
  for (const msg of parsed.context.messages) {
@@ -224,6 +262,13 @@ function messagesToGeminiFormat(
224
262
  parts.push(data ? { inline_data: { mime_type: data.mediaType, data: data.base64 } } : { text: `[image: ${p.imageUrl}]` });
225
263
  continue;
226
264
  }
265
+ if (p.type === "video") {
266
+ const data = parseDataUrl(p.videoUrl);
267
+ // Gemini accepts inline video bytes in the same Part union as images. Arbitrary
268
+ // remote URLs are not valid fileData references, so retain only a short marker.
269
+ parts.push(data ? { inline_data: { mime_type: data.mediaType, data: data.base64 } } : { text: `[video: ${p.videoUrl}]` });
270
+ continue;
271
+ }
227
272
  // Drop empty/malformed text instead of emitting `{ text: "" }` or a bare `{}` part.
228
273
  const textPart = geminiTextPart(p.text);
229
274
  if (textPart) parts.push(textPart);
@@ -271,7 +316,10 @@ function messagesToGeminiFormat(
271
316
  const signature = tc.providerMetadata?.google?.thoughtSignature
272
317
  ?? tc.thoughtSignature
273
318
  ?? lookupReplayThoughtSignature(tc.id, parsed._reasoningReplayScope);
274
- if (isLikelyRealThoughtSignature(signature)) part.thoughtSignature = signature;
319
+ if (isLikelyRealThoughtSignature(signature)) {
320
+ part.thoughtSignature = signature;
321
+ replayedCallIds.push(tc.id);
322
+ }
275
323
  parts.push(part);
276
324
  }
277
325
  }
@@ -323,7 +371,7 @@ function messagesToGeminiFormat(
323
371
  }
324
372
  }
325
373
 
326
- return { systemInstruction, contents };
374
+ return { systemInstruction, contents, replayedCallIds };
327
375
  }
328
376
 
329
377
  function toolsToGeminiFormat(parsed: OcxParsedRequest): unknown[] | undefined {
@@ -610,6 +658,47 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
610
658
  let vertexReplayModel: string | undefined;
611
659
  let vertexReplaySession: string | undefined;
612
660
  let restoreGoogleToolName = (name: string): string => name;
661
+ let lastInjectedCallIds: string[] = [];
662
+ let lastReasoningReplayScope: OcxParsedRequest["_reasoningReplayScope"];
663
+
664
+ // Conservative batch invalidation: upstream Gemini/Antigravity errors (e.g.
665
+ // "Function call is missing a thought_signature in functionCall parts") do not specify which
666
+ // specific call_id was rejected. When a request containing replayed signatures is rejected,
667
+ // we evict all callIds injected in that turn (lastInjectedCallIds) from the durable store
668
+ // and clear the session replay cache, preventing poisoned-signature loops while allowing
669
+ // subsequent turns to re-accumulate valid signatures. Unrelated calls from other turns remain intact.
670
+ //
671
+ // Memory-cache clearing stays broad (any invalid-argument/signature error can poison the
672
+ // session replay cache), but durable-store eviction is intentionally narrower: it only runs
673
+ // when the error text explicitly mentions a signature, so a generic tool-schema
674
+ // INVALID_ARGUMENT does not destroy valid durable signatures.
675
+ function handleSignatureRejection(errorMessage?: string) {
676
+ const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel;
677
+ const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession : vertexReplaySession;
678
+ const text = errorMessage ?? "";
679
+ const isInvalidArgument = /invalid_argument|invalid argument/i.test(text);
680
+ const isSignatureError = /signature|thought_signature|thoughtSignature/i.test(text);
681
+ // The in-memory Antigravity replay cache only exists for CCA/Vertex, so clearing it stays
682
+ // scoped to those modes (replayModel/replaySession are undefined elsewhere anyway).
683
+ if (
684
+ (provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex")
685
+ && replayModel && replaySession && (isInvalidArgument || isSignatureError)
686
+ ) {
687
+ clearAntigravityReplay(replayModel, replaySession);
688
+ }
689
+ // The DURABLE store is not mode-scoped: signatures are remembered through
690
+ // rememberAndSerializeExtraContent and read back by lookupReplayThoughtSignature on every
691
+ // Google mode, including AI Studio. Gating eviction on CCA/Vertex therefore left AI Studio
692
+ // with rejected signatures cached forever, replaying them into every subsequent turn — the
693
+ // store poisons itself and the request keeps failing. Eviction follows the same scope the
694
+ // write does.
695
+ if (isSignatureError) {
696
+ for (const callId of lastInjectedCallIds) {
697
+ forgetThoughtSignatureForReplay(callId, lastReasoningReplayScope);
698
+ }
699
+ }
700
+ }
701
+
613
702
  return {
614
703
  name: "google",
615
704
 
@@ -638,7 +727,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
638
727
  : resolveDirectGeminiWireModelId(parsed.modelId, provider.directGeminiWireRenames !== false);
639
728
  // AI Studio's `-tiered` spelling is wire-only; CCA aliases may migrate to another generation.
640
729
  const identityModelId = provider.googleMode === "cloud-code-assist" ? routedModelId : parsed.modelId;
641
- const { systemInstruction, contents } = messagesToGeminiFormat(parsed, identityModelId);
730
+ const { systemInstruction, contents, replayedCallIds } = messagesToGeminiFormat(parsed, identityModelId);
731
+ lastInjectedCallIds = [...replayedCallIds];
732
+ lastReasoningReplayScope = parsed._reasoningReplayScope;
642
733
  const tools = toolsToGeminiFormat(parsed);
643
734
 
644
735
  const body: Record<string, unknown> = { contents };
@@ -650,7 +741,8 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
650
741
  if (toolConfig) body.toolConfig = toolConfig;
651
742
 
652
743
  const generationConfig: Record<string, unknown> = {};
653
- if (parsed.options.maxOutputTokens) generationConfig.maxOutputTokens = parsed.options.maxOutputTokens;
744
+ const clampedMaxOutputTokens = clampGoogleMaxOutputTokens(identityModelId, parsed.options.maxOutputTokens);
745
+ if (clampedMaxOutputTokens !== undefined) generationConfig.maxOutputTokens = clampedMaxOutputTokens;
654
746
  if (parsed.options.temperature !== undefined) generationConfig.temperature = parsed.options.temperature;
655
747
  if (parsed.options.topP !== undefined) generationConfig.topP = parsed.options.topP;
656
748
  if (parsed.options.stopSequences) generationConfig.stopSequences = parsed.options.stopSequences;
@@ -864,14 +956,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
864
956
  if (chunk.error) {
865
957
  const err = chunk.error as { message?: string } | undefined;
866
958
  // Clear-on-invalid: a signature rejection means our replayed thoughtSignatures are stale.
867
- // Drop the cache entry so the next turn starts clean instead of re-injecting a bad sig.
868
- const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel;
869
- const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession : vertexReplaySession;
870
- if ((provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex")
871
- && replayModel && replaySession
872
- && /signature|invalid_argument|invalid argument/i.test(err?.message ?? "")) {
873
- clearAntigravityReplay(replayModel, replaySession);
874
- }
959
+ // Drop the cache entry and durable store entry for rejected calls so the next turn
960
+ // starts clean instead of re-injecting a bad sig.
961
+ handleSignatureRejection(err?.message);
875
962
  yield { type: "error", message: err?.message ?? "upstream error" };
876
963
  return "terminate";
877
964
  }
@@ -1158,7 +1245,18 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
1158
1245
  let raw: Record<string, unknown>;
1159
1246
  let rawBytes = 0;
1160
1247
  try {
1161
- raw = JSON.parse(rawText) as Record<string, unknown>;
1248
+ const parsedRaw: unknown = JSON.parse(rawText);
1249
+ // `JSON.parse("null")` returns null instead of throwing, so the catch below cannot see it
1250
+ // and the `raw.error` read crashed the turn — #1219 at the buffered body root, which #1240
1251
+ // never reached because that audit swept SSE frame parsers only. There is no next frame to
1252
+ // recover into here, so unlike a stream frame this fails closed, matching the
1253
+ // unparseable-body branch just below and the buffered candidate guards added in #2232.
1254
+ if (!isGoogleRecord(parsedRaw)) {
1255
+ budget.releaseRetained(rawTextBytes, { kind: "retained_collectors" });
1256
+ const valueType = googleStructuralValueType(parsedRaw);
1257
+ return [{ type: "error", message: `google response was not a JSON object (${valueType})` }];
1258
+ }
1259
+ raw = parsedRaw;
1162
1260
  rawBytes = new TextEncoder().encode(JSON.stringify(raw)).byteLength;
1163
1261
  const rawReservation = budget.reserveTransient(rawBytes, { kind: "retained_collectors" });
1164
1262
  rawReservation.commitRetained();
@@ -1175,6 +1273,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
1175
1273
  };
1176
1274
  if (raw.error) {
1177
1275
  const err = raw.error as { message?: string };
1276
+ handleSignatureRejection(err.message);
1178
1277
  return finish([{ type: "error", message: err.message ?? "upstream error" }]);
1179
1278
  }
1180
1279
  // Antigravity (CCA) nests the standard Gemini payload under `response`; unwrap it.
@@ -18,6 +18,6 @@ export function parseDataUrl(url: string): { mediaType: string; base64: string }
18
18
  */
19
19
  export function contentPartsToText(content: string | OcxContentPart[]): string {
20
20
  if (typeof content === "string") return content;
21
- const text = content.map(p => (p.type === "text" ? p.text : "[image]")).join("");
21
+ const text = content.map(p => p.type === "text" ? p.text : p.type === "image" ? "[image]" : "[video]").join("");
22
22
  return text || "[image]";
23
23
  }
@@ -1,4 +1,19 @@
1
1
  export const KIRO_COMPLETION_TOOL_NAME = "codex_kiro_final_answer";
2
+
3
+ /**
4
+ * Request-scoped CodeWhisperer service profile for AWS Builder ID accounts.
5
+ *
6
+ * Builder ID is a personal identity with no AWS account behind it, so AWS never mints an
7
+ * account-scoped `profile/<id>` ARN for it. The Kiro CLI resolves this the same way: it carries
8
+ * this fixed service profile on Builder ID requests. The embedded account id is Amazon's own, not
9
+ * the user's, which is why sending it is not the same as synthesizing an account identity.
10
+ *
11
+ * Request-scoped is load-bearing. This value must never be persisted into `KiroOAuthMetadata`,
12
+ * never seed region inference (it is `us-east-1` and would pin every Builder ID account there),
13
+ * and never participate in account matching.
14
+ */
15
+ export const KIRO_BUILDER_ID_SERVICE_PROFILE_ARN =
16
+ "arn:aws:codewhisperer:us-east-1:638616132270:profile/AAAACCCCXXXX";
2
17
  export const KIRO_CONTINUATION_MESSAGE =
3
18
  "Continue from the prior conversation. Do not quote or mention this instruction.";
4
19
  export const KIRO_COMPLETION_RETRY_MESSAGE =
@@ -1,6 +1,7 @@
1
1
  import type { OcxParsedRequest, OcxTool } from "../types";
2
2
  import { namespacedToolName } from "../types";
3
3
  import { normalizeKiroModelId } from "../providers/kiro-models";
4
+ import { isCodexCodeModeExecTool } from "./tool-catalog-nudge";
4
5
  import { createKiroToolNameRegistry, type KiroToolNameRegistry } from "./kiro-wire";
5
6
 
6
7
  const MAX_KIRO_TOOL_DESCRIPTION_UNVERIFIED = 1024;
@@ -103,7 +104,7 @@ function ensureRootObjectType(schema: unknown): Record<string, unknown> {
103
104
  // Seed with the root's own properties/required so a schema like
104
105
  // { type:"object", properties:{path}, required:["path"], oneOf:[...] } keeps them.
105
106
  if (obj.properties && typeof obj.properties === "object") {
106
- Object.assign(props, sanitizeKiroSchema(obj.properties) as Record<string, unknown>);
107
+ Object.assign(props, sanitizeSchemaMap(obj.properties) as Record<string, unknown>);
107
108
  }
108
109
  if (Array.isArray(obj.required)) {
109
110
  for (const r of obj.required) if (typeof r === "string") required.add(r);
@@ -118,7 +119,7 @@ function ensureRootObjectType(schema: unknown): Record<string, unknown> {
118
119
  if (!variant || typeof variant !== "object" || Array.isArray(variant)) continue;
119
120
  const v = variant as Record<string, unknown>;
120
121
  if (v.properties && typeof v.properties === "object") {
121
- Object.assign(props, sanitizeKiroSchema(v.properties) as Record<string, unknown>);
122
+ Object.assign(props, sanitizeSchemaMap(v.properties) as Record<string, unknown>);
122
123
  }
123
124
  if (mergeRequired && Array.isArray(v.required)) {
124
125
  for (const r of v.required) if (typeof r === "string") required.add(r);
@@ -174,8 +175,14 @@ function omittedToolCatalogNotice(kept: number, omitted: readonly OcxTool[], reg
174
175
 
175
176
  function boundedCatalogPriority(tool: OcxTool): number {
176
177
  if (tool.loadedFromToolSearch) return 0;
177
- if (tool.toolSearch) return 1;
178
- return 2;
178
+ // Codex code mode reaches shell, file edits, apply_patch and every MCP helper ONLY as nested
179
+ // `tools.<name>(...)` calls inside this one tool. Dropping it does not shrink the catalog, it
180
+ // makes the rest of the catalog uncallable -- so it outranks the search gateway and filler.
181
+ // It stays BEHIND `loadedFromToolSearch` because those are tools the model asked for by name
182
+ // this turn (#2475). Cursor pins its execution path the same way (request-builder.ts, #399).
183
+ if (isCodexCodeModeExecTool(tool)) return 1;
184
+ if (tool.toolSearch) return 2;
185
+ return 3;
179
186
  }
180
187
 
181
188
  export function convertKiroToolContext(
@@ -209,19 +216,40 @@ export function convertKiroToolContext(
209
216
  const candidates = exceedsBudget
210
217
  ? convertedEntries.toSorted((a, b) => boundedCatalogPriority(a.tool) - boundedCatalogPriority(b.tool) || a.index - b.index)
211
218
  : convertedEntries;
212
- const convertedTools: unknown[] = [];
213
- let omittedAt = candidates.length;
214
- for (const [index, entry] of candidates.entries()) {
219
+ // Reserve a seat for the code-mode execution path before filling the rest.
220
+ //
221
+ // Priority alone cannot save it. `loadedFromToolSearch` tools outrank it and arrive unbounded
222
+ // (the Responses parser pushes every `tool_search_output` spec), so a session that accumulated
223
+ // MAX_KIRO_TOOL_COUNT loaded tools would exhaust the budget before reaching tier 1 and drop the
224
+ // one tool through which all of them are actually callable.
225
+ //
226
+ // Reservation rather than eviction: this lowers the room the fill loop sees, so it admits one
227
+ // fewer tool. It never removes a tool that already fit, which is what keeps #2475's loaded-result
228
+ // guarantee intact -- Cursor's `evictNonExecutionPath` exempts only the execution path and could
229
+ // evict a loaded tool instead.
230
+ const reserved = candidates.find(entry => isCodexCodeModeExecTool(entry.tool));
231
+ const admitted = new Set<number>();
232
+ const filled: unknown[] = [];
233
+ for (const entry of candidates) {
234
+ if (entry === reserved) continue;
235
+ // Measure the projected FINAL array: the byte budget is computed over the serialized array, so
236
+ // subtracting a standalone size would misjudge it by the separators JSON adds between entries.
237
+ const projected = reserved ? [...filled, entry.converted, reserved.converted] : [...filled, entry.converted];
215
238
  if (
216
- convertedTools.length >= MAX_KIRO_TOOL_COUNT
217
- || serializedToolCatalogBytes([...convertedTools, entry.converted]) > MAX_KIRO_TOOL_CATALOG_BYTES
218
- ) {
219
- omittedAt = index;
220
- break;
221
- }
222
- convertedTools.push(entry.converted);
239
+ projected.length > MAX_KIRO_TOOL_COUNT
240
+ || serializedToolCatalogBytes(projected) > MAX_KIRO_TOOL_CATALOG_BYTES
241
+ ) break;
242
+ filled.push(entry.converted);
243
+ admitted.add(entry.index);
223
244
  }
224
- const omittedTools = candidates.slice(omittedAt).map(entry => entry.tool);
245
+ if (reserved) admitted.add(reserved.index);
246
+ // Rebuild in sorted-candidate order so the wire order stays loaded -> exec -> gateway -> filler.
247
+ // Pushing the reserved entry after the loop would place it last instead.
248
+ const convertedTools = candidates.filter(entry => admitted.has(entry.index)).map(entry => entry.converted);
249
+ // Derive omissions by set difference. The old `candidates.slice(omittedAt)` assumed every
250
+ // candidate after the first rejection was omitted, which stops being true once one of them was
251
+ // reserved and admitted: the notice would name `exec` unavailable while it is on the wire.
252
+ const omittedTools = candidates.filter(entry => !admitted.has(entry.index)).map(entry => entry.tool);
225
253
  return {
226
254
  tools: convertedTools,
227
255
  systemAdditions: omittedTools.length > 0 ? [omittedToolCatalogNotice(convertedTools.length, omittedTools, registry)] : [],
@@ -1,7 +1,7 @@
1
1
  import { decodeEventStream } from "../lib/eventstream-decoder";
2
2
  import { estimateTokens } from "../lib/token-estimate";
3
3
  import { debugProviderDiagnostic } from "../lib/debug";
4
- import { resolveKiroApiRegion, resolveKiroProfileArn } from "../oauth/kiro";
4
+ import { resolveKiroApiRegion, resolveKiroRequestProfile } from "../oauth/kiro";
5
5
  import { KIRO_MODEL_CONTEXT_WINDOWS, normalizeKiroModelId } from "../providers/kiro-models";
6
6
  import { modelRecordValue } from "../reasoning-effort";
7
7
  import { parseKiroEvent } from "./kiro-events";
@@ -33,6 +33,7 @@ import type {
33
33
  OcxTextContent,
34
34
  OcxToolCall,
35
35
  OcxToolResultMessage,
36
+ OcxTool,
36
37
  OcxUsage,
37
38
  } from "../types";
38
39
  import type { ProviderAdapter } from "./base";
@@ -42,7 +43,7 @@ import { sniffImageDimensions } from "./anthropic-image-guard";
42
43
  import { fetchKiroWithRetry, noteKiroTransientThrottle } from "./kiro-retry";
43
44
  import { convertKiroToolContext } from "./kiro-tools";
44
45
  import { identifyRoutedModel } from "./identity";
45
- import { buildNonOpenAIToolCatalogNudgeFromNames } from "./tool-catalog-nudge";
46
+ import { buildNonOpenAIToolCatalogNudgeFromNames, isBareShellBridgeTool, isCodexCodeModeExecTool } from "./tool-catalog-nudge";
46
47
  import {
47
48
  KIRO_COMPLETION_INSTRUCTIONS,
48
49
  KIRO_COMPLETION_RETRY_MESSAGE,
@@ -321,9 +322,23 @@ function validateKiroCapabilities(parsed: OcxParsedRequest): void {
321
322
  if (parsed.options.serviceTier !== undefined) {
322
323
  throw new Error("Kiro does not support service tiers");
323
324
  }
324
- const raw = parsed._rawBody as Record<string, unknown> | undefined;
325
- if (parsed._structuredOutput || raw?.text !== undefined) {
326
- throw new Error("Kiro does not support Responses text controls or structured output");
325
+ // Structured output is a real contract Kiro cannot honour: the wire has no
326
+ // schema-constrained response mode, so a caller expecting parseable JSON would receive
327
+ // prose and fail downstream. Refuse it.
328
+ //
329
+ // The rest of the Responses `text` object is not that. `text.verbosity` is a length
330
+ // preference and `text.format: {type:"text"}` is ordinary prose — the default output
331
+ // mode, which no capability flag governs and every correct client may send. Testing
332
+ // `_rawBody.text !== undefined` refused those turns for the mere PRESENCE of the key,
333
+ // the same mistake db040e70f removed one condition earlier where a permissive
334
+ // `parallel_tool_calls` hint was read as a requirement.
335
+ //
336
+ // Nothing needs stripping the way openai-responses strips a no-op verbosity:
337
+ // buildKiroPayload composes conversationState field by field from `parsed` and never
338
+ // spreads `_rawBody`, so a tolerated control is dropped by construction. The test
339
+ // asserts that absence so it stays true.
340
+ if (parsed._structuredOutput) {
341
+ throw new Error("Kiro does not support Responses structured output");
327
342
  }
328
343
  }
329
344
 
@@ -460,9 +475,32 @@ export function buildKiroPayload(
460
475
  // name for a tool that was never advertised and pollute the collision domain.
461
476
  const advertisedAlias = new Map<string, string>();
462
477
  for (const [alias, wireName] of registry.nameMap) advertisedAlias.set(wireName, alias);
478
+ // Code mode is decided on the EMITTED catalog, not the requested list.
479
+ //
480
+ // `freeform` only exists on the requested tool objects -- `kiroToolWireNames` has already
481
+ // reduced the emitted catalog to strings -- so the predicates must read the objects. But the
482
+ // SHAPE that matters is the one the model receives: the count/byte budget can drop a requested
483
+ // `exec_command` while `exec` survives, and scanning the requested list would then find a shell
484
+ // bridge the model cannot call and suppress code mode for a catalog that is code-mode-shaped.
485
+ // Intersecting the two keeps `tool_choice: "none"` and budget omission correct for free: both
486
+ // empty the emitted set, so nothing can be named.
487
+ const emittedToolNames = new Set(kiroToolWireNames(kiroTools));
488
+ const emittedAlias = (tool: OcxTool): string | undefined => {
489
+ const wireName = namespacedToolName(tool.namespace, tool.name);
490
+ // Read the recorded mapping; `registry.alias()` would REGISTER a name here.
491
+ const alias = advertisedAlias.get(wireName) ?? wireName;
492
+ return emittedToolNames.has(alias) ? alias : undefined;
493
+ };
494
+ const requestedTools = parsed.context.tools ?? [];
495
+ const emittedCodeModeExec = requestedTools.find(tool => isCodexCodeModeExecTool(tool) && emittedAlias(tool));
496
+ const emittedShellBridge = requestedTools.some(tool => isBareShellBridgeTool(tool) && emittedAlias(tool));
497
+ const codeModeExecName = emittedCodeModeExec && !emittedShellBridge
498
+ ? emittedAlias(emittedCodeModeExec)
499
+ : undefined;
463
500
  const toolCatalogNudge = buildNonOpenAIToolCatalogNudgeFromNames(
464
501
  kiroToolWireNames(kiroTools),
465
502
  name => advertisedAlias.get(name) ?? name,
503
+ codeModeExecName,
466
504
  );
467
505
  const boundedNudge = toolCatalogNudge ? boundedInjectedInstruction(toolCatalogNudge, injectedChars) : undefined;
468
506
  if (boundedNudge) systemParts.push(boundedNudge);
@@ -1723,12 +1761,19 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
1723
1761
  throw new Error("kiro token missing — run ocx login kiro");
1724
1762
  }
1725
1763
  const region = resolveKiroApiRegion(parsed._kiroAuthContext);
1726
- const resolvedProfileArn = resolveKiroProfileArn(parsed._kiroAuthContext);
1764
+ // Request-scoped: an AWS Builder ID account has no profile of its own and resolves to Kiro's
1765
+ // fixed service profile here, without that value ever becoming the account's stored identity.
1766
+ const requestProfile = resolveKiroRequestProfile(parsed._kiroAuthContext);
1767
+ const resolvedProfileArn = requestProfile.profileArn;
1727
1768
  const isApiKey = provider.apiKey.trim().startsWith("ksk_");
1728
1769
  const profileArn = isApiKey ? undefined : resolvedProfileArn;
1729
- // Builder ID and Kiro API keys have no profile ARN and are accepted only on Kiro's CLI
1730
- // request path. Enterprise profiles retain the existing IDE-shaped request.
1731
- const wireClient: KiroWireClient = isApiKey || !profileArn ? "cli" : "ide";
1770
+ // Builder ID and Kiro API keys are accepted only on Kiro's CLI request path; enterprise
1771
+ // profiles retain the IDE-shaped request. Builder ID now carries a profile ARN, so a truthy
1772
+ // `profileArn` no longer implies "enterprise". The wire path reads the resolver's own verdict
1773
+ // rather than re-deriving it, so the accountless path — where the auth type comes from the
1774
+ // local import, not the request context — cannot send the fallback inside an IDE-shaped call.
1775
+ const isBuilderId = requestProfile.builderIdFallback;
1776
+ const wireClient: KiroWireClient = isApiKey || isBuilderId || !profileArn ? "cli" : "ide";
1732
1777
  const fp = fingerprint().slice(0, 64);
1733
1778
  const headers: Record<string, string> = wireClient === "cli" ? {
1734
1779
  authorization: `Bearer ${provider.apiKey}`,