@steve31415/baselib 3.40.1 → 3.42.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/dist/db.d.ts +47 -2
- package/dist/db.js +129 -24
- package/dist/llm/complete.js +20 -0
- package/dist/llm/index.d.ts +1 -1
- package/dist/llm/models.d.ts +7 -0
- package/dist/llm/models.js +23 -0
- package/dist/llm/providers/anthropic.js +14 -1
- package/dist/llm/providers/gemini.js +8 -1
- package/dist/llm/providers/openai.js +14 -1
- package/dist/llm/types.d.ts +16 -0
- package/package.json +1 -1
package/dist/db.d.ts
CHANGED
|
@@ -49,6 +49,9 @@ export interface PoolOptions {
|
|
|
49
49
|
/** Receives idle-client errors (see logPoolErrors). Without one they go to
|
|
50
50
|
* stderr — on the Cloud Logging backstop, but not in Axiom. */
|
|
51
51
|
logger?: Logger;
|
|
52
|
+
/** Tuning for `probeIdleCheckouts` (defaults IDLE_PROBE_AFTER_MS and
|
|
53
|
+
* IDLE_PROBE_TIMEOUT_MS); tests shorten them. */
|
|
54
|
+
idleProbe?: IdleProbeOptions;
|
|
52
55
|
}
|
|
53
56
|
/** Why a fleet-wide statement timeout (mirror, 2026-09-08): a transaction an
|
|
54
57
|
* app deliberately holds open across an application await (mirror's job
|
|
@@ -78,12 +81,34 @@ export interface PoolLoggingOptions {
|
|
|
78
81
|
* instead of hanging, and TCP keepalive so a half-open socket is noticed
|
|
79
82
|
* while the process has CPU. No max lifetime: reconnecting is the
|
|
80
83
|
* operation that fails on a throttled instance, so forcing more of it
|
|
81
|
-
* would be backwards.
|
|
84
|
+
* would be backwards.
|
|
85
|
+
*
|
|
86
|
+
* One warm connection (2026-10-30, `~/migration/prompts/baselib-pool-idle-timeout.md`):
|
|
87
|
+
* pg-pool's default 10 s idle timeout emptied every pool between a single
|
|
88
|
+
* user's requests, so nearly every request paid a ~77 ms Cloud SQL connect.
|
|
89
|
+
* pg-pool 3.14's `min` does not open connections; it exempts the first
|
|
90
|
+
* `min` connections from the idle timeout, so the pool keeps one once it
|
|
91
|
+
* has made one. Connections above it close after `idleTimeoutMillis` (a
|
|
92
|
+
* bound, never unlimited). A held connection can die while idle (the
|
|
93
|
+
* weekly Thursday bursts), unnoticed until a checkout, so `createPool`
|
|
94
|
+
* also probes a client that sat idle (`probeIdleCheckouts`). Budget: one
|
|
95
|
+
* connection per pool per instance at rest, about +12 fleet-wide. A
|
|
96
|
+
* process that forgets `pool.end()` no longer exits by itself: end pools
|
|
97
|
+
* in scripts and jobs. */
|
|
82
98
|
export declare const FLEET_POOL_DEFAULTS: {
|
|
83
99
|
readonly connectionTimeoutMillis: 15000;
|
|
84
100
|
readonly keepAlive: true;
|
|
85
101
|
readonly keepAliveInitialDelayMillis: 10000;
|
|
102
|
+
readonly min: 1;
|
|
103
|
+
readonly idleTimeoutMillis: 60000;
|
|
86
104
|
};
|
|
105
|
+
/** A client idle at least this long is probed before it is handed out.
|
|
106
|
+
* Below it the pool reused connections without a probe before 2026-10-30
|
|
107
|
+
* too (they lived 10 s), so nothing is less safe than it was. */
|
|
108
|
+
export declare const IDLE_PROBE_AFTER_MS = 10000;
|
|
109
|
+
/** How long the probe waits. A healthy `select 1` takes a millisecond or
|
|
110
|
+
* two; a half-open socket would otherwise wait for TCP to give up. */
|
|
111
|
+
export declare const IDLE_PROBE_TIMEOUT_MS = 3000;
|
|
87
112
|
/** Keep a pg connection's death from killing the process, and log every
|
|
88
113
|
* way a pool loses or fails to get a connection as one WARN family:
|
|
89
114
|
* `kind=db-connection-loss`, `meta.phase`:
|
|
@@ -93,7 +118,9 @@ export declare const FLEET_POOL_DEFAULTS: {
|
|
|
93
118
|
* - `wait`: the checkout timed out waiting for a free client. Pool
|
|
94
119
|
* starvation, not loss: the pool's `max` or a client held too long.
|
|
95
120
|
* - `idle`: a client idle in the pool died; pg-pool discards it and the
|
|
96
|
-
* next checkout opens a fresh one. Harmless by construction.
|
|
121
|
+
* next checkout opens a fresh one. Harmless by construction. With
|
|
122
|
+
* `meta.detectedAt: 'checkout'` the death went unnoticed until a
|
|
123
|
+
* checkout's probe found it (`probeIdleCheckouts`); also harmless.
|
|
97
124
|
* - `checked-out`: a borrowed client died. Its next query fails (pg has
|
|
98
125
|
* already failed its queued queries and marked it unqueryable, so the
|
|
99
126
|
* borrower cannot mistake the dead transaction for a live one), and
|
|
@@ -117,6 +144,24 @@ export declare const FLEET_POOL_DEFAULTS: {
|
|
|
117
144
|
* `createPool` attaches all of this; exported for a pool an app built
|
|
118
145
|
* itself. Idempotent per pool. */
|
|
119
146
|
export declare function logPoolErrors(pool: pg.Pool, logger?: Logger, options?: PoolLoggingOptions): void;
|
|
147
|
+
export interface IdleProbeOptions {
|
|
148
|
+
afterIdleMs?: number;
|
|
149
|
+
timeoutMs?: number;
|
|
150
|
+
}
|
|
151
|
+
/** Hand out no pooled client that died while idle. A client idle for
|
|
152
|
+
* `afterIdleMs` or more runs `select 1` (bounded by `timeoutMs`) before
|
|
153
|
+
* the borrower gets it; if that fails the client is discarded, one WARN
|
|
154
|
+
* (`kind=db-connection-loss`, `phase=idle`, `detectedAt=checkout`) is
|
|
155
|
+
* logged, and the checkout is retried, ending at a fresh connection at
|
|
156
|
+
* worst. Retrying is safe because the probe is the only statement that
|
|
157
|
+
* ran on the dead client. What stays unretried, as before: a connection
|
|
158
|
+
* that dies after the probe, mid-statement or mid-transaction (the
|
|
159
|
+
* borrower sees the error; whether a lone statement took effect is
|
|
160
|
+
* unknowable), and a failed new connection (`phase=connect`). New clients
|
|
161
|
+
* and recently used ones are not probed. Both connect forms are covered
|
|
162
|
+
* (`pool.query` checks out in callback form). `createPool` attaches this;
|
|
163
|
+
* exported for a pool an app built itself. Idempotent per pool. */
|
|
164
|
+
export declare function probeIdleCheckouts(pool: pg.Pool, logger?: Logger, options?: IdleProbeOptions): void;
|
|
120
165
|
export declare function createPool(opts?: PoolOptions): Promise<pg.Pool>;
|
|
121
166
|
/** End a pool and resolve only once every client's connection has actually
|
|
122
167
|
* closed. pg-pool's own `end()` resolves as soon as it has *asked* its idle
|
package/dist/db.js
CHANGED
|
@@ -78,18 +78,69 @@ export const DEFAULT_STATEMENT_TIMEOUT_MS = 10 * 60_000;
|
|
|
78
78
|
* instead of hanging, and TCP keepalive so a half-open socket is noticed
|
|
79
79
|
* while the process has CPU. No max lifetime: reconnecting is the
|
|
80
80
|
* operation that fails on a throttled instance, so forcing more of it
|
|
81
|
-
* would be backwards.
|
|
81
|
+
* would be backwards.
|
|
82
|
+
*
|
|
83
|
+
* One warm connection (2026-10-30, `~/migration/prompts/baselib-pool-idle-timeout.md`):
|
|
84
|
+
* pg-pool's default 10 s idle timeout emptied every pool between a single
|
|
85
|
+
* user's requests, so nearly every request paid a ~77 ms Cloud SQL connect.
|
|
86
|
+
* pg-pool 3.14's `min` does not open connections; it exempts the first
|
|
87
|
+
* `min` connections from the idle timeout, so the pool keeps one once it
|
|
88
|
+
* has made one. Connections above it close after `idleTimeoutMillis` (a
|
|
89
|
+
* bound, never unlimited). A held connection can die while idle (the
|
|
90
|
+
* weekly Thursday bursts), unnoticed until a checkout, so `createPool`
|
|
91
|
+
* also probes a client that sat idle (`probeIdleCheckouts`). Budget: one
|
|
92
|
+
* connection per pool per instance at rest, about +12 fleet-wide. A
|
|
93
|
+
* process that forgets `pool.end()` no longer exits by itself: end pools
|
|
94
|
+
* in scripts and jobs. */
|
|
82
95
|
export const FLEET_POOL_DEFAULTS = {
|
|
83
96
|
connectionTimeoutMillis: 15_000,
|
|
84
97
|
keepAlive: true,
|
|
85
98
|
keepAliveInitialDelayMillis: 10_000,
|
|
99
|
+
min: 1,
|
|
100
|
+
idleTimeoutMillis: 60_000,
|
|
86
101
|
};
|
|
102
|
+
/** A client idle at least this long is probed before it is handed out.
|
|
103
|
+
* Below it the pool reused connections without a probe before 2026-10-30
|
|
104
|
+
* too (they lived 10 s), so nothing is less safe than it was. */
|
|
105
|
+
export const IDLE_PROBE_AFTER_MS = 10_000;
|
|
106
|
+
/** How long the probe waits. A healthy `select 1` takes a millisecond or
|
|
107
|
+
* two; a half-open socket would otherwise wait for TCP to give up. */
|
|
108
|
+
export const IDLE_PROBE_TIMEOUT_MS = 3_000;
|
|
109
|
+
const IDLE_PROBE_SQL = 'select 1 /* baselib idle probe */';
|
|
87
110
|
const CONNECTION_LOSS_KIND = 'db-connection-loss';
|
|
88
111
|
const ACQUIRE_FAILED_MESSAGE = {
|
|
89
112
|
connect: 'database pool: new connection failed',
|
|
90
113
|
wait: 'database pool: checkout timed out waiting for a free client',
|
|
91
114
|
};
|
|
92
115
|
const instrumented = new WeakSet();
|
|
116
|
+
// Clients under an idle probe: their death is reported once, by the probe,
|
|
117
|
+
// as an idle loss rather than a checked-out one.
|
|
118
|
+
const probing = new WeakSet();
|
|
119
|
+
/** One `kind=db-connection-loss` WARN, the shape every pool loss shares. */
|
|
120
|
+
function connectionLossWarner(pool, logger, credentialExpiry) {
|
|
121
|
+
const log = logger?.with({ kind: CONNECTION_LOSS_KIND });
|
|
122
|
+
return (phase, message, err, since, extra = {}) => {
|
|
123
|
+
const expiry = credentialExpiry?.();
|
|
124
|
+
const meta = {
|
|
125
|
+
phase,
|
|
126
|
+
...extra,
|
|
127
|
+
...(since === undefined ? {} : { elapsedMs: Date.now() - since }),
|
|
128
|
+
code: err.code,
|
|
129
|
+
pool: { total: pool.totalCount, idle: pool.idleCount, waiting: pool.waitingCount },
|
|
130
|
+
...(credentialExpiry
|
|
131
|
+
? expiry === undefined
|
|
132
|
+
? { credentialUnknown: true }
|
|
133
|
+
: { credentialRemainingMs: expiry - Date.now() }
|
|
134
|
+
: {}),
|
|
135
|
+
error: err,
|
|
136
|
+
};
|
|
137
|
+
if (log)
|
|
138
|
+
log.warn(message, meta);
|
|
139
|
+
// No logger to add the activity fields, so add them here.
|
|
140
|
+
else
|
|
141
|
+
console.warn(message, { kind: CONNECTION_LOSS_KIND, ...processMeta(), ...meta, error: serializeError(err) });
|
|
142
|
+
};
|
|
143
|
+
}
|
|
93
144
|
/** Keep a pg connection's death from killing the process, and log every
|
|
94
145
|
* way a pool loses or fails to get a connection as one WARN family:
|
|
95
146
|
* `kind=db-connection-loss`, `meta.phase`:
|
|
@@ -99,7 +150,9 @@ const instrumented = new WeakSet();
|
|
|
99
150
|
* - `wait`: the checkout timed out waiting for a free client. Pool
|
|
100
151
|
* starvation, not loss: the pool's `max` or a client held too long.
|
|
101
152
|
* - `idle`: a client idle in the pool died; pg-pool discards it and the
|
|
102
|
-
* next checkout opens a fresh one. Harmless by construction.
|
|
153
|
+
* next checkout opens a fresh one. Harmless by construction. With
|
|
154
|
+
* `meta.detectedAt: 'checkout'` the death went unnoticed until a
|
|
155
|
+
* checkout's probe found it (`probeIdleCheckouts`); also harmless.
|
|
103
156
|
* - `checked-out`: a borrowed client died. Its next query fails (pg has
|
|
104
157
|
* already failed its queued queries and marked it unqueryable, so the
|
|
105
158
|
* borrower cannot mistake the dead transaction for a live one), and
|
|
@@ -126,27 +179,7 @@ export function logPoolErrors(pool, logger, options = {}) {
|
|
|
126
179
|
if (instrumented.has(pool))
|
|
127
180
|
return;
|
|
128
181
|
instrumented.add(pool);
|
|
129
|
-
const
|
|
130
|
-
const warn = (phase, message, err, since) => {
|
|
131
|
-
const expiry = options.credentialExpiry?.();
|
|
132
|
-
const meta = {
|
|
133
|
-
phase,
|
|
134
|
-
...(since === undefined ? {} : { elapsedMs: Date.now() - since }),
|
|
135
|
-
code: err.code,
|
|
136
|
-
pool: { total: pool.totalCount, idle: pool.idleCount, waiting: pool.waitingCount },
|
|
137
|
-
...(options.credentialExpiry
|
|
138
|
-
? expiry === undefined
|
|
139
|
-
? { credentialUnknown: true }
|
|
140
|
-
: { credentialRemainingMs: expiry - Date.now() }
|
|
141
|
-
: {}),
|
|
142
|
-
error: err,
|
|
143
|
-
};
|
|
144
|
-
if (log)
|
|
145
|
-
log.warn(message, meta);
|
|
146
|
-
// No logger to add the activity fields, so add them here.
|
|
147
|
-
else
|
|
148
|
-
console.warn(message, { kind: CONNECTION_LOSS_KIND, ...processMeta(), ...meta, error: serializeError(err) });
|
|
149
|
-
};
|
|
182
|
+
const warn = connectionLossWarner(pool, logger, options.credentialExpiry);
|
|
150
183
|
// Per-client bookkeeping across one checkout: the borrower-side error
|
|
151
184
|
// listener (pg-pool drops its own at checkout) and when the client was
|
|
152
185
|
// taken and given back.
|
|
@@ -167,7 +200,7 @@ export function logPoolErrors(pool, logger, options = {}) {
|
|
|
167
200
|
// close); one line per checkout is enough.
|
|
168
201
|
let logged = false;
|
|
169
202
|
entry.listener = (err) => {
|
|
170
|
-
if (logged)
|
|
203
|
+
if (logged || probing.has(client))
|
|
171
204
|
return;
|
|
172
205
|
logged = true;
|
|
173
206
|
warn('checked-out', 'database pool: checked-out client lost its connection (any transaction or advisory lock it held is gone; its next query fails)', err, entry.acquiredAt);
|
|
@@ -208,6 +241,76 @@ export function logPoolErrors(pool, logger, options = {}) {
|
|
|
208
241
|
});
|
|
209
242
|
});
|
|
210
243
|
}
|
|
244
|
+
const probed = new WeakSet();
|
|
245
|
+
/** Hand out no pooled client that died while idle. A client idle for
|
|
246
|
+
* `afterIdleMs` or more runs `select 1` (bounded by `timeoutMs`) before
|
|
247
|
+
* the borrower gets it; if that fails the client is discarded, one WARN
|
|
248
|
+
* (`kind=db-connection-loss`, `phase=idle`, `detectedAt=checkout`) is
|
|
249
|
+
* logged, and the checkout is retried, ending at a fresh connection at
|
|
250
|
+
* worst. Retrying is safe because the probe is the only statement that
|
|
251
|
+
* ran on the dead client. What stays unretried, as before: a connection
|
|
252
|
+
* that dies after the probe, mid-statement or mid-transaction (the
|
|
253
|
+
* borrower sees the error; whether a lone statement took effect is
|
|
254
|
+
* unknowable), and a failed new connection (`phase=connect`). New clients
|
|
255
|
+
* and recently used ones are not probed. Both connect forms are covered
|
|
256
|
+
* (`pool.query` checks out in callback form). `createPool` attaches this;
|
|
257
|
+
* exported for a pool an app built itself. Idempotent per pool. */
|
|
258
|
+
export function probeIdleCheckouts(pool, logger, options = {}) {
|
|
259
|
+
if (probed.has(pool))
|
|
260
|
+
return;
|
|
261
|
+
probed.add(pool);
|
|
262
|
+
const afterIdleMs = options.afterIdleMs ?? IDLE_PROBE_AFTER_MS;
|
|
263
|
+
const timeoutMs = options.timeoutMs ?? IDLE_PROBE_TIMEOUT_MS;
|
|
264
|
+
const releasedAt = new WeakMap();
|
|
265
|
+
pool.on('release', (_err, client) => releasedAt.set(client, Date.now()));
|
|
266
|
+
const warn = connectionLossWarner(pool, logger);
|
|
267
|
+
const connect = pool.connect.bind(pool);
|
|
268
|
+
const checkout = async () => {
|
|
269
|
+
// Each failed probe removes a client, so this ends at a client that
|
|
270
|
+
// passes or a fresh one (or a connect error, which propagates).
|
|
271
|
+
for (;;) {
|
|
272
|
+
const client = await connect();
|
|
273
|
+
const idleSince = releasedAt.get(client);
|
|
274
|
+
if (idleSince === undefined || Date.now() - idleSince < afterIdleMs)
|
|
275
|
+
return client;
|
|
276
|
+
probing.add(client);
|
|
277
|
+
// The death also arrives as a client 'error', possibly after the
|
|
278
|
+
// discard below; the probe reports it, and a discarded client keeps
|
|
279
|
+
// this listener so a late socket error cannot go unhandled.
|
|
280
|
+
const ignore = () => { };
|
|
281
|
+
client.on('error', ignore);
|
|
282
|
+
let timer;
|
|
283
|
+
try {
|
|
284
|
+
const probe = client.query(IDLE_PROBE_SQL);
|
|
285
|
+
// On a timeout the release below destroys the socket (pg's end()
|
|
286
|
+
// with a query active), which rejects the probe; nothing awaits it.
|
|
287
|
+
probe.catch(() => { });
|
|
288
|
+
await Promise.race([
|
|
289
|
+
probe,
|
|
290
|
+
new Promise((_, reject) => {
|
|
291
|
+
timer = setTimeout(() => reject(new Error(`idle probe timed out after ${timeoutMs} ms`)), timeoutMs);
|
|
292
|
+
}),
|
|
293
|
+
]);
|
|
294
|
+
client.removeListener('error', ignore);
|
|
295
|
+
return client;
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
299
|
+
warn('idle', 'database pool: idle client found dead at checkout; replaced', error, idleSince, { detectedAt: 'checkout' });
|
|
300
|
+
client.release(error);
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
clearTimeout(timer);
|
|
304
|
+
probing.delete(client);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
pool.connect = ((cb) => {
|
|
309
|
+
if (typeof cb !== 'function')
|
|
310
|
+
return checkout();
|
|
311
|
+
checkout().then((client) => cb(undefined, client, (release) => client.release(release)), (err) => cb(err));
|
|
312
|
+
});
|
|
313
|
+
}
|
|
211
314
|
export async function createPool(opts = {}) {
|
|
212
315
|
// pg sends statement_timeout in the startup packet of every connection —
|
|
213
316
|
// server-enforced, no per-connection SET. A 0 is falsy, so pg omits it and
|
|
@@ -230,6 +333,7 @@ export async function createPool(opts = {}) {
|
|
|
230
333
|
max: opts.max ?? 3, // shared instance: default max_connections is low
|
|
231
334
|
});
|
|
232
335
|
logPoolErrors(pool, opts.logger, { credentialExpiry });
|
|
336
|
+
probeIdleCheckouts(pool, opts.logger, opts.idleProbe);
|
|
233
337
|
return pool;
|
|
234
338
|
}
|
|
235
339
|
const url = new URL(requireEnv('DATABASE_URL'));
|
|
@@ -239,6 +343,7 @@ export async function createPool(opts = {}) {
|
|
|
239
343
|
url.username = opts.user;
|
|
240
344
|
const pool = new pg.Pool({ connectionString: url.toString(), ...shared, max: opts.max ?? 5 });
|
|
241
345
|
logPoolErrors(pool, opts.logger);
|
|
346
|
+
probeIdleCheckouts(pool, opts.logger, opts.idleProbe);
|
|
242
347
|
return pool;
|
|
243
348
|
}
|
|
244
349
|
/** End a pool and resolve only once every client's connection has actually
|
package/dist/llm/complete.js
CHANGED
|
@@ -39,6 +39,19 @@ export async function llmComplete(request) {
|
|
|
39
39
|
isRetryable: false,
|
|
40
40
|
});
|
|
41
41
|
}
|
|
42
|
+
// A request with images may only run on models that take them. The
|
|
43
|
+
// requested model not taking them is the caller's bug, so say so rather
|
|
44
|
+
// than silently answering from a fallback; fallbacks without image input
|
|
45
|
+
// are skipped in the loop below.
|
|
46
|
+
const hasImages = (request.images?.length ?? 0) > 0;
|
|
47
|
+
if (hasImages && !entry.supportsImages) {
|
|
48
|
+
throw new LLMError({
|
|
49
|
+
message: `Model ${request.model} does not accept image input. Image-capable models: ${Object.keys(MODEL_REGISTRY).filter((m) => MODEL_REGISTRY[m].supportsImages).join(', ')}`,
|
|
50
|
+
provider: entry.provider,
|
|
51
|
+
model: entry.apiModelId,
|
|
52
|
+
isRetryable: false,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
42
55
|
const maxTokens = request.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
43
56
|
const temperature = request.temperature ?? DEFAULT_TEMPERATURE;
|
|
44
57
|
const timeoutMs = request.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
@@ -75,6 +88,12 @@ export async function llmComplete(request) {
|
|
|
75
88
|
const modelEntry = MODEL_REGISTRY[modelName];
|
|
76
89
|
if (!modelEntry)
|
|
77
90
|
continue;
|
|
91
|
+
if (hasImages && !modelEntry.supportsImages) {
|
|
92
|
+
request.logger?.debug('llm.skip_provider', {
|
|
93
|
+
model: modelName, provider: modelEntry.provider, reason: 'no image input',
|
|
94
|
+
});
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
78
97
|
const apiKey = request.apiKeys[modelEntry.provider];
|
|
79
98
|
if (!apiKey) {
|
|
80
99
|
request.logger?.debug('llm.skip_provider', {
|
|
@@ -88,6 +107,7 @@ export async function llmComplete(request) {
|
|
|
88
107
|
const result = await provider.complete({
|
|
89
108
|
apiModelId: modelEntry.apiModelId,
|
|
90
109
|
prompt: request.prompt,
|
|
110
|
+
images: hasImages ? request.images : undefined,
|
|
91
111
|
systemPrompt: request.systemPrompt,
|
|
92
112
|
maxTokens,
|
|
93
113
|
temperature,
|
package/dist/llm/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { llmComplete } from './complete.js';
|
|
2
2
|
export { MODEL_REGISTRY } from './models.js';
|
|
3
3
|
export type { ModelEntry } from './models.js';
|
|
4
|
-
export { LLMError, EFFORT_LEVELS, type Effort, type LLMRequest, type LLMResponse, type FailedAttempt, type FinishCategory, type ProviderName, type ApiKeys, type JsonSchema, } from './types.js';
|
|
4
|
+
export { LLMError, EFFORT_LEVELS, type Effort, type LLMRequest, type LLMResponse, type FailedAttempt, type FinishCategory, type ProviderName, type ApiKeys, type JsonSchema, type LLMImage, type LLMImageMediaType, } from './types.js';
|
package/dist/llm/models.d.ts
CHANGED
|
@@ -59,6 +59,13 @@ export interface ModelEntry {
|
|
|
59
59
|
* `output_config.format` instead.
|
|
60
60
|
*/
|
|
61
61
|
supportsForcedToolChoice?: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* Whether this model accepts image input (`LLMRequest.images`). Defaults to
|
|
64
|
+
* `false`: a request with images is rejected for a model without it, and
|
|
65
|
+
* failover skips such fallbacks. Set only on models whose image handling
|
|
66
|
+
* was verified live (test/helpers/llm.ts records the probe).
|
|
67
|
+
*/
|
|
68
|
+
supportsImages?: boolean;
|
|
62
69
|
}
|
|
63
70
|
/**
|
|
64
71
|
* Output budget below which a `thinksByDefault` model is considered at risk of
|
package/dist/llm/models.js
CHANGED
|
@@ -26,10 +26,15 @@ export const MODEL_REGISTRY = {
|
|
|
26
26
|
// hidden reasoning tokens billed as output — hence `supportsTemperature:
|
|
27
27
|
// false` + `thinksByDefault: true` on each. (gpt-5.2 still accepts
|
|
28
28
|
// temperature; verified the same day.)
|
|
29
|
+
//
|
|
30
|
+
// `supportsImages`: every entry still served read a JPEG beside a
|
|
31
|
+
// jsonSchema in the live probe of 2026-10-29 (test/helpers/llm.ts);
|
|
32
|
+
// the shut-down gemini-2.5 pair is left unmarked.
|
|
29
33
|
// --- Fast tier ---
|
|
30
34
|
'gemini-3.5-flash-lite': {
|
|
31
35
|
provider: 'gemini',
|
|
32
36
|
apiModelId: 'gemini-3.5-flash-lite',
|
|
37
|
+
supportsImages: true,
|
|
33
38
|
fallbacks: ['gpt-5.6-luna', 'claude-haiku-4-5'],
|
|
34
39
|
// Default thinking_level is `minimal` — reasoning overhead is negligible,
|
|
35
40
|
// so no `thinksByDefault` headroom warning.
|
|
@@ -41,6 +46,7 @@ export const MODEL_REGISTRY = {
|
|
|
41
46
|
'gemini-3.6-flash': {
|
|
42
47
|
provider: 'gemini',
|
|
43
48
|
apiModelId: 'gemini-3.6-flash',
|
|
49
|
+
supportsImages: true,
|
|
44
50
|
fallbacks: ['gpt-5.6-luna', 'claude-haiku-4-5'],
|
|
45
51
|
// Thinks by default (thinking_level defaults to `medium`); reasoning
|
|
46
52
|
// tokens are billed as output and count against maxOutputTokens.
|
|
@@ -56,6 +62,7 @@ export const MODEL_REGISTRY = {
|
|
|
56
62
|
'gpt-5.6-luna': {
|
|
57
63
|
provider: 'openai',
|
|
58
64
|
apiModelId: 'gpt-5.6-luna',
|
|
65
|
+
supportsImages: true,
|
|
59
66
|
fallbacks: ['gemini-3.5-flash-lite', 'claude-haiku-4-5'],
|
|
60
67
|
supportsTemperature: false,
|
|
61
68
|
thinksByDefault: true,
|
|
@@ -63,6 +70,7 @@ export const MODEL_REGISTRY = {
|
|
|
63
70
|
'claude-haiku-4-5': {
|
|
64
71
|
provider: 'anthropic',
|
|
65
72
|
apiModelId: 'claude-haiku-4-5-20251001',
|
|
73
|
+
supportsImages: true,
|
|
66
74
|
fallbacks: ['gemini-3.5-flash-lite', 'gpt-5.6-luna'],
|
|
67
75
|
},
|
|
68
76
|
// Retiring 2026-10-16 — migrate callers to gemini-3.5-flash-lite.
|
|
@@ -74,12 +82,14 @@ export const MODEL_REGISTRY = {
|
|
|
74
82
|
'gemini-3.1-flash-lite': {
|
|
75
83
|
provider: 'gemini',
|
|
76
84
|
apiModelId: 'gemini-3.1-flash-lite',
|
|
85
|
+
supportsImages: true,
|
|
77
86
|
fallbacks: ['claude-sonnet-4-6'],
|
|
78
87
|
supportsEffort: true,
|
|
79
88
|
},
|
|
80
89
|
'gemini-3-flash-preview': {
|
|
81
90
|
provider: 'gemini',
|
|
82
91
|
apiModelId: 'gemini-3-flash-preview',
|
|
92
|
+
supportsImages: true,
|
|
83
93
|
fallbacks: ['claude-haiku-4-5', 'gpt-5.6-luna'],
|
|
84
94
|
supportsEffort: true,
|
|
85
95
|
thinkingLevels: ['minimal', 'low', 'medium', 'high'],
|
|
@@ -87,6 +97,7 @@ export const MODEL_REGISTRY = {
|
|
|
87
97
|
'gpt-4o-mini': {
|
|
88
98
|
provider: 'openai',
|
|
89
99
|
apiModelId: 'gpt-4o-mini',
|
|
100
|
+
supportsImages: true,
|
|
90
101
|
fallbacks: ['gemini-3.5-flash-lite', 'claude-haiku-4-5'],
|
|
91
102
|
},
|
|
92
103
|
// --- Mid tier ---
|
|
@@ -94,6 +105,7 @@ export const MODEL_REGISTRY = {
|
|
|
94
105
|
provider: 'anthropic',
|
|
95
106
|
supportsEffort: true,
|
|
96
107
|
apiModelId: 'claude-sonnet-5',
|
|
108
|
+
supportsImages: true,
|
|
97
109
|
fallbacks: ['gpt-5.6-terra', 'gemini-3.1-pro-preview'],
|
|
98
110
|
// Sonnet 5 rejects `temperature` (like Opus 4.7+/Fable 5). It supports
|
|
99
111
|
// adaptive thinking but does NOT think when the request omits `thinking`
|
|
@@ -103,6 +115,7 @@ export const MODEL_REGISTRY = {
|
|
|
103
115
|
'gpt-5.6-terra': {
|
|
104
116
|
provider: 'openai',
|
|
105
117
|
apiModelId: 'gpt-5.6-terra',
|
|
118
|
+
supportsImages: true,
|
|
106
119
|
fallbacks: ['claude-sonnet-5', 'gemini-3.1-pro-preview'],
|
|
107
120
|
supportsTemperature: false,
|
|
108
121
|
thinksByDefault: true,
|
|
@@ -111,6 +124,7 @@ export const MODEL_REGISTRY = {
|
|
|
111
124
|
provider: 'anthropic',
|
|
112
125
|
supportsEffort: true,
|
|
113
126
|
apiModelId: 'claude-sonnet-4-6',
|
|
127
|
+
supportsImages: true,
|
|
114
128
|
fallbacks: ['gpt-5.6-terra', 'gemini-3.1-pro-preview'],
|
|
115
129
|
},
|
|
116
130
|
// Legacy: consumer-retired 2026-02; API snapshots retire 2026-10 onward.
|
|
@@ -118,6 +132,7 @@ export const MODEL_REGISTRY = {
|
|
|
118
132
|
'gpt-4o': {
|
|
119
133
|
provider: 'openai',
|
|
120
134
|
apiModelId: 'gpt-4o',
|
|
135
|
+
supportsImages: true,
|
|
121
136
|
fallbacks: ['claude-sonnet-5', 'gemini-3.1-pro-preview'],
|
|
122
137
|
},
|
|
123
138
|
// --- Top tier ---
|
|
@@ -125,6 +140,7 @@ export const MODEL_REGISTRY = {
|
|
|
125
140
|
provider: 'anthropic',
|
|
126
141
|
supportsEffort: true,
|
|
127
142
|
apiModelId: 'claude-opus-5-5',
|
|
143
|
+
supportsImages: true,
|
|
128
144
|
fallbacks: ['gpt-5.6-sol', 'gemini-3.1-pro-preview'],
|
|
129
145
|
// All verified live 2026-09-22: rejects `temperature` and forced
|
|
130
146
|
// tool_choice (400 each); thinking is always on and its tokens count
|
|
@@ -139,6 +155,7 @@ export const MODEL_REGISTRY = {
|
|
|
139
155
|
provider: 'anthropic',
|
|
140
156
|
supportsEffort: true,
|
|
141
157
|
apiModelId: 'claude-opus-5',
|
|
158
|
+
supportsImages: true,
|
|
142
159
|
fallbacks: ['gpt-5.6-sol', 'gemini-3.1-pro-preview'],
|
|
143
160
|
// Opus 4.7+ rejects `temperature` outright (400 invalid_request_error).
|
|
144
161
|
supportsTemperature: false,
|
|
@@ -152,6 +169,7 @@ export const MODEL_REGISTRY = {
|
|
|
152
169
|
provider: 'anthropic',
|
|
153
170
|
supportsEffort: true,
|
|
154
171
|
apiModelId: 'claude-opus-4-8',
|
|
172
|
+
supportsImages: true,
|
|
155
173
|
fallbacks: ['gpt-5.6-sol', 'gemini-3.1-pro-preview'],
|
|
156
174
|
// Opus 4.7+ rejects `temperature` outright (400 invalid_request_error).
|
|
157
175
|
supportsTemperature: false,
|
|
@@ -160,6 +178,7 @@ export const MODEL_REGISTRY = {
|
|
|
160
178
|
provider: 'anthropic',
|
|
161
179
|
supportsEffort: true,
|
|
162
180
|
apiModelId: 'claude-opus-4-6',
|
|
181
|
+
supportsImages: true,
|
|
163
182
|
fallbacks: ['gpt-5.6-sol', 'gemini-3.1-pro-preview'],
|
|
164
183
|
},
|
|
165
184
|
// Released 2026-09-22; same limits as gpt-5.6-sol (1.05M / 128k). Verified
|
|
@@ -169,6 +188,7 @@ export const MODEL_REGISTRY = {
|
|
|
169
188
|
'gpt-6-sol': {
|
|
170
189
|
provider: 'openai',
|
|
171
190
|
apiModelId: 'gpt-6-sol',
|
|
191
|
+
supportsImages: true,
|
|
172
192
|
fallbacks: ['claude-opus-5-5', 'gemini-3.1-pro-preview'],
|
|
173
193
|
supportsTemperature: false,
|
|
174
194
|
thinksByDefault: true,
|
|
@@ -176,6 +196,7 @@ export const MODEL_REGISTRY = {
|
|
|
176
196
|
'gpt-5.6-sol': {
|
|
177
197
|
provider: 'openai',
|
|
178
198
|
apiModelId: 'gpt-5.6-sol',
|
|
199
|
+
supportsImages: true,
|
|
179
200
|
fallbacks: ['claude-opus-5-5', 'gemini-3.1-pro-preview'],
|
|
180
201
|
supportsTemperature: false,
|
|
181
202
|
thinksByDefault: true,
|
|
@@ -183,6 +204,7 @@ export const MODEL_REGISTRY = {
|
|
|
183
204
|
'gpt-5.2': {
|
|
184
205
|
provider: 'openai',
|
|
185
206
|
apiModelId: 'gpt-5.2',
|
|
207
|
+
supportsImages: true,
|
|
186
208
|
fallbacks: ['claude-opus-5-5', 'gemini-3.1-pro-preview'],
|
|
187
209
|
},
|
|
188
210
|
// Google's best Pro model as of 2026-08 — still Preview-labeled (no GA
|
|
@@ -191,6 +213,7 @@ export const MODEL_REGISTRY = {
|
|
|
191
213
|
'gemini-3.1-pro-preview': {
|
|
192
214
|
provider: 'gemini',
|
|
193
215
|
apiModelId: 'gemini-3.1-pro-preview',
|
|
216
|
+
supportsImages: true,
|
|
194
217
|
fallbacks: ['claude-opus-5-5', 'gpt-5.6-sol'],
|
|
195
218
|
// Thinks by default (thinking_level defaults to `high`).
|
|
196
219
|
thinksByDefault: true,
|
|
@@ -60,6 +60,18 @@ function toAnthropicStrictSchema(node) {
|
|
|
60
60
|
}
|
|
61
61
|
return out;
|
|
62
62
|
}
|
|
63
|
+
/** The user turn: the bare prompt string, or image blocks then the prompt as a text block. */
|
|
64
|
+
function userContent(request) {
|
|
65
|
+
if (!request.images)
|
|
66
|
+
return request.prompt;
|
|
67
|
+
return [
|
|
68
|
+
...request.images.map((image) => ({
|
|
69
|
+
type: 'image',
|
|
70
|
+
source: { type: 'base64', media_type: image.mediaType, data: image.data },
|
|
71
|
+
})),
|
|
72
|
+
{ type: 'text', text: request.prompt },
|
|
73
|
+
];
|
|
74
|
+
}
|
|
63
75
|
export const anthropicProvider = {
|
|
64
76
|
name: 'anthropic',
|
|
65
77
|
async complete(request) {
|
|
@@ -67,12 +79,13 @@ export const anthropicProvider = {
|
|
|
67
79
|
request.logger?.info('llm.request', {
|
|
68
80
|
provider: 'anthropic', model: request.apiModelId, endpoint,
|
|
69
81
|
...(request.callSite ? { callSite: request.callSite } : {}),
|
|
82
|
+
...(request.images ? { images: request.images.length } : {}),
|
|
70
83
|
});
|
|
71
84
|
const startTime = Date.now();
|
|
72
85
|
const body = {
|
|
73
86
|
model: request.apiModelId,
|
|
74
87
|
max_tokens: request.maxTokens,
|
|
75
|
-
messages: [{ role: 'user', content: request
|
|
88
|
+
messages: [{ role: 'user', content: userContent(request) }],
|
|
76
89
|
};
|
|
77
90
|
// Some models (Opus 4.7+, Sonnet 5, Fable 5, Opus 5.5) reject `temperature`
|
|
78
91
|
// outright with a 400 — omit the field entirely rather than sending it.
|
|
@@ -64,6 +64,7 @@ export const geminiProvider = {
|
|
|
64
64
|
request.logger?.info('llm.request', {
|
|
65
65
|
provider: 'gemini', model: request.apiModelId, endpoint,
|
|
66
66
|
...(request.callSite ? { callSite: request.callSite } : {}),
|
|
67
|
+
...(request.images ? { images: request.images.length } : {}),
|
|
67
68
|
});
|
|
68
69
|
const startTime = Date.now();
|
|
69
70
|
const generationConfig = {
|
|
@@ -95,7 +96,13 @@ export const geminiProvider = {
|
|
|
95
96
|
generationConfig.thinkingConfig = { thinkingBudget: request.thinkingBudget };
|
|
96
97
|
}
|
|
97
98
|
const body = {
|
|
98
|
-
|
|
99
|
+
// Images go as inline-data parts ahead of the prompt's text part.
|
|
100
|
+
contents: [{
|
|
101
|
+
parts: [
|
|
102
|
+
...(request.images ?? []).map((image) => ({ inlineData: { mimeType: image.mediaType, data: image.data } })),
|
|
103
|
+
{ text: request.prompt },
|
|
104
|
+
],
|
|
105
|
+
}],
|
|
99
106
|
generationConfig,
|
|
100
107
|
};
|
|
101
108
|
if (request.systemPrompt) {
|
|
@@ -90,13 +90,26 @@ export const openaiProvider = {
|
|
|
90
90
|
request.logger?.info('llm.request', {
|
|
91
91
|
provider: 'openai', model: request.apiModelId, endpoint,
|
|
92
92
|
...(request.callSite ? { callSite: request.callSite } : {}),
|
|
93
|
+
...(request.images ? { images: request.images.length } : {}),
|
|
93
94
|
});
|
|
94
95
|
const startTime = Date.now();
|
|
95
96
|
const messages = [];
|
|
96
97
|
if (request.systemPrompt) {
|
|
97
98
|
messages.push({ role: 'system', content: request.systemPrompt });
|
|
98
99
|
}
|
|
99
|
-
|
|
100
|
+
// Images go as data-URL `image_url` parts ahead of the prompt's text part.
|
|
101
|
+
messages.push({
|
|
102
|
+
role: 'user',
|
|
103
|
+
content: request.images
|
|
104
|
+
? [
|
|
105
|
+
...request.images.map((image) => ({
|
|
106
|
+
type: 'image_url',
|
|
107
|
+
image_url: { url: `data:${image.mediaType};base64,${image.data}` },
|
|
108
|
+
})),
|
|
109
|
+
{ type: 'text', text: request.prompt },
|
|
110
|
+
]
|
|
111
|
+
: request.prompt,
|
|
112
|
+
});
|
|
100
113
|
const body = {
|
|
101
114
|
model: request.apiModelId,
|
|
102
115
|
max_completion_tokens: request.maxTokens,
|
package/dist/llm/types.d.ts
CHANGED
|
@@ -14,12 +14,26 @@ export interface JsonSchema {
|
|
|
14
14
|
/** JSON Schema object. */
|
|
15
15
|
schema: Record<string, unknown>;
|
|
16
16
|
}
|
|
17
|
+
/** Media types every image-capable provider accepts (Anthropic, OpenAI and Gemini alike). */
|
|
18
|
+
export type LLMImageMediaType = 'image/jpeg' | 'image/png' | 'image/webp';
|
|
19
|
+
/** An image sent beside the prompt: raw base64, no `data:` URL prefix. */
|
|
20
|
+
export interface LLMImage {
|
|
21
|
+
mediaType: LLMImageMediaType;
|
|
22
|
+
data: string;
|
|
23
|
+
}
|
|
17
24
|
/** Request to llmComplete(). */
|
|
18
25
|
export interface LLMRequest {
|
|
19
26
|
/** Model name from the registry (e.g. 'claude-sonnet-4-6', 'gemini-2.5-flash'). */
|
|
20
27
|
model: string;
|
|
21
28
|
/** The user prompt. */
|
|
22
29
|
prompt: string;
|
|
30
|
+
/**
|
|
31
|
+
* Images for the model to look at beside the prompt, placed before the
|
|
32
|
+
* prompt text in the user turn. Only models the registry marks
|
|
33
|
+
* `supportsImages` are tried: the requested model must be one (else the
|
|
34
|
+
* call is rejected up front), and failover skips fallbacks that are not.
|
|
35
|
+
*/
|
|
36
|
+
images?: LLMImage[];
|
|
23
37
|
/** Optional system prompt. */
|
|
24
38
|
systemPrompt?: string;
|
|
25
39
|
/** Max output tokens. Default: 2048. */
|
|
@@ -174,6 +188,8 @@ export interface ProviderAdapter {
|
|
|
174
188
|
export interface ProviderCallRequest {
|
|
175
189
|
apiModelId: string;
|
|
176
190
|
prompt: string;
|
|
191
|
+
/** Present and non-empty only for a `supportsImages` model (gated in complete.ts). */
|
|
192
|
+
images?: LLMImage[];
|
|
177
193
|
systemPrompt?: string;
|
|
178
194
|
maxTokens: number;
|
|
179
195
|
temperature: number;
|
package/package.json
CHANGED