| 1 | import type { ServerProviderUsageWindow } from "@t3tools/contracts"; |
| 2 | import * as Crypto from "effect/Crypto"; |
| 3 | import * as DateTime from "effect/DateTime"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Option from "effect/Option"; |
| 7 | import * as Path from "effect/Path"; |
| 8 | import * as Schema from "effect/Schema"; |
| 9 | import * as Hex from "effect/encoding/Hex"; |
| 10 | import * as HttpClient from "effect/http/HttpClient"; |
| 11 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 12 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; |
| 13 | |
| 14 | import { |
| 15 | clampPercent, |
| 16 | makeUnavailableUsageLimits, |
| 17 | makeUsageLimits, |
| 18 | } from "@t3tools/provider-core/server/usageLimits"; |
| 19 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 20 | |
| 21 | const AuthFile = Schema.Struct({ "opencode-go": Schema.optionalKey(Schema.Unknown) }); |
| 22 | const ApiAuth = Schema.Struct({ type: Schema.Literal("api"), key: Schema.String }); |
| 23 | const decodeAuthFile = Schema.decodeEffect(Schema.fromJsonString(AuthFile)); |
| 24 | const decodeApiAuth = Schema.decodeUnknownOption(ApiAuth); |
| 25 | const UsageWindow = Schema.Struct({ |
| 26 | percent: Schema.Finite, |
| 27 | resetsAt: Schema.DateTimeUtcFromString, |
| 28 | }); |
| 29 | const 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. */ |
| 34 | export 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 | }); |