@agentguard-run/spend 0.6.1 → 0.8.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/index.d.ts +3 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +20 -2
  5. package/dist/index.js.map +1 -1
  6. package/dist/outcomes/index.d.ts +6 -0
  7. package/dist/outcomes/index.d.ts.map +1 -0
  8. package/dist/outcomes/index.js +22 -0
  9. package/dist/outcomes/index.js.map +1 -0
  10. package/dist/outcomes/learning.d.ts +19 -0
  11. package/dist/outcomes/learning.d.ts.map +1 -0
  12. package/dist/outcomes/learning.js +104 -0
  13. package/dist/outcomes/learning.js.map +1 -0
  14. package/dist/outcomes/ledger.d.ts +42 -0
  15. package/dist/outcomes/ledger.d.ts.map +1 -0
  16. package/dist/outcomes/ledger.js +110 -0
  17. package/dist/outcomes/ledger.js.map +1 -0
  18. package/dist/outcomes/quality-gate.d.ts +12 -0
  19. package/dist/outcomes/quality-gate.d.ts.map +1 -0
  20. package/dist/outcomes/quality-gate.js +104 -0
  21. package/dist/outcomes/quality-gate.js.map +1 -0
  22. package/dist/outcomes/runtime.d.ts +21 -0
  23. package/dist/outcomes/runtime.d.ts.map +1 -0
  24. package/dist/outcomes/runtime.js +321 -0
  25. package/dist/outcomes/runtime.js.map +1 -0
  26. package/dist/outcomes/types.d.ts +147 -0
  27. package/dist/outcomes/types.d.ts.map +1 -0
  28. package/dist/outcomes/types.js +14 -0
  29. package/dist/outcomes/types.js.map +1 -0
  30. package/dist/router.d.ts +52 -0
  31. package/dist/router.d.ts.map +1 -0
  32. package/dist/router.js +563 -0
  33. package/dist/router.js.map +1 -0
  34. package/dist/spend-guard.d.ts +31 -0
  35. package/dist/spend-guard.d.ts.map +1 -1
  36. package/dist/spend-guard.js +158 -3
  37. package/dist/spend-guard.js.map +1 -1
  38. package/dist/types.d.ts +3 -1
  39. package/dist/types.d.ts.map +1 -1
  40. package/package.json +14 -2
  41. package/src/outcomes/index.ts +5 -0
  42. package/src/outcomes/learning.ts +133 -0
  43. package/src/outcomes/ledger.ts +131 -0
  44. package/src/outcomes/quality-gate.ts +116 -0
  45. package/src/outcomes/runtime.ts +388 -0
  46. package/src/outcomes/types.ts +167 -0
  47. package/src/router.ts +614 -0
package/src/router.ts ADDED
@@ -0,0 +1,614 @@
1
+ /**
2
+ * AgentGuard(TM) Spend: outcome model router.
3
+ *
4
+ * The router is local config-as-data. It never sends prompts, completions,
5
+ * provider keys, policies, or customer data to AgentGuard infrastructure.
6
+ *
7
+ * Patent notice: Protected by U.S. patent-pending technology
8
+ * (App. Nos. 63/983,615; 63/983,621; 63/983,843; 63/984,626;
9
+ * 64/071,781; 64/071,789).
10
+ */
11
+
12
+ import { getCachedCatalog, type OpenRouterCatalog } from './openrouter-catalog';
13
+
14
+ export interface RouterCatalogModel {
15
+ id: string;
16
+ in_per_mtok: number;
17
+ out_per_mtok: number;
18
+ context?: number;
19
+ image_capable?: boolean;
20
+ origin?: 'US' | 'EU' | 'CN';
21
+ }
22
+
23
+ export interface RouterDefault {
24
+ vertical: string;
25
+ outcome: string;
26
+ need: string;
27
+ drafter: string;
28
+ reviewer: string;
29
+ vision_model?: string;
30
+ allowed_models: readonly string[];
31
+ blocked_origins: readonly string[];
32
+ }
33
+
34
+ export interface RecommendOptions {
35
+ vertical?: string;
36
+ outcome?: string;
37
+ posture?: string;
38
+ budgetTier?: string;
39
+ budget_tier?: string;
40
+ imageCapable?: boolean;
41
+ catalog?: RouterCatalogModel[];
42
+ }
43
+
44
+ export interface ModelRouteRecommendation {
45
+ vertical: string;
46
+ outcome: string;
47
+ need: string;
48
+ posture: string;
49
+ budgetTier: string;
50
+ drafter: string;
51
+ reviewer: string;
52
+ visionModel: string | null;
53
+ blockedOrigins: string[];
54
+ allowedModels: string[];
55
+ }
56
+
57
+ const DEFAULTS = {
58
+ "version": 1,
59
+ "compliance_verticals": [
60
+ "law",
61
+ "accounting",
62
+ "insurance"
63
+ ],
64
+ "blocked_origin_prefixes": [
65
+ "deepseek/",
66
+ "qwen/",
67
+ "moonshotai/",
68
+ "01-ai/",
69
+ "z-ai/",
70
+ "baichuan/",
71
+ "stepfun/",
72
+ "bytedance/",
73
+ "ernie/"
74
+ ],
75
+ "defaults": {
76
+ "law_client_intake_drafter": {
77
+ "vertical": "law",
78
+ "outcome": "client_intake_drafter",
79
+ "need": "drafting",
80
+ "drafter": "openai/gpt-5-mini",
81
+ "reviewer": "anthropic/claude-sonnet-4.6",
82
+ "allowed_models": [
83
+ "openai/gpt-5-mini",
84
+ "openai/gpt-oss-120b",
85
+ "meta-llama/llama-3.3-70b-instruct",
86
+ "mistralai/mistral-small-3.2-24b-instruct",
87
+ "anthropic/claude-sonnet-4.6"
88
+ ],
89
+ "blocked_origins": [
90
+ "CN"
91
+ ]
92
+ },
93
+ "law_conflict_check_runner": {
94
+ "vertical": "law",
95
+ "outcome": "conflict_check_runner",
96
+ "need": "structured-reasoning",
97
+ "drafter": "openai/gpt-5-mini",
98
+ "reviewer": "openai/gpt-5.1",
99
+ "allowed_models": [
100
+ "openai/gpt-5-mini",
101
+ "openai/gpt-oss-120b",
102
+ "openai/gpt-4.1-nano",
103
+ "openai/gpt-5.1"
104
+ ],
105
+ "blocked_origins": [
106
+ "CN"
107
+ ]
108
+ },
109
+ "law_billable_hour_reconciler": {
110
+ "vertical": "law",
111
+ "outcome": "billable_hour_reconciler",
112
+ "need": "structured-reasoning",
113
+ "drafter": "openai/gpt-4.1-nano",
114
+ "reviewer": "openai/o4-mini",
115
+ "allowed_models": [
116
+ "openai/gpt-4.1-nano",
117
+ "openai/gpt-oss-120b",
118
+ "openai/o4-mini"
119
+ ],
120
+ "blocked_origins": [
121
+ "CN"
122
+ ]
123
+ },
124
+ "law_court_filing_deadline_tracker": {
125
+ "vertical": "law",
126
+ "outcome": "court_filing_deadline_tracker",
127
+ "need": "fast-classification",
128
+ "drafter": "openai/gpt-5-nano",
129
+ "reviewer": "openai/gpt-5-mini",
130
+ "allowed_models": [
131
+ "openai/gpt-5-nano",
132
+ "meta-llama/llama-3.1-8b-instruct",
133
+ "openai/gpt-5-mini"
134
+ ],
135
+ "blocked_origins": [
136
+ "CN"
137
+ ]
138
+ },
139
+ "law_discovery_doc_summarizer": {
140
+ "vertical": "law",
141
+ "outcome": "discovery_doc_summarizer",
142
+ "need": "long-context",
143
+ "drafter": "openai/gpt-4.1",
144
+ "reviewer": "anthropic/claude-sonnet-4.6",
145
+ "vision_model": "openai/gpt-4.1-mini",
146
+ "allowed_models": [
147
+ "openai/gpt-4.1",
148
+ "meta-llama/llama-4-scout",
149
+ "openai/gpt-4.1-mini",
150
+ "anthropic/claude-sonnet-4.6"
151
+ ],
152
+ "blocked_origins": [
153
+ "CN"
154
+ ]
155
+ },
156
+ "accounting_bank_reconciliation_drafter": {
157
+ "vertical": "accounting",
158
+ "outcome": "bank_reconciliation_drafter",
159
+ "need": "structured-reasoning",
160
+ "drafter": "openai/gpt-5-mini",
161
+ "reviewer": "openai/gpt-5.1",
162
+ "allowed_models": [
163
+ "openai/gpt-5-mini",
164
+ "openai/gpt-oss-120b",
165
+ "openai/gpt-4.1-nano",
166
+ "openai/gpt-5.1"
167
+ ],
168
+ "blocked_origins": [
169
+ "CN"
170
+ ]
171
+ },
172
+ "accounting_1099_prep_agent": {
173
+ "vertical": "accounting",
174
+ "outcome": "1099_prep_agent",
175
+ "need": "structured-reasoning",
176
+ "drafter": "openai/gpt-4.1-nano",
177
+ "reviewer": "openai/o4-mini",
178
+ "vision_model": "mistralai/mistral-small-3.2-24b-instruct",
179
+ "allowed_models": [
180
+ "openai/gpt-4.1-nano",
181
+ "openai/gpt-oss-120b",
182
+ "mistralai/mistral-small-3.2-24b-instruct",
183
+ "openai/o4-mini"
184
+ ],
185
+ "blocked_origins": [
186
+ "CN"
187
+ ]
188
+ },
189
+ "accounting_client_deliverable_drafter": {
190
+ "vertical": "accounting",
191
+ "outcome": "client_deliverable_drafter",
192
+ "need": "drafting",
193
+ "drafter": "anthropic/claude-haiku-4.5",
194
+ "reviewer": "anthropic/claude-sonnet-4.6",
195
+ "allowed_models": [
196
+ "anthropic/claude-haiku-4.5",
197
+ "openai/gpt-5-mini",
198
+ "meta-llama/llama-3.3-70b-instruct",
199
+ "anthropic/claude-sonnet-4.6"
200
+ ],
201
+ "blocked_origins": [
202
+ "CN"
203
+ ]
204
+ },
205
+ "accounting_audit_trail_compiler": {
206
+ "vertical": "accounting",
207
+ "outcome": "audit_trail_compiler",
208
+ "need": "long-context",
209
+ "drafter": "openai/gpt-4.1",
210
+ "reviewer": "anthropic/claude-sonnet-4.6",
211
+ "allowed_models": [
212
+ "openai/gpt-4.1",
213
+ "meta-llama/llama-4-scout",
214
+ "anthropic/claude-sonnet-4.6"
215
+ ],
216
+ "blocked_origins": [
217
+ "CN"
218
+ ]
219
+ },
220
+ "accounting_sales_tax_filing_draft": {
221
+ "vertical": "accounting",
222
+ "outcome": "sales_tax_filing_draft",
223
+ "need": "structured-reasoning",
224
+ "drafter": "openai/gpt-5-mini",
225
+ "reviewer": "openai/o4-mini",
226
+ "allowed_models": [
227
+ "openai/gpt-5-mini",
228
+ "openai/gpt-oss-120b",
229
+ "openai/gpt-4.1-nano",
230
+ "openai/o4-mini"
231
+ ],
232
+ "blocked_origins": [
233
+ "CN"
234
+ ]
235
+ },
236
+ "insurance_policy_renewal_drafter": {
237
+ "vertical": "insurance",
238
+ "outcome": "policy_renewal_drafter",
239
+ "need": "drafting",
240
+ "drafter": "openai/gpt-5-mini",
241
+ "reviewer": "anthropic/claude-sonnet-4.6",
242
+ "allowed_models": [
243
+ "openai/gpt-5-mini",
244
+ "openai/gpt-oss-120b",
245
+ "meta-llama/llama-3.3-70b-instruct",
246
+ "anthropic/claude-sonnet-4.6"
247
+ ],
248
+ "blocked_origins": [
249
+ "CN"
250
+ ]
251
+ },
252
+ "insurance_carrier_quote_comparison": {
253
+ "vertical": "insurance",
254
+ "outcome": "carrier_quote_comparison",
255
+ "need": "structured-reasoning",
256
+ "drafter": "openai/gpt-4.1-nano",
257
+ "reviewer": "openai/gpt-5.1",
258
+ "vision_model": "mistralai/mistral-small-3.2-24b-instruct",
259
+ "allowed_models": [
260
+ "openai/gpt-4.1-nano",
261
+ "openai/gpt-oss-120b",
262
+ "mistralai/mistral-small-3.2-24b-instruct",
263
+ "openai/gpt-5.1"
264
+ ],
265
+ "blocked_origins": [
266
+ "CN"
267
+ ]
268
+ },
269
+ "insurance_coi_issuance_agent": {
270
+ "vertical": "insurance",
271
+ "outcome": "coi_issuance_agent",
272
+ "need": "fast-classification",
273
+ "drafter": "openai/gpt-5-nano",
274
+ "reviewer": "openai/gpt-5-mini",
275
+ "vision_model": "mistralai/mistral-small-3.2-24b-instruct",
276
+ "allowed_models": [
277
+ "openai/gpt-5-nano",
278
+ "meta-llama/llama-3.1-8b-instruct",
279
+ "mistralai/mistral-small-3.2-24b-instruct",
280
+ "openai/gpt-5-mini"
281
+ ],
282
+ "blocked_origins": [
283
+ "CN"
284
+ ]
285
+ },
286
+ "insurance_claims_intake_drafter": {
287
+ "vertical": "insurance",
288
+ "outcome": "claims_intake_drafter",
289
+ "need": "drafting",
290
+ "drafter": "anthropic/claude-haiku-4.5",
291
+ "reviewer": "anthropic/claude-sonnet-4.6",
292
+ "vision_model": "amazon/nova-lite-v1",
293
+ "allowed_models": [
294
+ "anthropic/claude-haiku-4.5",
295
+ "openai/gpt-5-mini",
296
+ "amazon/nova-lite-v1",
297
+ "anthropic/claude-sonnet-4.6"
298
+ ],
299
+ "blocked_origins": [
300
+ "CN"
301
+ ]
302
+ },
303
+ "insurance_commission_statement_reconciler": {
304
+ "vertical": "insurance",
305
+ "outcome": "commission_statement_reconciler",
306
+ "need": "structured-reasoning",
307
+ "drafter": "openai/gpt-4.1-nano",
308
+ "reviewer": "openai/o4-mini",
309
+ "allowed_models": [
310
+ "openai/gpt-4.1-nano",
311
+ "openai/gpt-oss-120b",
312
+ "openai/o4-mini"
313
+ ],
314
+ "blocked_origins": [
315
+ "CN"
316
+ ]
317
+ },
318
+ "realestate_lease_renewal_drafter": {
319
+ "vertical": "realestate",
320
+ "outcome": "lease_renewal_drafter",
321
+ "need": "drafting",
322
+ "drafter": "openai/gpt-5-mini",
323
+ "reviewer": "anthropic/claude-sonnet-4.6",
324
+ "allowed_models": [
325
+ "openai/gpt-5-mini",
326
+ "openai/gpt-oss-120b",
327
+ "meta-llama/llama-3.3-70b-instruct",
328
+ "anthropic/claude-sonnet-4.6"
329
+ ],
330
+ "blocked_origins": []
331
+ },
332
+ "realestate_maintenance_request_triage": {
333
+ "vertical": "realestate",
334
+ "outcome": "maintenance_request_triage",
335
+ "need": "fast-classification",
336
+ "drafter": "meta-llama/llama-3.1-8b-instruct",
337
+ "reviewer": "openai/gpt-5-mini",
338
+ "allowed_models": [
339
+ "meta-llama/llama-3.1-8b-instruct",
340
+ "openai/gpt-5-nano",
341
+ "qwen/qwen3-30b-a3b",
342
+ "openai/gpt-5-mini"
343
+ ],
344
+ "blocked_origins": []
345
+ },
346
+ "realestate_listing_description_writer": {
347
+ "vertical": "realestate",
348
+ "outcome": "listing_description_writer",
349
+ "need": "drafting",
350
+ "drafter": "deepseek/deepseek-chat-v3.1",
351
+ "reviewer": "openai/gpt-5-mini",
352
+ "vision_model": "amazon/nova-lite-v1",
353
+ "allowed_models": [
354
+ "deepseek/deepseek-chat-v3.1",
355
+ "mistralai/mistral-small-3.2-24b-instruct",
356
+ "amazon/nova-lite-v1",
357
+ "openai/gpt-5-mini"
358
+ ],
359
+ "blocked_origins": []
360
+ },
361
+ "realestate_application_screening_agent": {
362
+ "vertical": "realestate",
363
+ "outcome": "application_screening_agent",
364
+ "need": "structured-reasoning",
365
+ "drafter": "openai/gpt-5-mini",
366
+ "reviewer": "openai/gpt-5.1",
367
+ "allowed_models": [
368
+ "openai/gpt-5-mini",
369
+ "openai/gpt-oss-120b",
370
+ "openai/gpt-5.1"
371
+ ],
372
+ "blocked_origins": [
373
+ "CN"
374
+ ]
375
+ },
376
+ "realestate_owner_statement_compiler": {
377
+ "vertical": "realestate",
378
+ "outcome": "owner_statement_compiler",
379
+ "need": "long-context",
380
+ "drafter": "openai/gpt-4.1-nano",
381
+ "reviewer": "openai/gpt-4.1",
382
+ "allowed_models": [
383
+ "openai/gpt-4.1-nano",
384
+ "meta-llama/llama-4-scout",
385
+ "openai/gpt-4.1"
386
+ ],
387
+ "blocked_origins": []
388
+ },
389
+ "ecommerce_refund_decision_agent": {
390
+ "vertical": "ecommerce",
391
+ "outcome": "refund_decision_agent",
392
+ "need": "structured-reasoning",
393
+ "drafter": "openai/gpt-5-nano",
394
+ "reviewer": "openai/gpt-5-mini",
395
+ "allowed_models": [
396
+ "openai/gpt-5-nano",
397
+ "openai/gpt-oss-120b",
398
+ "openai/gpt-5-mini"
399
+ ],
400
+ "blocked_origins": [
401
+ "CN"
402
+ ]
403
+ },
404
+ "ecommerce_inventory_reorder_agent": {
405
+ "vertical": "ecommerce",
406
+ "outcome": "inventory_reorder_agent",
407
+ "need": "structured-reasoning",
408
+ "drafter": "meta-llama/llama-3.3-70b-instruct",
409
+ "reviewer": "openai/gpt-5-mini",
410
+ "allowed_models": [
411
+ "meta-llama/llama-3.3-70b-instruct",
412
+ "openai/gpt-oss-120b",
413
+ "openai/gpt-5-mini"
414
+ ],
415
+ "blocked_origins": []
416
+ },
417
+ "ecommerce_chargeback_response_drafter": {
418
+ "vertical": "ecommerce",
419
+ "outcome": "chargeback_response_drafter",
420
+ "need": "drafting",
421
+ "drafter": "mistralai/mistral-small-3.2-24b-instruct",
422
+ "reviewer": "anthropic/claude-sonnet-4.6",
423
+ "allowed_models": [
424
+ "mistralai/mistral-small-3.2-24b-instruct",
425
+ "openai/gpt-oss-120b",
426
+ "anthropic/claude-sonnet-4.6"
427
+ ],
428
+ "blocked_origins": [
429
+ "CN"
430
+ ]
431
+ },
432
+ "ecommerce_customer_segmentation_refresher": {
433
+ "vertical": "ecommerce",
434
+ "outcome": "customer_segmentation_refresher",
435
+ "need": "fast-classification",
436
+ "drafter": "qwen/qwen3-30b-a3b",
437
+ "reviewer": "deepseek/deepseek-r1",
438
+ "allowed_models": [
439
+ "qwen/qwen3-30b-a3b",
440
+ "meta-llama/llama-3.1-8b-instruct",
441
+ "deepseek/deepseek-r1",
442
+ "openai/gpt-5-mini"
443
+ ],
444
+ "blocked_origins": []
445
+ },
446
+ "ecommerce_catalog_listing_optimizer": {
447
+ "vertical": "ecommerce",
448
+ "outcome": "catalog_listing_optimizer",
449
+ "need": "drafting",
450
+ "drafter": "deepseek/deepseek-chat-v3.1",
451
+ "reviewer": "openai/gpt-5-mini",
452
+ "vision_model": "amazon/nova-lite-v1",
453
+ "allowed_models": [
454
+ "deepseek/deepseek-chat-v3.1",
455
+ "mistralai/mistral-small-3.2-24b-instruct",
456
+ "amazon/nova-lite-v1",
457
+ "openai/gpt-5-mini"
458
+ ],
459
+ "blocked_origins": []
460
+ }
461
+ }
462
+ } as const;
463
+
464
+ const NEED_FALLBACKS: Record<string, string[]> = {
465
+ 'fast-classification': ['meta-llama/llama-3.1-8b-instruct', 'openai/gpt-5-nano', 'mistralai/mistral-nemo', 'qwen/qwen3-30b-a3b'],
466
+ drafting: ['mistralai/mistral-small-3.2-24b-instruct', 'meta-llama/llama-3.3-70b-instruct', 'openai/gpt-oss-120b', 'openai/gpt-5-mini', 'deepseek/deepseek-chat-v3.1', 'anthropic/claude-haiku-4.5'],
467
+ 'structured-reasoning': ['openai/gpt-oss-120b', 'openai/gpt-4.1-nano', 'openai/gpt-5-mini', 'qwen/qwen3-235b-a22b-2507', 'openai/o4-mini'],
468
+ 'long-context': ['meta-llama/llama-4-scout', 'openai/gpt-4.1-nano', 'openai/gpt-4.1', 'anthropic/claude-sonnet-4.6', 'deepseek/deepseek-v4-flash'],
469
+ 'vision-document': ['mistralai/mistral-small-3.2-24b-instruct', 'openai/gpt-4.1-mini', 'qwen/qwen3-vl-8b-instruct', 'qwen/qwen3-vl-32b-instruct', 'openai/gpt-5.1'],
470
+ photo: ['amazon/nova-lite-v1', 'qwen/qwen3-vl-8b-instruct', 'openai/gpt-5.1', 'anthropic/claude-sonnet-4.6'],
471
+ };
472
+
473
+ const REVIEWER_FALLBACKS = ['openai/gpt-5-mini', 'openai/o4-mini', 'openai/gpt-5.1', 'anthropic/claude-sonnet-4.6', 'deepseek/deepseek-r1', 'qwen/qwen3-235b-a22b'];
474
+ const TIER_CEILINGS: Record<string, number> = { free: 0.5, solo: 0.5, solo_pro: 0.5, startup: 2, startup_pro: 2, growth: Number.POSITIVE_INFINITY, growth_pro: Number.POSITIVE_INFINITY };
475
+ const REVIEWER_CEILINGS: Record<string, number> = { free: 2, solo: 2, solo_pro: 2, startup: 10, startup_pro: 10, growth: Number.POSITIVE_INFINITY, growth_pro: Number.POSITIVE_INFINITY };
476
+
477
+ export function recommend(options: RecommendOptions = {}): ModelRouteRecommendation {
478
+ const catalog = options.catalog ?? catalogFromOpenRouter(getCachedCatalog()) ?? fallbackCatalogFromDefaults();
479
+ const catalogById = new Map(catalog.map((model) => [model.id, model]));
480
+ const vertical = normalizeVertical(options.vertical);
481
+ const def = defaultFor(vertical, options.outcome);
482
+ const posture = (options.posture || (isComplianceVertical(vertical) ? 'compliance' : 'standard')).toLowerCase();
483
+ const budgetTier = (options.budgetTier || options.budget_tier || 'solo').toLowerCase();
484
+ const drafterCandidates = unique([...(def.allowed_models || []), ...(NEED_FALLBACKS[def.need] || []), def.drafter]);
485
+ const reviewerCandidates = unique([def.reviewer, ...REVIEWER_FALLBACKS]);
486
+ const drafter = pickModel(drafterCandidates, catalogById, def, posture, TIER_CEILINGS[budgetTier] ?? TIER_CEILINGS.solo) ?? def.drafter;
487
+ const reviewer = pickModel(reviewerCandidates, catalogById, def, posture, REVIEWER_CEILINGS[budgetTier] ?? REVIEWER_CEILINGS.solo) ?? def.reviewer;
488
+ const needsVision = Boolean(def.vision_model || options.imageCapable || def.need.startsWith('vision'));
489
+ const visionCandidates = unique([def.vision_model, ...(NEED_FALLBACKS['vision-document'] || []), ...(NEED_FALLBACKS.photo || [])]);
490
+ const visionModel = needsVision ? pickModel(visionCandidates, catalogById, def, posture, TIER_CEILINGS[budgetTier] ?? TIER_CEILINGS.solo, true) ?? def.vision_model ?? null : null;
491
+ return {
492
+ vertical,
493
+ outcome: def.outcome,
494
+ need: def.need,
495
+ posture,
496
+ budgetTier,
497
+ drafter,
498
+ reviewer,
499
+ visionModel,
500
+ blockedOrigins: posture === 'compliance' ? ['CN'] : [...def.blocked_origins],
501
+ allowedModels: drafterCandidates.filter((id) => { const model = catalogById.get(id); return Boolean(model && allowedByOrigin(model, def, posture)); }),
502
+ };
503
+ }
504
+
505
+ function defaultFor(vertical: string, outcome?: string): RouterDefault {
506
+ const key = defaultKey(vertical, outcome);
507
+ const rows = DEFAULTS.defaults as unknown as Record<string, RouterDefault>;
508
+ return rows[key] ?? Object.values(rows).find((row) => row.vertical === vertical) ?? rows.law_client_intake_drafter;
509
+ }
510
+
511
+ export function defaultKey(vertical: string, outcome?: string): string {
512
+ const clean = normalizeVertical(vertical);
513
+ const raw = String(outcome || '').trim();
514
+ const normalized = raw.startsWith(clean + '_') ? raw.slice(clean.length + 1) : slug(raw);
515
+ return clean + '_' + normalized;
516
+ }
517
+
518
+ function pickModel(ids: string[], catalog: Map<string, RouterCatalogModel>, def: RouterDefault, posture: string, ceiling: number, requireImage = false): string | null {
519
+ const rows = ids
520
+ .map((id) => catalog.get(id))
521
+ .filter((model): model is RouterCatalogModel => Boolean(model))
522
+ .filter((model) => allowedByOrigin(model, def, posture))
523
+ .filter((model) => !requireImage || model.image_capable === true);
524
+ const underCeiling = rows.filter((model) => blended(model) <= ceiling);
525
+ const pool = underCeiling.length ? underCeiling : rows;
526
+ pool.sort((a, b) => blended(a) - blended(b) || a.id.localeCompare(b.id));
527
+ return pool[0]?.id ?? null;
528
+ }
529
+
530
+ function allowedByOrigin(model: RouterCatalogModel, def: RouterDefault, posture: string): boolean {
531
+ if (/^google\/gemini/i.test(model.id)) return false;
532
+ const blocksCn = posture === 'compliance' || def.blocked_origins.includes('CN');
533
+ return !(blocksCn && isCnOrigin(model));
534
+ }
535
+
536
+ function isCnOrigin(model: RouterCatalogModel): boolean {
537
+ return model.origin === 'CN' || /^(deepseek|qwen|moonshotai|01-ai|z-ai|baichuan|stepfun|bytedance|ernie)\//.test(model.id);
538
+ }
539
+
540
+ function blended(model: RouterCatalogModel): number {
541
+ return (model.in_per_mtok + model.out_per_mtok) / 2;
542
+ }
543
+
544
+ function normalizeVertical(value?: string): string {
545
+ const v = String(value || '').toLowerCase().replace(/[-_ ]/g, '');
546
+ if (v === 'realestate') return 'realestate';
547
+ return ['law', 'accounting', 'insurance', 'ecommerce'].includes(v) ? v : 'law';
548
+ }
549
+
550
+ function isComplianceVertical(vertical: string): boolean {
551
+ return (DEFAULTS.compliance_verticals as readonly string[]).includes(vertical);
552
+ }
553
+
554
+ function slug(value: string): string {
555
+ return value.toLowerCase().replace(/&/g, 'and').replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
556
+ }
557
+
558
+ function unique(values: Array<string | undefined>): string[] {
559
+ return [...new Set(values.filter((value): value is string => Boolean(value)))];
560
+ }
561
+
562
+ function catalogFromOpenRouter(catalog: OpenRouterCatalog | null): RouterCatalogModel[] | null {
563
+ if (!catalog?.data?.length) return null;
564
+ return catalog.data.map((model) => ({
565
+ id: model.id,
566
+ in_per_mtok: Number(model.pricing?.prompt || 0) * 1e6,
567
+ out_per_mtok: Number(model.pricing?.completion || 0) * 1e6,
568
+ context: model.context_length || model.top_provider?.context_length || 0,
569
+ image_capable: Array.isArray((model.architecture as { input_modalities?: unknown } | undefined)?.input_modalities)
570
+ ? ((model.architecture as { input_modalities?: string[] }).input_modalities || []).includes('image')
571
+ : false,
572
+ origin: (/^(deepseek|qwen|moonshotai|01-ai|z-ai|baichuan|stepfun|bytedance|ernie)\//.test(model.id) ? 'CN' : /^mistralai\//.test(model.id) ? 'EU' : 'US') as 'US' | 'EU' | 'CN',
573
+ })).filter((model) => Number.isFinite(model.in_per_mtok) && Number.isFinite(model.out_per_mtok));
574
+ }
575
+
576
+ function fallbackCatalogFromDefaults(): RouterCatalogModel[] {
577
+ const ids = new Set<string>();
578
+ for (const row of Object.values(DEFAULTS.defaults as unknown as Record<string, RouterDefault>)) {
579
+ ids.add(row.drafter);
580
+ ids.add(row.reviewer);
581
+ if (row.vision_model) ids.add(row.vision_model);
582
+ for (const id of row.allowed_models) ids.add(id);
583
+ }
584
+ return [...ids].map((id) => ({ id, in_per_mtok: fallbackPrice(id)[0], out_per_mtok: fallbackPrice(id)[1], image_capable: /nova|vl|vision|mistral-small|gpt-4\.1-mini|gpt-5\.1/.test(id), origin: /^(deepseek|qwen|moonshotai|01-ai|z-ai|baichuan|stepfun|bytedance|ernie)\//.test(id) ? 'CN' : /^mistralai\//.test(id) ? 'EU' : 'US' }));
585
+ }
586
+
587
+ function fallbackPrice(id: string): [number, number] {
588
+ const table: Record<string, [number, number]> = {
589
+ 'openai/gpt-5-nano': [0.05, 0.4],
590
+ 'openai/gpt-4.1-nano': [0.1, 0.4],
591
+ 'openai/gpt-5-mini': [0.25, 2],
592
+ 'openai/gpt-oss-120b': [0.039, 0.18],
593
+ 'openai/o4-mini': [1.1, 4.4],
594
+ 'openai/gpt-5.1': [1.25, 10],
595
+ 'openai/gpt-4.1': [2, 8],
596
+ 'openai/gpt-4.1-mini': [0.4, 1.6],
597
+ 'anthropic/claude-haiku-4.5': [1, 5],
598
+ 'anthropic/claude-sonnet-4.6': [3, 15],
599
+ 'meta-llama/llama-3.1-8b-instruct': [0.02, 0.05],
600
+ 'meta-llama/llama-3.3-70b-instruct': [0.1, 0.32],
601
+ 'meta-llama/llama-4-scout': [0.08, 0.3],
602
+ 'mistralai/mistral-small-3.2-24b-instruct': [0.075, 0.2],
603
+ 'mistralai/mistral-nemo': [0.02, 0.03],
604
+ 'amazon/nova-lite-v1': [0.06, 0.24],
605
+ 'deepseek/deepseek-chat-v3.1': [0.21, 0.79],
606
+ 'deepseek/deepseek-r1': [0.7, 2.5],
607
+ 'qwen/qwen3-30b-a3b': [0.09, 0.45],
608
+ 'qwen/qwen3-235b-a22b-2507': [0.071, 0.1],
609
+ 'qwen/qwen3-235b-a22b': [0.455, 1.82],
610
+ 'qwen/qwen3-vl-8b-instruct': [0.08, 0.5],
611
+ 'qwen/qwen3-vl-32b-instruct': [0.104, 0.416],
612
+ };
613
+ return table[id] || [1, 5];
614
+ }