@bitkyc08/opencodex 2.7.9-preview.20260712 → 2.7.9-preview.20260712.2

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.
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-BcaDQD3i.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-SnN_1Qr9.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-Cq8maiJf.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.7.9-preview.20260712",
3
+ "version": "2.7.9-preview.20260712.2",
4
4
  "description": "Universal provider proxy for OpenAI Codex — use any LLM with Codex CLI/App/SDK",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -36,7 +36,7 @@
36
36
  "dev:proxy": "bun run src/cli/index.ts start",
37
37
  "dev:gui": "cd gui && bun run dev",
38
38
  "start": "bun run src/cli/index.ts start",
39
- "test": "bun test ./tests/",
39
+ "test": "bun test --isolate ./tests/",
40
40
  "typecheck": "bun x tsc --noEmit",
41
41
  "privacy:scan": "bun scripts/privacy-scan.ts",
42
42
  "generate:jawcode-metadata": "bun scripts/generate-jawcode-metadata.ts",
@@ -1,6 +1,6 @@
1
1
  import type { CursorRunRequest, CursorServerMessage } from "./types";
2
2
  import type { CursorTransport, CursorTransportFactory, CursorTransportFactoryInput } from "./transport";
3
- import { abortError, sleepWithAbort } from "../../lib/upstream-retry";
3
+ import { abortError, retryBackoffDelayMs, sleepWithAbort } from "../../lib/upstream-retry";
4
4
  import { debugProviderDiagnostic } from "../../lib/debug";
5
5
  import { safeCursorErrorMessage } from "./cursor-errors";
6
6
 
@@ -40,8 +40,10 @@ export function isRetryableCursorError(err: unknown): boolean {
40
40
  }
41
41
 
42
42
  export function cursorRetryDelayMs(attempt: number): number {
43
- const exp = Math.min(CURSOR_RETRY_BASE_MS * 2 ** attempt, CURSOR_RETRY_MAX_MS);
44
- return Math.floor(exp * (0.8 + Math.random() * 0.4));
43
+ return retryBackoffDelayMs(attempt, {
44
+ baseDelayMs: CURSOR_RETRY_BASE_MS,
45
+ maxDelayMs: CURSOR_RETRY_MAX_MS,
46
+ });
45
47
  }
46
48
 
47
49
  /**
@@ -1,14 +1,4 @@
1
- import { redactSecretString } from "../lib/redact";
2
-
3
- const ABSOLUTE_PATH_PATTERN = /(?:\/Users\/[^ "';,]+|\/home\/[^ "';,]+|\/root\/[^ "';,]*|[A-Za-z]:\\Users\\[^ "';,]+)/g;
4
-
5
- function sanitizeGoogleErrorText(value: string): string {
6
- return redactSecretString(value).replace(ABSOLUTE_PATH_PATTERN, "[REDACTED_PATH]");
7
- }
8
-
9
- function safeString(value: unknown): string | undefined {
10
- return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
11
- }
1
+ import { parseUpstreamJsonPayload, safeUpstreamErrorString, sanitizeUpstreamErrorText } from "./upstream-http-error";
12
2
 
13
3
  /** Pull the human detail out of the Google API error envelope `{error:{message,status,code}}`. */
14
4
  function googleErrorDetail(payloadText: string): { message?: string; status?: string } {
@@ -16,13 +6,13 @@ function googleErrorDetail(payloadText: string): { message?: string; status?: st
16
6
  if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) {
17
7
  return { message: trimmed || undefined };
18
8
  }
19
- try {
20
- const parsed = JSON.parse(trimmed) as { error?: { message?: unknown; status?: unknown } };
21
- const err = parsed.error;
22
- return { message: safeString(err?.message), status: safeString(err?.status) };
23
- } catch {
24
- return {};
25
- }
9
+ const parsed = parseUpstreamJsonPayload(trimmed);
10
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
11
+ const err = (parsed as { error?: { message?: unknown; status?: unknown } }).error;
12
+ return {
13
+ message: safeUpstreamErrorString(err?.message),
14
+ status: safeUpstreamErrorString(err?.status),
15
+ };
26
16
  }
27
17
 
28
18
  function classifyGoogle(label: string, status: number | undefined, enumStatus: string | undefined, text: string): string {
@@ -59,7 +49,7 @@ function classifyGoogle(label: string, status: number | undefined, enumStatus: s
59
49
  export function safeGoogleHttpErrorMessage(label: string, status: number, payloadText: string): string {
60
50
  const { message, status: enumStatus } = googleErrorDetail(payloadText);
61
51
  const prefix = classifyGoogle(label, status, enumStatus, [message, enumStatus].filter(Boolean).join(" "));
62
- const detail = message ? sanitizeGoogleErrorText(message).slice(0, 500) : `HTTP ${status}`;
52
+ const detail = message ? sanitizeUpstreamErrorText(message).slice(0, 500) : `HTTP ${status}`;
63
53
  return `${prefix}: ${detail}`;
64
54
  }
65
55
 
@@ -1,57 +1,22 @@
1
1
  import type { AdapterFetchContext, AdapterRequest } from "./base";
2
2
  import { isQuotaExhaustedBody, retryableGoogleStatus, safeGoogleHttpErrorMessage } from "./google-errors";
3
- import { clearableDeadline } from "../lib/abort";
4
- import { readBoundedResponseBody } from "../lib/bounded-body";
5
- import { abortError, sleepWithAbort } from "../lib/upstream-retry";
3
+ import { normalizeUpstreamHttpErrorResponse, readDisplaySafeErrorPayloadText } from "./upstream-http-error";
4
+ import {
5
+ abortError,
6
+ cancelResponseBodyBestEffort,
7
+ fetchWithAttemptDeadline,
8
+ retryBackoffDelayMs,
9
+ sleepWithAbort,
10
+ } from "../lib/upstream-retry";
6
11
 
7
12
  const GOOGLE_RETRY_ATTEMPTS = 3;
8
13
  const GOOGLE_RETRY_BASE_MS = 250;
9
14
  const GOOGLE_RETRY_MAX_MS = 2_000;
10
15
 
11
- function retryAfterMs(headers: Headers): number | undefined {
12
- const raw = headers.get("retry-after")?.trim();
13
- if (!raw) return undefined;
14
- const seconds = Number(raw);
15
- if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
16
- const dateMs = Date.parse(raw);
17
- if (!Number.isFinite(dateMs)) return undefined;
18
- return Math.max(0, dateMs - Date.now());
19
- }
20
-
21
- function retryDelayMs(attempt: number, headers?: Headers): number {
22
- const retryAfter = headers ? retryAfterMs(headers) : undefined;
23
- if (retryAfter !== undefined) return Math.min(retryAfter, GOOGLE_RETRY_MAX_MS);
24
- const exp = Math.min(GOOGLE_RETRY_BASE_MS * (2 ** attempt), GOOGLE_RETRY_MAX_MS);
25
- return Math.floor(exp * (0.8 + Math.random() * 0.4));
26
- }
27
-
28
- function cancelResponseBodyBestEffort(res: Response): void {
29
- try {
30
- const cancellation = res.body?.cancel();
31
- if (cancellation) void cancellation.catch(() => {});
32
- } catch {
33
- // Cancellation is cleanup only; retries must not wait for or fail because of it.
34
- }
35
- }
36
-
37
- async function boundedBodyText(res: Response, signal?: AbortSignal): Promise<string> {
38
- try {
39
- const body = await readBoundedResponseBody(res, { signal });
40
- return body.displaySafe ? body.text : "";
41
- } catch (error) {
42
- if (signal?.aborted) throw error;
43
- return "";
44
- }
45
- }
46
-
47
16
  async function normalizeFinalGoogleError(label: string, res: Response, signal?: AbortSignal): Promise<Response> {
48
- if (res.ok) return res;
49
- const payloadText = await boundedBodyText(res, signal);
50
- const headers = new Headers(res.headers);
51
- headers.delete("content-encoding");
52
- headers.delete("content-length");
53
- return new Response(safeGoogleHttpErrorMessage(label, res.status, payloadText), {
54
- status: res.status, statusText: res.statusText, headers,
17
+ return normalizeUpstreamHttpErrorResponse(res, {
18
+ signal,
19
+ formatMessage: payloadText => safeGoogleHttpErrorMessage(label, res.status, payloadText),
55
20
  });
56
21
  }
57
22
 
@@ -67,41 +32,39 @@ export async function fetchGoogleWithRetry(label: string, request: AdapterReques
67
32
  for (let attempt = 0; attempt < GOOGLE_RETRY_ATTEMPTS; attempt++) {
68
33
  if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal);
69
34
  try {
70
- const attemptTimeout = clearableDeadline(timeoutMs, ctx.abortSignal);
71
- let res: Response;
72
- try {
73
- res = await fetch(request.url, {
74
- method: request.method, headers: request.headers, body: request.body,
75
- signal: attemptTimeout.signal,
76
- });
77
- } finally {
78
- // Only the header timer is cleared. The composed signal still contains the parent, so a
79
- // caller abort after headers continue to cancel consumption of the returned response body.
80
- attemptTimeout.clear();
81
- }
35
+ const res = await fetchWithAttemptDeadline(request.url, {
36
+ method: request.method,
37
+ headers: request.headers,
38
+ body: request.body,
39
+ }, timeoutMs, ctx.abortSignal);
82
40
  if (!retryableGoogleStatus(res.status) || attempt === GOOGLE_RETRY_ATTEMPTS - 1) {
83
41
  return ctx.returnRawErrors ? res : normalizeFinalGoogleError(label, res, ctx.abortSignal);
84
42
  }
85
43
  // A 429 may be a transient rate limit (retry) or hard quota exhaustion (do NOT retry —
86
44
  // it won't recover for hours and burns retries). Peek the body to tell them apart.
87
45
  if (res.status === 429 && !ctx.returnRawErrors) {
88
- const peek = await boundedBodyText(res, ctx.abortSignal);
46
+ const peek = await readDisplaySafeErrorPayloadText(res, ctx.abortSignal);
89
47
  if (isQuotaExhaustedBody(peek)) {
90
- const headers = new Headers(res.headers);
91
- headers.delete("content-encoding");
92
- headers.delete("content-length");
93
- return new Response(safeGoogleHttpErrorMessage(label, res.status, peek), {
94
- status: res.status, statusText: res.statusText, headers,
48
+ return normalizeUpstreamHttpErrorResponse(res, {
49
+ signal: ctx.abortSignal,
50
+ formatMessage: payloadText => safeGoogleHttpErrorMessage(label, res.status, payloadText || peek),
95
51
  });
96
52
  }
97
53
  }
98
54
  cancelResponseBodyBestEffort(res);
99
- await sleepWithAbort(retryDelayMs(attempt, res.headers), ctx.abortSignal);
55
+ await sleepWithAbort(retryBackoffDelayMs(attempt, {
56
+ baseDelayMs: GOOGLE_RETRY_BASE_MS,
57
+ maxDelayMs: GOOGLE_RETRY_MAX_MS,
58
+ headers: res.headers,
59
+ }), ctx.abortSignal);
100
60
  } catch (err) {
101
61
  if (ctx.abortSignal?.aborted) throw err;
102
62
  lastError = err;
103
63
  if (attempt === GOOGLE_RETRY_ATTEMPTS - 1) throw err;
104
- await sleepWithAbort(retryDelayMs(attempt), ctx.abortSignal);
64
+ await sleepWithAbort(retryBackoffDelayMs(attempt, {
65
+ baseDelayMs: GOOGLE_RETRY_BASE_MS,
66
+ maxDelayMs: GOOGLE_RETRY_MAX_MS,
67
+ }), ctx.abortSignal);
105
68
  }
106
69
  }
107
70
  throw lastError ?? new Error(`${label} fetch failed`);
@@ -1,35 +1,22 @@
1
- import { redactSecretString } from "../lib/redact";
2
-
3
- const ABSOLUTE_PATH_PATTERN = /(?:\/Users\/[^ "';,]+|\/home\/[^ "';,]+|[A-Za-z]:\\Users\\[^ "';,]+)/g;
1
+ import { parseUpstreamJsonPayload, safeUpstreamErrorString, sanitizeUpstreamErrorText } from "./upstream-http-error";
4
2
  const DETAIL_KEYS = ["__type", "code", "error", "name", "message", "Message", "errorMessage"];
5
3
 
6
- function sanitizeKiroErrorText(value: string): string {
7
- return redactSecretString(value).replace(ABSOLUTE_PATH_PATTERN, "[REDACTED_PATH]");
8
- }
9
-
10
- function safeString(value: unknown): string | undefined {
11
- return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
12
- }
13
-
14
4
  function headerValue(headers: Headers | Record<string, unknown>, name: string): string | undefined {
15
- if (headers instanceof Headers) return name.startsWith(":") ? undefined : safeString(headers.get(name));
16
- return safeString(headers[name]) || safeString(headers[name.toLowerCase()]);
5
+ if (headers instanceof Headers) return name.startsWith(":") ? undefined : safeUpstreamErrorString(headers.get(name));
6
+ return safeUpstreamErrorString(headers[name]) || safeUpstreamErrorString(headers[name.toLowerCase()]);
17
7
  }
18
8
 
19
9
  function payloadDetails(payloadText: string): string[] {
20
10
  const trimmed = payloadText.trim();
21
11
  if (!trimmed) return [];
22
12
  if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return [trimmed];
23
- try {
24
- const parsed = JSON.parse(trimmed) as unknown;
25
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
26
- const obj = parsed as Record<string, unknown>;
27
- return DETAIL_KEYS.map(key => safeString(obj[key])).filter((v): v is string => !!v);
28
- }
29
- if (typeof parsed === "string" && parsed.trim()) return [parsed.trim()];
30
- } catch {
31
- return [];
13
+ const parsed = parseUpstreamJsonPayload(trimmed);
14
+ if (parsed === undefined) return [];
15
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
16
+ const obj = parsed as Record<string, unknown>;
17
+ return DETAIL_KEYS.map(key => safeUpstreamErrorString(obj[key])).filter((v): v is string => !!v);
32
18
  }
19
+ if (typeof parsed === "string" && parsed.trim()) return [parsed.trim()];
33
20
  return [];
34
21
  }
35
22
 
@@ -87,7 +74,7 @@ function classifyKiroText(status: number | undefined, text: string): string {
87
74
  function normalizedKiroErrorMessage(headers: Headers | Record<string, unknown>, payloadText: string, status?: number): string {
88
75
  const headerType = headerValue(headers, ":exception-type") || headerValue(headers, ":error-type");
89
76
  const parts = [headerType, ...payloadDetails(payloadText)].filter((part): part is string => !!part);
90
- const detail = parts.length > 0 ? sanitizeKiroErrorText(parts.join(": ")).slice(0, 500) : status ? `HTTP ${status}` : "";
77
+ const detail = parts.length > 0 ? sanitizeUpstreamErrorText(parts.join(": ")).slice(0, 500) : status ? `HTTP ${status}` : "";
91
78
  const prefix = classifyKiroText(status, [detail, headerType].filter(Boolean).join(" "));
92
79
  return detail ? `${prefix}: ${detail}` : prefix;
93
80
  }
@@ -1,8 +1,14 @@
1
1
  import type { AdapterFetchContext, AdapterRequest } from "./base";
2
2
  import { safeKiroHttpErrorMessage } from "./kiro-errors";
3
- import { clearableDeadline } from "../lib/abort";
4
- import { readBoundedResponseBody } from "../lib/bounded-body";
5
- import { abortError, isConnectionResetError, sleepWithAbort } from "../lib/upstream-retry";
3
+ import { normalizeUpstreamHttpErrorResponse } from "./upstream-http-error";
4
+ import {
5
+ abortError,
6
+ cancelResponseBodyBestEffort,
7
+ fetchWithAttemptDeadline,
8
+ isConnectionResetError,
9
+ retryBackoffDelayMs,
10
+ sleepWithAbort,
11
+ } from "../lib/upstream-retry";
6
12
 
7
13
  const KIRO_RETRY_ATTEMPTS = 3;
8
14
  const KIRO_RETRY_BASE_MS = 250;
@@ -12,52 +18,14 @@ function retryableKiroStatus(status: number): boolean {
12
18
  return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
13
19
  }
14
20
 
15
- function retryAfterMs(headers: Headers): number | undefined {
16
- const raw = headers.get("retry-after")?.trim();
17
- if (!raw) return undefined;
18
- const seconds = Number(raw);
19
- if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
20
- const dateMs = Date.parse(raw);
21
- if (!Number.isFinite(dateMs)) return undefined;
22
- return Math.max(0, dateMs - Date.now());
23
- }
24
-
25
- function retryDelayMs(attempt: number, headers?: Headers): number {
26
- const retryAfter = headers ? retryAfterMs(headers) : undefined;
27
- if (retryAfter !== undefined) return Math.min(retryAfter, KIRO_RETRY_MAX_MS);
28
- const exp = Math.min(KIRO_RETRY_BASE_MS * (2 ** attempt), KIRO_RETRY_MAX_MS);
29
- return Math.floor(exp * (0.8 + Math.random() * 0.4));
30
- }
31
-
32
- function cancelResponseBodyBestEffort(res: Response): void {
33
- try {
34
- const cancellation = res.body?.cancel();
35
- if (cancellation) void cancellation.catch(() => {});
36
- } catch {
37
- // Cancellation is cleanup only; retries must not wait for or fail because of it.
38
- }
39
- }
40
-
41
21
  function retryableKiroFetchError(err: unknown): boolean {
42
22
  return isConnectionResetError(err) || (err instanceof Error && err.name === "TimeoutError");
43
23
  }
44
24
 
45
25
  async function normalizeFinalKiroHttpError(res: Response, signal?: AbortSignal): Promise<Response> {
46
- if (res.ok) return res;
47
- let payloadText = "";
48
- try {
49
- const body = await readBoundedResponseBody(res, { signal });
50
- if (body.displaySafe) payloadText = body.text;
51
- } catch (error) {
52
- if (signal?.aborted) throw error;
53
- }
54
- const headers = new Headers(res.headers);
55
- headers.delete("content-encoding");
56
- headers.delete("content-length");
57
- return new Response(safeKiroHttpErrorMessage(res.status, res.headers, payloadText), {
58
- status: res.status,
59
- statusText: res.statusText,
60
- headers,
26
+ return normalizeUpstreamHttpErrorResponse(res, {
27
+ signal,
28
+ formatMessage: payloadText => safeKiroHttpErrorMessage(res.status, res.headers, payloadText),
61
29
  });
62
30
  }
63
31
 
@@ -67,28 +35,28 @@ export async function fetchKiroWithRetry(request: AdapterRequest, ctx: AdapterFe
67
35
  for (let attempt = 0; attempt < KIRO_RETRY_ATTEMPTS; attempt++) {
68
36
  if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal);
69
37
  try {
70
- const attemptTimeout = clearableDeadline(timeoutMs, ctx.abortSignal);
71
- let res: Response;
72
- try {
73
- res = await fetch(request.url, {
74
- method: request.method,
75
- headers: request.headers,
76
- body: request.body,
77
- signal: attemptTimeout.signal,
78
- });
79
- } finally {
80
- attemptTimeout.clear();
81
- }
38
+ const res = await fetchWithAttemptDeadline(request.url, {
39
+ method: request.method,
40
+ headers: request.headers,
41
+ body: request.body,
42
+ }, timeoutMs, ctx.abortSignal);
82
43
  if (!retryableKiroStatus(res.status) || attempt === KIRO_RETRY_ATTEMPTS - 1) {
83
44
  return ctx.returnRawErrors ? res : normalizeFinalKiroHttpError(res, ctx.abortSignal);
84
45
  }
85
46
  cancelResponseBodyBestEffort(res);
86
- await sleepWithAbort(retryDelayMs(attempt, res.headers), ctx.abortSignal);
47
+ await sleepWithAbort(retryBackoffDelayMs(attempt, {
48
+ baseDelayMs: KIRO_RETRY_BASE_MS,
49
+ maxDelayMs: KIRO_RETRY_MAX_MS,
50
+ headers: res.headers,
51
+ }), ctx.abortSignal);
87
52
  } catch (err) {
88
53
  if (ctx.abortSignal?.aborted) throw err;
89
54
  if (!retryableKiroFetchError(err) || attempt === KIRO_RETRY_ATTEMPTS - 1) throw err;
90
55
  lastError = err;
91
- await sleepWithAbort(retryDelayMs(attempt), ctx.abortSignal);
56
+ await sleepWithAbort(retryBackoffDelayMs(attempt, {
57
+ baseDelayMs: KIRO_RETRY_BASE_MS,
58
+ maxDelayMs: KIRO_RETRY_MAX_MS,
59
+ }), ctx.abortSignal);
92
60
  }
93
61
  }
94
62
  throw lastError ?? new Error("Kiro fetch failed");
@@ -0,0 +1,48 @@
1
+ import { readBoundedResponseBody } from "../lib/bounded-body";
2
+ import { redactSecretString } from "../lib/redact";
3
+
4
+ const ABSOLUTE_PATH_PATTERN = /(?:\/Users\/[^ "';,]+|\/home\/[^ "';,]+|\/root\/[^ "';,]*|[A-Za-z]:\\Users\\[^ "';,]+)/g;
5
+
6
+ export function sanitizeUpstreamErrorText(value: string): string {
7
+ return redactSecretString(value).replace(ABSOLUTE_PATH_PATTERN, "[REDACTED_PATH]");
8
+ }
9
+
10
+ export function safeUpstreamErrorString(value: unknown): string | undefined {
11
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
12
+ }
13
+
14
+ export function parseUpstreamJsonPayload(payloadText: string): unknown | undefined {
15
+ const trimmed = payloadText.trim();
16
+ if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) return undefined;
17
+ try {
18
+ return JSON.parse(trimmed) as unknown;
19
+ } catch {
20
+ return undefined;
21
+ }
22
+ }
23
+
24
+ export async function readDisplaySafeErrorPayloadText(res: Response, signal?: AbortSignal): Promise<string> {
25
+ try {
26
+ const body = await readBoundedResponseBody(res, { signal });
27
+ return body.displaySafe ? body.text : "";
28
+ } catch (error) {
29
+ if (signal?.aborted) throw error;
30
+ return "";
31
+ }
32
+ }
33
+
34
+ export async function normalizeUpstreamHttpErrorResponse(
35
+ res: Response,
36
+ opts: { signal?: AbortSignal; formatMessage: (payloadText: string) => string | Promise<string> },
37
+ ): Promise<Response> {
38
+ if (res.ok) return res;
39
+ const payloadText = await readDisplaySafeErrorPayloadText(res, opts.signal);
40
+ const headers = new Headers(res.headers);
41
+ headers.delete("content-encoding");
42
+ headers.delete("content-length");
43
+ return new Response(await opts.formatMessage(payloadText), {
44
+ status: res.status,
45
+ statusText: res.statusText,
46
+ headers,
47
+ });
48
+ }
@@ -30,7 +30,6 @@ export function writeGatewayModelCache(baseUrl: string, models: readonly Gateway
30
30
  try {
31
31
  // Mirror the CLI's usable-id filter so our file matches what it would cache.
32
32
  const usable = models.filter(m => /^(claude|anthropic)/i.test(m.id));
33
- if (usable.length === 0) return null;
34
33
  const cacheDir = join(configDir, "cache");
35
34
  mkdirSync(cacheDir, { recursive: true });
36
35
  const path = join(cacheDir, "gateway-models.json");
@@ -56,8 +55,9 @@ export async function refreshGatewayModelCacheFromProxy(port: number, timeoutMs
56
55
  signal: AbortSignal.timeout(timeoutMs),
57
56
  });
58
57
  if (!res.ok) return null;
59
- const body = await res.json() as { data?: Array<Record<string, unknown>> };
60
- const models: GatewayModelRow[] = (Array.isArray(body.data) ? body.data : [])
58
+ const body = await res.json() as { data?: unknown };
59
+ if (!Array.isArray(body.data)) return null;
60
+ const models: GatewayModelRow[] = body.data
61
61
  .filter(m => typeof m.id === "string" && (m.id as string).length > 0)
62
62
  .map(m => ({
63
63
  id: m.id as string,
@@ -105,13 +105,15 @@ export function responsesSseToAnthropicSse(
105
105
  let buffer = "";
106
106
  let started = false;
107
107
  let terminated = false;
108
+ let cancelled = false;
108
109
  let blockIndex = 0;
109
110
  let open: OpenBlock | null = null;
110
111
  let sawToolUse = false;
111
112
  let pingTimer: ReturnType<typeof setInterval> | undefined;
113
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
112
114
 
113
115
  return new ReadableStream<Uint8Array>({
114
- async start(controller) {
116
+ start(controller) {
115
117
  const emit = (name: string, data: Rec) => controller.enqueue(encoder.encode(sseFrame(name, data)));
116
118
  const ensureStarted = () => {
117
119
  if (started) return;
@@ -267,46 +269,49 @@ export function responsesSseToAnthropicSse(
267
269
  }
268
270
  };
269
271
 
270
- const reader = upstream.getReader();
271
- try {
272
- for (;;) {
273
- const { done, value } = await reader.read();
274
- if (done) break;
275
- buffer += decoder.decode(value, { stream: true });
276
- let sep: number;
277
- while ((sep = buffer.indexOf("\n\n")) !== -1) {
278
- const rawFrame = buffer.slice(0, sep);
279
- buffer = buffer.slice(sep + 2);
280
- let eventName = "";
281
- let dataLine = "";
282
- for (const line of rawFrame.split("\n")) {
283
- if (line.startsWith("event: ")) eventName = line.slice(7).trim();
284
- else if (line.startsWith("data: ")) dataLine += line.slice(6);
272
+ reader = upstream.getReader();
273
+ void (async () => {
274
+ try {
275
+ for (;;) {
276
+ const { done, value } = await reader.read();
277
+ if (done) break;
278
+ buffer += decoder.decode(value, { stream: true });
279
+ let sep: number;
280
+ while ((sep = buffer.indexOf("\n\n")) !== -1) {
281
+ const rawFrame = buffer.slice(0, sep);
282
+ buffer = buffer.slice(sep + 2);
283
+ let eventName = "";
284
+ let dataLine = "";
285
+ for (const line of rawFrame.split("\n")) {
286
+ if (line.startsWith("event: ")) eventName = line.slice(7).trim();
287
+ else if (line.startsWith("data: ")) dataLine += line.slice(6);
288
+ }
289
+ if (!eventName || !dataLine) continue;
290
+ let data: unknown;
291
+ try { data = JSON.parse(dataLine); } catch { continue; }
292
+ if (!isRec(data)) continue;
293
+ if (terminated) continue;
294
+ handleFrame(eventName, data);
285
295
  }
286
- if (!eventName || !dataLine) continue;
287
- let data: unknown;
288
- try { data = JSON.parse(dataLine); } catch { continue; }
289
- if (!isRec(data)) continue;
290
- if (terminated) continue;
291
- handleFrame(eventName, data);
292
296
  }
297
+ // EOF without a terminal frame is a TRUNCATION, not success (devlog 100:
298
+ // gateways that close such streams politely hand Claude Code an empty/partial
299
+ // turn with no retryable error — CLIProxyAPI#2189 failure pattern). Fail closed
300
+ // with a mid-stream Anthropic error event so the client can retry.
301
+ if (!cancelled) fail(502, "upstream stream ended before a terminal frame (truncated response)");
302
+ } catch (err) {
303
+ fail(500, err instanceof Error ? err.message : String(err));
304
+ } finally {
305
+ if (pingTimer !== undefined) clearInterval(pingTimer);
306
+ reader.releaseLock();
307
+ if (!cancelled) controller.close();
293
308
  }
294
- // EOF without a terminal frame is a TRUNCATION, not success (devlog 100:
295
- // gateways that close such streams politely hand Claude Code an empty/partial
296
- // turn with no retryable error — CLIProxyAPI#2189 failure pattern). Fail closed
297
- // with a mid-stream Anthropic error event so the client can retry.
298
- fail(502, "upstream stream ended before a terminal frame (truncated response)");
299
- } catch (err) {
300
- fail(500, err instanceof Error ? err.message : String(err));
301
- } finally {
302
- if (pingTimer !== undefined) clearInterval(pingTimer);
303
- reader.releaseLock();
304
- controller.close();
305
- }
309
+ })();
306
310
  },
307
311
  cancel(reason) {
312
+ cancelled = true;
308
313
  if (pingTimer !== undefined) clearInterval(pingTimer);
309
- return upstream.cancel(reason);
314
+ return reader?.cancel(reason);
310
315
  },
311
316
  });
312
317
  }
package/src/cli/claude.ts CHANGED
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Mirrors `ccr code` UX (devlog/260711_claude_inbound/020, 003 E1/E2/E5/G1):
5
5
  * ensures the proxy is running, injects the Anthropic env slots, then execs the
6
- * `claude` CLI with stdio inherited. User-exported env always wins.
6
+ * `claude` CLI with stdio inherited. User-exported env wins except when a stale
7
+ * loopback opencodex base URL points at a different proxy port.
7
8
  */
8
9
  import { spawn } from "node:child_process";
9
10
  import { loadConfig } from "../config";
@@ -20,7 +21,8 @@ export interface ClaudeLaunchEnv {
20
21
  /**
21
22
  * Pure env assembly (unit-tested): never sets ANTHROPIC_API_KEY (setting both
22
23
  * token vars triggers Claude Code's auth-conflict warning, 003 E1), and never
23
- * overrides variables the user already exported.
24
+ * overrides variables the user already exported, apart from stale loopback
25
+ * ANTHROPIC_BASE_URL values owned by a previous opencodex launch.
24
26
  */
25
27
  export function buildClaudeEnv(config: OcxConfig, port: number, base: ClaudeLaunchEnv, contextWindows: Record<string, number> = {}): ClaudeLaunchEnv {
26
28
  const env: ClaudeLaunchEnv = { ...base };
@@ -30,6 +32,20 @@ export function buildClaudeEnv(config: OcxConfig, port: number, base: ClaudeLaun
30
32
  env[name] = value;
31
33
  };
32
34
  setDefault("ANTHROPIC_BASE_URL", `http://127.0.0.1:${port}`);
35
+ const existingBaseUrl = env.ANTHROPIC_BASE_URL;
36
+ if (existingBaseUrl) {
37
+ try {
38
+ const parsed = new URL(existingBaseUrl);
39
+ const isLoopback = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
40
+ if (isLoopback && parsed.port !== "" && Number(parsed.port) !== port) {
41
+ const replacement = `http://127.0.0.1:${port}`;
42
+ console.error(`⚠ Replacing stale opencodex ANTHROPIC_BASE_URL ${existingBaseUrl} with ${replacement}.`);
43
+ env.ANTHROPIC_BASE_URL = replacement;
44
+ }
45
+ } catch {
46
+ // Preserve user-provided values that are not parseable URLs.
47
+ }
48
+ }
33
49
  // Subscription-preserving default (teamclaude --no-mitm / Vercel gateway pattern):
34
50
  // setting ANTHROPIC_AUTH_TOKEN/API_KEY disables claude.ai connectors and overrides
35
51
  // the user's Claude login. Only inject a token when the proxy actually requires an
@@ -137,9 +153,25 @@ export async function cmdClaude(args: string[]): Promise<number> {
137
153
  const env = buildClaudeEnv(config, port, process.env, contextWindows);
138
154
  // Pre-write the CLI's gateway-model cache (devlog 030): without a token the CLI
139
155
  // never refreshes it, so the picker would keep showing yesterday's aliases.
140
- await refreshGatewayModelCacheFromProxy(port);
156
+ try {
157
+ const cachePath = await refreshGatewayModelCacheFromProxy(port);
158
+ if (cachePath === null) {
159
+ console.error("⚠ Gateway model cache could not be refreshed; the model picker may be stale.");
160
+ }
161
+ } catch (error) {
162
+ const message = error instanceof Error ? error.message : String(error);
163
+ console.error(`⚠ Gateway model cache could not be refreshed: ${message}`);
164
+ }
141
165
  // Sync roster agents (devlog 070): subagentModels + self -> ~/.claude/agents/ocx-*.md.
142
- injectClaudeAgentDefs(config, contextWindows);
166
+ try {
167
+ const written = injectClaudeAgentDefs(config, contextWindows);
168
+ if (written === null) {
169
+ console.error("⚠ Claude agent definitions could not be synced; check ~/.claude/agents permissions.");
170
+ }
171
+ } catch (error) {
172
+ const message = error instanceof Error ? error.message : String(error);
173
+ console.error(`⚠ Claude agent definitions could not be synced: ${message}`);
174
+ }
143
175
  return await new Promise<number>(resolve => {
144
176
  const child = spawn("claude", args, { stdio: "inherit", env: env as NodeJS.ProcessEnv });
145
177
  child.on("error", (err: NodeJS.ErrnoException) => {