@steve31415/baselib 3.40.1 → 3.41.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.
@@ -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.41.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",