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