Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions extensions/picode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"chatParticipantAdditions",
"chatParticipantPrivate",
"languageModelThinkingPart",
"languageModelPricing",
"chatSessionCustomizationProvider"
],
"activationEvents": [
Expand Down
11 changes: 9 additions & 2 deletions extensions/picode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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**.
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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),
});
}
}
Expand Down Expand Up @@ -1440,6 +1444,8 @@ interface PiRuntimeModel {
readonly name?: string;
readonly contextWindow?: number;
readonly maxTokens?: number;
readonly cost?: PiModelCost;
readonly input?: readonly string[];
}

interface PiRuntimeProvider {
Expand Down Expand Up @@ -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),
});
}
}
Expand Down
75 changes: 75 additions & 0 deletions extensions/picode/src/model-info.ts
Original file line number Diff line number Diff line change
@@ -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');
}
6 changes: 6 additions & 0 deletions extensions/picode/src/status-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 52 additions & 3 deletions extensions/picode/src/status-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -331,7 +331,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 {
Expand All @@ -342,10 +346,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<readonly SubscriptionStatus[] | undefined> {
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<StatusData> {
const runtime = readRuntimeMode();
Expand Down Expand Up @@ -398,6 +446,7 @@ export async function buildStatusData(deps: StatusDeps): Promise<StatusData> {
// 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<unknown>(UPDATES_STATE_KEY)),
Expand Down
18 changes: 18 additions & 0 deletions extensions/picode/src/usage-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends NanProviderRef>(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);
Expand Down
48 changes: 48 additions & 0 deletions extensions/picode/test/model-info.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
29 changes: 29 additions & 0 deletions extensions/picode/test/usage-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ import assert from 'assert';
import { test } from 'node:test';
import {
fetchNanUsage,
firstNanProvider,
getCachedNanUsage,
isNanProvider,
matchedNanProvider,
nanAccountMeters,
nanModelUsage,
nanUsageSummary,
parseNanQuota,
Expand Down Expand Up @@ -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);
});
1 change: 1 addition & 0 deletions extensions/picode/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions extensions/picode/tsconfig.test.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading