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.
@@ -55,11 +55,21 @@ 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 {
64
+ parseArguments,
65
+ normalizeThinkingParts,
66
+ newToolCallId,
67
+ redactedHeaders,
68
+ readErrorPayload,
69
+ incompleteStreamError,
70
+ combinedSignal,
71
+ timeoutFor,
72
+ } from "./shared.ts";
63
73
  import { getModelFromCatalog, createGenericModelSpec } from "../models/catalog.ts";
64
74
  import {
65
75
  applyCacheForCustom,
@@ -189,8 +199,6 @@ function resolveBaseUrl(
189
199
  getEnv(`${envPrefix}_BASE_URL`) ||
190
200
  getEnv(`${envPrefix}_BASEURL`) ||
191
201
  getEnv(`${envPrefix}_API_BASE`) ||
192
- getEnv("OPENAI_BASE_URL") ||
193
- getEnv("OPENAI_API_BASE") ||
194
202
  DEFAULT_BASE_URL
195
203
  ).replace(/\/+$/, "");
196
204
  }
@@ -206,32 +214,36 @@ function resolveApiKey(
206
214
  configuredApiKey ||
207
215
  options?.env?.[`${envPrefix}_API_KEY`] ||
208
216
  options?.env?.[`${envPrefix}_BASE_API_KEY`] ||
209
- options?.env?.["OPENAI_BASE_API_KEY"] ||
210
- options?.env?.["OPENAI_API_KEY"] ||
211
217
  getEnv(`${envPrefix}_API_KEY`) ||
212
218
  getEnv(`${envPrefix}_BASE_API_KEY`) ||
213
- getEnv("OPENAI_BASE_API_KEY") ||
214
- getEnv("OPENAI_API_KEY") ||
215
219
  getApiKey(prefix, undefined, options?.env) ||
216
220
  undefined
217
221
  );
218
222
  }
219
223
 
220
- /** Local endpoints (loopback / LAN / *.local) may omit the API key. */
224
+ /** Local endpoints (loopback / LAN / *.local) may omit the API key. Literal IPs only; hostnames like 10.evil.com are not local. */
221
225
  function isLocalEndpoint(baseUrl: string): boolean {
222
226
  try {
223
- const host = new URL(baseUrl).hostname.toLowerCase();
227
+ let host = new URL(baseUrl).hostname.toLowerCase();
228
+ if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
224
229
  if (
225
230
  host === "localhost" ||
226
231
  host === "127.0.0.1" ||
227
232
  host === "0.0.0.0" ||
228
233
  host === "::1" ||
234
+ host === "0:0:0:0:0:0:0:1" ||
229
235
  host.endsWith(".local")
230
236
  ) {
231
237
  return true;
232
238
  }
233
- if (/^10\./.test(host) || /^192\.168\./.test(host) || /^172\.(1[6-9]|2\d|3[01])\./.test(host)) {
234
- return true;
239
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) {
240
+ const parts = host.split(".").map(Number);
241
+ const [a, b] = parts as number[];
242
+ if (a === 10) return true;
243
+ if (a === 172 && b !== undefined && b >= 16 && b <= 31) return true;
244
+ if (a === 192 && b === 168) return true;
245
+ if (a === 127) return true;
246
+ return false;
235
247
  }
236
248
  return false;
237
249
  } catch {
@@ -362,6 +374,10 @@ function resultToOutput(result: unknown): string {
362
374
  * transport: every turn carries everything). Prior-turn reasoning is NOT
363
375
  * resent — except echoed Gemini thought signatures, which strict
364
376
  * OpenAI-compatible endpoints require on tool calls they generated.
377
+ *
378
+ * Q-18: signatures are namespaced — only echo when the stored provider is
379
+ * `google` (or untagged legacy). Signatures minted by other providers never
380
+ * leak onto this transport.
365
381
  */
366
382
  async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessage[]> {
367
383
  const pairing = new Map<string, string>();
@@ -417,8 +433,9 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
417
433
  // Echo provider-issued thought signatures verbatim so endpoints
418
434
  // that minted them (Gemini behind a compat proxy) keep working.
419
435
  // Only present when a previous turn captured one — other endpoints
420
- // never see this field.
421
- if (part.thoughtSignature) {
436
+ // never see this field. Q-18: echo only google/untagged signatures.
437
+ const storedProvider = (part as { thoughtSignatureProvider?: unknown }).thoughtSignatureProvider;
438
+ if (part.thoughtSignature && shouldEchoSignature(storedProvider, "google")) {
422
439
  call.extra_content = { google: { thought_signature: part.thoughtSignature } };
423
440
  }
424
441
  calls.push(call);
@@ -473,27 +490,6 @@ function mapUsage(raw?: ChatResponse["usage"]): TokenUsage {
473
490
  return usage;
474
491
  }
475
492
 
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
493
  function extractSignature(rawTc: {
498
494
  extra_content?: { google?: { thought_signature?: string } };
499
495
  }): string | undefined {
@@ -501,6 +497,12 @@ function extractSignature(rawTc: {
501
497
  return typeof sig === "string" && sig ? sig : undefined;
502
498
  }
503
499
 
500
+ /** Tags a captured compat signature with its issuing provider (`google`, Q-18). */
501
+ function withCompatSignature(sig: string | undefined): { thoughtSignature?: string; thoughtSignatureProvider?: string } {
502
+ if (!sig) return {};
503
+ return { thoughtSignature: sig, thoughtSignatureProvider: "google" };
504
+ }
505
+
504
506
  function throwResponseError(
505
507
  response: ChatResponse,
506
508
  prefix: string,
@@ -528,7 +530,9 @@ function parseResponse(
528
530
 
529
531
  const choice = response.choices?.[0];
530
532
  const message = choice?.message;
531
- const text = typeof message?.content === "string" ? message.content : "";
533
+ // Q-23: refusal content surfaces as text with a `refusal` finish reason.
534
+ const refusal = typeof message?.refusal === "string" && message.refusal ? message.refusal : "";
535
+ const text = typeof message?.content === "string" && message.content ? message.content : refusal;
532
536
  // Thinking tolerance: plain `reasoning` plus per-block texts (deepseek-style
533
537
  // `reasoning_details`). Details win when both ride along; both are
534
538
  // display-only here — never resent.
@@ -547,25 +551,26 @@ function parseResponse(
547
551
  const args = parseArguments(tc.function?.arguments);
548
552
  const sig = extractSignature(tc);
549
553
  toolCalls.push({
550
- id: tc.id || `call_${Math.random().toString(36).slice(2, 9)}`,
554
+ id: tc.id || newToolCallId(),
551
555
  name: tc.function?.name || "unknown",
552
556
  arguments: args,
553
557
  rawArguments:
554
558
  typeof tc.function?.arguments === "string"
555
559
  ? tc.function.arguments
556
560
  : JSON.stringify(tc.function?.arguments ?? {}),
557
- ...(sig ? { thoughtSignature: sig } : {}),
558
- });
561
+ ...withCompatSignature(sig),
562
+ } as ToolCallRecord);
559
563
  }
560
564
 
561
565
  const wireReason = choice?.finish_reason ?? undefined;
566
+ const finishReason = toolCalls.length > 0 ? "tool_calls" : refusal ? "refusal" : wireReason || "stop";
562
567
  return {
563
568
  text,
564
569
  thinking: normalizeThinkingParts(thinkingParts),
565
570
  thoughtSignature: toolCalls.map((c) => c.thoughtSignature).find(Boolean),
566
571
  toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
567
572
  usage: mapUsage(response.usage),
568
- finishReason: toolCalls.length > 0 ? "tool_calls" : wireReason || "stop",
573
+ finishReason,
569
574
  responseId: response.id,
570
575
  model: modelId,
571
576
  provider: prefix as ProviderId,
@@ -574,28 +579,6 @@ function parseResponse(
574
579
  };
575
580
  }
576
581
 
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
582
  // ---------------------------------------------------------------------------
600
583
  // Provider
601
584
  // ---------------------------------------------------------------------------
@@ -716,6 +699,19 @@ export class OpenAICompatibleChatProvider implements Provider {
716
699
  }
717
700
  // No reasoning / service_tier / session / cache body primitives: strict
718
701
  // endpoints reject unknown properties. Affinity is headers-only.
702
+ // Q-38: request usage on streams where supported so terminal accounting
703
+ // arrives on the final chunk. Strict servers that reject unknown fields
704
+ // opt out via `COMPAT_NO_STREAM_OPTIONS=1`; an explicit per-call
705
+ // `(options as any).streamOptions === false` also skips it.
706
+ if (stream) {
707
+ const noStreamOptions =
708
+ (options as { streamOptions?: unknown } | undefined)?.streamOptions === false ||
709
+ getEnv("COMPAT_NO_STREAM_OPTIONS") === "1" ||
710
+ (options?.env?.["COMPAT_NO_STREAM_OPTIONS"] === "1");
711
+ if (!noStreamOptions) {
712
+ (body as unknown as Record<string, unknown>)["stream_options"] = { include_usage: true };
713
+ }
714
+ }
719
715
  return body;
720
716
  }
721
717
 
@@ -735,8 +731,8 @@ export class OpenAICompatibleChatProvider implements Provider {
735
731
  for (const [k, v] of Object.entries(headers)) {
736
732
  if (INTERNAL_HEADERS.has(k.toLowerCase())) delete headers[k];
737
733
  }
738
- headers["Content-Type"] = "application/json";
739
- if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
734
+ setHeaderCaseInsensitive(headers, "Content-Type", "application/json");
735
+ if (apiKey) setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
740
736
  return { method: "POST", headers, body: JSON.stringify(body), signal };
741
737
  }
742
738
 
@@ -748,13 +744,18 @@ export class OpenAICompatibleChatProvider implements Provider {
748
744
  sessionId: string | undefined,
749
745
  signal?: AbortSignal
750
746
  ): 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 };
747
+ const { signal: effective, cleanup } = combinedSignal(signal, timeoutFor(options as { timeoutMs?: number } | undefined));
748
+ try {
749
+ const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, effective));
750
+ const text = await res.text();
751
+ const headers: Record<string, string> = {};
752
+ res.headers.forEach((v, k) => {
753
+ headers[k] = v;
754
+ });
755
+ return { status: res.status, statusText: res.statusText, headers, text };
756
+ } finally {
757
+ cleanup();
758
+ }
758
759
  }
759
760
 
760
761
  private throwIfError(
@@ -764,13 +765,14 @@ export class OpenAICompatibleChatProvider implements Provider {
764
765
  modelId: string
765
766
  ): void {
766
767
  if (status >= 200 && status < 300) return;
767
- const { message, code } = readErrorPayload(bodyText);
768
+ const { message, code, errorType } = readErrorPayload(bodyText);
768
769
  const err: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
769
770
  (err as Record<string, unknown>)["statusCode"] = status;
770
771
  (err as Record<string, unknown>)["status"] = status;
771
772
  (err as Record<string, unknown>)["responseBody"] = bodyText.slice(0, 500);
772
773
  (err as Record<string, unknown>)["url"] = url.split("?")[0];
773
774
  if (code !== undefined) (err as Record<string, unknown>)["code"] = code;
775
+ if (errorType) (err as Record<string, unknown>)["errorType"] = errorType;
774
776
  throw toConciseProviderError(err, this.prefix, modelId);
775
777
  }
776
778
 
@@ -904,7 +906,21 @@ export class OpenAICompatibleChatProvider implements Provider {
904
906
  body,
905
907
  };
906
908
 
907
- const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
909
+ const streamTimeoutMs = timeoutFor(options as { timeoutMs?: number } | undefined);
910
+ const streamTimeoutTimer =
911
+ streamTimeoutMs && streamTimeoutMs > 0
912
+ ? setTimeout(() => {
913
+ try {
914
+ linked.abort();
915
+ } catch {}
916
+ }, streamTimeoutMs)
917
+ : undefined;
918
+ let res: Response;
919
+ try {
920
+ res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
921
+ } finally {
922
+ if (streamTimeoutTimer) clearTimeout(streamTimeoutTimer);
923
+ }
908
924
  if (!res.ok || !res.body) {
909
925
  const text = !res.ok ? await res.text().catch(() => "") : "";
910
926
  if (!res.ok) this.throwIfError(res.status, url, text, clean);
@@ -914,7 +930,7 @@ export class OpenAICompatibleChatProvider implements Provider {
914
930
  res.headers.forEach((v, k) => {
915
931
  responseHeaders[k] = v;
916
932
  });
917
- const responseMeta = { status: res.status, statusText: res.statusText, headers: responseHeaders };
933
+ const responseMeta: Record<string, unknown> & { status: number; statusText: string; headers: Record<string, string> } = { status: res.status, statusText: res.statusText, headers: responseHeaders };
918
934
  eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
919
935
 
920
936
  const parser = new SSEParser();
@@ -928,6 +944,11 @@ export class OpenAICompatibleChatProvider implements Provider {
928
944
  const signatures = new Map<number, string>();
929
945
  let usage: TokenUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
930
946
  let finishReason = "stop";
947
+ let sawFinishReason = false;
948
+ let sawUsage = false;
949
+ let refusalSeen = false;
950
+ let badFrames = 0;
951
+ let framesSeen = 0;
931
952
  let responseId: string | undefined;
932
953
  let completedBody: unknown = undefined;
933
954
  let aborted = false;
@@ -943,11 +964,17 @@ export class OpenAICompatibleChatProvider implements Provider {
943
964
  );
944
965
 
945
966
  const handleMessage = (data: string): void => {
946
- if (!data || data === "[DONE]") return;
967
+ if (!data) return;
968
+ if (data === "[DONE]") {
969
+ sawFinishReason = sawFinishReason || completedBody !== undefined || sawUsage;
970
+ return;
971
+ }
947
972
  let msg: Record<string, unknown>;
948
973
  try {
949
974
  msg = JSON.parse(data) as Record<string, unknown>;
975
+ framesSeen += 1;
950
976
  } catch {
977
+ badFrames += 1;
951
978
  return;
952
979
  }
953
980
  // Mid-stream provider error: top-level `error`, HTTP stays 200.
@@ -969,6 +996,13 @@ export class OpenAICompatibleChatProvider implements Provider {
969
996
  const choice = (Array.isArray(msg["choices"]) ? (msg["choices"] as Record<string, unknown>[])[0] : undefined) ?? {};
970
997
  const delta = (choice["delta"] as Record<string, unknown>) ?? {};
971
998
  // Text delta (content-free accounting frames carry "" — ignored).
999
+ // Q-23: refusal deltas surface as text with a `refusal` finish.
1000
+ const deltaRefusal = delta["refusal"];
1001
+ if (typeof deltaRefusal === "string" && deltaRefusal) {
1002
+ text += deltaRefusal;
1003
+ refusalSeen = true;
1004
+ eventStream.push({ type: "text_delta", delta: deltaRefusal, partialText: text });
1005
+ }
972
1006
  if (typeof delta["content"] === "string" && delta["content"]) {
973
1007
  text += delta["content"] as string;
974
1008
  eventStream.push({ type: "text_delta", delta: delta["content"] as string, partialText: text });
@@ -1042,10 +1076,12 @@ export class OpenAICompatibleChatProvider implements Provider {
1042
1076
  throw toConciseProviderError(failure, prefix, clean);
1043
1077
  }
1044
1078
  finishReason = fr;
1079
+ sawFinishReason = true;
1045
1080
  }
1046
1081
  const chunkUsage = msg["usage"] as ChatResponse["usage"] | undefined;
1047
1082
  if (chunkUsage) {
1048
1083
  usage = mapUsage(chunkUsage);
1084
+ sawUsage = true;
1049
1085
  completedBody = completedBody ?? msg;
1050
1086
  eventStream.push({ type: "usage", usage });
1051
1087
  }
@@ -1077,20 +1113,41 @@ export class OpenAICompatibleChatProvider implements Provider {
1077
1113
  const args = parseStreamedToolArguments(c.startArgs, c.deltaArgs);
1078
1114
  const sig = signatures.get(index);
1079
1115
  const record: ToolCallRecord = {
1080
- id: c.id || `call_${Math.random().toString(36).slice(2, 9)}`,
1116
+ id: c.id || newToolCallId(),
1081
1117
  name: c.name,
1082
1118
  arguments: args,
1083
1119
  rawArguments: c.startArgs + c.deltaArgs,
1084
- ...(sig ? { thoughtSignature: sig } : {}),
1085
- };
1120
+ ...withCompatSignature(sig),
1121
+ } as ToolCallRecord;
1086
1122
  toolCalls.push(record);
1087
1123
  eventStream.push({ type: "tool_call_complete", toolCall: record });
1088
1124
  }
1089
1125
 
1090
1126
  noteProviderTurn(sessionId, prefix);
1091
- if (toolCalls.length > 0) finishReason = "tool_calls";
1127
+ if (toolCalls.length > 0) {
1128
+ finishReason = "tool_calls";
1129
+ sawFinishReason = true;
1130
+ } else if (refusalSeen) {
1131
+ finishReason = "refusal";
1132
+ sawFinishReason = true;
1133
+ }
1134
+
1135
+ // Q-23: terminal-state guard — fail `incomplete_stream` (retryable)
1136
+ // instead of resolving empty success. Skipped for non-SSE payloads
1137
+ // (JSON mocks) where no SSE frames were ever seen.
1138
+ const hasTerminal = completedBody !== undefined || sawFinishReason || sawUsage;
1139
+ const cleanThinkingEarly = thinking.replace(/\n{3,}/g, "\n\n").trim();
1140
+ const ctEntry = Object.entries(responseHeaders).find(([k]) => k.toLowerCase() === "content-type");
1141
+ const isSSEPayload =
1142
+ (typeof ctEntry?.[1] === "string" && ((ctEntry[1] as string).includes("event-stream") || (ctEntry[1] as string).startsWith("text/"))) ||
1143
+ framesSeen > 0 ||
1144
+ badFrames > 0;
1145
+ if (!hasTerminal && !text && !cleanThinkingEarly && toolCalls.length === 0 && isSSEPayload) {
1146
+ eventStream.fail(incompleteStreamError(prefix, clean));
1147
+ return;
1148
+ }
1092
1149
 
1093
- const cleanThinking = thinking.replace(/\n{3,}/g, "\n\n").trim();
1150
+ const cleanThinking = cleanThinkingEarly;
1094
1151
  const finalThoughtSignature = toolCalls.map((c) => c.thoughtSignature).find(Boolean);
1095
1152
  const finalResponse = new AgentResponse({
1096
1153
  text,
@@ -1102,7 +1159,10 @@ export class OpenAICompatibleChatProvider implements Provider {
1102
1159
  responseId,
1103
1160
  model: clean,
1104
1161
  provider: prefix as ProviderId,
1105
- raw: { request: rawRequest, response: { ...responseMeta, body: completedBody } },
1162
+ raw: {
1163
+ request: rawRequest,
1164
+ response: { ...responseMeta, body: completedBody, badFrames } as unknown as ProviderRawData["response"],
1165
+ },
1106
1166
  durationMs: Date.now() - startTime,
1107
1167
  });
1108
1168
  eventStream.push({ type: "done", delta: "", usage, finishReason, responseId });