diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 67acbe0d35b05..851de4c72ffe3 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -14,6 +14,9 @@ export const ServerProviderUsageWindow = Schema.Struct({ id: TrimmedNonEmptyString, kind: Schema.Literals(["session", "weekly", "monthly", "other"]), label: TrimmedNonEmptyString, + /** When present, this limit applies only to these provider model IDs. */ + modelIds: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + providerIds: Schema.optional(Schema.Array(TrimmedNonEmptyString)), usedPercent: Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 100 })), resetsAt: Schema.optional(IsoDateTime), windowDurationMins: Schema.optional(NonNegativeInt), diff --git a/packages/provider-core/src/server/usageLimits.test.ts b/packages/provider-core/src/server/usageLimits.test.ts index 4b9c1b39c862b..48bf7a76e85fa 100644 --- a/packages/provider-core/src/server/usageLimits.test.ts +++ b/packages/provider-core/src/server/usageLimits.test.ts @@ -33,6 +33,29 @@ describe("applyUsageLimitsUpdate", () => { }, }); expect(next).toBe(published); + const scoped = { checkedAt, windows: [{ ...weekly, modelIds: ["opencode/a"] }] }; + const providerScoped = { checkedAt, windows: [{ ...weekly, providerIds: ["opencode"] }] }; + expect( + applyUsageLimitsUpdate({ + previous: providerScoped, + checkedAt, + update: { windows: [{ ...weekly, providerIds: ["console-custom"] }] }, + })?.windows[0]?.providerIds, + ).toEqual(["console-custom"]); + expect( + applyUsageLimitsUpdate({ + previous: scoped, + checkedAt, + update: { windows: [{ ...weekly, modelIds: ["opencode/a"] }] }, + }), + ).toBe(scoped); + expect( + applyUsageLimitsUpdate({ + previous: scoped, + checkedAt, + update: { windows: [{ ...weekly, modelIds: ["opencode/b"] }] }, + })?.windows[0]?.modelIds, + ).toEqual(["opencode/b"]); }); it("upserts by id and keeps the reset a percent-only update omits", () => { diff --git a/packages/provider-core/src/server/usageLimits.ts b/packages/provider-core/src/server/usageLimits.ts index 1e4bcd0090854..f9b8def9d65e6 100644 --- a/packages/provider-core/src/server/usageLimits.ts +++ b/packages/provider-core/src/server/usageLimits.ts @@ -94,6 +94,16 @@ function usageWindowEquals(a: ServerProviderUsageWindow, b: ServerProviderUsageW a.kind === b.kind && a.label === b.label && a.usedPercent === b.usedPercent && + (a.modelIds === b.modelIds || + (a.modelIds !== undefined && + b.modelIds !== undefined && + a.modelIds.length === b.modelIds.length && + a.modelIds.every((id, index) => id === b.modelIds?.[index]))) && + (a.providerIds === b.providerIds || + (a.providerIds !== undefined && + b.providerIds !== undefined && + a.providerIds.length === b.providerIds.length && + a.providerIds.every((id, index) => id === b.providerIds?.[index]))) && a.resetsAt === b.resetsAt && a.windowDurationMins === b.windowDurationMins ); diff --git a/packages/provider-opencode/src/server/driver.test.ts b/packages/provider-opencode/src/server/driver.test.ts index 6d74dc8dd2542..b041a017b10e9 100644 --- a/packages/provider-opencode/src/server/driver.test.ts +++ b/packages/provider-opencode/src/server/driver.test.ts @@ -737,6 +737,81 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); + it.effect.each([ + { status: 200, data: null, reason: "unsupported" }, + { status: 404, data: null, reason: "unsupported" }, + { status: 503, data: null, reason: "probeFailed" }, + { + status: 200, + data: { + workspace: { + id: "remote-workspace", + name: "Remote workspace", + usageURL: "https://opencode.ai/console/remote-workspace", + }, + budgets: [ + { + id: "all", + limit: 20, + spent: 15, + scope: "combined", + per: "set", + coverage: "all", + models: [], + shared: true, + resetsAt: 1793491200000, + }, + ], + }, + reason: undefined, + }, + ])( + "reads external workspace usage without local credentials, HTTP $status, $reason", + (scenario) => + Effect.gen(function* () { + const requested: string[] = []; + const replay = replayOpenCodeServer(OPENCODE_2_RESPONSES, "secret", requested); + const server = HttpClient.make((request) => { + assert.notInclude(request.url, "opencode.ai/zen"); + if (request.url.endsWith("/api/experimental/usage/limits")) { + assert.strictEqual(request.headers.authorization, "Basic b3BlbmNvZGU6c2VjcmV0"); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json( + { + location: { directory: "/work" }, + data: scenario.data, + }, + { status: scenario.status }, + ), + ), + ); + } + return replay.execute(request); + }); + const instance = yield* create( + { serverUrl: "https://remote.example", serverPassword: "secret" }, + server, + ); + const [snapshot] = Array.from( + yield* instance.snapshot.streamChanges.pipe( + Stream.filter((snapshot) => snapshot.usageLimits !== undefined), + Stream.take(1), + Stream.runCollect, + ), + ); + assert.strictEqual(snapshot?.usageLimits?.unavailable?.reason, scenario.reason); + if (scenario.reason === undefined) { + assert.strictEqual(snapshot?.usageLimits?.windows[0]?.usedPercent, 75); + assert.strictEqual(snapshot?.usageLimits?.externalUsage?.label, "Remote workspace"); + assert.deepStrictEqual(snapshot?.usageLimits?.windows[0]?.providerIds, []); + } else { + assert.deepStrictEqual(snapshot?.usageLimits?.windows, []); + } + }).pipe(Effect.scoped), + ); + it.effect.each( [ { name: "recovers" }, @@ -776,6 +851,10 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { ); } const path = new URL(request.url).pathname; + if (path === "/api/experimental/usage/limits") + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(null, { status: 404 })), + ); if (path === "/api/model") modelReads += 1; const body = path === "/api/info" diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 916606cec2884..1edcad5bd4c34 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -8,6 +8,7 @@ import * as Digest from "effect/crypto/Digest"; import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as DateTime from "effect/DateTime"; import * as Equal from "effect/Equal"; import * as Option from "effect/Option"; import * as FileSystem from "effect/FileSystem"; @@ -454,7 +455,7 @@ export const OpenCodeDriver: ProviderDriver< ), ); - const checkUsageLimits = readOpenCodeGoUsageLimits({ + const legacyUsageLimits = readOpenCodeGoUsageLimits({ enabled: effectiveConfig.enabled, serverUrl: effectiveConfig.serverUrl, environment: processEnv, @@ -464,6 +465,30 @@ export const OpenCodeDriver: ProviderDriver< Effect.provideService(HttpClient.HttpClient, httpClient), Effect.provideService(Digest.Digest, digest), ); + let unsupportedUsageVersion: string | undefined; + const checkUsageLimits = Effect.gen(function* () { + if (!effectiveConfig.enabled) return yield* legacyUsageLimits; + 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), + ); + if (limits === undefined) unsupportedUsageVersion = runtime.version; + return limits ?? (yield* legacyUsageLimits); + }).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.", + }, + })), + ), + ); const checkProvider = Effect.all( { // A configured loopback server reports its running version. Probe the local diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts b/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts index 30d00390769d2..b2a6bff12c1e6 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts @@ -248,3 +248,206 @@ describe("OpenCode2Client native image jobs", () => { }), ); }); + +describe("OpenCode2Client usage limits", () => { + it.effect("reads the connected workspace and maps Go and Console budgets", () => + Effect.gen(function* () { + let workspace = "wrk_remote"; + let usageURL: string | undefined; + const transport = HttpClient.make((request) => { + assert.strictEqual(request.headers.authorization, utf8Basic("remote-password")); + assert.deepStrictEqual(Array.from(request.urlParams), [ + ["location[directory]", "/remote/project"], + ]); + if (request.url === "https://remote.example/prefix/api/provider") + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ + data: [ + { id: "opencode", integrationID: "opencode" }, + { id: "console-custom", integrationID: "opencode" }, + { id: "opencode-go", integrationID: "opencode" }, + { id: "anthropic", integrationID: "anthropic" }, + { id: "byok" }, + ], + }), + ), + ); + assert.strictEqual( + request.url, + "https://remote.example/prefix/api/experimental/usage/limits", + ); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ + location: { directory: "/remote/project" }, + data: { + workspace: { + id: workspace, + name: workspace, + usageURL: usageURL ?? "https://opencode.ai/console/" + workspace, + }, + go: { + rolling: { percent: 23, limited: false, resetsAt: 1793491200000 }, + weekly: { percent: 99, limited: true, resetsAt: 1793491200000 }, + monthly: { percent: 12, limited: false, resetsAt: 1793491200000 }, + }, + budgets: [ + { + id: "all", + limit: 20, + spent: 15, + scope: "combined", + per: "set", + coverage: "all", + models: [], + vendors: [], + picked: false, + shared: true, + resetsAt: 1793491200000, + }, + { + id: "models", + limit: 10, + spent: 12, + scope: "each", + per: "model", + coverage: "models", + models: [ + { providerID: "opencode", modelID: "a", name: "Model A", spent: 12 }, + { providerID: "opencode", modelID: "b", name: "Model B", spent: 2 }, + ], + vendors: [], + picked: true, + shared: false, + resetsAt: 1793491200000, + }, + { + id: "zz_missing", + limit: 10, + spent: 10, + scope: "each", + per: "model", + coverage: "models", + models: [{ providerID: "opencode", modelID: "unknown", name: "Unknown spend" }], + shared: false, + resetsAt: 1793491200000, + }, + { + id: "zero", + limit: 0, + spent: 0, + scope: "each", + per: "set", + coverage: "all", + models: [], + vendors: [], + picked: false, + shared: false, + resetsAt: 1793491200000, + }, + ], + }, + }), + ), + ); + }); + const opencode = yield* OpenCode2Client.make.pipe( + Effect.provideService(HttpClient.HttpClient, transport), + ); + const api = yield* opencode.connect({ + baseUrl: "https://remote.example/prefix", + password: "remote-password", + }); + const first = yield* api.usageLimits("/remote/project"); + assert.isDefined(first); + assert.deepStrictEqual( + first?.windows.map((window) => window.usedPercent), + [23, 100, 12, 75, 100, 20, 100, 100], + ); + assert.isTrue( + first?.windows.every((window) => window.resetsAt === "2026-11-01T00:00:00.000Z"), + ); + assert.include(first?.windows[3]?.label ?? "", "Shared"); + assert.include(first?.windows[4]?.label ?? "", "Model A"); + assert.deepStrictEqual(first?.windows[4]?.modelIds, ["opencode/a"]); + assert.strictEqual(first?.windows[3]?.modelIds, undefined); + assert.deepStrictEqual(first?.windows[3]?.providerIds, ["opencode", "console-custom"]); + assert.deepStrictEqual(first?.windows[7]?.modelIds, []); + assert.include(first?.windows[7]?.label ?? "", "Most used model"); + assert.strictEqual(first?.credentialFingerprint, undefined); + workspace = "wrk_switched"; + const next = yield* api.usageLimits("/remote/project"); + assert.strictEqual(next?.externalUsage?.label, "wrk_switched"); + assert.strictEqual(next?.externalUsage?.url, "https://opencode.ai/console/wrk_switched"); + for (const url of [ + "not a URL", + "javascript:alert(1)", + "custom-app://usage", + "file:///tmp/usage", + ]) { + usageURL = url; + const invalid = yield* api.usageLimits("/remote/project"); + assert.strictEqual(invalid?.externalUsage, undefined); + assert.deepStrictEqual(invalid?.windows, next?.windows); + } + usageURL = "http://console.example/workspace/usage"; + assert.strictEqual((yield* api.usageLimits("/remote/project"))?.externalUsage?.url, usageURL); + }), + ); + + it.effect("recognizes an older server's SPA fallback", () => + Effect.gen(function* () { + const opencode = yield* OpenCode2Client.make.pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("OpenCode", { + headers: { "content-type": "text/html; charset=utf-8" }, + }), + ), + ), + ), + ), + ); + const api = yield* opencode.connect({ baseUrl: "https://old.example", password: "secret" }); + assert.strictEqual(yield* api.usageLimits("/remote"), undefined); + }), + ); + + it.effect("distinguishes missing endpoints, signed out, and failed remote probes", () => + Effect.gen(function* () { + for (const [status, body, reason] of [ + [404, {}, undefined], + [405, {}, undefined], + [200, { location: { directory: "/remote" }, data: null }, "unsupported"], + [503, {}, "probeFailed"], + [401, {}, "probeFailed"], + [200, { data: { workspace: {} } }, "probeFailed"], + ] as const) { + const opencode = yield* OpenCode2Client.make.pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(body, { status }))), + ), + ), + ); + const api = yield* opencode.connect({ + baseUrl: "https://remote.example", + password: "secret", + }); + const result = yield* api.usageLimits("/remote"); + if (reason === undefined) assert.strictEqual(result, undefined); + else assert.strictEqual(result?.unavailable?.reason, reason); + if (status === 200 && reason === "unsupported") + assert.include(result?.unavailable?.message ?? "", "signed in"); + } + }), + ); +}); diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Client.ts b/packages/provider-opencode/src/server/v2/OpenCode2Client.ts index 56514825788e0..a9458b70cdb04 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Client.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Client.ts @@ -16,6 +16,8 @@ import * as HttpClientError from "effect/http/HttpClientError"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { readServerUsageLimits } from "./usageLimits.ts"; + /** OpenCode 2 accepts only HTTP Basic auth, always with this user name. */ const OPENCODE_USERNAME = "opencode"; @@ -93,6 +95,9 @@ const decodeImageJob = Schema.decodeUnknownOption(NativeImageJob); /** events resolves on subscription acceptance; subscribe before work because the stream is volatile. */ export interface OpenCode2Api { readonly client: OpenCodeClient; + readonly usageLimits: ( + directory: string, + ) => Effect.Effect; readonly shellJobs?: ( sessionID: string, ) => Effect.Effect; @@ -263,6 +268,13 @@ export const make = Effect.gen(function* () { Effect.provideService(HttpClient.HttpClient, fillCompactionRecents(authenticated)), Effect.map((client) => ({ client, + usageLimits: (directory) => + readServerUsageLimits(directory).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.mapRequest(authenticated, HttpClientRequest.prependUrl(baseUrl)), + ), + ), shellJobs: Effect.fn("OpenCode2Client.shellJobs")(function (sessionID: string) { return authenticated .get(`${baseUrl}/api/experimental/job`, { diff --git a/packages/provider-opencode/src/server/v2/usageLimits.ts b/packages/provider-opencode/src/server/v2/usageLimits.ts new file mode 100644 index 0000000000000..420d3bfe1a1aa --- /dev/null +++ b/packages/provider-opencode/src/server/v2/usageLimits.ts @@ -0,0 +1,188 @@ +import type { ServerProviderUsageWindow } from "@t3tools/contracts"; +import { + clampPercent, + makeUnavailableUsageLimits, + makeUsageLimits, +} from "@t3tools/provider-core/server/usageLimits"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; + +const Instant = Schema.Finite.check( + Schema.isBetween({ minimum: -8640000000000000, maximum: 8640000000000000 }), +); +const Meter = Schema.Struct({ + percent: Schema.Finite, + limited: Schema.Boolean, + resetsAt: Instant, +}); +const BudgetModel = Schema.Struct({ + providerID: Schema.String, + modelID: Schema.String, + name: Schema.String, + spent: Schema.optionalKey(Schema.Finite), +}); +// Decode only fields needed by T3, independently of the pinned 2.0 client. +const ResponseBody = Schema.Struct({ + location: Schema.Struct({ directory: Schema.String }), + data: Schema.NullOr( + Schema.Struct({ + workspace: Schema.Struct({ + id: Schema.String, + name: Schema.optionalKey(Schema.String), + usageURL: Schema.String, + }), + go: Schema.optionalKey(Schema.Struct({ rolling: Meter, weekly: Meter, monthly: Meter })), + budgets: Schema.Array( + Schema.Struct({ + id: Schema.String, + limit: Schema.Finite, + spent: Schema.Finite, + scope: Schema.Literals(["each", "combined"]), + per: Schema.Literals(["set", "model"]), + coverage: Schema.Literals(["all", "models"]), + models: Schema.Array(BudgetModel), + shared: Schema.Boolean, + resetsAt: Instant, + }), + ), + }), + ), +}); + +const Providers = Schema.Struct({ + data: Schema.Array( + Schema.Struct({ + id: Schema.String, + integrationID: Schema.optionalKey(Schema.String), + }), + ), +}); + +const resetTime = (ms: number) => DateTime.formatIso(DateTime.makeUnsafe(ms)); +const budgetPercent = (spent: number, limit: number) => + limit <= 0 ? 100 : clampPercent((spent / limit) * 100); + +/** Undefined alone permits the legacy credential reader; null data is authoritative. */ +export const readServerUsageLimits = Effect.fn("OpenCode2Client.usageLimits")(function* ( + directory: string, +) { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + return yield* Effect.gen(function* () { + const response = yield* HttpClient.get("/api/experimental/usage/limits", { + urlParams: { "location[directory]": directory }, + }); + if ( + response.status === 404 || + response.status === 405 || + (response.status === 200 && + response.headers["content-type"]?.split(";")[0]?.trim() === "text/html") + ) + return undefined; + const body = yield* HttpClientResponse.filterStatusOk(response).pipe( + Effect.flatMap(HttpClientResponse.schemaBodyJson(ResponseBody)), + ); + if (body.data === null) + return makeUnavailableUsageLimits({ + checkedAt, + reason: "unsupported", + message: "No OpenCode Console workspace is signed in.", + }); + const { go, budgets, workspace } = body.data; + const consoleProviders = budgets.some( + (budget) => budget.coverage === "all" && budget.per === "set", + ) + ? yield* HttpClient.get("/api/provider", { + urlParams: { "location[directory]": directory }, + }).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(Providers)), + Effect.map((page) => + page.data + .filter( + (provider) => + provider.integrationID === "opencode" && provider.id !== "opencode-go", + ) + .map((provider) => provider.id), + ), + Effect.orElseSucceed(() => []), + ) + : []; + const windows: ServerProviderUsageWindow[] = []; + if (go) { + for (const [key, kind, label, duration] of [ + ["rolling", "session", "Session", 300], + ["weekly", "weekly", "Weekly", 10080], + ["monthly", "monthly", "Monthly", undefined], + ] as const) { + const meter = go[key]; + windows.push({ + id: `go_${key}`, + kind, + label: `Go · ${label}`, + usedPercent: meter.limited ? 100 : clampPercent(meter.percent), + resetsAt: resetTime(meter.resetsAt), + ...(duration === undefined ? {} : { windowDurationMins: duration }), + }); + } + } + for (const budget of budgets) { + const scope = budget.shared || budget.scope === "combined" ? "Shared" : "Individual"; + const covered = + budget.coverage === "all" + ? "All spend" + : budget.models.map((model) => model.name).join(", ") || "Models"; + if ( + budget.per === "model" && + budget.coverage === "models" && + budget.models.length > 0 && + budget.models.every((model) => model.spent !== undefined) + ) { + for (const model of budget.models) { + windows.push({ + id: `budget_${budget.id}_${model.providerID}_${model.modelID}`, + kind: "other", + label: `Console · ${scope} · ${model.name}`, + modelIds: [`${model.providerID}/${model.modelID}`], + usedPercent: budgetPercent(model.spent ?? budget.spent, budget.limit), + resetsAt: resetTime(budget.resetsAt), + }); + } + } else { + windows.push({ + id: `budget_${budget.id}`, + kind: "other", + label: `Console · ${scope} · ${covered}${budget.per === "model" ? " · Most used model" : ""}`, + // Aggregate per-model spend cannot identify the exhausted model. + ...(budget.per === "model" + ? { modelIds: [] } + : budget.coverage === "models" + ? { + modelIds: budget.models.map((model) => `${model.providerID}/${model.modelID}`), + } + : { providerIds: consoleProviders }), + usedPercent: budgetPercent(budget.spent, budget.limit), + resetsAt: resetTime(budget.resetsAt), + }); + } + } + const usageURL = URL.parse(workspace.usageURL); + return { + ...makeUsageLimits({ checkedAt, windows }), + ...(usageURL?.protocol === "http:" || usageURL?.protocol === "https:" + ? { externalUsage: { label: workspace.name?.trim() || workspace.id, url: usageURL.href } } + : {}), + }; + }).pipe( + Effect.timeout("5 seconds"), + Effect.orElseSucceed(() => + makeUnavailableUsageLimits({ + checkedAt, + reason: "probeFailed", + message: "OpenCode could not read workspace usage.", + }), + ), + ); +}); diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index bed48428a93c4..828b32392e303 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -126,34 +126,88 @@ describe("new thread model fallback", () => { ).toBeDefined(); }); it.each([ - ["cursor", ["autoPercentUsed"], "claude-opus-5-5", false], - ["cursor", ["autoPercentUsed"], "composer-2.5", false], - ["cursor", ["autoPercentUsed", "apiPercentUsed"], "composer-2.5", true], - ["antigravity", ["gemini-weekly"], "claude-opus-5-5", false], - ["antigravity", ["3p-weekly"], "claude-opus-5-5", true], - ["antigravity", ["gemini-weekly"], "antigravity-default", true], - ["codex", ["individual"], "gpt-5.5", true], - ["opencode", ["go_weekly"], "opencode/big-pickle", false], - ["opencode", ["go_weekly"], "opencode-go/big-pickle", true], - ] as const)("matches %s pools %s to %s", (driver, ids, model, usesFallback) => { - const defaultSelection = { ...selection, model }; - expect( - resolveNewThreadModelSelection({ - ...defaults, - defaultSelection, - providers: [ - provider({ - driver: ProviderDriverKind.make(driver), - usageLimits: { - checkedAt: limited.checkedAt, - windows: ids.map((id) => ({ ...window, id, usedPercent: 100 })), - }, - }), - available, - ], - }).selection, - ).toEqual(usesFallback ? fallback : defaultSelection); - }); + ["cursor", ["autoPercentUsed"], "claude-opus-5-5", false, undefined, undefined], + ["cursor", ["autoPercentUsed"], "composer-2.5", false, undefined, undefined], + ["cursor", ["autoPercentUsed", "apiPercentUsed"], "composer-2.5", true, undefined, undefined], + ["antigravity", ["gemini-weekly"], "claude-opus-5-5", false, undefined, undefined], + ["antigravity", ["3p-weekly"], "claude-opus-5-5", true, undefined, undefined], + ["antigravity", ["gemini-weekly"], "antigravity-default", true, undefined, undefined], + ["codex", ["individual"], "gpt-5.5", true, undefined, undefined], + ["opencode", ["go_weekly"], "opencode/big-pickle", false, undefined, undefined], + ["opencode", ["go_weekly"], "opencode-go/big-pickle", true, undefined, undefined], + ["opencode", ["budget_model"], "opencode/other", false, ["opencode/limited"], undefined], + ["opencode", ["budget_model"], "opencode/limited", true, ["opencode/limited"], undefined], + [ + "opencode", + ["budget_set"], + "other/model", + false, + ["opencode/limited", "opencode/second"], + undefined, + ], + [ + "opencode", + ["budget_set"], + "opencode/second", + true, + ["opencode/limited", "opencode/second"], + undefined, + ], + ["opencode", ["budget_empty"], "opencode/other", false, [], undefined], + [ + "opencode", + ["budget_all"], + "anthropic/claude", + false, + undefined, + ["opencode", "console-custom"], + ], + [ + "opencode", + ["budget_all"], + "opencode-go/model", + false, + undefined, + ["opencode", "console-custom"], + ], + ["opencode", ["budget_all"], "opencode/model", true, undefined, ["opencode", "console-custom"]], + [ + "opencode", + ["budget_all"], + "console-custom/model", + true, + undefined, + ["opencode", "console-custom"], + ], + ["opencode", ["budget_all"], "opencode/model", false, undefined, []], + ] as const)( + "matches %s pools %s to %s", + (driver, ids, model, usesFallback, modelIds, providerIds) => { + const defaultSelection = { ...selection, model }; + expect( + resolveNewThreadModelSelection({ + ...defaults, + defaultSelection, + providers: [ + provider({ + driver: ProviderDriverKind.make(driver), + usageLimits: { + checkedAt: limited.checkedAt, + windows: ids.map((id) => ({ + ...window, + id, + usedPercent: 100, + ...(modelIds === undefined ? {} : { modelIds }), + ...(providerIds === undefined ? {} : { providerIds }), + })), + }, + }), + available, + ], + }).selection, + ).toEqual(usesFallback ? fallback : defaultSelection); + }, + ); }); describe("pace", () => { diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 716103ac2fc9b..88995b5d19939 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -32,6 +32,10 @@ export function exhaustedUsageWindow( const exhausted = windows.filter( (window) => (window.usedPercent ?? 0) >= 100 && + (model === undefined || window.modelIds === undefined || window.modelIds.includes(model)) && + (model === undefined || + window.providerIds === undefined || + window.providerIds.includes(model.split("/")[0] ?? "")) && (!window.resetsAt || !Number.isFinite(Date.parse(window.resetsAt)) || Date.parse(window.resetsAt) > now),