@bitkyc08/opencodex 2.46.0 → 2.47.0-preview.20260908

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 (66) hide show
  1. package/README.md +22 -0
  2. package/SPONSORS.md +105 -0
  3. package/gui/dist/assets/index-B8ZZ0ivI.css +1 -0
  4. package/gui/dist/assets/index-CNTWhC5F.js +115 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +2 -1
  7. package/src/adapters/cursor/protobuf-request.ts +34 -21
  8. package/src/adapters/cursor/tool-guidance.ts +2 -2
  9. package/src/adapters/cursor/tool-result-normalize.ts +22 -2
  10. package/src/adapters/exec-tool-result-normalize.ts +87 -0
  11. package/src/adapters/kiro.ts +29 -18
  12. package/src/adapters/responses-code-mode.ts +14 -4
  13. package/src/adapters/tool-catalog-nudge.ts +2 -2
  14. package/src/bridge.ts +40 -24
  15. package/src/claude/inbound.ts +21 -6
  16. package/src/claude/outbound.ts +43 -26
  17. package/src/cli/capabilities.ts +27 -0
  18. package/src/cli/integrations.ts +1 -1
  19. package/src/cli/models-runtime-subcommands.ts +2 -0
  20. package/src/cli/models-runtime.ts +108 -1
  21. package/src/cli/observe.ts +18 -3
  22. package/src/cli/usage-report.ts +6 -1
  23. package/src/clients/config-export.ts +5 -3
  24. package/src/codex/auth-api.ts +36 -0
  25. package/src/codex/catalog/provider-fetch.ts +2 -1
  26. package/src/codex/quota-auto-refresh-state.ts +8 -0
  27. package/src/codex/quota-auto-refresh.ts +157 -27
  28. package/src/codex/warmup.ts +5 -0
  29. package/src/config/provider-validation.ts +70 -1
  30. package/src/config.ts +73 -1
  31. package/src/generated/compatibility-version.json +67 -55
  32. package/src/lib/json-byte-size.ts +61 -0
  33. package/src/lib/provider-outbound.ts +11 -10
  34. package/src/oauth/index.ts +42 -8
  35. package/src/oauth/orcarouter.ts +200 -0
  36. package/src/providers/key-store.ts +22 -0
  37. package/src/providers/registry.ts +78 -29
  38. package/src/responses/citation-markers.ts +56 -24
  39. package/src/responses/reasoning-envelope.ts +53 -19
  40. package/src/server/auth-cors.ts +8 -0
  41. package/src/server/chat-completions.ts +7 -0
  42. package/src/server/chat-native.ts +65 -3
  43. package/src/server/claude-messages.ts +30 -10
  44. package/src/server/effort-policy.ts +183 -1
  45. package/src/server/management/agent-settings-routes.ts +50 -9
  46. package/src/server/management/config-routes.ts +2 -0
  47. package/src/server/management/logs-usage-routes.ts +11 -3
  48. package/src/server/management/model-routes.ts +65 -1
  49. package/src/server/management/model-rows.ts +5 -0
  50. package/src/server/management/provider-routes.ts +99 -6
  51. package/src/server/management/route-registry.ts +2 -0
  52. package/src/server/management/usage-aggregate-cache.ts +14 -7
  53. package/src/server/responses/compact.ts +3 -2
  54. package/src/server/responses/core.ts +16 -2
  55. package/src/server/startup-health-cache.ts +31 -9
  56. package/src/types/config.ts +5 -0
  57. package/src/types/provider.ts +4 -0
  58. package/src/usage/cost.ts +27 -31
  59. package/src/usage/summary.ts +52 -9
  60. package/src/usage/time-range.ts +48 -0
  61. package/src/usage/user-cost-overlays.ts +35 -3
  62. package/src/vision/anthropic-describe.ts +46 -2
  63. package/src/web-search/anthropic-executor.ts +49 -3
  64. package/src/web-search/parse.ts +1 -1
  65. package/gui/dist/assets/index-B72RPblC.js +0 -115
  66. package/gui/dist/assets/index-BFgUC17B.css +0 -1
@@ -0,0 +1,61 @@
1
+ import { TRANSLATOR_MAX_TURN_BYTES, TranslatorBudgetExceededError } from "./translator-budget";
2
+
3
+ /** Measure plain JSON data without allocating its serialized string or UTF-8 copy. */
4
+ export function jsonUtf8Bytes(value: unknown, limit = TRANSLATOR_MAX_TURN_BYTES): number {
5
+ let bytes = 0;
6
+ const add = (count: number) => {
7
+ if (count > limit - bytes) throw new TranslatorBudgetExceededError("request_copies", limit);
8
+ bytes += count;
9
+ };
10
+ const string = (text: string) => {
11
+ // Every UTF-16 code unit needs at least one JSON UTF-8 byte; reject large inputs
12
+ // before walking them. Escapes and unpaired surrogates are counted below.
13
+ if (text.length + 2 > limit - bytes) throw new TranslatorBudgetExceededError("request_copies", limit);
14
+ add(2);
15
+ for (let i = 0; i < text.length; i++) {
16
+ const code = text.charCodeAt(i);
17
+ if (code === 0x22 || code === 0x5c || code === 8 || code === 9 || code === 10 || code === 12 || code === 13) add(2);
18
+ else if (code < 0x20) add(6);
19
+ else if (code < 0x80) add(1);
20
+ else if (code < 0x800) add(2);
21
+ else if (code >= 0xd800 && code <= 0xdbff) {
22
+ const next = text.charCodeAt(i + 1);
23
+ if (next >= 0xdc00 && next <= 0xdfff) { add(4); i++; }
24
+ else add(6);
25
+ } else if (code >= 0xdc00 && code <= 0xdfff) add(6);
26
+ else add(3);
27
+ }
28
+ };
29
+ const visit = (item: unknown): void => {
30
+ if (item === null) { add(4); return; }
31
+ if (typeof item === "string") { string(item); return; }
32
+ if (typeof item === "boolean") { add(item ? 4 : 5); return; }
33
+ if (typeof item === "number") { add(Number.isFinite(item) ? String(item).length : 4); return; }
34
+ if (Array.isArray(item)) {
35
+ add(2);
36
+ for (let i = 0; i < item.length; i++) {
37
+ if (i > 0) add(1);
38
+ if (item[i] === undefined) add(4);
39
+ else visit(item[i]);
40
+ }
41
+ return;
42
+ }
43
+ if (typeof item === "object" && item !== null) {
44
+ add(2);
45
+ let first = true;
46
+ for (const key of Object.keys(item)) {
47
+ const field = (item as Record<string, unknown>)[key];
48
+ if (field === undefined) continue;
49
+ if (!first) add(1);
50
+ first = false;
51
+ string(key);
52
+ add(1);
53
+ visit(field);
54
+ }
55
+ return;
56
+ }
57
+ throw new TypeError("Expected plain JSON data for translation sizing");
58
+ };
59
+ visit(value);
60
+ return bytes;
61
+ }
@@ -42,7 +42,7 @@ function pickPinnedAddress(addresses: Array<{ address: string; family: number }>
42
42
  *
43
43
  * Under TUN mode the packet path intercepts the fake-IP destination itself, so a
44
44
  * canonical registry destination whose local DNS answers include Clash fake-IP
45
- * space (198.18.0.0/15) is reachable by pin-connecting through the TUN — no
45
+ * space (198.18.0.0/15 or fdfe:dcba:9876::/48) is reachable by pin-connecting through the TUN — no
46
46
  * outbound HTTP(S) proxy env is required. The exception is deliberately narrow:
47
47
  *
48
48
  * - hostname-only: a literal 198.18.x.x URL never reaches it (the literal gate
@@ -143,11 +143,12 @@ async function providerOutboundRequest(
143
143
  // below reason about the same value. `null` here means "no proxy fetch would actually use",
144
144
  // even if some other proxy variable is set.
145
145
  const effectiveProxy = effectiveProxyFor(parsed);
146
- const allowMihomoIpv6FakeIp = effectiveProxy !== null && !noProxyMatches(parsed);
146
+ const isCanonicalUrl = dependencies.isCanonicalUrl ?? (() => false);
147
+ const allowMihomoIpv6FakeIp = (effectiveProxy !== null && !noProxyMatches(parsed))
148
+ || transparentFakeIpException(url, parsed, isCanonicalUrl, name);
147
149
  const resolveAddresses = dependencies.resolveAddresses ?? resolvePublicAddresses;
148
150
  const pinnedGet = dependencies.pinnedGet ?? pinnedHttpGet;
149
151
  const pinnedPost = dependencies.pinnedPost ?? pinnedHttpPost;
150
- const isCanonicalUrl = dependencies.isCanonicalUrl ?? (() => false);
151
152
  const allowPrivate = providerAllowsPrivateNetwork(name, provider);
152
153
  let resolved: Awaited<ReturnType<typeof resolvePublicAddresses>>;
153
154
  try {
@@ -169,11 +170,9 @@ async function providerOutboundRequest(
169
170
  // pinned to the registry destination independently.
170
171
  allowBenchmarkAddresses: (proxyConfigured && !noProxyMatches(parsed))
171
172
  || transparentFakeIpException(url, parsed, isCanonicalUrl, name),
172
- // Mihomo IPv6 fake-IP (fdfe:dcba:9876::/48) answers are admitted on a stricter gate
173
- // than the benchmark range: the proxy must be the one fetch will use for this URL's
174
- // scheme, and the request below is then bound to it explicitly (#3462). A ULA answer
175
- // is otherwise indistinguishable from a real private host, so proxy presence alone
176
- // is not enough.
173
+ // Mihomo IPv6 fake-IP (fdfe:dcba:9876::/48) answers are admitted either when bound
174
+ // to a scheme-matched proxy (#3462) or under the TUN transparency exception for a
175
+ // canonical registry/accounting destination.
177
176
  allowMihomoIpv6FakeIp,
178
177
  });
179
178
  } catch (error) {
@@ -187,11 +186,13 @@ async function providerOutboundRequest(
187
186
  warnProxyDnsDegradationOnce();
188
187
  return globalThis.fetch(url, { ...init, method, redirect: "manual" });
189
188
  }
190
- if (proxyConfigured && !resolved.privateNetwork) {
189
+ // A canonical TUN exception with no scheme-matched proxy must retain the
190
+ // validated address, even when an unrelated HTTP_PROXY/ALL_PROXY is present.
191
+ if (proxyConfigured && !resolved.privateNetwork && (effectiveProxy !== null || !allowMihomoIpv6FakeIp)) {
191
192
  warnProxyBoundaryOnce();
192
193
  // When the Mihomo exception could have admitted an answer, pin the transport to the
193
194
  // proxy the admission assumed instead of letting fetch re-infer it from the environment.
194
- const proxy = allowMihomoIpv6FakeIp ? effectiveProxy : undefined;
195
+ const proxy = (allowMihomoIpv6FakeIp && effectiveProxy) ? effectiveProxy : undefined;
195
196
  return globalThis.fetch(url, { ...init, method, redirect: "manual", ...(proxy ? { proxy } : {}) });
196
197
  }
197
198
  if (proxyConfigured && resolved.privateNetwork && !noProxyMatches(parsed)) {
@@ -41,6 +41,7 @@ import { loginCursor, refreshCursorToken } from "./cursor";
41
41
  import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot";
42
42
  import { loginCommandCode, refreshCommandCodeToken } from "./command-code";
43
43
  import { loginMetaMuse, refreshMetaMuseToken } from "./meta-muse";
44
+ import { loginOrcaRouter, orcaRouterInferenceBaseUrl, refreshOrcaRouterKey } from "./orcarouter";
44
45
  import { ANTIGRAVITY_REQUEST_UA } from "../adapters/google-antigravity-wire";
45
46
  import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive";
46
47
  import { apiKeyPoolEntryId, sanitizeApiKeyValue } from "../providers/api-keys";
@@ -180,7 +181,7 @@ export interface LoginFlowLifecycle {
180
181
  }
181
182
 
182
183
  interface OAuthProviderDef {
183
- login(ctrl: OAuthController, opts?: LoginOpts): Promise<OAuthCredentials>;
184
+ login(ctrl: OAuthController, opts?: LoginOpts, providerConfig?: OcxProviderConfig): Promise<OAuthCredentials>;
184
185
  refresh(
185
186
  refreshToken: string,
186
187
  signal?: AbortSignal,
@@ -188,6 +189,8 @@ interface OAuthProviderDef {
188
189
  ): Promise<OAuthCredentials>;
189
190
  /** provider entry written into config.json on first login. */
190
191
  providerConfig: OcxProviderConfig;
192
+ /** Resolve login-owned config from the latest disk state (for configurable OAuth origins). */
193
+ resolveProviderConfig?: (config: OcxConfig) => OcxProviderConfig;
191
194
  defaultModel: string;
192
195
  /**
193
196
  * Built-in proactive-refresh policy, risk-tiered by the provider's ToS exposure (devlog
@@ -218,6 +221,27 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
218
221
  defaultModel: oauthDefaultModel("command-code"),
219
222
  defaultRefreshPolicy: "disabled",
220
223
  },
224
+ "orcarouter-oauth": {
225
+ login: (ctrl, _opts, providerConfig) => loginOrcaRouter(ctrl, {
226
+ baseUrl: process.env.ORCAROUTER_API_BASE_URL
227
+ ?? process.env.ORCAROUTER_BASE_URL
228
+ ?? providerConfig?.baseUrl,
229
+ authBaseUrl: process.env.ORCAROUTER_AUTH_BASE_URL,
230
+ }),
231
+ refresh: refreshOrcaRouterKey,
232
+ providerConfig: oauthConfig("orcarouter-oauth"),
233
+ resolveProviderConfig: config => ({
234
+ ...oauthConfig("orcarouter-oauth"),
235
+ baseUrl: orcaRouterInferenceBaseUrl(
236
+ process.env.ORCAROUTER_API_BASE_URL
237
+ ?? process.env.ORCAROUTER_BASE_URL
238
+ ?? config.providers["orcarouter-oauth"]?.baseUrl,
239
+ ),
240
+ }),
241
+ defaultModel: oauthDefaultModel("orcarouter-oauth"),
242
+ // The credential is a durable API key. There is no refresh endpoint.
243
+ defaultRefreshPolicy: "disabled",
244
+ },
221
245
  xai: {
222
246
  // forceLogin skips the local grok-cli import so a SECOND account can be chosen in the browser.
223
247
  login: (ctrl, opts) => loginXai(ctrl, { importLocal: opts?.forceLogin ? "off" : "fallback" }),
@@ -548,7 +572,13 @@ export async function getValidAccessTokenSnapshot(provider: string): Promise<OAu
548
572
  }
549
573
 
550
574
  /** Providers whose upstream-401 replay path may force a snapshot refresh. */
551
- const FORCE_REFRESH_PROVIDERS = new Set(["xai", "github-copilot", "kiro", "google-antigravity"]);
575
+ const FORCE_REFRESH_PROVIDERS = new Set([
576
+ "xai",
577
+ "github-copilot",
578
+ "kiro",
579
+ "google-antigravity",
580
+ "orcarouter-oauth",
581
+ ]);
552
582
 
553
583
  export async function forceRefreshOAuthAccessSnapshot(
554
584
  rejected: OAuthAccessSnapshot,
@@ -1442,10 +1472,11 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
1442
1472
  const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, provider);
1443
1473
  if (namespaceCollision) throw new Error(namespaceCollision);
1444
1474
  const existing = config.providers[provider];
1475
+ const providerConfig = def.resolveProviderConfig?.(config) ?? def.providerConfig;
1445
1476
  // Clone operator state, including xAI wire choices and their migration version.
1446
- const next: OcxProviderConfig = structuredClone(existing ?? def.providerConfig);
1477
+ const next: OcxProviderConfig = structuredClone(existing ?? providerConfig);
1447
1478
  for (const field of OAUTH_LOGIN_OWNED_PROVIDER_FIELDS) {
1448
- const value = def.providerConfig[field];
1479
+ const value = providerConfig[field];
1449
1480
  if (value === undefined) delete next[field];
1450
1481
  else next[field] = structuredClone(value) as never;
1451
1482
  }
@@ -1454,7 +1485,7 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
1454
1485
  if (next.googleMode === "cloud-code-assist") delete next.project;
1455
1486
  // Login used to rebuild the whole row from the preset, so catalog data refreshed
1456
1487
  // immediately. Keep that timing without overwriting unrelated operator-owned fields.
1457
- applyOAuthPresetCatalog(next, def.providerConfig);
1488
+ applyOAuthPresetCatalog(next, providerConfig);
1458
1489
  // The original Command Code seed was an implementation-owned static catalog, not an
1459
1490
  // operator opt-out. Promote that exact legacy shape when OAuth login refreshes the row.
1460
1491
  if (provider === "command-code" && existing && isLegacyCommandCodeStaticCatalog(existing)) {
@@ -1532,8 +1563,8 @@ export async function runLogin(
1532
1563
  if (!def) throw new UnsupportedOAuthProviderError(provider);
1533
1564
  const loadLatestConfig = deps.loadConfig ?? loadConfig;
1534
1565
  const saveLatestConfig = deps.saveConfig ?? saveConfig;
1535
- if (provider !== "chatgpt") {
1536
- const preflightConfig = loadLatestConfig();
1566
+ const preflightConfig = provider !== "chatgpt" ? loadLatestConfig() : undefined;
1567
+ if (preflightConfig) {
1537
1568
  const namespaceCollision = codexAccountNamespaceProviderCollisionError(
1538
1569
  preflightConfig.codexAccountNamespaces,
1539
1570
  provider,
@@ -1546,7 +1577,10 @@ export async function runLogin(
1546
1577
  const previousKiroAccounts = shouldRollbackKiroAccounts ? getAccountSet(provider) : undefined;
1547
1578
  const previousKiroActiveId = previousKiroAccounts?.activeAccountId;
1548
1579
  const previousKiroAccountIds = new Set(previousKiroAccounts?.accounts.map(account => account.id) ?? []);
1549
- const rawCred = await def.login(ctrl, opts);
1580
+ const loginProviderConfig = preflightConfig
1581
+ ? (def.resolveProviderConfig?.(preflightConfig) ?? preflightConfig.providers[provider] ?? def.providerConfig)
1582
+ : def.providerConfig;
1583
+ const rawCred = await def.login(ctrl, opts, loginProviderConfig);
1550
1584
  const cred: OAuthCredentials = rawCred.source ? rawCred : { ...rawCred, source: "oauth" };
1551
1585
  const settleKiroTransaction = deps.settleKiroLoginTransaction ?? settleKiroLoginTransaction;
1552
1586
  try {
@@ -0,0 +1,200 @@
1
+ /** OrcaRouter browser authorization: OAuth-style consent + PKCE, yielding a durable API key. */
2
+ import { OAuthCallbackFlow, type OAuthCallbackFlowOptions } from "./callback-server";
3
+ import { generatePKCE } from "./pkce";
4
+ import type { OAuthController, OAuthCredentials } from "./types";
5
+
6
+ export const ORCAROUTER_DEFAULT_API_BASE_URL = "https://api.orcarouter.ai";
7
+ export const ORCAROUTER_DEFAULT_AUTH_BASE_URL = "https://www.orcarouter.ai";
8
+ /** Backwards-compatible name for the inference/API origin. */
9
+ export const ORCAROUTER_DEFAULT_BASE_URL = ORCAROUTER_DEFAULT_API_BASE_URL;
10
+ const ORCAROUTER_CALLBACK_PORT = 51733;
11
+ const ORCAROUTER_CALLBACK_PATH = "/callback";
12
+ const ORCAROUTER_KEY_PREFIX = "sk-orca-";
13
+ const TOKEN_REQUEST_TIMEOUT_MS = 30_000;
14
+
15
+ export interface OrcaRouterLoginOptions {
16
+ /** Inference base URL. A non-public value also acts as the auth origin for one-origin self-hosting. */
17
+ baseUrl?: string;
18
+ /** Optional dedicated auth origin; the public service defaults to www.orcarouter.ai. */
19
+ authBaseUrl?: string;
20
+ }
21
+
22
+ interface OrcaRouterKeyPayload {
23
+ key?: unknown;
24
+ user_id?: unknown;
25
+ scope?: unknown;
26
+ }
27
+
28
+ function requestSignal(signal: AbortSignal | undefined): AbortSignal {
29
+ const timeout = AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS);
30
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
31
+ }
32
+
33
+ /**
34
+ * Resolve the one configurable OrcaRouter origin used by both auth and inference.
35
+ * Plain HTTP is accepted only on loopback so a long-lived key is never sent over a
36
+ * clear-text remote connection by a typo in `ORCAROUTER_BASE_URL`.
37
+ */
38
+ export function normalizeOrcaRouterBaseUrl(raw = ORCAROUTER_DEFAULT_BASE_URL): string {
39
+ let parsed: URL;
40
+ try {
41
+ parsed = new URL(raw.trim());
42
+ } catch {
43
+ // Do not echo malformed input: it may contain credentials pasted into the URL.
44
+ throw new Error("OrcaRouter base URL is invalid");
45
+ }
46
+ const hostname = parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase();
47
+ const loopback = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
48
+ if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && loopback)) {
49
+ throw new Error("OrcaRouter base URL must use HTTPS (HTTP is allowed only on loopback)");
50
+ }
51
+ if (parsed.username || parsed.password || parsed.search || parsed.hash) {
52
+ throw new Error("OrcaRouter base URL must not contain credentials, a query, or a fragment");
53
+ }
54
+ const path = parsed.pathname.replace(/\/+$/, "");
55
+ if (path && path !== "/v1") {
56
+ throw new Error("OrcaRouter base URL path must be empty or /v1");
57
+ }
58
+ return parsed.origin;
59
+ }
60
+
61
+ export function orcaRouterInferenceBaseUrl(raw?: string): string {
62
+ return `${normalizeOrcaRouterBaseUrl(raw)}/v1`;
63
+ }
64
+
65
+ export function orcaRouterAuthBaseUrl(apiBaseUrl?: string, authBaseUrl?: string): string {
66
+ if (authBaseUrl) return normalizeOrcaRouterBaseUrl(authBaseUrl);
67
+ const apiOrigin = normalizeOrcaRouterBaseUrl(apiBaseUrl);
68
+ return apiOrigin === ORCAROUTER_DEFAULT_API_BASE_URL
69
+ ? ORCAROUTER_DEFAULT_AUTH_BASE_URL
70
+ : apiOrigin;
71
+ }
72
+
73
+ function parseKeyPayload(value: unknown): OAuthCredentials {
74
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
75
+ throw new Error("OrcaRouter key exchange returned an invalid response");
76
+ }
77
+ const payload = value as OrcaRouterKeyPayload;
78
+ const key = typeof payload.key === "string" ? payload.key.trim() : "";
79
+ if (!key.startsWith(ORCAROUTER_KEY_PREFIX) || key.length > 4096 || /[\r\n]/.test(key)) {
80
+ throw new Error("OrcaRouter key exchange did not return a valid API key");
81
+ }
82
+ // The documented key/user_id response omits scope. If supplied, it must match
83
+ // the api scope requested by this PKCE flow.
84
+ if (payload.scope !== undefined && payload.scope !== "api") {
85
+ throw new Error("OrcaRouter key exchange did not grant the required api scope");
86
+ }
87
+ const accountId = typeof payload.user_id === "string"
88
+ ? payload.user_id.trim()
89
+ : typeof payload.user_id === "number" && Number.isSafeInteger(payload.user_id)
90
+ ? String(payload.user_id)
91
+ : "";
92
+ if (!accountId || accountId.length > 256 || /[\x00-\x1f\x7f]/.test(accountId)) {
93
+ throw new Error("OrcaRouter key exchange did not return a valid user id");
94
+ }
95
+ // OrcaRouter issues a normal long-lived API key, not a refresh token. The OAuth
96
+ // store requires both fields, so mirror the established Command Code key-grant
97
+ // representation. `expires` prevents background refresh; an upstream 401 asks the
98
+ // user to reconnect and mint a replacement key.
99
+ return {
100
+ access: key,
101
+ refresh: key,
102
+ expires: Number.MAX_SAFE_INTEGER,
103
+ accountId,
104
+ source: "oauth",
105
+ };
106
+ }
107
+
108
+ function assertDurableApiKey(apiKey: string): void {
109
+ const key = apiKey.trim();
110
+ if (!key.startsWith(ORCAROUTER_KEY_PREFIX) || key.length > 4096 || /[\r\n]/.test(key)) {
111
+ throw new Error("OrcaRouter API key is invalid; reconnect with ocx login orcarouter-oauth");
112
+ }
113
+ }
114
+
115
+ export class OrcaRouterOAuthFlow extends OAuthCallbackFlow {
116
+ readonly #authBaseUrl: string;
117
+ #verifier = "";
118
+
119
+ constructor(ctrl: OAuthController, options: OrcaRouterLoginOptions = {}) {
120
+ super(ctrl, {
121
+ preferredPort: ORCAROUTER_CALLBACK_PORT,
122
+ callbackPath: ORCAROUTER_CALLBACK_PATH,
123
+ callbackHostname: "127.0.0.1",
124
+ callbackBindHostname: "127.0.0.1",
125
+ } satisfies OAuthCallbackFlowOptions);
126
+ this.#authBaseUrl = orcaRouterAuthBaseUrl(options.baseUrl, options.authBaseUrl);
127
+ }
128
+
129
+ async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions: string }> {
130
+ const pkce = await generatePKCE();
131
+ this.#verifier = pkce.verifier;
132
+ const url = new URL("/auth", this.#authBaseUrl);
133
+ url.search = new URLSearchParams({
134
+ callback_url: redirectUri,
135
+ code_challenge: pkce.challenge,
136
+ code_challenge_method: "S256",
137
+ state,
138
+ app_name: "OpenCodex",
139
+ scope: "api",
140
+ }).toString();
141
+ return {
142
+ url: url.toString(),
143
+ instructions:
144
+ "Approve access in your browser. If the browser cannot reach this machine, choose the displayed-code option and paste the code here.",
145
+ };
146
+ }
147
+
148
+ async exchangeToken(code: string, _state: string, _redirectUri: string): Promise<OAuthCredentials> {
149
+ if (!this.#verifier) throw new Error("OrcaRouter PKCE verifier was not initialized");
150
+ let response: Response;
151
+ try {
152
+ response = await fetch(new URL("/api/v1/auth/keys", this.#authBaseUrl), {
153
+ method: "POST",
154
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
155
+ body: JSON.stringify({
156
+ code,
157
+ code_verifier: this.#verifier,
158
+ code_challenge_method: "S256",
159
+ }),
160
+ redirect: "error",
161
+ signal: requestSignal(this.ctrl.signal),
162
+ });
163
+ } catch (error) {
164
+ if (this.ctrl.signal?.aborted) {
165
+ throw this.ctrl.signal.reason ?? new DOMException("OrcaRouter login aborted", "AbortError");
166
+ }
167
+ throw new Error("OrcaRouter key exchange failed: network error", { cause: error });
168
+ }
169
+ if (!response.ok) {
170
+ // The body is deliberately not reflected: authentication error payloads must
171
+ // never turn a code, verifier, or accidentally returned key into console output.
172
+ throw new Error(`OrcaRouter key exchange failed with HTTP ${response.status}`);
173
+ }
174
+ let payload: unknown;
175
+ try {
176
+ payload = await response.json();
177
+ } catch {
178
+ throw new Error("OrcaRouter key exchange returned invalid JSON");
179
+ }
180
+ return parseKeyPayload(payload);
181
+ }
182
+ }
183
+
184
+ export async function loginOrcaRouter(
185
+ ctrl: OAuthController,
186
+ options: OrcaRouterLoginOptions = {},
187
+ ): Promise<OAuthCredentials> {
188
+ if (ctrl.signal?.aborted) {
189
+ throw ctrl.signal.reason ?? new DOMException("OrcaRouter login aborted", "AbortError");
190
+ }
191
+ return new OrcaRouterOAuthFlow(ctrl, options).login();
192
+ }
193
+
194
+ export async function refreshOrcaRouterKey(apiKey: string): Promise<never> {
195
+ assertDurableApiKey(apiKey);
196
+ // This hook is reached only after upstream rejected the durable key. There is no refresh
197
+ // grant to replay, so classify the credential as terminal and let the shared generation-safe
198
+ // refresh path mark this exact account as needing a new browser login.
199
+ throw new Error("invalid_grant: OrcaRouter API keys cannot be refreshed; reconnect with ocx login orcarouter-oauth");
200
+ }
@@ -64,6 +64,16 @@ function keychainAccount(reference: string): string {
64
64
  return reference.slice(KEYCHAIN_REFERENCE_PREFIX.length);
65
65
  }
66
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
+
67
77
  function readKeychain(account: string): string | undefined {
68
78
  const cached = resolvedCache.get(account);
69
79
  if (cached !== undefined) return cached;
@@ -185,6 +195,18 @@ export function restoreProviderKeyFromKeychain(config: OcxConfig, name: string):
185
195
  const pool = provider.apiKeyPool ?? [];
186
196
  const resolved = new Map<string, string>();
187
197
  const refs = [provider.apiKey, ...pool.map(e => e.key)].filter(isKeychainReference);
198
+ // Restore reads a secret out of the keychain, writes it back to config as plaintext, and then
199
+ // DELETES the keychain item. Following a reference to another provider's account would both
200
+ // disclose that secret through this provider's config and destroy the real owner's credential,
201
+ // so refuse before anything is read or removed.
202
+ const foreign = refs.filter(ref => !keychainReferenceBelongsToProvider(ref, name));
203
+ if (foreign.length > 0) {
204
+ return {
205
+ ok: false,
206
+ error: `provider "${name}" references a keychain account it does not own (${foreign.length} reference(s)); config left unchanged`,
207
+ status: 400,
208
+ };
209
+ }
188
210
  for (const ref of refs) {
189
211
  const account = keychainAccount(ref);
190
212
  if (resolved.has(account)) continue;
@@ -1122,6 +1122,45 @@ const CLINE_PASS_MODELS = [
1122
1122
  "cline-pass/qwen3.7-max",
1123
1123
  "cline-pass/qwen3.7-plus",
1124
1124
  ];
1125
+
1126
+ const ORCAROUTER_MODEL_DISCOVERY: ProviderModelDiscoverySpec = {
1127
+ path: "models",
1128
+ query: { capability: "chat" },
1129
+ maxResponseBytes: 512 * 1024,
1130
+ maxModels: 512,
1131
+ filter: {
1132
+ anyOf: [{
1133
+ path: ["supported_endpoint_types"],
1134
+ containsAny: ["openai", "openai-response", "anthropic", "gemini"],
1135
+ caseInsensitive: true,
1136
+ }],
1137
+ noneOf: [{
1138
+ path: ["supported_endpoint_types"],
1139
+ containsAny: ["image-generation", "openai-video", "jina-rerank"],
1140
+ caseInsensitive: true,
1141
+ }],
1142
+ },
1143
+ };
1144
+ // Preserve the previously verified cold-start catalog. Live discovery remains authoritative
1145
+ // when it succeeds, but a temporary catalog outage must not erase the provider's known-good
1146
+ // selectors from the picker. `orcarouter/auto` is intentionally retained here even though the
1147
+ // public catalog did not enumerate it at the latest verification (2026-09-07).
1148
+ const ORCAROUTER_MODELS = [
1149
+ "openai/gpt-5.5",
1150
+ "anthropic/claude-opus-4.8",
1151
+ "google/gemini-3.5-flash",
1152
+ "deepseek/deepseek-v4-pro",
1153
+ "orcarouter/auto",
1154
+ ];
1155
+ const ORCAROUTER_TEXT_ONLY_MODELS = ["deepseek/deepseek-v4-pro"];
1156
+ const ORCAROUTER_MODEL_REASONING_EFFORTS = {
1157
+ // Live /models currently exposes ids and modalities, not the accepted reasoning ladder.
1158
+ "openai/gpt-5.5": ["low", "medium", "high", "xhigh"],
1159
+ "deepseek/deepseek-v4-pro": deepseekThinkingEffortsFor("deepseek/deepseek-v4-pro"),
1160
+ };
1161
+ const ORCAROUTER_MODEL_REASONING_EFFORT_MAP = {
1162
+ "deepseek/deepseek-v4-pro": deepseekReasoningMapFor("deepseek/deepseek-v4-pro"),
1163
+ };
1125
1164
  const CLINE_PASS_MODEL_CONTEXT_WINDOWS: Record<string, number> = {
1126
1165
  "cline-pass/glm-5.3": 1_048_576,
1127
1166
  "cline-pass/glm-5.3-flash": 1_048_576,
@@ -1360,6 +1399,25 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1360
1399
  // The proprietary generate wire has no verified per-request serialization flag.
1361
1400
  parallelToolCalls: false,
1362
1401
  },
1402
+ {
1403
+ id: "orcarouter-oauth",
1404
+ label: "OrcaRouter - Auth",
1405
+ adapter: "openai-chat",
1406
+ baseUrl: "https://api.orcarouter.ai/v1",
1407
+ authKind: "oauth",
1408
+ oauthId: "orcarouter-oauth",
1409
+ featured: true,
1410
+ allowBaseUrlOverride: true,
1411
+ defaultModel: "openai/gpt-5.5",
1412
+ models: ORCAROUTER_MODELS,
1413
+ liveModels: true,
1414
+ modelDiscovery: ORCAROUTER_MODEL_DISCOVERY,
1415
+ noVisionModels: ORCAROUTER_TEXT_ONLY_MODELS,
1416
+ modelReasoningEfforts: ORCAROUTER_MODEL_REASONING_EFFORTS,
1417
+ modelReasoningEffortMap: ORCAROUTER_MODEL_REASONING_EFFORT_MAP,
1418
+ preserveReasoningContentModels: ORCAROUTER_TEXT_ONLY_MODELS,
1419
+ note: "Connect your OrcaRouter account with OAuth 2.0 + PKCE; the issued API key is stored in OpenCodex's existing credential store.",
1420
+ },
1363
1421
  {
1364
1422
  id: "anthropic",
1365
1423
  label: "Anthropic Claude",
@@ -1834,37 +1892,23 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1834
1892
  note: "Cline usage-billing API: one key, 100+ models, OpenRouter-style ids. Promotional free models are IDE/CLI-only per Cline docs; minimax/minimax-m2.5 is the documented API free experimentation model.",
1835
1893
  },
1836
1894
  {
1837
- // OrcaRouter: OpenAI-compatible adaptive router (api.orcarouter.ai). Model ids are
1838
- // vendor-namespaced (`<vendor>/<model>`) and pass through to the upstream as-is.
1839
- // The default pins a tool-capable model; the adaptive `orcarouter/auto` router is also
1840
- // selectable. Live-verified 2026-07-20: /v1/chat/completions accepts the `tools` field
1841
- // and routes to a function-calling-capable upstream.
1842
- id: "orcarouter", label: "OrcaRouter", adapter: "openai-chat", baseUrl: "https://api.orcarouter.ai/v1",
1895
+ // OrcaRouter: OpenAI-compatible adaptive router (api.orcarouter.ai). The public live
1896
+ // catalog is authoritative; model ids and input modalities are never maintained here.
1897
+ id: "orcarouter", label: "OrcaRouter - API", adapter: "openai-chat", baseUrl: "https://api.orcarouter.ai/v1",
1843
1898
  authKind: "key", dashboardUrl: "https://www.orcarouter.ai/console",
1899
+ // The catalog is public, so a successful /models probe cannot validate a submitted key.
1900
+ apiKeyValidation: "unknown",
1844
1901
  defaultModel: "openai/gpt-5.5",
1845
- models: [
1846
- "openai/gpt-5.5",
1847
- "anthropic/claude-opus-4.8",
1848
- "google/gemini-3.5-flash",
1849
- "deepseek/deepseek-v4-pro",
1850
- "orcarouter/auto",
1851
- ],
1852
- // Text-only models → the vision sidecar describes images instead.
1853
- noVisionModels: ["deepseek/deepseek-v4-pro"],
1854
- // Reasoning/temperature behavior verified live 2026-07-20 against api.orcarouter.ai:
1855
- // - openai/gpt-5.5 accepts reasoning_effort none|low|medium|high|xhigh but rejects `max` (400),
1856
- // so advertise up to xhigh and let mapReasoningEffort clamp a `max`/`ultra` request to xhigh.
1857
- // - deepseek/deepseek-v4-pro mirrors the direct-DeepSeek wiring (thinking-effort map +
1858
- // reasoning_content history replay) so the namespaced selection behaves identically.
1859
- // - temperature is accepted by every seeded model (gpt-5.5, claude-opus-4.8, deepseek-v4-pro all
1860
- // returned 200), so no noTemperatureModels entry is warranted here.
1861
- modelReasoningEfforts: {
1862
- "openai/gpt-5.5": ["low", "medium", "high", "xhigh"],
1863
- "deepseek/deepseek-v4-pro": deepseekThinkingEffortsFor("deepseek/deepseek-v4-pro"),
1864
- },
1865
- modelReasoningEffortMap: { "deepseek/deepseek-v4-pro": deepseekReasoningMapFor("deepseek/deepseek-v4-pro") },
1866
- preserveReasoningContentModels: ["deepseek/deepseek-v4-pro"],
1867
- note: "OpenAI-compatible adaptive router. Default is a tool-capable model; orcarouter/auto (adaptive routing) is also selectable. Full catalog: https://www.orcarouter.ai/models",
1902
+ models: ORCAROUTER_MODELS,
1903
+ liveModels: true,
1904
+ modelDiscovery: ORCAROUTER_MODEL_DISCOVERY,
1905
+ // Catalog discovery owns WHICH models exist. These entries only retain verified
1906
+ // request-shaping facts that the upstream catalog does not currently publish.
1907
+ noVisionModels: ORCAROUTER_TEXT_ONLY_MODELS,
1908
+ modelReasoningEfforts: ORCAROUTER_MODEL_REASONING_EFFORTS,
1909
+ modelReasoningEffortMap: ORCAROUTER_MODEL_REASONING_EFFORT_MAP,
1910
+ preserveReasoningContentModels: ORCAROUTER_TEXT_ONLY_MODELS,
1911
+ note: "OpenAI-compatible adaptive router. Models and multimodal capabilities are discovered live from the public chat catalog. Use the OrcaRouter account entry for PKCE login.",
1868
1912
  },
1869
1913
  {
1870
1914
  // BizRouter: Korean enterprise LLM gateway (api.bizrouter.ai). Model ids are
@@ -3081,6 +3125,11 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
3081
3125
  "gpt-5.6-luna": "openai-responses",
3082
3126
  "gpt-5.6-sol": "openai-responses",
3083
3127
  "gpt-5.6-terra": "openai-responses",
3128
+ "gpt-6-astra": "openai-responses",
3129
+ "grok-4.5": "openai-responses",
3130
+ "grok-4.6": "openai-responses",
3131
+ "mai-code-1.1-flash": "openai-responses",
3132
+ "mai-code-1-flash-picker": "openai-responses",
3084
3133
  },
3085
3134
  note: "Experimental unofficial Copilot bridge. Logs in via GitHub device flow using the public VS Code OAuth client id, then exchanges for a short-lived Copilot API token (copilot_internal). Requires an active Copilot subscription. GitHub may tighten or revoke this path; do not send confidential material you would not paste into Copilot Chat.",
3086
3135
  },