258 lines
9.2 KiB
TypeScript
258 lines
9.2 KiB
TypeScript
/**
|
|
* Full-log usage and current-context projection for Web clients.
|
|
*
|
|
* @module @deepseek-ai/dsh-host-apiproxy/session-metrics
|
|
*/
|
|
|
|
import type { Context } from 'cordis'
|
|
import type { Agent, AgentLlmTarget } from '@deepseek-ai/dsh-agent'
|
|
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
|
|
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
|
import type { SessionMetrics } from './api/sessions.ts'
|
|
|
|
interface UsageState {
|
|
logRevision: number
|
|
projectionRevision: number
|
|
uncachedInputTokens: number
|
|
outputTokens: number
|
|
cacheReadTokens: number
|
|
cacheWriteTokens: number
|
|
byStep: Map<string, TokenUsage>
|
|
}
|
|
|
|
interface CapacityState {
|
|
routeKey: string | undefined
|
|
generation: number
|
|
epoch: number
|
|
status: 'pending' | 'ready' | 'retryable'
|
|
contextWindow?: number
|
|
controller?: AbortController
|
|
}
|
|
|
|
type CapacityTarget = Pick<AgentLlmTarget, 'provider' | 'model'>
|
|
|
|
interface TokenMeterLike {
|
|
measure(session: Session): { totalTokens: number }
|
|
}
|
|
|
|
interface LlmLike {
|
|
resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<{
|
|
context?: { contextWindow: number }
|
|
}>
|
|
}
|
|
|
|
function usageFrom(event: SessionEvent): { turn: number; step: number; usage: TokenUsage } | undefined {
|
|
if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') {
|
|
return { turn: event.data.turn, step: event.data.step, usage: event.data.chunk.usage }
|
|
}
|
|
if (event.type === 'assistant/message' && event.data.usage !== undefined) {
|
|
return { turn: event.data.turn, step: event.data.step, usage: event.data.usage }
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* Whether an appended event can change cumulative usage or token-meter
|
|
* pressure. Text/reasoning stream deltas remain outside both projections.
|
|
* @param event - appended durable event.
|
|
* @returns true when the Host must publish a fresh metrics snapshot.
|
|
*/
|
|
export function affectsSessionMetrics(event: SessionEvent): boolean {
|
|
if (event.type === 'assistant/chunk') return event.data.chunk.type === 'usage'
|
|
if (event.type === 'request/header') return true
|
|
return 'surfaceOp' in event
|
|
}
|
|
|
|
function recordUsage(state: UsageState, turn: number, step: number, usage: TokenUsage): void {
|
|
const key = `${turn}:${step}`
|
|
const previous = state.byStep.get(key)
|
|
if (previous !== undefined) {
|
|
state.uncachedInputTokens -= previous.inputTokens
|
|
state.outputTokens -= previous.outputTokens
|
|
state.cacheReadTokens -= previous.cacheReadTokens ?? 0
|
|
state.cacheWriteTokens -= previous.cacheWriteTokens ?? 0
|
|
}
|
|
state.byStep.set(key, usage)
|
|
state.uncachedInputTokens += usage.inputTokens
|
|
state.outputTokens += usage.outputTokens
|
|
state.cacheReadTokens += usage.cacheReadTokens ?? 0
|
|
state.cacheWriteTokens += usage.cacheWriteTokens ?? 0
|
|
}
|
|
|
|
function routeKeyFor(target: CapacityTarget | undefined): string | undefined {
|
|
return target === undefined ? undefined : `${target.provider}\u0000${target.model}`
|
|
}
|
|
|
|
/**
|
|
* Projects durable cumulative usage and route-aware current context without
|
|
* awaiting model metadata on the session append path.
|
|
*/
|
|
export class SessionMetricsProjector {
|
|
private readonly usage = new WeakMap<Session, UsageState>()
|
|
private readonly capacities = new WeakMap<Agent, CapacityState>()
|
|
private readonly pendingCapacities = new Set<CapacityState>()
|
|
private capacityEpoch = 0
|
|
private disposed = false
|
|
|
|
/**
|
|
* @param ctx - Host context providing optional token-meter and LLM services.
|
|
* @param targetFor - side-effect-free selected or logged route lookup for one attached agent.
|
|
* @param onCapacityResolved - schedules a fresh live projection after exact-route metadata resolves.
|
|
*/
|
|
constructor(
|
|
private readonly ctx: Context,
|
|
private readonly targetFor: (agent: Agent) => CapacityTarget | undefined,
|
|
private readonly onCapacityResolved: (agent: Agent) => void,
|
|
) {}
|
|
|
|
/** Retire adapter-owned metadata and fence every resolution already in flight. */
|
|
invalidateCapacities(): void {
|
|
this.capacityEpoch++
|
|
for (const pending of this.pendingCapacities) this.abortCapacityResolution(pending)
|
|
}
|
|
|
|
/** Permanently retire capacity projection and cancel every adapter-owned lookup. */
|
|
dispose(): void {
|
|
this.disposed = true
|
|
this.invalidateCapacities()
|
|
}
|
|
|
|
/**
|
|
* Read a fresh detached projection through the session's durable tail.
|
|
* @param session - authoritative durable log owner.
|
|
* @param agent - attached route owner, when available.
|
|
* @returns cumulative usage and any currently available pressure/capacity.
|
|
*/
|
|
snapshot(session: Session, agent?: Agent): SessionMetrics {
|
|
const state = this.syncUsage(session)
|
|
const tokenMeter = this.ctx.get('tokenMeter') as TokenMeterLike | undefined
|
|
let contextTokens: number | undefined
|
|
if (tokenMeter !== undefined) {
|
|
try {
|
|
contextTokens = tokenMeter.measure(session).totalTokens
|
|
} catch {
|
|
// A malformed or temporarily unmeasurable replay has no honest pressure value.
|
|
}
|
|
}
|
|
const contextWindow = agent === undefined ? undefined : this.capacityFor(agent)
|
|
return {
|
|
logRevision: state.logRevision,
|
|
projectionRevision: state.projectionRevision++,
|
|
uncachedInputTokens: state.uncachedInputTokens,
|
|
outputTokens: state.outputTokens,
|
|
cacheReadTokens: state.cacheReadTokens,
|
|
cacheWriteTokens: state.cacheWriteTokens,
|
|
...contextTokens === undefined ? {} : { contextTokens },
|
|
...contextWindow === undefined ? {} : { contextWindow },
|
|
}
|
|
}
|
|
|
|
private syncUsage(session: Session): UsageState {
|
|
let state = this.usage.get(session)
|
|
if (state === undefined) {
|
|
state = {
|
|
logRevision: 0,
|
|
projectionRevision: 0,
|
|
uncachedInputTokens: 0,
|
|
outputTokens: 0,
|
|
cacheReadTokens: 0,
|
|
cacheWriteTokens: 0,
|
|
byStep: new Map(),
|
|
}
|
|
this.usage.set(session, state)
|
|
}
|
|
while (state.logRevision < session.events.length) {
|
|
const event = session.events[state.logRevision]
|
|
/* v8 ignore next -- Session events are append-only and dense; logRevision is bounded by length. */
|
|
if (event === undefined) break
|
|
const usage = usageFrom(event)
|
|
if (usage !== undefined) recordUsage(state, usage.turn, usage.step, usage.usage)
|
|
state.logRevision++
|
|
}
|
|
return state
|
|
}
|
|
|
|
private capacityFor(agent: Agent): number | undefined {
|
|
if (this.disposed) return undefined
|
|
const target = this.targetFor(agent)
|
|
const routeKey = routeKeyFor(target)
|
|
let state = this.capacities.get(agent)
|
|
if (state === undefined
|
|
|| state.routeKey !== routeKey
|
|
|| state.epoch !== this.capacityEpoch
|
|
|| state.status === 'retryable') {
|
|
this.abortCapacityResolution(state)
|
|
state = {
|
|
routeKey,
|
|
generation: (state?.generation ?? 0) + 1,
|
|
epoch: this.capacityEpoch,
|
|
status: target === undefined ? 'ready' : 'pending',
|
|
}
|
|
this.capacities.set(agent, state)
|
|
if (target !== undefined) this.resolveCapacity(agent, target, state)
|
|
}
|
|
return state.status === 'ready' ? state.contextWindow : undefined
|
|
}
|
|
|
|
private resolveCapacity(
|
|
agent: Agent,
|
|
target: CapacityTarget,
|
|
pending: CapacityState,
|
|
): void {
|
|
const llm = this.ctx.get('llm') as LlmLike | undefined
|
|
if (llm === undefined) {
|
|
pending.status = 'retryable'
|
|
return
|
|
}
|
|
const controller = new AbortController()
|
|
pending.controller = controller
|
|
this.pendingCapacities.add(pending)
|
|
void Promise.resolve()
|
|
.then(() => {
|
|
controller.signal.throwIfAborted()
|
|
return llm.resolveModelInfo(target.provider, target.model, controller.signal)
|
|
})
|
|
.then(
|
|
(resolved) => {
|
|
this.finishCapacityResolution(pending, controller)
|
|
if (this.capacityResolutionIsStale(agent, pending)) return
|
|
pending.status = 'ready'
|
|
if (resolved.context !== undefined) pending.contextWindow = resolved.context.contextWindow
|
|
this.onCapacityResolved(agent)
|
|
},
|
|
() => {
|
|
this.finishCapacityResolution(pending, controller)
|
|
if (!this.capacityResolutionIsStale(agent, pending)) pending.status = 'retryable'
|
|
},
|
|
)
|
|
}
|
|
|
|
private abortCapacityResolution(pending: CapacityState | undefined): void {
|
|
if (pending === undefined || pending.controller === undefined) return
|
|
const controller = pending.controller
|
|
delete pending.controller
|
|
this.pendingCapacities.delete(pending)
|
|
controller.abort()
|
|
}
|
|
|
|
private finishCapacityResolution(pending: CapacityState, controller: AbortController): void {
|
|
this.pendingCapacities.delete(pending)
|
|
if (pending.controller === controller) delete pending.controller
|
|
}
|
|
|
|
private capacityResolutionIsStale(agent: Agent, pending: CapacityState): boolean {
|
|
if (pending.epoch !== this.capacityEpoch) return true
|
|
if (this.capacities.get(agent)?.generation !== pending.generation) return true
|
|
if (routeKeyFor(this.targetFor(agent)) === pending.routeKey) return false
|
|
// Unknown is the neutral generation; the next observed concrete route
|
|
// starts a fresh resolution even when it equals the route that disappeared.
|
|
this.capacities.set(agent, {
|
|
routeKey: undefined,
|
|
generation: pending.generation + 1,
|
|
epoch: this.capacityEpoch,
|
|
status: 'ready',
|
|
})
|
|
return true
|
|
}
|
|
}
|