feat(core): update Copilot for token-based billing (#30181)

This commit is contained in:
Aiden Cline
2026-06-01 14:55:23 -05:00
committed by GitHub
parent fa23fb5d38
commit ae92f3158f
10 changed files with 340 additions and 77 deletions
@@ -1,53 +1,81 @@
import type { Model } from "@opencode-ai/sdk/v2"
import { Schema } from "effect"
import { Option, Schema } from "effect"
export const schema = Schema.Struct({
data: Schema.Array(
const item = Schema.Struct({
model_picker_enabled: Schema.Boolean,
id: Schema.String,
name: Schema.String,
// every version looks like: `{model.id}-YYYY-MM-DD`
version: Schema.String,
supported_endpoints: Schema.optional(Schema.Array(Schema.String)),
policy: Schema.optional(
Schema.Struct({
model_picker_enabled: Schema.Boolean,
id: Schema.String,
name: Schema.String,
// every version looks like: `{model.id}-YYYY-MM-DD`
version: Schema.String,
supported_endpoints: Schema.optional(Schema.Array(Schema.String)),
policy: Schema.optional(
Schema.Struct({
state: Schema.optional(Schema.String),
}),
),
capabilities: Schema.Struct({
family: Schema.String,
limits: Schema.Struct({
max_context_window_tokens: Schema.Number,
max_output_tokens: Schema.Number,
max_prompt_tokens: Schema.Number,
vision: Schema.optional(
Schema.Struct({
max_prompt_image_size: Schema.Number,
max_prompt_images: Schema.Number,
supported_media_types: Schema.Array(Schema.String),
}),
),
}),
supports: Schema.Struct({
adaptive_thinking: Schema.optional(Schema.Boolean),
max_thinking_budget: Schema.optional(Schema.Number),
min_thinking_budget: Schema.optional(Schema.Number),
reasoning_effort: Schema.optional(Schema.Array(Schema.String)),
streaming: Schema.Boolean,
structured_outputs: Schema.optional(Schema.Boolean),
tool_calls: Schema.Boolean,
vision: Schema.optional(Schema.Boolean),
}),
}),
state: Schema.optional(Schema.String),
}),
),
billing: Schema.optional(
Schema.Struct({
token_prices: Schema.optional(
Schema.Struct({
batch_size: Schema.Number,
default: Schema.Struct({
cache_price: Schema.Number,
input_price: Schema.Number,
output_price: Schema.Number,
}),
}),
),
}),
),
capabilities: Schema.Struct({
family: Schema.String,
limits: Schema.optional(
Schema.Struct({
max_context_window_tokens: Schema.optional(Schema.Number),
max_output_tokens: Schema.optional(Schema.Number),
max_prompt_tokens: Schema.optional(Schema.Number),
vision: Schema.optional(
Schema.Struct({
max_prompt_image_size: Schema.Number,
max_prompt_images: Schema.Number,
supported_media_types: Schema.Array(Schema.String),
}),
),
}),
),
supports: Schema.Struct({
adaptive_thinking: Schema.optional(Schema.Boolean),
max_thinking_budget: Schema.optional(Schema.Number),
min_thinking_budget: Schema.optional(Schema.Number),
reasoning_effort: Schema.optional(Schema.Array(Schema.String)),
streaming: Schema.optional(Schema.Boolean),
structured_outputs: Schema.optional(Schema.Boolean),
tool_calls: Schema.optional(Schema.Boolean),
vision: Schema.optional(Schema.Boolean),
}),
}),
})
type Item = Schema.Schema.Type<typeof schema>["data"][number]
const decodeModels = Schema.decodeUnknownSync(schema)
export const schema = Schema.Struct({
data: Schema.Array(Schema.Unknown),
})
function build(key: string, remote: Item, url: string, prev?: Model): Model {
type Item = Schema.Schema.Type<typeof item>
type SelectableItem = Item & {
capabilities: Item["capabilities"] & {
limits: NonNullable<Item["capabilities"]["limits"]> & {
max_output_tokens: number
max_prompt_tokens: number
}
supports: Item["capabilities"]["supports"] & {
tool_calls: boolean
}
}
}
const decodeModels = Schema.decodeUnknownSync(schema)
const decodeItem = Schema.decodeUnknownOption(item)
function build(key: string, remote: SelectableItem, url: string, prev?: Model): Model {
const reasoning =
!!remote.capabilities.supports.adaptive_thinking ||
!!remote.capabilities.supports.reasoning_effort?.length ||
@@ -58,6 +86,9 @@ function build(key: string, remote: Item, url: string, prev?: Model): Model {
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
const isMsgApi = remote.supported_endpoints?.includes("/v1/messages")
const prices = remote.billing?.token_prices
// Copilot prices are AIC per billing batch; OpenCode stores USD per million tokens.
const usdPerMillion = prices ? 10_000 / prices.batch_size : 0
const model: Model = {
id: key,
@@ -70,7 +101,7 @@ function build(key: string, remote: Item, url: string, prev?: Model): Model {
// API response wins
status: "active",
limit: {
context: remote.capabilities.limits.max_context_window_tokens,
context: remote.capabilities.limits.max_context_window_tokens ?? remote.capabilities.limits.max_prompt_tokens,
input: remote.capabilities.limits.max_prompt_tokens,
output: remote.capabilities.limits.max_output_tokens,
},
@@ -99,9 +130,13 @@ function build(key: string, remote: Item, url: string, prev?: Model): Model {
family: prev?.family ?? remote.capabilities.family,
name: prev?.name ?? remote.name,
cost: {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
input: (prices?.default.input_price ?? 0) * usdPerMillion,
output: (prices?.default.output_price ?? 0) * usdPerMillion,
cache: {
read: (prices?.default.cache_price ?? 0) * usdPerMillion,
// `/models` exposes cached-input reads only; per-request billing accounts for cache writes.
write: 0,
},
},
options: prev?.options ?? {},
headers: prev?.headers ?? {},
@@ -154,11 +189,20 @@ function build(key: string, remote: Item, url: string, prev?: Model): Model {
return model
}
function usable(item: Item): item is SelectableItem {
return (
item.policy?.state !== "disabled" &&
item.capabilities.limits?.max_output_tokens !== undefined &&
item.capabilities.limits.max_prompt_tokens !== undefined &&
item.capabilities.supports.tool_calls !== undefined
)
}
export async function get(
baseURL: string,
headers: HeadersInit = {},
existing: Record<string, Model> = {},
): Promise<Record<string, Model>> {
): Promise<{ models: Record<string, Model>; pickerEnabled: Set<string> }> {
const data = await fetch(`${baseURL}/models`, {
headers,
signal: AbortSignal.timeout(5_000),
@@ -171,7 +215,10 @@ export async function get(
const result = { ...existing }
const remote = new Map(
data.data.filter((m) => m.model_picker_enabled && m.policy?.state !== "disabled").map((m) => [m.id, m] as const),
data.data.flatMap((raw) => {
const item = Option.getOrUndefined(decodeItem(raw))
return item && usable(item) ? ([[item.id, item]] as const) : []
}),
)
// prune existing models whose api.id isn't in the endpoint response
@@ -190,7 +237,10 @@ export async function get(
result[id] = build(id, m, baseURL)
}
return result
return {
models: result,
pickerEnabled: new Set([...remote].filter(([, item]) => item.model_picker_enabled).map(([id]) => id)),
}
}
export * as CopilotModels from "./models"