@bitkyc08/opencodex 2.11.1 → 2.12.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 (175) hide show
  1. package/README.md +1 -1
  2. package/bin/ocx.mjs +4 -0
  3. package/gui/dist/assets/index-BNVYzdn0.css +1 -0
  4. package/gui/dist/assets/index-Cw1_mxO8.js +70 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/command-code.ts +94 -5
  8. package/src/adapters/cursor/live-transport.ts +14 -1
  9. package/src/adapters/google-tool-schema.ts +80 -15
  10. package/src/adapters/google.ts +24 -7
  11. package/src/adapters/openai-chat.ts +233 -305
  12. package/src/adapters/openai-responses.ts +62 -0
  13. package/src/adapters/tool-catalog-nudge.ts +2 -11
  14. package/src/claude/outbound.ts +42 -14
  15. package/src/cli/claude.ts +61 -9
  16. package/src/cli/codex-shim-autorestore.ts +2 -0
  17. package/src/cli/codex-shim-readiness.ts +8 -1
  18. package/src/cli/config-command.ts +4 -1
  19. package/src/cli/doctor.ts +10 -0
  20. package/src/cli/help.ts +15 -0
  21. package/src/cli/index.ts +27 -11
  22. package/src/cli/lab.ts +367 -0
  23. package/src/cli/provider.ts +9 -1
  24. package/src/clients/config-export.ts +100 -3
  25. package/src/codex/app-server-processes.ts +11 -8
  26. package/src/codex/auth-api.ts +92 -28
  27. package/src/codex/auth-collision.ts +5 -3
  28. package/src/codex/catalog/metadata.ts +7 -3
  29. package/src/codex/catalog/provider-fetch.ts +191 -47
  30. package/src/codex/catalog.ts +2 -2
  31. package/src/codex/history-job.ts +83 -18
  32. package/src/codex/history-migration-guardian.ts +9 -16
  33. package/src/codex/history-provider.ts +178 -1
  34. package/src/codex/history-transition.ts +5 -5
  35. package/src/codex/history-worker.ts +22 -2
  36. package/src/codex/native-profile-store.ts +1 -1
  37. package/src/codex/plan.ts +15 -0
  38. package/src/codex/quota.ts +6 -6
  39. package/src/codex/routing.ts +3 -3
  40. package/src/codex/subagent-model-fallback.ts +246 -22
  41. package/src/codex/user-identity.ts +63 -12
  42. package/src/config.ts +265 -32
  43. package/src/images/loop.ts +3 -1
  44. package/src/images/plan.ts +23 -13
  45. package/src/integrations/omp-yaml-source.ts +225 -0
  46. package/src/integrations/ownership.ts +5 -5
  47. package/src/integrations/state.ts +45 -6
  48. package/src/integrations/writer.ts +38 -3
  49. package/src/lab/artifacts/sanitize.ts +586 -0
  50. package/src/lab/artifacts/secure-fs.ts +475 -0
  51. package/src/lab/artifacts/store.ts +310 -0
  52. package/src/lab/conformance/assertion.ts +325 -0
  53. package/src/lab/conformance/digest.ts +22 -0
  54. package/src/lab/conformance/executor.ts +741 -0
  55. package/src/lab/conformance/fixture-provider.ts +27 -0
  56. package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
  57. package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
  58. package/src/lab/conformance/harness-budget.ts +47 -0
  59. package/src/lab/conformance/index.ts +5 -0
  60. package/src/lab/conformance/jcs.ts +24 -0
  61. package/src/lab/conformance/json-pointer.ts +39 -0
  62. package/src/lab/conformance/manifest.ts +180 -0
  63. package/src/lab/conformance/mcp-stub.ts +179 -0
  64. package/src/lab/conformance/negative-controls.ts +160 -0
  65. package/src/lab/conformance/observation.ts +355 -0
  66. package/src/lab/conformance/runner.ts +57 -0
  67. package/src/lab/conformance/sse-normalize.ts +59 -0
  68. package/src/lab/conformance/suite-manifest.ts +78 -0
  69. package/src/lab/conformance/types.ts +191 -0
  70. package/src/lab/constants.ts +126 -0
  71. package/src/lab/digest.ts +64 -0
  72. package/src/lab/events/errors.ts +9 -0
  73. package/src/lab/events/limits.ts +117 -0
  74. package/src/lab/events/types.ts +228 -0
  75. package/src/lab/events/validate.ts +780 -0
  76. package/src/lab/index.ts +36 -0
  77. package/src/lab/ledger/artifact-refs.ts +127 -0
  78. package/src/lab/ledger/invalidation.ts +136 -0
  79. package/src/lab/ledger/purge.ts +241 -0
  80. package/src/lab/ledger/store.ts +263 -0
  81. package/src/lab/live/credential-lease.ts +53 -0
  82. package/src/lab/live/destination.ts +155 -0
  83. package/src/lab/live/executor.ts +311 -0
  84. package/src/lab/live/inert-tools.ts +56 -0
  85. package/src/lab/live/manifest.ts +85 -0
  86. package/src/lab/live/mcp-loopback.ts +57 -0
  87. package/src/lab/live/runner.ts +19 -0
  88. package/src/lab/live/sandbox.ts +61 -0
  89. package/src/lab/live/suite-manifest.ts +41 -0
  90. package/src/lab/live/transport.ts +116 -0
  91. package/src/lab/live/types.ts +197 -0
  92. package/src/lab/observe/from-conformance.ts +344 -0
  93. package/src/lab/observe/from-live.ts +103 -0
  94. package/src/lab/paths.ts +65 -0
  95. package/src/lab/projection/rebuild.ts +477 -0
  96. package/src/lab/projection/schema.ts +122 -0
  97. package/src/lab/projection/verdicts.ts +438 -0
  98. package/src/lab/projection/verification.ts +332 -0
  99. package/src/lab/query/catalog.ts +72 -0
  100. package/src/lab/query/connection.ts +107 -0
  101. package/src/lab/query/constants.ts +4 -0
  102. package/src/lab/query/cursor.ts +132 -0
  103. package/src/lab/query/dto-map.ts +277 -0
  104. package/src/lab/query/errors.ts +22 -0
  105. package/src/lab/query/index.ts +34 -0
  106. package/src/lab/query/queries.ts +444 -0
  107. package/src/lab/query/types.ts +266 -0
  108. package/src/lab/subject/behavior-fingerprint.ts +77 -0
  109. package/src/lab/subject/installation-salt.ts +81 -0
  110. package/src/lab/subject/route-subject.ts +73 -0
  111. package/src/lib/bounded-body.ts +104 -0
  112. package/src/lib/lab-live-execution-authority.ts +13 -0
  113. package/src/lib/lab-live-host.ts +30 -0
  114. package/src/lib/lab-live-pinned-sender.ts +45 -0
  115. package/src/lib/pinned-http.ts +36 -7
  116. package/src/oauth/index.ts +20 -1
  117. package/src/oauth/local-token-detect.ts +11 -2
  118. package/src/oauth/login-cli.ts +38 -4
  119. package/src/providers/alibaba-region-migration.ts +1 -1
  120. package/src/providers/codex-capacity.ts +7 -4
  121. package/src/providers/command-code-efforts.ts +5 -2
  122. package/src/providers/derive.ts +105 -0
  123. package/src/providers/label.ts +1 -1
  124. package/src/providers/openai-tiers.ts +59 -3
  125. package/src/providers/opencode-zen-rate-limit.ts +102 -0
  126. package/src/providers/quota.ts +67 -21
  127. package/src/providers/registry.ts +118 -5
  128. package/src/router.ts +15 -1
  129. package/src/routing/analytics.ts +8 -7
  130. package/src/routing/evaluator.ts +51 -5
  131. package/src/routing/profile.ts +10 -1
  132. package/src/routing/trace.ts +42 -6
  133. package/src/server/auth-cors.ts +15 -0
  134. package/src/server/background-lifecycle.ts +169 -0
  135. package/src/server/chat-completions.ts +18 -3
  136. package/src/server/claude-messages.ts +1 -0
  137. package/src/server/images.ts +78 -11
  138. package/src/server/index.ts +72 -18
  139. package/src/server/lifecycle.ts +6 -0
  140. package/src/server/live.ts +5 -1
  141. package/src/server/management/agent-settings-routes.ts +41 -24
  142. package/src/server/management/body.ts +6 -0
  143. package/src/server/management/config-routes.ts +51 -8
  144. package/src/server/management/lab-routes.ts +388 -0
  145. package/src/server/management/logs-usage-routes.ts +19 -1
  146. package/src/server/management/model-rows.ts +25 -11
  147. package/src/server/management/native-integration-routes.ts +3 -3
  148. package/src/server/management/provider-routes.ts +5 -0
  149. package/src/server/management/shared.ts +8 -1
  150. package/src/server/management/usage-summary-cache.ts +2 -0
  151. package/src/server/management/vision-sidecar-options.ts +125 -0
  152. package/src/server/management-api.ts +2 -0
  153. package/src/server/request-decompress.ts +106 -5
  154. package/src/server/responses/collaboration.ts +4 -1
  155. package/src/server/responses/core.ts +117 -31
  156. package/src/server/responses-json-events.ts +50 -12
  157. package/src/server/search.ts +23 -7
  158. package/src/server/ws-bridge.ts +3 -1
  159. package/src/service.ts +40 -20
  160. package/src/types.ts +74 -0
  161. package/src/update/job.ts +10 -2
  162. package/src/usage/cost.ts +0 -0
  163. package/src/usage/expected-prices.ts +9 -0
  164. package/src/usage/log.ts +9 -9
  165. package/src/usage/user-cost-overlay-reconciler.ts +313 -0
  166. package/src/usage/user-cost-overlays.ts +314 -0
  167. package/src/vision/describe.ts +2 -2
  168. package/src/vision/eligibility.ts +229 -0
  169. package/src/vision/index.ts +20 -2
  170. package/src/web-search/executor.ts +2 -2
  171. package/src/web-search/index.ts +4 -2
  172. package/src/web-search/loop.ts +3 -1
  173. package/src/web-search/parse.ts +72 -3
  174. package/gui/dist/assets/index-BA1dgo4q.css +0 -1
  175. package/gui/dist/assets/index-DDZpgzKk.js +0 -70
@@ -4,7 +4,7 @@ import { loadConfig, saveConfig } from "../config";
4
4
  import { findLiveProxy, probeHostname } from "../server/proxy-liveness";
5
5
  import { isPublicOAuthProvider, listOAuthProviders, runLogin } from "./index";
6
6
  import { KEY_LOGIN_PROVIDERS, isKeyLoginProvider, validateApiKey, type KeyLoginProvider } from "./key-providers";
7
- import type { OcxProviderConfig } from "../types";
7
+ import type { OcxConfig, OcxProviderConfig } from "../types";
8
8
  import { configuredAdminToken } from "../lib/admin-secrets";
9
9
  import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match";
10
10
 
@@ -102,10 +102,46 @@ export function providerConfigFromKeyLoginProvider(def: KeyLoginProvider, key: s
102
102
  ...(def.noPenaltyModels ? { noPenaltyModels: [...def.noPenaltyModels] } : {}),
103
103
  ...(def.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...def.autoToolChoiceOnlyModels] } : {}),
104
104
  ...(def.preserveReasoningContentModels ? { preserveReasoningContentModels: [...def.preserveReasoningContentModels] } : {}),
105
+ ...(def.requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels: [...def.requiresReasoningPlaceholderModels] } : {}),
105
106
  ...(def.escapeBuiltinToolNames !== undefined ? { escapeBuiltinToolNames: def.escapeBuiltinToolNames } : {}),
106
107
  };
107
108
  }
108
109
 
110
+ /**
111
+ * Merge a freshly built key-login provider row with a previously saved row,
112
+ * carrying operator-owned fields the key-provider preset cannot know about.
113
+ * Currently that is the user-configured modelCosts overlay: rotating the API
114
+ * key must not silently revert Logs/Usage estimates to catalog prices.
115
+ */
116
+ export function mergeKeyLoginProviderRow(
117
+ provider: OcxProviderConfig,
118
+ existing: OcxProviderConfig | undefined,
119
+ ): OcxProviderConfig {
120
+ return {
121
+ ...provider,
122
+ ...(existing?.modelCosts !== undefined ? { modelCosts: existing.modelCosts } : {}),
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Commit a fresh key-login provider row: merge operator-owned fields from the
128
+ * existing row (currently the modelCosts overlay), persist the merged row, and
129
+ * push the SAME merged row to a running proxy so its live config cannot diverge
130
+ * from disk (e.g. by replacing the overlay with catalog prices until reload).
131
+ * Returns the merged row that was persisted and notified.
132
+ */
133
+ export async function commitKeyLoginProvider(
134
+ config: OcxConfig,
135
+ name: string,
136
+ provider: OcxProviderConfig,
137
+ ): Promise<OcxProviderConfig> {
138
+ const mergedProvider = mergeKeyLoginProviderRow(provider, config.providers[name]);
139
+ config.providers[name] = mergedProvider;
140
+ saveConfig(config);
141
+ await notifyRunningProxy(name, mergedProvider);
142
+ return mergedProvider;
143
+ }
144
+
109
145
  async function handleKeyLogin(name: string): Promise<void> {
110
146
  const def = KEY_LOGIN_PROVIDERS[name];
111
147
  const preflightConfig = loadConfig();
@@ -148,9 +184,7 @@ async function handleKeyLogin(name: string): Promise<void> {
148
184
  console.error(`Error: ${commitCollision}.`);
149
185
  process.exit(1);
150
186
  }
151
- config.providers[name] = provider;
152
- saveConfig(config);
153
- await notifyRunningProxy(name, provider);
187
+ await commitKeyLoginProvider(config, name, provider);
154
188
  console.log(`✅ ${def.label} added. Try: ocx sync`);
155
189
  }
156
190
 
@@ -24,7 +24,7 @@ const INTL_ID = "alibaba-token-plan-intl";
24
24
  * the one matching the destination's registry contract.
25
25
  * `defaultModel` and `note` are user-editable too and are handled below.
26
26
  */
27
- const USER_OWNED_FIELDS = ["apiKey", "apiKeyPool", "disabled", "baseUrl", "allowPrivateNetwork", "liveModels"] as const;
27
+ const USER_OWNED_FIELDS = ["apiKey", "apiKeyPool", "disabled", "baseUrl", "allowPrivateNetwork", "liveModels", "modelCosts"] as const;
28
28
 
29
29
  export interface AlibabaRegionMigrationProjection {
30
30
  config: OcxConfig;
@@ -1,3 +1,5 @@
1
+ import { codexPlanKey, codexPlanValue } from "../codex/plan";
2
+
1
3
  export const CODEX_CONFIGURED_CAPACITY_WEIGHTS = {
2
4
  plus: 1,
3
5
  business: 1,
@@ -22,7 +24,7 @@ export type CodexCapacityQuota = {
22
24
  export interface CodexCapacityAccount {
23
25
  isMain: boolean;
24
26
  active?: boolean;
25
- plan?: string | null;
27
+ plan?: unknown;
26
28
  paused: boolean;
27
29
  needsReauth?: boolean;
28
30
  quota: CodexCapacityQuota | null;
@@ -82,8 +84,8 @@ type MutableWindow = {
82
84
  oldestUpdatedAt: number;
83
85
  };
84
86
 
85
- function configuredWeight(plan: string | null | undefined): number | undefined {
86
- const normalized = plan?.trim().toLowerCase();
87
+ function configuredWeight(plan: unknown): number | undefined {
88
+ const normalized = codexPlanKey(plan);
87
89
  return normalized && Object.hasOwn(CODEX_CONFIGURED_CAPACITY_WEIGHTS, normalized)
88
90
  ? CODEX_CONFIGURED_CAPACITY_WEIGHTS[normalized as keyof typeof CODEX_CONFIGURED_CAPACITY_WEIGHTS]
89
91
  : undefined;
@@ -168,9 +170,10 @@ export function aggregateCodexPoolCapacity(
168
170
  const current = accounts.find(account => account.active)
169
171
  ?? accounts.find(account => account.isMain)
170
172
  ?? accounts[0];
173
+ const currentPlan = codexPlanValue(current?.plan);
171
174
  const currentAccount = current ? {
172
175
  isMain: current.isMain,
173
- ...(current.plan !== undefined ? { plan: current.plan } : {}),
176
+ ...(currentPlan !== undefined ? { plan: currentPlan } : {}),
174
177
  quota: currentQuotaForDisplay(current, now),
175
178
  } : undefined;
176
179
  const windows = new Map<string, MutableWindow>();
@@ -31,7 +31,8 @@ function keyFor(modelId: string): string {
31
31
 
32
32
  export function commandCodeReasoningEfforts(modelId: string): readonly string[] | undefined {
33
33
  const key = keyFor(modelId);
34
- return refreshedEfforts.get(key) ?? COMMAND_CODE_MODEL_REASONING_EFFORTS[key];
34
+ return refreshedEfforts.get(key)
35
+ ?? (Object.hasOwn(COMMAND_CODE_MODEL_REASONING_EFFORTS, key) ? COMMAND_CODE_MODEL_REASONING_EFFORTS[key] : undefined);
35
36
  }
36
37
 
37
38
  function parsedProfileEfforts(page: string): string[] | undefined {
@@ -59,7 +60,9 @@ export async function refreshCommandCodeReasoningEfforts(
59
60
  fetchFn: typeof globalThis.fetch = globalThis.fetch,
60
61
  ): Promise<readonly string[] | undefined> {
61
62
  const key = keyFor(modelId);
62
- const profile = COMMAND_CODE_MODEL_EFFORTS[key as keyof typeof COMMAND_CODE_MODEL_EFFORTS];
63
+ const profile = Object.hasOwn(COMMAND_CODE_MODEL_EFFORTS, key)
64
+ ? COMMAND_CODE_MODEL_EFFORTS[key as keyof typeof COMMAND_CODE_MODEL_EFFORTS]
65
+ : undefined;
63
66
  if (!profile) return undefined;
64
67
  try {
65
68
  const response = await fetchFn(profile.profileUrl, {
@@ -36,6 +36,7 @@ export interface DerivedKeyLoginProvider {
36
36
  noPenaltyModels?: string[];
37
37
  autoToolChoiceOnlyModels?: string[];
38
38
  preserveReasoningContentModels?: string[];
39
+ requiresReasoningPlaceholderModels?: string[];
39
40
  reasoningSplitModels?: string[];
40
41
  thinkingToggleModels?: string[];
41
42
  thinkingBudgetModels?: string[];
@@ -109,6 +110,94 @@ function cloneNestedRecord(input: Record<string, Record<string, string>>): Recor
109
110
  return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, { ...value }]));
110
111
  }
111
112
 
113
+ function sameStringArray(left: readonly string[] | undefined, right: readonly string[]): boolean {
114
+ return left?.length === right.length && left.every((value, index) => value === right[index]);
115
+ }
116
+
117
+ type DirectReasoningEffortOverrides = Pick<
118
+ OcxProviderConfig,
119
+ "thinkingBudgetModels" | "modelReasoningEfforts" | "modelDefaultReasoningEfforts" | "modelReasoningEffortMap"
120
+ >;
121
+
122
+ function fillFoldedModelDefault<T>(
123
+ merged: Record<string, T> | undefined,
124
+ explicit: Record<string, T> | undefined,
125
+ model: string,
126
+ registryDefault: T | undefined,
127
+ clone: (value: T) => T,
128
+ ): Record<string, T> | undefined {
129
+ const folded = model.toLowerCase();
130
+ const explicitEntries = Object.entries(explicit ?? {}).filter(([key]) => key.toLowerCase() === folded);
131
+ if (explicitEntries.length === 0) {
132
+ return registryDefault === undefined ? merged : { ...(merged ?? {}), [model]: clone(registryDefault) };
133
+ }
134
+
135
+ const next = Object.fromEntries(
136
+ Object.entries(merged ?? {}).filter(([key]) => key.toLowerCase() !== folded),
137
+ ) as Record<string, T>;
138
+ for (const [key, value] of explicitEntries) next[key] = clone(value);
139
+ return next;
140
+ }
141
+
142
+ /**
143
+ * Apply a registry-owned direct `reasoning_effort` contract after user/seed metadata is merged.
144
+ *
145
+ * Provider presets are persisted with capability metadata, so an existing config can retain an
146
+ * obsolete thinking-budget classification after the registry learns that a model has a native
147
+ * effort field. The registry opts only verified model contracts into this repair; providers that
148
+ * do not resolve through the matching registry entry keep their user-supplied classification.
149
+ */
150
+ export function applyDirectReasoningEffortContracts(
151
+ entry: ProviderRegistryEntry,
152
+ prov: OcxProviderConfig,
153
+ explicit: DirectReasoningEffortOverrides = prov,
154
+ ): void {
155
+ const models = entry.directReasoningEffortModels;
156
+ if (!models || models.length === 0) return;
157
+
158
+ for (const model of models) {
159
+ // Old generated presets persisted the direct model at the front of the registry's budget
160
+ // list. Routing merges registry-first, which moves that one stale entry to the end. Repair
161
+ // only those two exact generated shapes; any partial, reordered, or case-varied list is a
162
+ // deliberate user value and remains untouched.
163
+ const currentBudgetModels = entry.thinkingBudgetModels ?? [];
164
+ const staleSeedShape = [model, ...currentBudgetModels];
165
+ const staleRoutedShape = [...currentBudgetModels, model];
166
+ if (sameStringArray(explicit.thinkingBudgetModels, staleSeedShape)
167
+ || sameStringArray(explicit.thinkingBudgetModels, staleRoutedShape)) {
168
+ prov.thinkingBudgetModels = [...currentBudgetModels];
169
+ }
170
+
171
+ const efforts = entry.modelReasoningEfforts?.[model];
172
+ prov.modelReasoningEfforts = fillFoldedModelDefault(
173
+ prov.modelReasoningEfforts,
174
+ explicit.modelReasoningEfforts,
175
+ model,
176
+ efforts,
177
+ value => [...value],
178
+ );
179
+
180
+ const defaultEffort = entry.modelDefaultReasoningEfforts?.[model];
181
+ prov.modelDefaultReasoningEfforts = fillFoldedModelDefault(
182
+ prov.modelDefaultReasoningEfforts,
183
+ explicit.modelDefaultReasoningEfforts,
184
+ model,
185
+ defaultEffort,
186
+ value => value,
187
+ );
188
+
189
+ // An explicit empty model map masks any provider-wide aliases. Without it, a stale global
190
+ // mapping such as xhigh -> max would win before the verified direct ladder can clamp it.
191
+ prov.modelReasoningEffortMap = fillFoldedModelDefault(
192
+ prov.modelReasoningEffortMap,
193
+ explicit.modelReasoningEffortMap,
194
+ model,
195
+ {},
196
+ value => ({ ...value }),
197
+ );
198
+ }
199
+ }
200
+
112
201
  /**
113
202
  * Build the provider config a registry entry contributes when a preset is materialized.
114
203
  * The registry auth kind is preserved verbatim (including `"local"`) so fail-closed gates
@@ -152,8 +241,12 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
152
241
  ...(entry.promptCacheKey !== undefined ? { promptCacheKey: entry.promptCacheKey } : {}),
153
242
  ...(entry.responsesPath !== undefined ? { responsesPath: entry.responsesPath } : {}),
154
243
  ...(entry.statelessResponses !== undefined ? { statelessResponses: entry.statelessResponses } : {}),
244
+ ...(entry.requiresAdjacentResponsesToolResults !== undefined
245
+ ? { requiresAdjacentResponsesToolResults: entry.requiresAdjacentResponsesToolResults }
246
+ : {}),
155
247
  ...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}),
156
248
  ...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}),
249
+ ...(entry.requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels: [...entry.requiresReasoningPlaceholderModels] } : {}),
157
250
  ...(entry.reasoningSplitModels ? { reasoningSplitModels: [...entry.reasoningSplitModels] } : {}),
158
251
  ...(entry.thinkingToggleModels ? { thinkingToggleModels: [...entry.thinkingToggleModels] } : {}),
159
252
  ...(entry.thinkingBudgetModels ? { thinkingBudgetModels: [...entry.thinkingBudgetModels] } : {}),
@@ -199,6 +292,7 @@ export function deriveKeyLoginMap(): Record<string, DerivedKeyLoginProvider> {
199
292
  ...(entry.noPenaltyModels ? { noPenaltyModels: [...entry.noPenaltyModels] } : {}),
200
293
  ...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}),
201
294
  ...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}),
295
+ ...(entry.requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels: [...entry.requiresReasoningPlaceholderModels] } : {}),
202
296
  ...(entry.reasoningSplitModels ? { reasoningSplitModels: [...entry.reasoningSplitModels] } : {}),
203
297
  ...(entry.thinkingToggleModels ? { thinkingToggleModels: [...entry.thinkingToggleModels] } : {}),
204
298
  ...(entry.thinkingBudgetModels ? { thinkingBudgetModels: [...entry.thinkingBudgetModels] } : {}),
@@ -293,6 +387,12 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
293
387
  enrichReasoningSummariesByDestination(prov);
294
388
  return;
295
389
  }
390
+ const explicitDirectReasoning: DirectReasoningEffortOverrides = {
391
+ thinkingBudgetModels: prov.thinkingBudgetModels,
392
+ modelReasoningEfforts: prov.modelReasoningEfforts,
393
+ modelDefaultReasoningEfforts: prov.modelDefaultReasoningEfforts,
394
+ modelReasoningEffortMap: prov.modelReasoningEffortMap,
395
+ };
296
396
  const seed = providerConfigSeed(entry);
297
397
  if (prov.apiKeyTransport === undefined && seed.apiKeyTransport !== undefined) prov.apiKeyTransport = seed.apiKeyTransport;
298
398
  if (!prov.defaultModel && seed.defaultModel) prov.defaultModel = seed.defaultModel;
@@ -323,6 +423,9 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
323
423
  // learned this route still gets backfilled.
324
424
  if (prov.responsesPath === undefined && seed.responsesPath !== undefined) prov.responsesPath = seed.responsesPath;
325
425
  if (prov.statelessResponses === undefined && seed.statelessResponses !== undefined) prov.statelessResponses = seed.statelessResponses;
426
+ if (prov.requiresAdjacentResponsesToolResults === undefined && seed.requiresAdjacentResponsesToolResults !== undefined) {
427
+ prov.requiresAdjacentResponsesToolResults = seed.requiresAdjacentResponsesToolResults;
428
+ }
326
429
  // Registry-only metadata (never seeded into saved config): backfill straight from
327
430
  // the entry so an explicit user value stays distinguishable from the default.
328
431
  if (prov.supportsServiceTier === undefined && entry.supportsServiceTier !== undefined) prov.supportsServiceTier = entry.supportsServiceTier;
@@ -345,6 +448,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
345
448
  }
346
449
  if (!prov.autoToolChoiceOnlyModels && seed.autoToolChoiceOnlyModels) prov.autoToolChoiceOnlyModels = [...seed.autoToolChoiceOnlyModels];
347
450
  if (!prov.preserveReasoningContentModels && seed.preserveReasoningContentModels) prov.preserveReasoningContentModels = [...seed.preserveReasoningContentModels];
451
+ if (!prov.requiresReasoningPlaceholderModels && seed.requiresReasoningPlaceholderModels) prov.requiresReasoningPlaceholderModels = [...seed.requiresReasoningPlaceholderModels];
348
452
  if (!prov.reasoningSplitModels && seed.reasoningSplitModels) prov.reasoningSplitModels = [...seed.reasoningSplitModels];
349
453
  if (!prov.thinkingToggleModels && seed.thinkingToggleModels) prov.thinkingToggleModels = [...seed.thinkingToggleModels];
350
454
  if (!prov.thinkingBudgetModels && seed.thinkingBudgetModels) prov.thinkingBudgetModels = [...seed.thinkingBudgetModels];
@@ -353,6 +457,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
353
457
  if (prov.freeTier === undefined && seed.freeTier !== undefined) prov.freeTier = seed.freeTier;
354
458
  if (prov.modelSuffixBracketStrip === undefined && seed.modelSuffixBracketStrip !== undefined) prov.modelSuffixBracketStrip = seed.modelSuffixBracketStrip;
355
459
  if (!prov.headers && seed.headers) prov.headers = { ...seed.headers };
460
+ applyDirectReasoningEffortContracts(entry, prov, explicitDirectReasoning);
356
461
  }
357
462
 
358
463
  export function deriveFeaturedProviderIds(): string[] {
@@ -1,6 +1,6 @@
1
1
  import { CODEX_ACCOUNT_LOG_LABEL_RE } from "../codex/account-label";
2
2
 
3
- function canonicalUsageProviderLabel(provider: string): string {
3
+ export function canonicalUsageProviderLabel(provider: string): string {
4
4
  return provider === "chatgpt" || provider === "openai-multi" ? "openai" : provider;
5
5
  }
6
6
 
@@ -1,5 +1,6 @@
1
- import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types";
1
+ import type { CodexAccountMode, OcxConfig, OcxProviderConfig, ProviderCostOverlay } from "../types";
2
2
  import { OPENAI_PROVIDER_TIER_VERSION } from "../types";
3
+ import { MAX_COST4_RATE } from "../usage/expected-prices";
3
4
 
4
5
  export const OPENAI_CODEX_PROVIDER_ID = "openai";
5
6
  export const LEGACY_OPENAI_MULTI_PROVIDER_ID = "openai-multi";
@@ -69,11 +70,17 @@ export class OpenAiTierMigrationCollisionError extends Error {
69
70
  }
70
71
  }
71
72
 
72
- function managedLegacyMultiOverlay(provider: OcxProviderConfig): Pick<OcxProviderConfig, "disabled" | "selectedModels"> | null {
73
- const allowed = new Set(["adapter", "authMode", "baseUrl", "disabled", "selectedModels"]);
73
+ function managedLegacyMultiOverlay(
74
+ provider: OcxProviderConfig,
75
+ ): Pick<OcxProviderConfig, "disabled" | "selectedModels" | "modelCosts"> | null {
76
+ const allowed = new Set(["adapter", "authMode", "baseUrl", "disabled", "selectedModels", "modelCosts"]);
74
77
  if (!Object.keys(provider).every(key => allowed.has(key))) return null;
75
78
  if (!isCanonicalOpenAiForwardProvider(provider)) return null;
76
79
  if (provider.disabled !== undefined && typeof provider.disabled !== "boolean") return null;
80
+ // Keep the migration self-contained: a malformed overlay is a collision, not
81
+ // something to carry into the canonical row (importing config.ts here would
82
+ // create an import cycle — config.ts already imports this module).
83
+ if (provider.modelCosts !== undefined && !validLegacyOverlayCosts(provider.modelCosts)) return null;
77
84
  if (provider.selectedModels !== undefined && (
78
85
  !Array.isArray(provider.selectedModels)
79
86
  || provider.selectedModels.some(model => typeof model !== "string")
@@ -81,9 +88,26 @@ function managedLegacyMultiOverlay(provider: OcxProviderConfig): Pick<OcxProvide
81
88
  return {
82
89
  ...(provider.disabled !== undefined ? { disabled: provider.disabled } : {}),
83
90
  ...(provider.selectedModels !== undefined ? { selectedModels: [...provider.selectedModels] } : {}),
91
+ ...(provider.modelCosts !== undefined ? { modelCosts: provider.modelCosts } : {}),
84
92
  };
85
93
  }
86
94
 
95
+ /** Shape check for a legacy overlay: a plain record of complete non-negative finite Cost4 rows. */
96
+ function validLegacyOverlayCosts(value: unknown): boolean {
97
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
98
+ const fields = ["input", "output", "cacheRead", "cacheWrite"] as const;
99
+ return Object.values(value as Record<string, unknown>).every(entry => {
100
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
101
+ const rates = entry as Record<string, unknown>;
102
+ return Object.keys(rates).length === fields.length
103
+ && fields.every(key => Object.hasOwn(rates, key)
104
+ && typeof rates[key] === "number"
105
+ && Number.isFinite(rates[key])
106
+ && (rates[key] as number) >= 0
107
+ && (rates[key] as number) <= MAX_COST4_RATE);
108
+ });
109
+ }
110
+
87
111
  function rewriteLegacyOpenAiSelectedId(value: string): string {
88
112
  return value.startsWith(LEGACY_OPENAI_MULTI_PREFIX)
89
113
  ? value.slice(LEGACY_OPENAI_MULTI_PREFIX.length)
@@ -95,6 +119,28 @@ function rewriteLegacyOpenAiModelList(values: string[] | undefined): string[] |
95
119
  return [...new Set(values.map(rewriteLegacyOpenAiSelectedId))];
96
120
  }
97
121
 
122
+ /**
123
+ * Rewrite legacy `openai-multi/<model>` keys in a modelCosts overlay so the
124
+ * prices still match after the migration resolves logs as provider `openai`
125
+ * with the bare model id. Keys without the legacy prefix pass through.
126
+ */
127
+ function rewriteLegacyOpenAiCostKeys(costs: Record<string, ProviderCostOverlay> | undefined): Record<string, ProviderCostOverlay> {
128
+ // Null-prototype map so prototype-named model ids (e.g. "__proto__") are
129
+ // stored as own properties instead of invoking the inherited setter.
130
+ const rewritten = Object.create(null) as Record<string, ProviderCostOverlay>;
131
+ if (costs) {
132
+ for (const [key, value] of Object.entries(costs)) {
133
+ const canonicalKey = rewriteLegacyOpenAiSelectedId(key);
134
+ // A bare <model> key always wins over its openai-multi/<model> equivalent
135
+ // inside the same row, regardless of JSON property order.
136
+ if (key === canonicalKey || !Object.hasOwn(rewritten, canonicalKey)) {
137
+ rewritten[canonicalKey] = value;
138
+ }
139
+ }
140
+ }
141
+ return rewritten;
142
+ }
143
+
98
144
  function mergeLegacyOpenAiProviderRows(
99
145
  openai: OcxProviderConfig | undefined,
100
146
  legacyMulti: OcxProviderConfig | undefined,
@@ -104,12 +150,22 @@ function mergeLegacyOpenAiProviderRows(
104
150
  ...(openai?.selectedModels ?? []),
105
151
  ...(legacyMulti?.selectedModels ?? []),
106
152
  ]);
153
+ // Both rows can carry disjoint overlays; merge them (canonical openai wins on
154
+ // key conflicts) so legacy Multi prices are not silently dropped. Keys are
155
+ // rewritten first so `openai-multi/<model>` entries still resolve after the
156
+ // provider is canonicalized to `openai`.
157
+ const modelCosts = {
158
+ ...rewriteLegacyOpenAiCostKeys(legacyMulti?.modelCosts),
159
+ ...rewriteLegacyOpenAiCostKeys(openai?.modelCosts),
160
+ };
161
+ const hasModelCosts = Object.keys(modelCosts).length > 0;
107
162
  const formerRows = [openai, legacyMulti].filter((row): row is OcxProviderConfig => row !== undefined);
108
163
  const disabled = formerRows.length > 0 && formerRows.every(row => row.disabled === true);
109
164
  return {
110
165
  ...canonicalCodexForwardProvider(mode),
111
166
  ...(disabled ? { disabled: true } : {}),
112
167
  ...(selectedModels && selectedModels.length > 0 ? { selectedModels } : {}),
168
+ ...(hasModelCosts ? { modelCosts } : {}),
113
169
  };
114
170
  }
115
171
 
@@ -0,0 +1,102 @@
1
+ /**
2
+ * OpenCode Zen short-window rate-limit guidance (#1145 / OCX-56).
3
+ *
4
+ * OpenCode's keyed and keyless Zen chat endpoints share `https://opencode.ai/zen/v1`.
5
+ * Free-model traffic can hit a short-window burst ceiling around 15–20 RPM
6
+ * (community-measured). Zen often answers with opaque `429 Rate limit exceeded`
7
+ * bodies and may omit `Retry-After` / `X-RateLimit-*`; when those headers are
8
+ * present they still take precedence. Distinct from the keyless desktop
9
+ * ~200 requests / 5h quota documented on `opencode-free`.
10
+ */
11
+ import { validateClientRetryAfterHeader } from "../lib/retry-after";
12
+ import { registryEntryForProviderDestination } from "./registry";
13
+
14
+ const OPENCODE_ZEN_PROVIDER_IDS = new Set(["opencode-zen", "opencode-free"]);
15
+
16
+ /** Observed free-model burst ceiling on Zen (not an official OpenCode figure). */
17
+ export const OPENCODE_ZEN_OBSERVED_RPM_HINT = "roughly 15-20 requests per minute";
18
+
19
+ /**
20
+ * Synthetic client backoff when Zen omits Retry-After after a rate-limit 429.
21
+ * Longer than the generic 2s default so Codex-shaped clients do not immediately
22
+ * re-hammer a ~15-20 RPM window.
23
+ */
24
+ export const OPENCODE_ZEN_SYNTHETIC_RETRY_AFTER_SEC = 15;
25
+
26
+ const ENRICHMENT_MARKER = "15-20 requests per minute";
27
+
28
+ export function isOpenCodeZenRateLimitProvider(opts: {
29
+ providerName?: string;
30
+ baseUrl?: string;
31
+ adapter?: string;
32
+ }): boolean {
33
+ const name = opts.providerName?.trim();
34
+ if (name && OPENCODE_ZEN_PROVIDER_IDS.has(name)) return true;
35
+ const baseUrl = opts.baseUrl?.trim();
36
+ if (!baseUrl) return false;
37
+ const entry = registryEntryForProviderDestination({
38
+ baseUrl,
39
+ adapter: opts.adapter?.trim() || "openai-chat",
40
+ authMode: "key",
41
+ });
42
+ return entry !== undefined && OPENCODE_ZEN_PROVIDER_IDS.has(entry.id);
43
+ }
44
+
45
+ /**
46
+ * Same-key `retryOn429` only applies on key-authenticated HTTP paths — not
47
+ * keyless `opencode-free` traffic and not custom `runTurn` transports.
48
+ */
49
+ export function supportsOpenCodeZenRetryOn429Guidance(opts: {
50
+ authMode?: string;
51
+ hasApiKey?: boolean;
52
+ supportsHttpSameKeyRetry?: boolean;
53
+ }): boolean {
54
+ if (opts.supportsHttpSameKeyRetry === false) return false;
55
+ if (opts.authMode !== undefined && opts.authMode !== "key") return false;
56
+ return opts.hasApiKey === true;
57
+ }
58
+
59
+ /**
60
+ * Append actionable Zen rate-limit context to a generic upstream 429 message and
61
+ * embed a parseable `try again in Ns` hint so {@link resolveClientRetryAfter}
62
+ * surfaces a useful Retry-After when the gateway sent none.
63
+ */
64
+ export function enrichOpenCodeZenRateLimitMessage(
65
+ message: string,
66
+ opts: {
67
+ status: number;
68
+ providerName?: string;
69
+ baseUrl?: string;
70
+ adapter?: string;
71
+ authMode?: string;
72
+ hasApiKey?: boolean;
73
+ /** Upstream Retry-After header; when valid, skip the synthetic 15s text hint. */
74
+ upstreamRetryAfter?: string | null;
75
+ /** False for custom `runTurn` transports outside the HTTP retry loop. */
76
+ supportsHttpSameKeyRetry?: boolean;
77
+ now?: number;
78
+ },
79
+ ): string {
80
+ if (opts.status !== 429) return message;
81
+ if (!isOpenCodeZenRateLimitProvider(opts)) return message;
82
+ if (!/rate\s*limit/i.test(message)) return message;
83
+ if (message.includes(ENRICHMENT_MARKER)) return message;
84
+
85
+ const upstreamRetry = validateClientRetryAfterHeader(
86
+ opts.upstreamRetryAfter,
87
+ opts.now ?? Date.now(),
88
+ );
89
+ const retryHint = upstreamRetry || /try again in \d/i.test(message)
90
+ ? ""
91
+ : ` Try again in ${OPENCODE_ZEN_SYNTHETIC_RETRY_AFTER_SEC}s.`;
92
+ const paceHint = supportsOpenCodeZenRetryOn429Guidance(opts)
93
+ ? " Slow the request pace, or set providers.opencode-zen.retryOn429 for same-key backoff."
94
+ : " Slow the request pace.";
95
+ return (
96
+ `${message}`
97
+ + ` OpenCode Zen free-model traffic is often limited to ${OPENCODE_ZEN_OBSERVED_RPM_HINT}`
98
+ + ` (observed; OpenCode does not publish this RPM, and may omit rate-limit headers).`
99
+ + `${retryHint}`
100
+ + paceHint
101
+ );
102
+ }