@leo000001/opencode-quota-sidebar 4.0.9 → 4.0.12

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.
@@ -1,7 +1,7 @@
1
- import type { AssistantMessage, Message } from '@opencode-ai/sdk';
2
- import { type HistoryPeriod, type PeriodRange, type SinceSpec } from './period.js';
3
- import { type UsageSummary } from './usage.js';
4
- import type { CacheCoverageMode, CachedSessionUsage, IncrementalCursor } from './types.js';
1
+ import type { AssistantMessage, Message } from "@opencode-ai/sdk";
2
+ import { type HistoryPeriod, type PeriodRange, type SinceSpec } from "./period.js";
3
+ import { type UsageSummary } from "./usage.js";
4
+ import type { CacheCoverageMode, CachedSessionUsage, IncrementalCursor } from "./types.js";
5
5
  export type HistoryDialogRow = {
6
6
  label: string;
7
7
  isCurrent: boolean;
@@ -37,19 +37,21 @@ export type HistoryPersistenceHint = {
37
37
  ranges: UsageSummary[];
38
38
  totalUsage: UsageSummary;
39
39
  fullUsage: UsageSummary | undefined;
40
+ pricingFingerprint?: string;
41
+ pricingKeys?: string[];
40
42
  persist: boolean;
41
43
  cursor: IncrementalCursor | undefined;
42
44
  missing: boolean;
43
45
  loadFailed: boolean;
44
46
  };
45
47
  export type LoadMessagesPageResult = {
46
- status: 'ok';
48
+ status: "ok";
47
49
  entries: MessageEntry[];
48
50
  nextBefore?: string;
49
51
  } | {
50
- status: 'missing';
52
+ status: "missing";
51
53
  } | {
52
- status: 'error';
54
+ status: "error";
53
55
  };
54
56
  type SessionEntry = {
55
57
  sessionID: string;
@@ -74,10 +76,14 @@ export type ComputeHistoryUsageDeps = {
74
76
  classifyCacheMode: (message: AssistantMessage, modelCostMap: Record<string, unknown>) => CacheCoverageMode;
75
77
  /** Check whether a set of entries has at least one resolvable API-cost message. */
76
78
  hasResolvableApiCostMessages: (entries: MessageEntry[], modelCostMap: Record<string, unknown>) => boolean;
79
+ /** Build a pricing fingerprint from provider/model keys and current rates. */
80
+ pricingFingerprintForKeys: (pricingKeys: string[], modelCostMap: Record<string, unknown>) => string;
81
+ /** Whether cached usage still matches current billing + pricing semantics. */
82
+ isUsageBillingCurrent: (cached: CachedSessionUsage | undefined, modelCostMap: Record<string, unknown>) => boolean;
77
83
  /** Whether the cached usage for a session needs a full recompute. */
78
- shouldTrackFullUsage: (cached: CachedSessionUsage | undefined, hasPricing: boolean) => boolean;
84
+ shouldTrackFullUsage: (cached: CachedSessionUsage | undefined, modelCostMap: Record<string, unknown>) => boolean;
79
85
  /** Whether cached usage needs recompute (for persistence decision). */
80
- shouldRecomputeUsageCache: (cached: CachedSessionUsage, hasPricing: boolean, hasResolvableApiCostMessage: boolean) => boolean;
86
+ shouldRecomputeUsageCache: (cached: CachedSessionUsage, pricingFingerprint: string | undefined) => boolean;
81
87
  throwOnLoadFailure?: boolean;
82
88
  };
83
89
  /**
@@ -1,6 +1,6 @@
1
- import { parseSince, periodRanges, } from './period.js';
2
- import { mapConcurrent } from './helpers.js';
3
- import { accumulateMessagesAcrossCompletedRanges, accumulateMessagesInCompletedRange, emptyUsageSummary, fromCachedSessionUsage, mergeCursorFromEntries, mergeUsage, USAGE_BILLING_CACHE_VERSION, } from './usage.js';
1
+ import { parseSince, periodRanges, } from "./period.js";
2
+ import { mapConcurrent } from "./helpers.js";
3
+ import { accumulateMessagesAcrossCompletedRanges, accumulateMessagesInCompletedRange, emptyUsageSummary, fromCachedSessionUsage, mergeCursorFromEntries, mergeUsage, } from "./usage.js";
4
4
  // ── Constants ──────────────────────────────────────────────────────────────
5
5
  const RANGE_USAGE_CONCURRENCY = 5;
6
6
  // ── Core ───────────────────────────────────────────────────────────────────
@@ -11,7 +11,7 @@ function filterRangeSessions(sessions, startAt, endAt) {
11
11
  if (session.state.dirty === true)
12
12
  return true;
13
13
  const lastMessageTime = session.state.cursor?.lastMessageTime;
14
- if (typeof lastMessageTime === 'number' &&
14
+ if (typeof lastMessageTime === "number" &&
15
15
  Number.isFinite(lastMessageTime) &&
16
16
  lastMessageTime < startAt) {
17
17
  return false;
@@ -24,13 +24,13 @@ function pageLatestTimestamp(entries) {
24
24
  for (const entry of entries) {
25
25
  const info = entry.info;
26
26
  const completed = info.time?.completed;
27
- if (typeof completed === 'number' && Number.isFinite(completed)) {
27
+ if (typeof completed === "number" && Number.isFinite(completed)) {
28
28
  if (completed > latest)
29
29
  latest = completed;
30
30
  continue;
31
31
  }
32
32
  const created = info.time?.created;
33
- if (typeof created === 'number' && Number.isFinite(created)) {
33
+ if (typeof created === "number" && Number.isFinite(created)) {
34
34
  if (created > latest)
35
35
  latest = created;
36
36
  }
@@ -55,19 +55,19 @@ function rangeIndexForTimestamp(ranges, timestamp) {
55
55
  }
56
56
  return -1;
57
57
  }
58
- function canUseCurrentSessionCache(cached, session, ranges) {
58
+ function canUseCurrentSessionCache(cached, session, ranges, deps, modelCostMap) {
59
59
  if (!cached)
60
60
  return undefined;
61
- if (cached.billingVersion !== USAGE_BILLING_CACHE_VERSION)
61
+ if (!deps.isUsageBillingCurrent(cached, modelCostMap))
62
62
  return undefined;
63
63
  if (session.state.dirty === true)
64
64
  return undefined;
65
65
  const lastMessageTime = session.state.cursor?.lastMessageTime;
66
- if (typeof lastMessageTime !== 'number' ||
66
+ if (typeof lastMessageTime !== "number" ||
67
67
  !Number.isFinite(lastMessageTime)) {
68
68
  return undefined;
69
69
  }
70
- if (typeof session.state.createdAt !== 'number' ||
70
+ if (typeof session.state.createdAt !== "number" ||
71
71
  !Number.isFinite(session.state.createdAt)) {
72
72
  return undefined;
73
73
  }
@@ -107,13 +107,12 @@ export async function computeHistoryUsage(deps, period, rawSince) {
107
107
  const endAt = ranges[ranges.length - 1].endAt;
108
108
  const sessions = filterRangeSessions(deps.sessions, startAt, endAt);
109
109
  const modelCostMap = await deps.getModelCostMap();
110
- const hasPricing = Object.keys(modelCostMap).length > 0;
111
110
  if (sessions.length > 0) {
112
111
  const fetched = await mapConcurrent(sessions, RANGE_USAGE_CONCURRENCY, async (session) => {
113
112
  const cachedHit = canUseCurrentSessionCache(session.state.usage, session, rows.map((row) => ({
114
113
  startAt: row.range.startAt,
115
114
  endAt: row.range.endAt,
116
- })));
115
+ })), deps, modelCostMap);
117
116
  if (cachedHit) {
118
117
  const rangeUsage = rows.map(() => emptyUsageSummary());
119
118
  rangeUsage[cachedHit.index] = cachedHit.usage;
@@ -137,14 +136,14 @@ export async function computeHistoryUsage(deps, period, rawSince) {
137
136
  };
138
137
  const rangeUsage = rows.map(() => emptyUsageSummary());
139
138
  const totalUsage = emptyUsageSummary();
140
- const trackFullUsage = deps.shouldTrackFullUsage(session.state.usage, hasPricing);
139
+ const trackFullUsage = deps.shouldTrackFullUsage(session.state.usage, modelCostMap);
141
140
  const fullUsage = trackFullUsage ? emptyUsageSummary() : undefined;
142
141
  let cursor;
143
- let hasResolvable = false;
142
+ const pricingKeys = new Set();
144
143
  let before;
145
144
  while (true) {
146
145
  const load = await deps.loadMessagesPage(session.sessionID, before);
147
- if (load.status !== 'ok') {
146
+ if (load.status !== "ok") {
148
147
  return {
149
148
  sessionID: session.sessionID,
150
149
  dateKey: session.dateKey,
@@ -153,8 +152,8 @@ export async function computeHistoryUsage(deps, period, rawSince) {
153
152
  ranges: rows.map(() => emptyUsageSummary()),
154
153
  totalUsage: emptyUsageSummary(),
155
154
  fullUsage: undefined,
156
- loadFailed: load.status === 'error',
157
- missing: load.status === 'missing',
155
+ loadFailed: load.status === "error",
156
+ missing: load.status === "missing",
158
157
  persist: false,
159
158
  cursor: undefined,
160
159
  };
@@ -169,9 +168,11 @@ export async function computeHistoryUsage(deps, period, rawSince) {
169
168
  if (fullUsage) {
170
169
  accumulateMessagesInCompletedRange(fullUsage, entries, 0, Number.POSITIVE_INFINITY, usageOptions);
171
170
  cursor = mergeCursorFromEntries(cursor, entries);
172
- }
173
- if (!hasResolvable) {
174
- hasResolvable = deps.hasResolvableApiCostMessages(entries, modelCostMap);
171
+ for (const { info } of entries) {
172
+ if (info.role !== "assistant")
173
+ continue;
174
+ pricingKeys.add(`${info.providerID}:${info.modelID}`);
175
+ }
175
176
  }
176
177
  // `session.messages(limit, before)` pages from newest to oldest.
177
178
  // When we are only computing range usage (no full-session persistence),
@@ -195,9 +196,12 @@ export async function computeHistoryUsage(deps, period, rawSince) {
195
196
  if (totalUsage.assistantMessages > 0) {
196
197
  totalUsage.sessionCount = 1;
197
198
  }
199
+ const pricingFingerprint = fullUsage
200
+ ? deps.pricingFingerprintForKeys([...pricingKeys], modelCostMap)
201
+ : undefined;
198
202
  const shouldPersist = !!fullUsage &&
199
203
  (!session.state.usage ||
200
- deps.shouldRecomputeUsageCache(session.state.usage, hasPricing, hasResolvable));
204
+ deps.shouldRecomputeUsageCache(session.state.usage, pricingFingerprint));
201
205
  return {
202
206
  sessionID: session.sessionID,
203
207
  dateKey: session.dateKey,
@@ -206,6 +210,8 @@ export async function computeHistoryUsage(deps, period, rawSince) {
206
210
  ranges: rangeUsage,
207
211
  totalUsage,
208
212
  fullUsage: shouldPersist ? fullUsage : undefined,
213
+ pricingFingerprint,
214
+ pricingKeys: fullUsage ? [...pricingKeys].sort() : undefined,
209
215
  loadFailed: false,
210
216
  missing: false,
211
217
  persist: shouldPersist,
@@ -218,7 +224,7 @@ export async function computeHistoryUsage(deps, period, rawSince) {
218
224
  if (item.dirty)
219
225
  return true;
220
226
  const lastMessageTime = item.lastMessageTime;
221
- if (typeof lastMessageTime === 'number' && lastMessageTime < startAt) {
227
+ if (typeof lastMessageTime === "number" && lastMessageTime < startAt) {
222
228
  return false;
223
229
  }
224
230
  return true;
package/dist/index.js CHANGED
@@ -110,6 +110,7 @@ export async function QuotaSidebarPlugin(input) {
110
110
  statePath,
111
111
  client: input.client,
112
112
  directory: input.directory,
113
+ worktree: input.worktree,
113
114
  persistence: {
114
115
  markDirty,
115
116
  scheduleSave,
@@ -120,6 +121,15 @@ export async function QuotaSidebarPlugin(input) {
120
121
  const summarizeSessionUsageForDisplay = usageService.summarizeSessionUsageForDisplay;
121
122
  const summarizeForTool = usageService.summarizeForTool;
122
123
  const summarizeHistoryForTool = usageService.summarizeHistoryUsage;
124
+ const listCurrentProviderIDsSafe = async () => {
125
+ return listCurrentProviderIDs({
126
+ client: input.client,
127
+ directory: input.directory,
128
+ }).catch((error) => {
129
+ debug(`listCurrentProviderIDs failed: ${String(error)}`);
130
+ return undefined;
131
+ });
132
+ };
123
133
  const activeSessionUntil = new Map();
124
134
  let lastTuiSessionID;
125
135
  const markSessionActive = (sessionID, now = Date.now()) => {
@@ -181,6 +191,7 @@ export async function QuotaSidebarPlugin(input) {
181
191
  getTitleView: () => resolveTitleView({ config }),
182
192
  getQuotaSnapshots,
183
193
  summarizeSessionUsageForDisplay,
194
+ listCurrentProviderIDs: listCurrentProviderIDsSafe,
184
195
  scheduleParentRefreshIfSafe,
185
196
  isSessionActive,
186
197
  restoreConcurrency: RESTORE_TITLE_CONCURRENCY,
@@ -269,7 +280,10 @@ export async function QuotaSidebarPlugin(input) {
269
280
  }
270
281
  expiryToastInflight.add(sessionID);
271
282
  try {
272
- const quotas = await getQuotaSnapshots([], { allowDefault: true });
283
+ const allowedProviderIDs = await listCurrentProviderIDsSafe();
284
+ if (!allowedProviderIDs || allowedProviderIDs.size === 0)
285
+ return;
286
+ const quotas = await getQuotaSnapshots([...allowedProviderIDs]);
273
287
  const nowMs = Date.now();
274
288
  const expiryLines = quotas
275
289
  .filter((item) => item.status === 'ok')
@@ -0,0 +1,6 @@
1
+ import { type ModelCostRates } from "./cost.js";
2
+ import type { OpenCodePricingModel } from "./opencode_pricing.js";
3
+ export declare function modelsDevHasProvider(providerID: string): boolean;
4
+ export declare function loadModelsDevPricingModels(requests: OpenCodePricingModel[]): Promise<OpenCodePricingModel[]>;
5
+ export declare function clearModelsDevPricingCache(): void;
6
+ export declare function modelsDevCostToRates(cost: OpenCodePricingModel["cost"]): ModelCostRates | undefined;
@@ -0,0 +1,226 @@
1
+ import { debug, mapConcurrent } from "./helpers.js";
2
+ import { canonicalPricingProviderID, modelCostLookupKeys, } from "./cost.js";
3
+ const MODELS_DEV_RAW_BASE_URL = "https://raw.githubusercontent.com/anomalyco/models.dev/dev/providers";
4
+ const MODELS_DEV_TIMEOUT_MS = 10_000;
5
+ const MODELS_DEV_POSITIVE_TTL_MS = 6 * 60 * 60 * 1000;
6
+ const MODELS_DEV_NEGATIVE_TTL_MS = 60 * 60 * 1000;
7
+ const MODELS_DEV_PARSE_MISS_TTL_MS = 10 * 60 * 1000;
8
+ const MODELS_DEV_REQUEST_CONCURRENCY = 4;
9
+ const fileCache = new Map();
10
+ function modelsDevProviderDirs(providerID) {
11
+ const canonical = canonicalPricingProviderID(providerID);
12
+ if (canonical === "openai")
13
+ return ["openai"];
14
+ if (canonical === "anthropic")
15
+ return ["anthropic"];
16
+ if (canonical === "moonshotai")
17
+ return ["moonshotai", "kimi-for-coding"];
18
+ if (canonical === "minimax") {
19
+ return ["minimax-cn-coding-plan", "minimax-coding-plan", "minimax"];
20
+ }
21
+ if (canonical === "zhipu") {
22
+ return ["zai-coding-plan", "zai", "zhipuai-coding-plan", "zhipuai"];
23
+ }
24
+ return [];
25
+ }
26
+ function stripInlineComment(line) {
27
+ let inString = false;
28
+ let escaping = false;
29
+ let output = "";
30
+ for (const char of line) {
31
+ if (char === '"' && !escaping)
32
+ inString = !inString;
33
+ if (char === "#" && !inString)
34
+ break;
35
+ output += char;
36
+ if (escaping) {
37
+ escaping = false;
38
+ }
39
+ else if (char === "\\") {
40
+ escaping = true;
41
+ }
42
+ }
43
+ return output.trim();
44
+ }
45
+ function parseTomlNumber(raw) {
46
+ const parsed = Number(raw.replace(/_/g, "").trim());
47
+ return Number.isFinite(parsed) ? parsed : undefined;
48
+ }
49
+ function parseModelsDevCost(text) {
50
+ const rates = {};
51
+ const contextRates = {};
52
+ let section = "";
53
+ for (const rawLine of text.split(/\r?\n/)) {
54
+ const line = stripInlineComment(rawLine);
55
+ if (!line)
56
+ continue;
57
+ const sectionMatch = /^\[([^\]]+)\]$/.exec(line);
58
+ if (sectionMatch) {
59
+ section = sectionMatch[1] || "";
60
+ continue;
61
+ }
62
+ const kvMatch = /^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/.exec(line);
63
+ if (!kvMatch)
64
+ continue;
65
+ const key = kvMatch[1];
66
+ const value = parseTomlNumber(kvMatch[2] || "");
67
+ if (value === undefined)
68
+ continue;
69
+ if (section === "cost") {
70
+ rates[key] = value;
71
+ continue;
72
+ }
73
+ if (section === "cost.context_over_200k") {
74
+ contextRates[key] = value;
75
+ }
76
+ }
77
+ const hasBase = (rates.input || 0) > 0 ||
78
+ (rates.output || 0) > 0 ||
79
+ (rates.cache_read || 0) > 0 ||
80
+ (rates.cache_write || 0) > 0;
81
+ if (!hasBase)
82
+ return undefined;
83
+ const hasContext = (contextRates.input || 0) > 0 ||
84
+ (contextRates.output || 0) > 0 ||
85
+ (contextRates.cache_read || 0) > 0 ||
86
+ (contextRates.cache_write || 0) > 0;
87
+ return {
88
+ input: rates.input || 0,
89
+ output: rates.output || 0,
90
+ cache_read: rates.cache_read || 0,
91
+ cache_write: rates.cache_write || 0,
92
+ ...(hasContext
93
+ ? {
94
+ context_over_200k: {
95
+ input: contextRates.input || 0,
96
+ output: contextRates.output || 0,
97
+ cache_read: contextRates.cache_read || 0,
98
+ cache_write: contextRates.cache_write || 0,
99
+ },
100
+ }
101
+ : {}),
102
+ };
103
+ }
104
+ function modelsDevModelCandidates(model) {
105
+ const candidates = new Set();
106
+ for (const stem of [model.modelID, model.modelKey].filter((value) => Boolean(value))) {
107
+ for (const key of modelCostLookupKeys(model.providerID, stem)) {
108
+ const separator = key.indexOf(":");
109
+ const candidate = separator >= 0 ? key.slice(separator + 1) : key;
110
+ if (!candidate || candidate.includes("/"))
111
+ continue;
112
+ candidates.add(candidate);
113
+ }
114
+ }
115
+ return [...candidates];
116
+ }
117
+ async function fetchModelsDevCost(url) {
118
+ const cached = fileCache.get(url);
119
+ if (cached && cached.expiresAt > Date.now())
120
+ return cached.cost;
121
+ const controller = new AbortController();
122
+ const timeout = setTimeout(() => controller.abort(), MODELS_DEV_TIMEOUT_MS);
123
+ timeout.unref?.();
124
+ try {
125
+ const response = await fetch(url, { signal: controller.signal });
126
+ if (response.status === 404) {
127
+ fileCache.set(url, {
128
+ expiresAt: Date.now() + MODELS_DEV_NEGATIVE_TTL_MS,
129
+ cost: null,
130
+ });
131
+ return null;
132
+ }
133
+ if (!response.ok) {
134
+ debug(`models.dev fetch failed ${response.status} for ${url}`);
135
+ return undefined;
136
+ }
137
+ const parsed = parseModelsDevCost(await response.text());
138
+ fileCache.set(url, {
139
+ expiresAt: Date.now() +
140
+ (parsed ? MODELS_DEV_POSITIVE_TTL_MS : MODELS_DEV_PARSE_MISS_TTL_MS),
141
+ cost: parsed || null,
142
+ });
143
+ return parsed || null;
144
+ }
145
+ catch (error) {
146
+ debug(`models.dev fetch error for ${url}: ${String(error)}`);
147
+ return undefined;
148
+ }
149
+ finally {
150
+ clearTimeout(timeout);
151
+ }
152
+ }
153
+ export function modelsDevHasProvider(providerID) {
154
+ return modelsDevProviderDirs(providerID).length > 0;
155
+ }
156
+ export async function loadModelsDevPricingModels(requests) {
157
+ const resolved = new Map();
158
+ const entries = await mapConcurrent(requests, MODELS_DEV_REQUEST_CONCURRENCY, async (request) => {
159
+ const dirs = modelsDevProviderDirs(request.providerID);
160
+ if (dirs.length === 0)
161
+ return undefined;
162
+ const candidates = modelsDevModelCandidates(request);
163
+ if (candidates.length === 0)
164
+ return undefined;
165
+ let found = undefined;
166
+ for (const dir of dirs) {
167
+ for (const candidate of candidates) {
168
+ const url = `${MODELS_DEV_RAW_BASE_URL}/${dir}/models/${candidate}.toml`;
169
+ const cost = await fetchModelsDevCost(url);
170
+ if (cost === undefined || cost === null)
171
+ continue;
172
+ found = cost;
173
+ break;
174
+ }
175
+ if (found)
176
+ break;
177
+ }
178
+ if (!found)
179
+ return undefined;
180
+ return {
181
+ key: `${request.providerID}:${request.modelID}`,
182
+ model: {
183
+ providerKey: request.providerKey,
184
+ providerID: request.providerID,
185
+ modelID: request.modelID,
186
+ modelKey: request.modelKey,
187
+ cost: found,
188
+ options: request.options,
189
+ headers: request.headers,
190
+ api: request.api,
191
+ limit: request.limit,
192
+ },
193
+ };
194
+ });
195
+ for (const entry of entries) {
196
+ if (!entry)
197
+ continue;
198
+ resolved.set(entry.key, entry.model);
199
+ }
200
+ return [...resolved.values()];
201
+ }
202
+ export function clearModelsDevPricingCache() {
203
+ fileCache.clear();
204
+ }
205
+ export function modelsDevCostToRates(cost) {
206
+ if (!cost || typeof cost !== "object")
207
+ return undefined;
208
+ const record = cost;
209
+ const context = record.context_over_200k && typeof record.context_over_200k === "object"
210
+ ? record.context_over_200k
211
+ : undefined;
212
+ return {
213
+ input: Number(record.input || 0),
214
+ output: Number(record.output || 0),
215
+ cacheRead: Number(record.cache_read || 0),
216
+ cacheWrite: Number(record.cache_write || 0),
217
+ contextOver200k: context
218
+ ? {
219
+ input: Number(context.input || 0),
220
+ output: Number(context.output || 0),
221
+ cacheRead: Number(context.cache_read || 0),
222
+ cacheWrite: Number(context.cache_write || 0),
223
+ }
224
+ : undefined,
225
+ };
226
+ }
@@ -0,0 +1,14 @@
1
+ export type OpenCodePricingModel = {
2
+ providerKey?: string;
3
+ providerID: string;
4
+ modelID: string;
5
+ modelKey?: string;
6
+ cost?: unknown;
7
+ options?: Record<string, unknown>;
8
+ headers?: Record<string, unknown>;
9
+ api?: Record<string, unknown>;
10
+ limit?: Record<string, unknown>;
11
+ };
12
+ export declare function parseJsonc(text: string): unknown;
13
+ export declare function extractOpenCodePricingModels(config: unknown): OpenCodePricingModel[];
14
+ export declare function loadOpenCodePricingModels(paths: string[]): Promise<OpenCodePricingModel[]>;