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.
@@ -55,18 +55,34 @@ import { AssistantMessageEventStream } from "../streaming/event-stream.ts";
55
55
  import { SSEParser } from "../streaming/sse-parser.ts";
56
56
  import { AgentResponse } from "../types/response.ts";
57
57
  import { getApiKey, getEnv } from "../utils/env.ts";
58
- import { buildSessionHeaders } from "../utils/headers.ts";
58
+ import { buildSessionHeaders, setHeaderCaseInsensitive, shouldEchoSignature } from "../utils/headers.ts";
59
59
  import { normalizeMediaInput } from "../utils/media.ts";
60
60
  import { safeStringify } from "../utils/serialization.ts";
61
61
  import { toConciseProviderError, assertModalitiesSupported } from "../utils/errors.ts";
62
62
  import { withRetries } from "../utils/retry.ts";
63
- import { getModelFromCatalog, createGenericModelSpec } from "../models/catalog.ts";
63
+ import {
64
+ parseArguments,
65
+ normalizeThinkingParts,
66
+ newToolCallId,
67
+ redactedHeaders,
68
+ readErrorPayload,
69
+ incompleteStreamError,
70
+ hasTruncatedToolArguments,
71
+ combinedSignal,
72
+ timeoutFor,
73
+ hasAuthHeader,
74
+ clampToolCallId,
75
+ } from "./shared.ts";
76
+ import { getModelFromCatalog, createGenericModelSpec, modelSupportsStructuredOutput } from "../models/catalog.ts";
64
77
  import {
65
78
  applyCacheForCustom,
66
79
  mapToolChoiceToOpenAI,
67
80
  emitProviderWarning,
68
81
  noteProviderTurn,
69
82
  parseStreamedToolArguments,
83
+ mapOutputToCompatChat,
84
+ normalizeStructuredOutput,
85
+ augmentSystemPromptWithStructuredOutput,
70
86
  } from "../providers.ts";
71
87
 
72
88
  // ---------------------------------------------------------------------------
@@ -100,6 +116,7 @@ interface ChatRequestBody {
100
116
  messages: ChatMessage[];
101
117
  tools?: Array<Record<string, unknown>>;
102
118
  tool_choice?: string | { type: "function"; function: { name: string } };
119
+ response_format?: { type: "json_schema"; json_schema: { name: string; schema: Record<string, unknown>; strict: boolean; description?: string } };
103
120
  stream?: boolean;
104
121
  [k: string]: unknown;
105
122
  }
@@ -189,8 +206,6 @@ function resolveBaseUrl(
189
206
  getEnv(`${envPrefix}_BASE_URL`) ||
190
207
  getEnv(`${envPrefix}_BASEURL`) ||
191
208
  getEnv(`${envPrefix}_API_BASE`) ||
192
- getEnv("OPENAI_BASE_URL") ||
193
- getEnv("OPENAI_API_BASE") ||
194
209
  DEFAULT_BASE_URL
195
210
  ).replace(/\/+$/, "");
196
211
  }
@@ -200,38 +215,48 @@ function resolveApiKey(
200
215
  configuredApiKey: string | undefined,
201
216
  options?: ProviderRequestOptions
202
217
  ): string | undefined {
218
+ const clean = (v: unknown): string | undefined => {
219
+ if (typeof v !== "string") return undefined;
220
+ const t = v.trim().replace(/^["']|["']$/g, "").trim();
221
+ return t || undefined;
222
+ };
203
223
  const envPrefix = envPrefixOf(prefix);
204
- return (
205
- options?.apiKey ||
206
- configuredApiKey ||
207
- options?.env?.[`${envPrefix}_API_KEY`] ||
208
- options?.env?.[`${envPrefix}_BASE_API_KEY`] ||
209
- options?.env?.["OPENAI_BASE_API_KEY"] ||
210
- options?.env?.["OPENAI_API_KEY"] ||
211
- getEnv(`${envPrefix}_API_KEY`) ||
212
- getEnv(`${envPrefix}_BASE_API_KEY`) ||
213
- getEnv("OPENAI_BASE_API_KEY") ||
214
- getEnv("OPENAI_API_KEY") ||
224
+ const key = (
225
+ clean(options?.apiKey) ||
226
+ clean(configuredApiKey) ||
227
+ clean(options?.env?.[`${envPrefix}_API_KEY`]) ||
228
+ clean(options?.env?.[`${envPrefix}_BASE_API_KEY`]) ||
215
229
  getApiKey(prefix, undefined, options?.env) ||
216
230
  undefined
217
231
  );
232
+ if (key) return key;
233
+ if (hasAuthHeader(options?.headers)) return "unused";
234
+ return undefined;
218
235
  }
219
236
 
220
- /** Local endpoints (loopback / LAN / *.local) may omit the API key. */
237
+ /** Local endpoints (loopback / LAN / *.local) may omit the API key. Literal IPs only; hostnames like 10.evil.com are not local. */
221
238
  function isLocalEndpoint(baseUrl: string): boolean {
222
239
  try {
223
- const host = new URL(baseUrl).hostname.toLowerCase();
240
+ let host = new URL(baseUrl).hostname.toLowerCase();
241
+ if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
224
242
  if (
225
243
  host === "localhost" ||
226
244
  host === "127.0.0.1" ||
227
245
  host === "0.0.0.0" ||
228
246
  host === "::1" ||
247
+ host === "0:0:0:0:0:0:0:1" ||
229
248
  host.endsWith(".local")
230
249
  ) {
231
250
  return true;
232
251
  }
233
- if (/^10\./.test(host) || /^192\.168\./.test(host) || /^172\.(1[6-9]|2\d|3[01])\./.test(host)) {
234
- return true;
252
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) {
253
+ const parts = host.split(".").map(Number);
254
+ const [a, b] = parts as number[];
255
+ if (a === 10) return true;
256
+ if (a === 172 && b !== undefined && b >= 16 && b <= 31) return true;
257
+ if (a === 192 && b === 168) return true;
258
+ if (a === 127) return true;
259
+ return false;
235
260
  }
236
261
  return false;
237
262
  } catch {
@@ -362,6 +387,10 @@ function resultToOutput(result: unknown): string {
362
387
  * transport: every turn carries everything). Prior-turn reasoning is NOT
363
388
  * resent — except echoed Gemini thought signatures, which strict
364
389
  * OpenAI-compatible endpoints require on tool calls they generated.
390
+ *
391
+ * Q-18: signatures are namespaced — only echo when the stored provider is
392
+ * `google` (or untagged legacy). Signatures minted by other providers never
393
+ * leak onto this transport.
365
394
  */
366
395
  async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessage[]> {
367
396
  const pairing = new Map<string, string>();
@@ -379,14 +408,18 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
379
408
  if (context.systemPrompt) {
380
409
  messages.push({ role: "system", content: context.systemPrompt });
381
410
  }
411
+ let lastRole: string | null = "system";
382
412
  for (const m of context.messages) {
383
413
  if (m.role === "system") continue;
384
414
  if (m.role === "user") {
415
+ if (lastRole === "tool") {
416
+ messages.push({ role: "assistant", content: "I have processed the tool results." });
417
+ }
385
418
  if (typeof m.content === "string") {
386
- if (m.content) messages.push({ role: "user", content: m.content });
419
+ if (m.content) { messages.push({ role: "user", content: m.content }); lastRole = "user"; }
387
420
  } else {
388
421
  const blocks = await contentPartsToBlocks(m.content);
389
- if (blocks.length > 0) messages.push({ role: "user", content: blocks });
422
+ if (blocks.length > 0) { messages.push({ role: "user", content: blocks }); lastRole = "user"; }
390
423
  }
391
424
  } else if (m.role === "assistant") {
392
425
  if (typeof m.content === "string") {
@@ -410,15 +443,16 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
410
443
  function: { name: string; arguments: string };
411
444
  extra_content?: { google: { thought_signature: string } };
412
445
  } = {
413
- id: part.callId || part.id,
446
+ id: clampToolCallId(part.callId || part.id),
414
447
  type: "function",
415
448
  function: { name: part.name, arguments: JSON.stringify(part.arguments || {}) },
416
449
  };
417
450
  // Echo provider-issued thought signatures verbatim so endpoints
418
451
  // that minted them (Gemini behind a compat proxy) keep working.
419
452
  // Only present when a previous turn captured one — other endpoints
420
- // never see this field.
421
- if (part.thoughtSignature) {
453
+ // never see this field. Q-18: echo only google/untagged signatures.
454
+ const storedProvider = (part as { thoughtSignatureProvider?: unknown }).thoughtSignatureProvider;
455
+ if (part.thoughtSignature && shouldEchoSignature(storedProvider, "google")) {
422
456
  call.extra_content = { google: { thought_signature: part.thoughtSignature } };
423
457
  }
424
458
  calls.push(call);
@@ -426,21 +460,26 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
426
460
  texts.push(part.text);
427
461
  }
428
462
  }
463
+ // Skip empty assistant turns (no text + no tool calls): strict
464
+ // routes reject them, and they carry no signal (pi drop rule).
465
+ if (texts.length === 0 && calls.length === 0) continue;
429
466
  messages.push({
430
467
  role: "assistant",
431
468
  content: texts.length > 0 ? texts.join("\n") : null,
432
469
  ...(calls.length > 0 ? { tool_calls: calls } : {}),
433
470
  });
471
+ lastRole = "assistant";
434
472
  } else if (m.role === "tool") {
435
473
  if (!Array.isArray(m.content)) continue;
436
474
  for (const part of m.content) {
437
475
  if (part.type === "tool_result") {
438
476
  messages.push({
439
477
  role: "tool",
440
- tool_call_id: pairing.get(part.id) || part.id,
478
+ tool_call_id: clampToolCallId(pairing.get(part.id) || part.id),
441
479
  content: resultToOutput(part.result),
442
480
  name: part.name,
443
481
  });
482
+ lastRole = "tool";
444
483
  }
445
484
  }
446
485
  }
@@ -473,27 +512,6 @@ function mapUsage(raw?: ChatResponse["usage"]): TokenUsage {
473
512
  return usage;
474
513
  }
475
514
 
476
- function parseArguments(raw: string | undefined): Record<string, unknown> {
477
- if (!raw) return {};
478
- try {
479
- const parsed: unknown = JSON.parse(raw);
480
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
481
- return parsed as Record<string, unknown>;
482
- }
483
- return { raw };
484
- } catch {
485
- return { raw };
486
- }
487
- }
488
-
489
- /** Trims/collapses assembled thinking parts (no edge-tripling). */
490
- function normalizeThinkingParts(parts: string[]): string | undefined {
491
- const cleaned = parts
492
- .map((p) => p.replace(/\n{3,}/g, "\n\n").trim())
493
- .filter((p) => p.length > 0);
494
- return cleaned.length > 0 ? cleaned.join("\n") : undefined;
495
- }
496
-
497
515
  function extractSignature(rawTc: {
498
516
  extra_content?: { google?: { thought_signature?: string } };
499
517
  }): string | undefined {
@@ -501,6 +519,12 @@ function extractSignature(rawTc: {
501
519
  return typeof sig === "string" && sig ? sig : undefined;
502
520
  }
503
521
 
522
+ /** Tags a captured compat signature with its issuing provider (`google`, Q-18). */
523
+ function withCompatSignature(sig: string | undefined): { thoughtSignature?: string; thoughtSignatureProvider?: string } {
524
+ if (!sig) return {};
525
+ return { thoughtSignature: sig, thoughtSignatureProvider: "google" };
526
+ }
527
+
504
528
  function throwResponseError(
505
529
  response: ChatResponse,
506
530
  prefix: string,
@@ -528,7 +552,9 @@ function parseResponse(
528
552
 
529
553
  const choice = response.choices?.[0];
530
554
  const message = choice?.message;
531
- const text = typeof message?.content === "string" ? message.content : "";
555
+ // Q-23: refusal content surfaces as text with a `refusal` finish reason.
556
+ const refusal = typeof message?.refusal === "string" && message.refusal ? message.refusal : "";
557
+ const text = typeof message?.content === "string" && message.content ? message.content : refusal;
532
558
  // Thinking tolerance: plain `reasoning` plus per-block texts (deepseek-style
533
559
  // `reasoning_details`). Details win when both ride along; both are
534
560
  // display-only here — never resent.
@@ -547,25 +573,26 @@ function parseResponse(
547
573
  const args = parseArguments(tc.function?.arguments);
548
574
  const sig = extractSignature(tc);
549
575
  toolCalls.push({
550
- id: tc.id || `call_${Math.random().toString(36).slice(2, 9)}`,
576
+ id: tc.id && !tc.id.includes("|") && tc.id.length <= 40 ? tc.id : clampToolCallId(tc.id || newToolCallId()),
551
577
  name: tc.function?.name || "unknown",
552
578
  arguments: args,
553
579
  rawArguments:
554
580
  typeof tc.function?.arguments === "string"
555
581
  ? tc.function.arguments
556
582
  : JSON.stringify(tc.function?.arguments ?? {}),
557
- ...(sig ? { thoughtSignature: sig } : {}),
558
- });
583
+ ...withCompatSignature(sig),
584
+ } as ToolCallRecord);
559
585
  }
560
586
 
561
587
  const wireReason = choice?.finish_reason ?? undefined;
588
+ const finishReason = toolCalls.length > 0 ? "tool_calls" : refusal ? "refusal" : wireReason || "stop";
562
589
  return {
563
590
  text,
564
591
  thinking: normalizeThinkingParts(thinkingParts),
565
592
  thoughtSignature: toolCalls.map((c) => c.thoughtSignature).find(Boolean),
566
593
  toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
567
594
  usage: mapUsage(response.usage),
568
- finishReason: toolCalls.length > 0 ? "tool_calls" : wireReason || "stop",
595
+ finishReason,
569
596
  responseId: response.id,
570
597
  model: modelId,
571
598
  provider: prefix as ProviderId,
@@ -574,28 +601,6 @@ function parseResponse(
574
601
  };
575
602
  }
576
603
 
577
- function redactedHeaders(headers: Record<string, string>): Record<string, string> {
578
- const out: Record<string, string> = {};
579
- for (const [k, v] of Object.entries(headers)) {
580
- out[k] = k.toLowerCase() === "authorization" ? "[REDACTED]" : v;
581
- }
582
- return out;
583
- }
584
-
585
- function readErrorPayload(bodyText: string): { message: string; code?: string | number } {
586
- try {
587
- const parsed: unknown = JSON.parse(bodyText);
588
- const first = Array.isArray(parsed) ? parsed[0] : parsed;
589
- const err = (first as { error?: { message?: string; code?: string | number } })?.error;
590
- if (err && typeof err.message === "string") {
591
- return { message: err.message, code: err.code };
592
- }
593
- return { message: bodyText.slice(0, 300) };
594
- } catch {
595
- return { message: bodyText.slice(0, 300) };
596
- }
597
- }
598
-
599
604
  // ---------------------------------------------------------------------------
600
605
  // Provider
601
606
  // ---------------------------------------------------------------------------
@@ -695,7 +700,32 @@ export class OpenAICompatibleChatProvider implements Provider {
695
700
  }
696
701
  applyCacheForCustom(this.prefix, options?.cache, `${this.prefix}/${modelId}`);
697
702
 
698
- const messages = await fullHistoryMessages(context);
703
+ // Structured outputs: native `response_format` when supported, else
704
+ // emulated via system prompt (strict endpoints would 400 on json_schema
705
+ // for catalog `structured_output: false` models).
706
+ let effectiveContext = context;
707
+ let outputEmulated = false;
708
+ let outputSpec: import("../types/model.ts").StructuredOutputSpec | undefined;
709
+ try {
710
+ outputSpec = normalizeStructuredOutput(options?.output ?? (options as { outputSchema?: unknown })?.outputSchema);
711
+ } catch {
712
+ outputSpec = undefined;
713
+ }
714
+ if (outputSpec && !modelSupportsStructuredOutput(this.prefix, modelId)) {
715
+ outputEmulated = true;
716
+ effectiveContext = {
717
+ ...context,
718
+ systemPrompt: augmentSystemPromptWithStructuredOutput(context.systemPrompt, outputSpec),
719
+ };
720
+ emitProviderWarning({
721
+ provider: this.prefix,
722
+ capability: "structured output",
723
+ requested: `${outputSpec.name} (${this.prefix}/${modelId})`,
724
+ reason: "the catalog reports structured_output: false for this model, so native response_format is unsupported.",
725
+ fallback: "emulated JSON instruction + client validation (no response_format is sent)",
726
+ });
727
+ }
728
+ const messages = await fullHistoryMessages(effectiveContext);
699
729
  const body: ChatRequestBody = {
700
730
  model: modelId,
701
731
  messages,
@@ -714,8 +744,32 @@ export class OpenAICompatibleChatProvider implements Provider {
714
744
  ? { type: "function", function: { name: (toolChoice as { name: string }).name } }
715
745
  : (toolChoice as ChatRequestBody["tool_choice"]);
716
746
  }
717
- // No reasoning / service_tier / session / cache body primitives: strict
718
- // endpoints reject unknown properties. Affinity is headers-only.
747
+ // Structured outputs use the standard Chat Completions `response_format`
748
+ // (safe on strict endpoints). Skipped when emulated (instruction already
749
+ // in messages). No reasoning / service_tier / session /
750
+ // cache body primitives: strict endpoints reject unknown properties.
751
+ // Affinity stays headers-only.
752
+ if (outputSpec && !outputEmulated) {
753
+ try {
754
+ const mapped = mapOutputToCompatChat(outputSpec);
755
+ if (mapped) body.response_format = mapped.response_format;
756
+ } catch {
757
+ // why: invalid output shapes fail fast at Agent construction/loop validation.
758
+ }
759
+ }
760
+ // Q-38: request usage on streams where supported so terminal accounting
761
+ // arrives on the final chunk. Strict servers that reject unknown fields
762
+ // opt out via `COMPAT_NO_STREAM_OPTIONS=1`; an explicit per-call
763
+ // `(options as any).streamOptions === false` also skips it.
764
+ if (stream) {
765
+ const noStreamOptions =
766
+ (options as { streamOptions?: unknown } | undefined)?.streamOptions === false ||
767
+ getEnv("COMPAT_NO_STREAM_OPTIONS") === "1" ||
768
+ (options?.env?.["COMPAT_NO_STREAM_OPTIONS"] === "1");
769
+ if (!noStreamOptions) {
770
+ (body as unknown as Record<string, unknown>)["stream_options"] = { include_usage: true };
771
+ }
772
+ }
719
773
  return body;
720
774
  }
721
775
 
@@ -735,8 +789,26 @@ export class OpenAICompatibleChatProvider implements Provider {
735
789
  for (const [k, v] of Object.entries(headers)) {
736
790
  if (INTERNAL_HEADERS.has(k.toLowerCase())) delete headers[k];
737
791
  }
738
- headers["Content-Type"] = "application/json";
739
- if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
792
+ // Strict-endpoint opt-out (pi sendSessionAffinityHeaders pattern):
793
+ // `COMPAT_NO_SESSION_HEADERS=1` or per-call `sendSessionHeaders:false`
794
+ // drops affinity headers that trigger CORS preflights / strict 400s.
795
+ // Body `session_id` is already omitted on compat (headers-only affinity).
796
+ try {
797
+ const noAffinity =
798
+ (options as { sendSessionHeaders?: unknown } | undefined)?.sendSessionHeaders === false ||
799
+ getEnv("COMPAT_NO_SESSION_HEADERS") === "1" ||
800
+ options?.env?.["COMPAT_NO_SESSION_HEADERS"] === "1";
801
+ if (noAffinity) {
802
+ for (const k of Object.keys(headers)) {
803
+ const lower = k.toLowerCase();
804
+ if (lower === "x-session-id" || lower === "x-client-request-id" || lower === "session_id") {
805
+ delete headers[k];
806
+ }
807
+ }
808
+ }
809
+ } catch {}
810
+ setHeaderCaseInsensitive(headers, "Content-Type", "application/json");
811
+ if (apiKey && apiKey !== "unused") setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
740
812
  return { method: "POST", headers, body: JSON.stringify(body), signal };
741
813
  }
742
814
 
@@ -748,13 +820,18 @@ export class OpenAICompatibleChatProvider implements Provider {
748
820
  sessionId: string | undefined,
749
821
  signal?: AbortSignal
750
822
  ): Promise<{ status: number; statusText: string; headers: Record<string, string>; text: string }> {
751
- const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, signal));
752
- const text = await res.text();
753
- const headers: Record<string, string> = {};
754
- res.headers.forEach((v, k) => {
755
- headers[k] = v;
756
- });
757
- return { status: res.status, statusText: res.statusText, headers, text };
823
+ const { signal: effective, cleanup } = combinedSignal(signal, timeoutFor(options as { timeoutMs?: number } | undefined));
824
+ try {
825
+ const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, effective));
826
+ const text = await res.text();
827
+ const headers: Record<string, string> = {};
828
+ res.headers.forEach((v, k) => {
829
+ headers[k] = v;
830
+ });
831
+ return { status: res.status, statusText: res.statusText, headers, text };
832
+ } finally {
833
+ cleanup();
834
+ }
758
835
  }
759
836
 
760
837
  private throwIfError(
@@ -764,13 +841,21 @@ export class OpenAICompatibleChatProvider implements Provider {
764
841
  modelId: string
765
842
  ): void {
766
843
  if (status >= 200 && status < 300) return;
767
- const { message, code } = readErrorPayload(bodyText);
768
- const err: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
844
+ const { message, code, errorType, raw } = readErrorPayload(bodyText);
845
+ const hint =
846
+ status === 401
847
+ ? ` Check ${envPrefixOf(this.prefix)}_API_KEY (no quotes/spaces, correct provider) and that the key is loaded (bun --env-file=.env.local).`
848
+ : "";
849
+ const fullMessage = `${message}${hint}`;
850
+ const err: Record<string, unknown> & Error = new Error(fullMessage) as Record<string, unknown> & Error;
769
851
  (err as Record<string, unknown>)["statusCode"] = status;
770
852
  (err as Record<string, unknown>)["status"] = status;
771
853
  (err as Record<string, unknown>)["responseBody"] = bodyText.slice(0, 500);
772
854
  (err as Record<string, unknown>)["url"] = url.split("?")[0];
855
+ (err as Record<string, unknown>)["data"] = { error: { message: fullMessage } };
773
856
  if (code !== undefined) (err as Record<string, unknown>)["code"] = code;
857
+ if (errorType) (err as Record<string, unknown>)["errorType"] = errorType;
858
+ if (raw) (err as Record<string, unknown>)["raw"] = raw;
774
859
  throw toConciseProviderError(err, this.prefix, modelId);
775
860
  }
776
861
 
@@ -904,7 +989,21 @@ export class OpenAICompatibleChatProvider implements Provider {
904
989
  body,
905
990
  };
906
991
 
907
- const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
992
+ const streamTimeoutMs = timeoutFor(options as { timeoutMs?: number } | undefined);
993
+ const streamTimeoutTimer =
994
+ streamTimeoutMs && streamTimeoutMs > 0
995
+ ? setTimeout(() => {
996
+ try {
997
+ linked.abort();
998
+ } catch {}
999
+ }, streamTimeoutMs)
1000
+ : undefined;
1001
+ let res: Response;
1002
+ try {
1003
+ res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
1004
+ } finally {
1005
+ if (streamTimeoutTimer) clearTimeout(streamTimeoutTimer);
1006
+ }
908
1007
  if (!res.ok || !res.body) {
909
1008
  const text = !res.ok ? await res.text().catch(() => "") : "";
910
1009
  if (!res.ok) this.throwIfError(res.status, url, text, clean);
@@ -914,7 +1013,7 @@ export class OpenAICompatibleChatProvider implements Provider {
914
1013
  res.headers.forEach((v, k) => {
915
1014
  responseHeaders[k] = v;
916
1015
  });
917
- const responseMeta = { status: res.status, statusText: res.statusText, headers: responseHeaders };
1016
+ const responseMeta: Record<string, unknown> & { status: number; statusText: string; headers: Record<string, string> } = { status: res.status, statusText: res.statusText, headers: responseHeaders };
918
1017
  eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
919
1018
 
920
1019
  const parser = new SSEParser();
@@ -928,6 +1027,11 @@ export class OpenAICompatibleChatProvider implements Provider {
928
1027
  const signatures = new Map<number, string>();
929
1028
  let usage: TokenUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
930
1029
  let finishReason = "stop";
1030
+ let sawFinishReason = false;
1031
+ let sawUsage = false;
1032
+ let refusalSeen = false;
1033
+ let badFrames = 0;
1034
+ let framesSeen = 0;
931
1035
  let responseId: string | undefined;
932
1036
  let completedBody: unknown = undefined;
933
1037
  let aborted = false;
@@ -943,11 +1047,17 @@ export class OpenAICompatibleChatProvider implements Provider {
943
1047
  );
944
1048
 
945
1049
  const handleMessage = (data: string): void => {
946
- if (!data || data === "[DONE]") return;
1050
+ if (!data) return;
1051
+ if (data === "[DONE]") {
1052
+ sawFinishReason = sawFinishReason || completedBody !== undefined || sawUsage;
1053
+ return;
1054
+ }
947
1055
  let msg: Record<string, unknown>;
948
1056
  try {
949
1057
  msg = JSON.parse(data) as Record<string, unknown>;
1058
+ framesSeen += 1;
950
1059
  } catch {
1060
+ badFrames += 1;
951
1061
  return;
952
1062
  }
953
1063
  // Mid-stream provider error: top-level `error`, HTTP stays 200.
@@ -969,6 +1079,13 @@ export class OpenAICompatibleChatProvider implements Provider {
969
1079
  const choice = (Array.isArray(msg["choices"]) ? (msg["choices"] as Record<string, unknown>[])[0] : undefined) ?? {};
970
1080
  const delta = (choice["delta"] as Record<string, unknown>) ?? {};
971
1081
  // Text delta (content-free accounting frames carry "" — ignored).
1082
+ // Q-23: refusal deltas surface as text with a `refusal` finish.
1083
+ const deltaRefusal = delta["refusal"];
1084
+ if (typeof deltaRefusal === "string" && deltaRefusal) {
1085
+ text += deltaRefusal;
1086
+ refusalSeen = true;
1087
+ eventStream.push({ type: "text_delta", delta: deltaRefusal, partialText: text });
1088
+ }
972
1089
  if (typeof delta["content"] === "string" && delta["content"]) {
973
1090
  text += delta["content"] as string;
974
1091
  eventStream.push({ type: "text_delta", delta: delta["content"] as string, partialText: text });
@@ -1042,10 +1159,12 @@ export class OpenAICompatibleChatProvider implements Provider {
1042
1159
  throw toConciseProviderError(failure, prefix, clean);
1043
1160
  }
1044
1161
  finishReason = fr;
1162
+ sawFinishReason = true;
1045
1163
  }
1046
1164
  const chunkUsage = msg["usage"] as ChatResponse["usage"] | undefined;
1047
1165
  if (chunkUsage) {
1048
1166
  usage = mapUsage(chunkUsage);
1167
+ sawUsage = true;
1049
1168
  completedBody = completedBody ?? msg;
1050
1169
  eventStream.push({ type: "usage", usage });
1051
1170
  }
@@ -1077,20 +1196,48 @@ export class OpenAICompatibleChatProvider implements Provider {
1077
1196
  const args = parseStreamedToolArguments(c.startArgs, c.deltaArgs);
1078
1197
  const sig = signatures.get(index);
1079
1198
  const record: ToolCallRecord = {
1080
- id: c.id || `call_${Math.random().toString(36).slice(2, 9)}`,
1199
+ id: c.id && !c.id.includes("|") && c.id.length <= 40 ? c.id : clampToolCallId(c.id || newToolCallId()),
1081
1200
  name: c.name,
1082
1201
  arguments: args,
1083
1202
  rawArguments: c.startArgs + c.deltaArgs,
1084
- ...(sig ? { thoughtSignature: sig } : {}),
1085
- };
1203
+ ...withCompatSignature(sig),
1204
+ } as ToolCallRecord;
1086
1205
  toolCalls.push(record);
1087
1206
  eventStream.push({ type: "tool_call_complete", toolCall: record });
1088
1207
  }
1089
1208
 
1090
1209
  noteProviderTurn(sessionId, prefix);
1091
- if (toolCalls.length > 0) finishReason = "tool_calls";
1210
+ if (toolCalls.length > 0) {
1211
+ finishReason = "tool_calls";
1212
+ sawFinishReason = true;
1213
+ } else if (refusalSeen) {
1214
+ finishReason = "refusal";
1215
+ sawFinishReason = true;
1216
+ }
1092
1217
 
1093
- const cleanThinking = thinking.replace(/\n{3,}/g, "\n\n").trim();
1218
+ // Q-23: terminal-state guard — fail `incomplete_stream` (retryable)
1219
+ // instead of resolving empty success. Skipped for non-SSE payloads
1220
+ // (JSON mocks) where no SSE frames were ever seen.
1221
+ const hasTerminal = completedBody !== undefined || sawFinishReason || sawUsage;
1222
+ const cleanThinkingEarly = thinking.replace(/\n{3,}/g, "\n\n").trim();
1223
+ const ctEntry = Object.entries(responseHeaders).find(([k]) => k.toLowerCase() === "content-type");
1224
+ const isSSEPayload =
1225
+ (typeof ctEntry?.[1] === "string" && ((ctEntry[1] as string).includes("event-stream") || (ctEntry[1] as string).startsWith("text/"))) ||
1226
+ framesSeen > 0 ||
1227
+ badFrames > 0;
1228
+ if (!hasTerminal && !text && !cleanThinkingEarly && toolCalls.length === 0 && isSSEPayload) {
1229
+ eventStream.fail(incompleteStreamError(prefix, clean));
1230
+ return;
1231
+ }
1232
+ // Refuse unfinished tool calls whose args never parsed (output_item.done
1233
+ // never arrived): truncated `{raw}` must not reach the executor (pi
1234
+ // api/openai-responses-shared.ts:766 rule). Non-SSE mocks preserve legacy.
1235
+ if (!hasTerminal && toolCalls.some((c) => hasTruncatedToolArguments(c.arguments)) && isSSEPayload) {
1236
+ eventStream.fail(incompleteStreamError(prefix, clean));
1237
+ return;
1238
+ }
1239
+
1240
+ const cleanThinking = cleanThinkingEarly;
1094
1241
  const finalThoughtSignature = toolCalls.map((c) => c.thoughtSignature).find(Boolean);
1095
1242
  const finalResponse = new AgentResponse({
1096
1243
  text,
@@ -1102,7 +1249,10 @@ export class OpenAICompatibleChatProvider implements Provider {
1102
1249
  responseId,
1103
1250
  model: clean,
1104
1251
  provider: prefix as ProviderId,
1105
- raw: { request: rawRequest, response: { ...responseMeta, body: completedBody } },
1252
+ raw: {
1253
+ request: rawRequest,
1254
+ response: { ...responseMeta, body: completedBody, badFrames } as unknown as ProviderRawData["response"],
1255
+ },
1106
1256
  durationMs: Date.now() - startTime,
1107
1257
  });
1108
1258
  eventStream.push({ type: "done", delta: "", usage, finishReason, responseId });