@bitkyc08/opencodex 2.12.0 → 2.13.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 (173) hide show
  1. package/README.md +1 -1
  2. package/gui/dist/assets/index-BHldBl6_.js +76 -0
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +1 -1
  5. package/src/adapters/anthropic-output-schema.ts +137 -0
  6. package/src/adapters/anthropic.ts +15 -0
  7. package/src/adapters/google-antigravity-replay.ts +269 -5
  8. package/src/adapters/openai-chat.ts +25 -18
  9. package/src/adapters/openai-responses.ts +63 -17
  10. package/src/bridge.ts +11 -5
  11. package/src/claude/inbound.ts +24 -3
  12. package/src/cli/account-api.ts +2 -0
  13. package/src/cli/account-extended.ts +262 -1
  14. package/src/cli/account.ts +3 -1
  15. package/src/cli/dispatch.ts +532 -0
  16. package/src/cli/doctor.ts +26 -21
  17. package/src/cli/help.ts +9 -278
  18. package/src/cli/index.ts +45 -506
  19. package/src/cli/lab.ts +171 -65
  20. package/src/cli/registry.ts +384 -0
  21. package/src/cli/root.ts +86 -0
  22. package/src/cli/status.ts +9 -2
  23. package/src/cli/v2.ts +37 -2
  24. package/src/codex/account-label.ts +14 -1
  25. package/src/codex/account-store.ts +12 -1
  26. package/src/codex/admission.ts +2 -1
  27. package/src/codex/auth-api.ts +3 -2
  28. package/src/codex/catalog/metadata.ts +6 -4
  29. package/src/codex/catalog/parsing.ts +31 -7
  30. package/src/codex/catalog/provider-fetch.ts +2 -1
  31. package/src/codex/catalog/sync.ts +65 -11
  32. package/src/codex/features.ts +437 -31
  33. package/src/codex/history-job.ts +6 -11
  34. package/src/codex/history-provider.ts +10 -9
  35. package/src/codex/inject.ts +14 -1
  36. package/src/codex/native-profile-startup.ts +49 -3
  37. package/src/codex/native-residue.ts +14 -2
  38. package/src/codex/paths.ts +75 -1
  39. package/src/codex/reset-credit-recovery.ts +1044 -0
  40. package/src/codex/shim.ts +876 -16
  41. package/src/codex/sync.ts +27 -2
  42. package/src/config.ts +148 -10
  43. package/src/generated/compatibility-version.json +2572 -0
  44. package/src/images/loop.ts +1 -1
  45. package/src/lab/artifacts/sanitize.ts +3 -3
  46. package/src/lab/automation/budgets.ts +78 -0
  47. package/src/lab/automation/config-persistence.ts +256 -0
  48. package/src/lab/automation/constants.ts +39 -0
  49. package/src/lab/automation/cooldown.ts +103 -0
  50. package/src/lab/automation/dispatch.ts +211 -0
  51. package/src/lab/automation/index.ts +13 -0
  52. package/src/lab/automation/orchestrator.ts +480 -0
  53. package/src/lab/automation/persistence.ts +512 -0
  54. package/src/lab/automation/planner.ts +371 -0
  55. package/src/lab/automation/policy.ts +136 -0
  56. package/src/lab/automation/queue.ts +191 -0
  57. package/src/lab/automation/recovery.ts +24 -0
  58. package/src/lab/automation/route-context.ts +21 -0
  59. package/src/lab/automation/run-key.ts +44 -0
  60. package/src/lab/automation/runs-query.ts +34 -0
  61. package/src/lab/automation/types.ts +160 -0
  62. package/src/lab/conformance/types.ts +23 -0
  63. package/src/lab/events/types.ts +2 -1
  64. package/src/lab/events/validate.ts +2 -1
  65. package/src/lab/fabric/constants.ts +40 -0
  66. package/src/lab/fabric/executor.ts +492 -0
  67. package/src/lab/fabric/index.ts +80 -0
  68. package/src/lab/fabric/manifest.ts +222 -0
  69. package/src/lab/fabric/observe.ts +454 -0
  70. package/src/lab/fabric/patch.ts +79 -0
  71. package/src/lab/fabric/producer-child.ts +139 -0
  72. package/src/lab/fabric/producer-isolate.ts +276 -0
  73. package/src/lab/fabric/producer-protocol.ts +61 -0
  74. package/src/lab/fabric/scratch.ts +439 -0
  75. package/src/lab/fabric/subject.ts +106 -0
  76. package/src/lab/fabric/types.ts +134 -0
  77. package/src/lab/fabric/verifier.ts +98 -0
  78. package/src/lab/index.ts +2 -0
  79. package/src/lab/ledger/store.ts +173 -0
  80. package/src/lab/live/executor.ts +29 -4
  81. package/src/lab/live/transport.ts +3 -1
  82. package/src/lab/observe/from-conformance.ts +12 -57
  83. package/src/lab/observe/from-live.ts +9 -1
  84. package/src/lab/paths.ts +74 -9
  85. package/src/lab/projection/schema.ts +14 -1
  86. package/src/lab/projection/verdicts.ts +89 -53
  87. package/src/lab/projection/verification.ts +83 -3
  88. package/src/lab/query/catalog.ts +34 -5
  89. package/src/lab/query/freshness.ts +53 -0
  90. package/src/lab/query/index.ts +11 -0
  91. package/src/lab/query/latest-observation.ts +59 -0
  92. package/src/lab/query/passive-production.ts +159 -0
  93. package/src/lab/subject/installation-salt.ts +24 -4
  94. package/src/lab/subject/protocol-subject.ts +80 -0
  95. package/src/lab/subject/route-subject.ts +2 -1
  96. package/src/lib/fabric-task-execution-authority.ts +7 -0
  97. package/src/lib/fabric-task-host.ts +29 -0
  98. package/src/lib/lab-live-route-production.ts +130 -0
  99. package/src/lib/local-management-capability.ts +100 -0
  100. package/src/lib/server-resource-ownership.ts +71 -0
  101. package/src/lib/windows-elevation.ts +8 -1
  102. package/src/lib/windows-user-principal.ts +62 -2
  103. package/src/lib/winsw.ts +8 -2
  104. package/src/oauth/account-import/google-antigravity-adapter.ts +74 -0
  105. package/src/oauth/account-import/index.ts +15 -0
  106. package/src/oauth/account-import/parser.ts +83 -0
  107. package/src/oauth/account-import/registry.ts +18 -0
  108. package/src/oauth/account-import/service.ts +75 -0
  109. package/src/oauth/account-import/types.ts +91 -0
  110. package/src/oauth/anthropic.ts +12 -1
  111. package/src/oauth/callback-server.ts +8 -2
  112. package/src/oauth/chatgpt.ts +12 -1
  113. package/src/oauth/google-antigravity.ts +27 -0
  114. package/src/oauth/health.ts +12 -48
  115. package/src/oauth/index.ts +122 -0
  116. package/src/oauth/kimi.ts +16 -2
  117. package/src/oauth/nous.ts +749 -0
  118. package/src/oauth/store.ts +56 -3
  119. package/src/providers/codex-capacity.ts +1 -0
  120. package/src/providers/registry.ts +161 -16
  121. package/src/responses/custom-tool-compat.ts +182 -0
  122. package/src/responses/parser.ts +22 -0
  123. package/src/responses/reasoning-replay-cache.ts +168 -11
  124. package/src/responses/schema.ts +1 -1
  125. package/src/router.ts +5 -20
  126. package/src/routing/capability.ts +9 -3
  127. package/src/routing/compatibility/assemble.ts +159 -0
  128. package/src/routing/compatibility/behavior.ts +224 -0
  129. package/src/routing/compatibility/catalog.ts +99 -0
  130. package/src/routing/compatibility/endpoint.ts +52 -0
  131. package/src/routing/compatibility/policy.ts +181 -0
  132. package/src/routing/compatibility/reader.ts +110 -0
  133. package/src/routing/compatibility/subject.ts +191 -0
  134. package/src/routing/compatibility/types.ts +64 -0
  135. package/src/routing/compatibility/version.ts +104 -0
  136. package/src/routing/evaluator.ts +27 -32
  137. package/src/routing/profile.ts +118 -4
  138. package/src/routing/trace.ts +82 -28
  139. package/src/server/auth-cors.ts +7 -0
  140. package/src/server/background-lifecycle.ts +20 -7
  141. package/src/server/chat-completions.ts +2 -2
  142. package/src/server/claude-messages.ts +6 -3
  143. package/src/server/direct-local-http.ts +343 -0
  144. package/src/server/index.ts +80 -18
  145. package/src/server/lifecycle.ts +12 -4
  146. package/src/server/local-management-read-client.ts +90 -0
  147. package/src/server/management/agent-settings-routes.ts +27 -3
  148. package/src/server/management/lab-automation-routes.ts +206 -0
  149. package/src/server/management/lab-routes.ts +13 -0
  150. package/src/server/management/logs-usage-routes.ts +1 -0
  151. package/src/server/management/oauth-account-routes.ts +57 -1
  152. package/src/server/management/provider-routes.ts +16 -0
  153. package/src/server/management/routing-profile-routes.ts +12 -24
  154. package/src/server/management-api.ts +2 -0
  155. package/src/server/management-auth.ts +68 -3
  156. package/src/server/proxy-liveness.ts +3 -2
  157. package/src/server/request-decompress.ts +9 -3
  158. package/src/server/request-log.ts +53 -4
  159. package/src/server/responses/collaboration.ts +93 -17
  160. package/src/server/responses/compact.ts +3 -0
  161. package/src/server/responses/core.ts +202 -15
  162. package/src/server/responses/policy-fallback.ts +1 -0
  163. package/src/server/responses-custom-tool-repair.ts +280 -0
  164. package/src/server/responses-terminal-repair.ts +342 -0
  165. package/src/server/startup-action-control.ts +8 -1
  166. package/src/service.ts +278 -12
  167. package/src/types.ts +49 -3
  168. package/src/usage/expected-prices.ts +30 -0
  169. package/src/usage/log.ts +30 -4
  170. package/src/usage/summary.ts +153 -2
  171. package/src/web-search/loop.ts +1 -1
  172. package/gui/dist/assets/index-Cw1_mxO8.js +0 -70
  173. package/src/cli/internal-dispatch.ts +0 -20
@@ -10,6 +10,7 @@ import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
10
10
  import { OCX_REASONING_PREFIX } from "../responses/reasoning-envelope";
11
11
  import { modelRecordValue } from "../reasoning-effort";
12
12
  import type { TranslatorBudget } from "../lib/translator-budget";
13
+ import { rewriteRoutedCustomToolsForUpstream } from "../responses/custom-tool-compat";
13
14
 
14
15
  // Headers relayed verbatim from the caller in OAuth-passthrough ("forward") mode.
15
16
  // Exported so the web-search sidecar reuses the exact same forwarded-auth set for its ChatGPT call.
@@ -539,15 +540,15 @@ function repairOrphanedInputItems(body: unknown, dropReasoning: boolean): unknow
539
540
  }
540
541
 
541
542
  /**
542
- * Make unambiguous Responses tool pairs adjacent for upstream parsers that require it.
543
+ * Make unambiguous Responses tool batches contiguous for upstream parsers that require it.
543
544
  *
544
545
  * [Decision Log]
545
- * - 목적과 의도: Keep Codex hook-injected developer context without letting it make a strict upstream reject the matching tool result.
546
- * - 기존 구현 및 제약 조건: The orphan repair verifies only pair presence; globally reordering valid history would change tolerant providers unnecessarily.
547
- * - 검토한 주요 대안: Reorder every Responses request, drop the intervening message, or gate a lossless reorder behind provider capability metadata.
548
- * - 선택한 방식: Reorder only unique call/result pairs for providers that explicitly require adjacency, preserving every intervening item immediately after the result.
549
- * - 다른 대안 대신 이 방식을 선택한 이유: The provider gate limits semantic blast radius, while refusing ambiguous duplicate ids avoids guessing which result belongs to which call.
550
- * - 장점, 단점 및 영향: DeepSeek receives the adjacency its parser requires; tolerant providers stay byte/order equivalent. Ambiguous duplicate ids still fail upstream rather than being silently rewritten.
546
+ * - 목적과 의도: Keep Codex hook-injected developer context without splitting a parallel tool-call turn away from its reasoning or making a strict upstream reject matching results.
547
+ * - 기존 구현 및 제약 조건: The orphan repair verifies only pair presence, while the original pair-by-pair reorder turned `reasoning, call A, call B, output A, output B` into two assistant turns and made DeepSeek reject call B for missing reasoning (#1477).
548
+ * - 검토한 주요 대안: Disable parallel calls (DeepSeek always enables them); duplicate reasoning per call; reorder each pair; or normalize the complete unambiguous call batch.
549
+ * - 선택한 방식: Treat calls emitted before the first matched result as one batch, emit all calls followed by their matched outputs, and preserve intervening non-tool items immediately after the batch.
550
+ * - 다른 대안 대신 이 방식을 선택한 이유: Batch normalization matches the Responses parallel-call shape without fabricating reasoning, while the provider gate and unique-pair requirement keep the blast radius narrow.
551
+ * - 장점, 단점 및 영향: DeepSeek keeps one reasoning-bearing assistant turn for parallel calls and still accepts hook-interleaved single calls; tolerant providers stay byte/order equivalent, and duplicate, missing, or backwards call/result pairs are not guessed.
551
552
  */
552
553
  function normalizeResponsesToolResultAdjacency(body: unknown): unknown {
553
554
  if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
@@ -575,24 +576,66 @@ function normalizeResponsesToolResultAdjacency(body: unknown): unknown {
575
576
  }
576
577
  }
577
578
 
578
- const movedOutputIndices = new Set<number>();
579
- const outputAfterCall = new Map<number, unknown>();
579
+ const pairs: Array<{ callIndex: number; outputIndex: number }> = [];
580
580
  for (const [key, callIndices] of calls) {
581
581
  const outputIndices = outputs.get(key);
582
- if (callIndices.length !== 1 || outputIndices?.length !== 1) continue;
582
+ if (!outputIndices) return body;
583
+ if (callIndices.length !== 1 || outputIndices.length !== 1) return body;
583
584
  const callIndex = callIndices[0]!;
584
585
  const outputIndex = outputIndices[0]!;
585
- if (outputIndex === callIndex + 1) continue;
586
- movedOutputIndices.add(outputIndex);
587
- outputAfterCall.set(callIndex, input[outputIndex]);
586
+ if (outputIndex <= callIndex) return body;
587
+ pairs.push({ callIndex, outputIndex });
588
588
  }
589
- if (movedOutputIndices.size === 0) return body;
589
+ // Reject any collected output that lacks exactly one matching call. A lone or
590
+ // duplicated output is ambiguous, and normalizing on top of it could sever a
591
+ // result from the reasoning-bearing call turn it belongs to.
592
+ for (const [key, outputIndices] of outputs) {
593
+ const callIndices = calls.get(key);
594
+ if (!callIndices || callIndices.length !== 1 || outputIndices.length !== 1) return body;
595
+ }
596
+ pairs.sort((left, right) => left.callIndex - right.callIndex);
597
+
598
+ const movedIndices = new Set<number>();
599
+ const batchAt = new Map<number, unknown[]>();
600
+ for (let cursor = 0; cursor < pairs.length;) {
601
+ const group = [pairs[cursor]!];
602
+ let firstOutputIndex = pairs[cursor]!.outputIndex;
603
+ let next = cursor + 1;
604
+ while (next < pairs.length && pairs[next]!.callIndex < firstOutputIndex) {
605
+ group.push(pairs[next]!);
606
+ firstOutputIndex = Math.min(firstOutputIndex, pairs[next]!.outputIndex);
607
+ next += 1;
608
+ }
609
+
610
+ // Within one reasoning turn the outputs must appear in the same order as their
611
+ // calls. If they are reversed, normalizing would fabricate a new output order;
612
+ // leave the ambiguous history untouched instead.
613
+ for (let groupIndex = 1; groupIndex < group.length; groupIndex += 1) {
614
+ if (group[groupIndex]!.outputIndex < group[groupIndex - 1]!.outputIndex) return body;
615
+ }
616
+
617
+ const batch = [
618
+ ...group.map(pair => input[pair.callIndex]),
619
+ ...group.map(pair => input[pair.outputIndex]),
620
+ ];
621
+ const anchor = group[0]!.callIndex;
622
+ const alreadyContiguous = batch.every((item, offset) => input[anchor + offset] === item);
623
+ if (!alreadyContiguous) {
624
+ batchAt.set(anchor, batch);
625
+ for (const pair of group) {
626
+ movedIndices.add(pair.callIndex);
627
+ movedIndices.add(pair.outputIndex);
628
+ }
629
+ }
630
+ cursor = next;
631
+ }
632
+ if (batchAt.size === 0) return body;
590
633
 
591
634
  const normalized: unknown[] = [];
592
635
  for (let index = 0; index < input.length; index += 1) {
593
- if (movedOutputIndices.has(index)) continue;
594
- normalized.push(input[index]);
595
- if (outputAfterCall.has(index)) normalized.push(outputAfterCall.get(index));
636
+ const batch = batchAt.get(index);
637
+ if (batch) normalized.push(...batch);
638
+ if (!movedIndices.has(index)) normalized.push(input[index]);
596
639
  }
597
640
  return { ...body, input: normalized };
598
641
  }
@@ -1245,6 +1288,9 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
1245
1288
  if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) {
1246
1289
  outBody = buildRoutedCompactionBody(outBody);
1247
1290
  }
1291
+ if (provider.authMode !== "forward") {
1292
+ outBody = rewriteRoutedCustomToolsForUpstream(outBody).body;
1293
+ }
1248
1294
  const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody), { preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true })))))));
1249
1295
  const body = JSON.stringify(stripDisabledReasoningSummaries(
1250
1296
  normalizeConfiguredReasoningSummaryDelivery(sanitizedBody, provider, parsed.modelId),
package/src/bridge.ts CHANGED
@@ -1,4 +1,10 @@
1
- import type { AdapterEvent, OcxMessagePhase, OcxProviderContinuationState, OcxUsage } from "./types";
1
+ import type {
2
+ AdapterEvent,
3
+ OcxMessagePhase,
4
+ OcxProviderContinuationState,
5
+ OcxReasoningReplayScopeRef,
6
+ OcxUsage,
7
+ } from "./types";
2
8
  import { adapterFailureFromMessage, classifyError, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode, type OcxErrorPayload } from "./lib/errors";
3
9
  import { encodeCompactionSummary } from "./responses/compaction";
4
10
  import { encodeReasoningEnvelope, type ReasoningEnvelope } from "./responses/reasoning-envelope";
@@ -186,7 +192,7 @@ export function bridgeToResponsesSSE(
186
192
  * Provider call ids are not globally unique; scoping by thread keeps one
187
193
  * conversation's reasoning out of another's continuations.
188
194
  */
189
- replayCacheScope?: string;
195
+ replayCacheScope?: OcxReasoningReplayScopeRef;
190
196
  /**
191
197
  * Test seam for the wire/stall beat loop. Production omits this and uses the
192
198
  * global timers; injecting here must not change scheduling semantics.
@@ -197,7 +203,7 @@ export function bridgeToResponsesSSE(
197
203
  };
198
204
  },
199
205
  ): ReadableStream<Uint8Array> {
200
- const replayCacheScope = options?.replayCacheScope ?? "global";
206
+ const replayCacheScope = options?.replayCacheScope;
201
207
  const setBeatInterval = options?.timers?.setInterval ?? ((handler: () => void, ms: number) => setInterval(handler, ms));
202
208
  const clearBeatInterval = options?.timers?.clearInterval ?? ((id: unknown) => clearInterval(id as ReturnType<typeof setInterval>));
203
209
  // Freeform/custom tools (apply_patch) carry their body in `input`; the model is given a
@@ -1362,11 +1368,11 @@ function buildResponseJSONWithBudget(
1362
1368
  onUsage?: (usage: OcxUsage | undefined) => void;
1363
1369
  translatorBudget?: TranslatorBudget;
1364
1370
  /** Conversation identity for the reasoning replay cache (issue #950). */
1365
- replayCacheScope?: string;
1371
+ replayCacheScope?: OcxReasoningReplayScopeRef;
1366
1372
  },
1367
1373
  ): Record<string, unknown> {
1368
1374
  const responseId = `resp_${uuid()}`;
1369
- const replayCacheScope = options?.replayCacheScope ?? "global";
1375
+ const replayCacheScope = options?.replayCacheScope;
1370
1376
  const output: OutputItem[] = [];
1371
1377
  const budget = options?.translatorBudget;
1372
1378
  const encoder = new TextEncoder();
@@ -10,9 +10,11 @@
10
10
  * - top_k is accepted and silently dropped (no Responses equivalent, CCR parity).
11
11
  */
12
12
  import type { OcxClaudeCodeConfig } from "../types";
13
+ import { isAnthropicOutputSchema } from "../adapters/anthropic-output-schema";
13
14
  import { resolveAlias } from "./alias";
14
15
  import { stripOneMillionMarker } from "./context-windows";
15
16
  import { resolveDesktop3pAlias } from "./desktop-3p";
17
+ import { isClaudeWebSearchToolName } from "./outbound";
16
18
  import { createHash } from "node:crypto";
17
19
 
18
20
  export class AnthropicRequestError extends Error {}
@@ -68,6 +70,17 @@ export function effortFromOutputConfig(outputConfig: unknown): string | undefine
68
70
  return typeof effort === "string" && OUTPUT_CONFIG_EFFORTS.has(effort) ? effort : undefined;
69
71
  }
70
72
 
73
+ function formatFromOutputConfig(outputConfig: unknown): Rec | undefined {
74
+ if (!isRec(outputConfig) || !isRec(outputConfig.format)) return undefined;
75
+ const format = outputConfig.format;
76
+ if (
77
+ format.type !== "json_schema"
78
+ || !isRec(format.schema)
79
+ || !isAnthropicOutputSchema(format.schema)
80
+ ) return undefined;
81
+ return { type: "json_schema", name: "response", schema: format.schema };
82
+ }
83
+
71
84
  function systemToInstructions(system: unknown): string | undefined {
72
85
  if (typeof system === "string") return system.length > 0 ? system : undefined;
73
86
  if (Array.isArray(system)) {
@@ -375,7 +388,12 @@ function toolChoiceToResponses(choice: unknown, body: Rec): void {
375
388
  if (typeof choice.name !== "string" || choice.name.length === 0) {
376
389
  throw new AnthropicRequestError("tool_choice.tool requires a name");
377
390
  }
378
- body.tool_choice = { type: "function", name: choice.name };
391
+ // Anthropic represents hosted WebSearch as a named tool choice, while
392
+ // Responses requires the choice type to match the hosted declaration.
393
+ // Preserve forced-tool intent rather than weakening it to `auto`.
394
+ body.tool_choice = isClaudeWebSearchToolName(choice.name)
395
+ ? { type: "web_search" }
396
+ : { type: "function", name: choice.name };
379
397
  break;
380
398
  default: break;
381
399
  }
@@ -461,6 +479,8 @@ export function anthropicToResponsesTranslation(raw: unknown, cc?: OcxClaudeCode
461
479
  if (Array.isArray(raw.stop_sequences) && raw.stop_sequences.length > 0) {
462
480
  body.stop = raw.stop_sequences.filter((s): s is string => typeof s === "string");
463
481
  }
482
+ const outputConfigFormat = formatFromOutputConfig(raw.output_config);
483
+ if (outputConfigFormat) body.text = { format: outputConfigFormat };
464
484
  let cacheKeySource: ClaudeCacheKeySource = null;
465
485
  if (isRec(raw.metadata) && typeof raw.metadata.user_id === "string") {
466
486
  body.user = raw.metadata.user_id;
@@ -502,8 +522,9 @@ export function anthropicToResponsesTranslation(raw: unknown, cc?: OcxClaudeCode
502
522
  // An explicit "disabled" is an instruction, not an absence. Dropping it made this
503
523
  // indistinguishable from a request that never mentioned thinking — and for models that
504
524
  // think by default, omission means thinking is ON, sharing the caller's max_tokens (#545).
505
- // "none" is the parser's disable sentinel (parser.ts REASONING_EFFORTS).
506
- body.reasoning = { effort: "none", summary: "none" };
525
+ // `none` is the effort disable sentinel. It is not a valid OpenAI summary
526
+ // value, so do not attach the similarly named internal catalog sentinel.
527
+ body.reasoning = { effort: "none" };
507
528
  } else if (isRec(thinking) || outputConfigEffort !== undefined) {
508
529
  const reasoning: Rec = { summary: "auto" };
509
530
  if (outputConfigEffort !== undefined) {
@@ -48,6 +48,8 @@ export interface AccountDeps {
48
48
  loadConfigImpl?: () => OcxConfig;
49
49
  stdinImpl?: AccountStdin;
50
50
  stdinTimeoutMs?: number;
51
+ /** Internal test seam for the account-import POST; production is capped at ten minutes. */
52
+ importTimeoutMs?: number;
51
53
  /** Test/platform injection for the official Codex login in a restricted staging home. */
52
54
  spawnCodexLoginImpl?: (codexHome: string) => NativeMainLoginChild;
53
55
  /** Legacy test seam. Production always uses the spawned child handle above. */
@@ -1,4 +1,5 @@
1
1
  import { loadConfig } from "../config";
2
+ import { closeSync, openSync, readSync } from "node:fs";
2
3
  import {
3
4
  MAX_ACCOUNT_PRIORITY,
4
5
  MIN_ACCOUNT_PRIORITY,
@@ -21,6 +22,16 @@ import {
21
22
  codexCatalogRefreshPending,
22
23
  warnIfCodexCatalogRefreshPending,
23
24
  } from "./account-catalog-refresh";
25
+ import {
26
+ ACCOUNT_IMPORT_DEADLINE_MS,
27
+ ACCOUNT_IMPORT_FORMAT,
28
+ ACCOUNT_IMPORT_MAX_BYTES,
29
+ ACCOUNT_IMPORT_PROVIDER,
30
+ type AccountImportCode,
31
+ type AccountImportRecordResult,
32
+ type AccountImportResult,
33
+ type AccountImportStatus,
34
+ } from "../oauth/account-import";
24
35
 
25
36
  const MAIN_ID = "__main__";
26
37
  const AUTO_NOTE = "auto (no pin — lowest-usage account is selected per request)";
@@ -31,10 +42,12 @@ const EXTENDED_USAGE = `Usage:
31
42
  ocx account priority <provider> <id|main> [<-100..100|first|earlier|normal|later|last|reset>] [--json]
32
43
  ocx account remove <provider> <id|main> --yes [--json]
33
44
  ocx account clear-cooldown <provider> <id|main> [--json]
34
- ocx account add-key <provider> [--label <label>] [--json]`;
45
+ ocx account add-key <provider> [--label <label>] [--json]
46
+ ocx account import <provider> --format <format> (--file <path>|--stdin) [--json]`;
35
47
  const PIPE_GUIDANCE = `Pipe the API key on stdin, for example:
36
48
  ocx account add-key <provider> <<< "$MY_KEY"
37
49
  security find-generic-password -w <item> | ocx account add-key <provider>`;
50
+ const ACCOUNT_IMPORT_TIMEOUT_MS = ACCOUNT_IMPORT_DEADLINE_MS;
38
51
 
39
52
  function flag(args: string[], value: string): boolean {
40
53
  const index = args.indexOf(value);
@@ -52,6 +65,162 @@ function flagValue(args: string[], value: string): { found: boolean; value?: str
52
65
  return { found: true, value: result };
53
66
  }
54
67
 
68
+ function boundedUtf8(bytes: Uint8Array): string {
69
+ try {
70
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
71
+ } catch {
72
+ throw new Error("invalid_document");
73
+ }
74
+ }
75
+
76
+ function readBoundedImportFile(path: string): string {
77
+ let descriptor: number | undefined;
78
+ try {
79
+ descriptor = openSync(path, "r");
80
+ const buffer = Buffer.allocUnsafe(ACCOUNT_IMPORT_MAX_BYTES + 1);
81
+ let offset = 0;
82
+ while (offset < buffer.length) {
83
+ const read = readSync(descriptor, buffer, offset, buffer.length - offset, null);
84
+ if (read === 0) break;
85
+ offset += read;
86
+ }
87
+ if (offset > ACCOUNT_IMPORT_MAX_BYTES) throw new Error("invalid_document");
88
+ return boundedUtf8(buffer.subarray(0, offset));
89
+ } catch (error) {
90
+ if (error instanceof Error && error.message === "invalid_document") throw error;
91
+ throw new Error("source_read_failed");
92
+ } finally {
93
+ if (descriptor !== undefined) closeSync(descriptor);
94
+ }
95
+ }
96
+
97
+ async function readBoundedImportStdin(deps: AccountDeps): Promise<string> {
98
+ const input: AccountStdin = deps.stdinImpl ?? process.stdin;
99
+ if (input.isTTY) throw new Error("stdin_required");
100
+ const timeoutMs = deps.stdinTimeoutMs ?? 15_000;
101
+ return await new Promise<string>((resolve, reject) => {
102
+ const chunks: Buffer[] = [];
103
+ let bytes = 0;
104
+ let settled = false;
105
+ const cleanup = () => {
106
+ clearTimeout(timer);
107
+ input.removeListener("data", onData);
108
+ input.removeListener("end", onEnd);
109
+ input.removeListener("error", onError);
110
+ };
111
+ const finish = (fn: () => void) => {
112
+ if (settled) return;
113
+ settled = true;
114
+ cleanup();
115
+ fn();
116
+ };
117
+ const onData = (chunk: unknown) => {
118
+ const encoded = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
119
+ bytes += encoded.byteLength;
120
+ if (bytes > ACCOUNT_IMPORT_MAX_BYTES) {
121
+ finish(() => reject(new Error("invalid_document")));
122
+ return;
123
+ }
124
+ chunks.push(encoded);
125
+ };
126
+ const onEnd = () => finish(() => {
127
+ try { resolve(boundedUtf8(Buffer.concat(chunks, bytes))); }
128
+ catch (error) { reject(error); }
129
+ });
130
+ const onError = () => finish(() => reject(new Error("source_read_failed")));
131
+ const timer = setTimeout(() => finish(() => reject(new Error("stdin_timeout"))), timeoutMs);
132
+ input.on("data", onData);
133
+ input.on("end", onEnd);
134
+ input.on("error", onError);
135
+ });
136
+ }
137
+
138
+ const IMPORT_STATUSES = new Set<AccountImportStatus>(["imported", "updated", "failed", "unsupported"]);
139
+ const IMPORT_CODES = new Set<AccountImportCode>([
140
+ "imported", "updated", "import_cancelled", "unsupported_provider", "unsupported_format", "invalid_document",
141
+ "invalid_record", "credential_rejected", "identity_mismatch", "missing_project", "persist_failed",
142
+ ]);
143
+ const IMPORT_RESULT_FIELDS = new Set([
144
+ "totalCount", "importedCount", "updatedCount", "failedCount", "unsupportedCount", "results",
145
+ ]);
146
+ const IMPORT_RECORD_FIELDS = new Set(["index", "status", "code"]);
147
+ const IMPORT_STATUS_CODES: Record<AccountImportStatus, ReadonlySet<AccountImportCode>> = {
148
+ imported: new Set(["imported"]),
149
+ updated: new Set(["updated"]),
150
+ failed: new Set(["invalid_record", "credential_rejected", "identity_mismatch", "missing_project", "persist_failed"]),
151
+ unsupported: new Set(["unsupported_provider", "unsupported_format"]),
152
+ };
153
+
154
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
155
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
156
+ const prototype = Object.getPrototypeOf(value);
157
+ return prototype === Object.prototype || prototype === null;
158
+ }
159
+
160
+ function hasExactFields(value: Record<string, unknown>, allowed: ReadonlySet<string>): boolean {
161
+ const fields = Object.keys(value);
162
+ return fields.length === allowed.size && fields.every(field => allowed.has(field));
163
+ }
164
+
165
+ function isNonNegativeSafeInteger(value: unknown): value is number {
166
+ return typeof value === "number" && Number.isFinite(value) && Number.isSafeInteger(value) && value >= 0;
167
+ }
168
+
169
+ function safeImportResult(json: unknown): AccountImportResult | null {
170
+ if (!isPlainObject(json) || !hasExactFields(json, IMPORT_RESULT_FIELDS)) return null;
171
+ if (
172
+ !isNonNegativeSafeInteger(json.totalCount)
173
+ || !isNonNegativeSafeInteger(json.importedCount)
174
+ || !isNonNegativeSafeInteger(json.updatedCount)
175
+ || !isNonNegativeSafeInteger(json.failedCount)
176
+ || !isNonNegativeSafeInteger(json.unsupportedCount)
177
+ ) return null;
178
+ if (!Array.isArray(json.results)) return null;
179
+ if (json.results.length !== json.totalCount) return null;
180
+
181
+ const results: AccountImportRecordResult[] = [];
182
+ const indices = new Set<number>();
183
+ const aggregate: Record<AccountImportStatus, number> = {
184
+ imported: 0,
185
+ updated: 0,
186
+ failed: 0,
187
+ unsupported: 0,
188
+ };
189
+ for (const raw of json.results) {
190
+ if (!isPlainObject(raw) || !hasExactFields(raw, IMPORT_RECORD_FIELDS)) return null;
191
+ const item = raw;
192
+ if (!isNonNegativeSafeInteger(item.index) || item.index >= json.totalCount || indices.has(item.index)) return null;
193
+ if (!IMPORT_STATUSES.has(item.status as AccountImportStatus)) return null;
194
+ if (!IMPORT_CODES.has(item.code as AccountImportCode)) return null;
195
+ const status = item.status as AccountImportStatus;
196
+ const code = item.code as AccountImportCode;
197
+ if (!IMPORT_STATUS_CODES[status].has(code)) return null;
198
+ indices.add(item.index);
199
+ aggregate[status] += 1;
200
+ results.push({
201
+ index: item.index,
202
+ status,
203
+ code,
204
+ });
205
+ }
206
+ if (
207
+ indices.size !== json.totalCount
208
+ || aggregate.imported !== json.importedCount
209
+ || aggregate.updated !== json.updatedCount
210
+ || aggregate.failed !== json.failedCount
211
+ || aggregate.unsupported !== json.unsupportedCount
212
+ ) return null;
213
+
214
+ return {
215
+ totalCount: json.totalCount,
216
+ importedCount: json.importedCount,
217
+ updatedCount: json.updatedCount,
218
+ failedCount: json.failedCount,
219
+ unsupportedCount: json.unsupportedCount,
220
+ results,
221
+ };
222
+ }
223
+
55
224
  function usage(message?: string): number {
56
225
  if (message) console.error(message);
57
226
  console.error(EXTENDED_USAGE);
@@ -304,6 +473,98 @@ export async function cmdAddKey(args: string[], deps: AccountDeps): Promise<numb
304
473
  return 0;
305
474
  }
306
475
 
476
+ export async function cmdImport(args: string[], deps: AccountDeps): Promise<number> {
477
+ const wantsJson = flag(args, "--json");
478
+ const formatArg = flagValue(args, "--format");
479
+ const fileArg = flagValue(args, "--file");
480
+ const fromStdin = flag(args, "--stdin");
481
+ const provider = args.shift();
482
+
483
+ // Admission happens before source I/O. In particular, an inline JSON positional argument is
484
+ // rejected without inspecting or echoing it, keeping secrets out of any subsequent surface.
485
+ if (
486
+ provider !== ACCOUNT_IMPORT_PROVIDER
487
+ || formatArg.value !== ACCOUNT_IMPORT_FORMAT
488
+ || formatArg.found !== true
489
+ ) {
490
+ const code = provider === ACCOUNT_IMPORT_PROVIDER ? "unsupported_format" : "unsupported_provider";
491
+ console.error(`Error: ${code}`);
492
+ return 1;
493
+ }
494
+ const hasFile = fileArg.found && typeof fileArg.value === "string" && fileArg.value.length > 0;
495
+ if (args.length > 0 || hasFile === fromStdin || (fileArg.found && !hasFile)) {
496
+ return usage("Error: choose exactly one bounded source: --file <path> or --stdin");
497
+ }
498
+
499
+ let source: string;
500
+ try {
501
+ source = hasFile ? readBoundedImportFile(fileArg.value!) : await readBoundedImportStdin(deps);
502
+ } catch (error) {
503
+ const code = error instanceof Error ? error.message : "source_read_failed";
504
+ console.error(`Error: ${["invalid_document", "source_read_failed", "stdin_required", "stdin_timeout"].includes(code) ? code : "source_read_failed"}`);
505
+ return 1;
506
+ }
507
+
508
+ let document: unknown;
509
+ try {
510
+ document = JSON.parse(source) as unknown;
511
+ } catch {
512
+ console.error("Error: invalid_document");
513
+ return 1;
514
+ }
515
+
516
+ const baseUrl = await resolveBaseUrl(deps);
517
+ if (!baseUrl) return proxyUnreachable();
518
+ const configuredImportTimeout = deps.importTimeoutMs;
519
+ const importTimeoutMs = typeof configuredImportTimeout === "number" && Number.isFinite(configuredImportTimeout)
520
+ ? Math.min(ACCOUNT_IMPORT_TIMEOUT_MS, Math.max(1, Math.floor(configuredImportTimeout)))
521
+ : ACCOUNT_IMPORT_TIMEOUT_MS;
522
+ const controller = new AbortController();
523
+ let timedOut = false;
524
+ const timer = setTimeout(() => {
525
+ timedOut = true;
526
+ controller.abort();
527
+ }, importTimeoutMs);
528
+ let response;
529
+ try {
530
+ response = await apiJson(deps, baseUrl, "POST", "/api/oauth/accounts/import", {
531
+ provider,
532
+ format: formatArg.value,
533
+ document,
534
+ }, { signal: controller.signal });
535
+ } finally {
536
+ clearTimeout(timer);
537
+ }
538
+ if (response.status === 0) {
539
+ if (!timedOut) return proxyUnreachable();
540
+ console.error(`Error: import_timeout after ${importTimeoutMs}ms`);
541
+ return 1;
542
+ }
543
+ if (response.status !== 200) {
544
+ if (IMPORT_CODES.has(response.json.code as AccountImportCode)) {
545
+ console.error(`Error: ${response.json.code as AccountImportCode}`);
546
+ } else {
547
+ console.error(`Error: import_request_failed (status ${response.status})`);
548
+ }
549
+ return 1;
550
+ }
551
+ const result = safeImportResult(response.json);
552
+ if (!result) {
553
+ console.error("Error: invalid_response");
554
+ return 1;
555
+ }
556
+
557
+ if (wantsJson) {
558
+ console.log(JSON.stringify(result, null, 2));
559
+ } else {
560
+ console.log(
561
+ `${provider}: ${result.importedCount} imported, ${result.updatedCount} updated, ${result.failedCount} failed, ${result.unsupportedCount} unsupported`,
562
+ );
563
+ for (const item of result.results) console.log(` #${item.index + 1} ${item.status} (${item.code})`);
564
+ }
565
+ return result.failedCount > 0 || result.unsupportedCount > 0 ? 1 : 0;
566
+ }
567
+
307
568
  /**
308
569
  * Lift a quota cooldown on a Codex account.
309
570
  *
@@ -2,7 +2,7 @@
2
2
  import { loadConfig } from "../config";
3
3
  import { providerCodexAccountMode } from "../providers/registry";
4
4
  import type { OcxConfig } from "../types";
5
- import { cmdAddKey, cmdAlias, cmdAutoSwitch, cmdClearCooldown, cmdPriority, cmdRefresh, cmdRemove } from "./account-extended";
5
+ import { cmdAddKey, cmdAlias, cmdAutoSwitch, cmdClearCooldown, cmdImport, cmdPriority, cmdRefresh, cmdRemove } from "./account-extended";
6
6
  import { apiError, apiJson, classifyAccount, fetchRows, proxyUnreachable, resolveBaseUrl, type AccountDeps, type AccountRow, type AccountType, type ApiResult }
7
7
  from "./account-api";
8
8
 
@@ -26,6 +26,7 @@ const ACCOUNT_USAGE = `Usage:
26
26
  ocx account remove <provider> <account-or-key-id|main> --yes [--json]
27
27
  ocx account clear-cooldown <provider> <account-id|main> [--json]
28
28
  ocx account add-key <provider> [--label <label>] [--json]
29
+ ocx account import <provider> --format <format> (--file <path>|--stdin) [--json]
29
30
  ocx account login <provider> [--id <account-id>] [--reauth] [--code -] [--no-wait] [--json]
30
31
  ocx account code <provider> [--flow <flow-id>] [--json] (reads the code from stdin)
31
32
  ocx account cancel <provider> [--flow <flow-id>] [--json]
@@ -280,6 +281,7 @@ export async function cmdAccount(args: string[], deps: AccountDeps = {}): Promis
280
281
  if (sub === "remove") return await cmdRemove(rest, deps);
281
282
  if (sub === "clear-cooldown") return await cmdClearCooldown(rest, deps);
282
283
  if (sub === "add-key") return await cmdAddKey(rest, deps);
284
+ if (sub === "import") return await cmdImport(rest, deps);
283
285
  if (sub === "main") {
284
286
  const { cmdNativeMainAccount } = await import("./account-main");
285
287
  return await cmdNativeMainAccount(rest, deps);