From c1125534dce625ba81272444490de4551ec1c52e Mon Sep 17 00:00:00 2001 From: Joob1n Date: Wed, 2 Sep 2026 23:21:13 +0800 Subject: [PATCH] feat(desktop): show context usage beside the model controls A read-only indicator in the composer's model controls shows the latest request as the provider counted it: input plus output tokens of the last accepted request, read from the session's newest token_usage record. With a user-declared Maka window it shows the percentage (over 100% is shown as such, never clamped); without one it shows the absolute count and names the model's reported window in a tooltip; without usage it shows a dash and says the provider reported none. Chat model choices carry the reported and the declared window separately so the two are never confused (#4559). Refs #4559 Generated-by: Claude Code Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J --- CHANGELOG.md | 1 + apps/desktop/renderer-architecture.json | 6 +- ...chat-composer-region-draft-handoff.test.ts | 1 + .../__tests__/latest-request-usage.test.ts | 124 ++++++++++++++++++ apps/desktop/src/renderer/app-shell.tsx | 30 ++--- .../src/renderer/chat-composer-region.tsx | 73 +++++++++++ .../src/renderer/styles/model-switcher.css | 14 ++ .../src/__tests__/llm-connections.test.ts | 33 +++++ .../usage-record-last-request-anchor.test.ts | 9 ++ packages/core/src/chat-model-choice.ts | 9 +- packages/core/src/usage-record-schema.ts | 13 +- packages/runtime-host/src/protocol/index.ts | 5 +- packages/runtime/src/ai-sdk-backend.ts | 4 + packages/ui/src/composer.tsx | 41 ++++++ packages/ui/src/conversation-copy.ts | 11 ++ 15 files changed, 351 insertions(+), 23 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/latest-request-usage.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bf5ff4e9cb..bab1cf0dc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ owner, immutable request snapshots remain enforced at AgentRun acceptance and backend dispatch, and SessionEvent-to-RuntimeEvent conversion remains a pure mapper. - Retired the Task Ledger domain: SessionTodo is now the sole authority for in-session work items, and the operational-state schema drops the `workflow_task_ledger_events` table on first open. **Unfinished Tasks are not migrated and are permanently deleted.** This affects workspaces last opened by `v0.1.0` through `v0.1.11`, `cli-v0.1.0-beta.1`, `v0.2.0-incubating-rc1`, or a `v0.2.0-dev` build; those releases wrote Tasks to a table that no shipped build ever bridged into SessionTodo. Before opening such a workspace with this build, finish or export the Tasks you still need, or copy the workspace's `runtime.sqlite` aside — the migration removes the only live copy, so afterwards recovery requires a backup made in advance. +- `token_usage` anchors now record the model and connection that produced them. A token count is a number in one model's tokenizer against one connection; carrying the route on the record lets any reader apply the rule the runtime already enforces, instead of pairing one model's usage with another model's window. The record decodes against a closed allowlist, so sessions written with these keys do not open in earlier releases, and the Runtime Host compatibility epoch moves to 107. - Let the provider decide whether a request fits. Proactive compaction now uses only a user-declared Maka window and the previous accepted request's provider-reported `inputTokens + outputTokens`; no declaration means no proactive capacity threshold. `/models` and generated model metadata are display hints, not limits. `token_usage` records persist the last-request anchor under `lastRequestAnchor`; its new `{ inputTokens, outputTokens }` shape still decodes the retired `payloadChars` key from older sessions. Requests that are too large are compacted and retried once after a real provider rejection, then reported as a `context_overflow` provider error. Compaction is entered at most once per send, and a request rejected after a fold was actually applied is reported as still too large after compaction. A fold that failed open makes no such claim: that request went out with its full raw history. A reply cut at `finishReason: length` no longer triggers a fold, because the provider running out of window room and the provider's own lower output cap are indistinguishable from outside. Five system notes explain the provider-side cases: dropping context, a window worth declaring, an exchange past the declared window, a request accepted past the window the model reports (once per crossing, while nothing is declared), and a request still too large after compaction. The reply reserve that arms the proactive threshold is twice the last real reply, bounded at 8,000 tokens, rather than the model's maximum output. **Sessions this build writes do not open in earlier releases:** those decode `token_usage` against a closed allowlist, so the reshaped `lastRequestAnchor` key fails the record and, with it, the Session that contains it; downgrading therefore needs a copy of the workspace's `runtime.sqlite` taken before the upgrade. Nothing produces the `context_budget_exhausted` stop reason any more — a request that really is too large is compacted and retried once, then reported as a `context_overflow` provider error — though sessions that already recorded it still decode and present. The Runtime Host compatibility epoch moves to 106. - Unified context management under one Runtime-owned policy. `MAKA_CONTEXT_*` environment overrides no longer tune or disable compaction and Tool Result pruning; model-visible archive placeholders are read on demand through bounded `ArchiveRead` calls instead of eager hydration. Previously supported overrides are ignored on upgrade: if Tool Result pruning was set to `off`, pruning is re-enabled, and there is currently no supported replacement opt-out. - Moved Read image snapshots into the durable context-offload store with Runtime-owned diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 24d0367fae..669e5507ae 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -788,7 +788,7 @@ "nonTriviaTokens": 1410 }, "src/renderer/app-shell.tsx": { - "importDeclarations": 103, + "importDeclarations": 102, "bridgePaths": { "window.maka.app.installUpdate": 1, "window.maka.app.retryUpdateDownload": 1, @@ -970,7 +970,7 @@ "@maka/core/onboarding-milestone": 1, "@maka/core/orchestration": 1, "@maka/core/project": 1, - "@maka/core/session": 2, + "@maka/core/session": 1, "@maka/core/session-revisions": 1, "@maka/core/settings": 1, "@maka/core/slash-command-catalog": 2, @@ -980,7 +980,7 @@ "react": 1 }, "importSpecifiers": 184, - "nonTriviaTokens": 15692 + "nonTriviaTokens": 15687 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 3, diff --git a/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts b/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts index a6f0302ac7..907c24243d 100644 --- a/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts @@ -113,6 +113,7 @@ async function mountRegion(): Promise<{ composerRef: composer, directoryComposerProps: {}, directoryPickerEnabled: false, + active: true, onboardingComposerHidden: false, activeInteraction: undefined, diff --git a/apps/desktop/src/main/__tests__/latest-request-usage.test.ts b/apps/desktop/src/main/__tests__/latest-request-usage.test.ts new file mode 100644 index 0000000000..a77f4dbb35 --- /dev/null +++ b/apps/desktop/src/main/__tests__/latest-request-usage.test.ts @@ -0,0 +1,124 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { selectLatestRequestUsage } from '../../renderer/chat-composer-region.js'; + +const ROUTE = { llmConnectionId: 'conn-a' }; +const MODEL = 'model-a'; + +function usage(anchor?: { + inputTokens: number; + outputTokens?: number; + modelId?: string; + connectionId?: string; +}) { + return { type: 'token_usage', ...(anchor ? { lastRequestAnchor: anchor } : {}) }; +} + +test('reads the newest anchor on the active route', () => { + const tokens = selectLatestRequestUsage( + [ + usage({ inputTokens: 10, outputTokens: 2, modelId: MODEL, connectionId: 'conn-a' }), + { type: 'assistant' }, + usage({ inputTokens: 100, outputTokens: 20, modelId: MODEL, connectionId: 'conn-a' }), + ], + { hasNewer: false }, + MODEL, + ROUTE, + ); + assert.equal(tokens, 120); +}); + +test('scans past an anchorless usage row, which is what manual compaction writes', () => { + // `/compact` appends a synthetic `token_usage` with no anchor. The runtime's + // own reader skips it and keeps the last real request; stopping there would + // blank the indicator after every manual compaction. + const tokens = selectLatestRequestUsage( + [ + usage({ inputTokens: 100, outputTokens: 20, modelId: MODEL, connectionId: 'conn-a' }), + usage(), + ], + { hasNewer: false }, + MODEL, + ROUTE, + ); + assert.equal(tokens, 120); +}); + +test('refuses an anchor from another model', () => { + // A token count is a number in one model's tokenizer. Pairing model A's + // count with model B's window produces a precise-looking figure about a + // request the user is not making. + const tokens = selectLatestRequestUsage( + [usage({ inputTokens: 100_000, modelId: 'model-b', connectionId: 'conn-a' })], + { hasNewer: false }, + MODEL, + ROUTE, + ); + assert.equal(tokens, undefined); +}); + +test('refuses an anchor from another connection', () => { + const tokens = selectLatestRequestUsage( + [usage({ inputTokens: 100, modelId: MODEL, connectionId: 'conn-b' })], + { hasNewer: false }, + MODEL, + ROUTE, + ); + assert.equal(tokens, undefined); +}); + +test('refuses an anchor written before anchors carried their route', () => { + const tokens = selectLatestRequestUsage( + [usage({ inputTokens: 100, outputTokens: 20 })], + { hasNewer: false }, + MODEL, + ROUTE, + ); + assert.equal(tokens, undefined); +}); + +test('refuses every anchor while the loaded range is not the session tail', () => { + // Browsing history must not report an older range's usage as current. + const tokens = selectLatestRequestUsage( + [usage({ inputTokens: 100, outputTokens: 20, modelId: MODEL, connectionId: 'conn-a' })], + { hasNewer: true }, + MODEL, + ROUTE, + ); + assert.equal(tokens, undefined); +}); + +test('refuses when there is no active route yet', () => { + const anchored = [usage({ inputTokens: 100, modelId: MODEL, connectionId: 'conn-a' })]; + assert.equal(selectLatestRequestUsage(anchored, undefined, undefined, ROUTE), undefined); + assert.equal(selectLatestRequestUsage(anchored, undefined, MODEL, undefined), undefined); +}); + +test('refuses a non-positive input count', () => { + const tokens = selectLatestRequestUsage( + [usage({ inputTokens: 0, modelId: MODEL, connectionId: 'conn-a' })], + { hasNewer: false }, + MODEL, + ROUTE, + ); + assert.equal(tokens, undefined); +}); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index a847a21794..0a685bbab4 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -35,7 +35,6 @@ import type { InlineReference, QuoteRef, } from '@maka/core/events'; -import type { SessionSummary } from '@maka/core/session'; import type { OrchestrationMode } from '@maka/core/orchestration'; import type { ChatDefaultPermissionMode } from '@maka/core/settings'; import type { SlashCommandIdForSurface } from '@maka/core/slash-command-catalog'; @@ -86,7 +85,7 @@ import { deriveWorkspaceReadinessRecovery } from './workspace-readiness-recovery import { LiveTurnReconciler } from './live-turn-reconciler'; import { useAppShellSessionUiReads } from './use-app-shell-session-ui-reads'; import { AgentGraphPanel } from './agent-graph-panel'; -import { ChatComposerRegion } from './chat-composer-region'; +import { ChatComposerRegion, selectLatestRequestUsage } from './chat-composer-region'; import { WorkbarHost, WorkbarTitlebarActions, @@ -361,8 +360,7 @@ function AppShellContent({ sessionUiController, } = useAppShellSessionWorkspace(toastApi); const activeCatalogSession = sessions.find((session) => session.id === activeId); - const sharedSessionActive = - (activeCatalogSession as DesktopSessionSummary | undefined)?.shared === true; + const sharedSessionActive = activeCatalogSession?.shared === true; const ownerActiveId = activeCatalogSession && !sharedSessionActive ? activeId : undefined; const interactionHydrationEpochRef = useRef(new Map()); const markInteractionChanged = useCallback((sessionId: string) => { @@ -952,10 +950,9 @@ function AppShellContent({ openModelPicker: openComposerModelPicker, refreshModelChoices: sessionHostConnections.refreshConnections, }); - const newChatProviderType = newChatModel - ? connections.find((connection) => connection.slug === newChatModel.llmConnectionSlug)?.providerType - : undefined; - + const newChatProviderType = connections.find( + (connection) => connection.slug === newChatModel?.llmConnectionSlug, + )?.providerType; // PR109d-b: turn footer actions per turn. Derived from the // materialized turn list (status + lineage descendants) + pending // mask. Per @kenji PR109d review: pending state prevents double-click @@ -1160,15 +1157,13 @@ function AppShellContent({ // Transient placeholder while the real SessionSummary loads, so the composer // does not flash a value the session never had. - const activeSessionForView: SessionSummary | undefined = - activeSession ?? - (activeId - ? pendingSessionView({ - sessionId: activeId, - name: shellCopy.newConversation, - permissionMode: newTaskPermissionMode, - }) - : undefined); + const activeSessionForView = activeSession ?? (activeId + ? pendingSessionView({ + sessionId: activeId, + name: shellCopy.newConversation, + permissionMode: newTaskPermissionMode, + }) + : undefined); // Each control reads its own field. There is nothing to project and nothing // to keep in sync: a Session in Plan with Swarm as its orchestration default // says both, because it is both. @@ -2996,6 +2991,7 @@ function AppShellContent({ activeModel={activeModel} activeModelLabel={activeModelLabel} activeProviderType={activeConnection?.providerType} + latestRequestUsageTokens={selectLatestRequestUsage(messages, activeTranscriptRange, activeModel, activeSessionForModelControls)} modelChoices={chatModelChoices} modelSwitchHasHistory={modelSwitchHasHistory} hideUnavailableCurrentModel={sessionHealthNotice?.onClickTarget === 'model_picker'} diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx index 2c25797fd7..b2ab639c27 100644 --- a/apps/desktop/src/renderer/chat-composer-region.tsx +++ b/apps/desktop/src/renderer/chat-composer-region.tsx @@ -114,6 +114,13 @@ interface ChatComposerRegionProps respondToUserQuestion: ComponentProps['onRespond']; stop: ComponentProps['onStop']; boundaryUnreadableNotice?: BoundaryUnreadableNotice; + /** + * Tokens the provider counted for the session's latest request on the active + * route, or nothing when that cannot be established. Resolved by the owner, + * which knows the transcript range and the route; this control never derives + * it from the rendered slice. + */ + latestRequestUsageTokens?: number; directoryComposerProps: Pick< ComponentProps, 'pendingDirectories' | 'onRemoveDirectory' | 'onPickDirectory' @@ -121,6 +128,56 @@ interface ChatComposerRegionProps directoryPickerEnabled: boolean; } +/** + * The session's latest provider-counted request, or nothing. + * + * A token count belongs to one request on one route: it is a number in that + * model's tokenizer, and it is only the session's latest if nothing newer + * exists. The runtime enforces both when it reads an anchor back, refusing one + * whose run header names another model or connection. A control that shows the + * number has to enforce the same two facts or it will display a precise-looking + * figure about a request the user is not making — model A's tokens against + * model B's window, or a historical range's usage presented as current. + * + * So this refuses rather than approximates, and the three refusals are the + * three normal states that break the pairing: + * + * - the loaded transcript range is not the session tail, so a newer request may + * exist that this range cannot see; + * - the newest usage row carries no anchor, which is what manual `/compact` + * writes, so the scan continues past it exactly as the runtime's does; + * - the anchor names a different route than the active one, or names none at + * all because it was written before anchors carried their route. + */ +export interface LatestRequestUsageAnchor { + inputTokens: number; + outputTokens?: number; + modelId?: string; + connectionId?: string; +} + +export function selectLatestRequestUsage( + messages: readonly { type: string; lastRequestAnchor?: LatestRequestUsageAnchor }[], + /** `hasNewer` means the loaded range is not the session tail. */ + range: { hasNewer?: boolean } | undefined, + model: string | undefined, + route: { llmConnectionId?: string } | undefined, +): number | undefined { + const connectionId = route?.llmConnectionId; + if (range?.hasNewer || model === undefined || connectionId === undefined) return undefined; + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message?.type !== 'token_usage') continue; + const anchor = message.lastRequestAnchor; + if (!anchor) continue; + if (anchor.modelId !== model || anchor.connectionId !== connectionId) return undefined; + if (!Number.isFinite(anchor.inputTokens) || anchor.inputTokens <= 0) return undefined; + const output = Number.isFinite(anchor.outputTokens ?? 0) ? Math.max(0, anchor.outputTokens ?? 0) : 0; + return anchor.inputTokens + output; + } + return undefined; +} + export function ChatComposerRegion({ composerRef, active, @@ -135,6 +192,7 @@ export function ChatComposerRegion({ respondToUserQuestion, stop, boundaryUnreadableNotice, + latestRequestUsageTokens, directoryComposerProps, directoryPickerEnabled, ...composerRest @@ -145,6 +203,20 @@ export function ChatComposerRegion({ const activeClientCapability = activeInteraction?.type === 'client_capability_request' ? activeInteraction : undefined; const activeQuestion = activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined; + const activeModelChoice = composerRest.activeModel + ? composerRest.modelChoices?.find( + (choice) => + choice.connectionId === composerRest.activeModelConnectionId && + choice.model === composerRest.activeModel, + ) + : undefined; + const contextUsage = activeId + ? { + usageTokens: latestRequestUsageTokens, + declaredContextWindow: activeModelChoice?.declaredContextWindow, + metadataContextWindow: activeModelChoice?.contextWindow, + } + : undefined; const previousNewTaskDraftKey = useRef(newTaskDraftKey); useLayoutEffect(() => { const previous = previousNewTaskDraftKey.current; @@ -254,6 +326,7 @@ export function ChatComposerRegion({ { + const choices = chatModelChoicesFor([ + { + connectionId: 'connection-context', + slug: 'openai-compatible', + name: 'OpenAI compatible', + providerType: 'openai-compatible', + enabled: true, + defaultModel: 'declared-model', + enabledModelIds: ['declared-model', 'reported-model'], + models: [ + { id: 'declared-model', contextWindow: 64_000, inputLimit: 48_000 }, + { id: 'reported-model', contextWindow: 128_000 }, + ], + relayModelProfiles: { 'declared-model': { contextWindow: 32_000 } }, + createdAt: 1, + updatedAt: 1, + }, + ]); + + assert.deepEqual( + choices.map(({ model, contextWindow, declaredContextWindow }) => ({ + model, + contextWindow, + declaredContextWindow, + })), + [ + { model: 'declared-model', contextWindow: 64_000, declaredContextWindow: 32_000 }, + { model: 'reported-model', contextWindow: 128_000, declaredContextWindow: undefined }, + ], + ); +}); + test('provider recognition does not resolve inherited object members', () => { // `PROVIDER_REGISTRY` is an object literal, so plain indexing answers truthy // for `__proto__` / `toString` / `constructor` and they would read as diff --git a/packages/core/src/__tests__/usage-record-last-request-anchor.test.ts b/packages/core/src/__tests__/usage-record-last-request-anchor.test.ts index c8aba6ee6e..e37c5af83e 100644 --- a/packages/core/src/__tests__/usage-record-last-request-anchor.test.ts +++ b/packages/core/src/__tests__/usage-record-last-request-anchor.test.ts @@ -48,6 +48,15 @@ test('token-usage fields carry the anchor and reject a broken one', () => { isTokenUsageFields({ ...usage, lastRequestAnchor: { inputTokens: 120, foo: 1 } }), false, ); + // The route the counts belong to. A reader pairs them with a window only + // when it matches the request it is about to make. + assert.equal( + isTokenUsageFields({ + ...usage, + lastRequestAnchor: { inputTokens: 120, modelId: 'm', connectionId: 'c' }, + }), + true, + ); }); test('an invalid anchor fails the whole token_usage message decode', () => { diff --git a/packages/core/src/chat-model-choice.ts b/packages/core/src/chat-model-choice.ts index ff628fd5b8..bdd94872fb 100644 --- a/packages/core/src/chat-model-choice.ts +++ b/packages/core/src/chat-model-choice.ts @@ -17,7 +17,7 @@ * under the License. */ -import { type ThinkingLevel } from './model-thinking.js'; +import { declaredContextWindow, type ThinkingLevel } from './model-thinking.js'; import { offerableCatalogEntries, providerDefaultsOf, @@ -40,6 +40,10 @@ export interface ChatModelChoice { thinkingLevels: readonly ThinkingLevel[]; /** Exact capability projection used by model-facing attachment composition. */ supportsVision?: boolean; + /** Provider/model metadata shown beside the user-declared context setting. */ + contextWindow?: number; + /** User-declared context target, if this model has one. */ + declaredContextWindow?: number; } export function buildChatModelChoices( @@ -50,6 +54,7 @@ export function buildChatModelChoices( const provider = providerDefaultsOf(connection.providerType); if (!provider) continue; for (const entry of offerableCatalogEntries(connection)) { + const declaredWindow = declaredContextWindow(connection, entry.id); choices.push({ connectionId: connection.connectionId, connectionSlug: connection.slug, @@ -63,6 +68,8 @@ export function buildChatModelChoices( isDefault: entry.isDefault, thinkingLevels: entry.thinkingLevels, supportsVision: entry.supportsVision, + ...(entry.contextWindow !== undefined ? { contextWindow: entry.contextWindow } : {}), + ...(declaredWindow !== undefined ? { declaredContextWindow: declaredWindow } : {}), }); } } diff --git a/packages/core/src/usage-record-schema.ts b/packages/core/src/usage-record-schema.ts index da41aa1234..f3c2d0e5cc 100644 --- a/packages/core/src/usage-record-schema.ts +++ b/packages/core/src/usage-record-schema.ts @@ -307,11 +307,22 @@ export interface TokenUsageFields { export interface LastRequestAnchor { inputTokens: number; outputTokens?: number; + /** + * The route that produced these counts. + * + * A token count is a number in one model's tokenizer against one connection. + * The runtime already refuses an anchor across a route change, validating it + * against the run header; carrying the route on the record lets every other + * reader apply the same rule without reconstructing run headers, and without + * pairing counts from one model with another model's window. + */ + modelId?: string; + connectionId?: string; } const LAST_REQUEST_ANCHOR_SHAPE = defineObjectShape()( ['inputTokens'], - ['outputTokens'], + ['outputTokens', 'modelId', 'connectionId'], ); const RETIRED_LAST_REQUEST_ANCHOR_KEYS = ['payloadChars'] as const; const LAST_REQUEST_ANCHOR_DECODE_SHAPE = { diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 077a0d58c6..aca388a526 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -100,7 +100,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 106 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 107 as const; +// 107: `token_usage` anchors record the model and connection that produced +// them. The record decodes against a closed allowlist, so an older client +// rejects the two new keys and, with them, the Session that carries them. // 106: Session transcripts gain five `system_note` kinds // (`context_provider_dropping`, `context_window_suggestion`, // `context_window_overrun`, `context_reported_window_exceeded`, diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index f5da71ea18..4f2db9e6d4 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -2975,6 +2975,10 @@ export class AiSdkBackend implements AgentBackend { ...(anchorOutputTokens !== undefined ? { outputTokens: anchorOutputTokens } : {}), + modelId: this.input.modelId, + ...(this.input.header.llmConnectionId !== undefined + ? { connectionId: this.input.header.llmConnectionId } + : {}), }, } : {}), diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index fe19b9ea86..5782a857ea 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -37,6 +37,7 @@ import { useMountedRef } from './use-mounted-ref.js'; import { ICON_SIZE, ArrowUp, + CircleGauge, FileText, ListTodo, Network, @@ -397,6 +398,12 @@ export const Composer = forwardRef< noModelConnection?: boolean; /** Optional Host-aware replacement for the generic no-model hint. */ noModelHint?: string; + /** Read-only usage indicator for the active model's latest request. */ + contextUsage?: { + usageTokens?: number; + declaredContextWindow?: number; + metadataContextWindow?: number; + }; /** * Optional edit-and-resend banner above the composer. Desktop owns the * revision draft; Composer only renders the notice + cancel affordance. @@ -2135,6 +2142,7 @@ export const Composer = forwardRef< onChange={props.onNewChatThinkingLevelChange} /> )} + {props.contextUsage ? : null} {/* The project decides where a NEW chat starts, which makes it a parameter of this send like the model beside it — so it sits @@ -2234,4 +2242,37 @@ export const Composer = forwardRef< ); }); +function ContextUsageIndicator(props: { + usageTokens?: number; + declaredContextWindow?: number; + metadataContextWindow?: number; +}) { + const copy = getConversationCopy(useUiLocale()).messages; + // A window from either source is enough to show a share: the user's + // declaration when there is one, otherwise the model's reported window. The + // distinction matters for the compaction threshold, which only a declaration + // arms, not for reading a number off the screen. With no window at all the + // usage stands on its own. + const window = props.declaredContextWindow ?? props.metadataContextWindow; + const label = + props.usageTokens === undefined + ? '—' + : window !== undefined && window > 0 + ? `${Math.round((props.usageTokens / window) * 100)}%` + : `${props.usageTokens} tok`; + const tooltip = + props.usageTokens === undefined + ? copy.systemNotes.contextUsageUnavailable + : window !== undefined && window > 0 + ? copy.systemNotes.contextUsageShare(props.usageTokens, window) + : copy.systemNotes.contextUsageNoWindow; + const indicator = ( + + + ); + return tooltip ? {indicator} : indicator; +} + export type ComposerProps = ComponentProps; diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index 41946eb14a..966847ebe8 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -325,6 +325,9 @@ export interface ConversationCopy { contextWindowOverrun: (used: number, declared: number) => string; contextReportedWindowExceeded: (used: number, reported: number) => string; contextOverflowAfterCompaction: string; + contextUsageShare: (used: number, window: number) => string; + contextUsageNoWindow: string; + contextUsageUnavailable: string; stepLimit: string; }; }; @@ -549,6 +552,10 @@ const CONVERSATION_COPY = { `本次交换用了约 ${used} tokens,已超过该模型上报的窗口(${reported}),但供应商没有拒绝。你未声明窗口,Maka 因此不会主动压缩。在连接设置里声明一个窗口即可让它先行压缩。`, contextOverflowAfterCompaction: '已经压缩过历史,供应商仍然说这次请求太大。剩下的部分还包含系统提示、工具定义、摘要和最近的原文,缩短这条消息是你能控制的那一半。', + contextUsageShare: (used, window) => + `${used.toLocaleString('zh-CN')} / ${window.toLocaleString('zh-CN')} tokens`, + contextUsageNoWindow: '该模型没有窗口大小可用:未声明,模型也未上报', + contextUsageUnavailable: '供应商未报告用量', stepLimit: '已达到本轮工具步骤上限,任务可能尚未完成。发送“继续”即可接着处理。', }, }, @@ -718,6 +725,10 @@ const CONVERSATION_COPY = { `This exchange used about ${used} tokens, past the ${reported} this model reports, and the provider accepted it without complaint. Nothing is declared, so Maka does not compact on its own. Declare a context window in the connection settings to have it compact first.`, contextOverflowAfterCompaction: 'History was compacted and the provider still called this request too large. What remains also carries the system prompt, the tool schemas, the summary and the recent tail; shortening this message is the part you control.', + contextUsageShare: (used, window) => + `${used.toLocaleString('en-US')} / ${window.toLocaleString('en-US')} tokens`, + contextUsageNoWindow: 'No context window size is available: none declared, none reported', + contextUsageUnavailable: 'The provider did not report usage', stepLimit: 'Reached the configured step limit. The task may be incomplete. Send “continue” to resume.', }, },