@yansigit/opencodex 2.36.1 → 2.36.2-dev.20260829.52
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 +9 -4
- package/gui/dist/assets/{ApiKeys-CsmNyf4I.js → ApiKeys-GpvAosG-.js} +1 -1
- package/gui/dist/assets/{Claude-B4sHsGTD.js → Claude-Biju6rfh.js} +1 -1
- package/gui/dist/assets/{CodexSet-BuAQ4YQn.js → CodexSet-_EkE9hNC.js} +1 -1
- package/gui/dist/assets/{FileIntegrationPage-BC65sEaS.js → FileIntegrationPage-BL55KJCW.js} +1 -1
- package/gui/dist/assets/{Grok-DNJWlISf.js → Grok-Dp3sOS5B.js} +1 -1
- package/gui/dist/assets/{Integrations-vGJshFZK.js → Integrations-Bx0V4Dx1.js} +2 -2
- package/gui/dist/assets/{IntegrationsOverview-DYMkDFHE.js → IntegrationsOverview-CT41lCh4.js} +1 -1
- package/gui/dist/assets/{Logs-Coc7_RPP.js → Logs-BxS_u7Hr.js} +1 -1
- package/gui/dist/assets/{Models-CKVT8Meb.js → Models-DvaBTkUM.js} +1 -1
- package/gui/dist/assets/{NumberStepper-Dug4wR8n.js → NumberStepper-97eq4OKR.js} +1 -1
- package/gui/dist/assets/Providers-CB9I0Vv1.js +1 -0
- package/gui/dist/assets/{RestoreDialog-BpuvVU2w.js → RestoreDialog-C9sltYZD.js} +1 -1
- package/gui/dist/assets/{Startup-DAUQqNEN.js → Startup-BvFv8yC2.js} +1 -1
- package/gui/dist/assets/{Storage-DfP95SlJ.js → Storage-CKa2mqSI.js} +1 -1
- package/gui/dist/assets/{Subagents-CMNsZLfk.js → Subagents-B4oMNOVh.js} +1 -1
- package/gui/dist/assets/{Usage-ESFRfBMO.js → Usage-gaSKtbMw.js} +1 -1
- package/gui/dist/assets/{codex-stale-banner-W4FhikN7.js → codex-stale-banner-B5VQPO3t.js} +1 -1
- package/gui/dist/assets/{data-surface-B8PX8rjm.js → data-surface-B0APtUxm.js} +1 -1
- package/gui/dist/assets/{data-surface-CETFXqiA.js → data-surface-CH5yKPY1.js} +1 -1
- package/gui/dist/assets/index-C8flH881.css +1 -0
- package/gui/dist/assets/{index-DHQHBRhb.js → index-DWxg-UlU.js} +3 -3
- package/gui/dist/assets/{model-display-OBTJhQwT.js → model-display-vDl3mo07.js} +1 -1
- package/gui/dist/assets/{provider-payload-CJRJUn7w.js → provider-payload-C3Xp5N0y.js} +1 -1
- package/gui/dist/assets/{section-tabs-B8sLMiz6.js → section-tabs-Bpm5lFcZ.js} +1 -1
- package/gui/dist/assets/shared-BHpPx-nZ.js +69 -0
- package/gui/dist/index.html +3 -3
- package/package.json +1 -1
- package/src/adapters/base.ts +2 -0
- package/src/adapters/command-code.ts +2 -1
- package/src/adapters/google-http.ts +34 -14
- package/src/adapters/google.ts +4 -1
- package/src/cli/index.ts +9 -1
- package/src/codex/inject.ts +14 -9
- package/src/config/process-state.ts +15 -0
- package/src/config.ts +17 -0
- package/src/generated/compatibility-version.json +61 -41
- package/src/lib/bun-stream-caps.ts +2 -1
- package/src/lib/pinned-http.ts +7 -1
- package/src/lib/process-control.ts +3 -2
- package/src/lib/provider-outbound.ts +77 -27
- package/src/lib/provider-runtime-fetch.ts +23 -0
- package/src/lib/provider-tls-profile.ts +97 -11
- package/src/lib/server-tls.ts +47 -0
- package/src/lib/test-provider-fetch.ts +6 -0
- package/src/lib/test-server-start.ts +5 -0
- package/src/lib/upstream-retry.ts +39 -25
- package/src/oauth/anthropic.ts +4 -3
- package/src/oauth/chatgpt.ts +7 -3
- package/src/oauth/command-code.ts +3 -2
- package/src/oauth/cursor.ts +3 -2
- package/src/oauth/github-copilot.ts +7 -5
- package/src/oauth/google-antigravity.ts +5 -4
- package/src/oauth/key-providers.ts +11 -13
- package/src/oauth/kimi.ts +7 -6
- package/src/oauth/kiro.ts +3 -2
- package/src/oauth/nous.ts +5 -5
- package/src/oauth/transport.ts +77 -0
- package/src/oauth/xai.ts +5 -4
- package/src/providers/replit/probe.ts +8 -3
- package/src/providers/xai-transport.ts +16 -2
- package/src/server/auth-cors.ts +15 -1
- package/src/server/chat-native.ts +9 -3
- package/src/server/index.ts +63 -23
- package/src/server/management/config-routes.ts +117 -15
- package/src/server/management/context.ts +4 -0
- package/src/server/management/provider-routes.ts +1 -0
- package/src/server/responses/compact.ts +5 -2
- package/src/server/responses/core.ts +16 -19
- package/src/server/responses/fetch-helpers.ts +15 -7
- package/src/server/ws-bridge.ts +33 -4
- package/src/types/config.ts +9 -0
- package/src/types/provider.ts +2 -0
- package/src/types.ts +1 -0
- package/gui/dist/assets/Providers-DyaCHRWL.js +0 -1
- package/gui/dist/assets/index-pLho_LBF.css +0 -1
- package/gui/dist/assets/shared-DI18-uF7.js +0 -69
|
@@ -2,6 +2,7 @@ import type { OcxProviderConfig } from "../types";
|
|
|
2
2
|
import { deriveKeyLoginMap, enrichProviderFromRegistry, type DerivedKeyLoginProvider } from "../providers/derive";
|
|
3
3
|
import { resolveProviderModelDiscoveryUrl } from "../providers/model-discovery";
|
|
4
4
|
import { parseGoogleCookieJar, validateAiStudioCookies } from "./google-aistudio-auth";
|
|
5
|
+
import { providerOutboundGet, providerOutboundPost, type ProviderOutboundDependencies } from "../lib/provider-outbound";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* API-key "login" providers: not OAuth — the flow opens the provider's dashboard so the user can
|
|
@@ -72,6 +73,7 @@ export async function validateApiKey(
|
|
|
72
73
|
providerName: string,
|
|
73
74
|
provider: KeyLoginProvider,
|
|
74
75
|
key: string,
|
|
76
|
+
dependencies: Pick<ProviderOutboundDependencies, "fetch"> = {},
|
|
75
77
|
): Promise<boolean | "unknown"> {
|
|
76
78
|
try {
|
|
77
79
|
// A public model catalog cannot prove that the supplied key is valid. Returning unknown keeps
|
|
@@ -80,17 +82,15 @@ export async function validateApiKey(
|
|
|
80
82
|
|
|
81
83
|
if (provider.adapter === "anthropic") {
|
|
82
84
|
const base = provider.baseUrl.replace(/\/v1\/?$/, "");
|
|
83
|
-
const res = await
|
|
84
|
-
method: "POST",
|
|
85
|
+
const res = await providerOutboundPost(providerName, provider, `${base}/v1/messages`, {
|
|
85
86
|
headers: anthropicKeyValidationHeaders(provider, key),
|
|
87
|
+
signal: AbortSignal.timeout(8_000),
|
|
86
88
|
body: JSON.stringify({
|
|
87
89
|
model: provider.defaultModel ?? "claude-haiku-4-5",
|
|
88
90
|
max_tokens: 1,
|
|
89
91
|
messages: [{ role: "user", content: "ping" }],
|
|
90
92
|
}),
|
|
91
|
-
|
|
92
|
-
signal: AbortSignal.timeout(8000),
|
|
93
|
-
});
|
|
93
|
+
}, dependencies);
|
|
94
94
|
if (res.ok) return true;
|
|
95
95
|
if (res.status === 401 || res.status === 403) return false;
|
|
96
96
|
return "unknown";
|
|
@@ -106,11 +106,10 @@ export async function validateApiKey(
|
|
|
106
106
|
if (provider.adapter === "google" && (provider.googleMode ?? "ai-studio") === "ai-studio") {
|
|
107
107
|
// Generative Language API rejects Bearer-wrapped API keys; probe models.list with the
|
|
108
108
|
// documented x-goog-api-key header instead (pageSize=1 — validation only needs a 200).
|
|
109
|
-
const res = await
|
|
109
|
+
const res = await providerOutboundGet(providerName, provider, `${provider.baseUrl}/v1beta/models?pageSize=1`, {
|
|
110
110
|
headers: { "x-goog-api-key": key },
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
});
|
|
111
|
+
signal: AbortSignal.timeout(8_000),
|
|
112
|
+
}, dependencies);
|
|
114
113
|
if (res.ok) return true;
|
|
115
114
|
if (res.status === 400 || res.status === 401 || res.status === 403) return false;
|
|
116
115
|
return "unknown";
|
|
@@ -127,11 +126,10 @@ export async function validateApiKey(
|
|
|
127
126
|
provider.baseUrl,
|
|
128
127
|
`${provider.baseUrl}/models`,
|
|
129
128
|
);
|
|
130
|
-
const res = await
|
|
129
|
+
const res = await providerOutboundGet(providerName, configuredProvider, modelsUrl, {
|
|
131
130
|
headers: { Authorization: `Bearer ${key}` },
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
});
|
|
131
|
+
signal: AbortSignal.timeout(8_000),
|
|
132
|
+
}, dependencies);
|
|
135
133
|
if (res.ok) return true;
|
|
136
134
|
if (res.status === 401 || res.status === 403) return false;
|
|
137
135
|
return "unknown";
|
package/src/oauth/kimi.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { randomUUID } from "node:crypto";
|
|
|
6
6
|
import { recordOwnedConfigPath } from "../lib/config-ownership";
|
|
7
7
|
import { getConfigDir } from "../config";
|
|
8
8
|
import type { OAuthController, OAuthCredentials } from "./types";
|
|
9
|
+
import { oauthFetch } from "./transport";
|
|
9
10
|
|
|
10
11
|
const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
|
|
11
12
|
const DEFAULT_OAUTH_HOST = "https://auth.kimi.com";
|
|
@@ -129,13 +130,13 @@ function getKimiCommonHeaders(): Record<string, string> {
|
|
|
129
130
|
async function requestDeviceAuthorization(): Promise<{
|
|
130
131
|
userCode: string; deviceCode: string; verificationUriComplete: string; expiresInMs: number; intervalMs: number;
|
|
131
132
|
}> {
|
|
132
|
-
const response = await
|
|
133
|
+
const response = await oauthFetch(`${resolveOAuthHost()}/api/oauth/device_authorization`, {
|
|
133
134
|
method: "POST",
|
|
134
135
|
headers: { "Content-Type": "application/x-www-form-urlencoded", ...getKimiCommonHeaders() },
|
|
135
136
|
body: new URLSearchParams({ client_id: CLIENT_ID }),
|
|
136
137
|
});
|
|
137
138
|
if (!response.ok) {
|
|
138
|
-
throw new Error(`Kimi device authorization failed: ${response.status}
|
|
139
|
+
throw new Error(`Kimi device authorization failed: ${response.status}`);
|
|
139
140
|
}
|
|
140
141
|
const payload = (await response.json()) as DeviceAuthorizationResponse;
|
|
141
142
|
if (!payload.user_code || !payload.device_code || !payload.verification_uri) {
|
|
@@ -184,7 +185,7 @@ async function pollForToken(deviceCode: string, intervalMs: number, expiresInMs:
|
|
|
184
185
|
let waitMs = Math.max(1000, intervalMs);
|
|
185
186
|
while (Date.now() < deadline) {
|
|
186
187
|
if (signal?.aborted) throw new Error("Login cancelled");
|
|
187
|
-
const response = await
|
|
188
|
+
const response = await oauthFetch(`${resolveOAuthHost()}/api/oauth/token`, {
|
|
188
189
|
method: "POST",
|
|
189
190
|
headers: { "Content-Type": "application/x-www-form-urlencoded", ...getKimiCommonHeaders() },
|
|
190
191
|
body: new URLSearchParams({ client_id: CLIENT_ID, device_code: deviceCode, grant_type: "urn:ietf:params:oauth:grant-type:device_code" }),
|
|
@@ -202,7 +203,7 @@ async function pollForToken(deviceCode: string, intervalMs: number, expiresInMs:
|
|
|
202
203
|
}
|
|
203
204
|
if (error === "expired_token") throw new Error("Kimi device authorization expired");
|
|
204
205
|
if (error === "access_denied") throw new Error("Kimi device authorization denied");
|
|
205
|
-
throw new Error(`Kimi device flow failed
|
|
206
|
+
throw new Error(`Kimi device flow failed (${response.status})`);
|
|
206
207
|
}
|
|
207
208
|
throw new Error("Kimi device flow timed out");
|
|
208
209
|
}
|
|
@@ -222,14 +223,14 @@ export async function loginKimi(ctrl: OAuthController): Promise<OAuthCredentials
|
|
|
222
223
|
}
|
|
223
224
|
|
|
224
225
|
export async function refreshKimiToken(refreshToken: string): Promise<OAuthCredentials> {
|
|
225
|
-
const response = await
|
|
226
|
+
const response = await oauthFetch(`${resolveOAuthHost()}/api/oauth/token`, {
|
|
226
227
|
method: "POST",
|
|
227
228
|
headers: { "Content-Type": "application/x-www-form-urlencoded", ...getKimiCommonHeaders() },
|
|
228
229
|
body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: CLIENT_ID }),
|
|
229
230
|
});
|
|
230
231
|
if (!response.ok) {
|
|
231
232
|
const payload = (await response.json().catch(() => undefined)) as TokenResponse | undefined;
|
|
232
|
-
throw new Error(`Kimi token refresh failed: ${response.status}
|
|
233
|
+
throw new Error(`Kimi token refresh failed: ${response.status}`);
|
|
233
234
|
}
|
|
234
235
|
return parseTokenPayload((await response.json()) as TokenResponse, refreshToken);
|
|
235
236
|
}
|
package/src/oauth/kiro.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
import { homedir } from "node:os";
|
|
30
30
|
import { getAccountSet, saveAccountCredential } from "./store";
|
|
31
31
|
import { KIRO_BUILDER_ID_SERVICE_PROFILE_ARN } from "../adapters/kiro-constants";
|
|
32
|
+
import { oauthFetch } from "./transport";
|
|
32
33
|
|
|
33
34
|
const DEFAULT_REGION = "us-east-1";
|
|
34
35
|
const REFRESH_URL = "https://prod.{region}.auth.desktop.kiro.dev/refreshToken";
|
|
@@ -548,7 +549,7 @@ function kiroRefreshSignal(signal?: AbortSignal): AbortSignal {
|
|
|
548
549
|
|
|
549
550
|
async function refreshKiroDesktopToken(refresh: string, signal?: AbortSignal, metadata?: KiroOAuthMetadata): Promise<OAuthCredentials> {
|
|
550
551
|
const region = resolveKiroRegion(metadata);
|
|
551
|
-
const res = await
|
|
552
|
+
const res = await oauthFetch(REFRESH_URL.replace("{region}", region), {
|
|
552
553
|
method: "POST",
|
|
553
554
|
headers: { "Content-Type": "application/json" },
|
|
554
555
|
body: JSON.stringify({ refreshToken: refresh }),
|
|
@@ -584,7 +585,7 @@ async function refreshAwsSsoOidcToken(
|
|
|
584
585
|
const clientSecret = metadata?.clientSecret;
|
|
585
586
|
if (!clientId || !clientSecret) return refreshKiroDesktopToken(refresh, signal, metadata);
|
|
586
587
|
const region = resolveKiroRegion(metadata);
|
|
587
|
-
const run = async (refreshToken: string): Promise<Response> =>
|
|
588
|
+
const run = async (refreshToken: string): Promise<Response> => oauthFetch(OIDC_URL.replace("{region}", region), {
|
|
588
589
|
method: "POST",
|
|
589
590
|
headers: { "Content-Type": "application/json" },
|
|
590
591
|
body: JSON.stringify({
|
package/src/oauth/nous.ts
CHANGED
|
@@ -40,6 +40,7 @@ import type { OAuthController, OAuthCredentials } from "./types";
|
|
|
40
40
|
import { getAuthStorePath } from "./store";
|
|
41
41
|
import { atomicWriteFile, hardenConfigDir, hardenExistingSecret } from "../config";
|
|
42
42
|
import { BOUNDED_BODY_MAX_BYTES, readBoundedResponseBytes } from "../lib/bounded-body";
|
|
43
|
+
import { oauthFetch } from "./transport";
|
|
43
44
|
|
|
44
45
|
export const NOUS_PORTAL_BASE_URL = "https://portal.nousresearch.com";
|
|
45
46
|
export const NOUS_INFERENCE_BASE_URL = "https://inference-api.nousresearch.com/v1";
|
|
@@ -535,7 +536,7 @@ async function requestDeviceAuthorization(signal?: AbortSignal): Promise<{
|
|
|
535
536
|
intervalMs: number;
|
|
536
537
|
}> {
|
|
537
538
|
const effectiveSignal = requestSignal(signal);
|
|
538
|
-
const response = await
|
|
539
|
+
const response = await oauthFetch(`${resolvePortalBaseUrl()}/api/oauth/device/code`, {
|
|
539
540
|
method: "POST",
|
|
540
541
|
headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" },
|
|
541
542
|
body: new URLSearchParams({
|
|
@@ -593,7 +594,7 @@ async function pollForToken(
|
|
|
593
594
|
let response: Response;
|
|
594
595
|
const effectiveSignal = requestSignal(signal);
|
|
595
596
|
try {
|
|
596
|
-
response = await
|
|
597
|
+
response = await oauthFetch(`${resolvePortalBaseUrl()}/api/oauth/token`, {
|
|
597
598
|
method: "POST",
|
|
598
599
|
headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" },
|
|
599
600
|
body: new URLSearchParams({
|
|
@@ -646,8 +647,7 @@ async function pollForToken(
|
|
|
646
647
|
// Unknown OAuth error: report it from the parsed payload, not by
|
|
647
648
|
// re-reading the (already consumed) response body.
|
|
648
649
|
if (error) {
|
|
649
|
-
|
|
650
|
-
throw new NousTokenError(response.status, String(error), `Nous Portal device authorization failed (${error})${detail}`);
|
|
650
|
+
throw new NousTokenError(response.status, String(error), `Nous Portal device authorization failed (${response.status})`);
|
|
651
651
|
}
|
|
652
652
|
throw tokenErrorFromPayload(response.status, payload);
|
|
653
653
|
}
|
|
@@ -717,7 +717,7 @@ export async function refreshNousToken(refreshToken: string, signal?: AbortSigna
|
|
|
717
717
|
let response: Response;
|
|
718
718
|
const effectiveSignal = requestSignal(signal);
|
|
719
719
|
try {
|
|
720
|
-
response = await
|
|
720
|
+
response = await oauthFetch(`${baseUrl}/api/oauth/token`, {
|
|
721
721
|
method: "POST",
|
|
722
722
|
headers: {
|
|
723
723
|
Accept: "application/json",
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const OAUTH_TIMEOUT_MS = 30_000;
|
|
2
|
+
const MAX_OAUTH_RESPONSE_BYTES = 1024 * 1024;
|
|
3
|
+
|
|
4
|
+
export class OAuthTransportError extends Error {
|
|
5
|
+
override readonly name = "OAuthTransportError";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
async function boundedResponse(response: Response, signal: AbortSignal): Promise<Response> {
|
|
9
|
+
const declaredLength = Number(response.headers.get("content-length"));
|
|
10
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_OAUTH_RESPONSE_BYTES) {
|
|
11
|
+
try { await response.body?.cancel(); } catch { /* ignore cancellation failures */ }
|
|
12
|
+
throw new OAuthTransportError("OAuth response exceeded 1 MiB");
|
|
13
|
+
}
|
|
14
|
+
if (!response.body) return response;
|
|
15
|
+
const reader = response.body.getReader();
|
|
16
|
+
const abort = () => { void reader.cancel(signal.reason).catch(() => undefined); };
|
|
17
|
+
if (signal.aborted) abort();
|
|
18
|
+
else signal.addEventListener("abort", abort, { once: true });
|
|
19
|
+
const chunks: Uint8Array[] = [];
|
|
20
|
+
let size = 0;
|
|
21
|
+
try {
|
|
22
|
+
for (;;) {
|
|
23
|
+
const { done, value } = await reader.read();
|
|
24
|
+
if (done) break;
|
|
25
|
+
size += value.byteLength;
|
|
26
|
+
if (size > MAX_OAUTH_RESPONSE_BYTES) {
|
|
27
|
+
await reader.cancel().catch(() => undefined);
|
|
28
|
+
throw new OAuthTransportError("OAuth response exceeded 1 MiB");
|
|
29
|
+
}
|
|
30
|
+
chunks.push(value);
|
|
31
|
+
}
|
|
32
|
+
} finally {
|
|
33
|
+
signal.removeEventListener("abort", abort);
|
|
34
|
+
reader.releaseLock();
|
|
35
|
+
}
|
|
36
|
+
const body = new Uint8Array(size);
|
|
37
|
+
let offset = 0;
|
|
38
|
+
for (const chunk of chunks) {
|
|
39
|
+
body.set(chunk, offset);
|
|
40
|
+
offset += chunk.byteLength;
|
|
41
|
+
}
|
|
42
|
+
return new Response(body, {
|
|
43
|
+
status: response.status,
|
|
44
|
+
statusText: response.statusText,
|
|
45
|
+
headers: response.headers,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function oauthFetch(
|
|
50
|
+
input: string | URL | Request,
|
|
51
|
+
init: RequestInit = {},
|
|
52
|
+
executor: typeof globalThis.fetch = globalThis.fetch,
|
|
53
|
+
): Promise<Response> {
|
|
54
|
+
let url: URL;
|
|
55
|
+
try {
|
|
56
|
+
url = input instanceof Request ? new URL(input.url) : new URL(String(input));
|
|
57
|
+
} catch {
|
|
58
|
+
throw new OAuthTransportError("OAuth endpoint is invalid");
|
|
59
|
+
}
|
|
60
|
+
if (url.username || url.password) throw new OAuthTransportError("OAuth endpoint must not contain credentials");
|
|
61
|
+
if (url.protocol !== "https:") throw new OAuthTransportError("OAuth endpoint must use HTTPS");
|
|
62
|
+
const timeout = AbortSignal.timeout(OAUTH_TIMEOUT_MS);
|
|
63
|
+
const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
|
|
64
|
+
try {
|
|
65
|
+
const response = await executor(input, { ...init, redirect: "manual", signal });
|
|
66
|
+
if (response.status >= 300 && response.status < 400) {
|
|
67
|
+
try { await response.body?.cancel(); } catch { /* ignore cancellation failures */ }
|
|
68
|
+
throw new OAuthTransportError(`OAuth endpoint refused the request (HTTP ${response.status})`);
|
|
69
|
+
}
|
|
70
|
+
return await boundedResponse(response, signal);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error instanceof OAuthTransportError) throw error;
|
|
73
|
+
if (init.signal?.aborted) throw init.signal.reason;
|
|
74
|
+
if (timeout.aborted) throw timeout.reason;
|
|
75
|
+
throw new OAuthTransportError("OAuth request failed");
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/oauth/xai.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { OAuthCallbackFlow, type OAuthCallbackFlowOptions } from "./callback-server";
|
|
3
3
|
import { generatePKCE } from "./pkce";
|
|
4
4
|
import type { LocalTokenImportMode, OAuthController, OAuthCredentials } from "./types";
|
|
5
|
+
import { oauthFetch } from "./transport";
|
|
5
6
|
|
|
6
7
|
const XAI_OAUTH_ISSUER = "https://auth.x.ai";
|
|
7
8
|
export const XAI_OAUTH_DISCOVERY_URL = `${XAI_OAUTH_ISSUER}/.well-known/openid-configuration`;
|
|
@@ -54,12 +55,12 @@ function validateXaiEndpoint(rawUrl: string): string {
|
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
export async function discoverXaiOAuthEndpoints(signal?: AbortSignal): Promise<XaiDiscovery> {
|
|
57
|
-
const response = await
|
|
58
|
+
const response = await oauthFetch(XAI_OAUTH_DISCOVERY_URL, {
|
|
58
59
|
headers: { Accept: "application/json" },
|
|
59
60
|
signal: requestSignal(signal),
|
|
60
61
|
});
|
|
61
62
|
if (!response.ok) {
|
|
62
|
-
throw new Error(`xAI OAuth discovery failed: ${response.status}
|
|
63
|
+
throw new Error(`xAI OAuth discovery failed: ${response.status}`);
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
const payload = (await response.json()) as XaiDiscoveryPayload;
|
|
@@ -96,14 +97,14 @@ export class XaiTokenRequestError extends Error { constructor(public readonly st
|
|
|
96
97
|
export interface XaiTokenRetryDeps { sleep?:(ms:number)=>Promise<void>; random?:()=>number }
|
|
97
98
|
function isAbortError(error:unknown):boolean{return error instanceof DOMException&&error.name==="AbortError";}
|
|
98
99
|
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
|
|
100
|
+
async function readTokenError(response:Response):Promise<XaiTokenRequestError>{let oauthError:string|undefined;try{const body=await response.json() as {error?:unknown};if(typeof body.error==="string"&&/^(?:access_denied|expired_token|invalid_client|invalid_grant|invalid_request|temporarily_unavailable|unauthorized_client|unsupported_grant_type)$/.test(body.error))oauthError=body.error;}catch{oauthError=undefined;}return new XaiTokenRequestError(response.status,oauthError,`xAI token request failed: ${response.status}`);}
|
|
100
101
|
export async function postXaiToken(
|
|
101
102
|
tokenEndpoint: string,
|
|
102
103
|
body: Record<string, string>,
|
|
103
104
|
signal?: AbortSignal, deps:XaiTokenRetryDeps={},
|
|
104
105
|
): Promise<XaiTokenPayload> {
|
|
105
106
|
const sleep=deps.sleep??(ms=>Bun.sleep(ms)),random=deps.random??Math.random;let last:unknown;
|
|
106
|
-
for(let attempt=1;attempt<=3;attempt++){let response:Response;try{response=await
|
|
107
|
+
for(let attempt=1;attempt<=3;attempt++){let response:Response;try{response=await oauthFetch(tokenEndpoint, {
|
|
107
108
|
method: "POST",
|
|
108
109
|
headers: {
|
|
109
110
|
Accept: "application/json",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { providerOutboundGet, providerRedirectError } from "../../lib/provider-outbound";
|
|
1
|
+
import { ProviderOutboundPolicyError, providerOutboundGet, providerRedirectError } from "../../lib/provider-outbound";
|
|
2
2
|
import { extractProviderModelItems, readBoundedDiscoveryJson } from "../model-discovery";
|
|
3
3
|
import type { OcxProviderConfig } from "../../types";
|
|
4
4
|
import {
|
|
@@ -41,6 +41,11 @@ function probeProviderStub(origin: ValidatedReplitOrigin): ProbeProviderConfig {
|
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
function probeFailureCategory(error: unknown): "redirect_rejected" | "timeout" | "internal" {
|
|
45
|
+
if (error instanceof ProviderOutboundPolicyError && / returned 3\d\d redirect;/.test(error.message)) return "redirect_rejected";
|
|
46
|
+
return error instanceof DOMException && error.name === "TimeoutError" ? "timeout" : "internal";
|
|
47
|
+
}
|
|
48
|
+
|
|
44
49
|
async function cancelResponseBody(response: Response): Promise<void> {
|
|
45
50
|
try {
|
|
46
51
|
await response.body?.cancel();
|
|
@@ -96,7 +101,7 @@ export async function probeReplitGateway(
|
|
|
96
101
|
return {
|
|
97
102
|
ok: false,
|
|
98
103
|
stage: "healthz",
|
|
99
|
-
category: error
|
|
104
|
+
category: probeFailureCategory(error),
|
|
100
105
|
error: error instanceof Error ? error.message : "healthz probe failed",
|
|
101
106
|
latencyMs: Date.now() - healthzStarted,
|
|
102
107
|
};
|
|
@@ -132,7 +137,7 @@ export async function probeReplitGateway(
|
|
|
132
137
|
return {
|
|
133
138
|
ok: false,
|
|
134
139
|
stage: "models",
|
|
135
|
-
category: error
|
|
140
|
+
category: probeFailureCategory(error),
|
|
136
141
|
error: error instanceof Error ? error.message : "models probe failed",
|
|
137
142
|
latencyMs: Date.now() - modelsStarted,
|
|
138
143
|
};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import type { OcxProviderConfig } from "../types";
|
|
3
3
|
import { resolveGithubCopilotTransport } from "./github-copilot-transport";
|
|
4
|
+
import { testProviderFetch } from "../lib/test-provider-fetch";
|
|
5
|
+
import { RUNTIME_PROVIDER_FETCH, type RuntimeProviderFetch } from "../lib/provider-runtime-fetch";
|
|
4
6
|
|
|
5
7
|
export const XAI_GROK_CLI_BASE_URL = "https://cli-chat-proxy.grok.com/v1";
|
|
6
8
|
|
|
@@ -46,6 +48,7 @@ export const XAI_CONV_ID_HEADER = XAI_GROK_COMPATIBILITY.headers.conversationId;
|
|
|
46
48
|
export type OcxProviderTransport = OcxProviderConfig & {
|
|
47
49
|
/** Request executor used only at runtime; never persisted. */
|
|
48
50
|
fetch?: typeof globalThis.fetch;
|
|
51
|
+
[RUNTIME_PROVIDER_FETCH]?: RuntimeProviderFetch;
|
|
49
52
|
};
|
|
50
53
|
|
|
51
54
|
const XAI_GROK_CLI_HEADERS: Readonly<Record<string, string>> = {
|
|
@@ -156,14 +159,25 @@ export function resolveProviderTransport(
|
|
|
156
159
|
// transient retries reuse one id so the upstream can dedupe them. A rotated key resolves a
|
|
157
160
|
// fresh transport, which gets its own id.
|
|
158
161
|
const requestId = configuredRequestId ?? randomUUID();
|
|
159
|
-
const baseFetch = provider
|
|
162
|
+
const baseFetch = testProviderFetch(provider) ?? globalThis.fetch;
|
|
160
163
|
const attemptFetch = ((input, init) =>
|
|
161
164
|
baseFetch(input, withGeneratedRequestId(init, requestId, stableHeaders))) as typeof globalThis.fetch;
|
|
162
165
|
|
|
163
|
-
|
|
166
|
+
const resolved = {
|
|
164
167
|
...provider,
|
|
165
168
|
...(provider.authMode === "oauth" ? { baseUrl: XAI_GROK_CLI_BASE_URL } : {}),
|
|
166
169
|
headers,
|
|
167
170
|
fetch: attemptFetch,
|
|
168
171
|
};
|
|
172
|
+
// Enumerable symbols survive runtime provider spreads (credential/key rotation), while
|
|
173
|
+
// JSON serialization still cannot persist or accept them from configuration.
|
|
174
|
+
Object.defineProperty(resolved, RUNTIME_PROVIDER_FETCH, {
|
|
175
|
+
value: {
|
|
176
|
+
providerName: "xai",
|
|
177
|
+
origins: ["https://api.x.ai", "https://cli-chat-proxy.grok.com"],
|
|
178
|
+
fetch: attemptFetch,
|
|
179
|
+
},
|
|
180
|
+
enumerable: true,
|
|
181
|
+
});
|
|
182
|
+
return resolved;
|
|
169
183
|
}
|
package/src/server/auth-cors.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { assertServerTlsFiles, serverTlsConfigError } from "../lib/server-tls";
|
|
2
3
|
import { formatErrorResponse } from "../bridge";
|
|
3
4
|
import {
|
|
4
5
|
codexAutoStartEnabled,
|
|
@@ -294,7 +295,10 @@ export function requestPolicyView(config: OcxConfig, bindHostname: string): Requ
|
|
|
294
295
|
};
|
|
295
296
|
}
|
|
296
297
|
|
|
297
|
-
export function assertServerAuthConfig(
|
|
298
|
+
export function assertServerAuthConfig(
|
|
299
|
+
config: OcxConfig,
|
|
300
|
+
options: { allowPlaintextRemoteForTests?: boolean } = {},
|
|
301
|
+
): void {
|
|
298
302
|
const hasConfiguredDataCredential = !!configuredApiAuthToken(config)
|
|
299
303
|
|| (config.apiKeys ?? []).some(entry => !!entry.key.trim());
|
|
300
304
|
if (isApiAuthRequired(config) && !hasConfiguredDataCredential) {
|
|
@@ -302,6 +306,12 @@ export function assertServerAuthConfig(config: OcxConfig): void {
|
|
|
302
306
|
"A data-plane credential (OPENCODEX_API_AUTH_TOKEN or config.apiKeys) is required when binding opencodex to a non-loopback hostname",
|
|
303
307
|
);
|
|
304
308
|
}
|
|
309
|
+
const tlsError = serverTlsConfigError(config.tls);
|
|
310
|
+
if (tlsError) throw new Error(`Invalid server TLS configuration: ${tlsError}`);
|
|
311
|
+
if (isApiAuthRequired(config) && !config.tls && !options.allowPlaintextRemoteForTests) {
|
|
312
|
+
throw new Error("Native TLS is required when binding opencodex to a non-loopback hostname; configure tls.certFile, tls.keyFile, and tls.publicOrigin or bind to loopback behind a TLS tunnel");
|
|
313
|
+
}
|
|
314
|
+
if (config.tls) assertServerTlsFiles(config.tls);
|
|
305
315
|
}
|
|
306
316
|
|
|
307
317
|
function secretEquals(actual: string, expected: string | undefined): boolean {
|
|
@@ -607,6 +617,9 @@ export function providerManagementConfigError(name: unknown, provider: unknown):
|
|
|
607
617
|
// it before it reaches the management API response.
|
|
608
618
|
return `provider ${JSON.stringify(redactSecretString(name))} ${retryOn429Error}`;
|
|
609
619
|
}
|
|
620
|
+
if (raw.replayTransientFailures !== undefined && typeof raw.replayTransientFailures !== "boolean") {
|
|
621
|
+
return `provider ${JSON.stringify(redactSecretString(name))} replayTransientFailures must be a boolean`;
|
|
622
|
+
}
|
|
610
623
|
const requestPacingError = requestPacingConfigError(raw.requestPacing);
|
|
611
624
|
if (requestPacingError) {
|
|
612
625
|
return `provider ${JSON.stringify(redactSecretString(name))} ${requestPacingError}`;
|
|
@@ -733,6 +746,7 @@ export function safeConfigDTO(config: OcxConfig): unknown {
|
|
|
733
746
|
"defaultAliases",
|
|
734
747
|
"disabled",
|
|
735
748
|
"allowPrivateNetwork",
|
|
749
|
+
"replayTransientFailures",
|
|
736
750
|
"authMode",
|
|
737
751
|
"googleMode",
|
|
738
752
|
"apiKeyTransport",
|
|
@@ -20,7 +20,7 @@ import { resolveClientRetryAfter } from "../lib/retry-after";
|
|
|
20
20
|
import { isModelTextOnly } from "../vision";
|
|
21
21
|
import {
|
|
22
22
|
applyUpstreamRecoveryInit,
|
|
23
|
-
|
|
23
|
+
fetchWithTransientRetry,
|
|
24
24
|
prepareSameTarget429Wait,
|
|
25
25
|
type UpstreamSendRecovery,
|
|
26
26
|
} from "../lib/upstream-retry";
|
|
@@ -202,9 +202,10 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
|
|
|
202
202
|
return fail(400, error instanceof Error ? error.message : String(error), "invalid_request_error");
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
const replayBudget = activeProvider.replayTransientFailures ? { remaining: 2 } : undefined;
|
|
205
206
|
const send = async (request: AdapterRequest, recovery?: "rate-limit-429" | "key-429"): Promise<Response> => {
|
|
206
207
|
try {
|
|
207
|
-
return await
|
|
208
|
+
return await fetchWithTransientRetry(
|
|
208
209
|
(transportRecovery?: UpstreamSendRecovery) => {
|
|
209
210
|
noteAttemptSend(attempt, logCtx.usageLogInputTokens, transportRecovery ?? recovery);
|
|
210
211
|
return fetchWithHeaderTimeout(
|
|
@@ -223,7 +224,12 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
|
|
|
223
224
|
}),
|
|
224
225
|
);
|
|
225
226
|
},
|
|
226
|
-
{
|
|
227
|
+
{
|
|
228
|
+
abortSignal: upstream.signal,
|
|
229
|
+
label: safeHostLabel(request.url),
|
|
230
|
+
replayTransientFailures: activeProvider.replayTransientFailures,
|
|
231
|
+
replayBudget,
|
|
232
|
+
},
|
|
227
233
|
);
|
|
228
234
|
} finally {
|
|
229
235
|
request.releaseBodyObservation?.();
|