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.
- package/.agents/skills/least-cost-routing/SKILL.md +123 -0
- package/CHANGELOG.md +28 -0
- package/dist/acp/client.js +5 -0
- package/dist/acp/flight-redaction.d.ts +2 -0
- package/dist/acp/flight-redaction.js +2 -0
- package/dist/acp/runtime.d.ts +1 -0
- package/dist/acp/runtime.js +20 -0
- package/dist/acp/types.d.ts +52 -0
- package/dist/acp/types.js +8 -0
- package/dist/api-provider.d.ts +1 -0
- package/dist/api-provider.js +1 -0
- package/dist/async-job-manager.d.ts +16 -0
- package/dist/async-job-manager.js +70 -22
- package/dist/claude-mcp-config.js +1 -1
- package/dist/compressor/transforms/ansi.js +12 -2
- package/dist/config.d.ts +31 -0
- package/dist/config.js +142 -7
- package/dist/db.js +4 -4
- package/dist/doctor.d.ts +34 -1
- package/dist/doctor.js +85 -3
- package/dist/executor.d.ts +5 -0
- package/dist/executor.js +11 -1
- package/dist/flight-recorder.d.ts +10 -0
- package/dist/flight-recorder.js +68 -2
- package/dist/http-transport.js +19 -21
- package/dist/index.d.ts +41 -1
- package/dist/index.js +658 -30
- package/dist/job-store.d.ts +10 -2
- package/dist/job-store.js +154 -43
- package/dist/lcr-priors.d.ts +60 -0
- package/dist/lcr-priors.js +190 -0
- package/dist/lcr-router-env.d.ts +20 -0
- package/dist/lcr-router-env.js +133 -0
- package/dist/lcr-telemetry.d.ts +2 -0
- package/dist/lcr-telemetry.js +17 -0
- package/dist/least-cost-router.d.ts +86 -0
- package/dist/least-cost-router.js +296 -0
- package/dist/least-cost-types.d.ts +34 -0
- package/dist/least-cost-types.js +1 -0
- package/dist/migrate-sessions.js +1 -1
- package/dist/migrate.js +1 -1
- package/dist/model-registry.js +1 -1
- package/dist/postgres-job-store-worker.js +56 -13
- package/dist/pricing.d.ts +17 -0
- package/dist/pricing.js +167 -0
- package/dist/provider-definitions.d.ts +4 -0
- package/dist/provider-definitions.js +28 -0
- package/dist/request-helpers.d.ts +2 -2
- package/dist/resources.d.ts +37 -2
- package/dist/resources.js +96 -1
- package/dist/retry.d.ts +1 -0
- package/dist/retry.js +1 -1
- package/dist/token-estimator.d.ts +6 -0
- package/dist/token-estimator.js +59 -0
- package/dist/upstream-contracts.js +1 -1
- package/dist/validation-receipt.js +1 -1
- package/dist/validation-tools.d.ts +6 -0
- package/dist/validation-tools.js +174 -54
- package/npm-shrinkwrap.json +2 -2
- package/package.json +9 -4
- package/setup/status.schema.json +68 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: least-cost-routing
|
|
3
|
+
description: Route a model-agnostic request to the cheapest capable (provider, model) via route_request, subject to a quality-tier floor and a hard budget cap. Use when you do not care WHICH model runs a task, only that it is cheap enough and good enough.
|
|
4
|
+
metadata:
|
|
5
|
+
author: verivus-oss
|
|
6
|
+
version: "1.0"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Least-cost routing (route_request)
|
|
10
|
+
|
|
11
|
+
`route_request` (sync) and `route_request_async` (async) pick the **cheapest
|
|
12
|
+
eligible `(provider, model)`** candidate that meets your constraints, then
|
|
13
|
+
dispatch it through the normal provider path. They are **dormant by default**:
|
|
14
|
+
registered only when `[least_cost].enabled = true` in
|
|
15
|
+
`~/.llm-cli-gateway/config.toml`. If the tools are absent, routing is off; use a
|
|
16
|
+
specific provider tool instead.
|
|
17
|
+
|
|
18
|
+
## When to use this vs a named provider tool
|
|
19
|
+
|
|
20
|
+
- Use `route_request` when the task is model-agnostic (summarize, answer,
|
|
21
|
+
classify, rewrite) and you want the cheapest model that clears a quality floor.
|
|
22
|
+
- Use `claude_request` / `codex_request` / etc. when you need a SPECIFIC model or
|
|
23
|
+
provider, a provider-specific flag, session resume, or a worktree. In phase_1
|
|
24
|
+
`route_request` is fresh one-shot only (no `sessionId` / `workspace` /
|
|
25
|
+
`worktree`).
|
|
26
|
+
|
|
27
|
+
## Inputs
|
|
28
|
+
|
|
29
|
+
- `prompt` (or `promptParts`): the request. Exactly one.
|
|
30
|
+
- `minTier`: `economy | standard | frontier` (default `standard`). A hard floor,
|
|
31
|
+
never downgraded to save money.
|
|
32
|
+
- `maxCostUsd`: per-request budget cap. Over-budget fails closed.
|
|
33
|
+
- `expectedOutputTokens` / `maxOutputTokens`: tune the ranking and the
|
|
34
|
+
conservative budget bound.
|
|
35
|
+
- `requiredCapabilities`: `{ images?, attachments?, toolCalling?, jsonSchema?,
|
|
36
|
+
outputFormat?, effort? }`. A candidate missing a required capability is
|
|
37
|
+
excluded.
|
|
38
|
+
- `candidates`: an explicit `(provider, model)[]` to restrict the pool (also
|
|
39
|
+
whitelists otherwise-untiered / maintain-only candidates like cursor/devin).
|
|
40
|
+
- `allowUnpriced` + `budgetWaiver`: BOTH are required to admit an unpriced
|
|
41
|
+
(`source: "unknown"`) candidate. An unpriced candidate always ranks strictly
|
|
42
|
+
last and cannot win over any priced candidate.
|
|
43
|
+
- `fallback`: a `(provider, model)` used ONLY when the eligible pool is empty;
|
|
44
|
+
bypasses cost ranking but still passes eligibility and (unless `budgetWaiver`)
|
|
45
|
+
the budget gate.
|
|
46
|
+
|
|
47
|
+
## Reading the result
|
|
48
|
+
|
|
49
|
+
Every routed response carries a `routing` block in `structuredContent.routing`,
|
|
50
|
+
and a one-line `[routing] chosen=... est=$... (basis, confidence) considered=N
|
|
51
|
+
reroutes=M` banner prepended to the text. Fields: `chosen`, `tier`,
|
|
52
|
+
`estCostUsd`, `costBasis` (`provider-reported | derived-from-tokens |
|
|
53
|
+
pre-flight-estimate`), `confidence`, `nearTie`, `estInputTokens`,
|
|
54
|
+
`estOutputTokens`, `priceAsOf`, `priceSource`, `consideredCount`,
|
|
55
|
+
`rejected: [{candidate, reason}]`, `reroutes`, and (on failure) `error`.
|
|
56
|
+
|
|
57
|
+
`estCostUsd` is always an ESTIMATE labelled with its inputs, never a billed cost.
|
|
58
|
+
|
|
59
|
+
## Failure semantics (fail closed)
|
|
60
|
+
|
|
61
|
+
- Over budget: `routing.error = "BudgetExceeded"`, `isError: true`. Raise
|
|
62
|
+
`maxCostUsd` rather than expecting a silent downgrade.
|
|
63
|
+
- No eligible candidate: `routing.error = "NoEligibleCandidate"` with the
|
|
64
|
+
per-candidate rejection reasons (auth / breaker / capacity / capability / tier
|
|
65
|
+
/ price / budget). Loosen `minTier`, add `candidates`, or set
|
|
66
|
+
`allowUnpriced` + `budgetWaiver`.
|
|
67
|
+
- Transient dispatch failure (breaker trip, timeout): LCR re-selects over the
|
|
68
|
+
remaining pool up to `[least_cost].max_reroutes`. Non-transient failures drop
|
|
69
|
+
the candidate and continue.
|
|
70
|
+
|
|
71
|
+
## Observability (phase_2)
|
|
72
|
+
|
|
73
|
+
When routing is enabled, three read-only surfaces expose how it is behaving
|
|
74
|
+
(all economics-only, no prompts/secrets/principal):
|
|
75
|
+
|
|
76
|
+
- `routing://decisions` (MCP resource): the recent routed decisions, each with
|
|
77
|
+
`provider`/`model`, `estCostUsd`, `costBasis`, `confidence`, `reason`,
|
|
78
|
+
`considered`, `reroutes`, `at`.
|
|
79
|
+
- `routing://priors` (MCP resource): the learned per-`(provider,model)`
|
|
80
|
+
output-token priors (median/p90/samples), the per-`(content-type,family)`
|
|
81
|
+
input calibration `k` + sample count + confidence, and `priceAsOf`. Priors are
|
|
82
|
+
anonymized model-level economics; `priors_scope` (`global | principal | off`)
|
|
83
|
+
scopes the learning (or disables it).
|
|
84
|
+
- `llm_process_health` gains a `leastCost` block: per-provider telemetry tier
|
|
85
|
+
(T1..T4) and per-candidate eligibility (priced? / authed? / breaker? / tier?).
|
|
86
|
+
- `doctor --json` gains a `least_cost` block: pricing `asOf` + staleness,
|
|
87
|
+
per-provider telemetry tier, per-candidate eligibility, untiered models, and
|
|
88
|
+
per-`(content-type,family)` calibration quality (`k`/samples/confidence).
|
|
89
|
+
|
|
90
|
+
API-provider models are priced from a published catalog (`prefer_catalog_price`
|
|
91
|
+
picks catalog over the CLI table when both resolve).
|
|
92
|
+
|
|
93
|
+
## Cheapest-reviewer selection (phase_3)
|
|
94
|
+
|
|
95
|
+
`validate_with_models`, `red_team_review`, `consensus_check`, `second_opinion`,
|
|
96
|
+
and `ask_model` accept an OPT-IN `select: "cheapest" | "cheapest_per_tier"`. When
|
|
97
|
+
omitted, the explicit `models`/`model` list is used verbatim (default unchanged).
|
|
98
|
+
When set, the reviewer target list is filled via the LCR selector:
|
|
99
|
+
`cheapest` picks the single cheapest eligible provider; `cheapest_per_tier` picks
|
|
100
|
+
the cheapest provider in each quality tier (economy/standard/frontier). It fails
|
|
101
|
+
closed (no jobs started) when `[least_cost].enabled` is false or nothing is
|
|
102
|
+
eligible.
|
|
103
|
+
|
|
104
|
+
## Config (operator, `~/.llm-cli-gateway/config.toml`)
|
|
105
|
+
|
|
106
|
+
```toml
|
|
107
|
+
[least_cost]
|
|
108
|
+
enabled = true # nothing routes until this is true
|
|
109
|
+
min_tier = "standard"
|
|
110
|
+
max_cost_usd = 0.50
|
|
111
|
+
allow_unpriced = false
|
|
112
|
+
max_reroutes = 2
|
|
113
|
+
|
|
114
|
+
[least_cost.tiers] # (provider:family) -> tier; ships with defaults
|
|
115
|
+
"claude:claude-opus" = "frontier"
|
|
116
|
+
|
|
117
|
+
[least_cost.candidates]
|
|
118
|
+
allow = [] # empty = all eligible
|
|
119
|
+
deny = []
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
See `docs/least-cost-routing-contract.md` for the frozen decisions and
|
|
123
|
+
invariants.
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,34 @@ All notable changes to the llm-cli-gateway project.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [2.17.0] - 2026-07-13
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Least-cost routing.** `route_request` and `route_request_async` select the
|
|
12
|
+
cheapest eligible provider and model within a quality floor, budget cap, and
|
|
13
|
+
current capacity constraints. The router now exposes pricing, estimates,
|
|
14
|
+
route decisions, health, and learned cost priors through `routing://`
|
|
15
|
+
resources.
|
|
16
|
+
- **Cheapest eligible validation.** `validate_with_models` can now select a
|
|
17
|
+
least-cost eligible model rather than requiring callers to name one.
|
|
18
|
+
- **Supply-chain release guards.** Dependency drift, tag-along packages,
|
|
19
|
+
unexpected install scripts, licenses, and policy changes are checked before
|
|
20
|
+
release, with fail-closed protection for uncertain findings.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **Durable Postgres job-store recovery.** Replaced temporary-file worker
|
|
25
|
+
result handoff with in-process message channels, preserving failure detail
|
|
26
|
+
and avoiding host `/tmp` inode exhaustion. A failed bridge now retires and
|
|
27
|
+
recreates its worker safely, and durable admission recovers only after a
|
|
28
|
+
successful re-registration.
|
|
29
|
+
- **Async availability reporting and lifecycle.** Readiness and process health
|
|
30
|
+
now reflect durable-admission state, configured-store startup failures exit
|
|
31
|
+
for supervised recovery, and shutdown reliably closes the job store.
|
|
32
|
+
- **Release and test gates.** The lint command now checks the actual source
|
|
33
|
+
tree, and Postgres integration tests work with either Docker or Podman.
|
|
34
|
+
|
|
7
35
|
## [2.16.0] - 2026-07-10
|
|
8
36
|
|
|
9
37
|
### Added
|
package/dist/acp/client.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AcpMethodUnsupportedError, AcpMutatingDisabledError, AcpProtocolError, isAcpError, } from "./errors.js";
|
|
2
2
|
import { deriveAcpMethodAvailability, parseCloseSessionResponse, parseDeleteSessionResponse, parseInitializeResponse, parseListSessionsResponse, parseReadTextFileRequest, parseRequestPermissionRequest, parseSessionLoadResponse, parseSessionNewResponse, parseSessionPromptResponse, parseSessionResumeResponse, parseSessionUpdateNotification, parseSetSessionConfigOptionResponse, parseSetSessionModeResponse, parseWriteTextFileRequest, sessionResponseMethods, } from "./types.js";
|
|
3
3
|
import { noopLogger } from "../logger.js";
|
|
4
|
+
import { gatewayVersion } from "../provider-capability-discovery.js";
|
|
4
5
|
export const DEFAULT_ACP_PROTOCOL_VERSION = 1;
|
|
5
6
|
export class AcpClient {
|
|
6
7
|
transport;
|
|
@@ -47,6 +48,10 @@ export class AcpClient {
|
|
|
47
48
|
}
|
|
48
49
|
const params = {
|
|
49
50
|
protocolVersion: this.protocolVersion,
|
|
51
|
+
clientInfo: {
|
|
52
|
+
name: "llm-cli-gateway",
|
|
53
|
+
version: gatewayVersion(),
|
|
54
|
+
},
|
|
50
55
|
clientCapabilities: {
|
|
51
56
|
fs: {
|
|
52
57
|
readTextFile: options.readTextFile ?? false,
|
|
@@ -24,5 +24,7 @@ export interface AcpFlightResultParams {
|
|
|
24
24
|
readonly errorMessage?: string;
|
|
25
25
|
readonly providerSessionId?: string;
|
|
26
26
|
readonly stopReason?: string;
|
|
27
|
+
readonly costUsd?: number;
|
|
28
|
+
readonly costBasis?: string;
|
|
27
29
|
}
|
|
28
30
|
export declare function buildAcpFlightResult(params: AcpFlightResultParams): FlightLogResult;
|
|
@@ -39,5 +39,7 @@ export function buildAcpFlightResult(params) {
|
|
|
39
39
|
errorMessage: params.errorMessage !== undefined ? redactAcpTextForFlight(params.errorMessage) : undefined,
|
|
40
40
|
providerSessionId: params.providerSessionId,
|
|
41
41
|
stopReason: params.stopReason,
|
|
42
|
+
costUsd: params.costUsd,
|
|
43
|
+
costBasis: params.costBasis,
|
|
42
44
|
};
|
|
43
45
|
}
|
package/dist/acp/runtime.d.ts
CHANGED
|
@@ -38,6 +38,7 @@ export interface AcpPromptUsage {
|
|
|
38
38
|
inputTokens?: number;
|
|
39
39
|
outputTokens?: number;
|
|
40
40
|
cacheReadTokens?: number;
|
|
41
|
+
reasoningTokens?: number;
|
|
41
42
|
}
|
|
42
43
|
export declare function extractAcpPromptUsage(meta: unknown): AcpPromptUsage;
|
|
43
44
|
export declare function runAcpRequest(deps: AcpRuntimeDeps, req: AcpRunRequest): Promise<AcpRunResult>;
|
package/dist/acp/runtime.js
CHANGED
|
@@ -6,6 +6,7 @@ import { createAcpPermissionDecider } from "./permission-bridge.js";
|
|
|
6
6
|
import { AcpProcessManager } from "./process-manager.js";
|
|
7
7
|
import { createAcpSession, recordAcpSessionInfo, resolveAcpResume } from "./session-map.js";
|
|
8
8
|
import { DEVIN_ACP_AGENT_TYPES } from "../provider-definitions.js";
|
|
9
|
+
import { composeCost, getModelCost } from "../pricing.js";
|
|
9
10
|
import { noopLogger } from "../logger.js";
|
|
10
11
|
export function extractAcpPromptUsage(meta) {
|
|
11
12
|
if (!meta || typeof meta !== "object" || Array.isArray(meta))
|
|
@@ -22,6 +23,9 @@ export function extractAcpPromptUsage(meta) {
|
|
|
22
23
|
const cacheReadTokens = num(record.cachedReadTokens);
|
|
23
24
|
if (cacheReadTokens !== undefined)
|
|
24
25
|
usage.cacheReadTokens = cacheReadTokens;
|
|
26
|
+
const reasoningTokens = num(record.reasoningTokens);
|
|
27
|
+
if (reasoningTokens !== undefined)
|
|
28
|
+
usage.reasoningTokens = reasoningTokens;
|
|
25
29
|
return usage;
|
|
26
30
|
}
|
|
27
31
|
export async function runAcpRequest(deps, req) {
|
|
@@ -120,6 +124,20 @@ export async function runAcpRequest(deps, req) {
|
|
|
120
124
|
const text = normalizer.finalText;
|
|
121
125
|
const durationMs = elapsed();
|
|
122
126
|
const usage = extractAcpPromptUsage(promptResult._meta);
|
|
127
|
+
let costUsd;
|
|
128
|
+
let costBasis;
|
|
129
|
+
const modelCost = getModelCost(req.provider, req.model ?? "default");
|
|
130
|
+
if (modelCost.source !== "unknown" &&
|
|
131
|
+
(usage.inputTokens !== undefined || usage.outputTokens !== undefined)) {
|
|
132
|
+
const composed = composeCost({
|
|
133
|
+
inputTokens: usage.inputTokens ?? 0,
|
|
134
|
+
outputTokens: usage.outputTokens ?? 0,
|
|
135
|
+
cacheReadTokens: usage.cacheReadTokens,
|
|
136
|
+
reasoningTokens: usage.reasoningTokens,
|
|
137
|
+
}, { estInputTokens: 0, estOutputTokens: 0 }, modelCost);
|
|
138
|
+
costUsd = composed.costUsd;
|
|
139
|
+
costBasis = composed.cost_basis;
|
|
140
|
+
}
|
|
123
141
|
deps.flightRecorder?.logComplete(req.correlationId, buildAcpFlightResult({
|
|
124
142
|
responseText: text,
|
|
125
143
|
durationMs,
|
|
@@ -130,6 +148,8 @@ export async function runAcpRequest(deps, req) {
|
|
|
130
148
|
inputTokens: usage.inputTokens,
|
|
131
149
|
outputTokens: usage.outputTokens,
|
|
132
150
|
cacheReadTokens: usage.cacheReadTokens,
|
|
151
|
+
costUsd,
|
|
152
|
+
costBasis,
|
|
133
153
|
}));
|
|
134
154
|
logger.info("acp.request.success", { provider: req.provider, durationMs });
|
|
135
155
|
return {
|
package/dist/acp/types.d.ts
CHANGED
|
@@ -54,8 +54,34 @@ export declare const ClientCapabilitiesSchema: z.ZodObject<{
|
|
|
54
54
|
}, z.ZodTypeAny, "passthrough">>>;
|
|
55
55
|
terminal: z.ZodOptional<z.ZodBoolean>;
|
|
56
56
|
}, z.ZodTypeAny, "passthrough">>;
|
|
57
|
+
export declare const ClientInfoSchema: z.ZodObject<{
|
|
58
|
+
name: z.ZodString;
|
|
59
|
+
version: z.ZodString;
|
|
60
|
+
title: z.ZodOptional<z.ZodString>;
|
|
61
|
+
}, "passthrough", z.ZodTypeAny, z.objectOutputType<{
|
|
62
|
+
name: z.ZodString;
|
|
63
|
+
version: z.ZodString;
|
|
64
|
+
title: z.ZodOptional<z.ZodString>;
|
|
65
|
+
}, z.ZodTypeAny, "passthrough">, z.objectInputType<{
|
|
66
|
+
name: z.ZodString;
|
|
67
|
+
version: z.ZodString;
|
|
68
|
+
title: z.ZodOptional<z.ZodString>;
|
|
69
|
+
}, z.ZodTypeAny, "passthrough">>;
|
|
57
70
|
export declare const InitializeRequestSchema: z.ZodObject<{
|
|
58
71
|
protocolVersion: z.ZodNumber;
|
|
72
|
+
clientInfo: z.ZodOptional<z.ZodObject<{
|
|
73
|
+
name: z.ZodString;
|
|
74
|
+
version: z.ZodString;
|
|
75
|
+
title: z.ZodOptional<z.ZodString>;
|
|
76
|
+
}, "passthrough", z.ZodTypeAny, z.objectOutputType<{
|
|
77
|
+
name: z.ZodString;
|
|
78
|
+
version: z.ZodString;
|
|
79
|
+
title: z.ZodOptional<z.ZodString>;
|
|
80
|
+
}, z.ZodTypeAny, "passthrough">, z.objectInputType<{
|
|
81
|
+
name: z.ZodString;
|
|
82
|
+
version: z.ZodString;
|
|
83
|
+
title: z.ZodOptional<z.ZodString>;
|
|
84
|
+
}, z.ZodTypeAny, "passthrough">>>;
|
|
59
85
|
clientCapabilities: z.ZodOptional<z.ZodObject<{
|
|
60
86
|
fs: z.ZodOptional<z.ZodObject<{
|
|
61
87
|
readTextFile: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -96,6 +122,19 @@ export declare const InitializeRequestSchema: z.ZodObject<{
|
|
|
96
122
|
_meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
97
123
|
}, "passthrough", z.ZodTypeAny, z.objectOutputType<{
|
|
98
124
|
protocolVersion: z.ZodNumber;
|
|
125
|
+
clientInfo: z.ZodOptional<z.ZodObject<{
|
|
126
|
+
name: z.ZodString;
|
|
127
|
+
version: z.ZodString;
|
|
128
|
+
title: z.ZodOptional<z.ZodString>;
|
|
129
|
+
}, "passthrough", z.ZodTypeAny, z.objectOutputType<{
|
|
130
|
+
name: z.ZodString;
|
|
131
|
+
version: z.ZodString;
|
|
132
|
+
title: z.ZodOptional<z.ZodString>;
|
|
133
|
+
}, z.ZodTypeAny, "passthrough">, z.objectInputType<{
|
|
134
|
+
name: z.ZodString;
|
|
135
|
+
version: z.ZodString;
|
|
136
|
+
title: z.ZodOptional<z.ZodString>;
|
|
137
|
+
}, z.ZodTypeAny, "passthrough">>>;
|
|
99
138
|
clientCapabilities: z.ZodOptional<z.ZodObject<{
|
|
100
139
|
fs: z.ZodOptional<z.ZodObject<{
|
|
101
140
|
readTextFile: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -136,6 +175,19 @@ export declare const InitializeRequestSchema: z.ZodObject<{
|
|
|
136
175
|
_meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
137
176
|
}, z.ZodTypeAny, "passthrough">, z.objectInputType<{
|
|
138
177
|
protocolVersion: z.ZodNumber;
|
|
178
|
+
clientInfo: z.ZodOptional<z.ZodObject<{
|
|
179
|
+
name: z.ZodString;
|
|
180
|
+
version: z.ZodString;
|
|
181
|
+
title: z.ZodOptional<z.ZodString>;
|
|
182
|
+
}, "passthrough", z.ZodTypeAny, z.objectOutputType<{
|
|
183
|
+
name: z.ZodString;
|
|
184
|
+
version: z.ZodString;
|
|
185
|
+
title: z.ZodOptional<z.ZodString>;
|
|
186
|
+
}, z.ZodTypeAny, "passthrough">, z.objectInputType<{
|
|
187
|
+
name: z.ZodString;
|
|
188
|
+
version: z.ZodString;
|
|
189
|
+
title: z.ZodOptional<z.ZodString>;
|
|
190
|
+
}, z.ZodTypeAny, "passthrough">>>;
|
|
139
191
|
clientCapabilities: z.ZodOptional<z.ZodObject<{
|
|
140
192
|
fs: z.ZodOptional<z.ZodObject<{
|
|
141
193
|
readTextFile: z.ZodOptional<z.ZodBoolean>;
|
package/dist/acp/types.js
CHANGED
|
@@ -50,9 +50,17 @@ export const ClientCapabilitiesSchema = z
|
|
|
50
50
|
terminal: z.boolean().optional(),
|
|
51
51
|
})
|
|
52
52
|
.passthrough();
|
|
53
|
+
export const ClientInfoSchema = z
|
|
54
|
+
.object({
|
|
55
|
+
name: z.string().min(1),
|
|
56
|
+
version: z.string().min(1),
|
|
57
|
+
title: z.string().min(1).optional(),
|
|
58
|
+
})
|
|
59
|
+
.passthrough();
|
|
53
60
|
export const InitializeRequestSchema = z
|
|
54
61
|
.object({
|
|
55
62
|
protocolVersion: ProtocolVersionSchema,
|
|
63
|
+
clientInfo: ClientInfoSchema.optional(),
|
|
56
64
|
clientCapabilities: ClientCapabilitiesSchema.optional(),
|
|
57
65
|
_meta: MetaSchema,
|
|
58
66
|
})
|
package/dist/api-provider.d.ts
CHANGED
package/dist/api-provider.js
CHANGED
|
@@ -126,6 +126,7 @@ export class AnthropicProvider {
|
|
|
126
126
|
inputTokens: firstNumber(usage.input_tokens),
|
|
127
127
|
outputTokens: firstNumber(usage.output_tokens),
|
|
128
128
|
cacheReadTokens: firstNumber(usage.cache_read_input_tokens),
|
|
129
|
+
cacheCreationTokens: firstNumber(usage.cache_creation_input_tokens),
|
|
129
130
|
raw: usage,
|
|
130
131
|
},
|
|
131
132
|
raw: parsed,
|
|
@@ -37,6 +37,7 @@ export interface JobLimiterSnapshot {
|
|
|
37
37
|
timedOut: number;
|
|
38
38
|
saturated: boolean;
|
|
39
39
|
}
|
|
40
|
+
export declare function providerAtCapacity(snapshot: JobLimiterSnapshot, provider: string): boolean;
|
|
40
41
|
export interface AsyncJobFlightRecorderEntry {
|
|
41
42
|
model: string;
|
|
42
43
|
prompt: string;
|
|
@@ -53,6 +54,7 @@ export type AsyncJobUsageExtractor = (stdout: string) => {
|
|
|
53
54
|
cacheReadTokens?: number;
|
|
54
55
|
cacheCreationTokens?: number;
|
|
55
56
|
costUsd?: number;
|
|
57
|
+
costBasis?: string;
|
|
56
58
|
};
|
|
57
59
|
export interface AsyncJobSnapshot {
|
|
58
60
|
id: string;
|
|
@@ -120,12 +122,25 @@ export declare class AsyncJobManager {
|
|
|
120
122
|
private sweepTimer;
|
|
121
123
|
private durableAdmission;
|
|
122
124
|
private consecutiveHeartbeatFailures;
|
|
125
|
+
private consecutiveHeartbeatSuccesses;
|
|
126
|
+
private lastHeartbeatFailureAt;
|
|
127
|
+
private lastHeartbeatRecoveryAt;
|
|
128
|
+
private lastHeartbeatErrorName;
|
|
123
129
|
private skipSweepThisCycle;
|
|
124
130
|
private nextHeartbeatExpectedAt;
|
|
125
131
|
private disposed;
|
|
126
132
|
private readonly pendingWrites;
|
|
127
133
|
constructor(logger?: Logger, onJobComplete?: ((cli: JobProvider, durationMs: number, success: boolean) => void) | undefined, store?: JobStore | null, flightRecorder?: FlightRecorderLike, limits?: JobLimitsConfig, _deprecatedOwnsOrphanRecovery?: boolean, leaseConfig?: LeaseRuntimeConfig);
|
|
128
134
|
canAdmitDurableJobs(): boolean;
|
|
135
|
+
getDurableAdmissionHealth(): {
|
|
136
|
+
storeAttached: boolean;
|
|
137
|
+
admitting: boolean;
|
|
138
|
+
consecutiveHeartbeatFailures: number;
|
|
139
|
+
consecutiveHeartbeatSuccesses: number;
|
|
140
|
+
lastHeartbeatFailureAt: string | null;
|
|
141
|
+
lastHeartbeatRecoveryAt: string | null;
|
|
142
|
+
lastHeartbeatErrorName: string | null;
|
|
143
|
+
};
|
|
129
144
|
private assertDurableAdmission;
|
|
130
145
|
private trackPendingWrite;
|
|
131
146
|
dispose(opts?: {
|
|
@@ -136,6 +151,7 @@ export declare class AsyncJobManager {
|
|
|
136
151
|
private onHeartbeatTick;
|
|
137
152
|
runOrphanSweepNow(): void;
|
|
138
153
|
private startReaper;
|
|
154
|
+
private restoreDurableAdmission;
|
|
139
155
|
private runOrphanSweep;
|
|
140
156
|
private confirmLiveProcessCandidates;
|
|
141
157
|
private recordStartOrFailClosed;
|
|
@@ -20,6 +20,7 @@ const DEFAULT_LEASE_RUNTIME_CONFIG = {
|
|
|
20
20
|
role: "gateway",
|
|
21
21
|
};
|
|
22
22
|
const MAX_CONSECUTIVE_HEARTBEAT_FAILURES = 3;
|
|
23
|
+
const MIN_CONSECUTIVE_HEARTBEAT_SUCCESSES_TO_RECOVER = 3;
|
|
23
24
|
export function extractApiHttpStatus(error) {
|
|
24
25
|
for (const candidate of [error, error?.cause]) {
|
|
25
26
|
if (candidate instanceof ApiHttpError && typeof candidate.status === "number") {
|
|
@@ -72,6 +73,9 @@ export class JobSaturationError extends Error {
|
|
|
72
73
|
this.name = "JobSaturationError";
|
|
73
74
|
}
|
|
74
75
|
}
|
|
76
|
+
export function providerAtCapacity(snapshot, provider) {
|
|
77
|
+
return (snapshot.runningByProvider[provider] ?? 0) >= snapshot.maxRunningPerProvider;
|
|
78
|
+
}
|
|
75
79
|
class JobLimiter {
|
|
76
80
|
cfg;
|
|
77
81
|
logger;
|
|
@@ -258,6 +262,10 @@ export class AsyncJobManager {
|
|
|
258
262
|
sweepTimer = null;
|
|
259
263
|
durableAdmission;
|
|
260
264
|
consecutiveHeartbeatFailures = 0;
|
|
265
|
+
consecutiveHeartbeatSuccesses = 0;
|
|
266
|
+
lastHeartbeatFailureAt = null;
|
|
267
|
+
lastHeartbeatRecoveryAt = null;
|
|
268
|
+
lastHeartbeatErrorName = null;
|
|
261
269
|
skipSweepThisCycle = false;
|
|
262
270
|
nextHeartbeatExpectedAt = 0;
|
|
263
271
|
disposed = false;
|
|
@@ -278,25 +286,11 @@ export class AsyncJobManager {
|
|
|
278
286
|
maxQueuedJobs: limits.maxQueuedJobs,
|
|
279
287
|
queueTimeoutMs: limits.queueTimeoutMs,
|
|
280
288
|
}, logger);
|
|
281
|
-
this.durableAdmission =
|
|
289
|
+
this.durableAdmission = false;
|
|
282
290
|
if (this.store) {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
role: this.lease.role ?? "gateway",
|
|
287
|
-
hostname: this.hostname,
|
|
288
|
-
pid: this.instancePid,
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
catch (err) {
|
|
292
|
-
this.durableAdmission = false;
|
|
293
|
-
this.logger.error("#139 registerInstance failed; durable async admission is disabled for this instance", err);
|
|
294
|
-
}
|
|
295
|
-
if (this.durableAdmission) {
|
|
296
|
-
this.runOrphanSweep();
|
|
297
|
-
this.startHeartbeat();
|
|
298
|
-
this.startReaper();
|
|
299
|
-
}
|
|
291
|
+
this.restoreDurableAdmission("startup");
|
|
292
|
+
this.startHeartbeat();
|
|
293
|
+
this.startReaper();
|
|
300
294
|
}
|
|
301
295
|
this.evictionTimer = setInterval(() => this.evictCompletedJobs(), EVICTION_INTERVAL_MS);
|
|
302
296
|
if (this.evictionTimer.unref) {
|
|
@@ -310,6 +304,17 @@ export class AsyncJobManager {
|
|
|
310
304
|
canAdmitDurableJobs() {
|
|
311
305
|
return this.store !== null && this.durableAdmission;
|
|
312
306
|
}
|
|
307
|
+
getDurableAdmissionHealth() {
|
|
308
|
+
return {
|
|
309
|
+
storeAttached: this.store !== null,
|
|
310
|
+
admitting: this.canAdmitDurableJobs(),
|
|
311
|
+
consecutiveHeartbeatFailures: this.consecutiveHeartbeatFailures,
|
|
312
|
+
consecutiveHeartbeatSuccesses: this.consecutiveHeartbeatSuccesses,
|
|
313
|
+
lastHeartbeatFailureAt: this.lastHeartbeatFailureAt,
|
|
314
|
+
lastHeartbeatRecoveryAt: this.lastHeartbeatRecoveryAt,
|
|
315
|
+
lastHeartbeatErrorName: this.lastHeartbeatErrorName,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
313
318
|
assertDurableAdmission(provider) {
|
|
314
319
|
if (this.store && !this.durableAdmission) {
|
|
315
320
|
throw new Error(`Durable async admission is disabled for ${provider}: this gateway instance could not register or lost its heartbeat lease. Retry after the gateway recovers.`);
|
|
@@ -394,13 +399,23 @@ export class AsyncJobManager {
|
|
|
394
399
|
try {
|
|
395
400
|
this.store.heartbeat(this.instanceId);
|
|
396
401
|
this.consecutiveHeartbeatFailures = 0;
|
|
402
|
+
if (!this.durableAdmission) {
|
|
403
|
+
this.consecutiveHeartbeatSuccesses++;
|
|
404
|
+
if (this.consecutiveHeartbeatSuccesses >= MIN_CONSECUTIVE_HEARTBEAT_SUCCESSES_TO_RECOVER) {
|
|
405
|
+
this.restoreDurableAdmission("heartbeat recovery");
|
|
406
|
+
}
|
|
407
|
+
}
|
|
397
408
|
}
|
|
398
409
|
catch (err) {
|
|
399
410
|
this.consecutiveHeartbeatFailures++;
|
|
411
|
+
this.consecutiveHeartbeatSuccesses = 0;
|
|
412
|
+
this.lastHeartbeatFailureAt = new Date().toISOString();
|
|
413
|
+
this.lastHeartbeatErrorName = err instanceof Error ? err.name : typeof err;
|
|
400
414
|
this.logger.error(`#139 heartbeat failed (${this.consecutiveHeartbeatFailures}/${MAX_CONSECUTIVE_HEARTBEAT_FAILURES})`, err);
|
|
401
415
|
if (this.consecutiveHeartbeatFailures >= MAX_CONSECUTIVE_HEARTBEAT_FAILURES) {
|
|
402
416
|
if (this.durableAdmission) {
|
|
403
417
|
this.durableAdmission = false;
|
|
418
|
+
this.consecutiveHeartbeatSuccesses = 0;
|
|
404
419
|
this.logger.error("#139 sustained heartbeat failure; disabling durable admission and orphan sweeping on this instance");
|
|
405
420
|
}
|
|
406
421
|
}
|
|
@@ -412,8 +427,10 @@ export class AsyncJobManager {
|
|
|
412
427
|
startReaper() {
|
|
413
428
|
this.sweepTimer = setInterval(() => {
|
|
414
429
|
this.runOrphanSweep();
|
|
430
|
+
if (!this.store || this.disposed || !this.durableAdmission)
|
|
431
|
+
return;
|
|
415
432
|
try {
|
|
416
|
-
this.store
|
|
433
|
+
this.store.gcInstances(this.lease.instanceGcMs);
|
|
417
434
|
}
|
|
418
435
|
catch (err) {
|
|
419
436
|
this.logger.error("#139 gateway_instances GC failed", err);
|
|
@@ -422,6 +439,35 @@ export class AsyncJobManager {
|
|
|
422
439
|
if (this.sweepTimer.unref)
|
|
423
440
|
this.sweepTimer.unref();
|
|
424
441
|
}
|
|
442
|
+
restoreDurableAdmission(reason) {
|
|
443
|
+
if (!this.store || this.disposed)
|
|
444
|
+
return;
|
|
445
|
+
try {
|
|
446
|
+
this.store.registerInstance({
|
|
447
|
+
instanceId: this.instanceId,
|
|
448
|
+
role: this.lease.role ?? "gateway",
|
|
449
|
+
hostname: this.hostname,
|
|
450
|
+
pid: this.instancePid,
|
|
451
|
+
});
|
|
452
|
+
this.store.heartbeat(this.instanceId);
|
|
453
|
+
const wasDisabled = !this.durableAdmission;
|
|
454
|
+
this.durableAdmission = true;
|
|
455
|
+
this.consecutiveHeartbeatFailures = 0;
|
|
456
|
+
this.consecutiveHeartbeatSuccesses = 0;
|
|
457
|
+
this.lastHeartbeatRecoveryAt = new Date().toISOString();
|
|
458
|
+
if (wasDisabled && reason === "heartbeat recovery") {
|
|
459
|
+
this.logger.info("#139 durable heartbeat recovered; re-enabled async admission and orphan sweeping");
|
|
460
|
+
}
|
|
461
|
+
this.runOrphanSweep();
|
|
462
|
+
}
|
|
463
|
+
catch (err) {
|
|
464
|
+
this.durableAdmission = false;
|
|
465
|
+
this.consecutiveHeartbeatSuccesses = 0;
|
|
466
|
+
this.lastHeartbeatFailureAt = new Date().toISOString();
|
|
467
|
+
this.lastHeartbeatErrorName = err instanceof Error ? err.name : typeof err;
|
|
468
|
+
this.logger.error(`#139 ${reason} register/heartbeat failed; durable async admission remains disabled`, err);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
425
471
|
runOrphanSweep() {
|
|
426
472
|
if (!this.store || this.disposed || !this.durableAdmission)
|
|
427
473
|
return;
|
|
@@ -493,13 +539,13 @@ export class AsyncJobManager {
|
|
|
493
539
|
acq.cancel();
|
|
494
540
|
this.jobs.delete(job.id);
|
|
495
541
|
this.logger.error(`#139 durable recordStart failed for job ${job.id}; failing the request (fail-closed)`, err);
|
|
496
|
-
throw new Error(`Durable job admission failed for ${job.cli}: the job store rejected recordStart
|
|
542
|
+
throw new Error(`Durable job admission failed for ${job.cli}: the job store rejected recordStart`, { cause: err });
|
|
497
543
|
}
|
|
498
544
|
}
|
|
499
545
|
markRunningDurable(job, pid, failClosed = false) {
|
|
500
546
|
if (!this.store)
|
|
501
547
|
return;
|
|
502
|
-
let transitioned
|
|
548
|
+
let transitioned;
|
|
503
549
|
try {
|
|
504
550
|
transitioned = this.store.markRunning(job.id, { pid });
|
|
505
551
|
}
|
|
@@ -645,7 +691,7 @@ export class AsyncJobManager {
|
|
|
645
691
|
if (evicted > 0) {
|
|
646
692
|
this.logger.debug(`Evicted ${evicted} completed jobs from memory (durable store retains them)`);
|
|
647
693
|
}
|
|
648
|
-
if (this.store) {
|
|
694
|
+
if (this.store && this.durableAdmission) {
|
|
649
695
|
try {
|
|
650
696
|
const removed = this.store.evictExpired();
|
|
651
697
|
if (removed > 0) {
|
|
@@ -938,6 +984,7 @@ export class AsyncJobManager {
|
|
|
938
984
|
cacheReadTokens: usage.cacheReadTokens,
|
|
939
985
|
cacheCreationTokens: usage.cacheCreationTokens,
|
|
940
986
|
costUsd: usage.costUsd,
|
|
987
|
+
costBasis: usage.costBasis,
|
|
941
988
|
providerSessionId: providerMeta?.sessionId,
|
|
942
989
|
stopReason: providerMeta?.stopReason,
|
|
943
990
|
});
|
|
@@ -966,6 +1013,7 @@ export class AsyncJobManager {
|
|
|
966
1013
|
inputTokens: u.inputTokens,
|
|
967
1014
|
outputTokens: u.outputTokens,
|
|
968
1015
|
cacheReadTokens: u.cacheReadTokens,
|
|
1016
|
+
cacheCreationTokens: u.cacheCreationTokens,
|
|
969
1017
|
costUsd: u.costUsd,
|
|
970
1018
|
};
|
|
971
1019
|
}
|
|
@@ -147,7 +147,7 @@ export function buildClaudeMcpConfig(servers) {
|
|
|
147
147
|
}
|
|
148
148
|
catch (error) {
|
|
149
149
|
const message = error instanceof Error ? error.message : String(error);
|
|
150
|
-
throw new Error(`Failed to write Claude MCP config: ${message}
|
|
150
|
+
throw new Error(`Failed to write Claude MCP config: ${message}`, { cause: error });
|
|
151
151
|
}
|
|
152
152
|
return { path: configPath, enabled, missing };
|
|
153
153
|
}
|
|
@@ -45,7 +45,17 @@ function stripEscapes(text) {
|
|
|
45
45
|
.replace(ESC_TWO_BYTE, "")
|
|
46
46
|
.replace(STRAY_C0, "");
|
|
47
47
|
}
|
|
48
|
-
|
|
48
|
+
function isSingleColumnAsciiFrame(text) {
|
|
49
|
+
for (const character of text) {
|
|
50
|
+
const codePoint = character.codePointAt(0);
|
|
51
|
+
if (codePoint !== 0x09 &&
|
|
52
|
+
codePoint !== 0x0d &&
|
|
53
|
+
(codePoint === undefined || codePoint < 0x20 || codePoint > 0x7e)) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
49
59
|
function overlayFrames(frames) {
|
|
50
60
|
const cells = [];
|
|
51
61
|
for (const frame of frames) {
|
|
@@ -61,7 +71,7 @@ function collapseLine(line, out, counts) {
|
|
|
61
71
|
out.push(line);
|
|
62
72
|
return;
|
|
63
73
|
}
|
|
64
|
-
if (!
|
|
74
|
+
if (!isSingleColumnAsciiFrame(body)) {
|
|
65
75
|
out.push(line);
|
|
66
76
|
return;
|
|
67
77
|
}
|