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),