@bitkyc08/opencodex 2.7.40 → 2.7.41

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 (110) hide show
  1. package/README.md +63 -12
  2. package/assets/claude-code-models.gif +0 -0
  3. package/gui/dist/assets/index-B2J4t3te.css +1 -0
  4. package/gui/dist/assets/index-BmvM6wRb.js +65 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +3 -2
  7. package/src/adapters/google.ts +48 -9
  8. package/src/adapters/kiro-events.ts +15 -3
  9. package/src/adapters/kiro.ts +292 -28
  10. package/src/adapters/openai-chat.ts +63 -15
  11. package/src/adapters/openai-responses.ts +18 -0
  12. package/src/bridge.ts +76 -21
  13. package/src/chat/outbound.ts +66 -34
  14. package/src/claude/auth-detect.ts +229 -0
  15. package/src/claude/auth-mode-migration.ts +32 -0
  16. package/src/claude/auth-mode.ts +62 -0
  17. package/src/claude/desktop-3p-guard.ts +35 -0
  18. package/src/claude/desktop-3p.ts +121 -21
  19. package/src/claude/desktop-health.ts +26 -0
  20. package/src/claude/desktop-profile.ts +263 -0
  21. package/src/claude/inbound-debug.ts +4 -0
  22. package/src/claude/model-info.ts +9 -3
  23. package/src/cli/account-extended.ts +34 -0
  24. package/src/cli/account.ts +3 -1
  25. package/src/cli/claude-desktop.ts +152 -0
  26. package/src/cli/claude.ts +37 -3
  27. package/src/cli/doctor.ts +187 -6
  28. package/src/cli/help.ts +13 -1
  29. package/src/cli/index.ts +151 -57
  30. package/src/cli/status-oauth.ts +68 -0
  31. package/src/cli/status.ts +4 -0
  32. package/src/codex/account-lifecycle.ts +31 -0
  33. package/src/codex/auth-api.ts +133 -26
  34. package/src/codex/auth-collision.ts +55 -11
  35. package/src/codex/auth-context.ts +50 -6
  36. package/src/codex/catalog/provider-fetch.ts +6 -2
  37. package/src/codex/home.ts +61 -1
  38. package/src/codex/inject.ts +28 -66
  39. package/src/codex/injected-marker.ts +72 -0
  40. package/src/codex/journal.ts +39 -3
  41. package/src/codex/main-account-cache.ts +25 -0
  42. package/src/codex/model-cache.ts +20 -1
  43. package/src/codex/paths.ts +5 -0
  44. package/src/codex/routing.ts +138 -11
  45. package/src/codex/subagent-model-fallback.ts +455 -0
  46. package/src/codex/sync.ts +17 -0
  47. package/src/combos/failover.ts +10 -2
  48. package/src/combos/index.ts +1 -0
  49. package/src/combos/types.ts +9 -0
  50. package/src/config.ts +117 -0
  51. package/src/grok/inject.ts +339 -0
  52. package/src/grok/status.ts +88 -0
  53. package/src/grok/sync.ts +66 -0
  54. package/src/lib/destination-policy.ts +13 -0
  55. package/src/lib/errors.ts +59 -0
  56. package/src/lib/privacy.ts +9 -0
  57. package/src/lib/process-control.ts +57 -4
  58. package/src/oauth/health.ts +375 -0
  59. package/src/oauth/index.ts +69 -22
  60. package/src/oauth/kiro.ts +5 -3
  61. package/src/oauth/log.ts +48 -0
  62. package/src/oauth/store.ts +55 -6
  63. package/src/providers/alibaba-region-backup.ts +75 -0
  64. package/src/providers/alibaba-region-migration.ts +143 -0
  65. package/src/providers/alibaba-region-startup.ts +36 -0
  66. package/src/providers/api-keys.ts +5 -5
  67. package/src/providers/derive.ts +22 -1
  68. package/src/providers/free-directory.ts +181 -0
  69. package/src/providers/key-failover.ts +2 -2
  70. package/src/providers/kiro-models.ts +3 -2
  71. package/src/providers/openai-tiers.ts +1 -1
  72. package/src/providers/provider-id-rewrite.ts +150 -0
  73. package/src/providers/registry.ts +49 -2
  74. package/src/responses/parser.ts +49 -20
  75. package/src/responses/state.ts +156 -2
  76. package/src/router.ts +73 -5
  77. package/src/server/chat-completions.ts +78 -22
  78. package/src/server/claude-messages.ts +8 -0
  79. package/src/server/images.ts +2 -1
  80. package/src/server/index.ts +50 -30
  81. package/src/server/live.ts +2 -1
  82. package/src/server/management/agent-settings-routes.ts +337 -18
  83. package/src/server/management/api-access.ts +141 -0
  84. package/src/server/management/combo-routes.ts +3 -3
  85. package/src/server/management/config-routes.ts +4 -4
  86. package/src/server/management/logs-usage-routes.ts +64 -4
  87. package/src/server/management/model-routes.ts +114 -8
  88. package/src/server/management/oauth-account-routes.ts +31 -5
  89. package/src/server/management/provider-routes.ts +42 -11
  90. package/src/server/management/shared.ts +81 -2
  91. package/src/server/management/system-routes.ts +6 -1
  92. package/src/server/management-api.ts +24 -7
  93. package/src/server/port-reclaim.ts +261 -0
  94. package/src/server/request-log.ts +11 -5
  95. package/src/server/responses/collaboration.ts +11 -6
  96. package/src/server/responses/compact.ts +2 -1
  97. package/src/server/responses/core.ts +358 -155
  98. package/src/server/responses/passthrough-error.ts +53 -0
  99. package/src/server/search.ts +2 -1
  100. package/src/server/system-env.ts +23 -6
  101. package/src/server/windows-tcp-drop.ts +174 -0
  102. package/src/service.ts +43 -3
  103. package/src/types.ts +74 -6
  104. package/src/update/index.ts +30 -19
  105. package/src/update/job.ts +30 -15
  106. package/src/usage/log.ts +158 -2
  107. package/src/usage/summary.ts +8 -4
  108. package/src/web-search/loop.ts +4 -1
  109. package/gui/dist/assets/index-CMip1DzF.css +0 -1
  110. package/gui/dist/assets/index-cydcmbzC.js +0 -52
@@ -2,6 +2,9 @@ import type { ProviderAdapter } from "./base";
2
2
  import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall, OcxUsage } from "../types";
3
3
  import { isAllowedToolChoice, modelInList, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice } from "../types";
4
4
  import { mapReasoningEffort, modelRecordValue } from "../reasoning-effort";
5
+ import { debugProviderDiagnostic } from "../lib/debug";
6
+ import { isDebugEnabled } from "../lib/debug-settings";
7
+ import { isCyberPolicyCode } from "../lib/errors";
5
8
  import { redactSecretString } from "../lib/redact";
6
9
  import { contentPartsToText } from "./image";
7
10
  import { neutralizeIdentity } from "./identity";
@@ -522,6 +525,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
522
525
  messages,
523
526
  stream: parsed.stream,
524
527
  };
528
+ if (modelInList(provider.reasoningSplitModels, parsed.modelId)) body.reasoning_split = true;
525
529
  const maxTokens = resolveMaxTokens(provider, parsed);
526
530
  const openRouterRouting = resolveOpenRouterRouting(provider, parsed.modelId);
527
531
  if (openRouterRouting) body.provider = openRouterProviderPayload(openRouterRouting);
@@ -545,9 +549,10 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
545
549
  const budget = thinkingBudgetForEffort(parsed, reasoningEffort, maxTokens);
546
550
  if (budget !== undefined) body.thinking_budget = budget;
547
551
  } else if (modelInList(provider.thinkingToggleModels, parsed.modelId)) {
548
- // Vendor thinking-toggle wire (MiMo v2.x, GLM 5/5.1): the mapped value is the toggle
549
- // state, sent as `thinking: {type}` — these models ignore/reject reasoning_effort.
550
- if (reasoningEffort === "enabled" || reasoningEffort === "disabled") {
552
+ // Vendor thinking-toggle wire: the mapped value is sent as `thinking: {type}` because
553
+ // these models ignore/reject reasoning_effort. Most use enabled/disabled; MiniMax-M3
554
+ // uses adaptive/disabled.
555
+ if (reasoningEffort === "enabled" || reasoningEffort === "disabled" || reasoningEffort === "adaptive") {
551
556
  body.thinking = { type: reasoningEffort };
552
557
  }
553
558
  } else {
@@ -588,7 +593,24 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
588
593
  if (hasCredential) headers["Authorization"] = `Bearer ${provider.apiKey}`;
589
594
  if (provider.headers) Object.assign(headers, provider.headers);
590
595
 
591
- return { url, method: "POST", headers, body: JSON.stringify(body) };
596
+ const bodyJson = JSON.stringify(body);
597
+ // Never log pathname/query — tenant-scoped hosts (e.g. Cloudflare
598
+ // /accounts/<account_id>/ai/v1) would otherwise leak account identifiers (#452).
599
+ if (isDebugEnabled()) {
600
+ let host = "upstream";
601
+ try { host = new URL(url).host; } catch { /* keep fallback */ }
602
+ debugProviderDiagnostic("openai-chat", "request", {
603
+ host,
604
+ model: body.model,
605
+ stream: parsed.stream,
606
+ messageCount: Array.isArray(messages) ? messages.length : 0,
607
+ toolCount: Array.isArray(tools) ? tools.length : 0,
608
+ hasCredential,
609
+ bodyBytes: new TextEncoder().encode(bodyJson).length,
610
+ });
611
+ }
612
+
613
+ return { url, method: "POST", headers, body: bodyJson };
592
614
  },
593
615
 
594
616
  async *parseStream(response: Response): AsyncGenerator<AdapterEvent> {
@@ -657,9 +679,21 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
657
679
  // instead of a clean [DONE]. Surface it as a terminal error so the bridge emits a
658
680
  // classified response.failed (bridge case "error") — never a truncated completion.
659
681
  if (chunk.error) {
660
- const err = chunk.error as { message?: string } | undefined;
682
+ const err = chunk.error as { message?: string; code?: string; type?: string; status?: number } | undefined;
683
+ const message = err?.message ?? "upstream error";
684
+ debugProviderDiagnostic("openai-chat", "stream-error", { message });
661
685
  yield* flushToolCalls();
662
- yield { type: "error", message: err?.message ?? "upstream error" };
686
+ yield {
687
+ type: "error",
688
+ message,
689
+ ...(typeof err?.code === "string" ? { code: err.code } : {}),
690
+ ...(typeof err?.type === "string" ? { errorType: err.type } : {}),
691
+ ...(isCyberPolicyCode(err?.code)
692
+ ? { status: 400 }
693
+ : typeof err?.status === "number" && Number.isInteger(err.status)
694
+ ? { status: err.status }
695
+ : {}),
696
+ };
663
697
  return "terminate";
664
698
  }
665
699
 
@@ -679,13 +713,12 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
679
713
  }
680
714
  const delta = choices[0].delta;
681
715
  if (delta) {
682
- if (typeof delta.content === "string" && delta.content.length > 0) {
683
- yield { type: "text_delta", text: delta.content };
684
- }
685
-
686
716
  if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) {
687
717
  yield { type: "reasoning_raw_delta", text: delta.reasoning_content };
688
718
  }
719
+ if (typeof delta.content === "string" && delta.content.length > 0) {
720
+ yield { type: "text_delta", text: delta.content };
721
+ }
689
722
 
690
723
  const toolCalls = delta.tool_calls as { index?: number; id?: string; function?: { name?: string; arguments?: string } }[] | undefined;
691
724
  if (toolCalls) {
@@ -748,6 +781,10 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
748
781
  // closed so the bridge emits a classified response.failed rather than a silent truncation.
749
782
  const sawFinish = finishReason !== undefined;
750
783
  if (!sawFinish && pendingUsage === undefined) {
784
+ debugProviderDiagnostic("openai-chat", "stream-truncated", {
785
+ finishReason: finishReason ?? null,
786
+ hadUsage: false,
787
+ });
751
788
  yield { type: "error", message: "upstream stream ended without a terminal signal ([DONE] or finish_reason) — possible truncation" };
752
789
  return;
753
790
  }
@@ -766,10 +803,21 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
766
803
  async parseResponse(response: Response): Promise<AdapterEvent[]> {
767
804
  const json = await response.json() as Record<string, unknown>;
768
805
  if (json.error) {
769
- const upstreamError = json.error as { message?: unknown };
806
+ const upstreamError = json.error as { message?: unknown; code?: unknown; type?: unknown; status?: unknown };
807
+ const message = typeof upstreamError.message === "string" ? upstreamError.message : "upstream error";
808
+ const code = typeof upstreamError.code === "string" ? upstreamError.code : undefined;
809
+ const errorType = typeof upstreamError.type === "string" ? upstreamError.type : undefined;
810
+ const status = isCyberPolicyCode(code)
811
+ ? 400
812
+ : typeof upstreamError.status === "number" && Number.isInteger(upstreamError.status)
813
+ ? upstreamError.status
814
+ : undefined;
770
815
  return [{
771
816
  type: "error",
772
- message: typeof upstreamError.message === "string" ? upstreamError.message : "upstream error",
817
+ message,
818
+ ...(code !== undefined ? { code } : {}),
819
+ ...(errorType !== undefined ? { errorType } : {}),
820
+ ...(status !== undefined ? { status } : {}),
773
821
  }];
774
822
  }
775
823
 
@@ -780,12 +828,12 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
780
828
  }
781
829
 
782
830
  const msg = choices[0].message;
783
- if (typeof msg.content === "string") {
784
- events.push({ type: "text_delta", text: msg.content });
785
- }
786
831
  if (typeof msg.reasoning_content === "string" && msg.reasoning_content.length > 0) {
787
832
  events.push({ type: "reasoning_raw_delta", text: msg.reasoning_content });
788
833
  }
834
+ if (typeof msg.content === "string") {
835
+ events.push({ type: "text_delta", text: msg.content });
836
+ }
789
837
  const toolCalls = msg.tool_calls as { id: string; function: { name: string; arguments: string } }[] | undefined;
790
838
  if (toolCalls) {
791
839
  for (const tc of toolCalls) {
@@ -461,6 +461,23 @@ function stripPreviousResponseId(body: unknown, strip: boolean): unknown {
461
461
  return rest;
462
462
  }
463
463
 
464
+ /**
465
+ * Remove top-level parameters the ChatGPT backend (`authMode: "forward"`) rejects
466
+ * with `{"detail":"Unsupported parameter: …"}` (strict allowlist). Codex CLI never
467
+ * sends these — it controls output length via `reasoning.effort` — but third-party
468
+ * Responses API clients (GJC, SDK wrappers) include `max_output_tokens` per the
469
+ * public spec. `metadata` is likewise absent from the allowlist. No-op when the
470
+ * body carries neither field, keeping the common Codex path allocation-free.
471
+ */
472
+ function stripUnsupportedForwardParams(body: unknown): unknown {
473
+ if (!isPlainObject(body)) return body;
474
+ const hasMot = Object.prototype.hasOwnProperty.call(body, "max_output_tokens");
475
+ const hasMeta = Object.prototype.hasOwnProperty.call(body, "metadata");
476
+ if (!hasMot && !hasMeta) return body;
477
+ const { max_output_tokens: _mot, metadata: _meta, ...rest } = body;
478
+ return rest;
479
+ }
480
+
464
481
  /**
465
482
  * Hosted tool types whose server-side function names collide with the client tools Codex
466
483
  * declares for the matching app skill. Codex sends BOTH (e.g. hosted `image_generation` plus a
@@ -644,6 +661,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
644
661
  );
645
662
  if (forward) {
646
663
  outBody = repairOrphanedInputItems(outBody, unexpandedMiss);
664
+ outBody = stripUnsupportedForwardParams(outBody);
647
665
  }
648
666
  else outBody = stripConflictingHostedTools(outBody);
649
667
  if (forward || parsed._previousResponseInputExpanded === true) {
package/src/bridge.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { AdapterEvent, OcxMessagePhase, OcxProviderContinuationState, OcxUsage } from "./types";
2
- import { adapterFailureFromMessage, classifyError, type OcxErrorPayload } from "./lib/errors";
2
+ import { adapterFailureFromMessage, classifyError, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode, type OcxErrorPayload } from "./lib/errors";
3
3
  import { encodeCompactionSummary } from "./responses/compaction";
4
4
  import { encodeReasoningEnvelope, type ReasoningEnvelope } from "./responses/reasoning-envelope";
5
5
  import { resolveStallTimeoutSec } from "./stall-timeout";
@@ -14,28 +14,48 @@ function sseEvent(name: string, data: Record<string, unknown>): string {
14
14
  }
15
15
 
16
16
  function responsesUsage(usage: OcxUsage | undefined): Record<string, unknown> {
17
- if (!usage) return { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
18
- // inputTokens is already inclusive of cache read/write (types.ts convention).
19
- const inputTokens = usage.inputTokens;
17
+ // input_tokens_details / output_tokens_details are ALWAYS emitted (zero defaults):
18
+ // strict Responses clients deserialize them as required fields — grok-build's pinned
19
+ // async-openai fork (rev 95b52ebd, response_usage.rs) has non-Option InputTokenDetails/
20
+ // OutputTokenDetails, so omitting them turns a successful turn into a hard exit after
21
+ // response.completed ("missing field `input_tokens_details`", verified live 2026-07-23).
22
+ if (!usage) {
23
+ return {
24
+ input_tokens: 0,
25
+ output_tokens: 0,
26
+ total_tokens: 0,
27
+ input_tokens_details: { cached_tokens: 0 },
28
+ output_tokens_details: { reasoning_tokens: 0 },
29
+ };
30
+ }
31
+ // inputTokens is already inclusive of cache read/write (types.ts convention). Stateful
32
+ // providers may report an absolute active-context checkpoint separately from their
33
+ // per-attempt usage. Split that checkpoint into input + output without adding output twice.
34
+ const inputTokens = usage.contextTotalTokens !== undefined
35
+ ? Math.max(0, usage.contextTotalTokens - usage.outputTokens)
36
+ : usage.inputTokens;
20
37
  const out: Record<string, unknown> = {
21
38
  input_tokens: inputTokens,
22
39
  output_tokens: usage.outputTokens,
23
- total_tokens: usageDisplayTotalTokens(usage) ?? inputTokens + usage.outputTokens,
40
+ total_tokens: usage.contextTotalTokens !== undefined
41
+ ? usage.contextTotalTokens
42
+ : usageDisplayTotalTokens(usage) ?? inputTokens + usage.outputTokens,
43
+ };
44
+ // cached_tokens carries cache READS only, matching OpenAI semantics, and is always present
45
+ // (zero default) for strict clients. Clamp to inputTokens so a provider's absolute
46
+ // checkpoint can never report more cache reads than input.
47
+ const inputDetails: Record<string, number> = {
48
+ cached_tokens: Math.min(usage.cachedInputTokens ?? 0, inputTokens),
24
49
  };
25
- const inputDetails: Record<string, number> = {};
26
- if (usage.cachedInputTokens !== undefined) {
27
- // cached_tokens carries cache READS only, matching OpenAI semantics.
28
- inputDetails.cached_tokens = usage.cachedInputTokens;
29
- }
30
50
  if (usage.cacheCreationInputTokens !== undefined) {
31
- inputDetails.cache_write_tokens = usage.cacheCreationInputTokens;
32
- }
33
- if (Object.keys(inputDetails).length > 0) {
34
- out.input_tokens_details = inputDetails;
35
- }
36
- if (usage.reasoningOutputTokens !== undefined) {
37
- out.output_tokens_details = { reasoning_tokens: usage.reasoningOutputTokens };
51
+ const cacheRead = inputDetails.cached_tokens ?? 0;
52
+ inputDetails.cache_write_tokens = Math.min(
53
+ usage.cacheCreationInputTokens,
54
+ Math.max(0, inputTokens - cacheRead),
55
+ );
38
56
  }
57
+ out.input_tokens_details = inputDetails;
58
+ out.output_tokens_details = { reasoning_tokens: usage.reasoningOutputTokens ?? 0 };
39
59
  return out;
40
60
  }
41
61
 
@@ -48,10 +68,16 @@ function adapterFailureFromEvent(event: Extract<AdapterEvent, { type: "error" }>
48
68
  return adapterFailureFromMessage(event.message);
49
69
  }
50
70
  const fallback = adapterFailureFromMessage(event.message);
51
- const httpStatus = event.status ?? fallback.httpStatus;
71
+ let httpStatus = event.status ?? fallback.httpStatus;
52
72
  const error = classifyError(httpStatus, event.errorType ?? fallback.error.type, event.message);
53
73
  if (event.errorType !== undefined) error.type = event.errorType;
54
74
  if (event.code !== undefined) error.code = event.code;
75
+ // Codex maps cyber_policy on HTTP 400 (body) or mid-stream code; never leave it as 502.
76
+ if (isCyberPolicyCode(error.code) || isCyberPolicyCode(event.code)) {
77
+ error.code = CYBER_POLICY_ERROR_CODE;
78
+ error.type = "invalid_request_error";
79
+ httpStatus = 400;
80
+ }
55
81
  return { httpStatus, error };
56
82
  }
57
83
 
@@ -98,6 +124,15 @@ export function bridgeToResponsesSSE(
98
124
  onFirstOutput?: () => void;
99
125
  onTerminal?: (status: ResponsesTerminalStatus) => void;
100
126
  onCompletedResponse?: (response: Record<string, unknown>, providerState?: OcxProviderContinuationState) => void;
127
+ /**
128
+ * Raw adapter-reported usage at the terminal event, BEFORE wire normalization.
129
+ * responsesUsage() always emits token-detail objects with zero defaults for strict
130
+ * clients (grok-build), which makes the wire unusable as a provenance source: the
131
+ * request log must not read synthetic zeros as measured cache/reasoning numbers
132
+ * (cache_detail_missing would be silently suppressed). Callers set logCtx.usage
133
+ * from this callback instead of re-parsing the bridged SSE.
134
+ */
135
+ onUsage?: (usage: OcxUsage | undefined) => void;
101
136
  },
102
137
  ): ReadableStream<Uint8Array> {
103
138
  // Freeform/custom tools (apply_patch) carry their body in `input`; the model is given a
@@ -672,11 +707,13 @@ export function bridgeToResponsesSSE(
672
707
  // Cache max-output partials so previous_response_id replay can continue them;
673
708
  // rememberResponseState rejects content-filtered incomplete responses.
674
709
  options?.onCompletedResponse?.(response, event.providerState);
710
+ options?.onUsage?.(event.usage);
675
711
  emit("response.incomplete", { response });
676
712
  reportTerminal("incomplete");
677
713
  } else {
678
714
  const response = { ...responseSnapshot("completed", finishedItems, event.endTurn), usage: responsesUsage(event.usage) };
679
715
  options?.onCompletedResponse?.(response, event.providerState);
716
+ options?.onUsage?.(event.usage);
680
717
  emit("response.completed", {
681
718
  response,
682
719
  });
@@ -693,6 +730,7 @@ export function bridgeToResponsesSSE(
693
730
  if (currentToolCall) closeCurrentToolCall();
694
731
  if (currentWebSearch) closeCurrentWebSearch("failed", []);
695
732
  flushHiddenReasoningEnvelope();
733
+ options?.onUsage?.(event.usage);
696
734
  emit("response.incomplete", {
697
735
  response: {
698
736
  ...responseSnapshot("incomplete", finishedItems, event.endTurn),
@@ -716,6 +754,7 @@ export function bridgeToResponsesSSE(
716
754
  if (currentToolCall) closeCurrentToolCall();
717
755
  if (currentWebSearch) closeCurrentWebSearch("failed", []);
718
756
  const failure = adapterFailureFromEvent(event);
757
+ if (event.usage) options?.onUsage?.(event.usage);
719
758
  emit("response.failed", {
720
759
  response: {
721
760
  ...responseSnapshot("failed", finishedItems),
@@ -773,6 +812,7 @@ export function bridgeToResponsesSSE(
773
812
  flushHiddenRawReasoning();
774
813
  if (currentToolCall) closeCurrentToolCall();
775
814
  if (currentWebSearch) closeCurrentWebSearch("failed", []);
815
+ options?.onUsage?.(undefined);
776
816
  emit("response.incomplete", {
777
817
  response: {
778
818
  ...responseSnapshot("incomplete", finishedItems),
@@ -867,6 +907,8 @@ export function buildResponseJSON(
867
907
  /** Remote compaction v2 turn — append one synthetic compaction output item (see bridgeToResponsesSSE). */
868
908
  compaction?: boolean;
869
909
  onProviderState?: (state: OcxProviderContinuationState) => void;
910
+ /** Raw adapter-reported usage before wire normalization (see bridgeToResponsesSSE onUsage). */
911
+ onUsage?: (usage: OcxUsage | undefined) => void;
870
912
  },
871
913
  ): Record<string, unknown> {
872
914
  const responseId = `resp_${uuid()}`;
@@ -1091,6 +1133,7 @@ export function buildResponseJSON(
1091
1133
  : incompleteEvent || stopReason === "max_tokens"
1092
1134
  ? "incomplete"
1093
1135
  : "completed";
1136
+ options?.onUsage?.(incompleteEvent?.usage ?? usage);
1094
1137
  return {
1095
1138
  id: responseId, object: "response",
1096
1139
  created_at: Math.floor(Date.now() / 1000),
@@ -1112,8 +1155,20 @@ export function buildResponseJSON(
1112
1155
  };
1113
1156
  }
1114
1157
 
1115
- export function formatErrorResponse(status: number, type: string, message: string): Response {
1116
- return new Response(JSON.stringify({ error: classifyError(status, type, message) }), {
1117
- status, headers: { "Content-Type": "application/json" },
1158
+ export function formatErrorResponse(
1159
+ status: number,
1160
+ type: string,
1161
+ message: string,
1162
+ options?: { code?: string | null },
1163
+ ): Response {
1164
+ const error = classifyError(status, type, message);
1165
+ if (isCyberPolicyCode(options?.code)) {
1166
+ error.code = CYBER_POLICY_ERROR_CODE;
1167
+ error.type = "invalid_request_error";
1168
+ }
1169
+ const finalStatus = error.code === CYBER_POLICY_ERROR_CODE ? 400 : status;
1170
+ return new Response(JSON.stringify({ error }), {
1171
+ status: finalStatus,
1172
+ headers: { "Content-Type": "application/json" },
1118
1173
  });
1119
1174
  }
@@ -8,6 +8,7 @@
8
8
  type Rec = Record<string, unknown>;
9
9
 
10
10
  import { decodeServerSentEvents } from "../lib/sse-decoder";
11
+ import { classifyError, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode, isCyberPolicyMessage } from "../lib/errors";
11
12
 
12
13
  function isRec(v: unknown): v is Rec {
13
14
  return !!v && typeof v === "object" && !Array.isArray(v);
@@ -27,39 +28,64 @@ export function chatCompletionsUsage(usage: unknown): Rec {
27
28
  const details = isRec(u.input_tokens_details) ? u.input_tokens_details : {};
28
29
  const prompt = typeof u.input_tokens === "number" ? u.input_tokens : 0;
29
30
  const completion = typeof u.output_tokens === "number" ? u.output_tokens : 0;
30
- const cached = typeof details.cached_tokens === "number" ? details.cached_tokens : undefined;
31
+ const cached = typeof details.cached_tokens === "number" ? details.cached_tokens : 0;
31
32
  const out: Rec = {
32
33
  prompt_tokens: prompt,
33
34
  completion_tokens: completion,
34
35
  total_tokens: prompt + completion,
35
36
  };
36
- if (cached !== undefined) {
37
- out.prompt_tokens_details = { cached_tokens: cached };
38
- }
37
+ // Detail objects are always emitted (zero defaults) so strict OpenAI-compatible
38
+ // clients that require them (see responsesUsage in src/bridge.ts) never fail on
39
+ // routed providers that report no cache/reasoning numbers.
40
+ out.prompt_tokens_details = { cached_tokens: cached };
39
41
  const outDetails = isRec(u.output_tokens_details) ? u.output_tokens_details : {};
40
- if (typeof outDetails.reasoning_tokens === "number") {
41
- out.completion_tokens_details = { reasoning_tokens: outDetails.reasoning_tokens };
42
- }
42
+ const reasoning = typeof outDetails.reasoning_tokens === "number" ? outDetails.reasoning_tokens : 0;
43
+ out.completion_tokens_details = { reasoning_tokens: reasoning };
43
44
  return out;
44
45
  }
45
46
 
46
- export function chatCompletionsErrorBody(status: number, message: string, type = "invalid_request_error"): Rec {
47
+ export function chatCompletionsErrorBody(
48
+ status: number,
49
+ message: string,
50
+ type = "invalid_request_error",
51
+ code?: string | null,
52
+ ): Rec {
53
+ if (isCyberPolicyCode(code) || isCyberPolicyMessage(message)) {
54
+ return {
55
+ error: {
56
+ message,
57
+ type: "invalid_request_error",
58
+ param: null,
59
+ code: CYBER_POLICY_ERROR_CODE,
60
+ },
61
+ };
62
+ }
47
63
  return {
48
64
  error: {
49
65
  message,
50
66
  type,
51
67
  param: null,
52
- code: status === 401 ? "invalid_api_key"
53
- : status === 404 ? "model_not_found"
54
- : status === 429 ? "rate_limit_exceeded"
55
- : null,
68
+ code: code !== undefined
69
+ ? code
70
+ : status === 401 ? "invalid_api_key"
71
+ : status === 404 ? "model_not_found"
72
+ : status === 429 ? "rate_limit_exceeded"
73
+ : null,
56
74
  },
57
75
  };
58
76
  }
59
77
 
60
- export function chatCompletionsErrorResponse(status: number, message: string, type?: string): Response {
61
- return new Response(JSON.stringify(chatCompletionsErrorBody(status, message, type)), {
62
- status,
78
+ export function chatCompletionsErrorResponse(
79
+ status: number,
80
+ message: string,
81
+ type?: string,
82
+ code?: string | null,
83
+ ): Response {
84
+ const body = chatCompletionsErrorBody(status, message, type, code);
85
+ const err = body.error as { code?: string | null };
86
+ const finalStatus = err.code === CYBER_POLICY_ERROR_CODE ? 400 : status;
87
+ return new Response(JSON.stringify(body), {
88
+ status: finalStatus,
63
89
  headers: { "Content-Type": "application/json" },
64
90
  });
65
91
  }
@@ -85,6 +111,7 @@ export function isChatCompletionsStreamError(err: unknown): err is ChatCompletio
85
111
 
86
112
  function streamErrorStatus(message: string): number {
87
113
  const lower = message.toLowerCase();
114
+ if (isCyberPolicyMessage(lower)) return 400;
88
115
  if (lower.includes("truncated")) return 502;
89
116
  if (lower.includes("rate") || lower.includes("429")) return 429;
90
117
  if (lower.includes("unauthor") || lower.includes("401") || lower.includes("api key")) return 401;
@@ -93,13 +120,6 @@ function streamErrorStatus(message: string): number {
93
120
  return 502;
94
121
  }
95
122
 
96
- function streamErrorType(status: number): string {
97
- if (status === 401) return "authentication_error";
98
- if (status === 429) return "rate_limit_error";
99
- if (status >= 500) return "server_error";
100
- return "invalid_request_error";
101
- }
102
-
103
123
  function dataFrame(payload: Rec | "[DONE]"): string {
104
124
  if (payload === "[DONE]") return "data: [DONE]\n\n";
105
125
  return `data: ${JSON.stringify(payload)}\n\n`;
@@ -220,7 +240,7 @@ export function responsesSseToChatCompletionsSse(
220
240
  emit(frame);
221
241
  emit("[DONE]");
222
242
  };
223
- const fail = (message: string) => {
243
+ const fail = (message: string, details?: { code?: string | null; type?: string; status?: number }) => {
224
244
  if (terminated) return;
225
245
  terminated = true;
226
246
  failed = true;
@@ -229,18 +249,21 @@ export function responsesSseToChatCompletionsSse(
229
249
  // Deliver the error frame then close the stream abnormally (no [DONE]).
230
250
  // Do not controller.error() — that can drop already-enqueued bytes from consumers
231
251
  // like response.text().
232
- const status = streamErrorStatus(message);
233
- const type = streamErrorType(status);
252
+ const statusHint = details?.status ?? streamErrorStatus(message);
253
+ const classified = classifyError(statusHint, details?.type ?? "upstream_error", message);
254
+ if (isCyberPolicyCode(details?.code) || classified.code === CYBER_POLICY_ERROR_CODE) {
255
+ classified.code = CYBER_POLICY_ERROR_CODE;
256
+ classified.type = "invalid_request_error";
257
+ } else if (details?.code !== undefined && details.code !== null && !classified.code) {
258
+ classified.code = details.code;
259
+ }
234
260
  try {
235
261
  controller.enqueue(encoder.encode(dataFrame({
236
262
  error: {
237
- message,
238
- type,
263
+ message: classified.message,
264
+ type: classified.type,
239
265
  param: null,
240
- code: status === 401 ? "invalid_api_key"
241
- : status === 404 ? "model_not_found"
242
- : status === 429 ? "rate_limit_exceeded"
243
- : null,
266
+ code: classified.code,
244
267
  },
245
268
  })));
246
269
  emittedFrames++;
@@ -373,7 +396,13 @@ export function responsesSseToChatCompletionsSse(
373
396
  const response = isRec(data.response) ? data.response : {};
374
397
  const error = isRec(response.error) ? response.error : {};
375
398
  const message = typeof error.message === "string" ? error.message : "upstream request failed";
376
- fail(message);
399
+ const code = typeof error.code === "string" ? error.code : null;
400
+ const type = typeof error.type === "string" ? error.type : undefined;
401
+ fail(message, {
402
+ code,
403
+ type,
404
+ ...(code === CYBER_POLICY_ERROR_CODE ? { status: 400 } : {}),
405
+ });
377
406
  break;
378
407
  }
379
408
  default:
@@ -555,8 +584,11 @@ export async function collectChatCompletion(
555
584
  ? parsed.error.message
556
585
  : "upstream request failed";
557
586
  const type = typeof parsed.error.type === "string" ? parsed.error.type : "server_error";
558
- const status = streamErrorStatus(message);
559
- streamError = new ChatCompletionsStreamError(message, { status, type });
587
+ const code = typeof parsed.error.code === "string" ? parsed.error.code : null;
588
+ const status = code === CYBER_POLICY_ERROR_CODE || isCyberPolicyMessage(message)
589
+ ? 400
590
+ : streamErrorStatus(message);
591
+ streamError = new ChatCompletionsStreamError(message, { status, type, code });
560
592
  continue;
561
593
  }
562
594
  if (parsed.usage) usage = parsed.usage;