@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 +5 -1
- package/dist/auth.d.ts +20 -7
- package/dist/auth.js +90 -16
- package/dist/llm/complete.d.ts +11 -0
- package/dist/llm/complete.js +145 -0
- package/dist/llm/index.d.ts +4 -0
- package/dist/llm/index.js +10 -0
- package/dist/llm/models.d.ts +54 -0
- package/dist/llm/models.js +166 -0
- package/dist/llm/providers/anthropic.d.ts +2 -0
- package/dist/llm/providers/anthropic.js +122 -0
- package/dist/llm/providers/gemini.d.ts +2 -0
- package/dist/llm/providers/gemini.js +128 -0
- package/dist/llm/providers/index.d.ts +2 -0
- package/dist/llm/providers/index.js +8 -0
- package/dist/llm/providers/openai.d.ts +2 -0
- package/dist/llm/providers/openai.js +183 -0
- package/dist/llm/types.d.ts +203 -0
- package/dist/llm/types.js +25 -0
- package/package.json +5 -1
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
6
|
-
//
|
|
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.
|
|
13
|
-
//
|
|
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
|
|
48
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
|
+
};
|