@realtimex/folio 0.1.9 → 0.1.11

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.
@@ -2,8 +2,12 @@ import { createLogger } from "../utils/logger.js";
2
2
  import { SDKService } from "./SDKService.js";
3
3
  const logger = createLogger("ModelCapabilityService");
4
4
  export class ModelCapabilityService {
5
- static SUPPORTED_TTL_DAYS = 180;
6
- static UNSUPPORTED_TTL_DAYS = 30;
5
+ static SUPPORTED_TTL_MS = 180 * 24 * 60 * 60 * 1000;
6
+ static UNSUPPORTED_TTL_MS = 30 * 24 * 60 * 60 * 1000;
7
+ static PENDING_UNSUPPORTED_TTL_MS = 24 * 60 * 60 * 1000;
8
+ static UNSUPPORTED_CONFIRMATION_WINDOW_MS = 24 * 60 * 60 * 1000;
9
+ static UNSUPPORTED_CONFIRMATION_FAILURES = 2;
10
+ static UNSUPPORTED_SCORE_THRESHOLD = 3;
7
11
  static resolveVisionSupport(settingsRow) {
8
12
  const provider = (settingsRow?.llm_provider || SDKService.DEFAULT_LLM_PROVIDER).trim();
9
13
  const model = (settingsRow?.llm_model || SDKService.DEFAULT_LLM_MODEL).trim();
@@ -18,14 +22,10 @@ export class ModelCapabilityService {
18
22
  static getVisionState(rawMap, provider, model) {
19
23
  const map = this.normalizeCapabilityMap(rawMap);
20
24
  const entry = map[this.capabilityKey(provider, model)];
21
- if (!entry)
25
+ if (!entry || this.isExpired(entry))
26
+ return "unknown";
27
+ if (entry.state === "pending_unsupported")
22
28
  return "unknown";
23
- if (entry.expires_at) {
24
- const expiryTs = Date.parse(entry.expires_at);
25
- if (Number.isFinite(expiryTs) && expiryTs <= Date.now()) {
26
- return "unknown";
27
- }
28
- }
29
29
  return entry.state;
30
30
  }
31
31
  static async learnVisionSuccess(opts) {
@@ -33,14 +33,42 @@ export class ModelCapabilityService {
33
33
  ...opts,
34
34
  state: "supported",
35
35
  reason: "vision_request_succeeded",
36
- ttlDays: this.SUPPORTED_TTL_DAYS,
36
+ ttlMs: this.SUPPORTED_TTL_MS,
37
37
  });
38
38
  }
39
39
  static async learnVisionFailure(opts) {
40
- const classification = this.classifyVisionFailure(opts.error);
40
+ const classification = this.classifyVisionFailure({
41
+ error: opts.error,
42
+ provider: opts.provider,
43
+ });
41
44
  if (!classification.isCapabilityError) {
42
- logger.info(`Vision failure for ${opts.provider}/${opts.model} treated as transient; leaving capability unknown`, {
45
+ logger.info(`Vision failure for ${opts.provider}/${opts.model} treated as non-capability; leaving capability unknown`, {
43
46
  reason: classification.reason,
47
+ score: classification.score,
48
+ evidence: classification.evidence,
49
+ });
50
+ return "unknown";
51
+ }
52
+ const map = await this.readCapabilityMap(opts.supabase, opts.userId);
53
+ if (!map) {
54
+ return "unknown";
55
+ }
56
+ const key = this.capabilityKey(opts.provider, opts.model);
57
+ const now = new Date();
58
+ const failureCount = this.nextFailureCount(map[key], now.getTime());
59
+ if (failureCount < this.UNSUPPORTED_CONFIRMATION_FAILURES) {
60
+ await this.writeCapability({
61
+ supabase: opts.supabase,
62
+ userId: opts.userId,
63
+ provider: opts.provider,
64
+ model: opts.model,
65
+ state: "pending_unsupported",
66
+ reason: "capability_signal_pending_confirmation",
67
+ ttlMs: this.PENDING_UNSUPPORTED_TTL_MS,
68
+ preloadedMap: map,
69
+ failureCount,
70
+ lastFailureAt: now.toISOString(),
71
+ evidence: classification.evidence,
44
72
  });
45
73
  return "unknown";
46
74
  }
@@ -51,41 +79,73 @@ export class ModelCapabilityService {
51
79
  model: opts.model,
52
80
  state: "unsupported",
53
81
  reason: classification.reason,
54
- ttlDays: this.UNSUPPORTED_TTL_DAYS,
82
+ ttlMs: this.UNSUPPORTED_TTL_MS,
83
+ preloadedMap: map,
84
+ failureCount,
85
+ lastFailureAt: now.toISOString(),
86
+ evidence: classification.evidence,
55
87
  });
56
88
  return "unsupported";
57
89
  }
58
- static async writeCapability(opts) {
59
- const { supabase, userId, provider, model, state, reason, ttlDays } = opts;
60
- const { data, error: readErr } = await supabase
90
+ static async readCapabilityMap(supabase, userId) {
91
+ const { data, error } = await supabase
61
92
  .from("user_settings")
62
93
  .select("vision_model_capabilities")
63
94
  .eq("user_id", userId)
64
95
  .maybeSingle();
65
- if (readErr) {
66
- logger.warn("Failed to read user_settings for model capability write", { userId, readErr });
96
+ if (error) {
97
+ logger.warn("Failed to read user_settings for model capability", { userId, error });
98
+ return null;
99
+ }
100
+ return this.normalizeCapabilityMap(data?.vision_model_capabilities);
101
+ }
102
+ static async persistCapabilityMap(supabase, userId, map) {
103
+ const { error } = await supabase
104
+ .from("user_settings")
105
+ .upsert({
106
+ user_id: userId,
107
+ vision_model_capabilities: map,
108
+ }, { onConflict: "user_id" });
109
+ if (error) {
110
+ logger.warn("Failed to persist model capability state", { userId, error });
111
+ return false;
112
+ }
113
+ return true;
114
+ }
115
+ static async writeCapability(opts) {
116
+ const { supabase, userId, provider, model, state, reason, ttlMs, preloadedMap, failureCount, lastFailureAt, evidence, } = opts;
117
+ const map = preloadedMap ?? (await this.readCapabilityMap(supabase, userId));
118
+ if (!map) {
67
119
  return;
68
120
  }
69
- const map = this.normalizeCapabilityMap(data?.vision_model_capabilities);
70
121
  const now = new Date();
71
- const expiresAt = new Date(now.getTime() + ttlDays * 24 * 60 * 60 * 1000).toISOString();
72
- map[this.capabilityKey(provider, model)] = {
122
+ const key = this.capabilityKey(provider, model);
123
+ const nextEntry = {
73
124
  state,
74
125
  learned_at: now.toISOString(),
75
- expires_at: expiresAt,
126
+ expires_at: new Date(now.getTime() + ttlMs).toISOString(),
76
127
  reason,
77
128
  };
78
- const { error: writeErr } = await supabase
79
- .from("user_settings")
80
- .upsert({
81
- user_id: userId,
82
- vision_model_capabilities: map,
83
- }, { onConflict: "user_id" });
84
- if (writeErr) {
85
- logger.warn("Failed to persist model capability state", { userId, provider, model, state, writeErr });
129
+ if (typeof failureCount === "number" && Number.isFinite(failureCount) && failureCount > 0) {
130
+ nextEntry.failure_count = Math.floor(failureCount);
131
+ }
132
+ if (typeof lastFailureAt === "string") {
133
+ nextEntry.last_failure_at = lastFailureAt;
134
+ }
135
+ if (Array.isArray(evidence) && evidence.length > 0) {
136
+ nextEntry.evidence = evidence.slice(0, 5);
137
+ }
138
+ map[key] = nextEntry;
139
+ const persisted = await this.persistCapabilityMap(supabase, userId, map);
140
+ if (!persisted) {
86
141
  return;
87
142
  }
88
- logger.info(`Updated model capability for ${provider}/${model}: ${state}`, { reason, ttlDays });
143
+ logger.info(`Updated model capability for ${provider}/${model}: ${state}`, {
144
+ reason,
145
+ ttlMs,
146
+ failureCount,
147
+ evidence: nextEntry.evidence,
148
+ });
89
149
  }
90
150
  static normalizeCapabilityMap(rawMap) {
91
151
  if (!rawMap || typeof rawMap !== "object" || Array.isArray(rawMap)) {
@@ -97,55 +157,206 @@ export class ModelCapabilityService {
97
157
  if (!value || typeof value !== "object" || Array.isArray(value)) {
98
158
  continue;
99
159
  }
100
- const state = String(value.state || "");
101
- if (state !== "supported" && state !== "unsupported") {
160
+ const record = value;
161
+ const state = String(record.state || "");
162
+ if (state !== "supported" && state !== "unsupported" && state !== "pending_unsupported") {
102
163
  continue;
103
164
  }
104
- const learnedAt = value.learned_at;
105
- const expiresAt = value.expires_at;
106
- const reason = value.reason;
107
- normalized[key] = {
165
+ const learnedAt = record.learned_at;
166
+ const expiresAt = record.expires_at;
167
+ const reason = record.reason;
168
+ const failureCount = record.failure_count;
169
+ const lastFailureAt = record.last_failure_at;
170
+ const evidence = record.evidence;
171
+ const normalizedEntry = {
108
172
  state,
109
173
  learned_at: typeof learnedAt === "string" ? learnedAt : new Date(0).toISOString(),
110
174
  expires_at: typeof expiresAt === "string" ? expiresAt : undefined,
111
175
  reason: typeof reason === "string" ? reason : undefined,
112
176
  };
177
+ if (typeof failureCount === "number" && Number.isFinite(failureCount) && failureCount > 0) {
178
+ normalizedEntry.failure_count = Math.floor(failureCount);
179
+ }
180
+ if (typeof lastFailureAt === "string") {
181
+ normalizedEntry.last_failure_at = lastFailureAt;
182
+ }
183
+ if (Array.isArray(evidence)) {
184
+ normalizedEntry.evidence = evidence
185
+ .filter((item) => typeof item === "string")
186
+ .map((item) => item.trim())
187
+ .filter((item) => item.length > 0)
188
+ .slice(0, 5);
189
+ }
190
+ normalized[key] = normalizedEntry;
113
191
  }
114
192
  return normalized;
115
193
  }
116
194
  static capabilityKey(provider, model) {
117
195
  return `${provider.toLowerCase().trim()}:${model.toLowerCase().trim()}`;
118
196
  }
119
- static classifyVisionFailure(error) {
120
- const message = this.errorToMessage(error).toLowerCase();
121
- if (!message)
122
- return { isCapabilityError: false, reason: "empty_error" };
123
- const capabilityHints = [
124
- "image_url",
125
- "vision",
126
- "multimodal",
127
- "multi-modal",
128
- "unsupported content type",
129
- "unsupported message content",
130
- "does not support images",
131
- "model does not support image",
132
- "invalid content type",
133
- "invalid image",
134
- "unrecognized content type",
135
- "invalid model", // e.g. text-only models fed image payloads in realtimexai provider
136
- ];
137
- if (capabilityHints.some((hint) => message.includes(hint))) {
138
- return { isCapabilityError: true, reason: "capability_mismatch" };
197
+ static isExpired(entry) {
198
+ if (!entry.expires_at)
199
+ return false;
200
+ const expiryTs = Date.parse(entry.expires_at);
201
+ return Number.isFinite(expiryTs) && expiryTs <= Date.now();
202
+ }
203
+ static nextFailureCount(entry, nowTs) {
204
+ if (!entry || entry.state !== "pending_unsupported" || this.isExpired(entry)) {
205
+ return 1;
206
+ }
207
+ const lastFailureTs = entry.last_failure_at ? Date.parse(entry.last_failure_at) : Number.NaN;
208
+ if (!Number.isFinite(lastFailureTs)) {
209
+ return 1;
210
+ }
211
+ if (nowTs - lastFailureTs > this.UNSUPPORTED_CONFIRMATION_WINDOW_MS) {
212
+ return 1;
139
213
  }
140
- const transientHints = [
214
+ const currentCount = typeof entry.failure_count === "number" && Number.isFinite(entry.failure_count)
215
+ ? Math.max(1, Math.floor(entry.failure_count))
216
+ : 1;
217
+ return currentCount + 1;
218
+ }
219
+ static classifyVisionFailure(opts) {
220
+ const signal = this.extractVisionFailureSignal(opts.error);
221
+ if (!signal.message && signal.codes.size === 0 && signal.statusCodes.size === 0) {
222
+ return { isCapabilityError: false, reason: "empty_error", score: 0, evidence: [] };
223
+ }
224
+ const transientEvidence = this.matchTransientOrAuth(signal);
225
+ if (transientEvidence.length > 0) {
226
+ return {
227
+ isCapabilityError: false,
228
+ reason: "transient_or_auth",
229
+ score: 0,
230
+ evidence: transientEvidence,
231
+ };
232
+ }
233
+ const documentEvidence = this.matchDocumentSpecific(signal);
234
+ if (documentEvidence.length > 0) {
235
+ return {
236
+ isCapabilityError: false,
237
+ reason: "document_specific_failure",
238
+ score: 0,
239
+ evidence: documentEvidence,
240
+ };
241
+ }
242
+ const capability = this.scoreCapabilitySignal(signal, opts.provider);
243
+ if (capability.score >= this.UNSUPPORTED_SCORE_THRESHOLD) {
244
+ return {
245
+ isCapabilityError: true,
246
+ reason: "capability_mismatch",
247
+ score: capability.score,
248
+ evidence: capability.evidence,
249
+ };
250
+ }
251
+ if (capability.score > 0) {
252
+ return {
253
+ isCapabilityError: false,
254
+ reason: "insufficient_capability_evidence",
255
+ score: capability.score,
256
+ evidence: capability.evidence,
257
+ };
258
+ }
259
+ return {
260
+ isCapabilityError: false,
261
+ reason: "unknown_error_class",
262
+ score: 0,
263
+ evidence: [],
264
+ };
265
+ }
266
+ static extractVisionFailureSignal(error) {
267
+ const messages = new Set();
268
+ const statusCodes = new Set();
269
+ const codes = new Set();
270
+ const pushMessage = (value) => {
271
+ if (typeof value !== "string")
272
+ return;
273
+ const normalized = value.trim().toLowerCase();
274
+ if (normalized)
275
+ messages.add(normalized);
276
+ };
277
+ const pushStatus = (value) => {
278
+ const parsed = typeof value === "number" ? value : Number(value);
279
+ if (!Number.isFinite(parsed) || parsed <= 0)
280
+ return;
281
+ statusCodes.add(Math.floor(parsed));
282
+ };
283
+ const pushCode = (value) => {
284
+ if (typeof value !== "string")
285
+ return;
286
+ const normalized = value.trim().toLowerCase();
287
+ if (!normalized)
288
+ return;
289
+ codes.add(normalized);
290
+ codes.add(normalized.replace(/[\s.-]+/g, "_"));
291
+ };
292
+ pushMessage(this.errorToMessage(error));
293
+ const queue = [{ value: error, depth: 0 }];
294
+ const visited = new Set();
295
+ while (queue.length > 0) {
296
+ const current = queue.shift();
297
+ if (!current || current.depth > 2) {
298
+ continue;
299
+ }
300
+ const { value, depth } = current;
301
+ if (!value || typeof value !== "object") {
302
+ continue;
303
+ }
304
+ if (visited.has(value)) {
305
+ continue;
306
+ }
307
+ visited.add(value);
308
+ const candidate = value;
309
+ pushMessage(candidate.message);
310
+ pushMessage(candidate.details);
311
+ pushMessage(candidate.error_description);
312
+ pushMessage(candidate.detail);
313
+ if (typeof candidate.error === "string") {
314
+ pushMessage(candidate.error);
315
+ }
316
+ pushStatus(candidate.status);
317
+ pushStatus(candidate.statusCode);
318
+ pushCode(candidate.code);
319
+ pushCode(candidate.type);
320
+ if (typeof candidate.error === "object") {
321
+ const nested = candidate.error;
322
+ pushCode(nested.code);
323
+ pushCode(nested.type);
324
+ pushStatus(nested.status);
325
+ pushMessage(nested.message);
326
+ }
327
+ for (const key of ["response", "data", "error", "cause"]) {
328
+ if (candidate[key] !== undefined) {
329
+ queue.push({ value: candidate[key], depth: depth + 1 });
330
+ }
331
+ }
332
+ }
333
+ return {
334
+ message: Array.from(messages).join(" | "),
335
+ statusCodes,
336
+ codes,
337
+ };
338
+ }
339
+ static matchTransientOrAuth(signal) {
340
+ const statusMatches = Array.from(signal.statusCodes).filter((status) => [401, 403, 408, 429, 500, 502, 503, 504].includes(status));
341
+ const codeMatches = this.matchCodes(signal.codes, [
342
+ "timeout",
343
+ "timed_out",
344
+ "rate_limit",
345
+ "too_many_requests",
346
+ "temporarily_unavailable",
347
+ "service_unavailable",
348
+ "network_error",
349
+ "connection_error",
350
+ "unauthorized",
351
+ "forbidden",
352
+ "invalid_api_key",
353
+ "insufficient_quota",
354
+ ]);
355
+ const messageMatches = this.matchMessage(signal.message, [
141
356
  "timeout",
142
357
  "timed out",
143
358
  "rate limit",
144
359
  "too many requests",
145
- "429",
146
- "503",
147
- "502",
148
- "504",
149
360
  "service unavailable",
150
361
  "temporar",
151
362
  "network",
@@ -153,11 +364,155 @@ export class ModelCapabilityService {
153
364
  "unauthorized",
154
365
  "forbidden",
155
366
  "invalid api key",
367
+ "insufficient quota",
368
+ "overloaded",
369
+ ]);
370
+ return [
371
+ ...statusMatches.map((status) => `status:${status}`),
372
+ ...codeMatches.map((match) => `code:${match}`),
373
+ ...messageMatches.map((match) => `msg:${match}`),
374
+ ];
375
+ }
376
+ static matchDocumentSpecific(signal) {
377
+ const codeMatches = this.matchCodes(signal.codes, [
378
+ "image_too_large",
379
+ "invalid_base64",
380
+ "invalid_image",
381
+ "invalid_image_data",
382
+ "malformed_image",
383
+ "invalid_image_url",
384
+ "image_decode_failed",
385
+ ]);
386
+ const messageMatches = this.matchMessage(signal.message, [
387
+ "image too large",
388
+ "invalid base64",
389
+ "malformed image",
390
+ "invalid image data",
391
+ "unable to decode image",
392
+ "failed to decode image",
393
+ "invalid image url",
394
+ ]);
395
+ const statusMatches = Array.from(signal.statusCodes).filter((status) => {
396
+ if (status === 413)
397
+ return true;
398
+ if (status === 415 || status === 422) {
399
+ return codeMatches.length > 0 || messageMatches.length > 0;
400
+ }
401
+ return false;
402
+ });
403
+ return [
404
+ ...statusMatches.map((status) => `status:${status}`),
405
+ ...codeMatches.map((match) => `code:${match}`),
406
+ ...messageMatches.map((match) => `msg:${match}`),
156
407
  ];
157
- if (transientHints.some((hint) => message.includes(hint))) {
158
- return { isCapabilityError: false, reason: "transient_or_auth" };
408
+ }
409
+ static scoreCapabilitySignal(signal, provider) {
410
+ const evidence = [];
411
+ let score = 0;
412
+ const explicitCapabilityCodes = this.matchCodes(signal.codes, [
413
+ "vision_not_supported",
414
+ "unsupported_vision",
415
+ "model_not_vision_capable",
416
+ "image_not_supported",
417
+ "unsupported_message_content",
418
+ "unsupported_content_type_for_model",
419
+ "unsupported_image_input",
420
+ "invalid_model_for_vision",
421
+ ]);
422
+ if (explicitCapabilityCodes.length > 0) {
423
+ score += 3;
424
+ evidence.push(...explicitCapabilityCodes.map((match) => `code:${match}`));
425
+ }
426
+ const highPrecisionMessageMatches = this.matchMessage(signal.message, [
427
+ "does not support images",
428
+ "does not support image inputs",
429
+ "model does not support image",
430
+ "this model cannot process images",
431
+ "text-only model",
432
+ "images are not supported for this model",
433
+ "vision is not supported for this model",
434
+ "vision is not supported",
435
+ "vision not supported",
436
+ "image_url is only supported by certain models",
437
+ ]);
438
+ if (highPrecisionMessageMatches.length > 0) {
439
+ score += 3;
440
+ evidence.push(...highPrecisionMessageMatches.map((match) => `msg:${match}`));
441
+ }
442
+ const providerSpecificMatches = this.matchMessage(signal.message, this.providerCapabilityHints(provider));
443
+ if (providerSpecificMatches.length > 0) {
444
+ score += 2;
445
+ evidence.push(...providerSpecificMatches.map((match) => `provider:${match}`));
446
+ }
447
+ const weakCapabilityHints = this.matchMessage(signal.message, [
448
+ "vision",
449
+ "unsupported content type",
450
+ "unsupported message content",
451
+ "invalid content type",
452
+ "unrecognized content type",
453
+ "image_url",
454
+ "multimodal",
455
+ "multi-modal",
456
+ ]);
457
+ const hasClientValidationStatus = Array.from(signal.statusCodes).some((status) => [400, 415, 422].includes(status));
458
+ if (weakCapabilityHints.length > 0 && hasClientValidationStatus) {
459
+ score += 1;
460
+ evidence.push(...weakCapabilityHints.map((match) => `weak:${match}`));
461
+ }
462
+ if (Array.from(signal.statusCodes).some((status) => status === 400 || status === 422)) {
463
+ score += 1;
464
+ evidence.push("status:client_validation");
465
+ }
466
+ return {
467
+ score,
468
+ evidence: Array.from(new Set(evidence)).slice(0, 8),
469
+ };
470
+ }
471
+ static providerCapabilityHints(provider) {
472
+ const normalized = provider.toLowerCase().trim();
473
+ if (normalized.includes("openai")) {
474
+ return [
475
+ "image_url is only supported by certain models",
476
+ "this model does not support image inputs",
477
+ ];
478
+ }
479
+ if (normalized.includes("anthropic")) {
480
+ return [
481
+ "only some claude models support vision",
482
+ "images are not supported for this model",
483
+ ];
484
+ }
485
+ if (normalized.includes("google") || normalized.includes("gemini")) {
486
+ return [
487
+ "model does not support multimodal input",
488
+ "unsupported input modality",
489
+ ];
490
+ }
491
+ if (normalized.includes("realtimex")) {
492
+ return [
493
+ "invalid model",
494
+ "text-only model",
495
+ ];
496
+ }
497
+ return [];
498
+ }
499
+ static matchMessage(message, hints) {
500
+ if (!message)
501
+ return [];
502
+ return hints.filter((hint) => message.includes(hint));
503
+ }
504
+ static matchCodes(codes, hints) {
505
+ const matches = [];
506
+ for (const code of codes) {
507
+ const normalizedCode = code.replace(/[\s.-]+/g, "_");
508
+ for (const hint of hints) {
509
+ if (normalizedCode === hint || normalizedCode.includes(hint)) {
510
+ matches.push(code);
511
+ break;
512
+ }
513
+ }
159
514
  }
160
- return { isCapabilityError: false, reason: "unknown_error_class" };
515
+ return matches;
161
516
  }
162
517
  static errorToMessage(error) {
163
518
  if (error instanceof Error)