From fda014d2d3a335fbcd302ed246b257e0bac694a4 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Tue, 10 Nov 2026 13:07:05 -0600 Subject: [PATCH 1/3] feat(opencode): read usage limits from the connected server --- packages/contracts/src/providerUsageLimits.ts | 2 + .../src/server/usageLimits.test.ts | 15 ++ .../provider-core/src/server/usageLimits.ts | 5 + .../src/server/driver.test.ts | 78 ++++++++ .../provider-opencode/src/server/driver.ts | 27 ++- .../src/server/v2/OpenCode2Client.test.ts | 173 ++++++++++++++++++ .../src/server/v2/OpenCode2Client.ts | 12 ++ .../src/server/v2/usageLimits.ts | 157 ++++++++++++++++ packages/shared/src/usageLimits.test.ts | 30 ++- packages/shared/src/usageLimits.ts | 1 + 10 files changed, 489 insertions(+), 11 deletions(-) create mode 100644 packages/provider-opencode/src/server/v2/usageLimits.ts diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 5c4927d4a164e..4747d5ff0dcbc 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -14,6 +14,8 @@ 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)), 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..b62236a5122de 100644 --- a/packages/provider-core/src/server/usageLimits.test.ts +++ b/packages/provider-core/src/server/usageLimits.test.ts @@ -33,6 +33,21 @@ describe("applyUsageLimitsUpdate", () => { }, }); expect(next).toBe(published); + const scoped = { checkedAt, windows: [{ ...weekly, modelIds: ["opencode/a"] }] }; + 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..9b825ed7812e5 100644 --- a/packages/provider-core/src/server/usageLimits.ts +++ b/packages/provider-core/src/server/usageLimits.ts @@ -94,6 +94,11 @@ 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.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 0dcb6cc53997d..850a02d1b1d6c 100644 --- a/packages/provider-opencode/src/server/driver.test.ts +++ b/packages/provider-opencode/src/server/driver.test.ts @@ -511,6 +511,80 @@ 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"); + } else { + assert.deepStrictEqual(snapshot?.usageLimits?.windows, []); + } + }).pipe(Effect.scoped), + ); + it.effect.each( [ { name: "recovers" }, @@ -550,6 +624,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 5c60ccfe43278..d0a107579306b 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -4,6 +4,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 Option from "effect/Option"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -385,7 +386,7 @@ export const OpenCodeDriver: ProviderDriver< ), ); - const checkUsageLimits = readOpenCodeGoUsageLimits({ + const legacyUsageLimits = readOpenCodeGoUsageLimits({ enabled: effectiveConfig.enabled, serverUrl: effectiveConfig.serverUrl, environment: processEnv, @@ -395,6 +396,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( { provider: checkOpenCodeProviderStatus( diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts b/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts index 5ce763e024b86..33af1c489e4d4 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts @@ -167,3 +167,176 @@ describe("OpenCode2Client message history", () => { }), ); }); + +describe("OpenCode2Client usage limits", () => { + it.effect("reads the connected workspace and maps Go and Console budgets", () => + Effect.gen(function* () { + let workspace = "wrk_remote"; + const transport = HttpClient.make((request) => { + assert.strictEqual( + request.url, + "https://remote.example/prefix/api/experimental/usage/limits", + ); + assert.strictEqual(request.headers.authorization, utf8Basic("remote-password")); + assert.deepStrictEqual(Array.from(request.urlParams), [ + ["location[directory]", "/remote/project"], + ]); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ + location: { directory: "/remote/project" }, + data: { + workspace: { + id: workspace, + name: workspace, + 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[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"); + }), + ); + + 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 71fb1d70ce7d4..bef4556a30f91 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Client.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Client.ts @@ -14,6 +14,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"; @@ -53,6 +55,9 @@ export type OpenCode2StreamEvent = /** 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 events: Effect.Effect< Stream.Stream< OpenCode2StreamEvent, @@ -212,6 +217,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)), + ), + ), events: readEvents.pipe( Effect.provideService( HttpClient.HttpClient, 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..b5d5e48de3dc1 --- /dev/null +++ b/packages/provider-opencode/src/server/v2/usageLimits.ts @@ -0,0 +1,157 @@ +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 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 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}`), + } + : {}), + usedPercent: budgetPercent(budget.spent, budget.limit), + resetsAt: resetTime(budget.resetsAt), + }); + } + } + return { + ...makeUsageLimits({ checkedAt, windows }), + externalUsage: { label: workspace.name?.trim() || workspace.id, url: workspace.usageURL }, + }; + }).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 236a8baa13a26..57c81710a0a35 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -124,15 +124,20 @@ 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], - ["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) => { + ["cursor", ["autoPercentUsed"], "claude-opus-5-5", false, undefined], + ["cursor", ["autoPercentUsed"], "composer-2.5", false, undefined], + ["cursor", ["autoPercentUsed", "apiPercentUsed"], "composer-2.5", true, undefined], + ["antigravity", ["gemini-weekly"], "claude-opus-5-5", false, undefined], + ["antigravity", ["3p-weekly"], "claude-opus-5-5", true, undefined], + ["antigravity", ["gemini-weekly"], "antigravity-default", true, undefined], + ["opencode", ["go_weekly"], "opencode/big-pickle", false, undefined], + ["opencode", ["go_weekly"], "opencode-go/big-pickle", true, undefined], + ["opencode", ["budget_model"], "opencode/other", false, ["opencode/limited"]], + ["opencode", ["budget_model"], "opencode/limited", true, ["opencode/limited"]], + ["opencode", ["budget_set"], "other/model", false, ["opencode/limited", "opencode/second"]], + ["opencode", ["budget_set"], "opencode/second", true, ["opencode/limited", "opencode/second"]], + ["opencode", ["budget_empty"], "opencode/other", false, []], + ] as const)("matches %s pools %s to %s", (driver, ids, model, usesFallback, modelIds) => { const defaultSelection = { ...selection, model }; expect( resolveNewThreadModelSelection({ @@ -143,7 +148,12 @@ describe("new thread model fallback", () => { driver: ProviderDriverKind.make(driver), usageLimits: { checkedAt: limited.checkedAt, - windows: ids.map((id) => ({ ...window, id, usedPercent: 100 })), + windows: ids.map((id) => ({ + ...window, + id, + usedPercent: 100, + ...(modelIds === undefined ? {} : { modelIds }), + })), }, }), available, diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 1707deb735a49..e205bd7a5d162 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -30,6 +30,7 @@ export function exhaustedUsageWindow( const exhausted = (provider?.usageLimits?.windows ?? []).filter( (window) => (window.usedPercent ?? 0) >= 100 && + (model === undefined || window.modelIds === undefined || window.modelIds.includes(model)) && (!window.resetsAt || !Number.isFinite(Date.parse(window.resetsAt)) || Date.parse(window.resetsAt) > now), From 6714ba0567a749c3942777e5bf16717a0f49ce10 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Tue, 10 Nov 2026 21:43:22 -0600 Subject: [PATCH 2/3] fix(opencode): scope Console budgets to served providers --- packages/contracts/src/providerUsageLimits.ts | 1 + .../src/server/usageLimits.test.ts | 8 ++ .../provider-core/src/server/usageLimits.ts | 5 + .../src/server/driver.test.ts | 1 + .../src/server/v2/OpenCode2Client.test.ts | 24 +++- .../src/server/v2/usageLimits.ts | 30 ++++- packages/shared/src/usageLimits.test.ts | 118 ++++++++++++------ packages/shared/src/usageLimits.ts | 3 + 8 files changed, 148 insertions(+), 42 deletions(-) diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index c9ec85dbaa56b..851de4c72ffe3 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -16,6 +16,7 @@ export const ServerProviderUsageWindow = Schema.Struct({ 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 b62236a5122de..48bf7a76e85fa 100644 --- a/packages/provider-core/src/server/usageLimits.test.ts +++ b/packages/provider-core/src/server/usageLimits.test.ts @@ -34,6 +34,14 @@ 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, diff --git a/packages/provider-core/src/server/usageLimits.ts b/packages/provider-core/src/server/usageLimits.ts index 9b825ed7812e5..f9b8def9d65e6 100644 --- a/packages/provider-core/src/server/usageLimits.ts +++ b/packages/provider-core/src/server/usageLimits.ts @@ -99,6 +99,11 @@ function usageWindowEquals(a: ServerProviderUsageWindow, b: ServerProviderUsageW 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 850a02d1b1d6c..ff07a4fa56961 100644 --- a/packages/provider-opencode/src/server/driver.test.ts +++ b/packages/provider-opencode/src/server/driver.test.ts @@ -579,6 +579,7 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { 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, []); } diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts b/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts index 9d9c86cd57d2e..5cd066b0ba1c0 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts @@ -254,14 +254,29 @@ describe("OpenCode2Client usage limits", () => { Effect.gen(function* () { let workspace = "wrk_remote"; const transport = HttpClient.make((request) => { - assert.strictEqual( - request.url, - "https://remote.example/prefix/api/experimental/usage/limits", - ); 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, @@ -358,6 +373,7 @@ describe("OpenCode2Client usage limits", () => { 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); diff --git a/packages/provider-opencode/src/server/v2/usageLimits.ts b/packages/provider-opencode/src/server/v2/usageLimits.ts index b5d5e48de3dc1..bae22c7af43b6 100644 --- a/packages/provider-opencode/src/server/v2/usageLimits.ts +++ b/packages/provider-opencode/src/server/v2/usageLimits.ts @@ -52,6 +52,15 @@ const ResponseBody = Schema.Struct({ ), }); +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); @@ -82,6 +91,25 @@ export const readServerUsageLimits = Effect.fn("OpenCode2Client.usageLimits")(fu 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 [ @@ -134,7 +162,7 @@ export const readServerUsageLimits = Effect.fn("OpenCode2Client.usageLimits")(fu ? { modelIds: budget.models.map((model) => `${model.providerID}/${model.modelID}`), } - : {}), + : { providerIds: consoleProviders }), usedPercent: budgetPercent(budget.spent, budget.limit), resetsAt: resetTime(budget.resetsAt), }); diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index e45fe0148f791..51b344beea5f7 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -124,43 +124,87 @@ describe("new thread model fallback", () => { ).toBeDefined(); }); it.each([ - ["cursor", ["autoPercentUsed"], "claude-opus-5-5", false, undefined], - ["cursor", ["autoPercentUsed"], "composer-2.5", false, undefined], - ["cursor", ["autoPercentUsed", "apiPercentUsed"], "composer-2.5", true, undefined], - ["antigravity", ["gemini-weekly"], "claude-opus-5-5", false, undefined], - ["antigravity", ["3p-weekly"], "claude-opus-5-5", true, undefined], - ["antigravity", ["gemini-weekly"], "antigravity-default", true, undefined], - ["opencode", ["go_weekly"], "opencode/big-pickle", false, undefined], - ["opencode", ["go_weekly"], "opencode-go/big-pickle", true, undefined], - ["opencode", ["budget_model"], "opencode/other", false, ["opencode/limited"]], - ["opencode", ["budget_model"], "opencode/limited", true, ["opencode/limited"]], - ["opencode", ["budget_set"], "other/model", false, ["opencode/limited", "opencode/second"]], - ["opencode", ["budget_set"], "opencode/second", true, ["opencode/limited", "opencode/second"]], - ["opencode", ["budget_empty"], "opencode/other", false, []], - ] as const)("matches %s pools %s to %s", (driver, ids, model, usesFallback, modelIds) => { - 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 }), - })), - }, - }), - 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], + ["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 4d39ceb248b6e..dddb04014d855 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -32,6 +32,9 @@ export function exhaustedUsageWindow( (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), From 016ad8d0b32817c83cab5aa1e13635316287af45 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Tue, 10 Nov 2026 21:47:39 -0600 Subject: [PATCH 3/3] fix(opencode): validate workspace usage links --- .../src/server/v2/OpenCode2Client.test.ts | 16 +++++++++++++++- .../src/server/v2/usageLimits.ts | 5 ++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts b/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts index 5cd066b0ba1c0..b2a6bff12c1e6 100644 --- a/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts +++ b/packages/provider-opencode/src/server/v2/OpenCode2Client.test.ts @@ -253,6 +253,7 @@ 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), [ @@ -286,7 +287,7 @@ describe("OpenCode2Client usage limits", () => { workspace: { id: workspace, name: workspace, - usageURL: "https://opencode.ai/console/" + workspace, + usageURL: usageURL ?? "https://opencode.ai/console/" + workspace, }, go: { rolling: { percent: 23, limited: false, resetsAt: 1793491200000 }, @@ -381,6 +382,19 @@ describe("OpenCode2Client usage limits", () => { 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); }), ); diff --git a/packages/provider-opencode/src/server/v2/usageLimits.ts b/packages/provider-opencode/src/server/v2/usageLimits.ts index bae22c7af43b6..420d3bfe1a1aa 100644 --- a/packages/provider-opencode/src/server/v2/usageLimits.ts +++ b/packages/provider-opencode/src/server/v2/usageLimits.ts @@ -168,9 +168,12 @@ export const readServerUsageLimits = Effect.fn("OpenCode2Client.usageLimits")(fu }); } } + const usageURL = URL.parse(workspace.usageURL); return { ...makeUsageLimits({ checkedAt, windows }), - externalUsage: { label: workspace.name?.trim() || workspace.id, url: workspace.usageURL }, + ...(usageURL?.protocol === "http:" || usageURL?.protocol === "https:" + ? { externalUsage: { label: workspace.name?.trim() || workspace.id, url: usageURL.href } } + : {}), }; }).pipe( Effect.timeout("5 seconds"),