@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.
- package/README.md +3 -1
- package/gui/dist/assets/index-SnN_1Qr9.js +40 -0
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/cursor/transport-retry.ts +5 -3
- package/src/adapters/google-errors.ts +9 -19
- package/src/adapters/google-http.ts +29 -66
- package/src/adapters/kiro-errors.ts +10 -23
- package/src/adapters/kiro-retry.ts +26 -58
- package/src/adapters/upstream-http-error.ts +48 -0
- package/src/claude/gateway-cache.ts +3 -3
- package/src/claude/outbound.ts +40 -35
- package/src/cli/claude.ts +36 -4
- package/src/config.ts +54 -3
- package/src/lib/destination-policy.ts +167 -0
- package/src/lib/injection-debug-log.ts +34 -0
- package/src/lib/upstream-retry.ts +53 -3
- package/src/lib/windows-secret-acl.ts +173 -0
- package/src/oauth/store.ts +1 -0
- package/src/providers/registry.ts +5 -3
- package/src/router.ts +6 -1
- package/src/server/auth-cors.ts +4 -0
- package/src/server/claude-messages.ts +7 -1
- package/src/server/management-api.ts +85 -31
- package/src/server/request-decompress.ts +45 -12
- package/src/server/responses.ts +5 -4
- package/src/server/system-env.ts +110 -68
- package/src/service.ts +4 -0
- package/src/types.ts +8 -3
- package/gui/dist/assets/index-BcaDQD3i.js +0 -40
package/gui/dist/index.html
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
} catch (e) {}
|
|
17
17
|
})();
|
|
18
18
|
</script>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
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
|
-
|
|
44
|
-
|
|
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 {
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 ?
|
|
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 {
|
|
4
|
-
import {
|
|
5
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
46
|
+
const peek = await readDisplaySafeErrorPayloadText(res, ctx.abortSignal);
|
|
89
47
|
if (isQuotaExhaustedBody(peek)) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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(
|
|
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(
|
|
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 {
|
|
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 :
|
|
16
|
-
return
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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 ?
|
|
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 {
|
|
4
|
-
import {
|
|
5
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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(
|
|
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(
|
|
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?:
|
|
60
|
-
|
|
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,
|
package/src/claude/outbound.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) => {
|