Skip to content
35 changes: 29 additions & 6 deletions packages/opencode/src/plugin/github-copilot/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())]))
}

Expand All @@ -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: {
Expand Down Expand Up @@ -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"
}
Expand Down
140 changes: 95 additions & 45 deletions packages/opencode/src/plugin/github-copilot/models.ts
Original file line number Diff line number Diff line change
@@ -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({
state: Schema.optional(Schema.String),
}),
),
billing: 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(
token_prices: Schema.optional(
Schema.Struct({
state: Schema.optional(Schema.String),
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.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),
}),
}),
}),
),
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),
}),
}),
})

export const schema = Schema.Struct({
data: Schema.Array(Schema.Unknown),
})

type Item = Schema.Schema.Type<typeof schema>["data"][number]
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: Item, url: string, prev?: Model): Model {
function build(key: string, remote: SelectableItem, url: string, prev?: Model): Model {
const reasoning =
!!remote.capabilities.supports.adaptive_thinking ||
!!remote.capabilities.supports.reasoning_effort?.length ||
Expand All @@ -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,
Expand All @@ -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,
},
Expand Down Expand Up @@ -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 ?? {},
Expand Down Expand Up @@ -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),
Expand All @@ -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
Expand All @@ -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"
13 changes: 13 additions & 0 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1765,6 +1765,19 @@ export const layer = Layer.effect(
const provider = s.providers[providerID]
if (!provider) return undefined

const experimental = yield* plugin.trigger<"experimental.provider.small_model">(
"experimental.provider.small_model",
{ provider: toPublicInfo(provider) },
{ model: undefined },
)
if (experimental.model) {
return {
...experimental.model,
id: ProviderV2.ModelID.make(experimental.model.id),
providerID: ProviderV2.ID.make(experimental.model.providerID),
}
}

const defaultPriority = [
"claude-haiku-4-5",
"claude-haiku-4.5",
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,8 @@ const live: Layer.Layer<
return {
type: "ai-sdk" as const,
result: streamText({
// Copilot returns the authoritative billed amount only in provider-specific response fields.
includeRawChunks: input.model.providerID.includes("github-copilot"),
onError(error) {
l.error("stream error", {
error,
Expand Down
52 changes: 43 additions & 9 deletions packages/opencode/src/session/llm/ai-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export function adapterState() {
currentTextID: undefined as string | undefined,
currentReasoningID: undefined as string | undefined,
toolNames: {} as Record<string, string>,
copilotTotalNanoAiu: undefined as number | undefined,
}
}

Expand All @@ -26,6 +27,20 @@ function providerMetadata(value: unknown): ProviderMetadata | undefined {
return Schema.is(ProviderMetadata)(value) ? value : undefined
}

// Temporary AI SDK bridge: Copilot billing survives only in raw provider chunks here.
// Move this extraction into @opencode-ai/llm when Copilot is handled by the native runtime.
function copilotTotalNanoAiu(value: unknown) {
if (!value || typeof value !== "object") return
const raw = value as Record<string, unknown>
const response =
raw.response && typeof raw.response === "object" ? (raw.response as Record<string, unknown>) : undefined
const usage = raw.copilot_usage ?? response?.copilot_usage
if (!usage || typeof usage !== "object") return
const total = (usage as Record<string, unknown>).total_nano_aiu
if (typeof total !== "number" || !Number.isFinite(total) || total < 0) return
return total
}

function usage(value: unknown) {
if (!value || typeof value !== "object") return undefined
const item = value as {
Expand Down Expand Up @@ -70,14 +85,28 @@ export function toLLMEvents(
return Effect.succeed([LLMEvent.stepStart({ index: state.step })])

case "finish-step":
return Effect.sync(() => [
LLMEvent.stepFinish({
index: state.step++,
reason: finishReason(event.finishReason),
usage: usage(event.usage),
providerMetadata: providerMetadata(event.providerMetadata),
}),
])
return Effect.sync(() => {
const original = providerMetadata(event.providerMetadata)
const metadata =
state.copilotTotalNanoAiu === undefined
? original
: {
...original,
copilot: {
...original?.copilot,
totalNanoAiu: state.copilotTotalNanoAiu,
},
}
state.copilotTotalNanoAiu = undefined
return [
LLMEvent.stepFinish({
index: state.step++,
reason: finishReason(event.finishReason),
usage: usage(event.usage),
providerMetadata: metadata,
}),
]
})

case "finish":
return Effect.sync(() => {
Expand Down Expand Up @@ -238,11 +267,16 @@ export function toLLMEvents(
case "abort":
case "source":
case "file":
case "raw":
case "tool-output-denied":
case "tool-approval-request":
return Effect.succeed([])

case "raw":
return Effect.sync(() => {
state.copilotTotalNanoAiu = copilotTotalNanoAiu(event.rawValue) ?? state.copilotTotalNanoAiu
return []
})

default: {
const _exhaustive: never = event
void _exhaustive
Expand Down
Loading
Loading