Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
feat: added auto model for github copilot provider
  • Loading branch information
OpeOginni committed Jun 30, 2026
commit 9697a1394cc918ebb2d4f91fa504e9f8ce37cf09
171 changes: 171 additions & 0 deletions packages/opencode/src/plugin/github-copilot/auto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import type { LanguageModelV3, LanguageModelV3CallOptions } from "@ai-sdk/provider"
import { InstallationVersion } from "@opencode-ai/core/installation/version"

const API_VERSION = "2026-06-01"
const INTEGRATION_ID = "vscode-chat"

const sessions = new Map<
string,
{
availableModels: string[]
selectedModel: string
sessionToken: string
expiresAt: number
}
>()

export function create(input: {
sdk: {
chat?: (modelID: string) => LanguageModelV3
responses?: (modelID: string) => LanguageModelV3
languageModel?: (modelID: string) => LanguageModelV3
}
baseURL: string
fetch?: typeof fetch
sessionID?: string
}) {
return new CopilotAutoLanguageModel(input)
}

class CopilotAutoLanguageModel implements LanguageModelV3 {
readonly specificationVersion = "v3"
readonly modelId = "auto"
readonly provider = "github-copilot.auto"

constructor(
private readonly input: {
sdk: {
chat?: (modelID: string) => LanguageModelV3
responses?: (modelID: string) => LanguageModelV3
languageModel?: (modelID: string) => LanguageModelV3
}
baseURL: string
fetch?: typeof fetch
sessionID?: string
},
) {}

get supportedUrls() {
return {}
}

async doGenerate(options: LanguageModelV3CallOptions) {
const routed = await this.route(options)
return routed.model.doGenerate({
...options,
headers: {
...options.headers,
"copilot-integration-id": INTEGRATION_ID,
"copilot-session-token": routed.sessionToken,
},
})
}

async doStream(options: LanguageModelV3CallOptions) {
const routed = await this.route(options)
return routed.model.doStream({
...options,
headers: {
...options.headers,
"copilot-integration-id": INTEGRATION_ID,
"copilot-session-token": routed.sessionToken,
},
})
}

private async route(options: LanguageModelV3CallOptions) {
const session = await this.session(options.abortSignal)
const prompt = promptText(options.prompt)
const response = await this.fetch(`${this.input.baseURL}/models/session/intent`, {
method: "POST",
headers: {
...this.headers(),
"copilot-session-token": session.sessionToken,
},
body: JSON.stringify({
prompt,
available_models: session.availableModels,
session_id: `opencode-session://${this.input.sessionID ?? "auto"}`,
reference_count: 0,
prompt_char_count: prompt.length,
turn_number: options.prompt.filter((message) => message.role === "user").length,
routing_method: "hydra",
copilot_plan: "individual",
}),
signal: options.abortSignal ?? AbortSignal.timeout(5_000),
})
if (!response.ok) throw new Error(`Failed to route Copilot auto model: ${response.status}`)
const intent = (await response.json()) as { chosen_model?: string }
const modelID = intent.chosen_model ?? session.selectedModel
return {
sessionToken: session.sessionToken,
model: this.model(modelID),
}
}

private async session(signal?: AbortSignal) {
const key = `${this.input.sessionID ?? "auto"}:${this.input.baseURL}`
const cached = sessions.get(key)
if (cached && cached.expiresAt > Math.floor(Date.now() / 1000) + 30) return cached

const response = await this.fetch(`${this.input.baseURL}/models/session`, {
method: "POST",
headers: this.headers(),
body: JSON.stringify({ auto_mode: { model_hints: ["auto"] } }),
signal: signal ?? AbortSignal.timeout(5_000),
})
if (!response.ok) throw new Error(`Failed to create Copilot auto session: ${response.status}`)
const data = (await response.json()) as {
available_models: string[]
selected_model: string
session_token: string
expires_at: number
}
const next = {
availableModels: data.available_models,
selectedModel: data.selected_model,
sessionToken: data.session_token,
expiresAt: data.expires_at,
}
sessions.set(key, next)
return next
}

private model(modelID: string) {
const match = /^gpt-(\d+)/.exec(modelID)
if (match && Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")) {
return (
this.input.sdk.responses?.(modelID) ?? this.input.sdk.languageModel?.(modelID) ?? this.input.sdk.chat!(modelID)
)
}
return (
this.input.sdk.chat?.(modelID) ?? this.input.sdk.languageModel?.(modelID) ?? this.input.sdk.responses!(modelID)
)
}

private headers() {
return {
"Content-Type": "text/plain;charset=UTF-8",
"User-Agent": `opencode/${InstallationVersion}`,
"X-GitHub-Api-Version": API_VERSION,
"copilot-integration-id": INTEGRATION_ID,
}
}

private fetch(input: string, init: RequestInit) {
return (this.input.fetch ?? fetch)(input, init)
}
}

function promptText(prompt: LanguageModelV3CallOptions["prompt"]) {
const message = prompt.findLast((message) => message.role === "user")
if (!message) return ""
if (typeof message.content === "string") return message.content
if (!Array.isArray(message.content)) return ""
return message.content
.map((part) => (part.type === "text" ? part.text : ""))
.filter(Boolean)
.join("\n")
}

export * as CopilotAuto from "./auto"
29 changes: 28 additions & 1 deletion packages/opencode/src/plugin/github-copilot/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,28 @@ type SelectableItem = Item & {
}
const decodeModels = Schema.decodeUnknownSync(schema)
const decodeItem = Schema.decodeUnknownOption(item)
const AUTO_BASE_URL = "https://api.individual.githubcopilot.com"

function autoModel(): SelectableItem {
return {
model_picker_enabled: true,
id: "auto",
name: "Auto",
version: "auto",
capabilities: {
family: "gpt",
limits: {
max_context_window_tokens: 128000,
max_output_tokens: 16384,
max_prompt_tokens: 128000,
},
supports: {
streaming: true,
tool_calls: true,
},
},
}
}

function build(key: string, remote: SelectableItem, url: string, prev?: Model): Model {
const reasoning =
Expand Down Expand Up @@ -237,9 +259,14 @@ export async function get(
result[id] = build(id, m, baseURL)
}

const picker = [...remote].filter(([, item]) => item.model_picker_enabled)
const model = autoModel()
result.auto = build("auto", model, AUTO_BASE_URL)
picker.unshift(["auto", model])

return {
models: result,
pickerEnabled: new Set([...remote].filter(([, item]) => item.model_picker_enabled).map(([id]) => id)),
pickerEnabled: new Set(picker.map(([id]) => id)),
}
}

Expand Down
10 changes: 9 additions & 1 deletion packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,15 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
"github-copilot": () =>
Effect.succeed({
autoload: false,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
if (modelID === "auto") {
const { CopilotAuto } = await import("@/plugin/github-copilot/auto")
return CopilotAuto.create({
sdk,
baseURL: typeof options?.baseURL === "string" ? options.baseURL : "https://api.individual.githubcopilot.com",
fetch: typeof options?.fetch === "function" ? options.fetch : undefined,
})
}
if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(modelID)
const match = /^gpt-(\d+)/.exec(modelID)
if (match && Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")) return sdk.responses(modelID)
Expand Down
90 changes: 90 additions & 0 deletions packages/opencode/test/plugin/github-copilot-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ test("preserves temperature support from existing provider models", async () =>

expect(models["gpt-4o"].capabilities.temperature).toBe(true)
expect(models["brand-new"].capabilities.temperature).toBe(true)
expect(result.pickerEnabled.has("auto")).toBe(true)
})

test("converts Copilot AIC token prices to USD per million tokens", async () => {
Expand Down Expand Up @@ -187,6 +188,95 @@ test("converts Copilot AIC token prices to USD per million tokens", async () =>
expect(models["ignored-non-chat-record"]).toBeUndefined()
})

test("keeps sparse Copilot auto model selectable", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
data: [
{
model_picker_enabled: true,
id: "auto",
name: "Auto",
},
],
}),
{ status: 200 },
),
),
) as unknown as typeof fetch

const result = await CopilotModels.get("https://api.githubcopilot.com")

expect(result.models.auto.api.id).toBe("auto")
expect(result.models.auto.api.npm).toBe("@ai-sdk/github-copilot")
expect(result.models.auto.capabilities.toolcall).toBe(true)
expect(result.pickerEnabled.has("auto")).toBe(true)
})

test("falls back to auto when Copilot picker flags are all false", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
data: [
{
model_picker_enabled: false,
id: "gpt-5.4-mini",
name: "GPT-5.4 mini",
version: "gpt-5.4-mini",
policy: {
state: "enabled",
},
capabilities: {
family: "gpt-5.4-mini",
limits: {
max_context_window_tokens: 400000,
max_output_tokens: 128000,
max_prompt_tokens: 272000,
},
supports: {
streaming: true,
tool_calls: true,
},
},
},
{
model_picker_enabled: false,
id: "gpt-5.5",
name: "GPT-5.5",
version: "gpt-5.5",
policy: {
state: "disabled",
},
capabilities: {
family: "gpt-5.5",
limits: {
max_context_window_tokens: 400000,
max_output_tokens: 128000,
max_prompt_tokens: 272000,
},
supports: {
streaming: true,
tool_calls: true,
},
},
},
],
}),
{ status: 200 },
),
),
) as unknown as typeof fetch

const result = await CopilotModels.get("https://api.githubcopilot.com")

expect(result.models.auto.api.id).toBe("auto")
expect(result.pickerEnabled.has("auto")).toBe(true)
expect(result.pickerEnabled.has("gpt-5.4-mini")).toBe(false)
expect(result.models["gpt-5.5"]).toBeUndefined()
})

test("clears existing variants so refreshed models calculate provider-specific variants", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
Expand Down
Loading