feat(core): update Copilot for token-based billing (#30181)
This commit is contained in:
@@ -10,6 +10,8 @@ import { MessageV2 } from "@/session/message-v2"
|
||||
const log = Log.create({ service: "plugin.copilot" })
|
||||
|
||||
const CLIENT_ID = "Ov23li8tweQw6odWQebz"
|
||||
const API_VERSION = "2026-06-01"
|
||||
const UTILITY_MODELS = ["gpt-5.4-nano", "gpt-4.1", "gpt-4o", "gpt-4o-mini"]
|
||||
// Add a small safety buffer when polling to avoid hitting the server
|
||||
// slightly too early due to clock skew / timer drift.
|
||||
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 // 3 seconds
|
||||
@@ -56,11 +58,13 @@ function fix(model: Model, url: string): Model {
|
||||
|
||||
export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
const sdk = input.client
|
||||
let models: Record<string, Model> = {}
|
||||
return {
|
||||
provider: {
|
||||
id: "github-copilot",
|
||||
async models(provider, ctx) {
|
||||
if (ctx.auth?.type !== "oauth") {
|
||||
models = {}
|
||||
return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model, base())]))
|
||||
}
|
||||
|
||||
@@ -71,14 +75,23 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
{
|
||||
Authorization: `Bearer ${auth.refresh}`,
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
"X-GitHub-Api-Version": API_VERSION,
|
||||
},
|
||||
provider.models,
|
||||
).catch((error) => {
|
||||
log.error("failed to fetch copilot models", { error })
|
||||
return Object.fromEntries(
|
||||
Object.entries(provider.models).map(([id, model]) => [id, fix(model, base(auth.enterpriseUrl))]),
|
||||
)
|
||||
})
|
||||
)
|
||||
.then((result) => {
|
||||
models = result.models
|
||||
return Object.fromEntries(
|
||||
Object.entries(result.models).filter(([, model]) => result.pickerEnabled.has(model.api.id)),
|
||||
)
|
||||
})
|
||||
.catch((error) => {
|
||||
models = {}
|
||||
log.error("failed to fetch copilot models", { error })
|
||||
return Object.fromEntries(
|
||||
Object.entries(provider.models).map(([id, model]) => [id, fix(model, base(auth.enterpriseUrl))]),
|
||||
)
|
||||
})
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
@@ -342,9 +355,19 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
output.options.toolStreaming = false
|
||||
}
|
||||
},
|
||||
"experimental.provider.small_model": async (incoming, output) => {
|
||||
if (incoming.provider.id !== "github-copilot") return
|
||||
// GitHub exposes utility models for title generation without including them in the picker.
|
||||
output.model = UTILITY_MODELS.map((id) => models[id]).find((model) => model !== undefined)
|
||||
},
|
||||
"chat.headers": async (incoming, output) => {
|
||||
if (!incoming.model.providerID.includes("github-copilot")) return
|
||||
|
||||
output.headers["X-GitHub-Api-Version"] = API_VERSION
|
||||
if (incoming.agent === "title") {
|
||||
output.headers["X-Interaction-Type"] = "agent-session-name-generation"
|
||||
}
|
||||
|
||||
if (incoming.model.api.npm === "@ai-sdk/anthropic") {
|
||||
output.headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user