@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 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 log = logger?.with({ kind: CONNECTION_LOSS_KIND });
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
@@ -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,
@@ -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';
@@ -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
@@ -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.prompt }],
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
- contents: [{ parts: [{ text: request.prompt }] }],
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
- messages.push({ role: 'user', content: request.prompt });
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,
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.40.1",
3
+ "version": "3.42.0",
4
4
  "description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
5
5
  "type": "module",
6
6
  "license": "MIT",