@bitkyc08/opencodex 2.39.0 → 2.40.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 (144) hide show
  1. package/README.md +49 -21
  2. package/gui/dist/assets/index-BHe2rl_C.js +112 -0
  3. package/gui/dist/assets/index-CJSb3HPe.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/cursor/catalog.ts +140 -7
  7. package/src/adapters/cursor/discovery.ts +79 -87
  8. package/src/adapters/cursor/effort-map.ts +11 -0
  9. package/src/adapters/cursor/request-builder.ts +31 -4
  10. package/src/adapters/cursor.ts +17 -0
  11. package/src/adapters/openai-chat.ts +95 -9
  12. package/src/adapters/openai-responses.ts +73 -9
  13. package/src/bridge.ts +58 -13
  14. package/src/claude/auth-mode.ts +6 -9
  15. package/src/claude/gateway-cache.ts +23 -3
  16. package/src/claude/model-info.ts +13 -2
  17. package/src/cli/access.ts +30 -0
  18. package/src/cli/account-extended.ts +19 -10
  19. package/src/cli/capabilities.ts +40 -1
  20. package/src/cli/claude-agent-startup-sync.ts +3 -0
  21. package/src/cli/claude.ts +134 -36
  22. package/src/cli/connect.ts +226 -0
  23. package/src/cli/dispatch.ts +126 -21
  24. package/src/cli/gui-pair-client.ts +170 -0
  25. package/src/cli/gui.ts +87 -0
  26. package/src/cli/help.ts +4 -1
  27. package/src/cli/index.ts +76 -22
  28. package/src/cli/models-runtime-subcommands.ts +34 -0
  29. package/src/cli/models-runtime.ts +4 -0
  30. package/src/cli/models.ts +2 -1
  31. package/src/cli/opencode.ts +2 -1
  32. package/src/cli/provider-runtime.ts +31 -1
  33. package/src/cli/registry.ts +32 -2
  34. package/src/cli/runtime-api.ts +10 -1
  35. package/src/cli/status.ts +26 -0
  36. package/src/cli/system-command.ts +9 -3
  37. package/src/client/connect.ts +650 -0
  38. package/src/client/hub-client.ts +457 -0
  39. package/src/client/hub-relay.ts +288 -0
  40. package/src/client/machine-api.ts +139 -0
  41. package/src/client/machine-auth.ts +54 -0
  42. package/src/client/machine-listener.ts +143 -0
  43. package/src/client/runtime.ts +93 -0
  44. package/src/client/state.ts +175 -0
  45. package/src/clients/config-export.ts +16 -5
  46. package/src/codex/auth-api.ts +169 -15
  47. package/src/codex/auth-context.ts +61 -6
  48. package/src/codex/catalog/aggregation.ts +17 -13
  49. package/src/codex/catalog/metadata.ts +28 -0
  50. package/src/codex/catalog/provider-fetch.ts +38 -5
  51. package/src/codex/catalog.ts +1 -1
  52. package/src/codex/desired-state.ts +13 -3
  53. package/src/codex/inject.ts +212 -31
  54. package/src/codex/journal.ts +54 -6
  55. package/src/codex/main-account.ts +105 -33
  56. package/src/codex/native-main-claim.ts +23 -1
  57. package/src/codex/native-residue.ts +7 -0
  58. package/src/codex/reset-credit-auto-redeem.ts +237 -0
  59. package/src/combos/request.ts +15 -3
  60. package/src/combos/types.ts +12 -0
  61. package/src/config/paths.ts +15 -1
  62. package/src/config/provider-validation.ts +40 -0
  63. package/src/config.ts +447 -3
  64. package/src/generated/compatibility-version.json +224 -108
  65. package/src/images/artifacts.ts +73 -37
  66. package/src/images/fulfill.ts +6 -1
  67. package/src/images/index.ts +1 -1
  68. package/src/images/plan.ts +16 -2
  69. package/src/images/synthetic-tool.ts +5 -0
  70. package/src/images/xai-client.ts +34 -1
  71. package/src/integrations/cursor-detect.ts +133 -0
  72. package/src/integrations/cursor-seen.ts +31 -0
  73. package/src/lib/errors.ts +18 -0
  74. package/src/lib/gui-pair-capability.ts +104 -0
  75. package/src/lib/lab-live-route-production.ts +2 -1
  76. package/src/lib/process-control.ts +4 -3
  77. package/src/lib/service-secrets.ts +161 -1
  78. package/src/lib/windows-system-proxy.ts +115 -0
  79. package/src/oauth/index.ts +3 -2
  80. package/src/oauth/pool-settings-capability.ts +55 -0
  81. package/src/providers/api-keys.ts +3 -1
  82. package/src/providers/codex-capacity.ts +34 -4
  83. package/src/providers/derive.ts +10 -0
  84. package/src/providers/fastwire.ts +10 -2
  85. package/src/providers/key-store.ts +197 -0
  86. package/src/providers/model-rename-migration.ts +3 -0
  87. package/src/providers/openai-sidecar.ts +3 -3
  88. package/src/providers/quota.ts +91 -42
  89. package/src/providers/registry.ts +33 -3
  90. package/src/reasoning-effort.ts +32 -0
  91. package/src/remote/protocol.ts +109 -0
  92. package/src/responses/citation-markers.ts +101 -0
  93. package/src/responses/parser.ts +43 -3
  94. package/src/responses/spill-store.ts +9 -4
  95. package/src/router.ts +72 -8
  96. package/src/server/auth-cors.ts +37 -1
  97. package/src/server/gui-session.ts +449 -0
  98. package/src/server/gui-static.ts +24 -7
  99. package/src/server/images.ts +237 -10
  100. package/src/server/index.ts +356 -30
  101. package/src/server/management/agent-settings-routes.ts +7 -1
  102. package/src/server/management/api-key-rotation.ts +74 -0
  103. package/src/server/management/combo-routes.ts +18 -6
  104. package/src/server/management/config-routes.ts +22 -3
  105. package/src/server/management/context.ts +5 -1
  106. package/src/server/management/cursor-integration-routes.ts +98 -0
  107. package/src/server/management/logs-usage-routes.ts +2 -1
  108. package/src/server/management/model-routes.ts +79 -1
  109. package/src/server/management/model-rows.ts +23 -1
  110. package/src/server/management/oauth-account-routes.ts +135 -2
  111. package/src/server/management/provider-routes.ts +143 -9
  112. package/src/server/management/route-registry.ts +10 -0
  113. package/src/server/management/session-routes.ts +13 -0
  114. package/src/server/management/system-routes.ts +13 -1
  115. package/src/server/management-api.ts +8 -3
  116. package/src/server/management-auth.ts +124 -82
  117. package/src/server/models-capabilities.ts +124 -0
  118. package/src/server/ports.ts +7 -0
  119. package/src/server/proxy-liveness.ts +7 -0
  120. package/src/server/request-log.ts +11 -5
  121. package/src/server/responses/agent-task-recovery.ts +1 -0
  122. package/src/server/responses/codex-auth-error.ts +3 -1
  123. package/src/server/responses/collaboration.ts +26 -1
  124. package/src/server/responses/combo-stream-preflight.ts +25 -6
  125. package/src/server/responses/compact.ts +32 -12
  126. package/src/server/responses/context-overflow.ts +49 -0
  127. package/src/server/responses/core.ts +196 -12
  128. package/src/server/responses/fetch-helpers.ts +2 -1
  129. package/src/server/responses/outbound-body-guard.ts +110 -0
  130. package/src/server/responses/ws-upstream.ts +87 -6
  131. package/src/server/responses-self-named-namespace-scrub.ts +181 -0
  132. package/src/server/system-env.ts +78 -25
  133. package/src/service.ts +218 -42
  134. package/src/types/config.ts +134 -0
  135. package/src/types/provider.ts +54 -1
  136. package/src/types/request.ts +2 -0
  137. package/src/types.ts +5 -0
  138. package/src/update/job.ts +33 -5
  139. package/src/usage/cost.ts +32 -1
  140. package/src/usage/expected-prices.ts +16 -0
  141. package/src/usage/log.ts +6 -2
  142. package/src/usage/summary.ts +48 -6
  143. package/gui/dist/assets/index-D-lchsPw.js +0 -112
  144. package/gui/dist/assets/index-uvENYLin.css +0 -1
@@ -1,11 +1,171 @@
1
+ import { createHash } from "node:crypto";
2
+ import { closeSync, existsSync, fsyncSync, lstatSync, openSync, readFileSync, unlinkSync } from "node:fs";
1
3
  import { join } from "node:path";
2
- import { readFileSync } from "node:fs";
3
4
  import { getConfigDir } from "../config";
5
+ import { atomicWriteFile } from "../config/atomic-write";
6
+
7
+ const MAX_SERVICE_API_TOKEN_BYTES = 4096;
8
+
9
+ export interface PersistedServiceApiToken {
10
+ path: string;
11
+ fingerprint: string;
12
+ }
13
+
14
+ export type ServiceApiTokenState =
15
+ | { kind: "absent" }
16
+ | { kind: "present"; token: string; fingerprint: string }
17
+ | { kind: "unsafe"; reason: string };
4
18
 
5
19
  export function serviceApiTokenFilePath(): string {
6
20
  return join(getConfigDir(), "service-api-token");
7
21
  }
8
22
 
23
+ export function serviceApiTokenBackupPath(): string {
24
+ return `${serviceApiTokenFilePath()}.prev`;
25
+ }
26
+
27
+ export function serviceApiTokenFingerprint(token: string): string {
28
+ return createHash("sha256").update(token).digest("hex");
29
+ }
30
+
31
+ export function readServiceApiTokenState(): ServiceApiTokenState {
32
+ const path = serviceApiTokenFilePath();
33
+ if (!existsSync(path)) return { kind: "absent" };
34
+ let stat;
35
+ try {
36
+ stat = lstatSync(path);
37
+ } catch {
38
+ return { kind: "unsafe", reason: "service token path could not be inspected" };
39
+ }
40
+ if (stat.isSymbolicLink() || !stat.isFile() || stat.size > MAX_SERVICE_API_TOKEN_BYTES) {
41
+ return { kind: "unsafe", reason: "service token path is not a bounded regular file" };
42
+ }
43
+ try {
44
+ const token = readFileSync(path, "utf8").trim();
45
+ if (!token) return { kind: "unsafe", reason: "service token file is empty" };
46
+ return { kind: "present", token, fingerprint: serviceApiTokenFingerprint(token) };
47
+ } catch {
48
+ return { kind: "unsafe", reason: "service token file could not be read" };
49
+ }
50
+ }
51
+
52
+ export function writeServiceApiTokenFile(token: string): PersistedServiceApiToken {
53
+ const value = token.trim();
54
+ if (!value || /[\r\n\0]/.test(value) || Buffer.byteLength(value) > MAX_SERVICE_API_TOKEN_BYTES) {
55
+ throw new Error("refusing to persist an invalid service API token");
56
+ }
57
+ const path = serviceApiTokenFilePath();
58
+ const existing = readServiceApiTokenState();
59
+ if (existing.kind !== "absent") {
60
+ throw new Error(existing.kind === "unsafe"
61
+ ? existing.reason
62
+ : "refusing to replace a pre-existing service API token");
63
+ }
64
+ atomicWriteFile(path, `${value}\n`);
65
+ return { path, fingerprint: serviceApiTokenFingerprint(value) };
66
+ }
67
+
68
+ function fsyncRegularFile(path: string): void {
69
+ // "r+", not "r": Windows rejects fsync on a read-only handle with EPERM, so a read-only
70
+ // open turned every token backup/replace/restore into a hard failure there.
71
+ const fd = openSync(path, "r+");
72
+ try { fsyncSync(fd); } finally { closeSync(fd); }
73
+ }
74
+
75
+ function validatedTokenValue(token: string): string {
76
+ const value = token.trim();
77
+ if (!value || /[\r\n\0]/.test(value) || Buffer.byteLength(value) > MAX_SERVICE_API_TOKEN_BYTES) {
78
+ throw new Error("refusing to persist an invalid service API token");
79
+ }
80
+ return value;
81
+ }
82
+
83
+ export function replaceServiceApiTokenFile(token: string): PersistedServiceApiToken {
84
+ const value = validatedTokenValue(token);
85
+ const current = readServiceApiTokenState();
86
+ if (current.kind !== "present") {
87
+ throw new Error(current.kind === "unsafe" ? current.reason : "service token file is missing");
88
+ }
89
+ const path = serviceApiTokenFilePath();
90
+ atomicWriteFile(path, `${value}\n`);
91
+ fsyncRegularFile(path);
92
+ return { path, fingerprint: serviceApiTokenFingerprint(value) };
93
+ }
94
+
95
+ export function readTokenBackupState(): ServiceApiTokenState {
96
+ const path = serviceApiTokenBackupPath();
97
+ if (!existsSync(path)) return { kind: "absent" };
98
+ let stat;
99
+ try { stat = lstatSync(path); }
100
+ catch { return { kind: "unsafe", reason: "service token backup could not be inspected" }; }
101
+ if (stat.isSymbolicLink() || !stat.isFile() || stat.size > MAX_SERVICE_API_TOKEN_BYTES
102
+ || (process.platform !== "win32" && (stat.mode & 0o077) !== 0)) {
103
+ return { kind: "unsafe", reason: "service token backup is not an owner-only bounded regular file" };
104
+ }
105
+ try {
106
+ const token = readFileSync(path, "utf8").trim();
107
+ if (!token || /[\r\n\0]/.test(token)) return { kind: "unsafe", reason: "service token backup is invalid" };
108
+ return { kind: "present", token, fingerprint: serviceApiTokenFingerprint(token) };
109
+ } catch {
110
+ return { kind: "unsafe", reason: "service token backup could not be read" };
111
+ }
112
+ }
113
+
114
+ export function writeTokenBackup(expectedFingerprint: string): PersistedServiceApiToken {
115
+ const current = readServiceApiTokenState();
116
+ if (current.kind !== "present" || current.fingerprint !== expectedFingerprint) {
117
+ throw new Error(current.kind === "unsafe" ? current.reason : "service token ownership changed before backup");
118
+ }
119
+ const existing = readTokenBackupState();
120
+ if (existing.kind !== "absent") {
121
+ throw new Error(existing.kind === "unsafe" ? existing.reason : "service token backup already exists");
122
+ }
123
+ const path = serviceApiTokenBackupPath();
124
+ atomicWriteFile(path, `${current.token}\n`);
125
+ fsyncRegularFile(path);
126
+ return { path, fingerprint: current.fingerprint };
127
+ }
128
+
129
+ export function restoreTokenBackup(expectedPath: string): PersistedServiceApiToken {
130
+ if (expectedPath !== serviceApiTokenBackupPath()) throw new Error("service token backup path mismatch");
131
+ const backup = readTokenBackupState();
132
+ if (backup.kind !== "present") {
133
+ throw new Error(backup.kind === "unsafe" ? backup.reason : "service token backup is missing");
134
+ }
135
+ const path = serviceApiTokenFilePath();
136
+ atomicWriteFile(path, `${backup.token}\n`);
137
+ fsyncRegularFile(path);
138
+ return { path, fingerprint: backup.fingerprint };
139
+ }
140
+
141
+ export function removeOrphanTokenBackup(): "removed" | "absent" {
142
+ const backup = readTokenBackupState();
143
+ if (backup.kind === "absent") return "absent";
144
+ if (backup.kind === "unsafe") throw new Error(backup.reason);
145
+ try {
146
+ unlinkSync(serviceApiTokenBackupPath());
147
+ return "removed";
148
+ } catch (error) {
149
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return "absent";
150
+ throw new Error("service token backup could not be removed", { cause: error });
151
+ }
152
+ }
153
+
154
+ export function removeServiceApiTokenFileIfOwned(
155
+ expectedFingerprint: string,
156
+ ): "removed" | "absent" | "changed" {
157
+ const state = readServiceApiTokenState();
158
+ if (state.kind === "absent") return "absent";
159
+ if (state.kind !== "present" || state.fingerprint !== expectedFingerprint) return "changed";
160
+ try {
161
+ unlinkSync(serviceApiTokenFilePath());
162
+ return "removed";
163
+ } catch (error) {
164
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return "absent";
165
+ throw new Error("owned service API token could not be removed", { cause: error });
166
+ }
167
+ }
168
+
9
169
  /**
10
170
  * App-side service token loading (WinSW native mode has no batch wrapper to read the
11
171
  * token file into the environment). Pure: returns the token or null — the CALLER
@@ -0,0 +1,115 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { decodeWindowsTextBytes } from "./windows-text";
5
+
6
+ /**
7
+ * Startup-time discovery of the Windows WinINET static proxy (#1525, slice 1).
8
+ *
9
+ * Reads `HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings` once and returns a
10
+ * normalized `http://host:port` URL when a static proxy is enabled. PAC/WPAD, per-request
11
+ * resolution, ProxyOverride, live refresh, and direct fallback are deliberately out of scope:
12
+ * this is the piece an operator can audit from one log line, and everything else needs the
13
+ * transport boundary the reviewer asked for first.
14
+ */
15
+
16
+ const INTERNET_SETTINGS_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings";
17
+
18
+ export type WindowsSystemProxyResult =
19
+ | { kind: "proxy"; url: string }
20
+ | { kind: "disabled" }
21
+ | { kind: "socks-only" }
22
+ | { kind: "unsupported" }
23
+ | { kind: "unreadable" };
24
+
25
+ /** Raw registry values; `null` when the value is absent or the read failed. */
26
+ export interface WindowsProxyRegistryValues {
27
+ proxyEnable: string | null;
28
+ proxyServer: string | null;
29
+ }
30
+
31
+ export type WindowsProxyRegistryReader = () => WindowsProxyRegistryValues | null;
32
+
33
+ function registryExe(): string {
34
+ const candidate = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "reg.exe");
35
+ return existsSync(candidate) ? candidate : "reg.exe";
36
+ }
37
+
38
+ function queryValue(name: string): string | null {
39
+ try {
40
+ const stdout = execFileSync(registryExe(), ["query", INTERNET_SETTINGS_KEY, "/v", name], {
41
+ encoding: "buffer",
42
+ stdio: ["ignore", "pipe", "ignore"],
43
+ timeout: 2000,
44
+ maxBuffer: 64 * 1024,
45
+ windowsHide: true,
46
+ });
47
+ const text = decodeWindowsTextBytes(stdout);
48
+ // " ProxyServer REG_SZ host:port"
49
+ const line = text.split(/\r?\n/).find(row => row.trim().startsWith(name));
50
+ if (!line) return null;
51
+ const match = line.match(/REG_(?:SZ|DWORD|EXPAND_SZ)\s+(.*)$/);
52
+ return match ? match[1]!.trim() : null;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ export function readWindowsProxyRegistry(): WindowsProxyRegistryValues | null {
59
+ const proxyEnable = queryValue("ProxyEnable");
60
+ if (proxyEnable === null) return null;
61
+ return { proxyEnable, proxyServer: queryValue("ProxyServer") };
62
+ }
63
+
64
+ /**
65
+ * `ProxyServer` is either a bare `host:port` (applies to every scheme) or a semicolon list of
66
+ * `scheme=host:port` entries. Prefer the https entry, then http; a SOCKS-only value cannot be
67
+ * mirrored into HTTP_PROXY/HTTPS_PROXY.
68
+ */
69
+ export function parseWindowsProxyServer(value: string): { kind: "proxy"; url: string } | { kind: "socks-only" } | { kind: "disabled" } {
70
+ const trimmed = value.trim();
71
+ if (!trimmed) return { kind: "disabled" };
72
+ if (!trimmed.includes("=")) return normalize(trimmed);
73
+ const entries = new Map<string, string>();
74
+ for (const part of trimmed.split(";")) {
75
+ const eq = part.indexOf("=");
76
+ if (eq <= 0) continue;
77
+ entries.set(part.slice(0, eq).trim().toLowerCase(), part.slice(eq + 1).trim());
78
+ }
79
+ const candidate = entries.get("https") || entries.get("http");
80
+ if (candidate) return normalize(candidate);
81
+ if (entries.has("socks")) return { kind: "socks-only" };
82
+ return { kind: "disabled" };
83
+ }
84
+
85
+ function normalize(hostPort: string): { kind: "proxy"; url: string } | { kind: "disabled" } {
86
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(hostPort) ? hostPort : `http://${hostPort}`;
87
+ try {
88
+ const url = new URL(withScheme);
89
+ if (!url.hostname || (url.protocol !== "http:" && url.protocol !== "https:")) return { kind: "disabled" };
90
+ // Keep userinfo: a credentialed proxy is valid in HTTP_PROXY. Only the log strips it.
91
+ const auth = url.username ? `${url.username}${url.password ? `:${url.password}` : ""}@` : "";
92
+ return { kind: "proxy", url: `${url.protocol}//${auth}${url.host}` };
93
+ } catch {
94
+ return { kind: "disabled" };
95
+ }
96
+ }
97
+
98
+ export function readWindowsSystemProxy(
99
+ reader: WindowsProxyRegistryReader = readWindowsProxyRegistry,
100
+ platform: NodeJS.Platform = process.platform,
101
+ ): WindowsSystemProxyResult {
102
+ if (platform !== "win32") return { kind: "unsupported" };
103
+ const values = reader();
104
+ if (!values) return { kind: "unreadable" };
105
+ // REG_DWORD prints as 0x1 / 0x0.
106
+ const enabled = /^(0x)?0*1$/i.test((values.proxyEnable ?? "").trim());
107
+ if (!enabled) return { kind: "disabled" };
108
+ if (!values.proxyServer) return { kind: "disabled" };
109
+ return parseWindowsProxyServer(values.proxyServer);
110
+ }
111
+
112
+ /** Log-safe form: origin only, so a credentialed value can never reach the console. */
113
+ export function describeProxyForLog(url: string): string {
114
+ try { return new URL(url).origin; } catch { return "<unparseable>"; }
115
+ }
@@ -1,7 +1,8 @@
1
1
  import type { KiroOAuthMetadata, OAuthController, OAuthCredentials } from "./types";
2
2
  import { parseCallbackInput } from "./callback-server";
3
3
  import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
4
- import { ConfigMutationLockError, loadConfig, resolveEnvValue, saveConfig } from "../config";
4
+ import { ConfigMutationLockError, loadConfig, saveConfig } from "../config";
5
+ import { resolveProviderApiKey } from "../providers/key-store";
5
6
  import { maskEmail } from "../lib/privacy";
6
7
  import { KiroTokenRefreshError, environmentKiroRoutingMetadata, loginKiro, refreshKiroToken, settleKiroLoginTransaction } from "./kiro";
7
8
  import {
@@ -1043,7 +1044,7 @@ export async function resolveModelsAuthToken(name: string, prov: OcxProviderConf
1043
1044
  return undefined;
1044
1045
  }
1045
1046
  }
1046
- return resolveEnvValue(prov.apiKey);
1047
+ return resolveProviderApiKey(prov.apiKey);
1047
1048
  }
1048
1049
 
1049
1050
  function modelDiscoveryTransportSeed(providerName: string, prov: OcxProviderConfig): OcxProviderConfig {
@@ -0,0 +1,55 @@
1
+ import { isGenericFailoverProvider } from "./generic-account-failover";
2
+ import type { OcxProviderConfig } from "../types";
3
+
4
+ /**
5
+ * Which pool-settings contract a provider speaks (#695, slice 1).
6
+ *
7
+ * `codex` and `anthropic` keep their own routes and storage untouched. `generic` is every
8
+ * other OAuth provider the generic failover module admits; its settings persist on
9
+ * `providers.<name>.oauthAccountFailover`. Settings stored for a generic provider are a
10
+ * declared contract the selector can consume in a later slice; today they change nothing.
11
+ */
12
+ export type PoolSettingsKind = "codex" | "anthropic" | "generic";
13
+
14
+ export const GENERIC_POOL_STRATEGIES = ["quota", "round-robin", "fill-first"] as const;
15
+ export type GenericPoolStrategy = typeof GENERIC_POOL_STRATEGIES[number];
16
+
17
+ export function poolSettingsCapability(name: string, provider: OcxProviderConfig | undefined): PoolSettingsKind | null {
18
+ if (name === "openai") return "codex";
19
+ if (name === "anthropic") return "anthropic";
20
+ if (!provider) return null;
21
+ return isGenericFailoverProvider(name, provider) ? "generic" : null;
22
+ }
23
+
24
+ export function parseGenericPoolStrategy(value: unknown): GenericPoolStrategy | null {
25
+ return typeof value === "string" && (GENERIC_POOL_STRATEGIES as readonly string[]).includes(value)
26
+ ? value as GenericPoolStrategy
27
+ : null;
28
+ }
29
+
30
+ export function parseGenericAutoSwitchThreshold(value: unknown): number | null {
31
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 100 ? value : null;
32
+ }
33
+
34
+ export interface GenericPoolSettingsDto {
35
+ provider: string;
36
+ kind: "generic";
37
+ enabled: boolean | null;
38
+ strategy: GenericPoolStrategy | null;
39
+ autoSwitchThreshold: number | null;
40
+ /** Slice-1 marker: persisted, not yet consumed by the selector. */
41
+ inert: true;
42
+ }
43
+
44
+ export function genericPoolSettingsDto(name: string, provider: OcxProviderConfig): GenericPoolSettingsDto {
45
+ const failover = provider.oauthAccountFailover ?? {};
46
+ return {
47
+ provider: name,
48
+ kind: "generic",
49
+ enabled: typeof failover.enabled === "boolean" ? failover.enabled : null,
50
+ strategy: parseGenericPoolStrategy(failover.strategy),
51
+ autoSwitchThreshold: parseGenericAutoSwitchThreshold(failover.autoSwitchThreshold),
52
+ inert: true,
53
+ };
54
+ }
55
+
@@ -24,7 +24,9 @@ function isEnvReference(value: string): boolean {
24
24
  }
25
25
 
26
26
  export function maskApiKey(value: string): string {
27
- if (isEnvReference(value)) return value;
27
+ // Env and keychain references carry no secret material; show them verbatim so an operator
28
+ // can tell where the key lives.
29
+ if (isEnvReference(value) || value.startsWith("keychain:")) return value;
28
30
  if (value.length <= 8) return "****";
29
31
  return `${value.slice(0, 4)}****${value.slice(-4)}`;
30
32
  }
@@ -8,6 +8,28 @@ export const CODEX_CONFIGURED_CAPACITY_WEIGHTS = {
8
8
  pro: 20,
9
9
  } as const;
10
10
 
11
+ /**
12
+ * Weight for a plan the map above has not calibrated.
13
+ *
14
+ * `CodexAccount.plan` is an unrestricted upstream string and cannot be enumerated: the
15
+ * bundled model snapshot alone carries 21 distinct plan names (`edu_plus`, `finserv`,
16
+ * `k12`, `quorum`, `self_serve_business_usage_based`, …) against the five listed above.
17
+ * Treating an unlisted plan as unknown dropped the account from the estimate entirely and
18
+ * reported "incomplete coverage" — which is how a Business seat upgraded to Premium
19
+ * disappeared from its own capacity report (#3155), and it was already happening to the
20
+ * other sixteen snapshot plans without anyone noticing.
21
+ *
22
+ * `src/codex/quota.ts` reached the same conclusion about the same field: an allowlist is a
23
+ * list of the plans someone remembered. Counting an unfamiliar plan at the baseline seat
24
+ * weight — the value `plus`, `team`, and `business` already carry — under-states a large
25
+ * seat, which is a visibly conservative estimate. Excluding it silently overstates coverage
26
+ * the operator does not have, which is worse.
27
+ *
28
+ * Uncalibrated plans are still counted in `unknownPlanAccounts` so the estimate's
29
+ * uncertainty stays on screen.
30
+ */
31
+ export const CODEX_DEFAULT_CAPACITY_WEIGHT = 1;
32
+
11
33
  /** Match the provider-report last-good freshness bound. */
12
34
  export const CODEX_CAPACITY_MAX_QUOTA_AGE_MS = 30 * 60_000;
13
35
 
@@ -85,11 +107,17 @@ type MutableWindow = {
85
107
  oldestUpdatedAt: number;
86
108
  };
87
109
 
88
- function configuredWeight(plan: unknown): number | undefined {
110
+ /** True when this plan has a calibrated weight rather than falling back to the default. */
111
+ function isCalibratedPlan(plan: unknown): boolean {
112
+ const normalized = codexPlanKey(plan);
113
+ return !!normalized && Object.hasOwn(CODEX_CONFIGURED_CAPACITY_WEIGHTS, normalized);
114
+ }
115
+
116
+ function configuredWeight(plan: unknown): number {
89
117
  const normalized = codexPlanKey(plan);
90
118
  return normalized && Object.hasOwn(CODEX_CONFIGURED_CAPACITY_WEIGHTS, normalized)
91
119
  ? CODEX_CONFIGURED_CAPACITY_WEIGHTS[normalized as keyof typeof CODEX_CONFIGURED_CAPACITY_WEIGHTS]
92
- : undefined;
120
+ : CODEX_DEFAULT_CAPACITY_WEIGHT;
93
121
  }
94
122
 
95
123
  function normalizedPercent(value: unknown): number | undefined {
@@ -188,7 +216,9 @@ export function aggregateCodexPoolCapacity(
188
216
 
189
217
  for (const account of accounts) {
190
218
  const weight = configuredWeight(account.plan);
191
- if (weight === undefined) unknownPlanAccounts += 1;
219
+ // Still reported, so the operator can see the estimate is conservative for this seat —
220
+ // but no longer a reason to drop the account from the aggregate (#3155).
221
+ if (!isCalibratedPlan(account.plan)) unknownPlanAccounts += 1;
192
222
  if (account.paused) pausedAccounts += 1;
193
223
  if (account.needsReauth) reauthAccounts += 1;
194
224
  const quota = account.quota;
@@ -204,7 +234,7 @@ export function aggregateCodexPoolCapacity(
204
234
  const custom = quota?.customWindows ?? [];
205
235
  const hasQuota = hasKnownQuotaWindow(quota);
206
236
  if (!hasQuota) missingQuotaAccounts += 1;
207
- if (account.paused || account.needsReauth || weight === undefined || !quota || !hasQuota || !quotaFresh) continue;
237
+ if (account.paused || account.needsReauth || !quota || !hasQuota || !quotaFresh) continue;
208
238
 
209
239
  let contributed = false;
210
240
  const contributionKeys = new Set<string>();
@@ -44,6 +44,7 @@ export interface DerivedKeyLoginProvider {
44
44
  preserveReasoningContentModels?: string[];
45
45
  requiresReasoningPlaceholderModels?: string[];
46
46
  reasoningSplitModels?: string[];
47
+ reasoningDetailsModels?: string[];
47
48
  thinkingToggleModels?: string[];
48
49
  thinkingBudgetModels?: string[];
49
50
  escapeBuiltinToolNames?: boolean;
@@ -230,6 +231,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
230
231
  ...(liveModels !== undefined ? { liveModels } : {}),
231
232
  ...(entry.contextWindow !== undefined ? { contextWindow: entry.contextWindow } : {}),
232
233
  ...(entry.modelContextWindows ? { modelContextWindows: { ...entry.modelContextWindows } } : {}),
234
+ ...(entry.modelDisplayNames ? { modelDisplayNames: { ...entry.modelDisplayNames } } : {}),
233
235
  ...(entry.modelInputModalities ? { modelInputModalities: cloneRecordOfArrays(entry.modelInputModalities) } : {}),
234
236
  ...(entry.modelMaxInputTokens ? { modelMaxInputTokens: { ...entry.modelMaxInputTokens } } : {}),
235
237
  ...(entry.defaultMaxOutputTokens !== undefined ? { defaultMaxOutputTokens: entry.defaultMaxOutputTokens } : {}),
@@ -261,6 +263,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
261
263
  ...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}),
262
264
  ...(entry.requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels: [...entry.requiresReasoningPlaceholderModels] } : {}),
263
265
  ...(entry.reasoningSplitModels ? { reasoningSplitModels: [...entry.reasoningSplitModels] } : {}),
266
+ ...(entry.reasoningDetailsModels ? { reasoningDetailsModels: [...entry.reasoningDetailsModels] } : {}),
264
267
  ...(entry.thinkingToggleModels ? { thinkingToggleModels: [...entry.thinkingToggleModels] } : {}),
265
268
  ...(entry.thinkingBudgetModels ? { thinkingBudgetModels: [...entry.thinkingBudgetModels] } : {}),
266
269
  ...(entry.escapeBuiltinToolNames !== undefined ? { escapeBuiltinToolNames: entry.escapeBuiltinToolNames } : {}),
@@ -308,6 +311,7 @@ export function deriveKeyLoginMap(): Record<string, DerivedKeyLoginProvider> {
308
311
  ...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}),
309
312
  ...(entry.requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels: [...entry.requiresReasoningPlaceholderModels] } : {}),
310
313
  ...(entry.reasoningSplitModels ? { reasoningSplitModels: [...entry.reasoningSplitModels] } : {}),
314
+ ...(entry.reasoningDetailsModels ? { reasoningDetailsModels: [...entry.reasoningDetailsModels] } : {}),
311
315
  ...(entry.thinkingToggleModels ? { thinkingToggleModels: [...entry.thinkingToggleModels] } : {}),
312
316
  ...(entry.thinkingBudgetModels ? { thinkingBudgetModels: [...entry.thinkingBudgetModels] } : {}),
313
317
  ...(entry.escapeBuiltinToolNames !== undefined ? { escapeBuiltinToolNames: entry.escapeBuiltinToolNames } : {}),
@@ -475,6 +479,11 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
475
479
  if (prov.liveModels === undefined && seed.liveModels !== undefined) prov.liveModels = seed.liveModels;
476
480
  if (prov.contextWindow === undefined && seed.contextWindow !== undefined) prov.contextWindow = seed.contextWindow;
477
481
  if (!prov.modelContextWindows && seed.modelContextWindows) prov.modelContextWindows = { ...seed.modelContextWindows };
482
+ // Per-model fill, not all-or-nothing: an operator who renamed ONE model must still receive
483
+ // labels for the rest, and an existing install must pick up newly seeded rows on enrich.
484
+ if (seed.modelDisplayNames) {
485
+ prov.modelDisplayNames = { ...seed.modelDisplayNames, ...(prov.modelDisplayNames ?? {}) };
486
+ }
478
487
  if (seed.modelInputModalities) prov.modelInputModalities = fillRecordOfArrays(seed.modelInputModalities, prov.modelInputModalities);
479
488
  if (prov.defaultMaxOutputTokens === undefined && seed.defaultMaxOutputTokens !== undefined) prov.defaultMaxOutputTokens = seed.defaultMaxOutputTokens;
480
489
  if (!prov.modelMaxOutputTokens && seed.modelMaxOutputTokens) prov.modelMaxOutputTokens = { ...seed.modelMaxOutputTokens };
@@ -542,6 +551,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
542
551
  if (!prov.preserveReasoningContentModels && seed.preserveReasoningContentModels) prov.preserveReasoningContentModels = [...seed.preserveReasoningContentModels];
543
552
  if (!prov.requiresReasoningPlaceholderModels && seed.requiresReasoningPlaceholderModels) prov.requiresReasoningPlaceholderModels = [...seed.requiresReasoningPlaceholderModels];
544
553
  if (!prov.reasoningSplitModels && seed.reasoningSplitModels) prov.reasoningSplitModels = [...seed.reasoningSplitModels];
554
+ if (!prov.reasoningDetailsModels && seed.reasoningDetailsModels) prov.reasoningDetailsModels = [...seed.reasoningDetailsModels];
545
555
  if (!prov.thinkingToggleModels && seed.thinkingToggleModels) prov.thinkingToggleModels = [...seed.thinkingToggleModels];
546
556
  if (!prov.thinkingBudgetModels && seed.thinkingBudgetModels) prov.thinkingBudgetModels = [...seed.thinkingBudgetModels];
547
557
  if (prov.escapeBuiltinToolNames === undefined && seed.escapeBuiltinToolNames !== undefined) prov.escapeBuiltinToolNames = seed.escapeBuiltinToolNames;
@@ -14,6 +14,9 @@ const FAST_WIRE_ADAPTERS: Readonly<Record<FastWire["kind"], ReadonlySet<string>>
14
14
  "service-tier": SERVICE_TIER_ADAPTERS,
15
15
  // A1 deliberately has no adapter implementation for Anthropic speed.
16
16
  "anthropic-speed": new Set(),
17
+ // Cursor expresses Fast as a variant dimension of the picked model, resolved in the
18
+ // request builder, so the adapter set is exactly the cursor adapter.
19
+ "cursor-variant": new Set(["cursor"]),
17
20
  };
18
21
 
19
22
  const DEFAULT_SERVICE_TIER_FAST_WIRE: FastWire = Object.freeze({
@@ -209,8 +212,13 @@ export function resolveFastPolicy(
209
212
  // On classified routes this permission applies only to a caller's foreign tier: proxy-owned
210
213
  // canonical Fast has already passed capability validation. On unclassified routes every caller
211
214
  // tier still needs the final wire's forwarding permission.
215
+ // A wire that declares `foreignCallerTiers: "drop"` cannot carry an arbitrary tier string at
216
+ // all — cursor-variant resolves a MODEL VARIANT, so there is nothing to forward a foreign
217
+ // value into. Without this, an unclassified route on such a wire projects "unknown" support
218
+ // and Codex would show a Fast toggle on a base that has no fast variant.
212
219
  const forwardCallerTier = capability !== false
213
220
  && callerWireAvailable
221
+ && fastWire?.foreignCallerTiers !== "drop"
214
222
  && forwardCallerServiceTier !== false
215
223
  && (adapter !== "openai-chat" || authority.capability.chatServiceTier === true);
216
224
 
@@ -467,8 +475,8 @@ export function fastWireDeclarationError(source: {
467
475
  }
468
476
  if (value === null) return null;
469
477
  if (!isPlainRecord(value)) return "fastWire must be an object, null, or absent";
470
- if (value.kind !== "service-tier" && value.kind !== "anthropic-speed") {
471
- return "fastWire.kind must be service-tier or anthropic-speed";
478
+ if (value.kind !== "service-tier" && value.kind !== "anthropic-speed" && value.kind !== "cursor-variant") {
479
+ return "fastWire.kind must be service-tier, anthropic-speed, or cursor-variant";
472
480
  }
473
481
  if (value.foreignCallerTiers !== "verbatim" && value.foreignCallerTiers !== "drop") {
474
482
  return "fastWire.foreignCallerTiers must be verbatim or drop";