@bitkyc08/opencodex 2.7.1 → 2.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,8 +16,8 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-CtHGtaW8.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-BUAMcKFd.css">
19
+ <script type="module" crossorigin src="/assets/index-DGFbiUtS.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-D7o1qwy-.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.7.1",
3
+ "version": "2.7.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",
@@ -241,7 +241,7 @@ function usesNativeAnthropicEndpoint(provider: OcxProviderConfig): boolean {
241
241
  try {
242
242
  return new URL(provider.baseUrl).hostname === "api.anthropic.com";
243
243
  } catch {
244
- return false;
244
+ throw new Error(`anthropic provider has malformed baseUrl: ${provider.baseUrl}`);
245
245
  }
246
246
  }
247
247
 
@@ -555,6 +555,13 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
555
555
  name: "anthropic",
556
556
 
557
557
  buildRequest(parsed: OcxParsedRequest) {
558
+ if (typeof provider.apiKey !== "string" || provider.apiKey.trim() === "") {
559
+ if (isOAuth) {
560
+ throw new Error("anthropic oauth token missing — run ocx login anthropic");
561
+ }
562
+ throw new Error("anthropic provider requires a non-empty apiKey (authMode: key)");
563
+ }
564
+
558
565
  const { system, messages } = messagesToAnthropicFormat(parsed, toolNames);
559
566
  // Anthropic rejects many-image requests (>20 images) carrying any image over
560
567
  // 2000px per side; see anthropic-image-guard.ts for the full limit policy.
@@ -618,6 +625,10 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
618
625
 
619
626
  const base = provider.baseUrl.replace(/\/v1\/?$/, "");
620
627
  const url = `${base}/v1/messages`;
628
+ const unresolvedPlaceholder = url.match(/\{[^}]*\}/)?.[0];
629
+ if (unresolvedPlaceholder) {
630
+ throw new Error(`anthropic baseUrl contains unresolved ${unresolvedPlaceholder}`);
631
+ }
621
632
  const headers: Record<string, string> = {
622
633
  "Content-Type": "application/json",
623
634
  "anthropic-version": "2023-06-01",
@@ -625,7 +636,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
625
636
  "User-Agent": "@anthropic-ai/sdk/0.74.0",
626
637
  };
627
638
  if (isOAuth) {
628
- if (provider.apiKey) headers["Authorization"] = `Bearer ${provider.apiKey}`;
639
+ headers["Authorization"] = `Bearer ${provider.apiKey}`;
629
640
  headers["anthropic-beta"] = ANTHROPIC_OAUTH_BETA;
630
641
  // Match the real Claude Code CLI request fingerprint: a valid OAuth token with an empty
631
642
  // header set is a non-first-party signature. (cch billing-header signing is intentionally
@@ -633,7 +644,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
633
644
  Object.assign(headers, CLAUDE_CODE_HEADERS);
634
645
  headers["X-Claude-Code-Session-Id"] = claudeCodeSessionId(provider.apiKey);
635
646
  headers["x-client-request-id"] = crypto.randomUUID();
636
- } else if (provider.apiKey) {
647
+ } else {
637
648
  headers["x-api-key"] = provider.apiKey;
638
649
  }
639
650
  if (provider.headers) Object.assign(headers, provider.headers);
@@ -13,19 +13,24 @@ export function createAzureAdapter(provider: OcxProviderConfig): ProviderAdapter
13
13
  name: "azure-openai",
14
14
 
15
15
  async buildRequest(parsed: OcxParsedRequest) {
16
- const request = await inner.buildRequest(parsed);
17
- const headers = { ...request.headers };
18
- if (provider.apiKey) {
19
- headers["api-key"] = provider.apiKey;
20
- delete headers["Authorization"];
16
+ if (provider.authMode === "forward") {
17
+ throw new Error("azure-openai does not support forward auth mode");
21
18
  }
22
- let url = request.url;
23
- if (!url.includes("/v1/")) {
24
- const apiVersion = (provider.headers?.["api-version"]) ?? "2025-04-01-preview";
25
- const separator = url.includes("?") ? "&" : "?";
26
- url = `${url}${separator}api-version=${apiVersion}`;
19
+ if (typeof provider.apiKey !== "string" || provider.apiKey.trim() === "") {
20
+ throw new Error("azure-openai requires a non-empty apiKey");
27
21
  }
28
- return { ...request, url, headers };
22
+
23
+ const request = await inner.buildRequest(parsed);
24
+ const unresolvedPlaceholder = request.url.match(/\{[^}]*\}/)?.[0] ?? request.url.match(/[{}]/)?.[0];
25
+ if (unresolvedPlaceholder) {
26
+ throw new Error(`azure-openai baseUrl contains unresolved ${unresolvedPlaceholder} — set your real resource URL`);
27
+ }
28
+
29
+ const headers = { ...request.headers };
30
+ headers["api-key"] = provider.apiKey;
31
+ delete headers["Authorization"];
32
+ // The inner adapter always targets Azure's v1 API here, which needs no api-version query.
33
+ return { ...request, headers };
29
34
  },
30
35
  };
31
36
  }
@@ -2,8 +2,8 @@
2
2
  * Live Cursor model discovery via the `GetUsableModels` RPC (HTTP/2 + Connect-unary protobuf).
3
3
  *
4
4
  * Returns the account's actually-usable model ids (the full effort-suffixed variants Cursor offers
5
- * for THIS plan), so the routed catalog reflects reality instead of a static superset. Returns null on
6
- * any failure (caller falls back to the static seed).
5
+ * for THIS plan), so the routed catalog reflects reality instead of a static superset. Failures are
6
+ * classified so callers can surface the reason before applying their existing degradation policy.
7
7
  *
8
8
  * Protocol notes (hard-won, see devlog 350.110):
9
9
  * - content-type `application/proto` + `connect-protocol-version: 1` (NOT `application/connect+proto`,
@@ -26,13 +26,17 @@ export interface CursorUsableModelsOptions {
26
26
  timeoutMs?: number;
27
27
  }
28
28
 
29
- export async function fetchCursorUsableModels(opts: CursorUsableModelsOptions): Promise<string[] | null> {
29
+ export type CursorUsableModelsResult =
30
+ | { ok: true; models: string[] }
31
+ | { ok: false; error: "auth" | "http" | "timeout" | "decode" | "empty"; detail?: string };
32
+
33
+ export async function fetchCursorUsableModels(opts: CursorUsableModelsOptions): Promise<CursorUsableModelsResult> {
30
34
  const baseUrl = (opts.baseUrl ?? "https://api2.cursor.sh").replace(/\/+$/, "");
31
35
  const timeoutMs = opts.timeoutMs ?? 8000;
32
36
 
33
- return new Promise<string[] | null>(resolve => {
37
+ return new Promise<CursorUsableModelsResult>(resolve => {
34
38
  let settled = false;
35
- const finish = (value: string[] | null): void => {
39
+ const finish = (value: CursorUsableModelsResult): void => {
36
40
  if (settled) return;
37
41
  settled = true;
38
42
  resolve(value);
@@ -42,32 +46,37 @@ export async function fetchCursorUsableModels(opts: CursorUsableModelsOptions):
42
46
  try {
43
47
  client = http2.connect(baseUrl);
44
48
  } catch {
45
- return finish(null);
49
+ return finish({ ok: false, error: "http", detail: "HTTP/2 connection setup failed" });
46
50
  }
47
51
 
48
52
  const timer = setTimeout(() => {
53
+ finish({ ok: false, error: "timeout", detail: `No response within ${timeoutMs}ms` });
49
54
  client.destroy();
50
- finish(null);
51
55
  }, timeoutMs);
52
- const close = (value: string[] | null): void => {
56
+ const close = (value: CursorUsableModelsResult): void => {
53
57
  clearTimeout(timer);
54
58
  client.close();
55
59
  finish(value);
56
60
  };
57
61
 
58
- client.on("error", () => close(null));
62
+ client.on("error", () => close({ ok: false, error: "http", detail: "HTTP/2 session failed" }));
59
63
 
60
- const req = client.request({
61
- ":method": "POST",
62
- ":path": CURSOR_GET_USABLE_MODELS_PATH,
63
- "content-type": "application/proto",
64
- "connect-protocol-version": "1",
65
- authorization: `Bearer ${opts.apiKey}`,
66
- "x-ghost-mode": "true",
67
- "x-cursor-client-version": opts.clientVersion ?? CURSOR_DISCOVERY_CLIENT_VERSION,
68
- "x-cursor-client-type": "cli",
69
- "x-session-id": crypto.randomUUID(),
70
- });
64
+ let req: http2.ClientHttp2Stream;
65
+ try {
66
+ req = client.request({
67
+ ":method": "POST",
68
+ ":path": CURSOR_GET_USABLE_MODELS_PATH,
69
+ "content-type": "application/proto",
70
+ "connect-protocol-version": "1",
71
+ authorization: `Bearer ${opts.apiKey}`,
72
+ "x-ghost-mode": "true",
73
+ "x-cursor-client-version": opts.clientVersion ?? CURSOR_DISCOVERY_CLIENT_VERSION,
74
+ "x-cursor-client-type": "cli",
75
+ "x-session-id": crypto.randomUUID(),
76
+ });
77
+ } catch {
78
+ return close({ ok: false, error: "http", detail: "HTTP/2 request setup failed" });
79
+ }
71
80
 
72
81
  let status = 0;
73
82
  const chunks: Buffer[] = [];
@@ -75,9 +84,12 @@ export async function fetchCursorUsableModels(opts: CursorUsableModelsOptions):
75
84
  status = Number(headers[":status"] ?? 0);
76
85
  });
77
86
  req.on("data", (chunk: Buffer) => chunks.push(chunk));
78
- req.on("error", () => close(null));
87
+ req.on("error", () => close({ ok: false, error: "http", detail: "HTTP/2 request failed" }));
79
88
  req.on("end", () => {
80
- if (status !== 200) return close(null);
89
+ if (status === 401 || status === 403) {
90
+ return close({ ok: false, error: "auth", detail: `HTTP ${status}` });
91
+ }
92
+ if (status !== 200) return close({ ok: false, error: "http", detail: `HTTP ${status || "unknown"}` });
81
93
  try {
82
94
  const response = fromBinary(GetUsableModelsResponseSchema, new Uint8Array(Buffer.concat(chunks)));
83
95
  // Account filtering uses wire `model_id` values only. Aliases like `composer-2-5` must not
@@ -85,9 +97,9 @@ export async function fetchCursorUsableModels(opts: CursorUsableModelsOptions):
85
97
  const ids = (response.models ?? [])
86
98
  .map(model => (model as { modelId?: string }).modelId)
87
99
  .filter((id): id is string => typeof id === "string" && id.length > 0);
88
- close(ids.length > 0 ? ids : null);
100
+ close(ids.length > 0 ? { ok: true, models: ids } : { ok: false, error: "empty" });
89
101
  } catch {
90
- close(null);
102
+ close({ ok: false, error: "decode", detail: "Invalid GetUsableModels protobuf response" });
91
103
  }
92
104
  });
93
105
 
@@ -19,8 +19,6 @@ import {
19
19
  ExaSearchRequestResponseSchema,
20
20
  ExaSearchRequestResponse_RejectedSchema,
21
21
  InteractionResponseSchema,
22
- SetupVmEnvironmentResultSchema,
23
- SetupVmEnvironmentSuccessSchema,
24
22
  SwitchModeRequestResponseSchema,
25
23
  SwitchModeRequestResponse_RejectedSchema,
26
24
  WebSearchRequestResponseSchema,
@@ -270,18 +268,9 @@ export function planInteractionQueryReply(query: InteractionQuery): { response:
270
268
  };
271
269
  }
272
270
  if (q.case === "setupVmEnvironmentArgs") {
273
- return {
274
- response: respond({
275
- case: "setupVmEnvironmentResult",
276
- value: create(SetupVmEnvironmentResultSchema, {
277
- result: { case: "success", value: create(SetupVmEnvironmentSuccessSchema, {}) },
278
- }),
279
- }),
280
- replyCase: "setupVmEnvironmentResult:success",
281
- };
271
+ throw new Error("Cursor setupVmEnvironment is not supported by opencodex");
282
272
  }
283
- // Unknown/future query case: an empty response still unblocks the matching id.
284
- return { response: respond({ case: undefined, value: undefined } as InteractionResponse["result"]), replyCase: "empty" };
273
+ throw new Error(`Unsupported Cursor interaction query case: ${q.case ?? "unknown"}`);
285
274
  }
286
275
 
287
276
  /**
@@ -354,8 +343,8 @@ class LiveCursorTransport implements CursorTransport {
354
343
  /**
355
344
  * Connect MCP servers and compute the tool definitions advertised to the Cursor server.
356
345
  * MUST complete before the first `requestContextArgs` (the server only calls MCP tools it was
357
- * told about), so `run()` awaits this before opening the stream. Best-effort: any failure
358
- * leaves an empty tool list and MCP disabled for the stream, never blocking the conversation.
346
+ * told about), so `run()` awaits this before opening the stream. Preparation failures reject the
347
+ * turn instead of silently running with MCP disabled.
359
348
  */
360
349
  private prepareMcp(): Promise<void> {
361
350
  if (!this.mcpManager) return Promise.resolve();
@@ -370,8 +359,7 @@ class LiveCursorTransport implements CursorTransport {
370
359
  unsafeAllowNativeLocalExec: cursorUnsafeNativeLocalExecEnabled(this.input.provider),
371
360
  };
372
361
  } catch (err) {
373
- console.warn(`[cursor-mcp] preparation failed, MCP disabled for this stream: ${err instanceof Error ? err.message : String(err)}`);
374
- this.execContext = { ...this.desktopDeps, unsafeAllowNativeLocalExec: cursorUnsafeNativeLocalExecEnabled(this.input.provider) };
362
+ throw new Error(`Cursor MCP preparation failed: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
375
363
  }
376
364
  })();
377
365
  }
@@ -1,8 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { create } from "@bufbuild/protobuf";
3
3
  import {
4
+ DiagnosticsErrorSchema,
4
5
  DiagnosticsResultSchema,
5
- DiagnosticsSuccessSchema,
6
6
  GetBlobResultSchema,
7
7
  KvClientMessageSchema,
8
8
  McpErrorSchema,
@@ -63,14 +63,12 @@ export interface CursorNativeExecContext extends CursorNativeExecDeps {
63
63
  clientToolDefs?: McpToolDefinition[];
64
64
  /** Unsafe opt-in escape hatch for Cursor server-driven local fs/shell/fetch execution. */
65
65
  unsafeAllowNativeLocalExec?: boolean;
66
- /** @deprecated Use unsafeAllowNativeLocalExec. Kept as a transition alias for local experiments. */
67
- allowNativeLocalExec?: boolean;
68
66
  /** apply_patch is visible for this request; Cursor-native write/delete must not bypass Codex. */
69
67
  rejectNativeFileMutations?: boolean;
70
68
  }
71
69
 
72
- export function cursorUnsafeNativeLocalExecEnabled(input: Pick<CursorNativeExecContext, "unsafeAllowNativeLocalExec" | "allowNativeLocalExec"> = {}): boolean {
73
- return input.unsafeAllowNativeLocalExec === true || input.allowNativeLocalExec === true;
70
+ export function cursorUnsafeNativeLocalExecEnabled(input: Pick<CursorNativeExecContext, "unsafeAllowNativeLocalExec"> = {}): boolean {
71
+ return input.unsafeAllowNativeLocalExec === true;
74
72
  }
75
73
 
76
74
  /**
@@ -183,10 +181,16 @@ export async function handleCursorNativeExec(execMsg: ExecServerMessage, deps: C
183
181
  if (execCase === "diagnosticsArgs") {
184
182
  const path = execMsg.message.value.path;
185
183
  return [execBytes(execMsg, "diagnosticsResult", create(DiagnosticsResultSchema, {
186
- result: { case: "success", value: create(DiagnosticsSuccessSchema, { path, diagnostics: [], totalDiagnostics: 0 }) },
184
+ result: {
185
+ case: "error",
186
+ value: create(DiagnosticsErrorSchema, {
187
+ path,
188
+ error: "Diagnostics are not supported by the opencodex Cursor transport.",
189
+ }),
190
+ },
187
191
  }))];
188
192
  }
189
- return [];
193
+ throw new Error(`Unsupported Cursor native exec case: ${execCase ?? "unknown"}`);
190
194
  }
191
195
 
192
196
 
@@ -68,8 +68,7 @@ export async function runCursorTurnWithRetry(
68
68
  signal: AbortSignal | undefined,
69
69
  onEvent: (message: CursorServerMessage, transport: CursorTransport) => void,
70
70
  ): Promise<void> {
71
- let lastError: unknown;
72
- for (let attempt = 0; attempt < CURSOR_RETRY_ATTEMPTS; attempt++) {
71
+ for (let attempt = 0; ; attempt++) {
73
72
  if (signal?.aborted) throw abortError(signal);
74
73
  const transport = makeTransport(input);
75
74
  let emittedAny = false;
@@ -80,7 +79,6 @@ export async function runCursorTurnWithRetry(
80
79
  }
81
80
  return;
82
81
  } catch (err) {
83
- lastError = err;
84
82
  const canRetry =
85
83
  !emittedAny &&
86
84
  attempt < CURSOR_RETRY_ATTEMPTS - 1 &&
@@ -108,7 +106,6 @@ export async function runCursorTurnWithRetry(
108
106
  await transport.close?.();
109
107
  }
110
108
  }
111
- throw lastError ?? new Error("Cursor transport failed");
112
109
  }
113
110
 
114
111
  export type { CursorTransportFactory };
@@ -231,7 +231,10 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
231
231
 
232
232
  if (provider.googleMode === "cloud-code-assist") {
233
233
  // Google Antigravity (Cloud Code Assist): wrap the flat Gemini body in the CCA envelope.
234
- const base = provider.baseUrl || "https://daily-cloudcode-pa.googleapis.com";
234
+ const token = provider.apiKey?.trim();
235
+ if (!token) throw new Error("google-antigravity oauth token missing — run ocx login google-antigravity");
236
+ const base = provider.baseUrl?.trim();
237
+ if (!base) throw new Error("google-antigravity requires a non-empty baseUrl");
235
238
  const url = `${base}/v1internal:${method}${streamParam}`;
236
239
  const project = provider.project;
237
240
  if (!project) throw new Error("Antigravity requires a discovered Cloud Code Assist project id (re-run `ocx login google-antigravity`).");
@@ -271,7 +274,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
271
274
  request,
272
275
  };
273
276
  headers["User-Agent"] = ANTIGRAVITY_REQUEST_UA;
274
- if (provider.apiKey) headers["Authorization"] = `Bearer ${provider.apiKey}`;
277
+ headers["Authorization"] = `Bearer ${token}`;
275
278
  return { url, method: "POST", headers, body: JSON.stringify(envelope) };
276
279
  }
277
280
 
@@ -296,7 +299,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
296
299
 
297
300
  // ai-studio (default): Generative Language API + x-goog-api-key.
298
301
  const url = `${provider.baseUrl}/v1beta/models/${parsed.modelId}:${method}${streamParam}`;
299
- if (provider.apiKey) headers["x-goog-api-key"] = provider.apiKey;
302
+ const apiKey = provider.apiKey?.trim();
303
+ if (!apiKey) throw new Error("google (AI Studio) requires a non-empty API key");
304
+ headers["x-goog-api-key"] = apiKey;
300
305
 
301
306
  return { url, method: "POST", headers, body: JSON.stringify(body) };
302
307
  },
@@ -345,9 +350,15 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
345
350
  }
346
351
 
347
352
  // Antigravity (CCA) nests the standard Gemini payload under `response`.
348
- const root = (provider.googleMode === "cloud-code-assist"
349
- ? (chunk.response as Record<string, unknown> | undefined) ?? chunk
350
- : chunk);
353
+ let root = chunk;
354
+ if (provider.googleMode === "cloud-code-assist") {
355
+ const wrapped = chunk.response;
356
+ if (!wrapped || typeof wrapped !== "object" || Array.isArray(wrapped)) {
357
+ yield { type: "error", message: "google-antigravity response missing response wrapper" };
358
+ return;
359
+ }
360
+ root = wrapped as Record<string, unknown>;
361
+ }
351
362
  // usageMetadata is a top-level field independent of candidates; read it BEFORE the
352
363
  // candidates guard so a usage-only final chunk is not dropped.
353
364
  const usageMeta = root.usageMetadata as Record<string, number> | undefined;
@@ -397,13 +408,25 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
397
408
 
398
409
  async parseResponse(response: Response): Promise<AdapterEvent[]> {
399
410
  const raw = await response.json() as Record<string, unknown>;
411
+ if (raw.error) {
412
+ const err = raw.error as { message?: string };
413
+ return [{ type: "error", message: err.message ?? "upstream error" }];
414
+ }
400
415
  // Antigravity (CCA) nests the standard Gemini payload under `response`; unwrap it.
401
- const json = (provider.googleMode === "cloud-code-assist"
402
- ? (raw.response as Record<string, unknown> | undefined) ?? raw
403
- : raw);
416
+ let json = raw;
417
+ if (provider.googleMode === "cloud-code-assist") {
418
+ const wrapped = raw.response;
419
+ if (!wrapped || typeof wrapped !== "object" || Array.isArray(wrapped)) {
420
+ return [{ type: "error", message: "google-antigravity response missing response wrapper" }];
421
+ }
422
+ json = wrapped as Record<string, unknown>;
423
+ }
404
424
  const events: AdapterEvent[] = [];
405
425
 
406
426
  const candidates = json.candidates as { content?: { parts?: { text?: string; functionCall?: { name: string; args: unknown } }[] }; finishReason?: string }[] | undefined;
427
+ if (!candidates?.length) {
428
+ return [{ type: "error", message: "google response contained no candidates" }];
429
+ }
407
430
  let toolCallsStarted = 0;
408
431
  if (candidates?.[0]?.content?.parts) {
409
432
  // Non-streaming CCA: observe thoughtSignatures for the next turn, same as the stream path.
@@ -1,6 +1,6 @@
1
1
  import type { AdapterFetchContext, AdapterRequest } from "./base";
2
2
  import { safeKiroHttpErrorMessage } from "./kiro-errors";
3
- import { abortError, sleepWithAbort } from "../lib/upstream-retry";
3
+ import { abortError, isConnectionResetError, sleepWithAbort } from "../lib/upstream-retry";
4
4
 
5
5
  const KIRO_RETRY_ATTEMPTS = 3;
6
6
  const KIRO_RETRY_BASE_MS = 250;
@@ -32,6 +32,10 @@ function signalWithAttemptTimeout(parent: AbortSignal | undefined, timeoutMs: nu
32
32
  return parent ? AbortSignal.any([parent, timeout]) : timeout;
33
33
  }
34
34
 
35
+ function retryableKiroFetchError(err: unknown): boolean {
36
+ return isConnectionResetError(err) || (err instanceof Error && err.name === "TimeoutError");
37
+ }
38
+
35
39
  async function normalizeFinalKiroHttpError(res: Response): Promise<Response> {
36
40
  if (res.ok) return res;
37
41
  const payloadText = await res.clone().text().catch(() => "");
@@ -62,8 +66,8 @@ export async function fetchKiroWithRetry(request: AdapterRequest, ctx: AdapterFe
62
66
  await sleepWithAbort(retryDelayMs(attempt, res.headers), ctx.abortSignal);
63
67
  } catch (err) {
64
68
  if (ctx.abortSignal?.aborted) throw err;
69
+ if (!retryableKiroFetchError(err) || attempt === KIRO_RETRY_ATTEMPTS - 1) throw err;
65
70
  lastError = err;
66
- if (attempt === KIRO_RETRY_ATTEMPTS - 1) throw err;
67
71
  await sleepWithAbort(retryDelayMs(attempt), ctx.abortSignal);
68
72
  }
69
73
  }
@@ -445,15 +445,17 @@ export async function* parseKiroStream(
445
445
  break;
446
446
  }
447
447
  case "tool_stop": {
448
- if (open) {
449
- const input = open.chunks.join("");
450
- if (!isCompleteKiroToolInput(input)) {
451
- open = null;
452
- yield { type: "error", message: kiroTruncationErrorMessage("incomplete tool input JSON") };
453
- return;
454
- }
455
- yield* flushTool();
448
+ if (!open) {
449
+ yield { type: "error", message: "Kiro response protocol error: tool stop received without an open tool call" };
450
+ return;
456
451
  }
452
+ const input = open.chunks.join("");
453
+ if (!isCompleteKiroToolInput(input)) {
454
+ open = null;
455
+ yield { type: "error", message: kiroTruncationErrorMessage("incomplete tool input JSON") };
456
+ return;
457
+ }
458
+ yield* flushTool();
457
459
  break;
458
460
  }
459
461
  case "truncation":
@@ -496,11 +498,14 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
496
498
  return {
497
499
  name: "kiro",
498
500
  buildRequest(parsed: OcxParsedRequest) {
501
+ if (typeof provider.apiKey !== "string" || provider.apiKey.trim() === "") {
502
+ throw new Error("kiro token missing — run ocx login kiro");
503
+ }
499
504
  const region = resolveKiroApiRegion();
500
505
  const profileArn = resolveKiroProfileArn();
501
506
  const fp = fingerprint().slice(0, 64);
502
507
  const headers: Record<string, string> = {
503
- authorization: `Bearer ${provider.apiKey ?? ""}`,
508
+ authorization: `Bearer ${provider.apiKey}`,
504
509
  "content-type": "application/x-amz-json-1.0",
505
510
  accept: "application/vnd.amazon.eventstream",
506
511
  "x-amz-target": AMZ_TARGET,
@@ -1,5 +1,4 @@
1
1
  import type { ProviderAdapter } from "./base";
2
- import { debugDroppedFrame } from "../lib/debug";
3
2
  import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall, OcxUsage } from "../types";
4
3
  import { isAllowedToolChoice, modelInList, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice } from "../types";
5
4
  import { mapReasoningEffort } from "../reasoning-effort";
@@ -7,11 +6,9 @@ import { contentPartsToText } from "./image";
7
6
  import { neutralizeIdentity } from "./identity";
8
7
  import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "./tool-catalog-nudge";
9
8
 
10
- // Z.AI's "glm-5.2[1m]" 1M-context id is a Claude-Code / Anthropic-endpoint-only
11
- // convention; OpenAI-compatible chat-completions endpoints reject the bracketed
12
- // suffix (Z.AI 400 code 1211 "Unknown Model"). Strip a single trailing "[...]"
13
- // group from the wire model id so the bare id is sent. Applies to the
14
- // openai-chat path only — the anthropic adapter keeps the suffix verbatim.
9
+ // Providers may opt into stripping one trailing "[...]" group from the wire model id.
10
+ // Z.AI needs this because its OpenAI path rejects glm-5.2[1m] with 400 code 1211;
11
+ // unflagged OpenAI-compatible providers and the Anthropic adapter keep ids verbatim.
15
12
  export function stripBracketedModelSuffix(modelId: string): string {
16
13
  return modelId.replace(/\[[^\]]*\]\s*$/, "");
17
14
  }
@@ -125,7 +122,7 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
125
122
  function safeToolName(name: string | undefined): string {
126
123
  const raw = name && name.trim().length > 0 ? name : "tool_result";
127
124
  const sanitized = raw.replace(/[^A-Za-z0-9_-]/g, "_");
128
- return sanitized.length > 0 ? sanitized : "tool_result";
125
+ return sanitized;
129
126
  }
130
127
 
131
128
  function toolsToChatFormat(parsed: OcxParsedRequest): unknown[] | undefined {
@@ -187,12 +184,17 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
187
184
  name: "openai-chat",
188
185
 
189
186
  buildRequest(parsed: OcxParsedRequest) {
187
+ const hasCredential = typeof provider.apiKey === "string" && provider.apiKey.trim().length > 0;
188
+ if ((provider.authMode === "key" || provider.authMode === "oauth") && !provider.keyOptional && !hasCredential) {
189
+ throw new Error(`${provider.adapter} requires a non-empty credential (authMode: ${provider.authMode})`);
190
+ }
191
+
190
192
  const messages = messagesToChatFormat(parsed, provider);
191
193
  const tools = toolsToChatFormat(parsed);
192
194
  const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools);
193
195
 
194
196
  const body: Record<string, unknown> = {
195
- model: stripBracketedModelSuffix(parsed.modelId),
197
+ model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(parsed.modelId) : parsed.modelId,
196
198
  messages,
197
199
  stream: parsed.stream,
198
200
  };
@@ -247,7 +249,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
247
249
 
248
250
  const url = `${provider.baseUrl}/chat/completions`;
249
251
  const headers: Record<string, string> = { "Content-Type": "application/json" };
250
- if (provider.apiKey) headers["Authorization"] = `Bearer ${provider.apiKey}`;
252
+ if (hasCredential) headers["Authorization"] = `Bearer ${provider.apiKey}`;
251
253
  if (provider.headers) Object.assign(headers, provider.headers);
252
254
 
253
255
  return { url, method: "POST", headers, body: JSON.stringify(body) };
@@ -306,8 +308,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
306
308
  try {
307
309
  chunk = JSON.parse(payload) as Record<string, unknown>;
308
310
  } catch {
309
- debugDroppedFrame("openai-chat", payload);
310
- return "continue";
311
+ yield { type: "error", message: "malformed upstream SSE data frame" };
312
+ return "terminate";
311
313
  }
312
314
 
313
315
  // A 200/OK chat-completions stream may carry an inline provider error envelope
@@ -416,25 +418,33 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
416
418
 
417
419
  async parseResponse(response: Response): Promise<AdapterEvent[]> {
418
420
  const json = await response.json() as Record<string, unknown>;
421
+ if (json.error) {
422
+ const upstreamError = json.error as { message?: unknown };
423
+ return [{
424
+ type: "error",
425
+ message: typeof upstreamError.message === "string" ? upstreamError.message : "upstream error",
426
+ }];
427
+ }
428
+
419
429
  const events: AdapterEvent[] = [];
420
430
  const choices = json.choices as { message?: Record<string, unknown> }[] | undefined;
421
- if (choices && choices.length > 0) {
422
- const msg = choices[0].message;
423
- if (msg) {
424
- if (typeof msg.content === "string") {
425
- events.push({ type: "text_delta", text: msg.content });
426
- }
427
- if (typeof msg.reasoning_content === "string" && msg.reasoning_content.length > 0) {
428
- events.push({ type: "reasoning_raw_delta", text: msg.reasoning_content });
429
- }
430
- const toolCalls = msg.tool_calls as { id: string; function: { name: string; arguments: string } }[] | undefined;
431
- if (toolCalls) {
432
- for (const tc of toolCalls) {
433
- events.push({ type: "tool_call_start", id: tc.id, name: tc.function.name });
434
- events.push({ type: "tool_call_delta", arguments: tc.function.arguments });
435
- events.push({ type: "tool_call_end" });
436
- }
437
- }
431
+ if (!Array.isArray(choices) || choices.length === 0 || !choices[0].message) {
432
+ return [{ type: "error", message: "upstream response contained no choices" }];
433
+ }
434
+
435
+ const msg = choices[0].message;
436
+ if (typeof msg.content === "string") {
437
+ events.push({ type: "text_delta", text: msg.content });
438
+ }
439
+ if (typeof msg.reasoning_content === "string" && msg.reasoning_content.length > 0) {
440
+ events.push({ type: "reasoning_raw_delta", text: msg.reasoning_content });
441
+ }
442
+ const toolCalls = msg.tool_calls as { id: string; function: { name: string; arguments: string } }[] | undefined;
443
+ if (toolCalls) {
444
+ for (const tc of toolCalls) {
445
+ events.push({ type: "tool_call_start", id: tc.id, name: tc.function.name });
446
+ events.push({ type: "tool_call_delta", arguments: tc.function.arguments });
447
+ events.push({ type: "tool_call_end" });
438
448
  }
439
449
  }
440
450
  const usage = json.usage as Record<string, unknown> | undefined;