@steve31415/baselib 2.4.3 → 3.0.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.
@@ -0,0 +1,122 @@
1
+ import { LLMError } from '../types.js';
2
+ /** Classify Anthropic's `stop_reason` (already lowercase from the API). */
3
+ function classifyFinish(stopReason) {
4
+ switch (stopReason) {
5
+ case 'refusal': return 'declined';
6
+ case 'max_tokens':
7
+ case 'model_context_window_exceeded': return 'truncated';
8
+ case 'end_turn':
9
+ case 'stop_sequence':
10
+ case 'tool_use':
11
+ case 'pause_turn': return 'complete';
12
+ default: return 'other';
13
+ }
14
+ }
15
+ export const anthropicProvider = {
16
+ name: 'anthropic',
17
+ async complete(request) {
18
+ const endpoint = 'https://api.anthropic.com/v1/messages';
19
+ request.logger?.info('llm.request', {
20
+ provider: 'anthropic', model: request.apiModelId, endpoint,
21
+ ...(request.callSite ? { callSite: request.callSite } : {}),
22
+ });
23
+ const startTime = Date.now();
24
+ const body = {
25
+ model: request.apiModelId,
26
+ max_tokens: request.maxTokens,
27
+ messages: [{ role: 'user', content: request.prompt }],
28
+ };
29
+ // Some models (Opus 4.7+, Sonnet 5, Fable 5) reject `temperature`
30
+ // outright with a 400 — omit the field entirely rather than sending it.
31
+ if (request.supportsTemperature) {
32
+ body.temperature = request.temperature;
33
+ }
34
+ // Output-effort control (Sonnet 4.6+/Opus 4.6+/Sonnet 5/Opus 5): lower
35
+ // effort trades thoroughness for latency/cost. Only ever set here when the
36
+ // registry marks the model effort-capable (gated in complete.ts).
37
+ if (request.effort) {
38
+ body.output_config = { effort: request.effort };
39
+ }
40
+ if (request.systemPrompt) {
41
+ body.system = request.cacheSystemPrompt
42
+ ? [{ type: 'text', text: request.systemPrompt, cache_control: { type: 'ephemeral' } }]
43
+ : request.systemPrompt;
44
+ }
45
+ // JSON schema: use tool_use to get structured output
46
+ if (request.jsonSchema) {
47
+ body.tools = [{
48
+ name: request.jsonSchema.name,
49
+ description: `Generate structured output matching the ${request.jsonSchema.name} schema`,
50
+ input_schema: request.jsonSchema.schema,
51
+ }];
52
+ body.tool_choice = { type: 'tool', name: request.jsonSchema.name };
53
+ }
54
+ const controller = new AbortController();
55
+ const timer = setTimeout(() => controller.abort(), request.timeoutMs);
56
+ let response;
57
+ try {
58
+ response = await fetch(endpoint, {
59
+ method: 'POST',
60
+ headers: {
61
+ 'Content-Type': 'application/json',
62
+ 'x-api-key': request.apiKey,
63
+ 'anthropic-version': '2023-06-01',
64
+ },
65
+ body: JSON.stringify(body),
66
+ signal: controller.signal,
67
+ });
68
+ }
69
+ catch (err) {
70
+ const message = err instanceof Error && err.name === 'AbortError'
71
+ ? `Anthropic API timeout after ${request.timeoutMs}ms`
72
+ : `Anthropic API network error: ${err instanceof Error ? err.message : String(err)}`;
73
+ throw new LLMError({
74
+ message,
75
+ provider: 'anthropic',
76
+ model: request.apiModelId,
77
+ isRetryable: true,
78
+ });
79
+ }
80
+ finally {
81
+ clearTimeout(timer);
82
+ }
83
+ if (!response.ok) {
84
+ const errorText = await response.text();
85
+ throw new LLMError({
86
+ message: `Anthropic API error: ${response.status} - ${errorText.slice(0, 500)}`,
87
+ provider: 'anthropic',
88
+ model: request.apiModelId,
89
+ statusCode: response.status,
90
+ isRetryable: response.status >= 500 || response.status === 429,
91
+ });
92
+ }
93
+ const data = await response.json();
94
+ const latencyMs = Date.now() - startTime;
95
+ const outputTokens = data.usage?.output_tokens ?? undefined;
96
+ const tokensUsed = (data.usage?.input_tokens || 0) + (data.usage?.output_tokens || 0);
97
+ const cacheReadTokens = data.usage?.cache_read_input_tokens ?? undefined;
98
+ const cacheCreationTokens = data.usage?.cache_creation_input_tokens ?? undefined;
99
+ const finishReason = data.stop_reason ?? undefined;
100
+ let text;
101
+ let json;
102
+ if (request.jsonSchema) {
103
+ const toolUse = data.content?.find((c) => c.type === 'tool_use');
104
+ json = toolUse?.input;
105
+ text = json !== undefined ? JSON.stringify(json) : '';
106
+ }
107
+ else {
108
+ text = data.content?.find((c) => c.type === 'text')?.text || '';
109
+ }
110
+ request.logger?.info('llm.response', {
111
+ provider: 'anthropic', model: request.apiModelId,
112
+ status: response.status, latencyMs, tokensUsed, outputTokens, finishReason,
113
+ ...(cacheReadTokens !== undefined ? { cacheReadTokens } : {}),
114
+ ...(cacheCreationTokens !== undefined ? { cacheCreationTokens } : {}),
115
+ ...(request.callSite ? { callSite: request.callSite } : {}),
116
+ });
117
+ return {
118
+ text, json, tokensUsed, outputTokens, cacheReadTokens, cacheCreationTokens,
119
+ finishReason, finishCategory: classifyFinish(finishReason), latencyMs,
120
+ };
121
+ },
122
+ };
@@ -0,0 +1,2 @@
1
+ import type { ProviderAdapter } from '../types.js';
2
+ export declare const geminiProvider: ProviderAdapter;
@@ -0,0 +1,128 @@
1
+ import { LLMError } from '../types.js';
2
+ /** Gemini `finishReason` enum values that mean the model was content-blocked. */
3
+ const GEMINI_DECLINE_REASONS = new Set([
4
+ 'safety', 'recitation', 'prohibited_content', 'blocklist', 'spii', 'image_safety',
5
+ ]);
6
+ /** Classify Gemini's `finishReason` (lowercased by the caller). */
7
+ function classifyFinish(finishReason) {
8
+ if (finishReason === undefined)
9
+ return 'other';
10
+ if (GEMINI_DECLINE_REASONS.has(finishReason))
11
+ return 'declined';
12
+ if (finishReason === 'max_tokens')
13
+ return 'truncated';
14
+ if (finishReason === 'stop')
15
+ return 'complete';
16
+ return 'other';
17
+ }
18
+ /** Recursively strip fields that Gemini's API doesn't support (e.g. additionalProperties). */
19
+ function stripUnsupportedSchemaFields(schema) {
20
+ if (Array.isArray(schema)) {
21
+ return schema.map(stripUnsupportedSchemaFields);
22
+ }
23
+ if (schema !== null && typeof schema === 'object') {
24
+ const result = {};
25
+ for (const [key, value] of Object.entries(schema)) {
26
+ if (key === 'additionalProperties')
27
+ continue;
28
+ result[key] = stripUnsupportedSchemaFields(value);
29
+ }
30
+ return result;
31
+ }
32
+ return schema;
33
+ }
34
+ export const geminiProvider = {
35
+ name: 'gemini',
36
+ async complete(request) {
37
+ const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/${request.apiModelId}:generateContent`;
38
+ request.logger?.info('llm.request', {
39
+ provider: 'gemini', model: request.apiModelId, endpoint,
40
+ ...(request.callSite ? { callSite: request.callSite } : {}),
41
+ });
42
+ const startTime = Date.now();
43
+ const generationConfig = {
44
+ maxOutputTokens: request.maxTokens,
45
+ };
46
+ // Contract parity with the Anthropic/OpenAI adapters: omit `temperature`
47
+ // for models whose registry entry says they reject it.
48
+ if (request.supportsTemperature) {
49
+ generationConfig.temperature = request.temperature;
50
+ }
51
+ if (request.jsonSchema) {
52
+ generationConfig.responseMimeType = 'application/json';
53
+ generationConfig.responseSchema = stripUnsupportedSchemaFields(request.jsonSchema.schema);
54
+ }
55
+ // Gemini thinking models reason before answering; the reasoning tokens
56
+ // count against maxOutputTokens. Capping the thinking budget reserves
57
+ // headroom for the visible response so a long reasoning pass can't
58
+ // truncate it. Models with a thinking floor (registry
59
+ // `supportsThinkingBudgetZero: false`) reject an explicit 0 budget with a
60
+ // 400 — for those a 0 means "as little thinking as possible", which their
61
+ // default already is, so the config is omitted entirely.
62
+ if (request.thinkingBudget !== undefined
63
+ && (request.thinkingBudget !== 0 || request.supportsThinkingBudgetZero)) {
64
+ generationConfig.thinkingConfig = { thinkingBudget: request.thinkingBudget };
65
+ }
66
+ const body = {
67
+ contents: [{ parts: [{ text: request.prompt }] }],
68
+ generationConfig,
69
+ };
70
+ if (request.systemPrompt) {
71
+ body.systemInstruction = { parts: [{ text: request.systemPrompt }] };
72
+ }
73
+ const controller = new AbortController();
74
+ const timer = setTimeout(() => controller.abort(), request.timeoutMs);
75
+ let response;
76
+ try {
77
+ response = await fetch(`${endpoint}?key=${request.apiKey}`, {
78
+ method: 'POST',
79
+ headers: { 'Content-Type': 'application/json' },
80
+ body: JSON.stringify(body),
81
+ signal: controller.signal,
82
+ });
83
+ }
84
+ catch (err) {
85
+ const message = err instanceof Error && err.name === 'AbortError'
86
+ ? `Gemini API timeout after ${request.timeoutMs}ms`
87
+ : `Gemini API network error: ${err instanceof Error ? err.message : String(err)}`;
88
+ throw new LLMError({
89
+ message,
90
+ provider: 'gemini',
91
+ model: request.apiModelId,
92
+ isRetryable: true,
93
+ });
94
+ }
95
+ finally {
96
+ clearTimeout(timer);
97
+ }
98
+ if (!response.ok) {
99
+ const errorText = await response.text();
100
+ throw new LLMError({
101
+ message: `Gemini API error: ${response.status} - ${errorText.slice(0, 500)}`,
102
+ provider: 'gemini',
103
+ model: request.apiModelId,
104
+ statusCode: response.status,
105
+ isRetryable: response.status >= 500 || response.status === 429,
106
+ });
107
+ }
108
+ const data = await response.json();
109
+ const latencyMs = Date.now() - startTime;
110
+ const text = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
111
+ const tokensUsed = data.usageMetadata?.totalTokenCount || 0;
112
+ const outputTokens = data.usageMetadata?.candidatesTokenCount ?? undefined;
113
+ const finishReason = data.candidates?.[0]?.finishReason?.toLowerCase() ?? undefined;
114
+ let json;
115
+ if (request.jsonSchema) {
116
+ try {
117
+ json = JSON.parse(text);
118
+ }
119
+ catch { /* leave as text */ }
120
+ }
121
+ request.logger?.info('llm.response', {
122
+ provider: 'gemini', model: request.apiModelId,
123
+ status: response.status, latencyMs, tokensUsed, outputTokens, finishReason,
124
+ ...(request.callSite ? { callSite: request.callSite } : {}),
125
+ });
126
+ return { text, json, tokensUsed, outputTokens, finishReason, finishCategory: classifyFinish(finishReason), latencyMs };
127
+ },
128
+ };
@@ -0,0 +1,2 @@
1
+ import type { ProviderAdapter, ProviderName } from '../types.js';
2
+ export declare const providers: Record<ProviderName, ProviderAdapter>;
@@ -0,0 +1,8 @@
1
+ import { anthropicProvider } from './anthropic.js';
2
+ import { geminiProvider } from './gemini.js';
3
+ import { openaiProvider } from './openai.js';
4
+ export const providers = {
5
+ anthropic: anthropicProvider,
6
+ gemini: geminiProvider,
7
+ openai: openaiProvider,
8
+ };
@@ -0,0 +1,2 @@
1
+ import type { ProviderAdapter } from '../types.js';
2
+ export declare const openaiProvider: ProviderAdapter;
@@ -0,0 +1,183 @@
1
+ import { LLMError } from '../types.js';
2
+ /** True when a JSON-schema `type` value denotes (or includes) an object. */
3
+ function isObjectSchema(type) {
4
+ return type === 'object' || (Array.isArray(type) && type.includes('object'));
5
+ }
6
+ /**
7
+ * Widen a property schema so OpenAI strict mode permits `null`. OpenAI has no
8
+ * notion of an optional property (every property must be `required`), so an
9
+ * originally-optional field is emulated by making its type nullable — the model
10
+ * may then emit `null` to mean "absent". A nullable enum must also list `null`
11
+ * among its allowed values, or the union with `null` is unsatisfiable.
12
+ */
13
+ function makeNullable(propSchema) {
14
+ if (propSchema === null || typeof propSchema !== 'object' || Array.isArray(propSchema)) {
15
+ return propSchema;
16
+ }
17
+ const s = propSchema;
18
+ const type = s.type;
19
+ let nextType;
20
+ if (typeof type === 'string') {
21
+ nextType = type === 'null' ? type : [type, 'null'];
22
+ }
23
+ else if (Array.isArray(type)) {
24
+ nextType = type.includes('null') ? type : [...type, 'null'];
25
+ }
26
+ else {
27
+ // No concrete `type` to widen (e.g. anyOf/$ref) — leave as-is.
28
+ return s;
29
+ }
30
+ const result = { ...s, type: nextType };
31
+ if (Array.isArray(s.enum) && !s.enum.includes(null)) {
32
+ result.enum = [...s.enum, null];
33
+ }
34
+ return result;
35
+ }
36
+ /**
37
+ * Transform a portable JSON schema into the shape OpenAI's strict Structured
38
+ * Outputs mode requires: every object must set `additionalProperties: false`
39
+ * and list ALL of its properties in `required`. Callers author one portable
40
+ * schema (which also works on Anthropic's tool_use and Gemini's responseSchema,
41
+ * both of which tolerate missing `additionalProperties` and optional fields);
42
+ * this keeps the OpenAI-specific dialect local to this adapter, mirroring the
43
+ * Gemini adapter's own `stripUnsupportedSchemaFields`. Returns a deep copy — the
44
+ * caller's schema object is never mutated (it is reused across failover attempts).
45
+ */
46
+ function toStrictSchema(node) {
47
+ if (Array.isArray(node))
48
+ return node.map(toStrictSchema);
49
+ if (node === null || typeof node !== 'object')
50
+ return node;
51
+ const obj = node;
52
+ const originalRequired = new Set(Array.isArray(obj.required)
53
+ ? obj.required.filter((k) => typeof k === 'string')
54
+ : []);
55
+ // Deep-copy + recurse into every child first so nested objects/arrays are
56
+ // normalized before we widen originally-optional properties above them.
57
+ const out = {};
58
+ for (const [key, value] of Object.entries(obj)) {
59
+ out[key] = toStrictSchema(value);
60
+ }
61
+ const properties = out.properties;
62
+ if (isObjectSchema(out.type) && properties !== null && typeof properties === 'object') {
63
+ const propMap = properties;
64
+ const keys = Object.keys(propMap);
65
+ for (const key of keys) {
66
+ if (!originalRequired.has(key)) {
67
+ propMap[key] = makeNullable(propMap[key]);
68
+ }
69
+ }
70
+ out.required = keys;
71
+ out.additionalProperties = false;
72
+ }
73
+ return out;
74
+ }
75
+ /** Classify OpenAI's `finish_reason` (already lowercase from the API). */
76
+ function classifyFinish(finishReason) {
77
+ switch (finishReason) {
78
+ case 'content_filter': return 'declined';
79
+ case 'length': return 'truncated';
80
+ case 'stop':
81
+ case 'tool_calls':
82
+ case 'function_call': return 'complete';
83
+ default: return 'other';
84
+ }
85
+ }
86
+ export const openaiProvider = {
87
+ name: 'openai',
88
+ async complete(request) {
89
+ const endpoint = 'https://api.openai.com/v1/chat/completions';
90
+ request.logger?.info('llm.request', {
91
+ provider: 'openai', model: request.apiModelId, endpoint,
92
+ ...(request.callSite ? { callSite: request.callSite } : {}),
93
+ });
94
+ const startTime = Date.now();
95
+ const messages = [];
96
+ if (request.systemPrompt) {
97
+ messages.push({ role: 'system', content: request.systemPrompt });
98
+ }
99
+ messages.push({ role: 'user', content: request.prompt });
100
+ const body = {
101
+ model: request.apiModelId,
102
+ max_completion_tokens: request.maxTokens,
103
+ messages,
104
+ };
105
+ // Reasoning models (the gpt-5.6 family) reject any non-default
106
+ // `temperature` with 400 unsupported_value — omit it per the registry flag.
107
+ if (request.supportsTemperature) {
108
+ body.temperature = request.temperature;
109
+ }
110
+ if (request.jsonSchema) {
111
+ body.response_format = {
112
+ type: 'json_schema',
113
+ json_schema: {
114
+ name: request.jsonSchema.name,
115
+ // Normalize the portable schema into OpenAI's strict dialect
116
+ // (additionalProperties:false everywhere, all properties required,
117
+ // originally-optional ones made nullable). Without this, strict mode
118
+ // rejects the request with a 400 and this provider is dead as a
119
+ // failover target for any schema using optional fields.
120
+ schema: toStrictSchema(request.jsonSchema.schema),
121
+ strict: true,
122
+ },
123
+ };
124
+ }
125
+ const controller = new AbortController();
126
+ const timer = setTimeout(() => controller.abort(), request.timeoutMs);
127
+ let response;
128
+ try {
129
+ response = await fetch(endpoint, {
130
+ method: 'POST',
131
+ headers: {
132
+ 'Content-Type': 'application/json',
133
+ 'Authorization': `Bearer ${request.apiKey}`,
134
+ },
135
+ body: JSON.stringify(body),
136
+ signal: controller.signal,
137
+ });
138
+ }
139
+ catch (err) {
140
+ const message = err instanceof Error && err.name === 'AbortError'
141
+ ? `OpenAI API timeout after ${request.timeoutMs}ms`
142
+ : `OpenAI API network error: ${err instanceof Error ? err.message : String(err)}`;
143
+ throw new LLMError({
144
+ message,
145
+ provider: 'openai',
146
+ model: request.apiModelId,
147
+ isRetryable: true,
148
+ });
149
+ }
150
+ finally {
151
+ clearTimeout(timer);
152
+ }
153
+ if (!response.ok) {
154
+ const errorText = await response.text();
155
+ throw new LLMError({
156
+ message: `OpenAI API error: ${response.status} - ${errorText.slice(0, 500)}`,
157
+ provider: 'openai',
158
+ model: request.apiModelId,
159
+ statusCode: response.status,
160
+ isRetryable: response.status >= 500 || response.status === 429,
161
+ });
162
+ }
163
+ const data = await response.json();
164
+ const latencyMs = Date.now() - startTime;
165
+ const text = data.choices?.[0]?.message?.content || '';
166
+ const outputTokens = data.usage?.completion_tokens ?? undefined;
167
+ const tokensUsed = (data.usage?.prompt_tokens || 0) + (data.usage?.completion_tokens || 0);
168
+ const finishReason = data.choices?.[0]?.finish_reason ?? undefined;
169
+ let json;
170
+ if (request.jsonSchema) {
171
+ try {
172
+ json = JSON.parse(text);
173
+ }
174
+ catch { /* leave as text */ }
175
+ }
176
+ request.logger?.info('llm.response', {
177
+ provider: 'openai', model: request.apiModelId,
178
+ status: response.status, latencyMs, tokensUsed, outputTokens, finishReason,
179
+ ...(request.callSite ? { callSite: request.callSite } : {}),
180
+ });
181
+ return { text, json, tokensUsed, outputTokens, finishReason, finishCategory: classifyFinish(finishReason), latencyMs };
182
+ },
183
+ };
@@ -0,0 +1,203 @@
1
+ import type { Logger } from '../log-core.js';
2
+ /** Supported LLM provider names. */
3
+ export type ProviderName = 'anthropic' | 'gemini' | 'openai';
4
+ /** API keys for each provider. Only keys for available providers need to be set. */
5
+ export interface ApiKeys {
6
+ anthropic?: string;
7
+ gemini?: string;
8
+ openai?: string;
9
+ }
10
+ /** JSON schema definition for structured output. */
11
+ export interface JsonSchema {
12
+ /** Schema name (used by OpenAI and Anthropic). */
13
+ name: string;
14
+ /** JSON Schema object. */
15
+ schema: Record<string, unknown>;
16
+ }
17
+ /** Request to llmComplete(). */
18
+ export interface LLMRequest {
19
+ /** Model name from the registry (e.g. 'claude-sonnet-4-6', 'gemini-2.5-flash'). */
20
+ model: string;
21
+ /** The user prompt. */
22
+ prompt: string;
23
+ /** Optional system prompt. */
24
+ systemPrompt?: string;
25
+ /** Max output tokens. Default: 2048. */
26
+ maxTokens?: number;
27
+ /** Temperature. Default: 0.3. */
28
+ temperature?: number;
29
+ /** If provided, requests structured JSON output matching this schema. */
30
+ jsonSchema?: JsonSchema;
31
+ /** API keys for available providers. */
32
+ apiKeys: ApiKeys;
33
+ /** Logger instance for structured logging. */
34
+ logger?: Logger;
35
+ /** Timeout per provider attempt in ms. Default: 60000. */
36
+ timeoutMs?: number;
37
+ /**
38
+ * Cap on reasoning/thinking tokens for models that support hidden reasoning
39
+ * (currently Gemini 2.5 family). Reasoning tokens are charged against the
40
+ * same budget as output tokens, so when thinking is unbounded it can starve
41
+ * the visible response and cause mid-string truncation. Setting this
42
+ * reserves headroom for the response. Ignored by providers that don't
43
+ * expose a thinking budget.
44
+ */
45
+ thinkingBudget?: number;
46
+ /**
47
+ * Output-effort level for models that support Anthropic's `output_config.
48
+ * effort` (registry `supportsEffort`). Lower effort trades thoroughness for
49
+ * latency/cost. Currently honored on Anthropic models only; when a call
50
+ * fails over to another provider the fallback runs at its own defaults.
51
+ */
52
+ effort?: 'low' | 'medium' | 'high';
53
+ /**
54
+ * Optional short identifier for the calling feature (e.g. 'video_classify',
55
+ * 'message_prioritize', 'log_summary'). Included in llm.request and
56
+ * llm.response log meta so cost/usage reports can attribute spend to the
57
+ * specific feature driving the call. No behavioral effect.
58
+ */
59
+ callSite?: string;
60
+ /**
61
+ * If true, request Anthropic prompt caching for the system prompt. The
62
+ * library wraps the system string in a content-block array with
63
+ * `cache_control: { type: 'ephemeral' }`. Non-Anthropic providers ignore
64
+ * this flag (they have no equivalent caching mechanism here). Has no
65
+ * effect when `systemPrompt` is empty.
66
+ */
67
+ cacheSystemPrompt?: boolean;
68
+ }
69
+ /**
70
+ * Semantic classification of a provider's finish reason, normalized across
71
+ * providers so the failover loop needs no provider-specific knowledge:
72
+ * - `complete` — the model finished normally (incl. tool/function calls).
73
+ * - `truncated` — output was cut off by the token budget. The caller's concern;
74
+ * does NOT trigger failover, since a re-run would likely truncate too.
75
+ * - `declined` — the model actively refused / was content-filtered. Triggers
76
+ * failover to the next provider, which may have different policies.
77
+ * - `other` — anything unrecognized (including a missing finish reason).
78
+ *
79
+ * Each provider adapter maps its native finish reason to one of these, keeping
80
+ * provider-specific vocabulary local to the adapter that owns it.
81
+ */
82
+ export type FinishCategory = 'complete' | 'truncated' | 'declined' | 'other';
83
+ /** Successful response from llmComplete(). */
84
+ export interface LLMResponse {
85
+ /** The text output (or JSON stringified if jsonSchema was used). */
86
+ text: string;
87
+ /** Parsed JSON output when jsonSchema was requested. */
88
+ json?: unknown;
89
+ /** The provider that produced this response. */
90
+ provider: ProviderName;
91
+ /** The API model ID that produced this response. */
92
+ model: string;
93
+ /** Total tokens used (input + output). */
94
+ tokensUsed: number;
95
+ /** Output tokens only, when the provider reports them separately. */
96
+ outputTokens?: number;
97
+ /**
98
+ * Prompt-cache tokens read from the cache (Anthropic only, when caching is
99
+ * used). These are billed at a reduced rate vs. regular input tokens.
100
+ */
101
+ cacheReadTokens?: number;
102
+ /**
103
+ * Prompt-cache tokens written to the cache (Anthropic only, when caching is
104
+ * used). These are billed at a premium over regular input tokens.
105
+ */
106
+ cacheCreationTokens?: number;
107
+ /**
108
+ * Normalized finish reason from the provider, when available.
109
+ * Lowercased common values: 'stop', 'max_tokens', 'tool_use', 'content_filter',
110
+ * 'safety', 'length', 'other'. Provider-specific strings may also appear.
111
+ */
112
+ finishReason?: string;
113
+ /** Semantic classification of {@link finishReason}; see {@link FinishCategory}. */
114
+ finishCategory?: FinishCategory;
115
+ /** Wall-clock latency of the successful call in ms. */
116
+ latencyMs: number;
117
+ /** True if the primary model failed and a fallback was used. */
118
+ failedOver: boolean;
119
+ /** Details of each failed attempt before the successful one. */
120
+ failedAttempts: FailedAttempt[];
121
+ }
122
+ /** Details of a failed provider attempt. */
123
+ export interface FailedAttempt {
124
+ provider: ProviderName;
125
+ model: string;
126
+ error: string;
127
+ /** True when this attempt failed because the model declined (vs. an infra error). */
128
+ declined?: boolean;
129
+ }
130
+ /** Error thrown when an LLM provider call fails. */
131
+ export declare class LLMError extends Error {
132
+ provider: ProviderName;
133
+ model: string;
134
+ statusCode?: number;
135
+ isRetryable: boolean;
136
+ /**
137
+ * True when this error is the model actively declining to respond (safety
138
+ * refusal / content filter) rather than an infrastructure failure. On the
139
+ * terminal "all providers failed" error, true only when *every* attempt was
140
+ * a decline. Callers can use this to classify severity — e.g. log WARN, not
141
+ * ERROR, since an across-the-board decline is an expected external outcome,
142
+ * not a defect.
143
+ */
144
+ declined: boolean;
145
+ constructor(opts: {
146
+ message: string;
147
+ provider: ProviderName;
148
+ model: string;
149
+ statusCode?: number;
150
+ isRetryable: boolean;
151
+ declined?: boolean;
152
+ });
153
+ }
154
+ export interface ProviderAdapter {
155
+ name: ProviderName;
156
+ complete(request: ProviderCallRequest): Promise<ProviderCallResponse>;
157
+ }
158
+ export interface ProviderCallRequest {
159
+ apiModelId: string;
160
+ prompt: string;
161
+ systemPrompt?: string;
162
+ maxTokens: number;
163
+ temperature: number;
164
+ /**
165
+ * Whether the target model accepts the `temperature` parameter, per the
166
+ * MODEL_REGISTRY entry's `supportsTemperature` flag. Providers that always
167
+ * accept temperature can ignore this; providers with models that reject it
168
+ * must omit `temperature` from the request body when this is `false`.
169
+ */
170
+ supportsTemperature: boolean;
171
+ /**
172
+ * Whether the target model accepts `thinkingBudget: 0`, per the registry
173
+ * entry's `supportsThinkingBudgetZero` flag. When `false`, the Gemini
174
+ * adapter omits thinkingConfig for a 0 budget instead of sending it (the
175
+ * model would 400); non-zero budgets pass through unchanged.
176
+ */
177
+ supportsThinkingBudgetZero: boolean;
178
+ jsonSchema?: JsonSchema;
179
+ apiKey: string;
180
+ logger?: Logger;
181
+ timeoutMs: number;
182
+ thinkingBudget?: number;
183
+ /** Effort level, pre-gated by the registry's `supportsEffort` (absent when unsupported). */
184
+ effort?: 'low' | 'medium' | 'high';
185
+ callSite?: string;
186
+ cacheSystemPrompt?: boolean;
187
+ }
188
+ export interface ProviderCallResponse {
189
+ text: string;
190
+ json?: unknown;
191
+ tokensUsed: number;
192
+ /** Output tokens only, when the provider reports them separately. */
193
+ outputTokens?: number;
194
+ /** Prompt-cache read tokens (Anthropic, when caching is used). */
195
+ cacheReadTokens?: number;
196
+ /** Prompt-cache creation tokens (Anthropic, when caching is used). */
197
+ cacheCreationTokens?: number;
198
+ /** Normalized finish reason (lowercased common values where known). */
199
+ finishReason?: string;
200
+ /** Semantic classification of {@link finishReason}; see {@link FinishCategory}. */
201
+ finishCategory?: FinishCategory;
202
+ latencyMs: number;
203
+ }
@@ -0,0 +1,25 @@
1
+ /** Error thrown when an LLM provider call fails. */
2
+ export class LLMError extends Error {
3
+ provider;
4
+ model;
5
+ statusCode;
6
+ isRetryable;
7
+ /**
8
+ * True when this error is the model actively declining to respond (safety
9
+ * refusal / content filter) rather than an infrastructure failure. On the
10
+ * terminal "all providers failed" error, true only when *every* attempt was
11
+ * a decline. Callers can use this to classify severity — e.g. log WARN, not
12
+ * ERROR, since an across-the-board decline is an expected external outcome,
13
+ * not a defect.
14
+ */
15
+ declined;
16
+ constructor(opts) {
17
+ super(opts.message);
18
+ this.name = 'LLMError';
19
+ this.provider = opts.provider;
20
+ this.model = opts.model;
21
+ this.statusCode = opts.statusCode;
22
+ this.isRetryable = opts.isRetryable;
23
+ this.declined = opts.declined ?? false;
24
+ }
25
+ }