1import type { ServerProviderUsageWindow } from "@t3tools/contracts";
2import * as Crypto from "effect/Crypto";
3import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Option from "effect/Option";
7import * as Path from "effect/Path";
8import * as Schema from "effect/Schema";
9import * as Hex from "effect/encoding/Hex";
10import * as HttpClient from "effect/http/HttpClient";
11import * as HttpClientRequest from "effect/http/HttpClientRequest";
12import * as HttpClientResponse from "effect/http/HttpClientResponse";
13
14import {
15 clampPercent,
16 makeUnavailableUsageLimits,
17 makeUsageLimits,
18} from "@t3tools/provider-core/server/usageLimits";
19import * as HostProcess from "@t3tools/shared/HostProcess";
20
21const AuthFile = Schema.Struct({ "opencode-go": Schema.optionalKey(Schema.Unknown) });
22const ApiAuth = Schema.Struct({ type: Schema.Literal("api"), key: Schema.String });
23const decodeAuthFile = Schema.decodeEffect(Schema.fromJsonString(AuthFile));
24const decodeApiAuth = Schema.decodeUnknownOption(ApiAuth);
25const UsageWindow = Schema.Struct({
26 percent: Schema.Finite,
27 resetsAt: Schema.DateTimeUtcFromString,
28});
29const UsageResponse = Schema.Struct({
30 usage: Schema.Struct({ rolling: UsageWindow, weekly: UsageWindow, monthly: UsageWindow }),
31});
32
33/** External OpenCode servers own their credentials; never read the host's account for them. */
34export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")(function* (input: {
35 readonly enabled: boolean;
36 readonly serverUrl: string;
37 readonly environment: NodeJS.ProcessEnv;
38}) {
39 const checkedAt = DateTime.formatIso(yield* DateTime.now);
40 const unsupported = makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" });
41 if (!input.enabled || input.serverUrl.trim()) return unsupported;
42
43 return yield* Effect.gen(function* () {
44 const fs = yield* FileSystem.FileSystem;
45 const path = yield* Path.Path;
46 const env = input.environment;
47 const dataHome =
48 env.XDG_DATA_HOME ||
49 path.join(
50 env.HOME || env.USERPROFILE || (yield* HostProcess.HomeDirectory),
51 ".local",
52 "share",
53 );
54 const authPath = path.join(dataHome, "opencode", "auth.json");
55 const contents =
56 env.OPENCODE_AUTH_CONTENT ||
57 (yield* fs.readFileString(authPath).pipe(
58 Effect.catchTags({
59 PlatformError: (error) =>
60 error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error),
61 }),
62 ));
63 const auth = yield* decodeAuthFile(contents);
64 const apiAuth = decodeApiAuth(auth["opencode-go"]);
65 // OpenCode overlays stored API credentials after environment credentials.
66 const apiKey = (Option.isSome(apiAuth) ? apiAuth.value.key : env.OPENCODE_API_KEY)?.trim();
67 if (!apiKey) return unsupported;
68
69 const client = yield* HttpClient.HttpClient;
70 const response = yield* client.execute(
71 HttpClientRequest.get("https://opencode.ai/zen/go/v1/usage").pipe(
72 HttpClientRequest.bearerToken(apiKey),
73 ),
74 );
75 // A valid Zen key can exist without a Go subscription.
76 if (response.status === 403) return unsupported;
77 const body = yield* HttpClientResponse.filterStatusOk(response).pipe(
78 Effect.flatMap(HttpClientResponse.schemaBodyJson(UsageResponse)),
79 );
80 const windows: ServerProviderUsageWindow[] = [
81 {
82 id: "go_rolling",
83 kind: "session",
84 label: "Go · Session",
85 windowDurationMins: 5 * 60,
86 usedPercent: clampPercent(body.usage.rolling.percent),
87 resetsAt: DateTime.formatIso(body.usage.rolling.resetsAt),
88 },
89 {
90 id: "go_weekly",
91 kind: "weekly",
92 label: "Go · Weekly",
93 windowDurationMins: 7 * 24 * 60,
94 usedPercent: clampPercent(body.usage.weekly.percent),
95 resetsAt: DateTime.formatIso(body.usage.weekly.resetsAt),
96 },
97 {
98 id: "go_monthly",
99 kind: "monthly",
100 label: "Go · Monthly",
101 usedPercent: clampPercent(body.usage.monthly.percent),
102 resetsAt: DateTime.formatIso(body.usage.monthly.resetsAt),
103 },
104 ];
105 const crypto = yield* Crypto.Crypto;
106 // Go's usage response has no account ID. An unkeyed hash matches across
107 // environments without a shared secret. It permits offline guesses, but
108 // Go keys are randomly generated.
109 const credentialFingerprint = yield* crypto
110 .digest("SHA-256", new TextEncoder().encode(`opencode-go\0${apiKey}`))
111 .pipe(Effect.map(Hex.encode), Effect.orDie);
112 return {
113 ...makeUsageLimits({ checkedAt, windows }),
114 credentialFingerprint,
115 };
116 }).pipe(
117 Effect.timeout("5 seconds"),
118 Effect.orElseSucceed(() =>
119 makeUnavailableUsageLimits({
120 checkedAt,
121 reason: "probeFailed",
122 message: "OpenCode Go could not read usage.",
123 }),
124 ),
125 );
126});