@bitkyc08/opencodex 2.48.0-preview.20260908 → 2.49.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 (97) hide show
  1. package/README.md +8 -5
  2. package/SPONSORS.md +1 -1
  3. package/assets/sponsors/orcarouter.png +0 -0
  4. package/assets/sponsors/packycode.png +0 -0
  5. package/gui/dist/assets/index-BoBRSehJ.css +1 -0
  6. package/gui/dist/assets/index-BtyONQrZ.js +115 -0
  7. package/gui/dist/index.html +2 -2
  8. package/gui/dist/provider-icons/packycode.svg +19 -0
  9. package/gui/dist/provider-icons/qoder.svg +5 -0
  10. package/package.json +5 -3
  11. package/src/adapters/anthropic.ts +31 -16
  12. package/src/adapters/codebuddy/adapter.ts +85 -0
  13. package/src/adapters/codebuddy/profiles.ts +52 -0
  14. package/src/adapters/coding-agent/profile.ts +100 -0
  15. package/src/adapters/coding-agent/protocol.ts +463 -0
  16. package/src/adapters/coding-agent/turn.ts +353 -0
  17. package/src/adapters/google.ts +15 -11
  18. package/src/adapters/mimo-free.ts +3 -0
  19. package/src/adapters/openai-chat.ts +2 -2
  20. package/src/adapters/openai-responses.ts +18 -11
  21. package/src/adapters/qoder/adapter.ts +70 -0
  22. package/src/adapters/qoder/live-models.ts +89 -0
  23. package/src/adapters/qoder/profiles.ts +36 -0
  24. package/src/adapters/registry.ts +12 -0
  25. package/src/adapters/responses-tool-schema.ts +113 -8
  26. package/src/cli/dispatch.ts +2 -0
  27. package/src/cli/doctor.ts +40 -0
  28. package/src/cli/effort.ts +24 -8
  29. package/src/cli/help.ts +2 -0
  30. package/src/cli/index.ts +24 -1
  31. package/src/cli/provider-runtime.ts +2 -1
  32. package/src/cli/registry.ts +2 -2
  33. package/src/cli/system-command.ts +10 -3
  34. package/src/cli/usage-report.ts +9 -5
  35. package/src/codex/account-lifecycle.ts +35 -2
  36. package/src/codex/auth-api.ts +6 -3
  37. package/src/codex/auth-collision.ts +12 -2
  38. package/src/codex/auth-context.ts +75 -7
  39. package/src/codex/catalog/provider-fetch.ts +73 -9
  40. package/src/codex/inject.ts +127 -30
  41. package/src/codex/internal/catalog-writer.ts +3 -0
  42. package/src/codex/journal.ts +61 -12
  43. package/src/codex/model-cache.ts +11 -4
  44. package/src/codex/native-profile-startup.ts +72 -5
  45. package/src/codex/native-profile-store.ts +2 -2
  46. package/src/codex/ocx-compaction-history.ts +226 -0
  47. package/src/codex/project-config-warnings.ts +3 -1
  48. package/src/codex/quota.ts +18 -8
  49. package/src/codex/reserve-availability.ts +21 -5
  50. package/src/codex/runtime.ts +45 -1
  51. package/src/codex/sync.ts +5 -0
  52. package/src/config.ts +1 -0
  53. package/src/generated/compatibility-version.json +126 -74
  54. package/src/images/loop.ts +1 -0
  55. package/src/images/xai-video-client.ts +2 -0
  56. package/src/integrations/registry.ts +1 -0
  57. package/src/lib/process-control.ts +52 -8
  58. package/src/lib/upstream-retry.ts +1 -0
  59. package/src/oauth/chatgpt.ts +83 -0
  60. package/src/oauth/xai.ts +151 -8
  61. package/src/providers/api-key-selection-capture.ts +10 -0
  62. package/src/providers/api-key-selection.ts +2 -7
  63. package/src/providers/caller-authorization.ts +36 -0
  64. package/src/providers/codebuddy-models.ts +184 -0
  65. package/src/providers/derive.ts +5 -0
  66. package/src/providers/free-directory.ts +26 -2
  67. package/src/providers/openai-sidecar.ts +35 -11
  68. package/src/providers/qoder-models.ts +25 -0
  69. package/src/providers/registry.ts +139 -0
  70. package/src/responses/compaction.ts +4 -0
  71. package/src/responses/task-input.ts +21 -1
  72. package/src/router.ts +1 -1
  73. package/src/server/chat-completions.ts +26 -9
  74. package/src/server/claude-messages.ts +12 -2
  75. package/src/server/images.ts +1 -0
  76. package/src/server/management/account-selection-stream.ts +13 -4
  77. package/src/server/management/config-routes.ts +24 -5
  78. package/src/server/management/logs-usage-routes.ts +1 -0
  79. package/src/server/management/native-integration-routes.ts +2 -1
  80. package/src/server/management/provider-routes.ts +24 -0
  81. package/src/server/management-api.ts +14 -0
  82. package/src/server/port-reclaim.ts +11 -26
  83. package/src/server/request-log.ts +6 -0
  84. package/src/server/responses/collaboration.ts +4 -9
  85. package/src/server/responses/core.ts +141 -19
  86. package/src/server/responses/fetch-helpers.ts +18 -7
  87. package/src/server/responses/policy-fallback.ts +12 -0
  88. package/src/service.ts +36 -2
  89. package/src/storage/cleanup.ts +77 -45
  90. package/src/types/config.ts +7 -1
  91. package/src/usage/log.ts +24 -0
  92. package/src/vision/anthropic-describe.ts +1 -0
  93. package/src/web-search/anthropic-executor.ts +1 -0
  94. package/src/web-search/loop.ts +1 -0
  95. package/src/web-search/progress-stream.ts +4 -0
  96. package/gui/dist/assets/index-B6EFoatP.js +0 -115
  97. package/gui/dist/assets/index-D5SiRo8X.css +0 -1
@@ -537,6 +537,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
537
537
  // hop-by-hop header alone (oven-sh/bun#20492).
538
538
  return requestFetch(request.url, applyUpstreamRecoveryInit({
539
539
  method: request.method,
540
+ redirect: "manual",
540
541
  headers: h,
541
542
  body: request.body,
542
543
  signal: headerDeadline.signal,
@@ -80,6 +80,7 @@ export async function submitVideoJob(
80
80
 
81
81
  const resp = await fetch(`${auth.baseUrl}/videos/generations`, {
82
82
  method: "POST",
83
+ redirect: "manual",
83
84
  headers: {
84
85
  "Authorization": `Bearer ${auth.token}`,
85
86
  "Content-Type": "application/json",
@@ -118,6 +119,7 @@ export async function pollVideoJob(
118
119
 
119
120
  const resp = await fetch(`${auth.baseUrl}/videos/${encodeURIComponent(requestId)}`, {
120
121
  method: "GET",
122
+ redirect: "manual",
121
123
  headers: {
122
124
  "Authorization": `Bearer ${auth.token}`,
123
125
  },
@@ -190,6 +190,7 @@ export const INTEGRATION_CLIENTS: Record<IntegrationClientId, IntegrationClientS
190
190
  id: "hermes",
191
191
  configPath: (env = process.env, home = homedir()) => hermesConfigPath(env, home),
192
192
  detectDir: (env = process.env, home = homedir()) => hermesHomeDir(env, home),
193
+ sourcePreservingYaml: { path: ["providers", "opencodex"] },
193
194
  },
194
195
  openclaw: {
195
196
  id: "openclaw",
@@ -66,12 +66,30 @@ export function gracefulStopHost(hostname: string | undefined): string {
66
66
  }
67
67
 
68
68
  /**
69
- * Outcome of a graceful stop attempt. `"refused"` is distinct from failure: the proxy answered
70
- * that it must NOT be stopped from here, so callers must not escalate to a forced kill.
69
+ * `"refused"` forbids forced stop. `"teardown-unconfirmed"` means the process exited,
70
+ * but its assigned shared teardown was not confirmed; callers must not kill it again.
71
71
  */
72
- export type GracefulStopResult = boolean | "refused";
72
+ export type GracefulStopResult = boolean | "refused" | "teardown-unconfirmed";
73
73
 
74
- /** A proxy declined shutdown because a service under another home owns it (HTTP 409). */
74
+ /**
75
+ * The server's own explanation for the most recent 409, captured so `stopProxy` can report
76
+ * the real reason. There is more than one: a scheduler wrapper under another home, or the
77
+ * proxy being the installed service itself (#4023). Module-scoped because
78
+ * `GracefulStopResult` is a public contract with several callers, and widening it to carry
79
+ * the text would change every one of them for a message only this file reports.
80
+ */
81
+ let lastRefusalMessage: string | null = null;
82
+
83
+ /** The server's explanation for the most recent 409, or `null` when it sent none. */
84
+ export function lastStopRefusalMessage(): string | null {
85
+ return lastRefusalMessage;
86
+ }
87
+
88
+ /**
89
+ * A proxy declined shutdown (HTTP 409). There is more than one reason it can say no — a
90
+ * scheduler wrapper under another home, or the proxy being the installed service itself
91
+ * (#4023) — so the server's own message is carried through rather than guessed at.
92
+ */
75
93
  export class ProxyOwnershipRefusedError extends Error {}
76
94
 
77
95
  /**
@@ -82,6 +100,8 @@ export class ProxyOwnershipRefusedError extends Error {}
82
100
  * chance to run its shutdown handlers. Returns false when the proxy can't be reached
83
101
  * or doesn't exit in time — callers fall back to {@link killProxy}. Returns `"refused"`
84
102
  * when the proxy declines the stop (HTTP 409), which callers must NOT force past.
103
+ * True requires the expected shared-teardown response and an observed exit. It does not
104
+ * attest the process exit code or completion of every drain/shutdown hook.
85
105
  */
86
106
  export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}): Promise<GracefulStopResult> {
87
107
  const readRuntime = io.readRuntime ?? readRuntimePort;
@@ -92,6 +112,7 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}):
92
112
  const token = configuredAdminToken(env.OPENCODEX_HOME?.trim() || undefined, env as NodeJS.ProcessEnv);
93
113
  if (token) headers["x-opencodex-api-key"] = token;
94
114
  const fetchFn = io.fetchFn ?? fetch;
115
+ let sharedTeardownConfirmed = false;
95
116
  try {
96
117
  // `ocx stop` asks the proxy NOT to restore shared client config: it does that itself,
97
118
  // after verifying a stopped Task Scheduler did not respawn the proxy (#3008). Letting
@@ -111,8 +132,23 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}):
111
132
  // would respawn it anyway). That is a policy answer, not a dead endpoint — escalating to
112
133
  // SIGTERM here would run the daemon's cleanup and strip shared config out from under the
113
134
  // still-running service. Report the refusal instead of forcing.
114
- if (res.status === 409) return "refused";
135
+ if (res.status === 409) {
136
+ lastRefusalMessage = await res.json()
137
+ .then(body => {
138
+ const message = (body as { message?: unknown } | null)?.message;
139
+ return typeof message === "string" && message.trim() ? message.trim() : null;
140
+ })
141
+ .catch(() => null);
142
+ return "refused";
143
+ }
115
144
  if (!res.ok) return false;
145
+ const body: unknown = await res.json().catch(() => null);
146
+ const expectedTeardown = io.deferSharedTeardownNonce ? "deferred" : "performed";
147
+ sharedTeardownConfirmed = body !== null
148
+ && typeof body === "object"
149
+ && !Array.isArray(body)
150
+ && "success" in body && body.success === true
151
+ && "sharedTeardown" in body && body.sharedTeardown === expectedTeardown;
116
152
  } catch {
117
153
  return false;
118
154
  }
@@ -120,7 +156,8 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}):
120
156
  // Honor the server's own drain window: /api/stop answers 200 first, then drains for
121
157
  // config.shutdownTimeoutMs. Waiting less than that hard-kills mid-drain.
122
158
  const exitTimeoutMs = io.exitTimeoutMs ?? drainDeadlineMs();
123
- return waitExit(pid, exitTimeoutMs);
159
+ if (!waitExit(pid, exitTimeoutMs)) return false;
160
+ return sharedTeardownConfirmed ? true : "teardown-unconfirmed";
124
161
  }
125
162
 
126
163
  function drainDeadlineMs(): number {
@@ -140,10 +177,17 @@ export async function stopProxy(pid: number, io: GracefulStopIo = {}): Promise<b
140
177
  // The proxy refused on purpose (foreign service owns it). Forcing would strip shared
141
178
  // config while that service keeps the proxy alive.
142
179
  throw new ProxyOwnershipRefusedError(
143
- "The running proxy refused to stop: a service installed under a different "
144
- + "CODEX_HOME/OPENCODEX_HOME owns it. Run the stop from that home.",
180
+ lastRefusalMessage
181
+ ?? "The running proxy refused to stop: a service installed under a different "
182
+ + "CODEX_HOME/OPENCODEX_HOME owns it. Run the stop from that home.",
145
183
  );
146
184
  }
185
+ if (graceful === "teardown-unconfirmed") {
186
+ // Exit was observed, so do not enter the forced-stop fallback. Returning false keeps
187
+ // shared restoration with `ocx stop` instead of claiming that the proxy completed it.
188
+ await waitForStoppedPort(runtime, pid);
189
+ return false;
190
+ }
147
191
  if (graceful) {
148
192
  await waitForStoppedPort(runtime, pid);
149
193
  return true;
@@ -226,6 +226,7 @@ export async function fetchWithAttemptDeadline(
226
226
  return await executor(url, {
227
227
  ...init,
228
228
  headers,
229
+ redirect: "manual",
229
230
  signal: attemptTimeout.signal,
230
231
  });
231
232
  } finally {
@@ -40,6 +40,62 @@ export function extractAccountId(idToken?: string, accessToken?: string): string
40
40
  return undefined;
41
41
  }
42
42
 
43
+ /**
44
+ * Three-way answer to "is this token marked as belonging to the ChatGPT account domain".
45
+ * Only ChatGPT-specific claims count as markers: a top-level chatgpt_account_id or the
46
+ * https://api.openai.com/auth namespace claim. A generic organizations claim is NOT domain
47
+ * evidence. JWT claims are decoded locally as routing markers, never as authenticity proof.
48
+ *
49
+ * absent — no JWT, a payload that is not a JSON object, or an object carrying neither
50
+ * marker key: the token may be a foreign credential and legacy foreign handling
51
+ * applies. This function is total; it never throws on an attacker-shaped token.
52
+ * invalid — a marker key is present but yields no usable account id (non-string, blank,
53
+ * namespace that is not an object, namespace without the claim) or the two
54
+ * markers disagree. Presence is decided by the KEY, not by its shape, so a token
55
+ * that claims this domain can never fall through to foreign handling just
56
+ * because its marker is malformed.
57
+ * valid — one consistent, non-blank ChatGPT account id.
58
+ */
59
+ export type ChatGptDomainClaim =
60
+ | { kind: "absent" }
61
+ | { kind: "invalid" }
62
+ | { kind: "valid"; accountId: string };
63
+
64
+ const CHATGPT_AUTH_NAMESPACE = "https://api.openai.com/auth";
65
+
66
+ /** A usable account id is a non-blank string; blank or non-string values are malformed. */
67
+ function usableAccountId(value: unknown): string | undefined {
68
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
69
+ }
70
+
71
+ export function inspectChatGptDomainClaim(token: string): ChatGptDomainClaim {
72
+ const payload: unknown = decodeJwtPayload(token);
73
+ // decodeJwtPayload returns whatever the payload segment parses to, which may be a
74
+ // primitive or an array. Those carry no marker and must not reach the key lookups,
75
+ // where `in`/hasOwn would throw and take the whole request down.
76
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return { kind: "absent" };
77
+ const claims = payload as Record<string, unknown>;
78
+ // Presence is the KEY being there, as an own key. A reserved namespace that is null, a
79
+ // primitive, an array, or an object without the claim is a present-but-broken marker, so
80
+ // it stays invalid instead of being treated as a foreign token.
81
+ const topPresent = Object.hasOwn(claims, "chatgpt_account_id");
82
+ const nsPresent = Object.hasOwn(claims, CHATGPT_AUTH_NAMESPACE);
83
+ if (!topPresent && !nsPresent) return { kind: "absent" };
84
+ const topId = topPresent ? usableAccountId(claims.chatgpt_account_id) : undefined;
85
+ if (topPresent && !topId) return { kind: "invalid" };
86
+ let nsId: string | undefined;
87
+ if (nsPresent) {
88
+ const ns = claims[CHATGPT_AUTH_NAMESPACE];
89
+ const nsObj = ns !== null && typeof ns === "object" && !Array.isArray(ns)
90
+ ? ns as Record<string, unknown> : undefined;
91
+ nsId = nsObj ? usableAccountId(nsObj.chatgpt_account_id) : undefined;
92
+ if (!nsId) return { kind: "invalid" };
93
+ }
94
+ if (topId && nsId && topId !== nsId) return { kind: "invalid" };
95
+ const accountId = topId ?? nsId;
96
+ return accountId ? { kind: "valid", accountId } : { kind: "invalid" };
97
+ }
98
+
43
99
  export function extractEmail(idToken?: string, accessToken?: string): string | undefined {
44
100
  for (const token of [idToken, accessToken]) {
45
101
  if (!token) continue;
@@ -50,6 +106,33 @@ export function extractEmail(idToken?: string, accessToken?: string): string | u
50
106
  return undefined;
51
107
  }
52
108
 
109
+ /**
110
+ * Identity-agreement view of one token for security-sensitive bindings. `accountId` follows the
111
+ * existing extractAccountId precedence (top-level, then namespaced, then organizations[0]).
112
+ * `conflict` is true only when the two chatgpt_account_id encodings are both present and
113
+ * disagree — organizations entries are workspace memberships, not identity, so they never
114
+ * participate. Never logs token material.
115
+ */
116
+ export function extractAccountIdClaims(token?: string): { accountId: string | undefined; conflict: boolean } {
117
+ if (!token) return { accountId: undefined, conflict: false };
118
+ const payload = decodeJwtPayload(token);
119
+ if (!payload) return { accountId: undefined, conflict: false };
120
+ const top = typeof payload.chatgpt_account_id === "string" ? payload.chatgpt_account_id : undefined;
121
+ const ns = payload["https://api.openai.com/auth"];
122
+ const namespaced = ns && typeof ns === "object"
123
+ && typeof (ns as Record<string, unknown>).chatgpt_account_id === "string"
124
+ ? (ns as Record<string, unknown>).chatgpt_account_id as string
125
+ : undefined;
126
+ const orgs = payload.organizations;
127
+ const org = Array.isArray(orgs) && orgs[0] && typeof orgs[0].id === "string"
128
+ ? orgs[0].id as string
129
+ : undefined;
130
+ return {
131
+ accountId: top ?? namespaced ?? org,
132
+ conflict: top !== undefined && namespaced !== undefined && top !== namespaced,
133
+ };
134
+ }
135
+
53
136
  export function credsFromToken(data: Record<string, unknown>): OAuthCredentials {
54
137
  const idToken = typeof data.id_token === "string" ? data.id_token : undefined;
55
138
  // This parses a response from an external boundary, so the access token is
package/src/oauth/xai.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  /** xAI OAuth flow (Grok account login). Ported from jawcode oauth/xai.ts. */
2
+ import { abortError, sleepWithAbort } from "../lib/upstream-retry";
2
3
  import { OAuthCallbackFlow, type OAuthCallbackFlowOptions } from "./callback-server";
3
4
  import { generatePKCE } from "./pkce";
4
5
  import type { LocalTokenImportMode, OAuthController, OAuthCredentials } from "./types";
@@ -11,6 +12,9 @@ const XAI_OAUTH_CALLBACK_PORT = 56121;
11
12
  const XAI_OAUTH_CALLBACK_PATH = "/callback";
12
13
  const XAI_OAUTH_REFRESH_SKEW_MS = 2 * 60 * 1000;
13
14
  const TOKEN_REQUEST_TIMEOUT_MS = 30_000;
15
+ const RETRY_AFTER_MAX_DELAY_MS = 60_000;
16
+ const JITTER_DELAY_CAP_MS = 2_000;
17
+ const XAI_TRUSTED_AUTH_HOSTS = new Set(["auth.x.ai", "accounts.x.ai"]);
14
18
 
15
19
  export const XAI_LOCAL_CLI_DETACH_WARNING =
16
20
  "[oauth:xai] Grok CLI credential was stale; refreshed into OpenCodex ownership. Grok CLI may require login again.";
@@ -45,10 +49,21 @@ function requestSignal(signal: AbortSignal | undefined): AbortSignal {
45
49
  }
46
50
 
47
51
  function validateXaiEndpoint(rawUrl: string): string {
48
- const parsed = new URL(rawUrl);
52
+ let parsed: URL;
53
+ try {
54
+ parsed = new URL(rawUrl);
55
+ } catch {
56
+ throw new Error("xAI OAuth discovery returned an unparseable endpoint URL");
57
+ }
49
58
  const host = parsed.hostname.toLowerCase();
50
- if (parsed.protocol !== "https:" || (host !== "x.ai" && !host.endsWith(".x.ai"))) {
51
- throw new Error(`xAI OAuth discovery returned an unexpected endpoint: ${rawUrl}`);
59
+ if (
60
+ parsed.protocol !== "https:"
61
+ || parsed.username !== ""
62
+ || parsed.password !== ""
63
+ || parsed.port !== ""
64
+ || !XAI_TRUSTED_AUTH_HOSTS.has(host)
65
+ ) {
66
+ throw new Error(`xAI OAuth discovery returned an unexpected endpoint (host: ${host || "none"})`);
52
67
  }
53
68
  return parsed.toString();
54
69
  }
@@ -94,15 +109,135 @@ function getTokenIdentity(accessToken: string, idToken: string | undefined): { a
94
109
 
95
110
  export class XaiTokenRequestError extends Error { constructor(public readonly status?:number,public readonly oauthError?:string,message="xAI token request failed",options?:{cause?:unknown}){super(message,options);this.name="XaiTokenRequestError";} }
96
111
  export interface XaiTokenRetryDeps { sleep?:(ms:number)=>Promise<void>; random?:()=>number }
97
- function isAbortError(error:unknown):boolean{return error instanceof DOMException&&error.name==="AbortError";}
98
- function retryDelay(attempt:number,retryAfter:string|null,random:()=>number):number{const base=attempt===1?100:250,j=Math.round(base*(.75+random()*.5)),seconds=retryAfter!==null&&/^\d+$/.test(retryAfter)?Number(retryAfter):0;return Math.min(2000,Math.max(j,seconds*1000));}
99
- async function readTokenError(response:Response):Promise<XaiTokenRequestError>{let oauthError:string|undefined,detail="";try{const body=await response.json() as {error?:unknown;error_description?:unknown};if(typeof body.error==="string")oauthError=body.error;if(typeof body.error_description==="string")detail=body.error_description;}catch{}const suffix=detail?`: ${detail}`:oauthError?`: ${oauthError}`:"";return new XaiTokenRequestError(response.status,oauthError,`xAI token request failed: ${response.status}${suffix}`);}
112
+ const IMF_FIXDATE_RE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/i;
113
+ const RFC850_DATE_RE = /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\d{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/i;
114
+ const ASCTIME_DATE_RE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ( \d|\d{2}) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/i;
115
+ const HTTP_MONTH_INDEX: Record<string, number> = {
116
+ jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5,
117
+ jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11,
118
+ };
119
+
120
+ function parseUtcDateParts(
121
+ year: number,
122
+ monthName: string,
123
+ day: number,
124
+ hour: number,
125
+ minute: number,
126
+ second: number,
127
+ ): number | undefined {
128
+ const month = HTTP_MONTH_INDEX[monthName.toLowerCase()];
129
+ if (month === undefined) return undefined;
130
+ const timestamp = Date.UTC(year, month, day, hour, minute, second);
131
+ const parsed = new Date(timestamp);
132
+ return parsed.getUTCFullYear() === year
133
+ && parsed.getUTCMonth() === month
134
+ && parsed.getUTCDate() === day
135
+ && parsed.getUTCHours() === hour
136
+ && parsed.getUTCMinutes() === minute
137
+ && parsed.getUTCSeconds() === second
138
+ ? timestamp
139
+ : undefined;
140
+ }
141
+
142
+ function parseHttpDateMs(value: string, now: number): number | undefined {
143
+ const match = IMF_FIXDATE_RE.exec(value);
144
+ if (match) {
145
+ return parseUtcDateParts(
146
+ Number(match[3]), match[2]!, Number(match[1]),
147
+ Number(match[4]), Number(match[5]), Number(match[6]),
148
+ );
149
+ }
150
+ const rfc850 = RFC850_DATE_RE.exec(value);
151
+ if (rfc850) {
152
+ // Two-digit years more than 50 years in the future are in the past (RFC 9110).
153
+ const currentYear = new Date(now).getUTCFullYear();
154
+ let year = Math.floor(currentYear / 100) * 100 + Number(rfc850[3]);
155
+ const candidateTimeOfYear = Date.UTC(
156
+ 2000, HTTP_MONTH_INDEX[rfc850[2]!.toLowerCase()]!, Number(rfc850[1]),
157
+ Number(rfc850[4]), Number(rfc850[5]), Number(rfc850[6]),
158
+ );
159
+ const current = new Date(now);
160
+ const currentTimeOfYear = Date.UTC(
161
+ 2000, current.getUTCMonth(), current.getUTCDate(),
162
+ current.getUTCHours(), current.getUTCMinutes(), current.getUTCSeconds(),
163
+ current.getUTCMilliseconds(),
164
+ );
165
+ const yearDelta = year - currentYear;
166
+ if (yearDelta < -50 || (yearDelta === -50 && candidateTimeOfYear < currentTimeOfYear)) {
167
+ year += 100;
168
+ } else if (yearDelta > 50 || (yearDelta === 50 && candidateTimeOfYear > currentTimeOfYear)) {
169
+ year -= 100;
170
+ }
171
+ return parseUtcDateParts(
172
+ year, rfc850[2]!, Number(rfc850[1]),
173
+ Number(rfc850[4]), Number(rfc850[5]), Number(rfc850[6]),
174
+ );
175
+ }
176
+ const asctime = ASCTIME_DATE_RE.exec(value);
177
+ if (!asctime) return undefined;
178
+ return parseUtcDateParts(
179
+ Number(asctime[6]), asctime[1]!, Number(asctime[2]),
180
+ Number(asctime[3]), Number(asctime[4]), Number(asctime[5]),
181
+ );
182
+ }
183
+
184
+ function parseRetryAfterMs(retryAfter: string | null): number | undefined {
185
+ const text = retryAfter?.trim();
186
+ if (!text) return undefined;
187
+ if (/^\d+(?:\.\d+)?$/.test(text)) {
188
+ const ms = Math.ceil(Number(text) * 1000);
189
+ return ms > 0 ? ms : undefined;
190
+ }
191
+ const now = Date.now();
192
+ const timestamp = parseHttpDateMs(text, now);
193
+ if (timestamp === undefined) return undefined;
194
+ const delay = timestamp - now;
195
+ return delay > 0 ? delay : undefined;
196
+ }
197
+
198
+ function jitterDelay(attempt: number, random: () => number): number {
199
+ const base = attempt === 1 ? 100 : 250;
200
+ return Math.min(JITTER_DELAY_CAP_MS, Math.round(base * (0.75 + random() * 0.5)));
201
+ }
202
+
203
+ /**
204
+ * Delay before the next attempt, or undefined when the server asked for a wait
205
+ * beyond the retry budget — retrying earlier than Retry-After would hammer the
206
+ * token endpoint, so the caller fails the request instead of clamping.
207
+ */
208
+ function retryDelay(attempt: number, retryAfter: string | null, random: () => number): number | undefined {
209
+ const serverMs = parseRetryAfterMs(retryAfter);
210
+ if (serverMs === undefined) return jitterDelay(attempt, random);
211
+ return serverMs <= RETRY_AFTER_MAX_DELAY_MS ? serverMs : undefined;
212
+ }
213
+
214
+ async function sleepAbortable(
215
+ ms: number,
216
+ sleep: (ms: number) => Promise<void>,
217
+ signal: AbortSignal | undefined,
218
+ ): Promise<void> {
219
+ if (!signal) return sleep(ms);
220
+ if (signal.aborted) throw abortError(signal);
221
+ let onAbort!: () => void;
222
+ try {
223
+ await Promise.race([
224
+ sleep(ms),
225
+ new Promise<never>((_, reject) => {
226
+ onAbort = () => reject(abortError(signal));
227
+ signal.addEventListener("abort", onAbort, { once: true });
228
+ }),
229
+ ]);
230
+ } finally {
231
+ signal.removeEventListener("abort", onAbort);
232
+ }
233
+ }
234
+ async function readTokenError(response:Response):Promise<XaiTokenRequestError>{let oauthError:string|undefined,detail="";try{const body=await response.json() as {error?:unknown;error_description?:unknown};if(typeof body.error==="string")oauthError=body.error;if(typeof body.error_description==="string")detail=body.error_description;}catch{/* non-JSON error body: fall through to the generic message */}const suffix=detail?`: ${detail}`:oauthError?`: ${oauthError}`:"";return new XaiTokenRequestError(response.status,oauthError,`xAI token request failed: ${response.status}${suffix}`);}
100
235
  export async function postXaiToken(
101
236
  tokenEndpoint: string,
102
237
  body: Record<string, string>,
103
238
  signal?: AbortSignal, deps:XaiTokenRetryDeps={},
104
239
  ): Promise<XaiTokenPayload> {
105
- const sleep=deps.sleep??(ms=>Bun.sleep(ms)),random=deps.random??Math.random;let last:unknown;
240
+ const sleep=deps.sleep??((ms:number)=>sleepWithAbort(ms,signal)),random=deps.random??Math.random;let last:unknown;
106
241
  for(let attempt=1;attempt<=3;attempt++){let response:Response;try{response=await fetch(tokenEndpoint, {
107
242
  method: "POST",
108
243
  headers: {
@@ -111,7 +246,15 @@ export async function postXaiToken(
111
246
  },
112
247
  body: new URLSearchParams(body).toString(),
113
248
  signal: requestSignal(signal),
114
- });}catch(error){if(isAbortError(error)&&signal?.aborted)throw error;last=error;if(attempt===3)throw new XaiTokenRequestError(undefined,undefined,"xAI token request failed: network error",{cause:error});await sleep(retryDelay(attempt,null,random));continue;}if(response.ok)return await response.json() as XaiTokenPayload;const error=await readTokenError(response);last=error;if(!(response.status===429||response.status>=500)||attempt===3)throw error;await sleep(retryDelay(attempt,response.headers.get("retry-after"),random));}throw last;
249
+ });}catch(error){
250
+ if(signal?.aborted)throw error;
251
+ const name=(error as {name?:string}|undefined)?.name;
252
+ if(name==="AbortError"||name==="TimeoutError")throw error;
253
+ last=error;
254
+ if(attempt===3)throw new XaiTokenRequestError(undefined,undefined,"xAI token request failed: network error",{cause:error});
255
+ await sleepAbortable(jitterDelay(attempt,random),sleep,signal);
256
+ continue;
257
+ }if(response.ok)return await response.json() as XaiTokenPayload;const error=await readTokenError(response);last=error;if(!(response.status===429||response.status>=500)||attempt===3)throw error;if(signal?.aborted)throw error;const delay=retryDelay(attempt,response.headers.get("retry-after"),random);if(delay===undefined)throw error;await sleepAbortable(delay,sleep,signal);}throw last;
115
258
  }
116
259
 
117
260
  function credentialsFromTokenPayload(payload: XaiTokenPayload, refreshFallback = ""): OAuthCredentials {
@@ -0,0 +1,10 @@
1
+ import type { OcxProviderConfig } from "../types";
2
+ import type { ProviderApiKeySelection } from "../types/provider";
3
+
4
+ export function captureProviderApiKeySelection(provider: OcxProviderConfig): ProviderApiKeySelection {
5
+ return {
6
+ entryId: provider.apiKeyPool?.find(entry => entry.key === provider.apiKey)?.id,
7
+ reference: provider.apiKey,
8
+ revision: provider.apiKeySelectionRevision,
9
+ };
10
+ }
@@ -6,14 +6,9 @@ import type { ProviderApiKeySelection } from "../types/provider";
6
6
  import { routedProviderConfig } from "../router";
7
7
  import { OPENCODE_GO_SESSION_HEADER } from "./opencode-go-transport";
8
8
  import { resolveProviderTransport, XAI_GROK_COMPATIBILITY, type OcxProviderTransport } from "./xai-transport";
9
+ import { captureProviderApiKeySelection } from "./api-key-selection-capture";
9
10
 
10
- export function captureProviderApiKeySelection(provider: OcxProviderConfig): ProviderApiKeySelection {
11
- return {
12
- entryId: provider.apiKeyPool?.find(entry => entry.key === provider.apiKey)?.id,
13
- reference: provider.apiKey,
14
- revision: provider.apiKeySelectionRevision,
15
- };
16
- }
11
+ export { captureProviderApiKeySelection } from "./api-key-selection-capture";
17
12
 
18
13
  function matchesSelection(provider: OcxProviderConfig, expected: ProviderApiKeySelection): boolean {
19
14
  const current = captureProviderApiKeySelection(provider);
@@ -0,0 +1,36 @@
1
+ import type { OcxConfig, OcxProviderConfig } from "../types";
2
+ import { inspectChatGptDomainClaim } from "../oauth/chatgpt";
3
+ import { isProxyAdmissionSecret } from "../server/auth-cors";
4
+ import { isCanonicalOpenAiForwardProvider } from "./openai-tiers";
5
+
6
+ /** The caller's own ChatGPT-domain credential as plain Direct forwarding would use it. */
7
+ export type CallerDirectAuth = Readonly<{ authorization: string; chatgptAccountId?: string }>;
8
+
9
+ /** Whether this transport can consume the request's Authorization as its upstream credential. */
10
+ export function providerConsumesCallerAuthorization(provider: OcxProviderConfig): boolean {
11
+ return isCanonicalOpenAiForwardProvider(provider)
12
+ || (provider.adapter === "cursor" && provider.authMode !== "oauth" && !provider.apiKey?.trim());
13
+ }
14
+
15
+ /**
16
+ * Capture the caller's Direct credential for a canonical-route restore after an internal
17
+ * rewrite. This restore is intentionally STRICTER than plain unchanged-route Direct
18
+ * forwarding: only a bearer with a VALID ChatGPT-domain claim qualifies (a clean single
19
+ * non-proxy JWT whose ChatGPT-specific account marker is well-formed and unambiguous, with
20
+ * any explicit account header matching it). An opaque bearer, a foreign JWT carrying only a
21
+ * generic organizations claim, and a ChatGPT-marked but malformed/conflicting token are all
22
+ * rejected: after a shadow/thread rewrite a self-asserted header cannot distinguish a
23
+ * caller-owned main credential from a foreign source-route token, so those cases stay
24
+ * fail-closed. Claims are decoded locally as routing markers, not authenticity proof, and
25
+ * unchanged-route Direct forwarding is governed by its own legacy rules.
26
+ */
27
+ export function captureCallerDirectAuth(incomingHeaders: Headers, config: OcxConfig): CallerDirectAuth | null {
28
+ const raw = incomingHeaders.get("authorization")?.trim();
29
+ const bearer = /^Bearer[\t ]+([^\s,]+)$/i.exec(raw ?? "")?.[1];
30
+ if (!bearer || isProxyAdmissionSecret(bearer, config)) return null;
31
+ const claim = inspectChatGptDomainClaim(bearer);
32
+ if (claim.kind !== "valid") return null;
33
+ const headerAccount = incomingHeaders.get("chatgpt-account-id")?.trim();
34
+ if (headerAccount && headerAccount !== claim.accountId) return null;
35
+ return { authorization: `Bearer ${bearer}`, chatgptAccountId: claim.accountId };
36
+ }