diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 9ff9cb9369462..2a3efbd2eea46 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -187,6 +187,7 @@ export class UsageAggregator { readonly day: string; readonly hourIndex: number; readonly provider: string; + readonly upstreamProvider: string; readonly model: string; readonly source: string; readonly bucket: MutableBucket; @@ -255,7 +256,14 @@ export class UsageAggregator { this.#hourlyWindow === null ? -1 : Math.floor((record.timestampMs - this.#hourlyWindow.sinceTimeMs) / HOUR_MS); - return this.#bucketFor(day, hourIndex, record.provider, record.model, source); + return this.#bucketFor( + day, + hourIndex, + record.provider, + record.upstreamProvider ?? "", + record.model, + source, + ); } #accumulate(bucket: MutableBucket, record: UsageRecord): void { @@ -314,6 +322,7 @@ export class UsageAggregator { day: string, hourIndex: number, provider: string, + upstreamProvider: string, model: string, source: string, ): MutableBucket { @@ -323,6 +332,7 @@ export class UsageAggregator { last.day === day && last.hourIndex === hourIndex && last.provider === provider && + last.upstreamProvider === upstreamProvider && last.model === model && last.source === source ) { @@ -331,7 +341,7 @@ export class UsageAggregator { const window = this.#hourlyWindow; const hourStart = window === null ? "" : new Date(window.sinceTimeMs + hourIndex * HOUR_MS).toISOString(); - const key = `${day}\u0000${hourStart}\u0000${provider}\u0000${model}\u0000${source}`; + const key = `${day}\u0000${hourStart}\u0000${provider}\u0000${upstreamProvider}\u0000${model}\u0000${source}`; let bucket = this.#buckets.get(key); if (bucket === undefined) { bucket = { @@ -350,7 +360,7 @@ export class UsageAggregator { }; this.#buckets.set(key, bucket); } - this.#lastBucket = { day, hourIndex, provider, model, source, bucket }; + this.#lastBucket = { day, hourIndex, provider, upstreamProvider, model, source, bucket }; return bucket; } @@ -361,8 +371,14 @@ export class UsageAggregator { this.#seen.clear(); const buckets: UsageBucket[] = []; for (const [key, bucket] of this.#buckets) { - const [day = "", hourStart = "", provider = "", model = "", sourcePath = ""] = - key.split("\u0000"); + const [ + day = "", + hourStart = "", + provider = "", + upstreamProvider = "", + model = "", + sourcePath = "", + ] = key.split("\u0000"); const category = bucket.categoryCostUsd; const fastCostUsd = roundUsd(bucket.fastCostUsd); const ultrafastCostUsd = roundUsd(bucket.ultrafastCostUsd); @@ -372,6 +388,7 @@ export class UsageAggregator { ...(hourStart === "" ? {} : { hourStart }), provider: provider as UsageBucket["provider"], model, + ...(upstreamProvider === "" ? {} : { upstreamProvider }), ...(sourcePath === "" ? {} : { sourcePath }), totals: { ...bucket.totals }, costUsd: bucket.costUsd, diff --git a/apps/web/src/components/usage/UsageModelDialog.tsx b/apps/web/src/components/usage/UsageModelDialog.tsx index 0fb6b89ce73a4..96677d8006577 100644 --- a/apps/web/src/components/usage/UsageModelDialog.tsx +++ b/apps/web/src/components/usage/UsageModelDialog.tsx @@ -50,7 +50,10 @@ export function UsageModelDialog({ }) { const usage = mergeAnsweredUsage( environments, - (bucket) => bucket.provider === model.provider && bucket.model === model.model, + (bucket) => + bucket.provider === model.provider && + bucket.model === model.model && + bucket.upstreamProvider === model.upstreamProvider, ); const providers = [model.provider]; const presentation = PROVIDER_PRESENTATION[model.provider]; @@ -83,6 +86,7 @@ export function UsageModelDialog({ {presentation.label} + {model.upstreamProvider ? ` · ${model.upstreamProvider}` : ""} {costUnknown ? "" : ` · ${formatPercent(model.costShare)} of cost`} diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 8bc8a93a43d69..2162635432b3b 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -159,7 +159,11 @@ function UsageModelDialogHost({ const model = selectedKey === null ? undefined - : models.find((candidate) => `${candidate.provider}:${candidate.model}` === selectedKey); + : models.find( + (candidate) => + `${candidate.provider}:${candidate.upstreamProvider ?? ""}:${candidate.model}` === + selectedKey, + ); if (model === undefined) return null; return ( ) : ( breakdownModels.map((model, index) => { - const key = `${model.provider}:${model.model}`; + const key = `${model.provider}:${model.upstreamProvider ?? ""}:${model.model}`; const value = metric === "tokens" ? model.totalTokens : model.costUsd; const hasBar = value > 0 && breakdownPeak > 0; const share = modelShare(model, metric === "tokens" ? "tokens" : "cost"); @@ -909,6 +913,11 @@ function UsageOverview({ > {model.model} + {model.upstreamProvider ? ( + + {model.upstreamProvider} + + ) : null} {hasBar ? (
diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index bf5de2e330a3f..2da774c4cb129 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -98,14 +98,11 @@ export function mergeAnsweredUsage( { environmentId, label, - summary: - keepBucket === undefined - ? summary - : { ...summary, buckets: summary.buckets.filter(keepBucket) }, + summary, }, ], ); - return mergeUsage(answered, USAGE_CONTRACT_VERSION); + return mergeUsage(answered, USAGE_CONTRACT_VERSION, keepBucket); } const NO_HIDDEN_PROVIDERS: ReadonlySet = new Set(); diff --git a/docs/user/usage.md b/docs/user/usage.md index e6b3a46d5a14e..6512ec0772542 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -14,6 +14,8 @@ cost, split by token type and by speed. These estimates are not your subscriptio **Premium** is what Fast and Ultrafast requests cost above standard rates. Cost that cannot be split, such as a provider-reported cost for a model without public rates, shows as **Other**. Select a model under **Breakdown** to see its trend, cache hit rate, and cost per million tokens. +An OpenCode model shows the upstream that served it, so the same model billed by OpenCode Go and +by another provider is listed separately. Totals depend on the history available on each server. Grok turns without a saved completed-turn record are missing from the totals. @@ -155,7 +157,12 @@ the environment. T3 cannot report limits for external OpenCode servers because t belong to the remote server. Limits need an OpenCode Go API key, or an OpenCode Console connected in OpenCode with an API key. A Console browser sign-in alone does not report them. You can also add your Go API key as `OPENCODE_API_KEY` in the OpenCode instance's **Environment variables**, then -refresh provider status. Cursor reports +refresh provider status. +An OpenCode account on a MiniMax Token Plan reports that plan's interval and weekly allowance +next to the Go budget. It needs its own key: sign in to OpenCode with the MiniMax Token Plan and +store the plan key there. T3 reads the plan's quota only when OpenCode resolves that provider to +MiniMax's own endpoint, so a provider pointed at another endpoint reports no plan limits. +Cursor reports its monthly allowance, including separate Auto and API usage, for `CURSOR_AUTH_TOKEN` when it is set, otherwise for the account you signed in with in T3 or set as `CURSOR_API_KEY`. Sign-in and API keys report limits only with Cursor's default API endpoint. If you configure a custom diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 851de4c72ffe3..e99c35733fd05 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -17,6 +17,8 @@ export const ServerProviderUsageWindow = Schema.Struct({ /** When present, this limit applies only to these provider model IDs. */ modelIds: Schema.optional(Schema.Array(TrimmedNonEmptyString)), providerIds: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + /** The credential that meters this window; a pool counts one credential once. */ + credentialFingerprint: Schema.optional(TrimmedNonEmptyString), usedPercent: Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 100 })), resetsAt: Schema.optional(IsoDateTime), windowDurationMins: Schema.optional(NonNegativeInt), diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 76dd69ff91699..ae6ce0b42decb 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -3,7 +3,7 @@ import * as Schema from "effect/Schema"; import { ForwardCompatibleArray, NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; /** Bump for incompatible UsageSummary changes; skipped unknown array variants need no bump. */ -export const USAGE_CONTRACT_VERSION = 6 as const; +export const USAGE_CONTRACT_VERSION = 7 as const; export const USAGE_MERGE_COMPATIBLE_SINCE = 4 as const; @@ -55,6 +55,8 @@ export const UsageBucket = Schema.Struct({ hourStart: Schema.optional(TrimmedNonEmptyString), provider: UsageProviderKind, model: TrimmedNonEmptyString, + /** The upstream integration that served the model, when the harness knows it. */ + upstreamProvider: Schema.optional(TrimmedNonEmptyString), sourcePath: Schema.optional(TrimmedNonEmptyString), totals: UsageTokenTotals, costUsd: Schema.Number, diff --git a/packages/provider-core/src/server/usage.ts b/packages/provider-core/src/server/usage.ts index c2c75a641e53a..81351a8d3e22b 100644 --- a/packages/provider-core/src/server/usage.ts +++ b/packages/provider-core/src/server/usage.ts @@ -13,6 +13,13 @@ export type UsageSpeed = "standard" | "fast" | "ultrafast"; export interface UsageRecord { readonly provider: UsageProviderKind; + /** + * The upstream integration that served the model, when the harness reports + * one. A provider harness can front several upstreams (OpenCode fronts + * OpenCode Go, Zen, MiniMax and others), and only this names which one a + * record belongs to. + */ + readonly upstreamProvider?: string; readonly timestampMs: number; readonly model: string; readonly rateModel?: string; diff --git a/packages/provider-opencode/src/server/driver.test.ts b/packages/provider-opencode/src/server/driver.test.ts index b3c628e28fc63..e02d13c49e9bc 100644 --- a/packages/provider-opencode/src/server/driver.test.ts +++ b/packages/provider-opencode/src/server/driver.test.ts @@ -88,14 +88,42 @@ const layer = Layer.mergeAll( Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, openCode2Runtime), ).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); -const create = (config: Partial, http: HttpClient.HttpClient, enabled = true) => - OpenCodeDriver.create({ - instanceId: ProviderInstanceId.make("opencode-test"), - displayName: undefined, - environment: [], - enabled, - config: { ...OpenCodeDriver.defaultConfig(), ...config }, - }).pipe(Effect.provideService(HttpClient.HttpClient, http)); +/** + * Keeps the provider from reading the developer's own OpenCode store: the + * quota probe would send those credentials to the provider's API. + */ +const sandboxStore = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-opencode-driver-home-" }); + return [ + { name: "HOME", value: home, sensitive: false }, + { name: "XDG_DATA_HOME", value: home, sensitive: false }, + ]; +}); + +const create = ( + config: Partial, + http: HttpClient.HttpClient, + options: { + readonly enabled?: boolean; + readonly environment?: NodeJS.ProcessEnv; + } = {}, +) => + Effect.gen(function* () { + const sandbox = yield* sandboxStore; + return yield* OpenCodeDriver.create({ + instanceId: ProviderInstanceId.make("opencode-test"), + displayName: undefined, + environment: [ + ...sandbox, + ...Object.entries(options.environment ?? {}).flatMap(([name, value]) => + value === undefined ? [] : [{ name, value, sensitive: false }], + ), + ], + enabled: options.enabled ?? true, + config: { ...OpenCodeDriver.defaultConfig(), ...config }, + }).pipe(Effect.provideService(HttpClient.HttpClient, http)); + }); const noHttp = HttpClient.make(() => Effect.die("A local binary must not be probed over HTTP")); @@ -219,7 +247,7 @@ it.live("discovers models through a home-relative OpenCode wrapper", () => const instance = yield* OpenCodeDriver.create({ instanceId: ProviderInstanceId.make("opencode-home"), displayName: undefined, - environment: [], + environment: yield* sandboxStore, enabled: true, config: { ...OpenCodeDriver.defaultConfig(), @@ -326,9 +354,9 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { return { stdout: "opencode v2.1.2\n", stderr: "", code: 0 }; }), } as OpenCodeRuntime.OpenCodeRuntime["Service"]; - const instance = yield* create({ serverUrl: "http://127.0.0.1:4096" }, noHttp, false).pipe( - Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime), - ); + const instance = yield* create({ serverUrl: "http://127.0.0.1:4096" }, noHttp, { + enabled: false, + }).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime)); const snapshot = yield* instance.snapshot.refresh; assert.isFalse(snapshot.enabled); @@ -970,6 +998,7 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { instanceId: ProviderInstanceId.make("opencode-usage-recovery"), displayName: undefined, environment: [ + ...(yield* sandboxStore), { name: "OPENCODE_AUTH_CONTENT", sensitive: true, @@ -1244,7 +1273,7 @@ it.layer(layerUpdate)("OpenCodeDriver updates", (it) => { const instance = yield* OpenCodeDriver.create({ instanceId: ProviderInstanceId.make("opencode-update"), displayName: undefined, - environment: [], + environment: yield* sandboxStore, enabled: true, config: { ...OpenCodeDriver.defaultConfig(), binaryPath }, }).pipe(Effect.provideService(HttpClient.HttpClient, noHttp)); diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 9b39fed2546b5..e1807a90922c1 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -33,7 +33,11 @@ import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAda import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; import { openCodeUsageReader, type OpenCodeUsageReaderEnv } from "./usage.ts"; -import { readOpenCodeGoUsageLimits } from "./usageLimits.ts"; +import { + mergeUsageLimits, + readOpenCodeGoUsageLimits, + readOpenCodeIntegrationUsageLimits, +} from "./usageLimits.ts"; import { loadOpenCode2Catalog, loadOpenCode2DirectoryCatalog } from "./openCode2Catalog.ts"; import { checkOpenCodeProviderStatus, @@ -58,6 +62,7 @@ import { } from "./versionProbe.ts"; import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; import * as OpenCode2Client from "./v2/OpenCode2Client.ts"; +import { readProviderEndpoints } from "./v2/providerEndpoints.ts"; import * as OpenCode2Server from "./v2/OpenCode2Server.ts"; import { replaceOutdatedService } from "./v2/OpenCode2Service.ts"; import { @@ -474,7 +479,7 @@ export const OpenCodeDriver: ProviderDriver< Effect.provideService(Digest.Digest, digest), ); let unsupportedUsageVersion: string | undefined; - const checkUsageLimits = Effect.gen(function* () { + const checkConsoleUsageLimits = Effect.gen(function* () { if (!effectiveConfig.enabled) return yield* legacyUsageLimits; const runtime = yield* runtimeProbe.get; if (runtime.generation === "v1" || runtime.version === unsupportedUsageVersion) @@ -497,6 +502,36 @@ export const OpenCodeDriver: ProviderDriver< })), ), ); + // A custom provider meters its own plan, so its quota has to be read from + // that provider rather than from the OpenCode Console budget. The endpoint + // OpenCode resolves for a provider says whose key a credential is, so it + // is read only for a provider that has a credential to check. + const checkIntegrationUsageLimits = readOpenCodeIntegrationUsageLimits({ + enabled: effectiveConfig.enabled, + serverUrl: effectiveConfig.serverUrl, + environment: processEnv, + resolveEndpoint: (integrationId) => + openCode2Server + .withConnection((connection) => + readProviderEndpoints(connection, host.paths.cwd).pipe( + Effect.map((endpoints) => endpoints.get(integrationId)), + ), + ) + .pipe(Effect.catchCause(() => Effect.undefined)), + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, pathService), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(Digest.Digest, digest), + ); + // One probe for the provider's limits, so the recovery that retries a + // failed read retries the Console budget and the integration quotas alike. + const checkUsageLimits = Effect.zipWith( + checkConsoleUsageLimits, + checkIntegrationUsageLimits, + (usageLimits, integration) => mergeUsageLimits(usageLimits, integration), + { concurrent: true }, + ); const checkProvider = Effect.all( { // A configured loopback server reports its running version. Probe the local diff --git a/packages/provider-opencode/src/server/usage.test.ts b/packages/provider-opencode/src/server/usage.test.ts index d211563738ad5..537cffed77ed8 100644 --- a/packages/provider-opencode/src/server/usage.test.ts +++ b/packages/provider-opencode/src/server/usage.test.ts @@ -415,6 +415,44 @@ describe("readOpenCodeUsage", () => { }); import * as NodeSqlite from "node:sqlite"; +describe("readOpenCodeUsage upstream provider", () => { + it.effect("keeps the integration that served the model", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "usage-upstream-test-" }); + const db = yield* openDatabase(path.join(dir, "opencode.db")); + yield* db.unsafe( + "CREATE TABLE message (id TEXT, session_id TEXT, data TEXT, time_created INTEGER)", + ); + const sinceMs = 1780000000000; + const insert = (id: string, providerID: string) => + db.unsafe("INSERT INTO message (id, session_id, data, time_created) VALUES (?, ?, ?, ?)", [ + id, + "session-1", + JSON.stringify({ + role: "assistant", + model: { id: "MiniMax-M3.1-Flash", providerID }, + time: { created: sinceMs }, + tokens: { input: 10, output: 5 }, + }), + sinceMs, + ]); + yield* insert("msg-plan", "minimax-coding-plan"); + yield* insert("msg-go", "opencode-go"); + + const result = yield* readOpenCodeUsage(dir, sinceMs); + const records = result.files.flatMap((file) => file.records); + assert.deepStrictEqual(records.map((record) => record.upstreamProvider).sort(), [ + "minimax-coding-plan", + "opencode-go", + ]); + // The harness stays the provider; only the upstream integration is added. + assert.isTrue(records.every((record) => record.provider === "opencode")); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); + describe("openCodeUsageReader", () => { it.effect("scans canonical instance roots once, including XDG and home defaults", () => Effect.gen(function* () { diff --git a/packages/provider-opencode/src/server/usage.ts b/packages/provider-opencode/src/server/usage.ts index 4ae7b6df358e9..e86dec8fff7ae 100644 --- a/packages/provider-opencode/src/server/usage.ts +++ b/packages/provider-opencode/src/server/usage.ts @@ -54,6 +54,10 @@ function parseOpenCodeMessage( const modelReference = object(message.model); const model = text(modelReference.id) || text(modelReference.modelID) || text(message.modelID); const providerId = text(modelReference.providerID) || text(message.providerID); + // OpenCode fronts several upstreams (OpenCode Go, Zen, MiniMax, ...) behind + // one harness, and the integration id is the only thing that says which one + // served the model. Keep it so usage is not attributed to the wrong account. + const upstreamProvider = providerId || undefined; const timestampMs = object(message.time).created ?? fallback.timestampMs; if (!model || typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return null; const reasoningTokens = tokens(usage.reasoning); @@ -71,6 +75,7 @@ function parseOpenCodeMessage( const free = OPENCODE_GATEWAYS.has(providerId) && model.endsWith("-free"); return { provider: "opencode", + ...(upstreamProvider ? { upstreamProvider } : {}), timestampMs, model, ...(prefix !== undefined && !model.includes("/") ? { rateModel: `${prefix}/${model}` } : {}), diff --git a/packages/provider-opencode/src/server/usageLimits.test.ts b/packages/provider-opencode/src/server/usageLimits.test.ts new file mode 100644 index 0000000000000..74d1264cd8262 --- /dev/null +++ b/packages/provider-opencode/src/server/usageLimits.test.ts @@ -0,0 +1,305 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as FileSystem from "effect/FileSystem"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as SqlClient from "effect/sql/SqlClient"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; + +import { mergeUsageLimits, readOpenCodeIntegrationUsageLimits } from "./usageLimits.ts"; + +const openDatabase = (filename: string) => + Layer.build(NodeSqliteClient.layer({ filename })).pipe( + Effect.map(Context.get(SqlClient.SqlClient)), + ); + +const MINIMAX_ENDPOINT = "https://api.minimax.io/anthropic/v1"; + +const MINIMAX_RESPONSE = { + base_resp: { status_code: 0, status_msg: "success" }, + model_remains: [ + { + model_name: "general", + current_interval_remaining_percent: 85, + current_interval_status: 1, + end_time: 1794328907302, + current_weekly_remaining_percent: 91, + current_weekly_status: 1, + weekly_end_time: 1794905017270, + }, + { + model_name: "video", + current_interval_remaining_percent: 0, + current_interval_status: 2, + current_weekly_remaining_percent: 0, + current_weekly_status: 2, + }, + ], +}; + +/** A credential store with one active key for the given integration. */ +const storeWithCredential = ( + integrationId: string, + key: string, + metadata?: Record, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "opencode-limits-test-" }); + const dataHome = path.join(dir, ".local", "share"); + yield* fileSystem.makeDirectory(path.join(dataHome, "opencode"), { recursive: true }); + const db = yield* openDatabase(path.join(dataHome, "opencode", "opencode.db")); + yield* db.unsafe( + "CREATE TABLE credential (id TEXT, integration_id TEXT, value TEXT, active INTEGER, time_created INTEGER)", + ); + yield* db.unsafe( + "INSERT INTO credential (id, integration_id, value, active, time_created) VALUES (?, ?, ?, 1, 0)", + [ + "cred-1", + integrationId, + JSON.stringify({ type: "key", key, ...(metadata ? { metadata } : {}) }), + ], + ); + return { dir, dataHome }; + }); + +const readLimits = ( + dataHome: string, + dir: string, + client: HttpClient.HttpClient, + endpoint: string | undefined, +) => + readOpenCodeIntegrationUsageLimits({ + enabled: true, + serverUrl: "", + environment: { + HOME: dir, + XDG_DATA_HOME: dataHome, + }, + resolveEndpoint: () => Effect.succeed(endpoint), + }).pipe(Effect.provideService(HttpClient.HttpClient, client)); + +describe("readOpenCodeIntegrationUsageLimits", () => { + it.effect("reads an integration's own quota instead of the OpenCode Console budget", () => + Effect.gen(function* () { + const { dir, dataHome } = yield* storeWithCredential("minimax-coding-plan", "sk-cp-plan-key"); + + const client = HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(MINIMAX_RESPONSE))), + ); + const result = yield* readLimits(dataHome, dir, client, MINIMAX_ENDPOINT); + + assert.strictEqual(result.windows.length, 2); + const [interval, weekly] = result.windows; + assert.strictEqual(interval?.id, "minimax-coding-plan_interval"); + assert.strictEqual(interval?.label, "MiniMax Token Plan · Interval"); + assert.strictEqual(interval?.usedPercent, 15); + assert.strictEqual(weekly?.id, "minimax-coding-plan_weekly"); + assert.strictEqual(weekly?.usedPercent, 9); + assert.isDefined(interval?.resetsAt); + assert.isDefined(result.credentialFingerprint); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + ); + + it.effect("reports only the bucket that meters the plan", () => + Effect.gen(function* () { + const { dir, dataHome } = yield* storeWithCredential("minimax-coding-plan", "sk-cp-plan-key"); + + const client = HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(MINIMAX_RESPONSE))), + ); + const result = yield* readLimits(dataHome, dir, client, MINIMAX_ENDPOINT); + + // The video bucket is exhausted; reporting it would exhaust the plan too. + assert.isTrue(result.windows.every((window) => window.usedPercent < 100)); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + ); + + it.effect("fingerprints a plan by its own credential, not by the account", () => + Effect.gen(function* () { + const { dir, dataHome } = yield* storeWithCredential("minimax-coding-plan", "sk-cp-plan-key"); + const client = HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(MINIMAX_RESPONSE))), + ); + const endpoints = MINIMAX_ENDPOINT; + const alone = yield* readLimits(dataHome, dir, client, endpoints); + const plan = alone.windows.find((window) => window.id === "minimax-coding-plan_weekly"); + assert.isDefined(plan?.credentialFingerprint); + + // The same plan next to another credential keeps its own fingerprint. + const withBoth = yield* Effect.gen(function* () { + const path = yield* Path.Path; + const db = yield* openDatabase(path.join(dataHome, "opencode", "opencode.db")); + yield* db.unsafe( + "INSERT INTO credential (id, integration_id, value, active, time_created) VALUES (?, ?, ?, 1, 0)", + ["cred-2", "minimax", JSON.stringify({ type: "key", key: "sk-cp-other-key" })], + ); + return yield* readLimits(dataHome, dir, client, endpoints); + }); + const samePlan = withBoth.windows.find( + (window) => window.id === "minimax-coding-plan_weekly", + ); + assert.strictEqual(samePlan?.credentialFingerprint, plan?.credentialFingerprint); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + ); + + it.effect("never sends a key that MiniMax did not issue", () => + Effect.gen(function* () { + const { dir, dataHome } = yield* storeWithCredential("minimax-coding-plan", "sk-third-party"); + + const client = HttpClient.make((request) => + Effect.die(`The key left the machine: ${request.url}`), + ); + const result = yield* readLimits(dataHome, dir, client, MINIMAX_ENDPOINT); + + assert.deepStrictEqual(result.windows, []); + assert.isUndefined(result.credentialFingerprint); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + ); + + it.effect("never sends a key that the provider points at another endpoint", () => + Effect.gen(function* () { + const { dir, dataHome } = yield* storeWithCredential("minimax-coding-plan", "sk-cp-plan-key"); + + const client = HttpClient.make((request) => + Effect.die(`The key left the machine: ${request.url}`), + ); + const result = yield* readLimits(dataHome, dir, client, "https://gateway.example/v1"); + + assert.deepStrictEqual(result.windows, []); + assert.isUndefined(result.credentialFingerprint); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + ); + + it.effect("never sends a key whose owner is unknown", () => + Effect.gen(function* () { + const { dir, dataHome } = yield* storeWithCredential("minimax-coding-plan", "sk-cp-plan-key"); + + const client = HttpClient.make((request) => + Effect.die(`The key left the machine: ${request.url}`), + ); + const result = yield* readLimits(dataHome, dir, client, undefined); + + assert.deepStrictEqual(result.windows, []); + assert.isUndefined(result.credentialFingerprint); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + ); + + it.effect("never sends a key that names another endpoint itself", () => + Effect.gen(function* () { + const { dir, dataHome } = yield* storeWithCredential( + "minimax-coding-plan", + "sk-cp-plan-key", + { + baseURL: "https://gateway.example/v1", + }, + ); + + const client = HttpClient.make((request) => + Effect.die(`The key left the machine: ${request.url}`), + ); + const result = yield* readLimits(dataHome, dir, client, MINIMAX_ENDPOINT); + + assert.deepStrictEqual(result.windows, []); + assert.isUndefined(result.credentialFingerprint); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + ); +}); + +describe("mergeUsageLimits", () => { + it("keeps the integration windows and drops the unavailable marker", () => { + const merged = mergeUsageLimits( + { + checkedAt: "2026-11-10T00:00:00.000Z", + windows: [], + credentialFingerprint: "go-account", + unavailable: { reason: "unsupported" }, + }, + { + checkedAt: "2026-11-10T00:00:00.000Z", + windows: [ + { + id: "minimax-coding-plan_weekly", + kind: "weekly", + label: "MiniMax Token Plan · Weekly", + usedPercent: 9, + }, + ], + }, + ); + assert.strictEqual(merged.windows.length, 1); + assert.strictEqual(merged.credentialFingerprint, "go-account"); + assert.isUndefined(merged.unavailable); + }); + + it("keeps the Console budget untouched when no integration reports a quota", () => { + const console = { + checkedAt: "2026-11-10T00:00:00.000Z", + windows: [], + unavailable: { reason: "unsupported" as const }, + }; + assert.deepStrictEqual( + mergeUsageLimits(console, { checkedAt: console.checkedAt, windows: [] }), + console, + ); + }); + + it("tells two accounts apart when only the integration key differs", () => { + const console = { + checkedAt: "2026-11-10T00:00:00.000Z", + windows: [], + credentialFingerprint: "go-account", + }; + const merged = mergeUsageLimits(console, { + checkedAt: console.checkedAt, + windows: [ + { + id: "minimax-coding-plan_weekly", + kind: "weekly", + label: "MiniMax Token Plan · Weekly", + usedPercent: 9, + }, + ], + credentialFingerprint: "minimax-account", + }); + assert.strictEqual(merged.credentialFingerprint, "go-account.minimax-account"); + }); + + it("names the credential that meters each window", () => { + const merged = mergeUsageLimits( + { + checkedAt: "2026-11-10T00:00:00.000Z", + windows: [ + { + id: "go_weekly", + kind: "weekly", + label: "Go · Weekly", + usedPercent: 20, + }, + ], + credentialFingerprint: "go-account", + }, + { + checkedAt: "2026-11-10T00:00:00.000Z", + windows: [ + { + id: "minimax-coding-plan_weekly", + kind: "weekly", + label: "MiniMax Token Plan · Weekly", + usedPercent: 9, + credentialFingerprint: "plan-account", + }, + ], + credentialFingerprint: "minimax-account", + }, + ); + assert.strictEqual(merged.windows[0]?.credentialFingerprint, "go-account"); + // The integration window keeps the credential that meters it. + assert.strictEqual(merged.windows[1]?.credentialFingerprint, "plan-account"); + }); +}); diff --git a/packages/provider-opencode/src/server/usageLimits.ts b/packages/provider-opencode/src/server/usageLimits.ts index 03488e515a79f..c81dbb1d2f6ed 100644 --- a/packages/provider-opencode/src/server/usageLimits.ts +++ b/packages/provider-opencode/src/server/usageLimits.ts @@ -1,6 +1,6 @@ import * as NodeSqlite from "node:sqlite"; -import type { ServerProviderUsageWindow } from "@t3tools/contracts"; +import type { ServerProviderUsageLimits, ServerProviderUsageWindow } from "@t3tools/contracts"; import * as Digest from "effect/crypto/Digest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -29,8 +29,14 @@ const StoredKey = Schema.Struct({ type: Schema.Literal("key"), key: Schema.String, metadata: Schema.optionalKey( - Schema.NullOr(Schema.Struct({ server: Schema.optionalKey(Schema.Unknown) })), + Schema.NullOr( + Schema.Struct({ + server: Schema.optionalKey(Schema.Unknown), + baseURL: Schema.optionalKey(Schema.Unknown), + }), + ), ), + configuration: Schema.optionalKey(Schema.Struct({ baseURL: Schema.optionalKey(Schema.Unknown) })), }); const decodeStoredKey = Schema.decodeOption(Schema.fromJsonString(StoredKey)); const UsageWindow = Schema.Struct({ @@ -41,6 +47,91 @@ const UsageResponse = Schema.Struct({ usage: Schema.Struct({ rolling: UsageWindow, weekly: UsageWindow, monthly: UsageWindow }), }); +/** + * Upstream integrations that publish their own quota. OpenCode fronts several + * providers behind one harness, and each of them meters its own plan, so the + * quota has to be read from the integration that serves the model rather than + * from the OpenCode Console budget. + */ +const INTEGRATION_QUOTA_ENDPOINTS: Readonly< + Record +> = { + "minimax-coding-plan": { + endpoint: "https://api.minimax.io/v1/token_plan/remains", + label: "MiniMax Token Plan", + }, + minimax: { + endpoint: "https://api.minimax.io/v1/token_plan/remains", + label: "MiniMax", + }, +}; + +/** + * MiniMax issues its token-plan keys with this prefix. A pay-as-you-go key + * meters no plan, and a key that belongs to another endpoint must never leave + * the machine, so only a plan key is sent to the quota API. + */ +const MINI_MAX_PLAN_KEY_PREFIX = "sk-cp-"; + +/** The hosts that serve MiniMax's own API. */ +const MINI_MAX_HOSTS = new Set(["api.minimax.io", "api.minimaxi.com"]); + +// MiniMax answers with one entry per quota and two metered windows. +const MiniMaxQuotaResponse = Schema.Struct({ + base_resp: Schema.optionalKey( + Schema.Struct({ + status_code: Schema.optionalKey(Schema.Finite), + status_msg: Schema.optionalKey(Schema.String), + }), + ), + model_remains: Schema.optionalKey(Schema.Array(Schema.Unknown)), +}); + +interface MiniMaxQuotaEntry { + readonly model_name?: string; + readonly current_interval_remaining_percent?: number; + readonly current_interval_status?: number; + readonly end_time?: number; + readonly start_time?: number; + readonly weekly_end_time?: number; + readonly weekly_start_time?: number; + readonly current_weekly_remaining_percent?: number; + readonly current_weekly_status?: number; +} + +const miniMaxQuotaEntry = Schema.decodeUnknownOption( + Schema.Struct({ + model_name: Schema.optionalKey(Schema.String), + current_interval_remaining_percent: Schema.optionalKey(Schema.Finite), + current_interval_status: Schema.optionalKey(Schema.Finite), + end_time: Schema.optionalKey(Schema.Finite), + start_time: Schema.optionalKey(Schema.Finite), + weekly_end_time: Schema.optionalKey(Schema.Finite), + weekly_start_time: Schema.optionalKey(Schema.Finite), + current_weekly_remaining_percent: Schema.optionalKey(Schema.Finite), + current_weekly_status: Schema.optionalKey(Schema.Finite), + }), +); + +/** + * The bucket that meters the plan every chat model draws from. MiniMax reports + * one bucket per quota, and the others (a video allowance, for example) meter a + * quota no model in T3 Code spends. + */ +const MINI_MAX_PLAN_BUCKET = "general"; + +/** + * The plan's own bucket. Reporting a second bucket under the same window ids + * would pool two different quotas into one percentage, so only the bucket that + * meters the plan is reported. + */ +function miniMaxPlanBucket(entries: readonly MiniMaxQuotaEntry[]): MiniMaxQuotaEntry | undefined { + const plan = entries.find((entry) => entry.model_name === MINI_MAX_PLAN_BUCKET); + if (plan) return plan; + // A payload with a single bucket leaves no room for ambiguity. + return entries.length === 1 ? entries[0] : undefined; +} + // Select before decoding so an OAuth or self-hosted connection cannot fall // through to another stored account. NULL-active rows predate the selection flag. class OpenCodeCredentialReadError extends Schema.TaggedError()( @@ -52,59 +143,370 @@ class OpenCodeCredentialReadError extends Schema.TaggedError +interface StoredCredential { + readonly key: string; + /** The OpenCode deployment the credential belongs to, when the store names one. */ + readonly server: string | undefined; + /** The endpoint the credential reaches, when the store names one. */ + readonly baseURL: string | undefined; +} + +/** + * Every active credential in the local OpenCode store, newest first per + * integration. One read, so every caller sees the same store. + */ +const readStoredCredentials = (databasePath: string) => Effect.try({ try: () => { const database = new NodeSqlite.DatabaseSync(databasePath, { readOnly: true }); try { database.exec("PRAGMA busy_timeout = 100"); - const row = database + const rows = database .prepare( - `SELECT value FROM credential - WHERE integration_id = ? AND active IS NOT 0 - ORDER BY active DESC, time_created DESC, id DESC LIMIT 1`, + `SELECT integration_id, value FROM credential + WHERE active IS NOT 0 ORDER BY active DESC, time_created DESC, id DESC`, ) - .get(integrationId); - const stored = - typeof row?.value === "string" - ? Option.getOrUndefined(decodeStoredKey(row.value)) - : undefined; - const server = stored?.metadata?.server; - if (typeof server === "string") { - const url = new URL(server); - if (`${url.origin}${url.pathname.replace(/\/+$/, "")}` !== "https://opencode.ai/console") - return undefined; + .all() as Array<{ readonly integration_id: unknown; readonly value: unknown }>; + const credentials = new Map(); + for (const row of rows) { + const integrationId = typeof row.integration_id === "string" ? row.integration_id : ""; + if (integrationId === "" || credentials.has(integrationId)) continue; + const stored = + typeof row.value === "string" + ? Option.getOrUndefined(decodeStoredKey(row.value)) + : undefined; + const key = stored?.key.trim(); + if (!key) continue; + const server = stored?.metadata?.server; + const configuredBaseURL = + typeof stored?.metadata?.baseURL === "string" + ? stored.metadata.baseURL + : typeof stored?.configuration?.baseURL === "string" + ? stored.configuration.baseURL + : undefined; + credentials.set(integrationId, { + key, + server: typeof server === "string" ? server : undefined, + baseURL: configuredBaseURL, + }); } - return stored?.key.trim(); + return credentials; } finally { database.close(); } }, catch: (cause) => new OpenCodeCredentialReadError({ databasePath, cause }), - }).pipe(Effect.orElseSucceed(() => undefined)); + }).pipe(Effect.orElseSucceed(() => new Map())); -/** External OpenCode servers own their credentials; never read the host's account for them. */ -export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")(function* (input: { +/** A Zen key belongs to the deployment that stored it, so only the Console's own is used. */ +function isOfficialConsole(server: string | undefined): boolean { + if (server === undefined) return true; + try { + const url = new URL(server); + return `${url.origin}${url.pathname.replace(/\/+$/, "")}` === "https://opencode.ai/console"; + } catch { + return false; + } +} + +/** A credential that names another server belongs to that server, not to MiniMax. */ +function servesMiniMax(server: string | undefined): boolean { + if (server === undefined) return true; + try { + const url = new URL(server); + return MINI_MAX_HOSTS.has(url.host); + } catch { + return false; + } +} + +/** The endpoint MiniMax serves its own API from. */ +function isMiniMaxEndpoint(baseURL: string | undefined): boolean { + if (baseURL === undefined) return false; + try { + return MINI_MAX_HOSTS.has(new URL(baseURL).host); + } catch { + return false; + } +} + +/** The local OpenCode store: where its data home, auth file and credential database live. */ +const openCodeStorePaths = Effect.fn("OpenCodeStore.paths")(function* ( + environment: NodeJS.ProcessEnv, +) { + const path = yield* Path.Path; + const dataHome = + environment.XDG_DATA_HOME || + path.join( + environment.HOME || environment.USERPROFILE || (yield* HostProcess.HomeDirectory), + ".local", + "share", + ); + const databaseFile = environment.OPENCODE_DB ?? "opencode.db"; + return { + dataHome, + authPath: path.join(dataHome, "opencode", "auth.json"), + databasePath: path.resolve(dataHome, "opencode", databaseFile), + }; +}); + +const miniMaxWindow = ( + integrationId: string, + label: string, + id: string, + kind: "session" | "weekly", + remainingPercent: number | undefined, + statusCode: number | undefined, + resetsAtMs: number | undefined, + startsAtMs: number | undefined, +): ServerProviderUsageWindow | null => { + if (remainingPercent === undefined) return null; + // MiniMax reports 1 normal, 2 exhausted and 3 unlimited. + if (statusCode === 3) return null; + const usedPercent = clampPercent(100 - remainingPercent); + const resetsAt = + resetsAtMs !== undefined && Number.isFinite(resetsAtMs) + ? Option.getOrUndefined(DateTime.make(resetsAtMs)) + : undefined; + // The window length is what lets the client judge pace, so take it from the + // window's own bounds. + const windowDurationMins = + startsAtMs !== undefined && + resetsAtMs !== undefined && + Number.isFinite(startsAtMs) && + Number.isFinite(resetsAtMs) && + resetsAtMs > startsAtMs + ? Math.round((resetsAtMs - startsAtMs) / 60_000) + : undefined; + return { + id: `${integrationId}_${id}`, + kind, + label: `${label} · ${kind === "session" ? "Interval" : "Weekly"}`, + usedPercent, + ...(windowDurationMins === undefined ? {} : { windowDurationMins }), + ...(resetsAt ? { resetsAt: DateTime.formatIso(resetsAt) } : {}), + }; +}; + +export interface OpenCodeUsageLimitsInput { readonly enabled: boolean; readonly serverUrl: string; readonly environment: NodeJS.ProcessEnv; -}) { + /** + * The endpoint OpenCode resolves for a provider. A provider without one is + * not probed: the key's owner would be unknown. + */ + readonly resolveEndpoint?: + | ((integrationId: string) => Effect.Effect) + | undefined; +} + +export interface OpenCodeIntegrationUsageLimits { + readonly checkedAt: string; + readonly windows: readonly ServerProviderUsageWindow[]; + /** The identity of the credentials that meter the reported windows. */ + readonly credentialFingerprint?: string | undefined; +} + +/** + * Quota for the upstream integrations the user actually runs. OpenCode Go is + * the Console budget; a custom provider meters its own plan, so its windows + * come from that provider's API. + */ +export const readOpenCodeIntegrationUsageLimits = Effect.fn("readOpenCodeIntegrationUsageLimits")( + function* (input: OpenCodeUsageLimitsInput) { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const empty = { + checkedAt, + windows: [] as readonly ServerProviderUsageWindow[], + credentialFingerprint: undefined as string | undefined, + }; + if (!input.enabled || input.serverUrl.trim()) return empty; + + return yield* Effect.gen(function* () { + const env = input.environment; + const { databasePath } = yield* openCodeStorePaths(env); + const storedCredentials = yield* readStoredCredentials(databasePath); + // Only a credential that MiniMax itself issued may reach MiniMax's API. + const candidates: Array = []; + for (const integrationId of Object.keys(INTEGRATION_QUOTA_ENDPOINTS)) { + const credential = storedCredentials.get(integrationId); + if (credential === undefined) continue; + if (!credential.key.startsWith(MINI_MAX_PLAN_KEY_PREFIX)) continue; + if (!servesMiniMax(credential.server)) continue; + // The provider must resolve to MiniMax's own endpoint, and the + // credential must not point somewhere else. + const endpoint = + input.resolveEndpoint === undefined + ? undefined + : yield* input.resolveEndpoint(integrationId); + if (endpoint === undefined || !isMiniMaxEndpoint(endpoint)) continue; + if (credential.baseURL !== undefined && !isMiniMaxEndpoint(credential.baseURL)) continue; + candidates.push([integrationId, credential]); + } + if (candidates.length === 0) return empty; + + const digest = yield* Digest.Digest; + const fingerprint = (value: string) => + digest + .digest(Digest.sha256, new TextEncoder().encode(value)) + .pipe(Effect.map(Hex.encode), Effect.orDie); + // The account names every credential that meters it, so two environments + // that share one key but differ in another stay apart. + const credentialFingerprint = yield* fingerprint( + candidates + .map( + ([integrationId, credential]) => + `opencode-integrations\0${integrationId}\0${credential.key}`, + ) + .sort() + .join("\0"), + ); + // Each window names the credential that meters it, so the same plan on + // two environments is one quota. + const windowFingerprints = new Map(); + for (const [integrationId, credential] of candidates) { + windowFingerprints.set( + integrationId, + yield* fingerprint(`opencode-integration\0${integrationId}\0${credential.key}`), + ); + } + + const client = yield* HttpClient.HttpClient; + const windows: ServerProviderUsageWindow[] = []; + for (const [integrationId, credential] of candidates) { + const quota = INTEGRATION_QUOTA_ENDPOINTS[integrationId]; + if (quota === undefined) continue; + const response = yield* client + .execute( + HttpClientRequest.get(quota.endpoint).pipe( + HttpClientRequest.bearerToken(credential.key), + ), + ) + .pipe(Effect.timeout("5 seconds"), Effect.option); + if (Option.isNone(response)) continue; + const result = response.value; + if (result.status !== 200) continue; + // The body read needs its own bound: a server that sends headers and + // then stalls would hold the refresh permit forever. + const body = yield* HttpClientResponse.schemaBodyJson(MiniMaxQuotaResponse)(result).pipe( + Effect.timeout("5 seconds"), + Effect.option, + ); + if (Option.isNone(body)) continue; + const payload = body.value; + if ((payload.base_resp?.status_code ?? 0) !== 0) continue; + const entries = (payload.model_remains ?? []).flatMap((raw) => { + const entry = Option.getOrUndefined(miniMaxQuotaEntry(raw)); + return entry ? [entry] : []; + }); + const plan = miniMaxPlanBucket(entries); + if (plan === undefined) continue; + const interval = miniMaxWindow( + integrationId, + quota.label, + "interval", + "session", + plan.current_interval_remaining_percent, + plan.current_interval_status, + plan.end_time, + plan.start_time, + ); + const weekly = miniMaxWindow( + integrationId, + quota.label, + "weekly", + "weekly", + plan.current_weekly_remaining_percent, + plan.current_weekly_status, + plan.weekly_end_time, + plan.weekly_start_time, + ); + const credentialFingerprint = windowFingerprints.get(integrationId); + if (interval !== null) + windows.push( + credentialFingerprint === undefined ? interval : { ...interval, credentialFingerprint }, + ); + if (weekly !== null) + windows.push( + credentialFingerprint === undefined ? weekly : { ...weekly, credentialFingerprint }, + ); + } + return { checkedAt, windows, credentialFingerprint }; + }).pipe( + Effect.orElseSucceed(() => ({ + checkedAt, + windows: [] as readonly ServerProviderUsageWindow[], + credentialFingerprint: undefined as string | undefined, + })), + ); + }, +); + +/** + * The account identity covers every credential that meters it, so two + * environments that share a Console key but not an integration key stay + * separate accounts. + */ +function mergeFingerprints( + console: string | undefined, + integration: string | undefined, +): string | undefined { + if (console === undefined) return integration; + if (integration === undefined) return console; + return `${console}.${integration}`; +} + +/** + * Combine the Console budget with the quotas of the integrations in use. + * + * The Console budget can be unavailable while a custom provider still meters + * a plan, so the unavailable marker is dropped once there are windows to + * report. + */ +export function mergeUsageLimits( + console: ServerProviderUsageLimits, + integration: OpenCodeIntegrationUsageLimits, +): ServerProviderUsageLimits { + // Without a quota of its own the account is what the Console says it is. + if (integration.windows.length === 0) return console; + // The integration's quota replaces the unavailable marker, but the Console + // metadata stays: its fingerprint is what tells one account apart from + // another when several environments report the same one. + const { unavailable: _unavailable, ...consoleMetadata } = console; + const credentialFingerprint = mergeFingerprints( + consoleMetadata.credentialFingerprint, + integration.credentialFingerprint, + ); + // The Console windows name the Console credential; an integration window + // already names the credential that meters it. + const consoleWindows = + consoleMetadata.credentialFingerprint === undefined + ? console.windows + : console.windows.map((window) => ({ + ...window, + credentialFingerprint: consoleMetadata.credentialFingerprint, + })); + return { + ...consoleMetadata, + ...(credentialFingerprint === undefined ? {} : { credentialFingerprint }), + checkedAt: console.checkedAt, + windows: [...consoleWindows, ...integration.windows], + }; +} + +/** External OpenCode servers own their credentials; never read the host's account for them. */ +export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")(function* ( + input: OpenCodeUsageLimitsInput, +) { const checkedAt = DateTime.formatIso(yield* DateTime.now); const unsupported = makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); if (!input.enabled || input.serverUrl.trim()) return unsupported; return yield* Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; const env = input.environment; - const dataHome = - env.XDG_DATA_HOME || - path.join( - env.HOME || env.USERPROFILE || (yield* HostProcess.HomeDirectory), - ".local", - "share", - ); - const authPath = path.join(dataHome, "opencode", "auth.json"); + const { authPath, databasePath } = yield* openCodeStorePaths(env); const contents = env.OPENCODE_AUTH_CONTENT || (yield* fs.readFileString(authPath).pipe( @@ -119,17 +521,18 @@ export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")( const goKey = (Option.isSome(apiAuth) ? apiAuth.value.key : env.OPENCODE_API_KEY)?.trim(); // Console supplies Go credentials in OpenCode 2. Inline auth explicitly // overrides local credentials, including the Console connection. - const databaseFile = env.OPENCODE_DB ?? "opencode.db"; - const databasePath = path.resolve(dataHome, "opencode", databaseFile); - const storedKeys = - env.OPENCODE_AUTH_CONTENT || databaseFile === ":memory:" - ? [] - : [ - yield* readStoredApiKey(databasePath, "opencode"), - // OpenCode 2 imports auth.json once, so a replaced Go key only exists here. - yield* readStoredApiKey(databasePath, "opencode-go"), - ]; - const keys = [...new Set([...storedKeys, goKey].filter((key): key is string => Boolean(key)))]; + const storedCredentials = + env.OPENCODE_AUTH_CONTENT || env.OPENCODE_DB === ":memory:" + ? new Map() + : yield* readStoredCredentials(databasePath); + const consoleKey = ["opencode", "opencode-go"] + .map((integrationId) => storedCredentials.get(integrationId)) + .filter( + (credential): credential is StoredCredential => + credential !== undefined && isOfficialConsole(credential.server), + ) + .map((credential) => credential.key); + const keys = [...new Set([...consoleKey, goKey].filter((key): key is string => Boolean(key)))]; let apiKey = keys[0]; if (!apiKey) return unsupported; @@ -182,7 +585,12 @@ export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")( .digest(Digest.sha256, new TextEncoder().encode(`opencode-go\0${apiKey}`)) .pipe(Effect.map(Hex.encode), Effect.orDie); return { - ...makeUsageLimits({ checkedAt, windows }), + ...makeUsageLimits({ + checkedAt, + // The window names the credential that meters it, so a pool counts one + // Go account once even when two environments report it. + windows: windows.map((window) => ({ ...window, credentialFingerprint })), + }), credentialFingerprint, }; }).pipe( diff --git a/packages/provider-opencode/src/server/v2/providerEndpoints.test.ts b/packages/provider-opencode/src/server/v2/providerEndpoints.test.ts new file mode 100644 index 0000000000000..916cb01791084 --- /dev/null +++ b/packages/provider-opencode/src/server/v2/providerEndpoints.test.ts @@ -0,0 +1,162 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import type * as OpenCode2Server from "./OpenCode2Server.ts"; +import { readProviderEndpoints } from "./providerEndpoints.ts"; + +interface ProviderEntry { + readonly id: string; + readonly integrationID?: string; + readonly baseURL?: string; +} + +interface ModelEntry { + readonly providerID: string; + readonly baseURL?: string; +} + +/** The provider list OpenCode answers with, with its resolved settings. */ +const providerEntry = ({ id, integrationID, baseURL }: ProviderEntry) => ({ + id, + ...(integrationID === undefined ? {} : { integrationID }), + ...(baseURL === undefined ? {} : { settings: { baseURL } }), +}); + +/** The model list OpenCode answers with, with its resolved settings. */ +const modelEntry = ({ providerID, baseURL }: ModelEntry) => ({ + providerID, + ...(baseURL === undefined ? {} : { settings: { baseURL } }), +}); + +const connectionWith = (providers: readonly ProviderEntry[], models: readonly ModelEntry[] = []) => + ({ + client: { + provider: { + list: () => Effect.succeed({ data: providers.map(providerEntry) }), + }, + model: { + list: () => Effect.succeed({ data: models.map(modelEntry) }), + }, + }, + }) as unknown as OpenCode2Server.OpenCode2Connection; + +describe("readProviderEndpoints", () => { + it.effect("names the endpoint of every provider and of its integration", () => + Effect.gen(function* () { + const endpoints = yield* readProviderEndpoints( + connectionWith([ + { id: "minimax-coding-plan", integrationID: "minimax-coding-plan" }, + { id: "opencode", integrationID: "opencode", baseURL: "https://opencode.ai/console" }, + ]), + "/work", + ); + assert.strictEqual(endpoints.get("minimax-coding-plan"), undefined); + assert.strictEqual(endpoints.get("opencode"), "https://opencode.ai/console"); + }), + ); + + it.effect("leaves an integration unknown when its providers disagree", () => + Effect.gen(function* () { + const endpoints = yield* readProviderEndpoints( + connectionWith([ + { + id: "minimax-coding-plan", + integrationID: "minimax-coding-plan", + baseURL: "https://api.minimax.io/anthropic/v1", + }, + { + id: "minimax-gateway", + integrationID: "minimax-coding-plan", + baseURL: "https://gateway.example/v1", + }, + ]), + "/work", + ); + // The probe must not pick one of the two endpoints. + assert.strictEqual(endpoints.get("minimax-coding-plan"), undefined); + assert.strictEqual(endpoints.get("minimax-gateway"), "https://gateway.example/v1"); + }), + ); + + it.effect("leaves an integration unknown when one of its providers has no endpoint", () => + Effect.gen(function* () { + const endpoints = yield* readProviderEndpoints( + connectionWith([ + { + id: "minimax-coding-plan", + integrationID: "minimax-coding-plan", + baseURL: "https://api.minimax.io/anthropic/v1", + }, + { id: "minimax-gateway", integrationID: "minimax-coding-plan" }, + ]), + "/work", + ); + assert.strictEqual(endpoints.get("minimax-coding-plan"), undefined); + }), + ); + + it.effect("leaves a provider unknown when one of its models resolves elsewhere", () => + Effect.gen(function* () { + const endpoints = yield* readProviderEndpoints( + connectionWith( + [ + { + id: "minimax-coding-plan", + integrationID: "minimax-coding-plan", + baseURL: "https://api.minimax.io/anthropic/v1", + }, + ], + [{ providerID: "minimax-coding-plan", baseURL: "https://gateway.example/v1" }], + ), + "/work", + ); + assert.strictEqual(endpoints.get("minimax-coding-plan"), undefined); + }), + ); + + it.effect("keeps an endpoint when its providers and models agree", () => + Effect.gen(function* () { + const endpoints = yield* readProviderEndpoints( + connectionWith( + [ + { + id: "minimax-coding-plan", + integrationID: "minimax-coding-plan", + baseURL: "https://api.minimax.io/anthropic/v1", + }, + { + id: "minimax-coding-plan-eu", + integrationID: "minimax-coding-plan", + baseURL: "https://api.minimax.io/anthropic/v1", + }, + ], + [{ providerID: "minimax-coding-plan", baseURL: "https://api.minimax.io/anthropic/v1" }], + ), + "/work", + ); + assert.strictEqual( + endpoints.get("minimax-coding-plan"), + "https://api.minimax.io/anthropic/v1", + ); + }), + ); + + it.effect("returns no endpoints when the provider list cannot be read", () => + Effect.gen(function* () { + const endpoints = yield* readProviderEndpoints( + { + client: { + provider: { + list: () => Effect.die("no server"), + }, + model: { + list: () => Effect.succeed({ data: [] }), + }, + }, + } as unknown as OpenCode2Server.OpenCode2Connection, + "/work", + ); + assert.strictEqual(endpoints.size, 0); + }), + ); +}); diff --git a/packages/provider-opencode/src/server/v2/providerEndpoints.ts b/packages/provider-opencode/src/server/v2/providerEndpoints.ts new file mode 100644 index 0000000000000..39876a23c69a4 --- /dev/null +++ b/packages/provider-opencode/src/server/v2/providerEndpoints.ts @@ -0,0 +1,70 @@ +import * as Effect from "effect/Effect"; + +import type * as OpenCode2Server from "./OpenCode2Server.ts"; + +/** + * The endpoint OpenCode resolves for every provider, by provider id and by + * integration id. An empty map means the endpoint is unknown, so a credential + * is not sent anywhere. + */ +export const readProviderEndpoints = Effect.fn("OpenCode2Client.providerEndpoints")(function* ( + connection: OpenCode2Server.OpenCode2Connection, + directory: string, +) { + return yield* Effect.all( + [ + connection.client.provider.list({ location: { directory } }), + connection.client.model.list({ location: { directory } }), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.map(([providers, models]) => { + const endpoints = new Map(); + // A provider whose endpoint is unknown, or whose providers disagree, leaves + // the integration unknown: the probe must not guess which one meters the key. + const unknown = new Set(); + const record = (key: string, baseURL: string | undefined) => { + if (unknown.has(key)) return; + if (baseURL === undefined) { + endpoints.delete(key); + unknown.add(key); + return; + } + const known = endpoints.get(key); + if (known === undefined) { + endpoints.set(key, baseURL); + return; + } + if (known !== baseURL) { + endpoints.delete(key); + unknown.add(key); + } + }; + const providerEndpoint = new Map(); + for (const provider of providers.data) { + providerEndpoint.set( + provider.id, + typeof provider.settings?.baseURL === "string" ? provider.settings.baseURL : undefined, + ); + } + // A model can resolve to another endpoint on its own, so the provider's + // endpoint is not the one every model goes to. + for (const model of models.data) { + const baseURL = + typeof model.settings?.baseURL === "string" ? model.settings.baseURL : undefined; + if (baseURL === undefined) continue; + const provider = providerEndpoint.get(model.providerID); + if (provider !== undefined && provider !== baseURL) { + providerEndpoint.set(model.providerID, undefined); + } + } + for (const provider of providers.data) { + const baseURL = providerEndpoint.get(provider.id); + record(provider.id, baseURL); + if (provider.integrationID !== undefined) record(provider.integrationID, baseURL); + } + return endpoints; + }), + Effect.catchCause(() => Effect.succeed(new Map())), + ); +}); diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 9d2bf395a9d72..88da0c4f13b40 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -180,6 +180,32 @@ describe("new thread model fallback", () => { ["opencode", "console-custom"], ], ["opencode", ["budget_all"], "opencode/model", false, undefined, []], + ["opencode", ["minimax_weekly"], "minimax/MiniMax-M3.1-Flash", true, undefined, undefined], + [ + "opencode", + ["minimax-coding-plan_weekly"], + "minimax/MiniMax-M3.1-Flash", + false, + undefined, + undefined, + ], + [ + "opencode", + ["minimax-coding-plan_weekly"], + "minimax-coding-plan/MiniMax-M3", + true, + undefined, + undefined, + ], + [ + "opencode", + ["minimax-coding-plan_weekly"], + "opencode-go/big-pickle", + false, + undefined, + undefined, + ], + ["opencode", ["go_weekly"], "minimax/MiniMax-M3.1-Flash", false, undefined, undefined], ] as const)( "matches %s pools %s to %s", (driver, ids, model, usesFallback, modelIds, providerIds) => { @@ -1056,6 +1082,55 @@ describe("pooled account columns", () => { row.columns.map((member) => (member.window ? member.account.key : null)), ); + it("counts one credential once in a pool", () => { + const accounts = [ + { + ...account("env-a", [ + { ...weekly, id: "go_weekly", usedPercent: 20, credentialFingerprint: "go-key" }, + ]), + driver: ProviderDriverKind.make("opencode"), + }, + { + // The same Go credential on another environment, with a plan of its own. + ...account("env-b", [ + { ...weekly, id: "go_weekly", usedPercent: 20, credentialFingerprint: "go-key" }, + ]), + driver: ProviderDriverKind.make("opencode"), + }, + { + ...account("env-c", [ + { ...weekly, id: "go_weekly", usedPercent: 80, credentialFingerprint: "other-go-key" }, + ]), + driver: ProviderDriverKind.make("opencode"), + }, + ]; + const [pool] = collectLimitPools(accounts, now); + expect(pool?.windows[0]?.members).toHaveLength(2); + expect(pool?.windows[0]?.usedPercent).toBe(50); + }); + + it("keeps the freshest report of a credential that repeats", () => { + const goAccount = (key: string, checkedAt: string, usedPercent: number) => { + const windows = [ + { ...weekly, id: "go_weekly", usedPercent, credentialFingerprint: "go-key" }, + ]; + return { + ...account(key, windows), + driver: ProviderDriverKind.make("opencode"), + limits: { checkedAt, windows }, + }; + }; + const [pool] = collectLimitPools( + [ + goAccount("env-a", "2026-09-03T10:00:00.000Z", 20), + goAccount("env-b", "2026-09-03T11:30:00.000Z", 80), + ], + now, + ); + expect(pool?.windows[0]?.members).toHaveLength(1); + expect(pool?.windows[0]?.usedPercent).toBe(80); + }); + it.each([ { reports: [ diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index b7902f11d6733..1b40ea03fa492 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -66,10 +66,19 @@ export function exhaustedUsageWindow( ); return exhausted.find((window) => window.id !== "individual" || !hasPlanWindows); } - case "opencode": - return exhausted.find( - (window) => !window.id.startsWith("go_") || model.startsWith("opencode-go/"), - ); + case "opencode": { + // Each window names the integration that meters it, so a model's limits + // come from its own provider instead of whichever plan happens to be + // exhausted. The Console budget is metered as "go" or "budget". + const integration = model.split("/")[0] ?? ""; + return exhausted.find((window) => { + const owner = window.id.split("_")[0]!; + if (owner === "go") return integration === "opencode-go"; + // A Console workspace budget covers every model in the workspace. + if (owner === "budget") return true; + return owner === integration; + }); + } default: return exhausted[0]; } @@ -622,8 +631,26 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L ? `${window.kind}:${window.id}` : pooled; const list = byKey.get(key); - if (list) list.push(member); - else byKey.set(key, [member]); + if (list) { + // The same credential on two accounts is one allowance, so it counts + // once, and the fresher report of it is the one that counts. + const credential = window.credentialFingerprint; + const repeated = + credential === undefined + ? -1 + : list.findIndex((member) => member.window.credentialFingerprint === credential); + if (repeated !== -1) { + const previous = list[repeated]!; + if ( + Date.parse(member.account.limits.checkedAt) > + Date.parse(previous.account.limits.checkedAt) + ) { + list[repeated] = member; + } + continue; + } + list.push(member); + } else byKey.set(key, [member]); } } const pools = [...byKey].map(([key, members]): LimitPoolWindow => { diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 828b652077f62..ec2e31dd1709d 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -312,6 +312,61 @@ describe("mergeUsage", () => { expect(merged.records).toBe(5); expect(merged.sessions).toBe(1); expect(merged.contributingEnvironments).toEqual(["old"]); + + const attributed = environment("new", { + ...partial.summary, + buckets: partial.summary.buckets.map((entry) => ({ + ...entry, + upstreamProvider: "anthropic", + })), + }); + expect(mergeUsage([complete, attributed], USAGE_CONTRACT_VERSION).costUsd).toBe(10); + + const attributedComplete = environment("old", { + ...complete.summary, + buckets: complete.summary.buckets.map((entry) => ({ + ...entry, + upstreamProvider: "anthropic", + })), + }); + expect(mergeUsage([attributedComplete, partial], USAGE_CONTRACT_VERSION).costUsd).toBe(10); + }); + + it("filters the reconciled contributions instead of the summaries", () => { + const source = { provider: "claude" as const, hostId: "mac", homePath: "/home/theo/.claude" }; + const complete = environment("old", summary([bucket()], [source])); + const partialSummary = summary( + [ + bucket({ sourcePath: source.homePath, costUsd: 4, records: 2 }), + bucket({ + day: "2026-08-08" as UsageDay, + sourcePath: source.homePath, + costUsd: 3, + records: 1, + }), + ], + [{ ...source, distinctSessions: 2 }], + ); + const attributed = environment("new", { + ...partialSummary, + readAt: "2026-08-08T01:00:00.000Z", + sources: partialSummary.sources.map((entry) => ({ ...entry, status: "partial" as const })), + buckets: partialSummary.buckets.map((entry) => ({ + ...entry, + upstreamProvider: "anthropic", + })), + }); + + // The complete scan owns the overlapping day, so only its later day is the + // attributed model's own usage. + expect(mergeUsage([complete, attributed], USAGE_CONTRACT_VERSION).costUsd).toBe(13); + expect( + mergeUsage( + [complete, attributed], + USAGE_CONTRACT_VERSION, + (bucket) => bucket.upstreamProvider === "anthropic", + ).costUsd, + ).toBe(3); }); it("identifies an environment reporting an older contract version", () => { diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 423ccef4bbd63..723b46e98df88 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -28,6 +28,8 @@ export interface ProviderTotals { export interface ModelTotals { readonly model: string; readonly provider: UsageProviderKind; + /** The upstream integration that served the model, when the harness knows it. */ + readonly upstreamProvider?: string; readonly costUsd: number; readonly totalTokens: number; readonly tokens: UsageTokenTotals; @@ -130,8 +132,17 @@ function bucketsForSource(summary: UsageSummary, source: UsageSource): readonly ); } -function bucketKey(bucket: UsageBucket): string { - return JSON.stringify([bucket.day, bucket.hourStart ?? null, bucket.provider, bucket.model]); +function bucketKey( + bucket: UsageBucket, + upstreamProvider: string | null = bucket.upstreamProvider ?? null, +): string { + return JSON.stringify([ + bucket.day, + bucket.hourStart ?? null, + bucket.provider, + upstreamProvider, + bucket.model, + ]); } function claimSources(environments: readonly EnvironmentUsage[]): { @@ -145,7 +156,17 @@ function claimSources(environments: readonly EnvironmentUsage[]): { string, { environment: EnvironmentUsage; source: UsageSource } >(); - const seenBucketKeysByFingerprint = new Map>(); + const seenBucketKeysByFingerprint = new Map< + string, + { + /** The owner's cells, keyed by their own attribution. */ + readonly attributed: Set; + /** The owner's cells that carry no attribution. */ + readonly unattributed: Set; + /** The owner's cells, keyed without their attribution. */ + readonly any: Set; + } + >(); const supplementalBucketsByEnvironment = new Map>(); const sessionsByFingerprint = new Map(); const duplicates: string[] = []; @@ -189,7 +210,16 @@ function claimSources(environments: readonly EnvironmentUsage[]): { } let seen = seenBucketKeysByFingerprint.get(key); if (seen === undefined) { - seen = new Set(bucketsForSource(owner.environment.summary, owner.source).map(bucketKey)); + const ownerCells = bucketsForSource(owner.environment.summary, owner.source); + seen = { + attributed: new Set(ownerCells.map((bucket) => bucketKey(bucket))), + unattributed: new Set( + ownerCells + .filter((bucket) => bucket.upstreamProvider === undefined) + .map((bucket) => bucketKey(bucket, null)), + ), + any: new Set(ownerCells.map((bucket) => bucketKey(bucket, null))), + }; seenBucketKeysByFingerprint.set(key, seen); } const supplemental = @@ -197,8 +227,18 @@ function claimSources(environments: readonly EnvironmentUsage[]): { let added = false; for (const bucket of bucketsForSource(environment.summary, source)) { const cell = bucketKey(bucket); - if (seen.has(cell)) continue; - seen.add(cell); + const unattributedCell = bucketKey(bucket, null); + // A cell is already counted when the owner holds the same records: with + // the same attribution, without one (a server that did not know the + // upstream), or, for a cell without attribution, under any attribution. + if ( + seen.attributed.has(cell) || + seen.unattributed.has(unattributedCell) || + (bucket.upstreamProvider === undefined && seen.any.has(unattributedCell)) + ) + continue; + seen.attributed.add(cell); + seen.any.add(unattributedCell); supplemental.add(bucket); added = true; } @@ -302,6 +342,7 @@ const EMPTY_MERGED: MergedUsage = { export function mergeUsage( environments: readonly EnvironmentUsage[], expectedContractVersion: number, + keepBucket?: (bucket: UsageBucket) => boolean, ): MergedUsage { if (environments.length === 0) return EMPTY_MERGED; @@ -353,6 +394,8 @@ export function mergeUsage( string, { provider: UsageProviderKind; + upstreamProvider?: string; + model: string; costUsd: number; totalTokens: number; tokens: UsageTokenTotals; @@ -388,7 +431,10 @@ export function mergeUsage( supplementalBucketsByEnvironment.get(environment.environmentId) ?? new Set(), sessionsByFingerprint, ); - if (buckets.length > 0) contributingEnvironments.push(environment.environmentId); + // The filter runs on the reconciled contributions, so a cell that only a + // newer scan attributes still counts once. + const keptBuckets = keepBucket === undefined ? buckets : buckets.filter(keepBucket); + if (keptBuckets.length > 0) contributingEnvironments.push(environment.environmentId); for (const [providerKind, providerSessions] of sessionsByProvider) { sessions += providerSessions; @@ -403,7 +449,7 @@ export function mergeUsage( providerAccumulator.set(providerKind, provider); } - for (const bucket of buckets) { + for (const bucket of keptBuckets) { const tokens = bucketTokens(bucket); costUsd += bucket.costUsd; @@ -437,9 +483,11 @@ export function mergeUsage( provider.records += bucket.records; providerAccumulator.set(bucket.provider, provider); - const modelKey = `${bucket.provider} ${bucket.model}`; + const modelKey = `${bucket.provider} ${bucket.upstreamProvider ?? ""} ${bucket.model}`; const model = modelAccumulator.get(modelKey) ?? { provider: bucket.provider, + ...(bucket.upstreamProvider ? { upstreamProvider: bucket.upstreamProvider } : {}), + model: bucket.model, costUsd: 0, totalTokens: 0, tokens: { @@ -521,9 +569,10 @@ export function mergeUsage( .sort((a, b) => b.costUsd - a.costUsd); const models: ModelTotals[] = [...modelAccumulator.entries()] - .map(([key, totals]) => ({ - model: key.slice(key.indexOf(" ") + 1), + .map(([, totals]) => ({ + model: totals.model, provider: totals.provider, + ...(totals.upstreamProvider ? { upstreamProvider: totals.upstreamProvider } : {}), costUsd: totals.costUsd, totalTokens: totals.totalTokens, tokens: totals.tokens,