@adecore/agents 0.31.4 → 0.31.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chat/acp-backend.d.ts +7 -0
- package/dist/chat/acp-backend.js +87 -6
- package/dist/chat/acp-protocol.d.ts +6 -0
- package/dist/chat/acp-protocol.js +34 -9
- package/dist/providers/accounts/acp-account.d.ts +1 -0
- package/dist/providers/accounts/acp-account.js +25 -3
- package/dist/providers/acp-agent-state.d.ts +35 -0
- package/dist/providers/acp-agent-state.js +170 -0
- package/dist/providers/acp-provider.d.ts +2 -0
- package/dist/providers/acp-provider.js +29 -33
- package/dist/providers/catalog.d.ts +2 -0
- package/dist/providers/catalog.js +14 -4
- package/dist/providers/index.d.ts +1 -0
- package/dist/providers/index.js +1 -0
- package/dist/providers/provider.d.ts +1 -0
- package/dist/providers/registry.d.ts +1 -0
- package/dist/providers/registry.js +9 -0
- package/dist/testing/fake-acp.d.ts +3 -0
- package/dist/testing/fake-acp.js +11 -4
- package/dist/usage/aggregate.js +1 -1
- package/dist/usage/index-file.d.ts +2 -0
- package/dist/usage/index-file.js +19 -10
- package/dist/usage/pricing.d.ts +3 -2
- package/dist/usage/pricing.js +42 -8
- package/dist/usage/readers/codex.d.ts +9 -0
- package/dist/usage/readers/codex.js +75 -14
- package/dist/usage/record.d.ts +1 -0
- package/dist/usage/scanner.js +32 -8
- package/package.json +3 -3
- package/src/chat/acp-backend.ts +93 -5
- package/src/chat/acp-protocol.ts +41 -9
- package/src/providers/accounts/acp-account.ts +34 -3
- package/src/providers/acp-agent-state.ts +206 -0
- package/src/providers/acp-provider.ts +33 -37
- package/src/providers/catalog.ts +19 -5
- package/src/providers/index.ts +1 -0
- package/src/providers/provider.ts +2 -0
- package/src/providers/registry.ts +10 -0
- package/src/testing/fake-acp.ts +17 -4
- package/src/usage/aggregate.ts +1 -1
- package/src/usage/index-file.ts +25 -18
- package/src/usage/pricing.ts +46 -8
- package/src/usage/readers/codex.ts +86 -14
- package/src/usage/record.ts +1 -0
- package/src/usage/scanner.ts +35 -8
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
import type { AgentKind,
|
|
1
|
+
import type { AgentKind, ProviderCapabilities, ProviderInstall } from '@adecore/agent-contracts';
|
|
2
2
|
import { createAcpBackend, type AcpAgentSpec, type AcpUsageReport } from '../chat/acp-backend.ts';
|
|
3
3
|
import type { AcpSessionInfo } from '../chat/acp-protocol.ts';
|
|
4
4
|
import { askAcpAccount } from './accounts/acp-account.ts';
|
|
5
|
+
import { AcpAgentState, type AcpCatalogStore } from './acp-agent-state.ts';
|
|
5
6
|
import { writeAcpUsage } from './acp-usage-log.ts';
|
|
6
7
|
import type { ModelCatalog } from './catalog.ts';
|
|
7
8
|
import { detectCli, type CliDetection } from './detect.ts';
|
|
8
9
|
import type { ChatProvider, ProviderHome } from './provider.ts';
|
|
9
10
|
|
|
10
11
|
/*
|
|
11
|
-
* What every ACP agent can do through this backend. A file change comes as the text
|
|
12
|
-
* a message names its mentions and attachments by path,
|
|
12
|
+
* What every ACP agent can do through this backend, before what it shows of itself. A file change comes as the text
|
|
13
|
+
* before and after; a message names its mentions and attachments by path, and carries the files themselves where the agent takes them.
|
|
13
14
|
*/
|
|
14
15
|
export const ACP_CAPABILITIES: ProviderCapabilities = {
|
|
15
16
|
chat: true,
|
|
@@ -36,7 +37,7 @@ export interface AcpProviderSpec extends AcpAgentSpec {
|
|
|
36
37
|
command: string;
|
|
37
38
|
args?: readonly string[];
|
|
38
39
|
catalog: ModelCatalog;
|
|
39
|
-
// Over `ACP_CAPABILITIES
|
|
40
|
+
// Over `ACP_CAPABILITIES` and what the agent showed it can do, for what the host knows better.
|
|
40
41
|
capabilities?: Partial<ProviderCapabilities>;
|
|
41
42
|
// What a terminal runs to continue one of its sessions; empty where it has none.
|
|
42
43
|
resumeCommand?: string;
|
|
@@ -56,45 +57,29 @@ export interface AcpProviderSpec extends AcpAgentSpec {
|
|
|
56
57
|
adoptModels?: boolean;
|
|
57
58
|
// The folder the usage of every turn the agent reports is logged to, for a usage root of the agent's kind; absent logs nothing.
|
|
58
59
|
usageLog?: string;
|
|
60
|
+
// Keeps the models the agent reported across a restart of the host; absent starts from `catalog` every time.
|
|
61
|
+
catalogStore?: AcpCatalogStore;
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
/* Agents whose own usage files the scanner reads; a log of their turns would count every one twice. */
|
|
62
65
|
const OWN_USAGE_FILES: ReadonlySet<AgentKind> = new Set(['grok']);
|
|
63
66
|
|
|
64
|
-
/* An agent's model id may reach a command line, so one that reads as a flag is left out. */
|
|
65
|
-
function adoptableModels(info: AcpSessionInfo): Array<{ slug: string; name: string }> {
|
|
66
|
-
return (info.models?.models ?? []).filter((model) => model.id !== '' && !model.id.startsWith('-')).map((model) => ({ slug: model.id, name: model.name }));
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/* The reasoning effort a session offers, as the `effort` option the composer draws for every provider. */
|
|
70
|
-
function effortOption(info: AcpSessionInfo): ModelOptionDescriptor[] {
|
|
71
|
-
const level = info.thoughtLevel;
|
|
72
|
-
if (level === null || !level.choices.some((choice) => choice.id === level.current)) {
|
|
73
|
-
return [];
|
|
74
|
-
}
|
|
75
|
-
return [
|
|
76
|
-
{
|
|
77
|
-
id: 'effort',
|
|
78
|
-
label: 'Reasoning',
|
|
79
|
-
type: 'select',
|
|
80
|
-
defaultChoice: level.current,
|
|
81
|
-
choices: level.choices.map((choice) => ({
|
|
82
|
-
id: choice.id,
|
|
83
|
-
label: choice.name,
|
|
84
|
-
...(choice.description === null ? {} : { description: choice.description })
|
|
85
|
-
}))
|
|
86
|
-
}
|
|
87
|
-
];
|
|
88
|
-
}
|
|
89
|
-
|
|
90
67
|
export function createAcpProvider(spec: AcpProviderSpec): ChatProvider {
|
|
91
|
-
const
|
|
68
|
+
const state = new AcpAgentState({
|
|
69
|
+
catalog: spec.catalog,
|
|
70
|
+
adoptModels: spec.adoptModels !== false,
|
|
71
|
+
store: spec.catalogStore ?? null,
|
|
72
|
+
base: ACP_CAPABILITIES,
|
|
73
|
+
overrides: spec.capabilities ?? {}
|
|
74
|
+
});
|
|
92
75
|
const onSession = (info: AcpSessionInfo): void => {
|
|
93
|
-
|
|
94
|
-
spec.catalog.adopt(adoptableModels(info), info.models.current, effortOption(info));
|
|
95
|
-
}
|
|
76
|
+
state.session(info);
|
|
96
77
|
spec.onSession?.(info);
|
|
97
78
|
};
|
|
79
|
+
const onCommands = (commands: string[]): void => {
|
|
80
|
+
state.commandsListed(commands);
|
|
81
|
+
spec.onCommands?.(commands);
|
|
82
|
+
};
|
|
98
83
|
const usageLog = spec.usageLog === undefined || OWN_USAGE_FILES.has(spec.kind) ? null : spec.usageLog;
|
|
99
84
|
const onUsage =
|
|
100
85
|
usageLog === null
|
|
@@ -107,7 +92,8 @@ export function createAcpProvider(spec: AcpProviderSpec): ChatProvider {
|
|
|
107
92
|
kind: spec.kind,
|
|
108
93
|
name: spec.name,
|
|
109
94
|
catalog: spec.catalog,
|
|
110
|
-
capabilities,
|
|
95
|
+
capabilities: state.capabilities,
|
|
96
|
+
onChange: (listener) => state.onChange(listener),
|
|
111
97
|
command: [spec.command, ...(spec.args ?? [])],
|
|
112
98
|
resumeCommand: spec.resumeCommand ?? '',
|
|
113
99
|
...(spec.ignoresProjectConfig === undefined ? {} : { ignoresProjectConfig: spec.ignoresProjectConfig }),
|
|
@@ -131,9 +117,19 @@ export function createAcpProvider(spec: AcpProviderSpec): ChatProvider {
|
|
|
131
117
|
command,
|
|
132
118
|
env: { ...env, ...spec.env },
|
|
133
119
|
...(spec.client ? { client: spec.client } : {}),
|
|
134
|
-
onSession
|
|
120
|
+
onSession,
|
|
121
|
+
onCommands
|
|
135
122
|
})
|
|
136
123
|
}),
|
|
137
|
-
createBackend: createAcpBackend({
|
|
124
|
+
createBackend: createAcpBackend({
|
|
125
|
+
...spec,
|
|
126
|
+
onSession,
|
|
127
|
+
onCommands,
|
|
128
|
+
onContextReport: () => {
|
|
129
|
+
state.contextReported();
|
|
130
|
+
spec.onContextReport?.();
|
|
131
|
+
},
|
|
132
|
+
...(onUsage === undefined ? {} : { onUsage })
|
|
133
|
+
})
|
|
138
134
|
};
|
|
139
135
|
}
|
package/src/providers/catalog.ts
CHANGED
|
@@ -18,6 +18,8 @@ function timeOf(data: ModelCatalogInput): number {
|
|
|
18
18
|
export class ModelCatalog {
|
|
19
19
|
private readonly shippedAt: number;
|
|
20
20
|
private data: ModelCatalogInput;
|
|
21
|
+
// The sizes the host's own default had before an agent's models replaced it, for an adopted model that names none.
|
|
22
|
+
private hostWindows: ModelCatalogProfile['contextWindowTokens'] | undefined;
|
|
21
23
|
|
|
22
24
|
constructor(data: ModelCatalogInput = manifest as never) {
|
|
23
25
|
this.shippedAt = timeOf(data);
|
|
@@ -35,6 +37,7 @@ export class ModelCatalog {
|
|
|
35
37
|
}
|
|
36
38
|
const changed = JSON.stringify(data) !== JSON.stringify(this.data);
|
|
37
39
|
this.data = data;
|
|
40
|
+
this.hostWindows = undefined;
|
|
38
41
|
return changed;
|
|
39
42
|
}
|
|
40
43
|
|
|
@@ -42,20 +45,31 @@ export class ModelCatalog {
|
|
|
42
45
|
* Takes the models an agent reported for itself in place of the list, the agent's current model as the
|
|
43
46
|
* default and its order kept; answers whether the models changed. Every model gets the profile the
|
|
44
47
|
* default has now, as an agent names its models but not what each can be asked for, with `options`
|
|
45
|
-
* in place of the profile's options of the same id.
|
|
48
|
+
* in place of the profile's options of the same id, and the context window in tokens a model names as its only size.
|
|
46
49
|
*/
|
|
47
|
-
adopt(
|
|
50
|
+
adopt(
|
|
51
|
+
models: ReadonlyArray<{ slug: string; name: string; contextWindow?: number }>,
|
|
52
|
+
defaultModel: string,
|
|
53
|
+
options: readonly ModelOptionDescriptor[] = []
|
|
54
|
+
): boolean {
|
|
48
55
|
if (models.length === 0 || !models.some((model) => model.slug === defaultModel)) {
|
|
49
56
|
return false;
|
|
50
57
|
}
|
|
51
58
|
const base = this.profileOf(this.data.defaultModel) ?? { options: [], contextWindowTokens: {} };
|
|
52
59
|
const replaced = new Set(options.map((option) => option.id));
|
|
53
|
-
|
|
60
|
+
this.hostWindows ??= base.contextWindowTokens;
|
|
61
|
+
const profile = { ...base, contextWindowTokens: this.hostWindows, options: [...base.options.filter((option) => !replaced.has(option.id)), ...options] };
|
|
62
|
+
const profileName = (window: number | undefined): string => (window === undefined ? ADOPTED_PROFILE : `${ADOPTED_PROFILE}-${window}`);
|
|
63
|
+
const profiles: Record<string, ModelCatalogProfile> = {};
|
|
64
|
+
for (const model of models) {
|
|
65
|
+
profiles[profileName(model.contextWindow)] =
|
|
66
|
+
model.contextWindow === undefined ? profile : { ...profile, contextWindowTokens: { '*': model.contextWindow } };
|
|
67
|
+
}
|
|
54
68
|
const data: ModelCatalogInput = {
|
|
55
69
|
...(this.data.updatedAt === undefined ? {} : { updatedAt: this.data.updatedAt }),
|
|
56
70
|
defaultModel,
|
|
57
|
-
profiles
|
|
58
|
-
models: models.map((model) => ({ slug: model.slug, name: model.name, profile:
|
|
71
|
+
profiles,
|
|
72
|
+
models: models.map((model) => ({ slug: model.slug, name: model.name, profile: profileName(model.contextWindow) }))
|
|
59
73
|
};
|
|
60
74
|
const changed = JSON.stringify(data) !== JSON.stringify(this.data);
|
|
61
75
|
this.data = data;
|
package/src/providers/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Public API: exports.test.ts snapshots this list, so a name added or dropped here is an API change.
|
|
2
|
+
export { type AcpCatalogStore } from './acp-agent-state.ts';
|
|
2
3
|
export { ACP_CAPABILITIES, type AcpProviderSpec, createAcpProvider } from './acp-provider.ts';
|
|
3
4
|
export { type AcpAccountProbe, askAcpAccount, type ProbeTimer } from './accounts/acp-account.ts';
|
|
4
5
|
export { AccountError, type AccountLaunches, storedAccount, launchEnv } from './accounts/launch.ts';
|
|
@@ -50,5 +50,7 @@ export interface ChatProvider {
|
|
|
50
50
|
enabled?(): boolean;
|
|
51
51
|
/* Asks the CLI, started in an account's environment, who is signed in there. Absent leaves the host's own reader. */
|
|
52
52
|
askAccount?(command: readonly string[], env: Record<string, string | undefined>): Promise<AccountReading>;
|
|
53
|
+
/* Calls the listener whenever the models or the capabilities changed, for a provider that learns them from its CLI; answers how to stop. */
|
|
54
|
+
onChange?(listener: () => void): () => void;
|
|
53
55
|
createBackend(launch: BackendLaunch, host: BackendHost): ChatBackend;
|
|
54
56
|
}
|
|
@@ -128,6 +128,16 @@ export class ProviderRegistry {
|
|
|
128
128
|
return this.get(kind).enabled?.() !== false;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/* Calls the listener with the kind of every provider whose models or capabilities changed, so a host can list them again; answers how to stop. */
|
|
132
|
+
onChange(listener: (kind: AgentKind) => void): () => void {
|
|
133
|
+
const stops = this.providers.flatMap((provider) => (provider.onChange ? [provider.onChange(() => listener(provider.kind))] : []));
|
|
134
|
+
return () => {
|
|
135
|
+
for (const stop of stops) {
|
|
136
|
+
stop();
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
131
141
|
invalidate(kind: AgentKind): void {
|
|
132
142
|
this.cache.delete(kind);
|
|
133
143
|
}
|
package/src/testing/fake-acp.ts
CHANGED
|
@@ -27,6 +27,12 @@ export interface FakeAcpOptions {
|
|
|
27
27
|
// Offers a reasoning effort as the config option `reasoning_effort`, without a category: Low and High, and
|
|
28
28
|
// Extra high on `fake-large`. `reject` refuses every change to it.
|
|
29
29
|
reasoningEffort?: 'accept' | 'reject';
|
|
30
|
+
// Names each model's context window in its `_meta` (`totalContextTokens`): 128000 for `fake-small`, 1000000 for `fake-large`.
|
|
31
|
+
contextWindows?: boolean;
|
|
32
|
+
// The slash commands a new session lists, sent before the answer that names the session.
|
|
33
|
+
commands?: readonly string[];
|
|
34
|
+
// Advertises `promptCapabilities.embeddedContext`, so a prompt may carry files as `resource` blocks.
|
|
35
|
+
embeddedContext?: boolean;
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
|
|
@@ -74,9 +80,10 @@ export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
|
|
|
74
80
|
};
|
|
75
81
|
|
|
76
82
|
const sessionState = (): Frame => {
|
|
83
|
+
const meta = (totalContextTokens: number): Frame => (options.contextWindows ? { _meta: { totalContextTokens } } : {});
|
|
77
84
|
const modelChoices = [
|
|
78
|
-
{ value: 'fake-small', name: 'Fake small' },
|
|
79
|
-
{ value: 'fake-large', name: 'Fake large' }
|
|
85
|
+
{ value: 'fake-small', name: 'Fake small', ...meta(128000) },
|
|
86
|
+
{ value: 'fake-large', name: 'Fake large', ...meta(1000000) }
|
|
80
87
|
];
|
|
81
88
|
const effortChoices = [
|
|
82
89
|
{ value: 'low', name: 'Low', description: 'Answers quickly' },
|
|
@@ -90,7 +97,7 @@ export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
|
|
|
90
97
|
return {
|
|
91
98
|
...(modes.length === 0 ? {} : { modes: { currentModeId: mode, availableModes: modes.map((id) => ({ id, name: id })) } }),
|
|
92
99
|
...(options.legacyModels
|
|
93
|
-
? { models: { currentModelId: model, availableModels: modelChoices.map((choice) => ({ modelId:
|
|
100
|
+
? { models: { currentModelId: model, availableModels: modelChoices.map(({ value, ...choice }) => ({ modelId: value, ...choice })) } }
|
|
94
101
|
: {
|
|
95
102
|
configOptions: [
|
|
96
103
|
{ id: 'model', name: 'Model', type: 'select', category: 'model', currentValue: model, options: modelChoices },
|
|
@@ -233,7 +240,7 @@ export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
|
|
|
233
240
|
protocolVersion: 1,
|
|
234
241
|
agentCapabilities: {
|
|
235
242
|
loadSession: options.loadSession ?? true,
|
|
236
|
-
promptCapabilities: { image: true, audio: false, embeddedContext:
|
|
243
|
+
promptCapabilities: { image: true, audio: false, embeddedContext: options.embeddedContext === true },
|
|
237
244
|
sessionCapabilities: { ...(options.resumeSession ? { resume: {} } : {}), ...(options.closeSession ? { close: {} } : {}) }
|
|
238
245
|
},
|
|
239
246
|
authMethods: options.authMethod === undefined ? [] : [{ id: options.authMethod, name: 'Sign in' }],
|
|
@@ -251,6 +258,12 @@ export function fakeAcpWith(options: FakeAcpOptions): FakeCli {
|
|
|
251
258
|
}
|
|
252
259
|
sessionId = `fake-session-${Math.random().toString(36).slice(2, 8)}`;
|
|
253
260
|
sessionHistory.set(sessionId, []);
|
|
261
|
+
if (options.commands !== undefined) {
|
|
262
|
+
update({
|
|
263
|
+
sessionUpdate: 'available_commands_update',
|
|
264
|
+
availableCommands: options.commands.map((name) => ({ name, description: name }))
|
|
265
|
+
});
|
|
266
|
+
}
|
|
254
267
|
respond(id, { sessionId, ...sessionState() });
|
|
255
268
|
return;
|
|
256
269
|
case 'session/load':
|
package/src/usage/aggregate.ts
CHANGED
|
@@ -186,7 +186,7 @@ export async function aggregate(
|
|
|
186
186
|
if (day < payload.from || day > payload.to) {
|
|
187
187
|
continue;
|
|
188
188
|
}
|
|
189
|
-
const lookup = prices.look(record.model);
|
|
189
|
+
const lookup = prices.look(record.model, record.serviceTier, record.timestampMs);
|
|
190
190
|
const cost = record.costUsd ?? (lookup.price === null ? 0 : costOf(record.totals, lookup.price));
|
|
191
191
|
foldBucket(buckets, slot, record, account, lookup.price, cost);
|
|
192
192
|
foldModel(models, record, account, lookup, cost);
|
package/src/usage/index-file.ts
CHANGED
|
@@ -2,8 +2,8 @@ import { UsageProviderSchema, type UsageProvider } from '@adecore/agent-contract
|
|
|
2
2
|
import type { UsageRecord } from './record.ts';
|
|
3
3
|
import type { CodexParserState } from './readers/codex.ts';
|
|
4
4
|
|
|
5
|
-
/*
|
|
6
|
-
const INDEX_VERSION =
|
|
5
|
+
/* Old records survive deleted transcripts; the scanner reparses existing Codex files with its current reader. */
|
|
6
|
+
const INDEX_VERSION = 3;
|
|
7
7
|
/* Before accounts: every record was the default account's, so it reads as that without a scan. */
|
|
8
8
|
const ACCOUNTLESS_VERSION = 1;
|
|
9
9
|
|
|
@@ -21,18 +21,14 @@ export interface IndexedFile {
|
|
|
21
21
|
codex: CodexParserState | null;
|
|
22
22
|
/* A versioned hash of all bytes before the offset; absent in indexes that cannot verify an append. */
|
|
23
23
|
head: string | null;
|
|
24
|
+
parserVersion?: number;
|
|
25
|
+
compressed?: boolean;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
export type UsageIndex = Map<string, IndexedFile>;
|
|
27
29
|
|
|
28
|
-
/*
|
|
29
|
-
|
|
30
|
-
* directory and account strings over and over. Interning those and writing a record as a tuple is
|
|
31
|
-
* what keeps the file at a few megabytes instead of tens of them. The account is one past its place
|
|
32
|
-
* in its table, and 0 for the default account, which is also what a version 1 row without it means.
|
|
33
|
-
* A cost the CLI reported itself rides last, only on a row that has one, so every other row keeps its length.
|
|
34
|
-
*/
|
|
35
|
-
type RecordRow = [number, number, number, number, number, number, number, number, number, number, number, string | 0, number?, number?];
|
|
30
|
+
/* Interned names keep the index compact; optional cost and tier fields preserve per-call pricing. */
|
|
31
|
+
type RecordRow = [number, number, number, number, number, number, number, number, number, number, number, string | 0, number?, number?, string?];
|
|
36
32
|
|
|
37
33
|
interface FileRow {
|
|
38
34
|
p: UsageProvider;
|
|
@@ -43,6 +39,8 @@ interface FileRow {
|
|
|
43
39
|
t: RecordRow[];
|
|
44
40
|
c: CodexParserState | null;
|
|
45
41
|
h?: string;
|
|
42
|
+
v?: number;
|
|
43
|
+
z?: boolean;
|
|
46
44
|
}
|
|
47
45
|
|
|
48
46
|
interface IndexFile {
|
|
@@ -92,8 +90,11 @@ export function encodeIndex(index: UsageIndex): string {
|
|
|
92
90
|
record.dedupeKey ?? 0,
|
|
93
91
|
record.account === undefined ? 0 : accounts.index(record.account) + 1
|
|
94
92
|
];
|
|
95
|
-
if (record.costUsd !== undefined) {
|
|
96
|
-
counted
|
|
93
|
+
if (record.costUsd !== undefined || record.serviceTier !== undefined) {
|
|
94
|
+
counted[13] = record.costUsd;
|
|
95
|
+
}
|
|
96
|
+
if (record.serviceTier !== undefined) {
|
|
97
|
+
counted[14] = record.serviceTier;
|
|
97
98
|
}
|
|
98
99
|
return counted;
|
|
99
100
|
};
|
|
@@ -106,7 +107,9 @@ export function encodeIndex(index: UsageIndex): string {
|
|
|
106
107
|
c: file.codex,
|
|
107
108
|
r: file.records.map(row),
|
|
108
109
|
t: file.tail.map(row),
|
|
109
|
-
...(file.head === null ? {} : { h: file.head })
|
|
110
|
+
...(file.head === null ? {} : { h: file.head }),
|
|
111
|
+
...(file.parserVersion === undefined ? {} : { v: file.parserVersion }),
|
|
112
|
+
...(file.compressed === undefined ? {} : { z: file.compressed })
|
|
110
113
|
};
|
|
111
114
|
}
|
|
112
115
|
const document: IndexFile = {
|
|
@@ -123,11 +126,12 @@ export function encodeIndex(index: UsageIndex): string {
|
|
|
123
126
|
function isRow(row: unknown, version: number): row is RecordRow {
|
|
124
127
|
return (
|
|
125
128
|
Array.isArray(row) &&
|
|
126
|
-
(version === ACCOUNTLESS_VERSION ? row.length === 12 : row.length
|
|
129
|
+
(version === ACCOUNTLESS_VERSION ? row.length === 12 : row.length >= 13 && row.length <= (version === INDEX_VERSION ? 15 : 14)) &&
|
|
127
130
|
row.slice(0, 11).every((value) => typeof value === 'number') &&
|
|
128
131
|
(typeof row[11] === 'string' || row[11] === 0) &&
|
|
129
132
|
(version === ACCOUNTLESS_VERSION || typeof row[12] === 'number') &&
|
|
130
|
-
(row.length < 14 || typeof row[13] === 'number')
|
|
133
|
+
(row.length < 14 || row[13] === null || row[13] === undefined || typeof row[13] === 'number') &&
|
|
134
|
+
(row.length < 15 || typeof row[14] === 'string')
|
|
131
135
|
);
|
|
132
136
|
}
|
|
133
137
|
|
|
@@ -145,7 +149,7 @@ export function decodeIndex(text: string): UsageIndex {
|
|
|
145
149
|
}
|
|
146
150
|
const version = document?.version;
|
|
147
151
|
if (
|
|
148
|
-
(version !== INDEX_VERSION && version !== ACCOUNTLESS_VERSION) ||
|
|
152
|
+
(version !== INDEX_VERSION && version !== 2 && version !== ACCOUNTLESS_VERSION) ||
|
|
149
153
|
!Array.isArray(document.models) ||
|
|
150
154
|
!Array.isArray(document.sessions) ||
|
|
151
155
|
!Array.isArray(document.folders)
|
|
@@ -184,7 +188,8 @@ export function decodeIndex(text: string): UsageIndex {
|
|
|
184
188
|
cwd,
|
|
185
189
|
...(account === undefined ? {} : { account }),
|
|
186
190
|
totals: { calls: row[4], input: row[5], cacheRead: row[6], cacheWrite: row[7], cacheWrite1h: row[8], output: row[9], reasoning: row[10] },
|
|
187
|
-
...(row[13] ===
|
|
191
|
+
...(typeof row[13] === 'number' ? { costUsd: row[13] } : {}),
|
|
192
|
+
...(typeof row[14] === 'string' ? { serviceTier: row[14] } : {}),
|
|
188
193
|
dedupeKey: row[11] === 0 ? null : row[11]
|
|
189
194
|
});
|
|
190
195
|
}
|
|
@@ -201,7 +206,9 @@ export function decodeIndex(text: string): UsageIndex {
|
|
|
201
206
|
records,
|
|
202
207
|
tail,
|
|
203
208
|
codex: file.c ?? null,
|
|
204
|
-
head: typeof file.h === 'string' ? file.h : null
|
|
209
|
+
head: typeof file.h === 'string' ? file.h : null,
|
|
210
|
+
...(typeof file.v === 'number' ? { parserVersion: file.v } : {}),
|
|
211
|
+
...(typeof file.z === 'boolean' ? { compressed: file.z } : {})
|
|
205
212
|
});
|
|
206
213
|
}
|
|
207
214
|
}
|
package/src/usage/pricing.ts
CHANGED
|
@@ -22,6 +22,7 @@ export interface ModelPrice {
|
|
|
22
22
|
cacheRead: number;
|
|
23
23
|
cacheWrite: number;
|
|
24
24
|
cacheWrite1h: number;
|
|
25
|
+
tiers?: Partial<Record<string, ModelPrice>>;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
export type PriceTable = Map<string, ModelPrice>;
|
|
@@ -40,6 +41,7 @@ const PROVIDERS = new Set(['anthropic', 'openai']);
|
|
|
40
41
|
const MODES = new Set(['chat', 'responses']);
|
|
41
42
|
|
|
42
43
|
interface LiteLlmEntry {
|
|
44
|
+
[key: string]: unknown;
|
|
43
45
|
input_cost_per_token?: unknown;
|
|
44
46
|
output_cost_per_token?: unknown;
|
|
45
47
|
cache_read_input_token_cost?: unknown;
|
|
@@ -84,6 +86,23 @@ export function parsePriceTable(document: unknown): PriceTable {
|
|
|
84
86
|
cacheWrite: rate(value.cache_creation_input_token_cost) ?? input * CACHE_WRITE_SHARE,
|
|
85
87
|
cacheWrite1h: input * CACHE_WRITE_1H_SHARE
|
|
86
88
|
};
|
|
89
|
+
const tiers: Partial<Record<string, ModelPrice>> = {};
|
|
90
|
+
for (const [tier, multiplier] of [
|
|
91
|
+
['priority', 2],
|
|
92
|
+
['flex', 0.5],
|
|
93
|
+
['ultrafast', 6]
|
|
94
|
+
] as const) {
|
|
95
|
+
tiers[tier] = {
|
|
96
|
+
input: rate(value[`input_cost_per_token_${tier}`]) ?? input * multiplier,
|
|
97
|
+
output: rate(value[`output_cost_per_token_${tier}`]) ?? output * multiplier,
|
|
98
|
+
cacheRead: rate(value[`cache_read_input_token_cost_${tier}`]) ?? price.cacheRead * multiplier,
|
|
99
|
+
cacheWrite: rate(value[`cache_creation_input_token_cost_${tier}`]) ?? price.cacheWrite * multiplier,
|
|
100
|
+
cacheWrite1h: price.cacheWrite1h * multiplier
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (value.litellm_provider === 'openai') {
|
|
104
|
+
price.tiers = tiers;
|
|
105
|
+
}
|
|
87
106
|
const key = name.trim().toLowerCase();
|
|
88
107
|
table.set(key, price);
|
|
89
108
|
const bare = key.slice(key.lastIndexOf('/') + 1);
|
|
@@ -100,12 +119,25 @@ function withoutDate(key: string): string | null {
|
|
|
100
119
|
return match === null ? null : match[1]!;
|
|
101
120
|
}
|
|
102
121
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
122
|
+
// The internal review alias has no published price; expose ccusage's dated estimate as a family match.
|
|
123
|
+
// https://github.com/ccusage/ccusage/blob/main/rust/adapters/codex/src/codex-auto-review-fallbacks.json
|
|
124
|
+
const REVIEW_MODELS = [
|
|
125
|
+
['2026-07-30', 'gpt-5.6-luna'],
|
|
126
|
+
['2026-03-05', 'gpt-5.4'],
|
|
127
|
+
['2026-02-05', 'gpt-5.3-codex'],
|
|
128
|
+
['2025-12-11', 'gpt-5.2-codex'],
|
|
129
|
+
['2025-11-13', 'gpt-5.1-codex'],
|
|
130
|
+
['2025-09-15', 'gpt-5-codex'],
|
|
131
|
+
['2025-08-07', 'gpt-5']
|
|
132
|
+
] as const;
|
|
133
|
+
|
|
134
|
+
export function lookupPrice(table: PriceTable, model: string, serviceTier?: string, timestampMs?: number): PriceLookup {
|
|
135
|
+
const found = lookupBasePrice(table, model, timestampMs);
|
|
136
|
+
const tier = serviceTier === 'fast' ? 'priority' : serviceTier;
|
|
137
|
+
return { ...found, price: (tier === undefined ? undefined : found.price?.tiers?.[tier]) ?? found.price };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function lookupBasePrice(table: PriceTable, model: string, timestampMs?: number): PriceLookup {
|
|
109
141
|
const key = model.trim().toLowerCase().split('[')[0]!;
|
|
110
142
|
const bare = key.slice(key.lastIndexOf('/') + 1);
|
|
111
143
|
if (bare === '' || AMBIGUOUS.has(bare)) {
|
|
@@ -115,6 +147,12 @@ export function lookupPrice(table: PriceTable, model: string): PriceLookup {
|
|
|
115
147
|
if (exact !== undefined) {
|
|
116
148
|
return { price: exact, basis: 'exact', pricedAs: null };
|
|
117
149
|
}
|
|
150
|
+
if (bare === 'codex-auto-review' && timestampMs !== undefined && Number.isFinite(timestampMs)) {
|
|
151
|
+
const date = new Date(timestampMs).toISOString().slice(0, 10);
|
|
152
|
+
const fallback = REVIEW_MODELS.find(([released]) => date >= released)?.[1] ?? 'gpt-5';
|
|
153
|
+
const found = lookupBasePrice(table, fallback);
|
|
154
|
+
return { price: found.price, basis: found.price === null ? 'unknown' : 'family', pricedAs: found.price === null ? null : fallback };
|
|
155
|
+
}
|
|
118
156
|
const dated = withoutDate(bare);
|
|
119
157
|
const undated = dated === null ? undefined : table.get(dated);
|
|
120
158
|
if (undated !== undefined && dated !== null) {
|
|
@@ -177,8 +215,8 @@ export class PriceBook {
|
|
|
177
215
|
return { source: this.table.size === 0 ? 'none' : this.source, fetchedAt: this.fetchedAt, models: this.table.size };
|
|
178
216
|
}
|
|
179
217
|
|
|
180
|
-
look(model: string): PriceLookup {
|
|
181
|
-
return lookupPrice(this.table, model);
|
|
218
|
+
look(model: string, serviceTier?: string, timestampMs?: number): PriceLookup {
|
|
219
|
+
return lookupPrice(this.table, model, serviceTier, timestampMs);
|
|
182
220
|
}
|
|
183
221
|
|
|
184
222
|
/* Reads the table an earlier run stored, once; asking LiteLLM is left to `ensure`. */
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { asObject, asString, int, type UsageRecord } from '../record.ts';
|
|
2
2
|
|
|
3
|
-
/* A `token_count` line carries the counts; the other two carry the model and the directory they belong to. */
|
|
4
3
|
export function codexMightCarryUsage(line: string): boolean {
|
|
5
|
-
return
|
|
4
|
+
return ['token_count', 'turn_context', 'session_meta', 'thread_settings_applied', 'token_usage_record', 'compacted'].some((type) => line.includes(type));
|
|
6
5
|
}
|
|
7
6
|
|
|
8
7
|
/* A fork replays its ancestors' events into the new file the moment it is created, so anything this
|
|
@@ -34,6 +33,11 @@ export interface CodexParserState {
|
|
|
34
33
|
anchorMs: number;
|
|
35
34
|
/* The last counts as they were written, so a line repeated verbatim is not counted twice. */
|
|
36
35
|
lastSignature: string | null;
|
|
36
|
+
serviceTier: string | null;
|
|
37
|
+
pendingCompactions: Record<string, { record: UsageRecord; counts: CodexCounts; threadTotal: CodexCounts | null }>;
|
|
38
|
+
compacted: string[];
|
|
39
|
+
handledResponses: string[];
|
|
40
|
+
latestResponse: string | null;
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
export function createCodexState(): CodexParserState {
|
|
@@ -45,12 +49,17 @@ export function createCodexState(): CodexParserState {
|
|
|
45
49
|
sawSessionMeta: false,
|
|
46
50
|
suppressing: false,
|
|
47
51
|
anchorMs: 0,
|
|
48
|
-
lastSignature: null
|
|
52
|
+
lastSignature: null,
|
|
53
|
+
serviceTier: null,
|
|
54
|
+
pendingCompactions: {},
|
|
55
|
+
compacted: [],
|
|
56
|
+
handledResponses: [],
|
|
57
|
+
latestResponse: null
|
|
49
58
|
};
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
export function cloneCodexState(state: CodexParserState): CodexParserState {
|
|
53
|
-
return
|
|
62
|
+
return structuredClone(state);
|
|
54
63
|
}
|
|
55
64
|
|
|
56
65
|
function countsOf(usage: Record<string, unknown>): CodexCounts {
|
|
@@ -87,13 +96,8 @@ export function isForkOrSubagent(payload: Record<string, unknown>): boolean {
|
|
|
87
96
|
return typeof asObject(spawn)?.parent_thread_id === 'string';
|
|
88
97
|
}
|
|
89
98
|
|
|
90
|
-
/*
|
|
91
|
-
|
|
92
|
-
* reports a cumulative total per turn, so the call is the difference with the line before it; when
|
|
93
|
-
* that difference falls the run started over and `last_token_usage` is the turn on its own. Codex
|
|
94
|
-
* counts cache reads and writes inside `input_tokens`, unlike Anthropic, so what is left after
|
|
95
|
-
* taking both out is the input that was actually paid for at the full rate.
|
|
96
|
-
*/
|
|
99
|
+
/* Codex includes cache reads and writes in input_tokens. Compaction can appear separately from
|
|
100
|
+
cumulative token_count events, so response identities prevent charging the same request twice. */
|
|
97
101
|
export function parseCodexLine(line: string, state: CodexParserState): UsageRecord | null {
|
|
98
102
|
let parsed: unknown;
|
|
99
103
|
try {
|
|
@@ -134,6 +138,18 @@ export function parseCodexLine(line: string, state: CodexParserState): UsageReco
|
|
|
134
138
|
return null;
|
|
135
139
|
}
|
|
136
140
|
|
|
141
|
+
if (payload.type === 'thread_settings_applied') {
|
|
142
|
+
const settings = asObject(payload.thread_settings);
|
|
143
|
+
if (settings !== null && typeof settings.service_tier === 'string') {
|
|
144
|
+
state.serviceTier = settings.service_tier;
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (record.type === 'token_usage_record' || record.type === 'compacted') {
|
|
150
|
+
return parseCompaction(record, payload, state);
|
|
151
|
+
}
|
|
152
|
+
|
|
137
153
|
if (payload.type !== 'token_count') {
|
|
138
154
|
return null;
|
|
139
155
|
}
|
|
@@ -170,6 +186,62 @@ export function parseCodexLine(line: string, state: CodexParserState): UsageReco
|
|
|
170
186
|
state.suppressing = false;
|
|
171
187
|
}
|
|
172
188
|
|
|
189
|
+
let dedupeKey: string | null = null;
|
|
190
|
+
const pending = state.latestResponse === null ? undefined : state.pendingCompactions[state.latestResponse];
|
|
191
|
+
if (pending !== undefined && (sameCounts(pending.counts, counts) || sameCounts(pending.threadTotal, cumulative))) {
|
|
192
|
+
dedupeKey = `codex:response:${state.latestResponse}`;
|
|
193
|
+
delete state.pendingCompactions[state.latestResponse!];
|
|
194
|
+
state.handledResponses.push(state.latestResponse!);
|
|
195
|
+
}
|
|
196
|
+
return usageRecord(counts, timestampMs, state.model, state, dedupeKey);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function sameCounts(first: CodexCounts | null, second: CodexCounts): boolean {
|
|
200
|
+
return (
|
|
201
|
+
first !== null &&
|
|
202
|
+
first.input === second.input &&
|
|
203
|
+
first.cached === second.cached &&
|
|
204
|
+
first.cacheWrite === second.cacheWrite &&
|
|
205
|
+
first.output === second.output &&
|
|
206
|
+
first.reasoning === second.reasoning
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function parseCompaction(record: Record<string, unknown>, payload: Record<string, unknown>, state: CodexParserState): UsageRecord | null {
|
|
211
|
+
const id = asString(record.type === 'compacted' ? payload.compaction_response_id : payload.response_id);
|
|
212
|
+
if (id === '' || state.handledResponses.includes(id)) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
if (record.type === 'compacted') {
|
|
216
|
+
if (!state.compacted.includes(id)) {
|
|
217
|
+
state.compacted.push(id);
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
const usage = asObject(payload.usage);
|
|
221
|
+
const model = asString(payload.model) || state.model;
|
|
222
|
+
const timestampMs = Date.parse(asString(record.timestamp));
|
|
223
|
+
if (usage === null || model === null || !Number.isFinite(timestampMs)) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
const counts = countsOf(usage);
|
|
227
|
+
const parsed = usageRecord(counts, timestampMs, model, state, `codex:response:${id}`);
|
|
228
|
+
if (parsed === null) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
state.latestResponse = id;
|
|
232
|
+
const threadTotal = asObject(payload.thread_token_usage);
|
|
233
|
+
state.pendingCompactions[id] ??= { record: parsed, counts, threadTotal: threadTotal === null ? null : countsOf(threadTotal) };
|
|
234
|
+
}
|
|
235
|
+
const pending = state.pendingCompactions[id];
|
|
236
|
+
if (!state.compacted.includes(id) || pending === undefined) {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
state.handledResponses.push(id);
|
|
240
|
+
delete state.pendingCompactions[id];
|
|
241
|
+
return pending.record;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function usageRecord(counts: CodexCounts, timestampMs: number, model: string, state: CodexParserState, dedupeKey: string | null): UsageRecord | null {
|
|
173
245
|
const uncached = Math.max(0, counts.input - counts.cached - counts.cacheWrite);
|
|
174
246
|
const totals = {
|
|
175
247
|
calls: 1,
|
|
@@ -186,11 +258,11 @@ export function parseCodexLine(line: string, state: CodexParserState): UsageReco
|
|
|
186
258
|
return {
|
|
187
259
|
provider: 'codex',
|
|
188
260
|
timestampMs,
|
|
189
|
-
model
|
|
261
|
+
model,
|
|
190
262
|
sessionId: state.sessionId,
|
|
191
263
|
cwd: state.cwd,
|
|
192
264
|
totals,
|
|
193
|
-
|
|
194
|
-
dedupeKey
|
|
265
|
+
...(state.serviceTier === null ? {} : { serviceTier: state.serviceTier }),
|
|
266
|
+
dedupeKey
|
|
195
267
|
};
|
|
196
268
|
}
|
package/src/usage/record.ts
CHANGED
|
@@ -13,6 +13,7 @@ export interface UsageRecord {
|
|
|
13
13
|
totals: UsageTotals;
|
|
14
14
|
/* What the CLI itself says the call cost, which wins over a price from the table; absent where it says nothing. */
|
|
15
15
|
costUsd?: number;
|
|
16
|
+
serviceTier?: string;
|
|
16
17
|
/* What identifies the message this line reports on, or null when nothing does. */
|
|
17
18
|
dedupeKey: string | null;
|
|
18
19
|
}
|