From a17729a6cf3a008727a0908122644673e5dd6b8a Mon Sep 17 00:00:00 2001 From: Rodrigo Dantes Gonzalez Mantuano Date: Tue, 10 Nov 2026 13:42:07 +0100 Subject: [PATCH 01/10] feat(usage): report usage and limits for the provider you actually run OpenCode fronts several upstreams (OpenCode Go, Zen, MiniMax, ...) behind one harness. Two things were lost on the way in, so a user on a custom provider saw the wrong account and could not act on it: - Usage records dropped the integration id, so every token was attributed to the "OpenCode" harness with no hint of which upstream served it. - The OpenCode provider reported only the Console budget as its limits, so the Usage tab opened on its default "Limits" metric with nothing to show, and `/usage-limits` named a subscription the user does not hold. Keep the integration id on the usage record and carry it through the aggregation and the summary, so the Usage tab can say which provider a model's usage came from. Read the quota of the integrations the local OpenCode store holds a key for, next to the Console budget, so a custom provider's plan is reported from that provider's own API. Each window names the integration that meters it, and the limit-exhaustion lookup matches a model against the windows of its own provider, so the recovery that offers a fallback model fires for it. The Console budget's "unavailable" marker is dropped once an integration reports windows, because the account does have limits to show. Tested against a real OpenCode store: the MiniMax Token Plan reports an interval window at 17% used and a weekly window at 9% used. --- apps/server/src/usage/usageAggregation.ts | 27 ++- .../src/components/usage/UsageModelDialog.tsx | 6 +- apps/web/src/components/usage/UsagePage.tsx | 13 +- packages/contracts/src/usage.ts | 4 +- packages/provider-core/src/server/usage.ts | 7 + .../src/server/driver.test.ts | 29 ++- .../provider-opencode/src/server/driver.ts | 28 ++- .../src/server/usage.test.ts | 38 +++ .../provider-opencode/src/server/usage.ts | 5 + .../src/server/usageLimits.test.ts | 104 +++++++++ .../src/server/usageLimits.ts | 220 +++++++++++++++++- packages/shared/src/usageLimits.test.ts | 27 +++ packages/shared/src/usageLimits.ts | 17 +- packages/shared/src/usageMerge.ts | 21 +- 14 files changed, 521 insertions(+), 25 deletions(-) create mode 100644 packages/provider-opencode/src/server/usageLimits.test.ts 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..cc32910efccd3 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 && + (model.upstreamProvider === undefined || 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/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..78202903b52b0 100644 --- a/packages/provider-opencode/src/server/driver.test.ts +++ b/packages/provider-opencode/src/server/driver.test.ts @@ -88,12 +88,21 @@ 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) => +const create = ( + config: Partial, + http: HttpClient.HttpClient, + options: { + readonly enabled?: boolean; + readonly environment?: NodeJS.ProcessEnv; + } = {}, +) => OpenCodeDriver.create({ instanceId: ProviderInstanceId.make("opencode-test"), displayName: undefined, - environment: [], - enabled, + environment: 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)); @@ -326,7 +335,11 @@ 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( + const instance = yield* create( + { serverUrl: "http://127.0.0.1:4096" }, + noHttp, + { enabled: false }, + ).pipe( Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime), ); @@ -1270,7 +1283,13 @@ it.layer(layerUpdate)("OpenCodeDriver updates", (it) => { () => Effect.gen(function* () { versionOutput.current = "1.18.18\n"; - const instance = yield* create({}, noHttp); + // Sandbox HOME so the provider cannot read the developer's own OpenCode + // store, whose credentials would send the provider's quota probe out. + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-driver-home-" }); + const instance = yield* create({}, noHttp, { + environment: { HOME: home, XDG_DATA_HOME: home }, + }); // Let the startup status check claim the refresh permit, then wait behind it so it // cannot probe after the upgrade below. yield* Effect.yieldNow; diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 9b39fed2546b5..29dac93937166 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, @@ -474,7 +478,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 +501,25 @@ 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. + const checkIntegrationUsageLimits = readOpenCodeIntegrationUsageLimits({ + enabled: effectiveConfig.enabled, + serverUrl: effectiveConfig.serverUrl, + environment: processEnv, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, pathService), + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + // 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.windows), + { concurrent: true }, + ); const checkProvider = Effect.all( { // A configured loopback server reports its running version. Probe the local @@ -523,6 +546,7 @@ export const OpenCodeDriver: ProviderDriver< Effect.annotateSpans({ providerInstanceId: instanceId }), ), usageLimits: checkUsageLimits, + integrationUsageLimits: checkIntegrationUsageLimits, }, { concurrency: "unbounded" }, ).pipe( 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..4f7e57c667d78 --- /dev/null +++ b/packages/provider-opencode/src/server/usageLimits.test.ts @@ -0,0 +1,104 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +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_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, + }, + ], +}; + +describe("readOpenCodeIntegrationUsageLimits", () => { + it.effect("reads an integration's own quota instead of the OpenCode Console budget", () => + 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", "minimax-coding-plan", JSON.stringify({ type: "key", key: "sk-test" })], + ); + + const client = HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(MINIMAX_RESPONSE))), + ); + const result = yield* readOpenCodeIntegrationUsageLimits({ + enabled: true, + serverUrl: "", + environment: { + HOME: dir, + XDG_DATA_HOME: dataHome, + }, + }).pipe(Effect.provideService(HttpClient.HttpClient, client)); + + 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); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); + +describe("mergeUsageLimits", () => { + it("keeps the integration windows and drops the unavailable marker", () => { + const merged = mergeUsageLimits( + { + checkedAt: "2026-11-10T00:00:00.000Z", + windows: [], + unavailable: { reason: "unsupported" }, + }, + [ + { + id: "minimax-coding-plan_weekly", + kind: "weekly", + label: "MiniMax Token Plan · Weekly", + usedPercent: 9, + }, + ], + ); + assert.strictEqual(merged.windows.length, 1); + 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, []), console); + }); +}); diff --git a/packages/provider-opencode/src/server/usageLimits.ts b/packages/provider-opencode/src/server/usageLimits.ts index 03488e515a79f..9a727189bfc14 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"; @@ -41,6 +41,56 @@ 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 answers with one entry per model 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 current_interval_remaining_percent?: number; + readonly current_interval_status?: number; + readonly end_time?: number; + readonly weekly_end_time?: number; + readonly current_weekly_remaining_percent?: number; + readonly current_weekly_status?: number; +} + +const miniMaxQuotaEntry = Schema.decodeUnknownOption( + Schema.Struct({ + current_interval_remaining_percent: Schema.optionalKey(Schema.Finite), + current_interval_status: Schema.optionalKey(Schema.Finite), + end_time: Schema.optionalKey(Schema.Finite), + weekly_end_time: Schema.optionalKey(Schema.Finite), + current_weekly_remaining_percent: Schema.optionalKey(Schema.Finite), + current_weekly_status: Schema.optionalKey(Schema.Finite), + }), +); + // 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()( @@ -83,6 +133,174 @@ const readStoredApiKey = (databasePath: string, integrationId: "opencode" | "ope catch: (cause) => new OpenCodeCredentialReadError({ databasePath, cause }), }).pipe(Effect.orElseSucceed(() => undefined)); +/** + * Every integration the local OpenCode store holds a key for, so a quota can be + * read from the provider that actually serves the model. + */ +const readStoredIntegrationKeys = (databasePath: string) => + Effect.try({ + try: () => { + const database = new NodeSqlite.DatabaseSync(databasePath, { readOnly: true }); + try { + database.exec("PRAGMA busy_timeout = 100"); + const rows = database + .prepare( + `SELECT integration_id, value FROM credential + WHERE active IS NOT 0 ORDER BY active DESC, time_created DESC, id DESC`, + ) + .all() as Array<{ readonly integration_id: unknown; readonly value: unknown }>; + const keys = new Map(); + for (const row of rows) { + const integrationId = typeof row.integration_id === "string" ? row.integration_id : ""; + if (integrationId === "" || keys.has(integrationId)) continue; + const stored = + typeof row.value === "string" + ? Option.getOrUndefined(decodeStoredKey(row.value)) + : undefined; + const key = stored?.key.trim(); + if (key) keys.set(integrationId, key); + } + return keys; + } finally { + database.close(); + } + }, + catch: (cause) => new OpenCodeCredentialReadError({ databasePath, cause }), + }).pipe(Effect.orElseSucceed(() => new Map())); + +const miniMaxWindow = ( + integrationId: string, + label: string, + id: string, + kind: "session" | "weekly", + remainingPercent: number | undefined, + statusCode: number | undefined, + resetsAtMs: 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; + return { + id: `${integrationId}_${id}`, + kind, + label: `${label} · ${kind === "session" ? "Interval" : "Weekly"}`, + usedPercent, + ...(resetsAt ? { resetsAt: DateTime.formatIso(resetsAt) } : {}), + }; +}; + +/** + * 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: { + readonly enabled: boolean; + readonly serverUrl: string; + readonly environment: NodeJS.ProcessEnv; + }) { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + if (!input.enabled || input.serverUrl.trim()) { + return { checkedAt, windows: [] as readonly ServerProviderUsageWindow[] }; + } + + return yield* Effect.gen(function* () { + 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 databaseFile = env.OPENCODE_DB ?? "opencode.db"; + const databasePath = path.resolve(dataHome, "opencode", databaseFile); + const storedKeys = yield* readStoredIntegrationKeys(databasePath); + const candidates = Object.entries(INTEGRATION_QUOTA_ENDPOINTS).filter(([integrationId]) => + storedKeys.has(integrationId), + ); + if (candidates.length === 0) + return { checkedAt, windows: [] as readonly ServerProviderUsageWindow[] }; + + const client = yield* HttpClient.HttpClient; + const windows: ServerProviderUsageWindow[] = []; + for (const [integrationId, quota] of candidates) { + const key = storedKeys.get(integrationId); + if (key === undefined) continue; + const response = yield* client + .execute(HttpClientRequest.get(quota.endpoint).pipe(HttpClientRequest.bearerToken(key))) + .pipe(Effect.timeout("5 seconds"), Effect.option); + if (Option.isNone(response)) continue; + const result = response.value; + if (result.status !== 200) continue; + const body = yield* HttpClientResponse.schemaBodyJson(MiniMaxQuotaResponse)(result).pipe( + Effect.option, + ); + if (Option.isNone(body)) continue; + const payload = body.value; + if ((payload.base_resp?.status_code ?? 0) !== 0) continue; + for (const raw of payload.model_remains ?? []) { + const entry: MiniMaxQuotaEntry | undefined = Option.getOrUndefined( + miniMaxQuotaEntry(raw), + ); + if (entry === undefined) continue; + const interval = miniMaxWindow( + integrationId, + quota.label, + "interval", + "session", + entry.current_interval_remaining_percent, + entry.current_interval_status, + entry.end_time, + ); + const weekly = miniMaxWindow( + integrationId, + quota.label, + "weekly", + "weekly", + entry.current_weekly_remaining_percent, + entry.current_weekly_status, + entry.weekly_end_time, + ); + if (interval !== null) windows.push(interval); + if (weekly !== null) windows.push(weekly); + } + } + return { checkedAt, windows }; + }).pipe( + Effect.orElseSucceed(() => ({ + checkedAt, + windows: [] as readonly ServerProviderUsageWindow[], + })), + ); + }, +); + +/** + * 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, + integrationWindows: readonly ServerProviderUsageWindow[], +): ServerProviderUsageLimits { + if (integrationWindows.length === 0) return console; + return { + checkedAt: console.checkedAt, + windows: [...console.windows, ...integrationWindows], + }; +} + /** External OpenCode servers own their credentials; never read the host's account for them. */ export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")(function* (input: { readonly enabled: boolean; diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 9d2bf395a9d72..10878c73b0ab2 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) => { @@ -208,6 +234,7 @@ describe("new thread model fallback", () => { ).toEqual(usesFallback ? fallback : defaultSelection); }, ); + }); describe("pace", () => { diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index b7902f11d6733..55e9101d75be8 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". + const integration = model.split("/")[0] ?? ""; + const family = (value: string) => value.split("-")[0]!.split("_")[0]!; + const target = family(integration); + return exhausted.find((window) => { + const owner = window.id.split("_")[0]!; + if (owner === "go") return integration === "opencode-go"; + return family(owner) === target; + }); + } default: return exhausted[0]; } diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 423ccef4bbd63..b66bb7e513858 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; @@ -131,7 +133,13 @@ function bucketsForSource(summary: UsageSummary, source: UsageSource): readonly } function bucketKey(bucket: UsageBucket): string { - return JSON.stringify([bucket.day, bucket.hourStart ?? null, bucket.provider, bucket.model]); + return JSON.stringify([ + bucket.day, + bucket.hourStart ?? null, + bucket.provider, + bucket.upstreamProvider ?? null, + bucket.model, + ]); } function claimSources(environments: readonly EnvironmentUsage[]): { @@ -353,6 +361,8 @@ export function mergeUsage( string, { provider: UsageProviderKind; + upstreamProvider?: string; + model: string; costUsd: number; totalTokens: number; tokens: UsageTokenTotals; @@ -437,9 +447,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 +533,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, From 61a5011b058507caf34549e072cf5c469f1a87cc Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 11 Nov 2026 15:18:21 +0000 Subject: [PATCH 02/10] fix(usage): address review findings on upstream usage and limits - retry integration quotas during usage recovery - keep Console account metadata when merging integration windows - bound the MiniMax quota body read and ignore non-text quota entries - match unattributed dialog rows exactly - do not supplement an unattributed complete cell with attributed partial cells --- .../src/components/usage/UsageModelDialog.tsx | 2 +- packages/provider-opencode/src/server/driver.ts | 1 - .../src/server/usageLimits.test.ts | 9 +++++++++ .../provider-opencode/src/server/usageLimits.ts | 12 +++++++----- packages/shared/src/usageMerge.test.ts | 9 +++++++++ packages/shared/src/usageMerge.ts | 16 ++++++++++++---- 6 files changed, 38 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/usage/UsageModelDialog.tsx b/apps/web/src/components/usage/UsageModelDialog.tsx index cc32910efccd3..96677d8006577 100644 --- a/apps/web/src/components/usage/UsageModelDialog.tsx +++ b/apps/web/src/components/usage/UsageModelDialog.tsx @@ -53,7 +53,7 @@ export function UsageModelDialog({ (bucket) => bucket.provider === model.provider && bucket.model === model.model && - (model.upstreamProvider === undefined || bucket.upstreamProvider === model.upstreamProvider), + bucket.upstreamProvider === model.upstreamProvider, ); const providers = [model.provider]; const presentation = PROVIDER_PRESENTATION[model.provider]; diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 29dac93937166..01664b5e6ea57 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -546,7 +546,6 @@ export const OpenCodeDriver: ProviderDriver< Effect.annotateSpans({ providerInstanceId: instanceId }), ), usageLimits: checkUsageLimits, - integrationUsageLimits: checkIntegrationUsageLimits, }, { concurrency: "unbounded" }, ).pipe( diff --git a/packages/provider-opencode/src/server/usageLimits.test.ts b/packages/provider-opencode/src/server/usageLimits.test.ts index 4f7e57c667d78..d83a0f571bc8f 100644 --- a/packages/provider-opencode/src/server/usageLimits.test.ts +++ b/packages/provider-opencode/src/server/usageLimits.test.ts @@ -28,6 +28,13 @@ const MINIMAX_RESPONSE = { 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, + }, ], }; @@ -78,6 +85,7 @@ describe("mergeUsageLimits", () => { { checkedAt: "2026-11-10T00:00:00.000Z", windows: [], + credentialFingerprint: "go-account", unavailable: { reason: "unsupported" }, }, [ @@ -90,6 +98,7 @@ describe("mergeUsageLimits", () => { ], ); assert.strictEqual(merged.windows.length, 1); + assert.strictEqual(merged.credentialFingerprint, "go-account"); assert.isUndefined(merged.unavailable); }); diff --git a/packages/provider-opencode/src/server/usageLimits.ts b/packages/provider-opencode/src/server/usageLimits.ts index 9a727189bfc14..bd5143830b400 100644 --- a/packages/provider-opencode/src/server/usageLimits.ts +++ b/packages/provider-opencode/src/server/usageLimits.ts @@ -72,6 +72,7 @@ const MiniMaxQuotaResponse = Schema.Struct({ }); interface MiniMaxQuotaEntry { + readonly model_name?: string; readonly current_interval_remaining_percent?: number; readonly current_interval_status?: number; readonly end_time?: number; @@ -82,6 +83,7 @@ interface MiniMaxQuotaEntry { 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), @@ -241,6 +243,7 @@ export const readOpenCodeIntegrationUsageLimits = Effect.fn("readOpenCodeIntegra const result = response.value; if (result.status !== 200) continue; const body = yield* HttpClientResponse.schemaBodyJson(MiniMaxQuotaResponse)(result).pipe( + Effect.timeout("5 seconds"), Effect.option, ); if (Option.isNone(body)) continue; @@ -250,7 +253,8 @@ export const readOpenCodeIntegrationUsageLimits = Effect.fn("readOpenCodeIntegra const entry: MiniMaxQuotaEntry | undefined = Option.getOrUndefined( miniMaxQuotaEntry(raw), ); - if (entry === undefined) continue; + // Other entries (e.g. video) meter separate quotas that text models never use. + if (entry === undefined || (entry.model_name ?? "general") !== "general") continue; const interval = miniMaxWindow( integrationId, quota.label, @@ -295,10 +299,8 @@ export function mergeUsageLimits( integrationWindows: readonly ServerProviderUsageWindow[], ): ServerProviderUsageLimits { if (integrationWindows.length === 0) return console; - return { - checkedAt: console.checkedAt, - windows: [...console.windows, ...integrationWindows], - }; + const { unavailable: _unavailable, ...rest } = console; + return { ...rest, windows: [...console.windows, ...integrationWindows] }; } /** External OpenCode servers own their credentials; never read the host's account for them. */ diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 828b652077f62..88062f5fa869f 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -312,6 +312,15 @@ 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); }); it("identifies an environment reporting an older contract version", () => { diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index b66bb7e513858..02868812f7239 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -132,12 +132,15 @@ function bucketsForSource(summary: UsageSummary, source: UsageSource): readonly ); } -function bucketKey(bucket: UsageBucket): string { +function bucketKey( + bucket: UsageBucket, + upstreamProvider: string | null = bucket.upstreamProvider ?? null, +): string { return JSON.stringify([ bucket.day, bucket.hourStart ?? null, bucket.provider, - bucket.upstreamProvider ?? null, + upstreamProvider, bucket.model, ]); } @@ -197,7 +200,11 @@ function claimSources(environments: readonly EnvironmentUsage[]): { } let seen = seenBucketKeysByFingerprint.get(key); if (seen === undefined) { - seen = new Set(bucketsForSource(owner.environment.summary, owner.source).map(bucketKey)); + seen = new Set( + bucketsForSource(owner.environment.summary, owner.source).map((bucket) => + bucketKey(bucket), + ), + ); seenBucketKeysByFingerprint.set(key, seen); } const supplemental = @@ -205,7 +212,8 @@ 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; + // An unattributed owner cell (e.g. from an older server) may hold these same records. + if (seen.has(cell) || seen.has(bucketKey(bucket, null))) continue; seen.add(cell); supplemental.add(bucket); added = true; From cbf1a55913ec6ae45daa55d6558d2570f9305fd9 Mon Sep 17 00:00:00 2001 From: Rodrigo Dantes Gonzalez Mantuano Date: Fri, 13 Nov 2026 14:15:20 +0100 Subject: [PATCH 03/10] fix(usage): settle the review findings on upstream usage and limits The rebase brought the Console budget back, so the provider now reads the Console budget and the integration quotas in one pass and the recovery that retries a failed read retries both. An integration's quota only counts when the stored credential is MiniMax's own plan key, so a key that belongs to another endpoint never leaves the machine. The quota body read is bounded, and only the bucket that meters the plan is reported, so an exhausted video allowance no longer rejects the chat models. The exhaustion lookup matches a window's integration exactly, the account identity covers the integration key next to the Console key, and a cell is matched against the owner's records whether or not either side knows the upstream, so overlapping scans are counted once. The usage dialog filters the reconciled contributions instead of the summaries, so its chart and the page's statistics agree. Model: minimax/MiniMax-M3.1-Flash, Harness: opencode --- apps/web/src/state/usage.ts | 7 +- .../src/server/driver.test.ts | 60 +-- .../provider-opencode/src/server/driver.ts | 3 +- .../src/server/usageLimits.test.ts | 124 ++++-- .../src/server/usageLimits.ts | 360 ++++++++++++------ packages/shared/src/usageLimits.test.ts | 1 - packages/shared/src/usageLimits.ts | 8 +- packages/shared/src/usageMerge.test.ts | 46 +++ packages/shared/src/usageMerge.ts | 48 ++- 9 files changed, 466 insertions(+), 191 deletions(-) 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/packages/provider-opencode/src/server/driver.test.ts b/packages/provider-opencode/src/server/driver.test.ts index 78202903b52b0..e02d13c49e9bc 100644 --- a/packages/provider-opencode/src/server/driver.test.ts +++ b/packages/provider-opencode/src/server/driver.test.ts @@ -88,6 +88,19 @@ const layer = Layer.mergeAll( Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, openCode2Runtime), ).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); +/** + * 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, @@ -96,15 +109,21 @@ const create = ( readonly environment?: NodeJS.ProcessEnv; } = {}, ) => - OpenCodeDriver.create({ - instanceId: ProviderInstanceId.make("opencode-test"), - displayName: undefined, - environment: 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)); + 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")); @@ -228,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(), @@ -335,13 +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, - { enabled: 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); @@ -983,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, @@ -1257,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)); @@ -1283,13 +1299,7 @@ it.layer(layerUpdate)("OpenCodeDriver updates", (it) => { () => Effect.gen(function* () { versionOutput.current = "1.18.18\n"; - // Sandbox HOME so the provider cannot read the developer's own OpenCode - // store, whose credentials would send the provider's quota probe out. - const fs = yield* FileSystem.FileSystem; - const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-driver-home-" }); - const instance = yield* create({}, noHttp, { - environment: { HOME: home, XDG_DATA_HOME: home }, - }); + const instance = yield* create({}, noHttp); // Let the startup status check claim the refresh permit, then wait behind it so it // cannot probe after the upgrade below. yield* Effect.yieldNow; diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 01664b5e6ea57..3024e1cfe0dd6 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -511,13 +511,14 @@ export const OpenCodeDriver: ProviderDriver< 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.windows), + (usageLimits, integration) => mergeUsageLimits(usageLimits, integration), { concurrent: true }, ); const checkProvider = Effect.all( diff --git a/packages/provider-opencode/src/server/usageLimits.test.ts b/packages/provider-opencode/src/server/usageLimits.test.ts index d83a0f571bc8f..9280b5395091c 100644 --- a/packages/provider-opencode/src/server/usageLimits.test.ts +++ b/packages/provider-opencode/src/server/usageLimits.test.ts @@ -1,6 +1,7 @@ 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"; @@ -38,22 +39,29 @@ const MINIMAX_RESPONSE = { ], }; +/** A credential store with one active key for the given integration. */ +const storeWithCredential = (integrationId: string, key: string) => + 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 })], + ); + return { dir, dataHome }; + }); + describe("readOpenCodeIntegrationUsageLimits", () => { it.effect("reads an integration's own quota instead of the OpenCode Console budget", () => 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", "minimax-coding-plan", JSON.stringify({ type: "key", key: "sk-test" })], - ); + 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))), @@ -75,7 +83,50 @@ describe("readOpenCodeIntegrationUsageLimits", () => { assert.strictEqual(weekly?.id, "minimax-coding-plan_weekly"); assert.strictEqual(weekly?.usedPercent, 9); assert.isDefined(interval?.resetsAt); - }).pipe(Effect.provide(NodeServices.layer)), + 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* readOpenCodeIntegrationUsageLimits({ + enabled: true, + serverUrl: "", + environment: { + HOME: dir, + XDG_DATA_HOME: dataHome, + }, + }).pipe(Effect.provideService(HttpClient.HttpClient, client)); + + // 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("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* readOpenCodeIntegrationUsageLimits({ + enabled: true, + serverUrl: "", + environment: { + HOME: dir, + XDG_DATA_HOME: dataHome, + }, + }).pipe(Effect.provideService(HttpClient.HttpClient, client)); + + assert.deepStrictEqual(result.windows, []); + assert.isUndefined(result.credentialFingerprint); + }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); }); @@ -88,14 +139,17 @@ describe("mergeUsageLimits", () => { credentialFingerprint: "go-account", unavailable: { reason: "unsupported" }, }, - [ - { - id: "minimax-coding-plan_weekly", - kind: "weekly", - label: "MiniMax Token Plan · Weekly", - usedPercent: 9, - }, - ], + { + 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"); @@ -108,6 +162,30 @@ describe("mergeUsageLimits", () => { windows: [], unavailable: { reason: "unsupported" as const }, }; - assert.deepStrictEqual(mergeUsageLimits(console, []), console); + 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"); }); }); diff --git a/packages/provider-opencode/src/server/usageLimits.ts b/packages/provider-opencode/src/server/usageLimits.ts index bd5143830b400..47ff14d9db838 100644 --- a/packages/provider-opencode/src/server/usageLimits.ts +++ b/packages/provider-opencode/src/server/usageLimits.ts @@ -60,7 +60,17 @@ const INTEGRATION_QUOTA_ENDPOINTS: Readonly< }, }; -// MiniMax answers with one entry per model and two metered windows. +/** + * 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({ @@ -76,7 +86,9 @@ interface MiniMaxQuotaEntry { 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; } @@ -87,12 +99,33 @@ const miniMaxQuotaEntry = Schema.decodeUnknownOption( 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()( @@ -104,42 +137,17 @@ class OpenCodeCredentialReadError extends Schema.TaggedError - Effect.try({ - try: () => { - const database = new NodeSqlite.DatabaseSync(databasePath, { readOnly: true }); - try { - database.exec("PRAGMA busy_timeout = 100"); - const row = 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`, - ) - .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; - } - return stored?.key.trim(); - } finally { - database.close(); - } - }, - catch: (cause) => new OpenCodeCredentialReadError({ databasePath, cause }), - }).pipe(Effect.orElseSucceed(() => undefined)); +interface StoredCredential { + readonly key: string; + /** The OpenCode deployment the credential belongs to, when the store names one. */ + readonly server: string | undefined; +} /** - * Every integration the local OpenCode store holds a key for, so a quota can be - * read from the provider that actually serves the model. + * Every active credential in the local OpenCode store, newest first per + * integration. One read, so every caller sees the same store. */ -const readStoredIntegrationKeys = (databasePath: string) => +const readStoredCredentials = (databasePath: string) => Effect.try({ try: () => { const database = new NodeSqlite.DatabaseSync(databasePath, { readOnly: true }); @@ -151,24 +159,70 @@ const readStoredIntegrationKeys = (databasePath: string) => WHERE active IS NOT 0 ORDER BY active DESC, time_created DESC, id DESC`, ) .all() as Array<{ readonly integration_id: unknown; readonly value: unknown }>; - const keys = new Map(); + const credentials = new Map(); for (const row of rows) { const integrationId = typeof row.integration_id === "string" ? row.integration_id : ""; - if (integrationId === "" || keys.has(integrationId)) continue; + 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) keys.set(integrationId, key); + if (!key) continue; + const server = stored?.metadata?.server; + credentials.set(integrationId, { + key, + server: typeof server === "string" ? server : undefined, + }); } - return keys; + return credentials; } finally { database.close(); } }, catch: (cause) => new OpenCodeCredentialReadError({ databasePath, cause }), - }).pipe(Effect.orElseSucceed(() => new Map())); + }).pipe(Effect.orElseSucceed(() => new Map())); + +/** 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 { + return MINI_MAX_HOSTS.has(new URL(server).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, @@ -178,6 +232,7 @@ const miniMaxWindow = ( 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. @@ -187,61 +242,102 @@ const miniMaxWindow = ( 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; +} + +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: { - readonly enabled: boolean; - readonly serverUrl: string; - readonly environment: NodeJS.ProcessEnv; - }) { + function* (input: OpenCodeUsageLimitsInput) { const checkedAt = DateTime.formatIso(yield* DateTime.now); - if (!input.enabled || input.serverUrl.trim()) { - return { checkedAt, windows: [] as readonly ServerProviderUsageWindow[] }; - } + 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 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 { databasePath } = yield* openCodeStorePaths(env); + const storedCredentials = yield* readStoredCredentials(databasePath); + // Only a credential that MiniMax itself issued may reach MiniMax's API. + const candidates = Object.entries(INTEGRATION_QUOTA_ENDPOINTS).filter(([integrationId]) => { + const credential = storedCredentials.get(integrationId); + return ( + credential !== undefined && + credential.key.startsWith(MINI_MAX_PLAN_KEY_PREFIX) && + servesMiniMax(credential.server) ); - const databaseFile = env.OPENCODE_DB ?? "opencode.db"; - const databasePath = path.resolve(dataHome, "opencode", databaseFile); - const storedKeys = yield* readStoredIntegrationKeys(databasePath); - const candidates = Object.entries(INTEGRATION_QUOTA_ENDPOINTS).filter(([integrationId]) => - storedKeys.has(integrationId), - ); - if (candidates.length === 0) - return { checkedAt, windows: [] as readonly ServerProviderUsageWindow[] }; + }); + if (candidates.length === 0) return empty; + + const digest = yield* Digest.Digest; + const credentialFingerprint = yield* digest + .digest( + Digest.sha256, + new TextEncoder().encode( + candidates + .map( + ([integrationId]) => + `${integrationId}\0${storedCredentials.get(integrationId)!.key}`, + ) + .sort() + .join("\0"), + ), + ) + .pipe(Effect.map(Hex.encode), Effect.orDie); const client = yield* HttpClient.HttpClient; const windows: ServerProviderUsageWindow[] = []; for (const [integrationId, quota] of candidates) { - const key = storedKeys.get(integrationId); - if (key === undefined) continue; + const credential = storedCredentials.get(integrationId); + if (credential === undefined) continue; const response = yield* client - .execute(HttpClientRequest.get(quota.endpoint).pipe(HttpClientRequest.bearerToken(key))) + .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, @@ -249,44 +345,60 @@ export const readOpenCodeIntegrationUsageLimits = Effect.fn("readOpenCodeIntegra if (Option.isNone(body)) continue; const payload = body.value; if ((payload.base_resp?.status_code ?? 0) !== 0) continue; - for (const raw of payload.model_remains ?? []) { - const entry: MiniMaxQuotaEntry | undefined = Option.getOrUndefined( - miniMaxQuotaEntry(raw), - ); - // Other entries (e.g. video) meter separate quotas that text models never use. - if (entry === undefined || (entry.model_name ?? "general") !== "general") continue; - const interval = miniMaxWindow( - integrationId, - quota.label, - "interval", - "session", - entry.current_interval_remaining_percent, - entry.current_interval_status, - entry.end_time, - ); - const weekly = miniMaxWindow( - integrationId, - quota.label, - "weekly", - "weekly", - entry.current_weekly_remaining_percent, - entry.current_weekly_status, - entry.weekly_end_time, - ); - if (interval !== null) windows.push(interval); - if (weekly !== null) windows.push(weekly); - } + 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, + ); + if (interval !== null) windows.push(interval); + if (weekly !== null) windows.push(weekly); } - return { checkedAt, windows }; + 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. * @@ -296,35 +408,38 @@ export const readOpenCodeIntegrationUsageLimits = Effect.fn("readOpenCodeIntegra */ export function mergeUsageLimits( console: ServerProviderUsageLimits, - integrationWindows: readonly ServerProviderUsageWindow[], + integration: OpenCodeIntegrationUsageLimits, ): ServerProviderUsageLimits { - if (integrationWindows.length === 0) return console; - const { unavailable: _unavailable, ...rest } = console; - return { ...rest, windows: [...console.windows, ...integrationWindows] }; + // 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, + ); + return { + ...consoleMetadata, + ...(credentialFingerprint === undefined ? {} : { credentialFingerprint }), + checkedAt: console.checkedAt, + windows: [...console.windows, ...integration.windows], + }; } /** External OpenCode servers own their credentials; never read the host's account for them. */ -export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")(function* (input: { - readonly enabled: boolean; - readonly serverUrl: string; - readonly environment: NodeJS.ProcessEnv; -}) { +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( @@ -339,17 +454,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; diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 10878c73b0ab2..e28cd93826f22 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -234,7 +234,6 @@ describe("new thread model fallback", () => { ).toEqual(usesFallback ? fallback : defaultSelection); }, ); - }); describe("pace", () => { diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 55e9101d75be8..2517104424c50 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -69,14 +69,14 @@ export function exhaustedUsageWindow( 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". + // exhausted. The Console budget is metered as "go" or "budget". const integration = model.split("/")[0] ?? ""; - const family = (value: string) => value.split("-")[0]!.split("_")[0]!; - const target = family(integration); return exhausted.find((window) => { const owner = window.id.split("_")[0]!; if (owner === "go") return integration === "opencode-go"; - return family(owner) === target; + // A Console workspace budget covers every model in the workspace. + if (owner === "budget") return true; + return owner === integration; }); } default: diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 88062f5fa869f..ec2e31dd1709d 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -321,6 +321,52 @@ describe("mergeUsage", () => { })), }); 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 02868812f7239..723b46e98df88 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -156,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[] = []; @@ -200,11 +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((bucket) => - bucketKey(bucket), + 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 = @@ -212,9 +227,18 @@ function claimSources(environments: readonly EnvironmentUsage[]): { let added = false; for (const bucket of bucketsForSource(environment.summary, source)) { const cell = bucketKey(bucket); - // An unattributed owner cell (e.g. from an older server) may hold these same records. - if (seen.has(cell) || seen.has(bucketKey(bucket, null))) 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; } @@ -318,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; @@ -406,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; @@ -421,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; From c785bca612391d248d2ac57d7d3fa579e7f05f67 Mon Sep 17 00:00:00 2001 From: Rodrigo Dantes Gonzalez Mantuano Date: Fri, 13 Nov 2026 14:43:05 +0100 Subject: [PATCH 04/10] docs(usage): say which upstream served an OpenCode model The Usage tab now names the upstream that served a model, and an OpenCode account on a MiniMax Token Plan reports that plan's allowance next to the Go budget. Model: minimax/MiniMax-M3.1-Flash, Harness: opencode --- docs/user/usage.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/user/usage.md b/docs/user/usage.md index e6b3a46d5a14e..ebf7f880ead84 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. @@ -151,8 +153,9 @@ context use against the model's limit. A tick on the bar marks where the provide automatically, when it reports that point. OpenCode Go reports its session, weekly, and monthly allowance when OpenCode runs locally in -the environment. T3 cannot report limits for external OpenCode servers because their credentials -belong to the remote server. Limits need an OpenCode Go API key, or an OpenCode Console connected +the environment. An OpenCode account on a MiniMax Token Plan also reports that plan's interval +and weekly allowance, next to the Go budget. T3 cannot report limits for external OpenCode +servers because their credentials 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 From dc0eea35158e4305048b3d7d14f888b2f0c7cffe Mon Sep 17 00:00:00 2001 From: Rodrigo Dantes Gonzalez Mantuano Date: Fri, 13 Nov 2026 15:17:13 +0100 Subject: [PATCH 05/10] fix(usage): only read a plan quota from the provider that owns the key The quota probe now asks OpenCode which endpoint each provider resolves to, and it sends a credential only when that endpoint is MiniMax's own and the credential carries MiniMax's plan-key format. A provider pointed at another endpoint, or a credential that names another endpoint itself, is not probed at all, so its key never leaves the machine. A provider OpenCode does not name is not probed either: an unknown endpoint means an unknown key owner. One server call reads the Console limits and the provider endpoints, so the server is contacted once. Each window now names the credential that meters it. Two environments that share a Console key but not a plan key stay separate accounts, and a pool counts the shared credential once instead of averaging it twice. Model: minimax/MiniMax-M3.1-Flash, Harness: opencode --- docs/user/usage.md | 12 +- packages/contracts/src/providerUsageLimits.ts | 2 + .../provider-opencode/src/server/driver.ts | 75 +++++---- .../src/server/usageLimits.test.ts | 159 +++++++++++++++--- .../src/server/usageLimits.ts | 73 +++++++- .../src/server/v2/providerEndpoints.ts | 27 +++ packages/shared/src/usageLimits.test.ts | 27 +++ packages/shared/src/usageLimits.ts | 12 +- 8 files changed, 317 insertions(+), 70 deletions(-) create mode 100644 packages/provider-opencode/src/server/v2/providerEndpoints.ts diff --git a/docs/user/usage.md b/docs/user/usage.md index ebf7f880ead84..6512ec0772542 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -153,12 +153,16 @@ context use against the model's limit. A tick on the bar marks where the provide automatically, when it reports that point. OpenCode Go reports its session, weekly, and monthly allowance when OpenCode runs locally in -the environment. An OpenCode account on a MiniMax Token Plan also reports that plan's interval -and weekly allowance, next to the Go budget. T3 cannot report limits for external OpenCode -servers because their credentials belong to the remote server. Limits need an OpenCode Go API key, or an OpenCode Console connected +the environment. T3 cannot report limits for external OpenCode servers because their credentials +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/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 3024e1cfe0dd6..e7942df12c8d2 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -62,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 { @@ -478,49 +479,63 @@ export const OpenCodeDriver: ProviderDriver< Effect.provideService(Digest.Digest, digest), ); let unsupportedUsageVersion: string | undefined; - const checkConsoleUsageLimits = Effect.gen(function* () { - if (!effectiveConfig.enabled) return yield* legacyUsageLimits; + const noEndpoints = new Map(); + // One server call reads the Console limits and the endpoint of every + // provider, so the server is contacted once and the quota probe knows + // whose key it may send. + const checkOpenCodeLimits = Effect.gen(function* () { + if (!effectiveConfig.enabled) + return { limits: yield* legacyUsageLimits, endpoints: noEndpoints }; const runtime = yield* runtimeProbe.get; if (runtime.generation === "v1" || runtime.version === unsupportedUsageVersion) - return yield* legacyUsageLimits; - const limits = yield* openCode2Server.withConnection((connection) => - connection.usageLimits(host.paths.cwd), + return { limits: yield* legacyUsageLimits, endpoints: noEndpoints }; + const read = yield* openCode2Server.withConnection((connection) => + Effect.zipWith( + connection.usageLimits(host.paths.cwd), + readProviderEndpoints(connection, host.paths.cwd), + (limits, endpoints) => ({ limits, endpoints }), + ), ); - if (limits === undefined) unsupportedUsageVersion = runtime.version; - return limits ?? (yield* legacyUsageLimits); + if (read.limits === undefined) unsupportedUsageVersion = runtime.version; + return { + limits: read.limits ?? (yield* legacyUsageLimits), + endpoints: read.endpoints, + }; }).pipe( Effect.timeout("10 seconds"), Effect.catch(() => Effect.map(DateTime.now, (now) => ({ - checkedAt: DateTime.formatIso(now), - windows: [], - unavailable: { - reason: "probeFailed" as const, - message: "OpenCode could not read workspace usage.", + limits: { + checkedAt: DateTime.formatIso(now), + windows: [], + unavailable: { + reason: "probeFailed" as const, + message: "OpenCode could not read workspace usage.", + }, }, + endpoints: noEndpoints, })), ), ); - // A custom provider meters its own plan, so its quota has to be read from - // that provider rather than from the OpenCode Console budget. - const checkIntegrationUsageLimits = readOpenCodeIntegrationUsageLimits({ - enabled: effectiveConfig.enabled, - serverUrl: effectiveConfig.serverUrl, - environment: processEnv, - }).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 checkUsageLimits = Effect.gen(function* () { + const { limits, endpoints } = yield* checkOpenCodeLimits; + // A custom provider meters its own plan, so its quota has to be read + // from that provider rather than from the OpenCode Console budget. + const integration = yield* readOpenCodeIntegrationUsageLimits({ + enabled: effectiveConfig.enabled, + serverUrl: effectiveConfig.serverUrl, + environment: processEnv, + endpoints, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, pathService), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(Digest.Digest, digest), + ); + return mergeUsageLimits(limits, integration); + }); const checkProvider = Effect.all( { // A configured loopback server reports its running version. Probe the local diff --git a/packages/provider-opencode/src/server/usageLimits.test.ts b/packages/provider-opencode/src/server/usageLimits.test.ts index 9280b5395091c..dc698234f33e3 100644 --- a/packages/provider-opencode/src/server/usageLimits.test.ts +++ b/packages/provider-opencode/src/server/usageLimits.test.ts @@ -17,6 +17,8 @@ const openDatabase = (filename: string) => 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: [ @@ -40,7 +42,11 @@ const MINIMAX_RESPONSE = { }; /** A credential store with one active key for the given integration. */ -const storeWithCredential = (integrationId: string, key: string) => +const storeWithCredential = ( + integrationId: string, + key: string, + metadata?: Record, +) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -53,11 +59,31 @@ const storeWithCredential = (integrationId: string, key: string) => ); yield* db.unsafe( "INSERT INTO credential (id, integration_id, value, active, time_created) VALUES (?, ?, ?, 1, 0)", - ["cred-1", integrationId, JSON.stringify({ type: "key", key })], + [ + "cred-1", + integrationId, + JSON.stringify({ type: "key", key, ...(metadata ? { metadata } : {}) }), + ], ); return { dir, dataHome }; }); +const readLimits = ( + dataHome: string, + dir: string, + client: HttpClient.HttpClient, + endpoints: ReadonlyMap, +) => + readOpenCodeIntegrationUsageLimits({ + enabled: true, + serverUrl: "", + environment: { + HOME: dir, + XDG_DATA_HOME: dataHome, + }, + endpoints, + }).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* () { @@ -66,14 +92,12 @@ describe("readOpenCodeIntegrationUsageLimits", () => { const client = HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(MINIMAX_RESPONSE))), ); - const result = yield* readOpenCodeIntegrationUsageLimits({ - enabled: true, - serverUrl: "", - environment: { - HOME: dir, - XDG_DATA_HOME: dataHome, - }, - }).pipe(Effect.provideService(HttpClient.HttpClient, client)); + const result = yield* readLimits( + dataHome, + dir, + client, + new Map([["minimax-coding-plan", MINIMAX_ENDPOINT]]), + ); assert.strictEqual(result.windows.length, 2); const [interval, weekly] = result.windows; @@ -94,14 +118,12 @@ describe("readOpenCodeIntegrationUsageLimits", () => { const client = HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(MINIMAX_RESPONSE))), ); - const result = yield* readOpenCodeIntegrationUsageLimits({ - enabled: true, - serverUrl: "", - environment: { - HOME: dir, - XDG_DATA_HOME: dataHome, - }, - }).pipe(Effect.provideService(HttpClient.HttpClient, client)); + const result = yield* readLimits( + dataHome, + dir, + client, + new Map([["minimax-coding-plan", MINIMAX_ENDPOINT]]), + ); // The video bucket is exhausted; reporting it would exhaust the plan too. assert.isTrue(result.windows.every((window) => window.usedPercent < 100)); @@ -115,14 +137,70 @@ describe("readOpenCodeIntegrationUsageLimits", () => { const client = HttpClient.make((request) => Effect.die(`The key left the machine: ${request.url}`), ); - const result = yield* readOpenCodeIntegrationUsageLimits({ - enabled: true, - serverUrl: "", - environment: { - HOME: dir, - XDG_DATA_HOME: dataHome, + const result = yield* readLimits( + dataHome, + dir, + client, + new Map([["minimax-coding-plan", 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, + new Map([["minimax-coding-plan", "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, new Map()); + + 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", }, - }).pipe(Effect.provideService(HttpClient.HttpClient, client)); + ); + + const client = HttpClient.make((request) => + Effect.die(`The key left the machine: ${request.url}`), + ); + const result = yield* readLimits( + dataHome, + dir, + client, + new Map([["minimax-coding-plan", MINIMAX_ENDPOINT]]), + ); assert.deepStrictEqual(result.windows, []); assert.isUndefined(result.credentialFingerprint); @@ -188,4 +266,35 @@ describe("mergeUsageLimits", () => { }); 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: "minimax-account", + }, + ); + assert.strictEqual(merged.windows[0]?.credentialFingerprint, "go-account"); + assert.strictEqual(merged.windows[1]?.credentialFingerprint, "minimax-account"); + }); }); diff --git a/packages/provider-opencode/src/server/usageLimits.ts b/packages/provider-opencode/src/server/usageLimits.ts index 47ff14d9db838..da84587cafd16 100644 --- a/packages/provider-opencode/src/server/usageLimits.ts +++ b/packages/provider-opencode/src/server/usageLimits.ts @@ -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({ @@ -141,6 +147,8 @@ 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; } /** @@ -170,9 +178,16 @@ const readStoredCredentials = (databasePath: string) => 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 credentials; @@ -198,7 +213,18 @@ function isOfficialConsole(server: string | undefined): boolean { function servesMiniMax(server: string | undefined): boolean { if (server === undefined) return true; try { - return MINI_MAX_HOSTS.has(new URL(server).host); + 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; } @@ -266,6 +292,12 @@ export interface OpenCodeUsageLimitsInput { readonly enabled: boolean; readonly serverUrl: string; readonly environment: NodeJS.ProcessEnv; + /** + * The endpoint OpenCode resolves for each provider, by provider or + * integration id. A provider without one is not probed: the key's owner + * would be unknown. + */ + readonly endpoints?: ReadonlyMap | undefined; } export interface OpenCodeIntegrationUsageLimits { @@ -297,11 +329,17 @@ export const readOpenCodeIntegrationUsageLimits = Effect.fn("readOpenCodeIntegra // Only a credential that MiniMax itself issued may reach MiniMax's API. const candidates = Object.entries(INTEGRATION_QUOTA_ENDPOINTS).filter(([integrationId]) => { const credential = storedCredentials.get(integrationId); - return ( - credential !== undefined && - credential.key.startsWith(MINI_MAX_PLAN_KEY_PREFIX) && - servesMiniMax(credential.server) - ); + if ( + credential === undefined || + !credential.key.startsWith(MINI_MAX_PLAN_KEY_PREFIX) || + !servesMiniMax(credential.server) + ) + return false; + // The provider must resolve to MiniMax's own endpoint, and the + // credential must not point somewhere else. + const endpoint = input.endpoints?.get(integrationId); + if (endpoint === undefined || !isMiniMaxEndpoint(endpoint)) return false; + return credential.baseURL === undefined || isMiniMaxEndpoint(credential.baseURL); }); if (candidates.length === 0) return empty; @@ -420,11 +458,23 @@ export function mergeUsageLimits( consoleMetadata.credentialFingerprint, integration.credentialFingerprint, ); + // Each window names the credential that meters it, so a pool counts one + // credential once even when two accounts report it. + const withCredential = ( + windows: readonly ServerProviderUsageWindow[], + fingerprint: string | undefined, + ) => + fingerprint === undefined + ? windows + : windows.map((window) => ({ ...window, credentialFingerprint: fingerprint })); return { ...consoleMetadata, ...(credentialFingerprint === undefined ? {} : { credentialFingerprint }), checkedAt: console.checkedAt, - windows: [...console.windows, ...integration.windows], + windows: [ + ...withCredential(console.windows, consoleMetadata.credentialFingerprint), + ...withCredential(integration.windows, integration.credentialFingerprint), + ], }; } @@ -518,7 +568,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.ts b/packages/provider-opencode/src/server/v2/providerEndpoints.ts new file mode 100644 index 0000000000000..ab027e54dfc2e --- /dev/null +++ b/packages/provider-opencode/src/server/v2/providerEndpoints.ts @@ -0,0 +1,27 @@ +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* connection.client.provider.list({ location: { directory } }).pipe( + Effect.map((list) => { + const endpoints = new Map(); + for (const provider of list.data) { + const baseURL = provider.settings?.baseURL; + if (typeof baseURL !== "string") continue; + endpoints.set(provider.id, baseURL); + if (provider.integrationID !== undefined) endpoints.set(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 e28cd93826f22..fdb7ec7841552 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -1082,6 +1082,33 @@ 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.each([ { reports: [ diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 2517104424c50..2f46113e602e8 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -631,8 +631,16 @@ 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. + const credential = window.credentialFingerprint; + if ( + credential !== undefined && + list.some((member) => member.window.credentialFingerprint === credential) + ) + continue; + list.push(member); + } else byKey.set(key, [member]); } } const pools = [...byKey].map(([key, members]): LimitPoolWindow => { From 628afe89527c1cb96a6ade603dd95271d3125910 Mon Sep 17 00:00:00 2001 From: Rodrigo Dantes Gonzalez Mantuano Date: Fri, 13 Nov 2026 15:40:44 +0100 Subject: [PATCH 06/10] fix(usage): read a provider's endpoint only when a credential needs it The endpoint OpenCode resolves for a provider is now read for the providers that have a credential to check, instead of on every probe. A probe that restarts an idle OpenCode server costs more than the plan quota it reads. Model: minimax/MiniMax-M3.1-Flash, Harness: opencode --- .../provider-opencode/src/server/driver.ts | 84 +++++++++---------- .../src/server/usageLimits.test.ts | 41 ++------- .../src/server/usageLimits.ts | 44 +++++----- 3 files changed, 70 insertions(+), 99 deletions(-) diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index e7942df12c8d2..e1807a90922c1 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -479,63 +479,59 @@ export const OpenCodeDriver: ProviderDriver< Effect.provideService(Digest.Digest, digest), ); let unsupportedUsageVersion: string | undefined; - const noEndpoints = new Map(); - // One server call reads the Console limits and the endpoint of every - // provider, so the server is contacted once and the quota probe knows - // whose key it may send. - const checkOpenCodeLimits = Effect.gen(function* () { - if (!effectiveConfig.enabled) - return { limits: yield* legacyUsageLimits, endpoints: noEndpoints }; + const checkConsoleUsageLimits = Effect.gen(function* () { + if (!effectiveConfig.enabled) return yield* legacyUsageLimits; const runtime = yield* runtimeProbe.get; if (runtime.generation === "v1" || runtime.version === unsupportedUsageVersion) - return { limits: yield* legacyUsageLimits, endpoints: noEndpoints }; - const read = yield* openCode2Server.withConnection((connection) => - Effect.zipWith( - connection.usageLimits(host.paths.cwd), - readProviderEndpoints(connection, host.paths.cwd), - (limits, endpoints) => ({ limits, endpoints }), - ), + return yield* legacyUsageLimits; + const limits = yield* openCode2Server.withConnection((connection) => + connection.usageLimits(host.paths.cwd), ); - if (read.limits === undefined) unsupportedUsageVersion = runtime.version; - return { - limits: read.limits ?? (yield* legacyUsageLimits), - endpoints: read.endpoints, - }; + if (limits === undefined) unsupportedUsageVersion = runtime.version; + return limits ?? (yield* legacyUsageLimits); }).pipe( Effect.timeout("10 seconds"), Effect.catch(() => Effect.map(DateTime.now, (now) => ({ - limits: { - checkedAt: DateTime.formatIso(now), - windows: [], - unavailable: { - reason: "probeFailed" as const, - message: "OpenCode could not read workspace usage.", - }, + checkedAt: DateTime.formatIso(now), + windows: [], + unavailable: { + reason: "probeFailed" as const, + message: "OpenCode could not read workspace usage.", }, - endpoints: noEndpoints, })), ), ); + // 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.gen(function* () { - const { limits, endpoints } = yield* checkOpenCodeLimits; - // A custom provider meters its own plan, so its quota has to be read - // from that provider rather than from the OpenCode Console budget. - const integration = yield* readOpenCodeIntegrationUsageLimits({ - enabled: effectiveConfig.enabled, - serverUrl: effectiveConfig.serverUrl, - environment: processEnv, - endpoints, - }).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, pathService), - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.provideService(Digest.Digest, digest), - ); - return mergeUsageLimits(limits, integration); - }); + 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/usageLimits.test.ts b/packages/provider-opencode/src/server/usageLimits.test.ts index dc698234f33e3..7e7de76cc9556 100644 --- a/packages/provider-opencode/src/server/usageLimits.test.ts +++ b/packages/provider-opencode/src/server/usageLimits.test.ts @@ -72,7 +72,7 @@ const readLimits = ( dataHome: string, dir: string, client: HttpClient.HttpClient, - endpoints: ReadonlyMap, + endpoint: string | undefined, ) => readOpenCodeIntegrationUsageLimits({ enabled: true, @@ -81,7 +81,7 @@ const readLimits = ( HOME: dir, XDG_DATA_HOME: dataHome, }, - endpoints, + resolveEndpoint: () => Effect.succeed(endpoint), }).pipe(Effect.provideService(HttpClient.HttpClient, client)); describe("readOpenCodeIntegrationUsageLimits", () => { @@ -92,12 +92,7 @@ describe("readOpenCodeIntegrationUsageLimits", () => { const client = HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(MINIMAX_RESPONSE))), ); - const result = yield* readLimits( - dataHome, - dir, - client, - new Map([["minimax-coding-plan", MINIMAX_ENDPOINT]]), - ); + const result = yield* readLimits(dataHome, dir, client, MINIMAX_ENDPOINT); assert.strictEqual(result.windows.length, 2); const [interval, weekly] = result.windows; @@ -118,12 +113,7 @@ describe("readOpenCodeIntegrationUsageLimits", () => { const client = HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(MINIMAX_RESPONSE))), ); - const result = yield* readLimits( - dataHome, - dir, - client, - new Map([["minimax-coding-plan", MINIMAX_ENDPOINT]]), - ); + 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)); @@ -137,12 +127,7 @@ describe("readOpenCodeIntegrationUsageLimits", () => { const client = HttpClient.make((request) => Effect.die(`The key left the machine: ${request.url}`), ); - const result = yield* readLimits( - dataHome, - dir, - client, - new Map([["minimax-coding-plan", MINIMAX_ENDPOINT]]), - ); + const result = yield* readLimits(dataHome, dir, client, MINIMAX_ENDPOINT); assert.deepStrictEqual(result.windows, []); assert.isUndefined(result.credentialFingerprint); @@ -156,12 +141,7 @@ describe("readOpenCodeIntegrationUsageLimits", () => { const client = HttpClient.make((request) => Effect.die(`The key left the machine: ${request.url}`), ); - const result = yield* readLimits( - dataHome, - dir, - client, - new Map([["minimax-coding-plan", "https://gateway.example/v1"]]), - ); + const result = yield* readLimits(dataHome, dir, client, "https://gateway.example/v1"); assert.deepStrictEqual(result.windows, []); assert.isUndefined(result.credentialFingerprint); @@ -175,7 +155,7 @@ describe("readOpenCodeIntegrationUsageLimits", () => { const client = HttpClient.make((request) => Effect.die(`The key left the machine: ${request.url}`), ); - const result = yield* readLimits(dataHome, dir, client, new Map()); + const result = yield* readLimits(dataHome, dir, client, undefined); assert.deepStrictEqual(result.windows, []); assert.isUndefined(result.credentialFingerprint); @@ -195,12 +175,7 @@ describe("readOpenCodeIntegrationUsageLimits", () => { const client = HttpClient.make((request) => Effect.die(`The key left the machine: ${request.url}`), ); - const result = yield* readLimits( - dataHome, - dir, - client, - new Map([["minimax-coding-plan", MINIMAX_ENDPOINT]]), - ); + const result = yield* readLimits(dataHome, dir, client, MINIMAX_ENDPOINT); assert.deepStrictEqual(result.windows, []); assert.isUndefined(result.credentialFingerprint); diff --git a/packages/provider-opencode/src/server/usageLimits.ts b/packages/provider-opencode/src/server/usageLimits.ts index da84587cafd16..ddbabe973f4cc 100644 --- a/packages/provider-opencode/src/server/usageLimits.ts +++ b/packages/provider-opencode/src/server/usageLimits.ts @@ -293,11 +293,12 @@ export interface OpenCodeUsageLimitsInput { readonly serverUrl: string; readonly environment: NodeJS.ProcessEnv; /** - * The endpoint OpenCode resolves for each provider, by provider or - * integration id. A provider without one is not probed: the key's owner - * would be unknown. + * The endpoint OpenCode resolves for a provider. A provider without one is + * not probed: the key's owner would be unknown. */ - readonly endpoints?: ReadonlyMap | undefined; + readonly resolveEndpoint?: + | ((integrationId: string) => Effect.Effect) + | undefined; } export interface OpenCodeIntegrationUsageLimits { @@ -327,20 +328,22 @@ export const readOpenCodeIntegrationUsageLimits = Effect.fn("readOpenCodeIntegra const { databasePath } = yield* openCodeStorePaths(env); const storedCredentials = yield* readStoredCredentials(databasePath); // Only a credential that MiniMax itself issued may reach MiniMax's API. - const candidates = Object.entries(INTEGRATION_QUOTA_ENDPOINTS).filter(([integrationId]) => { + const candidates: Array = []; + for (const integrationId of Object.keys(INTEGRATION_QUOTA_ENDPOINTS)) { const credential = storedCredentials.get(integrationId); - if ( - credential === undefined || - !credential.key.startsWith(MINI_MAX_PLAN_KEY_PREFIX) || - !servesMiniMax(credential.server) - ) - return false; + 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.endpoints?.get(integrationId); - if (endpoint === undefined || !isMiniMaxEndpoint(endpoint)) return false; - return credential.baseURL === undefined || isMiniMaxEndpoint(credential.baseURL); - }); + 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; @@ -349,10 +352,7 @@ export const readOpenCodeIntegrationUsageLimits = Effect.fn("readOpenCodeIntegra Digest.sha256, new TextEncoder().encode( candidates - .map( - ([integrationId]) => - `${integrationId}\0${storedCredentials.get(integrationId)!.key}`, - ) + .map(([integrationId, credential]) => `${integrationId}\0${credential.key}`) .sort() .join("\0"), ), @@ -361,9 +361,9 @@ export const readOpenCodeIntegrationUsageLimits = Effect.fn("readOpenCodeIntegra const client = yield* HttpClient.HttpClient; const windows: ServerProviderUsageWindow[] = []; - for (const [integrationId, quota] of candidates) { - const credential = storedCredentials.get(integrationId); - if (credential === undefined) continue; + 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( From 625720332fabd2b2eaa35da26a753f51fe2e3f27 Mon Sep 17 00:00:00 2001 From: Rodrigo Dantes Gonzalez Mantuano Date: Fri, 13 Nov 2026 15:51:09 +0100 Subject: [PATCH 07/10] fix(usage): fingerprint a plan by the credential that meters it The account names every credential that meters it, but each window now names only its own. Two environments that share a plan key stay one quota, even when one of them holds a further credential the other does not. Model: minimax/MiniMax-M3.1-Flash, Harness: opencode --- .../src/server/usageLimits.test.ts | 32 ++++++++- .../src/server/usageLimits.ts | 69 ++++++++++++------- 2 files changed, 74 insertions(+), 27 deletions(-) diff --git a/packages/provider-opencode/src/server/usageLimits.test.ts b/packages/provider-opencode/src/server/usageLimits.test.ts index 7e7de76cc9556..74d1264cd8262 100644 --- a/packages/provider-opencode/src/server/usageLimits.test.ts +++ b/packages/provider-opencode/src/server/usageLimits.test.ts @@ -120,6 +120,34 @@ describe("readOpenCodeIntegrationUsageLimits", () => { }).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"); @@ -264,12 +292,14 @@ describe("mergeUsageLimits", () => { kind: "weekly", label: "MiniMax Token Plan · Weekly", usedPercent: 9, + credentialFingerprint: "plan-account", }, ], credentialFingerprint: "minimax-account", }, ); assert.strictEqual(merged.windows[0]?.credentialFingerprint, "go-account"); - assert.strictEqual(merged.windows[1]?.credentialFingerprint, "minimax-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 ddbabe973f4cc..c81dbb1d2f6ed 100644 --- a/packages/provider-opencode/src/server/usageLimits.ts +++ b/packages/provider-opencode/src/server/usageLimits.ts @@ -347,17 +347,30 @@ export const readOpenCodeIntegrationUsageLimits = Effect.fn("readOpenCodeIntegra if (candidates.length === 0) return empty; const digest = yield* Digest.Digest; - const credentialFingerprint = yield* digest - .digest( - Digest.sha256, - new TextEncoder().encode( - candidates - .map(([integrationId, credential]) => `${integrationId}\0${credential.key}`) - .sort() - .join("\0"), - ), - ) - .pipe(Effect.map(Hex.encode), Effect.orDie); + 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[] = []; @@ -409,8 +422,15 @@ export const readOpenCodeIntegrationUsageLimits = Effect.fn("readOpenCodeIntegra plan.weekly_end_time, plan.weekly_start_time, ); - if (interval !== null) windows.push(interval); - if (weekly !== null) windows.push(weekly); + 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( @@ -458,23 +478,20 @@ export function mergeUsageLimits( consoleMetadata.credentialFingerprint, integration.credentialFingerprint, ); - // Each window names the credential that meters it, so a pool counts one - // credential once even when two accounts report it. - const withCredential = ( - windows: readonly ServerProviderUsageWindow[], - fingerprint: string | undefined, - ) => - fingerprint === undefined - ? windows - : windows.map((window) => ({ ...window, credentialFingerprint: fingerprint })); + // 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: [ - ...withCredential(console.windows, consoleMetadata.credentialFingerprint), - ...withCredential(integration.windows, integration.credentialFingerprint), - ], + windows: [...consoleWindows, ...integration.windows], }; } From e78e004a7670ead45c2c6cdc97fb2ca86285a41f Mon Sep 17 00:00:00 2001 From: Rodrigo Dantes Gonzalez Mantuano Date: Fri, 13 Nov 2026 16:13:57 +0100 Subject: [PATCH 08/10] fix(usage): keep the freshest report of a credential that repeats Two environments can report the same credential at different times, and the pool orders its members by reset time rather than by age. The fresher report of a credential is the one that counts now, so it replaces the older one instead of being dropped. Model: minimax/MiniMax-M3.1-Flash, Harness: opencode --- packages/shared/src/usageLimits.test.ts | 22 ++++++++++++++++++++++ packages/shared/src/usageLimits.ts | 20 +++++++++++++++----- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index fdb7ec7841552..88da0c4f13b40 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -1109,6 +1109,28 @@ describe("pooled account columns", () => { 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 2f46113e602e8..1b40ea03fa492 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -632,13 +632,23 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L : pooled; const list = byKey.get(key); if (list) { - // The same credential on two accounts is one allowance, so it counts once. + // 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; - if ( - credential !== undefined && - list.some((member) => member.window.credentialFingerprint === credential) - ) + 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]); } From 33f7834a43a6bb884e182ffcc97285c0a3516dc7 Mon Sep 17 00:00:00 2001 From: Rodrigo Dantes Gonzalez Mantuano Date: Fri, 13 Nov 2026 16:57:18 +0100 Subject: [PATCH 09/10] fix(usage): leave an integration unknown when its providers disagree Two providers can claim one integration and resolve to different endpoints. The last one written won, so the probe depended on the order of the list and could send a credential to the wrong endpoint. An integration whose providers disagree is now unknown, and the probe sends nothing for it. Model: minimax/MiniMax-M3.1-Flash, Harness: opencode --- .../src/server/v2/providerEndpoints.test.ts | 106 ++++++++++++++++++ .../src/server/v2/providerEndpoints.ts | 19 +++- 2 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 packages/provider-opencode/src/server/v2/providerEndpoints.test.ts 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..d32266c55dfb8 --- /dev/null +++ b/packages/provider-opencode/src/server/v2/providerEndpoints.test.ts @@ -0,0 +1,106 @@ +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; +} + +/** The provider list OpenCode answers with, with its resolved settings. */ +const providerEntry = ({ id, integrationID, baseURL }: ProviderEntry) => ({ + id, + ...(integrationID === undefined ? {} : { integrationID }), + ...(baseURL === undefined ? {} : { settings: { baseURL } }), +}); + +const connectionWith = (providers: readonly ProviderEntry[]) => + ({ + client: { + provider: { + list: () => Effect.succeed({ data: providers.map(providerEntry) }), + }, + }, + }) 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("keeps an endpoint when its providers 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", + }, + ]), + "/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"), + }, + }, + } 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 index ab027e54dfc2e..0c5c84354f07c 100644 --- a/packages/provider-opencode/src/server/v2/providerEndpoints.ts +++ b/packages/provider-opencode/src/server/v2/providerEndpoints.ts @@ -14,11 +14,26 @@ export const readProviderEndpoints = Effect.fn("OpenCode2Client.providerEndpoint return yield* connection.client.provider.list({ location: { directory } }).pipe( Effect.map((list) => { const endpoints = new Map(); + // Two providers that claim one integration with different endpoints leave + // it unknown: the probe must not guess which one meters the key. + const conflicting = new Set(); + const record = (key: string, baseURL: string) => { + if (conflicting.has(key)) return; + const known = endpoints.get(key); + if (known === undefined) { + endpoints.set(key, baseURL); + return; + } + if (known !== baseURL) { + endpoints.delete(key); + conflicting.add(key); + } + }; for (const provider of list.data) { const baseURL = provider.settings?.baseURL; if (typeof baseURL !== "string") continue; - endpoints.set(provider.id, baseURL); - if (provider.integrationID !== undefined) endpoints.set(provider.integrationID, baseURL); + record(provider.id, baseURL); + if (provider.integrationID !== undefined) record(provider.integrationID, baseURL); } return endpoints; }), From 1e1d3fbb87ccfeeddbaac31345c3e7413df878b8 Mon Sep 17 00:00:00 2001 From: Rodrigo Dantes Gonzalez Mantuano Date: Fri, 13 Nov 2026 17:13:12 +0100 Subject: [PATCH 10/10] fix(usage): fail closed when a provider's endpoint is unknown A provider without an endpoint left its integration resolvable through another provider of the same integration, and a model can resolve to another endpoint on its own. Either way the key may not be MiniMax's, so the integration stays unknown and the probe sends nothing for it. Model: minimax/MiniMax-M3.1-Flash, Harness: opencode --- .../src/server/v2/providerEndpoints.test.ts | 70 +++++++++++++++++-- .../src/server/v2/providerEndpoints.ts | 50 ++++++++++--- 2 files changed, 102 insertions(+), 18 deletions(-) diff --git a/packages/provider-opencode/src/server/v2/providerEndpoints.test.ts b/packages/provider-opencode/src/server/v2/providerEndpoints.test.ts index d32266c55dfb8..916cb01791084 100644 --- a/packages/provider-opencode/src/server/v2/providerEndpoints.test.ts +++ b/packages/provider-opencode/src/server/v2/providerEndpoints.test.ts @@ -10,6 +10,11 @@ interface ProviderEntry { 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, @@ -17,12 +22,21 @@ const providerEntry = ({ id, integrationID, baseURL }: ProviderEntry) => ({ ...(baseURL === undefined ? {} : { settings: { baseURL } }), }); -const connectionWith = (providers: readonly ProviderEntry[]) => +/** 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; @@ -64,7 +78,7 @@ describe("readProviderEndpoints", () => { }), ); - it.effect("keeps an endpoint when its providers agree", () => + it.effect("leaves an integration unknown when one of its providers has no endpoint", () => Effect.gen(function* () { const endpoints = yield* readProviderEndpoints( connectionWith([ @@ -73,14 +87,53 @@ describe("readProviderEndpoints", () => { 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", - }, + { 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", @@ -96,6 +149,9 @@ describe("readProviderEndpoints", () => { provider: { list: () => Effect.die("no server"), }, + model: { + list: () => Effect.succeed({ data: [] }), + }, }, } as unknown as OpenCode2Server.OpenCode2Connection, "/work", diff --git a/packages/provider-opencode/src/server/v2/providerEndpoints.ts b/packages/provider-opencode/src/server/v2/providerEndpoints.ts index 0c5c84354f07c..39876a23c69a4 100644 --- a/packages/provider-opencode/src/server/v2/providerEndpoints.ts +++ b/packages/provider-opencode/src/server/v2/providerEndpoints.ts @@ -11,14 +11,25 @@ export const readProviderEndpoints = Effect.fn("OpenCode2Client.providerEndpoint connection: OpenCode2Server.OpenCode2Connection, directory: string, ) { - return yield* connection.client.provider.list({ location: { directory } }).pipe( - Effect.map((list) => { + 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(); - // Two providers that claim one integration with different endpoints leave - // it unknown: the probe must not guess which one meters the key. - const conflicting = new Set(); - const record = (key: string, baseURL: string) => { - if (conflicting.has(key)) return; + // 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); @@ -26,12 +37,29 @@ export const readProviderEndpoints = Effect.fn("OpenCode2Client.providerEndpoint } if (known !== baseURL) { endpoints.delete(key); - conflicting.add(key); + unknown.add(key); } }; - for (const provider of list.data) { - const baseURL = provider.settings?.baseURL; - if (typeof baseURL !== "string") continue; + 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); }