@leo000001/opencode-quota-sidebar 4.0.9 → 4.0.11

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.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { type Hooks, type PluginInput } from '@opencode-ai/plugin';
1
+ import { type Hooks, type PluginInput } from "@opencode-ai/plugin";
2
2
  export declare function QuotaSidebarPlugin(input: PluginInput): Promise<Hooks>;
3
3
  export default QuotaSidebarPlugin;
4
- export type { QuotaSidebarConfig, QuotaSidebarState, QuotaSnapshot, QuotaStatus, SessionState, CachedSessionUsage, CachedProviderUsage, IncrementalCursor, } from './types.js';
5
- export type { UsageSummary } from './usage.js';
4
+ export type { QuotaSidebarConfig, QuotaSidebarState, QuotaSnapshot, QuotaStatus, SessionState, CachedSessionUsage, CachedProviderUsage, IncrementalCursor, } from "./types.js";
5
+ export type { UsageSummary } from "./usage.js";
package/dist/index.js CHANGED
@@ -1,19 +1,19 @@
1
- import { renderHistoryMarkdownReport, renderMarkdownReport, resolveTitleView, renderSidebarTitle, renderToastMessage, } from './format.js';
2
- import { createQuotaRuntime } from './quota.js';
3
- import { authFilePath, dateKeyFromTimestamp, deleteSessionFromDayChunk, evictOldSessions, loadConfig, loadState, normalizeTimestampMs, quotaConfigPaths, resolveOpencodeDataDir, saveState, stateFilePath, } from './storage.js';
4
- import { debug, swallow } from './helpers.js';
5
- import { normalizeBaseTitle } from './title.js';
6
- import { createDescendantsResolver } from './descendants.js';
7
- import { createTitleRefreshScheduler } from './title_refresh.js';
8
- import { createQuotaSidebarTools } from './tools.js';
9
- import { createEventDispatcher } from './events.js';
10
- import { createPersistenceScheduler } from './persistence.js';
11
- import { createQuotaService } from './quota_service.js';
12
- import { createUsageService } from './usage_service.js';
13
- import { createTitleApplicator } from './title_apply.js';
14
- import { listCurrentProviderIDs } from './provider_catalog.js';
15
- const SHUTDOWN_HOOK_KEY = Symbol.for('opencode-quota-sidebar.shutdown-hook');
16
- const SHUTDOWN_CALLBACKS_KEY = Symbol.for('opencode-quota-sidebar.shutdown-callbacks');
1
+ import { renderHistoryMarkdownReport, renderMarkdownReport, resolveTitleView, renderSidebarTitle, renderToastMessage, } from "./format.js";
2
+ import { createQuotaRuntime } from "./quota.js";
3
+ import { authFilePath, dateKeyFromTimestamp, deleteSessionFromDayChunk, evictOldSessions, loadConfig, loadState, normalizeTimestampMs, quotaConfigPaths, resolveOpencodeDataDir, saveState, stateFilePath, } from "./storage.js";
4
+ import { debug, swallow } from "./helpers.js";
5
+ import { normalizeBaseTitle } from "./title.js";
6
+ import { createDescendantsResolver } from "./descendants.js";
7
+ import { createTitleRefreshScheduler } from "./title_refresh.js";
8
+ import { createQuotaSidebarTools } from "./tools.js";
9
+ import { createEventDispatcher } from "./events.js";
10
+ import { createPersistenceScheduler } from "./persistence.js";
11
+ import { createQuotaService } from "./quota_service.js";
12
+ import { createUsageService } from "./usage_service.js";
13
+ import { createTitleApplicator } from "./title_apply.js";
14
+ import { listCurrentProviderIDs } from "./provider_catalog.js";
15
+ const SHUTDOWN_HOOK_KEY = Symbol.for("opencode-quota-sidebar.shutdown-hook");
16
+ const SHUTDOWN_CALLBACKS_KEY = Symbol.for("opencode-quota-sidebar.shutdown-callbacks");
17
17
  const SESSION_ACTIVE_GRACE_MS = 15_000;
18
18
  export async function QuotaSidebarPlugin(input) {
19
19
  const quotaRuntime = createQuotaRuntime();
@@ -95,7 +95,7 @@ export async function QuotaSidebarPlugin(input) {
95
95
  query: { directory: input.directory },
96
96
  throwOnError: true,
97
97
  })
98
- .catch(swallow('listSessionChildren'));
98
+ .catch(swallow("listSessionChildren"));
99
99
  return response?.data ?? [];
100
100
  },
101
101
  getParentID: (sessionID) => state.sessions[sessionID]?.parentID,
@@ -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,
@@ -191,7 +192,7 @@ export async function QuotaSidebarPlugin(input) {
191
192
  return;
192
193
  await titleApplicator.applyTitle(sessionID);
193
194
  },
194
- onError: swallow('titleRefresh'),
195
+ onError: swallow("titleRefresh"),
195
196
  });
196
197
  scheduleTitleRefresh = titleRefresh.schedule;
197
198
  const startupTitleWork = Promise.resolve();
@@ -199,11 +200,11 @@ export async function QuotaSidebarPlugin(input) {
199
200
  await Promise.race([
200
201
  startupTitleWork,
201
202
  new Promise((resolve) => setTimeout(resolve, 5_000)),
202
- ]).catch(swallow('shutdown:startupTitleWork'));
203
+ ]).catch(swallow("shutdown:startupTitleWork"));
203
204
  await titleRefresh
204
205
  .waitForQuiescence()
205
- .catch(swallow('shutdown:titleQuiescence'));
206
- await flushSave().catch(swallow('shutdown:flushSave'));
206
+ .catch(swallow("shutdown:titleQuiescence"));
207
+ await flushSave().catch(swallow("shutdown:flushSave"));
207
208
  };
208
209
  const processWithHook = process;
209
210
  const shutdownCallbacks = (processWithHook[SHUTDOWN_CALLBACKS_KEY] ||=
@@ -211,10 +212,10 @@ export async function QuotaSidebarPlugin(input) {
211
212
  shutdownCallbacks.add(shutdown);
212
213
  if (!processWithHook[SHUTDOWN_HOOK_KEY]) {
213
214
  processWithHook[SHUTDOWN_HOOK_KEY] = true;
214
- process.once('beforeExit', () => {
215
+ process.once("beforeExit", () => {
215
216
  void Promise.allSettled(Array.from(shutdownCallbacks).map((callback) => callback()));
216
217
  });
217
- for (const signal of ['SIGINT', 'SIGTERM']) {
218
+ for (const signal of ["SIGINT", "SIGTERM"]) {
218
219
  process.once(signal, () => {
219
220
  void Promise.allSettled(Array.from(shutdownCallbacks).map((callback) => callback())).finally(() => {
220
221
  process.kill(process.pid, signal);
@@ -231,12 +232,12 @@ export async function QuotaSidebarPlugin(input) {
231
232
  body: {
232
233
  title: `Quota ${period}`,
233
234
  message,
234
- variant: 'info',
235
+ variant: "info",
235
236
  duration: config.toast.durationMs,
236
237
  },
237
238
  throwOnError: true,
238
239
  })
239
- .catch(swallow('showToast'));
240
+ .catch(swallow("showToast"));
240
241
  };
241
242
  const expiryAlertText = (iso, nowMs = Date.now()) => {
242
243
  if (!iso)
@@ -250,7 +251,7 @@ export async function QuotaSidebarPlugin(input) {
250
251
  return undefined;
251
252
  const value = new Date(timestamp);
252
253
  const now = new Date(nowMs);
253
- const two = (num) => `${num}`.padStart(2, '0');
254
+ const two = (num) => `${num}`.padStart(2, "0");
254
255
  const hhmm = `${two(value.getHours())}:${two(value.getMinutes())}`;
255
256
  const sameDay = value.getFullYear() === now.getFullYear() &&
256
257
  value.getMonth() === now.getMonth() &&
@@ -272,7 +273,7 @@ export async function QuotaSidebarPlugin(input) {
272
273
  const quotas = await getQuotaSnapshots([], { allowDefault: true });
273
274
  const nowMs = Date.now();
274
275
  const expiryLines = quotas
275
- .filter((item) => item.status === 'ok')
276
+ .filter((item) => item.status === "ok")
276
277
  .map((item) => ({
277
278
  label: item.shortLabel || item.label,
278
279
  value: expiryAlertText(item.expiresAt, nowMs),
@@ -287,10 +288,10 @@ export async function QuotaSidebarPlugin(input) {
287
288
  markDirty(dateKey);
288
289
  scheduleSave();
289
290
  const body = [
290
- 'Expiry Soon',
291
+ "Expiry Soon",
291
292
  ...expiryLines.map((item) => `${item.label} ${item.value}`),
292
- ].join('\n');
293
- await showToast('session', body);
293
+ ].join("\n");
294
+ await showToast("session", body);
294
295
  }
295
296
  catch (error) {
296
297
  debug(`expiry toast check failed: ${String(error)}`);
@@ -328,7 +329,7 @@ export async function QuotaSidebarPlugin(input) {
328
329
  });
329
330
  },
330
331
  onSessionDeleted: async (session) => {
331
- await flushSave().catch(swallow('onSessionDeleted:flushSave'));
332
+ await flushSave().catch(swallow("onSessionDeleted:flushSave"));
332
333
  descendantsResolver.invalidateForAncestors(session.parentID);
333
334
  descendantsResolver.invalidateForAncestors(session.id);
334
335
  usageService.forgetSession(session.id);
@@ -342,7 +343,7 @@ export async function QuotaSidebarPlugin(input) {
342
343
  delete state.sessionDateMap[session.id];
343
344
  markDirty(dateKey);
344
345
  scheduleSave();
345
- const deletedFromChunk = await deleteSessionFromDayChunk(statePath, session.id, dateKey).catch(swallow('deleteSessionFromDayChunk'));
346
+ const deletedFromChunk = await deleteSessionFromDayChunk(statePath, session.id, dateKey).catch(swallow("deleteSessionFromDayChunk"));
346
347
  if (deletedFromChunk) {
347
348
  delete state.deletedSessionDateMap[session.id];
348
349
  scheduleSave();
@@ -371,9 +372,9 @@ export async function QuotaSidebarPlugin(input) {
371
372
  onAssistantMessageUpdated: async (message) => {
372
373
  const now = Date.now();
373
374
  const completed = message.time.completed;
374
- if (typeof completed !== 'number' || !Number.isFinite(completed)) {
375
+ if (typeof completed !== "number" || !Number.isFinite(completed)) {
375
376
  const created = message.time.created;
376
- if (typeof created === 'number' &&
377
+ if (typeof created === "number" &&
377
378
  Number.isFinite(created) &&
378
379
  created < now - SESSION_ACTIVE_GRACE_MS) {
379
380
  return;
@@ -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[]>;