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.
@@ -46,14 +46,27 @@ import { AssistantMessageEventStream } from "../streaming/event-stream.ts";
46
46
  import { SSEParser } from "../streaming/sse-parser.ts";
47
47
  import { AgentResponse } from "../types/response.ts";
48
48
  import { getApiKey, getEnv } from "../utils/env.ts";
49
- import { buildSessionHeaders } from "../utils/headers.ts";
49
+ import { buildSessionHeaders, setHeaderCaseInsensitive } from "../utils/headers.ts";
50
50
  import { clampCacheKey } from "../utils/cache.ts";
51
51
  import { normalizeMediaInput } from "../utils/media.ts";
52
52
  import { safeStringify } from "../utils/serialization.ts";
53
53
  import { toConciseProviderError, assertModalitiesSupported, assertNoVideoPartsOnResponses } from "../utils/errors.ts";
54
54
  import { withRetries } from "../utils/retry.ts";
55
+ import {
56
+ parseArguments,
57
+ normalizeThinkingParts,
58
+ newToolCallId,
59
+ redactedHeaders,
60
+ readErrorPayload,
61
+ incompleteStreamError,
62
+ hasTruncatedToolArguments,
63
+ combinedSignal,
64
+ timeoutFor,
65
+ hasAuthHeader,
66
+ clampToolCallId,
67
+ } from "./shared.ts";
55
68
  import { createGenericModelSpec } from "../models/catalog.ts";
56
- import { getModelFromCatalog, getModelsForProvider } from "../models/catalog.ts";
69
+ import { getModelFromCatalog, getModelsForProvider, modelSupportsStructuredOutput } from "../models/catalog.ts";
57
70
  import {
58
71
  mapThinkingLevelToOpenAI,
59
72
  mapServiceTierToOpenAI,
@@ -61,6 +74,10 @@ import {
61
74
  mapToolChoiceToOpenAI,
62
75
  noteProviderTurn,
63
76
  parseStreamedToolArguments,
77
+ mapOutputToOpenAI,
78
+ normalizeStructuredOutput,
79
+ augmentSystemPromptWithStructuredOutput,
80
+ emitProviderWarning,
64
81
  } from "../providers.ts";
65
82
 
66
83
  // ---------------------------------------------------------------------------
@@ -92,6 +109,7 @@ interface ResponsesRequestBody {
92
109
  prompt_cache_key?: string;
93
110
  store?: boolean;
94
111
  stream?: boolean;
112
+ text?: { format: { type: "json_schema"; name: string; schema: Record<string, unknown>; strict: boolean; description?: string } };
95
113
  [k: string]: unknown;
96
114
  }
97
115
 
@@ -131,6 +149,12 @@ interface ResponsesObject {
131
149
 
132
150
  const DEFAULT_BASE_URL = "https://api.openai.com/v1";
133
151
 
152
+ /** Q-18: Responses transport never echoes thought signatures (no wire field).
153
+ * Local helper documents the namespacing decision per provider file. */
154
+ function withProviderSignature(_sig: string | undefined): { thoughtSignature?: undefined } {
155
+ return {};
156
+ }
157
+
134
158
  /** Internal headers that must never leak onto native REST requests. */
135
159
  const INTERNAL_HEADERS = new Set([
136
160
  "x-thought-signature-map",
@@ -154,7 +178,10 @@ function resolveBaseUrl(options?: ProviderRequestOptions): string {
154
178
  }
155
179
 
156
180
  function resolveApiKey(options?: ProviderRequestOptions): string | undefined {
157
- return options?.apiKey || getApiKey("openai", undefined, options?.env);
181
+ const key = getApiKey("openai", options?.apiKey, options?.env);
182
+ if (key) return key;
183
+ if (hasAuthHeader(options?.headers)) return "unused";
184
+ return undefined;
158
185
  }
159
186
 
160
187
  /** Strips ONLY the `openai/` prefix. Bare ids pass through untouched. */
@@ -235,6 +262,20 @@ function resultToOutput(result: unknown): string {
235
262
  return safeStringify(result);
236
263
  }
237
264
 
265
+ function normalizeResponsesIdPart(part: string): string {
266
+ const sanitized = String(part ?? "").replace(/[^a-zA-Z0-9_-]/g, "_");
267
+ const truncated = sanitized.length > 64 ? sanitized.slice(0, 64) : sanitized;
268
+ return truncated.replace(/_+$/, "") || "fc_unknown";
269
+ }
270
+
271
+ function normalizeResponsesItemId(itemId: string): string {
272
+ let norm = normalizeResponsesIdPart(itemId);
273
+ if (!norm.startsWith("fc_")) {
274
+ norm = normalizeResponsesIdPart(`fc_${norm}`);
275
+ }
276
+ return norm;
277
+ }
278
+
238
279
  /**
239
280
  * Builds the FULL explicit history (stateless — no server state, no
240
281
  * chaining). Assistant items intentionally carry no provider `id`/`status`:
@@ -284,10 +325,25 @@ async function fullHistoryInput(context: ProviderContext): Promise<ResponseInput
284
325
  const texts: string[] = [];
285
326
  for (const part of m.content) {
286
327
  if (part.type === "tool_call") {
328
+ // Cross-provider pipe IDs (`callId|itemId`) are split and
329
+ // normalized (64-char, `fc_` prefix) so replay never 400s.
330
+ const rawId = String(part.id ?? "");
331
+ const rawCallId = String(part.callId ?? part.id ?? "");
332
+ let itemId = rawId;
333
+ let callId = rawCallId;
334
+ if (rawId.includes("|")) {
335
+ const [c, i] = rawId.split("|");
336
+ if (c) callId = c;
337
+ if (i) itemId = i;
338
+ }
339
+ if (callId.includes("|")) {
340
+ const [c] = callId.split("|");
341
+ if (c) callId = c;
342
+ }
287
343
  items.push({
288
344
  type: "function_call",
289
- id: part.id,
290
- call_id: part.callId || part.id,
345
+ id: normalizeResponsesItemId(itemId),
346
+ call_id: normalizeResponsesIdPart(callId),
291
347
  name: part.name,
292
348
  arguments: JSON.stringify(part.arguments || {}),
293
349
  });
@@ -303,9 +359,11 @@ async function fullHistoryInput(context: ProviderContext): Promise<ResponseInput
303
359
  } else if (m.role === "tool") {
304
360
  if (typeof m.content === "string") {
305
361
  const queue = (m.name && idsByName.get(m.name)) || [];
362
+ const rawPair = queue.shift() || newToolCallId();
363
+ const rawCall = rawPair.includes("|") ? rawPair.split("|")[0]! : rawPair;
306
364
  items.push({
307
365
  type: "function_call_output",
308
- call_id: queue.shift() || "call_0",
366
+ call_id: normalizeResponsesIdPart(rawCall),
309
367
  output: m.content,
310
368
  });
311
369
  continue;
@@ -313,9 +371,11 @@ async function fullHistoryInput(context: ProviderContext): Promise<ResponseInput
313
371
  if (!Array.isArray(m.content)) continue;
314
372
  for (const part of m.content) {
315
373
  if (part.type === "tool_result") {
374
+ const rawPair = pairing.get(part.id) || part.id;
375
+ const rawCall = rawPair.includes("|") ? rawPair.split("|")[0]! : rawPair;
316
376
  items.push({
317
377
  type: "function_call_output",
318
- call_id: pairing.get(part.id) || part.id,
378
+ call_id: normalizeResponsesIdPart(rawCall),
319
379
  output: resultToOutput(part.result),
320
380
  });
321
381
  }
@@ -345,27 +405,6 @@ function mapUsage(raw?: ResponsesObject["usage"]): TokenUsage {
345
405
  };
346
406
  }
347
407
 
348
- function parseArguments(raw: string | undefined): Record<string, unknown> {
349
- if (!raw) return {};
350
- try {
351
- const parsed: unknown = JSON.parse(raw);
352
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
353
- return parsed as Record<string, unknown>;
354
- }
355
- return { raw };
356
- } catch {
357
- return { raw };
358
- }
359
- }
360
-
361
- /** Trims/collapses assembled thinking parts (no edge-tripling). */
362
- function normalizeThinkingParts(parts: string[]): string | undefined {
363
- const cleaned = parts
364
- .map((p) => p.replace(/\n{3,}/g, "\n\n").trim())
365
- .filter((p) => p.length > 0);
366
- return cleaned.length > 0 ? cleaned.join("\n") : undefined;
367
- }
368
-
369
408
  function parseResponse(
370
409
  response: ResponsesObject,
371
410
  modelId: string,
@@ -384,6 +423,7 @@ function parseResponse(
384
423
  }
385
424
 
386
425
  let text = "";
426
+ let refusalText = "";
387
427
  const thinkingParts: string[] = [];
388
428
  const toolCalls: ToolCallRecord[] = [];
389
429
 
@@ -391,6 +431,15 @@ function parseResponse(
391
431
  if (item.type === "message") {
392
432
  for (const block of item.content ?? []) {
393
433
  if (block.type === "output_text" && block.text) text += block.text;
434
+ else if (block.type === "refusal" && block.text) {
435
+ text += block.text;
436
+ refusalText += block.text;
437
+ }
438
+ }
439
+ const itemRefusal = (item as { refusal?: unknown }).refusal;
440
+ if (typeof itemRefusal === "string" && itemRefusal) {
441
+ text += itemRefusal;
442
+ refusalText += itemRefusal;
394
443
  }
395
444
  } else if (item.type === "reasoning") {
396
445
  for (const block of item.content ?? []) {
@@ -404,7 +453,7 @@ function parseResponse(
404
453
  } else if (item.type === "function_call") {
405
454
  const args = parseArguments(item.arguments);
406
455
  toolCalls.push({
407
- id: item.id || item.call_id || `call_${Math.random().toString(36).slice(2, 9)}`,
456
+ id: item.id || item.call_id || newToolCallId(),
408
457
  callId: item.call_id,
409
458
  name: item.name || "unknown",
410
459
  arguments: args,
@@ -413,6 +462,17 @@ function parseResponse(
413
462
  }
414
463
  }
415
464
 
465
+ // Q-23: `incomplete` -> length (content-filter truncations surface as
466
+ // refusal when refusal text is present).
467
+ const incompleteReason = (response as { incomplete_details?: { reason?: string } }).incomplete_details?.reason;
468
+ const finishReason =
469
+ toolCalls.length > 0
470
+ ? "tool_calls"
471
+ : refusalText || incompleteReason === "content_filter"
472
+ ? "refusal"
473
+ : response.status === "incomplete"
474
+ ? "length"
475
+ : "stop";
416
476
  return {
417
477
  text,
418
478
  // Trim/collapse reasoning assembly (provider summaries can trail with
@@ -422,7 +482,7 @@ function parseResponse(
422
482
  thoughtSignature: undefined,
423
483
  toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
424
484
  usage: mapUsage(response.usage),
425
- finishReason: toolCalls.length > 0 ? "tool_calls" : response.status === "incomplete" ? "length" : "stop",
485
+ finishReason,
426
486
  responseId: response.id,
427
487
  model: modelId,
428
488
  provider: "openai",
@@ -431,32 +491,6 @@ function parseResponse(
431
491
  };
432
492
  }
433
493
 
434
- function redactedHeaders(headers: Record<string, string>): Record<string, string> {
435
- const out: Record<string, string> = {};
436
- for (const [k, v] of Object.entries(headers)) {
437
- out[k] = k.toLowerCase() === "authorization" ? "[REDACTED]" : v;
438
- }
439
- return out;
440
- }
441
-
442
- function readErrorPayload(bodyText: string): { message: string; code?: string | number; errorType?: string } {
443
- try {
444
- const parsed: unknown = JSON.parse(bodyText);
445
- const first = Array.isArray(parsed) ? parsed[0] : parsed;
446
- const err = (first as { error?: { message?: string; code?: string | number; type?: string } })?.error;
447
- if (err && typeof err.message === "string") {
448
- return {
449
- message: err.message,
450
- code: err.code,
451
- ...(typeof err.type === "string" ? { errorType: err.type } : {}),
452
- };
453
- }
454
- return { message: bodyText.slice(0, 300) };
455
- } catch {
456
- return { message: bodyText.slice(0, 300) };
457
- }
458
- }
459
-
460
494
  // ---------------------------------------------------------------------------
461
495
  // Provider
462
496
  // ---------------------------------------------------------------------------
@@ -496,16 +530,38 @@ export class OpenAIResponsesProvider implements Provider {
496
530
  const serviceTier = mapServiceTierToOpenAI(options?.serviceTier);
497
531
  applyCacheForOpenAI(options?.cache, `openai/${modelId}`);
498
532
 
533
+ // Structured outputs: native `text.format` when supported, else emulated
534
+ // via instructions (catalog `structured_output: false` would 400).
535
+ let effectiveSystemPrompt = context.systemPrompt;
536
+ let outputEmulated = false;
537
+ let outputSpec: import("../types/model.ts").StructuredOutputSpec | undefined;
538
+ try {
539
+ outputSpec = normalizeStructuredOutput(options?.output ?? (options as { outputSchema?: unknown })?.outputSchema);
540
+ } catch {
541
+ outputSpec = undefined;
542
+ }
543
+ if (outputSpec && !modelSupportsStructuredOutput("openai", modelId)) {
544
+ outputEmulated = true;
545
+ effectiveSystemPrompt = augmentSystemPromptWithStructuredOutput(context.systemPrompt, outputSpec);
546
+ emitProviderWarning({
547
+ provider: "openai",
548
+ capability: "structured output",
549
+ requested: `${outputSpec.name} (openai/${modelId})`,
550
+ reason: "the catalog reports structured_output: false for this model, so native text.format is unsupported.",
551
+ fallback: "emulated JSON instruction + client validation (no text.format is sent)",
552
+ });
553
+ }
554
+
499
555
  const body: ResponsesRequestBody = {
500
556
  model: modelId,
501
- input: await fullHistoryInput(context),
557
+ input: await fullHistoryInput({ ...context, systemPrompt: effectiveSystemPrompt }),
502
558
  // Stateless by design: the server defaults store:true, so opt out
503
559
  // explicitly. Full history is always sent, so no chaining is needed
504
560
  // and provider switching stays trivial.
505
561
  store: false,
506
562
  ...(stream ? { stream: true } : {}),
507
563
  };
508
- if (context.systemPrompt) body.instructions = context.systemPrompt;
564
+ if (effectiveSystemPrompt) body.instructions = effectiveSystemPrompt;
509
565
  const aiTools = toOpenAITools(tools);
510
566
  if (aiTools) body.tools = aiTools;
511
567
  const toolChoice = mapToolChoiceToOpenAI(
@@ -514,8 +570,17 @@ export class OpenAIResponsesProvider implements Provider {
514
570
  if (toolChoice !== undefined) body.tool_choice = toolChoice;
515
571
  if (effort) body.reasoning = { effort };
516
572
  if (serviceTier) body.service_tier = serviceTier;
573
+ // Structured outputs: Responses `text.format{type:json_schema,name,schema,strict}`.
574
+ // Skipped when emulated (instruction already in `instructions`).
575
+ if (outputSpec && !outputEmulated) {
576
+ try {
577
+ const mapped = mapOutputToOpenAI(outputSpec);
578
+ if (mapped) body.text = { format: mapped.format };
579
+ } catch {
580
+ // why: invalid output shapes fail fast at Agent construction/loop validation.
581
+ }
582
+ }
517
583
  // Cache affinity: prompt_cache_key replaces the legacy `user` field.
518
- // (prompt_cache_options explicit breakpoints are out of scope.)
519
584
  // OpenAI enforces max 64 chars — clamp defensively so child session ids
520
585
  // (`parent-sub-tag-rand`) and user-supplied long ids never 400. Headers
521
586
  // are clamped the same way via buildSessionHeaders, keeping affinity
@@ -524,6 +589,29 @@ export class OpenAIResponsesProvider implements Provider {
524
589
  const cacheKey = clampCacheKey(sessionId);
525
590
  if (cacheKey) body.prompt_cache_key = cacheKey;
526
591
  }
592
+ // Long retention: `prompt_cache_retention: "24h"` when the catalog
593
+ // supports it (pi api/openai-responses.ts:97 rule). Other explicit modes
594
+ // stay warn+drop via applyCacheForOpenAI above.
595
+ if (options?.cache?.retention === "long") {
596
+ let supportsLong = true;
597
+ try {
598
+ const spec = getModelFromCatalog("openai", modelId);
599
+ if (spec && typeof spec.capabilities?.supportsLongCacheRetention === "boolean") {
600
+ supportsLong = spec.capabilities.supportsLongCacheRetention;
601
+ }
602
+ } catch {}
603
+ if (supportsLong) {
604
+ (body as Record<string, unknown>)["prompt_cache_retention"] = "24h";
605
+ } else {
606
+ emitProviderWarning({
607
+ provider: "openai",
608
+ capability: "cache retention",
609
+ requested: `long (openai/${modelId})`,
610
+ reason: "the catalog reports no long-cache support for this model.",
611
+ fallback: "default caching with prompt_cache_key affinity (no retention payload is sent)",
612
+ });
613
+ }
614
+ }
527
615
  // Stateless API use: previous_response_id / background / conversation are
528
616
  // NEVER sent (server state would break provider-agnostic switching).
529
617
  return body;
@@ -545,8 +633,11 @@ export class OpenAIResponsesProvider implements Provider {
545
633
  for (const [k, v] of Object.entries(headers)) {
546
634
  if (INTERNAL_HEADERS.has(k.toLowerCase())) delete headers[k];
547
635
  }
548
- headers["Content-Type"] = "application/json";
549
- headers["Authorization"] = `Bearer ${apiKey}`;
636
+ // Case-insensitive merge so a lowercase `authorization` custom header
637
+ // never coexists with the canonical bearer. Proxy flows
638
+ // (`apiKey === "unused"`) preserve custom auth instead of overwriting.
639
+ setHeaderCaseInsensitive(headers, "Content-Type", "application/json");
640
+ if (apiKey !== "unused") setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
550
641
  return { method: "POST", headers, body: JSON.stringify(body), signal };
551
642
  }
552
643
 
@@ -558,13 +649,18 @@ export class OpenAIResponsesProvider implements Provider {
558
649
  sessionId: string | undefined,
559
650
  signal?: AbortSignal
560
651
  ): Promise<{ status: number; statusText: string; headers: Record<string, string>; text: string }> {
561
- const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, signal));
562
- const text = await res.text();
563
- const headers: Record<string, string> = {};
564
- res.headers.forEach((v, k) => {
565
- headers[k] = v;
566
- });
567
- return { status: res.status, statusText: res.statusText, headers, text };
652
+ const { signal: effective, cleanup } = combinedSignal(signal, timeoutFor(options as { timeoutMs?: number } | undefined));
653
+ try {
654
+ const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, effective));
655
+ const text = await res.text();
656
+ const headers: Record<string, string> = {};
657
+ res.headers.forEach((v, k) => {
658
+ headers[k] = v;
659
+ });
660
+ return { status: res.status, statusText: res.statusText, headers, text };
661
+ } finally {
662
+ cleanup();
663
+ }
568
664
  }
569
665
 
570
666
  private throwIfError(
@@ -574,14 +670,23 @@ export class OpenAIResponsesProvider implements Provider {
574
670
  modelId: string
575
671
  ): void {
576
672
  if (status >= 200 && status < 300) return;
577
- const { message, code, errorType } = readErrorPayload(bodyText);
578
- const err: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
673
+ const { message, code, errorType, raw } = readErrorPayload(bodyText);
674
+ const hint =
675
+ status === 401
676
+ ? " Check OPENAI_API_KEY (no quotes/spaces, correct provider), OPENAI_BASE_URL, and that the key is loaded (bun --env-file=.env.local)."
677
+ : "";
678
+ const fullMessage = `${message}${hint}`;
679
+ const err: Record<string, unknown> & Error = new Error(fullMessage) as Record<string, unknown> & Error;
579
680
  (err as Record<string, unknown>)["statusCode"] = status;
580
681
  (err as Record<string, unknown>)["status"] = status;
581
682
  (err as Record<string, unknown>)["responseBody"] = bodyText.slice(0, 500);
582
683
  (err as Record<string, unknown>)["url"] = url.split("?")[0];
684
+ // Preserve hint through toConciseProviderError re-wrap (it prefers
685
+ // data.error.message over responseBody).
686
+ (err as Record<string, unknown>)["data"] = { error: { message: fullMessage } };
583
687
  if (code !== undefined) (err as Record<string, unknown>)["code"] = code;
584
688
  if (errorType) (err as Record<string, unknown>)["errorType"] = errorType;
689
+ if (raw) (err as Record<string, unknown>)["raw"] = raw;
585
690
  throw toConciseProviderError(err, "openai", modelId);
586
691
  }
587
692
 
@@ -728,7 +833,22 @@ export class OpenAIResponsesProvider implements Provider {
728
833
  body,
729
834
  };
730
835
 
731
- const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
836
+ // Q-17: optional per-call timeout races the linked abort signal.
837
+ const streamTimeoutMs = timeoutFor(options as { timeoutMs?: number } | undefined);
838
+ const streamTimeoutTimer =
839
+ streamTimeoutMs && streamTimeoutMs > 0
840
+ ? setTimeout(() => {
841
+ try {
842
+ linked.abort();
843
+ } catch {}
844
+ }, streamTimeoutMs)
845
+ : undefined;
846
+ let res: Response;
847
+ try {
848
+ res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
849
+ } finally {
850
+ if (streamTimeoutTimer) clearTimeout(streamTimeoutTimer);
851
+ }
732
852
  if (!res.ok || !res.body) {
733
853
  const text = !res.ok ? await res.text().catch(() => "") : "";
734
854
  if (!res.ok) this.throwIfError(res.status, url, text, clean);
@@ -738,7 +858,7 @@ export class OpenAIResponsesProvider implements Provider {
738
858
  res.headers.forEach((v, k) => {
739
859
  responseHeaders[k] = v;
740
860
  });
741
- const responseMeta = { status: res.status, statusText: res.statusText, headers: responseHeaders };
861
+ const responseMeta: Record<string, unknown> & { status: number; statusText: string; headers: Record<string, string> } = { status: res.status, statusText: res.statusText, headers: responseHeaders };
742
862
  eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
743
863
 
744
864
  const parser = new SSEParser();
@@ -750,6 +870,11 @@ export class OpenAIResponsesProvider implements Provider {
750
870
  const calls = new Map<number, { itemId: string; callId: string; name: string; startArgs: string; deltaArgs: string }>();
751
871
  let usage: TokenUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
752
872
  let finishReason = "stop";
873
+ let sawFinishReason = false;
874
+ let sawUsage = false;
875
+ let refusalSeen = false;
876
+ let badFrames = 0;
877
+ let framesSeen = 0;
753
878
  let responseId: string | undefined;
754
879
  let completedBody: unknown = undefined;
755
880
  let aborted = false;
@@ -765,11 +890,16 @@ export class OpenAIResponsesProvider implements Provider {
765
890
  );
766
891
 
767
892
  const handleMessage = (data: string): void => {
768
- if (!data || data === "[DONE]") return;
893
+ if (!data || data === "[DONE]") {
894
+ if (data === "[DONE]") sawFinishReason = sawFinishReason || completedBody !== undefined || sawUsage;
895
+ return;
896
+ }
769
897
  let msg: Record<string, unknown>;
770
898
  try {
771
899
  msg = JSON.parse(data) as Record<string, unknown>;
900
+ framesSeen += 1;
772
901
  } catch {
902
+ badFrames += 1;
773
903
  return;
774
904
  }
775
905
  const type = msg["type"] as string;
@@ -790,6 +920,17 @@ export class OpenAIResponsesProvider implements Provider {
790
920
  } else if (type === "response.output_text.delta" && typeof msg["delta"] === "string") {
791
921
  text += msg["delta"] as string;
792
922
  eventStream.push({ type: "text_delta", delta: msg["delta"] as string, partialText: text });
923
+ } else if (
924
+ type === "response.refusal.delta" ||
925
+ type === "response.output_refusal.delta"
926
+ ) {
927
+ // Q-23: refusal deltas carry the filter text; surface as text and
928
+ // remember for a `refusal` finish reason.
929
+ if (typeof msg["delta"] === "string" && msg["delta"]) {
930
+ text += msg["delta"] as string;
931
+ refusalSeen = true;
932
+ eventStream.push({ type: "text_delta", delta: msg["delta"] as string, partialText: text });
933
+ }
793
934
  } else if (
794
935
  (type === "response.reasoning_text.delta" || type === "response.reasoning.delta") &&
795
936
  typeof msg["delta"] === "string"
@@ -827,7 +968,11 @@ export class OpenAIResponsesProvider implements Provider {
827
968
  const response = (msg["response"] as ResponsesObject) ?? {};
828
969
  completedBody = msg["response"];
829
970
  if (response.id) responseId = response.id;
830
- if (response.usage) usage = mapUsage(response.usage);
971
+ if (response.usage) {
972
+ usage = mapUsage(response.usage);
973
+ sawUsage = true;
974
+ }
975
+ sawFinishReason = true;
831
976
  if (type === "response.failed" || response.status === "failed") {
832
977
  const message =
833
978
  (response.error && typeof response.error.message === "string" && response.error.message) ||
@@ -839,7 +984,12 @@ export class OpenAIResponsesProvider implements Provider {
839
984
  }
840
985
  // Non-streaming maps `incomplete` → `length` (max tokens). Streaming
841
986
  // must do the same — otherwise truncated turns misreport `stop`.
842
- if (response.status === "incomplete") finishReason = "length";
987
+ // Q-23: content-filter incompletes surface as `refusal`.
988
+ const incompleteReason = (response as { incomplete_details?: { reason?: string } }).incomplete_details?.reason;
989
+ if (response.status === "incomplete") {
990
+ finishReason = incompleteReason === "content_filter" ? "refusal" : "length";
991
+ sawFinishReason = true;
992
+ }
843
993
  // Merge any full tool items delivered at completion (authoritative
844
994
  // when present) so nothing depends solely on delta assembly.
845
995
  for (const item of response.output ?? []) {
@@ -876,6 +1026,17 @@ export class OpenAIResponsesProvider implements Provider {
876
1026
  }
877
1027
  }
878
1028
  }
1029
+ // Merge completed refusal text (filter truncations often arrive only
1030
+ // here with no preceding refusal delta).
1031
+ for (const item of response.output ?? []) {
1032
+ if (item.type !== "message") continue;
1033
+ for (const block of item.content ?? []) {
1034
+ if (block.type === "refusal" && block.text) {
1035
+ if (!text.includes(block.text)) text += block.text;
1036
+ refusalSeen = true;
1037
+ }
1038
+ }
1039
+ }
879
1040
  eventStream.push({ type: "usage", usage });
880
1041
  }
881
1042
  };
@@ -905,7 +1066,7 @@ export class OpenAIResponsesProvider implements Provider {
905
1066
  for (const c of calls.values()) {
906
1067
  const args = parseStreamedToolArguments(c.startArgs, c.deltaArgs);
907
1068
  const record: ToolCallRecord = {
908
- id: c.itemId || c.callId || `call_${Math.random().toString(36).slice(2, 9)}`,
1069
+ id: c.itemId || c.callId || newToolCallId(),
909
1070
  callId: c.callId || undefined,
910
1071
  name: c.name,
911
1072
  arguments: args,
@@ -916,9 +1077,40 @@ export class OpenAIResponsesProvider implements Provider {
916
1077
  }
917
1078
 
918
1079
  noteProviderTurn(sessionId, "openai");
919
- if (toolCalls.length > 0) finishReason = "tool_calls";
1080
+ if (toolCalls.length > 0) {
1081
+ finishReason = "tool_calls";
1082
+ sawFinishReason = true;
1083
+ } else if (refusalSeen && finishReason !== "length") {
1084
+ finishReason = "refusal";
1085
+ sawFinishReason = true;
1086
+ }
920
1087
 
921
- const cleanThinking = thinking.replace(/\n{3,}/g, "\n\n").trim();
1088
+ // Q-23: terminal-state guard. Loops ending with no completed body, no
1089
+ // finish_reason, and no usage while carrying no content must fail as
1090
+ // `incomplete_stream` (retryable) instead of resolving empty success.
1091
+ // Skipped for non-SSE payloads (e.g. JSON mocks in delegation tests)
1092
+ // where no SSE frames were ever seen — those preserve legacy empty
1093
+ // success rather than failing the caller on a mock artifact.
1094
+ const hasTerminal = completedBody !== undefined || sawFinishReason || sawUsage;
1095
+ const cleanThinkingEarly = thinking.replace(/\n{3,}/g, "\n\n").trim();
1096
+ const ctEntry = Object.entries(responseHeaders).find(([k]) => k.toLowerCase() === "content-type");
1097
+ const isSSEPayload =
1098
+ (typeof ctEntry?.[1] === "string" && ((ctEntry[1] as string).includes("event-stream") || (ctEntry[1] as string).startsWith("text/"))) ||
1099
+ framesSeen > 0 ||
1100
+ badFrames > 0;
1101
+ if (!hasTerminal && !text && !cleanThinkingEarly && toolCalls.length === 0 && isSSEPayload) {
1102
+ eventStream.fail(incompleteStreamError("openai", clean));
1103
+ return;
1104
+ }
1105
+ // Refuse unfinished tool calls whose args never parsed (output_item.done
1106
+ // never arrived): truncated `{raw}` must not reach the executor (pi
1107
+ // api/openai-responses-shared.ts:766 rule). Non-SSE mocks preserve legacy.
1108
+ if (!hasTerminal && toolCalls.some((c) => hasTruncatedToolArguments(c.arguments)) && isSSEPayload) {
1109
+ eventStream.fail(incompleteStreamError("openai", clean));
1110
+ return;
1111
+ }
1112
+
1113
+ const cleanThinking = cleanThinkingEarly;
922
1114
  const finalResponse = new AgentResponse({
923
1115
  text,
924
1116
  thinking: cleanThinking || undefined,
@@ -929,7 +1121,10 @@ export class OpenAIResponsesProvider implements Provider {
929
1121
  responseId,
930
1122
  model: clean,
931
1123
  provider: "openai",
932
- raw: { request: rawRequest, response: { ...responseMeta, body: completedBody } },
1124
+ raw: {
1125
+ request: rawRequest,
1126
+ response: { ...responseMeta, body: completedBody, badFrames } as unknown as ProviderRawData["response"],
1127
+ },
933
1128
  durationMs: Date.now() - startTime,
934
1129
  });
935
1130
  eventStream.push({ type: "done", delta: "", usage, finishReason, responseId });