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.
- package/package.json +1 -1
- package/src/agent/agent.ts +845 -116
- package/src/agent/context.ts +35 -0
- package/src/agent/delegation.ts +390 -36
- package/src/agent/loop.ts +785 -110
- package/src/agent/subagent.ts +59 -0
- package/src/index.ts +173 -59
- package/src/models/catalog-cache.ts +68 -25
- package/src/models/catalog.ts +123 -18
- package/src/providers/google.ts +152 -43
- package/src/providers/openai-compat.ts +246 -96
- package/src/providers/openai.ts +274 -79
- package/src/providers/openrouter.ts +191 -82
- package/src/providers/registry.ts +16 -12
- package/src/providers/shared.ts +190 -0
- package/src/providers.ts +259 -9
- package/src/session/store.ts +521 -33
- package/src/streaming/event-stream.ts +156 -41
- package/src/tools/executor.ts +248 -15
- package/src/tools/tool.ts +147 -3
- package/src/types/agent.ts +244 -2
- package/src/types/errors.ts +133 -0
- package/src/types/model.ts +22 -1
- package/src/types/response.ts +133 -3
- package/src/types/tool.ts +69 -0
- package/src/update-models.ts +8 -2
- package/src/utils/cache.ts +68 -1
- package/src/utils/env.ts +23 -13
- package/src/utils/errors.ts +15 -1
- package/src/utils/headers.ts +153 -19
- package/src/utils/retry.ts +27 -13
- package/src/utils/session.ts +284 -0
- package/src/providers/openrouter-responses.ts +0 -949
- package/src/types/provider-payloads.ts +0 -352
package/src/models/catalog.ts
CHANGED
|
@@ -39,12 +39,6 @@ const MODALITY_OVERRIDES: Record<string, { input?: string[]; output?: string[] }
|
|
|
39
39
|
// OpenRouter routes this free model to an endpoint that parses PDFs,
|
|
40
40
|
// verified end-to-end via examples/08. If routing changes, the runtime
|
|
41
41
|
// 404 still surfaces as a one-line error.
|
|
42
|
-
"openrouter/nvidia/nemotron-3.5-lightning:free": { input: ["text", "pdf"] },
|
|
43
|
-
// Live-verified 2026-09-24: `stealth/space-bunny-alpha` accepts PDF
|
|
44
|
-
// `input_file` over Responses and PDFs via the file-parser plugin over Chat
|
|
45
|
-
// Completions (Berkshire letter + sample-files PDF both completed), though
|
|
46
|
-
// models.dev lists text/image/video only.
|
|
47
|
-
"openrouter/stealth/space-bunny-alpha": { input: ["text", "image", "video", "pdf"] },
|
|
48
42
|
};
|
|
49
43
|
|
|
50
44
|
function applyModalityOverrides(provider: string, modelId: string, base: ModelModalities): ModelModalities {
|
|
@@ -68,10 +62,72 @@ function normalizeModelIdForLookup(modelId: string): string {
|
|
|
68
62
|
|
|
69
63
|
// ---------------------------------------------------------------------------
|
|
70
64
|
// Internal cache — battle-tested: avoid repeated global scans (7487 models)
|
|
65
|
+
// Bound lookupCache to 1000 entries LRU (Q-33): evict oldest on overflow.
|
|
71
66
|
// ---------------------------------------------------------------------------
|
|
72
67
|
const lookupCache = new Map<string, ModelSpec | undefined>();
|
|
73
68
|
const providerModelsCache = new Map<string, ModelSpec[]>();
|
|
74
69
|
|
|
70
|
+
/** Bounded LRU set for lookupCache (Q-33): keeps at most 1000 entries. */
|
|
71
|
+
function setLookupCache(key: string, value: ModelSpec | undefined): void {
|
|
72
|
+
if (lookupCache.has(key)) lookupCache.delete(key);
|
|
73
|
+
else if (lookupCache.size >= 1000) {
|
|
74
|
+
const oldest = lookupCache.keys().next();
|
|
75
|
+
if (!oldest.done) lookupCache.delete(oldest.value);
|
|
76
|
+
}
|
|
77
|
+
lookupCache.set(key, value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Custom-registered models (Q-33): checked first in getModelFromCatalog.
|
|
81
|
+
const customModelRegistry = new Map<string, ModelSpec>();
|
|
82
|
+
|
|
83
|
+
function customRegistryKey(provider: string, modelId: string): string {
|
|
84
|
+
return `${provider.toLowerCase()}::${modelId.toLowerCase()}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Registers a custom model spec that takes precedence over the catalog (Q-33).
|
|
89
|
+
* Stored in a separate map, checked first in `getModelFromCatalog`.
|
|
90
|
+
*/
|
|
91
|
+
export function registerModel(spec: ModelSpec): void {
|
|
92
|
+
if (!spec || typeof spec.id !== "string" || typeof spec.provider !== "string") {
|
|
93
|
+
throw new Error("[Agent Accelerator] registerModel requires spec.id and spec.provider strings");
|
|
94
|
+
}
|
|
95
|
+
customModelRegistry.set(customRegistryKey(String(spec.provider), String(spec.id)), { ...spec });
|
|
96
|
+
// Invalidate derived caches so the custom model is visible immediately.
|
|
97
|
+
lookupCache.clear();
|
|
98
|
+
providerModelsCache.clear();
|
|
99
|
+
globalIndex = null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Clears custom-registered models (primarily for tests). */
|
|
103
|
+
export function clearCustomModels(): void {
|
|
104
|
+
customModelRegistry.clear();
|
|
105
|
+
lookupCache.clear();
|
|
106
|
+
providerModelsCache.clear();
|
|
107
|
+
globalIndex = null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Returns where a model resolves from without touching frozen types (Q-33):
|
|
112
|
+
* `"override"` for custom-registered or MODALITY_OVERRIDES-covered models,
|
|
113
|
+
* `"catalog"` for plain catalog hits, `"unknown"` when unresolvable.
|
|
114
|
+
*/
|
|
115
|
+
export function getModelSource(provider: string, modelId: string): "catalog" | "override" | "unknown" {
|
|
116
|
+
const p = String(provider ?? "").toLowerCase();
|
|
117
|
+
const m = String(modelId ?? "");
|
|
118
|
+
const norm = normalizeModelIdForLookup(m);
|
|
119
|
+
if (customModelRegistry.has(customRegistryKey(p, m)) || customModelRegistry.has(customRegistryKey(p, norm))) {
|
|
120
|
+
return "override";
|
|
121
|
+
}
|
|
122
|
+
if (
|
|
123
|
+
MODALITY_OVERRIDES[`${p}/${m}`] !== undefined ||
|
|
124
|
+
MODALITY_OVERRIDES[`${p}/${norm}`] !== undefined
|
|
125
|
+
) {
|
|
126
|
+
return "override";
|
|
127
|
+
}
|
|
128
|
+
return getModelFromCatalog(provider, modelId) ? "catalog" : "unknown";
|
|
129
|
+
}
|
|
130
|
+
|
|
75
131
|
function mapCapabilities(raw: any, cost: ModelCost, reasoning: boolean, toolCall: boolean, modalities: ModelModalities) {
|
|
76
132
|
return {
|
|
77
133
|
supportsThinking: reasoning,
|
|
@@ -82,6 +138,7 @@ function mapCapabilities(raw: any, cost: ModelCost, reasoning: boolean, toolCall
|
|
|
82
138
|
supportsExplicitCaching: cost.cache_read !== undefined,
|
|
83
139
|
supportsParallelToolCalls: toolCall,
|
|
84
140
|
supportsStreaming: true,
|
|
141
|
+
supportsStructuredOutput: raw.structured_output === undefined ? true : !!raw.structured_output,
|
|
85
142
|
supportsReasoningToggle: Array.isArray(raw.reasoning_options) && raw.reasoning_options.some((o: any) => o.type === "toggle"),
|
|
86
143
|
supportsReasoningEffort: Array.isArray(raw.reasoning_options) && raw.reasoning_options.some((o: any) => o.type === "effort"),
|
|
87
144
|
modalities: [...(modalities.input || []), ...(modalities.output || [])].filter((v: any, i: any, a: any) => a.indexOf(v) === i),
|
|
@@ -180,6 +237,16 @@ export function getModelFromCatalog(providerInput: string, modelIdInput: string)
|
|
|
180
237
|
const modelId = modelIdInput.trim();
|
|
181
238
|
let result: ModelSpec | undefined;
|
|
182
239
|
|
|
240
|
+
// Q-33: custom-registered models win over the catalog.
|
|
241
|
+
const customHit =
|
|
242
|
+
customModelRegistry.get(customRegistryKey(provider, modelId)) ??
|
|
243
|
+
customModelRegistry.get(customRegistryKey(provider, normalizeModelIdForLookup(modelId)));
|
|
244
|
+
if (customHit) {
|
|
245
|
+
result = { ...customHit };
|
|
246
|
+
setLookupCache(cacheKey, result);
|
|
247
|
+
return result;
|
|
248
|
+
}
|
|
249
|
+
|
|
183
250
|
const aliasProviders = PROVIDER_ALIASES[provider] || [provider];
|
|
184
251
|
const tryProviders = [...new Set([...aliasProviders, provider, provider.replace("-zen", ""), provider.replace("-go", "")])];
|
|
185
252
|
const catalog = getActiveCatalog();
|
|
@@ -197,14 +264,17 @@ export function getModelFromCatalog(providerInput: string, modelIdInput: string)
|
|
|
197
264
|
const raw = providerData.models[cand];
|
|
198
265
|
if (raw) {
|
|
199
266
|
result = mapModelSpec(p as ProviderId, cand, raw);
|
|
200
|
-
|
|
267
|
+
setLookupCache(cacheKey, result);
|
|
201
268
|
return result;
|
|
202
269
|
}
|
|
203
270
|
}
|
|
271
|
+
// Q-33: tightened fuzzy match — exact, `endsWith("/"+modelId)` boundary,
|
|
272
|
+
// or normalized equality. The old loose `modelId.endsWith(key)` is removed
|
|
273
|
+
// (e.g. "gpt-4o-mini-extra" must not match "mini").
|
|
204
274
|
for (const [key, raw] of Object.entries(providerData.models as Record<string, any>)) {
|
|
205
|
-
if (key === modelId || key.endsWith(`/${modelId}`) ||
|
|
275
|
+
if (key === modelId || key.endsWith(`/${modelId}`) || normalizeModelIdForLookup(key) === normalizeModelIdForLookup(modelId)) {
|
|
206
276
|
result = mapModelSpec(p as ProviderId, key, raw as any);
|
|
207
|
-
|
|
277
|
+
setLookupCache(cacheKey, result);
|
|
208
278
|
return result;
|
|
209
279
|
}
|
|
210
280
|
}
|
|
@@ -217,11 +287,11 @@ export function getModelFromCatalog(providerInput: string, modelIdInput: string)
|
|
|
217
287
|
const hit = index.get(lowerModelId) || index.get(strippedLower);
|
|
218
288
|
if (hit) {
|
|
219
289
|
result = mapModelSpec(hit.provider, hit.key, hit.raw);
|
|
220
|
-
|
|
290
|
+
setLookupCache(cacheKey, result);
|
|
221
291
|
return result;
|
|
222
292
|
}
|
|
223
293
|
|
|
224
|
-
|
|
294
|
+
setLookupCache(cacheKey, undefined);
|
|
225
295
|
return undefined;
|
|
226
296
|
}
|
|
227
297
|
|
|
@@ -349,16 +419,16 @@ export class ThinkingLevelError extends Error {
|
|
|
349
419
|
remedy?: string;
|
|
350
420
|
}) {
|
|
351
421
|
const remedyLines = opts.remedy
|
|
352
|
-
? `\n\n
|
|
422
|
+
? `\n\n How to fix:\n ${opts.remedy}`
|
|
353
423
|
: "";
|
|
354
424
|
const allowedLine = opts.allowedLevels.length > 0
|
|
355
|
-
? `\n
|
|
425
|
+
? `\n Allowed Levels: [${opts.allowedLevels.map((l) => `"${l}"`).join(", ")}]`
|
|
356
426
|
: "";
|
|
357
427
|
|
|
358
428
|
const formattedMessage =
|
|
359
|
-
|
|
360
|
-
`
|
|
361
|
-
`
|
|
429
|
+
`[Agent Accelerator] ThinkingLevel Mismatch for "${opts.provider}/${opts.modelId}":\n` +
|
|
430
|
+
` Requested Level: "${opts.requestedLevel}"\n` +
|
|
431
|
+
` Issue: ${opts.reason}` +
|
|
362
432
|
allowedLine +
|
|
363
433
|
remedyLines;
|
|
364
434
|
|
|
@@ -454,7 +524,7 @@ export function validateModelThinking(provider: string, modelId: string, request
|
|
|
454
524
|
* @example `const googleModels = getModelsForProvider("google");`
|
|
455
525
|
*/
|
|
456
526
|
export function getModelsForProvider(provider: string): ModelSpec[] {
|
|
457
|
-
if (providerModelsCache.has(provider)) return providerModelsCache.get(provider)
|
|
527
|
+
if (providerModelsCache.has(provider)) return [...providerModelsCache.get(provider)!];
|
|
458
528
|
const aliases = PROVIDER_ALIASES[provider] || [provider];
|
|
459
529
|
const seen = new Set<string>();
|
|
460
530
|
const out: ModelSpec[] = [];
|
|
@@ -468,9 +538,19 @@ export function getModelsForProvider(provider: string): ModelSpec[] {
|
|
|
468
538
|
out.push(mapModelSpec(alias as ProviderId, key, raw));
|
|
469
539
|
}
|
|
470
540
|
}
|
|
541
|
+
// Q-33: include custom-registered models for this provider/alias set.
|
|
542
|
+
for (const spec of customModelRegistry.values()) {
|
|
543
|
+
if (aliases.includes(String(spec.provider)) || String(spec.provider) === provider) {
|
|
544
|
+
if (!seen.has(spec.id)) {
|
|
545
|
+
seen.add(spec.id);
|
|
546
|
+
out.push({ ...spec });
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
471
550
|
// Also include our internal provider id mapping
|
|
551
|
+
// Q-33: return copies — cache the canonical array, hand out a fresh copy.
|
|
472
552
|
providerModelsCache.set(provider, out);
|
|
473
|
-
return out;
|
|
553
|
+
return [...out];
|
|
474
554
|
}
|
|
475
555
|
|
|
476
556
|
export const GOOGLE_MODELS: ModelSpec[] = [...getModelsForProvider("google")];
|
|
@@ -509,6 +589,30 @@ export {
|
|
|
509
589
|
type RefreshCatalogOptions,
|
|
510
590
|
} from "./catalog-cache.ts";
|
|
511
591
|
|
|
592
|
+
/**
|
|
593
|
+
* True when the model can enforce `response_format`/`text.format` natively.
|
|
594
|
+
* Unknown models (custom endpoints, dynamic routers) count as capable —
|
|
595
|
+
* the provider verdict stands, matching `assertModalitiesSupported`.
|
|
596
|
+
* Models with catalog `structured_output: false` return false so callers can
|
|
597
|
+
* emulate via prompt instruction + client validation instead of 400ing.
|
|
598
|
+
*
|
|
599
|
+
* @example `modelSupportsStructuredOutput("openrouter", "inclusionai/ling-3.0-flash-sante:free")`
|
|
600
|
+
*/
|
|
601
|
+
export function modelSupportsStructuredOutput(provider: string, modelId: string): boolean {
|
|
602
|
+
try {
|
|
603
|
+
const spec = getModelFromCatalog(provider, modelId);
|
|
604
|
+
if (!spec) return true;
|
|
605
|
+
if (typeof spec.capabilities?.supportsStructuredOutput === "boolean") {
|
|
606
|
+
return spec.capabilities.supportsStructuredOutput;
|
|
607
|
+
}
|
|
608
|
+
const raw = (spec as { raw?: { structured_output?: unknown } }).raw;
|
|
609
|
+
if (raw && typeof raw.structured_output === "boolean") return raw.structured_output;
|
|
610
|
+
return true;
|
|
611
|
+
} catch {
|
|
612
|
+
return true;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
512
616
|
/**
|
|
513
617
|
* Permissive placeholder so custom endpoints never fail preflight:
|
|
514
618
|
* allows every ThinkingLevel (none -> max + dynamic).
|
|
@@ -543,6 +647,7 @@ export function createGenericModelSpec(provider: string, modelId: string): Model
|
|
|
543
647
|
supportsLongCacheRetention: false,
|
|
544
648
|
supportsParallelToolCalls: true,
|
|
545
649
|
supportsStreaming: true,
|
|
650
|
+
supportsStructuredOutput: true,
|
|
546
651
|
modalities: ["text"],
|
|
547
652
|
},
|
|
548
653
|
pricing: {},
|
package/src/providers/google.ts
CHANGED
|
@@ -40,8 +40,19 @@ import { safeStringify } from "../utils/serialization.ts";
|
|
|
40
40
|
import { stripSchemaForGoogle } from "../tools/schema.ts";
|
|
41
41
|
import { toConciseProviderError, assertModalitiesSupported } from "../utils/errors.ts";
|
|
42
42
|
import { withRetries } from "../utils/retry.ts";
|
|
43
|
+
import { setHeaderCaseInsensitive, shouldEchoSignature } from "../utils/headers.ts";
|
|
44
|
+
import {
|
|
45
|
+
newToolCallId,
|
|
46
|
+
redactedHeaders,
|
|
47
|
+
readErrorPayload,
|
|
48
|
+
incompleteStreamError,
|
|
49
|
+
combinedSignal,
|
|
50
|
+
timeoutFor,
|
|
51
|
+
hasAuthHeader,
|
|
52
|
+
clampToolCallId,
|
|
53
|
+
} from "./shared.ts";
|
|
43
54
|
import { createGenericModelSpec } from "../models/catalog.ts";
|
|
44
|
-
import { getModelFromCatalog, getModelsForProvider } from "../models/catalog.ts";
|
|
55
|
+
import { getModelFromCatalog, getModelsForProvider, modelSupportsStructuredOutput } from "../models/catalog.ts";
|
|
45
56
|
import {
|
|
46
57
|
mapThinkingLevelToGoogle,
|
|
47
58
|
mapServiceTierToGoogle,
|
|
@@ -50,6 +61,10 @@ import {
|
|
|
50
61
|
lastProviderFor,
|
|
51
62
|
noteProviderTurn,
|
|
52
63
|
parseStreamedToolArguments,
|
|
64
|
+
mapOutputToGoogle,
|
|
65
|
+
normalizeStructuredOutput,
|
|
66
|
+
augmentSystemPromptWithStructuredOutput,
|
|
67
|
+
emitProviderWarning,
|
|
53
68
|
} from "../providers.ts";
|
|
54
69
|
|
|
55
70
|
// ---------------------------------------------------------------------------
|
|
@@ -167,7 +182,10 @@ function resolveBaseUrl(options?: ProviderRequestOptions): string {
|
|
|
167
182
|
}
|
|
168
183
|
|
|
169
184
|
function resolveApiKey(options?: ProviderRequestOptions): string | undefined {
|
|
170
|
-
|
|
185
|
+
const key = getApiKey("google", options?.apiKey, options?.env);
|
|
186
|
+
if (key) return key;
|
|
187
|
+
if (hasAuthHeader(options?.headers)) return "unused";
|
|
188
|
+
return undefined;
|
|
171
189
|
}
|
|
172
190
|
|
|
173
191
|
function cleanModelId(model: string | ModelSpec): string {
|
|
@@ -304,9 +322,16 @@ async function fullHistorySteps(context: ProviderContext): Promise<InteractionIn
|
|
|
304
322
|
const texts: string[] = [];
|
|
305
323
|
for (const part of m.content) {
|
|
306
324
|
if (part.type === "thinking" && part.thinking) {
|
|
325
|
+
// Q-18: preserve namespaced signatures only — foreign-provider
|
|
326
|
+
// signatures never echo here; untagged legacy echoes (compat).
|
|
327
|
+
const partProvider = (part as { thoughtSignatureProvider?: unknown }).thoughtSignatureProvider;
|
|
328
|
+
const msgProvider = (m as { thoughtSignatureProvider?: unknown }).thoughtSignatureProvider;
|
|
329
|
+
const sig = shouldEchoSignature(partProvider ?? msgProvider, "google")
|
|
330
|
+
? part.thoughtSignature || m.thoughtSignature || ""
|
|
331
|
+
: "";
|
|
307
332
|
steps.push({
|
|
308
333
|
type: "thought",
|
|
309
|
-
signature:
|
|
334
|
+
signature: sig,
|
|
310
335
|
summary: [{ type: "text", text: part.thinking }],
|
|
311
336
|
});
|
|
312
337
|
} else if (part.type === "tool_call") {
|
|
@@ -328,7 +353,11 @@ async function fullHistorySteps(context: ProviderContext): Promise<InteractionIn
|
|
|
328
353
|
} else if (m.thoughtSignature && !steps.some((s) => s.type === "thought")) {
|
|
329
354
|
// Preserve a signature-only thought so stateless chaining keeps
|
|
330
355
|
// reasoning continuity even when no summary text was retained.
|
|
331
|
-
|
|
356
|
+
// Q-18: only for google/untagged signatures.
|
|
357
|
+
const msgProvider = (m as { thoughtSignatureProvider?: unknown }).thoughtSignatureProvider;
|
|
358
|
+
if (shouldEchoSignature(msgProvider, "google")) {
|
|
359
|
+
steps.push({ type: "thought", signature: m.thoughtSignature });
|
|
360
|
+
}
|
|
332
361
|
}
|
|
333
362
|
} else if (m.role === "tool") {
|
|
334
363
|
if (!Array.isArray(m.content)) continue;
|
|
@@ -408,7 +437,7 @@ function parseInteraction(
|
|
|
408
437
|
const args =
|
|
409
438
|
step.arguments && typeof step.arguments === "object" ? step.arguments : { raw: step.arguments };
|
|
410
439
|
toolCalls.push({
|
|
411
|
-
id: step.id ||
|
|
440
|
+
id: step.id || newToolCallId(),
|
|
412
441
|
name: step.name || "unknown",
|
|
413
442
|
arguments: args as Record<string, unknown>,
|
|
414
443
|
rawArguments: JSON.stringify(args),
|
|
@@ -431,26 +460,11 @@ function parseInteraction(
|
|
|
431
460
|
};
|
|
432
461
|
}
|
|
433
462
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
}
|
|
439
|
-
return out;
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
function readErrorPayload(bodyText: string): { message: string; code?: string | number } {
|
|
443
|
-
try {
|
|
444
|
-
const parsed: unknown = JSON.parse(bodyText);
|
|
445
|
-
const first = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
446
|
-
const err = (first as { error?: { message?: string; code?: string | number; status?: string } })?.error;
|
|
447
|
-
if (err && typeof err.message === "string") {
|
|
448
|
-
return { message: err.message, code: err.code ?? err.status };
|
|
449
|
-
}
|
|
450
|
-
return { message: bodyText.slice(0, 300) };
|
|
451
|
-
} catch {
|
|
452
|
-
return { message: bodyText.slice(0, 300) };
|
|
453
|
-
}
|
|
463
|
+
/** Local per-provider signature tag (Q-18): google history preserves only
|
|
464
|
+
* google/untagged signatures; foreign-provider signatures never echo here. */
|
|
465
|
+
function withGoogleSignature(sig: string | undefined): { thoughtSignature?: string; thoughtSignatureProvider?: string } {
|
|
466
|
+
if (!sig) return {};
|
|
467
|
+
return { thoughtSignature: sig, thoughtSignatureProvider: "google" };
|
|
454
468
|
}
|
|
455
469
|
|
|
456
470
|
// ---------------------------------------------------------------------------
|
|
@@ -512,11 +526,43 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
512
526
|
input: "",
|
|
513
527
|
...(stream ? { stream: true } : {}),
|
|
514
528
|
};
|
|
515
|
-
|
|
529
|
+
// Structured outputs: native `response_format` when supported, else
|
|
530
|
+
// emulated via system instruction (catalog `structured_output: false`
|
|
531
|
+
// would 400 on the wire shape).
|
|
532
|
+
let outputEmulated = false;
|
|
533
|
+
let outputSpec: import("../types/model.ts").StructuredOutputSpec | undefined;
|
|
534
|
+
try {
|
|
535
|
+
outputSpec = normalizeStructuredOutput(options?.output ?? (options as { outputSchema?: unknown })?.outputSchema);
|
|
536
|
+
} catch {
|
|
537
|
+
outputSpec = undefined;
|
|
538
|
+
}
|
|
539
|
+
let effectiveSystemPrompt = context.systemPrompt;
|
|
540
|
+
if (outputSpec && !modelSupportsStructuredOutput("google", modelId)) {
|
|
541
|
+
outputEmulated = true;
|
|
542
|
+
effectiveSystemPrompt = augmentSystemPromptWithStructuredOutput(context.systemPrompt, outputSpec);
|
|
543
|
+
emitProviderWarning({
|
|
544
|
+
provider: "google",
|
|
545
|
+
capability: "structured output",
|
|
546
|
+
requested: `${outputSpec.name} (google/${modelId})`,
|
|
547
|
+
reason: "the catalog reports structured_output: false for this model, so native response_format is unsupported.",
|
|
548
|
+
fallback: "emulated JSON instruction + client validation (no response_format is sent)",
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
if (effectiveSystemPrompt) body.system_instruction = effectiveSystemPrompt;
|
|
516
552
|
const googleTools = toGoogleTools(tools);
|
|
517
553
|
if (googleTools) body.tools = googleTools;
|
|
518
554
|
if (Object.keys(generationConfig).length > 0) body.generation_config = generationConfig;
|
|
519
555
|
if (serviceTier) body.service_tier = serviceTier;
|
|
556
|
+
// Structured outputs: Interactions `response_format{type:text,mime_type:application/json,schema}`.
|
|
557
|
+
// Skipped when emulated (instruction already in `system_instruction`).
|
|
558
|
+
if (outputSpec && !outputEmulated) {
|
|
559
|
+
try {
|
|
560
|
+
const mapped = mapOutputToGoogle(outputSpec);
|
|
561
|
+
if (mapped) (body as unknown as Record<string, unknown>)["response_format"] = mapped.responseFormat;
|
|
562
|
+
} catch {
|
|
563
|
+
// why: invalid output shapes fail fast at Agent construction/loop validation.
|
|
564
|
+
}
|
|
565
|
+
}
|
|
520
566
|
|
|
521
567
|
const prevProvider = lastProviderFor(sessionId);
|
|
522
568
|
const switched = !!prevProvider && prevProvider !== "google";
|
|
@@ -557,7 +603,11 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
557
603
|
for (const [k, v] of Object.entries(options?.headers ?? {})) {
|
|
558
604
|
if (!INTERNAL_HEADERS.has(k.toLowerCase())) headers[k] = v;
|
|
559
605
|
}
|
|
560
|
-
|
|
606
|
+
// Q-17: key travels as the `x-goog-api-key` header (never `?key=` in the
|
|
607
|
+
// URL, which would leak into logs). Case-insensitive merge so a custom
|
|
608
|
+
// `X-Goog-Api-Key` never duplicates the canonical header. Proxy flows
|
|
609
|
+
// (`apiKey === "unused"`) preserve custom auth instead of overwriting.
|
|
610
|
+
if (apiKey !== "unused") setHeaderCaseInsensitive(headers, "x-goog-api-key", apiKey);
|
|
561
611
|
return { method: "POST", headers, body: JSON.stringify(body), signal };
|
|
562
612
|
}
|
|
563
613
|
|
|
@@ -568,13 +618,18 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
568
618
|
apiKey: string,
|
|
569
619
|
signal?: AbortSignal
|
|
570
620
|
): Promise<{ status: number; statusText: string; headers: Record<string, string>; text: string }> {
|
|
571
|
-
const
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
headers
|
|
576
|
-
|
|
577
|
-
|
|
621
|
+
const { signal: effective, cleanup } = combinedSignal(signal, timeoutFor(options as { timeoutMs?: number } | undefined));
|
|
622
|
+
try {
|
|
623
|
+
const res = await fetch(url, this.requestInit(body, options, apiKey, effective));
|
|
624
|
+
const text = await res.text();
|
|
625
|
+
const headers: Record<string, string> = {};
|
|
626
|
+
res.headers.forEach((v, k) => {
|
|
627
|
+
headers[k] = v;
|
|
628
|
+
});
|
|
629
|
+
return { status: res.status, statusText: res.statusText, headers, text };
|
|
630
|
+
} finally {
|
|
631
|
+
cleanup();
|
|
632
|
+
}
|
|
578
633
|
}
|
|
579
634
|
|
|
580
635
|
private throwIfError(
|
|
@@ -585,13 +640,21 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
585
640
|
modelId: string
|
|
586
641
|
): void {
|
|
587
642
|
if (status >= 200 && status < 300) return;
|
|
588
|
-
const { message, code } = readErrorPayload(bodyText);
|
|
589
|
-
const
|
|
643
|
+
const { message, code, errorType, raw } = readErrorPayload(bodyText);
|
|
644
|
+
const hint =
|
|
645
|
+
status === 401
|
|
646
|
+
? " Check GEMINI_API_KEY/GOOGLE_API_KEY (no quotes/spaces, correct provider), GOOGLE_BASE_URL, and that the key is loaded (bun --env-file=.env.local)."
|
|
647
|
+
: "";
|
|
648
|
+
const fullMessage = `${message}${hint}`;
|
|
649
|
+
const err: Record<string, unknown> & Error = new Error(fullMessage) as Record<string, unknown> & Error;
|
|
590
650
|
(err as Record<string, unknown>)["statusCode"] = status;
|
|
591
651
|
(err as Record<string, unknown>)["status"] = status;
|
|
592
652
|
(err as Record<string, unknown>)["responseBody"] = bodyText.slice(0, 500);
|
|
593
653
|
(err as Record<string, unknown>)["url"] = url.split("?")[0];
|
|
654
|
+
(err as Record<string, unknown>)["data"] = { error: { message: fullMessage } };
|
|
594
655
|
if (code !== undefined) (err as Record<string, unknown>)["code"] = code;
|
|
656
|
+
if (errorType) (err as Record<string, unknown>)["errorType"] = errorType;
|
|
657
|
+
if (raw) (err as Record<string, unknown>)["raw"] = raw;
|
|
595
658
|
throw toConciseProviderError(err, "google", modelId);
|
|
596
659
|
}
|
|
597
660
|
|
|
@@ -733,7 +796,21 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
733
796
|
body,
|
|
734
797
|
};
|
|
735
798
|
|
|
736
|
-
const
|
|
799
|
+
const streamTimeoutMs = timeoutFor(options as { timeoutMs?: number } | undefined);
|
|
800
|
+
const streamTimeoutTimer =
|
|
801
|
+
streamTimeoutMs && streamTimeoutMs > 0
|
|
802
|
+
? setTimeout(() => {
|
|
803
|
+
try {
|
|
804
|
+
linked.abort();
|
|
805
|
+
} catch {}
|
|
806
|
+
}, streamTimeoutMs)
|
|
807
|
+
: undefined;
|
|
808
|
+
let res: Response;
|
|
809
|
+
try {
|
|
810
|
+
res = await fetch(url, this.requestInit(body, options, apiKey, linked.signal));
|
|
811
|
+
} finally {
|
|
812
|
+
if (streamTimeoutTimer) clearTimeout(streamTimeoutTimer);
|
|
813
|
+
}
|
|
737
814
|
if (!res.ok || !res.body) {
|
|
738
815
|
const text = !res.ok ? await res.text().catch(() => "") : "";
|
|
739
816
|
if (!res.ok) this.throwIfError(res.status, res.statusText, url, text, clean);
|
|
@@ -743,7 +820,7 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
743
820
|
res.headers.forEach((v, k) => {
|
|
744
821
|
responseHeaders[k] = v;
|
|
745
822
|
});
|
|
746
|
-
const responseMeta = { status: res.status, statusText: res.statusText, headers: responseHeaders };
|
|
823
|
+
const responseMeta: Record<string, unknown> & { status: number; statusText: string; headers: Record<string, string> } = { status: res.status, statusText: res.statusText, headers: responseHeaders };
|
|
747
824
|
eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
|
|
748
825
|
|
|
749
826
|
const parser = new SSEParser();
|
|
@@ -755,6 +832,10 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
755
832
|
const calls = new Map<number, { id: string; name: string; startArgs: string; deltaArgs: string }>();
|
|
756
833
|
let usage: TokenUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
757
834
|
let finishReason = "stop";
|
|
835
|
+
let sawFinishReason = false;
|
|
836
|
+
let sawUsage = false;
|
|
837
|
+
let badFrames = 0;
|
|
838
|
+
let framesSeen = 0;
|
|
758
839
|
let responseId: string | undefined;
|
|
759
840
|
let completedBody: unknown = undefined;
|
|
760
841
|
let aborted = false;
|
|
@@ -770,11 +851,17 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
770
851
|
);
|
|
771
852
|
|
|
772
853
|
const handleMessage = (event: string | undefined, data: string): void => {
|
|
773
|
-
if (!data
|
|
854
|
+
if (!data) return;
|
|
855
|
+
if (data === "[DONE]") {
|
|
856
|
+
sawFinishReason = sawFinishReason || completedBody !== undefined || sawUsage;
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
774
859
|
let msg: Record<string, unknown>;
|
|
775
860
|
try {
|
|
776
861
|
msg = JSON.parse(data) as Record<string, unknown>;
|
|
862
|
+
framesSeen += 1;
|
|
777
863
|
} catch {
|
|
864
|
+
badFrames += 1;
|
|
778
865
|
return;
|
|
779
866
|
}
|
|
780
867
|
const type = (msg["event_type"] as string) || event;
|
|
@@ -826,7 +913,7 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
826
913
|
const args = step["arguments"];
|
|
827
914
|
const startArgs = typeof args === "string" ? args : args ? JSON.stringify(args) : "";
|
|
828
915
|
calls.set(index, {
|
|
829
|
-
id: (step["id"] as string) ||
|
|
916
|
+
id: (step["id"] as string) || newToolCallId(),
|
|
830
917
|
name: (step["name"] as string) || "unknown",
|
|
831
918
|
startArgs,
|
|
832
919
|
deltaArgs: "",
|
|
@@ -836,8 +923,12 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
836
923
|
const interaction = (msg["interaction"] as InteractionObject) ?? {};
|
|
837
924
|
completedBody = msg["interaction"];
|
|
838
925
|
if (interaction.id) responseId = interaction.id;
|
|
839
|
-
if (interaction.usage)
|
|
926
|
+
if (interaction.usage) {
|
|
927
|
+
usage = mapUsage(interaction.usage);
|
|
928
|
+
sawUsage = true;
|
|
929
|
+
}
|
|
840
930
|
finishReason = mapFinishReason(interaction.status);
|
|
931
|
+
sawFinishReason = true;
|
|
841
932
|
eventStream.push({ type: "usage", usage });
|
|
842
933
|
}
|
|
843
934
|
};
|
|
@@ -882,10 +973,25 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
882
973
|
}
|
|
883
974
|
noteProviderTurn(sessionId, "google");
|
|
884
975
|
|
|
976
|
+
// Q-23: terminal-state guard — fail `incomplete_stream` (retryable)
|
|
977
|
+
// instead of resolving empty success. Skipped for non-SSE payloads
|
|
978
|
+
// (JSON mocks) where no SSE frames were ever seen.
|
|
979
|
+
const hasTerminal = completedBody !== undefined || sawFinishReason || sawUsage;
|
|
885
980
|
// Trim provider trailing blank lines from finalized thinking (live
|
|
886
981
|
// deltas already emitted raw; display collapse happens in the
|
|
887
982
|
// wrapThinking presentation layer). Stateful bodies unaffected.
|
|
888
|
-
const
|
|
983
|
+
const cleanThinkingEarly = thinking.replace(/\n{3,}/g, "\n\n").trim();
|
|
984
|
+
const ctEntry = Object.entries(responseHeaders).find(([k]) => k.toLowerCase() === "content-type");
|
|
985
|
+
const isSSEPayload =
|
|
986
|
+
(typeof ctEntry?.[1] === "string" && ((ctEntry[1] as string).includes("event-stream") || (ctEntry[1] as string).startsWith("text/"))) ||
|
|
987
|
+
framesSeen > 0 ||
|
|
988
|
+
badFrames > 0;
|
|
989
|
+
if (!hasTerminal && !text && !cleanThinkingEarly && toolCalls.length === 0 && isSSEPayload) {
|
|
990
|
+
eventStream.fail(incompleteStreamError("google", clean));
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
const cleanThinking = cleanThinkingEarly;
|
|
889
995
|
const finalResponse = new AgentResponse({
|
|
890
996
|
text,
|
|
891
997
|
thinking: cleanThinking || undefined,
|
|
@@ -896,7 +1002,10 @@ export class GoogleInteractionsProvider implements Provider {
|
|
|
896
1002
|
responseId,
|
|
897
1003
|
model: clean,
|
|
898
1004
|
provider: "google",
|
|
899
|
-
raw: {
|
|
1005
|
+
raw: {
|
|
1006
|
+
request: rawRequest,
|
|
1007
|
+
response: { ...responseMeta, body: completedBody, badFrames } as unknown as ProviderRawData["response"],
|
|
1008
|
+
},
|
|
900
1009
|
durationMs: Date.now() - startTime,
|
|
901
1010
|
});
|
|
902
1011
|
eventStream.push({ type: "done", delta: "", usage, finishReason, responseId });
|