Status (cost limit): shipped in 3.53.0, hardened through 3.54.11.
Backend coverage: enforcement is backend-agnostic — see the Backend Coverage Matrix for how cost is sourced (provider-stamped vs local-pricing fallback) for each of llm-router, llm-local-router/llm-provider, OpenRouter, and direct upstream providers, and Known Gaps by backend for the residual cases where cost is unknowable and the cap therefore can't trip.
A user-configurable USD spend cap, scoped to the root task, with
subtask costs aggregated into the root via the existing
aggregateTaskCostsRecursive helper. Implements automatic
pause / abort / kill when a task's cumulative cost (root + all
descendant subtasks) reaches the configured limit.
How Shofer tracks API cost, token usage, and displays totals in the chat UI.
Shofer computes the total cost for a task by aggregating token usage and pricing data from every AI provider API call made during the conversation, plus any context condensation costs. The total is displayed in the TaskHeader at the top of the chat window.
flowchart TD
RESP["Provider API response"]
CHUNK["stream chunks — usage type<br/>inputTokens, outputTokens,<br/>cacheWriteTokens, cacheReadTokens, totalCost"]
UPD["updateApiReqMsg()<br/>stamps the api_req_started message text as JSON"]
CONS["consolidateTokenUsage()<br/>sums every api_req_started<br/>and condense_context message"]
HDR["TaskHeader — total cost display"]
RESP --> CHUNK --> UPD --> CONS --> HDR
| File | Role |
|---|---|
Task.ts |
Emits api_req_started, accumulates usage during streaming, calls updateApiReqMsg() |
consolidateTokenUsage.ts |
Aggregates all api_req_started and condense_context messages into a TokenUsage total |
ChatRow.tsx |
Renders (or hides) the per-request api_req_started row in the chat |
TaskHeader.tsx |
Displays the aggregated total cost |
cost.ts |
Provider-specific pricing functions (calculateApiCostAnthropic, calculateApiCostOpenAI, applyCustomPricing) |
api/index.ts |
buildApiHandler — wraps getModel() to apply customPricing overrides when set |
provider-settings.ts |
customPricing schema field on baseProviderSettingsSchema |
When Shofer is about to call the AI provider, it emits a placeholder api_req_started message:
// Task.ts line ~3418
await this.say(
"api_req_started",
JSON.stringify({ apiProtocol, model: modelId, retryAttempt: currentItem.retryAttempt ?? 0 }),
)At this point the message has no cost or token data — just the protocol ("anthropic" or "openai"), model ID, and retry attempt counter.
As the provider streams its response, Shofer receives periodic "usage" chunks that carry token counts:
// Task.ts lines 3735-3741
case "usage":
inputTokens += chunk.inputTokens
outputTokens += chunk.outputTokens
cacheWriteTokens += chunk.cacheWriteTokens ?? 0
cacheReadTokens += chunk.cacheReadTokens ?? 0
totalCost = chunk.totalCostThe updateApiReqMsg() function (Task.ts line 3554) stamps the accumulated usage into the api_req_started message's text field:
this.shoferMessages[lastApiReqIndex].text = JSON.stringify({
...existingData,
tokensIn: costResult.totalInputTokens,
tokensOut: costResult.totalOutputTokens,
cacheWrites: cacheWriteTokens,
cacheReads: cacheReadTokens,
cost: totalCost ?? costResult.totalCost, // provider-reported or Shofer-calculated
})Cost is calculated using provider-specific functions:
- Anthropic protocol:
calculateApiCostAnthropic - OpenAI protocol:
calculateApiCostOpenAI
updateApiReqMsg() is called:
- During the stream from
drainStreamInBackgroundToFindAllUsage(captures usage even on interruptions) - At the end of the stream
- On abort/cancellation (with
cancelReason)
consolidateTokenUsage() walks all messages and sums:
| Source | Fields aggregated |
|---|---|
api_req_started messages |
tokensIn, tokensOut, cacheWrites, cacheReads, cost |
condense_context messages |
contextCondense.cost |
// consolidateTokenUsage.ts lines 40-71 (simplified)
messages.forEach((message) => {
if (message.type === "say" && message.say === "api_req_started" && message.text) {
const parsedText = JSON.parse(message.text)
const { tokensIn, tokensOut, cacheWrites, cacheReads, cost } = parsedText
if (typeof tokensIn === "number") {
result.totalTokensIn += tokensIn
}
if (typeof tokensOut === "number") {
result.totalTokensOut += tokensOut
}
if (typeof cacheWrites === "number") {
result.totalCacheWrites = (result.totalCacheWrites ?? 0) + cacheWrites
}
if (typeof cacheReads === "number") {
result.totalCacheReads = (result.totalCacheReads ?? 0) + cacheReads
}
if (typeof cost === "number") {
result.totalCost += cost
}
} else if (message.type === "say" && message.say === "condense_context") {
result.totalCost += message.contextCondense?.cost ?? 0
}
})- Every AI provider API call — each turn that invokes the model (including tool call responses) creates an
api_req_startedmessage - Context condensation — the cost of running the summarization model to condense conversation history
- Cancelled/aborted requests — partial cost is preserved via
updateApiReqMsg(cancelReason, ...) - Background-subtask requests — aggregated into the parent task's total (shown with
*indicator for subtask-inclusive totals)
Orphaned api_req_started messages — if a request was started (api_req_started emitted) but the extension crashed or the task was force-closed before ANY response data arrived, the message has no cost and no cancelReason. These are removed during saveShoferMessages():
// Task.ts lines 2517-2519
if (cost === undefined && cancelReason === undefined) {
modifiedShoferMessages.splice(lastApiReqStartedIndex, 1)
}This means tokens from a request that was initiated but never received any response bytes are lost from the total. In practice this only happens on hard crashes or force-quits.
As of the current implementation:
- Per-request "API Request" rows are hidden on success (cost present, no cancel reason) to avoid chat clutter — same pattern as
tool_preparingdismissal - Failure/cancellation rows remain visible ("API Request Failed", "API Request Cancelled", "API Streaming Failed")
- Total cost in the TaskHeader aggregates all
api_req_startedcosts regardless of whether individual rows are hidden
Parent tasks aggregate costs from all child subtasks. The TaskHeader displays:
totalCost— this task's own API costsaggregatedCost— this task + all subtask costs (whenhasSubtasksis true)
A * indicator shows that the total includes subtask costs.
The aggregate itself comes from aggregateTaskCostsRecursive,
which walks HistoryItem.childIds and sums tokensIn/tokensOut alongside
totalCost. ChatView requests it for any task with childIds via the
getTaskWithAggregatedCosts message, and the host answers on
taskWithAggregatedCosts with aggregatedCosts: { totalCost, ownCost, childrenCost, tokensIn, tokensOut }. Summing tokens as well as cost is what lets
a parent that makes few calls of its own show the tree's real usage rather than
its own near-empty count.
Two paths converge on the same totalCost field in the usage chunk, which feeds the in-stream gate and the api_req_started message that consolidateTokenUsage sums.
shofer.llm.getModelPricing → vscode-lm.getModel().info
(inputPrice / outputPrice / cacheReadsPrice) →
calculateApiCostOpenAI → shoferMessages[lastApiReqIndex].cost.
Landed in llm-provider 0.6.0 / Shofer 3.52.87.
Used as the fallback whenever the usage chunk carries no
totalCost (Path 2 not available). Does not differentiate
cache-hit vs cache-miss tokens because the vscode-lm provider counts
tokens locally (VS Code LM API) and has no per-token cache metadata.
llm-router computes and stamps usage.cost (USD float, OpenRouter
convention) on the final streaming chunk →
llm-provider accumulates per-taskId in a bounded LRU
ledger → vscode-lm snapshots the ledger before and after the stream,
yielding the delta as totalCost in the usage chunk.
Originally added for composite (shofer/*) models where the
underlying is selected per-attempt. Now applied universally to
every provider whose upstream returns a usage object in the
streaming response (OpenAI, Google, Zhipu, Xiaomi, Moonshot, MiniMax,
DeepSeek). The router normalises the several upstream spellings of cached-token usage
before pricing, and assembles Anthropic's split streaming usage into a single
synthetic chunk. Those mechanics belong to llm-router and are documented there
(llm-router/DESIGN.md § Cost stamping) rather than restated here.
When Path 2 is active, totalCost arrives in the chunk and
calculateApiCostOpenAI is NOT called — the chunk's value wins
(totalCost ?? costResult.totalCost in Task.ts). This avoids
double-counting.
Path 2 always wins when totalCost is present in the usage chunk
(ledger delta available). Path 1 is the fallback, still needed for:
- Non-streaming requests —
handleNonStreamingRequestdoes not stampusage.coston the response (only the streaming path does). Non-streaming requests always use Path 1. - Unknown models — if
GetModelByIDreturns nil (model not inmodel_registry.go),stampUsageCostreturns the chunk unchanged and Path 1 takes over. - Defense in depth — if stamping fails for any reason (malformed chunk, pricing not available), Path 1 provides a reasonable estimate.
Without either path, vscode-lm-routed models report cost: 0 and the
budget limit can never trip — this is by design (we only enforce on
real billed cost), but worth flagging to users debugging "why isn't my
limit firing?".
Users can supply explicit per-token prices (USD / 1 M tokens) in Settings → Providers → Advanced Settings → Pricing Override. The four configurable fields are:
| Field | Overrides |
|---|---|
inputPrice |
ModelInfo.inputPrice |
outputPrice |
ModelInfo.outputPrice |
cacheReadsPrice |
ModelInfo.cacheReadsPrice |
cacheWritesPrice |
ModelInfo.cacheWritesPrice |
How it works:
customPricing is stored as an optional field in ProviderSettings
(schema: baseProviderSettingsSchema.customPricing). When
buildApiHandler constructs a handler, it wraps the underlying
handler's getModel() with a thin closure:
// packages/core/src/api/index.ts
raw.getModel = () => {
const m = rawGetModel() // auto-discovered
return { id: m.id, info: applyCustomPricing(m.info, customPricing) }
}applyCustomPricing (in src/shared/cost.ts) merges only the fields
that are set to a numeric value; undefined fields are silently
skipped and the auto-discovered value is kept:
return { ...modelInfo, ...overrides } // custom values overwrite auto-discoveredPriority across all three paths:
customPricing (Path 3) overrides the ModelInfo returned by
getModel(), which is what Path 1 prices against. Path 2 wins over
Path 1, but it does not "bypass" Path 3: customPricing affects
ModelInfo only, so when Path 2 delivers totalCost directly the
custom prices have no effect on that value.
flowchart TD
CP["Path 3 — customPricing<br/>per-provider-profile override"]
GM["getModel() wrapped by buildApiHandler<br/>applyCustomPricing(info, customPricing)"]
P1["Path 1 — static per-token pricing<br/>calculateApiCostAnthropic / calculateApiCostOpenAI"]
P2["Path 2 — usage.cost stamped by llm-router<br/>arrives as the chunk's totalCost"]
PICK{"totalCost present<br/>in the usage chunk?"}
USE2["chunk value wins"]
USE1["locally computed costResult.totalCost"]
COST["cost field on api_req_started"]
CP --> GM --> P1 --> PICK
P2 --> PICK
PICK -->|yes| USE2
PICK -->|no| USE1
USE2 --> COST
USE1 --> COST
CP -.->|"no effect once Path 2 supplies totalCost"| USE2
In practice: if customPricing is set and Path 2 is active (the
router stamps totalCost), the router's value is used as-is and the
custom prices have no effect. Custom prices are most useful for
providers where Path 2 is unavailable (non-streaming, unknown models,
or providers not routed through llm-router).
Backward compatibility: customPricing is fully optional. Existing
profiles without the field behave exactly as before — getModel() is
not wrapped and auto-discovery runs unchanged.
The cost-limit machinery in Task.ts is backend-agnostic by
construction: it never special-cases a provider. Enforcement depends
on exactly two values being correct for whichever backend serves the
traffic:
usagechunktotalCost— feeds the tight in-stream gate (checkInFlightCostLimit). Either the backend stamps it, orestimateRequestCostUsdcomputes it locally from token counts ×getModel().infopricing.- persisted
cost(totalCost ?? calculateApiCost*) — feeds the post-stream aggregate (checkCostLimit) and the TaskHeader total viaconsolidateTokenUsage.
If a backend supplies a usable cost on either axis, limits enforce.
The only way enforcement can silently fail is when a request's cost is
genuinely 0/unknown on both axes — i.e. the backend stamps no
cost AND getModel().info has no pricing. The matrix below maps each
backend the user can route through to its cost source.
| Backend (as the user sees it) | Shofer provider / path | Stamps usage.totalCost? |
Local-pricing fallback works? | In-stream gate | Post-stream gate |
|---|---|---|---|---|---|
| Direct upstream — Anthropic | anthropic.ts |
✅ calculateApiCostAnthropic on final chunk |
✅ (self-computed) | ✅ | ✅ |
| Direct upstream — OpenAI (native) | openai-native.ts |
✅ calculateApiCostOpenAI (tier-aware) |
✅ | ✅ | ✅ |
| Direct upstream — Gemini | gemini.ts |
✅ unless model lacks pricing | ✅ priced models | ✅ priced models | ✅ priced models |
| Direct upstream — OpenAI-compatible | openai.ts |
❌ never stamps | ✅ only if custom model info has prices (sane defaults = 0) | ✅ via fallback (was ❌ pre-fix) | ✅ if priced |
| Direct upstream — Bedrock | bedrock.ts |
❌ never stamps | ✅ via fallback for priced (was ❌) | ✅ if priced | |
| Direct upstream — DeepSeek | deepseek.ts |
❌ never stamps | ✅ deepSeekModels has real prices |
✅ via fallback (was ❌) | ✅ |
| OpenRouter (direct) | openrouter.ts |
✅ provider-reported cost + upstream_inference_cost |
✅ when OR returns cost | ✅ | |
llm-router (local) — shofer/* & routed models |
shofer.ts (extends OpenRouter handler) |
✅ iff llm-router stamps usage.cost |
inputPrice: 0 |
✅ when stamped | ✅ when stamped |
| llm-local-router / llm-provider (VS Code LM) | vscode-lm.ts |
✅ iff enableLlmProviderIntegration and llmLocalRouter.getRequestCost resolves |
0 when integration off |
✅ when integration on | ✅ when integration on |
Legend: ✅ enforced, estimateRequestCostUsd fallback (this change).
These are the residual cases where cost can be 0/unknown on both
axes, so the cap cannot trip. None are silent regressions of the
fallback — they are inherent "we can't price it" situations, listed so
operators debugging "my limit never fired" know where to look.
Shofer-extension side (enforcement chokepoint):
-
Unpriced models — any provider whose
getModel().infohas noinputPrice/outputPriceAND stamps nototalCostreports0. Affects: rawopenai.tsagainst an OpenAI-compatible endpoint with no custom model info (sane defaults carry0prices); custom-ARN orguessModelInfoFromIdBedrock models;vscode-lmwhenenableLlmProviderIntegrationis off (default) — both the cost side-channel and the pricing side-channel returnundefined, soinfo.inputPrice/outputPricedefault to0. Mitigation: set acustomPricingoverride (Path 3) so the fallback has real prices. -
Composite
shofer/*models — theirModelInfofrequently carriesinputPrice: 0(the real price is per-attempt and only known after the router selects an upstream). When llm-router stampsusage.cost(the normal case) this is fine; if stamping is missing the local fallback is0. Depends on llm-router behavior below.
llm-router side (cost accuracy when traffic transits the router). Three
paths through the router emit no usage.cost at all — non-streaming requests,
OpenRouter-routed traffic, and an inconsistently-applied registry discount. They
are defects in that service rather than in Shofer, and they are recorded where
they can be fixed: llm-router/TODO.md § Cost stamping, with the mechanism in
llm-router/DESIGN.md § Cost stamping. What matters here is only the
consequence: when the router does not stamp, Path 1 (local estimate) carries the
cap, and an unpriced model leaves it at 0.
- The live cost path recomputes; it does not read the router's
stamped
usage.cost.vscode-lm.tscalls thellmLocalRouter.*commands (registered by the llm-local-router extension), gated behind the setting namedenableLlmProviderIntegration(the documented "naming wart" — see Operational Dependencies). llm-local-router's ledger fills from a localcomputeCost(model, tokens…)against its own registry, not from the upstreamusage.cost. (The separatellm-providerextension's ledger does readusage.cost, butvscode-lm.tsdoes not call itsshofer.llm.*commands, so that path is dead for this consumer.) Consequence: if llm-local-router's registry is missing a served model,computeCostreturns0and the ledger stays0forever even when llm-router stamped a correct cost —getRequestCostthen yields a0delta and the cap can't trip via this backend. Recommended fix: have llm-local-router prefer the upstream-stampedusage.costwhen present, falling back to local recompute only when absent (i.e. converge its behavior with llm-provider's).
Shofer already surfaces a per-task API Cost and an aggregatedCost
that rolls subtask spend into the root task (see
aggregateTaskCosts.ts and
the getTaskWithAggregatedCosts IPC path in
webviewMessageHandler.ts).
But there was no enforcement: a runaway agentic loop on a frontier
model — or a poorly-bounded new_task tree — could quietly burn
through real money with no upper bound.
- Per-root-task USD budget cap that the user can set:
- Globally (default for all new tasks) via the
defaultCostLimitglobal setting. - Live-editable on a running task (raise or lower) via the
pencil affordance next to the API Cost row in
TaskHeader.
- Globally (default for all new tasks) via the
- Cost accounting reuses the same
aggregatedCostview that the UI already shows (root + all descendant subtasks, recursive). - Three configurable behaviours when the limit is hit:
pause— interrupts the streaming loop and surfaces anask: budget_limitwith three outcomes:- Continue without limit (yes button) — sets a per-root bypass flag for the rest of the task; no further checks fire.
- Abort task (no button) — calls
root.abortTask(false); subtasks die via the existing recursive abort path. - New limit (free-text reply with a positive dollar
amount, e.g.
0.10or$2.50) — replaces the root'smaxUsdwith the user-supplied value and persists it to history. Non-numeric input falls back to "continue without limit" so we never silently ignore the user.
abort—root.abortTask(false)(clean abort: graceful stream teardown, error diagnostics persisted, user input salvaged).kill—root.abortTask(true)(abandoned: skips stream teardown, drops error reporting and in-flight input; suppresses unhandled rejections to avoid host-process crashes). Intended for headless / CLI / evals. See Design → Where the check fires for a detailed breakdown of the four behavioral differences.
- Subtasks: when a
new_taskwould push the root's aggregated cost over the limit, the spawn is refused with a tool error. - Telemetry: emits
BUDGET_EXCEEDEDwith{rootTaskId, limitUsd, spentUsd, action, modelId}.
- Per-day or per-organisation spend caps (belongs in
llm-router, not in the editor extension). - Token-count limits (
maxTokens/ context-window pressure is handled bycondenseContext). - Hard cost prediction before a request is made — enforcement is post-hoc on the running aggregate, not pre-flight.
-
New optional field on
HistoryItem(persisted) and onTask(in-memory), stored only on the root task:// packages/types/src/history.ts export const costLimitSchema = z.object({ maxUsd: z.number().positive(), action: z.enum(["pause", "abort", "kill"]), })
Subtasks never carry their own
costLimit. The check always resolves toroot.costLimitby walking theparentTaskchain inTask.resolveCostLimit(). -
Global setting
defaultCostLimitinglobalSettingsSchema(same shape). Applied to the root task at creation time inShoferProvider.createTask()viacontextProxy.getValue("defaultCostLimit"). See also:configuration.mdfor allshofer.*settings.
Two chokepoints inside the streaming loop in
Task.ts, both awaited so the abort
flag is observed before the next chunk is yielded — otherwise we'd
keep burning tokens past the cap for the remainder of the stream.
A third chokepoint sits in NewTaskTool, before a child is
constructed.
flowchart TD
START["API request starts"]
SNAP["snapshotPriorAggregateForCostLimit()<br/>resolveCostLimit() walks parentTask to the root<br/>_priorAggregateUsd = aggregateTaskCostsRecursive(root)"]
STREAM["streaming loop"]
SRC["currentRequestCostUsd =<br/>chunk.totalCost, else estimateRequestCostUsd(...)"]
IN{"checkInFlightCostLimit<br/>_priorAggregateUsd + currentRequestCostUsd >= maxUsd?"}
DRAIN["drainStreamInBackgroundToFindAllUsage"]
POST{"checkCostLimit(requestIndex)<br/>aggregateTaskCostsRecursive(root) >= maxUsd?"}
ENF["enforceCostLimit(root, limit, spent)"]
NEXT["keep streaming"]
START --> SNAP --> STREAM
STREAM -->|"each usage chunk"| SRC --> IN
IN -->|no| NEXT --> STREAM
IN -->|yes| ENF
STREAM -->|"request finished"| DRAIN --> POST
POST -->|no| NEXT
POST -->|yes| ENF
NT["new_task — NewTaskTool"]
NTC{"aggregated.totalCost >= maxUsd?"}
NTC -->|yes| TE["refuse with a tool error"]
NTC -->|no| SPAWN["construct the child task"]
NT --> NTC
1. In-stream gate (checkInFlightCostLimit(currentRequestCostUsd)),
fired on every usage chunk during the main streaming loop:
- At the start of each API request,
snapshotPriorAggregateForCostLimit()records_priorAggregateUsd = aggregateTaskCostsRecursive(root.taskId, …)— the spend across the root's history, BEFORE this request's own usage is added. Resets the per-request enforcement latch. - On every
usagechunk, computespent = _priorAggregateUsd + currentRequestCostUsd, wherecurrentRequestCostUsd = chunk.totalCost ?? estimateRequestCostUsd(…). When the backend stamps a per-requesttotalCost(anthropic, openai-native, gemini, openrouter, llm-router/llm-local-router via vscode-lm) the chunk value is used. When it does NOT (openai.ts, bedrock.ts, deepseek.ts, raw OpenAI-compatible endpoints), the gate falls back to a local-pricing estimate computed from the accumulated token counters via the same protocol-awarecalculateApiCost{Anthropic,OpenAI}mathupdateApiReqMsguses for the persistedcostfield. This is what makes the tight cap enforce regardless of which backend serves the traffic — seeestimateRequestCostUsdand the Backend Coverage Matrix. - Bypass if
_costLimitBypassed,_costLimitEnforcementFiredForRequest, or no snapshot. - If
spent >= limit.maxUsd, latch the per-request flag and callenforceCostLimit(root, limit, spent)(shared with the post-stream check).
This is what makes the cap tight — a single expensive completion can't silently blow past a small limit (e.g. $0.05) before the post-stream check fires, because the abort/pause is triggered as soon as the running spend crosses the cap.
Before the fallback (≤ 3.54.10):
checkInFlightCostLimitno-opped wheneverchunk.totalCostwasundefined. The in-stream gate therefore only ever fired for backends that self-report cost; for openai.ts / bedrock.ts / deepseek.ts the tight cap was dead and enforcement degraded to the post-stream boundary, where a single expensive completion could already have blown past a small limit. The local-pricing fallback closes that gap. The estimate is0only when the model carries no pricing info at all (then nothing fires — by design we only cap real, priced spend; see Known Gaps).
2. Post-stream gate (checkCostLimit(requestIndex)), fired
from drainStreamInBackgroundToFindAllUsage after the request
finishes — catches cases where usage arrives only at the very end
or after stream drain. Behaves identically:
- Bypass if
_costLimitBypassedis set on the root. - Bypass if
_costLimitCheckCache.requestIndex === requestIndex(already evaluated for this request — avoids repeated history scans inside one stream). - Resolve
{root, limit}viaresolveCostLimit(). - If
limitis unset ormaxUsd <= 0, return early. spent = aggregateTaskCostsRecursive(root.taskId, …).totalCost(failures are logged viaprovider.log()and treated as "don't block"; we err on the side of not interrupting the user).- Cache
{spent, requestIndex}. - If
spent < limit.maxUsd, return. - Otherwise call
enforceCostLimit(root, limit, spent).
enforceCostLimit(root, limit, spent) (shared):
- Emit
TelemetryEventName.BUDGET_EXCEEDED. - Branch on
limit.action:pause→askUserForBudgetDecision(root, limit, spent).abort→ cancel in-flight request,await this.abortTask(false)and root if different.kill→ cancel in-flight request,await this.abortTask(true)and root if different.
flowchart TD
ENF["enforceCostLimit(root, limit, spent)"]
TEL["TelemetryService.captureBudgetExceeded<br/>TelemetryEventName.BUDGET_EXCEEDED"]
ACT{"limit.action"}
PAUSE["askUserForBudgetDecision(root, limit, spent)<br/>ask: budget_limit"]
AB["cancel in-flight request<br/>abortTask(false)"]
KI["cancel in-flight request<br/>abortTask(true) — abandoned"]
YES["Continue without limit<br/>root._costLimitBypassed = true"]
NO["Abort task<br/>root.abortTask(false)"]
NEW["free-text positive USD amount<br/>replaces maxUsd, persisted to history"]
ENF --> TEL --> ACT
ACT -->|pause| PAUSE
ACT -->|abort| AB
ACT -->|kill| KI
PAUSE -->|"yes button"| YES
PAUSE -->|"no button"| NO
PAUSE -->|"text reply"| NEW
PAUSE -.->|"non-numeric text"| YES
Both abort and kill call the same abortTask() method, differing
only in the isAbandoned boolean parameter. kill sets
this.abandoned = true inside abortTask(), which has four practical
effects beyond changing the TaskAborted reason from "user" to
"abandoned":
-
Skips graceful HTTP stream abort — under
abort(isAbandoned=false), the streaming loop callsabortStream("user_cancelled")to cleanly tear down the HTTP connection before proceeding. Underkill(isAbandoned=true), this call is skipped entirely (Task.ts line 4415). If the provider's stream is hanging (e.g. OpenRouter), the dangling stream is orphaned with no graceful-shutdown handshake. -
Drops in-flight error diagnostics — under
abort, any error caught during the streaming loop is persisted viasnapshotApiReqError()andapi_req_failedis emitted to the chat UI so the user can see what happened. Underkill, all error reporting is suppressed (Task.ts line 4632). -
Silently discards in-flight user input — under
abort, if a typed message arrives mid-abort but after the ask cleared,handleWebviewAskResponseprepends it back to the message queue (Task.ts line 2320). Underkill, the message is silently dropped. -
Suppresses unhandled rejections — three catch blocks in
startTask()and the resume/cancellation race handler (Task.ts lines 2849, 2857, 3191) usethis.abandonedas a guard to swallow errors that would otherwise become unhandled promise rejections and crash the VS Code extension host process. Underabort, errors may still re-throw in narrow timing windows (beforethis.abortis set). Underkill, theabandoned=trueflag guarantees all three catch blocks swallow.
In summary: kill tears down the task tree identically to abort
(disposal, background children, MCP calls, cost flush) but omits every
user-facing and stream-facing graceful step. It is designed for
automated / eval / headless scenarios where there is no user to show
errors to and hanging streams should be left to time out on their own.
A third chokepoint guards new_task in
NewTaskTool.ts: before
constructing the child, walk to the root, aggregate costs, and refuse
with a tool error if aggregated.totalCost >= limit.maxUsd.
TaskHeader.tsxshows$spent / $limitnext to the existing API Cost row whencostLimitis set, with a pencil icon that opensBudgetLimitDialog.tsxfor live editing.- The pause-mode
askis wired to ChatView's existing primary / secondary button infrastructure (yes = "Continue without limit", no = "Abort task", free-text reply with a positive dollar amount = new limit). No separate dialog component is needed for the ask itself. - ChatView owns the
updateCostLimitpostMessage and passes a callback down via theonUpdateCostLimitprop, so TaskHeader doesn't talk to the host directly.
costLimitround-trips throughtaskMetadata.tsalongsidetotalCost.- The
Taskconstructor restoreshistoryItem.costLimitonly whenparentTaskis unset, enforcing the "single source of truth on the root" invariant even if a malformed history item carried the field on a subtask.
TelemetryService.captureBudgetExceeded(taskId, {rootTaskId, limitUsd, spentUsd, action, modelId}) emits the
TelemetryEventName.BUDGET_EXCEEDED event before the action runs.
- Schema additions in
@shofer/types:budgetActionSchema,costLimitSchema,historyItem.costLimit,globalSettings.defaultCostLimit,shoferAsks.budget_limit(also added tointeractiveAsks),WebviewMessage.updateCostLimit+costLimitfield,TelemetryEventName.BUDGET_EXCEEDED. - Core enforcement in
Task.ts:costLimitfield,_costLimitCheckCache,_costLimitBypassed,_priorAggregateUsd,_costLimitEnforcementFiredForRequest,resolveCostLimit(),invalidateCostLimitCache(),snapshotPriorAggregateForCostLimit()(per-request snapshot),checkInFlightCostLimit()(per-usage-chunk in-stream gate),estimateRequestCostUsd()(local-pricing fallback so the in-stream gate enforces for backends that don't stamptotalCost— see Backend Coverage Matrix),checkCostLimit()(post-stream gate fromdrainStreamInBackgroundToFindAllUsage), sharedenforceCostLimit(),askUserForBudgetDecision()(yes = "Continue without limit" / no = "Abort task" / text = new positive USD limit). -
new_tasktool guard. - Default-limit seeding in
ShoferProvider.createTask(). -
webviewMessageHandler.tsupdateCostLimithandler that walks to root, updates the liveTask, invalidates the cache, and persists to history. - UI: TaskHeader inline
$spent / $limit+ pencil affordance visible from task start (default seeded immediately, not on first request),BudgetLimitDialogfor live editing, ChatView wiring of thebudget_limitask to primary/secondary buttons with "Continue without limit" / "Abort task" labels. - Persistence round-trip via
taskMetadata.ts. - Telemetry event.
- Unit tests: parent-walk semantics + recursive cost aggregation
(
cost-limit.spec.ts).
- 3.54.1 — Suppress webview
Ctrl+Fforwarding to host find widget. - 3.54.2 —
pause-mode hard-stop on exceed (don't keep yielding "Cost limit reached: $X.XX of $Y.YY" messages); surface the seeded default cap immediately on task start so the row is visible from request 1. - 3.54.5 — Removed the hard-coded
+ $5increment on the pause dialog (which produced absurdities like0.04 + 5 = 5.04for tight budgets). The yes button now means "Continue without limit"; to raise the cap the user types a new positive USD amount in the chat reply. - 3.54.6 — Added the in-stream
checkInFlightCostLimitgate (above). Previously enforcement only ran from the fire-and-forgetdrainStreamInBackgroundToFindAllUsage, so the main loop kicked off the next request before the budgetasksurfaced — meaning a tight cap could be exceeded several times over before the prompt appeared. The in-stream gate fires on everyusagechunk and cancels the in-flight HTTP request as soon as the running spend crosses the cap. - 3.54.7 — Fixed cumulative-vs-per-request cost mismatch in the
vscode-lmprovider.shofer.llm.getRequestCostreturns the running ledger total for the whole conversation, but Shofer's pipeline expects eachusagechunk'stotalCostto be the cost of THIS request only (it gets stored on theapiReqInfomessage and re-summed byconsolidateTokenUsage). The provider now snapshots the ledger before the request and yields the delta, so per-message accounting and the in-stream gate'sprior + thisReqmath are both correct. Without this fix the cap could either fire wildly early (ledger over-counted across consolidate) or — when the ledger never moved past zero (e.g. composite pricing miss) — never fire at all. Also added[DIAG cost-limit]provider-log output at snapshot/in-flight/enforce points so future "exceeded without stopping" reports are debuggable from the output channel. - 3.54.8 – 3.54.10 — Iterated on the composite-cost path end-to-end
to confirm the in-stream gate fires for
shofer/*models. Validated in production logs:[vscode-lm] cost ledger: before=0.005237 after=0.015193 perRequest=0.009956followed immediately by[DIAG cost-limit] in-flight: prior=0.005237 + thisReq=0.009956 = spent=0.015193, limit=0.01, willFire=trueand[DIAG cost-limit] enforce: action=pause, spent=0.015193, limit=0.01. Pairs withllm-router0.8.9 (forcesstream_options.include_usage=trueso OpenAI-compatible upstreams emit the final usage chunk that carries the stampedusage.cost) andllm-provider0.6.1 (per-conversation cost ledger andshofer.llm.getRequestCostcommand). - 3.54.11 — In-stream gate now enforces regardless of backend.
checkInFlightCostLimitpreviously no-opped whenever the backend didn't stampchunk.totalCost, so the tight cap was dead foropenai.ts/bedrock.ts/deepseek.ts(and any OpenAI-compatible endpoint) — enforcement degraded to the post-stream boundary, where a single expensive completion could already have blown past a small limit. AddedestimateRequestCostUsd(): when the chunk carries nototalCost, the gate falls back to a local-pricing estimate from the accumulated token counters (same protocol-awarecalculateApiCost{Anthropic,OpenAI}mathupdateApiReqMsguses for the persisted cost), so the in-stream and post-stream gates agree on what an un-stamped request costs. The estimate is0only for models with no pricing info (then nothing fires — by design we only cap real, priced spend). New unit tests lock the fallback contract (positive estimate for priced models,0for unpriced) incost-limit.spec.ts. See the Backend Coverage Matrix and Known Gaps by backend for the full per-backend picture, including the residual llm-router-side gaps (non-streaming not stamped, OpenRouter uncosted, discount divergence) that remain open.
These items from the original spec did not ship in 3.53.0 and remain follow-ups:
- Per-task cap input on the New Task creation flow (today the cap can only be set globally or live-edited mid-task).
- Settings panel UI for the global default — the
defaultCostLimitschema is wired but no settings-pane row exists yet; users have to set it via JSON. - Resume-into-already-exceeded-task: today the check fires on
the next API request after resume. A pre-flight check at task
restore would surface the
askimmediately. - "Soft" warning at 80% of the cap before the hard action at 100%.
- Integration tests for each of
pause/abort/killend-to-end (the heavyTask.spec.tsharness was left untouched; current tests cover the pure pieces). Behavioral differences betweenabortandkillare documented in Design → Where the check fires. - Re-entrancy guard around concurrent parallel subtasks racing
checkCostLimit(today the cache + per-request index eliminates intra-task races, but cross-subtask parallel execution can in principle have multiple racers all observespent >= limitand each fire the action; only the firstabortTaskmatters in practice but the behaviour is not formally specified). -
AGENTS.md /→ Done: Operational Dependencies section documents both cost paths end-to-end, coveringextensions/llm-provider/README.mddoc updates mentioning the dependency onshofer.llm.getModelPricing.shofer.llm.getModelPricing,shofer.llm.getRequestCost, and the llm-router cost-stamping pipeline.
The llm-provider integration is opt-in and controlled by the
shofer.enableLlmProviderIntegration setting (default: false).
When disabled, Shofer operates without the llm-provider — token counts
are available but USD pricing and cost-limit enforcement are not.
When enabled, both cost paths depend on well-known VS Code commands
registered by the Shofer LLM Model Provider extension
(extensions/llm-provider/):
| Command | Registers in | Consumed by | Role |
|---|---|---|---|
llmLocalRouter.getModelPricing |
llm-local-router/main.ts |
vscode-lm.ts |
Path 1: per-token USD rates for calculateApiCostOpenAI |
llmLocalRouter.getRequestCost |
llm-local-router/main.ts |
vscode-lm.ts |
Path 2: per-conversation cumulative USD cost |
llmLocalRouter.getModelCapabilities |
llm-local-router/main.ts |
vscode-lm.ts |
Tool calling, image input, prompt cache flags |
Naming wart:
vscode-lm.tsactually calls thellmLocalRouter.*commands registered by thellm-local-routerextension (verified in source), not theshofer.llm.*commands ofllm-provider— even though the gating setting is namedenableLlmProviderIntegration. Both extensions register the same logical commands under different namespaces; this is an unresolved architectural inconsistency (seeimages.md).
If the llm-provider extension is not installed, not activated,
or its command names don't match what the vscode-lm provider
expects, both cost paths silently return undefined. The consequence:
totalCost stays at $0 for every request, consolidateTokenUsage
reports zero, and the budget limit can never trip.
Diagnostics (v3.56.x+): When enableLlmProviderIntegration is
enabled, the vscode-lm provider logs a one-shot warning to the Shofer
output channel when any of these commands fails:
[vscode-lm] shofer.llm.getModelPricing command not found — is the Shofer LLM Model Provider extension installed and active?
[vscode-lm] shofer.llm.getRequestCost command not found — is the Shofer LLM Model Provider extension installed and active?
If you have enabled the integration but still see cost: $0 for every
request and the budget limit never trips, check the Shofer output
channel for these messages.
These are accuracy and maintainability issues discovered during the May 2026 doc-review verification (all corrected inline).
When originally written, Task.ts was ~3,250 lines. By May 2026 it
had grown to 6,122 lines. Every code-example line number was off by
170–300 lines. There is no automated mechanism to detect this drift
other than a manual grep-and-compare pass.
Corrected references:
| Symbol | Doc claimed | Actual (May 2026) |
|---|---|---|
api_req_started emission |
~3150 | 3418 |
case "usage": |
3438-3441 | 3735-3741 |
updateApiReqMsg() definition |
3261 | 3554 |
| Orphan cleanup check | 2344-2350 | 2517-2519 |
- The
api_req_startedexample omitted themodel: modelIdandretryAttemptfields present in the actual source. - The
consolidateTokenUsage.tsexample omitted themessage.type === "say"guard and thetypeofnumeric checks that the real code uses for defensive parsing.
- Add a CI/lint rule or script that verifies doc line numbers against current source (similar to a link checker).
- Mark code examples that are simplified with a visible
"(simplified)" annotation (already done for
consolidateTokenUsage.tsin this review). - Consider using symbol names (e.g.
#updateApiReqMsg) instead of line numbers in doc anchors, since line numbers drift but function names are stable.
Shipped as Shofer 3.53.0 (minor bump): new user-visible
setting, new ask type, new persisted field on HistoryItem. No
backward-compat shims — missing costLimit is treated as "no limit".
Hardened across 3.54.1 – 3.54.11 (see "Bug fixes since
3.53.0" above). 3.54.11 made the in-stream gate backend-agnostic
via the estimateRequestCostUsd local-pricing fallback — a behavior
change for backends that don't stamp totalCost (the tight cap now
fires for them too), with no schema or persistence change.