@linkshell/wire 0.2.29 → 0.2.31

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/src/lsa.ts ADDED
@@ -0,0 +1,159 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * LinkShell Agent (LSA) Phase 1 RPC contracts: deterministic task ledger,
5
+ * automated post-turn acceptance verification, memories, and due followups.
6
+ */
7
+
8
+ export const lsaTaskStatusSchema = z.enum([
9
+ "queued",
10
+ "running",
11
+ "verifying",
12
+ "waiting_decision",
13
+ "completed",
14
+ "failed",
15
+ "cancelled",
16
+ ]);
17
+ export type LsaTaskStatus = z.infer<typeof lsaTaskStatusSchema>;
18
+
19
+ export const lsaDecisionSchema = z.enum(["accept", "retry", "cancel"]);
20
+ export type LsaDecision = z.infer<typeof lsaDecisionSchema>;
21
+
22
+ export const lsaTaskSchema = z.object({
23
+ id: z.string().min(1),
24
+ title: z.string().optional(),
25
+ goal: z.string().min(1),
26
+ status: lsaTaskStatusSchema,
27
+ cwd: z.string(),
28
+ worktree: z.boolean().optional(),
29
+ agent: z.string().optional(),
30
+ sessionId: z.string().optional(),
31
+ controllerEpoch: z.number().int().nonnegative().optional(),
32
+ acceptanceChecks: z.array(z.string()),
33
+ summary: z.string().optional(),
34
+ createdAt: z.number(),
35
+ updatedAt: z.number(),
36
+ });
37
+ export type LsaTask = z.infer<typeof lsaTaskSchema>;
38
+
39
+ export const lsaMemorySchema = z.object({
40
+ id: z.string().optional(),
41
+ scope: z.string().optional(),
42
+ key: z.string().min(1),
43
+ content: z.string(),
44
+ tags: z.array(z.string()),
45
+ updatedAt: z.number(),
46
+ });
47
+ export type LsaMemory = z.infer<typeof lsaMemorySchema>;
48
+
49
+ export const lsaFollowupSchema = z.object({
50
+ id: z.string().min(1),
51
+ taskId: z.string().optional(),
52
+ dueAt: z.number(),
53
+ note: z.string(),
54
+ done: z.boolean(),
55
+ triggeredAt: z.number().optional(),
56
+ });
57
+ export type LsaFollowup = z.infer<typeof lsaFollowupSchema>;
58
+
59
+ export const lsaCandidateProjectSchema = z.object({
60
+ cwd: z.string(),
61
+ name: z.string(),
62
+ score: z.number(),
63
+ remote: z.string().optional(),
64
+ reason: z.string().optional(),
65
+ });
66
+ export type LsaCandidateProject = z.infer<typeof lsaCandidateProjectSchema>;
67
+
68
+ export const lsaStatusSchema = z.object({
69
+ enabled: z.boolean(),
70
+ controllerEpoch: z.number().int().nonnegative(),
71
+ generation: z.number().int().positive(),
72
+ activeTasks: z.number().int().nonnegative(),
73
+ waitingDecisionTasks: z.number().int().nonnegative(),
74
+ dueFollowups: z.number().int().nonnegative(),
75
+ requiresYearlyTier: z.boolean(),
76
+ handoffSummary: z.string().optional(),
77
+ candidates: z.array(lsaCandidateProjectSchema).optional(),
78
+ });
79
+ export type LsaStatus = z.infer<typeof lsaStatusSchema>;
80
+
81
+ export const lsaMethods = {
82
+ "lsa.status": {
83
+ params: z.object({
84
+ query: z.string().max(200).optional(),
85
+ }),
86
+ result: lsaStatusSchema,
87
+ },
88
+ "lsa.tasks": {
89
+ params: z.object({
90
+ taskId: z.string().optional(),
91
+ status: lsaTaskStatusSchema.optional(),
92
+ limit: z.number().int().positive().max(200).optional(),
93
+ }),
94
+ result: z.object({
95
+ tasks: z.array(lsaTaskSchema),
96
+ }),
97
+ },
98
+ "lsa.dispatch": {
99
+ params: z.object({
100
+ goal: z.string().min(1).max(20_000),
101
+ title: z.string().max(200).optional(),
102
+ cwd: z.string().optional(),
103
+ agent: z.string().optional(),
104
+ worktree: z.boolean().optional(),
105
+ acceptanceChecks: z.array(z.string().max(2000)).max(50).optional(),
106
+ }),
107
+ result: z.object({
108
+ task: lsaTaskSchema,
109
+ }),
110
+ },
111
+ "lsa.steer": {
112
+ params: z.object({
113
+ taskId: z.string().min(1),
114
+ instruction: z.string().min(1).max(20_000),
115
+ controllerEpoch: z.number().int().nonnegative().optional(),
116
+ }),
117
+ result: z.object({
118
+ task: lsaTaskSchema,
119
+ }),
120
+ },
121
+ "lsa.decide": {
122
+ params: z.object({
123
+ taskId: z.string().min(1),
124
+ decision: lsaDecisionSchema,
125
+ feedback: z.string().max(20_000).optional(),
126
+ controllerEpoch: z.number().int().nonnegative().optional(),
127
+ }),
128
+ result: z.object({
129
+ task: lsaTaskSchema,
130
+ }),
131
+ },
132
+ "lsa.memories": {
133
+ params: z.object({
134
+ op: z.enum(["list", "search", "save", "delete"]).optional(),
135
+ query: z.string().max(500).optional(),
136
+ scope: z.string().max(120).optional(),
137
+ key: z.string().max(200).optional(),
138
+ content: z.string().max(20_000).optional(),
139
+ tags: z.array(z.string().max(80)).max(32).optional(),
140
+ limit: z.number().int().positive().max(200).optional(),
141
+ }),
142
+ result: z.object({
143
+ memories: z.array(lsaMemorySchema),
144
+ }),
145
+ },
146
+ "lsa.followups": {
147
+ params: z.object({
148
+ op: z.enum(["list", "schedule", "complete", "delete"]).optional(),
149
+ id: z.string().optional(),
150
+ taskId: z.string().optional(),
151
+ dueAt: z.number().optional(),
152
+ note: z.string().max(2000).optional(),
153
+ includeDone: z.boolean().optional(),
154
+ }),
155
+ result: z.object({
156
+ followups: z.array(lsaFollowupSchema),
157
+ }),
158
+ },
159
+ } as const;
package/src/model.ts CHANGED
@@ -302,6 +302,10 @@ export const gitInfoSchema = z.object({
302
302
  head: z.string().optional(),
303
303
  /** Uncommitted changes in the working directory (a new worktree starts from the last commit, without them). */
304
304
  dirty: z.boolean(),
305
+ /** Normalized git origin remote (`host/owner/repo`), when configured. */
306
+ remote: z.string().optional(),
307
+ /** First commit hash of the repository, for cross-machine repo identity. */
308
+ rootCommit: z.string().optional(),
305
309
  });
306
310
  export type GitInfo = z.infer<typeof gitInfoSchema>;
307
311
 
@@ -356,6 +360,23 @@ export const machineInfoSchema = z.object({
356
360
  });
357
361
  export type MachineInfo = z.infer<typeof machineInfoSchema>;
358
362
 
363
+ export const keepAwakeModeSchema = z.enum(["smart", "always", "off"]);
364
+ export type KeepAwakeMode = z.infer<typeof keepAwakeModeSchema>;
365
+
366
+ export const machinePowerSchema = z.object({
367
+ onBattery: z.boolean(),
368
+ percent: z.number().min(0).max(100).nullable(),
369
+ charging: z.boolean().optional(),
370
+ });
371
+ export type MachinePower = z.infer<typeof machinePowerSchema>;
372
+
373
+ export const machineKeepAwakeSchema = z.object({
374
+ mode: keepAwakeModeSchema,
375
+ active: z.boolean(),
376
+ reason: z.string().optional(),
377
+ });
378
+ export type MachineKeepAwake = z.infer<typeof machineKeepAwakeSchema>;
379
+
359
380
  /** Whole-computer resource use; CPU is a delta across all logical cores, not load average. */
360
381
  export const machineMetricsSchema = z.object({
361
382
  sampledAt: z.number().nonnegative(),
@@ -382,6 +403,10 @@ export const machineMetricsSchema = z.object({
382
403
  /** Space available to this user; excludes filesystem reservations. */
383
404
  availableBytes: z.number().nonnegative(),
384
405
  }).optional(),
406
+ /** Battery and AC power state on laptops; absent on desktops or older hosts. */
407
+ power: machinePowerSchema.optional(),
408
+ /** Host idle-sleep assertion state; absent on older hosts. */
409
+ keepAwake: machineKeepAwakeSchema.optional(),
385
410
  });
386
411
  export type MachineMetrics = z.infer<typeof machineMetricsSchema>;
387
412
 
package/src/relay.ts CHANGED
@@ -123,7 +123,7 @@ export interface RelayMethods {
123
123
  "machines.forget": { params: { machineId: string }; result: Record<string, never> };
124
124
  "devices.list": { params: Record<string, never>; result: { devices: DeviceEntry[] } };
125
125
  "devices.revoke": { params: { deviceId: string }; result: Record<string, never> };
126
- /** Device: take this device off its account (signing out); paired computers stay. */
126
+ /** Take this peer off its account (signing out); explicit pairings stay. */
127
127
  "account.leave": { params: Record<string, never>; result: Record<string, never> };
128
128
  }
129
129
  export type RelayMethod = keyof RelayMethods;
package/src/rpc.ts CHANGED
@@ -3,12 +3,14 @@ import { usageQuerySchema, usageReportSchema } from "./usage.js";
3
3
  import { goalChangeSchema, sessionGoalSchema } from "./goal.js";
4
4
  import { acpAgentSettingsSchema, acpFeaturesSchema, acpRemoteAgentSchema, acpProviderSchema } from "./acp.js";
5
5
  import { editorRequestSchema, editorResultSchema } from "./editor.js";
6
- import { liveActivityIdSchema, liveActivitySubscriptionSchema } from "./live-activity.js";
6
+ import { liveActivityIdSchema, liveActivityPushToStartSchema, liveActivitySubscriptionSchema, wakeupNoticeSchema } from "./live-activity.js";
7
7
  import {
8
8
  backgroundTaskSchema,
9
9
  agentInfoSchema,
10
10
  gatewayStatusSchema,
11
+ keepAwakeModeSchema,
11
12
  machineInfoSchema,
13
+ machineKeepAwakeSchema,
12
14
  machineMetricsSnapshotSchema,
13
15
  portInfoSchema,
14
16
  projectSummarySchema,
@@ -24,6 +26,7 @@ import {
24
26
  import { contentBlockSchema, sessionConfigOptionSchema, sessionEventSchema, sessionNoticeSchema } from "./updates.js";
25
27
  import { sideChatSchema } from "./side.js";
26
28
  import { mcpMethods } from "./rpc-mcp.js";
29
+ import { lsaMethods } from "./lsa.js";
27
30
 
28
31
  // ── JSON-RPC 2.0 framing ─────────────────────────────────────────────
29
32
 
@@ -77,11 +80,51 @@ export const RPC_APP_ERROR = -32000;
77
80
  * 1: side conversations (`sessions.side`), `machine.info.protocol` itself.
78
81
  * 2: session terminals (`terminals.create` `sessionId`, `TerminalInfo.sessionId`).
79
82
  * 3: whole-computer CPU and physical memory sampling (`machine.metrics`).
83
+ * 4: smart keep-awake & battery telemetry (`machine.keepAwake`), real git working-tree
84
+ * diff/revert (`git.diff`, `git.revertFile`), fast `@file` search (`fs.projectFiles`),
85
+ * clipboard & screen privacy (`clipboard.*`, `screen.privacy`), push-to-start wakeup
86
+ * (`liveActivities.registerPushToStart`), and LSA Phase 1 steward RPCs (`lsa.*`).
80
87
  */
81
- export const HOST_PROTOCOL = 3;
88
+ export const HOST_PROTOCOL = 4;
82
89
 
83
90
  const empty = z.looseObject({});
84
91
 
92
+ export const gitDiffFileStatusSchema = z.enum(["modified", "added", "deleted", "renamed", "untracked"]);
93
+ export type GitDiffFileStatus = z.infer<typeof gitDiffFileStatusSchema>;
94
+
95
+ export const gitDiffFileSchema = z.object({
96
+ path: z.string(),
97
+ oldPath: z.string().optional(),
98
+ status: gitDiffFileStatusSchema,
99
+ oldText: z.string(),
100
+ newText: z.string(),
101
+ additions: z.number().int().nonnegative(),
102
+ deletions: z.number().int().nonnegative(),
103
+ binary: z.boolean().optional(),
104
+ });
105
+ export type GitDiffFile = z.infer<typeof gitDiffFileSchema>;
106
+
107
+ export const gitDiffResultSchema = z.object({
108
+ files: z.array(gitDiffFileSchema),
109
+ truncated: z.boolean(),
110
+ branch: z.string().optional(),
111
+ });
112
+ export type GitDiffResult = z.infer<typeof gitDiffResultSchema>;
113
+
114
+ export const projectFileEntrySchema = z.object({
115
+ path: z.string(),
116
+ relativePath: z.string(),
117
+ isDir: z.boolean().optional(),
118
+ });
119
+ export type ProjectFileEntry = z.infer<typeof projectFileEntrySchema>;
120
+
121
+ export const screenPrivacyStateSchema = z.object({
122
+ curtain: z.boolean(),
123
+ lockOnDisconnect: z.boolean(),
124
+ supported: z.boolean(),
125
+ });
126
+ export type ScreenPrivacyState = z.infer<typeof screenPrivacyStateSchema>;
127
+
85
128
  export const directoryEntrySchema = z.object({
86
129
  name: z.string(),
87
130
  path: z.string(),
@@ -133,11 +176,19 @@ const coreMethods = {
133
176
  params: z.object({ activityId: liveActivityIdSchema }),
134
177
  result: z.object({}),
135
178
  },
179
+ "liveActivities.registerPushToStart": {
180
+ params: liveActivityPushToStartSchema,
181
+ result: z.looseObject({ ok: z.boolean().optional() }),
182
+ },
136
183
  "machine.info": {
137
184
  params: empty,
138
185
  result: machineInfoSchema,
139
186
  },
140
187
  "machine.metrics": { params: empty, result: machineMetricsSnapshotSchema },
188
+ "machine.keepAwake": {
189
+ params: z.object({ mode: keepAwakeModeSchema.optional() }),
190
+ result: machineKeepAwakeSchema,
191
+ },
141
192
  "machine.rename": {
142
193
  /** Empty restores the configured default; names never change peer identities. */
143
194
  params: z.object({ name: z.string().trim().max(40) }),
@@ -480,6 +531,8 @@ const coreMethods = {
480
531
  * host connects, moves or disconnects without a restart.
481
532
  */
482
533
  "gateway.refresh": { params: empty, result: gatewayStatusSchema },
534
+ /** Explicitly end this computer's gateway account binding (local only). */
535
+ "gateway.account.leave": { params: empty, result: empty },
483
536
  /**
484
537
  * Opens a pairing window (local only). `link` is what the QR encodes; the
485
538
  * code can be typed instead. `pairing.done` follows when a device pairs.
@@ -506,9 +559,24 @@ const coreMethods = {
506
559
  params: z.object({ password: z.string().min(4).max(128).optional() }),
507
560
  result: z.object({ password: z.boolean() }),
508
561
  },
509
- // ── Git: sessions in worktrees ──
562
+ // ── Git: sessions in worktrees & working-tree diffs ──
510
563
  /** What git says about `path`; no `git` when it isn't in a repository. */
511
564
  "git.info": { params: z.object({ path: z.string().min(1) }), result: z.object({ git: gitInfoSchema.optional() }) },
565
+ /** Real git working-tree diff (or against `base`), bounded by `maxBytes` and including untracked text files by default. */
566
+ "git.diff": {
567
+ params: z.object({
568
+ cwd: z.string().min(1),
569
+ base: z.string().optional(),
570
+ includeUntracked: z.boolean().optional(),
571
+ maxBytes: z.number().int().positive().max(10_000_000).optional(),
572
+ }),
573
+ result: gitDiffResultSchema,
574
+ },
575
+ /** Restores a tracked file or removes an untracked file strictly inside the repository root. */
576
+ "git.revertFile": {
577
+ params: z.object({ cwd: z.string().min(1), path: z.string().min(1) }),
578
+ result: z.object({ ok: z.boolean() }),
579
+ },
512
580
  /** The worktrees LinkShell made, newest first. */
513
581
  "worktrees.list": { params: empty, result: z.object({ worktrees: z.array(worktreeEntrySchema) }) },
514
582
  /** Removes a worktree and its branch. Refused (`busy`) while a session uses it, or (`dirty`) when work in it would be lost, unless `force`. */
@@ -598,6 +666,27 @@ const coreMethods = {
598
666
  params: z.object({ query: z.string().min(1).max(200), limit: z.number().int().min(1).max(200).default(60) }),
599
667
  result: z.object({ entries: z.array(directoryEntrySchema), truncated: z.boolean() }),
600
668
  },
669
+ /** Fast project file listing under `cwd` (`git ls-files` with directory walk fallback) for `@file` autocomplete. */
670
+ "fs.projectFiles": {
671
+ params: z.object({
672
+ cwd: z.string().min(1),
673
+ query: z.string().max(200).optional(),
674
+ limit: z.number().int().min(1).max(2000).optional(),
675
+ }),
676
+ result: z.object({
677
+ files: z.array(projectFileEntrySchema),
678
+ truncated: z.boolean(),
679
+ }),
680
+ },
681
+ // ── Clipboard: encrypted host clipboard access ──
682
+ "clipboard.read": {
683
+ params: empty,
684
+ result: z.object({ text: z.string() }),
685
+ },
686
+ "clipboard.write": {
687
+ params: z.object({ text: z.string().max(2_000_000) }),
688
+ result: z.object({ ok: z.boolean() }),
689
+ },
601
690
  // ── Terminals: plain shells on the host, any command, like SSH ──
602
691
  "terminals.list": {
603
692
  params: empty,
@@ -731,6 +820,14 @@ const coreMethods = {
731
820
  problem: z.string().optional(),
732
821
  }),
733
822
  },
823
+ /** Privacy curtain (dim/blank physical screen while viewing remotely) and auto-lock on disconnect. */
824
+ "screen.privacy": {
825
+ params: z.object({
826
+ curtain: z.boolean().optional(),
827
+ lockOnDisconnect: z.boolean().optional(),
828
+ }),
829
+ result: screenPrivacyStateSchema,
830
+ },
734
831
  /** Bytes to the host's end of a stream (base64). */
735
832
  "proxy.write": {
736
833
  params: z.object({ streamId: z.string().min(1), data: z.string().max(2_000_000) }),
@@ -742,7 +839,12 @@ const coreMethods = {
742
839
  },
743
840
  } as const;
744
841
 
745
- export const methods: typeof coreMethods & typeof acpMethods & typeof mcpMethods = { ...coreMethods, ...acpMethods, ...mcpMethods };
842
+ export const methods: typeof coreMethods & typeof acpMethods & typeof mcpMethods & typeof lsaMethods = {
843
+ ...coreMethods,
844
+ ...acpMethods,
845
+ ...mcpMethods,
846
+ ...lsaMethods,
847
+ };
746
848
 
747
849
  export type MethodName = keyof typeof methods;
748
850
  export type MethodParams<M extends MethodName> = z.input<(typeof methods)[M]["params"]>;
@@ -787,6 +889,8 @@ export const notifications = {
787
889
  "proxy.data": z.object({ streamId: z.string(), data: z.string() }),
788
890
  /** The host's end of a stream closed (the server hung up, or refused). */
789
891
  "proxy.closed": z.object({ streamId: z.string(), error: z.string().optional() }),
892
+ /** Zero-plaintext transition wakeup metadata. */
893
+ "liveActivities.wakeup": wakeupNoticeSchema,
790
894
  } as const;
791
895
 
792
896
  export type NotificationName = keyof typeof notifications;
package/src/usage.ts CHANGED
@@ -4,21 +4,133 @@ export const usageQuerySchema = z.object({
4
4
  days: z.union([z.literal(1), z.literal(7), z.literal(30)]).default(7),
5
5
  agent: z.string().min(1).max(128).default("all"),
6
6
  });
7
+
7
8
  export const usageTotalsSchema = z.object({
8
- records: z.number(), sessions: z.number(), inputTokens: z.number(), outputTokens: z.number(),
9
- cacheReadTokens: z.number(), cacheWriteTokens: z.number(), totalTokens: z.number(),
9
+ records: z.number(),
10
+ sessions: z.number(),
11
+ inputTokens: z.number(),
12
+ outputTokens: z.number(),
13
+ cacheReadTokens: z.number(),
14
+ cacheWriteTokens: z.number(),
15
+ totalTokens: z.number(),
16
+ estimatedCostUsd: z.number().nonnegative().optional(),
17
+ });
18
+
19
+ export const usageRemoteStatsSchema = z.object({
20
+ remoteTurns: z.number().int().nonnegative(),
21
+ remoteApprovals: z.number().int().nonnegative(),
10
22
  });
23
+
11
24
  export const usageReportSchema = z.object({
12
- generatedAt: z.number(), days: z.number(), agent: z.string(), timezone: z.string(),
25
+ generatedAt: z.number(),
26
+ days: z.number(),
27
+ agent: z.string(),
28
+ timezone: z.string(),
13
29
  totals: usageTotalsSchema,
14
30
  daily: z.array(usageTotalsSchema.extend({ date: z.string() })),
15
31
  models: z.array(usageTotalsSchema.extend({ agent: z.string(), model: z.string() })),
16
32
  agents: z.array(z.object({
17
- id: z.string(), label: z.string(), source: z.enum(["native", "acp"]),
18
- status: z.enum(["available", "no_data"]), records: z.number(), totalTokens: z.number(), note: z.string(),
33
+ id: z.string(),
34
+ label: z.string(),
35
+ source: z.enum(["native", "acp"]),
36
+ status: z.enum(["available", "no_data"]),
37
+ records: z.number(),
38
+ totalTokens: z.number(),
39
+ note: z.string(),
40
+ estimatedCostUsd: z.number().nonnegative().optional(),
19
41
  })).optional(),
42
+ estimatedCostUsd: z.number().nonnegative().optional(),
43
+ remoteStats: usageRemoteStatsSchema.optional(),
20
44
  warnings: z.array(z.string()),
21
45
  });
46
+
22
47
  export type UsageQuery = z.infer<typeof usageQuerySchema>;
23
48
  export type UsageTotals = z.infer<typeof usageTotalsSchema>;
49
+ export type UsageRemoteStats = z.infer<typeof usageRemoteStatsSchema>;
24
50
  export type UsageReport = z.infer<typeof usageReportSchema>;
51
+
52
+ export interface ModelPricingPerMillion {
53
+ input: number;
54
+ output: number;
55
+ cacheRead: number;
56
+ cacheWrite: number;
57
+ }
58
+
59
+ export interface TokenCostInput {
60
+ inputTokens: number;
61
+ outputTokens: number;
62
+ cacheReadTokens?: number;
63
+ cacheWriteTokens?: number;
64
+ }
65
+
66
+ /**
67
+ * Reference public API pricing (USD per 1M tokens), ordered most-specific first.
68
+ * Subscription users (ChatGPT Plus/Pro, Claude Pro/Max) pay flat fees; this
69
+ * estimates the equivalent API value of work orchestrated through LinkShell.
70
+ */
71
+ const MODEL_PRICING_RULES: ReadonlyArray<{ match: RegExp; pricing: ModelPricingPerMillion }> = [
72
+ // Anthropic Claude
73
+ { match: /claude[-_.]?opus[-_.]?(?:4[._][56]|5)/i, pricing: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 } },
74
+ { match: /opus/i, pricing: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 } },
75
+ { match: /sonnet/i, pricing: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 } },
76
+ { match: /claude[-_.]?haiku[-_.]?4[._]5/i, pricing: { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 } },
77
+ { match: /haiku/i, pricing: { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 } },
78
+ // OpenAI / Codex
79
+ { match: /(?:gpt-5|o1|o3)[-_]pro/i, pricing: { input: 15, output: 60, cacheRead: 3.75, cacheWrite: 15 } },
80
+ { match: /gpt-5[-_.]?mini/i, pricing: { input: 0.25, output: 2, cacheRead: 0.025, cacheWrite: 0.25 } },
81
+ { match: /gpt-5[-_.]?nano/i, pricing: { input: 0.05, output: 0.4, cacheRead: 0.005, cacheWrite: 0.05 } },
82
+ { match: /codex[-_.]?mini/i, pricing: { input: 1.5, output: 6, cacheRead: 0.375, cacheWrite: 1.5 } },
83
+ { match: /gpt-5|codex/i, pricing: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 1.25 } },
84
+ { match: /gpt-4\.1[-_.]?mini/i, pricing: { input: 0.4, output: 1.6, cacheRead: 0.1, cacheWrite: 0.4 } },
85
+ { match: /gpt-4\.1[-_.]?nano/i, pricing: { input: 0.1, output: 0.4, cacheRead: 0.025, cacheWrite: 0.1 } },
86
+ { match: /gpt-4\.1/i, pricing: { input: 2, output: 8, cacheRead: 0.5, cacheWrite: 2 } },
87
+ { match: /gpt-4o[-_.]?mini/i, pricing: { input: 0.15, output: 0.6, cacheRead: 0.075, cacheWrite: 0.15 } },
88
+ { match: /gpt-4o/i, pricing: { input: 2.5, output: 10, cacheRead: 1.25, cacheWrite: 2.5 } },
89
+ { match: /o[34][-_.]?mini/i, pricing: { input: 1.1, output: 4.4, cacheRead: 0.275, cacheWrite: 1.1 } },
90
+ { match: /\bo3\b|\bo1\b/i, pricing: { input: 2, output: 8, cacheRead: 0.5, cacheWrite: 2 } },
91
+ // Google Gemini
92
+ { match: /gemini.*flash/i, pricing: { input: 0.3, output: 2.5, cacheRead: 0.075, cacheWrite: 0.3 } },
93
+ { match: /gemini/i, pricing: { input: 1.25, output: 10, cacheRead: 0.3125, cacheWrite: 1.25 } },
94
+ // xAI Grok & DeepSeek
95
+ { match: /grok/i, pricing: { input: 3, output: 15, cacheRead: 0.75, cacheWrite: 3 } },
96
+ { match: /deepseek/i, pricing: { input: 0.27, output: 1.1, cacheRead: 0.07, cacheWrite: 0.27 } },
97
+ ];
98
+
99
+ const DEFAULT_PRICING_PER_MILLION: ModelPricingPerMillion = {
100
+ input: 2,
101
+ output: 8,
102
+ cacheRead: 0.2,
103
+ cacheWrite: 2,
104
+ };
105
+
106
+ export function modelPricingPerMillionUsd(model: string): ModelPricingPerMillion {
107
+ const normalized = model.trim();
108
+ for (const rule of MODEL_PRICING_RULES) {
109
+ if (rule.match.test(normalized)) return rule.pricing;
110
+ }
111
+ return DEFAULT_PRICING_PER_MILLION;
112
+ }
113
+
114
+ function safeTokenCount(value: number | undefined): number {
115
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
116
+ }
117
+
118
+ /**
119
+ * Pure reference cost estimator (in USD) for a model and non-overlapping token bucket totals.
120
+ */
121
+ export function estimateModelCostUsd(model: string, totals: TokenCostInput): number {
122
+ const input = safeTokenCount(totals.inputTokens);
123
+ const output = safeTokenCount(totals.outputTokens);
124
+ const cacheRead = safeTokenCount(totals.cacheReadTokens);
125
+ const cacheWrite = safeTokenCount(totals.cacheWriteTokens);
126
+ if (input === 0 && output === 0 && cacheRead === 0 && cacheWrite === 0) return 0;
127
+
128
+ const rate = modelPricingPerMillionUsd(model);
129
+ const rawUsd =
130
+ (input * rate.input +
131
+ output * rate.output +
132
+ cacheRead * rate.cacheRead +
133
+ cacheWrite * rate.cacheWrite) /
134
+ 1_000_000;
135
+ return Math.round(rawUsd * 1_000_000) / 1_000_000;
136
+ }