@bitkyc08/opencodex 2.43.0 → 2.44.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 (138) hide show
  1. package/README.md +25 -17
  2. package/gui/dist/assets/index-B7_K1Hsj.js +115 -0
  3. package/gui/dist/assets/index-ltx3L-WS.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/command-code.ts +23 -2
  7. package/src/adapters/cursor/native-exec.ts +63 -11
  8. package/src/adapters/cursor/tool-definitions.ts +1 -1
  9. package/src/adapters/cursor/tool-schemas.ts +59 -0
  10. package/src/adapters/kiro.ts +54 -8
  11. package/src/adapters/openai-chat.ts +23 -6
  12. package/src/adapters/openai-responses.ts +3 -1
  13. package/src/adapters/opencode-go.ts +35 -0
  14. package/src/claude/agents-inject.ts +12 -5
  15. package/src/claude/desktop-3p-library.ts +89 -0
  16. package/src/claude/desktop-3p.ts +166 -95
  17. package/src/claude/desktop-discovery-inputs.ts +44 -0
  18. package/src/claude/desktop-profile.ts +1 -1
  19. package/src/claude/desktop-remote-store-artifact.ts +185 -0
  20. package/src/claude/desktop-remote-store-io.ts +91 -0
  21. package/src/claude/desktop-remote-store-state.ts +127 -0
  22. package/src/claude/desktop-remote-store.ts +269 -0
  23. package/src/claude/inbound-model-options.ts +10 -2
  24. package/src/claude/inbound-records.ts +7 -0
  25. package/src/claude/inbound.ts +1 -1
  26. package/src/claude/model-info.ts +16 -3
  27. package/src/cli/account-api.ts +7 -1
  28. package/src/cli/aside-profiles.ts +17 -0
  29. package/src/cli/capabilities.ts +39 -0
  30. package/src/cli/claude-agent-startup-sync.ts +3 -1
  31. package/src/cli/claude-desktop.ts +127 -18
  32. package/src/cli/connect.ts +27 -14
  33. package/src/cli/dispatch.ts +30 -16
  34. package/src/cli/export-command.ts +3 -1
  35. package/src/cli/index.ts +28 -16
  36. package/src/cli/integrations.ts +46 -12
  37. package/src/cli/opencode.ts +11 -4
  38. package/src/cli/runtime-api.ts +7 -2
  39. package/src/cli/uninstall-client-state.ts +78 -0
  40. package/src/client/connect.ts +476 -223
  41. package/src/client/hub-client.ts +81 -0
  42. package/src/client/hub-relay.ts +38 -6
  43. package/src/client/lifecycle-lock.ts +129 -0
  44. package/src/client/state.ts +71 -42
  45. package/src/clients/aside-profiles.ts +224 -0
  46. package/src/codex/account-lifecycle.ts +5 -9
  47. package/src/codex/auth-api.ts +50 -4
  48. package/src/codex/catalog/metadata.ts +0 -2
  49. package/src/codex/catalog/provider-fetch.ts +7 -5
  50. package/src/codex/catalog/sync.ts +88 -24
  51. package/src/codex/convergence.ts +2 -0
  52. package/src/codex/inject.ts +45 -14
  53. package/src/codex/journal.ts +6 -6
  54. package/src/codex/quota-refresh-outcome.ts +27 -0
  55. package/src/codex/shim.ts +7 -1
  56. package/src/combos/resolve.ts +10 -7
  57. package/src/config.ts +18 -5
  58. package/src/generated/compatibility-version.json +209 -105
  59. package/src/images/loop.ts +5 -2
  60. package/src/integrations/aside-profile-context.ts +270 -0
  61. package/src/integrations/aside-profile-journal.ts +229 -0
  62. package/src/integrations/aside-profiles.ts +176 -0
  63. package/src/integrations/catalog-refresh.ts +40 -0
  64. package/src/integrations/config-io.ts +16 -1
  65. package/src/integrations/owned-refresh.ts +14 -2
  66. package/src/integrations/state.ts +3 -1
  67. package/src/integrations/writer.ts +3 -1
  68. package/src/lab/fabric/producer-isolate.ts +84 -46
  69. package/src/lib/account-selection-events.ts +32 -0
  70. package/src/lib/errors.ts +9 -1
  71. package/src/lib/provider-outbound.ts +2 -45
  72. package/src/lib/proxy-env.ts +67 -0
  73. package/src/oauth/account-quota-rank.ts +9 -1
  74. package/src/oauth/anthropic-routing.ts +91 -23
  75. package/src/oauth/generic-account-failover.ts +15 -53
  76. package/src/oauth/index.ts +67 -68
  77. package/src/oauth/pool-settings-capability.ts +2 -1
  78. package/src/oauth/store.ts +91 -7
  79. package/src/oauth/types.ts +8 -0
  80. package/src/providers/api-key-selection.ts +110 -0
  81. package/src/providers/api-keys.ts +38 -31
  82. package/src/providers/context-cap.ts +27 -3
  83. package/src/providers/key-failover.ts +27 -17
  84. package/src/providers/provider-id-rewrite.ts +10 -8
  85. package/src/providers/registry.ts +1 -0
  86. package/src/responses/custom-tool-compat.ts +10 -0
  87. package/src/responses/function-call-compat.ts +173 -0
  88. package/src/responses/namespace-tool-compat.ts +27 -4
  89. package/src/responses/parser.ts +8 -0
  90. package/src/responses/task-input.ts +36 -0
  91. package/src/responses/tool-name-aliases.ts +79 -0
  92. package/src/router.ts +2 -0
  93. package/src/routing/capability.ts +10 -3
  94. package/src/routing/compatibility/assemble.ts +16 -1
  95. package/src/routing/evaluator.ts +5 -1
  96. package/src/server/auth-cors.ts +3 -0
  97. package/src/server/chat-native.ts +44 -9
  98. package/src/server/claude-messages.ts +71 -36
  99. package/src/server/grok-responses-snapshot-repair.ts +333 -0
  100. package/src/server/index.ts +26 -10
  101. package/src/server/management/account-selection-stream.ts +70 -0
  102. package/src/server/management/aside-profile-routes.ts +231 -0
  103. package/src/server/management/config-routes.ts +36 -41
  104. package/src/server/management/context.ts +3 -0
  105. package/src/server/management/integration-routes.ts +61 -3
  106. package/src/server/management/logs-usage-routes.ts +1 -0
  107. package/src/server/management/model-routes.ts +20 -9
  108. package/src/server/management/model-rows.ts +10 -2
  109. package/src/server/management/oauth-account-routes.ts +7 -0
  110. package/src/server/management/provider-routes.ts +10 -8
  111. package/src/server/management/route-registry.ts +20 -2
  112. package/src/server/management-auth.ts +16 -0
  113. package/src/server/relay-eager.ts +17 -5
  114. package/src/server/relay.ts +43 -4
  115. package/src/server/request-log.ts +29 -1
  116. package/src/server/responses/agent-task-recovery-cache.ts +11 -0
  117. package/src/server/responses/agent-task-recovery.ts +24 -4
  118. package/src/server/responses/codex-ws-pool.ts +4 -4
  119. package/src/server/responses/codex-ws-session.ts +2 -2
  120. package/src/server/responses/collaboration.ts +2 -1
  121. package/src/server/responses/combo-stream-preflight.ts +26 -7
  122. package/src/server/responses/core.ts +668 -151
  123. package/src/server/responses/fetch-helpers.ts +6 -1
  124. package/src/server/responses/ws-upstream.ts +8 -3
  125. package/src/server/responses-custom-tool-repair.ts +29 -9
  126. package/src/server/responses-function-tool-repair.ts +183 -0
  127. package/src/server/responses-snapshot-codec.ts +14 -0
  128. package/src/server/responses-snapshot-repair.ts +1 -13
  129. package/src/server/responses-undeclared-tool-guard.ts +1 -68
  130. package/src/server/sse-payload-rewrite.ts +3 -1
  131. package/src/service.ts +6 -2
  132. package/src/types/config.ts +23 -16
  133. package/src/types/provider.ts +14 -3
  134. package/src/types/request.ts +2 -0
  135. package/src/usage/log.ts +9 -0
  136. package/src/web-search/loop.ts +5 -2
  137. package/gui/dist/assets/index-DS1NE4Jn.css +0 -1
  138. package/gui/dist/assets/index-Djowl68T.js +0 -112
@@ -0,0 +1,173 @@
1
+ import { coerceIntegerToolArguments } from "../lib/tool-argument-integers";
2
+ import { namespacedToolName } from "../types/tools";
3
+ import { rewriteRoutedNamespaceToolsForUpstream } from "./namespace-tool-compat";
4
+ import { collectResponsesToolGroups } from "./tool-groups";
5
+
6
+ export interface FunctionCallRepairSchema {
7
+ name: string;
8
+ namespace?: string;
9
+ parameters?: Record<string, unknown>;
10
+ }
11
+
12
+ /** Keys are canonical original identities, never a bare-name fallback for a namespace. */
13
+ export type FunctionCallRepairSchemas = ReadonlyMap<string, FunctionCallRepairSchema>;
14
+
15
+ function isObject(value: unknown): value is Record<string, unknown> {
16
+ return value !== null && typeof value === "object" && !Array.isArray(value);
17
+ }
18
+
19
+ /** JSON object member order is immaterial; array elements retain their exact order. */
20
+ function sameSchemaValue(left: unknown, right: unknown): boolean {
21
+ if (left === right) return true;
22
+ if (Array.isArray(left) || Array.isArray(right)) {
23
+ return Array.isArray(left) && Array.isArray(right) && left.length === right.length
24
+ && left.every((value, index) => sameSchemaValue(value, right[index]));
25
+ }
26
+ if (!isObject(left) || !isObject(right)) return false;
27
+ const keys = Object.keys(left);
28
+ return keys.length === Object.keys(right).length
29
+ && keys.every(key => Object.hasOwn(right, key) && sameSchemaValue(left[key], right[key]));
30
+ }
31
+
32
+ function namespaceOf(value: unknown): string | undefined {
33
+ return typeof value === "string" && value !== "functions" ? value : undefined;
34
+ }
35
+
36
+ function selectorAllows(
37
+ selector: unknown,
38
+ lowered: unknown,
39
+ wireName: string,
40
+ identity: FunctionCallRepairSchema,
41
+ ): boolean {
42
+ if (!isObject(selector) || selector.type !== "function" || typeof selector.name !== "string") return false;
43
+ if ("namespace" in selector) {
44
+ if (typeof selector.namespace !== "string" || selector.namespace.length === 0) return false;
45
+ return namespaceOf(selector.namespace) === identity.namespace && selector.name === identity.name;
46
+ }
47
+ return isObject(lowered) && lowered.type === "function" && lowered.name === wireName;
48
+ }
49
+
50
+ /** Caller supplies currentTurnWireToolCatalogBody BEFORE provider schema lowering. */
51
+ export function collectFunctionCallRepairSchemas(body: unknown): Map<string, FunctionCallRepairSchema> {
52
+ const schemas = new Map<string, FunctionCallRepairSchema>();
53
+ if (!isObject(body)) return schemas;
54
+ const groups = collectResponsesToolGroups(body);
55
+ if (Array.isArray(body.input)) {
56
+ for (const entry of body.input) {
57
+ if (isObject(entry) && entry.type === "tool_search_output" && Array.isArray(entry.tools)) groups.push(entry.tools);
58
+ }
59
+ }
60
+ // Reuse namespace selector resolution, retaining schemas from the original objects below.
61
+ // This local catalog view includes loaded definitions without revisiting replay history or
62
+ // teaching the shared tool-group collector a new transport-wide interpretation.
63
+ const lowered = rewriteRoutedNamespaceToolsForUpstream({ ...body, tools: groups.flat(), input: [] }).body;
64
+ const choice = body.tool_choice;
65
+ const loweredChoice = isObject(lowered) ? lowered.tool_choice : undefined;
66
+ const occupied = new Map<string, { kind: unknown; identity: FunctionCallRepairSchema } | null>();
67
+ const register = (tool: unknown, namespace?: string): void => {
68
+ if (!isObject(tool) || typeof tool.name !== "string" || tool.name.length === 0) return;
69
+ const identity: FunctionCallRepairSchema = {
70
+ name: tool.name,
71
+ ...(namespace ? { namespace } : {}),
72
+ ...(isObject(tool.parameters) ? { parameters: tool.parameters } : {}),
73
+ };
74
+ const key = namespacedToolName(namespace, tool.name);
75
+ if (occupied.has(key)) {
76
+ const previous = occupied.get(key);
77
+ // Conflicting duplicate declarations cannot choose a schema by insertion order.
78
+ if (!previous || previous.kind !== tool.type
79
+ || previous.identity.namespace !== namespace
80
+ || previous.identity.name !== tool.name
81
+ || !sameSchemaValue(previous.identity.parameters, identity.parameters)) {
82
+ occupied.set(key, null);
83
+ }
84
+ } else occupied.set(key, { kind: tool.type, identity });
85
+ };
86
+ for (const group of groups) {
87
+ for (const tool of group) {
88
+ if (!isObject(tool)) continue;
89
+ if (tool.type === "namespace") {
90
+ if (typeof tool.name !== "string" || !tool.name || !Array.isArray(tool.tools)) continue;
91
+ for (const child of tool.tools) register(child, namespaceOf(tool.name));
92
+ } else if (tool.type === "function" && isObject(tool.function)) {
93
+ register({ ...tool.function, type: "function" });
94
+ } else register(tool);
95
+ }
96
+ }
97
+ for (const [key, entry] of occupied) {
98
+ if (!entry || entry.kind !== "function") continue;
99
+ let allowed = choice === undefined || choice === "auto" || choice === "required";
100
+ if (isObject(choice)) {
101
+ if (choice.type === "allowed_tools" && Array.isArray(choice.tools)) {
102
+ const selectors = isObject(loweredChoice) && Array.isArray(loweredChoice.tools) ? loweredChoice.tools : [];
103
+ allowed = choice.tools.some((selector, index) => selectorAllows(selector, selectors[index], key, entry.identity));
104
+ } else allowed = selectorAllows(choice, loweredChoice, key, entry.identity);
105
+ }
106
+ if (allowed) schemas.set(key, entry.identity);
107
+ }
108
+ return schemas;
109
+ }
110
+
111
+ function repairItem(item: unknown, schemas: FunctionCallRepairSchemas, completed: boolean): unknown {
112
+ if (!isObject(item) || item.type !== "function_call" || typeof item.name !== "string"
113
+ || typeof item.arguments !== "string") return item;
114
+ if (item.status !== "completed" && !(item.status === undefined && completed)) return item;
115
+ if ("namespace" in item && (typeof item.namespace !== "string" || !item.namespace)) return item;
116
+ const namespace = namespaceOf(item.namespace);
117
+ const schema = schemas.get(namespacedToolName(namespace, item.name));
118
+ if (!schema) return item;
119
+ // An explicit namespace is an identity coordinate, not another spelling to guess at.
120
+ if ("namespace" in item && (schema.namespace !== namespace || schema.name !== item.name)) return item;
121
+ const raw = item.arguments;
122
+ if (raw !== "") {
123
+ try {
124
+ let unsafe = false;
125
+ JSON.parse(raw, (_key, value: unknown) => {
126
+ if (typeof value === "number" && (!Number.isFinite(value)
127
+ || (Number.isInteger(value) && !Number.isSafeInteger(value)))) unsafe = true;
128
+ return value;
129
+ });
130
+ // Re-stringifying another repaired field must not round an unsafe sibling number.
131
+ if (unsafe) return item;
132
+ } catch { return item; }
133
+ }
134
+ const argumentsText = coerceIntegerToolArguments(raw || "{}", schema.parameters, schema.namespace ? undefined : schema.name);
135
+ return argumentsText === raw ? item : { ...item, arguments: argumentsText };
136
+ }
137
+
138
+ /** Only executable completion slots are visited; metadata and custom input are opaque. */
139
+ export function repairFunctionCalls(
140
+ value: unknown,
141
+ schemas: FunctionCallRepairSchemas,
142
+ ): { value: unknown; changed: boolean } {
143
+ if (schemas.size === 0 || !isObject(value)) return { value, changed: false };
144
+ if (typeof value.status === "string" && ["failed", "incomplete", "cancelled", "in_progress", "queued"].includes(value.status)) return { value, changed: false };
145
+ let next: unknown = value;
146
+ if (value.type === "function_call") next = repairItem(value, schemas, false);
147
+ else if (value.type === "response.output_item.done") {
148
+ const item = repairItem(value.item, schemas, true);
149
+ if (item !== value.item) next = { ...value, item };
150
+ } else if (value.type === "response.completed" && isObject(value.response)) {
151
+ const response = value.response;
152
+ if ((response.status === undefined || response.status === "completed") && Array.isArray(response.output)) {
153
+ const original = response.output;
154
+ const output = original.map(item => repairItem(item, schemas, true));
155
+ if (output.some((item, index) => item !== original[index])) next = { ...value, response: { ...response, output } };
156
+ }
157
+ } else if (typeof value.type !== "string" || !value.type.startsWith("response.")) {
158
+ if (Array.isArray(value.output)) {
159
+ const original = value.output;
160
+ const output = original.map(item => repairItem(item, schemas, value.status === "completed"));
161
+ if (output.some((item, index) => item !== original[index])) next = { ...value, output };
162
+ }
163
+ }
164
+ return { value: next, changed: next !== value };
165
+ }
166
+
167
+ export function repairFunctionCallsInJson(text: string, schemas: FunctionCallRepairSchemas): string {
168
+ if (schemas.size === 0) return text;
169
+ let payload: unknown;
170
+ try { payload = JSON.parse(text); } catch { return text; }
171
+ const repaired = repairFunctionCalls(payload, schemas);
172
+ return repaired.changed ? JSON.stringify(repaired.value) : text;
173
+ }
@@ -1,5 +1,6 @@
1
- import { namespacedToolName } from "../types";
1
+ import { dottedToolName, namespacedToolName } from "../types";
2
2
  import { collectResponsesToolGroups } from "./tool-groups";
3
+ import { collectAmbiguousDottedAliases, dottedAliasIsUnambiguous } from "./tool-name-aliases";
3
4
 
4
5
  export interface RoutedNamespaceToolIdentity {
5
6
  namespace: string;
@@ -340,7 +341,10 @@ function rewriteInputItem(item: unknown, plan: NamespaceRewritePlan, emitted: Se
340
341
  * `<namespace>__<name>` wire identity as the chat adapters. The returned request-local aliases
341
342
  * are the only names response restoration is allowed to expand.
342
343
  */
343
- export function rewriteRoutedNamespaceToolsForUpstream(body: unknown): {
344
+ export function rewriteRoutedNamespaceToolsForUpstream(
345
+ body: unknown,
346
+ convertedCustomToolNames?: ReadonlySet<string>,
347
+ ): {
344
348
  body: unknown;
345
349
  aliases: Map<string, RoutedNamespaceToolIdentity>;
346
350
  } {
@@ -365,6 +369,21 @@ export function rewriteRoutedNamespaceToolsForUpstream(body: unknown): {
365
369
  }
366
370
 
367
371
  const toolChoice = rewriteToolChoice(body.tool_choice, plan);
372
+ const aliases = authorizedAliases(plan.aliases, toolChoice);
373
+ const ambiguousDotted = collectAmbiguousDottedAliases(groups);
374
+ // Authorize canonical identities first, then add only unambiguous spellings.
375
+ // Selection cannot hide a collision elsewhere in the original declaration set.
376
+ for (const identity of [...aliases.values()]) {
377
+ const dotted = dottedToolName(identity.namespace, identity.name);
378
+ if (dottedAliasIsUnambiguous(identity.namespace, identity.name)
379
+ && !ambiguousDotted.has(dotted) && !plan.bareWireNames.has(dotted)
380
+ && !aliases.has(dotted)) aliases.set(dotted, identity);
381
+ }
382
+ // The adapter lowers custom tools before namespaces. Preserve their declared
383
+ // kind only in already-authorized response aliases; wire selectors remain lowered.
384
+ for (const identity of aliases.values()) {
385
+ if (convertedCustomToolNames?.has(namespacedToolName(identity.namespace, identity.name))) identity.kind = "custom";
386
+ }
368
387
  return {
369
388
  body: {
370
389
  ...body,
@@ -372,7 +391,7 @@ export function rewriteRoutedNamespaceToolsForUpstream(body: unknown): {
372
391
  ...(input !== body.input ? { input } : {}),
373
392
  ...(toolChoice !== body.tool_choice ? { tool_choice: toolChoice } : {}),
374
393
  },
375
- aliases: authorizedAliases(plan.aliases, toolChoice),
394
+ aliases,
376
395
  };
377
396
  }
378
397
 
@@ -404,7 +423,11 @@ export function restoreRoutedNamespaceCalls(
404
423
  && typeof value.name === "string"
405
424
  ) {
406
425
  const identity = aliases.get(value.name);
407
- if (identity) {
426
+ if (identity
427
+ // Custom declarations may be lowered to function calls upstream, but an
428
+ // ordinary function declaration never authorizes a custom call payload.
429
+ && (value.type !== "custom_tool_call" || identity.kind === "custom")
430
+ && (!Object.hasOwn(value, "namespace") || value.namespace === identity.namespace)) {
408
431
  restored.name = identity.name;
409
432
  restored.namespace = identity.namespace;
410
433
  changed = true;
@@ -25,6 +25,7 @@ import { toolSearchDescription, toolSearchParameters } from "./tool-search-compa
25
25
  import { isObj, inputContentParts, outputTextOf, outputToToolResultContent, toolOutputContainsEncryptedContent } from "./parser-content";
26
26
  import { mapToolChoice, buildTools, customToolNamespaces } from "./parser-tools";
27
27
  import { parseTextFormat } from "./parser-text-format";
28
+ import { externalTaskInputContent } from "./task-input";
28
29
 
29
30
  /**
30
31
  * Wrap a remembered proxy-side signature as provider metadata for a replayed tool call.
@@ -146,6 +147,7 @@ export function parseRequest(
146
147
  const item = data.input[inputIndex];
147
148
  const effectiveType = (item as { type?: string }).type ?? ("role" in item ? "message" : undefined);
148
149
  const itemRole = (item as { role?: string }).role;
150
+ const externalTaskInput = effectiveType === "function_call_output" ? externalTaskInputContent(item) : undefined;
149
151
  // Raw protocol items do not map one-to-one onto context messages. Capture the boundary while
150
152
  // both representations are available so later metadata can stay before conversation in both.
151
153
  if (
@@ -154,6 +156,7 @@ export function parseRequest(
154
156
  && continuationConversationMessageIndex === undefined
155
157
  && (
156
158
  effectiveType === "agent_message"
159
+ || externalTaskInput !== undefined
157
160
  || (effectiveType === "message" && (itemRole === "user" || itemRole === "assistant"))
158
161
  )
159
162
  ) {
@@ -429,6 +432,11 @@ export function parseRequest(
429
432
  }
430
433
 
431
434
  if (effectiveType === "function_call_output") {
435
+ if (externalTaskInput !== undefined) {
436
+ pendingReasoning.length = 0;
437
+ messages.push({ role: "user", content: externalTaskInput, timestamp: now });
438
+ continue;
439
+ }
432
440
  const output = item as { call_id: string; output?: string | unknown[] };
433
441
  attachPendingReasoningToCallOwner(messages, output.call_id, pendingReasoning);
434
442
  pendingReasoning.length = 0;
@@ -0,0 +1,36 @@
1
+ import type { OcxContentPart } from "../types";
2
+ import { inputContentParts, isObj } from "./parser-content";
3
+
4
+ type TaskInputBlock =
5
+ | { type: "input_text" | "output_text" | "text"; text: string }
6
+ | { type: "input_image"; image_url: string; detail?: string };
7
+
8
+ const imageDetails = new Set(["auto", "low", "high", "original"]);
9
+
10
+ function nonBlank(value: unknown): value is string {
11
+ return typeof value === "string" && value.trim().length > 0;
12
+ }
13
+
14
+ function supportedBlock(value: unknown): value is TaskInputBlock {
15
+ if (!isObj(value)) return false;
16
+ if (value.type === "input_text" || value.type === "output_text" || value.type === "text") {
17
+ return typeof value.text === "string";
18
+ }
19
+ if (value.type !== "input_image" || !nonBlank(value.image_url)) return false;
20
+ return value.detail === undefined || (typeof value.detail === "string" && imageDetails.has(value.detail));
21
+ }
22
+
23
+ /** Recognize Codex external task input without repairing ordinary orphaned tool results. */
24
+ export function externalTaskInputContent(item: unknown): string | OcxContentPart[] | undefined {
25
+ if (!isObj(item) || item.type !== "function_call_output" || "call_id" in item) return undefined;
26
+ if (!nonBlank(item.id) || !nonBlank(item.name) || !nonBlank(item.namespace)) return undefined;
27
+ const output = item.output;
28
+ if (typeof output === "string") return nonBlank(output) ? output : undefined;
29
+ if (!Array.isArray(output) || output.length === 0 || !output.every(supportedBlock)) return undefined;
30
+ if (!output.some(block => block.type === "input_image" || nonBlank(block.text))) return undefined;
31
+ // Validate the entire array first: the general converter intentionally drops unknown
32
+ // blocks, while a partial external task would silently lose the caller's input.
33
+ return inputContentParts(output.map(block =>
34
+ block.type === "output_text" ? { ...block, type: "input_text" } : block,
35
+ ));
36
+ }
@@ -0,0 +1,79 @@
1
+ import { dottedToolName, namespacedToolName } from "../types";
2
+
3
+ const BUILTIN_FUNCTIONS_NAMESPACE = "functions";
4
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
5
+ return !!value && typeof value === "object" && !Array.isArray(value);
6
+ }
7
+
8
+ /**
9
+ * A dotted spelling is a safe alias only when it cannot ALSO be read as some other identity's
10
+ * canonical `ns__name`.
11
+ *
12
+ * `{namespace: "x__y", name: "z"}` produces the dotted spelling "x__y.z", which is exactly the
13
+ * canonical wire name of `{namespace: "x", name: "y.z"}`. If only the latter is declared, an
14
+ * echoed call for the former would still find "x__y.z" in the declared set and be authorized as
15
+ * a tool the caller never granted. Requiring both halves to be free of the `__` separator keeps
16
+ * a dotted alias from ever impersonating a canonical name.
17
+ */
18
+ export function dottedAliasIsUnambiguous(namespace: string, name: string): boolean {
19
+ return !namespace.includes("__") && !name.includes("__");
20
+ }
21
+
22
+ export function wireToolInnerName(tool: unknown): string | undefined {
23
+ if (!isPlainObject(tool)) return undefined;
24
+ const nestedFunction = tool.type === "function" && isPlainObject(tool.function)
25
+ ? tool.function
26
+ : undefined;
27
+ return typeof tool.name === "string" && tool.name.length > 0
28
+ ? tool.name
29
+ : typeof nestedFunction?.name === "string" && nestedFunction.name.length > 0
30
+ ? nestedFunction.name
31
+ : undefined;
32
+ }
33
+
34
+ /**
35
+ * Dotted aliases that more than one declared identity would claim, plus dotted aliases that
36
+ * collide with a canonical or bare declared name.
37
+ *
38
+ * Resolved over the WHOLE catalog before any name is registered, so which identity "wins" can
39
+ * never depend on declaration order -- an order the caller controls.
40
+ */
41
+ export function collectAmbiguousDottedAliases(specGroups: readonly unknown[]): Set<string> {
42
+ const owners = new Map<string, string | null>();
43
+ const claim = (alias: string, identity: string): void => {
44
+ const owner = owners.get(alias);
45
+ if (owner === undefined) owners.set(alias, identity);
46
+ else if (owner !== identity) owners.set(alias, null);
47
+ };
48
+ for (const specs of specGroups) {
49
+ if (!Array.isArray(specs)) continue;
50
+ for (const spec of specs) {
51
+ if (!isPlainObject(spec)) continue;
52
+ if (spec.type === "namespace" && Array.isArray(spec.tools)) {
53
+ const namespace = typeof spec.name === "string" ? spec.name : undefined;
54
+ if (!namespace) continue;
55
+ for (const inner of spec.tools) {
56
+ const name = wireToolInnerName(inner);
57
+ if (!name) continue;
58
+ if (namespace === BUILTIN_FUNCTIONS_NAMESPACE) {
59
+ claim(name, JSON.stringify([undefined, name]));
60
+ continue;
61
+ }
62
+ const identity = JSON.stringify([namespace, name]);
63
+ claim(dottedToolName(namespace, name), identity);
64
+ // A canonical or bare name already owned by a different identity poisons the dotted
65
+ // alias that would shadow it.
66
+ claim(namespacedToolName(namespace, name), identity);
67
+ claim(name, identity);
68
+ }
69
+ continue;
70
+ }
71
+ const name = wireToolInnerName(spec);
72
+ if (name) claim(name, JSON.stringify([undefined, name]));
73
+ }
74
+ }
75
+ const ambiguous = new Set<string>();
76
+ for (const [alias, owner] of owners) if (owner === null) ambiguous.add(alias);
77
+ return ambiguous;
78
+ }
79
+
package/src/router.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  import type { NormalizedComboConfig } from "./combos/types";
11
11
  import { hasOwnProvider } from "./config/provider-name";
12
12
  import { providerUsesKeyAuthOverride, resolveProviderApiKey } from "./providers/key-store";
13
+ import { captureProviderApiKeySelection } from "./providers/api-key-selection";
13
14
  import { assertProviderDestinationAllowed } from "./lib/destination-policy";
14
15
  import { redactSecretString, redactUrlForLog } from "./lib/redact";
15
16
  import {
@@ -297,6 +298,7 @@ function usableResolvedApiKey(apiKey: string | undefined): string | undefined {
297
298
  }
298
299
 
299
300
  export function routedProviderConfig(providerName: string, provider: OcxProviderConfig): OcxProviderConfig {
301
+ provider = { ...provider, _apiKeyAttempt: provider._apiKeyAttempt ?? captureProviderApiKeySelection(provider) };
300
302
  const registryEntry = PROVIDER_REGISTRY.find(entry => entry.id === providerName);
301
303
  if (!registryEntry || !providerMatchesRegistryTransportWithStaticGuards(providerName, provider)) {
302
304
  assertProviderDestinationAllowed(providerName, provider);
@@ -10,7 +10,7 @@
10
10
  * how that affects eligibility.
11
11
  */
12
12
 
13
- import { modelInList, type OcxConfig } from "../types";
13
+ import { modelInList, type OcxConfig, type OcxProviderConfig } from "../types";
14
14
  import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
15
15
  import { serviceTierSupportForModel } from "../providers/service-tier";
16
16
  import { PROVIDER_REGISTRY } from "../providers/registry";
@@ -149,14 +149,20 @@ function localRemoteEvidence(baseUrl: string | undefined): Pick<RouteCapabilityE
149
149
  * Assemble canonical capability evidence for one `provider/model` candidate.
150
150
  * Sources (in priority order): provider config maps, provider registry hints,
151
151
  * cached Codex catalog row, native-model metadata.
152
+ * Policy assembly supplies the resolved provider so every transport capability
153
+ * describes the destination dispatch will use. Its maps already include applicable
154
+ * registry defaults; name-only registry fallbacks must not override that authority.
152
155
  */
153
156
  export function candidateCapabilityEvidence(
154
157
  config: OcxConfig,
155
158
  providerName: string,
156
159
  modelId: string,
160
+ resolvedProvider?: OcxProviderConfig,
157
161
  ): RouteCapabilityEvidence {
158
- const provider = config.providers[providerName];
159
- const registryEntry = PROVIDER_REGISTRY.find(entry => entry.id === providerName);
162
+ const provider = resolvedProvider ?? config.providers[providerName];
163
+ const registryEntry = resolvedProvider === undefined
164
+ ? PROVIDER_REGISTRY.find(entry => entry.id === providerName)
165
+ : undefined;
160
166
  const catalogRow = cachedCatalogModels().find(model => model.provider === providerName && model.id === modelId);
161
167
  const isNative = providerName === OPENAI_CODEX_PROVIDER_ID && !modelId.includes("/");
162
168
 
@@ -224,6 +230,7 @@ export function candidateCapabilityEvidence(
224
230
  ? []
225
231
  : modelRecordValue(provider?.modelReasoningEfforts, modelId)
226
232
  ?? modelRecordValue(registryEntry?.modelReasoningEfforts, modelId)
233
+ ?? provider?.reasoningEfforts
227
234
  ?? (isNative ? nativeReasoningEfforts(modelId) : undefined);
228
235
 
229
236
  const tierSupport = provider
@@ -52,11 +52,26 @@ export function assemblePolicyCandidateEvidence(
52
52
  return profile.candidates.map(candidate => {
53
53
  const key = `${candidate.provider}/${candidate.model}`;
54
54
  const compatibility = compatibilityByCandidate?.get(key);
55
+ const provider = config.providers[candidate.provider];
56
+ let routed: OcxProviderConfig | undefined;
57
+ let routeResolutionFailed = !provider || provider.disabled === true;
58
+ if (provider && provider.disabled !== true) {
59
+ try {
60
+ routed = options.routedProviderConfig(candidate.provider, provider);
61
+ } catch {
62
+ // This is known unavailability, not unknown capability evidence. Keep
63
+ // the failure separate so permissive unknown policies cannot select it.
64
+ routeResolutionFailed = true;
65
+ }
66
+ }
55
67
 
56
68
  return {
57
69
  provider: candidate.provider,
58
70
  model: candidate.model,
59
- capability: candidateCapabilityEvidence(config, candidate.provider, candidate.model),
71
+ ...(routeResolutionFailed ? { routeResolutionFailed: true } : {}),
72
+ capability: routed
73
+ ? candidateCapabilityEvidence(config, candidate.provider, candidate.model, routed)
74
+ : undefined,
60
75
  health: policyCandidateHealthEvidence(config, candidate, now),
61
76
  quota: quotaEvidenceForCandidate({
62
77
  provider: candidate.provider,
@@ -54,6 +54,8 @@ export interface PolicyCandidateEvidence {
54
54
  accountRef?: string;
55
55
  /** Codex pool account id (provider "openai"); used to derive account-scoped quota evidence. */
56
56
  codexAccountId?: string;
57
+ /** A failed effective-transport resolution excludes the candidate under every unknown policy. */
58
+ routeResolutionFailed?: boolean;
57
59
  capability?: RouteCapabilityEvidence;
58
60
  health?: RouteHealthEvidence;
59
61
  quota?: RouteQuotaEvidence;
@@ -278,6 +280,8 @@ export function evaluatePolicyProfile(
278
280
  ...requestRequirementFor(requestEvidence, evidence.capability),
279
281
  ];
280
282
  const exclusions: RouteExclusionReason[] = [];
283
+ const routeUnavailable = evidence.routeResolutionFailed === true;
284
+ if (routeUnavailable) exclusions.push({ code: "route-unavailable" });
281
285
  const bad = unsatisfiedOrUnknown(requirements);
282
286
  for (const requirement of bad) {
283
287
  if (requirement.outcome === "unsatisfied") {
@@ -310,7 +314,7 @@ export function evaluatePolicyProfile(
310
314
  if (unknownCostBlocked) {
311
315
  exclusions.push({ code: "cost-limit-unknown", detail: "maxEstimatedCostUsd" });
312
316
  }
313
- let eligible = !unsatisfied && !excludedByUnknown && !overCostLimit && !unknownCostBlocked;
317
+ let eligible = !routeUnavailable && !unsatisfied && !excludedByUnknown && !overCostLimit && !unknownCostBlocked;
314
318
 
315
319
  // Trace/dry-run copy only: report the profile cap that was applied and the
316
320
  // operator-visible outcome. Do not feed this copy into costScore() — that
@@ -530,6 +530,7 @@ export function requireResponsesApiAuth(req: Request, config: RequestPolicyView)
530
530
  const FORBIDDEN_PROVIDER_RUNTIME_FIELDS = [
531
531
  "virtualModels", "codexAuthContext", "selectedForwardHeaders",
532
532
  "sidecarOutcomeRecorder", "_codexAccountOverride", "_codexAccountRequired",
533
+ "_apiKeyAttempt",
533
534
  ] as const;
534
535
 
535
536
  function sameCanonicalProviderSeed(actual: Record<string, unknown>, expected: OcxProviderConfig): boolean {
@@ -795,6 +796,8 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
795
796
  apiKey: "redacted",
796
797
  apiKeyTransport: "editor",
797
798
  apiKeyPool: "redacted",
799
+ apiKeySelectionRevision: "runtime",
800
+ _apiKeyAttempt: "runtime",
798
801
  defaultModel: "editor",
799
802
  models: "editor",
800
803
  liveModels: "editor",
@@ -37,6 +37,8 @@ import {
37
37
  transientRetryPolicyFor,
38
38
  } from "../providers/key-failover";
39
39
  import { fastPolicyForModel } from "../providers/service-tier";
40
+ import { providerApiKeySelectionIsCurrent, resolveCurrentProviderApiKeyTransport } from "../providers/api-key-selection";
41
+ import type { OcxProviderTransport } from "../providers/xai-transport";
40
42
  import type { RouteResult } from "../router";
41
43
  import type { OcxConfig, OcxProviderConfig } from "../types";
42
44
  import { fetchWithHeaderTimeout, providerFetch, safeHostLabel } from "./responses/fetch-helpers";
@@ -46,6 +48,7 @@ import {
46
48
  beginRequestAttempt,
47
49
  noteAttemptSend,
48
50
  recordFirstOutput,
51
+ recordAttemptCredentialSource,
49
52
  sealRequestAttemptIdentity,
50
53
  type RequestLogContext,
51
54
  } from "./request-log";
@@ -183,14 +186,17 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
183
186
  translatorBudget.chargeRetained(bytes, { kind: "request_copies" });
184
187
  retainedRequestBytes = bytes;
185
188
  };
186
- const buildActiveRequest = () => buildOpenAIChatPassthroughRequest(
187
- activeProvider,
188
- options.chatBody,
189
- route.modelId,
190
- requestedStream,
191
- fastPolicyForModel(activeProvider, route.modelId, route.providerName, "chat"),
192
- config.fastMode,
193
- );
189
+ const buildActiveRequest = () => {
190
+ recordAttemptCredentialSource(attempt, route.providerName, activeProvider, activeAdapter.name);
191
+ return buildOpenAIChatPassthroughRequest(
192
+ activeProvider,
193
+ options.chatBody,
194
+ route.modelId,
195
+ requestedStream,
196
+ fastPolicyForModel(activeProvider, route.modelId, route.providerName, "chat"),
197
+ config.fastMode,
198
+ );
199
+ };
194
200
  try {
195
201
  activeRequest = buildActiveRequest();
196
202
  retainRequest(activeRequest);
@@ -224,7 +230,6 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
224
230
  const fetchWithPolicy = requestTransientPolicy ? fetchWithTransientRetry : fetchWithResetRetry;
225
231
  return await fetchWithPolicy(
226
232
  (transportRecovery?: UpstreamSendRecovery) => {
227
- noteAttemptSend(attempt, logCtx.usageLogInputTokens, transportRecovery ?? recovery);
228
233
  return fetchWithHeaderTimeout(
229
234
  request.url,
230
235
  applyUpstreamRecoveryInit({
@@ -238,6 +243,32 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
238
243
  providerFetch(activeProvider, undefined, {
239
244
  providerName: route.providerName,
240
245
  modelId: route.modelId,
246
+ dispatchOverride: async (_input, init, execute) => {
247
+ if (!providerApiKeySelectionIsCurrent(config, route.providerName, activeProvider)) {
248
+ const current = resolveCurrentProviderApiKeyTransport(config, route.providerName, activeProvider);
249
+ if (!current || !isNativeChatRouteEligible({ ...route, provider: current }, options.chatBody)) {
250
+ throw new Error("Provider key selection is no longer available for native Chat");
251
+ }
252
+ activeProvider = current;
253
+ activeAdapter = createOpenAIChatAdapter(current);
254
+ activeRequest.releaseBodyObservation?.();
255
+ releaseRetainedRequest();
256
+ activeRequest = buildActiveRequest();
257
+ try { retainRequest(activeRequest); }
258
+ catch (error) { activeRequest.releaseBodyObservation?.(); throw error; }
259
+ }
260
+ // The retry closure may still hold a pre-pacing request. Replace its entire
261
+ // wire shape, not just Authorization, and retain transport recovery flags.
262
+ request = activeRequest;
263
+ const headers = new Headers(request.headers);
264
+ const encoding = new Headers(init.headers).get("accept-encoding");
265
+ if (!headers.has("accept-encoding") && encoding) headers.set("accept-encoding", encoding);
266
+ if (init.signal?.aborted) throw init.signal.reason;
267
+ noteAttemptSend(attempt, logCtx.usageLogInputTokens, transportRecovery ?? recovery);
268
+ return ((activeProvider as OcxProviderTransport).fetch ?? execute)(request.url, applyUpstreamRecoveryInit({
269
+ ...init, method: request.method, headers, body: request.body,
270
+ }, transportRecovery));
271
+ },
241
272
  }),
242
273
  );
243
274
  },
@@ -282,6 +313,7 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
282
313
  retryAfter: response.headers.get("retry-after"),
283
314
  now: Date.now(),
284
315
  attemptedKey: activeProvider.apiKey,
316
+ attemptedSelection: activeProvider._apiKeyAttempt,
285
317
  promptCacheKey: typeof options.chatBody.prompt_cache_key === "string" ? options.chatBody.prompt_cache_key : undefined,
286
318
  });
287
319
  if (!rotated) break;
@@ -302,6 +334,9 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
302
334
  cleanupAbort();
303
335
  upstream.abort();
304
336
  if (req.signal.aborted) return fail(499, "Client cancelled request", "client_cancelled");
337
+ if (isTranslatorBudgetExceededError(error)) {
338
+ return fail(413, "request translation buffer exceeded the safe limit", "request_too_large", "translation_buffer_limit");
339
+ }
305
340
  return fail(502, error instanceof Error ? error.message : String(error), "server_error");
306
341
  }
307
342
  releaseRetainedRequest();