From 993ba4b9d46e531b2b99d85be2d0a76e7fc3db76 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 15:29:03 +0000 Subject: [PATCH 1/2] feat(models): show tokens in/out and prices per model, and the account's usage limits - The model list (Providers & Models) now shows what each model reads and writes ("1M in", "64k out") and its price per million tokens, instead of only the context size. Vision models are marked as vision (they all read as tools-only). - pi's catalogue already carries cost and accepted inputs; the connector now passes them on (languageModelPricing), so the editor's own Language Models "Pricing" column is filled too. A model with no price in the catalogue shows no price rather than a made-up "Free". - Status > Usage limits: NaN's subscription is drawn as bars (used, resets in) for every metered model with the model in use first, and no longer disappears when a model from another provider is the one in the chat. Claude Pro/Max limits are not covered: those windows only arrive in response headers the connector never sees. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013MVCF6g83vB8dmSjDqo2B2 --- extensions/picode/package.json | 1 + extensions/picode/src/extension.ts | 11 ++- extensions/picode/src/model-info.ts | 75 +++++++++++++++++++ extensions/picode/src/status-contract.ts | 6 ++ extensions/picode/src/status-data.ts | 55 +++++++++++++- extensions/picode/src/usage-data.ts | 18 +++++ extensions/picode/test/model-info.test.ts | 48 ++++++++++++ extensions/picode/test/usage-data.test.ts | 29 +++++++ extensions/picode/tsconfig.json | 1 + .../browser/media/picodeStatusSection.css | 31 ++++++++ .../browser/picodeProvidersModelsSection.ts | 55 +++++++++++++- .../picode/browser/picodeStatusSection.ts | 60 +++++++++++++-- 12 files changed, 375 insertions(+), 15 deletions(-) create mode 100644 extensions/picode/src/model-info.ts create mode 100644 extensions/picode/test/model-info.test.ts diff --git a/extensions/picode/package.json b/extensions/picode/package.json index 62a630b6..5932c2bc 100644 --- a/extensions/picode/package.json +++ b/extensions/picode/package.json @@ -26,6 +26,7 @@ "chatParticipantAdditions", "chatParticipantPrivate", "languageModelThinkingPart", + "languageModelPricing", "chatSessionCustomizationProvider" ], "activationEvents": [ diff --git a/extensions/picode/src/extension.ts b/extensions/picode/src/extension.ts index 9804c32b..a2aa6e98 100644 --- a/extensions/picode/src/extension.ts +++ b/extensions/picode/src/extension.ts @@ -72,6 +72,7 @@ import { listProjectSessionFiles } from './sessions-provider'; import { REASONING_SETTING, THINKING_TOGGLE_COMMAND } from './piConfig'; import { describeTargets, fetchNpmLatest, parseSnapshot, PI_RUNTIME_PACKAGE, runPiUpdate, updatableTargets, UPDATES_STATE_KEY, type CandidateTarget, type UpdatesSnapshot } from './updates-check'; import { locateNpmCli } from './npm-run'; +import { acceptsImages, modelPricing, type PiModelCost } from './model-info'; /** * PiCode's bridge, living **inside the core**. @@ -143,6 +144,8 @@ interface PiModelEntry { readonly contextWindow?: number; readonly maxTokens?: number; readonly reasoning?: boolean; + readonly cost?: PiModelCost; + readonly input?: readonly string[]; } /** One provider as pi's `models.json` declares it. */ @@ -193,7 +196,8 @@ function toChatInformation(models: PiModelsFile): vscode.LanguageModelChatInform maxInputTokens: model.contextWindow ?? 128000, maxOutputTokens: model.maxTokens ?? 16384, detail: providerId, - capabilities: { toolCalling: true }, + capabilities: { toolCalling: true, ...(acceptsImages(model.input) ? { imageInput: true } : {}) }, + ...modelPricing(model.cost), }); } } @@ -1440,6 +1444,8 @@ interface PiRuntimeModel { readonly name?: string; readonly contextWindow?: number; readonly maxTokens?: number; + readonly cost?: PiModelCost; + readonly input?: readonly string[]; } interface PiRuntimeProvider { @@ -1668,7 +1674,8 @@ async function subscriptionModels(distributionRoot: string, agentDir: string | u maxInputTokens: model.contextWindow ?? 128000, maxOutputTokens: model.maxTokens ?? 16384, detail: provider.id, - capabilities: { toolCalling: true }, + capabilities: { toolCalling: true, ...(acceptsImages(model.input) ? { imageInput: true } : {}) }, + ...modelPricing(model.cost), }); } } diff --git a/extensions/picode/src/model-info.ts b/extensions/picode/src/model-info.ts new file mode 100644 index 00000000..2b5200e2 --- /dev/null +++ b/extensions/picode/src/model-info.ts @@ -0,0 +1,75 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) PiCode. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * What the editor's model list says about a model beyond its name: what it costs and whether it + * reads images. + * + * pi's catalogue carries both (`cost` in US dollars per million tokens, `input` the kinds of + * content the model accepts). Nothing here is invented: a model whose catalogue entry says no + * price has no price row at all, rather than a "Free" the catalogue never claimed — a zero can mean + * free, included in a plan, or simply unknown. + * + * No `vscode` import and no local imports, so the rules can be exercised by running them. + */ + +/** A model's price as pi's catalogue writes it: US dollars per million tokens. */ +export interface PiModelCost { + readonly input?: number; + readonly output?: number; + readonly cacheRead?: number; + readonly cacheWrite?: number; +} + +/** The fields the editor's model list reads for a price (`languageModelPricing`). */ +export interface ModelPricing { + readonly pricing?: string; + readonly inputCost?: number; + readonly outputCost?: number; + readonly cacheCost?: number; + readonly cacheWriteCost?: number; +} + +function amount(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined; +} + +/** A price in dollars, in as few digits as keep it true: `3`, `0.25`, `0.075`. */ +export function formatPrice(dollars: number): string { + const digits = dollars >= 1 ? 2 : 3; + return `$${Number(dollars.toFixed(digits))}`; +} + +/** + * The price fields for one model, or none when the catalogue has no price for it. + * + * The label is for people (`$3 in · $15 out per 1M tokens`); the numbers are there for the lists + * that sort or show them in columns. + */ +export function modelPricing(cost: PiModelCost | undefined): ModelPricing { + const input = amount(cost?.input); + const output = amount(cost?.output); + if ((input ?? 0) === 0 && (output ?? 0) === 0) { + return {}; + } + const cacheRead = amount(cost?.cacheRead); + const cacheWrite = amount(cost?.cacheWrite); + const parts = [ + ...(input === undefined ? [] : [`${formatPrice(input)} in`]), + ...(output === undefined ? [] : [`${formatPrice(output)} out`]), + ]; + return { + pricing: `${parts.join(' · ')} per 1M tokens`, + ...(input === undefined ? {} : { inputCost: input }), + ...(output === undefined ? {} : { outputCost: output }), + ...(cacheRead === undefined || cacheRead === 0 ? {} : { cacheCost: cacheRead }), + ...(cacheWrite === undefined || cacheWrite === 0 ? {} : { cacheWriteCost: cacheWrite }), + }; +} + +/** Whether the catalogue says the model accepts images. */ +export function acceptsImages(input: readonly unknown[] | undefined): boolean { + return Array.isArray(input) && input.includes('image'); +} diff --git a/extensions/picode/src/status-contract.ts b/extensions/picode/src/status-contract.ts index 3dbba77a..5e22ec55 100644 --- a/extensions/picode/src/status-contract.ts +++ b/extensions/picode/src/status-contract.ts @@ -139,6 +139,12 @@ export interface StatusData { * empty or inventing a window. */ subscription?: SubscriptionStatus; + /** + * Every meter of the account's subscription, the model in use first (`usage-data.ts`). The + * quota is the account's, so this is present while another provider's model is the one + * answering — the case `subscription` above, which follows the session's model, leaves empty. + */ + subscriptions?: readonly SubscriptionStatus[]; /** * The chat's background jobs still running (`background-jobs.ts`), the same answer the pill * above the chat input reads. Each leaves when its completion message closes it, so a job diff --git a/extensions/picode/src/status-data.ts b/extensions/picode/src/status-data.ts index 2cabb03a..06153f13 100644 --- a/extensions/picode/src/status-data.ts +++ b/extensions/picode/src/status-data.ts @@ -18,7 +18,7 @@ import { internalProfileDir, readRuntimeMode, resolveProjectScope } from './runt import { STATUS_DATA_COMMAND, type BackgroundJobStatus, type McpServerSwitch, type StatusData, type SubscriptionStatus } from './status-contract'; import { isOurEngramDeclaration } from './engram'; import { parseSnapshot, UPDATES_STATE_KEY } from './updates-check'; -import { getCachedNanUsage, matchedNanProvider, nanModelUsage, resolveNanApiKey, spokenWindowLabel } from './usage-data'; +import { firstNanProvider, getCachedNanUsage, matchedNanProvider, nanAccountMeters, nanModelUsage, resolveNanApiKey, spokenWindowLabel, type NanUsageLimit } from './usage-data'; /** * The data behind PiCode's status surface. @@ -329,7 +329,11 @@ async function readSubscription(profileDir: string, model: string | undefined): // A read that names no meter for this model says nothing: no section, not an empty one. return undefined; } - // Tightest window first — the one that runs out first leads. + return toSubscriptionStatus(limit, cached.fresh); +} + +/** One meter as the panel draws it: the tightest window first, because it runs out first. */ +function toSubscriptionStatus(limit: NanUsageLimit, fresh: boolean): SubscriptionStatus { const labelled = limit.windows.filter(window => window.label.length > 0).sort((left, right) => left.windowSeconds - right.windowSeconds); const unlabelled = limit.windows.filter(window => window.label.length === 0); return { @@ -340,10 +344,54 @@ async function readSubscription(profileDir: string, model: string | undefined): resetAt: window.resetAt, })), limitReached: limit.limitReached, - fresh: cached.fresh, + fresh, }; } +/** + * The account's whole allowance: every metered model, the one in use first, whichever provider's + * model is answering right now. + * + * NaN's quota belongs to the account, so it is worth seeing while a Claude model is the one in the + * chat — the case the one-meter rule above leaves empty. The provider is found where the owner may + * have put it: declared in the settings, or only carried in the profile's own model list. Nothing is + * drawn when no NaN provider with a key exists, and a read that names no meter says nothing. + */ +async function readAccountSubscriptions(profileDir: string, model: string | undefined): Promise { + const declared = declarationsFromSetting(vscode.workspace.getConfiguration('picode').get('providers')); + const carried = readJsonObject(path.join(profileDir, 'models.json'))?.['providers']; + const refs: { id: string; baseUrl: string; key?: string }[] = [ + ...declared.map(declaration => ({ id: declaration.id, baseUrl: declaration.baseUrl, key: declaration.key })), + ...(isRecord(carried) + ? Object.entries(carried).map(([id, entry]) => ({ + id, + baseUrl: isRecord(entry) && typeof entry['baseUrl'] === 'string' ? entry['baseUrl'] : '', + key: isRecord(entry) && typeof entry['apiKey'] === 'string' ? entry['apiKey'] : undefined, + })) + : []), + ]; + const provider = firstNanProvider(refs); + if (provider === undefined) { + return undefined; + } + const apiKey = resolveNanApiKey({ + declared: provider.key, + environment: process.env, + stored: storedApiKey(profileDir, provider.id), + }); + if (apiKey === undefined) { + return undefined; + } + const cached = await getCachedNanUsage(apiKey, Date.now()); + if (cached.value === undefined) { + return undefined; + } + const meters = nanAccountMeters(cached.value, currentModelRef(model)?.model) + .filter(limit => limit.windows.length > 0) + .map(limit => toSubscriptionStatus(limit, cached.fresh)); + return meters.length > 0 ? meters : undefined; +} + /** Builds the one answer the status tree renders, from the profile in force. */ export async function buildStatusData(deps: StatusDeps): Promise { const runtime = readRuntimeMode(); @@ -396,6 +444,7 @@ export async function buildStatusData(deps: StatusDeps): Promise { // The model's subscription meter, read the way the Session Usage row used to be: the lookup // and its one-minute cache belong to `usage-data.ts`, and the block draws what it answered. subscription: await readSubscription(profileDir, usage?.model), + subscriptions: await readAccountSubscriptions(profileDir, usage?.model), // What the update check last found, read from where the check stored it: the lookup and its // cadence belong to `updates-check.ts`, and this reading only draws the answer it left behind. updates: parseSnapshot(deps.globalState.get(UPDATES_STATE_KEY)), diff --git a/extensions/picode/src/usage-data.ts b/extensions/picode/src/usage-data.ts index 208d2317..7a0df970 100644 --- a/extensions/picode/src/usage-data.ts +++ b/extensions/picode/src/usage-data.ts @@ -311,6 +311,24 @@ export function nanModelUsage(usage: NanUsage, modelId: string | undefined): Nan return usage.limits.length === 1 ? usage.limits[0] : undefined; } +/** + * Every meter the account has, the model in use first. + * + * The quota is the **account's**, not the session's: it is worth seeing while another provider's + * model is the one answering, which is exactly when the one-meter-per-session rule above leaves the + * panel empty. The rest follow in the provider's own order, so the list does not reshuffle between + * refreshes. + */ +export function nanAccountMeters(usage: NanUsage, modelId: string | undefined): NanUsageLimit[] { + const active = modelId === undefined || modelId.length === 0 ? undefined : usage.limits.find(limit => limit.name === modelId); + return active === undefined ? [...usage.limits] : [active, ...usage.limits.filter(limit => limit !== active)]; +} + +/** The first declared provider that is NaN's, whichever model the session is on. */ +export function firstNanProvider(refs: readonly T[]): T | undefined { + return refs.find(isNanProvider); +} + /** Whether a declared provider is NaN's: by id, or by the address it points at. */ export function isNanProvider(ref: NanProviderRef): boolean { return ref.id === NAN_PROVIDER_ID || ref.baseUrl.includes(NAN_ENDPOINT_MARKER); diff --git a/extensions/picode/test/model-info.test.ts b/extensions/picode/test/model-info.test.ts new file mode 100644 index 00000000..833a61be --- /dev/null +++ b/extensions/picode/test/model-info.test.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) PiCode. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * What the model list says about price and images, run on its own (`node --test`). + */ + +import assert from 'assert'; +import { test } from 'node:test'; +import { acceptsImages, formatPrice, modelPricing } from '../src/model-info.ts'; + +test('a priced model gets a readable label and the numbers behind it', () => { + assert.deepStrictEqual( + modelPricing({ input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }), + { pricing: '$3 in · $15 out per 1M tokens', inputCost: 3, outputCost: 15, cacheCost: 0.3, cacheWriteCost: 3.75 }, + ); +}); + +test('small prices keep the digits that matter and no more', () => { + assert.strictEqual(formatPrice(0.075), '$0.075'); + assert.strictEqual(formatPrice(0.25), '$0.25'); + assert.strictEqual(formatPrice(2.5), '$2.5'); + assert.strictEqual(formatPrice(15), '$15'); + assert.strictEqual(formatPrice(1.2345), '$1.23'); +}); + +test('a model with no price in the catalogue has no price row, not a made-up "Free"', () => { + assert.deepStrictEqual(modelPricing(undefined), {}); + assert.deepStrictEqual(modelPricing({}), {}); + assert.deepStrictEqual(modelPricing({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }), {}); +}); + +test('a half-known price says only the half it knows', () => { + assert.deepStrictEqual(modelPricing({ output: 8 }), { pricing: '$8 out per 1M tokens', outputCost: 8 }); +}); + +test('garbage in a price is ignored, never printed', () => { + assert.deepStrictEqual(modelPricing({ input: -1, output: Number.NaN }), {}); + assert.deepStrictEqual(modelPricing({ input: '3' as unknown as number, output: 6 }), { pricing: '$6 out per 1M tokens', outputCost: 6 }); +}); + +test('image support is what the catalogue lists', () => { + assert.strictEqual(acceptsImages(['text', 'image']), true); + assert.strictEqual(acceptsImages(['text']), false); + assert.strictEqual(acceptsImages(undefined), false); +}); diff --git a/extensions/picode/test/usage-data.test.ts b/extensions/picode/test/usage-data.test.ts index 86232a4f..2a64678a 100644 --- a/extensions/picode/test/usage-data.test.ts +++ b/extensions/picode/test/usage-data.test.ts @@ -15,9 +15,11 @@ import assert from 'assert'; import { test } from 'node:test'; import { fetchNanUsage, + firstNanProvider, getCachedNanUsage, isNanProvider, matchedNanProvider, + nanAccountMeters, nanModelUsage, nanUsageSummary, parseNanQuota, @@ -276,3 +278,30 @@ test('a fetch that outlives its budget resolves without a value', async () => { const cached: NanUsage | undefined = (await getCachedNanUsage('timeout-key', NOW, hanging)).value; assert.strictEqual(cached, undefined); }); + +test('the account meters list every metered model, the one in use first', () => { + const usage: NanUsage = { + fetchedAt: 0, + limits: [ + { name: 'a-model', windows: [], limitReached: false }, + { name: 'b-model', windows: [], limitReached: false }, + { name: 'c-model', windows: [], limitReached: false }, + ], + }; + assert.deepStrictEqual(nanAccountMeters(usage, 'b-model').map(limit => limit.name), ['b-model', 'a-model', 'c-model']); + // A model with no meter of its own (another provider's) leaves the provider's order alone. + assert.deepStrictEqual(nanAccountMeters(usage, 'claude-sonnet-5').map(limit => limit.name), ['a-model', 'b-model', 'c-model']); + assert.deepStrictEqual(nanAccountMeters(usage, undefined).map(limit => limit.name), ['a-model', 'b-model', 'c-model']); + assert.deepStrictEqual(nanAccountMeters({ fetchedAt: 0, limits: [] }, 'b-model'), []); +}); + +test('the first NaN provider is found whichever model is active', () => { + const refs = [ + { id: 'omni', baseUrl: 'http://192.168.1.5:20128/v1' }, + { id: 'work', baseUrl: 'https://api.nan.builders/v1' }, + { id: 'nan', baseUrl: '' }, + ]; + assert.strictEqual(firstNanProvider(refs)?.id, 'work'); + assert.strictEqual(firstNanProvider([{ id: 'omni', baseUrl: 'http://x/v1' }]), undefined); + assert.strictEqual(firstNanProvider([]), undefined); +}); diff --git a/extensions/picode/tsconfig.json b/extensions/picode/tsconfig.json index c4eb5d53..050eabbf 100644 --- a/extensions/picode/tsconfig.json +++ b/extensions/picode/tsconfig.json @@ -18,6 +18,7 @@ "../../src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts", "../../src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts", "../../src/vscode-dts/vscode.proposed.languageModelThinkingPart.d.ts", + "../../src/vscode-dts/vscode.proposed.languageModelPricing.d.ts", "../../src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts", "../../src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts", "../../src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts" diff --git a/src/vs/workbench/contrib/picode/browser/media/picodeStatusSection.css b/src/vs/workbench/contrib/picode/browser/media/picodeStatusSection.css index 11ad56d7..8236a188 100644 --- a/src/vs/workbench/contrib/picode/browser/media/picodeStatusSection.css +++ b/src/vs/workbench/contrib/picode/browser/media/picodeStatusSection.css @@ -311,3 +311,34 @@ border-color: var(--vscode-widget-border, var(--vscode-panel-border)); background: transparent; } + +/* The usage-limit bars: how much of a window is used, in the colours the rest of the panel keeps. */ +.picode-status-meter-model { + padding: 10px 0 2px; + font-size: 12px; + font-weight: 600; + color: var(--vscode-foreground); +} + +.picode-status-meter { + height: 6px; + margin-top: 6px; + border-radius: 999px; + overflow: hidden; + background: color-mix(in srgb, var(--vscode-foreground) 12%, transparent); +} + +.picode-status-meter-fill { + height: 100%; + border-radius: 999px; + background: var(--vscode-testing-iconPassed); + transition: width 200ms ease; +} + +.picode-status-meter.warn .picode-status-meter-fill { + background: var(--vscode-editorWarning-foreground); +} + +.picode-status-meter.danger .picode-status-meter-fill { + background: var(--vscode-errorForeground); +} diff --git a/src/vs/workbench/contrib/picode/browser/picodeProvidersModelsSection.ts b/src/vs/workbench/contrib/picode/browser/picodeProvidersModelsSection.ts index 2fd37efe..f64982c3 100644 --- a/src/vs/workbench/contrib/picode/browser/picodeProvidersModelsSection.ts +++ b/src/vs/workbench/contrib/picode/browser/picodeProvidersModelsSection.ts @@ -632,7 +632,7 @@ export class PiCodeProvidersModelsSection extends Disposable implements IAICusto const onlyTools = toolsChip.getAttribute('aria-pressed') === 'true'; const onlyVision = visionChip.getAttribute('aria-pressed') === 'true'; - type Row = { ref: string; name: string; provider: string; context: string; tools: boolean; vision: boolean }; + type Row = { ref: string; name: string; provider: string; context: string; output: string; price: string | undefined; priceTitle: string | undefined; tools: boolean; vision: boolean }; const rows: Row[] = []; for (const modelId of this.languageModelsService.getLanguageModelIds()) { const metadata = this.languageModelsService.lookupLanguageModel(modelId); @@ -649,7 +649,10 @@ export class PiCodeProvidersModelsSection extends Disposable implements IAICusto ref: metadata.id, name: metadata.name, provider, - context: metadata.maxInputTokens >= 1000 ? `${Math.round(metadata.maxInputTokens / 1000)}k` : String(metadata.maxInputTokens), + context: formatTokenCount(metadata.maxInputTokens), + output: formatTokenCount(metadata.maxOutputTokens), + price: priceChip(metadata.inputCost, metadata.outputCost), + priceTitle: metadata.pricing, tools: metadata.capabilities?.toolCalling === true, vision: metadata.capabilities?.vision === true, }); @@ -708,7 +711,7 @@ export class PiCodeProvidersModelsSection extends Disposable implements IAICusto * a list instead of a wall of buttons. A card per model looked tidy with six of them and became * unusable with a hundred. */ - private renderModelCard(row: { ref: string; name: string; provider: string; context: string; tools: boolean; vision: boolean }, activeRef: string): HTMLElement { + private renderModelCard(row: { ref: string; name: string; provider: string; context: string; output: string; price: string | undefined; priceTitle: string | undefined; tools: boolean; vision: boolean }, activeRef: string): HTMLElement { const line = $('.picode-pm-model'); const left = append(line, $('.picode-pm-model-left')); @@ -723,7 +726,19 @@ export class PiCodeProvidersModelsSection extends Disposable implements IAICusto } const facts = append(line, $('.picode-pm-model-facts')); - append(facts, $('span.picode-pm-fact')).textContent = row.context; + // What goes in, what comes out, and what it costs: the three numbers that decide which model + // to pick. A fact the provider did not report is not drawn. + const contextFact = append(facts, $('span.picode-pm-fact')); + contextFact.textContent = localize('picodePm.factContext', "{0} in", row.context); + contextFact.title = localize('picodePm.factContextTooltip', "The most the model can read at once: your conversation, files and instructions together."); + const outputFact = append(facts, $('span.picode-pm-fact')); + outputFact.textContent = localize('picodePm.factOutput', "{0} out", row.output); + outputFact.title = localize('picodePm.factOutputTooltip', "The most the model can write in one answer."); + if (row.price !== undefined) { + const priceFact = append(facts, $('span.picode-pm-fact.picode-pm-fact-price')); + priceFact.textContent = row.price; + priceFact.title = row.priceTitle ?? localize('picodePm.factPriceTooltip', "Price per million tokens, in and out."); + } if (row.tools) { append(facts, $('span.picode-pm-fact')).textContent = localize('picodePm.factTools', "Tools"); } @@ -778,3 +793,35 @@ const contribution: IAICustomizationManagementSectionContribution = { }; aiCustomizationManagementSectionRegistry.register(contribution); + +/** Token counts the way people say them: 200k, 1M. */ +function formatTokenCount(tokens: number): string { + if (tokens >= 1_000_000) { + return `${Number((tokens / 1_000_000).toFixed(1))}M`; + } + if (tokens >= 1000) { + return `${Math.round(tokens / 1000)}k`; + } + return String(tokens); +} + +/** A price in dollars with only the digits that matter: $3, $0.25, $0.075. */ +function formatDollars(dollars: number): string { + return `$${Number(dollars.toFixed(dollars >= 1 ? 2 : 3))}`; +} + +/** + * The price chip of a model: what a million tokens in and out cost. Nothing at all when the + * provider reported no price — an absent price is not a free model. + */ +function priceChip(input: number | undefined, output: number | undefined): string | undefined { + if (input === undefined && output === undefined) { + return undefined; + } + if (input !== undefined && output !== undefined) { + return localize('picodePm.factPrice', "{0} in · {1} out", formatDollars(input), formatDollars(output)); + } + return input !== undefined + ? localize('picodePm.factPriceIn', "{0} in", formatDollars(input)) + : localize('picodePm.factPriceOut', "{0} out", formatDollars(output as number)); +} diff --git a/src/vs/workbench/contrib/picode/browser/picodeStatusSection.ts b/src/vs/workbench/contrib/picode/browser/picodeStatusSection.ts index 6a652d06..7aec49ab 100644 --- a/src/vs/workbench/contrib/picode/browser/picodeStatusSection.ts +++ b/src/vs/workbench/contrib/picode/browser/picodeStatusSection.ts @@ -68,11 +68,15 @@ interface IProjectRow { interface ISubscriptionWindow { readonly label: string; readonly usedPercent: number; - readonly resetAt: number; + /** When the window resets, in epoch ms; `null` when the provider did not say. */ + readonly resetAt: number | null; } interface ISubscriptionStatus { + /** The model the meter belongs to, as the provider names it. */ + readonly meter?: string; readonly windows: readonly ISubscriptionWindow[]; + readonly limitReached?: boolean; } interface IUpdatesSnapshot { @@ -102,6 +106,8 @@ interface IStatusData { readonly cacheRead?: number; readonly cacheWrite?: number; readonly subscription?: ISubscriptionStatus; + /** Every meter of the account's subscription, the model in use first. */ + readonly subscriptions?: readonly ISubscriptionStatus[]; readonly jobs?: readonly { readonly label?: string; readonly command?: string }[]; readonly updates?: IUpdatesSnapshot; readonly error?: string; @@ -329,10 +335,18 @@ export class PicodeStatusSection extends Disposable implements IAICustomizationM this.addRow(card, Codicon.diff, localize('picodeStatusChanges', "Changes"), changes); }); - if (data.subscription !== undefined) { - this.renderCard(localize('picodeStatusCardSubscription', "Model subscription"), Codicon.creditCard, card => { - for (const window of data.subscription!.windows) { - this.addRow(card, Codicon.clock, window.label, `${window.usedPercent}% used`); + // The account's allowance, drawn like a usage meter: how much is used, and when it resets. The + // quota is the account's, so it shows whichever provider's model is answering. + const meters = data.subscriptions ?? (data.subscription === undefined ? [] : [data.subscription]); + if (meters.length > 0) { + this.renderCard(localize('picodeStatusCardUsageLimits', "Usage limits"), Codicon.creditCard, card => { + for (const meter of meters) { + if (meter.meter !== undefined && meter.meter.length > 0) { + DOM.append(card, $('.picode-status-meter-model')).textContent = meter.meter; + } + for (const window of meter.windows) { + this.addMeterRow(card, window, meter.limitReached === true); + } } }); } @@ -404,6 +418,26 @@ export class PicodeStatusSection extends Disposable implements IAICustomizationM } /** A fact: what it is on the left, what it says on the right, on one line each. */ + /** One allowance window: its name, a bar with how much is used, and when it resets. */ + private addMeterRow(card: HTMLElement, window: ISubscriptionWindow, limitReached: boolean): void { + const percent = Math.max(0, Math.min(100, Math.round(window.usedPercent))); + const row = DOM.append(card, $('.row')); + const text = this.addRowTitle(row, Codicon.clock, window.label); + const bar = DOM.append(text, $('.picode-status-meter')); + bar.classList.add(limitReached || percent >= 90 ? 'danger' : percent >= 60 ? 'warn' : 'ok'); + bar.setAttribute('role', 'progressbar'); + bar.setAttribute('aria-valuemin', '0'); + bar.setAttribute('aria-valuemax', '100'); + bar.setAttribute('aria-valuenow', String(percent)); + bar.setAttribute('aria-label', window.label); + DOM.append(bar, $('.picode-status-meter-fill')).style.width = `${percent}%`; + if (window.resetAt !== null) { + DOM.append(text, $('.picode-status-row-text')).textContent = localize('picodeStatusResetsIn', "Resets in {0}", formatResetIn(window.resetAt)); + } + const value = DOM.append(row, $('.value')); + value.textContent = localize('picodeStatusPercentUsed', "{0}% used", String(percent)); + } + private addRow(card: HTMLElement, icon: { readonly id: string }, label: string, value: string): void { const row = DOM.append(card, $('.row')); this.addRowTitle(row, icon, label); @@ -517,4 +551,18 @@ const contribution: IAICustomizationManagementSectionContribution = { supportsHarness: () => true, create: (instantiationService, container) => instantiationService.createInstance(PicodeStatusSection, container), }; -aiCustomizationManagementSectionRegistry.register(contribution); \ No newline at end of file +aiCustomizationManagementSectionRegistry.register(contribution); + +/** How long until a window resets, in the shortest words that are still true: 40m, 2h 33m, 3d 5h. */ +function formatResetIn(resetAt: number): string { + const minutes = Math.max(0, Math.round((resetAt - Date.now()) / 60_000)); + if (minutes < 60) { + return `${minutes}m`; + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return minutes % 60 > 0 ? `${hours}h ${minutes % 60}m` : `${hours}h`; + } + const days = Math.floor(hours / 24); + return hours % 24 > 0 ? `${days}d ${hours % 24}h` : `${days}d`; +} From 01957340b870289368d0b3fae9172117fbcbe14d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 19:30:04 +0000 Subject: [PATCH 2/2] ci: type the pricing proposal in the connector's test config The test config lists the proposed typings by hand; the pricing fields the model list now sets need theirs too, or the test types stop compiling. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013MVCF6g83vB8dmSjDqo2B2 --- extensions/picode/tsconfig.test.json | 1 + 1 file changed, 1 insertion(+) diff --git a/extensions/picode/tsconfig.test.json b/extensions/picode/tsconfig.test.json index a8d7ccaa..866c2b44 100644 --- a/extensions/picode/tsconfig.test.json +++ b/extensions/picode/tsconfig.test.json @@ -21,6 +21,7 @@ "../../src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts", "../../src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts", "../../src/vscode-dts/vscode.proposed.languageModelThinkingPart.d.ts", + "../../src/vscode-dts/vscode.proposed.languageModelPricing.d.ts", "../../src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts", "../../src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts", "../../src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts"