refactor(web): project usage and snapshot request context

This commit is contained in:
Hypatia May
2026-07-29 15:27:59 +08:00
parent e37cb23336
commit bf618dabf9
78 changed files with 748 additions and 934 deletions
+28 -65
View File
@@ -51,7 +51,6 @@ import type {
AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest,
} from '@deepseek-ai/dsh-user-interaction'
import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
import { affectsSessionMetrics, SessionMetricsProjector } from './session-metrics.ts'
import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
import { openNativePath } from './native-path-opener.ts'
@@ -496,56 +495,34 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
for (const queue of muxQueues) queue.push(envelope)
}
const pendingMetricSessions = new Set<Session>()
let metricFlushScheduled = false
const metricsProjector = new SessionMetricsProjector(ctx)
/** Queue one full-log metrics publication after synchronous session listeners drain. */
function scheduleMetrics(session: Session): void {
if (muxQueues.size === 0) return
pendingMetricSessions.add(session)
if (metricFlushScheduled) return
metricFlushScheduled = true
queueMicrotask(() => {
metricFlushScheduled = false
const sessions = [...pendingMetricSessions]
pendingMetricSessions.clear()
for (const current of sessions) {
broadcast({
type: 'session/metrics',
sessionId: current.id,
metrics: metricsProjector.snapshot(current),
})
}
})
}
ctx.effect(() => {
const disposers = [
ctx.on('session/event', (session: Session, event: SessionEvent) => {
if (affectsSessionMetrics(event)) scheduleMetrics(session)
}),
ctx.on('agent/created', (agent: Agent) => { scheduleMetrics(agent.session) }),
ctx.on('agent/model-request', (agent, turn, step, request) => {
broadcast({
type: 'session/model-request',
sessionId: agent.session.id,
turn,
step,
provider: request.provider,
model: request.model,
...request.contextWindow === undefined
? {}
: { contextWindow: request.contextWindow },
})
}),
ctx.on('session/disposed', (session: Session) => { pendingMetricSessions.delete(session) }),
]
return () => {
pendingMetricSessions.clear()
for (const dispose of disposers) dispose()
}
}, 'api-proxy: session metrics')
return ctx.on('agent/model-request', (agent, turn, step, request) => {
const tokenMeter = ctx.get('tokenMeter') as {
measure(session: Session): { totalTokens: number }
} | undefined
let contextTokens: number | undefined
if (tokenMeter !== undefined) {
try {
contextTokens = tokenMeter.measure(agent.session).totalTokens
} catch {
// A malformed or temporarily unmeasurable replay omits only the
// numerator; this request still replaces stale telemetry.
}
}
broadcast({
type: 'session/model-request',
sessionId: agent.session.id,
turn,
step,
provider: request.provider,
model: request.model,
...contextTokens === undefined ? {} : { contextTokens },
...request.contextWindow === undefined
? {}
: { contextWindow: request.contextWindow },
})
})
}, 'api-proxy: model request telemetry')
// Projection change feed → session/projection push frames. The carrier
// mints the wire frame (the seam package holds no wire vocabulary); the
@@ -991,16 +968,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return { event, ...view === undefined ? {} : { view } }
})
// Baseline rider: tail page only — loadOlder (beforeSeq present) is
// the one path that never needs fresh projection or metrics state.
// the one path that never needs fresh projection state.
const projections = beforeSeq === undefined ? projectionsFor(ctx, found.agent) : undefined
const metrics = beforeSeq === undefined
? metricsProjector.snapshot(found.agent.session)
: undefined
return ok(request, {
events: entries,
hasMore: page.hasMore,
...projections === undefined ? {} : { projections },
...metrics === undefined ? {} : { metrics },
})
},
@@ -1501,11 +1474,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
muxQueues.add(queue)
for (const session of ctx.sessions.list()) {
subscribeSession(queue, session)
queue.push(frame({
type: 'session/metrics',
sessionId: session.id,
metrics: metricsProjector.snapshot(session),
}))
}
for (const pending of pendingQuestions.values()) {
queue.push({
@@ -1556,11 +1524,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}),
ctx.on('session/created', (session: Session) => {
subscribeSession(queue, session)
queue.push(frame({
type: 'session/metrics',
sessionId: session.id,
metrics: metricsProjector.snapshot(session),
}))
}),
ctx.on('session/disposed', (session: Session) => {
openCalls.delete(session.id)
@@ -11,7 +11,7 @@ import type { Wire } from './rpc.schema.ts'
import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts'
import { approvalRequestIdSchema } from './approvals.schema.ts'
import {
contentBlockSchema, sessionEventSchema, sessionIdSchema, sessionMetricsSchema, toolEventViewSchema,
contentBlockSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema,
} from './sessions.schema.ts'
import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts'
@@ -37,7 +37,6 @@ const messageSchema = z.object({
export const muxFrameSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('session/event'), sessionId: sessionIdSchema, event: sessionEventSchema, view: toolEventViewSchema.optional() }),
z.object({ type: z.literal('session/subscribed'), sessionId: sessionIdSchema, lastSeq: z.number().int() }),
z.object({ type: z.literal('session/metrics'), sessionId: sessionIdSchema, metrics: sessionMetricsSchema }),
z.object({
type: z.literal('session/model-request'),
sessionId: sessionIdSchema,
@@ -45,6 +44,7 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
step: z.number().int().positive(),
provider: z.string().min(1),
model: z.string().min(1),
contextTokens: z.number().int().nonnegative().optional(),
contextWindow: z.number().int().positive().optional(),
}),
z.object({ type: z.literal('approval/requested'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, toolName: z.string(), callId: z.string().optional(), reason: z.string().optional() }),
+17 -11
View File
@@ -13,7 +13,6 @@ import type { CallId } from '@deepseek-ai/dsh-llm/brand'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
import type { RpcError, RpcId, RpcRequest } from './rpc.ts'
import type { SessionMetrics } from './sessions.ts'
import type { WorkspaceView } from './workspace.ts'
// Client-side consumers take the render-intent vocabulary from the contract;
@@ -32,6 +31,18 @@ export type ToolEventView =
| { for: 'call'; view: ToolCallView }
| { for: 'result'; view: ToolResultView }
/** Atomic telemetry captured at one observed model-request boundary. */
export interface ModelRequestTelemetry {
turn: number
step: number
provider: string
model: string
/** Token-meter pressure measured synchronously for this exact request. */
contextTokens?: number
/** Registration-bound capacity from this exact prepared call. */
contextWindow?: number
}
/** Streaming face of the contract: the two SSE stream openers (mux + host). */
export interface EventsApi {
/**
@@ -58,23 +69,18 @@ export interface EventsApi {
export type MuxFrame =
| { type: 'session/event'; sessionId: SessionId; event: SessionEvent; view?: ToolEventView }
| { type: 'session/subscribed'; sessionId: SessionId; lastSeq: number }
| { type: 'session/metrics'; sessionId: SessionId; metrics: SessionMetrics }
/**
* One request attempt observed by this already-open mux connection after its
* final route and outer `llm/stream` handle were obtained. This does not prove
* provider I/O began. The frame is transient: mux baselines, reconnects, and
* session history never replay it. An absent `contextWindow` explicitly clears
* a capacity observed from an earlier request on the same connection.
* session history never replay it. The optional numerator and capacity are
* one atomic request snapshot; absent fields explicitly replace, rather
* than inherit from, the preceding request.
*/
| {
| ({
type: 'session/model-request'
sessionId: SessionId
turn: number
step: number
provider: string
model: string
contextWindow?: number
}
} & ModelRequestTelemetry)
| { type: 'approval/requested'; sessionId: SessionId; approvalId: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string }
| { type: 'approval/resolved'; sessionId: SessionId; approvalId: ApprovalRequestId; outcome: ApprovalOutcome }
| { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] }
+4 -2
View File
@@ -29,14 +29,16 @@ export interface ApiProxy {
// ---- Domain interfaces and payload entities ----
export type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionMetrics, SessionModels, SessionProjectionsBlock,
ModelReasoningEffort, ModelTarget, SessionModels, SessionProjectionsBlock,
SessionsApi, SessionSummary,
} from './sessions.ts'
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
export type { CommandsApi, CommandDescriptor } from './commands.ts'
export type { SkillsApi, SkillEntry } from './skills.ts'
export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
export type {
EventsApi, HostFrame, ModelRequestTelemetry, MuxFrame, ToolCallView, ToolEventView, ToolResultView,
} from './events.ts'
export type { GoalsApi, GoalId, GoalRef } from './goals.ts'
export type { ApprovalResponsePayload } from './approvals.ts'
export type { QuestionResponsePayload } from './questions.ts'
@@ -11,7 +11,7 @@ import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionMetrics, SessionProjectionsBlock, SessionSummary,
ModelReasoningEffort, ModelTarget, SessionProjectionsBlock, SessionSummary,
} from './sessions.ts'
import type { ToolEventView } from './events.ts'
import type { WorkspaceId } from './workspace.ts'
@@ -151,23 +151,11 @@ export const sessionProjectionsBlockSchema = z.object({
values: z.record(z.string(), z.unknown()),
}) as unknown as z.ZodType<SessionProjectionsBlock>
/** Host-owned durable usage and current-pressure projection. */
export const sessionMetricsSchema = z.object({
logRevision: z.number().int().nonnegative(),
projectionRevision: z.number().int().nonnegative(),
uncachedInputTokens: z.number().nonnegative(),
outputTokens: z.number().nonnegative(),
cacheReadTokens: z.number().nonnegative(),
cacheWriteTokens: z.number().nonnegative(),
contextTokens: z.number().nonnegative().optional(),
}) satisfies z.ZodType<Wire<SessionMetrics>>
/** session.history response value (projections and metrics ride the tail page only). */
/** session.history response value (projections ride the tail page only). */
export const sessionHistoryValueSchema = z.object({
events: z.array(historyEntrySchema),
hasMore: z.boolean(),
projections: sessionProjectionsBlockSchema.optional(),
metrics: sessionMetricsSchema.optional(),
}) satisfies z.ZodType<Wire<ResponseValue<'session.history'>>>
/** session.models request payload. */
@@ -223,4 +211,3 @@ export const sessionCancelRequestSchema = z.object({
export const sessionCancelValueSchema = z.object({
accepted: z.literal(true),
}) satisfies z.ZodType<Wire<ResponseValue<'session.cancel'>>>
+2 -28
View File
@@ -35,29 +35,6 @@ export interface HistoryEntry {
view?: ToolEventView
}
/**
* Host-owned token metrics for one durable session revision. Provider usage
* buckets are cumulative across the full log; current context pressure
* describes the replayed request surface at this revision and is absent when
* the Host cannot measure it.
*/
export interface SessionMetrics {
/** Number of durable events included in this projection. */
logRevision: number
/** Monotone ordering within one Host process and mux subscription generation. */
projectionRevision: number
/** Cumulative uncached provider input. */
uncachedInputTokens: number
/** Cumulative provider output. */
outputTokens: number
/** Cumulative provider cache reads. */
cacheReadTokens: number
/** Cumulative provider cache writes; excluded from the Web cache-hit formula. */
cacheWriteTokens: number
/** Current request pressure from `ctx.tokenMeter.measure(session).totalTokens`. */
contextTokens?: number
}
/**
* The projection baseline riding the history tail page: one synchronous cut
* over every registered projection unit, read from the registry's watermark
@@ -211,17 +188,14 @@ export interface SessionsApi {
* the client needs a fresh baseline already pulls the tail page, and
* loadOlder (the only beforeSeq path) is the only path that never needs one.
* A deployment without the registry serves histories without the block.
* The same tail-only rule carries `metrics`, whose cumulative usage and
* current token-meter pressure are Host projections over the full log
* rather than products of the returned page. Live model capacity is
* connection-local telemetry and is never reconstructed here.
* Model-request telemetry is connection-local and is never reconstructed
* from history.
*/
history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>):
Promise<RpcResponse<{
events: HistoryEntry[]
hasMore: boolean
projections?: SessionProjectionsBlock
metrics?: SessionMetrics
}>>
/** Reads a fresh advisory model directory for this session. Provider lookups run independently. */
@@ -1,122 +0,0 @@
/**
* 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 { 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 TokenMeterLike {
measure(session: Session): { totalTokens: 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
}
/** Projects durable cumulative usage and synchronous current context pressure. */
export class SessionMetricsProjector {
private readonly usage = new WeakMap<Session, UsageState>()
/** @param ctx - Host context providing an optional token-meter service. */
constructor(private readonly ctx: Context) {}
/**
* Read a fresh detached projection through the session's durable tail.
* @param session - authoritative durable log owner.
* @returns cumulative usage and any currently measurable pressure.
*/
snapshot(session: Session): 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.
}
}
return {
logRevision: state.logRevision,
projectionRevision: state.projectionRevision++,
uncachedInputTokens: state.uncachedInputTokens,
outputTokens: state.outputTokens,
cacheReadTokens: state.cacheReadTokens,
cacheWriteTokens: state.cacheWriteTokens,
...contextTokens === undefined ? {} : { contextTokens },
}
}
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
}
}