opencode/packages/core/src/usage-limits.ts

192 lines
7.3 KiB
TypeScript

export * as UsageLimits from "./usage-limits.js"
import { Model } from "@opencode/schema/model"
import { Money } from "@opencode/schema/money"
import { Provider } from "@opencode/schema/provider"
import { UsageLimits } from "@opencode/schema/usage-limits"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { httpClient } from "@opencode/util/effect/app-node-platform"
import { Context, Duration, Effect, Layer, Schema } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"
import { ConsoleAccess } from "./console-access.js"
import type { Credential } from "./credential.js"
import { Integration } from "./integration.js"
export const Info = UsageLimits.Info
export type Info = UsageLimits.Info
export class UnavailableError extends Schema.TaggedError<UnavailableError>()("UsageLimits.UnavailableError", {
message: Schema.String,
}) {}
export interface Interface {
/** The usage limits of the OpenCode Console workspace this location is signed in to, if it is. */
readonly get: () => Effect.Effect<Info | undefined, UnavailableError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/UsageLimits") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const integrations = yield* Integration.Service
const http = yield* HttpClient.HttpClient
const get = Effect.fn("UsageLimits.get")(function* () {
const connection = yield* integrations.connection.active(Integration.ID.make("opencode"))
if (!connection) return undefined
const credential = yield* integrations.connection
.resolve(connection)
.pipe(Effect.mapError((error) => new UnavailableError({ message: error.message })))
if (!credential || credential.type === "external") return undefined
const workspaceID = credential.metadata?.orgID
if (typeof workspaceID !== "string") return undefined
const server = ConsoleAccess.server(credential)
const [budgets, go] = yield* Effect.all(
[readBudgets(http, server, credential), readGo(http, server, credential, workspaceID)],
{ concurrency: 2 },
).pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(15),
orElse: () => Effect.fail(new UnavailableError({ message: "OpenCode Console usage limits timed out" })),
}),
)
return {
workspace: {
id: workspaceID,
...(typeof credential.metadata?.orgName === "string" ? { name: credential.metadata.orgName } : {}),
usageURL: `${server}/${workspaceID}/activity/overview`,
},
...(go === undefined ? {} : { go }),
budgets,
}
})
return Service.of({ get })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Integration.node, httpClient],
})
// One budget as `GET /api/policies/current-budgets` describes it.
const ConsoleBudget = Schema.Struct({
ruleID: Schema.String,
limitDollars: Schema.Finite,
spentDollars: Schema.Finite,
scope: UsageLimits.Budget.fields.scope,
per: UsageLimits.Budget.fields.per,
coverage: UsageLimits.Budget.fields.coverage,
models: Schema.Array(
Schema.Struct({
providerID: Schema.String,
modelID: Schema.String,
name: Schema.String,
spentDollars: Schema.optional(Schema.Finite),
}),
),
vendors: Schema.Array(Schema.Struct({ id: Schema.String, name: Schema.String })),
inputPriceAtLeast: Schema.optional(Schema.Finite),
picked: Schema.Boolean,
shared: Schema.Boolean,
resetsAt: Schema.DateFromString,
})
const GoMeter = Schema.Struct({
status: Schema.Literals(["ok", "rate-limited"]),
percent: Schema.Finite,
resetsAt: Schema.DateFromString,
})
const GoUsage = Schema.Struct({ usage: Schema.Struct({ rolling: GoMeter, weekly: GoMeter, monthly: GoMeter }) })
/** Every budget covering the member. Console refuses a service account's key with 403, and it has no budgets. */
function readBudgets(http: HttpClient.HttpClient, server: string, credential: Credential.Key | Credential.OAuth) {
return HttpClient.withScope(http)
.execute(
HttpClientRequest.get(`${server}/api/policies/current-budgets`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.setHeaders(ConsoleAccess.headers(credential)),
),
)
.pipe(
Effect.flatMap((response) =>
response.status === 403
? Effect.succeed([])
: HttpClientResponse.filterStatusOk(response).pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(Schema.Array(ConsoleBudget))),
Effect.map((budgets) =>
budgets.map((budget) => ({
id: budget.ruleID,
limit: Money.USD.make(budget.limitDollars),
spent: Money.USD.make(budget.spentDollars),
scope: budget.scope,
per: budget.per,
coverage: budget.coverage,
models: budget.models.map((model) => ({
providerID: Provider.ID.make(model.providerID),
modelID: Model.ID.make(model.modelID),
name: model.name,
...(model.spentDollars === undefined ? {} : { spent: Money.USD.make(model.spentDollars) }),
})),
vendors: budget.vendors.map((vendor) => vendor.name),
...(budget.inputPriceAtLeast === undefined
? {}
: { inputPriceAtLeast: Money.USDPerMillionTokens.make(budget.inputPriceAtLeast) }),
picked: budget.picked,
shared: budget.shared,
resetsAt: budget.resetsAt.getTime(),
})),
),
),
),
Effect.scoped,
Effect.mapError(
(error) => new UnavailableError({ message: `OpenCode Console budgets are unavailable: ${error.message}` }),
),
)
}
/**
* The Go allowance, from the inference API that shares the Console's origin. Go refuses (403) a workspace the member
* doesn't subscribe to Go in, which has no allowance to show.
*/
function readGo(
http: HttpClient.HttpClient,
server: string,
credential: Credential.Key | Credential.OAuth,
workspaceID: string,
) {
return HttpClient.withScope(http)
.execute(
HttpClientRequest.get(`${new URL(server).origin}/zen/go/v1/usage`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(credential.type === "oauth" ? credential.access : credential.key),
HttpClientRequest.setHeader("x-opencode-org-id", workspaceID),
),
)
.pipe(
Effect.flatMap((response) =>
response.status === 403
? Effect.succeed(undefined)
: HttpClientResponse.filterStatusOk(response).pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(GoUsage)),
Effect.map((body) => ({
rolling: meter(body.usage.rolling),
weekly: meter(body.usage.weekly),
monthly: meter(body.usage.monthly),
})),
),
),
Effect.scoped,
Effect.mapError(
(error) => new UnavailableError({ message: `OpenCode Go usage is unavailable: ${error.message}` }),
),
)
}
function meter(value: typeof GoMeter.Type) {
return { percent: value.percent, limited: value.status === "rate-limited", resetsAt: value.resetsAt.getTime() }
}