llm-cli-gateway 2.16.0 → 2.17.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 (61) hide show
  1. package/.agents/skills/least-cost-routing/SKILL.md +123 -0
  2. package/CHANGELOG.md +28 -0
  3. package/dist/acp/client.js +5 -0
  4. package/dist/acp/flight-redaction.d.ts +2 -0
  5. package/dist/acp/flight-redaction.js +2 -0
  6. package/dist/acp/runtime.d.ts +1 -0
  7. package/dist/acp/runtime.js +20 -0
  8. package/dist/acp/types.d.ts +52 -0
  9. package/dist/acp/types.js +8 -0
  10. package/dist/api-provider.d.ts +1 -0
  11. package/dist/api-provider.js +1 -0
  12. package/dist/async-job-manager.d.ts +16 -0
  13. package/dist/async-job-manager.js +70 -22
  14. package/dist/claude-mcp-config.js +1 -1
  15. package/dist/compressor/transforms/ansi.js +12 -2
  16. package/dist/config.d.ts +31 -0
  17. package/dist/config.js +142 -7
  18. package/dist/db.js +4 -4
  19. package/dist/doctor.d.ts +34 -1
  20. package/dist/doctor.js +85 -3
  21. package/dist/executor.d.ts +5 -0
  22. package/dist/executor.js +11 -1
  23. package/dist/flight-recorder.d.ts +10 -0
  24. package/dist/flight-recorder.js +68 -2
  25. package/dist/http-transport.js +19 -21
  26. package/dist/index.d.ts +41 -1
  27. package/dist/index.js +658 -30
  28. package/dist/job-store.d.ts +10 -2
  29. package/dist/job-store.js +154 -43
  30. package/dist/lcr-priors.d.ts +60 -0
  31. package/dist/lcr-priors.js +190 -0
  32. package/dist/lcr-router-env.d.ts +20 -0
  33. package/dist/lcr-router-env.js +133 -0
  34. package/dist/lcr-telemetry.d.ts +2 -0
  35. package/dist/lcr-telemetry.js +17 -0
  36. package/dist/least-cost-router.d.ts +86 -0
  37. package/dist/least-cost-router.js +296 -0
  38. package/dist/least-cost-types.d.ts +34 -0
  39. package/dist/least-cost-types.js +1 -0
  40. package/dist/migrate-sessions.js +1 -1
  41. package/dist/migrate.js +1 -1
  42. package/dist/model-registry.js +1 -1
  43. package/dist/postgres-job-store-worker.js +56 -13
  44. package/dist/pricing.d.ts +17 -0
  45. package/dist/pricing.js +167 -0
  46. package/dist/provider-definitions.d.ts +4 -0
  47. package/dist/provider-definitions.js +28 -0
  48. package/dist/request-helpers.d.ts +2 -2
  49. package/dist/resources.d.ts +37 -2
  50. package/dist/resources.js +96 -1
  51. package/dist/retry.d.ts +1 -0
  52. package/dist/retry.js +1 -1
  53. package/dist/token-estimator.d.ts +6 -0
  54. package/dist/token-estimator.js +59 -0
  55. package/dist/upstream-contracts.js +1 -1
  56. package/dist/validation-receipt.js +1 -1
  57. package/dist/validation-tools.d.ts +6 -0
  58. package/dist/validation-tools.js +174 -54
  59. package/npm-shrinkwrap.json +2 -2
  60. package/package.json +9 -4
  61. package/setup/status.schema.json +68 -0
@@ -3,8 +3,62 @@ import { CLI_TYPES } from "./session-manager.js";
3
3
  import { getAvailableCliInfo } from "./model-registry.js";
4
4
  import { apiProviderCatalogEntry } from "./api-request.js";
5
5
  import { getRequestContext, principalCanAccess, resolveOwnerPrincipal } from "./request-context.js";
6
+ import { PerformanceMetrics } from "./metrics.js";
7
+ import { loadLeastCostConfig } from "./config.js";
8
+ import { buildRouterEnv, toRouterConfig } from "./lcr-router-env.js";
9
+ import { selectCandidate, selectCheapestPerTier, } from "./least-cost-router.js";
6
10
  import { currentCaller, eagerMintFromJobId, eagerMintFromValidationId, resolveValidationReceipt, } from "./validation-receipt.js";
7
11
  import { collectValidationJobResult, startJudgeSynthesis, startValidationRun, } from "./validation-orchestrator.js";
12
+ const selectSchema = z
13
+ .enum(["cheapest", "cheapest_per_tier"])
14
+ .optional()
15
+ .describe("Optional least-cost routing: fill the provider target(s) from the LCR selector instead of the explicit list. 'cheapest' picks the single cheapest eligible provider; 'cheapest_per_tier' picks the cheapest in each quality tier. Requires [least_cost].enabled=true; fails closed (no default-list fallback) when disabled or nothing is eligible.");
16
+ function selectorFailureMessage(decision) {
17
+ const reasons = decision.rejected.map(r => `${r.candidate.provider}/${r.candidate.model}: ${r.reason}`);
18
+ const detail = reasons.length > 0 ? ` Rejections: ${reasons.join("; ")}.` : "";
19
+ return `least-cost routing found no eligible provider (${decision.error ?? "NoEligibleCandidate"}); no fallback to the default provider list (fail closed).${detail}`;
20
+ }
21
+ function resolveSelectedProviders(ctx, prompt, mode, singleProvider) {
22
+ if (!ctx.leastCost.enabled) {
23
+ return {
24
+ ok: false,
25
+ error: "select requires least-cost routing; set [least_cost].enabled=true in ~/.llm-cli-gateway/config.toml (no fallback to the default provider list).",
26
+ };
27
+ }
28
+ const env = buildRouterEnv({
29
+ performanceMetrics: ctx.performanceMetrics,
30
+ limiterSnapshot: ctx.asyncJobManager.getLimiterSnapshot(),
31
+ apiProviders: ctx.apiProviders,
32
+ preferCatalogPrice: ctx.leastCost.preferCatalogPrice,
33
+ flightRecorder: ctx.flightRecorder,
34
+ priorsScope: ctx.leastCost.priorsScope,
35
+ ownerPrincipal: resolveOwnerPrincipal(getRequestContext()),
36
+ });
37
+ const config = toRouterConfig(ctx.leastCost);
38
+ const req = { prompt };
39
+ if (singleProvider || mode === "cheapest") {
40
+ const decision = selectCandidate(req, env, config);
41
+ if (!decision.chosen)
42
+ return { ok: false, error: selectorFailureMessage(decision) };
43
+ return { ok: true, providers: [decision.chosen.provider] };
44
+ }
45
+ const decisions = selectCheapestPerTier(req, env, config);
46
+ const seen = new Set();
47
+ const providers = [];
48
+ for (const decision of decisions) {
49
+ if (!decision.chosen || seen.has(decision.chosen.provider))
50
+ continue;
51
+ seen.add(decision.chosen.provider);
52
+ providers.push(decision.chosen.provider);
53
+ }
54
+ if (providers.length === 0) {
55
+ return {
56
+ ok: false,
57
+ error: "least-cost routing found no eligible provider for select='cheapest_per_tier' (fail closed).",
58
+ };
59
+ }
60
+ return { ok: true, providers };
61
+ }
8
62
  export function buildValidationSchemas(deps) {
9
63
  const apiNames = (deps.apiProviders ?? []).map(p => p.name);
10
64
  const allowed = [...CLI_TYPES, ...apiNames];
@@ -57,6 +111,13 @@ function findHumanReadableReport(value) {
57
111
  }
58
112
  export function registerValidationTools(server, deps) {
59
113
  const { providerSchema, providerListSchema, normalizedProviderResultSchema } = buildValidationSchemas(deps);
114
+ const selectionContext = {
115
+ asyncJobManager: deps.asyncJobManager,
116
+ apiProviders: deps.apiProviders ?? [],
117
+ leastCost: deps.leastCost ?? loadLeastCostConfig(),
118
+ performanceMetrics: deps.performanceMetrics ?? new PerformanceMetrics(),
119
+ flightRecorder: deps.flightRecorder,
120
+ };
60
121
  server.tool("validate_with_models", "Ask two or more provider CLIs to independently validate a question. Starts validation jobs — poll with job_status, collect with job_result (not llm_job_*).", {
61
122
  question: z.string().min(1).describe("Question or content to validate."),
62
123
  models: providerListSchema.describe("Providers to ask. Defaults to Claude and Codex."),
@@ -67,45 +128,71 @@ export function registerValidationTools(server, deps) {
67
128
  judgeModel: providerSchema
68
129
  .optional()
69
130
  .describe("Optional provider to run an explicit judge synthesis job."),
131
+ select: selectSchema,
70
132
  }, {
71
133
  title: "Multi-model validation",
72
134
  readOnlyHint: false,
73
135
  destructiveHint: true,
74
136
  idempotentHint: false,
75
137
  openWorldHint: true,
76
- }, async ({ question, models, focus, judgeModel }) => textResponse({
77
- success: true,
78
- tool: "validate_with_models",
79
- readMostly: true,
80
- report: startValidationRun(deps, {
81
- intent: "validate",
82
- question,
83
- providers: models,
84
- focus,
85
- judgeProvider: judgeModel,
86
- }),
87
- }));
138
+ }, async ({ question, models, focus, judgeModel, select }) => {
139
+ let providers = models;
140
+ if (select) {
141
+ const resolved = resolveSelectedProviders(selectionContext, question, select, false);
142
+ if (!resolved.ok) {
143
+ return textResponse({
144
+ success: false,
145
+ tool: "validate_with_models",
146
+ error: resolved.error,
147
+ });
148
+ }
149
+ providers = resolved.providers;
150
+ }
151
+ return textResponse({
152
+ success: true,
153
+ tool: "validate_with_models",
154
+ readMostly: true,
155
+ report: startValidationRun(deps, {
156
+ intent: "validate",
157
+ question,
158
+ providers,
159
+ focus,
160
+ judgeProvider: judgeModel,
161
+ }),
162
+ });
163
+ });
88
164
  server.tool("second_opinion", "Ask one provider CLI to review an answer (starts a validation job; poll job_status, collect job_result).", {
89
165
  answer: z.string().min(1).describe("Answer to review."),
90
166
  question: z.string().optional().describe("Original question, if available."),
91
167
  model: providerSchema.default("codex").describe("Provider to ask for the second opinion."),
168
+ select: selectSchema,
92
169
  }, {
93
170
  title: "Second opinion",
94
171
  readOnlyHint: false,
95
172
  destructiveHint: true,
96
173
  idempotentHint: false,
97
174
  openWorldHint: true,
98
- }, async ({ answer, question, model }) => textResponse({
99
- success: true,
100
- tool: "second_opinion",
101
- readMostly: true,
102
- report: startValidationRun(deps, {
103
- intent: "second_opinion",
104
- question,
105
- content: answer,
106
- providers: [model],
107
- }),
108
- }));
175
+ }, async ({ answer, question, model, select }) => {
176
+ let providers = [model];
177
+ if (select) {
178
+ const resolved = resolveSelectedProviders(selectionContext, answer, select, true);
179
+ if (!resolved.ok) {
180
+ return textResponse({ success: false, tool: "second_opinion", error: resolved.error });
181
+ }
182
+ providers = resolved.providers;
183
+ }
184
+ return textResponse({
185
+ success: true,
186
+ tool: "second_opinion",
187
+ readMostly: true,
188
+ report: startValidationRun(deps, {
189
+ intent: "second_opinion",
190
+ question,
191
+ content: answer,
192
+ providers,
193
+ }),
194
+ });
195
+ });
109
196
  server.tool("compare_answers", "Summarize agreement/differences between caller-provided answers LOCALLY — does not call any provider.", {
110
197
  question: z.string().min(1).describe("Question the answers respond to."),
111
198
  answers: z.array(z.string().min(1)).min(2).describe("Two or more answers to compare."),
@@ -134,61 +221,94 @@ export function registerValidationTools(server, deps) {
134
221
  .default("normal")
135
222
  .describe("How aggressively to review."),
136
223
  models: providerListSchema.describe("Providers to ask for adversarial review."),
224
+ select: selectSchema,
137
225
  }, {
138
226
  title: "Red-team review",
139
227
  readOnlyHint: false,
140
228
  destructiveHint: true,
141
229
  idempotentHint: false,
142
230
  openWorldHint: true,
143
- }, async ({ content, riskLevel, models }) => textResponse({
144
- success: true,
145
- tool: "red_team_review",
146
- readMostly: true,
147
- report: startValidationRun(deps, {
148
- intent: "red_team",
149
- content,
150
- providers: models,
151
- riskLevel,
152
- }),
153
- }));
231
+ }, async ({ content, riskLevel, models, select }) => {
232
+ let providers = models;
233
+ if (select) {
234
+ const resolved = resolveSelectedProviders(selectionContext, content, select, false);
235
+ if (!resolved.ok) {
236
+ return textResponse({ success: false, tool: "red_team_review", error: resolved.error });
237
+ }
238
+ providers = resolved.providers;
239
+ }
240
+ return textResponse({
241
+ success: true,
242
+ tool: "red_team_review",
243
+ readMostly: true,
244
+ report: startValidationRun(deps, {
245
+ intent: "red_team",
246
+ content,
247
+ providers,
248
+ riskLevel,
249
+ }),
250
+ });
251
+ });
154
252
  server.tool("consensus_check", "Ask provider CLIs whether they agree or disagree with a claim (starts validation jobs).", {
155
253
  claim: z.string().min(1).describe("Claim to check across providers."),
156
254
  models: providerListSchema.describe("Providers to ask for agreement or disagreement."),
255
+ select: selectSchema,
157
256
  }, {
158
257
  title: "Consensus check",
159
258
  readOnlyHint: false,
160
259
  destructiveHint: true,
161
260
  idempotentHint: false,
162
261
  openWorldHint: true,
163
- }, async ({ claim, models }) => textResponse({
164
- success: true,
165
- tool: "consensus_check",
166
- readMostly: true,
167
- report: startValidationRun(deps, {
168
- intent: "consensus",
169
- content: claim,
170
- providers: models,
171
- }),
172
- }));
262
+ }, async ({ claim, models, select }) => {
263
+ let providers = models;
264
+ if (select) {
265
+ const resolved = resolveSelectedProviders(selectionContext, claim, select, false);
266
+ if (!resolved.ok) {
267
+ return textResponse({ success: false, tool: "consensus_check", error: resolved.error });
268
+ }
269
+ providers = resolved.providers;
270
+ }
271
+ return textResponse({
272
+ success: true,
273
+ tool: "consensus_check",
274
+ readMostly: true,
275
+ report: startValidationRun(deps, {
276
+ intent: "consensus",
277
+ content: claim,
278
+ providers,
279
+ }),
280
+ });
281
+ });
173
282
  server.tool("ask_model", "Ask one provider CLI a question through the simplified validation surface (starts a validation job).", {
174
283
  question: z.string().min(1).describe("Question for one provider."),
175
284
  model: providerSchema.default("claude").describe("Provider to ask."),
285
+ select: selectSchema,
176
286
  }, {
177
287
  title: "Ask one model",
178
288
  readOnlyHint: false,
179
289
  destructiveHint: true,
180
290
  idempotentHint: false,
181
291
  openWorldHint: true,
182
- }, async ({ question, model }) => textResponse({
183
- success: true,
184
- tool: "ask_model",
185
- readMostly: true,
186
- report: startValidationRun(deps, {
187
- intent: "ask_model",
188
- question,
189
- providers: [model],
190
- }),
191
- }));
292
+ }, async ({ question, model, select }) => {
293
+ let providers = [model];
294
+ if (select) {
295
+ const resolved = resolveSelectedProviders(selectionContext, question, select, true);
296
+ if (!resolved.ok) {
297
+ return textResponse({ success: false, tool: "ask_model", error: resolved.error });
298
+ }
299
+ providers = resolved.providers;
300
+ }
301
+ return textResponse({
302
+ success: true,
303
+ tool: "ask_model",
304
+ readMostly: true,
305
+ report: startValidationRun(deps, {
306
+ intent: "ask_model",
307
+ question,
308
+ providers,
309
+ }),
310
+ });
311
+ });
192
312
  server.tool("synthesize_validation", "Run an explicit judge model over already-collected validation results to produce a synthesis.", {
193
313
  question: z.string().min(1).describe("Original request that was validated."),
194
314
  providerResults: z
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "llm-cli-gateway",
3
- "version": "2.16.0",
3
+ "version": "2.17.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "llm-cli-gateway",
9
- "version": "2.16.0",
9
+ "version": "2.17.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@modelcontextprotocol/sdk": "^1.29.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-cli-gateway",
3
- "version": "2.16.0",
3
+ "version": "2.17.0",
4
4
  "mcpName": "io.github.verivus-oss/llm-cli-gateway",
5
5
  "description": "Secure local control plane for AI coding agents across supported MCP clients, with workspace-scoped remote access, approval gates, durable jobs, sessions, and audit receipts.",
6
6
  "license": "MIT",
@@ -60,7 +60,8 @@
60
60
  ".agents/skills/secure-orchestration/SKILL.md",
61
61
  ".agents/skills/implement-review-fix/SKILL.md",
62
62
  ".agents/skills/retrospective-walk/SKILL.md",
63
- ".agents/skills/public-demo-session/SKILL.md"
63
+ ".agents/skills/public-demo-session/SKILL.md",
64
+ ".agents/skills/least-cost-routing/SKILL.md"
64
65
  ],
65
66
  "scripts": {
66
67
  "build": "tsc -p tsconfig.build.json",
@@ -88,8 +89,12 @@
88
89
  "site:generate": "node scripts/generate-site-discovery.mjs",
89
90
  "site:generate:check": "node scripts/generate-site-discovery.mjs --check",
90
91
  "site:validate": "node scripts/validate-site-discovery.mjs",
91
- "lint": "eslint src/**/*.ts",
92
- "lint:fix": "eslint src/**/*.ts --fix",
92
+ "supply-chain:scan": "node scripts/supply-chain/dep-drift-scan.mjs --advisory",
93
+ "supply-chain:scan:check": "node scripts/supply-chain/dep-drift-scan.mjs --frozen",
94
+ "supply-chain:seed": "node scripts/supply-chain/dep-drift-scan.mjs --seed",
95
+ "test:supply-chain": "vitest run scripts/supply-chain/dep-drift-scan.test.mjs",
96
+ "lint": "eslint src --ignore-pattern 'src/__tests__/**'",
97
+ "lint:fix": "eslint src --ignore-pattern 'src/__tests__/**' --fix",
93
98
  "format": "prettier --write 'src/**/*.ts'",
94
99
  "format:check": "prettier --check 'src/**/*.ts'",
95
100
  "security:audit": "bash scripts/release-security-audit.sh",
@@ -18,6 +18,7 @@
18
18
  "client_config",
19
19
  "cache_awareness",
20
20
  "provider_capabilities",
21
+ "least_cost",
21
22
  "upstream",
22
23
  "next_actions"
23
24
  ],
@@ -480,6 +481,73 @@
480
481
  },
481
482
  "additionalProperties": false
482
483
  },
484
+ "least_cost": {
485
+ "type": "object",
486
+ "description": "LCR phase_3: least-cost-routing eligibility summary. Economics-only and secret-free (model names, pricing families, price provenance, tiers, telemetry tiers, price-table staleness, anonymized calibration buckets). ALWAYS present; compact (empty arrays) when routing is disabled.",
487
+ "required": ["enabled", "pricing", "providers", "untieredModels", "calibrationQuality"],
488
+ "properties": {
489
+ "enabled": { "type": "boolean" },
490
+ "pricing": {
491
+ "type": "object",
492
+ "required": ["tableAsOf", "apiCatalogAsOf", "staleDays"],
493
+ "properties": {
494
+ "tableAsOf": { "type": "string" },
495
+ "apiCatalogAsOf": { "type": "string" },
496
+ "staleDays": { "type": "integer", "minimum": 0 }
497
+ },
498
+ "additionalProperties": false
499
+ },
500
+ "providers": {
501
+ "type": "array",
502
+ "items": {
503
+ "type": "object",
504
+ "required": ["provider", "telemetryTier", "candidates"],
505
+ "properties": {
506
+ "provider": { "type": "string" },
507
+ "telemetryTier": { "enum": ["T1", "T2", "T3", "T4"] },
508
+ "candidates": {
509
+ "type": "array",
510
+ "items": {
511
+ "type": "object",
512
+ "required": ["model", "family", "priced", "priceSource", "tier"],
513
+ "properties": {
514
+ "model": { "type": "string" },
515
+ "family": { "type": "string" },
516
+ "priced": { "type": "boolean" },
517
+ "priceSource": { "enum": ["table", "api-catalog", "unknown"] },
518
+ "tier": {
519
+ "type": ["string", "null"],
520
+ "enum": ["economy", "standard", "frontier", null]
521
+ }
522
+ },
523
+ "additionalProperties": false
524
+ }
525
+ }
526
+ },
527
+ "additionalProperties": false
528
+ }
529
+ },
530
+ "untieredModels": {
531
+ "type": "array",
532
+ "items": { "type": "string" }
533
+ },
534
+ "calibrationQuality": {
535
+ "type": "array",
536
+ "items": {
537
+ "type": "object",
538
+ "required": ["bucket", "k", "samples", "confidence"],
539
+ "properties": {
540
+ "bucket": { "type": "string" },
541
+ "k": { "type": "number" },
542
+ "samples": { "type": "integer", "minimum": 0 },
543
+ "confidence": { "enum": ["high", "medium", "low"] }
544
+ },
545
+ "additionalProperties": false
546
+ }
547
+ }
548
+ },
549
+ "additionalProperties": false
550
+ },
483
551
  "api_providers": {
484
552
  "type": "object",
485
553
  "description": "Slice 6: health of enabled [providers.<name>] (kind:api) providers. OMITTED entirely when none are enabled (dormant byte-identical).",