Compare commits

...

6 Commits

Author SHA1 Message Date
Aiden Cline a19b167fbb fix(opencode): isolate Copilot model decode failures 2026-06-01 01:37:27 -05:00
Aiden Cline a78b3217a5 fix(opencode): parse non-chat Copilot models 2026-06-01 01:29:20 -05:00
Aiden Cline a858c0948a fix(opencode): tolerate incomplete Copilot models 2026-06-01 01:16:22 -05:00
Aiden Cline 31ac4c347c fix(opencode): parse Copilot model pricing 2026-06-01 00:58:05 -05:00
Aiden Cline a7d6f759ae Merge branch 'dev' into gh-copilot-change 2026-06-01 00:28:02 -05:00
Aiden Cline 36c3d15b3f fix(opencode): add Copilot request headers 2026-05-31 23:34:17 -05:00
3 changed files with 171 additions and 47 deletions
@@ -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 = new Set(["gpt-4o-mini", "gpt-4.1", "gpt-4o", "gpt-5.4-nano"])
// 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
@@ -71,6 +73,7 @@ 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) => {
@@ -345,6 +348,11 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
"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" && UTILITY_MODELS.has(incoming.model.api.id)) {
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,12 @@ 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,
write: (prices?.default.cache_price ?? 0) * usdPerMillion,
},
},
options: prev?.options ?? {},
headers: prev?.headers ?? {},
@@ -154,6 +188,16 @@ function build(key: string, remote: Item, url: string, prev?: Model): Model {
return model
}
function selectable(item: Item): item is SelectableItem {
return (
item.model_picker_enabled &&
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 = {},
@@ -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 && selectable(item) ? ([[item.id, item]] as const) : []
}),
)
// prune existing models whose api.id isn't in the endpoint response
@@ -117,6 +117,75 @@ test("preserves temperature support from existing provider models", async () =>
expect(models["brand-new"].capabilities.temperature).toBe(true)
})
test("converts Copilot AIC token prices to USD per million tokens", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
data: [
{
model_picker_enabled: true,
id: "gpt-5",
name: "GPT-5",
version: "gpt-5-2026-06-01",
billing: {
token_prices: {
batch_size: 500000,
default: {
input_price: 500,
output_price: 3000,
cache_price: 50,
},
},
},
capabilities: {
family: "gpt",
limits: {
max_context_window_tokens: 200000,
max_output_tokens: 16384,
max_prompt_tokens: 200000,
},
supports: {
streaming: true,
tool_calls: true,
},
},
},
{
model_picker_enabled: true,
id: "incomplete-internal-model",
name: "Incomplete Internal Model",
version: "incomplete-internal-model-2026-06-01",
capabilities: {
family: "internal",
supports: {},
},
},
{
model_picker_enabled: false,
id: "ignored-non-chat-record",
},
],
}),
{ status: 200 },
),
),
) as unknown as typeof fetch
const models = await CopilotModels.get("https://api.githubcopilot.com")
expect(models["gpt-5"].cost).toEqual({
input: 10,
output: 60,
cache: {
read: 1,
write: 1,
},
})
expect(models["incomplete-internal-model"]).toBeUndefined()
expect(models["ignored-non-chat-record"]).toBeUndefined()
})
test("clears existing variants so refreshed models calculate provider-specific variants", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(