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
@@ -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
@@ -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
  }
@@ -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>;
@@ -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 {
@@ -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
  })
@@ -25,6 +25,7 @@ export interface ApiUsage {
25
25
  inputTokens?: number;
26
26
  outputTokens?: number;
27
27
  cacheReadTokens?: number;
28
+ cacheCreationTokens?: number;
28
29
  costUsd?: number;
29
30
  raw?: unknown;
30
31
  }
@@ -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 = store !== null;
289
+ this.durableAdmission = false;
282
290
  if (this.store) {
283
- try {
284
- this.store.registerInstance({
285
- instanceId: this.instanceId,
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?.gcInstances(this.lease.instanceGcMs);
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 = false;
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
- const ASCII_ONLY = /^[\x09\x0d\x20-\x7e]*$/;
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 (!ASCII_ONLY.test(body)) {
74
+ if (!isSingleColumnAsciiFrame(body)) {
65
75
  out.push(line);
66
76
  return;
67
77
  }