@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.
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
 
package/dist/auth.d.ts CHANGED
@@ -3,11 +3,20 @@ import type { Logger } from './log-core.js';
3
3
  import { ServiceCaller, S2sOptions } from './s2s.js';
4
4
  export declare const DEFAULT_AUTH_URL = "https://auth.apps.snewman.net";
5
5
  export declare const SESSION_COOKIE = "pw_session";
6
+ /** The `code` in a 403 JSON body when a signed-in user is refused by the
7
+ * app's userAccess policy (as opposed to any other 403). */
8
+ export declare const ACCESS_DENIED_CODE = "app_access_denied";
9
+ /** A user's fleet role, assigned in auth2's users table. */
10
+ export type UserRole = 'owner' | 'invited';
11
+ /** Which signed-in users an app admits. 'owner' (the default): only the
12
+ * fleet owner(s). 'invited': any active fleet user. */
13
+ export type UserAccessPolicy = 'owner' | 'invited';
6
14
  export interface AuthedUser {
7
15
  type: 'user';
8
16
  email: string;
9
17
  name?: string;
10
18
  userId?: string;
19
+ role: UserRole;
11
20
  }
12
21
  export type Identity = AuthedUser | ServiceCaller;
13
22
  declare module 'hono' {
@@ -22,16 +31,20 @@ export interface RequireIdentityOptions extends S2sOptions {
22
31
  /** Injectable for tests (defaults to global fetch). */
23
32
  whoamiFetch?: typeof fetch;
24
33
  logger?: Logger;
25
- /** Distinguishes API requests (401) from page requests (redirect).
26
- * Default: path starts with /api/ or client prefers JSON. */
34
+ /** Distinguishes API requests (401/403 JSON) from page requests (redirect
35
+ * / denial page). Default: path starts with /api/ or client prefers JSON. */
27
36
  isApiRequest?: (c: Context) => boolean;
28
37
  cacheTtl?: {
29
38
  hitMs?: number;
30
39
  missMs?: number;
31
40
  };
41
+ /** Which signed-in users this app admits. Default 'owner': only users
42
+ * whose auth2 role is owner. Pass 'invited' only for an app that is
43
+ * multi-user by design, and say so in its SPEC. Applies to browser
44
+ * sessions and the test bypass alike; service callers are governed by
45
+ * grants, not by this. */
46
+ userAccess?: UserAccessPolicy;
32
47
  }
33
- export declare function requireIdentity(opts: RequireIdentityOptions): import("hono").MiddlewareHandler<any, string, {}, (Response & import("hono").TypedResponse<undefined, 302, "redirect">) | (Response & import("hono").TypedResponse<{
34
- error: string;
35
- }, 401 | 403 | 429, "json">) | (Response & import("hono").TypedResponse<{
36
- error: string;
37
- }, 503, "json">)>;
48
+ /** The one place the policy is evaluated. */
49
+ export declare function userAllowed(policy: UserAccessPolicy, user: Pick<AuthedUser, 'role'>): boolean;
50
+ export declare function requireIdentity(opts: RequireIdentityOptions): import("hono").MiddlewareHandler<any, string, {}, Response>;
package/dist/auth.js CHANGED
@@ -1,27 +1,49 @@
1
- // Identity middleware for consuming apps (design §3.4).
1
+ // Identity middleware for consuming apps (design §3.4; per-app access:
2
+ // ~/migration/research/per-app-access-design.md).
2
3
  //
3
4
  // Resolves who is calling, in order:
4
5
  // 1. Test bypass (AUTH_MODE=test): X-Test-S2S-Caller → service caller;
5
- // X-Test-User header or TEST_USER env → user. Credential verification is
6
- // bypassed; authorization (grants, app-level user checks) still runs.
6
+ // X-Test-User header or TEST_USER env → user, with the fleet role from
7
+ // X-Test-User-Role / TEST_USER_ROLE (default 'invited'). Credential
8
+ // verification is bypassed; authorization (grants, the userAccess
9
+ // policy, app-level checks) still runs.
7
10
  // 2. Authorization: Bearer → service-to-service OIDC (s2s.ts).
8
11
  // 3. pw_session cookie → whoami call to the auth service, cached
9
12
  // in-instance (10 min hits / 30 s misses — the old-world numbers).
10
13
  //
14
+ // Then the user-access policy: by default an app admits only users whose
15
+ // auth2 role is 'owner'; `userAccess: 'invited'` admits any active fleet
16
+ // user. Being on the fleet sign-in allowlist never grants access by itself,
17
+ // so a port that forgets the question is closed, not open. Service callers
18
+ // are governed by grants, not by this policy.
19
+ //
11
20
  // Unauthenticated: APIs get 401 JSON, pages redirect to the auth service's
12
- // login with a return URL. Auth-service *failures* (network, 5xx) return 503
13
- // and never redirect — a redirect on failure loops forever (old-world lesson).
21
+ // login with a return URL. Denied users get 403 (JSON with a stable `code`
22
+ // on APIs, a plain "not enabled" page otherwise). Auth-service *failures*
23
+ // (network, 5xx, a whoami answer with no role) return 503 and never
24
+ // redirect — a redirect on failure loops forever (old-world lesson).
14
25
  import { createHash } from 'node:crypto';
15
26
  import { createMiddleware } from 'hono/factory';
16
27
  import { authMode } from './config.js';
17
28
  import { RateLimiter, resolveS2sCaller } from './s2s.js';
18
29
  export const DEFAULT_AUTH_URL = 'https://auth.apps.snewman.net';
19
30
  export const SESSION_COOKIE = 'pw_session';
31
+ /** The `code` in a 403 JSON body when a signed-in user is refused by the
32
+ * app's userAccess policy (as opposed to any other 403). */
33
+ export const ACCESS_DENIED_CODE = 'app_access_denied';
34
+ /** The one place the policy is evaluated. */
35
+ export function userAllowed(policy, user) {
36
+ return policy === 'invited' || user.role === 'owner';
37
+ }
38
+ function parseRole(value) {
39
+ return value === 'owner' || value === 'invited' ? value : undefined;
40
+ }
20
41
  export function requireIdentity(opts) {
21
42
  const authUrl = (opts.authUrl ?? DEFAULT_AUTH_URL).replace(/\/$/, '');
22
43
  const whoamiFetch = opts.whoamiFetch ?? fetch;
23
44
  const hitMs = opts.cacheTtl?.hitMs ?? 600_000;
24
45
  const missMs = opts.cacheTtl?.missMs ?? 30_000;
46
+ const policy = opts.userAccess ?? 'owner';
25
47
  const limiter = new RateLimiter(opts.rateLimitPerMin ?? 600);
26
48
  const cache = new Map();
27
49
  const isApi = opts.isApiRequest ??
@@ -37,17 +59,33 @@ export function requireIdentity(opts) {
37
59
  }
38
60
  return c.redirect(`${authUrl}/login?redirect=${encodeURIComponent(returnUrl)}`);
39
61
  };
62
+ // WARN, not ERROR: a fleet user opening an owner-only app is the policy
63
+ // working as designed (an s2s grant denial is ERROR because it usually
64
+ // means a missing grant during development).
65
+ const accessDenied = (c, user) => {
66
+ opts.logger?.warn('user access denied', { email: user.email, policy, path: c.req.path });
67
+ c.header('cache-control', 'no-store');
68
+ if (isApi(c)) {
69
+ return c.json({ error: 'not enabled for this account', code: ACCESS_DENIED_CODE, email: user.email }, 403);
70
+ }
71
+ return c.html(accessDeniedPage({ host: c.req.header('host') ?? 'This app', email: user.email, authUrl }), 403);
72
+ };
40
73
  return createMiddleware(async (c, next) => {
74
+ const admit = (user) => {
75
+ if (!userAllowed(policy, user))
76
+ return accessDenied(c, user);
77
+ c.set('identity', user);
78
+ c.set('user', user);
79
+ return next();
80
+ };
41
81
  // Test-mode user bypass (service-caller test bypass is handled by
42
82
  // resolveS2sCaller below, so both paths stay testable).
43
83
  if (authMode() === 'test' && !c.req.header('x-test-s2s-caller')) {
44
84
  const email = c.req.header('x-test-user') ?? process.env.TEST_USER;
45
85
  if (!email)
46
86
  return unauthenticated(c);
47
- const user = { type: 'user', email };
48
- c.set('identity', user);
49
- c.set('user', user);
50
- return next();
87
+ const role = parseRole(c.req.header('x-test-user-role') ?? process.env.TEST_USER_ROLE) ?? 'invited';
88
+ return admit({ type: 'user', email, role });
51
89
  }
52
90
  // Service-to-service path.
53
91
  if (c.req.header('authorization') || c.req.header('x-test-s2s-caller')) {
@@ -72,9 +110,7 @@ export function requireIdentity(opts) {
72
110
  if (cached && cached.expires > Date.now()) {
73
111
  if (!cached.user)
74
112
  return unauthenticated(c);
75
- c.set('identity', cached.user);
76
- c.set('user', cached.user);
77
- return next();
113
+ return admit(cached.user);
78
114
  }
79
115
  let res;
80
116
  try {
@@ -86,11 +122,17 @@ export function requireIdentity(opts) {
86
122
  }
87
123
  if (res.ok) {
88
124
  const data = (await res.json());
89
- const user = { type: 'user', email: data.email, name: data.name, userId: data.user_id };
125
+ const role = parseRole(data.role);
126
+ if (!role) {
127
+ // An auth service from before roles existed (a rollback), or a
128
+ // protocol change: a fault, not an "invited" answer. Fail closed,
129
+ // loudly, and without caching so recovery is instant.
130
+ opts.logger?.error('whoami answer has no role', { email: data.email, role: data.role });
131
+ return c.json({ error: 'auth service unavailable' }, 503);
132
+ }
133
+ const user = { type: 'user', email: data.email, name: data.name, userId: data.user_id, role };
90
134
  cache.set(key, { expires: Date.now() + hitMs, user });
91
- c.set('identity', user);
92
- c.set('user', user);
93
- return next();
135
+ return admit(user);
94
136
  }
95
137
  if (res.status === 401) {
96
138
  cache.set(key, { expires: Date.now() + missMs });
@@ -100,3 +142,35 @@ export function requireIdentity(opts) {
100
142
  return c.json({ error: 'auth service unavailable' }, 503);
101
143
  });
102
144
  }
145
+ // The fleet-wide "not enabled" page, in the auth service's plain style so
146
+ // every app shows the same thing. Carries nothing but the request's host,
147
+ // the user's address, and the configured auth URL.
148
+ function accessDeniedPage(p) {
149
+ return `<!doctype html>
150
+ <html lang="en">
151
+ <head>
152
+ <meta charset="utf-8">
153
+ <meta name="viewport" content="width=device-width, initial-scale=1">
154
+ <title>Not enabled — Plasticine</title>
155
+ <style>
156
+ :root { --accent: #23a55a; --ink: #1a1a1a; --muted: #6b7280; }
157
+ body { font-family: system-ui, sans-serif; color: var(--ink); background: #fff;
158
+ display: flex; min-height: 100vh; margin: 0; align-items: center; justify-content: center; }
159
+ main { text-align: center; padding: 2rem; max-width: 32rem; }
160
+ h1 { font-size: 1.4rem; margin-bottom: 0.5rem; }
161
+ h1 .dot { color: var(--accent); }
162
+ p.muted { color: var(--muted); font-size: 0.95rem; }
163
+ a { color: var(--accent); }
164
+ </style>
165
+ </head>
166
+ <body><main>
167
+ <h1>Plasticine<span class="dot">.</span></h1>
168
+ <p><strong>${escapeHtml(p.host)}</strong> is not enabled for your account.</p>
169
+ <p class="muted">You are signed in as ${escapeHtml(p.email)}. To use a different account,
170
+ <a href="${escapeHtml(p.authUrl)}/">sign out</a> first.</p>
171
+ </main></body>
172
+ </html>`;
173
+ }
174
+ function escapeHtml(s) {
175
+ return s.replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[ch]);
176
+ }
@@ -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;