@bitkyc08/opencodex 2.42.0 → 2.43.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 (246) hide show
  1. package/AGENTS_INSTALL.md +2 -2
  2. package/README.md +31 -0
  3. package/bin/ocx.mjs +10 -1
  4. package/gui/dist/assets/index-DS1NE4Jn.css +1 -0
  5. package/gui/dist/assets/index-Djowl68T.js +112 -0
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic-image-codec.ts +304 -0
  9. package/src/adapters/anthropic-image-normalize.ts +8 -298
  10. package/src/adapters/anthropic.ts +30 -7
  11. package/src/adapters/command-code.ts +7 -5
  12. package/src/adapters/cursor/desktop-executor-contract.ts +15 -0
  13. package/src/adapters/cursor/images.ts +36 -6
  14. package/src/adapters/cursor/live-transport.ts +7 -2
  15. package/src/adapters/cursor/native-exec-desktop.ts +2 -15
  16. package/src/adapters/cursor/protobuf-request.ts +13 -3
  17. package/src/adapters/cursor/tool-definitions.ts +5 -670
  18. package/src/adapters/cursor/tool-guidance.ts +236 -0
  19. package/src/adapters/cursor/tool-naming.ts +252 -0
  20. package/src/adapters/cursor/tool-schemas.ts +195 -0
  21. package/src/adapters/cursor/types.ts +6 -3
  22. package/src/adapters/exec-tool-result-normalize.ts +1 -1
  23. package/src/adapters/google-errors.ts +9 -1
  24. package/src/adapters/google.ts +1 -0
  25. package/src/adapters/kiro-calibration.ts +181 -0
  26. package/src/adapters/kiro.ts +135 -3
  27. package/src/adapters/openai-responses.ts +216 -26
  28. package/src/adapters/responses-code-mode.ts +59 -0
  29. package/src/adapters/tool-catalog-nudge.ts +1 -1
  30. package/src/adapters/xai-schema-analysis.ts +86 -0
  31. package/src/adapters/xai-tool-schema.ts +2 -87
  32. package/src/adapters/xai-web-search.ts +1 -1
  33. package/src/bridge.ts +22 -10
  34. package/src/chat/inbound.ts +11 -3
  35. package/src/claude/inbound-content-options.ts +60 -0
  36. package/src/claude/inbound-model-options.ts +142 -0
  37. package/src/claude/inbound-records.ts +7 -0
  38. package/src/claude/inbound.ts +10 -202
  39. package/src/claude/model-info.ts +45 -0
  40. package/src/cli/account-auth.ts +21 -6
  41. package/src/cli/capabilities.ts +13 -2
  42. package/src/cli/claude.ts +232 -39
  43. package/src/cli/config-command.ts +9 -1
  44. package/src/cli/dispatch.ts +5 -1
  45. package/src/cli/doctor.ts +10 -0
  46. package/src/cli/effort.ts +372 -0
  47. package/src/cli/export-command.ts +3 -9
  48. package/src/cli/help.ts +1 -0
  49. package/src/cli/index.ts +13 -0
  50. package/src/cli/init.ts +4 -0
  51. package/src/cli/model-selection-guidance.ts +30 -0
  52. package/src/cli/models-runtime.ts +3 -2
  53. package/src/cli/models.ts +8 -3
  54. package/src/cli/opencode.ts +4 -1
  55. package/src/cli/provider-runtime.ts +65 -0
  56. package/src/cli/provider.ts +8 -0
  57. package/src/cli/registry.ts +16 -2
  58. package/src/cli/runtime-api.ts +3 -1
  59. package/src/cli/star-prompt.ts +22 -6
  60. package/src/cli/status-probes.ts +168 -0
  61. package/src/cli/status.ts +5 -168
  62. package/src/clients/config-export/constants.ts +69 -0
  63. package/src/clients/config-export/contracts.ts +154 -0
  64. package/src/clients/config-export/dsh.ts +132 -0
  65. package/src/clients/config-export/fast-models.ts +29 -0
  66. package/src/clients/config-export/mcode.ts +83 -0
  67. package/src/clients/config-export/model-metadata.ts +108 -0
  68. package/src/clients/config-export/omp.ts +104 -0
  69. package/src/clients/config-export/zcode.ts +92 -0
  70. package/src/clients/config-export.ts +18 -710
  71. package/src/codex/account-lifecycle.ts +20 -3
  72. package/src/codex/account-usability.ts +2 -0
  73. package/src/codex/auth-api.ts +170 -23
  74. package/src/codex/auth-context.ts +328 -24
  75. package/src/codex/catalog/effort.ts +30 -4
  76. package/src/codex/catalog/metadata.ts +39 -9
  77. package/src/codex/catalog/native-models.ts +53 -12
  78. package/src/codex/catalog/parsing.ts +119 -5
  79. package/src/codex/catalog/provider-fetch.ts +150 -20
  80. package/src/codex/catalog/reserve.ts +52 -0
  81. package/src/codex/catalog/sync.ts +89 -16
  82. package/src/codex/convergence-types.ts +1 -0
  83. package/src/codex/convergence.ts +2 -0
  84. package/src/codex/data/upstream-models.json +1 -1
  85. package/src/codex/forward-transport-headers.ts +25 -0
  86. package/src/codex/inject.ts +3 -28
  87. package/src/codex/legacy-config-keys.ts +68 -0
  88. package/src/codex/log-guard/inspect-schema.ts +137 -0
  89. package/src/codex/log-guard/inspect.ts +2 -134
  90. package/src/codex/loopback-target.ts +54 -0
  91. package/src/codex/main-account-cache.ts +63 -1
  92. package/src/codex/main-account-hard-lock.ts +52 -0
  93. package/src/codex/main-account.ts +3 -1
  94. package/src/codex/management-convergence.ts +3 -0
  95. package/src/codex/model-entitlements.ts +54 -4
  96. package/src/codex/project-config-warnings.ts +92 -2
  97. package/src/codex/prompt-layers/encoding.ts +80 -0
  98. package/src/codex/prompt-layers/paths.ts +54 -0
  99. package/src/codex/prompt-layers/revision.ts +55 -0
  100. package/src/codex/prompt-layers/toml-edit.ts +163 -0
  101. package/src/codex/prompt-layers/toml-read.ts +181 -0
  102. package/src/codex/prompt-layers.ts +14 -520
  103. package/src/codex/quota-auto-refresh-state.ts +16 -0
  104. package/src/codex/quota-auto-refresh.ts +219 -0
  105. package/src/codex/quota-types.ts +51 -0
  106. package/src/codex/quota.ts +252 -93
  107. package/src/codex/reserve-availability.ts +177 -0
  108. package/src/codex/routing.ts +28 -9
  109. package/src/codex/shim.ts +53 -11
  110. package/src/codex/subagent-model-fallback.ts +23 -3
  111. package/src/combos/failover.ts +125 -7
  112. package/src/combos/identifiers.ts +89 -0
  113. package/src/combos/index.ts +4 -0
  114. package/src/combos/resolve.ts +80 -9
  115. package/src/combos/types.ts +20 -93
  116. package/src/config/subagent-models.ts +24 -0
  117. package/src/config.ts +156 -13
  118. package/src/generated/compatibility-version.json +454 -166
  119. package/src/generated/model-metadata.ts +1 -1
  120. package/src/integrations/journal.ts +65 -4
  121. package/src/integrations/store.ts +5 -0
  122. package/src/lab/events/limits.ts +4 -0
  123. package/src/lib/destination-policy.ts +31 -2
  124. package/src/lib/errors.ts +39 -0
  125. package/src/lib/provider-outbound.ts +69 -3
  126. package/src/lib/proxy-env.ts +22 -0
  127. package/src/lib/redact-folding.ts +176 -0
  128. package/src/lib/redact.ts +2 -175
  129. package/src/lib/state-store-sweeper.ts +20 -6
  130. package/src/lib/token-estimate.ts +94 -27
  131. package/src/lib/windows-user-principal.ts +53 -5
  132. package/src/oauth/anthropic-routing.ts +99 -3
  133. package/src/oauth/generic-account-failover.ts +36 -13
  134. package/src/oauth/index.ts +127 -26
  135. package/src/oauth/login-cli.ts +5 -0
  136. package/src/oauth/meta-muse.ts +117 -15
  137. package/src/oauth/pool-settings-capability.ts +15 -4
  138. package/src/providers/api-keys.ts +8 -10
  139. package/src/providers/default-aliases.ts +39 -0
  140. package/src/providers/derive.ts +10 -2
  141. package/src/providers/fastwire.ts +36 -7
  142. package/src/providers/initial-model-selection-runtime.ts +90 -0
  143. package/src/providers/initial-model-selection.ts +120 -0
  144. package/src/providers/key-failover.ts +134 -54
  145. package/src/providers/key-store.ts +11 -1
  146. package/src/providers/label.ts +1 -1
  147. package/src/providers/model-discovery.ts +76 -0
  148. package/src/providers/model-rename-startup.ts +72 -8
  149. package/src/providers/openai-sidecar.ts +17 -5
  150. package/src/providers/openai-tiers-destination.ts +102 -0
  151. package/src/providers/openai-tiers.ts +2 -99
  152. package/src/providers/opencode-go-transport.ts +41 -0
  153. package/src/providers/quota-key-accounts.ts +141 -0
  154. package/src/providers/quota-types.ts +9 -0
  155. package/src/providers/quota.ts +535 -104
  156. package/src/providers/registry.ts +60 -17
  157. package/src/providers/xai-responses-opt-in.ts +31 -5
  158. package/src/quota/reset-activation.ts +81 -0
  159. package/src/quota/reset-detector.ts +305 -0
  160. package/src/quota/reset-notify-config.ts +162 -0
  161. package/src/quota/reset-observer.ts +125 -0
  162. package/src/quota/reset-poller.ts +160 -0
  163. package/src/quota/reset-seen-store.ts +385 -0
  164. package/src/quota/reset-sinks.ts +199 -0
  165. package/src/quota/window-mapping.ts +106 -0
  166. package/src/responses/apply-patch-envelope.ts +46 -0
  167. package/src/responses/code-mode-helper-compat.ts +39 -1
  168. package/src/responses/custom-tool-compat.ts +10 -4
  169. package/src/responses/hosted-tool-policy.ts +12 -4
  170. package/src/responses/parser-content.ts +133 -0
  171. package/src/responses/parser-text-format.ts +24 -0
  172. package/src/responses/parser-tools.ts +188 -0
  173. package/src/responses/parser.ts +3 -326
  174. package/src/responses/state.ts +124 -28
  175. package/src/router.ts +48 -13
  176. package/src/routing/analytics.ts +1 -0
  177. package/src/routing/capability.ts +17 -4
  178. package/src/server/auth-cors.ts +7 -1
  179. package/src/server/background-lifecycle.ts +23 -1
  180. package/src/server/chat-completions.ts +25 -3
  181. package/src/server/claude-messages.ts +62 -5
  182. package/src/server/effort-row.ts +1 -1
  183. package/src/server/fast-row.ts +295 -0
  184. package/src/server/gui-static.ts +30 -4
  185. package/src/server/index.ts +107 -21
  186. package/src/server/management/agent-settings-routes.ts +2 -2
  187. package/src/server/management/combo-routes.ts +37 -9
  188. package/src/server/management/config-routes.ts +93 -2
  189. package/src/server/management/integration-routes.ts +108 -0
  190. package/src/server/management/model-routes.ts +13 -3
  191. package/src/server/management/model-rows.ts +20 -1
  192. package/src/server/management/native-integration-routes.ts +4 -1
  193. package/src/server/management/oauth-account-routes.ts +38 -10
  194. package/src/server/management/provider-routes.ts +34 -3
  195. package/src/server/management/quota-reset-routes.ts +57 -0
  196. package/src/server/management/route-registry.ts +7 -4
  197. package/src/server/management/shared.ts +19 -5
  198. package/src/server/management/system-routes.ts +3 -2
  199. package/src/server/management-api.ts +14 -2
  200. package/src/server/ports.ts +12 -2
  201. package/src/server/relay-eager.ts +38 -23
  202. package/src/server/relay.ts +4 -0
  203. package/src/server/request-log.ts +6 -0
  204. package/src/server/responses/codex-ws-correlation.ts +65 -0
  205. package/src/server/responses/codex-ws-exchange.ts +261 -0
  206. package/src/server/responses/codex-ws-metadata.ts +134 -0
  207. package/src/server/responses/codex-ws-pool.ts +162 -0
  208. package/src/server/responses/codex-ws-request.ts +87 -0
  209. package/src/server/responses/codex-ws-session.ts +93 -0
  210. package/src/server/responses/codex-ws-wire.ts +144 -0
  211. package/src/server/responses/collaboration.ts +41 -1
  212. package/src/server/responses/compact.ts +105 -12
  213. package/src/server/responses/core.ts +476 -57
  214. package/src/server/responses/fetch-helpers.ts +10 -3
  215. package/src/server/responses/input-admission.ts +16 -9
  216. package/src/server/responses/responses-field-backfill.ts +1 -1
  217. package/src/server/responses/ws-upstream.ts +34 -318
  218. package/src/server/responses-custom-tool-repair.ts +20 -4
  219. package/src/server/responses-undeclared-tool-guard.ts +100 -8
  220. package/src/server/safe-response-headers.ts +23 -0
  221. package/src/server/search.ts +9 -0
  222. package/src/server/subagent-models-startup.ts +27 -0
  223. package/src/server/system-env-shell.ts +238 -0
  224. package/src/server/system-env.ts +7 -234
  225. package/src/server/ws-bridge.ts +3 -25
  226. package/src/server/xai-responses-startup.ts +21 -0
  227. package/src/service-manager-probe.ts +1 -1
  228. package/src/service.ts +55 -16
  229. package/src/types/config.ts +108 -12
  230. package/src/types/provider.ts +36 -7
  231. package/src/types/tools.ts +26 -1
  232. package/src/types.ts +2 -0
  233. package/src/update/notify.ts +8 -2
  234. package/src/usage/cost.ts +38 -28
  235. package/src/usage/expected-prices.ts +34 -15
  236. package/src/usage/log.ts +2 -0
  237. package/src/usage/model-identity.ts +26 -0
  238. package/src/usage/summary.ts +15 -1
  239. package/src/vision/describe.ts +6 -0
  240. package/src/vision/image-rewrite.ts +108 -0
  241. package/src/vision/index.ts +19 -306
  242. package/src/vision/plan.ts +205 -0
  243. package/src/web-search/executor.ts +6 -0
  244. package/src/web-search/index.ts +8 -1
  245. package/gui/dist/assets/index-BU1tE0sr.js +0 -112
  246. package/gui/dist/assets/index-DL9-iS6J.css +0 -1
@@ -1,10 +1,47 @@
1
- import { saveConfig } from "../config";
1
+ import { mutatePersistedConfig } from "../config";
2
2
  import { projectModelRenames } from "./model-rename-migration";
3
3
  import type { OcxConfig } from "../types";
4
4
 
5
5
  export interface ModelRenameStartupDeps {
6
6
  project: typeof projectModelRenames;
7
- save: (config: OcxConfig) => void;
7
+ /** Injected writer for tests and callers that own their own persistence. */
8
+ save?: (config: OcxConfig) => void;
9
+ }
10
+
11
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
12
+ return typeof value === "object" && value !== null && !Array.isArray(value);
13
+ }
14
+
15
+ /** Recursive key-by-key adopt: descend into a changed container, replace only changed leaves. */
16
+ function adoptRecord(live: Record<string, unknown>, next: Record<string, unknown>): void {
17
+ for (const key of Object.keys(live)) {
18
+ if (!(key in next)) delete live[key];
19
+ }
20
+ for (const [key, value] of Object.entries(next)) {
21
+ const current = live[key];
22
+ if (JSON.stringify(current) === JSON.stringify(value)) continue;
23
+ if (isPlainObject(current) && isPlainObject(value)) {
24
+ adoptRecord(current, value);
25
+ continue;
26
+ }
27
+ live[key] = value;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Copy `source` onto `target` in place, touching only the keys that actually differ.
33
+ *
34
+ * A clear-and-reassign preserves the top-level object identity while silently detaching every
35
+ * nested sub-object a caller still holds a live reference to. The renames rewrite one provider
36
+ * row at a time, so every sibling row — and the `providers` container itself — must survive
37
+ * with its identity intact.
38
+ */
39
+ function adoptConfig(target: OcxConfig, source: OcxConfig): void {
40
+ if (target === source) return;
41
+ adoptRecord(
42
+ target as unknown as Record<string, unknown>,
43
+ structuredClone(source) as unknown as Record<string, unknown>,
44
+ );
8
45
  }
9
46
 
10
47
  /**
@@ -15,14 +52,41 @@ export interface ModelRenameStartupDeps {
15
52
  * recoverable from the config alone. This one only rewrites model ids that the
16
53
  * registry itself no longer seeds, and the pre-migration value is a string this
17
54
  * file still names, so the change is reversible by hand.
55
+ *
56
+ * Persistence failure is not fatal. This runs inside `startServer`, so a missing, malformed or
57
+ * contended config degrades to a warning plus the in-memory projection rather than taking the
58
+ * boot path down.
18
59
  */
19
60
  export function runModelRenameStartupMigration(
20
61
  config: OcxConfig,
21
- deps: ModelRenameStartupDeps = { project: projectModelRenames, save: saveConfig },
62
+ deps: ModelRenameStartupDeps = { project: projectModelRenames },
22
63
  ): OcxConfig {
23
- const projection = deps.project(config);
24
- for (const warning of projection.warnings) console.warn(`[model-rename-migration] ${warning}`);
25
- if (!projection.changed) return projection.config;
26
- deps.save(projection.config);
27
- return projection.config;
64
+ const projection = deps.project(structuredClone(config));
65
+ if (!projection.changed) {
66
+ for (const warning of projection.warnings) console.warn(`[model-rename-migration] ${warning}`);
67
+ return config;
68
+ }
69
+ if (deps.save) {
70
+ deps.save(projection.config);
71
+ adoptConfig(config, projection.config);
72
+ for (const warning of projection.warnings) console.warn(`[model-rename-migration] ${warning}`);
73
+ return config;
74
+ }
75
+ const outcome = mutatePersistedConfig(fresh => {
76
+ const next = deps.project(fresh);
77
+ if (next.changed) adoptConfig(fresh, next.config);
78
+ return { changed: next.changed, value: next };
79
+ });
80
+ if (outcome.status === "unavailable") {
81
+ console.warn(
82
+ `[model-rename-migration] persistence unavailable (${outcome.reason}); applying the renames `
83
+ + "in memory for this run only.",
84
+ );
85
+ adoptConfig(config, projection.config);
86
+ for (const warning of projection.warnings) console.warn(`[model-rename-migration] ${warning}`);
87
+ return config;
88
+ }
89
+ adoptConfig(config, outcome.value.config);
90
+ for (const warning of outcome.value.warnings) console.warn(`[model-rename-migration] ${warning}`);
91
+ return config;
28
92
  }
@@ -7,10 +7,11 @@ import {
7
7
  resolveCodexAuthContext,
8
8
  type CodexAccountSelectionAdmission,
9
9
  type CodexAuthContext,
10
+ type CodexAuthPolicyConfig,
10
11
  } from "../codex/auth-context";
11
12
  import { recordCodexUpstreamOutcome, type CodexUpstreamOutcome } from "../codex/routing";
12
13
  import { extractAccountId } from "../oauth/chatgpt";
13
- import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../server/auth-cors";
14
+ import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential, type DataPlaneAdmission } from "../server/auth-cors";
14
15
  import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types";
15
16
  import {
16
17
  CODEX_FORWARD_BASE_URL,
@@ -78,6 +79,8 @@ export function listOpenAiForwardSidecarCandidates(config: OcxConfig): OpenAiFor
78
79
 
79
80
  function directSidecarHeaders(
80
81
  incomingHeaders: Headers,
82
+ config: CodexAuthPolicyConfig,
83
+ admission?: Pick<DataPlaneAdmission, "source">,
81
84
  ): Headers | undefined {
82
85
  const bearer = incomingHeaders.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
83
86
  if (!bearer) return undefined;
@@ -89,7 +92,7 @@ function directSidecarHeaders(
89
92
  // intentional ChatGPT-auth operation instead of silently reclassifying any JWT-shaped
90
93
  // provider credential as a Codex bearer.
91
94
  if (!requestedAccountId || requestedAccountId !== derivedAccountId) return undefined;
92
- const selected = headersForCodexAuthContext(incomingHeaders, { kind: "main", accountId: null });
95
+ const selected = headersForCodexAuthContext(incomingHeaders, { kind: "main", accountId: null }, config, undefined, admission);
93
96
  return selected;
94
97
  }
95
98
 
@@ -99,10 +102,13 @@ export async function resolveFirstUsableOpenAiSidecar(
99
102
  config: OcxConfig,
100
103
  options: {
101
104
  exactAccount?: ExactOpenAiSidecarAccount;
105
+ admission?: Pick<DataPlaneAdmission, "source">;
106
+ codexAuthPolicy?: CodexAuthPolicyConfig;
102
107
  beginCodexAccountSelection?: () => CodexAccountSelectionAdmission | undefined;
103
108
  } = {},
104
109
  ): Promise<ResolvedOpenAiForwardSidecar | undefined> {
105
110
  const { exactAccount } = options;
111
+ const policy = options.codexAuthPolicy ?? config;
106
112
  let callerBearerMayBeForwarded = true;
107
113
  try {
108
114
  validateForwardAdmissionCredential(incomingHeaders, config);
@@ -116,10 +122,13 @@ export async function resolveFirstUsableOpenAiSidecar(
116
122
  // credential directly even when the provider is globally Direct, and never
117
123
  // consult Pool active state, affinity, probes, or alternates.
118
124
  const authContext = await resolveCodexAuthContext(incomingHeaders, config, "pool", {
125
+ codexAuthPolicy: policy,
119
126
  accountId: exactAccount.accountId,
120
127
  modelId: exactAccount.modelId,
128
+ admission: options.admission,
121
129
  beginCodexAccountSelection: options.beginCodexAccountSelection,
122
130
  });
131
+ const selectedHeaders = headersForCodexAuthContext(incomingHeaders, authContext, policy, exactAccount.modelId, options.admission);
123
132
  if ((authContext.kind !== "pool" && authContext.kind !== "main-pool")
124
133
  || !isCodexAuthContextUsable(authContext, config)) {
125
134
  // Exact selection is fail-closed. A generation/runtime-state race must not fall through
@@ -129,7 +138,7 @@ export async function resolveFirstUsableOpenAiSidecar(
129
138
  return {
130
139
  ...candidate,
131
140
  authContext,
132
- headers: headersForCodexAuthContext(incomingHeaders, authContext),
141
+ headers: selectedHeaders,
133
142
  recordOutcome: (outcome: CodexUpstreamOutcome) => recordCodexUpstreamOutcome(
134
143
  config,
135
144
  authContext.accountId,
@@ -149,7 +158,7 @@ export async function resolveFirstUsableOpenAiSidecar(
149
158
  }
150
159
  if (candidate.accountMode === "direct") {
151
160
  if (!callerBearerMayBeForwarded || !hasCallerCodexBearer(incomingHeaders)) continue;
152
- const headers = directSidecarHeaders(incomingHeaders);
161
+ const headers = directSidecarHeaders(incomingHeaders, policy, options.admission);
153
162
  if (!headers) continue;
154
163
  return {
155
164
  ...candidate,
@@ -158,13 +167,16 @@ export async function resolveFirstUsableOpenAiSidecar(
158
167
  };
159
168
  }
160
169
  const authContext = await resolveCodexAuthContext(incomingHeaders, config, candidate.accountMode, {
170
+ codexAuthPolicy: policy,
171
+ admission: options.admission,
161
172
  beginCodexAccountSelection: options.beginCodexAccountSelection,
162
173
  });
174
+ const selectedHeaders = headersForCodexAuthContext(incomingHeaders, authContext, policy, undefined, options.admission);
163
175
  if (!isCodexAuthContextUsable(authContext, config)) continue;
164
176
  return {
165
177
  ...candidate,
166
178
  authContext,
167
- headers: headersForCodexAuthContext(incomingHeaders, authContext),
179
+ headers: selectedHeaders,
168
180
  ...(authContext.kind === "pool" || authContext.kind === "main-pool"
169
181
  ? {
170
182
  recordOutcome: (outcome: CodexUpstreamOutcome) => recordCodexUpstreamOutcome(
@@ -0,0 +1,102 @@
1
+ import type { OcxProviderConfig } from "../types";
2
+ import { openaiResponsesUrl } from "../adapters/openai-responses-url";
3
+
4
+ export const OPENAI_CODEX_PROVIDER_ID = "openai";
5
+ export const LEGACY_OPENAI_MULTI_PROVIDER_ID = "openai-multi";
6
+ export const OPENAI_API_PROVIDER_ID = "openai-apikey";
7
+ export const LEGACY_CHATGPT_PROVIDER_ID = "chatgpt";
8
+
9
+ export const CODEX_FORWARD_BASE_URL = "https://chatgpt.com/backend-api/codex";
10
+
11
+ function normalizedBaseUrl(value: string): string | undefined {
12
+ try {
13
+ const url = new URL(value.trim());
14
+ if (url.username || url.password || url.search || url.hash) return undefined;
15
+ const path = url.pathname.replace(/\/+$/, "");
16
+ return `${url.origin}${path}`;
17
+ } catch {
18
+ return undefined;
19
+ }
20
+ }
21
+
22
+ export function isCanonicalOpenAiForwardProvider(provider: OcxProviderConfig): boolean {
23
+ return provider.adapter === "openai-responses"
24
+ && provider.authMode === "forward"
25
+ && normalizedBaseUrl(provider.baseUrl) === CODEX_FORWARD_BASE_URL;
26
+ }
27
+
28
+ const OPENAI_API_ORIGIN = "https://api.openai.com";
29
+ const OPENAI_API_BASE_URL = `${OPENAI_API_ORIGIN}/v1`;
30
+ const OPENAI_API_RESPONSES_URL = `${OPENAI_API_BASE_URL}/responses`;
31
+
32
+ /**
33
+ * The Responses endpoint the adapter would actually POST key-auth traffic to, normalized.
34
+ *
35
+ * Mirrors the adapter's own construction (`src/adapters/openai-responses.ts`): a configured
36
+ * `responsesPath` is appended to the base verbatim, and only the default branch runs the
37
+ * `/v1/responses` suffix normalization. Classifying on the base URL alone would call
38
+ * `baseUrl: "https://api.openai.com"` with `responsesPath: "/other"` official even though that
39
+ * request never reaches the official Responses endpoint.
40
+ */
41
+ function resolvedResponsesEndpoint(provider: OcxProviderConfig): string | undefined {
42
+ try {
43
+ const raw = provider.responsesPath === undefined
44
+ ? openaiResponsesUrl(provider.baseUrl)
45
+ : `${provider.baseUrl.replace(/\/$/, "")}${provider.responsesPath}`;
46
+ return normalizedBaseUrl(raw);
47
+ } catch {
48
+ return undefined;
49
+ }
50
+ }
51
+
52
+ function isOfficialOpenAiResponsesDestination(provider: OcxProviderConfig): boolean {
53
+ // Exact normalized URL keeps lookalike/suffix hosts out of this set: `api.openai.com.evil.test`
54
+ // resolves to its own origin, never to the official one.
55
+ return resolvedResponsesEndpoint(provider) === OPENAI_API_RESPONSES_URL;
56
+ }
57
+
58
+ /**
59
+ * Whether this provider can serve `POST /responses/compact`. The canonical ChatGPT
60
+ * backend can, and so can the official OpenAI API — but an arbitrary gateway that
61
+ * merely speaks the Responses wire cannot, and calling it there fails compaction
62
+ * with an unhelpful error instead of falling back to a routed summary (#422).
63
+ */
64
+ export function supportsNativeResponsesCompactEndpoint(
65
+ providerName: string,
66
+ provider: OcxProviderConfig,
67
+ ): boolean {
68
+ if (isCanonicalOpenAiForwardProvider(provider)) return true;
69
+ return providerName === OPENAI_API_PROVIDER_ID
70
+ && provider.adapter === "openai-responses"
71
+ && normalizedBaseUrl(provider.baseUrl) === OPENAI_API_BASE_URL;
72
+ }
73
+
74
+ /**
75
+ * Whether this destination is an OpenAI-operated Responses backend — the canonical ChatGPT Codex
76
+ * surface or the official OpenAI API.
77
+ *
78
+ * Deliberately not keyed on `authMode === "forward"`: a noncanonical forward provider does not
79
+ * receive the caller's credentials (see the forward-header gate in the Responses adapter), so
80
+ * forward auth says nothing about which backend is on the other end.
81
+ */
82
+ export function isOpenAiOperatedResponsesDestination(provider: OcxProviderConfig): boolean {
83
+ if (isCanonicalOpenAiForwardProvider(provider)) return true;
84
+ return provider.adapter === "openai-responses"
85
+ && isOfficialOpenAiResponsesDestination(provider);
86
+ }
87
+
88
+ /**
89
+ * Whether this destination can decode a native (non-`ocx1:`) compaction blob.
90
+ *
91
+ * Only the backend that minted a blob can decode it. `authMode: "forward"` alone is not a signal:
92
+ * the adapter forwards caller credentials only to the canonical ChatGPT Codex surface, while a
93
+ * noncanonical forward provider receives no caller credentials and may point at any backend.
94
+ *
95
+ * Relay only to an OpenAI-operated destination or a destination whose operator explicitly opts in.
96
+ * Keyed by destination rather than provider id: a blob's issuer is the URL that produced it, not the
97
+ * local config key a replay travels under.
98
+ */
99
+ export function destinationDecodesNativeCompactionBlob(provider: OcxProviderConfig): boolean {
100
+ return isOpenAiOperatedResponsesDestination(provider)
101
+ || provider.decodesNativeCompactionBlobs === true;
102
+ }
@@ -2,13 +2,9 @@ import type { CodexAccountMode, OcxConfig, OcxProviderConfig, ProviderCostOverla
2
2
  import { OPENAI_PROVIDER_TIER_VERSION } from "../types";
3
3
  import { openaiResponsesUrl } from "../adapters/openai-responses-url";
4
4
  import { MAX_COST4_RATE } from "../usage/expected-prices";
5
+ import { OPENAI_CODEX_PROVIDER_ID, LEGACY_OPENAI_MULTI_PROVIDER_ID, LEGACY_CHATGPT_PROVIDER_ID, CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "./openai-tiers-destination";
6
+ export { OPENAI_CODEX_PROVIDER_ID, LEGACY_OPENAI_MULTI_PROVIDER_ID, OPENAI_API_PROVIDER_ID, LEGACY_CHATGPT_PROVIDER_ID, CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint, isOpenAiOperatedResponsesDestination, destinationDecodesNativeCompactionBlob } from "./openai-tiers-destination";
5
7
 
6
- export const OPENAI_CODEX_PROVIDER_ID = "openai";
7
- export const LEGACY_OPENAI_MULTI_PROVIDER_ID = "openai-multi";
8
- export const OPENAI_API_PROVIDER_ID = "openai-apikey";
9
- export const LEGACY_CHATGPT_PROVIDER_ID = "chatgpt";
10
-
11
- export const CODEX_FORWARD_BASE_URL = "https://chatgpt.com/backend-api/codex";
12
8
  const LEGACY_OPENAI_MULTI_PREFIX = `${LEGACY_OPENAI_MULTI_PROVIDER_ID}/`;
13
9
 
14
10
  function canonicalCodexForwardProvider(mode: CodexAccountMode): OcxProviderConfig {
@@ -20,99 +16,6 @@ function canonicalCodexForwardProvider(mode: CodexAccountMode): OcxProviderConfi
20
16
  };
21
17
  }
22
18
 
23
- function normalizedBaseUrl(value: string): string | undefined {
24
- try {
25
- const url = new URL(value.trim());
26
- if (url.username || url.password || url.search || url.hash) return undefined;
27
- const path = url.pathname.replace(/\/+$/, "");
28
- return `${url.origin}${path}`;
29
- } catch {
30
- return undefined;
31
- }
32
- }
33
-
34
- export function isCanonicalOpenAiForwardProvider(provider: OcxProviderConfig): boolean {
35
- return provider.adapter === "openai-responses"
36
- && provider.authMode === "forward"
37
- && normalizedBaseUrl(provider.baseUrl) === CODEX_FORWARD_BASE_URL;
38
- }
39
-
40
- const OPENAI_API_ORIGIN = "https://api.openai.com";
41
- const OPENAI_API_BASE_URL = `${OPENAI_API_ORIGIN}/v1`;
42
- const OPENAI_API_RESPONSES_URL = `${OPENAI_API_BASE_URL}/responses`;
43
-
44
- /**
45
- * The Responses endpoint the adapter would actually POST key-auth traffic to, normalized.
46
- *
47
- * Mirrors the adapter's own construction (`src/adapters/openai-responses.ts`): a configured
48
- * `responsesPath` is appended to the base verbatim, and only the default branch runs the
49
- * `/v1/responses` suffix normalization. Classifying on the base URL alone would call
50
- * `baseUrl: "https://api.openai.com"` with `responsesPath: "/other"` official even though that
51
- * request never reaches the official Responses endpoint.
52
- */
53
- function resolvedResponsesEndpoint(provider: OcxProviderConfig): string | undefined {
54
- try {
55
- const raw = provider.responsesPath === undefined
56
- ? openaiResponsesUrl(provider.baseUrl)
57
- : `${provider.baseUrl.replace(/\/$/, "")}${provider.responsesPath}`;
58
- return normalizedBaseUrl(raw);
59
- } catch {
60
- return undefined;
61
- }
62
- }
63
-
64
- function isOfficialOpenAiResponsesDestination(provider: OcxProviderConfig): boolean {
65
- // Exact normalized URL keeps lookalike/suffix hosts out of this set: `api.openai.com.evil.test`
66
- // resolves to its own origin, never to the official one.
67
- return resolvedResponsesEndpoint(provider) === OPENAI_API_RESPONSES_URL;
68
- }
69
-
70
- /**
71
- * Whether this provider can serve `POST /responses/compact`. The canonical ChatGPT
72
- * backend can, and so can the official OpenAI API — but an arbitrary gateway that
73
- * merely speaks the Responses wire cannot, and calling it there fails compaction
74
- * with an unhelpful error instead of falling back to a routed summary (#422).
75
- */
76
- export function supportsNativeResponsesCompactEndpoint(
77
- providerName: string,
78
- provider: OcxProviderConfig,
79
- ): boolean {
80
- if (isCanonicalOpenAiForwardProvider(provider)) return true;
81
- return providerName === OPENAI_API_PROVIDER_ID
82
- && provider.adapter === "openai-responses"
83
- && normalizedBaseUrl(provider.baseUrl) === OPENAI_API_BASE_URL;
84
- }
85
-
86
- /**
87
- * Whether this destination is an OpenAI-operated Responses backend — the canonical ChatGPT Codex
88
- * surface or the official OpenAI API.
89
- *
90
- * Deliberately not keyed on `authMode === "forward"`: a noncanonical forward provider does not
91
- * receive the caller's credentials (see the forward-header gate in the Responses adapter), so
92
- * forward auth says nothing about which backend is on the other end.
93
- */
94
- export function isOpenAiOperatedResponsesDestination(provider: OcxProviderConfig): boolean {
95
- if (isCanonicalOpenAiForwardProvider(provider)) return true;
96
- return provider.adapter === "openai-responses"
97
- && isOfficialOpenAiResponsesDestination(provider);
98
- }
99
-
100
- /**
101
- * Whether this destination can decode a native (non-`ocx1:`) compaction blob.
102
- *
103
- * Only the backend that minted a blob can decode it. `authMode: "forward"` alone is not a signal:
104
- * the adapter forwards caller credentials only to the canonical ChatGPT Codex surface, while a
105
- * noncanonical forward provider receives no caller credentials and may point at any backend.
106
- *
107
- * Relay only to an OpenAI-operated destination or a destination whose operator explicitly opts in.
108
- * Keyed by destination rather than provider id: a blob's issuer is the URL that produced it, not the
109
- * local config key a replay travels under.
110
- */
111
- export function destinationDecodesNativeCompactionBlob(provider: OcxProviderConfig): boolean {
112
- return isOpenAiOperatedResponsesDestination(provider)
113
- || provider.decodesNativeCompactionBlobs === true;
114
- }
115
-
116
19
  export interface OpenAiTierMigrationProjection {
117
20
  config: OcxConfig;
118
21
  changed: boolean;
@@ -0,0 +1,41 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { OcxProviderConfig } from "../types";
3
+ import { registryEntryForProviderDestination } from "./registry";
4
+
5
+ export const OPENCODE_GO_SESSION_HEADER = "x-opencode-session";
6
+
7
+ function hasHeaderCaseInsensitive(
8
+ headers: Record<string, string> | undefined,
9
+ name: string,
10
+ ): boolean {
11
+ const target = name.toLowerCase();
12
+ return Object.keys(headers ?? {}).some(key => key.toLowerCase() === target);
13
+ }
14
+
15
+ /** Derive a provider-scoped opaque value without exposing Codex task or subagent ids. */
16
+ export function deriveOpenCodeGoSessionId(sessionLane: string): string {
17
+ const digest = createHash("sha256")
18
+ .update("opencodex/opencode-go/session/v1\0")
19
+ .update(sessionLane)
20
+ .digest("hex")
21
+ .slice(0, 32);
22
+ return `ocx_${digest}`;
23
+ }
24
+
25
+ /** Add per-conversation Go affinity only to the canonical fixed-key destination. */
26
+ export function resolveOpenCodeGoTransport<T extends OcxProviderConfig>(
27
+ provider: T,
28
+ sessionLane: string | undefined,
29
+ ): T {
30
+ if (registryEntryForProviderDestination(provider)?.id !== "opencode-go") return provider;
31
+ if (!sessionLane) return provider;
32
+ if (hasHeaderCaseInsensitive(provider.headers, OPENCODE_GO_SESSION_HEADER)) return provider;
33
+
34
+ return {
35
+ ...provider,
36
+ headers: {
37
+ ...(provider.headers ?? {}),
38
+ [OPENCODE_GO_SESSION_HEADER]: deriveOpenCodeGoSessionId(sessionLane),
39
+ },
40
+ };
41
+ }
@@ -0,0 +1,141 @@
1
+ /** Isolated, process-local API-key quota rows. Never publishes provider/routing caches. */
2
+ import { createHash } from "node:crypto";
3
+ import type { OcxConfig, OcxProviderConfig } from "../types";
4
+ import { apiKeyPoolEntryId } from "./api-keys";
5
+ import { resolveProviderApiKey } from "./key-store";
6
+ import type { ProviderQuota } from "./quota-types";
7
+ import { ACCOUNT_QUOTA_TTL_MS } from "./quota-wire";
8
+
9
+ const MAX_ENTRIES = 256;
10
+ const LAST_GOOD_MS = 30 * 60_000;
11
+ type Entry = { ts: number; quota: ProviderQuota | null; unavailable?: true };
12
+ export type KeyQuotaProbeOutcome =
13
+ | { kind: "quota"; quota: ProviderQuota }
14
+ | { kind: "empty" | "terminal" | "unavailable" };
15
+ export interface ProviderApiKeyQuota {
16
+ keyId: string;
17
+ quota: ProviderQuota | null;
18
+ unavailable?: true;
19
+ /** Server-only, non-enumerable; call immediately before the safe DTO join. */
20
+ isCurrent: () => boolean;
21
+ }
22
+ const cache = new Map<string, Entry>();
23
+ const flights = new Map<string, Promise<Entry>>();
24
+ let epoch = 0;
25
+
26
+ export function clearProviderApiKeyQuotaCache(): void {
27
+ epoch += 1;
28
+ cache.clear();
29
+ flights.clear();
30
+ }
31
+
32
+ /** Four workers per roster, not a process-wide network limit. */
33
+ export async function mapQuotaRoster<T, R>(rows: readonly T[], read: (row: T) => Promise<R>): Promise<R[]> {
34
+ const out = new Array<R>(rows.length);
35
+ let cursor = 0;
36
+ await Promise.all(Array.from({ length: Math.min(4, rows.length) }, async () => {
37
+ while (cursor < rows.length) {
38
+ const index = cursor++;
39
+ out[index] = await read(rows[index]!);
40
+ }
41
+ }));
42
+ return out;
43
+ }
44
+
45
+ function roster(provider: OcxProviderConfig) {
46
+ return provider.apiKeyPool?.length ? provider.apiKeyPool
47
+ : provider.apiKey ? [{ id: apiKeyPoolEntryId(provider.apiKey), key: provider.apiKey }] : [];
48
+ }
49
+
50
+ function identity(name: string, provider: OcxProviderConfig, id: string, key: string): string {
51
+ return createHash("sha256").update(JSON.stringify([
52
+ "quota-key", name, provider.adapter, provider.baseUrl, provider.authMode ?? "key",
53
+ provider.disabled === true, id, key,
54
+ ])).digest("hex");
55
+ }
56
+
57
+ function remember(key: string, entry: Entry): void {
58
+ cache.delete(key);
59
+ for (const [id, row] of cache) if (Date.now() - row.ts >= LAST_GOOD_MS) cache.delete(id);
60
+ while (cache.size >= MAX_ENTRIES) cache.delete(cache.keys().next().value!);
61
+ cache.set(key, entry);
62
+ }
63
+
64
+ async function readEntry(
65
+ key: string,
66
+ force: boolean,
67
+ isCurrent: () => boolean,
68
+ probe: () => Promise<KeyQuotaProbeOutcome>,
69
+ ): Promise<Entry> {
70
+ const previous = cache.get(key);
71
+ if (!force && previous && Date.now() - previous.ts < ACCOUNT_QUOTA_TTL_MS
72
+ && (!previous.quota || Date.now() - previous.quota.updatedAt < LAST_GOOD_MS)) {
73
+ cache.delete(key);
74
+ cache.set(key, previous);
75
+ return previous;
76
+ }
77
+ const running = flights.get(key);
78
+ if (running) return running;
79
+ const lastGood = previous?.quota && Date.now() - previous.quota.updatedAt < LAST_GOOD_MS
80
+ ? previous.quota : null;
81
+ if (flights.size >= MAX_ENTRIES) return { ts: Date.now(), quota: lastGood, unavailable: true };
82
+ const flight = (async (): Promise<Entry> => {
83
+ let result: KeyQuotaProbeOutcome;
84
+ try { result = await probe(); } catch { result = { kind: "unavailable" }; }
85
+ const entry: Entry = result.kind === "quota"
86
+ ? { ts: Date.now(), quota: result.quota }
87
+ : result.kind === "empty"
88
+ ? { ts: Date.now(), quota: null }
89
+ : {
90
+ ts: Date.now(),
91
+ quota: result.kind !== "terminal" && lastGood && Date.now() - lastGood.updatedAt < LAST_GOOD_MS ? lastGood : null,
92
+ unavailable: true,
93
+ };
94
+ if (isCurrent()) remember(key, entry);
95
+ return entry;
96
+ })().finally(() => { if (flights.get(key) === flight) flights.delete(key); });
97
+ flights.set(key, flight);
98
+ return flight;
99
+ }
100
+
101
+ export async function readProviderApiKeyQuotas(
102
+ config: OcxConfig,
103
+ name: string,
104
+ force: boolean,
105
+ probe: (provider: OcxProviderConfig, config: OcxConfig) => Promise<KeyQuotaProbeOutcome>,
106
+ ): Promise<ProviderApiKeyQuota[]> {
107
+ const liveProvider = config.providers[name];
108
+ if (!liveProvider) return [];
109
+ const providerSnapshot = { ...liveProvider };
110
+ const rows = roster(liveProvider).map(row => ({ ...row }));
111
+ const generation = epoch;
112
+ return mapQuotaRoster(rows, async row => {
113
+ let resolved: string | undefined;
114
+ try { resolved = resolveProviderApiKey(row.key)?.trim(); } catch { /* unavailable */ }
115
+ const key = resolved ? identity(name, providerSnapshot, row.id, resolved) : null;
116
+ const isCurrent = () => {
117
+ if (epoch !== generation || !key) return false;
118
+ const current = config.providers[name];
119
+ if (!current) return false;
120
+ const entry = roster(current).find(candidate => candidate.id === row.id);
121
+ if (!entry) return false;
122
+ try {
123
+ const currentKey = resolveProviderApiKey(entry.key)?.trim();
124
+ return !!currentKey && identity(name, current, row.id, currentKey) === key;
125
+ } catch { return false; }
126
+ };
127
+ const entry = key && resolved && isCurrent()
128
+ ? await readEntry(key, force, isCurrent, () => {
129
+ const isolatedProvider = { ...providerSnapshot, apiKey: resolved, apiKeyPool: undefined };
130
+ const isolatedConfig = { ...config, providers: { [name]: isolatedProvider } };
131
+ return probe(isolatedProvider, isolatedConfig);
132
+ })
133
+ : { ts: Date.now(), quota: null, unavailable: true as const };
134
+ const result = {
135
+ keyId: row.id,
136
+ quota: isCurrent() ? entry.quota : null,
137
+ ...(!isCurrent() || entry.unavailable ? { unavailable: true as const } : {}),
138
+ };
139
+ return Object.defineProperty(result, "isCurrent", { value: isCurrent }) as ProviderApiKeyQuota;
140
+ });
141
+ }
@@ -34,3 +34,12 @@ export interface ProviderQuota {
34
34
  creditsUsd?: ProviderQuotaCreditsUsd;
35
35
  updatedAt: number;
36
36
  }
37
+
38
+ export type AccountQuotaMode = "probe" | "passive" | "unsupported";
39
+
40
+ /** Additive management-row fields; cheap lists emit only quotaMode. */
41
+ export interface AccountQuotaFields {
42
+ quotaMode?: AccountQuotaMode;
43
+ quota?: ProviderQuota | null;
44
+ quotaUnavailable?: boolean;
45
+ }