agent-accelerator 0.2.0 → 0.4.0

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,12 +46,22 @@ 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
+ combinedSignal,
63
+ timeoutFor,
64
+ } from "./shared.ts";
55
65
  import { createGenericModelSpec } from "../models/catalog.ts";
56
66
  import { getModelFromCatalog, getModelsForProvider } from "../models/catalog.ts";
57
67
  import {
@@ -131,6 +141,12 @@ interface ResponsesObject {
131
141
 
132
142
  const DEFAULT_BASE_URL = "https://api.openai.com/v1";
133
143
 
144
+ /** Q-18: Responses transport never echoes thought signatures (no wire field).
145
+ * Local helper documents the namespacing decision per provider file. */
146
+ function withProviderSignature(_sig: string | undefined): { thoughtSignature?: undefined } {
147
+ return {};
148
+ }
149
+
134
150
  /** Internal headers that must never leak onto native REST requests. */
135
151
  const INTERNAL_HEADERS = new Set([
136
152
  "x-thought-signature-map",
@@ -305,7 +321,7 @@ async function fullHistoryInput(context: ProviderContext): Promise<ResponseInput
305
321
  const queue = (m.name && idsByName.get(m.name)) || [];
306
322
  items.push({
307
323
  type: "function_call_output",
308
- call_id: queue.shift() || "call_0",
324
+ call_id: queue.shift() || newToolCallId(),
309
325
  output: m.content,
310
326
  });
311
327
  continue;
@@ -345,27 +361,6 @@ function mapUsage(raw?: ResponsesObject["usage"]): TokenUsage {
345
361
  };
346
362
  }
347
363
 
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
364
  function parseResponse(
370
365
  response: ResponsesObject,
371
366
  modelId: string,
@@ -384,6 +379,7 @@ function parseResponse(
384
379
  }
385
380
 
386
381
  let text = "";
382
+ let refusalText = "";
387
383
  const thinkingParts: string[] = [];
388
384
  const toolCalls: ToolCallRecord[] = [];
389
385
 
@@ -391,6 +387,15 @@ function parseResponse(
391
387
  if (item.type === "message") {
392
388
  for (const block of item.content ?? []) {
393
389
  if (block.type === "output_text" && block.text) text += block.text;
390
+ else if (block.type === "refusal" && block.text) {
391
+ text += block.text;
392
+ refusalText += block.text;
393
+ }
394
+ }
395
+ const itemRefusal = (item as { refusal?: unknown }).refusal;
396
+ if (typeof itemRefusal === "string" && itemRefusal) {
397
+ text += itemRefusal;
398
+ refusalText += itemRefusal;
394
399
  }
395
400
  } else if (item.type === "reasoning") {
396
401
  for (const block of item.content ?? []) {
@@ -404,7 +409,7 @@ function parseResponse(
404
409
  } else if (item.type === "function_call") {
405
410
  const args = parseArguments(item.arguments);
406
411
  toolCalls.push({
407
- id: item.id || item.call_id || `call_${Math.random().toString(36).slice(2, 9)}`,
412
+ id: item.id || item.call_id || newToolCallId(),
408
413
  callId: item.call_id,
409
414
  name: item.name || "unknown",
410
415
  arguments: args,
@@ -413,6 +418,17 @@ function parseResponse(
413
418
  }
414
419
  }
415
420
 
421
+ // Q-23: `incomplete` -> length (content-filter truncations surface as
422
+ // refusal when refusal text is present).
423
+ const incompleteReason = (response as { incomplete_details?: { reason?: string } }).incomplete_details?.reason;
424
+ const finishReason =
425
+ toolCalls.length > 0
426
+ ? "tool_calls"
427
+ : refusalText || incompleteReason === "content_filter"
428
+ ? "refusal"
429
+ : response.status === "incomplete"
430
+ ? "length"
431
+ : "stop";
416
432
  return {
417
433
  text,
418
434
  // Trim/collapse reasoning assembly (provider summaries can trail with
@@ -422,7 +438,7 @@ function parseResponse(
422
438
  thoughtSignature: undefined,
423
439
  toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
424
440
  usage: mapUsage(response.usage),
425
- finishReason: toolCalls.length > 0 ? "tool_calls" : response.status === "incomplete" ? "length" : "stop",
441
+ finishReason,
426
442
  responseId: response.id,
427
443
  model: modelId,
428
444
  provider: "openai",
@@ -431,32 +447,6 @@ function parseResponse(
431
447
  };
432
448
  }
433
449
 
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
450
  // ---------------------------------------------------------------------------
461
451
  // Provider
462
452
  // ---------------------------------------------------------------------------
@@ -545,8 +535,10 @@ export class OpenAIResponsesProvider implements Provider {
545
535
  for (const [k, v] of Object.entries(headers)) {
546
536
  if (INTERNAL_HEADERS.has(k.toLowerCase())) delete headers[k];
547
537
  }
548
- headers["Content-Type"] = "application/json";
549
- headers["Authorization"] = `Bearer ${apiKey}`;
538
+ // Case-insensitive merge so a lowercase `authorization` custom header
539
+ // never coexists with the canonical bearer.
540
+ setHeaderCaseInsensitive(headers, "Content-Type", "application/json");
541
+ setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
550
542
  return { method: "POST", headers, body: JSON.stringify(body), signal };
551
543
  }
552
544
 
@@ -558,13 +550,18 @@ export class OpenAIResponsesProvider implements Provider {
558
550
  sessionId: string | undefined,
559
551
  signal?: AbortSignal
560
552
  ): 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 };
553
+ const { signal: effective, cleanup } = combinedSignal(signal, timeoutFor(options as { timeoutMs?: number } | undefined));
554
+ try {
555
+ const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, effective));
556
+ const text = await res.text();
557
+ const headers: Record<string, string> = {};
558
+ res.headers.forEach((v, k) => {
559
+ headers[k] = v;
560
+ });
561
+ return { status: res.status, statusText: res.statusText, headers, text };
562
+ } finally {
563
+ cleanup();
564
+ }
568
565
  }
569
566
 
570
567
  private throwIfError(
@@ -728,7 +725,22 @@ export class OpenAIResponsesProvider implements Provider {
728
725
  body,
729
726
  };
730
727
 
731
- const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
728
+ // Q-17: optional per-call timeout races the linked abort signal.
729
+ const streamTimeoutMs = timeoutFor(options as { timeoutMs?: number } | undefined);
730
+ const streamTimeoutTimer =
731
+ streamTimeoutMs && streamTimeoutMs > 0
732
+ ? setTimeout(() => {
733
+ try {
734
+ linked.abort();
735
+ } catch {}
736
+ }, streamTimeoutMs)
737
+ : undefined;
738
+ let res: Response;
739
+ try {
740
+ res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
741
+ } finally {
742
+ if (streamTimeoutTimer) clearTimeout(streamTimeoutTimer);
743
+ }
732
744
  if (!res.ok || !res.body) {
733
745
  const text = !res.ok ? await res.text().catch(() => "") : "";
734
746
  if (!res.ok) this.throwIfError(res.status, url, text, clean);
@@ -738,7 +750,7 @@ export class OpenAIResponsesProvider implements Provider {
738
750
  res.headers.forEach((v, k) => {
739
751
  responseHeaders[k] = v;
740
752
  });
741
- const responseMeta = { status: res.status, statusText: res.statusText, headers: responseHeaders };
753
+ const responseMeta: Record<string, unknown> & { status: number; statusText: string; headers: Record<string, string> } = { status: res.status, statusText: res.statusText, headers: responseHeaders };
742
754
  eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
743
755
 
744
756
  const parser = new SSEParser();
@@ -750,6 +762,11 @@ export class OpenAIResponsesProvider implements Provider {
750
762
  const calls = new Map<number, { itemId: string; callId: string; name: string; startArgs: string; deltaArgs: string }>();
751
763
  let usage: TokenUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
752
764
  let finishReason = "stop";
765
+ let sawFinishReason = false;
766
+ let sawUsage = false;
767
+ let refusalSeen = false;
768
+ let badFrames = 0;
769
+ let framesSeen = 0;
753
770
  let responseId: string | undefined;
754
771
  let completedBody: unknown = undefined;
755
772
  let aborted = false;
@@ -765,11 +782,16 @@ export class OpenAIResponsesProvider implements Provider {
765
782
  );
766
783
 
767
784
  const handleMessage = (data: string): void => {
768
- if (!data || data === "[DONE]") return;
785
+ if (!data || data === "[DONE]") {
786
+ if (data === "[DONE]") sawFinishReason = sawFinishReason || completedBody !== undefined || sawUsage;
787
+ return;
788
+ }
769
789
  let msg: Record<string, unknown>;
770
790
  try {
771
791
  msg = JSON.parse(data) as Record<string, unknown>;
792
+ framesSeen += 1;
772
793
  } catch {
794
+ badFrames += 1;
773
795
  return;
774
796
  }
775
797
  const type = msg["type"] as string;
@@ -790,6 +812,17 @@ export class OpenAIResponsesProvider implements Provider {
790
812
  } else if (type === "response.output_text.delta" && typeof msg["delta"] === "string") {
791
813
  text += msg["delta"] as string;
792
814
  eventStream.push({ type: "text_delta", delta: msg["delta"] as string, partialText: text });
815
+ } else if (
816
+ type === "response.refusal.delta" ||
817
+ type === "response.output_refusal.delta"
818
+ ) {
819
+ // Q-23: refusal deltas carry the filter text; surface as text and
820
+ // remember for a `refusal` finish reason.
821
+ if (typeof msg["delta"] === "string" && msg["delta"]) {
822
+ text += msg["delta"] as string;
823
+ refusalSeen = true;
824
+ eventStream.push({ type: "text_delta", delta: msg["delta"] as string, partialText: text });
825
+ }
793
826
  } else if (
794
827
  (type === "response.reasoning_text.delta" || type === "response.reasoning.delta") &&
795
828
  typeof msg["delta"] === "string"
@@ -827,7 +860,11 @@ export class OpenAIResponsesProvider implements Provider {
827
860
  const response = (msg["response"] as ResponsesObject) ?? {};
828
861
  completedBody = msg["response"];
829
862
  if (response.id) responseId = response.id;
830
- if (response.usage) usage = mapUsage(response.usage);
863
+ if (response.usage) {
864
+ usage = mapUsage(response.usage);
865
+ sawUsage = true;
866
+ }
867
+ sawFinishReason = true;
831
868
  if (type === "response.failed" || response.status === "failed") {
832
869
  const message =
833
870
  (response.error && typeof response.error.message === "string" && response.error.message) ||
@@ -839,7 +876,12 @@ export class OpenAIResponsesProvider implements Provider {
839
876
  }
840
877
  // Non-streaming maps `incomplete` → `length` (max tokens). Streaming
841
878
  // must do the same — otherwise truncated turns misreport `stop`.
842
- if (response.status === "incomplete") finishReason = "length";
879
+ // Q-23: content-filter incompletes surface as `refusal`.
880
+ const incompleteReason = (response as { incomplete_details?: { reason?: string } }).incomplete_details?.reason;
881
+ if (response.status === "incomplete") {
882
+ finishReason = incompleteReason === "content_filter" ? "refusal" : "length";
883
+ sawFinishReason = true;
884
+ }
843
885
  // Merge any full tool items delivered at completion (authoritative
844
886
  // when present) so nothing depends solely on delta assembly.
845
887
  for (const item of response.output ?? []) {
@@ -876,6 +918,17 @@ export class OpenAIResponsesProvider implements Provider {
876
918
  }
877
919
  }
878
920
  }
921
+ // Merge completed refusal text (filter truncations often arrive only
922
+ // here with no preceding refusal delta).
923
+ for (const item of response.output ?? []) {
924
+ if (item.type !== "message") continue;
925
+ for (const block of item.content ?? []) {
926
+ if (block.type === "refusal" && block.text) {
927
+ if (!text.includes(block.text)) text += block.text;
928
+ refusalSeen = true;
929
+ }
930
+ }
931
+ }
879
932
  eventStream.push({ type: "usage", usage });
880
933
  }
881
934
  };
@@ -905,7 +958,7 @@ export class OpenAIResponsesProvider implements Provider {
905
958
  for (const c of calls.values()) {
906
959
  const args = parseStreamedToolArguments(c.startArgs, c.deltaArgs);
907
960
  const record: ToolCallRecord = {
908
- id: c.itemId || c.callId || `call_${Math.random().toString(36).slice(2, 9)}`,
961
+ id: c.itemId || c.callId || newToolCallId(),
909
962
  callId: c.callId || undefined,
910
963
  name: c.name,
911
964
  arguments: args,
@@ -916,9 +969,33 @@ export class OpenAIResponsesProvider implements Provider {
916
969
  }
917
970
 
918
971
  noteProviderTurn(sessionId, "openai");
919
- if (toolCalls.length > 0) finishReason = "tool_calls";
972
+ if (toolCalls.length > 0) {
973
+ finishReason = "tool_calls";
974
+ sawFinishReason = true;
975
+ } else if (refusalSeen && finishReason !== "length") {
976
+ finishReason = "refusal";
977
+ sawFinishReason = true;
978
+ }
920
979
 
921
- const cleanThinking = thinking.replace(/\n{3,}/g, "\n\n").trim();
980
+ // Q-23: terminal-state guard. Loops ending with no completed body, no
981
+ // finish_reason, and no usage while carrying no content must fail as
982
+ // `incomplete_stream` (retryable) instead of resolving empty success.
983
+ // Skipped for non-SSE payloads (e.g. JSON mocks in delegation tests)
984
+ // where no SSE frames were ever seen — those preserve legacy empty
985
+ // success rather than failing the caller on a mock artifact.
986
+ const hasTerminal = completedBody !== undefined || sawFinishReason || sawUsage;
987
+ const cleanThinkingEarly = thinking.replace(/\n{3,}/g, "\n\n").trim();
988
+ const ctEntry = Object.entries(responseHeaders).find(([k]) => k.toLowerCase() === "content-type");
989
+ const isSSEPayload =
990
+ (typeof ctEntry?.[1] === "string" && ((ctEntry[1] as string).includes("event-stream") || (ctEntry[1] as string).startsWith("text/"))) ||
991
+ framesSeen > 0 ||
992
+ badFrames > 0;
993
+ if (!hasTerminal && !text && !cleanThinkingEarly && toolCalls.length === 0 && isSSEPayload) {
994
+ eventStream.fail(incompleteStreamError("openai", clean));
995
+ return;
996
+ }
997
+
998
+ const cleanThinking = cleanThinkingEarly;
922
999
  const finalResponse = new AgentResponse({
923
1000
  text,
924
1001
  thinking: cleanThinking || undefined,
@@ -929,7 +1006,10 @@ export class OpenAIResponsesProvider implements Provider {
929
1006
  responseId,
930
1007
  model: clean,
931
1008
  provider: "openai",
932
- raw: { request: rawRequest, response: { ...responseMeta, body: completedBody } },
1009
+ raw: {
1010
+ request: rawRequest,
1011
+ response: { ...responseMeta, body: completedBody, badFrames } as unknown as ProviderRawData["response"],
1012
+ },
933
1013
  durationMs: Date.now() - startTime,
934
1014
  });
935
1015
  eventStream.push({ type: "done", delta: "", usage, finishReason, responseId });