agent-accelerator 0.4.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.
@@ -67,16 +67,22 @@ import {
67
67
  redactedHeaders,
68
68
  readErrorPayload,
69
69
  incompleteStreamError,
70
+ hasTruncatedToolArguments,
70
71
  combinedSignal,
71
72
  timeoutFor,
73
+ hasAuthHeader,
74
+ clampToolCallId,
72
75
  } from "./shared.ts";
73
- import { getModelFromCatalog, createGenericModelSpec } from "../models/catalog.ts";
76
+ import { getModelFromCatalog, createGenericModelSpec, modelSupportsStructuredOutput } from "../models/catalog.ts";
74
77
  import {
75
78
  applyCacheForCustom,
76
79
  mapToolChoiceToOpenAI,
77
80
  emitProviderWarning,
78
81
  noteProviderTurn,
79
82
  parseStreamedToolArguments,
83
+ mapOutputToCompatChat,
84
+ normalizeStructuredOutput,
85
+ augmentSystemPromptWithStructuredOutput,
80
86
  } from "../providers.ts";
81
87
 
82
88
  // ---------------------------------------------------------------------------
@@ -110,6 +116,7 @@ interface ChatRequestBody {
110
116
  messages: ChatMessage[];
111
117
  tools?: Array<Record<string, unknown>>;
112
118
  tool_choice?: string | { type: "function"; function: { name: string } };
119
+ response_format?: { type: "json_schema"; json_schema: { name: string; schema: Record<string, unknown>; strict: boolean; description?: string } };
113
120
  stream?: boolean;
114
121
  [k: string]: unknown;
115
122
  }
@@ -208,17 +215,23 @@ function resolveApiKey(
208
215
  configuredApiKey: string | undefined,
209
216
  options?: ProviderRequestOptions
210
217
  ): string | undefined {
218
+ const clean = (v: unknown): string | undefined => {
219
+ if (typeof v !== "string") return undefined;
220
+ const t = v.trim().replace(/^["']|["']$/g, "").trim();
221
+ return t || undefined;
222
+ };
211
223
  const envPrefix = envPrefixOf(prefix);
212
- return (
213
- options?.apiKey ||
214
- configuredApiKey ||
215
- options?.env?.[`${envPrefix}_API_KEY`] ||
216
- options?.env?.[`${envPrefix}_BASE_API_KEY`] ||
217
- getEnv(`${envPrefix}_API_KEY`) ||
218
- getEnv(`${envPrefix}_BASE_API_KEY`) ||
224
+ const key = (
225
+ clean(options?.apiKey) ||
226
+ clean(configuredApiKey) ||
227
+ clean(options?.env?.[`${envPrefix}_API_KEY`]) ||
228
+ clean(options?.env?.[`${envPrefix}_BASE_API_KEY`]) ||
219
229
  getApiKey(prefix, undefined, options?.env) ||
220
230
  undefined
221
231
  );
232
+ if (key) return key;
233
+ if (hasAuthHeader(options?.headers)) return "unused";
234
+ return undefined;
222
235
  }
223
236
 
224
237
  /** Local endpoints (loopback / LAN / *.local) may omit the API key. Literal IPs only; hostnames like 10.evil.com are not local. */
@@ -395,14 +408,18 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
395
408
  if (context.systemPrompt) {
396
409
  messages.push({ role: "system", content: context.systemPrompt });
397
410
  }
411
+ let lastRole: string | null = "system";
398
412
  for (const m of context.messages) {
399
413
  if (m.role === "system") continue;
400
414
  if (m.role === "user") {
415
+ if (lastRole === "tool") {
416
+ messages.push({ role: "assistant", content: "I have processed the tool results." });
417
+ }
401
418
  if (typeof m.content === "string") {
402
- if (m.content) messages.push({ role: "user", content: m.content });
419
+ if (m.content) { messages.push({ role: "user", content: m.content }); lastRole = "user"; }
403
420
  } else {
404
421
  const blocks = await contentPartsToBlocks(m.content);
405
- if (blocks.length > 0) messages.push({ role: "user", content: blocks });
422
+ if (blocks.length > 0) { messages.push({ role: "user", content: blocks }); lastRole = "user"; }
406
423
  }
407
424
  } else if (m.role === "assistant") {
408
425
  if (typeof m.content === "string") {
@@ -426,7 +443,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
426
443
  function: { name: string; arguments: string };
427
444
  extra_content?: { google: { thought_signature: string } };
428
445
  } = {
429
- id: part.callId || part.id,
446
+ id: clampToolCallId(part.callId || part.id),
430
447
  type: "function",
431
448
  function: { name: part.name, arguments: JSON.stringify(part.arguments || {}) },
432
449
  };
@@ -443,21 +460,26 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
443
460
  texts.push(part.text);
444
461
  }
445
462
  }
463
+ // Skip empty assistant turns (no text + no tool calls): strict
464
+ // routes reject them, and they carry no signal (pi drop rule).
465
+ if (texts.length === 0 && calls.length === 0) continue;
446
466
  messages.push({
447
467
  role: "assistant",
448
468
  content: texts.length > 0 ? texts.join("\n") : null,
449
469
  ...(calls.length > 0 ? { tool_calls: calls } : {}),
450
470
  });
471
+ lastRole = "assistant";
451
472
  } else if (m.role === "tool") {
452
473
  if (!Array.isArray(m.content)) continue;
453
474
  for (const part of m.content) {
454
475
  if (part.type === "tool_result") {
455
476
  messages.push({
456
477
  role: "tool",
457
- tool_call_id: pairing.get(part.id) || part.id,
478
+ tool_call_id: clampToolCallId(pairing.get(part.id) || part.id),
458
479
  content: resultToOutput(part.result),
459
480
  name: part.name,
460
481
  });
482
+ lastRole = "tool";
461
483
  }
462
484
  }
463
485
  }
@@ -551,7 +573,7 @@ function parseResponse(
551
573
  const args = parseArguments(tc.function?.arguments);
552
574
  const sig = extractSignature(tc);
553
575
  toolCalls.push({
554
- id: tc.id || newToolCallId(),
576
+ id: tc.id && !tc.id.includes("|") && tc.id.length <= 40 ? tc.id : clampToolCallId(tc.id || newToolCallId()),
555
577
  name: tc.function?.name || "unknown",
556
578
  arguments: args,
557
579
  rawArguments:
@@ -678,7 +700,32 @@ export class OpenAICompatibleChatProvider implements Provider {
678
700
  }
679
701
  applyCacheForCustom(this.prefix, options?.cache, `${this.prefix}/${modelId}`);
680
702
 
681
- const messages = await fullHistoryMessages(context);
703
+ // Structured outputs: native `response_format` when supported, else
704
+ // emulated via system prompt (strict endpoints would 400 on json_schema
705
+ // for catalog `structured_output: false` models).
706
+ let effectiveContext = context;
707
+ let outputEmulated = false;
708
+ let outputSpec: import("../types/model.ts").StructuredOutputSpec | undefined;
709
+ try {
710
+ outputSpec = normalizeStructuredOutput(options?.output ?? (options as { outputSchema?: unknown })?.outputSchema);
711
+ } catch {
712
+ outputSpec = undefined;
713
+ }
714
+ if (outputSpec && !modelSupportsStructuredOutput(this.prefix, modelId)) {
715
+ outputEmulated = true;
716
+ effectiveContext = {
717
+ ...context,
718
+ systemPrompt: augmentSystemPromptWithStructuredOutput(context.systemPrompt, outputSpec),
719
+ };
720
+ emitProviderWarning({
721
+ provider: this.prefix,
722
+ capability: "structured output",
723
+ requested: `${outputSpec.name} (${this.prefix}/${modelId})`,
724
+ reason: "the catalog reports structured_output: false for this model, so native response_format is unsupported.",
725
+ fallback: "emulated JSON instruction + client validation (no response_format is sent)",
726
+ });
727
+ }
728
+ const messages = await fullHistoryMessages(effectiveContext);
682
729
  const body: ChatRequestBody = {
683
730
  model: modelId,
684
731
  messages,
@@ -697,8 +744,19 @@ export class OpenAICompatibleChatProvider implements Provider {
697
744
  ? { type: "function", function: { name: (toolChoice as { name: string }).name } }
698
745
  : (toolChoice as ChatRequestBody["tool_choice"]);
699
746
  }
700
- // No reasoning / service_tier / session / cache body primitives: strict
701
- // endpoints reject unknown properties. Affinity is headers-only.
747
+ // Structured outputs use the standard Chat Completions `response_format`
748
+ // (safe on strict endpoints). Skipped when emulated (instruction already
749
+ // in messages). No reasoning / service_tier / session /
750
+ // cache body primitives: strict endpoints reject unknown properties.
751
+ // Affinity stays headers-only.
752
+ if (outputSpec && !outputEmulated) {
753
+ try {
754
+ const mapped = mapOutputToCompatChat(outputSpec);
755
+ if (mapped) body.response_format = mapped.response_format;
756
+ } catch {
757
+ // why: invalid output shapes fail fast at Agent construction/loop validation.
758
+ }
759
+ }
702
760
  // Q-38: request usage on streams where supported so terminal accounting
703
761
  // arrives on the final chunk. Strict servers that reject unknown fields
704
762
  // opt out via `COMPAT_NO_STREAM_OPTIONS=1`; an explicit per-call
@@ -731,8 +789,26 @@ export class OpenAICompatibleChatProvider implements Provider {
731
789
  for (const [k, v] of Object.entries(headers)) {
732
790
  if (INTERNAL_HEADERS.has(k.toLowerCase())) delete headers[k];
733
791
  }
792
+ // Strict-endpoint opt-out (pi sendSessionAffinityHeaders pattern):
793
+ // `COMPAT_NO_SESSION_HEADERS=1` or per-call `sendSessionHeaders:false`
794
+ // drops affinity headers that trigger CORS preflights / strict 400s.
795
+ // Body `session_id` is already omitted on compat (headers-only affinity).
796
+ try {
797
+ const noAffinity =
798
+ (options as { sendSessionHeaders?: unknown } | undefined)?.sendSessionHeaders === false ||
799
+ getEnv("COMPAT_NO_SESSION_HEADERS") === "1" ||
800
+ options?.env?.["COMPAT_NO_SESSION_HEADERS"] === "1";
801
+ if (noAffinity) {
802
+ for (const k of Object.keys(headers)) {
803
+ const lower = k.toLowerCase();
804
+ if (lower === "x-session-id" || lower === "x-client-request-id" || lower === "session_id") {
805
+ delete headers[k];
806
+ }
807
+ }
808
+ }
809
+ } catch {}
734
810
  setHeaderCaseInsensitive(headers, "Content-Type", "application/json");
735
- if (apiKey) setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
811
+ if (apiKey && apiKey !== "unused") setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
736
812
  return { method: "POST", headers, body: JSON.stringify(body), signal };
737
813
  }
738
814
 
@@ -765,14 +841,21 @@ export class OpenAICompatibleChatProvider implements Provider {
765
841
  modelId: string
766
842
  ): void {
767
843
  if (status >= 200 && status < 300) return;
768
- const { message, code, errorType } = readErrorPayload(bodyText);
769
- const err: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
844
+ const { message, code, errorType, raw } = readErrorPayload(bodyText);
845
+ const hint =
846
+ status === 401
847
+ ? ` Check ${envPrefixOf(this.prefix)}_API_KEY (no quotes/spaces, correct provider) and that the key is loaded (bun --env-file=.env.local).`
848
+ : "";
849
+ const fullMessage = `${message}${hint}`;
850
+ const err: Record<string, unknown> & Error = new Error(fullMessage) as Record<string, unknown> & Error;
770
851
  (err as Record<string, unknown>)["statusCode"] = status;
771
852
  (err as Record<string, unknown>)["status"] = status;
772
853
  (err as Record<string, unknown>)["responseBody"] = bodyText.slice(0, 500);
773
854
  (err as Record<string, unknown>)["url"] = url.split("?")[0];
855
+ (err as Record<string, unknown>)["data"] = { error: { message: fullMessage } };
774
856
  if (code !== undefined) (err as Record<string, unknown>)["code"] = code;
775
857
  if (errorType) (err as Record<string, unknown>)["errorType"] = errorType;
858
+ if (raw) (err as Record<string, unknown>)["raw"] = raw;
776
859
  throw toConciseProviderError(err, this.prefix, modelId);
777
860
  }
778
861
 
@@ -1113,7 +1196,7 @@ export class OpenAICompatibleChatProvider implements Provider {
1113
1196
  const args = parseStreamedToolArguments(c.startArgs, c.deltaArgs);
1114
1197
  const sig = signatures.get(index);
1115
1198
  const record: ToolCallRecord = {
1116
- id: c.id || newToolCallId(),
1199
+ id: c.id && !c.id.includes("|") && c.id.length <= 40 ? c.id : clampToolCallId(c.id || newToolCallId()),
1117
1200
  name: c.name,
1118
1201
  arguments: args,
1119
1202
  rawArguments: c.startArgs + c.deltaArgs,
@@ -1146,6 +1229,13 @@ export class OpenAICompatibleChatProvider implements Provider {
1146
1229
  eventStream.fail(incompleteStreamError(prefix, clean));
1147
1230
  return;
1148
1231
  }
1232
+ // Refuse unfinished tool calls whose args never parsed (output_item.done
1233
+ // never arrived): truncated `{raw}` must not reach the executor (pi
1234
+ // api/openai-responses-shared.ts:766 rule). Non-SSE mocks preserve legacy.
1235
+ if (!hasTerminal && toolCalls.some((c) => hasTruncatedToolArguments(c.arguments)) && isSSEPayload) {
1236
+ eventStream.fail(incompleteStreamError(prefix, clean));
1237
+ return;
1238
+ }
1149
1239
 
1150
1240
  const cleanThinking = cleanThinkingEarly;
1151
1241
  const finalThoughtSignature = toolCalls.map((c) => c.thoughtSignature).find(Boolean);
@@ -59,11 +59,14 @@ import {
59
59
  redactedHeaders,
60
60
  readErrorPayload,
61
61
  incompleteStreamError,
62
+ hasTruncatedToolArguments,
62
63
  combinedSignal,
63
64
  timeoutFor,
65
+ hasAuthHeader,
66
+ clampToolCallId,
64
67
  } from "./shared.ts";
65
68
  import { createGenericModelSpec } from "../models/catalog.ts";
66
- import { getModelFromCatalog, getModelsForProvider } from "../models/catalog.ts";
69
+ import { getModelFromCatalog, getModelsForProvider, modelSupportsStructuredOutput } from "../models/catalog.ts";
67
70
  import {
68
71
  mapThinkingLevelToOpenAI,
69
72
  mapServiceTierToOpenAI,
@@ -71,6 +74,10 @@ import {
71
74
  mapToolChoiceToOpenAI,
72
75
  noteProviderTurn,
73
76
  parseStreamedToolArguments,
77
+ mapOutputToOpenAI,
78
+ normalizeStructuredOutput,
79
+ augmentSystemPromptWithStructuredOutput,
80
+ emitProviderWarning,
74
81
  } from "../providers.ts";
75
82
 
76
83
  // ---------------------------------------------------------------------------
@@ -102,6 +109,7 @@ interface ResponsesRequestBody {
102
109
  prompt_cache_key?: string;
103
110
  store?: boolean;
104
111
  stream?: boolean;
112
+ text?: { format: { type: "json_schema"; name: string; schema: Record<string, unknown>; strict: boolean; description?: string } };
105
113
  [k: string]: unknown;
106
114
  }
107
115
 
@@ -170,7 +178,10 @@ function resolveBaseUrl(options?: ProviderRequestOptions): string {
170
178
  }
171
179
 
172
180
  function resolveApiKey(options?: ProviderRequestOptions): string | undefined {
173
- 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;
174
185
  }
175
186
 
176
187
  /** Strips ONLY the `openai/` prefix. Bare ids pass through untouched. */
@@ -251,6 +262,20 @@ function resultToOutput(result: unknown): string {
251
262
  return safeStringify(result);
252
263
  }
253
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
+
254
279
  /**
255
280
  * Builds the FULL explicit history (stateless — no server state, no
256
281
  * chaining). Assistant items intentionally carry no provider `id`/`status`:
@@ -300,10 +325,25 @@ async function fullHistoryInput(context: ProviderContext): Promise<ResponseInput
300
325
  const texts: string[] = [];
301
326
  for (const part of m.content) {
302
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
+ }
303
343
  items.push({
304
344
  type: "function_call",
305
- id: part.id,
306
- call_id: part.callId || part.id,
345
+ id: normalizeResponsesItemId(itemId),
346
+ call_id: normalizeResponsesIdPart(callId),
307
347
  name: part.name,
308
348
  arguments: JSON.stringify(part.arguments || {}),
309
349
  });
@@ -319,9 +359,11 @@ async function fullHistoryInput(context: ProviderContext): Promise<ResponseInput
319
359
  } else if (m.role === "tool") {
320
360
  if (typeof m.content === "string") {
321
361
  const queue = (m.name && idsByName.get(m.name)) || [];
362
+ const rawPair = queue.shift() || newToolCallId();
363
+ const rawCall = rawPair.includes("|") ? rawPair.split("|")[0]! : rawPair;
322
364
  items.push({
323
365
  type: "function_call_output",
324
- call_id: queue.shift() || newToolCallId(),
366
+ call_id: normalizeResponsesIdPart(rawCall),
325
367
  output: m.content,
326
368
  });
327
369
  continue;
@@ -329,9 +371,11 @@ async function fullHistoryInput(context: ProviderContext): Promise<ResponseInput
329
371
  if (!Array.isArray(m.content)) continue;
330
372
  for (const part of m.content) {
331
373
  if (part.type === "tool_result") {
374
+ const rawPair = pairing.get(part.id) || part.id;
375
+ const rawCall = rawPair.includes("|") ? rawPair.split("|")[0]! : rawPair;
332
376
  items.push({
333
377
  type: "function_call_output",
334
- call_id: pairing.get(part.id) || part.id,
378
+ call_id: normalizeResponsesIdPart(rawCall),
335
379
  output: resultToOutput(part.result),
336
380
  });
337
381
  }
@@ -486,16 +530,38 @@ export class OpenAIResponsesProvider implements Provider {
486
530
  const serviceTier = mapServiceTierToOpenAI(options?.serviceTier);
487
531
  applyCacheForOpenAI(options?.cache, `openai/${modelId}`);
488
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
+
489
555
  const body: ResponsesRequestBody = {
490
556
  model: modelId,
491
- input: await fullHistoryInput(context),
557
+ input: await fullHistoryInput({ ...context, systemPrompt: effectiveSystemPrompt }),
492
558
  // Stateless by design: the server defaults store:true, so opt out
493
559
  // explicitly. Full history is always sent, so no chaining is needed
494
560
  // and provider switching stays trivial.
495
561
  store: false,
496
562
  ...(stream ? { stream: true } : {}),
497
563
  };
498
- if (context.systemPrompt) body.instructions = context.systemPrompt;
564
+ if (effectiveSystemPrompt) body.instructions = effectiveSystemPrompt;
499
565
  const aiTools = toOpenAITools(tools);
500
566
  if (aiTools) body.tools = aiTools;
501
567
  const toolChoice = mapToolChoiceToOpenAI(
@@ -504,8 +570,17 @@ export class OpenAIResponsesProvider implements Provider {
504
570
  if (toolChoice !== undefined) body.tool_choice = toolChoice;
505
571
  if (effort) body.reasoning = { effort };
506
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
+ }
507
583
  // Cache affinity: prompt_cache_key replaces the legacy `user` field.
508
- // (prompt_cache_options explicit breakpoints are out of scope.)
509
584
  // OpenAI enforces max 64 chars — clamp defensively so child session ids
510
585
  // (`parent-sub-tag-rand`) and user-supplied long ids never 400. Headers
511
586
  // are clamped the same way via buildSessionHeaders, keeping affinity
@@ -514,6 +589,29 @@ export class OpenAIResponsesProvider implements Provider {
514
589
  const cacheKey = clampCacheKey(sessionId);
515
590
  if (cacheKey) body.prompt_cache_key = cacheKey;
516
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
+ }
517
615
  // Stateless API use: previous_response_id / background / conversation are
518
616
  // NEVER sent (server state would break provider-agnostic switching).
519
617
  return body;
@@ -536,9 +634,10 @@ export class OpenAIResponsesProvider implements Provider {
536
634
  if (INTERNAL_HEADERS.has(k.toLowerCase())) delete headers[k];
537
635
  }
538
636
  // Case-insensitive merge so a lowercase `authorization` custom header
539
- // never coexists with the canonical bearer.
637
+ // never coexists with the canonical bearer. Proxy flows
638
+ // (`apiKey === "unused"`) preserve custom auth instead of overwriting.
540
639
  setHeaderCaseInsensitive(headers, "Content-Type", "application/json");
541
- setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
640
+ if (apiKey !== "unused") setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
542
641
  return { method: "POST", headers, body: JSON.stringify(body), signal };
543
642
  }
544
643
 
@@ -571,14 +670,23 @@ export class OpenAIResponsesProvider implements Provider {
571
670
  modelId: string
572
671
  ): void {
573
672
  if (status >= 200 && status < 300) return;
574
- const { message, code, errorType } = readErrorPayload(bodyText);
575
- 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;
576
680
  (err as Record<string, unknown>)["statusCode"] = status;
577
681
  (err as Record<string, unknown>)["status"] = status;
578
682
  (err as Record<string, unknown>)["responseBody"] = bodyText.slice(0, 500);
579
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 } };
580
687
  if (code !== undefined) (err as Record<string, unknown>)["code"] = code;
581
688
  if (errorType) (err as Record<string, unknown>)["errorType"] = errorType;
689
+ if (raw) (err as Record<string, unknown>)["raw"] = raw;
582
690
  throw toConciseProviderError(err, "openai", modelId);
583
691
  }
584
692
 
@@ -994,6 +1102,13 @@ export class OpenAIResponsesProvider implements Provider {
994
1102
  eventStream.fail(incompleteStreamError("openai", clean));
995
1103
  return;
996
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
+ }
997
1112
 
998
1113
  const cleanThinking = cleanThinkingEarly;
999
1114
  const finalResponse = new AgentResponse({