@realtimex/folio 0.1.10 → 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.
|
@@ -5,12 +5,16 @@ import { SDKService } from "./SDKService.js";
|
|
|
5
5
|
const logger = createLogger("ModelCapabilityService");
|
|
6
6
|
|
|
7
7
|
export type VisionCapabilityState = "supported" | "unsupported" | "unknown";
|
|
8
|
+
type StoredVisionCapabilityState = "supported" | "unsupported" | "pending_unsupported";
|
|
8
9
|
|
|
9
10
|
interface StoredVisionCapability {
|
|
10
|
-
state:
|
|
11
|
+
state: StoredVisionCapabilityState;
|
|
11
12
|
learned_at: string;
|
|
12
13
|
expires_at?: string;
|
|
13
14
|
reason?: string;
|
|
15
|
+
failure_count?: number;
|
|
16
|
+
last_failure_at?: string;
|
|
17
|
+
evidence?: string[];
|
|
14
18
|
}
|
|
15
19
|
|
|
16
20
|
type VisionCapabilityMap = Record<string, StoredVisionCapability>;
|
|
@@ -28,9 +32,26 @@ export interface VisionResolution {
|
|
|
28
32
|
shouldAttempt: boolean;
|
|
29
33
|
}
|
|
30
34
|
|
|
35
|
+
interface VisionFailureSignal {
|
|
36
|
+
message: string;
|
|
37
|
+
statusCodes: Set<number>;
|
|
38
|
+
codes: Set<string>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface VisionFailureClassification {
|
|
42
|
+
isCapabilityError: boolean;
|
|
43
|
+
reason: string;
|
|
44
|
+
score: number;
|
|
45
|
+
evidence: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
31
48
|
export class ModelCapabilityService {
|
|
32
|
-
private static readonly
|
|
33
|
-
private static readonly
|
|
49
|
+
private static readonly SUPPORTED_TTL_MS = 180 * 24 * 60 * 60 * 1000;
|
|
50
|
+
private static readonly UNSUPPORTED_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
51
|
+
private static readonly PENDING_UNSUPPORTED_TTL_MS = 24 * 60 * 60 * 1000;
|
|
52
|
+
private static readonly UNSUPPORTED_CONFIRMATION_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
53
|
+
private static readonly UNSUPPORTED_CONFIRMATION_FAILURES = 2;
|
|
54
|
+
private static readonly UNSUPPORTED_SCORE_THRESHOLD = 3;
|
|
34
55
|
|
|
35
56
|
static resolveVisionSupport(settingsRow: SettingsLike | null | undefined): VisionResolution {
|
|
36
57
|
const provider = (settingsRow?.llm_provider || SDKService.DEFAULT_LLM_PROVIDER).trim();
|
|
@@ -47,15 +68,8 @@ export class ModelCapabilityService {
|
|
|
47
68
|
static getVisionState(rawMap: unknown, provider: string, model: string): VisionCapabilityState {
|
|
48
69
|
const map = this.normalizeCapabilityMap(rawMap);
|
|
49
70
|
const entry = map[this.capabilityKey(provider, model)];
|
|
50
|
-
if (!entry) return "unknown";
|
|
51
|
-
|
|
52
|
-
if (entry.expires_at) {
|
|
53
|
-
const expiryTs = Date.parse(entry.expires_at);
|
|
54
|
-
if (Number.isFinite(expiryTs) && expiryTs <= Date.now()) {
|
|
55
|
-
return "unknown";
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
71
|
+
if (!entry || this.isExpired(entry)) return "unknown";
|
|
72
|
+
if (entry.state === "pending_unsupported") return "unknown";
|
|
59
73
|
return entry.state;
|
|
60
74
|
}
|
|
61
75
|
|
|
@@ -69,7 +83,7 @@ export class ModelCapabilityService {
|
|
|
69
83
|
...opts,
|
|
70
84
|
state: "supported",
|
|
71
85
|
reason: "vision_request_succeeded",
|
|
72
|
-
|
|
86
|
+
ttlMs: this.SUPPORTED_TTL_MS,
|
|
73
87
|
});
|
|
74
88
|
}
|
|
75
89
|
|
|
@@ -80,10 +94,42 @@ export class ModelCapabilityService {
|
|
|
80
94
|
model: string;
|
|
81
95
|
error: unknown;
|
|
82
96
|
}): Promise<VisionCapabilityState> {
|
|
83
|
-
const classification = this.classifyVisionFailure(
|
|
97
|
+
const classification = this.classifyVisionFailure({
|
|
98
|
+
error: opts.error,
|
|
99
|
+
provider: opts.provider,
|
|
100
|
+
});
|
|
101
|
+
|
|
84
102
|
if (!classification.isCapabilityError) {
|
|
85
|
-
logger.info(`Vision failure for ${opts.provider}/${opts.model} treated as
|
|
103
|
+
logger.info(`Vision failure for ${opts.provider}/${opts.model} treated as non-capability; leaving capability unknown`, {
|
|
86
104
|
reason: classification.reason,
|
|
105
|
+
score: classification.score,
|
|
106
|
+
evidence: classification.evidence,
|
|
107
|
+
});
|
|
108
|
+
return "unknown";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const map = await this.readCapabilityMap(opts.supabase, opts.userId);
|
|
112
|
+
if (!map) {
|
|
113
|
+
return "unknown";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const key = this.capabilityKey(opts.provider, opts.model);
|
|
117
|
+
const now = new Date();
|
|
118
|
+
const failureCount = this.nextFailureCount(map[key], now.getTime());
|
|
119
|
+
|
|
120
|
+
if (failureCount < this.UNSUPPORTED_CONFIRMATION_FAILURES) {
|
|
121
|
+
await this.writeCapability({
|
|
122
|
+
supabase: opts.supabase,
|
|
123
|
+
userId: opts.userId,
|
|
124
|
+
provider: opts.provider,
|
|
125
|
+
model: opts.model,
|
|
126
|
+
state: "pending_unsupported",
|
|
127
|
+
reason: "capability_signal_pending_confirmation",
|
|
128
|
+
ttlMs: this.PENDING_UNSUPPORTED_TTL_MS,
|
|
129
|
+
preloadedMap: map,
|
|
130
|
+
failureCount,
|
|
131
|
+
lastFailureAt: now.toISOString(),
|
|
132
|
+
evidence: classification.evidence,
|
|
87
133
|
});
|
|
88
134
|
return "unknown";
|
|
89
135
|
}
|
|
@@ -95,43 +141,33 @@ export class ModelCapabilityService {
|
|
|
95
141
|
model: opts.model,
|
|
96
142
|
state: "unsupported",
|
|
97
143
|
reason: classification.reason,
|
|
98
|
-
|
|
144
|
+
ttlMs: this.UNSUPPORTED_TTL_MS,
|
|
145
|
+
preloadedMap: map,
|
|
146
|
+
failureCount,
|
|
147
|
+
lastFailureAt: now.toISOString(),
|
|
148
|
+
evidence: classification.evidence,
|
|
99
149
|
});
|
|
150
|
+
|
|
100
151
|
return "unsupported";
|
|
101
152
|
}
|
|
102
153
|
|
|
103
|
-
private static async
|
|
104
|
-
supabase
|
|
105
|
-
userId: string;
|
|
106
|
-
provider: string;
|
|
107
|
-
model: string;
|
|
108
|
-
state: "supported" | "unsupported";
|
|
109
|
-
reason: string;
|
|
110
|
-
ttlDays: number;
|
|
111
|
-
}): Promise<void> {
|
|
112
|
-
const { supabase, userId, provider, model, state, reason, ttlDays } = opts;
|
|
113
|
-
const { data, error: readErr } = await supabase
|
|
154
|
+
private static async readCapabilityMap(supabase: SupabaseClient, userId: string): Promise<VisionCapabilityMap | null> {
|
|
155
|
+
const { data, error } = await supabase
|
|
114
156
|
.from("user_settings")
|
|
115
157
|
.select("vision_model_capabilities")
|
|
116
158
|
.eq("user_id", userId)
|
|
117
159
|
.maybeSingle();
|
|
118
160
|
|
|
119
|
-
if (
|
|
120
|
-
logger.warn("Failed to read user_settings for model capability
|
|
121
|
-
return;
|
|
161
|
+
if (error) {
|
|
162
|
+
logger.warn("Failed to read user_settings for model capability", { userId, error });
|
|
163
|
+
return null;
|
|
122
164
|
}
|
|
123
165
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const expiresAt = new Date(now.getTime() + ttlDays * 24 * 60 * 60 * 1000).toISOString();
|
|
127
|
-
map[this.capabilityKey(provider, model)] = {
|
|
128
|
-
state,
|
|
129
|
-
learned_at: now.toISOString(),
|
|
130
|
-
expires_at: expiresAt,
|
|
131
|
-
reason,
|
|
132
|
-
};
|
|
166
|
+
return this.normalizeCapabilityMap(data?.vision_model_capabilities);
|
|
167
|
+
}
|
|
133
168
|
|
|
134
|
-
|
|
169
|
+
private static async persistCapabilityMap(supabase: SupabaseClient, userId: string, map: VisionCapabilityMap): Promise<boolean> {
|
|
170
|
+
const { error } = await supabase
|
|
135
171
|
.from("user_settings")
|
|
136
172
|
.upsert(
|
|
137
173
|
{
|
|
@@ -141,12 +177,81 @@ export class ModelCapabilityService {
|
|
|
141
177
|
{ onConflict: "user_id" }
|
|
142
178
|
);
|
|
143
179
|
|
|
144
|
-
if (
|
|
145
|
-
logger.warn("Failed to persist model capability state", { userId,
|
|
180
|
+
if (error) {
|
|
181
|
+
logger.warn("Failed to persist model capability state", { userId, error });
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private static async writeCapability(opts: {
|
|
189
|
+
supabase: SupabaseClient;
|
|
190
|
+
userId: string;
|
|
191
|
+
provider: string;
|
|
192
|
+
model: string;
|
|
193
|
+
state: StoredVisionCapabilityState;
|
|
194
|
+
reason: string;
|
|
195
|
+
ttlMs: number;
|
|
196
|
+
preloadedMap?: VisionCapabilityMap;
|
|
197
|
+
failureCount?: number;
|
|
198
|
+
lastFailureAt?: string;
|
|
199
|
+
evidence?: string[];
|
|
200
|
+
}): Promise<void> {
|
|
201
|
+
const {
|
|
202
|
+
supabase,
|
|
203
|
+
userId,
|
|
204
|
+
provider,
|
|
205
|
+
model,
|
|
206
|
+
state,
|
|
207
|
+
reason,
|
|
208
|
+
ttlMs,
|
|
209
|
+
preloadedMap,
|
|
210
|
+
failureCount,
|
|
211
|
+
lastFailureAt,
|
|
212
|
+
evidence,
|
|
213
|
+
} = opts;
|
|
214
|
+
|
|
215
|
+
const map = preloadedMap ?? (await this.readCapabilityMap(supabase, userId));
|
|
216
|
+
if (!map) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const now = new Date();
|
|
221
|
+
const key = this.capabilityKey(provider, model);
|
|
222
|
+
|
|
223
|
+
const nextEntry: StoredVisionCapability = {
|
|
224
|
+
state,
|
|
225
|
+
learned_at: now.toISOString(),
|
|
226
|
+
expires_at: new Date(now.getTime() + ttlMs).toISOString(),
|
|
227
|
+
reason,
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
if (typeof failureCount === "number" && Number.isFinite(failureCount) && failureCount > 0) {
|
|
231
|
+
nextEntry.failure_count = Math.floor(failureCount);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (typeof lastFailureAt === "string") {
|
|
235
|
+
nextEntry.last_failure_at = lastFailureAt;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (Array.isArray(evidence) && evidence.length > 0) {
|
|
239
|
+
nextEntry.evidence = evidence.slice(0, 5);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
map[key] = nextEntry;
|
|
243
|
+
|
|
244
|
+
const persisted = await this.persistCapabilityMap(supabase, userId, map);
|
|
245
|
+
if (!persisted) {
|
|
146
246
|
return;
|
|
147
247
|
}
|
|
148
248
|
|
|
149
|
-
logger.info(`Updated model capability for ${provider}/${model}: ${state}`, {
|
|
249
|
+
logger.info(`Updated model capability for ${provider}/${model}: ${state}`, {
|
|
250
|
+
reason,
|
|
251
|
+
ttlMs,
|
|
252
|
+
failureCount,
|
|
253
|
+
evidence: nextEntry.evidence,
|
|
254
|
+
});
|
|
150
255
|
}
|
|
151
256
|
|
|
152
257
|
private static normalizeCapabilityMap(rawMap: unknown): VisionCapabilityMap {
|
|
@@ -160,21 +265,44 @@ export class ModelCapabilityService {
|
|
|
160
265
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
161
266
|
continue;
|
|
162
267
|
}
|
|
163
|
-
|
|
164
|
-
|
|
268
|
+
|
|
269
|
+
const record = value as Record<string, unknown>;
|
|
270
|
+
const state = String(record.state || "");
|
|
271
|
+
if (state !== "supported" && state !== "unsupported" && state !== "pending_unsupported") {
|
|
165
272
|
continue;
|
|
166
273
|
}
|
|
167
274
|
|
|
168
|
-
const learnedAt =
|
|
169
|
-
const expiresAt =
|
|
170
|
-
const reason =
|
|
275
|
+
const learnedAt = record.learned_at;
|
|
276
|
+
const expiresAt = record.expires_at;
|
|
277
|
+
const reason = record.reason;
|
|
278
|
+
const failureCount = record.failure_count;
|
|
279
|
+
const lastFailureAt = record.last_failure_at;
|
|
280
|
+
const evidence = record.evidence;
|
|
171
281
|
|
|
172
|
-
|
|
282
|
+
const normalizedEntry: StoredVisionCapability = {
|
|
173
283
|
state,
|
|
174
284
|
learned_at: typeof learnedAt === "string" ? learnedAt : new Date(0).toISOString(),
|
|
175
285
|
expires_at: typeof expiresAt === "string" ? expiresAt : undefined,
|
|
176
286
|
reason: typeof reason === "string" ? reason : undefined,
|
|
177
287
|
};
|
|
288
|
+
|
|
289
|
+
if (typeof failureCount === "number" && Number.isFinite(failureCount) && failureCount > 0) {
|
|
290
|
+
normalizedEntry.failure_count = Math.floor(failureCount);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (typeof lastFailureAt === "string") {
|
|
294
|
+
normalizedEntry.last_failure_at = lastFailureAt;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (Array.isArray(evidence)) {
|
|
298
|
+
normalizedEntry.evidence = evidence
|
|
299
|
+
.filter((item): item is string => typeof item === "string")
|
|
300
|
+
.map((item) => item.trim())
|
|
301
|
+
.filter((item) => item.length > 0)
|
|
302
|
+
.slice(0, 5);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
normalized[key] = normalizedEntry;
|
|
178
306
|
}
|
|
179
307
|
|
|
180
308
|
return normalized;
|
|
@@ -184,47 +312,192 @@ export class ModelCapabilityService {
|
|
|
184
312
|
return `${provider.toLowerCase().trim()}:${model.toLowerCase().trim()}`;
|
|
185
313
|
}
|
|
186
314
|
|
|
187
|
-
private static
|
|
188
|
-
|
|
189
|
-
|
|
315
|
+
private static isExpired(entry: StoredVisionCapability): boolean {
|
|
316
|
+
if (!entry.expires_at) return false;
|
|
317
|
+
const expiryTs = Date.parse(entry.expires_at);
|
|
318
|
+
return Number.isFinite(expiryTs) && expiryTs <= Date.now();
|
|
319
|
+
}
|
|
190
320
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
];
|
|
321
|
+
private static nextFailureCount(entry: StoredVisionCapability | undefined, nowTs: number): number {
|
|
322
|
+
if (!entry || entry.state !== "pending_unsupported" || this.isExpired(entry)) {
|
|
323
|
+
return 1;
|
|
324
|
+
}
|
|
196
325
|
|
|
197
|
-
|
|
198
|
-
|
|
326
|
+
const lastFailureTs = entry.last_failure_at ? Date.parse(entry.last_failure_at) : Number.NaN;
|
|
327
|
+
if (!Number.isFinite(lastFailureTs)) {
|
|
328
|
+
return 1;
|
|
199
329
|
}
|
|
200
330
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
331
|
+
if (nowTs - lastFailureTs > this.UNSUPPORTED_CONFIRMATION_WINDOW_MS) {
|
|
332
|
+
return 1;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const currentCount = typeof entry.failure_count === "number" && Number.isFinite(entry.failure_count)
|
|
336
|
+
? Math.max(1, Math.floor(entry.failure_count))
|
|
337
|
+
: 1;
|
|
338
|
+
|
|
339
|
+
return currentCount + 1;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private static classifyVisionFailure(opts: { error: unknown; provider: string }): VisionFailureClassification {
|
|
343
|
+
const signal = this.extractVisionFailureSignal(opts.error);
|
|
344
|
+
if (!signal.message && signal.codes.size === 0 && signal.statusCodes.size === 0) {
|
|
345
|
+
return { isCapabilityError: false, reason: "empty_error", score: 0, evidence: [] };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const transientEvidence = this.matchTransientOrAuth(signal);
|
|
349
|
+
if (transientEvidence.length > 0) {
|
|
350
|
+
return {
|
|
351
|
+
isCapabilityError: false,
|
|
352
|
+
reason: "transient_or_auth",
|
|
353
|
+
score: 0,
|
|
354
|
+
evidence: transientEvidence,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const documentEvidence = this.matchDocumentSpecific(signal);
|
|
359
|
+
if (documentEvidence.length > 0) {
|
|
360
|
+
return {
|
|
361
|
+
isCapabilityError: false,
|
|
362
|
+
reason: "document_specific_failure",
|
|
363
|
+
score: 0,
|
|
364
|
+
evidence: documentEvidence,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const capability = this.scoreCapabilitySignal(signal, opts.provider);
|
|
369
|
+
if (capability.score >= this.UNSUPPORTED_SCORE_THRESHOLD) {
|
|
370
|
+
return {
|
|
371
|
+
isCapabilityError: true,
|
|
372
|
+
reason: "capability_mismatch",
|
|
373
|
+
score: capability.score,
|
|
374
|
+
evidence: capability.evidence,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (capability.score > 0) {
|
|
379
|
+
return {
|
|
380
|
+
isCapabilityError: false,
|
|
381
|
+
reason: "insufficient_capability_evidence",
|
|
382
|
+
score: capability.score,
|
|
383
|
+
evidence: capability.evidence,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
isCapabilityError: false,
|
|
389
|
+
reason: "unknown_error_class",
|
|
390
|
+
score: 0,
|
|
391
|
+
evidence: [],
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private static extractVisionFailureSignal(error: unknown): VisionFailureSignal {
|
|
396
|
+
const messages = new Set<string>();
|
|
397
|
+
const statusCodes = new Set<number>();
|
|
398
|
+
const codes = new Set<string>();
|
|
399
|
+
|
|
400
|
+
const pushMessage = (value: unknown): void => {
|
|
401
|
+
if (typeof value !== "string") return;
|
|
402
|
+
const normalized = value.trim().toLowerCase();
|
|
403
|
+
if (normalized) messages.add(normalized);
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const pushStatus = (value: unknown): void => {
|
|
407
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
408
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return;
|
|
409
|
+
statusCodes.add(Math.floor(parsed));
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const pushCode = (value: unknown): void => {
|
|
413
|
+
if (typeof value !== "string") return;
|
|
414
|
+
const normalized = value.trim().toLowerCase();
|
|
415
|
+
if (!normalized) return;
|
|
416
|
+
codes.add(normalized);
|
|
417
|
+
codes.add(normalized.replace(/[\s.-]+/g, "_"));
|
|
418
|
+
};
|
|
214
419
|
|
|
215
|
-
|
|
216
|
-
|
|
420
|
+
pushMessage(this.errorToMessage(error));
|
|
421
|
+
|
|
422
|
+
const queue: Array<{ value: unknown; depth: number }> = [{ value: error, depth: 0 }];
|
|
423
|
+
const visited = new Set<object>();
|
|
424
|
+
|
|
425
|
+
while (queue.length > 0) {
|
|
426
|
+
const current = queue.shift();
|
|
427
|
+
if (!current || current.depth > 2) {
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const { value, depth } = current;
|
|
432
|
+
if (!value || typeof value !== "object") {
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (visited.has(value)) {
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
visited.add(value);
|
|
440
|
+
|
|
441
|
+
const candidate = value as Record<string, unknown>;
|
|
442
|
+
pushMessage(candidate.message);
|
|
443
|
+
pushMessage(candidate.details);
|
|
444
|
+
pushMessage(candidate.error_description);
|
|
445
|
+
pushMessage(candidate.detail);
|
|
446
|
+
if (typeof candidate.error === "string") {
|
|
447
|
+
pushMessage(candidate.error);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
pushStatus(candidate.status);
|
|
451
|
+
pushStatus(candidate.statusCode);
|
|
452
|
+
pushCode(candidate.code);
|
|
453
|
+
pushCode(candidate.type);
|
|
454
|
+
if (typeof candidate.error === "object") {
|
|
455
|
+
const nested = candidate.error as Record<string, unknown>;
|
|
456
|
+
pushCode(nested.code);
|
|
457
|
+
pushCode(nested.type);
|
|
458
|
+
pushStatus(nested.status);
|
|
459
|
+
pushMessage(nested.message);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
for (const key of ["response", "data", "error", "cause"]) {
|
|
463
|
+
if (candidate[key] !== undefined) {
|
|
464
|
+
queue.push({ value: candidate[key], depth: depth + 1 });
|
|
465
|
+
}
|
|
466
|
+
}
|
|
217
467
|
}
|
|
218
468
|
|
|
219
|
-
|
|
469
|
+
return {
|
|
470
|
+
message: Array.from(messages).join(" | "),
|
|
471
|
+
statusCodes,
|
|
472
|
+
codes,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private static matchTransientOrAuth(signal: VisionFailureSignal): string[] {
|
|
477
|
+
const statusMatches = Array.from(signal.statusCodes).filter((status) =>
|
|
478
|
+
[401, 403, 408, 429, 500, 502, 503, 504].includes(status)
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
const codeMatches = this.matchCodes(signal.codes, [
|
|
482
|
+
"timeout",
|
|
483
|
+
"timed_out",
|
|
484
|
+
"rate_limit",
|
|
485
|
+
"too_many_requests",
|
|
486
|
+
"temporarily_unavailable",
|
|
487
|
+
"service_unavailable",
|
|
488
|
+
"network_error",
|
|
489
|
+
"connection_error",
|
|
490
|
+
"unauthorized",
|
|
491
|
+
"forbidden",
|
|
492
|
+
"invalid_api_key",
|
|
493
|
+
"insufficient_quota",
|
|
494
|
+
]);
|
|
495
|
+
|
|
496
|
+
const messageMatches = this.matchMessage(signal.message, [
|
|
220
497
|
"timeout",
|
|
221
498
|
"timed out",
|
|
222
499
|
"rate limit",
|
|
223
500
|
"too many requests",
|
|
224
|
-
"429",
|
|
225
|
-
"503",
|
|
226
|
-
"502",
|
|
227
|
-
"504",
|
|
228
501
|
"service unavailable",
|
|
229
502
|
"temporar",
|
|
230
503
|
"network",
|
|
@@ -232,13 +505,178 @@ export class ModelCapabilityService {
|
|
|
232
505
|
"unauthorized",
|
|
233
506
|
"forbidden",
|
|
234
507
|
"invalid api key",
|
|
508
|
+
"insufficient quota",
|
|
509
|
+
"overloaded",
|
|
510
|
+
]);
|
|
511
|
+
|
|
512
|
+
return [
|
|
513
|
+
...statusMatches.map((status) => `status:${status}`),
|
|
514
|
+
...codeMatches.map((match) => `code:${match}`),
|
|
515
|
+
...messageMatches.map((match) => `msg:${match}`),
|
|
235
516
|
];
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private static matchDocumentSpecific(signal: VisionFailureSignal): string[] {
|
|
520
|
+
const codeMatches = this.matchCodes(signal.codes, [
|
|
521
|
+
"image_too_large",
|
|
522
|
+
"invalid_base64",
|
|
523
|
+
"invalid_image",
|
|
524
|
+
"invalid_image_data",
|
|
525
|
+
"malformed_image",
|
|
526
|
+
"invalid_image_url",
|
|
527
|
+
"image_decode_failed",
|
|
528
|
+
]);
|
|
529
|
+
|
|
530
|
+
const messageMatches = this.matchMessage(signal.message, [
|
|
531
|
+
"image too large",
|
|
532
|
+
"invalid base64",
|
|
533
|
+
"malformed image",
|
|
534
|
+
"invalid image data",
|
|
535
|
+
"unable to decode image",
|
|
536
|
+
"failed to decode image",
|
|
537
|
+
"invalid image url",
|
|
538
|
+
]);
|
|
539
|
+
|
|
540
|
+
const statusMatches = Array.from(signal.statusCodes).filter((status) => {
|
|
541
|
+
if (status === 413) return true;
|
|
542
|
+
if (status === 415 || status === 422) {
|
|
543
|
+
return codeMatches.length > 0 || messageMatches.length > 0;
|
|
544
|
+
}
|
|
545
|
+
return false;
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
return [
|
|
549
|
+
...statusMatches.map((status) => `status:${status}`),
|
|
550
|
+
...codeMatches.map((match) => `code:${match}`),
|
|
551
|
+
...messageMatches.map((match) => `msg:${match}`),
|
|
552
|
+
];
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private static scoreCapabilitySignal(signal: VisionFailureSignal, provider: string): { score: number; evidence: string[] } {
|
|
556
|
+
const evidence: string[] = [];
|
|
557
|
+
let score = 0;
|
|
558
|
+
|
|
559
|
+
const explicitCapabilityCodes = this.matchCodes(signal.codes, [
|
|
560
|
+
"vision_not_supported",
|
|
561
|
+
"unsupported_vision",
|
|
562
|
+
"model_not_vision_capable",
|
|
563
|
+
"image_not_supported",
|
|
564
|
+
"unsupported_message_content",
|
|
565
|
+
"unsupported_content_type_for_model",
|
|
566
|
+
"unsupported_image_input",
|
|
567
|
+
"invalid_model_for_vision",
|
|
568
|
+
]);
|
|
569
|
+
|
|
570
|
+
if (explicitCapabilityCodes.length > 0) {
|
|
571
|
+
score += 3;
|
|
572
|
+
evidence.push(...explicitCapabilityCodes.map((match) => `code:${match}`));
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const highPrecisionMessageMatches = this.matchMessage(signal.message, [
|
|
576
|
+
"does not support images",
|
|
577
|
+
"does not support image inputs",
|
|
578
|
+
"model does not support image",
|
|
579
|
+
"this model cannot process images",
|
|
580
|
+
"text-only model",
|
|
581
|
+
"images are not supported for this model",
|
|
582
|
+
"vision is not supported for this model",
|
|
583
|
+
"vision is not supported",
|
|
584
|
+
"vision not supported",
|
|
585
|
+
"image_url is only supported by certain models",
|
|
586
|
+
]);
|
|
587
|
+
|
|
588
|
+
if (highPrecisionMessageMatches.length > 0) {
|
|
589
|
+
score += 3;
|
|
590
|
+
evidence.push(...highPrecisionMessageMatches.map((match) => `msg:${match}`));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const providerSpecificMatches = this.matchMessage(signal.message, this.providerCapabilityHints(provider));
|
|
594
|
+
if (providerSpecificMatches.length > 0) {
|
|
595
|
+
score += 2;
|
|
596
|
+
evidence.push(...providerSpecificMatches.map((match) => `provider:${match}`));
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const weakCapabilityHints = this.matchMessage(signal.message, [
|
|
600
|
+
"vision",
|
|
601
|
+
"unsupported content type",
|
|
602
|
+
"unsupported message content",
|
|
603
|
+
"invalid content type",
|
|
604
|
+
"unrecognized content type",
|
|
605
|
+
"image_url",
|
|
606
|
+
"multimodal",
|
|
607
|
+
"multi-modal",
|
|
608
|
+
]);
|
|
609
|
+
|
|
610
|
+
const hasClientValidationStatus = Array.from(signal.statusCodes).some((status) => [400, 415, 422].includes(status));
|
|
611
|
+
if (weakCapabilityHints.length > 0 && hasClientValidationStatus) {
|
|
612
|
+
score += 1;
|
|
613
|
+
evidence.push(...weakCapabilityHints.map((match) => `weak:${match}`));
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (Array.from(signal.statusCodes).some((status) => status === 400 || status === 422)) {
|
|
617
|
+
score += 1;
|
|
618
|
+
evidence.push("status:client_validation");
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
return {
|
|
622
|
+
score,
|
|
623
|
+
evidence: Array.from(new Set(evidence)).slice(0, 8),
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
private static providerCapabilityHints(provider: string): string[] {
|
|
628
|
+
const normalized = provider.toLowerCase().trim();
|
|
629
|
+
|
|
630
|
+
if (normalized.includes("openai")) {
|
|
631
|
+
return [
|
|
632
|
+
"image_url is only supported by certain models",
|
|
633
|
+
"this model does not support image inputs",
|
|
634
|
+
];
|
|
635
|
+
}
|
|
236
636
|
|
|
237
|
-
if (
|
|
238
|
-
return
|
|
637
|
+
if (normalized.includes("anthropic")) {
|
|
638
|
+
return [
|
|
639
|
+
"only some claude models support vision",
|
|
640
|
+
"images are not supported for this model",
|
|
641
|
+
];
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (normalized.includes("google") || normalized.includes("gemini")) {
|
|
645
|
+
return [
|
|
646
|
+
"model does not support multimodal input",
|
|
647
|
+
"unsupported input modality",
|
|
648
|
+
];
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (normalized.includes("realtimex")) {
|
|
652
|
+
return [
|
|
653
|
+
"invalid model",
|
|
654
|
+
"text-only model",
|
|
655
|
+
];
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return [];
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
private static matchMessage(message: string, hints: string[]): string[] {
|
|
662
|
+
if (!message) return [];
|
|
663
|
+
return hints.filter((hint) => message.includes(hint));
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
private static matchCodes(codes: Set<string>, hints: string[]): string[] {
|
|
667
|
+
const matches: string[] = [];
|
|
668
|
+
|
|
669
|
+
for (const code of codes) {
|
|
670
|
+
const normalizedCode = code.replace(/[\s.-]+/g, "_");
|
|
671
|
+
for (const hint of hints) {
|
|
672
|
+
if (normalizedCode === hint || normalizedCode.includes(hint)) {
|
|
673
|
+
matches.push(code);
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
239
677
|
}
|
|
240
678
|
|
|
241
|
-
return
|
|
679
|
+
return matches;
|
|
242
680
|
}
|
|
243
681
|
|
|
244
682
|
private static errorToMessage(error: unknown): string {
|