Skip to content

Commit 4525b66

Browse files
feat: add OAuth provider usage tracking
Add an Effect-backed /usage service and generated SDK contract for OAuth usage from Anthropic Claude, OpenAI ChatGPT, GitHub Copilot, and Google Antigravity. Keep credentials read-only, cache snapshots in process by active credential identity, deduplicate in-flight work by complete auth state, and surface stale or provider-specific failures without refreshing tokens. Add an app-scoped, route-aware TUI usage resource with /usage commands, detail and sidebar views, background refresh, threshold toasts, config, keybinding metadata, and documentation. Refresh usage after every successful credential replacement and ignore late foreground responses after navigation, newer requests, or intervening dialogs. Cover provider normalization, credential rotation and concurrency, Antigravity local-cache behavior, Copilot origin safety and independent AI-credit accounting, HttpApi/OpenAPI parity, and TUI lifecycle, command, formatting, and toast behavior.
1 parent 38e10eb commit 4525b66

44 files changed

Lines changed: 7052 additions & 34 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/opencode/src/effect/app-runtime.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import { Worktree } from "@/worktree"
4646
import { Installation } from "@/installation"
4747
import { ShareNext } from "@/share/share-next"
4848
import { SessionShare } from "@/share/session"
49+
import { Usage } from "@/usage/usage"
4950
import { Npm } from "@opencode-ai/core/npm"
5051
import { memoMap } from "@opencode-ai/core/effect/memo-map"
5152
import { BackgroundJob } from "@/background/job"
@@ -105,6 +106,7 @@ export const AppLayer = AppNodeBuilderV1.build(
105106
Installation.node,
106107
ShareNext.node,
107108
SessionShare.node,
109+
Usage.node,
108110
]),
109111
).pipe(Layer.provideMerge(AppNodeBuilderV1.build(Ripgrep.node)), Layer.provideMerge(Observability.layer))
110112

packages/opencode/src/server/routes/instance/httpapi/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { QuestionApi } from "./groups/question"
2424
import { SessionApi } from "./groups/session"
2525
import { SyncApi } from "./groups/sync"
2626
import { TuiApi } from "./groups/tui"
27+
import { UsageApi } from "./groups/usage"
2728
import { WorkspaceApi } from "./groups/workspace"
2829
import { makeApi } from "@opencode-ai/protocol/api"
2930
import { LocationMiddleware } from "@opencode-ai/server/location"
@@ -73,6 +74,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
7374
.addHttpApi(SessionApi)
7475
.addHttpApi(SyncApi)
7576
.addHttpApi(TuiApi)
77+
.addHttpApi(UsageApi)
7678
.addHttpApi(WorkspaceApi)
7779
.middleware(SchemaErrorMiddleware)
7880

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { responseSchema } from "@/usage/types"
2+
import { Schema, SchemaGetter } from "effect"
3+
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
4+
import { Authorization } from "../middleware/authorization"
5+
import { InstanceContextMiddleware } from "../middleware/instance-context"
6+
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
7+
import { described } from "./metadata"
8+
9+
const root = "/usage"
10+
const QueryRefresh = Schema.String.check(Schema.isPattern(/^\s*(true|false|1|0)\s*$/i)).pipe(
11+
Schema.decodeTo(Schema.Boolean, {
12+
decode: SchemaGetter.transform((value) => {
13+
const normalized = value.trim().toLowerCase()
14+
return normalized === "true" || normalized === "1"
15+
}),
16+
encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
17+
}),
18+
)
19+
20+
export const UsageQuerySchema = Schema.Struct({
21+
...WorkspaceRoutingQueryFields,
22+
provider: Schema.optional(Schema.String),
23+
refresh: Schema.optional(QueryRefresh),
24+
})
25+
export const UsagePaths = {
26+
get: root,
27+
} as const
28+
29+
export const UsageApi = HttpApi.make("usage")
30+
.add(
31+
HttpApiGroup.make("usage")
32+
.add(
33+
HttpApiEndpoint.get("get", UsagePaths.get, {
34+
query: UsageQuerySchema,
35+
success: described(responseSchema, "Usage response"),
36+
}).annotateMerge(
37+
OpenApi.annotations({
38+
identifier: "usage.get",
39+
summary: "Get usage",
40+
description: "Fetch usage limits for authenticated providers.",
41+
}),
42+
),
43+
)
44+
.annotateMerge(
45+
OpenApi.annotations({
46+
title: "usage",
47+
description: "Experimental HttpApi usage routes.",
48+
}),
49+
)
50+
.middleware(InstanceContextMiddleware)
51+
.middleware(WorkspaceRoutingMiddleware)
52+
.middleware(Authorization),
53+
)
54+
.annotateMerge(
55+
OpenApi.annotations({
56+
title: "opencode experimental HttpApi",
57+
version: "0.0.1",
58+
description: "Experimental HttpApi surface for selected instance routes.",
59+
}),
60+
)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Usage } from "@/usage/usage"
2+
import { Effect } from "effect"
3+
import { HttpApiBuilder } from "effect/unstable/httpapi"
4+
import { InstanceHttpApi } from "../api"
5+
import { UsageQuerySchema } from "../groups/usage"
6+
7+
export const usageHandlers = HttpApiBuilder.group(InstanceHttpApi, "usage", (handlers) =>
8+
Effect.gen(function* () {
9+
const usage = yield* Usage.Service
10+
11+
const get = Effect.fn("UsageHttpApi.get")(function* (ctx: { query: typeof UsageQuerySchema.Type }) {
12+
return yield* usage.getResponse(ctx.query).pipe(Effect.orDie)
13+
})
14+
15+
return handlers.handle("get", get)
16+
}),
17+
)

packages/opencode/src/server/routes/instance/httpapi/public.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ const QueryParameterSchemas: Record<string, OpenApiSchema> = {
7171
"GET /api/session start": { type: "number" },
7272
"GET /api/session roots": QueryBooleanOpenApi,
7373
"GET /api/session/{sessionID}/message limit": { type: "number" },
74+
"GET /usage refresh": { type: "boolean" },
7475
}
7576

7677
const LegacyComponentDescriptions: Record<string, string> = {
@@ -269,6 +270,7 @@ function applyLegacySchemaOverrides(spec: OpenApiSpec) {
269270
}
270271
if (schemas.GlobalSession?.properties?.project)
271272
schemas.GlobalSession.properties.project = nullable(schemas.GlobalSession.properties.project)
273+
applyUsageSchemaNullability(schemas)
272274
const providerOptions = schemas.ProviderConfig?.properties?.options
273275
if (providerOptions) providerOptions.additionalProperties = {}
274276
const model = schemas.ProviderConfig?.properties?.models?.additionalProperties
@@ -278,6 +280,35 @@ function applyLegacySchemaOverrides(spec: OpenApiSpec) {
278280
if (syncInfo?.properties) makePropertiesNullable(syncInfo.properties)
279281
}
280282

283+
function applyUsageSchemaNullability(schemas: Record<string, OpenApiSchema>) {
284+
const result = schemas.UsageResult
285+
const properties = result?.properties
286+
const snapshot = properties?.snapshot
287+
if (!snapshot) return
288+
properties.snapshot = nullable(snapshot)
289+
makeUsageSnapshotNullable(snapshot)
290+
}
291+
292+
function makeUsageSnapshotNullable(schema: OpenApiSchema) {
293+
const target = schema.anyOf?.find((item) => item.type !== "null") ?? schema
294+
const properties = target.properties
295+
if (!properties) return
296+
const window = typeof properties.windows?.items === "object" ? properties.windows.items : undefined
297+
if (window?.properties) {
298+
if (window.properties.windowMinutes) window.properties.windowMinutes = nullable(window.properties.windowMinutes)
299+
if (window.properties.resetsAt) window.properties.resetsAt = nullable(window.properties.resetsAt)
300+
}
301+
const credits = properties.credits
302+
if (credits) {
303+
if (credits.properties?.balance) credits.properties.balance = nullable(credits.properties.balance)
304+
if (credits.properties?.total) credits.properties.total = nullable(credits.properties.total)
305+
if (credits.properties?.used) credits.properties.used = nullable(credits.properties.used)
306+
if (credits.properties?.remaining) credits.properties.remaining = nullable(credits.properties.remaining)
307+
properties.credits = nullable(credits)
308+
}
309+
if (properties.planType) properties.planType = nullable(properties.planType)
310+
}
311+
281312
function normalizeComponentDescriptions(spec: OpenApiSpec) {
282313
for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) {
283314
const description = LegacyComponentDescriptions[name]

packages/opencode/src/server/routes/instance/httpapi/server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { Snapshot } from "@/snapshot"
4747
import { Storage } from "@/storage/storage"
4848
import { ToolRegistry } from "@/tool/registry"
4949
import { Truncate } from "@/tool/truncate"
50+
import { Usage } from "@/usage/usage"
5051
import { Worktree } from "@/worktree"
5152
import { RuntimeFlags } from "@/effect/runtime-flags"
5253
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
@@ -105,6 +106,7 @@ import { layer as locationLayer } from "@opencode-ai/server/location"
105106
import { sessionLocationLayer } from "@opencode-ai/server/middleware/session-location"
106107
import { PtyEnvironment } from "@opencode-ai/server/pty-environment"
107108
import { schemaErrorLayer as v2SchemaErrorLayer } from "@opencode-ai/server/middleware/schema-error"
109+
import { usageHandlers } from "./handlers/usage"
108110
import { workspaceHandlers } from "./handlers/workspace"
109111
import { instanceContextLayer } from "./middleware/instance-context"
110112
import { workspaceRoutingLayer } from "./middleware/workspace-routing"
@@ -167,6 +169,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
167169
sessionHandlers,
168170
syncHandlers,
169171
tuiHandlers,
172+
usageHandlers,
170173
workspaceHandlers,
171174
]),
172175
)
@@ -252,6 +255,7 @@ const app = LayerNode.group([
252255
Command.node,
253256
Truncate.node,
254257
ToolRegistry.node,
258+
Usage.node,
255259
Format.node,
256260
Project.node,
257261
Vcs.node,

0 commit comments

Comments
 (0)