1import * as NodeSqlite from "node:sqlite";
2
3import type { ServerProviderUsageWindow } from "@t3tools/contracts";
4import * as Digest from "effect/crypto/Digest";
5import * as DateTime from "effect/DateTime";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Option from "effect/Option";
9import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";
11import * as Hex from "effect/encoding/Hex";
12import * as HttpClient from "effect/http/HttpClient";
13import * as HttpClientRequest from "effect/http/HttpClientRequest";
14import * as HttpClientResponse from "effect/http/HttpClientResponse";
15
16import {
17 clampPercent,
18 makeUnavailableUsageLimits,
19 makeUsageLimits,
20} from "@t3tools/provider-core/server/usageLimits";
21import * as HostProcess from "@t3tools/shared/HostProcess";
22
23const AuthFile = Schema.Struct({ "opencode-go": Schema.optionalKey(Schema.Unknown) });
24const ApiAuth = Schema.Struct({ type: Schema.Literal("api"), key: Schema.String });
25const decodeAuthFile = Schema.decodeEffect(Schema.fromJsonString(AuthFile));
26const decodeApiAuth = Schema.decodeUnknownOption(ApiAuth);
27// OpenCode 2's database tags a stored key "key"; auth.json tags it "api".
28const StoredKey = Schema.Struct({
29 type: Schema.Literal("key"),
30 key: Schema.String,
31 metadata: Schema.optionalKey(
32 Schema.NullOr(Schema.Struct({ server: Schema.optionalKey(Schema.Unknown) })),
33 ),
34});
35const decodeStoredKey = Schema.decodeOption(Schema.fromJsonString(StoredKey));
36const UsageWindow = Schema.Struct({
37 percent: Schema.Finite,
38 resetsAt: Schema.DateTimeUtcFromString,
39});
40const UsageResponse = Schema.Struct({
41 usage: Schema.Struct({ rolling: UsageWindow, weekly: UsageWindow, monthly: UsageWindow }),
42});
43
44// Select before decoding so an OAuth or self-hosted connection cannot fall
45// through to another stored account. NULL-active rows predate the selection flag.
46class OpenCodeCredentialReadError extends Schema.TaggedError<OpenCodeCredentialReadError>()(
47 "OpenCodeCredentialReadError",
48 { databasePath: Schema.String, cause: Schema.Defect() },
49) {
50 override get message(): string {
51 return `Could not read OpenCode credentials from ${this.databasePath}.`;
52 }
53}
54
55const readStoredApiKey = (databasePath: string, integrationId: "opencode" | "opencode-go") =>
56 Effect.try({
57 try: () => {
58 const database = new NodeSqlite.DatabaseSync(databasePath, { readOnly: true });
59 try {
60 database.exec("PRAGMA busy_timeout = 100");
61 const row = database
62 .prepare(
63 `SELECT value FROM credential
64 WHERE integration_id = ? AND active IS NOT 0
65 ORDER BY active DESC, time_created DESC, id DESC LIMIT 1`,
66 )
67 .get(integrationId);
68 const stored =
69 typeof row?.value === "string"
70 ? Option.getOrUndefined(decodeStoredKey(row.value))
71 : undefined;
72 const server = stored?.metadata?.server;
73 if (typeof server === "string") {
74 const url = new URL(server);
75 if (`${url.origin}${url.pathname.replace(/\/+$/, "")}` !== "https://opencode.ai/console")
76 return undefined;
77 }
78 return stored?.key.trim();
79 } finally {
80 database.close();
81 }
82 },
83 catch: (cause) => new OpenCodeCredentialReadError({ databasePath, cause }),
84 }).pipe(Effect.orElseSucceed(() => undefined));
85
86/** External OpenCode servers own their credentials; never read the host's account for them. */
87export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")(function* (input: {
88 readonly enabled: boolean;
89 readonly serverUrl: string;
90 readonly environment: NodeJS.ProcessEnv;
91}) {
92 const checkedAt = DateTime.formatIso(yield* DateTime.now);
93 const unsupported = makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" });
94 if (!input.enabled || input.serverUrl.trim()) return unsupported;
95
96 return yield* Effect.gen(function* () {
97 const fs = yield* FileSystem.FileSystem;
98 const path = yield* Path.Path;
99 const env = input.environment;
100 const dataHome =
101 env.XDG_DATA_HOME ||
102 path.join(
103 env.HOME || env.USERPROFILE || (yield* HostProcess.HomeDirectory),
104 ".local",
105 "share",
106 );
107 const authPath = path.join(dataHome, "opencode", "auth.json");
108 const contents =
109 env.OPENCODE_AUTH_CONTENT ||
110 (yield* fs.readFileString(authPath).pipe(
111 Effect.catchTags({
112 PlatformError: (error) =>
113 error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error),
114 }),
115 ));
116 const auth = yield* decodeAuthFile(contents);
117 const apiAuth = decodeApiAuth(auth["opencode-go"]);
118 // OpenCode overlays stored API credentials after environment credentials.
119 const goKey = (Option.isSome(apiAuth) ? apiAuth.value.key : env.OPENCODE_API_KEY)?.trim();
120 // Console supplies Go credentials in OpenCode 2. Inline auth explicitly
121 // overrides local credentials, including the Console connection.
122 const databaseFile = env.OPENCODE_DB ?? "opencode.db";
123 const databasePath = path.resolve(dataHome, "opencode", databaseFile);
124 const storedKeys =
125 env.OPENCODE_AUTH_CONTENT || databaseFile === ":memory:"
126 ? []
127 : [
128 yield* readStoredApiKey(databasePath, "opencode"),
129 // OpenCode 2 imports auth.json once, so a replaced Go key only exists here.
130 yield* readStoredApiKey(databasePath, "opencode-go"),
131 ];
132 const keys = [...new Set([...storedKeys, goKey].filter((key): key is string => Boolean(key)))];
133 let apiKey = keys[0];
134 if (!apiKey) return unsupported;
135
136 const client = yield* HttpClient.HttpClient;
137 const readUsage = (key: string) =>
138 client.execute(
139 HttpClientRequest.get("https://opencode.ai/zen/go/v1/usage").pipe(
140 HttpClientRequest.bearerToken(key),
141 ),
142 );
143 let response = yield* readUsage(apiKey);
144 for (const key of keys.slice(1)) {
145 if (response.status !== 401 && response.status !== 403) break;
146 apiKey = key;
147 response = yield* readUsage(apiKey);
148 }
149 // A valid Zen key can exist without a Go subscription.
150 if (response.status === 403) return unsupported;
151 const body = yield* HttpClientResponse.filterStatusOk(response).pipe(
152 Effect.flatMap(HttpClientResponse.schemaBodyJson(UsageResponse)),
153 );
154 const windows: ServerProviderUsageWindow[] = [
155 {
156 id: "go_rolling",
157 kind: "session",
158 label: "Go · Session",
159 windowDurationMins: 5 * 60,
160 usedPercent: clampPercent(body.usage.rolling.percent),
161 resetsAt: DateTime.formatIso(body.usage.rolling.resetsAt),
162 },
163 {
164 id: "go_weekly",
165 kind: "weekly",
166 label: "Go · Weekly",
167 windowDurationMins: 7 * 24 * 60,
168 usedPercent: clampPercent(body.usage.weekly.percent),
169 resetsAt: DateTime.formatIso(body.usage.weekly.resetsAt),
170 },
171 {
172 id: "go_monthly",
173 kind: "monthly",
174 label: "Go · Monthly",
175 usedPercent: clampPercent(body.usage.monthly.percent),
176 resetsAt: DateTime.formatIso(body.usage.monthly.resetsAt),
177 },
178 ];
179 const digest = yield* Digest.Digest;
180 // Unkeyed hashes match across environments; random Go keys resist offline guessing.
181 const credentialFingerprint = yield* digest
182 .digest(Digest.sha256, new TextEncoder().encode(`opencode-go\0${apiKey}`))
183 .pipe(Effect.map(Hex.encode), Effect.orDie);
184 return {
185 ...makeUsageLimits({ checkedAt, windows }),
186 credentialFingerprint,
187 };
188 }).pipe(
189 Effect.timeout("5 seconds"),
190 Effect.orElseSucceed(() =>
191 makeUnavailableUsageLimits({
192 checkedAt,
193 reason: "probeFailed",
194 message: "OpenCode Go could not read usage.",
195 }),
196 ),
197 );
198});