@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.
Files changed (48) hide show
  1. package/README.md +8 -0
  2. package/bin/ocx.mjs +41 -9
  3. package/gui/dist/assets/index-LK87QnT7.js +9 -0
  4. package/gui/dist/index.html +1 -1
  5. package/package.json +1 -1
  6. package/src/abort.ts +22 -0
  7. package/src/adapters/base.ts +17 -4
  8. package/src/adapters/kiro-errors.ts +101 -0
  9. package/src/adapters/kiro-events.ts +48 -0
  10. package/src/adapters/kiro-images.ts +33 -0
  11. package/src/adapters/kiro-retry.ts +95 -0
  12. package/src/adapters/kiro-thinking.ts +82 -0
  13. package/src/adapters/kiro-tool-fallback.ts +36 -0
  14. package/src/adapters/kiro-tools.ts +44 -0
  15. package/src/adapters/kiro-truncation.ts +33 -0
  16. package/src/adapters/kiro-wire.ts +51 -0
  17. package/src/adapters/kiro.ts +527 -0
  18. package/src/adapters/openai-chat.ts +10 -1
  19. package/src/bridge.ts +1 -1
  20. package/src/cli.ts +25 -3
  21. package/src/codex-catalog.ts +97 -13
  22. package/src/codex-inject.ts +18 -0
  23. package/src/config.ts +52 -0
  24. package/src/crash-guard.ts +197 -9
  25. package/src/debug.ts +11 -0
  26. package/src/errors.ts +39 -3
  27. package/src/lib/eventstream-decoder.ts +244 -0
  28. package/src/lib/token-estimate.ts +43 -0
  29. package/src/oauth/anthropic.ts +1 -1
  30. package/src/oauth/index.ts +53 -6
  31. package/src/oauth/kiro-credentials.ts +256 -0
  32. package/src/oauth/kiro.ts +164 -0
  33. package/src/oauth/local-token-detect.ts +2 -1
  34. package/src/oauth/store.ts +36 -3
  35. package/src/oauth/types.ts +3 -0
  36. package/src/oauth/xai.ts +1 -1
  37. package/src/providers/kiro-models.ts +55 -0
  38. package/src/providers/registry.ts +15 -0
  39. package/src/redact.ts +71 -0
  40. package/src/server.ts +40 -22
  41. package/src/sidecar-tracker.ts +49 -0
  42. package/src/types.ts +3 -0
  43. package/src/usage-debug.ts +7 -4
  44. package/src/usage-log.ts +41 -3
  45. package/src/vision/describe.ts +11 -2
  46. package/src/web-search/executor.ts +10 -2
  47. package/src/web-search/loop.ts +27 -7
  48. package/gui/dist/assets/index-BmHrbTmO.js +0 -9
@@ -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 (the slugs Codex
40
- * itself ships for the installed version) via nativeOpenAiSlugs(); this static list is used only when
41
- * no catalog is present. Keep it to ids ChatGPT actually accepts — advertising a phantom (e.g. an
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 (always-latest: matches exactly what the installed Codex supports), else the static
51
- * fallback above. Single source for the /v1/models native list and the subagent-default seed.
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
- .filter(m => typeof m.slug === "string" && !(m.slug as string).includes("/") && m.visibility === "list")
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" && !(m.slug as string).includes("/") && !goIds.has(m.slug as 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
- catalog.models = [...native, ...goEntries].map(m => {
734
- const e = ensureStrictCatalogFields(normalizeServiceTiers(m));
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;
@@ -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({
@@ -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 formatError(kind: string, err: unknown): string {
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
- function record(kind: string, err: unknown): void {
55
- const line = formatError(kind, err);
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 (status === 429 || text.includes("rate limit") || text.includes("too many requests")) {
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 (status === 401 || status === 403 || type === "authentication_error") {
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
  }