agent-accelerator 0.5.5 → 0.5.6

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.
package/README.md CHANGED
@@ -713,9 +713,12 @@ Thinking levels map to `reasoning.effort` verbatim, the same as OpenAI (`dynamic
713
713
  It also:
714
714
 
715
715
  * passes `service_tier` (`flex` | `priority`);
716
- * enables the `file-parser` plugin when file parts are present (remote file URLs are additionally surfaced in text);
716
+ * sends audio with its real format (`wav`, `mp3`, `aiff`, `aac`, `ogg`, `flac`, `m4a`) and QuickTime video as `video/mov`;
717
+ * sends file parts as `file.file_data` (a public URL or a base64 data URL) and never adds a plugin: OpenRouter reads the file natively or with its own default parser;
717
718
  * maps `tool_choice` (`auto` omitted; `none`, `required`, and function pins supported);
718
- * reports `cached_tokens` / `cache_write_tokens` / reasoning tokens, preferring provider-reported `cost` when present.
719
+ * treats an error chunk inside a stream (HTTP 200) like an HTTP error: its status comes from the chunk's `code`, or from its `error_type` (`rate_limit_exceeded` 429, `provider_overloaded` 503, `provider_unavailable` 502, `timeout` 504, `server` 500), so `runRetries` retries the retryable ones;
720
+ * reports `cached_tokens` / `cache_write_tokens` / reasoning tokens, preferring provider-reported `cost` when present (a reported `0`, a free model or a cache hit, stays a known `$0`);
721
+ * accepts `anthropic/...` and `~anthropic/...` model names for the `5m` / `1h` cache setting, and sends at most 4 `stop` sequences (the Chat Completions maximum; extra ones are dropped with one `provider_warning`).
719
722
 
720
723
  `serviceTier` values other than `flex` / `priority` are omitted (standard routing).
721
724
 
@@ -1300,8 +1303,11 @@ documents. The supported lists are the `GOOGLE_*_MIME` constants at the top of
1300
1303
  `resolution` (`MediaResolution`: `"low" | "medium" | "high" | "ultra_high"`)
1301
1304
  sets how much detail Google spends on one image or video, and so how many
1302
1305
  input tokens it costs (image: 280 / 560 / 1120 / 2240; video per frame:
1303
- `low` and `medium` 70, `high` 280). Omit it for Google's default. Other
1304
- providers ignore it with one `provider_warning`.
1306
+ `low` and `medium` 70, `high` 280). Omit it for Google's default. On
1307
+ OpenRouter an image's `resolution` is sent as `image_url.detail` (`low` → `low`,
1308
+ `medium` → `auto`, `high` → `high`, `ultra_high` → `original`, which OpenRouter
1309
+ downgrades to `high` where unsupported); a video's `resolution` is ignored. Other
1310
+ providers (and OpenRouter videos) ignore it with one `provider_warning`.
1305
1311
 
1306
1312
  Before any network call, the executor checks `image` / `audio` / `video` /
1307
1313
  `file`-as-PDF parts against the catalog's `modalities.input` for that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-accelerator",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
4
4
  "description": "High-performance SDK for AI agents with a unified API across AI providers. Optimized for high cache hit rates, observability, and seamless agent orchestration. Built to power production-grade agentic systems and harnesses for teams at any scale.",
5
5
  "license": "MIT",
6
6
  "author": "Sashvat Bharat",
package/src/agent/loop.ts CHANGED
@@ -418,7 +418,7 @@ async function generateWithTimeout<T>(task: () => Promise<T>, timeoutMs: number)
418
418
  /** Prices one usage snapshot with the one cost model (`priceUsage`): provider-reported total first, else catalog pricing scaled by `serviceTier`; undefined when unknown. In: loop turns and `accumulateUsage`, out: `usage.cost`. */
419
419
  export function computeCostFromPricing(usage: TokenUsage, spec?: ModelSpec, serviceTier?: string): TokenUsage["cost"] {
420
420
  try {
421
- if (usage.cost && usage.cost.totalCost !== undefined && usage.cost.totalCost > 0) {
421
+ if (usage.cost && typeof usage.cost.totalCost === "number" && usage.cost.totalCost >= 0) {
422
422
  return usage.cost;
423
423
  }
424
424
  if (!spec) return usage.cost;
@@ -58,6 +58,8 @@ import {
58
58
  retryOptionsFrom,
59
59
  headersToRecord,
60
60
  parseRetryAfterMs,
61
+ linkStreamAbort,
62
+ withAbortTimer,
61
63
  } from "./shared.ts";
62
64
  import { telemetryBus } from "../telemetry.ts";
63
65
  import { createGenericModelSpec } from "../models/catalog.ts";
@@ -1097,23 +1099,7 @@ export class GoogleInteractionsProvider implements Provider {
1097
1099
  const startTime = Date.now();
1098
1100
  const clean = cleanModelId(model);
1099
1101
 
1100
- const linked = new AbortController();
1101
- const forwardUserAbort = () => {
1102
- try {
1103
- linked.abort((options?.signal as { reason?: unknown })?.reason);
1104
- } catch {
1105
- try {
1106
- linked.abort();
1107
- } catch {}
1108
- }
1109
- };
1110
- if (options?.signal?.aborted) forwardUserAbort();
1111
- else options?.signal?.addEventListener("abort", forwardUserAbort, { once: true });
1112
- const removeStreamCancel = eventStream.onCancel(() => {
1113
- try {
1114
- linked.abort();
1115
- } catch {}
1116
- });
1102
+ const { linked, release } = linkStreamAbort(eventStream, options?.signal);
1117
1103
 
1118
1104
  (async () => {
1119
1105
  // Hoisted for the catch-block self-heal (consts inside try are not
@@ -1159,21 +1145,7 @@ export class GoogleInteractionsProvider implements Provider {
1159
1145
  // (429/5xx/network) are retried before the first byte; a live stream is never retried.
1160
1146
  const open = (target: string, method: "POST" | "GET"): Promise<Response> =>
1161
1147
  openStreamResponse({
1162
- fetchOnce: async () => {
1163
- const timer =
1164
- streamTimeoutMs && streamTimeoutMs > 0
1165
- ? setTimeout(() => {
1166
- try {
1167
- linked.abort();
1168
- } catch {}
1169
- }, streamTimeoutMs)
1170
- : undefined;
1171
- try {
1172
- return await (options?.fetch ?? fetch)(target, this.requestInit(method, method === "POST" ? body : undefined, options, apiKey, linked.signal));
1173
- } finally {
1174
- if (timer) clearTimeout(timer);
1175
- }
1176
- },
1148
+ fetchOnce: () => withAbortTimer(linked, streamTimeoutMs, () => (options?.fetch ?? fetch)(target, this.requestInit(method, method === "POST" ? body : undefined, options, apiKey, linked.signal))),
1177
1149
  onHttpError: (r, text) => this.throwIfError(r.status, r.statusText, target, text, clean, headersToRecord(r.headers)),
1178
1150
  noBodyError: () => toConciseProviderError(new Error("Google streaming response had no body"), "google", clean),
1179
1151
  retry: retryOptionsFrom(options),
@@ -1210,7 +1182,7 @@ export class GoogleInteractionsProvider implements Provider {
1210
1182
  () => {
1211
1183
  aborted = true;
1212
1184
  try {
1213
- void reader?.cancel();
1185
+ void reader?.cancel().catch(() => {}); // an aborted body is already errored: its cancel() rejects, and nobody else listens
1214
1186
  } catch {}
1215
1187
  },
1216
1188
  { once: true }
@@ -1465,12 +1437,7 @@ export class GoogleInteractionsProvider implements Provider {
1465
1437
  eventStream.fail(raw);
1466
1438
  }
1467
1439
  } finally {
1468
- try {
1469
- options?.signal?.removeEventListener("abort", forwardUserAbort);
1470
- } catch {}
1471
- try {
1472
- removeStreamCancel();
1473
- } catch {}
1440
+ release();
1474
1441
  }
1475
1442
  })();
1476
1443
 
@@ -52,7 +52,6 @@ import type { ProviderContext, ContentPart } from "../types/message.ts";
52
52
  import type { StandardToolDeclaration, ToolCallRecord } from "../types/tool.ts";
53
53
  import type { TokenUsage, ThinkingConfig } from "../types/core.ts";
54
54
  import { AssistantMessageEventStream } from "../streaming/event-stream.ts";
55
- import { SSEParser } from "../streaming/sse-parser.ts";
56
55
  import { AgentResponse } from "../types/response.ts";
57
56
  import { getApiKey, getEnv } from "../utils/env.ts";
58
57
  import { buildSessionHeaders, setHeaderCaseInsensitive, shouldEchoSignature } from "../utils/headers.ts";
@@ -68,7 +67,14 @@ import {
68
67
  redactedHeaders,
69
68
  readErrorPayload,
70
69
  incompleteStreamError,
71
- hasTruncatedToolArguments,
70
+ hasUnfinishedToolCall,
71
+ linkStreamAbort,
72
+ withAbortTimer,
73
+ readSseData,
74
+ incompleteStreamCheck,
75
+ failStream,
76
+ toolArgumentsJson,
77
+ toolTurnFinishReason,
72
78
  combinedSignal,
73
79
  timeoutFor,
74
80
  hasAuthHeader,
@@ -450,7 +456,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
450
456
  } = {
451
457
  id: clampToolCallId(part.callId || part.id),
452
458
  type: "function",
453
- function: { name: part.name, arguments: JSON.stringify(part.arguments || {}) },
459
+ function: { name: part.name, arguments: toolArgumentsJson(part.arguments) },
454
460
  };
455
461
  // Echo provider-issued thought signatures verbatim so endpoints
456
462
  // that minted them (Gemini behind a compat proxy) keep working.
@@ -741,7 +747,7 @@ export class OpenAICompatibleChatProvider implements Provider {
741
747
  const compatTools = toCompatTools(tools);
742
748
  if (compatTools) body.tools = compatTools;
743
749
  // Standard Chat Completions generation fields, only when the caller set them.
744
- Object.assign(body, mapGenerationParamsToChat(options));
750
+ Object.assign(body, mapGenerationParamsToChat(options, this.prefix, `${this.prefix}/${modelId}`));
745
751
  const toolChoice = mapToolChoiceToOpenAI(
746
752
  options?.toolChoice as "auto" | "none" | "required" | { type: "function"; function: { name: string } } | undefined
747
753
  );
@@ -949,23 +955,7 @@ export class OpenAICompatibleChatProvider implements Provider {
949
955
  const prefix = this.prefix;
950
956
  const clean = cleanModelId(this.prefix, model);
951
957
 
952
- const linked = new AbortController();
953
- const forwardUserAbort = () => {
954
- try {
955
- linked.abort((options?.signal as { reason?: unknown })?.reason);
956
- } catch {
957
- try {
958
- linked.abort();
959
- } catch {}
960
- }
961
- };
962
- if (options?.signal?.aborted) forwardUserAbort();
963
- else options?.signal?.addEventListener("abort", forwardUserAbort, { once: true });
964
- const removeStreamCancel = eventStream.onCancel(() => {
965
- try {
966
- linked.abort();
967
- } catch {}
968
- });
958
+ const { linked, release } = linkStreamAbort(eventStream, options?.signal);
969
959
 
970
960
  (async () => {
971
961
  try {
@@ -994,21 +984,7 @@ export class OpenAICompatibleChatProvider implements Provider {
994
984
  // Transient failures (429/5xx/network) are retried here, before the
995
985
  // first byte; once the stream is live it is never retried.
996
986
  const res = await openStreamResponse({
997
- fetchOnce: async () => {
998
- const timer =
999
- streamTimeoutMs && streamTimeoutMs > 0
1000
- ? setTimeout(() => {
1001
- try {
1002
- linked.abort();
1003
- } catch {}
1004
- }, streamTimeoutMs)
1005
- : undefined;
1006
- try {
1007
- return await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
1008
- } finally {
1009
- if (timer) clearTimeout(timer);
1010
- }
1011
- },
987
+ fetchOnce: () => withAbortTimer(linked, streamTimeoutMs, () => (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, linked.signal))),
1012
988
  onHttpError: (r, text) => this.throwIfError(r.status, url, text, clean, headersToRecord(r.headers)),
1013
989
  noBodyError: () => toConciseProviderError(new Error("OpenAI-compatible streaming response had no body"), prefix, clean),
1014
990
  retry: retryOptionsFrom(options),
@@ -1022,9 +998,6 @@ export class OpenAICompatibleChatProvider implements Provider {
1022
998
  const responseMeta: Record<string, unknown> & { status: number; statusText: string; headers: Record<string, string> } = { status: res.status, statusText: res.statusText, headers: responseHeaders };
1023
999
  eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
1024
1000
 
1025
- const parser = new SSEParser();
1026
- const reader = res.body!.getReader(); // openStreamResponse guarantees a body
1027
- const decoder = new TextDecoder();
1028
1001
  let text = "";
1029
1002
  let thinking = "";
1030
1003
  // Tool calls keyed by choice index: { id, name, startArgs, deltaArgs }.
@@ -1040,18 +1013,6 @@ export class OpenAICompatibleChatProvider implements Provider {
1040
1013
  let framesSeen = 0;
1041
1014
  let responseId: string | undefined;
1042
1015
  let completedBody: unknown = undefined;
1043
- let aborted = false;
1044
- linked.signal.addEventListener(
1045
- "abort",
1046
- () => {
1047
- aborted = true;
1048
- try {
1049
- void reader.cancel();
1050
- } catch {}
1051
- },
1052
- { once: true }
1053
- );
1054
-
1055
1016
  const handleMessage = (data: string): void => {
1056
1017
  if (!data) return;
1057
1018
  if (data === "[DONE]") {
@@ -1176,26 +1137,7 @@ export class OpenAICompatibleChatProvider implements Provider {
1176
1137
  }
1177
1138
  };
1178
1139
 
1179
- while (true) {
1180
- if (linked.signal.aborted || eventStream.isCancelled()) {
1181
- try {
1182
- await reader.cancel();
1183
- } catch {}
1184
- throw Object.assign(new Error("Stream aborted"), { name: "AbortError" });
1185
- }
1186
- const { done, value } = await reader.read();
1187
- if (done) break;
1188
- const chunk = decoder.decode(value, { stream: true });
1189
- for (const m of parser.feed(chunk)) handleMessage(m.data);
1190
- }
1191
- for (const m of parser.flush()) handleMessage(m.data);
1192
- try {
1193
- reader.releaseLock();
1194
- } catch {}
1195
-
1196
- if (linked.signal.aborted || eventStream.isCancelled() || aborted) {
1197
- throw Object.assign(new Error("Stream aborted"), { name: "AbortError" });
1198
- }
1140
+ await readSseData(res.body!, linked, eventStream, handleMessage); // openStreamResponse guarantees a body
1199
1141
 
1200
1142
  const toolCalls: ToolCallRecord[] = [];
1201
1143
  for (const [index, c] of calls) {
@@ -1212,9 +1154,11 @@ export class OpenAICompatibleChatProvider implements Provider {
1212
1154
  eventStream.push({ type: "tool_call_complete", toolCall: record });
1213
1155
  }
1214
1156
 
1157
+ // Did the stream really end? Read before the tool-call and refusal overrides below, which also set sawFinishReason.
1158
+ const hasTerminal = completedBody !== undefined || sawFinishReason || sawUsage;
1215
1159
  noteProviderTurn(sessionId, prefix);
1216
1160
  if (toolCalls.length > 0) {
1217
- finishReason = "tool_calls";
1161
+ finishReason = toolTurnFinishReason(finishReason, toolCalls);
1218
1162
  sawFinishReason = true;
1219
1163
  } else if (refusalSeen) {
1220
1164
  finishReason = "refusal";
@@ -1224,26 +1168,21 @@ export class OpenAICompatibleChatProvider implements Provider {
1224
1168
  // Q-23: terminal-state guard — fail `incomplete_stream` (retryable)
1225
1169
  // instead of resolving empty success. Skipped for non-SSE payloads
1226
1170
  // (JSON mocks) where no SSE frames were ever seen.
1227
- const hasTerminal = completedBody !== undefined || sawFinishReason || sawUsage;
1228
- const cleanThinkingEarly = thinking.replace(/\n{3,}/g, "\n\n").trim();
1229
- const ctEntry = Object.entries(responseHeaders).find(([k]) => k.toLowerCase() === "content-type");
1230
- const isSSEPayload =
1231
- (typeof ctEntry?.[1] === "string" && ((ctEntry[1] as string).includes("event-stream") || (ctEntry[1] as string).startsWith("text/"))) ||
1232
- framesSeen > 0 ||
1233
- badFrames > 0;
1234
- if (!hasTerminal && !text && !cleanThinkingEarly && toolCalls.length === 0 && isSSEPayload) {
1235
- eventStream.fail(incompleteStreamError(prefix, clean));
1236
- return;
1237
- }
1238
- // Refuse unfinished tool calls whose args never parsed (output_item.done
1239
- // never arrived): truncated `{raw}` must not reach the executor (pi
1240
- // api/openai-responses-shared.ts:766 rule). Non-SSE mocks preserve legacy.
1241
- if (!hasTerminal && toolCalls.some((c) => hasTruncatedToolArguments(c.arguments)) && isSSEPayload) {
1242
- eventStream.fail(incompleteStreamError(prefix, clean));
1171
+ const cleanThinking = thinking.replace(/\n{3,}/g, "\n\n").trim();
1172
+ const incomplete = incompleteStreamCheck({
1173
+ provider: prefix,
1174
+ modelId: clean,
1175
+ hasTerminal,
1176
+ empty: !text && !cleanThinking && toolCalls.length === 0,
1177
+ unfinishedCall: hasUnfinishedToolCall(calls.values(), toolCalls),
1178
+ responseHeaders,
1179
+ framesSeen,
1180
+ badFrames,
1181
+ });
1182
+ if (incomplete) {
1183
+ eventStream.fail(incomplete);
1243
1184
  return;
1244
1185
  }
1245
-
1246
- const cleanThinking = cleanThinkingEarly;
1247
1186
  const finalThoughtSignature = toolCalls.map((c) => c.thoughtSignature).find(Boolean);
1248
1187
  const finalResponse = new AgentResponse({
1249
1188
  text,
@@ -1264,21 +1203,9 @@ export class OpenAICompatibleChatProvider implements Provider {
1264
1203
  eventStream.push({ type: "done", delta: "", usage, finishReason, responseId });
1265
1204
  eventStream.end(finalResponse);
1266
1205
  } catch (err: unknown) {
1267
- const raw = err instanceof Error ? err : new Error(String(err));
1268
- const isAbort =
1269
- linked.signal.aborted ||
1270
- eventStream.isCancelled() ||
1271
- (raw as { name?: string }).name === "AbortError";
1272
- eventStream.fail(
1273
- isAbort ? Object.assign(new Error("Stream aborted"), { name: "AbortError" }) : raw
1274
- );
1206
+ failStream(eventStream, linked, err);
1275
1207
  } finally {
1276
- try {
1277
- options?.signal?.removeEventListener("abort", forwardUserAbort);
1278
- } catch {}
1279
- try {
1280
- removeStreamCancel();
1281
- } catch {}
1208
+ release();
1282
1209
  }
1283
1210
  })();
1284
1211
 
@@ -44,7 +44,6 @@ import type { ProviderContext, ContentPart } from "../types/message.ts";
44
44
  import type { StandardToolDeclaration, ToolCallRecord } from "../types/tool.ts";
45
45
  import type { TokenUsage } from "../types/core.ts";
46
46
  import { AssistantMessageEventStream } from "../streaming/event-stream.ts";
47
- import { SSEParser } from "../streaming/sse-parser.ts";
48
47
  import { AgentResponse } from "../types/response.ts";
49
48
  import { getApiKey, getEnv } from "../utils/env.ts";
50
49
  import { buildSessionHeaders, setHeaderCaseInsensitive } from "../utils/headers.ts";
@@ -60,7 +59,14 @@ import {
60
59
  redactedHeaders,
61
60
  readErrorPayload,
62
61
  incompleteStreamError,
63
- hasTruncatedToolArguments,
62
+ hasUnfinishedToolCall,
63
+ linkStreamAbort,
64
+ withAbortTimer,
65
+ readSseData,
66
+ incompleteStreamCheck,
67
+ failStream,
68
+ toolArgumentsJson,
69
+ toolTurnFinishReason,
64
70
  combinedSignal,
65
71
  timeoutFor,
66
72
  hasAuthHeader,
@@ -340,7 +346,7 @@ async function fullHistoryInput(context: ProviderContext): Promise<ResponseInput
340
346
  id: normalizeResponsesItemId(itemId),
341
347
  call_id: normalizeResponsesIdPart(callId),
342
348
  name: part.name,
343
- arguments: JSON.stringify(part.arguments || {}),
349
+ arguments: toolArgumentsJson(part.arguments),
344
350
  });
345
351
  } else if (part.type === "text" && part.text) {
346
352
  texts.push(part.text);
@@ -776,23 +782,7 @@ export class OpenAIResponsesProvider implements Provider {
776
782
  const startTime = Date.now();
777
783
  const clean = cleanModelId(model);
778
784
 
779
- const linked = new AbortController();
780
- const forwardUserAbort = () => {
781
- try {
782
- linked.abort((options?.signal as { reason?: unknown })?.reason);
783
- } catch {
784
- try {
785
- linked.abort();
786
- } catch {}
787
- }
788
- };
789
- if (options?.signal?.aborted) forwardUserAbort();
790
- else options?.signal?.addEventListener("abort", forwardUserAbort, { once: true });
791
- const removeStreamCancel = eventStream.onCancel(() => {
792
- try {
793
- linked.abort();
794
- } catch {}
795
- });
785
+ const { linked, release } = linkStreamAbort(eventStream, options?.signal);
796
786
 
797
787
  (async () => {
798
788
  try {
@@ -827,21 +817,7 @@ export class OpenAIResponsesProvider implements Provider {
827
817
  // Transient failures (429/5xx/network) are retried here, before the
828
818
  // first byte; once the stream is live it is never retried.
829
819
  const res = await openStreamResponse({
830
- fetchOnce: async () => {
831
- const timer =
832
- streamTimeoutMs && streamTimeoutMs > 0
833
- ? setTimeout(() => {
834
- try {
835
- linked.abort();
836
- } catch {}
837
- }, streamTimeoutMs)
838
- : undefined;
839
- try {
840
- return await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
841
- } finally {
842
- if (timer) clearTimeout(timer);
843
- }
844
- },
820
+ fetchOnce: () => withAbortTimer(linked, streamTimeoutMs, () => (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, linked.signal))),
845
821
  onHttpError: (r, text) => this.throwIfError(r.status, url, text, clean, headersToRecord(r.headers)),
846
822
  noBodyError: () => toConciseProviderError(new Error("OpenAI streaming response had no body"), "openai", clean),
847
823
  retry: retryOptionsFrom(options),
@@ -855,9 +831,6 @@ export class OpenAIResponsesProvider implements Provider {
855
831
  const responseMeta: Record<string, unknown> & { status: number; statusText: string; headers: Record<string, string> } = { status: res.status, statusText: res.statusText, headers: responseHeaders };
856
832
  eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
857
833
 
858
- const parser = new SSEParser();
859
- const reader = res.body!.getReader(); // openStreamResponse guarantees a body
860
- const decoder = new TextDecoder();
861
834
  let text = "";
862
835
  let thinking = "";
863
836
  // Tool calls keyed by output_index: { itemId, callId, name, startArgs, deltaArgs }.
@@ -871,18 +844,6 @@ export class OpenAIResponsesProvider implements Provider {
871
844
  let framesSeen = 0;
872
845
  let responseId: string | undefined;
873
846
  let completedBody: unknown = undefined;
874
- let aborted = false;
875
- linked.signal.addEventListener(
876
- "abort",
877
- () => {
878
- aborted = true;
879
- try {
880
- void reader.cancel();
881
- } catch {}
882
- },
883
- { once: true }
884
- );
885
-
886
847
  const handleMessage = (data: string): void => {
887
848
  if (!data || data === "[DONE]") {
888
849
  if (data === "[DONE]") sawFinishReason = sawFinishReason || completedBody !== undefined || sawUsage;
@@ -1035,26 +996,7 @@ export class OpenAIResponsesProvider implements Provider {
1035
996
  }
1036
997
  };
1037
998
 
1038
- while (true) {
1039
- if (linked.signal.aborted || eventStream.isCancelled()) {
1040
- try {
1041
- await reader.cancel();
1042
- } catch {}
1043
- throw Object.assign(new Error("Stream aborted"), { name: "AbortError" });
1044
- }
1045
- const { done, value } = await reader.read();
1046
- if (done) break;
1047
- const chunk = decoder.decode(value, { stream: true });
1048
- for (const m of parser.feed(chunk)) handleMessage(m.data);
1049
- }
1050
- for (const m of parser.flush()) handleMessage(m.data);
1051
- try {
1052
- reader.releaseLock();
1053
- } catch {}
1054
-
1055
- if (linked.signal.aborted || eventStream.isCancelled() || aborted) {
1056
- throw Object.assign(new Error("Stream aborted"), { name: "AbortError" });
1057
- }
999
+ await readSseData(res.body!, linked, eventStream, handleMessage); // openStreamResponse guarantees a body
1058
1000
 
1059
1001
  const toolCalls: ToolCallRecord[] = [];
1060
1002
  for (const c of calls.values()) {
@@ -1070,9 +1012,11 @@ export class OpenAIResponsesProvider implements Provider {
1070
1012
  eventStream.push({ type: "tool_call_complete", toolCall: record });
1071
1013
  }
1072
1014
 
1015
+ // Did the stream really end? Read before the tool-call and refusal overrides below, which also set sawFinishReason.
1016
+ const hasTerminal = completedBody !== undefined || sawFinishReason || sawUsage;
1073
1017
  noteProviderTurn(sessionId, "openai");
1074
1018
  if (toolCalls.length > 0) {
1075
- finishReason = "tool_calls";
1019
+ finishReason = toolTurnFinishReason(finishReason, toolCalls);
1076
1020
  sawFinishReason = true;
1077
1021
  } else if (refusalSeen && finishReason !== "length") {
1078
1022
  finishReason = "refusal";
@@ -1085,26 +1029,21 @@ export class OpenAIResponsesProvider implements Provider {
1085
1029
  // Skipped for non-SSE payloads (e.g. JSON mocks in delegation tests)
1086
1030
  // where no SSE frames were ever seen — those preserve legacy empty
1087
1031
  // success rather than failing the caller on a mock artifact.
1088
- const hasTerminal = completedBody !== undefined || sawFinishReason || sawUsage;
1089
- const cleanThinkingEarly = thinking.replace(/\n{3,}/g, "\n\n").trim();
1090
- const ctEntry = Object.entries(responseHeaders).find(([k]) => k.toLowerCase() === "content-type");
1091
- const isSSEPayload =
1092
- (typeof ctEntry?.[1] === "string" && ((ctEntry[1] as string).includes("event-stream") || (ctEntry[1] as string).startsWith("text/"))) ||
1093
- framesSeen > 0 ||
1094
- badFrames > 0;
1095
- if (!hasTerminal && !text && !cleanThinkingEarly && toolCalls.length === 0 && isSSEPayload) {
1096
- eventStream.fail(incompleteStreamError("openai", clean));
1097
- return;
1098
- }
1099
- // Refuse unfinished tool calls whose args never parsed (output_item.done
1100
- // never arrived): truncated `{raw}` must not reach the executor (pi
1101
- // api/openai-responses-shared.ts:766 rule). Non-SSE mocks preserve legacy.
1102
- if (!hasTerminal && toolCalls.some((c) => hasTruncatedToolArguments(c.arguments)) && isSSEPayload) {
1103
- eventStream.fail(incompleteStreamError("openai", clean));
1032
+ const cleanThinking = thinking.replace(/\n{3,}/g, "\n\n").trim();
1033
+ const incomplete = incompleteStreamCheck({
1034
+ provider: "openai",
1035
+ modelId: clean,
1036
+ hasTerminal,
1037
+ empty: !text && !cleanThinking && toolCalls.length === 0,
1038
+ unfinishedCall: hasUnfinishedToolCall(calls.values(), toolCalls),
1039
+ responseHeaders,
1040
+ framesSeen,
1041
+ badFrames,
1042
+ });
1043
+ if (incomplete) {
1044
+ eventStream.fail(incomplete);
1104
1045
  return;
1105
1046
  }
1106
-
1107
- const cleanThinking = cleanThinkingEarly;
1108
1047
  const finalResponse = new AgentResponse({
1109
1048
  text,
1110
1049
  thinking: cleanThinking || undefined,
@@ -1124,21 +1063,9 @@ export class OpenAIResponsesProvider implements Provider {
1124
1063
  eventStream.push({ type: "done", delta: "", usage, finishReason, responseId });
1125
1064
  eventStream.end(finalResponse);
1126
1065
  } catch (err: unknown) {
1127
- const raw = err instanceof Error ? err : new Error(String(err));
1128
- const isAbort =
1129
- linked.signal.aborted ||
1130
- eventStream.isCancelled() ||
1131
- (raw as { name?: string }).name === "AbortError";
1132
- eventStream.fail(
1133
- isAbort ? Object.assign(new Error("Stream aborted"), { name: "AbortError" }) : raw
1134
- );
1066
+ failStream(eventStream, linked, err);
1135
1067
  } finally {
1136
- try {
1137
- options?.signal?.removeEventListener("abort", forwardUserAbort);
1138
- } catch {}
1139
- try {
1140
- removeStreamCancel();
1141
- } catch {}
1068
+ release();
1142
1069
  }
1143
1070
  })();
1144
1071