From 1fcfb69bf772f085289dd22f6898ac5d926226e9 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 1 Apr 2026 18:04:14 -0500 Subject: feat: add new provider plugin hook for resolving models and sync models from github models endpoint (falls back to models.dev) (#20533) --- packages/opencode/src/plugin/copilot.ts | 343 -------------------- .../opencode/src/plugin/github-copilot/copilot.ts | 353 +++++++++++++++++++++ .../opencode/src/plugin/github-copilot/models.ts | 143 +++++++++ packages/opencode/src/plugin/index.ts | 2 +- packages/opencode/src/provider/provider.ts | 59 +++- .../test/plugin/github-copilot-models.test.ts | 117 +++++++ packages/plugin/src/index.ts | 11 + 7 files changed, 668 insertions(+), 360 deletions(-) delete mode 100644 packages/opencode/src/plugin/copilot.ts create mode 100644 packages/opencode/src/plugin/github-copilot/copilot.ts create mode 100644 packages/opencode/src/plugin/github-copilot/models.ts create mode 100644 packages/opencode/test/plugin/github-copilot-models.test.ts (limited to 'packages') diff --git a/packages/opencode/src/plugin/copilot.ts b/packages/opencode/src/plugin/copilot.ts deleted file mode 100644 index 44c5289dd..000000000 --- a/packages/opencode/src/plugin/copilot.ts +++ /dev/null @@ -1,343 +0,0 @@ -import type { Hooks, PluginInput } from "@opencode-ai/plugin" -import { Installation } from "@/installation" -import { iife } from "@/util/iife" -import { setTimeout as sleep } from "node:timers/promises" - -const CLIENT_ID = "Ov23li8tweQw6odWQebz" -// 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 -function normalizeDomain(url: string) { - return url.replace(/^https?:\/\//, "").replace(/\/$/, "") -} - -function getUrls(domain: string) { - return { - DEVICE_CODE_URL: `https://${domain}/login/device/code`, - ACCESS_TOKEN_URL: `https://${domain}/login/oauth/access_token`, - } -} - -export async function CopilotAuthPlugin(input: PluginInput): Promise { - const sdk = input.client - return { - auth: { - provider: "github-copilot", - async loader(getAuth, provider) { - const info = await getAuth() - if (!info || info.type !== "oauth") return {} - - const enterpriseUrl = info.enterpriseUrl - const baseURL = enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : undefined - - if (provider && provider.models) { - for (const model of Object.values(provider.models)) { - model.cost = { - input: 0, - output: 0, - cache: { - read: 0, - write: 0, - }, - } - - // TODO: re-enable once messages api has higher rate limits - // TODO: move some of this hacky-ness to models.dev presets once we have better grasp of things here... - // const base = baseURL ?? model.api.url - // const claude = model.id.includes("claude") - // const url = iife(() => { - // if (!claude) return base - // if (base.endsWith("/v1")) return base - // if (base.endsWith("/")) return `${base}v1` - // return `${base}/v1` - // }) - - // model.api.url = url - // model.api.npm = claude ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot" - model.api.npm = "@ai-sdk/github-copilot" - } - } - - return { - baseURL, - apiKey: "", - async fetch(request: RequestInfo | URL, init?: RequestInit) { - const info = await getAuth() - if (info.type !== "oauth") return fetch(request, init) - - const url = request instanceof URL ? request.href : request.toString() - const { isVision, isAgent } = iife(() => { - try { - const body = typeof init?.body === "string" ? JSON.parse(init.body) : init?.body - - // Completions API - if (body?.messages && url.includes("completions")) { - const last = body.messages[body.messages.length - 1] - return { - isVision: body.messages.some( - (msg: any) => - Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"), - ), - isAgent: last?.role !== "user", - } - } - - // Responses API - if (body?.input) { - const last = body.input[body.input.length - 1] - return { - isVision: body.input.some( - (item: any) => - Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"), - ), - isAgent: last?.role !== "user", - } - } - - // Messages API - if (body?.messages) { - const last = body.messages[body.messages.length - 1] - const hasNonToolCalls = - Array.isArray(last?.content) && last.content.some((part: any) => part?.type !== "tool_result") - return { - isVision: body.messages.some( - (item: any) => - Array.isArray(item?.content) && - item.content.some( - (part: any) => - part?.type === "image" || - // images can be nested inside tool_result content - (part?.type === "tool_result" && - Array.isArray(part?.content) && - part.content.some((nested: any) => nested?.type === "image")), - ), - ), - isAgent: !(last?.role === "user" && hasNonToolCalls), - } - } - } catch {} - return { isVision: false, isAgent: false } - }) - - const headers: Record = { - "x-initiator": isAgent ? "agent" : "user", - ...(init?.headers as Record), - "User-Agent": `opencode/${Installation.VERSION}`, - Authorization: `Bearer ${info.refresh}`, - "Openai-Intent": "conversation-edits", - } - - if (isVision) { - headers["Copilot-Vision-Request"] = "true" - } - - delete headers["x-api-key"] - delete headers["authorization"] - - return fetch(request, { - ...init, - headers, - }) - }, - } - }, - methods: [ - { - type: "oauth", - label: "Login with GitHub Copilot", - prompts: [ - { - type: "select", - key: "deploymentType", - message: "Select GitHub deployment type", - options: [ - { - label: "GitHub.com", - value: "github.com", - hint: "Public", - }, - { - label: "GitHub Enterprise", - value: "enterprise", - hint: "Data residency or self-hosted", - }, - ], - }, - { - type: "text", - key: "enterpriseUrl", - message: "Enter your GitHub Enterprise URL or domain", - placeholder: "company.ghe.com or https://company.ghe.com", - when: { key: "deploymentType", op: "eq", value: "enterprise" }, - validate: (value) => { - if (!value) return "URL or domain is required" - try { - const url = value.includes("://") ? new URL(value) : new URL(`https://${value}`) - if (!url.hostname) return "Please enter a valid URL or domain" - return undefined - } catch { - return "Please enter a valid URL (e.g., company.ghe.com or https://company.ghe.com)" - } - }, - }, - ], - async authorize(inputs = {}) { - const deploymentType = inputs.deploymentType || "github.com" - - let domain = "github.com" - - if (deploymentType === "enterprise") { - const enterpriseUrl = inputs.enterpriseUrl - domain = normalizeDomain(enterpriseUrl!) - } - - const urls = getUrls(domain) - - const deviceResponse = await fetch(urls.DEVICE_CODE_URL, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - "User-Agent": `opencode/${Installation.VERSION}`, - }, - body: JSON.stringify({ - client_id: CLIENT_ID, - scope: "read:user", - }), - }) - - if (!deviceResponse.ok) { - throw new Error("Failed to initiate device authorization") - } - - const deviceData = (await deviceResponse.json()) as { - verification_uri: string - user_code: string - device_code: string - interval: number - } - - return { - url: deviceData.verification_uri, - instructions: `Enter code: ${deviceData.user_code}`, - method: "auto" as const, - async callback() { - while (true) { - const response = await fetch(urls.ACCESS_TOKEN_URL, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - "User-Agent": `opencode/${Installation.VERSION}`, - }, - body: JSON.stringify({ - client_id: CLIENT_ID, - device_code: deviceData.device_code, - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }), - }) - - if (!response.ok) return { type: "failed" as const } - - const data = (await response.json()) as { - access_token?: string - error?: string - interval?: number - } - - if (data.access_token) { - const result: { - type: "success" - refresh: string - access: string - expires: number - provider?: string - enterpriseUrl?: string - } = { - type: "success", - refresh: data.access_token, - access: data.access_token, - expires: 0, - } - - if (deploymentType === "enterprise") { - result.enterpriseUrl = domain - } - - return result - } - - if (data.error === "authorization_pending") { - await sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS) - continue - } - - if (data.error === "slow_down") { - // Based on the RFC spec, we must add 5 seconds to our current polling interval. - // (See https://www.rfc-editor.org/rfc/rfc8628#section-3.5) - let newInterval = (deviceData.interval + 5) * 1000 - - // GitHub OAuth API may return the new interval in seconds in the response. - // We should try to use that if provided with safety margin. - const serverInterval = data.interval - if (serverInterval && typeof serverInterval === "number" && serverInterval > 0) { - newInterval = serverInterval * 1000 - } - - await sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS) - continue - } - - if (data.error) return { type: "failed" as const } - - await sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS) - continue - } - }, - } - }, - }, - ], - }, - "chat.headers": async (incoming, output) => { - if (!incoming.model.providerID.includes("github-copilot")) return - - if (incoming.model.api.npm === "@ai-sdk/anthropic") { - output.headers["anthropic-beta"] = "interleaved-thinking-2025-05-14" - } - - const parts = await sdk.session - .message({ - path: { - id: incoming.message.sessionID, - messageID: incoming.message.id, - }, - query: { - directory: input.directory, - }, - throwOnError: true, - }) - .catch(() => undefined) - - if (parts?.data.parts?.some((part) => part.type === "compaction")) { - output.headers["x-initiator"] = "agent" - return - } - - const session = await sdk.session - .get({ - path: { - id: incoming.sessionID, - }, - query: { - directory: input.directory, - }, - throwOnError: true, - }) - .catch(() => undefined) - if (!session || !session.data.parentID) return - // mark subagent sessions as agent initiated matching standard that other copilot tools have - output.headers["x-initiator"] = "agent" - }, - } -} diff --git a/packages/opencode/src/plugin/github-copilot/copilot.ts b/packages/opencode/src/plugin/github-copilot/copilot.ts new file mode 100644 index 000000000..ea759b508 --- /dev/null +++ b/packages/opencode/src/plugin/github-copilot/copilot.ts @@ -0,0 +1,353 @@ +import type { Hooks, PluginInput } from "@opencode-ai/plugin" +import type { Model } from "@opencode-ai/sdk/v2" +import { Installation } from "@/installation" +import { iife } from "@/util/iife" +import { Log } from "../../util/log" +import { setTimeout as sleep } from "node:timers/promises" +import { CopilotModels } from "./models" + +const log = Log.create({ service: "plugin.copilot" }) + +const CLIENT_ID = "Ov23li8tweQw6odWQebz" +// 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 +function normalizeDomain(url: string) { + return url.replace(/^https?:\/\//, "").replace(/\/$/, "") +} + +function getUrls(domain: string) { + return { + DEVICE_CODE_URL: `https://${domain}/login/device/code`, + ACCESS_TOKEN_URL: `https://${domain}/login/oauth/access_token`, + } +} + +function base(enterpriseUrl?: string) { + return enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : "https://api.githubcopilot.com" +} + +function fix(model: Model): Model { + return { + ...model, + api: { + ...model.api, + npm: "@ai-sdk/github-copilot", + }, + } +} + +export async function CopilotAuthPlugin(input: PluginInput): Promise { + const sdk = input.client + return { + provider: { + id: "github-copilot", + async models(provider, ctx) { + if (ctx.auth?.type !== "oauth") { + return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model)])) + } + + return CopilotModels.get( + base(ctx.auth.enterpriseUrl), + { + Authorization: `Bearer ${ctx.auth.refresh}`, + "User-Agent": `opencode/${Installation.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)])) + }) + }, + }, + auth: { + provider: "github-copilot", + async loader(getAuth) { + const info = await getAuth() + if (!info || info.type !== "oauth") return {} + + const baseURL = base(info.enterpriseUrl) + + return { + baseURL, + apiKey: "", + async fetch(request: RequestInfo | URL, init?: RequestInit) { + const info = await getAuth() + if (info.type !== "oauth") return fetch(request, init) + + const url = request instanceof URL ? request.href : request.toString() + const { isVision, isAgent } = iife(() => { + try { + const body = typeof init?.body === "string" ? JSON.parse(init.body) : init?.body + + // Completions API + if (body?.messages && url.includes("completions")) { + const last = body.messages[body.messages.length - 1] + return { + isVision: body.messages.some( + (msg: any) => + Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"), + ), + isAgent: last?.role !== "user", + } + } + + // Responses API + if (body?.input) { + const last = body.input[body.input.length - 1] + return { + isVision: body.input.some( + (item: any) => + Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"), + ), + isAgent: last?.role !== "user", + } + } + + // Messages API + if (body?.messages) { + const last = body.messages[body.messages.length - 1] + const hasNonToolCalls = + Array.isArray(last?.content) && last.content.some((part: any) => part?.type !== "tool_result") + return { + isVision: body.messages.some( + (item: any) => + Array.isArray(item?.content) && + item.content.some( + (part: any) => + part?.type === "image" || + // images can be nested inside tool_result content + (part?.type === "tool_result" && + Array.isArray(part?.content) && + part.content.some((nested: any) => nested?.type === "image")), + ), + ), + isAgent: !(last?.role === "user" && hasNonToolCalls), + } + } + } catch {} + return { isVision: false, isAgent: false } + }) + + const headers: Record = { + "x-initiator": isAgent ? "agent" : "user", + ...(init?.headers as Record), + "User-Agent": `opencode/${Installation.VERSION}`, + Authorization: `Bearer ${info.refresh}`, + "Openai-Intent": "conversation-edits", + } + + if (isVision) { + headers["Copilot-Vision-Request"] = "true" + } + + delete headers["x-api-key"] + delete headers["authorization"] + + return fetch(request, { + ...init, + headers, + }) + }, + } + }, + methods: [ + { + type: "oauth", + label: "Login with GitHub Copilot", + prompts: [ + { + type: "select", + key: "deploymentType", + message: "Select GitHub deployment type", + options: [ + { + label: "GitHub.com", + value: "github.com", + hint: "Public", + }, + { + label: "GitHub Enterprise", + value: "enterprise", + hint: "Data residency or self-hosted", + }, + ], + }, + { + type: "text", + key: "enterpriseUrl", + message: "Enter your GitHub Enterprise URL or domain", + placeholder: "company.ghe.com or https://company.ghe.com", + when: { key: "deploymentType", op: "eq", value: "enterprise" }, + validate: (value) => { + if (!value) return "URL or domain is required" + try { + const url = value.includes("://") ? new URL(value) : new URL(`https://${value}`) + if (!url.hostname) return "Please enter a valid URL or domain" + return undefined + } catch { + return "Please enter a valid URL (e.g., company.ghe.com or https://company.ghe.com)" + } + }, + }, + ], + async authorize(inputs = {}) { + const deploymentType = inputs.deploymentType || "github.com" + + let domain = "github.com" + + if (deploymentType === "enterprise") { + const enterpriseUrl = inputs.enterpriseUrl + domain = normalizeDomain(enterpriseUrl!) + } + + const urls = getUrls(domain) + + const deviceResponse = await fetch(urls.DEVICE_CODE_URL, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": `opencode/${Installation.VERSION}`, + }, + body: JSON.stringify({ + client_id: CLIENT_ID, + scope: "read:user", + }), + }) + + if (!deviceResponse.ok) { + throw new Error("Failed to initiate device authorization") + } + + const deviceData = (await deviceResponse.json()) as { + verification_uri: string + user_code: string + device_code: string + interval: number + } + + return { + url: deviceData.verification_uri, + instructions: `Enter code: ${deviceData.user_code}`, + method: "auto" as const, + async callback() { + while (true) { + const response = await fetch(urls.ACCESS_TOKEN_URL, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": `opencode/${Installation.VERSION}`, + }, + body: JSON.stringify({ + client_id: CLIENT_ID, + device_code: deviceData.device_code, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }) + + if (!response.ok) return { type: "failed" as const } + + const data = (await response.json()) as { + access_token?: string + error?: string + interval?: number + } + + if (data.access_token) { + const result: { + type: "success" + refresh: string + access: string + expires: number + provider?: string + enterpriseUrl?: string + } = { + type: "success", + refresh: data.access_token, + access: data.access_token, + expires: 0, + } + + if (deploymentType === "enterprise") { + result.enterpriseUrl = domain + } + + return result + } + + if (data.error === "authorization_pending") { + await sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS) + continue + } + + if (data.error === "slow_down") { + // Based on the RFC spec, we must add 5 seconds to our current polling interval. + // (See https://www.rfc-editor.org/rfc/rfc8628#section-3.5) + let newInterval = (deviceData.interval + 5) * 1000 + + // GitHub OAuth API may return the new interval in seconds in the response. + // We should try to use that if provided with safety margin. + const serverInterval = data.interval + if (serverInterval && typeof serverInterval === "number" && serverInterval > 0) { + newInterval = serverInterval * 1000 + } + + await sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS) + continue + } + + if (data.error) return { type: "failed" as const } + + await sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS) + continue + } + }, + } + }, + }, + ], + }, + "chat.headers": async (incoming, output) => { + if (!incoming.model.providerID.includes("github-copilot")) return + + if (incoming.model.api.npm === "@ai-sdk/anthropic") { + output.headers["anthropic-beta"] = "interleaved-thinking-2025-05-14" + } + + const parts = await sdk.session + .message({ + path: { + id: incoming.message.sessionID, + messageID: incoming.message.id, + }, + query: { + directory: input.directory, + }, + throwOnError: true, + }) + .catch(() => undefined) + + if (parts?.data.parts?.some((part) => part.type === "compaction")) { + output.headers["x-initiator"] = "agent" + return + } + + const session = await sdk.session + .get({ + path: { + id: incoming.sessionID, + }, + query: { + directory: input.directory, + }, + throwOnError: true, + }) + .catch(() => undefined) + if (!session || !session.data.parentID) return + // mark subagent sessions as agent initiated matching standard that other copilot tools have + output.headers["x-initiator"] = "agent" + }, + } +} diff --git a/packages/opencode/src/plugin/github-copilot/models.ts b/packages/opencode/src/plugin/github-copilot/models.ts new file mode 100644 index 000000000..6cbb17fe9 --- /dev/null +++ b/packages/opencode/src/plugin/github-copilot/models.ts @@ -0,0 +1,143 @@ +import { z } from "zod" +import type { Model } from "@opencode-ai/sdk/v2" + +export namespace CopilotModels { + export const schema = z.object({ + data: z.array( + z.object({ + model_picker_enabled: z.boolean(), + id: z.string(), + name: z.string(), + // every version looks like: `{model.id}-YYYY-MM-DD` + version: z.string(), + supported_endpoints: z.array(z.string()).optional(), + capabilities: z.object({ + family: z.string(), + limits: z.object({ + max_context_window_tokens: z.number(), + max_output_tokens: z.number(), + max_prompt_tokens: z.number(), + vision: z + .object({ + max_prompt_image_size: z.number(), + max_prompt_images: z.number(), + supported_media_types: z.array(z.string()), + }) + .optional(), + }), + supports: z.object({ + adaptive_thinking: z.boolean().optional(), + max_thinking_budget: z.number().optional(), + min_thinking_budget: z.number().optional(), + reasoning_effort: z.array(z.string()).optional(), + streaming: z.boolean(), + structured_outputs: z.boolean().optional(), + tool_calls: z.boolean(), + vision: z.boolean().optional(), + }), + }), + }), + ), + }) + + type Item = z.infer["data"][number] + + function build(key: string, remote: Item, url: string, prev?: Model): Model { + const reasoning = + !!remote.capabilities.supports.adaptive_thinking || + !!remote.capabilities.supports.reasoning_effort?.length || + remote.capabilities.supports.max_thinking_budget !== undefined || + remote.capabilities.supports.min_thinking_budget !== undefined + const image = + (remote.capabilities.supports.vision ?? false) || + (remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/")) + + return { + id: key, + providerID: "github-copilot", + api: { + id: remote.id, + url, + npm: "@ai-sdk/github-copilot", + }, + // API response wins + status: "active", + limit: { + context: remote.capabilities.limits.max_context_window_tokens, + input: remote.capabilities.limits.max_prompt_tokens, + output: remote.capabilities.limits.max_output_tokens, + }, + capabilities: { + temperature: prev?.capabilities.temperature ?? true, + reasoning: prev?.capabilities.reasoning ?? reasoning, + attachment: prev?.capabilities.attachment ?? true, + toolcall: remote.capabilities.supports.tool_calls, + input: { + text: true, + audio: false, + image, + video: false, + pdf: false, + }, + output: { + text: true, + audio: false, + image: false, + video: false, + pdf: false, + }, + interleaved: false, + }, + // existing wins + family: prev?.family ?? remote.capabilities.family, + name: prev?.name ?? remote.name, + cost: { + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }, + options: prev?.options ?? {}, + headers: prev?.headers ?? {}, + release_date: + prev?.release_date ?? + (remote.version.startsWith(`${remote.id}-`) ? remote.version.slice(remote.id.length + 1) : remote.version), + variants: prev?.variants ?? {}, + } + } + + export async function get( + baseURL: string, + headers: HeadersInit = {}, + existing: Record = {}, + ): Promise> { + const data = await fetch(`${baseURL}/models`, { + headers, + }).then(async (res) => { + if (!res.ok) { + throw new Error(`Failed to fetch models: ${res.status}`) + } + return schema.parse(await res.json()) + }) + + const result = { ...existing } + const remote = new Map(data.data.filter((m) => m.model_picker_enabled).map((m) => [m.id, m] as const)) + + // prune existing models whose api.id isn't in the endpoint response + for (const [key, model] of Object.entries(result)) { + const m = remote.get(model.api.id) + if (!m) { + delete result[key] + continue + } + result[key] = build(key, m, baseURL, model) + } + + // add new endpoint models not already keyed in result + for (const [id, m] of remote) { + if (id in result) continue + result[id] = build(id, m, baseURL) + } + + return result + } +} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 8cd9776cd..4f14d4d1f 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -7,7 +7,7 @@ import { Flag } from "../flag/flag" import { CodexAuthPlugin } from "./codex" import { Session } from "../session" import { NamedError } from "@opencode-ai/util/error" -import { CopilotAuthPlugin } from "./copilot" +import { CopilotAuthPlugin } from "./github-copilot/copilot" import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" import { PoeAuthPlugin } from "opencode-poe-auth" import { Effect, Layer, ServiceMap, Stream } from "effect" diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index f8917b66c..e1506f593 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1178,6 +1178,49 @@ export namespace Provider { mergeProvider(providerID, partial) } + const gitlab = ProviderID.make("gitlab") + if (discoveryLoaders[gitlab] && providers[gitlab] && isProviderAllowed(gitlab)) { + yield* Effect.promise(async () => { + try { + const discovered = await discoveryLoaders[gitlab]() + for (const [modelID, model] of Object.entries(discovered)) { + if (!providers[gitlab].models[modelID]) { + providers[gitlab].models[modelID] = model + } + } + } catch (e) { + log.warn("state discovery error", { id: "gitlab", error: e }) + } + }) + } + + for (const hook of plugins) { + const p = hook.provider + const models = p?.models + if (!p || !models) continue + + const providerID = ProviderID.make(p.id) + if (disabled.has(providerID)) continue + + const provider = providers[providerID] + if (!provider) continue + const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie) + + provider.models = yield* Effect.promise(async () => { + const next = await models(provider, { auth: pluginAuth }) + return Object.fromEntries( + Object.entries(next).map(([id, model]) => [ + id, + { + ...model, + id: ModelID.make(id), + providerID, + }, + ]), + ) + }) + } + for (const [id, provider] of Object.entries(providers)) { const providerID = ProviderID.make(id) if (!isProviderAllowed(providerID)) { @@ -1222,22 +1265,6 @@ export namespace Provider { log.info("found", { providerID }) } - const gitlab = ProviderID.make("gitlab") - if (discoveryLoaders[gitlab] && providers[gitlab]) { - yield* Effect.promise(async () => { - try { - const discovered = await discoveryLoaders[gitlab]() - for (const [modelID, model] of Object.entries(discovered)) { - if (!providers[gitlab].models[modelID]) { - providers[gitlab].models[modelID] = model - } - } - } catch (e) { - log.warn("state discovery error", { id: "gitlab", error: e }) - } - }) - } - return { models: languages, providers, diff --git a/packages/opencode/test/plugin/github-copilot-models.test.ts b/packages/opencode/test/plugin/github-copilot-models.test.ts new file mode 100644 index 000000000..78fe40aea --- /dev/null +++ b/packages/opencode/test/plugin/github-copilot-models.test.ts @@ -0,0 +1,117 @@ +import { afterEach, expect, mock, test } from "bun:test" +import { CopilotModels } from "@/plugin/github-copilot/models" + +const originalFetch = globalThis.fetch + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +test("preserves temperature support from existing provider models", async () => { + globalThis.fetch = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: [ + { + model_picker_enabled: true, + id: "gpt-4o", + name: "GPT-4o", + version: "gpt-4o-2024-05-13", + capabilities: { + family: "gpt", + limits: { + max_context_window_tokens: 64000, + max_output_tokens: 16384, + max_prompt_tokens: 64000, + }, + supports: { + streaming: true, + tool_calls: true, + }, + }, + }, + { + model_picker_enabled: true, + id: "brand-new", + name: "Brand New", + version: "brand-new-2026-04-01", + capabilities: { + family: "test", + limits: { + max_context_window_tokens: 32000, + max_output_tokens: 8192, + max_prompt_tokens: 32000, + }, + supports: { + streaming: true, + tool_calls: false, + }, + }, + }, + ], + }), + { status: 200 }, + ), + ), + ) as unknown as typeof fetch + + const models = await CopilotModels.get( + "https://api.githubcopilot.com", + {}, + { + "gpt-4o": { + id: "gpt-4o", + providerID: "github-copilot", + api: { + id: "gpt-4o", + url: "https://api.githubcopilot.com", + npm: "@ai-sdk/openai-compatible", + }, + name: "GPT-4o", + family: "gpt", + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { + text: true, + audio: false, + image: true, + video: false, + pdf: false, + }, + output: { + text: true, + audio: false, + image: false, + video: false, + pdf: false, + }, + interleaved: false, + }, + cost: { + input: 0, + output: 0, + cache: { + read: 0, + write: 0, + }, + }, + limit: { + context: 64000, + output: 16384, + }, + options: {}, + headers: {}, + release_date: "2024-05-13", + variants: {}, + status: "active", + }, + }, + ) + + expect(models["gpt-4o"].capabilities.temperature).toBe(true) + expect(models["brand-new"].capabilities.temperature).toBe(true) +}) diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index a264cf5aa..473cac8a9 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -11,6 +11,7 @@ import type { Auth, Config as SDKConfig, } from "@opencode-ai/sdk" +import type { Provider as ProviderV2, Model as ModelV2 } from "@opencode-ai/sdk/v2" import type { BunShell } from "./shell.js" import { type ToolDefinition } from "./tool.js" @@ -173,6 +174,15 @@ export type AuthOAuthResult = { url: string; instructions: string } & ( } ) +export type ProviderHookContext = { + auth?: Auth +} + +export type ProviderHook = { + id: string + models?: (provider: ProviderV2, ctx: ProviderHookContext) => Promise> +} + /** @deprecated Use AuthOAuthResult instead. */ export type AuthOuathResult = AuthOAuthResult @@ -183,6 +193,7 @@ export interface Hooks { [key: string]: ToolDefinition } auth?: AuthHook + provider?: ProviderHook /** * Called when a new message is received */ -- cgit v1.2.3