@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.
@@ -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
+ }
@@ -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 parsed = Date.parse(value);
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 === undefined || limit <= 0) return null;
239
- let used = toFiniteNumber(row.used);
240
- if (used === undefined) {
241
- const remaining = toFiniteNumber(row.remaining);
242
- if (remaining === undefined) return null;
243
- used = limit - remaining;
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
- const percent = normalizePercent((used / limit) * 100);
246
- if (percent === undefined) return null;
247
- const resetAt = quotaResetAt(row) ?? (resetFallback ? quotaResetAt(resetFallback) : undefined);
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
- const label = [item.name, item.title, item.scope, detail.name, detail.title]
256
- .filter((value): value is string => typeof value === "string")
257
- .join(" ")
258
- .toLowerCase();
259
- return /(^|\b)5\s*(?:h|hour)/.test(label);
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 = asRecord(value);
303
+ const body = unwrapKimiQuotaPayload(value);
264
304
  if (!body) return null;
265
- const weekly = parseKimiQuotaRow(body.usage);
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)) continue;
275
- fiveHour = parseKimiQuotaRow(detail, window);
276
- if (fiveHour) break;
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 fetchKimiQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaReport | null> {
295
- // Never release an OAuth token to a user-edited or lookalike provider host.
296
- if (normalizedBaseUrl(config.baseUrl) !== KIMI_CODE_BASE_URL) return null;
297
- let accessToken: string;
298
- try {
299
- accessToken = await getValidAccessToken("kimi");
300
- } catch {
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
- // Cursor tracks two linked pools: First-party models (Auto/Composer/Grok) and API usage.
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
- const limit = toFiniteNumber(planUsage.limit ?? planUsage.limitCents ?? planUsage.totalLimitCents);
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
- monthlyPercent: totalPercent,
393
- ...(resetAt !== undefined ? { monthlyResetAt: resetAt } : {}),
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 is auth-gated, so ids remain unverified. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md.
761
- { id: "github-copilot", label: "GitHub Copilot", baseUrl: "https://api.githubcopilot.com", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://github.com/settings/copilot" },
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();
@@ -24,8 +24,50 @@ export async function isPortAvailable(port: number, hostname = "127.0.0.1"): Pro
24
24
  });
25
25
  }
26
26
 
27
- export async function findAvailablePort(preferredPort: number, hostname = "127.0.0.1"): Promise<number> {
28
- if (await isPortAvailable(preferredPort, hostname)) return preferredPort;
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, readPid, readRuntimePort } from "../config";
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
- const readPidFn = io.readPidFn ?? readPid;
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) return { pid, port: runtime.port, hostname: runtime.hostname };
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 ?? null, port, hostname: config.hostname };
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
- const errorCode = requestLogErrorCode(status, logCtx.upstreamError);
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
- status,
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
- ...(meta?.closeReason ? { closeReason: meta.closeReason } : {}),
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: status,
475
+ upstreamStatus: effectiveStatus,
462
476
  bodyKind: logCtx.usageDebugBodyKind ?? "none",
463
477
  bodySample: logCtx.usageDebugBodySample ?? "",
464
478
  extractedUsage: loggedUsage ?? null,
@@ -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 isXaiOAuthRequest = route.providerName === "xai" && route.provider.authMode === "oauth";
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 (isXaiOAuthRequest) sentOAuthSnapshot = resolved;
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(route.providerName, route.provider, parsed.options.promptCacheKey);
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
- && isXaiOAuthRequest
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
- async function bufferCompactResponse(upstream: Response, signal: AbortSignal): Promise<Response> {
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 } });