feat: add economy/maximum presets, tool-lab and subagent-cursor extensions
Some checks failed
CI / windows node 24 / wine blocking (push) Has been skipped
CI / node 22.19 (push) Has been skipped
CI / node 26 (push) Has been skipped
CI / python 3.10 / keyless SDK (push) Has been skipped
CI / python runtime / release-shaped Linux x64 (push) Has been skipped
CI / wine apt cache (push) Successful in 7s
CI / serial / linux (push) Has been skipped
Deploy documentation / build (push) Failing after 1m25s
Deploy documentation / deploy (push) Has been skipped
Landlock Run / Matrix (push) Successful in 5s
Release (vendor) / Pack npm tarballs (push) Failing after 2m47s
Release (dsh) / Pack npm tarballs (push) Failing after 1m56s
Sandbox / sandbox e2e (landlock, ubuntu-24.04) (push) Failing after 1m57s
Sandbox / sandbox e2e (bwrap, ubuntu-latest) (push) Failing after 1m19s
Release (vendor) / Publish to npm (push) Has been skipped
Release (dsh) / Publish to npm (push) Has been skipped
CI / serial / windows (self-hosted standby) (push) Has been cancelled
CI / larger-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, typecheck) (push) Has been cancelled
Landlock Run / darwin (no platform package — degradation proof) (push) Has been cancelled
Landlock Run / ${{ matrix.platform }} (push) Has been cancelled
CI / node 24 / static (push) Has been cancelled
CI / node 24 / coverage (push) Has been cancelled
CI / node 24 / snapshots and artifacts (push) Has been cancelled
CI / windows node 24 / native complete (push) Has been cancelled
CI / serial / linux (self-hosted standby) (push) Has been cancelled
CI / serial / macos (push) Has been cancelled
CI / larger-runner-benchmark (16, windows, dsh-windows-2025-16core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (32, windows, dsh-windows-2025-32core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (4, windows, dsh-windows-2025-4core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (64, windows, dsh-windows-2025-64core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (8, windows, dsh-windows-2025-8core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (96, windows, dsh-windows-2025-96core, production-site) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, 16) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, windows, dsh-windows-2025-16core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, windows, dsh-windows-2025-32core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, 4) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, windows, dsh-windows-2025-4core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, windows, dsh-windows-2025-64core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, 8) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, windows, dsh-windows-2025-8core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, windows, dsh-windows-2025-96core, 2) (push) Has been cancelled
CI / all checks passed (push) Has been cancelled
Sandbox / sandbox e2e (seatbelt, macos-latest) (push) Has been cancelled
CI / larger-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, typecheck) (push) Has been cancelled
Sandbox / sandbox e2e (landlock, ubuntu-24.04-arm) (push) Has been cancelled
E2E (real DeepSeek API) / e2e (push) Failing after 1m24s
Some checks failed
CI / windows node 24 / wine blocking (push) Has been skipped
CI / node 22.19 (push) Has been skipped
CI / node 26 (push) Has been skipped
CI / python 3.10 / keyless SDK (push) Has been skipped
CI / python runtime / release-shaped Linux x64 (push) Has been skipped
CI / wine apt cache (push) Successful in 7s
CI / serial / linux (push) Has been skipped
Deploy documentation / build (push) Failing after 1m25s
Deploy documentation / deploy (push) Has been skipped
Landlock Run / Matrix (push) Successful in 5s
Release (vendor) / Pack npm tarballs (push) Failing after 2m47s
Release (dsh) / Pack npm tarballs (push) Failing after 1m56s
Sandbox / sandbox e2e (landlock, ubuntu-24.04) (push) Failing after 1m57s
Sandbox / sandbox e2e (bwrap, ubuntu-latest) (push) Failing after 1m19s
Release (vendor) / Publish to npm (push) Has been skipped
Release (dsh) / Publish to npm (push) Has been skipped
CI / serial / windows (self-hosted standby) (push) Has been cancelled
CI / larger-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, typecheck) (push) Has been cancelled
Landlock Run / darwin (no platform package — degradation proof) (push) Has been cancelled
Landlock Run / ${{ matrix.platform }} (push) Has been cancelled
CI / node 24 / static (push) Has been cancelled
CI / node 24 / coverage (push) Has been cancelled
CI / node 24 / snapshots and artifacts (push) Has been cancelled
CI / windows node 24 / native complete (push) Has been cancelled
CI / serial / linux (self-hosted standby) (push) Has been cancelled
CI / serial / macos (push) Has been cancelled
CI / larger-runner-benchmark (16, windows, dsh-windows-2025-16core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (32, windows, dsh-windows-2025-32core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (4, windows, dsh-windows-2025-4core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (64, windows, dsh-windows-2025-64core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (8, windows, dsh-windows-2025-8core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (96, windows, dsh-windows-2025-96core, production-site) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, 16) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, windows, dsh-windows-2025-16core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, windows, dsh-windows-2025-32core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, 4) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, windows, dsh-windows-2025-4core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, windows, dsh-windows-2025-64core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, 8) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, windows, dsh-windows-2025-8core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, windows, dsh-windows-2025-96core, 2) (push) Has been cancelled
CI / all checks passed (push) Has been cancelled
Sandbox / sandbox e2e (seatbelt, macos-latest) (push) Has been cancelled
CI / larger-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, typecheck) (push) Has been cancelled
Sandbox / sandbox e2e (landlock, ubuntu-24.04-arm) (push) Has been cancelled
E2E (real DeepSeek API) / e2e (push) Failing after 1m24s
- new economy and maximum agent presets with three-role pipeline skill - new packages/extensions/tool-lab (home-lab ComfyUI/Docling/Whishper tools) - new packages/subagent/subagent-cursor provider - openrouter balance UI with on-demand refresh - session projection context-seed boundary fold - regenerate docs catalogs; keep local searxng benchmark scripts
This commit is contained in:
@@ -20,11 +20,17 @@ import { toPiReplayState } from './replay.ts'
|
||||
* @returns harness counts; cache fields appear only when non-zero (pi-ai reports zeros, not absence).
|
||||
*/
|
||||
export function mapUsage(usage: PiUsage): TokenUsage {
|
||||
const usageRecord = usage as unknown as Record<string, unknown>
|
||||
const rawCost = usageRecord['cost'] ?? usageRecord['total_cost']
|
||||
const costUsd = typeof rawCost === 'number' && Number.isFinite(rawCost) && rawCost >= 0
|
||||
? rawCost
|
||||
: undefined
|
||||
return {
|
||||
inputTokens: usage.input,
|
||||
outputTokens: usage.output,
|
||||
...usage.cacheRead > 0 ? { cacheReadTokens: usage.cacheRead } : {},
|
||||
...usage.cacheWrite > 0 ? { cacheWriteTokens: usage.cacheWrite } : {},
|
||||
...costUsd !== undefined ? { costUsd } : {},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ export interface TokenUsage {
|
||||
cacheReadTokens?: number
|
||||
cacheWriteTokens?: number
|
||||
reasoningTokens?: number
|
||||
costUsd?: number
|
||||
}
|
||||
|
||||
/** Display metadata for one registered provider route. */
|
||||
|
||||
@@ -43,7 +43,11 @@ is the available balance from `GET /credits` (`total_credits` minus the spent
|
||||
the monthly `usageTokens`/`limitTokens` budget from `GET /auth/key`,
|
||||
`isFreeTier`, and an `updatedAt` epoch. Before any successful fetch it serves
|
||||
an all-`null`
|
||||
record; a failed refresh keeps the last-known snapshot and logs. The same key
|
||||
record; a failed refresh keeps the last-known snapshot and logs. The
|
||||
`refresh()` method fetches the account NOW and resolves with the resulting
|
||||
snapshot, for a user-initiated read that must not wait for the next scheduled
|
||||
tick; concurrent callers share one in-flight fetch, and a failed fetch
|
||||
resolves with the last-known snapshot rather than rejecting. The same key
|
||||
also refreshes the model pricing table from `GET /models`
|
||||
(`pricing.prompt`/`completion` USD per token, plus a flat `request` fee and
|
||||
optional `input_cache_read`/`input_cache_write` when disclosed).
|
||||
@@ -54,7 +58,14 @@ step) against the pricing table. Attribution prefers the assembled message's
|
||||
own `provider`/`model`; a chunk-only (failed) step prices from the newest
|
||||
`request/context` route. A step on a non-`openrouter` provider is outside the
|
||||
domain and changes nothing; an OpenRouter step whose model has no pricing
|
||||
entry counts as an unknown (unpriced) step. The projection's `steps` view
|
||||
entry counts as an unknown (unpriced) step.
|
||||
|
||||
Every value describes only its own session's work. A forked child session's
|
||||
log opens with a verbatim copy of its parent's up to the header's
|
||||
`seedLength`, and the fold skips that inherited prefix (reading only its
|
||||
`request/context` route records, which carry no cost but attribute the
|
||||
child's first chunk-only step). A session total plus its subagents' totals is
|
||||
therefore a sum, never a double count. The projection's `steps` view
|
||||
field maps each priced step to its cost in USD under `${turn}:${step}` keys,
|
||||
so per-step surfaces can render spend without re-pricing; unpriced steps are
|
||||
absent from the map.
|
||||
@@ -95,7 +106,10 @@ usage events, never a new model-visible input.
|
||||
promotional pricing may differ from the model table.
|
||||
- **Pricing as of the fold** — the projection prices a cell with the model
|
||||
table current when that cell folds. Refreshing pricing only affects cells
|
||||
folded afterward; already-folded history keeps its prior figures.
|
||||
folded afterward; already-folded history keeps its prior figures. A session
|
||||
folded while the table was empty (no key yet, or a failed first fetch)
|
||||
keeps its steps recorded as unpriced until a `stateVersion` bump refolds
|
||||
them.
|
||||
- **Per-token approximation** — cache-read/cache-write fall back to the
|
||||
prompt rate when the API does not disclose separate cache rates, and the
|
||||
flat request fee is charged once per step. Bills may differ by fractions
|
||||
|
||||
@@ -103,6 +103,8 @@ export class OpenRouterUsageGateway extends TypertRemoteService {
|
||||
private readonly abortController = new AbortController()
|
||||
/** Authoritative settings thunk; re-pointed when the section (re)mounts. */
|
||||
private currentSource: () => Config
|
||||
/** In-flight on-demand balance refresh, shared by concurrent callers. */
|
||||
private pendingRefresh: Promise<OpenRouterBalance> | undefined
|
||||
|
||||
constructor(ctx: Context, config: Config = {}) {
|
||||
super(ctx, 'openRouterUsage')
|
||||
@@ -124,7 +126,7 @@ export class OpenRouterUsageGateway extends TypertRemoteService {
|
||||
// live pricing thunk. The unit child activates only when a projection
|
||||
// registry is composed (headless assemblies stay unaffected).
|
||||
ctx.inject(['sessionProjections'], (projectionCtx) => {
|
||||
projectionCtx.sessionProjections.register(createOpenRouterCostProjection((model) => this.pricing.get(model)))
|
||||
projectionCtx.sessionProjections.register(createOpenRouterCostProjection(model => this.lookupPricing(model)))
|
||||
})
|
||||
|
||||
ctx.effect(() => () => {
|
||||
@@ -158,6 +160,44 @@ export class OpenRouterUsageGateway extends TypertRemoteService {
|
||||
return { ...this.balance }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the account snapshot NOW and serve the result, for a user-initiated
|
||||
* read that must not wait for the next scheduled tick. Concurrent callers
|
||||
* share one in-flight fetch, so repeated clicks cost one request. A failed
|
||||
* fetch resolves with the last-known snapshot rather than rejecting — the
|
||||
* caller's own `updatedAt` comparison tells it whether the figure moved.
|
||||
* @returns the snapshot after the refresh attempt settled.
|
||||
*/
|
||||
@Remote('refresh')
|
||||
async refresh(): Promise<OpenRouterBalance> {
|
||||
this.pendingRefresh ??= (async () => {
|
||||
try {
|
||||
await this.refreshBalance(this.resolve(this.currentSource()))
|
||||
return { ...this.balance }
|
||||
} finally {
|
||||
this.pendingRefresh = undefined
|
||||
}
|
||||
})()
|
||||
return await this.pendingRefresh
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up pricing for a model ID, with fallback for tagged variants (`:free`, etc.).
|
||||
* @param model - model identifier.
|
||||
* @returns matched rate, or undefined if unknown.
|
||||
*/
|
||||
lookupPricing(model: string): ModelPricing | undefined {
|
||||
const direct = this.pricing.get(model)
|
||||
if (direct !== undefined) return direct
|
||||
if (model.includes(':')) {
|
||||
const tagIndex = model.indexOf(':')
|
||||
const base = model.slice(0, tagIndex)
|
||||
const basePricing = this.pricing.get(base)
|
||||
if (basePricing !== undefined) return basePricing
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Materialize plugin defaults against the validated section. */
|
||||
private resolve(config: Config): Required<Config> {
|
||||
return {
|
||||
|
||||
@@ -11,6 +11,14 @@
|
||||
* Refreshing pricing only affects cells folded afterward — the documented
|
||||
* "as of fold" limitation.
|
||||
*
|
||||
* Inherited history: a forked child session opens with a verbatim copy of its
|
||||
* parent's log up to `header.seedLength`. Those steps are the parent's spend,
|
||||
* which the parent's own value already reports, so the fold skips them and
|
||||
* every value describes only the session's OWN work — a parent total plus its
|
||||
* children's totals is a sum, never a double count. `request/context` records
|
||||
* are still read from the inherited prefix: they carry no cost and the route
|
||||
* they establish is what attributes the child's first chunk-only step.
|
||||
*
|
||||
* Model attribution: an `assistant/message` carries its own provider/model in
|
||||
* `message.source`; a chunk-only (failed) step has none, so the fold prices it
|
||||
* from the newest `request/context` last-wins record. A step on a provider that
|
||||
@@ -24,7 +32,7 @@
|
||||
import { z } from 'zod'
|
||||
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
||||
import type { ProjectionDefinition, ProjectionFoldContext } from '@deepseek-ai/dsh-session-projection'
|
||||
import type { ModelPricing, OpenRouterCost } from './types.ts'
|
||||
|
||||
/** The provider route this projection prices; LLM routing must land here. */
|
||||
@@ -84,7 +92,13 @@ export function createOpenRouterCostProjection(
|
||||
key: 'openRouterCost',
|
||||
schema: costSchema as unknown as z.ZodType<OpenRouterCost>,
|
||||
init: () => ({ totalUsd: 0, pricedSteps: 0, unknownModelSteps: 0, steps: {}, last: null, lastModel: null }),
|
||||
apply: (state, event: SessionEvent) => {
|
||||
apply: (state, event: SessionEvent, context: ProjectionFoldContext) => {
|
||||
// A forked child's log opens with a verbatim copy of its parent's log.
|
||||
// That spend belongs to the parent — which already reports it — so
|
||||
// pricing it here would report it twice, once per forked child. The
|
||||
// route records still apply: attribution carries across the boundary,
|
||||
// so `request/context` below is read from the inherited prefix too.
|
||||
if (event.seq < context.seedLength && event.type !== 'request/context') return state
|
||||
if (event.type === 'request/context') {
|
||||
const nextModel = { provider: event.data.provider, model: event.data.model }
|
||||
if (state.lastModel?.provider === nextModel.provider && state.lastModel?.model === nextModel.model) return state
|
||||
@@ -106,14 +120,21 @@ export function createOpenRouterCostProjection(
|
||||
} else {
|
||||
return state
|
||||
}
|
||||
// A non-openrouter step is outside this plugin's domain: never counted,
|
||||
// never recorded (a later, correctly-attributed message for the same
|
||||
// step must still land fresh).
|
||||
if (attribution === undefined || attribution.provider !== OPENROUTER_PROVIDER) return state
|
||||
const directCost = usage.costUsd
|
||||
const pricing = attribution !== undefined ? pricingOf(attribution.model) : undefined
|
||||
const isOpenRouterProvider = attribution !== undefined && (
|
||||
attribution.provider === OPENROUTER_PROVIDER
|
||||
|| attribution.provider.toLowerCase().includes('openrouter')
|
||||
)
|
||||
|
||||
const pricing = pricingOf(attribution.model)
|
||||
const priced = pricing !== undefined
|
||||
const costUsd = priced ? stepCostUsd(usage, pricing) : 0
|
||||
if (directCost === undefined && !isOpenRouterProvider && pricing === undefined) return state
|
||||
|
||||
const costUsd = directCost !== undefined
|
||||
? directCost
|
||||
: pricing !== undefined
|
||||
? stepCostUsd(usage, pricing)
|
||||
: 0
|
||||
const priced = directCost !== undefined || pricing !== undefined
|
||||
const previous = state.last !== null && state.last.turn === turn && state.last.step === step
|
||||
? state.last
|
||||
: null
|
||||
@@ -144,6 +165,6 @@ export function createOpenRouterCostProjection(
|
||||
steps: state.steps,
|
||||
currency: 'USD',
|
||||
}),
|
||||
stateVersion: 2,
|
||||
stateVersion: 4,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,11 +33,13 @@ export interface ModelPricing {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole-log OpenRouter spend for one session, priced from the logged token
|
||||
* usage of its steps against the pricing table current at fold time. Every
|
||||
* field is 0 until its first contributing priced step lands; a session whose
|
||||
* provider route is not `openrouter`, or whose models have no pricing entry,
|
||||
* stays all-zero.
|
||||
* One session's OWN OpenRouter spend, priced from the logged token usage of
|
||||
* its steps against the pricing table current at fold time. A forked child's
|
||||
* inherited log prefix (the parent's history up to `header.seedLength`) is
|
||||
* excluded, so a parent value plus its subagents' values is a sum rather
|
||||
* than a double count. Every field is 0 until its first contributing priced
|
||||
* step lands; a session whose provider route is not `openrouter`, or whose
|
||||
* models have no pricing entry, stays all-zero.
|
||||
*/
|
||||
export interface OpenRouterCost {
|
||||
/** Summed USD over steps priced against a known model entry. */
|
||||
|
||||
@@ -38,6 +38,7 @@ function stubOpenRouter() {
|
||||
const pricing = [
|
||||
{ id: 'deepseek/deepseek-chat', pricing: { prompt: '0.0000014', completion: '0.0000028', request: '0' } },
|
||||
]
|
||||
const credits = { total_credits: 42, total_usage: 1, is_free_tier: false }
|
||||
const calls: string[] = []
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
@@ -46,9 +47,7 @@ function stubOpenRouter() {
|
||||
return new Response(JSON.stringify({ data: pricing }), { status: 200 })
|
||||
}
|
||||
if (url.endsWith('/credits')) {
|
||||
return new Response(JSON.stringify({
|
||||
data: { total_credits: 42, total_usage: 1, is_free_tier: false },
|
||||
}), { status: 200 })
|
||||
return new Response(JSON.stringify({ data: { ...credits } }), { status: 200 })
|
||||
}
|
||||
if (url.endsWith('/auth/key')) {
|
||||
return new Response(JSON.stringify({
|
||||
@@ -57,7 +56,7 @@ function stubOpenRouter() {
|
||||
}
|
||||
return new Response('not found', { status: 404 })
|
||||
}))
|
||||
return { calls }
|
||||
return { calls, credits }
|
||||
}
|
||||
|
||||
async function loadComposition(): Promise<Context> {
|
||||
@@ -170,4 +169,39 @@ describe('openrouter-usage real composition', () => {
|
||||
expect(balance.currency).toBe('USD')
|
||||
expect(balance.updatedAt).not.toBeNull()
|
||||
})
|
||||
|
||||
it('re-fetches the account on demand and serves the moved figure', async () => {
|
||||
const openRouter = stubOpenRouter()
|
||||
const loaded = await loadComposition()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(openRouter.calls.some(url => url.endsWith('/credits'))).toBe(true)
|
||||
}, { timeout: 5000 })
|
||||
expect(loaded.openRouterUsage.snapshot().balanceUsd).toBe(41)
|
||||
|
||||
openRouter.credits.total_usage = 12
|
||||
await expect(loaded.openRouterUsage.refresh()).resolves.toMatchObject({ balanceUsd: 30 })
|
||||
expect(loaded.openRouterUsage.snapshot().balanceUsd).toBe(30)
|
||||
})
|
||||
|
||||
it('shares one in-flight fetch across concurrent on-demand refreshes', async () => {
|
||||
const openRouter = stubOpenRouter()
|
||||
const loaded = await loadComposition()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(openRouter.calls.some(url => url.endsWith('/credits'))).toBe(true)
|
||||
}, { timeout: 5000 })
|
||||
const before = openRouter.calls.filter(url => url.endsWith('/credits')).length
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
loaded.openRouterUsage.refresh(),
|
||||
loaded.openRouterUsage.refresh(),
|
||||
])
|
||||
expect(first).toEqual(second)
|
||||
expect(openRouter.calls.filter(url => url.endsWith('/credits')).length).toBe(before + 1)
|
||||
|
||||
// The shared promise is released once it settles, so a later click fetches again.
|
||||
await loaded.openRouterUsage.refresh()
|
||||
expect(openRouter.calls.filter(url => url.endsWith('/credits')).length).toBe(before + 2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -224,6 +224,28 @@ describe('openRouterCost session projection', () => {
|
||||
expect(projected(ctx, session)).toEqual({ totalUsd: 0, pricedSteps: 0, unknownModelSteps: 1, steps: {}, currency: 'USD' })
|
||||
})
|
||||
|
||||
it('uses explicitly logged costUsd on usage directly even without pricing table entry', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
session.append('request/context', { provider: 'openrouter', model: 'unlisted/custom-model' })
|
||||
startStep(session, 1, 1)
|
||||
session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: {
|
||||
type: 'usage',
|
||||
usage: { inputTokens: 500, outputTokens: 200, costUsd: 0.0042 },
|
||||
},
|
||||
})
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
expect(projected(ctx, session)).toEqual({
|
||||
totalUsd: 0.0042,
|
||||
pricedSteps: 1,
|
||||
unknownModelSteps: 0,
|
||||
steps: { '1:1': 0.0042 },
|
||||
currency: 'USD',
|
||||
})
|
||||
})
|
||||
|
||||
it('pushes no change for unrelated events', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
recordContext(session)
|
||||
@@ -235,6 +257,47 @@ describe('openRouterCost session projection', () => {
|
||||
expect(changed).not.toContain('openRouterCost')
|
||||
})
|
||||
|
||||
it('excludes the inherited prefix of a forked child, so parent + child is a sum', async () => {
|
||||
const { ctx, session: parent } = await harness()
|
||||
recordContext(parent)
|
||||
startStep(parent, 1, 1)
|
||||
const parentSource = usageChunk(parent, { inputTokens: 1_000, outputTokens: 500 }, 1, 1)
|
||||
finalUsage(parent, { inputTokens: 1_000, outputTokens: 500 }, 1, 1, [parentSource])
|
||||
const parentCost = 1_000 * 1.4e-6 + 500 * 2.8e-6
|
||||
expect(projected(ctx, parent).totalUsd).toBe(parentCost)
|
||||
|
||||
// The fork: the child's log opens with a verbatim copy of the parent's.
|
||||
const seed = [...parent.events]
|
||||
const child = ctx.sessions.create(undefined, { seed, meta: { seedLength: seed.length } })
|
||||
startStep(child, 2, 1)
|
||||
const childSource = usageChunk(child, { inputTokens: 10, outputTokens: 4 }, 2, 1)
|
||||
finalUsage(child, { inputTokens: 10, outputTokens: 4 }, 2, 1, [childSource])
|
||||
const childCost = 10 * 1.4e-6 + 4 * 2.8e-6
|
||||
|
||||
const childValue = projected(ctx, child)
|
||||
expect(childValue.totalUsd).toBe(childCost)
|
||||
expect(childValue.pricedSteps).toBe(1)
|
||||
expect(childValue.steps).toEqual({ '2:1': childCost })
|
||||
// The parent's own figure is untouched by the fork.
|
||||
expect(projected(ctx, parent).totalUsd).toBe(parentCost)
|
||||
})
|
||||
|
||||
it('attributes a chunk-only step of a forked child from a route in the inherited prefix', async () => {
|
||||
const { ctx, session: parent } = await harness()
|
||||
// The route record is the LAST thing the parent logs, so only the
|
||||
// inherited prefix can supply the child's attribution.
|
||||
recordContext(parent)
|
||||
const seed = [...parent.events]
|
||||
const child = ctx.sessions.create(undefined, { seed, meta: { seedLength: seed.length } })
|
||||
startStep(child, 1, 1)
|
||||
usageChunk(child, { inputTokens: 100, outputTokens: 20 }, 1, 1)
|
||||
expect(projected(ctx, child)).toMatchObject({
|
||||
totalUsd: 100 * 1.4e-6 + 20 * 2.8e-6,
|
||||
pricedSteps: 1,
|
||||
unknownModelSteps: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('restores from a JSON checkpoint', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
recordContext(session)
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import SessionProjectionRegistry, { foldContextOf } from '@deepseek-ai/dsh-session-projection'
|
||||
import TokenMeter from '@deepseek-ai/dsh-token-meter'
|
||||
import type { ContextBreakdownProjection } from '@deepseek-ai/dsh-token-meter/client'
|
||||
import { CompactionId } from '@deepseek-ai/dsh-compaction'
|
||||
@@ -184,6 +184,9 @@ describe('contextBreakdown session projection', () => {
|
||||
|
||||
it('folds a replacement without a claim at zero and fails on a mismatched claim', () => {
|
||||
const definition = contextBreakdownProjectionDefinition
|
||||
// The unit ignores the fold context; this session inherits no fork prefix.
|
||||
const applyBreakdown = (state: Parameters<typeof definition.apply>[0], event: SessionEvent) =>
|
||||
definition.apply(state, event, foldContextOf({}))
|
||||
const replace = (start: number, end: number): SessionEvent => ({
|
||||
type: 'user/message',
|
||||
seq: 9,
|
||||
@@ -206,22 +209,22 @@ describe('contextBreakdown session projection', () => {
|
||||
data: { shadowedRange: { start, end }, shadowedSeqs: [start, end], shadowedTokenCount: 5 },
|
||||
} as unknown as SessionEvent)
|
||||
let state = definition.init()
|
||||
state = definition.apply(state, append(1))
|
||||
state = definition.apply(state, append(3))
|
||||
state = applyBreakdown(state, append(1))
|
||||
state = applyBreakdown(state, append(3))
|
||||
// No metering event: the replacement contributes zero instead of throwing.
|
||||
expect(definition.view(definition.apply(state, replace(1, 3))).messageTokens)
|
||||
expect(definition.view(applyBreakdown(state, replace(1, 3))).messageTokens)
|
||||
.toBe(definition.view(state).messageTokens)
|
||||
// An adjacent claim for another range contradicts the replacement.
|
||||
const mismatched = definition.apply(state, meter(1, 1, 8))
|
||||
expect(() => definition.apply(mismatched, replace(1, 3))).toThrow('no adjacent shadow price')
|
||||
const mismatched = applyBreakdown(state, meter(1, 1, 8))
|
||||
expect(() => applyBreakdown(mismatched, replace(1, 3))).toThrow('no adjacent shadow price')
|
||||
// A claim expires after one intervening event, so replacement delta is zero.
|
||||
let expired = definition.apply(state, meter(1, 3, 8))
|
||||
expired = definition.apply(expired, { type: 'todo/write', seq: 9, time: 0, data: { todos: [] } } as unknown as SessionEvent)
|
||||
expect(definition.view(definition.apply(expired, replace(1, 3))).messageTokens)
|
||||
let expired = applyBreakdown(state, meter(1, 3, 8))
|
||||
expired = applyBreakdown(expired, { type: 'todo/write', seq: 9, time: 0, data: { todos: [] } } as unknown as SessionEvent)
|
||||
expect(definition.view(applyBreakdown(expired, replace(1, 3))).messageTokens)
|
||||
.toBe(definition.view(state).messageTokens)
|
||||
// The armed claim prices exactly the next event's matching replacement.
|
||||
const armed = definition.apply(state, meter(1, 3, 8))
|
||||
expect(definition.view(definition.apply(armed, replace(1, 3))).messageTokens)
|
||||
const armed = applyBreakdown(state, meter(1, 3, 8))
|
||||
expect(definition.view(applyBreakdown(armed, replace(1, 3))).messageTokens)
|
||||
.toBe(definition.view(state).messageTokens - 5 + estimateMessage(
|
||||
createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }),
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user