@bitkyc08/opencodex 2.6.1 → 2.6.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 +8 -0
- package/bin/ocx.mjs +41 -9
- package/gui/dist/assets/index-LK87QnT7.js +9 -0
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/abort.ts +22 -0
- package/src/adapters/base.ts +17 -4
- package/src/adapters/kiro-errors.ts +101 -0
- package/src/adapters/kiro-events.ts +48 -0
- package/src/adapters/kiro-images.ts +33 -0
- package/src/adapters/kiro-retry.ts +95 -0
- package/src/adapters/kiro-thinking.ts +82 -0
- package/src/adapters/kiro-tool-fallback.ts +36 -0
- package/src/adapters/kiro-tools.ts +44 -0
- package/src/adapters/kiro-truncation.ts +33 -0
- package/src/adapters/kiro-wire.ts +51 -0
- package/src/adapters/kiro.ts +527 -0
- package/src/adapters/openai-chat.ts +10 -1
- package/src/bridge.ts +1 -1
- package/src/cli.ts +25 -3
- package/src/codex-catalog.ts +97 -13
- package/src/codex-inject.ts +18 -0
- package/src/config.ts +52 -0
- package/src/crash-guard.ts +197 -9
- package/src/debug.ts +11 -0
- package/src/errors.ts +39 -3
- package/src/lib/eventstream-decoder.ts +244 -0
- package/src/lib/token-estimate.ts +43 -0
- package/src/oauth/anthropic.ts +1 -1
- package/src/oauth/index.ts +53 -6
- package/src/oauth/kiro-credentials.ts +256 -0
- package/src/oauth/kiro.ts +164 -0
- package/src/oauth/local-token-detect.ts +2 -1
- package/src/oauth/store.ts +36 -3
- package/src/oauth/types.ts +3 -0
- package/src/oauth/xai.ts +1 -1
- package/src/providers/kiro-models.ts +55 -0
- package/src/providers/registry.ts +15 -0
- package/src/redact.ts +71 -0
- package/src/server.ts +40 -22
- package/src/sidecar-tracker.ts +49 -0
- package/src/types.ts +3 -0
- package/src/usage-debug.ts +7 -4
- package/src/usage-log.ts +41 -3
- package/src/vision/describe.ts +11 -2
- package/src/web-search/executor.ts +10 -2
- package/src/web-search/loop.ts +27 -7
- package/gui/dist/assets/index-BmHrbTmO.js +0 -9
package/src/codex-catalog.ts
CHANGED
|
@@ -36,23 +36,49 @@ function isDefaultCatalogPath(path: string): boolean {
|
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
38
|
* Native OpenAI / Codex models served via ChatGPT OAuth passthrough — FALLBACK only. The ChatGPT
|
|
39
|
-
* backend has no `GET /models`, so the real set is read from the live Codex catalog
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* old `gpt-5.2`/`gpt-5.3-codex` that a newer Codex dropped) makes it 400 "model is not supported".
|
|
39
|
+
* backend has no `GET /models`, so the real set is read from the live Codex catalog via
|
|
40
|
+
* nativeOpenAiSlugs(); this static list is used when no catalog is present, plus selected documented
|
|
41
|
+
* Codex-native additions that may lag in a user's installed Codex catalog.
|
|
43
42
|
*/
|
|
44
43
|
export const NATIVE_OPENAI_MODELS = [
|
|
45
44
|
"gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark",
|
|
46
45
|
];
|
|
47
46
|
|
|
47
|
+
const DOCUMENTED_NATIVE_OPENAI_ADDITIONS = ["gpt-5.3-codex-spark"];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The ONLY native OpenAI/Codex slugs opencodex advertises. A user's installed Codex ships extra
|
|
51
|
+
* native models in its live catalog (e.g. `gpt-5.2`, `gpt-5.3-codex`, `codex-auto-review`); those
|
|
52
|
+
* are legacy/internal and must never surface in `/v1/models` or the subagent picker. Live-catalog
|
|
53
|
+
* native slugs are filtered against this allowlist so only the supported set is exposed.
|
|
54
|
+
*/
|
|
55
|
+
const SUPPORTED_NATIVE_OPENAI_SLUGS = new Set(NATIVE_OPENAI_MODELS);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* True when a bare slug is an OpenAI/Codex-family native that opencodex does NOT support
|
|
59
|
+
* (legacy/internal like `gpt-5.2`, `gpt-5.3-codex`, `codex-auto-review`). Used to drop these
|
|
60
|
+
* from the ON-DISK catalog so the Codex file picker matches the live `/v1/models` filter,
|
|
61
|
+
* WITHOUT removing genuine user-added natives (non gpt-/codex- slugs are preserved).
|
|
62
|
+
*/
|
|
63
|
+
function isUnsupportedOpenAiNativeSlug(slug: string): boolean {
|
|
64
|
+
if (slug.includes("/")) return false;
|
|
65
|
+
if (SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug)) return false;
|
|
66
|
+
return /^(?:gpt|codex)-/.test(slug);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const NATIVE_OPENAI_CONTEXT_OVERRIDES: Record<string, { contextWindow?: number; maxContextWindow?: number }> = {
|
|
70
|
+
"gpt-5.5": { contextWindow: 272_000, maxContextWindow: 272_000 },
|
|
71
|
+
"gpt-5.4": { maxContextWindow: 1_000_000 },
|
|
72
|
+
};
|
|
73
|
+
|
|
48
74
|
/**
|
|
49
75
|
* The native (passthrough) OpenAI slugs to advertise — the LIVE Codex catalog's own bare slugs when
|
|
50
|
-
* available
|
|
51
|
-
*
|
|
76
|
+
* available, with documented Codex-native additions layered in, else the static fallback above.
|
|
77
|
+
* Single source for the /v1/models native list and the subagent-default seed.
|
|
52
78
|
*/
|
|
53
79
|
export function nativeOpenAiSlugs(): string[] {
|
|
54
80
|
const live = listCatalogNativeSlugs();
|
|
55
|
-
return live.length > 0 ? live : NATIVE_OPENAI_MODELS;
|
|
81
|
+
return live.length > 0 ? unique([...live, ...DOCUMENTED_NATIVE_OPENAI_ADDITIONS]) : NATIVE_OPENAI_MODELS;
|
|
56
82
|
}
|
|
57
83
|
|
|
58
84
|
export interface CatalogModel { id: string; provider: string; owned_by?: string; reasoningEfforts?: string[]; contextWindow?: number; inputModalities?: string[]; }
|
|
@@ -142,6 +168,23 @@ function ensureAutoCompactTokenLimit(entry: RawEntry): RawEntry {
|
|
|
142
168
|
return entry;
|
|
143
169
|
}
|
|
144
170
|
|
|
171
|
+
function isNativeOpenAiEntry(entry: RawEntry): boolean {
|
|
172
|
+
return typeof entry.slug === "string" && !entry.slug.includes("/");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function applyNativeOpenAiContextOverride(entry: RawEntry): void {
|
|
176
|
+
if (!isNativeOpenAiEntry(entry)) return;
|
|
177
|
+
const override = NATIVE_OPENAI_CONTEXT_OVERRIDES[entry.slug as string];
|
|
178
|
+
if (!override) return;
|
|
179
|
+
if (typeof override.contextWindow === "number") {
|
|
180
|
+
entry.context_window = override.contextWindow;
|
|
181
|
+
entry.auto_compact_token_limit = Math.floor(override.contextWindow * 0.9);
|
|
182
|
+
}
|
|
183
|
+
if (typeof override.maxContextWindow === "number") {
|
|
184
|
+
entry.max_context_window = override.maxContextWindow;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
145
188
|
function ensureStrictCatalogFields(entry: RawEntry): RawEntry {
|
|
146
189
|
if (typeof entry.supports_reasoning_summaries !== "boolean") entry.supports_reasoning_summaries = true;
|
|
147
190
|
if (typeof entry.default_reasoning_summary !== "string") entry.default_reasoning_summary = "none";
|
|
@@ -160,7 +203,7 @@ function ensureStrictCatalogFields(entry: RawEntry): RawEntry {
|
|
|
160
203
|
if (
|
|
161
204
|
typeof entry.max_context_window !== "number"
|
|
162
205
|
|| entry.max_context_window <= 0
|
|
163
|
-
|| entry.max_context_window > contextWindow
|
|
206
|
+
|| (!isNativeOpenAiEntry(entry) && entry.max_context_window > contextWindow)
|
|
164
207
|
) {
|
|
165
208
|
entry.max_context_window = contextWindow;
|
|
166
209
|
}
|
|
@@ -398,6 +441,8 @@ function deriveEntry(template: RawEntry | null, slug: string, desc: string, prio
|
|
|
398
441
|
normalizeRoutedCatalogEntry(e);
|
|
399
442
|
applyJawcodeCatalogMetadata(e, slug);
|
|
400
443
|
applyCatalogModelMetadata(e, model);
|
|
444
|
+
} else {
|
|
445
|
+
applyNativeOpenAiContextOverride(e);
|
|
401
446
|
}
|
|
402
447
|
return ensureStrictCatalogFields(normalizeServiceTiers(e));
|
|
403
448
|
}
|
|
@@ -412,6 +457,7 @@ function deriveEntry(template: RawEntry | null, slug: string, desc: string, prio
|
|
|
412
457
|
else applyReasoningLevels(entry);
|
|
413
458
|
applyJawcodeCatalogMetadata(entry, slug);
|
|
414
459
|
applyCatalogModelMetadata(entry, model);
|
|
460
|
+
applyNativeOpenAiContextOverride(entry);
|
|
415
461
|
return ensureStrictCatalogFields(normalizeServiceTiers(entry));
|
|
416
462
|
}
|
|
417
463
|
|
|
@@ -451,8 +497,18 @@ export function buildCatalogEntries(template: RawEntry | null, gptSlugs: string[
|
|
|
451
497
|
/** Bare picker-visible native slugs in the live Codex catalog (drives the subagent picker UI). */
|
|
452
498
|
export function listCatalogNativeSlugs(): string[] {
|
|
453
499
|
const cat = readCurrentCatalogOrCache();
|
|
454
|
-
return (cat?.models ?? [])
|
|
455
|
-
|
|
500
|
+
return filterSupportedNativeSlugs(cat?.models ?? []);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Keep only picker-visible, bare (non-routed) native slugs that opencodex actually supports.
|
|
505
|
+
* A user's installed Codex may list legacy/internal natives (`gpt-5.2`, `gpt-5.3-codex`,
|
|
506
|
+
* `codex-auto-review`, …); the allowlist drops them so `/v1/models` and the subagent picker
|
|
507
|
+
* never advertise an unsupported native. Exported for regression coverage.
|
|
508
|
+
*/
|
|
509
|
+
export function filterSupportedNativeSlugs(models: RawEntry[]): string[] {
|
|
510
|
+
return models
|
|
511
|
+
.filter(m => typeof m.slug === "string" && !(m.slug as string).includes("/") && m.visibility === "list" && SUPPORTED_NATIVE_OPENAI_SLUGS.has(m.slug as string))
|
|
456
512
|
.map(m => m.slug as string);
|
|
457
513
|
}
|
|
458
514
|
|
|
@@ -715,7 +771,13 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num
|
|
|
715
771
|
const baseline = readNativeBaseline(catalogPath);
|
|
716
772
|
const goIds = new Set(enabledGo.map(m => m.id));
|
|
717
773
|
const native = (catalog.models ?? [])
|
|
718
|
-
.filter(m => typeof m.slug === "string"
|
|
774
|
+
.filter(m => typeof m.slug === "string"
|
|
775
|
+
&& !(m.slug as string).includes("/")
|
|
776
|
+
&& !goIds.has(m.slug as string)
|
|
777
|
+
// Gap B: drop legacy/internal OpenAI-family natives (gpt-5.2, gpt-5.3-codex,
|
|
778
|
+
// codex-auto-review, …) from the on-disk catalog too, matching the live /v1/models
|
|
779
|
+
// allowlist. Genuine user-added natives (non gpt-/codex- slugs) are preserved.
|
|
780
|
+
&& !isUnsupportedOpenAiNativeSlug(m.slug as string))
|
|
719
781
|
.map(m => {
|
|
720
782
|
const slug = m.slug as string;
|
|
721
783
|
const baselinePriority = baseline.get(slug) ?? (m.priority as number);
|
|
@@ -726,12 +788,34 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num
|
|
|
726
788
|
: baselinePriority;
|
|
727
789
|
return normalizeServiceTiers({ ...m, priority });
|
|
728
790
|
});
|
|
791
|
+
const nativeSlugs = new Set(native.flatMap(m => typeof m.slug === "string" ? [m.slug] : []));
|
|
792
|
+
for (const slug of nativeOpenAiSlugs()) {
|
|
793
|
+
if (nativeSlugs.has(slug)) continue;
|
|
794
|
+
nativeSlugs.add(slug);
|
|
795
|
+
const priority = rank.has(slug)
|
|
796
|
+
? rank.get(slug)!
|
|
797
|
+
: featured.length > 0
|
|
798
|
+
? featured.length + 100
|
|
799
|
+
: 9;
|
|
800
|
+
native.push(deriveEntry(template ? JSON.parse(JSON.stringify(template)) : null, slug, "OpenAI native model (Codex OAuth passthrough).", priority));
|
|
801
|
+
}
|
|
729
802
|
// Central WS capability override on the FINAL on-disk catalog (the file Codex reads). Applies to
|
|
730
803
|
// native AND routed so the advertised flag matches the implemented endpoint (phase 120.4) and a
|
|
731
804
|
// native template can never leak supports_websockets while the flag is off.
|
|
732
805
|
const wsEnabled = websocketsEnabled(config);
|
|
733
|
-
|
|
734
|
-
|
|
806
|
+
// Gap A: never let a transient EMPTY routed fetch wipe routed entries that were on disk. If
|
|
807
|
+
// gatherRoutedModels returned nothing (provider down / flaky / cache miss) but the pre-sync
|
|
808
|
+
// catalog DID carry routed entries, preserve those prior routed entries instead of overwriting
|
|
809
|
+
// them with an empty set — otherwise the Codex picker silently loses kiro/opencode-go models.
|
|
810
|
+
let routedEntries = goEntries;
|
|
811
|
+
if (goEntries.length === 0 && catalogHasRoutedEntries(catalog)) {
|
|
812
|
+
routedEntries = (catalog.models ?? []).filter(m => typeof m.slug === "string" && (m.slug as string).includes("/"));
|
|
813
|
+
console.warn(`[opencodex] catalog sync: routed model fetch returned empty; preserving ${routedEntries.length} existing routed entr${routedEntries.length === 1 ? "y" : "ies"} on disk.`);
|
|
814
|
+
}
|
|
815
|
+
catalog.models = [...native, ...routedEntries].map(m => {
|
|
816
|
+
const normalized = normalizeServiceTiers(m);
|
|
817
|
+
applyNativeOpenAiContextOverride(normalized);
|
|
818
|
+
const e = ensureStrictCatalogFields(normalized);
|
|
735
819
|
if (wsEnabled) e.supports_websockets = true;
|
|
736
820
|
else delete e.supports_websockets;
|
|
737
821
|
return e;
|
package/src/codex-inject.ts
CHANGED
|
@@ -81,6 +81,23 @@ function stripExistingModelProvider(content: string): string {
|
|
|
81
81
|
return out.join("\n");
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Drop ROOT-level `model_context_window` / `model_auto_compact_token_limit` overrides (keys before
|
|
86
|
+
* the first table header). Codex treats these root keys as a global override that wins over the
|
|
87
|
+
* per-model catalog values, so a stale `model_context_window = 1000000` makes every model (e.g.
|
|
88
|
+
* gpt-5.5) report a 1M window. Stripping them on (re)injection lets the catalog drive context size.
|
|
89
|
+
*/
|
|
90
|
+
export function stripRootContextWindowOverrides(content: string): string {
|
|
91
|
+
const lines = content.split("\n");
|
|
92
|
+
const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
|
|
93
|
+
return lines
|
|
94
|
+
.filter((line, i) => {
|
|
95
|
+
const isRoot = firstTable === -1 || i < firstTable;
|
|
96
|
+
return !isRoot || !/^\s*model_(?:context_window|auto_compact_token_limit)\s*=/.test(line);
|
|
97
|
+
})
|
|
98
|
+
.join("\n");
|
|
99
|
+
}
|
|
100
|
+
|
|
84
101
|
function stripRootRoutedModel(content: string): string {
|
|
85
102
|
const lines = content.split("\n");
|
|
86
103
|
const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
|
|
@@ -243,6 +260,7 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
|
|
|
243
260
|
}
|
|
244
261
|
content = removeProfileSection(content);
|
|
245
262
|
content = stripExistingModelProvider(content);
|
|
263
|
+
content = stripRootContextWindowOverrides(content);
|
|
246
264
|
content = normalizeServiceTier(content);
|
|
247
265
|
content = ensureFastModeFeature(content);
|
|
248
266
|
|
package/src/config.ts
CHANGED
|
@@ -47,6 +47,16 @@ const providerConfigSchema = z.object({
|
|
|
47
47
|
|
|
48
48
|
const RESERVED_PROVIDER_NAMES = new Set(["__proto__", "prototype", "constructor"]);
|
|
49
49
|
const PROVIDER_NAME_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$/;
|
|
50
|
+
const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
51
|
+
const SENSITIVE_PROVIDER_HEADERS = new Set([
|
|
52
|
+
"authorization",
|
|
53
|
+
"cookie",
|
|
54
|
+
"set-cookie",
|
|
55
|
+
"proxy-authorization",
|
|
56
|
+
"x-api-key",
|
|
57
|
+
"x-goog-api-key",
|
|
58
|
+
"x-amz-security-token",
|
|
59
|
+
]);
|
|
50
60
|
|
|
51
61
|
export function isValidProviderName(name: string): boolean {
|
|
52
62
|
const trimmed = name.trim();
|
|
@@ -59,6 +69,31 @@ export function hasOwnProvider(providers: Record<string, unknown>, name: string)
|
|
|
59
69
|
return Object.prototype.hasOwnProperty.call(providers, name);
|
|
60
70
|
}
|
|
61
71
|
|
|
72
|
+
export function providerBaseUrlConfigError(baseUrl: string): string | null {
|
|
73
|
+
try {
|
|
74
|
+
const parsed = new URL(baseUrl.trim());
|
|
75
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "baseUrl must be an http(s) URL";
|
|
76
|
+
if (parsed.username || parsed.password) return "baseUrl must not include embedded credentials";
|
|
77
|
+
if (parsed.search || parsed.hash) return "baseUrl must not include query strings or fragments";
|
|
78
|
+
} catch {
|
|
79
|
+
return "baseUrl must be a valid URL";
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function providerHeadersConfigError(headers: unknown): string | null {
|
|
85
|
+
if (headers === undefined) return null;
|
|
86
|
+
if (!headers || typeof headers !== "object" || Array.isArray(headers)) return "headers must be an object";
|
|
87
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
88
|
+
const normalized = name.trim().toLowerCase();
|
|
89
|
+
if (!normalized || !HEADER_NAME_PATTERN.test(name)) return "headers must use valid HTTP header names";
|
|
90
|
+
if (SENSITIVE_PROVIDER_HEADERS.has(normalized)) return `headers must not include sensitive header "${name}"; use apiKey/authMode instead`;
|
|
91
|
+
if (typeof value !== "string") return `header "${name}" value must be a string`;
|
|
92
|
+
if (/[\r\n]/.test(value)) return `header "${name}" value must not include line breaks`;
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
62
97
|
const configSchema = z.object({
|
|
63
98
|
port: z.number().int().min(0).max(65535).default(10100),
|
|
64
99
|
providers: z.record(z.string(), providerConfigSchema),
|
|
@@ -72,6 +107,23 @@ const configSchema = z.object({
|
|
|
72
107
|
message: "provider names must use letters, numbers, dot, underscore, or hyphen and cannot be reserved JavaScript object keys",
|
|
73
108
|
});
|
|
74
109
|
}
|
|
110
|
+
const provider = config.providers[name];
|
|
111
|
+
const baseUrlError = providerBaseUrlConfigError(provider.baseUrl);
|
|
112
|
+
if (baseUrlError) {
|
|
113
|
+
ctx.addIssue({
|
|
114
|
+
code: "custom",
|
|
115
|
+
path: ["providers", name, "baseUrl"],
|
|
116
|
+
message: baseUrlError,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
const headersError = providerHeadersConfigError((provider as { headers?: unknown }).headers);
|
|
120
|
+
if (headersError) {
|
|
121
|
+
ctx.addIssue({
|
|
122
|
+
code: "custom",
|
|
123
|
+
path: ["providers", name, "headers"],
|
|
124
|
+
message: headersError,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
75
127
|
}
|
|
76
128
|
if (!hasOwnProvider(config.providers, config.defaultProvider)) {
|
|
77
129
|
ctx.addIssue({
|
package/src/crash-guard.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { getConfigDir } from "./config";
|
|
4
|
+
import { redactSecretString, redactUrlForLog } from "./redact";
|
|
5
|
+
import { sidecarBreadcrumb, activityBreadcrumb } from "./sidecar-tracker";
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Process-level safety net for the long-running proxy daemon.
|
|
@@ -32,15 +34,106 @@ function crashLogPath(): string {
|
|
|
32
34
|
return join(dir, "crash.log");
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
function
|
|
37
|
+
export function formatCrashEntry(kind: string, err: unknown, promise?: unknown): string {
|
|
36
38
|
const ts = new Date().toISOString();
|
|
37
39
|
const detail =
|
|
38
40
|
err instanceof Error
|
|
39
|
-
? `${err.name}: ${err.message}\n${err.stack ?? "(no stack)"}`
|
|
41
|
+
? `${err.name}: ${redactDiagnosticText(err.message)}\n${redactDiagnosticText(err.stack ?? "(no stack)")}`
|
|
40
42
|
: typeof err === "object"
|
|
41
|
-
? safeStringify(err)
|
|
42
|
-
: String(err);
|
|
43
|
-
return `\n[${ts}] ${kind}\n${detail}\n`;
|
|
43
|
+
? redactDiagnosticText(safeStringify(err))
|
|
44
|
+
: redactDiagnosticText(String(err));
|
|
45
|
+
return `\n[${ts}] ${kind}\n${detail}${diagnose(err)}${diagnosePromise(promise)}${breadcrumb()}\n`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Bun surfaces some request-time stream/abort errors with only native frames
|
|
50
|
+
* (`at <anonymous> (native:1:11)`), so `err.stack` alone cannot locate the
|
|
51
|
+
* fault. JSC still records the true throw site on hidden own properties
|
|
52
|
+
* (`sourceURL` / `originalLine` / `originalColumn`) and `Bun.inspect` renders a
|
|
53
|
+
* code snippet from them — capture both so the next occurrence is pinpointable.
|
|
54
|
+
*/
|
|
55
|
+
function diagnose(err: unknown): string {
|
|
56
|
+
const lines: string[] = [];
|
|
57
|
+
try {
|
|
58
|
+
const ctor = (err as { constructor?: { name?: string } } | null)?.constructor?.name;
|
|
59
|
+
if (ctor && ctor !== "Error" && ctor !== "Object") lines.push(` ctor: ${ctor}`);
|
|
60
|
+
if (err && typeof err === "object") {
|
|
61
|
+
const e = err as Record<string, unknown>;
|
|
62
|
+
const cause = e.cause;
|
|
63
|
+
if (cause !== undefined) {
|
|
64
|
+
lines.push(` cause: ${redactDiagnosticText(cause instanceof Error ? `${cause.name}: ${cause.message}` : String(cause))}`);
|
|
65
|
+
}
|
|
66
|
+
if (e.code !== undefined) lines.push(` code: ${redactDiagnosticText(String(e.code))}`);
|
|
67
|
+
// JSC hidden throw-site fields survive even when the stack is native-only.
|
|
68
|
+
const sourceURL = e.sourceURL;
|
|
69
|
+
const line = e.line ?? e.originalLine;
|
|
70
|
+
const column = e.column ?? e.originalColumn;
|
|
71
|
+
if (typeof sourceURL === "string" && sourceURL) {
|
|
72
|
+
lines.push(` origin: ${redactUrlForLog(sourceURL)}${line !== undefined ? `:${String(line)}` : ""}${column !== undefined ? `:${String(column)}` : ""}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const stack = err instanceof Error ? err.stack ?? "" : "";
|
|
76
|
+
const hasUsableStack = /\((?!native:)[^)]*:\d+:\d+\)/.test(stack);
|
|
77
|
+
if (!hasUsableStack) {
|
|
78
|
+
const snippet = inspectErr(err);
|
|
79
|
+
if (snippet) lines.push(` inspect:\n${snippet.split("\n").map(l => ` ${l}`).join("\n")}`);
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
/* diagnosis must never throw */
|
|
83
|
+
}
|
|
84
|
+
return lines.length ? `\n${lines.join("\n")}` : "";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Bun.inspect renders the JSC source snippet (with the offending line + caret)
|
|
89
|
+
* for errors whose throw site is otherwise lost to native frames.
|
|
90
|
+
*/
|
|
91
|
+
function inspectErr(err: unknown): string {
|
|
92
|
+
try {
|
|
93
|
+
const bun = (globalThis as { Bun?: { inspect?: (v: unknown, o?: unknown) => string } }).Bun;
|
|
94
|
+
if (!bun?.inspect) return "";
|
|
95
|
+
return redactDiagnosticText(bun.inspect(err, { depth: 2 }).trim());
|
|
96
|
+
} catch {
|
|
97
|
+
return "";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Inspect the rejected promise itself. Bun sometimes attaches richer context to the promise object
|
|
103
|
+
* than to the reason, and the rendered form helps distinguish a fetch/stream teardown from app code.
|
|
104
|
+
*/
|
|
105
|
+
function diagnosePromise(promise: unknown): string {
|
|
106
|
+
if (promise === undefined) return "";
|
|
107
|
+
try {
|
|
108
|
+
const bun = (globalThis as { Bun?: { inspect?: (v: unknown, o?: unknown) => string } }).Bun;
|
|
109
|
+
const rendered = bun?.inspect ? bun.inspect(promise, { depth: 1 }).trim() : String(promise);
|
|
110
|
+
if (!rendered || rendered === "Promise { <rejected> }") return "";
|
|
111
|
+
return `\n promise: ${redactDiagnosticText(rendered.split("\n").join(" "))}`;
|
|
112
|
+
} catch {
|
|
113
|
+
return "";
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Record whether a sidecar (web-search / vision) was in flight when the fault fired. A native-only
|
|
119
|
+
* rejection coinciding with sidecar work is the prime suspect; this turns the correlation into a
|
|
120
|
+
* logged fact instead of an inference.
|
|
121
|
+
*/
|
|
122
|
+
function breadcrumb(): string {
|
|
123
|
+
try {
|
|
124
|
+
const lines: string[] = [];
|
|
125
|
+
const b = sidecarBreadcrumb();
|
|
126
|
+
if (b.inFlight > 0 || b.lastLabel) {
|
|
127
|
+
lines.push(` sidecar: inFlight=${b.inFlight} last=${b.lastLabel || "-"} sinceMs=${b.sinceMs}`);
|
|
128
|
+
}
|
|
129
|
+
const a = activityBreadcrumb();
|
|
130
|
+
if (a.note) lines.push(` activity: ${a.note} sinceMs=${a.sinceMs}`);
|
|
131
|
+
const fetches = recentFetches();
|
|
132
|
+
if (fetches) lines.push(fetches);
|
|
133
|
+
return lines.length ? `\n${lines.join("\n")}` : "";
|
|
134
|
+
} catch {
|
|
135
|
+
return "";
|
|
136
|
+
}
|
|
44
137
|
}
|
|
45
138
|
|
|
46
139
|
function safeStringify(value: unknown): string {
|
|
@@ -51,8 +144,40 @@ function safeStringify(value: unknown): string {
|
|
|
51
144
|
}
|
|
52
145
|
}
|
|
53
146
|
|
|
54
|
-
|
|
55
|
-
|
|
147
|
+
let benignSuppressed = 0;
|
|
148
|
+
let benignLastLoggedAt = 0;
|
|
149
|
+
const BENIGN_LOG_INTERVAL_MS = 5 * 60_000;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Bun raises an off-path `unhandledRejection: TypeError: null is not an object` (native-only stack)
|
|
153
|
+
* whenever a streaming `fetch(..., { signal })` response body is torn down by an abort before/while
|
|
154
|
+
* we read it — turn supersede, client disconnect, upstream RST. The daemon is never at risk (the
|
|
155
|
+
* failed request is already isolated), the throw has no JS source location, and call-site body
|
|
156
|
+
* cancellation cannot fully close the runtime-internal window. Treat this exact shape as benign:
|
|
157
|
+
* keep the process alive, drop the alarmist banner, and fold repeats into a rate-limited summary so
|
|
158
|
+
* crash.log stays readable for genuinely novel faults.
|
|
159
|
+
*/
|
|
160
|
+
export function isBenignAbortTeardown(err: unknown): boolean {
|
|
161
|
+
if (!(err instanceof TypeError)) return false;
|
|
162
|
+
if (err.message !== "null is not an object") return false; // bare form only (no `(evaluating …)`)
|
|
163
|
+
const stack = err.stack ?? "";
|
|
164
|
+
// Native-only: no JS source frame. A real app TypeError would carry a `(file:line:col)` frame.
|
|
165
|
+
return !/\((?!native:)[^)]*:\d+:\d+\)/.test(stack);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function record(kind: string, err: unknown, promise?: unknown): void {
|
|
169
|
+
if (kind === "unhandledRejection" && isBenignAbortTeardown(err)) {
|
|
170
|
+
benignSuppressed++;
|
|
171
|
+
const now = Date.now();
|
|
172
|
+
if (now - benignLastLoggedAt < BENIGN_LOG_INTERVAL_MS) return; // fold repeats silently
|
|
173
|
+
benignLastLoggedAt = now;
|
|
174
|
+
const summary = `\n[${new Date(now).toISOString()}] benign-abort-teardown x${benignSuppressed}`
|
|
175
|
+
+ ` (Bun fetch-body abort; proxy unaffected)${diagnose(err)}${diagnosePromise(promise)}${breadcrumb()}\n`;
|
|
176
|
+
benignSuppressed = 0;
|
|
177
|
+
try { appendFileSync(crashLogPath(), summary); } catch { /* logging must never throw */ }
|
|
178
|
+
return; // no stderr banner — this is expected noise, not a crash
|
|
179
|
+
}
|
|
180
|
+
const line = formatCrashEntry(kind, err, promise);
|
|
56
181
|
// Always surface to stderr so foreground `ocx start` users still see it,
|
|
57
182
|
// then persist for later diagnosis.
|
|
58
183
|
console.error(`⚠️ ${kind} (proxy stayed up; logged to crash.log)`);
|
|
@@ -64,6 +189,68 @@ function record(kind: string, err: unknown): void {
|
|
|
64
189
|
}
|
|
65
190
|
}
|
|
66
191
|
|
|
192
|
+
interface FetchTrace { url: string; at: number; origin: string; settled: boolean; rejected?: string }
|
|
193
|
+
const FETCH_RING_MAX = 12;
|
|
194
|
+
const fetchRing: FetchTrace[] = [];
|
|
195
|
+
let fetchInstrumented = false;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The recurring native-only rejection carries no source location, and every JS `await fetch(...)`
|
|
199
|
+
* is already try/caught — so the offending promise is created INSIDE Bun's fetch and rejects off the
|
|
200
|
+
* awaited path. Wrap global fetch to record each call's origin (a JS stack captured at call time) and
|
|
201
|
+
* whether it later rejected. crash-guard then dumps the still-pending / recently-rejected fetches so
|
|
202
|
+
* the next fault names the exact call site Bun lost.
|
|
203
|
+
*/
|
|
204
|
+
function instrumentFetch(): void {
|
|
205
|
+
if (fetchInstrumented) return;
|
|
206
|
+
const g = globalThis as { fetch?: typeof fetch };
|
|
207
|
+
const original = g.fetch;
|
|
208
|
+
if (typeof original !== "function") return;
|
|
209
|
+
fetchInstrumented = true;
|
|
210
|
+
g.fetch = function instrumentedFetch(this: unknown, ...args: Parameters<typeof fetch>): ReturnType<typeof fetch> {
|
|
211
|
+
let url = "";
|
|
212
|
+
try {
|
|
213
|
+
const input = args[0];
|
|
214
|
+
url = typeof input === "string" ? input : input instanceof URL ? input.href : (input as Request)?.url ?? "";
|
|
215
|
+
} catch { /* best-effort */ }
|
|
216
|
+
const origin = (new Error().stack ?? "").split("\n").slice(2, 5).map(l => l.trim()).join(" <- ");
|
|
217
|
+
const trace: FetchTrace = { url: redactUrlForLog(url), at: Date.now(), origin, settled: false };
|
|
218
|
+
fetchRing.push(trace);
|
|
219
|
+
if (fetchRing.length > FETCH_RING_MAX) fetchRing.shift();
|
|
220
|
+
let p: ReturnType<typeof fetch>;
|
|
221
|
+
try {
|
|
222
|
+
p = original.apply(this, args);
|
|
223
|
+
} catch (e) {
|
|
224
|
+
trace.settled = true;
|
|
225
|
+
trace.rejected = redactDiagnosticText(e instanceof Error ? `${e.name}: ${e.message}` : String(e));
|
|
226
|
+
throw e;
|
|
227
|
+
}
|
|
228
|
+
return p.then(
|
|
229
|
+
r => { trace.settled = true; return r; },
|
|
230
|
+
e => { trace.settled = true; trace.rejected = redactDiagnosticText(e instanceof Error ? `${e.name}: ${e.message}` : String(e)); throw e; },
|
|
231
|
+
);
|
|
232
|
+
} as typeof fetch;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Render the recent fetch ring (pending first) for the crash breadcrumb. */
|
|
236
|
+
function recentFetches(): string {
|
|
237
|
+
try {
|
|
238
|
+
if (fetchRing.length === 0) return "";
|
|
239
|
+
const now = Date.now();
|
|
240
|
+
const rows = fetchRing.slice(-6).map(f => {
|
|
241
|
+
const state = !f.settled ? "PENDING" : f.rejected ? `REJECTED(${f.rejected})` : "ok";
|
|
242
|
+
return ` [${state}] ${f.url} ageMs=${now - f.at}${!f.settled ? ` origin=${f.origin}` : ""}`;
|
|
243
|
+
});
|
|
244
|
+
return ` fetches:\n${rows.join("\n")}`;
|
|
245
|
+
} catch {
|
|
246
|
+
return "";
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function redactDiagnosticText(value: string): string {
|
|
251
|
+
return redactSecretString(value);
|
|
252
|
+
}
|
|
253
|
+
|
|
67
254
|
/**
|
|
68
255
|
* Register global handlers that keep the proxy alive and capture full stacks.
|
|
69
256
|
* Idempotent: safe to call more than once.
|
|
@@ -71,9 +258,10 @@ function record(kind: string, err: unknown): void {
|
|
|
71
258
|
export function installCrashGuards(): void {
|
|
72
259
|
if (installed) return;
|
|
73
260
|
installed = true;
|
|
261
|
+
instrumentFetch();
|
|
74
262
|
|
|
75
|
-
process.on("unhandledRejection", reason => {
|
|
76
|
-
record("unhandledRejection", reason);
|
|
263
|
+
process.on("unhandledRejection", (reason, promise) => {
|
|
264
|
+
record("unhandledRejection", reason, promise);
|
|
77
265
|
});
|
|
78
266
|
|
|
79
267
|
process.on("uncaughtException", err => {
|
package/src/debug.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { redactSecrets } from "./redact";
|
|
2
|
+
|
|
1
3
|
// Opt-in frame-drop visibility. The streaming path is intentionally quiet (no unconditional
|
|
2
4
|
// console output), so this no-ops unless OCX_DEBUG_FRAMES=1. Lets a malformed/chunk-split
|
|
3
5
|
// upstream frame be detected instead of silently truncating content.
|
|
@@ -9,3 +11,12 @@ export function debugDroppedFrame(adapter: string, payload: string): void {
|
|
|
9
11
|
if (!debugFramesEnabled()) return;
|
|
10
12
|
console.error(`[ocx:frame-drop] ${adapter}: dropped malformed upstream frame (payload redacted, bytes=${payload.length})`);
|
|
11
13
|
}
|
|
14
|
+
|
|
15
|
+
export function debugProviderDiagnostic(adapter: string, event: string, details: Record<string, unknown>): void {
|
|
16
|
+
if (!debugFramesEnabled()) return;
|
|
17
|
+
try {
|
|
18
|
+
console.error(`[ocx:${adapter}:${event}] ${JSON.stringify(redactSecrets(details))}`);
|
|
19
|
+
} catch {
|
|
20
|
+
/* diagnostics must never affect request handling */
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -17,14 +17,38 @@ export function classifyError(status: number, type: string, message: string): Oc
|
|
|
17
17
|
}
|
|
18
18
|
if (
|
|
19
19
|
text.includes("insufficient_quota") ||
|
|
20
|
-
text.includes("exceeded your current quota")
|
|
20
|
+
text.includes("exceeded your current quota") ||
|
|
21
|
+
text.includes("quota exhausted") ||
|
|
22
|
+
text.includes("account quota exceeded") ||
|
|
23
|
+
text.includes("monthly quota exceeded") ||
|
|
24
|
+
text.includes("daily quota exceeded")
|
|
21
25
|
) {
|
|
22
26
|
return { message, type: "insufficient_quota", code: "insufficient_quota" };
|
|
23
27
|
}
|
|
24
|
-
if (
|
|
28
|
+
if (
|
|
29
|
+
status === 429 ||
|
|
30
|
+
text.includes("rate limit") ||
|
|
31
|
+
text.includes("rate limited") ||
|
|
32
|
+
text.includes("too many requests") ||
|
|
33
|
+
text.includes("throttlingexception")
|
|
34
|
+
) {
|
|
25
35
|
return { message, type: "rate_limit_error", code: "rate_limit_exceeded" };
|
|
26
36
|
}
|
|
27
|
-
if (
|
|
37
|
+
if (type === "origin_rejected") {
|
|
38
|
+
return { message, type: "invalid_request_error", code: "origin_rejected" };
|
|
39
|
+
}
|
|
40
|
+
if (
|
|
41
|
+
status === 401 ||
|
|
42
|
+
status === 403 ||
|
|
43
|
+
type === "authentication_error" ||
|
|
44
|
+
text.includes("authentication failed") ||
|
|
45
|
+
text.includes("access denied") ||
|
|
46
|
+
text.includes("unauthorizedexception") ||
|
|
47
|
+
text.includes("unrecognizedclientexception") ||
|
|
48
|
+
text.includes("unrecognizedclient") ||
|
|
49
|
+
text.includes("expired token") ||
|
|
50
|
+
text.includes("expiredtoken")
|
|
51
|
+
) {
|
|
28
52
|
return { message, type: "authentication_error", code: "invalid_api_key" };
|
|
29
53
|
}
|
|
30
54
|
if (
|
|
@@ -37,6 +61,18 @@ export function classifyError(status: number, type: string, message: string): Oc
|
|
|
37
61
|
// (responses.rs is_server_overloaded_error); generic "upstream_server_error" is not recognized.
|
|
38
62
|
return { message, type: "server_error", code: "server_is_overloaded" };
|
|
39
63
|
}
|
|
64
|
+
if (
|
|
65
|
+
text.includes("validationexception") ||
|
|
66
|
+
text.includes("invalid request") ||
|
|
67
|
+
text.includes("model unavailable") ||
|
|
68
|
+
text.includes("model not found") ||
|
|
69
|
+
text.includes("unsupported model") ||
|
|
70
|
+
text.includes("profile arn") ||
|
|
71
|
+
text.includes("wrong region") ||
|
|
72
|
+
text.includes("invalid region")
|
|
73
|
+
) {
|
|
74
|
+
return { message, type: "invalid_request_error", code: "invalid_request_error" };
|
|
75
|
+
}
|
|
40
76
|
if (status >= 500) {
|
|
41
77
|
return { message, type: "server_error", code: "upstream_server_error" };
|
|
42
78
|
}
|