@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.
- package/README.md +8 -5
- package/SPONSORS.md +1 -1
- package/assets/sponsors/orcarouter.png +0 -0
- package/assets/sponsors/packycode.png +0 -0
- package/gui/dist/assets/index-BoBRSehJ.css +1 -0
- package/gui/dist/assets/index-BtyONQrZ.js +115 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/packycode.svg +19 -0
- package/gui/dist/provider-icons/qoder.svg +5 -0
- package/package.json +5 -3
- package/src/adapters/anthropic.ts +31 -16
- package/src/adapters/codebuddy/adapter.ts +85 -0
- package/src/adapters/codebuddy/profiles.ts +52 -0
- package/src/adapters/coding-agent/profile.ts +100 -0
- package/src/adapters/coding-agent/protocol.ts +463 -0
- package/src/adapters/coding-agent/turn.ts +353 -0
- package/src/adapters/google.ts +15 -11
- package/src/adapters/mimo-free.ts +3 -0
- package/src/adapters/openai-chat.ts +2 -2
- package/src/adapters/openai-responses.ts +18 -11
- package/src/adapters/qoder/adapter.ts +70 -0
- package/src/adapters/qoder/live-models.ts +89 -0
- package/src/adapters/qoder/profiles.ts +36 -0
- package/src/adapters/registry.ts +12 -0
- package/src/adapters/responses-tool-schema.ts +113 -8
- package/src/cli/dispatch.ts +2 -0
- package/src/cli/doctor.ts +40 -0
- package/src/cli/effort.ts +24 -8
- package/src/cli/help.ts +2 -0
- package/src/cli/index.ts +24 -1
- package/src/cli/provider-runtime.ts +2 -1
- package/src/cli/registry.ts +2 -2
- package/src/cli/system-command.ts +10 -3
- package/src/cli/usage-report.ts +9 -5
- package/src/codex/account-lifecycle.ts +35 -2
- package/src/codex/auth-api.ts +6 -3
- package/src/codex/auth-collision.ts +12 -2
- package/src/codex/auth-context.ts +75 -7
- package/src/codex/catalog/provider-fetch.ts +73 -9
- package/src/codex/inject.ts +127 -30
- package/src/codex/internal/catalog-writer.ts +3 -0
- package/src/codex/journal.ts +61 -12
- package/src/codex/model-cache.ts +11 -4
- package/src/codex/native-profile-startup.ts +72 -5
- package/src/codex/native-profile-store.ts +2 -2
- package/src/codex/ocx-compaction-history.ts +226 -0
- package/src/codex/project-config-warnings.ts +3 -1
- package/src/codex/quota.ts +18 -8
- package/src/codex/reserve-availability.ts +21 -5
- package/src/codex/runtime.ts +45 -1
- package/src/codex/sync.ts +5 -0
- package/src/config.ts +1 -0
- package/src/generated/compatibility-version.json +126 -74
- package/src/images/loop.ts +1 -0
- package/src/images/xai-video-client.ts +2 -0
- package/src/integrations/registry.ts +1 -0
- package/src/lib/process-control.ts +52 -8
- package/src/lib/upstream-retry.ts +1 -0
- package/src/oauth/chatgpt.ts +83 -0
- package/src/oauth/xai.ts +151 -8
- package/src/providers/api-key-selection-capture.ts +10 -0
- package/src/providers/api-key-selection.ts +2 -7
- package/src/providers/caller-authorization.ts +36 -0
- package/src/providers/codebuddy-models.ts +184 -0
- package/src/providers/derive.ts +5 -0
- package/src/providers/free-directory.ts +26 -2
- package/src/providers/openai-sidecar.ts +35 -11
- package/src/providers/qoder-models.ts +25 -0
- package/src/providers/registry.ts +139 -0
- package/src/responses/compaction.ts +4 -0
- package/src/responses/task-input.ts +21 -1
- package/src/router.ts +1 -1
- package/src/server/chat-completions.ts +26 -9
- package/src/server/claude-messages.ts +12 -2
- package/src/server/images.ts +1 -0
- package/src/server/management/account-selection-stream.ts +13 -4
- package/src/server/management/config-routes.ts +24 -5
- package/src/server/management/logs-usage-routes.ts +1 -0
- package/src/server/management/native-integration-routes.ts +2 -1
- package/src/server/management/provider-routes.ts +24 -0
- package/src/server/management-api.ts +14 -0
- package/src/server/port-reclaim.ts +11 -26
- package/src/server/request-log.ts +6 -0
- package/src/server/responses/collaboration.ts +4 -9
- package/src/server/responses/core.ts +141 -19
- package/src/server/responses/fetch-helpers.ts +18 -7
- package/src/server/responses/policy-fallback.ts +12 -0
- package/src/service.ts +36 -2
- package/src/storage/cleanup.ts +77 -45
- package/src/types/config.ts +7 -1
- package/src/usage/log.ts +24 -0
- package/src/vision/anthropic-describe.ts +1 -0
- package/src/web-search/anthropic-executor.ts +1 -0
- package/src/web-search/loop.ts +1 -0
- package/src/web-search/progress-stream.ts +4 -0
- package/gui/dist/assets/index-B6EFoatP.js +0 -115
- package/gui/dist/assets/index-D5SiRo8X.css +0 -1
package/src/images/loop.ts
CHANGED
|
@@ -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
|
-
*
|
|
70
|
-
*
|
|
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
|
-
/**
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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;
|
package/src/oauth/chatgpt.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
51
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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=>
|
|
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){
|
|
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
|
|
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
|
+
}
|