You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/09/03 15:29:38 UTC
(maka) branch main updated: feat(desktop): show context usage beside the model controls (#4576)
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new b9748a7733 feat(desktop): show context usage beside the model controls (#4576)
b9748a7733 is described below
commit b9748a7733459c309faf1039721a8fccc84dc59d
Author: Yunpei Zhou <Jo...@outlook.com>
AuthorDate: Thu Sep 3 23:29:33 2026 +0800
feat(desktop): show context usage beside the model controls (#4576)
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 +
.../main/__tests__/latest-request-usage.test.ts | 124 +++++++++++++++++++++
apps/desktop/src/renderer/app-shell.tsx | 30 +++--
apps/desktop/src/renderer/chat-composer-region.tsx | 73 ++++++++++++
.../desktop/src/renderer/styles/model-switcher.css | 14 +++
.../core/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(-)
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. Bef [...]
+- `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 [...]
- 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<string, number>());
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<typeof UserQuestionPrompt>['onRespond'];
stop: ComponentProps<typeof UserQuestionPrompt>['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<typeof Composer>,
'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({
<Composer
ref={composerRef}
{...composerRest}
+ contextUsage={contextUsage}
// AppShell carries staged attachments into both queued and steering
// follow-ups. Other Composer hosts remain gated by default because a
// text-only running-turn submission would leave attachments behind.
diff --git a/apps/desktop/src/renderer/styles/model-switcher.css b/apps/desktop/src/renderer/styles/model-switcher.css
index 06b95b61a2..253304bf1f 100644
--- a/apps/desktop/src/renderer/styles/model-switcher.css
+++ b/apps/desktop/src/renderer/styles/model-switcher.css
@@ -26,6 +26,20 @@
max-width: 100%;
}
+.maka-context-usage-indicator {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ min-width: 4ch;
+ color: var(--muted-foreground);
+ font: var(--maka-text-supporting);
+ white-space: nowrap;
+}
+
+.maka-context-usage-indicator svg {
+ flex: 0 0 auto;
+}
+
/* The composer footer's model and thinking pickers are ghost-button
DropdownMenus — the same toolbar primitive as + and permission, so their
resting, hover, focus, and disabled chrome all derive from the Astryx
diff --git a/packages/core/src/__tests__/llm-connections.test.ts b/packages/core/src/__tests__/llm-connections.test.ts
index e17aaf28f8..97cce94cb9 100644
--- a/packages/core/src/__tests__/llm-connections.test.ts
+++ b/packages/core/src/__tests__/llm-connections.test.ts
@@ -288,6 +288,39 @@ test('chat model choices project exact vision support for attachment composition
);
});
+test('chat model choices keep provider metadata separate from user context declarations', () => {
+ 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<LastRequestAnchor>()(
['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 ? <ContextUsageIndicator {...props.contextUsage} /> : null}
</div>
{/* 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 = (
+ <span className="maka-context-usage-indicator" aria-label={tooltip ?? label}>
+ <CircleGauge size={ICON_SIZE.meta} aria-hidden="true" />
+ <span>{label}</span>
+ </span>
+ );
+ return tooltip ? <Tooltip content={tooltip}>{indicator}</Tooltip> : indicator;
+}
+
export type ComposerProps = ComponentProps<typeof Composer>;
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.',
},
},
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org