@bitkyc08/opencodex 2.7.25 → 2.7.26
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/gui/dist/assets/{index-Dq3eZ1cU.css → index-BnrJO9Wz.css} +1 -1
- package/gui/dist/assets/{index-QyUBi3W_.js → index-BvQ5spEX.js} +3 -3
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/cli/index.ts +7 -2
- package/src/config.ts +60 -2
- package/src/lib/errors.ts +51 -12
- package/src/lib/process-control.ts +1 -1
- package/src/lib/redact.ts +8 -0
- package/src/lib/windows-secret-acl.ts +217 -74
- package/src/oauth/github-copilot.ts +427 -0
- package/src/oauth/index.ts +41 -4
- package/src/oauth/kimi.ts +52 -1
- package/src/oauth/store.ts +20 -3
- package/src/oauth/types.ts +5 -0
- package/src/providers/github-copilot-transport.ts +56 -0
- package/src/providers/quota.ts +111 -59
- package/src/providers/registry.ts +56 -2
- package/src/providers/xai-transport.ts +5 -0
- package/src/server/ports.ts +44 -2
- package/src/server/proxy-liveness.ts +26 -4
- package/src/server/request-log.ts +20 -6
- package/src/server/responses.ts +14 -5
- package/src/update/job.ts +59 -11
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { OcxProviderConfig } from "../types";
|
|
2
|
+
import {
|
|
3
|
+
GITHUB_COPILOT_DEFAULT_API_BASE,
|
|
4
|
+
GITHUB_COPILOT_EDITOR_HEADERS,
|
|
5
|
+
validateCopilotApiBaseUrl,
|
|
6
|
+
} from "../oauth/github-copilot";
|
|
7
|
+
|
|
8
|
+
export type OcxProviderTransport = OcxProviderConfig & {
|
|
9
|
+
fetch?: typeof globalThis.fetch;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function hasHeaderCaseInsensitive(
|
|
13
|
+
headers: Record<string, string> | undefined,
|
|
14
|
+
name: string,
|
|
15
|
+
): boolean {
|
|
16
|
+
const target = name.toLowerCase();
|
|
17
|
+
return Object.keys(headers ?? {}).some(key => key.toLowerCase() === target);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function withoutUserOverridden(
|
|
21
|
+
defaults: Readonly<Record<string, string>>,
|
|
22
|
+
userHeaders: Record<string, string> | undefined,
|
|
23
|
+
): Record<string, string> {
|
|
24
|
+
return Object.fromEntries(
|
|
25
|
+
Object.entries(defaults).filter(([name]) => !hasHeaderCaseInsensitive(userHeaders, name)),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Copilot chat requires editor fingerprint headers. Defaults are honest OpenCodex values
|
|
31
|
+
* with Copilot-Integration-Id set to the vscode-chat integration id the public client uses.
|
|
32
|
+
* User-configured headers always win.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveGithubCopilotTransport(
|
|
35
|
+
provider: OcxProviderTransport,
|
|
36
|
+
apiBaseUrl?: string,
|
|
37
|
+
): OcxProviderTransport {
|
|
38
|
+
const stableDefaults = withoutUserOverridden(GITHUB_COPILOT_EDITOR_HEADERS, provider.headers);
|
|
39
|
+
const headers = {
|
|
40
|
+
...stableDefaults,
|
|
41
|
+
...(provider.headers ?? {}),
|
|
42
|
+
};
|
|
43
|
+
// Fail closed: the OAuth bearer only ever goes to an allowlisted *.githubcopilot.com
|
|
44
|
+
// host. A legacy/crafted credential without endpoints.api, or a user-edited baseUrl,
|
|
45
|
+
// must not redirect the token — fall back to the canonical default instead.
|
|
46
|
+
const baseUrl = provider.authMode === "oauth"
|
|
47
|
+
? validateCopilotApiBaseUrl(apiBaseUrl)
|
|
48
|
+
?? validateCopilotApiBaseUrl(provider.baseUrl)
|
|
49
|
+
?? GITHUB_COPILOT_DEFAULT_API_BASE
|
|
50
|
+
: apiBaseUrl?.trim() || provider.baseUrl || GITHUB_COPILOT_DEFAULT_API_BASE;
|
|
51
|
+
return {
|
|
52
|
+
...provider,
|
|
53
|
+
baseUrl,
|
|
54
|
+
headers,
|
|
55
|
+
};
|
|
56
|
+
}
|
package/src/providers/quota.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { fetchMainAccountInfo, listCodexAuthAccounts } from "../codex/auth-api";
|
|
2
2
|
import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account";
|
|
3
|
+
import { resolveEnvValue } from "../config";
|
|
3
4
|
import { getValidAccessToken } from "../oauth";
|
|
4
5
|
import { getCredential } from "../oauth/store";
|
|
5
6
|
import { antigravityUserAgent } from "../adapters/client-fingerprint";
|
|
@@ -79,7 +80,14 @@ function providerLabel(providerId: string): string {
|
|
|
79
80
|
function normalizeResetAt(value: unknown): number | undefined {
|
|
80
81
|
if (typeof value === "number" && Number.isFinite(value)) return value > 10_000_000_000 ? value : value * 1000;
|
|
81
82
|
if (typeof value === "string" && value.trim()) {
|
|
82
|
-
const
|
|
83
|
+
const trimmed = value.trim();
|
|
84
|
+
// Cursor Connect RPC returns billingCycleEnd as a unix-ms decimal string ("1771077734000").
|
|
85
|
+
// Date.parse treats that as invalid; numeric epoch strings must be handled explicitly.
|
|
86
|
+
if (/^\d+(\.\d+)?$/.test(trimmed)) {
|
|
87
|
+
const numeric = Number(trimmed);
|
|
88
|
+
if (Number.isFinite(numeric)) return numeric > 10_000_000_000 ? numeric : numeric * 1000;
|
|
89
|
+
}
|
|
90
|
+
const parsed = Date.parse(trimmed);
|
|
83
91
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
84
92
|
}
|
|
85
93
|
return undefined;
|
|
@@ -231,38 +239,70 @@ function quotaResetAt(row: Record<string, unknown>): number | undefined {
|
|
|
231
239
|
return normalizeResetAt(row.resetTime ?? row.resetAt ?? row.reset_time ?? row.reset_at);
|
|
232
240
|
}
|
|
233
241
|
|
|
242
|
+
function isCanonicalKimiCodeBaseUrl(baseUrl: string): boolean {
|
|
243
|
+
return normalizedBaseUrl(baseUrl) === KIMI_CODE_BASE_URL;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Prefer the nested `data` shell when the outer object is only an envelope. */
|
|
247
|
+
function unwrapKimiQuotaPayload(value: unknown): Record<string, unknown> | null {
|
|
248
|
+
const body = asRecord(value);
|
|
249
|
+
if (!body) return null;
|
|
250
|
+
const nested = asRecord(body.data);
|
|
251
|
+
if (!nested) return body;
|
|
252
|
+
// A null/non-usable outer field is a placeholder, not data — an envelope like
|
|
253
|
+
// { usage: null, data: { usage: {...} } } must still unwrap to the nested payload.
|
|
254
|
+
const usable = (field: unknown): boolean => field !== undefined && field !== null;
|
|
255
|
+
const outerHasUsage = usable(body.usage) || usable(body.limits) || usable(body.totalQuota);
|
|
256
|
+
const nestedHasUsage = usable(nested.usage) || usable(nested.limits) || usable(nested.totalQuota);
|
|
257
|
+
return !outerHasUsage && nestedHasUsage ? nested : body;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function kimiLimitLabel(item: Record<string, unknown>, detail: Record<string, unknown>): string {
|
|
261
|
+
return [item.name, item.title, item.scope, detail.name, detail.title]
|
|
262
|
+
.filter((value): value is string => typeof value === "string")
|
|
263
|
+
.join(" ")
|
|
264
|
+
.toLowerCase();
|
|
265
|
+
}
|
|
266
|
+
|
|
234
267
|
function parseKimiQuotaRow(value: unknown, resetFallback?: Record<string, unknown>): { percent: number; resetAt?: number } | null {
|
|
235
268
|
const row = asRecord(value);
|
|
236
269
|
if (!row) return null;
|
|
270
|
+
const resetAt = quotaResetAt(row) ?? (resetFallback ? quotaResetAt(resetFallback) : undefined);
|
|
237
271
|
const limit = toFiniteNumber(row.limit);
|
|
238
|
-
if (limit
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
272
|
+
if (limit !== undefined && limit > 0) {
|
|
273
|
+
let used = toFiniteNumber(row.used);
|
|
274
|
+
if (used === undefined) {
|
|
275
|
+
const remaining = toFiniteNumber(row.remaining);
|
|
276
|
+
if (remaining !== undefined) used = limit - remaining;
|
|
277
|
+
}
|
|
278
|
+
if (used !== undefined) {
|
|
279
|
+
const percent = normalizePercent((used / limit) * 100);
|
|
280
|
+
if (percent !== undefined) return { percent, ...(resetAt !== undefined ? { resetAt } : {}) };
|
|
281
|
+
}
|
|
244
282
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
return { percent, ...(resetAt !== undefined ? { resetAt } : {}) };
|
|
283
|
+
// Some payloads expose utilisation directly when limit/used arithmetic is absent.
|
|
284
|
+
const direct = normalizePercent(row.utilization ?? row.percent ?? row.usedPercent ?? row.used_percent);
|
|
285
|
+
return direct === undefined ? null : { percent: direct, ...(resetAt !== undefined ? { resetAt } : {}) };
|
|
249
286
|
}
|
|
250
287
|
|
|
251
288
|
function isKimiFiveHourLimit(item: Record<string, unknown>, detail: Record<string, unknown>, window: Record<string, unknown>): boolean {
|
|
252
289
|
const duration = toFiniteNumber(window.duration ?? item.duration ?? detail.duration);
|
|
253
290
|
const unit = String(window.timeUnit ?? item.timeUnit ?? detail.timeUnit ?? "").toUpperCase();
|
|
254
291
|
if ((unit.includes("MINUTE") && duration === 300) || (unit.includes("HOUR") && duration === 5)) return true;
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
292
|
+
return /(^|\b)5\s*(?:h|hour)/.test(kimiLimitLabel(item, detail));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function isKimiWeeklyLimit(item: Record<string, unknown>, detail: Record<string, unknown>, window: Record<string, unknown>): boolean {
|
|
296
|
+
const duration = toFiniteNumber(window.duration ?? item.duration ?? detail.duration);
|
|
297
|
+
const unit = String(window.timeUnit ?? item.timeUnit ?? detail.timeUnit ?? "").toUpperCase();
|
|
298
|
+
if ((unit.includes("DAY") && duration === 7) || (unit.includes("HOUR") && duration === 168)) return true;
|
|
299
|
+
return /weekly|7\s*(?:d|day)/.test(kimiLimitLabel(item, detail));
|
|
260
300
|
}
|
|
261
301
|
|
|
262
302
|
function parseKimiQuotaPayload(value: unknown): ProviderQuota | null {
|
|
263
|
-
const body =
|
|
303
|
+
const body = unwrapKimiQuotaPayload(value);
|
|
264
304
|
if (!body) return null;
|
|
265
|
-
|
|
305
|
+
let weekly = parseKimiQuotaRow(body.usage);
|
|
266
306
|
const total = parseKimiQuotaRow(body.totalQuota);
|
|
267
307
|
let fiveHour: { percent: number; resetAt?: number } | null = null;
|
|
268
308
|
if (Array.isArray(body.limits)) {
|
|
@@ -271,9 +311,13 @@ function parseKimiQuotaPayload(value: unknown): ProviderQuota | null {
|
|
|
271
311
|
if (!item) continue;
|
|
272
312
|
const detail = asRecord(item.detail) ?? item;
|
|
273
313
|
const window = asRecord(item.window) ?? {};
|
|
274
|
-
if (!isKimiFiveHourLimit(item, detail, window))
|
|
275
|
-
|
|
276
|
-
|
|
314
|
+
if (!fiveHour && isKimiFiveHourLimit(item, detail, window)) {
|
|
315
|
+
fiveHour = parseKimiQuotaRow(detail, window);
|
|
316
|
+
}
|
|
317
|
+
if (!weekly && isKimiWeeklyLimit(item, detail, window)) {
|
|
318
|
+
weekly = parseKimiQuotaRow(detail, window);
|
|
319
|
+
}
|
|
320
|
+
if (fiveHour && weekly) break;
|
|
277
321
|
}
|
|
278
322
|
}
|
|
279
323
|
const quota: ProviderQuota = {
|
|
@@ -291,15 +335,26 @@ function parseKimiQuotaPayload(value: unknown): ProviderQuota | null {
|
|
|
291
335
|
return hasQuotaRows(quota) ? quota : null;
|
|
292
336
|
}
|
|
293
337
|
|
|
294
|
-
async function
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
return null;
|
|
338
|
+
async function resolveKimiQuotaBearer(config: OcxProviderConfig): Promise<string | null> {
|
|
339
|
+
if (config.authMode === "oauth") {
|
|
340
|
+
try {
|
|
341
|
+
return await getValidAccessToken("kimi");
|
|
342
|
+
} catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
302
345
|
}
|
|
346
|
+
// ACTIVE key only: silently walking apiKeyPool when the primary env reference is
|
|
347
|
+
// unresolved would render a quota bar for a DIFFERENT account than the one routing
|
|
348
|
+
// requests — a wrong meter is worse than no meter.
|
|
349
|
+
const primary = resolveEnvValue(config.apiKey)?.trim();
|
|
350
|
+
return primary || null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function fetchKimiQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaReport | null> {
|
|
354
|
+
// Never release credentials to a user-edited or lookalike provider host.
|
|
355
|
+
if (!isCanonicalKimiCodeBaseUrl(config.baseUrl)) return null;
|
|
356
|
+
const accessToken = await resolveKimiQuotaBearer(config);
|
|
357
|
+
if (!accessToken) return null;
|
|
303
358
|
const response = await fetch(KIMI_CODE_USAGE_URL, {
|
|
304
359
|
headers: { Accept: "application/json", Authorization: `Bearer ${accessToken}` },
|
|
305
360
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
@@ -342,7 +397,22 @@ async function fetchCursorQuota(provider: string): Promise<ProviderQuotaReport |
|
|
|
342
397
|
const planUsage = asRecord(body?.planUsage);
|
|
343
398
|
if (planUsage) {
|
|
344
399
|
const resetAt = normalizeResetAt(body?.billingCycleEnd ?? planUsage.billingCycleEnd ?? body?.periodEnd);
|
|
345
|
-
|
|
400
|
+
|
|
401
|
+
// Primary meter: overall included allowance (Cursor Settings → Usage total %).
|
|
402
|
+
// autoPercentUsed / apiPercentUsed are secondary pools and must not replace the total.
|
|
403
|
+
const limit = toFiniteNumber(planUsage.limit ?? planUsage.limitCents ?? planUsage.totalLimitCents);
|
|
404
|
+
const remaining = toFiniteNumber(planUsage.remaining ?? planUsage.remainingCents);
|
|
405
|
+
const includedSpend = toFiniteNumber(planUsage.includedSpend ?? planUsage.usedCents ?? planUsage.used);
|
|
406
|
+
const totalSpend = toFiniteNumber(planUsage.totalSpend);
|
|
407
|
+
let used: number | undefined;
|
|
408
|
+
if (includedSpend !== undefined) used = includedSpend;
|
|
409
|
+
else if (limit !== undefined && remaining !== undefined) used = Math.max(0, limit - remaining);
|
|
410
|
+
else if (totalSpend !== undefined) used = totalSpend;
|
|
411
|
+
const totalPercent = normalizePercent(planUsage.totalPercentUsed ?? planUsage.percentUsed)
|
|
412
|
+
?? (limit !== undefined && limit > 0 && used !== undefined
|
|
413
|
+
? normalizePercent((used / limit) * 100)
|
|
414
|
+
: undefined);
|
|
415
|
+
|
|
346
416
|
const autoPercent = normalizePercent(planUsage.autoPercentUsed);
|
|
347
417
|
const apiPercent = normalizePercent(planUsage.apiPercentUsed);
|
|
348
418
|
const customWindows: ProviderQuotaWindow[] = [];
|
|
@@ -360,37 +430,14 @@ async function fetchCursorQuota(provider: string): Promise<ProviderQuotaReport |
|
|
|
360
430
|
...(resetAt !== undefined ? { resetAt } : {}),
|
|
361
431
|
});
|
|
362
432
|
}
|
|
363
|
-
if (customWindows.length > 0) {
|
|
364
|
-
const built = report(provider, "cursor:period-usage", {
|
|
365
|
-
customWindows,
|
|
366
|
-
updatedAt: Date.now(),
|
|
367
|
-
});
|
|
368
|
-
if (built) return { ...built, reverseEngineered: true };
|
|
369
|
-
}
|
|
370
433
|
|
|
371
|
-
|
|
372
|
-
const remaining = toFiniteNumber(planUsage.remaining ?? planUsage.remainingCents);
|
|
373
|
-
const includedSpend = toFiniteNumber(planUsage.includedSpend ?? planUsage.usedCents ?? planUsage.used);
|
|
374
|
-
const totalSpend = toFiniteNumber(planUsage.totalSpend);
|
|
375
|
-
let used: number | undefined;
|
|
376
|
-
if (includedSpend !== undefined) used = includedSpend;
|
|
377
|
-
else if (limit !== undefined && remaining !== undefined) used = Math.max(0, limit - remaining);
|
|
378
|
-
else if (totalSpend !== undefined) used = totalSpend;
|
|
379
|
-
const totalPercent = normalizePercent(planUsage.totalPercentUsed ?? planUsage.percentUsed);
|
|
380
|
-
if (limit !== undefined && limit > 0 && used !== undefined) {
|
|
381
|
-
const percent = totalPercent ?? normalizePercent((used / limit) * 100);
|
|
382
|
-
if (percent !== undefined) {
|
|
383
|
-
const built = report(provider, "cursor:period-usage", {
|
|
384
|
-
monthlyPercent: percent,
|
|
385
|
-
...(resetAt !== undefined ? { monthlyResetAt: resetAt } : {}),
|
|
386
|
-
updatedAt: Date.now(),
|
|
387
|
-
});
|
|
388
|
-
if (built) return { ...built, reverseEngineered: true };
|
|
389
|
-
}
|
|
390
|
-
} else if (totalPercent !== undefined) {
|
|
434
|
+
if (totalPercent !== undefined || customWindows.length > 0) {
|
|
391
435
|
const built = report(provider, "cursor:period-usage", {
|
|
392
|
-
|
|
393
|
-
|
|
436
|
+
...(totalPercent !== undefined ? {
|
|
437
|
+
monthlyPercent: totalPercent,
|
|
438
|
+
...(resetAt !== undefined ? { monthlyResetAt: resetAt } : {}),
|
|
439
|
+
} : {}),
|
|
440
|
+
...(customWindows.length > 0 ? { customWindows } : {}),
|
|
394
441
|
updatedAt: Date.now(),
|
|
395
442
|
});
|
|
396
443
|
if (built) return { ...built, reverseEngineered: true };
|
|
@@ -596,7 +643,12 @@ async function maybeFetchProviderQuota(
|
|
|
596
643
|
if (provider.authMode === "oauth" && name === "anthropic") return fetchAnthropicQuota(name);
|
|
597
644
|
if (provider.authMode === "oauth" && name === "cursor") return fetchCursorQuota(name);
|
|
598
645
|
if (provider.authMode === "oauth" && name === "google-antigravity") return fetchAntigravityQuota(name, provider);
|
|
646
|
+
// Kimi Code `/usages` accepts OAuth or coding-plan API keys, but only on the canonical
|
|
647
|
+
// host and only for real key auth — forward/local modes carry no credential of ours.
|
|
599
648
|
if (provider.authMode === "oauth" && name === "kimi") return fetchKimiQuota(name, provider);
|
|
649
|
+
if (provider.authMode === "key" && isCanonicalKimiCodeBaseUrl(provider.baseUrl)) {
|
|
650
|
+
return fetchKimiQuota(name, provider);
|
|
651
|
+
}
|
|
600
652
|
return null;
|
|
601
653
|
} catch {
|
|
602
654
|
return null;
|
|
@@ -172,6 +172,25 @@ const DEEPSEEK_THINKING_REASONING_MAP: Record<string, string> = {
|
|
|
172
172
|
xhigh: "max",
|
|
173
173
|
max: "max",
|
|
174
174
|
};
|
|
175
|
+
// 260719 Alibaba Token Plan Personal Edition (China/Beijing). Keep it distinct from
|
|
176
|
+
// Coding Plan: the products use different exact allowlists and different base URLs.
|
|
177
|
+
// Evidence: https://help.aliyun.com/en/model-studio/token-plan-personal-overview
|
|
178
|
+
// https://help.aliyun.com/en/model-studio/token-plan-quickstart
|
|
179
|
+
const ALIBABA_TOKEN_PLAN_MODELS = [
|
|
180
|
+
"qwen3.8-max-preview", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-flash",
|
|
181
|
+
"glm-5.2", "deepseek-v4-pro",
|
|
182
|
+
];
|
|
183
|
+
const ALIBABA_TOKEN_PLAN_QWEN_MODELS = [
|
|
184
|
+
"qwen3.8-max-preview", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-flash",
|
|
185
|
+
];
|
|
186
|
+
const ALIBABA_TOKEN_PLAN_INPUT_MODALITIES: Record<string, string[]> = {
|
|
187
|
+
"qwen3.8-max-preview": ["text", "image"],
|
|
188
|
+
"qwen3.7-max": ["text"],
|
|
189
|
+
"qwen3.7-plus": ["text", "image"],
|
|
190
|
+
"qwen3.6-flash": ["text", "image"],
|
|
191
|
+
"glm-5.2": ["text"],
|
|
192
|
+
"deepseek-v4-pro": ["text"],
|
|
193
|
+
};
|
|
175
194
|
// 260717 Kimi K3: the subscription endpoint uses one upstream id (`k3`) for both
|
|
176
195
|
// entitlement tiers. Bare `k3` advertises the Moderato 256K ceiling; the local `[1m]`
|
|
177
196
|
// alias advertises Allegretto's 1M ceiling and is stripped before the upstream request.
|
|
@@ -649,6 +668,27 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
649
668
|
{ id: "qianfan", label: "Qianfan (Baidu)", baseUrl: "https://qianfan.baidubce.com/v2", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://console.bce.baidu.com/iam/#/iam/apikey/list" },
|
|
650
669
|
// 2026-07-10: docs unverified; model data frozen. Evidence: devlog/_plan/260710_provider_hardening/002_research_cn.md.
|
|
651
670
|
{ id: "alibaba", label: "Alibaba Coding Plan", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://dashscope.console.aliyun.com/apiKey" },
|
|
671
|
+
{
|
|
672
|
+
id: "alibaba-token-plan",
|
|
673
|
+
label: "Alibaba Token Plan (Beijing)",
|
|
674
|
+
baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
|
|
675
|
+
adapter: "openai-chat",
|
|
676
|
+
authKind: "key",
|
|
677
|
+
dashboardUrl: "https://bailian.console.aliyun.com/cn-beijing?tab=plan",
|
|
678
|
+
defaultModel: "qwen3.8-max-preview",
|
|
679
|
+
models: ALIBABA_TOKEN_PLAN_MODELS,
|
|
680
|
+
liveModels: false,
|
|
681
|
+
note: "Token Plan Personal Edition · China (Beijing)",
|
|
682
|
+
modelInputModalities: ALIBABA_TOKEN_PLAN_INPUT_MODALITIES,
|
|
683
|
+
modelReasoningEfforts: {
|
|
684
|
+
...Object.fromEntries(ALIBABA_TOKEN_PLAN_QWEN_MODELS.map(id => [id, THINKING_BUDGET_EFFORTS])),
|
|
685
|
+
"glm-5.2": ZAI_GLM_52_REASONING_EFFORTS,
|
|
686
|
+
"deepseek-v4-pro": DEEPSEEK_THINKING_EFFORTS,
|
|
687
|
+
},
|
|
688
|
+
modelReasoningEffortMap: { "deepseek-v4-pro": DEEPSEEK_THINKING_REASONING_MAP },
|
|
689
|
+
thinkingBudgetModels: ALIBABA_TOKEN_PLAN_QWEN_MODELS,
|
|
690
|
+
preserveReasoningContentModels: ["glm-5.2", "deepseek-v4-pro"],
|
|
691
|
+
},
|
|
652
692
|
// NEEDS_HUMAN 2026-07-10: kept for config compatibility, but this is a dashboard URL,
|
|
653
693
|
// no /models endpoint is documented, and tools are silently ignored upstream per docs.parallel.ai.
|
|
654
694
|
// Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md.
|
|
@@ -757,8 +797,22 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
757
797
|
note: "No key needed — uses Xiaomi MiMo's free public tier (limited-time offer). A JWT is bootstrapped automatically with an anonymous random client id stored locally. The endpoint contract mirrors the official MiMoCode client and is not publicly documented — Xiaomi may change or restrict it at any time. Prompts may be processed/retained by Xiaomi; do not send confidential material.",
|
|
758
798
|
},
|
|
759
799
|
{ id: "cloudflare-ai-gateway", label: "Cloudflare AI Gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{account-id}/{gateway}/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://dash.cloudflare.com/?to=/:account/ai/ai-gateway" },
|
|
760
|
-
// FREEZE 2026-07-10: /models
|
|
761
|
-
|
|
800
|
+
// FREEZE 2026-07-10: /models was auth-gated under key login. OAuth device-flow + copilot_internal
|
|
801
|
+
// exchange (issue #151) unlocks live discovery; static seed is a cold-start fallback only.
|
|
802
|
+
{
|
|
803
|
+
id: "github-copilot",
|
|
804
|
+
label: "GitHub Copilot",
|
|
805
|
+
baseUrl: "https://api.githubcopilot.com",
|
|
806
|
+
adapter: "openai-chat",
|
|
807
|
+
authKind: "oauth",
|
|
808
|
+
allowKeyAuthOverride: true,
|
|
809
|
+
featured: false,
|
|
810
|
+
dashboardUrl: "https://github.com/settings/copilot",
|
|
811
|
+
liveModels: true,
|
|
812
|
+
models: ["gpt-4o", "gpt-4.1", "gpt-4.1-mini", "claude-sonnet-4", "gemini-2.5-pro"],
|
|
813
|
+
defaultModel: "gpt-4o",
|
|
814
|
+
note: "Experimental unofficial Copilot bridge. Logs in via GitHub device flow using the public VS Code OAuth client id, then exchanges for a short-lived Copilot API token (copilot_internal). Requires an active Copilot subscription. GitHub may tighten or revoke this path; do not send confidential material you would not paste into Copilot Chat.",
|
|
815
|
+
},
|
|
762
816
|
// FREEZE 2026-07-10: no public OpenAI-compatible endpoint is documented. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md.
|
|
763
817
|
{ id: "gitlab-duo", label: "GitLab Duo", baseUrl: "https://cloud.gitlab.com/ai/v1/proxy/openai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://gitlab.com/-/user_settings/personal_access_tokens" },
|
|
764
818
|
];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import type { OcxProviderConfig } from "../types";
|
|
3
|
+
import { resolveGithubCopilotTransport } from "./github-copilot-transport";
|
|
3
4
|
|
|
4
5
|
export const XAI_GROK_CLI_BASE_URL = "https://cli-chat-proxy.grok.com/v1";
|
|
5
6
|
|
|
@@ -90,7 +91,11 @@ export function resolveProviderTransport(
|
|
|
90
91
|
providerName: string,
|
|
91
92
|
provider: OcxProviderTransport,
|
|
92
93
|
promptCacheKey?: string,
|
|
94
|
+
apiBaseUrl?: string,
|
|
93
95
|
): OcxProviderTransport {
|
|
96
|
+
if (providerName === "github-copilot") {
|
|
97
|
+
return resolveGithubCopilotTransport(provider, apiBaseUrl);
|
|
98
|
+
}
|
|
94
99
|
if (providerName !== "xai") return provider;
|
|
95
100
|
|
|
96
101
|
const cacheKey = promptCacheKey?.trim();
|
package/src/server/ports.ts
CHANGED
|
@@ -24,8 +24,50 @@ export async function isPortAvailable(port: number, hostname = "127.0.0.1"): Pro
|
|
|
24
24
|
});
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
export
|
|
28
|
-
|
|
27
|
+
export type WaitForPortOptions = {
|
|
28
|
+
timeoutMs?: number;
|
|
29
|
+
intervalMs?: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Poll until `port` accepts a bind, or until the timeout elapses. */
|
|
33
|
+
export async function waitForPortAvailable(
|
|
34
|
+
port: number,
|
|
35
|
+
hostname = "127.0.0.1",
|
|
36
|
+
opts: WaitForPortOptions = {},
|
|
37
|
+
): Promise<boolean> {
|
|
38
|
+
const timeoutMs = opts.timeoutMs ?? 5000;
|
|
39
|
+
const intervalMs = opts.intervalMs ?? 50;
|
|
40
|
+
const deadline = Date.now() + timeoutMs;
|
|
41
|
+
for (;;) {
|
|
42
|
+
if (await isPortAvailable(port, hostname)) return true;
|
|
43
|
+
if (Date.now() >= deadline) return false;
|
|
44
|
+
await new Promise(resolve => setTimeout(resolve, intervalMs));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type FindAvailablePortOptions = {
|
|
49
|
+
/** How long to keep retrying the preferred port before falling back to an ephemeral port. */
|
|
50
|
+
preferRetryMs?: number;
|
|
51
|
+
preferRetryIntervalMs?: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export async function findAvailablePort(
|
|
55
|
+
preferredPort: number,
|
|
56
|
+
hostname = "127.0.0.1",
|
|
57
|
+
opts: FindAvailablePortOptions = {},
|
|
58
|
+
): Promise<number> {
|
|
59
|
+
const preferRetryMs = opts.preferRetryMs ?? 0;
|
|
60
|
+
if (preferRetryMs > 0) {
|
|
61
|
+
if (await waitForPortAvailable(preferredPort, hostname, {
|
|
62
|
+
timeoutMs: preferRetryMs,
|
|
63
|
+
intervalMs: opts.preferRetryIntervalMs ?? 50,
|
|
64
|
+
})) {
|
|
65
|
+
return preferredPort;
|
|
66
|
+
}
|
|
67
|
+
} else if (await isPortAvailable(preferredPort, hostname)) {
|
|
68
|
+
return preferredPort;
|
|
69
|
+
}
|
|
70
|
+
|
|
29
71
|
return await new Promise((resolve, reject) => {
|
|
30
72
|
const server = createServer();
|
|
31
73
|
server.once("error", reject);
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Lives outside cli.ts (which dispatches argv at module top level) so tests can import it.
|
|
11
11
|
*/
|
|
12
|
-
import { loadConfig,
|
|
12
|
+
import { loadConfig, readAlivePid, readRuntimePort, verifyPidIdentity } from "../config";
|
|
13
13
|
|
|
14
14
|
export interface HealthzIdentity {
|
|
15
15
|
service?: unknown;
|
|
@@ -22,6 +22,11 @@ export interface HealthzIdentity {
|
|
|
22
22
|
export interface LivenessIo {
|
|
23
23
|
fetchFn?: typeof fetch;
|
|
24
24
|
readPidFn?: () => number | null;
|
|
25
|
+
/**
|
|
26
|
+
* Full identity check of the passed candidate pid; must return the SAME pid or null.
|
|
27
|
+
* Destructive callers only ever receive pids that passed this gate.
|
|
28
|
+
*/
|
|
29
|
+
verifyPidFn?: (candidatePid: number) => number | null;
|
|
25
30
|
readRuntimeFn?: (pid?: number) => { pid?: number; port: number; hostname?: string } | null;
|
|
26
31
|
configFn?: () => { port?: number; hostname?: string };
|
|
27
32
|
timeoutMs?: number;
|
|
@@ -86,10 +91,22 @@ export async function proxyIdentityAt(
|
|
|
86
91
|
* found and a foreign listener on the configured port is rejected.
|
|
87
92
|
*/
|
|
88
93
|
export async function findLiveProxy(io: LivenessIo = {}): Promise<LiveProxy | null> {
|
|
89
|
-
|
|
94
|
+
// Prefer the cheap alive-pid check: the Windows cmdline probe (WMIC/PowerShell) is too
|
|
95
|
+
// expensive for waitForProxy's 150ms poll loop, and /healthz identity is the real trust gate.
|
|
96
|
+
const readPidFn = io.readPidFn ?? readAlivePid;
|
|
97
|
+
const verifyPidFn = io.verifyPidFn ?? verifyPidIdentity;
|
|
90
98
|
const readRuntimeFn = io.readRuntimeFn ?? readRuntimePort;
|
|
91
99
|
const configFn = io.configFn ?? loadConfig;
|
|
92
100
|
|
|
101
|
+
// The cheap pid is discovery-only. Before it can appear in a returned (killable) result
|
|
102
|
+
// it must pass the full identity check AND the verifier must echo the exact candidate —
|
|
103
|
+
// a pidfile rewrite between discovery and verification can never swap in another process.
|
|
104
|
+
const killablePid = (candidate: number | null): number | null => {
|
|
105
|
+
if (candidate === null) return null;
|
|
106
|
+
const verified = verifyPidFn(candidate);
|
|
107
|
+
return verified === candidate ? verified : null;
|
|
108
|
+
};
|
|
109
|
+
|
|
93
110
|
const pid = readPidFn();
|
|
94
111
|
let probedPort: number | null = null;
|
|
95
112
|
if (pid) {
|
|
@@ -97,7 +114,12 @@ export async function findLiveProxy(io: LivenessIo = {}): Promise<LiveProxy | nu
|
|
|
97
114
|
if (runtime?.port) {
|
|
98
115
|
probedPort = runtime.port;
|
|
99
116
|
const identity = await proxyIdentityAt(runtime.port, { hostname: runtime.hostname, expectedPid: pid }, io);
|
|
100
|
-
if (identity)
|
|
117
|
+
if (identity) {
|
|
118
|
+
// healthz confirmed the pid itself → trusted; a pidless legacy body did not,
|
|
119
|
+
// so the cheap pid must pass full identity verification before it is returned.
|
|
120
|
+
const trusted = identity.pid === pid ? pid : killablePid(pid);
|
|
121
|
+
return { pid: trusted, port: runtime.port, hostname: runtime.hostname };
|
|
122
|
+
}
|
|
101
123
|
}
|
|
102
124
|
}
|
|
103
125
|
|
|
@@ -117,6 +139,6 @@ export async function findLiveProxy(io: LivenessIo = {}): Promise<LiveProxy | nu
|
|
|
117
139
|
const config = configFn();
|
|
118
140
|
const port = config.port ?? 10100;
|
|
119
141
|
const identity = await proxyIdentityAt(port, { hostname: config.hostname }, io);
|
|
120
|
-
if (identity) return { pid: identity.pid ?? pid
|
|
142
|
+
if (identity) return { pid: identity.pid ?? killablePid(pid), port, hostname: config.hostname };
|
|
121
143
|
return null;
|
|
122
144
|
}
|
|
@@ -3,6 +3,7 @@ import type { ResponsesTerminalStatus } from "../bridge";
|
|
|
3
3
|
import {
|
|
4
4
|
classifyError,
|
|
5
5
|
httpStatusFromTerminalError as httpStatusFromClassifiedTerminalError,
|
|
6
|
+
isClientClosedMessage,
|
|
6
7
|
} from "../lib/errors";
|
|
7
8
|
import { CODEX_CONFIG_PATH, readRootTomlString } from "../codex/paths";
|
|
8
9
|
import { readCodexCatalogPath } from "../codex/catalog";
|
|
@@ -134,6 +135,10 @@ export function nextRequestLogId(timestamp = Date.now()): string {
|
|
|
134
135
|
|
|
135
136
|
export function requestLogErrorCode(status: number, upstreamError?: string): string | undefined {
|
|
136
137
|
if (status >= 200 && status < 400) return undefined;
|
|
138
|
+
// Defense in depth: mid-stream web-search aborts used to land as 502 with this message.
|
|
139
|
+
if (status === 499 || (upstreamError?.trim() && classifyError(status, "upstream_error", upstreamError).code === "client_closed_request")) {
|
|
140
|
+
return "client_closed_request";
|
|
141
|
+
}
|
|
137
142
|
if (status === 400 || status === 409) return "invalid_request_error";
|
|
138
143
|
if (status === 401) return "invalid_api_key";
|
|
139
144
|
if (status === 403) {
|
|
@@ -146,7 +151,6 @@ export function requestLogErrorCode(status: number, upstreamError?: string): str
|
|
|
146
151
|
return "permission_denied";
|
|
147
152
|
}
|
|
148
153
|
if (status === 429) return "rate_limit_exceeded";
|
|
149
|
-
if (status === 499) return "client_closed_request";
|
|
150
154
|
if (status === 503) return "server_is_overloaded";
|
|
151
155
|
if (status >= 500) return "upstream_server_error";
|
|
152
156
|
return `http_${status}`;
|
|
@@ -400,11 +404,21 @@ export function addFinalRequestLog(
|
|
|
400
404
|
meta?: Pick<RequestLogEntry, "terminalStatus" | "closeReason">,
|
|
401
405
|
addLog: (entry: RequestLogEntry) => void = addRequestLog,
|
|
402
406
|
): void {
|
|
403
|
-
|
|
407
|
+
// Mid-stream web-search aborts used to emit response.failed and land as 502/upstream_server_error.
|
|
408
|
+
// Prefer the client-close classification whenever the captured reason says so.
|
|
409
|
+
const effectiveStatus = status >= 500 && logCtx.upstreamError && isClientClosedMessage(logCtx.upstreamError)
|
|
410
|
+
? 499
|
|
411
|
+
: status;
|
|
412
|
+
const errorCode = requestLogErrorCode(effectiveStatus, logCtx.upstreamError);
|
|
413
|
+
// A response.failed whose classified status is 499 is still a client cancel, not an upstream
|
|
414
|
+
// terminal failure — keep /api/logs closeReason aligned with that.
|
|
415
|
+
const closeReason = effectiveStatus === 499
|
|
416
|
+
? "client_cancel"
|
|
417
|
+
: meta?.closeReason;
|
|
404
418
|
if (logCtx.activeAttempt) {
|
|
405
419
|
finishRequestAttempt(
|
|
406
420
|
logCtx.activeAttempt,
|
|
407
|
-
|
|
421
|
+
effectiveStatus,
|
|
408
422
|
Date.now() - (logCtx.activeAttemptStartedAt ?? start),
|
|
409
423
|
logCtx.usage,
|
|
410
424
|
);
|
|
@@ -440,11 +454,11 @@ export function addFinalRequestLog(
|
|
|
440
454
|
...(logCtx.modelSupportsServiceTier !== undefined ? { modelSupportsServiceTier: logCtx.modelSupportsServiceTier } : {}),
|
|
441
455
|
...(logCtx.responseServiceTier ? { responseServiceTier: logCtx.responseServiceTier } : {}),
|
|
442
456
|
...(logCtx.resolvedModel ? { resolvedModel: logCtx.resolvedModel } : {}),
|
|
443
|
-
status,
|
|
457
|
+
status: effectiveStatus,
|
|
444
458
|
durationMs: Date.now() - start,
|
|
445
459
|
...(errorCode ? { errorCode } : {}),
|
|
446
460
|
...(meta?.terminalStatus ? { terminalStatus: meta.terminalStatus } : {}),
|
|
447
|
-
...(
|
|
461
|
+
...(closeReason ? { closeReason } : {}),
|
|
448
462
|
...(logCtx.upstreamError ? { upstreamError: logCtx.upstreamError } : {}),
|
|
449
463
|
usageStatus,
|
|
450
464
|
...(loggedUsage ? { usage: loggedUsage } : {}),
|
|
@@ -458,7 +472,7 @@ export function addFinalRequestLog(
|
|
|
458
472
|
provider: logCtx.provider,
|
|
459
473
|
model: logCtx.model,
|
|
460
474
|
upstreamContentType: logCtx.usageDebugContentType ?? null,
|
|
461
|
-
upstreamStatus:
|
|
475
|
+
upstreamStatus: effectiveStatus,
|
|
462
476
|
bodyKind: logCtx.usageDebugBodyKind ?? "none",
|
|
463
477
|
bodySample: logCtx.usageDebugBodySample ?? "",
|
|
464
478
|
extractedUsage: loggedUsage ?? null,
|
package/src/server/responses.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { modelInList, namespacedToolName } from "../types";
|
|
|
29
29
|
import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig, OcxUsage } from "../types";
|
|
30
30
|
import {
|
|
31
31
|
forceRefreshOAuthAccessSnapshot,
|
|
32
|
+
getOAuthCredentialApiBaseUrl,
|
|
32
33
|
getOAuthCredentialProjectId,
|
|
33
34
|
getValidAccessTokenSnapshot,
|
|
34
35
|
type OAuthAccessSnapshot,
|
|
@@ -950,12 +951,13 @@ export async function handleResponses(
|
|
|
950
951
|
|
|
951
952
|
// OAuth providers: swap in a fresh access token (auto-refreshed) as the Bearer key, so the
|
|
952
953
|
// existing openai-chat / anthropic adapters authenticate with no change.
|
|
953
|
-
const
|
|
954
|
+
const isOAuth401ReplayProvider = (route.providerName === "xai" || route.providerName === "github-copilot")
|
|
955
|
+
&& route.provider.authMode === "oauth";
|
|
954
956
|
let sentOAuthSnapshot: OAuthAccessSnapshot | undefined;
|
|
955
957
|
if (route.provider.authMode === "oauth") {
|
|
956
958
|
try {
|
|
957
959
|
const resolved = await getValidAccessTokenSnapshot(route.providerName);
|
|
958
|
-
if (
|
|
960
|
+
if (isOAuth401ReplayProvider) sentOAuthSnapshot = resolved;
|
|
959
961
|
route.provider = { ...route.provider, apiKey: resolved.accessToken };
|
|
960
962
|
// Antigravity (cloud-code-assist) needs the discovered Cloud Code Assist project id in the
|
|
961
963
|
// CCA envelope; the server injects only the bare token, so pull project from the credential.
|
|
@@ -974,7 +976,12 @@ export async function handleResponses(
|
|
|
974
976
|
return formatErrorResponse(401, "authentication_error", err instanceof Error ? err.message : String(err));
|
|
975
977
|
}
|
|
976
978
|
}
|
|
977
|
-
route.provider = resolveProviderTransport(
|
|
979
|
+
route.provider = resolveProviderTransport(
|
|
980
|
+
route.providerName,
|
|
981
|
+
route.provider,
|
|
982
|
+
parsed.options.promptCacheKey,
|
|
983
|
+
route.providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(route.providerName) : undefined,
|
|
984
|
+
);
|
|
978
985
|
const adapterProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider);
|
|
979
986
|
const adapter = resolveAdapter(adapterProvider, config.cacheRetention);
|
|
980
987
|
logCtx.providerAdapter = adapter.name;
|
|
@@ -1425,7 +1432,7 @@ export async function handleResponses(
|
|
|
1425
1432
|
recovery: for (;;) {
|
|
1426
1433
|
if (
|
|
1427
1434
|
upstreamResponse.status === 401
|
|
1428
|
-
&&
|
|
1435
|
+
&& isOAuth401ReplayProvider
|
|
1429
1436
|
&& sentOAuthSnapshot
|
|
1430
1437
|
&& !oauth401ReplayAttempted
|
|
1431
1438
|
) {
|
|
@@ -1443,6 +1450,7 @@ export async function handleResponses(
|
|
|
1443
1450
|
route.providerName,
|
|
1444
1451
|
{ ...route.provider, apiKey: refreshed.accessToken },
|
|
1445
1452
|
parsed.options.promptCacheKey,
|
|
1453
|
+
route.providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(route.providerName) : undefined,
|
|
1446
1454
|
);
|
|
1447
1455
|
route.provider = refreshedProvider;
|
|
1448
1456
|
activeAdapter = resolveAdapter(
|
|
@@ -1588,7 +1596,8 @@ function compactResponseTooLargeError(): Response {
|
|
|
1588
1596
|
}), { status: 502, headers: { "Content-Type": "application/json" } });
|
|
1589
1597
|
}
|
|
1590
1598
|
|
|
1591
|
-
|
|
1599
|
+
/** Exported for tests: owns the compact client-cancel branch (499 client_cancelled). */
|
|
1600
|
+
export async function bufferCompactResponse(upstream: Response, signal: AbortSignal): Promise<Response> {
|
|
1592
1601
|
const reader = upstream.body?.getReader();
|
|
1593
1602
|
const contentType = upstream.headers.get("content-type") ?? "application/json";
|
|
1594
1603
|
if (!reader) return new Response(null, { status: upstream.status, headers: { "Content-Type": contentType } });
|