/** * Host half of `dsh-deepseek-cost-watch`. * * Serves two read-only JSON routes over the Connection Fetch registry: * * GET /api/deepseek-cost-watch/report?sessionId= * GET /api/deepseek-cost-watch/balance[?refresh] * * The report folds the Session log's provider-reported usage into a cost * estimate that respects DeepSeek's peak / off-peak schedule, so the browser * half only has to render numbers. * * The fold lives on the Host because only the Session log records WHEN each * request was billed: the browser's `tokenUsage` projection carries aggregate * token totals and no timestamps, so it cannot split a Session between the * peak and off-peak rate cards. * * The balance route answers with the account's remaining credit, read from the * official `GET /user/balance` endpoint with the same credential the DeepSeek * model route uses. The API key never crosses to the browser. * * @module dsh-deepseek-cost-watch/host */ /** Exact Fetch route the browser half requests for the cost estimate. */ export const REPORT_PATH = '/api/deepseek-cost-watch/report' /** Exact Fetch route the browser half requests for the account balance. */ export const BALANCE_PATH = '/api/deepseek-cost-watch/balance' /** Credential ref the DeepSeek model route reads by default. */ export const DEFAULT_API_KEY_REF = 'DEEPSEEK_API_KEY' /** How long one balance answer is reused before the API is asked again. */ export const BALANCE_TTL_MS = 60000 /** Give up on the balance endpoint after this long. */ const BALANCE_TIMEOUT_MS = 10000 /** Date the rate tables below were last checked against the official page. */ export const RATE_AS_OF = '2026-09-15' /** Official pricing page the rate tables are taken from. */ export const RATE_SOURCE = 'https://api-docs.deepseek.com/quick_start/pricing' /** * The half-hour-free schedule, in UTC: DeepSeek bills the peak card Monday to * Friday 01:00-04:00 and 06:00-10:00, and the off-peak card (half price) at * every other hour, weekends included. */ export const PEAK_WINDOWS = [[1, 4], [6, 10]] /** * Prices in USD per 1,000,000 tokens as `[off-peak, peak]`, one entry per * billed bucket. Update these when DeepSeek changes its published rates; the * plugin never fetches pricing at runtime. */ export const RATE_TABLES = [ { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', models: ['deepseek-v4-pro'], cacheHit: [0.022, 0.044], cacheMiss: [0.66, 1.32], output: [1.98, 3.96], }, { id: 'deepseek-flash', name: 'DeepSeek-V4.1-Flash', models: ['deepseek-flash', 'deepseek-v4-flash'], cacheHit: [0.003, 0.006], cacheMiss: [0.15, 0.3], output: [0.6, 1.2], }, ] const HOUR_MS = 3600000 /** True when the instant `ms` falls inside a billed peak window. */ export function isPeakTime(ms) { const date = new Date(ms) const weekday = date.getUTCDay() if (weekday === 0 || weekday === 6) return false const minutes = date.getUTCHours() * 60 + date.getUTCMinutes() for (let i = 0; i < PEAK_WINDOWS.length; i++) { const window = PEAK_WINDOWS[i] if (minutes >= window[0] * 60 && minutes < window[1] * 60) return true } return false } /** Epoch milliseconds of the next peak/off-peak flip strictly after `ms`. */ export function nextChangeAt(ms) { const peakNow = isPeakTime(ms) let cursor = ms - (ms % HOUR_MS) + HOUR_MS for (let i = 0; i < 24 * 8; i++) { if (isPeakTime(cursor) !== peakNow) return cursor cursor += HOUR_MS } return null } /** The rate entry billed for one `provider` / `model` route, or null. */ export function rateTableFor(provider, model) { if (provider !== 'deepseek-official') return null const id = typeof model === 'string' ? model : '' if (id === '') return null for (let i = 0; i < RATE_TABLES.length; i++) { const table = RATE_TABLES[i] for (let k = 0; k < table.models.length; k++) { const key = table.models[k] if (id === key || id.indexOf(key) === 0) return table } } return null } function count(value) { return typeof value === 'number' && value > 0 ? value : 0 } /** * Fold a Session's committed events into one estimate. * * Only leaf fields are read from the live events (type, time, provider, * model, and the usage counters); the returned value is a plain JSON object * that never references a Session, an event, or any other live object. * * @param events - committed Session events in sequence order. * @returns the token totals, the peak/off-peak split, and the per-model split. */ export function foldUsage(events) { let provider = '' let model = '' let lastTime = 0 let steps = 0 let costTotal = 0 let costPeak = 0 let costOffPeak = 0 const tokens = { uncachedInput: 0, cacheRead: 0, cacheWrite: 0, output: 0, priced: 0, unpriced: 0, peakPriced: 0, offPeakPriced: 0, } const models = {} const unpriced = {} for (let i = 0; i < events.length; i++) { const event = events[i] if (event === null || typeof event !== 'object') continue if (event.type === 'request/context') { const context = event.data if (context !== null && typeof context === 'object') { if (typeof context.provider === 'string') provider = context.provider if (typeof context.model === 'string') model = context.model } continue } if (event.type !== 'assistant/message') continue const data = event.data const usage = data !== null && typeof data === 'object' ? data.usage : undefined if (usage === null || usage === undefined || typeof usage !== 'object') continue const uncachedInput = count(usage.inputTokens) const cacheRead = count(usage.cacheReadTokens) const cacheWrite = count(usage.cacheWriteTokens) const output = count(usage.outputTokens) const total = uncachedInput + cacheRead + cacheWrite + output if (total === 0) continue steps += 1 const time = typeof event.time === 'number' && event.time > 0 ? event.time : lastTime if (time > 0) lastTime = time tokens.uncachedInput += uncachedInput tokens.cacheRead += cacheRead tokens.cacheWrite += cacheWrite tokens.output += output const table = rateTableFor(provider, model) if (table === null) { tokens.unpriced += total unpriced[provider + '/' + model] = true continue } const peak = isPeakTime(time) tokens.priced += total if (peak) tokens.peakPriced += total else tokens.offPeakPriced += total const tier = peak ? 1 : 0 const spend = (cacheRead * table.cacheHit[tier] + (uncachedInput + cacheWrite) * table.cacheMiss[tier] + output * table.output[tier]) / 1000000 costTotal += spend if (peak) costPeak += spend else costOffPeak += spend let entry = models[table.id] if (entry === undefined) { entry = { id: table.id, name: table.name, tokens: 0, cost: 0, peakCost: 0, offPeakCost: 0 } models[table.id] = entry } entry.tokens += total entry.cost += spend if (peak) entry.peakCost += spend else entry.offPeakCost += spend } const byModel = [] for (const key in models) { if (Object.prototype.hasOwnProperty.call(models, key)) byModel.push(models[key]) } const unpricedRoutes = [] for (const key in unpriced) { if (Object.prototype.hasOwnProperty.call(unpriced, key)) unpricedRoutes.push(key) } return { steps: steps, lastTime: lastTime, tokens: tokens, cost: { total: costTotal, peak: costPeak, offPeak: costOffPeak, currency: 'USD' }, models: byModel, unpricedRoutes: unpricedRoutes, } } /** * Resolve the live Session named by a report request. * * Only Sessions the Host already holds in memory can be priced: the fold needs * per-request timestamps, and a cold Session's log is not read here. * * @param ctx - the composed Host context. * @param sessionId - the requested Session id. * @returns the Session, or undefined when it is not live. */ function liveSessionOf(ctx, sessionId) { const sessions = ctx.get('sessions') const session = sessions !== undefined && typeof sessions.get === 'function' ? sessions.get(sessionId) : undefined if (session !== undefined && session !== null) return session const agents = ctx.get('agents') const agent = agents !== undefined && typeof agents.get === 'function' ? agents.get(sessionId) : undefined return agent !== undefined && agent !== null ? agent.session : undefined } function jsonResponse(status, body) { return new Response(JSON.stringify(body), { status: status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, }) } function reportResponse(ctx, request) { const url = new URL(request.url) const sessionId = url.searchParams.get('sessionId') if (sessionId === null || sessionId === '') return jsonResponse(400, { ok: false, reason: 'missing-session' }) const session = liveSessionOf(ctx, sessionId) if (session === undefined || session === null) return jsonResponse(404, { ok: false, reason: 'session-not-found' }) let events try { events = session.snapshotEvents() } catch { return jsonResponse(500, { ok: false, reason: 'log-unreadable' }) } if (events === null || events === undefined || typeof events.length !== 'number') { return jsonResponse(500, { ok: false, reason: 'log-unreadable' }) } const folded = foldUsage(events) const now = Date.now() return jsonResponse(200, { ok: true, asOf: RATE_AS_OF, source: RATE_SOURCE, steps: folded.steps, now: now, peakNow: isPeakTime(now), nextChangeAt: nextChangeAt(now), tokens: folded.tokens, cost: folded.cost, models: folded.models, unpricedRoutes: folded.unpricedRoutes, }) } /** Stable Cordis plugin name. */ export const name = 'deepseek-cost-watch' /** A Fetch route needs the connection carrier; every other service is optional. */ export const inject = ['connection'] /** * Base URL of the DeepSeek API. The model adapter reads the same environment * variable, so a deployment pointed at a proxy keeps both routes together. * @returns the configured origin, without a trailing slash. */ export function deepseekBaseUrl() { const configured = typeof process !== 'undefined' && process.env !== undefined ? process.env.DEEPSEEK_BASE_URL : undefined const value = typeof configured === 'string' && configured.length > 0 ? configured : 'https://api.deepseek.com' return value.replace(/\/+$/, '') } /** * Resolve one credential the way the DeepSeek model route does: the stored * credential first, then the launching environment. * @param ctx - the composed Host context. * @param ref - credential reference name. * @returns the secret value, or undefined when neither source has it. */ async function resolveCredential(ctx, ref) { const credentials = ctx.get('credentials') if (credentials !== undefined && typeof credentials.resolve === 'function') { try { const hit = await credentials.resolve(ref) if (hit !== undefined && hit !== null && typeof hit.value === 'string' && hit.value.length > 0) return hit.value } catch { // Fall through to the ambient variable. } } const ambient = typeof process !== 'undefined' && process.env !== undefined ? process.env[ref] : undefined return typeof ambient === 'string' && ambient.length > 0 ? ambient : undefined } /** * Normalize the official `/user/balance` payload into owned JSON: scalar * fields only, no upstream object is retained. * @param payload - the parsed response body. * @returns the normalized balance, or null when the shape is unusable. */ export function foldBalance(payload) { if (payload === null || typeof payload !== 'object') return null const infos = Array.isArray(payload.balance_infos) ? payload.balance_infos : null if (infos === null) return null const balances = [] for (let i = 0; i < infos.length; i++) { const info = infos[i] if (info === null || typeof info !== 'object') continue if (typeof info.currency !== 'string' || typeof info.total_balance !== 'string') continue balances.push({ currency: info.currency, total: info.total_balance, granted: typeof info.granted_balance === 'string' ? info.granted_balance : null, toppedUp: typeof info.topped_up_balance === 'string' ? info.topped_up_balance : null, }) } if (balances.length === 0) return null return { isAvailable: payload.is_available === true, balances: balances } } function messageOf(error) { return error instanceof Error ? error.message : String(error) } /** * Ask the DeepSeek API for the account balance. Every failure is reported as a * reason the browser can render, never as a thrown error: a missing credential * must degrade the badge, not break it. * @param ctx - the composed Host context. * @returns the balance answer body. */ async function fetchBalance(ctx) { const ref = DEFAULT_API_KEY_REF const fetchedAt = Date.now() const key = await resolveCredential(ctx, ref) if (key === undefined) return { ok: false, reason: 'missing-credential', ref: ref, fetchedAt: fetchedAt } let response try { response = await fetch(deepseekBaseUrl() + '/user/balance', { headers: { authorization: 'Bearer ' + key, accept: 'application/json' }, signal: typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(BALANCE_TIMEOUT_MS) : undefined, }) } catch (error) { return { ok: false, reason: 'unreachable', ref: ref, message: messageOf(error), fetchedAt: fetchedAt } } if (response.ok !== true) { return { ok: false, reason: 'api-error', ref: ref, status: response.status, fetchedAt: fetchedAt } } let payload try { payload = await response.json() } catch { return { ok: false, reason: 'malformed', ref: ref, fetchedAt: fetchedAt } } const folded = foldBalance(payload) if (folded === null) return { ok: false, reason: 'malformed', ref: ref, fetchedAt: fetchedAt } return { ok: true, ref: ref, fetchedAt: fetchedAt, isAvailable: folded.isAvailable, balances: folded.balances, } } /** * Register both Fetch routes on the Connection registry. The registrations * belong to this plugin's fiber, so stopping or updating the plugin withdraws * them with it. The balance answer is cached in this closure for * {@link BALANCE_TTL_MS}, which is what keeps a chatty browser from hammering * the account endpoint. * * @param ctx - the composed Host context. */ export function apply(ctx) { ctx.connection.fetch.register({ path: REPORT_PATH, methods: ['GET'], requestBody: 'buffered', fetch: async (request) => reportResponse(ctx, request), }) let cached = null const balanceBody = async (force) => { const now = Date.now() if (force !== true && cached !== null && now - cached.at < BALANCE_TTL_MS) return cached.body const body = await fetchBalance(ctx) cached = { at: Date.now(), body: body } return body } ctx.connection.fetch.register({ path: BALANCE_PATH, methods: ['GET'], requestBody: 'buffered', // Always 200: the browser renders the reason, and a refresh gesture must // not look like a transport failure. fetch: async (request) => jsonResponse(200, await balanceBody(new URL(request.url).searchParams.has('refresh'))), }) }