@bitkyc08/opencodex 2.60.0 → 2.61.0-preview.20260922

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 (249) hide show
  1. package/AGENTS_INSTALL.md +64 -0
  2. package/README.md +28 -1
  3. package/bin/ocx.mjs +382 -209
  4. package/gui/dist/assets/App-E64Rzjap.js +50 -0
  5. package/gui/dist/assets/Tray-_nfzD8k4.js +1 -0
  6. package/gui/dist/assets/index-DpdfZWMK.js +86 -0
  7. package/gui/dist/assets/index-_bpvxJu0.css +1 -0
  8. package/gui/dist/assets/usage-companion-chart-DtoK7T6h.js +1 -0
  9. package/gui/dist/favicon.png +0 -0
  10. package/gui/dist/index.html +2 -2
  11. package/gui/dist/provider-icons/stepfun-color.svg +1 -0
  12. package/package.json +5 -1
  13. package/src/adapters/anthropic.ts +16 -0
  14. package/src/adapters/coding-agent/protocol.ts +36 -6
  15. package/src/adapters/coding-agent/turn.ts +10 -2
  16. package/src/adapters/command-code.ts +2 -1
  17. package/src/adapters/cursor/catalog.ts +51 -7
  18. package/src/adapters/cursor/protobuf-request.ts +6 -3
  19. package/src/adapters/cursor/request-builder.ts +13 -3
  20. package/src/adapters/cursor.ts +11 -2
  21. package/src/adapters/declaration-carrier.ts +45 -0
  22. package/src/adapters/devin.ts +75 -23
  23. package/src/adapters/google-antigravity-wire.ts +5 -2
  24. package/src/adapters/google-errors.ts +7 -1
  25. package/src/adapters/google.ts +29 -5
  26. package/src/adapters/image.ts +4 -1
  27. package/src/adapters/input-media-guard.ts +21 -9
  28. package/src/adapters/kiro/usage.ts +3 -2
  29. package/src/adapters/kiro-tool-fallback.ts +1 -1
  30. package/src/adapters/ollama-native.ts +6 -0
  31. package/src/adapters/openai-chat/developer-role.ts +61 -0
  32. package/src/adapters/openai-chat/messages.ts +46 -27
  33. package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
  34. package/src/adapters/openai-chat/passthrough.ts +33 -9
  35. package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
  36. package/src/adapters/openai-chat.ts +18 -57
  37. package/src/adapters/openai-responses/passthrough.ts +2 -0
  38. package/src/adapters/registry.ts +3 -2
  39. package/src/adapters/run-turn-queue.ts +178 -29
  40. package/src/adapters/xai-web-search.ts +16 -1
  41. package/src/bridge/errors.ts +8 -2
  42. package/src/bridge/response-json.ts +9 -1
  43. package/src/bridge/sse.ts +10 -0
  44. package/src/chat/inbound.ts +141 -5
  45. package/src/claude/desktop-3p.ts +7 -1
  46. package/src/claude/desktop-first-party.ts +183 -0
  47. package/src/claude/desktop-gateway-state.ts +41 -0
  48. package/src/claude/inbound-content-options.ts +6 -0
  49. package/src/claude/inbound.ts +32 -6
  50. package/src/claude/intercept/connect-proxy.ts +179 -0
  51. package/src/claude/intercept/listener.ts +122 -0
  52. package/src/claude/intercept/local-ca.ts +298 -0
  53. package/src/claude/intercept/runtime.ts +98 -0
  54. package/src/claude/intercept/settings.ts +189 -0
  55. package/src/cli/access.ts +87 -0
  56. package/src/cli/account-auth.ts +19 -0
  57. package/src/cli/capabilities.ts +31 -0
  58. package/src/cli/claude-desktop.ts +206 -16
  59. package/src/cli/codex-shim-autorestore.ts +3 -0
  60. package/src/cli/companion.ts +56 -0
  61. package/src/cli/dispatch.ts +43 -4
  62. package/src/cli/ensure-desired-integrations.ts +43 -5
  63. package/src/cli/help.ts +7 -9
  64. package/src/cli/index.ts +200 -61
  65. package/src/cli/init.ts +8 -0
  66. package/src/cli/integrations.ts +7 -1
  67. package/src/cli/registry.ts +41 -2
  68. package/src/cli/resolve.ts +230 -0
  69. package/src/cli/root.ts +24 -1
  70. package/src/cli/start-ownership-publication.ts +56 -0
  71. package/src/cli/status-probes.ts +2 -18
  72. package/src/cli/status.ts +62 -0
  73. package/src/cli/stop-report.ts +143 -0
  74. package/src/cli/uninstall-plan.ts +9 -0
  75. package/src/client/machine-listener.ts +2 -5
  76. package/src/clients/aside-profiles.ts +4 -0
  77. package/src/clients/config-export/zcode-store.ts +157 -0
  78. package/src/clients/config-export.ts +36 -0
  79. package/src/codex/app-server-processes.ts +72 -40
  80. package/src/codex/auth-api/login-flow.ts +6 -1
  81. package/src/codex/autostart-health.ts +28 -0
  82. package/src/codex/catalog/build-entries.ts +2 -2
  83. package/src/codex/catalog/effort.ts +3 -3
  84. package/src/codex/catalog/provider-models.ts +24 -15
  85. package/src/codex/catalog/retained-sync.ts +2 -2
  86. package/src/codex/convergence.ts +2 -2
  87. package/src/codex/history-provider.ts +12 -1
  88. package/src/codex/inject/config-toml.ts +41 -6
  89. package/src/codex/inject/paginated-openai-compat.ts +90 -0
  90. package/src/codex/inject.ts +18 -15
  91. package/src/codex/injected-marker.ts +18 -0
  92. package/src/codex/main-account.ts +6 -0
  93. package/src/codex/model-cache.ts +52 -6
  94. package/src/codex/model-entitlement-admission.ts +59 -0
  95. package/src/codex/model-entitlements.ts +87 -44
  96. package/src/codex/native-main-admission.ts +83 -0
  97. package/src/codex/routing/health-store.ts +39 -0
  98. package/src/codex/routing/selection.ts +37 -1
  99. package/src/codex/routing.ts +5 -41
  100. package/src/codex/shim-templates.ts +29 -3
  101. package/src/companion/settings.ts +132 -0
  102. package/src/config/atomic-write.ts +117 -5
  103. package/src/config/load-degrade.ts +34 -7
  104. package/src/config/process-state.ts +1 -1
  105. package/src/config/schema/config-schema.ts +27 -1
  106. package/src/config/schema/leaf-validators.ts +47 -0
  107. package/src/config.ts +1 -1
  108. package/src/generated/compatibility-version.json +418 -174
  109. package/src/integrations/config-io.ts +44 -10
  110. package/src/integrations/merge.ts +120 -13
  111. package/src/integrations/mutation-plan.ts +124 -18
  112. package/src/integrations/registry.ts +38 -0
  113. package/src/integrations/state.ts +78 -45
  114. package/src/integrations/target.ts +208 -0
  115. package/src/integrations/writer.ts +49 -11
  116. package/src/lab/conformance/fixture-provider.ts +5 -0
  117. package/src/lib/browser-launch-notice.ts +59 -0
  118. package/src/lib/bun-runtime.ts +6 -2
  119. package/src/lib/debug.ts +40 -0
  120. package/src/lib/open-url.ts +51 -7
  121. package/src/lib/package-tree-integrity.ts +2 -1
  122. package/src/lib/package-version.ts +8 -0
  123. package/src/lib/provider-egress.ts +310 -0
  124. package/src/lib/provider-outbound.ts +59 -14
  125. package/src/lib/proxy-env.ts +82 -7
  126. package/src/lib/request-execution-budget.ts +72 -0
  127. package/src/lib/request-failure-attribution.ts +183 -0
  128. package/src/lib/request-failure-model.ts +236 -0
  129. package/src/lib/request-resend-gate.ts +138 -0
  130. package/src/lib/standalone.ts +16 -0
  131. package/src/lib/upstream-retry.ts +167 -16
  132. package/src/lib/winsw.ts +2 -2
  133. package/src/oauth/index.ts +24 -1
  134. package/src/oauth/login-cli.ts +80 -29
  135. package/src/providers/api-key-resolve.ts +133 -0
  136. package/src/providers/api-key-selection.ts +5 -1
  137. package/src/providers/key-failover.ts +31 -1
  138. package/src/providers/key-store.ts +34 -110
  139. package/src/providers/model-rename-fields.ts +147 -0
  140. package/src/providers/model-rename-migration.ts +124 -37
  141. package/src/providers/quota/vendor-probes-key.ts +37 -22
  142. package/src/providers/reasoning-metadata.ts +43 -18
  143. package/src/providers/registry/entries-core.ts +9 -4
  144. package/src/providers/registry/entries-extended.ts +29 -4
  145. package/src/providers/registry/model-seeds.ts +47 -10
  146. package/src/providers/xai-transport.ts +12 -1
  147. package/src/reasoning-effort.ts +8 -0
  148. package/src/responses/function-call-compat.ts +38 -1
  149. package/src/responses/inline-document.ts +65 -0
  150. package/src/responses/input-media.ts +42 -8
  151. package/src/responses/muse-tool-name-alias.ts +19 -0
  152. package/src/responses/parser-content.ts +8 -2
  153. package/src/responses/parser-tools.ts +3 -0
  154. package/src/responses/parser.ts +3 -1
  155. package/src/responses/schema.ts +3 -0
  156. package/src/router.ts +17 -2
  157. package/src/server/admission-model-scope.ts +219 -0
  158. package/src/server/audio-live.ts +9 -3
  159. package/src/server/audio-upstream.ts +18 -0
  160. package/src/server/auth-cors.ts +26 -0
  161. package/src/server/chat-completions.ts +55 -2
  162. package/src/server/chat-native.ts +19 -4
  163. package/src/server/claude-messages.ts +55 -17
  164. package/src/server/grok-responses-snapshot-repair.ts +113 -11
  165. package/src/server/gui-freshness.ts +103 -0
  166. package/src/server/gui-static.ts +7 -9
  167. package/src/server/images.ts +59 -6
  168. package/src/server/index/claude-intercept-lifecycle.ts +49 -0
  169. package/src/server/index/serve-options.ts +56 -10
  170. package/src/server/index/spend-ledger-lifecycle.ts +34 -8
  171. package/src/server/index/startup-warnings.ts +24 -0
  172. package/src/server/index.ts +21 -28
  173. package/src/server/lifecycle.ts +4 -4
  174. package/src/server/live-call-bindings.ts +6 -0
  175. package/src/server/live.ts +88 -3
  176. package/src/server/management/agent-settings-routes.ts +121 -36
  177. package/src/server/management/companion-routes.ts +77 -0
  178. package/src/server/management/logs-usage-routes.ts +19 -0
  179. package/src/server/management/native-integration-routes.ts +103 -6
  180. package/src/server/management/oauth-account-routes.ts +45 -7
  181. package/src/server/management/route-registry.ts +6 -0
  182. package/src/server/management/shared.ts +18 -1
  183. package/src/server/management/usage-timeline-routes.ts +44 -0
  184. package/src/server/management-api.ts +8 -9
  185. package/src/server/proxy-liveness.ts +75 -0
  186. package/src/server/relay.ts +19 -2
  187. package/src/server/request-log-failure-attribution.ts +99 -0
  188. package/src/server/request-log.ts +114 -0
  189. package/src/server/request-metrics.ts +92 -30
  190. package/src/server/responses/codex-ws-wire.ts +34 -8
  191. package/src/server/responses/combo-stream-preflight.ts +168 -6
  192. package/src/server/responses/compact.ts +11 -0
  193. package/src/server/responses/core-opaque-recovery.ts +90 -0
  194. package/src/server/responses/fetch-helpers.ts +124 -8
  195. package/src/server/responses/input-admission.ts +10 -0
  196. package/src/server/responses/passthrough-delivery.ts +14 -1
  197. package/src/server/responses/passthrough-dispatch.ts +179 -35
  198. package/src/server/responses/passthrough-error.ts +27 -8
  199. package/src/server/responses/request-prepare.ts +42 -1
  200. package/src/server/responses/request-send-budget.ts +12 -0
  201. package/src/server/responses/request-transport.ts +24 -4
  202. package/src/server/responses/reset-replay.ts +108 -0
  203. package/src/server/responses-request-tool-scope.ts +214 -0
  204. package/src/server/responses-undeclared-tool-guard.ts +4 -1
  205. package/src/server/search.ts +25 -1
  206. package/src/server/usage-ledger-retention.ts +73 -0
  207. package/src/service/cli.ts +48 -2
  208. package/src/service/health.ts +3 -2
  209. package/src/service/install-state-contract.d.mts +27 -0
  210. package/src/service/install-state-contract.mjs +34 -0
  211. package/src/service/launchd.ts +1 -1
  212. package/src/service/orchestration.ts +2 -4
  213. package/src/service/ownership-compatibility.ts +164 -0
  214. package/src/service/ownership-mutation-lease.d.mts +32 -0
  215. package/src/service/ownership-mutation-lease.mjs +211 -0
  216. package/src/service/repair.ts +45 -1
  217. package/src/service/state-lock.ts +269 -0
  218. package/src/service/state-record.d.mts +36 -0
  219. package/src/service/state-record.mjs +138 -0
  220. package/src/service/state.ts +582 -68
  221. package/src/service/windows-taskxml.ts +11 -10
  222. package/src/service.ts +7 -3
  223. package/src/tray/windows-tray.ps1 +1 -1
  224. package/src/types/config.ts +37 -0
  225. package/src/types/provider.ts +73 -0
  226. package/src/types/request.ts +28 -2
  227. package/src/types/tools.ts +19 -0
  228. package/src/types.ts +3 -0
  229. package/src/update/index.ts +207 -63
  230. package/src/update/job.ts +9 -5
  231. package/src/update/ownership-transaction.ts +47 -0
  232. package/src/update/restart-ownership.ts +54 -0
  233. package/src/update/runtime-ownership.d.mts +40 -0
  234. package/src/update/runtime-ownership.mjs +122 -0
  235. package/src/usage/attempt-delivery.ts +198 -0
  236. package/src/usage/cache-diagnostic.ts +305 -0
  237. package/src/usage/failure-fingerprint.ts +118 -0
  238. package/src/usage/failure-projection-cache.ts +174 -0
  239. package/src/usage/failure-projection.ts +174 -0
  240. package/src/usage/ledger-retention.ts +165 -0
  241. package/src/usage/log.ts +126 -79
  242. package/src/usage/request-outcome.ts +150 -0
  243. package/src/usage/retention-contract.ts +28 -0
  244. package/src/usage/summary.ts +2 -2
  245. package/src/usage/telemetry-contract.ts +237 -0
  246. package/src/usage/timeline.ts +236 -0
  247. package/src/web-search/alpha-search.ts +21 -1
  248. package/gui/dist/assets/index-BTuCbqQd.css +0 -1
  249. package/gui/dist/assets/index-DoBVdPHP.js +0 -134
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Read-path for provider key material: env references, keychain references, or the literal
3
+ * value.
4
+ *
5
+ * Leaf module on purpose: reasoning-metadata.ts imports resolveProviderApiKey() here so a
6
+ * learned-refusal identity can hash the same credential the wire sends. key-store.ts needs
7
+ * the ../config barrel for the write path (saveConfigPreservingClaudeCode), and importing it
8
+ * from reasoning-metadata would close the cycle the reasoning-metadata header warns about.
9
+ * resolveEnvValue comes from ../config/proxy-env for the same reason.
10
+ *
11
+ * `config.json` keeps only a reference (`keychain:<provider>` for the active key,
12
+ * `keychain:<provider>/<pool id>` for pool entries); the secret lives in the OS credential store
13
+ * under one service name. Reads are synchronous on purpose: `routedProviderConfig` and the
14
+ * quota/compaction/catalog callers are all sync, and `@napi-rs/keyring` ships a sync `Entry`.
15
+ *
16
+ * Policy: a reference that cannot be resolved fails closed (no key) and is warned once per
17
+ * account; nothing ever rewrites plaintext into config or its backups.
18
+ */
19
+ import { createRequire } from "node:module";
20
+ import { resolveEnvValue } from "../config/proxy-env";
21
+ import type { OcxProviderConfig } from "../types";
22
+
23
+ export const KEYCHAIN_REFERENCE_PREFIX = "keychain:";
24
+ export const PROVIDER_KEYCHAIN_SERVICE = "opencodex.provider-api-key.v1";
25
+
26
+ export interface ProviderKeychainEntry {
27
+ getPassword(): string | null;
28
+ setPassword(password: string): void;
29
+ deletePassword(): boolean;
30
+ }
31
+
32
+ export type ProviderKeychainEntryFactory = (service: string, account: string) => ProviderKeychainEntry;
33
+
34
+ const nodeRequire = createRequire(import.meta.url);
35
+
36
+ function defaultEntryFactory(service: string, account: string): ProviderKeychainEntry {
37
+ const { Entry } = nodeRequire("@napi-rs/keyring") as { Entry: new (s: string, a: string) => ProviderKeychainEntry };
38
+ return new Entry(service, account);
39
+ }
40
+
41
+ let entryFactory: ProviderKeychainEntryFactory = defaultEntryFactory;
42
+ const resolvedCache = new Map<string, string>();
43
+ const warnedAccounts = new Set<string>();
44
+
45
+ /** Test seam: swap the OS entry for an in-memory one and drop caches. */
46
+ export function setProviderKeychainEntryFactoryForTests(factory: ProviderKeychainEntryFactory | null): void {
47
+ entryFactory = factory ?? defaultEntryFactory;
48
+ resolvedCache.clear();
49
+ warnedAccounts.clear();
50
+ }
51
+
52
+ /** Write-path seam: a store/restore mutated secrets, so cached reads and warnings are stale. */
53
+ export function invalidateResolvedProviderKeyCache(): void {
54
+ resolvedCache.clear();
55
+ warnedAccounts.clear();
56
+ }
57
+
58
+ export function isKeychainReference(value: string | undefined): value is string {
59
+ return typeof value === "string" && value.startsWith(KEYCHAIN_REFERENCE_PREFIX) && value.length > KEYCHAIN_REFERENCE_PREFIX.length;
60
+ }
61
+
62
+ export function keychainAccount(reference: string): string {
63
+ return reference.slice(KEYCHAIN_REFERENCE_PREFIX.length);
64
+ }
65
+
66
+ /**
67
+ * A reference belongs to `name` only when its account is that provider's own active account
68
+ * or one of its pool accounts. `storeProviderKeyInKeychain` writes exactly those two shapes,
69
+ * so anything else in a provider's config names another provider's secret.
70
+ */
71
+ export function keychainReferenceBelongsToProvider(reference: string, name: string): boolean {
72
+ const account = keychainAccount(reference);
73
+ return account === name || account.startsWith(`${name}/`);
74
+ }
75
+
76
+ /** Entry for `account` under the provider-key service; read and write paths share the factory. */
77
+ export function providerKeychainEntry(account: string): ProviderKeychainEntry {
78
+ return entryFactory(PROVIDER_KEYCHAIN_SERVICE, account);
79
+ }
80
+
81
+ function readKeychain(account: string): string | undefined {
82
+ const cached = resolvedCache.get(account);
83
+ if (cached !== undefined) return cached;
84
+ try {
85
+ const value = providerKeychainEntry(account).getPassword();
86
+ if (typeof value === "string" && value.trim()) {
87
+ resolvedCache.set(account, value);
88
+ return value;
89
+ }
90
+ } catch {
91
+ // fall through to the single warning below
92
+ }
93
+ if (!warnedAccounts.has(account)) {
94
+ warnedAccounts.add(account);
95
+ console.warn(`[opencodex] provider key reference keychain:${account} could not be read from the OS keychain; requests for this provider have no credential until the keychain is available (no plaintext fallback)`);
96
+ }
97
+ return undefined;
98
+ }
99
+
100
+ /**
101
+ * Single resolver for provider key material: env references, keychain references, or the
102
+ * literal value. Every request-time read of `apiKey` goes through here.
103
+ */
104
+ export function resolveProviderApiKey(value: string | undefined): string | undefined {
105
+ if (!value) return undefined;
106
+ if (isKeychainReference(value)) return readKeychain(keychainAccount(value));
107
+ return resolveEnvValue(value);
108
+ }
109
+
110
+ export type ProviderKeyStoreKind = "keychain" | "env" | "file" | "none";
111
+
112
+ export function providerKeyStoreKind(provider: Pick<OcxProviderConfig, "apiKey"> | undefined): ProviderKeyStoreKind {
113
+ const key = provider?.apiKey;
114
+ if (!key) return "none";
115
+ if (isKeychainReference(key)) return "keychain";
116
+ if (/^\$\{?\w+\}?$/.test(key)) return "env";
117
+ return "file";
118
+ }
119
+
120
+ /** Probe the OS keychain with a throwaway account: write, read back, delete. */
121
+ export function probeProviderKeychain(): { available: true } | { available: false; reason: string } {
122
+ const account = `probe-${process.pid}-${Date.now()}`;
123
+ try {
124
+ const entry = providerKeychainEntry(account);
125
+ entry.setPassword("ok");
126
+ const back = entry.getPassword();
127
+ try { entry.deletePassword(); } catch { /* best effort */ }
128
+ if (back !== "ok") return { available: false, reason: "keychain read-back did not match" };
129
+ return { available: true };
130
+ } catch (error) {
131
+ return { available: false, reason: error instanceof Error ? error.message : "keychain unavailable" };
132
+ }
133
+ }
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { mutatePersistedConfig } from "../config";
3
3
  import { publishAccountSelection } from "../lib/account-selection-events";
4
+ import { clearModelCache } from "../codex/model-cache";
4
5
  import type { OcxConfig, OcxProviderConfig } from "../types";
5
6
  import type { ProviderApiKeySelection } from "../types/provider";
6
7
  import { routedProviderConfig } from "../router";
@@ -100,6 +101,9 @@ export function commitProviderApiKeySelection<T>(
100
101
  if (outcome.status === "unavailable") return { status: "unavailable" };
101
102
  const committed = outcome.value;
102
103
  if (committed.status !== "unavailable") config.providers[name] = structuredClone(committed.provider);
103
- if (committed.status === "committed" && committed.notify) publishAccountSelection(name, "api-key");
104
+ if (committed.status === "committed" && committed.notify) {
105
+ clearModelCache(name);
106
+ publishAccountSelection(name, "api-key");
107
+ }
104
108
  return committed;
105
109
  }
@@ -13,7 +13,7 @@ import type { ProviderApiKeySelection } from "../types/provider";
13
13
  import { routedProviderConfig } from "../router";
14
14
  import { getProviderRegistryEntry } from "./registry";
15
15
  import { normalizedBaseUrl } from "./quota/vendor-probes-key";
16
- import type { OcxConfig, OcxProviderConfig, RateLimitRetryPolicy, TransientRetryPolicy } from "../types";
16
+ import type { OcxConfig, OcxProviderConfig, RateLimitRetryPolicy, ResetReplayPolicy, TransientRetryPolicy } from "../types";
17
17
  import { OPENCODE_GO_SESSION_HEADER } from "./opencode-go-transport";
18
18
  import { resolveProviderTransport, type OcxProviderTransport } from "./xai-transport";
19
19
  import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
@@ -326,6 +326,16 @@ const DEFAULT_TRANSIENT_RETRY = {
326
326
  attempts: 3,
327
327
  } as const satisfies Required<TransientRetryPolicy>;
328
328
 
329
+ /**
330
+ * Default used when a provider opts in with a bare `retryOnReset: {}`: one replacement for
331
+ * the whole logical request. `replacements` counts duplicate inferences the operator accepts,
332
+ * not retries and not sends.
333
+ */
334
+ const DEFAULT_RESET_REPLAY = {
335
+ enabled: true,
336
+ replacements: 1,
337
+ } as const satisfies Required<ResetReplayPolicy>;
338
+
329
339
  /** Map<`${providerName}\0${keyId}`, KeyCooldown> */
330
340
  const keyCooldowns = new Map<string, KeyCooldown>();
331
341
 
@@ -616,6 +626,26 @@ export function transientRetryPolicyFor(
616
626
  };
617
627
  }
618
628
 
629
+ /**
630
+ * Normalize a provider's `retryOnReset` policy, or return null when it is absent or explicitly
631
+ * disabled.
632
+ *
633
+ * No auth-mode gate: the canonical ChatGPT backend is `forward` auth and is the send this
634
+ * policy exists for. Whether a given REQUEST may be replaced is a per-body decision made in
635
+ * src/server/responses/reset-replay.ts, and how many replacements the request gets is the
636
+ * shared allowance on its execution budget. This function only reads the operator's intent.
637
+ */
638
+ export function resetReplayPolicyFor(
639
+ provider: Pick<OcxProviderConfig, "retryOnReset">,
640
+ ): Required<ResetReplayPolicy> | null {
641
+ const policy = provider.retryOnReset;
642
+ if (!policy || policy.enabled === false) return null;
643
+ return {
644
+ enabled: policy.enabled ?? DEFAULT_RESET_REPLAY.enabled,
645
+ replacements: policy.replacements ?? DEFAULT_RESET_REPLAY.replacements,
646
+ };
647
+ }
648
+
619
649
  /**
620
650
  * Wait before the next same-target replay: upstream Retry-After (seconds or HTTP-date) when
621
651
  * `respectRetryAfter` is on and the header parses, capped at `maxIntervalMs`; otherwise the
@@ -1,7 +1,34 @@
1
- import { createRequire } from "node:module";
2
1
  import { resolveEnvValue, saveConfigPreservingClaudeCode } from "../config";
3
2
  import type { OcxConfig, OcxProviderConfig } from "../types";
4
3
  import type { ProviderRegistryEntry } from "./registry";
4
+ import {
5
+ KEYCHAIN_REFERENCE_PREFIX,
6
+ invalidateResolvedProviderKeyCache,
7
+ isKeychainReference,
8
+ keychainAccount,
9
+ keychainReferenceBelongsToProvider,
10
+ probeProviderKeychain,
11
+ providerKeychainEntry,
12
+ } from "./api-key-resolve";
13
+
14
+ // The read-path (reference predicates, the OS entry factory, resolveProviderApiKey, the probe
15
+ // and the kind classification) lives in ./api-key-resolve -- a leaf module that
16
+ // reasoning-metadata can also import without pulling the ../config barrel into its
17
+ // cycle-sensitive graph. Re-exported here so existing key-store consumers keep working.
18
+ export {
19
+ KEYCHAIN_REFERENCE_PREFIX,
20
+ PROVIDER_KEYCHAIN_SERVICE,
21
+ isKeychainReference,
22
+ probeProviderKeychain,
23
+ providerKeyStoreKind,
24
+ resolveProviderApiKey,
25
+ setProviderKeychainEntryFactoryForTests,
26
+ } from "./api-key-resolve";
27
+ export type {
28
+ ProviderKeychainEntry,
29
+ ProviderKeychainEntryFactory,
30
+ ProviderKeyStoreKind,
31
+ } from "./api-key-resolve";
5
32
 
6
33
  /** Shared with routing: a key-mode override is effective only while its key resolves. */
7
34
  export function providerUsesKeyAuthOverride(
@@ -27,109 +54,8 @@ export function providerUsesKeyAuthOverride(
27
54
  * (headless service, locked session) refuses rather than half-migrating.
28
55
  */
29
56
 
30
- export const KEYCHAIN_REFERENCE_PREFIX = "keychain:";
31
- export const PROVIDER_KEYCHAIN_SERVICE = "opencodex.provider-api-key.v1";
32
-
33
- export interface ProviderKeychainEntry {
34
- getPassword(): string | null;
35
- setPassword(password: string): void;
36
- deletePassword(): boolean;
37
- }
38
-
39
- export type ProviderKeychainEntryFactory = (service: string, account: string) => ProviderKeychainEntry;
40
-
41
- const nodeRequire = createRequire(import.meta.url);
42
-
43
- function defaultEntryFactory(service: string, account: string): ProviderKeychainEntry {
44
- const { Entry } = nodeRequire("@napi-rs/keyring") as { Entry: new (s: string, a: string) => ProviderKeychainEntry };
45
- return new Entry(service, account);
46
- }
47
-
48
- let entryFactory: ProviderKeychainEntryFactory = defaultEntryFactory;
49
- const resolvedCache = new Map<string, string>();
50
- const warnedAccounts = new Set<string>();
51
-
52
- /** Test seam: swap the OS entry for an in-memory one and drop caches. */
53
- export function setProviderKeychainEntryFactoryForTests(factory: ProviderKeychainEntryFactory | null): void {
54
- entryFactory = factory ?? defaultEntryFactory;
55
- resolvedCache.clear();
56
- warnedAccounts.clear();
57
- }
58
-
59
- export function isKeychainReference(value: string | undefined): value is string {
60
- return typeof value === "string" && value.startsWith(KEYCHAIN_REFERENCE_PREFIX) && value.length > KEYCHAIN_REFERENCE_PREFIX.length;
61
- }
62
-
63
- function keychainAccount(reference: string): string {
64
- return reference.slice(KEYCHAIN_REFERENCE_PREFIX.length);
65
- }
66
-
67
- /**
68
- * A reference belongs to `name` only when its account is that provider's own active account
69
- * or one of its pool accounts. `storeProviderKeyInKeychain` writes exactly those two shapes,
70
- * so anything else in a provider's config names another provider's secret.
71
- */
72
- function keychainReferenceBelongsToProvider(reference: string, name: string): boolean {
73
- const account = keychainAccount(reference);
74
- return account === name || account.startsWith(`${name}/`);
75
- }
76
-
77
- function readKeychain(account: string): string | undefined {
78
- const cached = resolvedCache.get(account);
79
- if (cached !== undefined) return cached;
80
- try {
81
- const value = entryFactory(PROVIDER_KEYCHAIN_SERVICE, account).getPassword();
82
- if (typeof value === "string" && value.trim()) {
83
- resolvedCache.set(account, value);
84
- return value;
85
- }
86
- } catch {
87
- // fall through to the single warning below
88
- }
89
- if (!warnedAccounts.has(account)) {
90
- warnedAccounts.add(account);
91
- console.warn(`[opencodex] provider key reference keychain:${account} could not be read from the OS keychain; requests for this provider have no credential until the keychain is available (no plaintext fallback)`);
92
- }
93
- return undefined;
94
- }
95
-
96
- /**
97
- * Single resolver for provider key material: env references, keychain references, or the
98
- * literal value. Every request-time read of `apiKey` goes through here.
99
- */
100
- export function resolveProviderApiKey(value: string | undefined): string | undefined {
101
- if (!value) return undefined;
102
- if (isKeychainReference(value)) return readKeychain(keychainAccount(value));
103
- return resolveEnvValue(value);
104
- }
105
-
106
- export type ProviderKeyStoreKind = "keychain" | "env" | "file" | "none";
107
-
108
- export function providerKeyStoreKind(provider: Pick<OcxProviderConfig, "apiKey"> | undefined): ProviderKeyStoreKind {
109
- const key = provider?.apiKey;
110
- if (!key) return "none";
111
- if (isKeychainReference(key)) return "keychain";
112
- if (/^\$\{?\w+\}?$/.test(key)) return "env";
113
- return "file";
114
- }
115
-
116
- /** Probe the OS keychain with a throwaway account: write, read back, delete. */
117
- export function probeProviderKeychain(): { available: true } | { available: false; reason: string } {
118
- const account = `probe-${process.pid}-${Date.now()}`;
119
- try {
120
- const entry = entryFactory(PROVIDER_KEYCHAIN_SERVICE, account);
121
- entry.setPassword("ok");
122
- const back = entry.getPassword();
123
- try { entry.deletePassword(); } catch { /* best effort */ }
124
- if (back !== "ok") return { available: false, reason: "keychain read-back did not match" };
125
- return { available: true };
126
- } catch (error) {
127
- return { available: false, reason: error instanceof Error ? error.message : "keychain unavailable" };
128
- }
129
- }
130
-
131
57
  function writeVerified(account: string, secret: string): void {
132
- const entry = entryFactory(PROVIDER_KEYCHAIN_SERVICE, account);
58
+ const entry = providerKeychainEntry(account);
133
59
  entry.setPassword(secret);
134
60
  if (entry.getPassword() !== secret) throw new Error(`keychain read-back mismatch for ${account}`);
135
61
  }
@@ -177,13 +103,12 @@ export function storeProviderKeyInKeychain(config: OcxConfig, name: string): { o
177
103
  }
178
104
  } catch (error) {
179
105
  for (const account of written) {
180
- try { entryFactory(PROVIDER_KEYCHAIN_SERVICE, account).deletePassword(); } catch { /* best effort */ }
106
+ try { providerKeychainEntry(account).deletePassword(); } catch { /* best effort */ }
181
107
  }
182
108
  return { ok: false, error: `OS keychain write failed: ${error instanceof Error ? error.message : "unknown"}`, status: 503 };
183
109
  }
184
110
  for (const apply of planned) apply();
185
- resolvedCache.clear();
186
- warnedAccounts.clear();
111
+ invalidateResolvedProviderKeyCache();
187
112
  saveConfigPreservingClaudeCode(config);
188
113
  return { ok: true, moved: written.length };
189
114
  }
@@ -211,7 +136,7 @@ export function restoreProviderKeyFromKeychain(config: OcxConfig, name: string):
211
136
  const account = keychainAccount(ref);
212
137
  if (resolved.has(account)) continue;
213
138
  let value: string | null = null;
214
- try { value = entryFactory(PROVIDER_KEYCHAIN_SERVICE, account).getPassword(); } catch { value = null; }
139
+ try { value = providerKeychainEntry(account).getPassword(); } catch { value = null; }
215
140
  if (!value) return { ok: false, error: `OS keychain has no readable secret for ${ref}; config left unchanged`, status: 503 };
216
141
  resolved.set(account, value);
217
142
  }
@@ -220,10 +145,9 @@ export function restoreProviderKeyFromKeychain(config: OcxConfig, name: string):
220
145
  }
221
146
  if (isKeychainReference(provider.apiKey)) provider.apiKey = resolved.get(keychainAccount(provider.apiKey))!;
222
147
  for (const account of resolved.keys()) {
223
- try { entryFactory(PROVIDER_KEYCHAIN_SERVICE, account).deletePassword(); } catch { /* best effort */ }
148
+ try { providerKeychainEntry(account).deletePassword(); } catch { /* best effort */ }
224
149
  }
225
- resolvedCache.clear();
226
- warnedAccounts.clear();
150
+ invalidateResolvedProviderKeyCache();
227
151
  saveConfigPreservingClaudeCode(config);
228
152
  return { ok: true, restored: resolved.size };
229
153
  }
@@ -0,0 +1,147 @@
1
+ import type { OcxProviderConfig } from "../types";
2
+
3
+ // Every provider field receives a deliberate identity role. A new field fails typecheck
4
+ // until its model-id semantics are classified; credentials and provider-wide policy are none.
5
+ type ModelRenameRole = "none" | "record" | "list" | "scalar" | "nested-models";
6
+ export const PROVIDER_MODEL_RENAME_ROLES = {
7
+ alias: "none",
8
+ modelAliases: "record",
9
+ modelDisplayNames: "record",
10
+ defaultAliases: "none",
11
+ adapter: "none",
12
+ codexToolMode: "none",
13
+ requestPacing: "nested-models",
14
+ mcpMaxTools: "none",
15
+ mcpMaxSchemaBytes: "none",
16
+ mcpMaxResultBytes: "none",
17
+ modelAdapters: "record",
18
+ fastWire: "none",
19
+ baseUrl: "none",
20
+ responsesPath: "none",
21
+ chatCompletionsPath: "none",
22
+ commandCodeVersion: "none",
23
+ statelessResponses: "none",
24
+ requiresAdjacentResponsesToolResults: "none",
25
+ requiresPairedResponsesToolResults: "none",
26
+ annotateEmptyToolOutputs: "none",
27
+ supportsServiceTier: "none",
28
+ modelSupportsServiceTier: "record",
29
+ preserveResponsesReasoningContent: "none",
30
+ modelReasoningEffortsAuthoritative: "none",
31
+ dropResponsesReasoningItems: "none",
32
+ decodesNativeCompactionBlobs: "none",
33
+ allowEncryptedV2AgentTasks: "none",
34
+ allowPrivateNetwork: "none",
35
+ proxy: "none",
36
+ noProxy: "none",
37
+ upstreamHttpVersion: "none",
38
+ upstreamWebsocket: "none",
39
+ directGeminiWireRenames: "none",
40
+ disabled: "none",
41
+ codexAccountMode: "none",
42
+ apiKey: "none",
43
+ apiKeyTransport: "none",
44
+ apiKeyPool: "none",
45
+ apiKeyPoolStrategy: "none",
46
+ apiKeySelectionRevision: "none",
47
+ _apiKeyAttempt: "none",
48
+ defaultModel: "scalar",
49
+ models: "list",
50
+ liveModels: "none",
51
+ selectedModels: "list",
52
+ initialModelSelection: "none",
53
+ retainModels: "list",
54
+ newModelPolicy: "none",
55
+ modelPreset: "none",
56
+ contextWindow: "none",
57
+ modelContextWindows: "record",
58
+ modelInputModalities: "record",
59
+ modelCapabilities: "record",
60
+ modelMaxInputTokens: "record",
61
+ modelAutoCompactTokenLimits: "record",
62
+ defaultMaxOutputTokens: "none",
63
+ modelMaxOutputTokens: "record",
64
+ modelCosts: "record",
65
+ autoReviewModel: "none",
66
+ autoReviewModelOverrides: "record",
67
+ headers: "none",
68
+ openRouterRouting: "none",
69
+ modelOpenRouterRouting: "record",
70
+ vercelGatewayRouting: "none",
71
+ modelVercelGatewayRouting: "record",
72
+ authMode: "none",
73
+ oauthAccountFailover: "none",
74
+ keyOptional: "none",
75
+ freeTier: "none",
76
+ note: "none",
77
+ modelSuffixBracketStrip: "none",
78
+ refreshPolicy: "none",
79
+ reasoningEfforts: "none",
80
+ modelReasoningEfforts: "record",
81
+ modelSuppressSyntheticMax: "record",
82
+ modelDefaultReasoningEfforts: "record",
83
+ pinnedReasoningEffort: "none",
84
+ modelPinnedReasoningEfforts: "record",
85
+ modelSupportsReasoningSummaries: "record",
86
+ modelSupportsVerbosity: "record",
87
+ supportsVerbosity: "none",
88
+ modelReasoningSummaryDelivery: "record",
89
+ modelPreferHostedTools: "record",
90
+ supportsOpenAiWebSearchToolFields: "none",
91
+ xaiResponsesXSearch: "none",
92
+ xaiResponsesDefaultVersion: "none",
93
+ zaiResponsesDefaultVersion: "none",
94
+ supportsResponsesCustomTools: "none",
95
+ unsupportedHostedTools: "none",
96
+ responsesSnapshotRepair: "none",
97
+ webSearchBridge: "none",
98
+ reasoningEffortMap: "none",
99
+ modelReasoningEffortMap: "record",
100
+ reasoningWireFormat: "none",
101
+ noReasoningModels: "list",
102
+ noTemperatureModels: "list",
103
+ noTopPModels: "list",
104
+ noPenaltyModels: "list",
105
+ noStructuredOutputModels: "list",
106
+ noJsonSchemaModels: "list",
107
+ omitReasoningEffortWithToolsModels: "list",
108
+ parallelToolCalls: "none",
109
+ pinParallelToolCallsFalse: "none",
110
+ terminalContinuationGuard: "none",
111
+ openaiChatEofTolerance: "none",
112
+ foldDeveloperRoleToSystem: "none",
113
+ promptCacheKey: "none",
114
+ chatServiceTier: "none",
115
+ responsesItemIdRepair: "none",
116
+ autoToolChoiceOnlyModels: "list",
117
+ preserveReasoningContentModels: "list",
118
+ requiresReasoningPlaceholderModels: "list",
119
+ showThinkingSummary: "none",
120
+ retryOn429: "none",
121
+ transientRetryOn5xx: "none",
122
+ retryOnReset: "none",
123
+ reasoningSplitModels: "list",
124
+ reasoningDetailsModels: "list",
125
+ thinkingToggleModels: "list",
126
+ thinkingBudgetModels: "list",
127
+ escapeBuiltinToolNames: "none",
128
+ anthropicEofTolerance: "none",
129
+ noVisionModels: "list",
130
+ googleMode: "none",
131
+ googleToolSchemaPolicy: "none",
132
+ project: "none",
133
+ location: "none",
134
+ mcpServers: "none",
135
+ desktopExecutor: "none",
136
+ unsafeAllowNativeLocalExec: "none",
137
+ nativeLocalExec: "none",
138
+ } as const satisfies Record<keyof OcxProviderConfig, ModelRenameRole>;
139
+
140
+ function fieldsWithRole(role: ModelRenameRole): string[] {
141
+ return Object.entries(PROVIDER_MODEL_RENAME_ROLES).filter(([, value]) => value === role).map(([key]) => key);
142
+ }
143
+ export const MODEL_KEYED_RECORDS = fieldsWithRole("record");
144
+ // Legacy persisted registry field, no longer part of the public provider config interface.
145
+ export const MODEL_ID_LISTS = [...fieldsWithRole("list"), "directReasoningEffortModels"];
146
+ export const MODEL_ID_SCALARS = fieldsWithRole("scalar");
147
+ export const MODEL_NESTED_RECORDS = fieldsWithRole("nested-models");