@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.
Files changed (45) hide show
  1. package/dist/chat/acp-backend.d.ts +7 -0
  2. package/dist/chat/acp-backend.js +87 -6
  3. package/dist/chat/acp-protocol.d.ts +6 -0
  4. package/dist/chat/acp-protocol.js +34 -9
  5. package/dist/providers/accounts/acp-account.d.ts +1 -0
  6. package/dist/providers/accounts/acp-account.js +25 -3
  7. package/dist/providers/acp-agent-state.d.ts +35 -0
  8. package/dist/providers/acp-agent-state.js +170 -0
  9. package/dist/providers/acp-provider.d.ts +2 -0
  10. package/dist/providers/acp-provider.js +29 -33
  11. package/dist/providers/catalog.d.ts +2 -0
  12. package/dist/providers/catalog.js +14 -4
  13. package/dist/providers/index.d.ts +1 -0
  14. package/dist/providers/index.js +1 -0
  15. package/dist/providers/provider.d.ts +1 -0
  16. package/dist/providers/registry.d.ts +1 -0
  17. package/dist/providers/registry.js +9 -0
  18. package/dist/testing/fake-acp.d.ts +3 -0
  19. package/dist/testing/fake-acp.js +11 -4
  20. package/dist/usage/aggregate.js +1 -1
  21. package/dist/usage/index-file.d.ts +2 -0
  22. package/dist/usage/index-file.js +19 -10
  23. package/dist/usage/pricing.d.ts +3 -2
  24. package/dist/usage/pricing.js +42 -8
  25. package/dist/usage/readers/codex.d.ts +9 -0
  26. package/dist/usage/readers/codex.js +75 -14
  27. package/dist/usage/record.d.ts +1 -0
  28. package/dist/usage/scanner.js +32 -8
  29. package/package.json +3 -3
  30. package/src/chat/acp-backend.ts +93 -5
  31. package/src/chat/acp-protocol.ts +41 -9
  32. package/src/providers/accounts/acp-account.ts +34 -3
  33. package/src/providers/acp-agent-state.ts +206 -0
  34. package/src/providers/acp-provider.ts +33 -37
  35. package/src/providers/catalog.ts +19 -5
  36. package/src/providers/index.ts +1 -0
  37. package/src/providers/provider.ts +2 -0
  38. package/src/providers/registry.ts +10 -0
  39. package/src/testing/fake-acp.ts +17 -4
  40. package/src/usage/aggregate.ts +1 -1
  41. package/src/usage/index-file.ts +25 -18
  42. package/src/usage/pricing.ts +46 -8
  43. package/src/usage/readers/codex.ts +86 -14
  44. package/src/usage/record.ts +1 -0
  45. package/src/usage/scanner.ts +35 -8
@@ -1,15 +1,16 @@
1
- import type { AgentKind, ModelOptionDescriptor, ProviderCapabilities, ProviderInstall } from '@adecore/agent-contracts';
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 before and after;
12
- * a message names its mentions and attachments by path, as text the agent opens with its own tools.
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`, for an agent that reports its cost or context window, or whose commands fold its context.
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 capabilities = { ...ACP_CAPABILITIES, ...spec.capabilities };
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
- if (spec.adoptModels !== false && info.models !== null) {
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({ ...spec, onSession, ...(onUsage === undefined ? {} : { onUsage }) })
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
  }
@@ -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(models: ReadonlyArray<{ slug: string; name: string }>, defaultModel: string, options: readonly ModelOptionDescriptor[] = []): boolean {
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
- const profile = { ...base, options: [...base.options.filter((option) => !replaced.has(option.id)), ...options] };
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: { [ADOPTED_PROFILE]: profile },
58
- models: models.map((model) => ({ slug: model.slug, name: model.name, profile: ADOPTED_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;
@@ -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
  }
@@ -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: choice.value, name: choice.name })) } }
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: false },
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':
@@ -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);
@@ -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
- /* Another version is thrown away rather than migrated, since rebuilding costs one cold scan; version 1 is read as it is. */
6
- const INDEX_VERSION = 2;
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
- * The index is a few hundred thousand records of seven numbers each, and the same model, session,
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.push(record.costUsd);
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 === 13 || row.length === 14) &&
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] === undefined ? {} : { costUsd: 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
  }
@@ -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
- * Exact first, then the same name without its date, then the longest family the table knows. A name
105
- * nobody prices is left unpriced rather than guessed at: `$0.00` for a model that costs money is a
106
- * worse answer than a question mark.
107
- */
108
- export function lookupPrice(table: PriceTable, model: string): PriceLookup {
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 line.includes('token_count') || line.includes('turn_context') || line.includes('session_meta');
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 { ...state, total: state.total === null ? null : { ...state.total } };
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
- * One line of `~/.codex/sessions/**\/*.jsonl`, against the state of the file it came from. Codex
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: state.model,
261
+ model,
190
262
  sessionId: state.sessionId,
191
263
  cwd: state.cwd,
192
264
  totals,
193
- // Fork copies are gone by here, so every event that is left is a call of its own.
194
- dedupeKey: null
265
+ ...(state.serviceTier === null ? {} : { serviceTier: state.serviceTier }),
266
+ dedupeKey
195
267
  };
196
268
  }
@@ -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
  }