Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/user/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,10 @@ anything. The command is offered only for providers that appear under **Usage

OpenCode Go reports its session, weekly, and monthly allowance when OpenCode runs locally in
the environment. T3 cannot report limits for external OpenCode servers because their credentials
belong to the remote server. Limits need an OpenCode Go API key. A Console sign-in alone
does not report them. Add your Go API key as `OPENCODE_API_KEY` in the OpenCode instance's
**Environment variables**, then refresh provider status. Cursor reports
belong to the remote server. Limits need an OpenCode Go API key, or an OpenCode Console connected
in OpenCode with an API key. A Console browser sign-in alone does not report them. You can also add
your Go API key as `OPENCODE_API_KEY` in the OpenCode instance's **Environment variables**, then
refresh provider status. Cursor reports
its monthly allowance, including separate Auto and API usage, using the CLI login or
`CURSOR_AUTH_TOKEN`. On macOS, this includes the default Keychain login after you enable Cursor
usage. Keychain login is used for limits only with Cursor's default API endpoint. If you configure
Expand Down
132 changes: 132 additions & 0 deletions packages/provider-opencode/src/server/status.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as NodeAssert from "node:assert/strict";
import * as NodeSqlite from "node:sqlite";

import * as NodeServices from "@effect/platform-node/NodeServices";
import { it } from "@effect/vitest";
Expand Down Expand Up @@ -98,6 +99,137 @@ it.effect("reads Go limits with the instance's XDG credentials and preserves res
}),
);

it.effect(
"uses the selected public Console key for Go limits, falling back only on auth rejection",
() =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const dataHome = yield* fs.makeTempDirectoryScoped();
yield* fs.makeDirectory(`${dataHome}/opencode`);
yield* fs.writeFileString(
`${dataHome}/opencode/auth.json`,
JSON.stringify({ "opencode-go": { type: "api", key: "go-key" } }),
);
const database = yield* Effect.acquireRelease(
Effect.sync(() => new NodeSqlite.DatabaseSync(`${dataHome}/opencode/opencode.db`)),
(database) => Effect.sync(() => database.close()),
);
database.exec(
"CREATE TABLE credential (id text PRIMARY KEY, integration_id text, value text, active integer, time_created integer, time_updated integer)",
);
const insert = database.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?)");
const window = { percent: 1, resetsAt: "2026-09-17T12:00:00.000Z" };
const consoleKey = { type: "key", key: "console-key" };
const fingerprints = new Map([
["console-key", "3e45cfde9158185930d4cc7020a4e390895574fd54bd8f9631d23a336bbf94f0"],
["go-key", "d0e590e93fc9bd9353f2b993c0c287391cae505da2a78cb698b75418d2f5367e"],
["inline-key", "85649de18e63305548b6407e185f708483727ee6189cc4bc9512ddb17e23e776"],
]);
for (const {
consoleRow = consoleKey,
active = 1,
consoleStatus = 200,
inline = false,
databaseFile,
requested,
} of [
{
consoleRow: { ...consoleKey, metadata: { server: "https://opencode.ai/console" } },
requested: ["console-key"],
},
{ consoleStatus: 401, requested: ["console-key", "go-key"] },
{ consoleStatus: 403, requested: ["console-key", "go-key"] },
{ consoleStatus: 500, requested: ["console-key"] },
{ active: 0, requested: ["go-key"] },
{
consoleRow: {
type: "oauth",
methodID: "device",
access: "token",
refresh: "refresh",
expires: 0,
},
requested: ["go-key"],
},
{
consoleRow: { ...consoleKey, metadata: { server: "https://console.example" } },
requested: ["go-key"],
},
{ inline: true, requested: ["inline-key"] },
{ databaseFile: "selected.db", requested: ["console-key"] },
{ databaseFile: ":memory:", requested: ["go-key"] },
{ active: null, requested: ["console-key"] },
]) {
database.exec("DELETE FROM credential");
insert.run("cred_selected", "opencode", JSON.stringify(consoleRow), active, 2, 2);
// Legacy selections follow creation order, even when an older key was updated later.
insert.run(
"cred_older",
"opencode",
JSON.stringify({ type: "key", key: "old-key" }),
active === null ? null : 0,
1,
9,
);
insert.run("cred_other", "other", JSON.stringify(consoleKey), 1, 10, 10);
if (databaseFile === "selected.db") {
yield* fs.writeFile(
`${dataHome}/opencode/selected.db`,
yield* fs.readFile(`${dataHome}/opencode/opencode.db`),
);
database
.prepare("UPDATE credential SET value = ? WHERE id = 'cred_selected'")
.run(JSON.stringify({ type: "key", key: "old-key" }));
}
const before = yield* fs.readFile(`${dataHome}/opencode/opencode.db`);
const authorizations: string[] = [];
const limits = yield* readOpenCodeGoUsageLimits({
enabled: true,
serverUrl: "",
environment: {
XDG_DATA_HOME: dataHome,
...(databaseFile !== undefined ? { OPENCODE_DB: databaseFile } : {}),
...(inline
? { OPENCODE_AUTH_CONTENT: '{"opencode-go":{"type":"api","key":"inline-key"}}' }
: {}),
},
}).pipe(
Effect.provideService(
HttpClient.HttpClient,
HttpClient.make((request) => {
NodeAssert.equal(request.url, "https://opencode.ai/zen/go/v1/usage");
const key = (request.headers.authorization ?? "").replace("Bearer ", "");
authorizations.push(key);
// The legacy key is expired in the regression; it only works in fallback scenarios.
const status =
key === "console-key" ? consoleStatus : requested.includes(key) ? 200 : 401;
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json(
{ usage: { rolling: window, weekly: window, monthly: window } },
{ status },
),
),
);
}),
),
);
NodeAssert.deepEqual(authorizations, requested);
NodeAssert.deepEqual(yield* fs.readFile(`${dataHome}/opencode/opencode.db`), before);
NodeAssert.equal(
limits.unavailable?.reason,
consoleStatus === 500 ? "probeFailed" : undefined,
);
NodeAssert.equal(limits.windows.length, consoleStatus === 500 ? 0 : 3);
NodeAssert.equal(
limits.credentialFingerprint,
consoleStatus === 500 ? undefined : fingerprints.get(requested.at(-1)!),
);
}
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);

it.effect("does not read local credentials for external or disabled OpenCode instances", () =>
Effect.gen(function* () {
for (const settings of [
Expand Down
67 changes: 61 additions & 6 deletions packages/provider-opencode/src/server/usageLimits.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import * as NodeSqlite from "node:sqlite";

import type { ServerProviderUsageWindow } from "@t3tools/contracts";
import * as Crypto from "effect/Crypto";
import * as DateTime from "effect/DateTime";
Expand All @@ -22,6 +24,15 @@ const AuthFile = Schema.Struct({ "opencode-go": Schema.optionalKey(Schema.Unknow
const ApiAuth = Schema.Struct({ type: Schema.Literal("api"), key: Schema.String });
const decodeAuthFile = Schema.decodeEffect(Schema.fromJsonString(AuthFile));
const decodeApiAuth = Schema.decodeUnknownOption(ApiAuth);
// OpenCode 2's database tags a stored key "key"; auth.json tags it "api".
const StoredKey = Schema.Struct({
type: Schema.Literal("key"),
key: Schema.String,
metadata: Schema.optionalKey(
Schema.NullOr(Schema.Struct({ server: Schema.optionalKey(Schema.Unknown) })),
),
});
const decodeStoredKey = Schema.decodeOption(Schema.fromJsonString(StoredKey));
const UsageWindow = Schema.Struct({
percent: Schema.Finite,
resetsAt: Schema.DateTimeUtcFromString,
Expand All @@ -30,6 +41,36 @@ const UsageResponse = Schema.Struct({
usage: Schema.Struct({ rolling: UsageWindow, weekly: UsageWindow, monthly: UsageWindow }),
});

// Select before decoding so an OAuth or self-hosted connection cannot fall
// through to another stored account. NULL-active rows predate the selection flag.
const readConsoleApiKey = (databasePath: string) =>
Effect.try(() => {
const database = new NodeSqlite.DatabaseSync(databasePath, { readOnly: true });
try {
database.exec("PRAGMA busy_timeout = 100");
const row = database
.prepare(
`SELECT value FROM credential
WHERE integration_id = 'opencode' AND active IS NOT 0
ORDER BY active DESC, time_created DESC, id DESC LIMIT 1`,
)
.get();
const stored =
typeof row?.value === "string"
? Option.getOrUndefined(decodeStoredKey(row.value))
: undefined;
const server = stored?.metadata?.server;
if (typeof server === "string") {
const url = new URL(server);
if (`${url.origin}${url.pathname.replace(/\/+$/, "")}` !== "https://opencode.ai/console")
return undefined;
}
return stored?.key.trim();
} finally {
database.close();
}
}).pipe(Effect.orElseSucceed(() => undefined));

/** External OpenCode servers own their credentials; never read the host's account for them. */
export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")(function* (input: {
readonly enabled: boolean;
Expand Down Expand Up @@ -63,15 +104,29 @@ export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")(
const auth = yield* decodeAuthFile(contents);
const apiAuth = decodeApiAuth(auth["opencode-go"]);
// OpenCode overlays stored API credentials after environment credentials.
const apiKey = (Option.isSome(apiAuth) ? apiAuth.value.key : env.OPENCODE_API_KEY)?.trim();
const goKey = (Option.isSome(apiAuth) ? apiAuth.value.key : env.OPENCODE_API_KEY)?.trim();
// Console supplies Go credentials in OpenCode 2. Inline auth explicitly
// overrides local credentials, including the Console connection.
const databaseFile = env.OPENCODE_DB ?? "opencode.db";
const consoleKey =
env.OPENCODE_AUTH_CONTENT || databaseFile === ":memory:"
? undefined
: yield* readConsoleApiKey(path.resolve(dataHome, "opencode", databaseFile));
let apiKey = consoleKey || goKey;
if (!apiKey) return unsupported;

const client = yield* HttpClient.HttpClient;
const response = yield* client.execute(
HttpClientRequest.get("https://opencode.ai/zen/go/v1/usage").pipe(
HttpClientRequest.bearerToken(apiKey),
),
);
const readUsage = (key: string) =>
client.execute(
HttpClientRequest.get("https://opencode.ai/zen/go/v1/usage").pipe(
HttpClientRequest.bearerToken(key),
),
);
let response = yield* readUsage(apiKey);
if ((response.status === 401 || response.status === 403) && goKey && goKey !== apiKey) {
apiKey = goKey;
response = yield* readUsage(apiKey);
}
// A valid Zen key can exist without a Go subscription.
if (response.status === 403) return unsupported;
const body = yield* HttpClientResponse.filterStatusOk(response).pipe(
Expand Down
Loading