@steve31415/baselib 2.4.2 → 2.5.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.
package/README.md CHANGED
@@ -6,7 +6,7 @@ What it provides and why: `docs/SPEC.md`. How it's put together:
6
6
  `~/migration/research/base-services-design.md` (step 5).
7
7
 
8
8
  Server subpath exports: `config`, `log`, `auth`, `s2s`, `db`, `http`, `sync`,
9
- and `app-update`. Browser exports: `log-browser`, `rum`, `sync-browser`, and
9
+ `app-update`, and `llm`. Browser exports: `log-browser`, `rum`, `sync-browser`, and
10
10
  `app-update-browser`.
11
11
 
12
12
  `sync` provides the Postgres event-log and server protocol primitives;
@@ -19,6 +19,10 @@ adapter, transport/auth-state integration, and update hooks. These modules do
19
19
  not decide authentication or ownership, contain app domain logic, or act as a
20
20
  generic Yjs or service-worker coordinator.
21
21
 
22
+ `llm` is the fleet's one LLM client (Anthropic, Gemini, OpenAI): model
23
+ registry, automatic cross-provider failover, portable JSON-schema output.
24
+ Usage guide: `~/plasticine-way/docs/LLM.md`.
25
+
22
26
  Bins: `check-test-owners` — the fleet's structural test-coverage gate; every
23
27
  app runs it from `npm run verify`.
24
28
 
@@ -209,7 +209,10 @@ export async function runDeploy(options) {
209
209
  await evidence.save(`gate-${index}.log`, result.stdout + result.stderr);
210
210
  timer.record(`gate:${gate}`, Date.now() - gateStart);
211
211
  if (result.code !== 0) {
212
- const tail = (result.stderr.trim() || result.stdout.trim()).slice(-1500);
212
+ // Quote the tail of BOTH streams: test runners put the failing spec on
213
+ // stdout and only build/server noise on stderr, so stderr alone can
214
+ // hide which test failed. stderr last, so it survives the cut.
215
+ const tail = [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join('\n').slice(-1500);
213
216
  throw new Error(`gate failed: ${gate}\n${tail}`);
214
217
  }
215
218
  log(`gate passed: ${gate}`);
@@ -0,0 +1,11 @@
1
+ import type { LLMRequest, LLMResponse } from './types.js';
2
+ /**
3
+ * Call an LLM with automatic failover to alternative providers.
4
+ *
5
+ * Tries the requested model first. On any error, falls back to equivalent
6
+ * models from other providers. Skips providers whose API key is not set.
7
+ * Logs WARN on every failover attempt.
8
+ *
9
+ * Only models in the MODEL_REGISTRY are accepted. All calls get failover.
10
+ */
11
+ export declare function llmComplete(request: LLMRequest): Promise<LLMResponse>;
@@ -0,0 +1,145 @@
1
+ import { LLMError } from './types.js';
2
+ import { MODEL_REGISTRY, THINKING_HEADROOM_WARN_TOKENS } from './models.js';
3
+ import { providers } from './providers/index.js';
4
+ const DEFAULT_MAX_TOKENS = 2048;
5
+ const DEFAULT_TEMPERATURE = 0.3;
6
+ const DEFAULT_TIMEOUT_MS = 60_000;
7
+ /**
8
+ * Call an LLM with automatic failover to alternative providers.
9
+ *
10
+ * Tries the requested model first. On any error, falls back to equivalent
11
+ * models from other providers. Skips providers whose API key is not set.
12
+ * Logs WARN on every failover attempt.
13
+ *
14
+ * Only models in the MODEL_REGISTRY are accepted. All calls get failover.
15
+ */
16
+ export async function llmComplete(request) {
17
+ const entry = MODEL_REGISTRY[request.model];
18
+ if (!entry) {
19
+ throw new LLMError({
20
+ message: `Unknown model: ${request.model}. Known models: ${Object.keys(MODEL_REGISTRY).join(', ')}`,
21
+ provider: 'anthropic',
22
+ model: request.model,
23
+ isRetryable: false,
24
+ });
25
+ }
26
+ const maxTokens = request.maxTokens ?? DEFAULT_MAX_TOKENS;
27
+ const temperature = request.temperature ?? DEFAULT_TEMPERATURE;
28
+ const timeoutMs = request.timeoutMs ?? DEFAULT_TIMEOUT_MS;
29
+ // Models that think by default spend reasoning tokens out of the same
30
+ // `maxTokens` budget as the visible response, so a budget carried over from a
31
+ // non-thinking model can be consumed by reasoning and truncate the body —
32
+ // fatal when `jsonSchema` is in play, since a cut-off body will not parse.
33
+ // Truncation must never be silent (PW LLM.md), so flag the risk up front
34
+ // rather than leaving the caller to diagnose it from finishReason after the
35
+ // fact.
36
+ if (entry.thinksByDefault && maxTokens < THINKING_HEADROOM_WARN_TOKENS) {
37
+ request.logger?.warn('llm.thinking_budget_tight', {
38
+ model: request.model,
39
+ maxTokens,
40
+ recommendedMinimum: THINKING_HEADROOM_WARN_TOKENS,
41
+ hasJsonSchema: request.jsonSchema !== undefined,
42
+ ...(request.callSite ? { callSite: request.callSite } : {}),
43
+ });
44
+ }
45
+ // Build the ordered list of models to try: primary + fallbacks
46
+ const modelsToTry = [request.model, ...entry.fallbacks];
47
+ const failedAttempts = [];
48
+ for (const modelName of modelsToTry) {
49
+ const modelEntry = MODEL_REGISTRY[modelName];
50
+ if (!modelEntry)
51
+ continue;
52
+ const apiKey = request.apiKeys[modelEntry.provider];
53
+ if (!apiKey) {
54
+ request.logger?.debug('llm.skip_provider', {
55
+ model: modelName, provider: modelEntry.provider, reason: 'no API key',
56
+ });
57
+ continue;
58
+ }
59
+ const provider = providers[modelEntry.provider];
60
+ try {
61
+ const result = await provider.complete({
62
+ apiModelId: modelEntry.apiModelId,
63
+ prompt: request.prompt,
64
+ systemPrompt: request.systemPrompt,
65
+ maxTokens,
66
+ temperature,
67
+ supportsTemperature: modelEntry.supportsTemperature ?? true,
68
+ supportsThinkingBudgetZero: modelEntry.supportsThinkingBudgetZero ?? true,
69
+ effort: modelEntry.supportsEffort ? request.effort : undefined,
70
+ jsonSchema: request.jsonSchema,
71
+ apiKey,
72
+ logger: request.logger,
73
+ timeoutMs,
74
+ thinkingBudget: request.thinkingBudget,
75
+ callSite: request.callSite,
76
+ cacheSystemPrompt: request.cacheSystemPrompt,
77
+ });
78
+ // A model declining (safety refusal / content filter) comes back as a
79
+ // normal HTTP 200, so it never throws on its own. Treat it as a failed
80
+ // attempt so the loop fails over to the next provider, which may have
81
+ // different policies. Truncation and other non-declines are NOT retried
82
+ // here — those are the caller's concern (a re-run would likely truncate
83
+ // too). See FinishCategory.
84
+ if (result.finishCategory === 'declined') {
85
+ throw new LLMError({
86
+ message: `Model declined to respond (finishReason: ${result.finishReason ?? 'unknown'})`,
87
+ provider: modelEntry.provider,
88
+ model: modelEntry.apiModelId,
89
+ isRetryable: true,
90
+ declined: true,
91
+ });
92
+ }
93
+ // If we failed over, log a final summary
94
+ if (failedAttempts.length > 0) {
95
+ request.logger?.warn('llm.failover_succeeded', {
96
+ originalModel: request.model,
97
+ fallbackModel: modelName,
98
+ fallbackProvider: modelEntry.provider,
99
+ attemptsBeforeSuccess: failedAttempts.length,
100
+ });
101
+ }
102
+ return {
103
+ text: result.text,
104
+ json: result.json,
105
+ provider: modelEntry.provider,
106
+ model: modelEntry.apiModelId,
107
+ tokensUsed: result.tokensUsed,
108
+ outputTokens: result.outputTokens,
109
+ cacheReadTokens: result.cacheReadTokens,
110
+ cacheCreationTokens: result.cacheCreationTokens,
111
+ finishReason: result.finishReason,
112
+ finishCategory: result.finishCategory,
113
+ latencyMs: result.latencyMs,
114
+ failedOver: failedAttempts.length > 0,
115
+ failedAttempts,
116
+ };
117
+ }
118
+ catch (err) {
119
+ const errorMessage = err instanceof Error ? err.message : String(err);
120
+ failedAttempts.push({
121
+ provider: modelEntry.provider,
122
+ model: modelEntry.apiModelId,
123
+ error: errorMessage,
124
+ declined: err instanceof LLMError && err.declined,
125
+ });
126
+ request.logger?.warn('llm.failover_attempt', {
127
+ failedModel: modelName,
128
+ failedProvider: modelEntry.provider,
129
+ error: errorMessage,
130
+ attemptNumber: failedAttempts.length,
131
+ nextModel: modelsToTry[failedAttempts.length] ?? 'none',
132
+ });
133
+ }
134
+ }
135
+ // All attempts exhausted. If every attempt was a decline (not an infra
136
+ // failure), mark the terminal error `declined` so callers can treat a
137
+ // genuinely-unanswerable request as an expected outcome rather than a defect.
138
+ throw new LLMError({
139
+ message: `All LLM providers failed for model ${request.model}. Attempts: ${failedAttempts.map((a) => `${a.provider}/${a.model}: ${a.error}`).join('; ')}`,
140
+ provider: entry.provider,
141
+ model: entry.apiModelId,
142
+ isRetryable: false,
143
+ declined: failedAttempts.length > 0 && failedAttempts.every((a) => a.declined === true),
144
+ });
145
+ }
@@ -0,0 +1,4 @@
1
+ export { llmComplete } from './complete.js';
2
+ export { MODEL_REGISTRY } from './models.js';
3
+ export type { ModelEntry } from './models.js';
4
+ export { LLMError, type LLMRequest, type LLMResponse, type FailedAttempt, type FinishCategory, type ProviderName, type ApiKeys, type JsonSchema, } from './types.js';
@@ -0,0 +1,10 @@
1
+ // LLM API client with automatic provider failover (Anthropic, Gemini,
2
+ // OpenAI). Ported into baselib from the old-world package
3
+ // @steve31415/llm-failover 1.9.0 (plasticine-apps/llm-failover@95461e2);
4
+ // the one deliberate change is the Logger type, which is baselib's own
5
+ // (log-core.ts) instead of @steve31415/log-logger-ts. Usage guide:
6
+ // ~/plasticine-way/docs/LLM.md.
7
+ // Public API
8
+ export { llmComplete } from './complete.js';
9
+ export { MODEL_REGISTRY } from './models.js';
10
+ export { LLMError, } from './types.js';
@@ -0,0 +1,54 @@
1
+ import type { ProviderName } from './types.js';
2
+ export interface ModelEntry {
3
+ /** Provider that serves this model. */
4
+ provider: ProviderName;
5
+ /** The model ID to send in the API request. */
6
+ apiModelId: string;
7
+ /** Fallback models to try (in order) if this model's provider fails. */
8
+ fallbacks: string[];
9
+ /**
10
+ * Whether this model accepts the `temperature` request parameter.
11
+ * Defaults to `true` when absent. Set `false` for models that reject it
12
+ * outright (e.g. `400 invalid_request_error: "temperature is deprecated
13
+ * for this model."` — Anthropic's Opus 4.7+/Sonnet 5/Fable 5 family).
14
+ */
15
+ supportsTemperature?: boolean;
16
+ /**
17
+ * Whether this model accepts `thinkingBudget: 0` ("disable thinking").
18
+ * Defaults to `true`. Gemini 3.x models with a thinking floor of `minimal`
19
+ * or higher reject an explicit 0 budget with a 400; for those the adapter
20
+ * omits thinkingConfig instead, honoring the caller's intent of "as little
21
+ * thinking as this model allows".
22
+ */
23
+ supportsThinkingBudgetZero?: boolean;
24
+ /**
25
+ * Whether this model accepts Anthropic's `output_config.effort` parameter
26
+ * (verified on Sonnet 5 / Sonnet 4.6 2026-08-10; documented GA on Opus
27
+ * 4.6+/5). Defaults to `false`; the effort option is dropped for models
28
+ * without it.
29
+ */
30
+ supportsEffort?: boolean;
31
+ /**
32
+ * Whether this model performs hidden reasoning when the request omits any
33
+ * thinking configuration (which this library always does). Defaults to
34
+ * `false`. Anthropic's Opus 5 is the first model where thinking is on by
35
+ * default; its reasoning tokens are charged against the same `maxTokens`
36
+ * budget as the visible response, so a budget sized for a non-thinking model
37
+ * can be consumed by reasoning and truncate the body mid-token.
38
+ *
39
+ * Set this on any model whose default is to think, so llmComplete() can warn
40
+ * when the caller's output budget leaves no meaningful headroom.
41
+ */
42
+ thinksByDefault?: boolean;
43
+ }
44
+ /**
45
+ * Output budget below which a `thinksByDefault` model is considered at risk of
46
+ * having its visible response starved by reasoning tokens. Not a hard limit —
47
+ * llmComplete() only warns, since the right budget is task-specific.
48
+ */
49
+ export declare const THINKING_HEADROOM_WARN_TOKENS = 4096;
50
+ /**
51
+ * Model registry. Keys are the user-facing model names passed to llmComplete().
52
+ * Each entry maps to a provider, API model ID, and ordered fallback list.
53
+ */
54
+ export declare const MODEL_REGISTRY: Record<string, ModelEntry>;
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Output budget below which a `thinksByDefault` model is considered at risk of
3
+ * having its visible response starved by reasoning tokens. Not a hard limit —
4
+ * llmComplete() only warns, since the right budget is task-specific.
5
+ */
6
+ export const THINKING_HEADROOM_WARN_TOKENS = 4096;
7
+ /**
8
+ * Model registry. Keys are the user-facing model names passed to llmComplete().
9
+ * Each entry maps to a provider, API model ID, and ordered fallback list.
10
+ */
11
+ export const MODEL_REGISTRY = {
12
+ // 2026-08 suite refresh: current models are claude-sonnet-5 / claude-opus-5
13
+ // (Anthropic), the gpt-5.6 sol/terra/luna family (OpenAI), and the Gemini
14
+ // 3.5/3.6 GA line plus gemini-3.1-pro-preview (Google). Older names stay
15
+ // registered so existing callers keep working, but no fallback chain routes
16
+ // through a retiring model: gemini-2.5-pro and gemini-2.5-flash shut down
17
+ // 2026-10-16, and the gpt-4o snapshots retire from 2026-10 onward.
18
+ //
19
+ // The gpt-5.6 family are reasoning models: they reject non-default
20
+ // `temperature` (400 unsupported_value, verified 2026-08-09) and spend
21
+ // hidden reasoning tokens billed as output — hence `supportsTemperature:
22
+ // false` + `thinksByDefault: true` on each. (gpt-5.2 still accepts
23
+ // temperature; verified the same day.)
24
+ // --- Fast tier ---
25
+ 'gemini-3.5-flash-lite': {
26
+ provider: 'gemini',
27
+ apiModelId: 'gemini-3.5-flash-lite',
28
+ fallbacks: ['gpt-5.6-luna', 'claude-haiku-4-5'],
29
+ // Default thinking_level is `minimal` — reasoning overhead is negligible,
30
+ // so no `thinksByDefault` headroom warning.
31
+ // Rejects thinkingBudget 0 (400, verified 2026-08-09).
32
+ supportsThinkingBudgetZero: false,
33
+ },
34
+ 'gemini-3.6-flash': {
35
+ provider: 'gemini',
36
+ apiModelId: 'gemini-3.6-flash',
37
+ fallbacks: ['gpt-5.6-luna', 'claude-haiku-4-5'],
38
+ // Thinks by default (thinking_level defaults to `medium`); reasoning
39
+ // tokens are billed as output and count against maxOutputTokens.
40
+ thinksByDefault: true,
41
+ // Rejects thinkingBudget 0 (400, verified 2026-08-09).
42
+ supportsThinkingBudgetZero: false,
43
+ },
44
+ 'gpt-5.6-luna': {
45
+ provider: 'openai',
46
+ apiModelId: 'gpt-5.6-luna',
47
+ fallbacks: ['gemini-3.5-flash-lite', 'claude-haiku-4-5'],
48
+ supportsTemperature: false,
49
+ thinksByDefault: true,
50
+ },
51
+ 'claude-haiku-4-5': {
52
+ provider: 'anthropic',
53
+ apiModelId: 'claude-haiku-4-5-20251001',
54
+ fallbacks: ['gemini-3.5-flash-lite', 'gpt-5.6-luna'],
55
+ },
56
+ // Retiring 2026-10-16 — migrate callers to gemini-3.5-flash-lite.
57
+ 'gemini-2.5-flash': {
58
+ provider: 'gemini',
59
+ apiModelId: 'gemini-2.5-flash',
60
+ fallbacks: ['gpt-5.6-luna', 'claude-haiku-4-5'],
61
+ },
62
+ 'gemini-3.1-flash-lite': {
63
+ provider: 'gemini',
64
+ apiModelId: 'gemini-3.1-flash-lite',
65
+ fallbacks: ['claude-sonnet-4-6'],
66
+ },
67
+ 'gemini-3-flash-preview': {
68
+ provider: 'gemini',
69
+ apiModelId: 'gemini-3-flash-preview',
70
+ fallbacks: ['claude-haiku-4-5', 'gpt-5.6-luna'],
71
+ },
72
+ 'gpt-4o-mini': {
73
+ provider: 'openai',
74
+ apiModelId: 'gpt-4o-mini',
75
+ fallbacks: ['gemini-3.5-flash-lite', 'claude-haiku-4-5'],
76
+ },
77
+ // --- Mid tier ---
78
+ 'claude-sonnet-5': {
79
+ provider: 'anthropic',
80
+ supportsEffort: true,
81
+ apiModelId: 'claude-sonnet-5',
82
+ fallbacks: ['gpt-5.6-terra', 'gemini-3.1-pro-preview'],
83
+ // Sonnet 5 rejects `temperature` (like Opus 4.7+/Fable 5). It supports
84
+ // adaptive thinking but does NOT think when the request omits `thinking`
85
+ // (verified 2026-08-09: thinking_tokens 0), so no thinksByDefault.
86
+ supportsTemperature: false,
87
+ },
88
+ 'gpt-5.6-terra': {
89
+ provider: 'openai',
90
+ apiModelId: 'gpt-5.6-terra',
91
+ fallbacks: ['claude-sonnet-5', 'gemini-3.1-pro-preview'],
92
+ supportsTemperature: false,
93
+ thinksByDefault: true,
94
+ },
95
+ 'claude-sonnet-4-6': {
96
+ provider: 'anthropic',
97
+ supportsEffort: true,
98
+ apiModelId: 'claude-sonnet-4-6',
99
+ fallbacks: ['gpt-5.6-terra', 'gemini-3.1-pro-preview'],
100
+ },
101
+ // Legacy: consumer-retired 2026-02; API snapshots retire 2026-10 onward.
102
+ // Migrate callers to gpt-5.6-terra (OpenAI's documented replacement).
103
+ 'gpt-4o': {
104
+ provider: 'openai',
105
+ apiModelId: 'gpt-4o',
106
+ fallbacks: ['claude-sonnet-5', 'gemini-3.1-pro-preview'],
107
+ },
108
+ // --- Top tier ---
109
+ 'claude-opus-5': {
110
+ provider: 'anthropic',
111
+ supportsEffort: true,
112
+ apiModelId: 'claude-opus-5',
113
+ fallbacks: ['gpt-5.6-sol', 'gemini-3.1-pro-preview'],
114
+ // Opus 4.7+ rejects `temperature` outright (400 invalid_request_error).
115
+ supportsTemperature: false,
116
+ // Unlike Opus 4.6/4.8, Opus 5 thinks by default when the request omits the
117
+ // `thinking` parameter (as this library does). Reasoning tokens are charged
118
+ // against `maxTokens` alongside the visible response, so budgets sized for
119
+ // a non-thinking Opus can truncate — see `thinksByDefault` on ModelEntry.
120
+ thinksByDefault: true,
121
+ },
122
+ 'claude-opus-4-8': {
123
+ provider: 'anthropic',
124
+ supportsEffort: true,
125
+ apiModelId: 'claude-opus-4-8',
126
+ fallbacks: ['gpt-5.6-sol', 'gemini-3.1-pro-preview'],
127
+ // Opus 4.7+ rejects `temperature` outright (400 invalid_request_error).
128
+ supportsTemperature: false,
129
+ },
130
+ 'claude-opus-4-6': {
131
+ provider: 'anthropic',
132
+ supportsEffort: true,
133
+ apiModelId: 'claude-opus-4-6',
134
+ fallbacks: ['gpt-5.6-sol', 'gemini-3.1-pro-preview'],
135
+ },
136
+ 'gpt-5.6-sol': {
137
+ provider: 'openai',
138
+ apiModelId: 'gpt-5.6-sol',
139
+ fallbacks: ['claude-opus-5', 'gemini-3.1-pro-preview'],
140
+ supportsTemperature: false,
141
+ thinksByDefault: true,
142
+ },
143
+ 'gpt-5.2': {
144
+ provider: 'openai',
145
+ apiModelId: 'gpt-5.2',
146
+ fallbacks: ['claude-opus-5', 'gemini-3.1-pro-preview'],
147
+ },
148
+ // Google's best Pro model as of 2026-08 — still Preview-labeled (no GA
149
+ // Gemini 3.x Pro exists), but the only Pro-class option once 2.5-pro
150
+ // retires. Accepts the legacy integer thinkingBudget (verified 2026-08-09).
151
+ 'gemini-3.1-pro-preview': {
152
+ provider: 'gemini',
153
+ apiModelId: 'gemini-3.1-pro-preview',
154
+ fallbacks: ['claude-opus-5', 'gpt-5.6-sol'],
155
+ // Thinks by default (thinking_level defaults to `high`).
156
+ thinksByDefault: true,
157
+ // Rejects thinkingBudget 0 (400, verified 2026-08-09).
158
+ supportsThinkingBudgetZero: false,
159
+ },
160
+ // Retiring 2026-10-16 — migrate callers to gemini-3.1-pro-preview.
161
+ 'gemini-2.5-pro': {
162
+ provider: 'gemini',
163
+ apiModelId: 'gemini-2.5-pro',
164
+ fallbacks: ['claude-opus-5', 'gpt-5.6-sol'],
165
+ },
166
+ };
@@ -0,0 +1,2 @@
1
+ import type { ProviderAdapter } from '../types.js';
2
+ export declare const anthropicProvider: ProviderAdapter;
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "2.4.2",
3
+ "version": "2.5.0",
4
4
  "description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -60,6 +60,10 @@
60
60
  "./app-update-browser": {
61
61
  "types": "./dist/app-update-browser/index.d.ts",
62
62
  "default": "./dist/app-update-browser/index.js"
63
+ },
64
+ "./llm": {
65
+ "types": "./dist/llm/index.d.ts",
66
+ "default": "./dist/llm/index.js"
63
67
  }
64
68
  },
65
69
  "bin": {