agent-accelerator 0.3.0 → 0.4.5

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.
@@ -10,7 +10,7 @@
10
10
  * `references/testings/openrouter/CAPABILITY-MATRIX.md`.
11
11
  *
12
12
  * This is the STABLE transport. The beta Responses skin
13
- * (`src/providers/openrouter-responses.ts`) is discontinued: no video shape,
13
+ * was removed from core: no video shape,
14
14
  * lossy error codes, beta event-vocabulary drift.
15
15
  *
16
16
  * Notes:
@@ -40,13 +40,26 @@ import { AssistantMessageEventStream } from "../streaming/event-stream.ts";
40
40
  import { SSEParser } from "../streaming/sse-parser.ts";
41
41
  import { AgentResponse } from "../types/response.ts";
42
42
  import { getApiKey, getEnv } from "../utils/env.ts";
43
- import { buildSessionHeaders } from "../utils/headers.ts";
43
+ import { buildSessionHeaders, setHeaderCaseInsensitive } from "../utils/headers.ts";
44
44
  import { normalizeMediaInput } from "../utils/media.ts";
45
45
  import { safeStringify } from "../utils/serialization.ts";
46
46
  import { toConciseProviderError, assertModalitiesSupported } from "../utils/errors.ts";
47
47
  import { withRetries } from "../utils/retry.ts";
48
+ import {
49
+ parseArguments,
50
+ normalizeThinkingParts,
51
+ newToolCallId,
52
+ redactedHeaders,
53
+ readErrorPayload,
54
+ incompleteStreamError,
55
+ hasTruncatedToolArguments,
56
+ combinedSignal,
57
+ timeoutFor,
58
+ hasAuthHeader,
59
+ clampToolCallId,
60
+ } from "./shared.ts";
48
61
  import { createGenericModelSpec } from "../models/catalog.ts";
49
- import { getModelFromCatalog, getModelsForProvider } from "../models/catalog.ts";
62
+ import { getModelFromCatalog, getModelsForProvider, modelSupportsStructuredOutput } from "../models/catalog.ts";
50
63
  import {
51
64
  mapThinkingLevelToOpenRouterChat,
52
65
  mapServiceTierToOpenRouter,
@@ -54,6 +67,10 @@ import {
54
67
  mapToolChoiceToOpenRouterChat,
55
68
  noteProviderTurn,
56
69
  parseStreamedToolArguments,
70
+ mapOutputToOpenRouterChat,
71
+ normalizeStructuredOutput,
72
+ augmentSystemPromptWithStructuredOutput,
73
+ emitProviderWarning,
57
74
  } from "../providers.ts";
58
75
 
59
76
  // ---------------------------------------------------------------------------
@@ -92,6 +109,7 @@ interface ChatRequestBody {
92
109
  service_tier?: string;
93
110
  session_id?: string;
94
111
  plugins?: Array<{ id: string }>;
112
+ response_format?: { type: "json_schema"; json_schema: { name: string; schema: Record<string, unknown>; strict: boolean; description?: string } };
95
113
  stream?: boolean;
96
114
  [k: string]: unknown;
97
115
  }
@@ -156,6 +174,12 @@ interface ChatResponse {
156
174
 
157
175
  const DEFAULT_BASE_URL = "https://openrouter.ai/api/v1";
158
176
 
177
+ /** Q-18: Chat transport never echoes thought signatures (no wire field).
178
+ * Local helper documents the namespacing decision per provider file. */
179
+ function withProviderSignature(_sig: string | undefined): { thoughtSignature?: undefined } {
180
+ return {};
181
+ }
182
+
159
183
  /** Internal headers that must never leak onto native REST requests. */
160
184
  const INTERNAL_HEADERS = new Set([
161
185
  "x-thought-signature-map",
@@ -177,7 +201,10 @@ function resolveBaseUrl(options?: ProviderRequestOptions): string {
177
201
  }
178
202
 
179
203
  function resolveApiKey(options?: ProviderRequestOptions): string | undefined {
180
- return options?.apiKey || getApiKey("openrouter", undefined, options?.env);
204
+ const key = getApiKey("openrouter", options?.apiKey, options?.env);
205
+ if (key) return key;
206
+ if (hasAuthHeader(options?.headers)) return "unused";
207
+ return undefined;
181
208
  }
182
209
 
183
210
  /**
@@ -336,11 +363,17 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
336
363
  messages.push({ role: "system", content: context.systemPrompt });
337
364
  }
338
365
  let hasFiles = false;
366
+ let lastRole: string | null = "system";
339
367
  for (const m of context.messages) {
340
368
  if (m.role === "system") continue;
341
369
  if (m.role === "user") {
370
+ // Strict routes reject `user` directly after `tool`: bridge with a
371
+ // synthetic assistant turn (pi api/openai-completions.ts:1233).
372
+ if (lastRole === "tool") {
373
+ messages.push({ role: "assistant", content: "I have processed the tool results." });
374
+ }
342
375
  if (typeof m.content === "string") {
343
- if (m.content) messages.push({ role: "user", content: m.content });
376
+ if (m.content) { messages.push({ role: "user", content: m.content }); lastRole = "user"; }
344
377
  } else {
345
378
  const { blocks, fileUrls, hasFiles: hf } = await contentPartsToBlocks(m.content);
346
379
  if (hf) hasFiles = true;
@@ -348,7 +381,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
348
381
  for (const url of fileUrls) {
349
382
  blocks.push({ type: "text", text: url });
350
383
  }
351
- if (blocks.length > 0) messages.push({ role: "user", content: blocks });
384
+ if (blocks.length > 0) { messages.push({ role: "user", content: blocks }); lastRole = "user"; }
352
385
  }
353
386
  } else if (m.role === "assistant") {
354
387
  if (typeof m.content === "string") {
@@ -362,7 +395,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
362
395
  for (const part of m.content) {
363
396
  if (part.type === "tool_call") {
364
397
  calls.push({
365
- id: part.callId || part.id,
398
+ id: clampToolCallId(part.callId || part.id),
366
399
  type: "function",
367
400
  function: { name: part.name, arguments: JSON.stringify(part.arguments || {}) },
368
401
  });
@@ -372,20 +405,25 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
372
405
  // Prior-turn reasoning is NOT resent: chat history is messages +
373
406
  // tool calls only (documented decision; avoids strict-route 400s).
374
407
  }
408
+ // Skip empty assistant turns (no text + no tool calls): strict
409
+ // routes reject them, and they carry no signal (pi drop rule).
410
+ if (texts.length === 0 && calls.length === 0) continue;
375
411
  messages.push({
376
412
  role: "assistant",
377
413
  content: texts.length > 0 ? texts.join("\n") : null,
378
414
  ...(calls.length > 0 ? { tool_calls: calls } : {}),
379
415
  });
416
+ lastRole = "assistant";
380
417
  } else if (m.role === "tool") {
381
418
  if (typeof m.content === "string") {
382
419
  const queue = (m.name && idsByName.get(m.name)) || [];
383
420
  messages.push({
384
421
  role: "tool",
385
- tool_call_id: queue.shift() || "call_0",
422
+ tool_call_id: clampToolCallId(queue.shift() || newToolCallId()),
386
423
  content: m.content,
387
424
  name: m.name,
388
425
  });
426
+ lastRole = "tool";
389
427
  continue;
390
428
  }
391
429
  if (!Array.isArray(m.content)) continue;
@@ -393,10 +431,11 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
393
431
  if (part.type === "tool_result") {
394
432
  messages.push({
395
433
  role: "tool",
396
- tool_call_id: pairing.get(part.id) || part.id,
434
+ tool_call_id: clampToolCallId(pairing.get(part.id) || part.id),
397
435
  content: resultToOutput(part.result),
398
436
  name: part.name,
399
437
  });
438
+ lastRole = "tool";
400
439
  }
401
440
  }
402
441
  }
@@ -429,27 +468,6 @@ function mapUsage(raw?: ChatResponse["usage"]): TokenUsage {
429
468
  return usage;
430
469
  }
431
470
 
432
- function parseArguments(raw: string | undefined): Record<string, unknown> {
433
- if (!raw) return {};
434
- try {
435
- const parsed: unknown = JSON.parse(raw);
436
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
437
- return parsed as Record<string, unknown>;
438
- }
439
- return { raw };
440
- } catch {
441
- return { raw };
442
- }
443
- }
444
-
445
- /** Trims/collapses assembled thinking parts (no edge-tripling). */
446
- function normalizeThinkingParts(parts: string[]): string | undefined {
447
- const cleaned = parts
448
- .map((p) => p.replace(/\n{3,}/g, "\n\n").trim())
449
- .filter((p) => p.length > 0);
450
- return cleaned.length > 0 ? cleaned.join("\n") : undefined;
451
- }
452
-
453
471
  function throwResponseError(response: ChatResponse, modelId: string): void {
454
472
  const message =
455
473
  response.error && typeof response.error.message === "string" && response.error.message
@@ -476,7 +494,9 @@ function parseResponse(
476
494
 
477
495
  const choice = response.choices?.[0];
478
496
  const message = choice?.message;
479
- const text = typeof message?.content === "string" ? message.content : "";
497
+ // Q-23: refusal content surfaces as text with a `refusal` finish reason.
498
+ const refusal = typeof message?.refusal === "string" && message.refusal ? message.refusal : "";
499
+ const text = typeof message?.content === "string" && message.content ? message.content : refusal;
480
500
  // Thinking: `reasoning_details[].text` duplicates `message.reasoning` when
481
501
  // both ride along (observed live) — prefer details, else the plain field.
482
502
  const thinkingParts: string[] = [];
@@ -493,7 +513,7 @@ function parseResponse(
493
513
  for (const tc of message?.tool_calls ?? []) {
494
514
  const args = parseArguments(tc.function?.arguments);
495
515
  toolCalls.push({
496
- id: tc.id || `call_${Math.random().toString(36).slice(2, 9)}`,
516
+ id: tc.id && !tc.id.includes("|") && tc.id.length <= 40 ? tc.id : clampToolCallId(tc.id || newToolCallId()),
497
517
  name: tc.function?.name || "unknown",
498
518
  arguments: args,
499
519
  rawArguments:
@@ -504,15 +524,17 @@ function parseResponse(
504
524
  }
505
525
 
506
526
  // Normalized finish reasons: tool_calls|stop|length|content_filter|error.
527
+ // Q-23: refusal content forces `refusal` (even when the wire says stop).
507
528
  const wireReason = choice?.finish_reason ?? undefined;
529
+ const finishReason =
530
+ toolCalls.length > 0 ? "tool_calls" : refusal ? "refusal" : wireReason || "stop";
508
531
  return {
509
532
  text,
510
533
  thinking: normalizeThinkingParts(thinkingParts),
511
534
  thoughtSignature: undefined,
512
535
  toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
513
536
  usage: mapUsage(response.usage),
514
- finishReason:
515
- toolCalls.length > 0 ? "tool_calls" : wireReason || "stop",
537
+ finishReason,
516
538
  responseId: response.id,
517
539
  model: modelId,
518
540
  provider: "openrouter",
@@ -521,33 +543,6 @@ function parseResponse(
521
543
  };
522
544
  }
523
545
 
524
- function redactedHeaders(headers: Record<string, string>): Record<string, string> {
525
- const out: Record<string, string> = {};
526
- for (const [k, v] of Object.entries(headers)) {
527
- out[k] = k.toLowerCase() === "authorization" ? "[REDACTED]" : v;
528
- }
529
- return out;
530
- }
531
-
532
- function readErrorPayload(bodyText: string): { message: string; code?: string | number; errorType?: string } {
533
- try {
534
- const parsed: unknown = JSON.parse(bodyText);
535
- const first = Array.isArray(parsed) ? parsed[0] : parsed;
536
- const err = (first as { error?: { message?: string; code?: string | number; metadata?: { error_type?: string } } })?.error;
537
- if (err && typeof err.message === "string") {
538
- const errorType = (err as { metadata?: { error_type?: string } })?.metadata?.error_type;
539
- return {
540
- message: err.message,
541
- code: err.code,
542
- ...(typeof errorType === "string" ? { errorType } : {}),
543
- };
544
- }
545
- return { message: bodyText.slice(0, 300) };
546
- } catch {
547
- return { message: bodyText.slice(0, 300) };
548
- }
549
- }
550
-
551
546
  // ---------------------------------------------------------------------------
552
547
  // Provider
553
548
  // ---------------------------------------------------------------------------
@@ -587,7 +582,33 @@ export class OpenRouterChatCompletionsProvider implements Provider {
587
582
  const serviceTier = mapServiceTierToOpenRouter(options?.serviceTier);
588
583
  applyCacheForOpenRouter(options?.cache, `openrouter/${modelId}`);
589
584
 
590
- const { messages, hasFiles } = await fullHistoryMessages(context);
585
+ // Structured outputs: native `response_format` when supported, else
586
+ // emulated via system-prompt instruction + client validation (catalog
587
+ // `structured_output: false` models would 400 on the wire shape).
588
+ let effectiveContext = context;
589
+ let outputEmulated = false;
590
+ let outputSpec: import("../types/model.ts").StructuredOutputSpec | undefined;
591
+ try {
592
+ outputSpec = normalizeStructuredOutput(options?.output ?? (options as { outputSchema?: unknown })?.outputSchema);
593
+ } catch {
594
+ // why: invalid output shapes fail fast at Agent construction/loop validation.
595
+ outputSpec = undefined;
596
+ }
597
+ if (outputSpec && !modelSupportsStructuredOutput("openrouter", modelId)) {
598
+ outputEmulated = true;
599
+ effectiveContext = {
600
+ ...context,
601
+ systemPrompt: augmentSystemPromptWithStructuredOutput(context.systemPrompt, outputSpec),
602
+ };
603
+ emitProviderWarning({
604
+ provider: "openrouter",
605
+ capability: "structured output",
606
+ requested: `${outputSpec.name} (openrouter/${modelId})`,
607
+ reason: "the catalog reports structured_output: false for this model, so native response_format is unsupported.",
608
+ fallback: "emulated JSON instruction + client validation (no response_format is sent)",
609
+ });
610
+ }
611
+ const { messages, hasFiles } = await fullHistoryMessages(effectiveContext);
591
612
  const body: ChatRequestBody = {
592
613
  model: modelId,
593
614
  messages,
@@ -601,12 +622,23 @@ export class OpenRouterChatCompletionsProvider implements Provider {
601
622
  if (toolChoice !== undefined) body.tool_choice = toolChoice;
602
623
  if (effort) body.reasoning = { effort };
603
624
  if (serviceTier) body.service_tier = serviceTier;
625
+ // Structured outputs: Chat Completions `response_format{type:json_schema,json_schema}`.
626
+ // Skipped when emulated (unsupported model): instruction already in messages.
627
+ if (outputSpec && !outputEmulated) {
628
+ try {
629
+ const mapped = mapOutputToOpenRouterChat(outputSpec);
630
+ if (mapped) body.response_format = mapped.response_format;
631
+ } catch {
632
+ // why: invalid output shapes fail fast at Agent construction/loop validation.
633
+ }
634
+ }
604
635
  // PDF ingestion is a documented plugin, not a message shape: enable it
605
636
  // only when file parts are present (never by default).
606
637
  if (hasFiles) body.plugins = [{ id: "file-parser" }];
607
- // No temperature/top_p/max_tokens/stop/response_format/user/seed knobs
638
+ // No temperature/top_p/max_tokens/stop/user/seed knobs
608
639
  // (no canonical options exist; omitting also keeps provider cache keys
609
- // stable). No store/previous_response_id/conversation: they do not exist
640
+ // stable). `response_format` above is the sole structured-output exception.
641
+ // No store/previous_response_id/conversation: they do not exist
610
642
  // on this endpoint.
611
643
  // Session affinity: top-level `session_id` is the documented sticky-routing
612
644
  // key on Chat Completions (prompt-caching guide) and takes precedence over
@@ -635,8 +667,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
635
667
  for (const [k, v] of Object.entries(headers)) {
636
668
  if (INTERNAL_HEADERS.has(k.toLowerCase())) delete headers[k];
637
669
  }
638
- headers["Content-Type"] = "application/json";
639
- headers["Authorization"] = `Bearer ${apiKey}`;
670
+ setHeaderCaseInsensitive(headers, "Content-Type", "application/json");
671
+ if (apiKey !== "unused") setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
640
672
  return { method: "POST", headers, body: JSON.stringify(body), signal };
641
673
  }
642
674
 
@@ -648,13 +680,18 @@ export class OpenRouterChatCompletionsProvider implements Provider {
648
680
  sessionId: string | undefined,
649
681
  signal?: AbortSignal
650
682
  ): Promise<{ status: number; statusText: string; headers: Record<string, string>; text: string }> {
651
- const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, signal));
652
- const text = await res.text();
653
- const headers: Record<string, string> = {};
654
- res.headers.forEach((v, k) => {
655
- headers[k] = v;
656
- });
657
- return { status: res.status, statusText: res.statusText, headers, text };
683
+ const { signal: effective, cleanup } = combinedSignal(signal, timeoutFor(options as { timeoutMs?: number } | undefined));
684
+ try {
685
+ const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, effective));
686
+ const text = await res.text();
687
+ const headers: Record<string, string> = {};
688
+ res.headers.forEach((v, k) => {
689
+ headers[k] = v;
690
+ });
691
+ return { status: res.status, statusText: res.statusText, headers, text };
692
+ } finally {
693
+ cleanup();
694
+ }
658
695
  }
659
696
 
660
697
  private throwIfError(
@@ -664,14 +701,21 @@ export class OpenRouterChatCompletionsProvider implements Provider {
664
701
  modelId: string
665
702
  ): void {
666
703
  if (status >= 200 && status < 300) return;
667
- const { message, code, errorType } = readErrorPayload(bodyText);
668
- const err: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
704
+ const { message, code, errorType, raw } = readErrorPayload(bodyText);
705
+ const hint =
706
+ status === 401
707
+ ? " Check OPENROUTER_API_KEY (no quotes/spaces, correct provider) and that the key is loaded (bun --env-file=.env.local)."
708
+ : "";
709
+ const fullMessage = `${message}${hint}`;
710
+ const err: Record<string, unknown> & Error = new Error(fullMessage) as Record<string, unknown> & Error;
669
711
  (err as Record<string, unknown>)["statusCode"] = status;
670
712
  (err as Record<string, unknown>)["status"] = status;
671
713
  (err as Record<string, unknown>)["responseBody"] = bodyText.slice(0, 500);
672
714
  (err as Record<string, unknown>)["url"] = url.split("?")[0];
715
+ (err as Record<string, unknown>)["data"] = { error: { message: fullMessage } };
673
716
  if (code !== undefined) (err as Record<string, unknown>)["code"] = code;
674
717
  if (errorType) (err as Record<string, unknown>)["errorType"] = errorType;
718
+ if (raw) (err as Record<string, unknown>)["raw"] = raw;
675
719
  throw toConciseProviderError(err, "openrouter", modelId);
676
720
  }
677
721
 
@@ -814,7 +858,21 @@ export class OpenRouterChatCompletionsProvider implements Provider {
814
858
  body,
815
859
  };
816
860
 
817
- const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
861
+ const streamTimeoutMs = timeoutFor(options as { timeoutMs?: number } | undefined);
862
+ const streamTimeoutTimer =
863
+ streamTimeoutMs && streamTimeoutMs > 0
864
+ ? setTimeout(() => {
865
+ try {
866
+ linked.abort();
867
+ } catch {}
868
+ }, streamTimeoutMs)
869
+ : undefined;
870
+ let res: Response;
871
+ try {
872
+ res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
873
+ } finally {
874
+ if (streamTimeoutTimer) clearTimeout(streamTimeoutTimer);
875
+ }
818
876
  if (!res.ok || !res.body) {
819
877
  const text = !res.ok ? await res.text().catch(() => "") : "";
820
878
  if (!res.ok) this.throwIfError(res.status, url, text, clean);
@@ -824,7 +882,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
824
882
  res.headers.forEach((v, k) => {
825
883
  responseHeaders[k] = v;
826
884
  });
827
- const responseMeta = { status: res.status, statusText: res.statusText, headers: responseHeaders };
885
+ const responseMeta: Record<string, unknown> & { status: number; statusText: string; headers: Record<string, string> } = { status: res.status, statusText: res.statusText, headers: responseHeaders };
828
886
  eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
829
887
 
830
888
  const parser = new SSEParser();
@@ -839,6 +897,11 @@ export class OpenRouterChatCompletionsProvider implements Provider {
839
897
  let lastToolIndex = 0;
840
898
  let usage: TokenUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
841
899
  let finishReason = "stop";
900
+ let sawFinishReason = false;
901
+ let sawUsage = false;
902
+ let refusalSeen = false;
903
+ let badFrames = 0;
904
+ let framesSeen = 0;
842
905
  let responseId: string | undefined;
843
906
  let completedBody: unknown = undefined;
844
907
  let aborted = false;
@@ -854,11 +917,17 @@ export class OpenRouterChatCompletionsProvider implements Provider {
854
917
  );
855
918
 
856
919
  const handleMessage = (data: string): void => {
857
- if (!data || data === "[DONE]") return;
920
+ if (!data) return;
921
+ if (data === "[DONE]") {
922
+ sawFinishReason = sawFinishReason || completedBody !== undefined || sawUsage;
923
+ return;
924
+ }
858
925
  let msg: Record<string, unknown>;
859
926
  try {
860
927
  msg = JSON.parse(data) as Record<string, unknown>;
928
+ framesSeen += 1;
861
929
  } catch {
930
+ badFrames += 1;
862
931
  return;
863
932
  }
864
933
  // Mid-stream provider error: top-level `error`, HTTP stays 200.
@@ -881,6 +950,13 @@ export class OpenRouterChatCompletionsProvider implements Provider {
881
950
  const choice = (Array.isArray(msg["choices"]) ? (msg["choices"] as Record<string, unknown>[])[0] : undefined) ?? {};
882
951
  const delta = (choice["delta"] as Record<string, unknown>) ?? {};
883
952
  // Text delta (content-free accounting frames carry "" — ignored).
953
+ // Q-23: refusal deltas surface as text with a `refusal` finish.
954
+ const deltaRefusal = delta["refusal"];
955
+ if (typeof deltaRefusal === "string" && deltaRefusal) {
956
+ text += deltaRefusal;
957
+ refusalSeen = true;
958
+ eventStream.push({ type: "text_delta", delta: deltaRefusal, partialText: text });
959
+ }
884
960
  if (typeof delta["content"] === "string" && delta["content"]) {
885
961
  text += delta["content"] as string;
886
962
  eventStream.push({ type: "text_delta", delta: delta["content"] as string, partialText: text });
@@ -950,10 +1026,12 @@ export class OpenRouterChatCompletionsProvider implements Provider {
950
1026
  throw toConciseProviderError(failure, "openrouter", clean);
951
1027
  }
952
1028
  finishReason = fr;
1029
+ sawFinishReason = true;
953
1030
  }
954
1031
  const chunkUsage = msg["usage"] as ChatResponse["usage"] | undefined;
955
1032
  if (chunkUsage) {
956
1033
  usage = mapUsage(chunkUsage);
1034
+ sawUsage = true;
957
1035
  completedBody = completedBody ?? msg;
958
1036
  eventStream.push({ type: "usage", usage });
959
1037
  }
@@ -984,7 +1062,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
984
1062
  for (const c of calls.values()) {
985
1063
  const args = parseStreamedToolArguments(c.startArgs, c.deltaArgs);
986
1064
  const record: ToolCallRecord = {
987
- id: c.id || `call_${Math.random().toString(36).slice(2, 9)}`,
1065
+ id: c.id && !c.id.includes("|") && c.id.length <= 40 ? c.id : clampToolCallId(c.id || newToolCallId()),
988
1066
  name: c.name,
989
1067
  arguments: args,
990
1068
  rawArguments: c.startArgs + c.deltaArgs,
@@ -994,9 +1072,37 @@ export class OpenRouterChatCompletionsProvider implements Provider {
994
1072
  }
995
1073
 
996
1074
  noteProviderTurn(sessionId, "openrouter");
997
- if (toolCalls.length > 0) finishReason = "tool_calls";
1075
+ if (toolCalls.length > 0) {
1076
+ finishReason = "tool_calls";
1077
+ sawFinishReason = true;
1078
+ } else if (refusalSeen && finishReason !== "content_filter") {
1079
+ finishReason = "refusal";
1080
+ sawFinishReason = true;
1081
+ }
998
1082
 
999
- const cleanThinking = thinking.replace(/\n{3,}/g, "\n\n").trim();
1083
+ // Q-23: terminal-state guard — fail `incomplete_stream` (retryable)
1084
+ // instead of resolving empty success. Skipped for non-SSE payloads
1085
+ // (JSON mocks) where no SSE frames were ever seen.
1086
+ const hasTerminal = completedBody !== undefined || sawFinishReason || sawUsage;
1087
+ const cleanThinkingEarly = thinking.replace(/\n{3,}/g, "\n\n").trim();
1088
+ const ctEntry = Object.entries(responseHeaders).find(([k]) => k.toLowerCase() === "content-type");
1089
+ const isSSEPayload =
1090
+ (typeof ctEntry?.[1] === "string" && ((ctEntry[1] as string).includes("event-stream") || (ctEntry[1] as string).startsWith("text/"))) ||
1091
+ framesSeen > 0 ||
1092
+ badFrames > 0;
1093
+ if (!hasTerminal && !text && !cleanThinkingEarly && toolCalls.length === 0 && isSSEPayload) {
1094
+ eventStream.fail(incompleteStreamError("openrouter", clean));
1095
+ return;
1096
+ }
1097
+ // Refuse unfinished tool calls whose args never parsed (output_item.done
1098
+ // never arrived): truncated `{raw}` must not reach the executor (pi
1099
+ // api/openai-responses-shared.ts:766 rule). Non-SSE mocks preserve legacy.
1100
+ if (!hasTerminal && toolCalls.some((c) => hasTruncatedToolArguments(c.arguments)) && isSSEPayload) {
1101
+ eventStream.fail(incompleteStreamError("openrouter", clean));
1102
+ return;
1103
+ }
1104
+
1105
+ const cleanThinking = cleanThinkingEarly;
1000
1106
  const finalResponse = new AgentResponse({
1001
1107
  text,
1002
1108
  thinking: cleanThinking || undefined,
@@ -1007,7 +1113,10 @@ export class OpenRouterChatCompletionsProvider implements Provider {
1007
1113
  responseId,
1008
1114
  model: clean,
1009
1115
  provider: "openrouter",
1010
- raw: { request: rawRequest, response: { ...responseMeta, body: completedBody } },
1116
+ raw: {
1117
+ request: rawRequest,
1118
+ response: { ...responseMeta, body: completedBody, badFrames } as unknown as ProviderRawData["response"],
1119
+ },
1011
1120
  durationMs: Date.now() - startTime,
1012
1121
  });
1013
1122
  eventStream.push({ type: "done", delta: "", usage, finishReason, responseId });
@@ -80,12 +80,25 @@ export function ensureCustomProvider(
80
80
  ): Provider {
81
81
  assertProviderNotRemoved(prefix);
82
82
  const id = normalizeProviderPrefix(prefix) as ProviderId;
83
+ if (FIRST_CLASS.has(id.toLowerCase())) {
84
+ if (opts?.baseUrl !== undefined || opts?.apiKey !== undefined) {
85
+ throw new Error(
86
+ `[Agent Accelerator] Provider "${id}" is first-class and cannot be replaced with custom baseUrl/apiKey. Pass baseUrl/apiKey per-Agent instead.`
87
+ );
88
+ }
89
+ return providerRegistry.get(id)!;
90
+ }
83
91
  const existing = providerRegistry.get(id);
84
- if (existing && !FIRST_CLASS.has(id.toLowerCase())) return existing;
85
- if (existing && opts?.baseUrl === undefined && opts?.apiKey === undefined) {
92
+ if (existing) {
93
+ const prevBase = (existing as { configuredBaseUrl?: string }).configuredBaseUrl;
94
+ if (opts?.baseUrl !== undefined && prevBase !== undefined && prevBase !== opts.baseUrl) {
95
+ throw new Error(
96
+ `[Agent Accelerator] Conflicting re-registration for provider "${id}". Registry is immutable after creation.`
97
+ );
98
+ }
86
99
  return existing;
87
100
  }
88
- const created = new OpenAICompatibleChatProvider(id, opts);
101
+ const created = new OpenAICompatibleChatProvider(id, { baseUrl: opts?.baseUrl, name: opts?.name });
89
102
  providerRegistry.set(id, created);
90
103
  return created;
91
104
  }
@@ -199,11 +212,6 @@ export function resolveModel(model: string | ModelSpec): ResolvedModel {
199
212
  parts.length === 2 && modelStr.includes(":") && !hasDedicatedEnv;
200
213
 
201
214
  if (!looksLikeOpenRouterScope) {
202
- if (hasCustomOpenAIBase && !hasDedicatedEnv) {
203
- const p = getProvider("openai");
204
- const spec = p.getModel(remainingModel) || getModelFromCatalog(remainingModel, remainingModel);
205
- return { provider: p, modelId: remainingModel, modelSpec: spec as ModelSpec };
206
- }
207
215
  const custom = ensureCustomProvider(providerPrefix);
208
216
  const spec =
209
217
  custom.getModel(remainingModel) ||
@@ -372,10 +380,6 @@ export const ModelProvider = {
372
380
  options?: { thinkingLevel?: ThinkingLevel; baseUrl?: string }
373
381
  ): ModelProviderInstance {
374
382
  const normalized = model.includes("/") ? model : `custom/${model}`;
375
- const prefix = normalized.split("/")[0]!;
376
- try {
377
- ensureCustomProvider(prefix, { baseUrl: options?.baseUrl, apiKey });
378
- } catch {}
379
383
  return {
380
384
  model: normalized,
381
385
  apiKey,