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.
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Shared provider helpers (Q-45-partial).
3
+ *
4
+ * Single home for the `redactedHeaders` / `readErrorPayload` /
5
+ * `parseArguments` / `normalizeThinkingParts` helpers previously duplicated
6
+ * across the four REST transports, plus timeout/signal and id helpers.
7
+ */
8
+ import { toConciseProviderError } from "../utils/errors.ts";
9
+
10
+ /** Parses a JSON tool-argument string; non-objects degrade to `{ raw }`. */
11
+ export function parseArguments(raw: string | undefined): Record<string, unknown> {
12
+ if (!raw) return {};
13
+ try {
14
+ const parsed: unknown = JSON.parse(raw);
15
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
16
+ return parsed as Record<string, unknown>;
17
+ }
18
+ return { raw };
19
+ } catch {
20
+ return { raw };
21
+ }
22
+ }
23
+
24
+ /** Trims/collapses assembled thinking parts (no edge-tripling). */
25
+ export function normalizeThinkingParts(parts: string[]): string | undefined {
26
+ const cleaned = parts
27
+ .map((p) => p.replace(/\n{3,}/g, "\n\n").trim())
28
+ .filter((p) => p.length > 0);
29
+ return cleaned.length > 0 ? cleaned.join("\n") : undefined;
30
+ }
31
+
32
+ /**
33
+ * Redacts secret-bearing headers for the raw audit trail. Superset of the
34
+ * four adapter versions: `authorization` (any case) and `x-goog-api-key` are
35
+ * redacted; all other headers pass through untouched.
36
+ */
37
+ export function redactedHeaders(headers: Record<string, string>): Record<string, string> {
38
+ const out: Record<string, string> = {};
39
+ for (const [k, v] of Object.entries(headers)) {
40
+ const lower = k.toLowerCase();
41
+ out[k] = lower === "authorization" || lower === "x-goog-api-key" ? "[REDACTED]" : v;
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /**
47
+ * Extracts `{ message, code, errorType, raw }` from a non-2xx JSON body. Handles
48
+ * OpenAI `type`, OpenRouter `metadata.error_type` + `metadata.raw`, and Google
49
+ * `status` code shapes; unknown bodies degrade to a 300-char slice.
50
+ */
51
+ export function readErrorPayload(
52
+ bodyText: string
53
+ ): { message: string; code?: string | number; errorType?: string; raw?: string } {
54
+ try {
55
+ const parsed: unknown = JSON.parse(bodyText);
56
+ const first = Array.isArray(parsed) ? parsed[0] : parsed;
57
+ const err = (first as { error?: { message?: string; code?: string | number; type?: string; status?: string | number; metadata?: { error_type?: string; raw?: unknown } } })?.error;
58
+ if (err && typeof err.message === "string") {
59
+ const code = err.code ?? err.status;
60
+ const errorType =
61
+ err.type ?? err.metadata?.error_type ?? (first as { error_type?: string })?.error_type;
62
+ const rawCandidate =
63
+ (err as { metadata?: { raw?: unknown } }).metadata?.raw ??
64
+ (first as { metadata?: { raw?: unknown } })?.metadata?.raw;
65
+ const raw = typeof rawCandidate === "string" && rawCandidate.trim() ? rawCandidate.trim().slice(0, 500) : undefined;
66
+ return {
67
+ message: err.message,
68
+ ...(code !== undefined ? { code } : {}),
69
+ ...(typeof errorType === "string" ? { errorType } : {}),
70
+ ...(raw ? { raw } : {}),
71
+ };
72
+ }
73
+ return { message: bodyText.slice(0, 300) };
74
+ } catch {
75
+ return { message: bodyText.slice(0, 300) };
76
+ }
77
+ }
78
+
79
+ /** Retryable incomplete-stream error for truncated SSE (Q-23). */
80
+ export function incompleteStreamError(provider: string, modelId: string): Error {
81
+ const err = toConciseProviderError(
82
+ Object.assign(new Error("Stream ended without terminal event"), {
83
+ statusCode: 502,
84
+ transient: true,
85
+ retryable: true,
86
+ url: provider,
87
+ }),
88
+ provider,
89
+ modelId
90
+ );
91
+ (err as any).code = "incomplete_stream";
92
+ return err;
93
+ }
94
+
95
+ /** Per-request timeout ms from provider options (Q-22). */
96
+ export function timeoutFor(options: { timeoutMs?: number } | undefined): number | undefined {
97
+ const t = (options as { timeoutMs?: unknown } | undefined)?.timeoutMs;
98
+ return typeof t === "number" && Number.isFinite(t) && t > 0 ? Math.floor(t) : undefined;
99
+ }
100
+
101
+ /** Merges a parent signal with a timeout into one effective signal. Caller must call cleanup when the request settles so the timer never holds the loop open. */
102
+ export function combinedSignal(signal?: AbortSignal, timeoutMs?: number): { signal: AbortSignal | undefined; cleanup: () => void } {
103
+ if (timeoutMs === undefined) return { signal, cleanup: () => {} };
104
+ const ctrl = new AbortController();
105
+ const timer = setTimeout(() => {
106
+ try { ctrl.abort(); } catch {}
107
+ }, timeoutMs);
108
+ try { (timer as unknown as { unref?: () => void }).unref?.(); } catch {}
109
+ const cleanup = () => {
110
+ try { clearTimeout(timer); } catch {}
111
+ };
112
+ if (signal) {
113
+ if (signal.aborted) {
114
+ cleanup();
115
+ ctrl.abort();
116
+ } else {
117
+ signal.addEventListener("abort", () => {
118
+ cleanup();
119
+ try { ctrl.abort(); } catch {}
120
+ }, { once: true });
121
+ }
122
+ }
123
+ return { signal: ctrl.signal, cleanup };
124
+ }
125
+
126
+ /** Q-34: crypto-backed tool-call id, no Math.random primary. */
127
+ export function newToolCallId(prefix = "call"): string {
128
+ try {
129
+ const c = (globalThis as any)?.crypto;
130
+ if (c?.randomUUID) return `${prefix}_${c.randomUUID().replace(/-/g, "").slice(0, 12)}`;
131
+ } catch {}
132
+ try {
133
+ const nc: any = (globalThis as any)?.process?.getBuiltinModule?.("node:crypto") ?? null;
134
+ if (nc?.randomUUID) return `${prefix}_${nc.randomUUID().replace(/-/g, "").slice(0, 12)}`;
135
+ } catch {}
136
+ return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
137
+ }
138
+
139
+ /**
140
+ * True when custom headers already carry auth (proxy/Copilot flows).
141
+ * Case-insensitive, non-empty `Authorization` or `cf-aig-authorization`.
142
+ * Callers return `"unused"` instead of throwing missing-key.
143
+ */
144
+ export function hasAuthHeader(headers: Record<string, string> | undefined): boolean {
145
+ if (!headers) return false;
146
+ for (const [k, v] of Object.entries(headers)) {
147
+ const lower = k.toLowerCase();
148
+ if ((lower === "authorization" || lower === "cf-aig-authorization") && typeof v === "string" && v.trim().length > 0) {
149
+ return true;
150
+ }
151
+ }
152
+ return false;
153
+ }
154
+
155
+ /** True for truncated streamed tool args (`{ raw }` marker from `parseStreamedToolArguments`). */
156
+ export function hasTruncatedToolArguments(args: unknown): boolean {
157
+ if (!args || typeof args !== "object" || Array.isArray(args)) return false;
158
+ const keys = Object.keys(args as Record<string, unknown>);
159
+ return keys.length === 1 && keys[0] === "raw" && typeof (args as Record<string, unknown>).raw === "string";
160
+ }
161
+
162
+ /** Short hash for tool-ID clamping (FNV-1a, 8 hex chars, no deps). */
163
+ export function shortHashId(value: string): string {
164
+ let h = 0x811c9dc5;
165
+ for (let i = 0; i < value.length; i++) {
166
+ h ^= value.charCodeAt(i);
167
+ h = Math.imul(h, 0x01000193);
168
+ }
169
+ return (h >>> 0).toString(16).padStart(8, "0");
170
+ }
171
+
172
+ /**
173
+ * Clamps a tool-call ID to the 40-char OpenAI limit (pi `api/openai-completions.ts:1194`).
174
+ * Pipe IDs (`callId|itemId`) become `callId_itemHash`; overlong IDs truncate
175
+ * with hash suffix. Plain IDs pass through (truncated if needed).
176
+ */
177
+ export function clampToolCallId(id: string): string {
178
+ const clean = String(id ?? "").replace(/[^a-zA-Z0-9_-]/g, "_");
179
+ if (!clean.includes("|")) {
180
+ return clean.length > 40 ? clean.slice(0, 40) : clean || "call_unknown";
181
+ }
182
+ const sep = clean.indexOf("|");
183
+ const callId = clean.slice(0, sep).replace(/_+$/, "") || "call";
184
+ const itemId = clean.slice(sep + 1).replace(/_+$/, "");
185
+ const combined = itemId ? `${callId}_${itemId}` : callId;
186
+ if (combined.length <= 40) return combined;
187
+ const hash = shortHashId(id).slice(0, 8);
188
+ const prefix = callId.slice(0, Math.max(1, 40 - hash.length - 1));
189
+ return `${prefix}_${hash}`;
190
+ }
package/src/providers.ts CHANGED
@@ -10,6 +10,9 @@
10
10
  * redesigning this abstraction — only new per-provider mappers/adapters.
11
11
  */
12
12
  import type { CacheConfig, ServiceTier, ThinkingLevel } from "./types/core.ts";
13
+ import type { StructuredOutputSpec } from "./types/model.ts";
14
+ import { zodToJsonSchema } from "./tools/schema.ts";
15
+ import { ValidationError } from "./types/errors.ts";
13
16
 
14
17
  /** Classification of a canonical capability on a given provider. */
15
18
  export type ProviderCapabilityStatus =
@@ -50,9 +53,8 @@ export function emitProviderWarning(options: {
50
53
  // Leading newline: warnings fire at the start of a turn's request build,
51
54
  // when the previous turn's streamed text may have left the terminal cursor
52
55
  // mid-line. Without it the warning glues onto streamed output.
53
- console.warn(
54
- `\n[Agent Accelerator] WARNING [${options.provider}] ${options.capability}${requested}: ${options.reason} Using ${options.fallback} instead.`
55
- );
56
+ // Q-41 dual: telemetry bus (new) + console.warn (compat for existing captureWarnings tests; /cli logger replaces in 0.5).
57
+ { const msg = `[Agent Accelerator] WARNING [${options.provider}] ${options.capability}${requested}: ${options.reason} Using ${options.fallback} instead.`; try { (globalThis as any).__agentAccelTelemetry?.emitWarning?.({ code: "provider_warning", provider: options.provider, capability: options.capability, message: msg }); } catch {} try { (globalThis as any).__agentAccelWarnSink?.(msg, options); } catch {} try { console.warn(msg); } catch {} }
56
58
  }
57
59
 
58
60
  /** Clears deduped-warning state (mainly for tests). */
@@ -184,7 +186,7 @@ export function mapThinkingLevelToOpenRouterChat(
184
186
  * echoed but are NOT documented levels, so they clamp to `high` with a warning.
185
187
  *
186
188
  * @deprecated The Responses skin is discontinued for OpenRouter
187
- * (`src/providers/openrouter-responses.ts`, archived). Use
189
+ * (removed from core). Use
188
190
  * {@link mapThinkingLevelToOpenRouterChat} with the stable Chat Completions
189
191
  * transport instead.
190
192
  */
@@ -358,17 +360,17 @@ export function mapServiceTierToOpenAI(tier: ServiceTier | undefined): "flex" |
358
360
 
359
361
  /**
360
362
  * Applies canonical cache config for OpenAI. Session affinity flows via
361
- * `prompt_cache_key` (handled by the adapter); explicit retention control
362
- * lives in `prompt_cache_options` (gpt-5.6+ explicit breakpoints) which is
363
- * out of scope for the most-important subset, so retention/cachedContentId
364
- * are UNSUPPORTED: warn and drop. `prompt_cache_retention` is deprecated.
363
+ * `prompt_cache_key` (handled by the adapter); `long` retention maps to
364
+ * `prompt_cache_retention: "24h"` when the catalog supports it (handled by
365
+ * the adapter, pi `api/openai-responses.ts:97` rule). Other explicit
366
+ * retention modes and `cachedContentId` are UNSUPPORTED: warn and drop.
365
367
  */
366
368
  export function applyCacheForOpenAI(
367
369
  cache: CacheConfig | undefined,
368
370
  modelRef: string
369
371
  ): void {
370
372
  if (!cache) return;
371
- if (cache.retention && cache.retention !== "implicit") {
373
+ if (cache.retention && cache.retention !== "implicit" && cache.retention !== "long") {
372
374
  emitProviderWarning({
373
375
  provider: "openai",
374
376
  capability: "cache retention",
@@ -490,3 +492,251 @@ export function parseStreamedToolArguments(startText: string, deltaText: string)
490
492
  }
491
493
  return { raw: startText + deltaText };
492
494
  }
495
+
496
+ // ---------------------------------------------------------------------------
497
+ // Structured outputs (canonical, pure, no I/O)
498
+ // ---------------------------------------------------------------------------
499
+
500
+ function isZodLike(value: unknown): boolean {
501
+ if (!value || typeof value !== "object") return false;
502
+ const v = value as Record<string, unknown>;
503
+ return (
504
+ typeof v["safeParse"] === "function" ||
505
+ typeof v["parse"] === "function" ||
506
+ "_def" in v ||
507
+ "~standard" in v
508
+ );
509
+ }
510
+
511
+ function looksLikeJsonSchema(value: unknown): boolean {
512
+ if (!isPlainObject(value)) return false;
513
+ const v = value as Record<string, unknown>;
514
+ return (
515
+ typeof v["type"] === "string" ||
516
+ isPlainObject(v["properties"]) ||
517
+ Array.isArray(v["anyOf"]) ||
518
+ Array.isArray(v["oneOf"]) ||
519
+ Array.isArray(v["allOf"]) ||
520
+ typeof v["$schema"] === "string" ||
521
+ isPlainObject(v["$defs"]) ||
522
+ isPlainObject(v["definitions"])
523
+ );
524
+ }
525
+
526
+ function sanitizeOutputName(raw: unknown): string {
527
+ const base = typeof raw === "string" && raw.trim() ? raw.trim() : "structured_output";
528
+ const cleaned = base.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "structured_output";
529
+ return cleaned;
530
+ }
531
+
532
+ function schemaToJsonSchema(schema: unknown, what: string): Record<string, unknown> {
533
+ if (isZodLike(schema)) {
534
+ try {
535
+ const converted = zodToJsonSchema(schema);
536
+ if (isPlainObject(converted)) return converted;
537
+ } catch {
538
+ // fall through to error below
539
+ }
540
+ }
541
+ if (isPlainObject(schema)) return schema as Record<string, unknown>;
542
+ throw new ValidationError(
543
+ `[Agent Accelerator] Invalid structured output ${what}: expected a Zod schema, a JSON Schema object, or { name, schema, strict }.`
544
+ );
545
+ }
546
+
547
+ /**
548
+ * Normalizes `AgentConfig.output` into a provider-agnostic spec.
549
+ *
550
+ * Accepts a Zod schema, a JSON Schema object, or an explicit
551
+ * `{ name?, description?, schema, strict? }` wrapper. Returns `undefined`
552
+ * when no output was requested. Throws `ValidationError` for invalid shapes.
553
+ *
554
+ * @example `normalizeStructuredOutput(z.object({ city: z.string() }))`
555
+ */
556
+ export function normalizeStructuredOutput(output: unknown): StructuredOutputSpec | undefined {
557
+ if (output === undefined || output === null) return undefined;
558
+ if (isPlainObject(output) && "schema" in (output as Record<string, unknown>)) {
559
+ const rec = output as Record<string, unknown>;
560
+ const schema = schemaToJsonSchema(rec["schema"], "`output.schema`");
561
+ return {
562
+ name: sanitizeOutputName(rec["name"]),
563
+ schema,
564
+ strict: typeof rec["strict"] === "boolean" ? (rec["strict"] as boolean) : true,
565
+ ...(typeof rec["description"] === "string" && (rec["description"] as string).trim()
566
+ ? { description: (rec["description"] as string).trim() }
567
+ : {}),
568
+ };
569
+ }
570
+ if (isZodLike(output) || looksLikeJsonSchema(output)) {
571
+ return {
572
+ name: "structured_output",
573
+ schema: schemaToJsonSchema(output, "`output`"),
574
+ strict: true,
575
+ };
576
+ }
577
+ throw new ValidationError(
578
+ "[Agent Accelerator] Invalid structured output: expected a Zod schema, a JSON Schema object, or { name, schema, strict }."
579
+ );
580
+ }
581
+
582
+ /**
583
+ * Extracts the original Zod validator from a canonical output option, when
584
+ * present (bare Zod schema or `{ schema: Zod }`). Used by the loop for
585
+ * post-parse validation; `undefined` means JSON-parse only.
586
+ */
587
+ export function extractOutputValidator(output: unknown): { safeParse: (v: unknown) => { success: boolean; data?: unknown; error?: { message: string } } } | undefined {
588
+ if (!output) return undefined;
589
+ if (isZodLike(output) && typeof (output as { safeParse?: unknown }).safeParse === "function") {
590
+ return output as { safeParse: (v: unknown) => { success: boolean; data?: unknown; error?: { message: string } } };
591
+ }
592
+ if (isPlainObject(output) && isZodLike((output as Record<string, unknown>)["schema"])) {
593
+ const inner = (output as Record<string, unknown>)["schema"] as { safeParse?: unknown };
594
+ if (typeof inner.safeParse === "function") {
595
+ return inner as { safeParse: (v: unknown) => { success: boolean; data?: unknown; error?: { message: string } } };
596
+ }
597
+ }
598
+ return undefined;
599
+ }
600
+
601
+ function stripCodeFences(text: string): string {
602
+ const trimmed = text.trim();
603
+ const fenced = trimmed.match(/^```(?:json)?\s*\n([\s\S]*?)\n```\s*$/i);
604
+ if (fenced && typeof fenced[1] === "string") return fenced[1].trim();
605
+ return trimmed;
606
+ }
607
+
608
+ /**
609
+ * Parses and validates a final answer against a canonical output option.
610
+ * Strips markdown fences, requires valid JSON, then applies the Zod validator
611
+ * when the original option carried one. Throws `ValidationError` on mismatch.
612
+ *
613
+ * @example `parseStructuredOutput('{"city":"Paris"}', z.object({ city: z.string() }))`
614
+ */
615
+ export function parseStructuredOutput(text: string, output: unknown): unknown {
616
+ const cleaned = stripCodeFences(typeof text === "string" ? text : String(text ?? ""));
617
+ if (!cleaned) {
618
+ throw new ValidationError(
619
+ "[Agent Accelerator] Structured output validation failed: model returned empty text, expected JSON matching the output schema."
620
+ );
621
+ }
622
+ let parsed: unknown;
623
+ try {
624
+ parsed = JSON.parse(cleaned);
625
+ } catch {
626
+ throw new ValidationError(
627
+ `[Agent Accelerator] Structured output validation failed: model text is not valid JSON (${cleaned.slice(0, 120)}).`
628
+ );
629
+ }
630
+ const validator = extractOutputValidator(output);
631
+ if (validator) {
632
+ const res = validator.safeParse(parsed);
633
+ if (!res.success) {
634
+ const detail = res.error?.message ? ` ${res.error.message}`.slice(0, 500) : "";
635
+ throw new ValidationError(
636
+ `[Agent Accelerator] Structured output validation failed: JSON does not match the output schema.${detail}`
637
+ );
638
+ }
639
+ return res.data ?? parsed;
640
+ }
641
+ return parsed;
642
+ }
643
+
644
+ /**
645
+ * Maps a normalized spec onto OpenAI Responses `text.format`.
646
+ * `{ type: json_schema, name, schema, strict }` (docs: responses/create).
647
+ */
648
+ export function mapOutputToOpenAI(
649
+ spec: StructuredOutputSpec | undefined
650
+ ): { format: { type: "json_schema"; name: string; schema: Record<string, unknown>; strict: boolean; description?: string } } | undefined {
651
+ if (!spec) return undefined;
652
+ return {
653
+ format: {
654
+ type: "json_schema",
655
+ name: spec.name,
656
+ schema: spec.schema,
657
+ strict: spec.strict ?? true,
658
+ ...(spec.description ? { description: spec.description } : {}),
659
+ },
660
+ };
661
+ }
662
+
663
+ /**
664
+ * Maps a normalized spec onto Google Interactions `response_format`.
665
+ * `{ type: text, mime_type: application/json, schema }` (docs: structured-output).
666
+ */
667
+ export function mapOutputToGoogle(
668
+ spec: StructuredOutputSpec | undefined
669
+ ): { responseFormat: { type: "text"; mime_type: "application/json"; schema: Record<string, unknown> } } | undefined {
670
+ if (!spec) return undefined;
671
+ return {
672
+ responseFormat: {
673
+ type: "text",
674
+ mime_type: "application/json",
675
+ schema: spec.schema,
676
+ },
677
+ };
678
+ }
679
+
680
+ /** Chat Completions `response_format` wire shape (OpenRouter + compat). */
681
+ export type ChatCompletionsResponseFormat =
682
+ | { type: "json_schema"; json_schema: { name: string; schema: Record<string, unknown>; strict: boolean; description?: string } };
683
+
684
+ /**
685
+ * Maps a normalized spec onto Chat Completions `response_format`.
686
+ * `{ type: json_schema, json_schema: { name, schema, strict } }`
687
+ * (docs: openai completions/create, openrouter parameters).
688
+ */
689
+ export function mapOutputToOpenRouterChat(
690
+ spec: StructuredOutputSpec | undefined
691
+ ): { response_format: ChatCompletionsResponseFormat } | undefined {
692
+ if (!spec) return undefined;
693
+ return {
694
+ response_format: {
695
+ type: "json_schema",
696
+ json_schema: {
697
+ name: spec.name,
698
+ schema: spec.schema,
699
+ strict: spec.strict ?? true,
700
+ ...(spec.description ? { description: spec.description } : {}),
701
+ },
702
+ },
703
+ };
704
+ }
705
+
706
+ /**
707
+ * Maps a normalized spec onto generic compat Chat Completions
708
+ * `response_format` (same standard shape as OpenRouter Chat).
709
+ */
710
+ export function mapOutputToCompatChat(
711
+ spec: StructuredOutputSpec | undefined
712
+ ): { response_format: ChatCompletionsResponseFormat } | undefined {
713
+ return mapOutputToOpenRouterChat(spec);
714
+ }
715
+
716
+ /**
717
+ * Builds the emulated structured-output instruction for models without
718
+ * native `response_format` support (catalog `structured_output: false`).
719
+ * Appended to the system prompt so the model still targets JSON; the loop
720
+ * still validates into `AgentResponse.parsed`.
721
+ *
722
+ * @example `augmentSystemPromptWithStructuredOutput(sys, spec)`
723
+ */
724
+ export function buildStructuredOutputInstruction(spec: StructuredOutputSpec): string {
725
+ return `Return ONLY valid JSON matching this JSON Schema, with no markdown fences, no commentary, no extra text.\nSchema: ${JSON.stringify(spec.schema)}`;
726
+ }
727
+
728
+ /**
729
+ * Returns a system prompt with the emulated structured-output instruction
730
+ * appended (stable per Agent since `output` is agent-level, so prefix-cache
731
+ * affinity is preserved). No-op when spec is undefined.
732
+ */
733
+ export function augmentSystemPromptWithStructuredOutput(
734
+ systemPrompt: string | undefined,
735
+ spec: StructuredOutputSpec | undefined
736
+ ): string | undefined {
737
+ if (!spec) return systemPrompt;
738
+ const instruction = `[Structured Output]\n${buildStructuredOutputInstruction(spec)}`;
739
+ if (!systemPrompt) return instruction;
740
+ if (systemPrompt.includes("[Structured Output]")) return systemPrompt;
741
+ return `${systemPrompt}\n\n${instruction}`;
742
+ }