mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-01 19:45:05 +00:00
Compare commits
1 Commits
opencode-s
...
copilot-sy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
333e4bf008 |
@@ -1,7 +1,12 @@
|
||||
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 "./github-copilot/models"
|
||||
|
||||
const log = Log.create({ service: "plugin.copilot" })
|
||||
|
||||
const CLIENT_ID = "Ov23li8tweQw6odWQebz"
|
||||
// Add a small safety buffer when polling to avoid hitting the server
|
||||
@@ -18,45 +23,50 @@ function getUrls(domain: string) {
|
||||
}
|
||||
}
|
||||
|
||||
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<Hooks> {
|
||||
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, provider) {
|
||||
async loader(getAuth) {
|
||||
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"
|
||||
}
|
||||
}
|
||||
const baseURL = base(info.enterpriseUrl)
|
||||
|
||||
return {
|
||||
baseURL,
|
||||
|
||||
126
packages/opencode/src/plugin/github-copilot/models.ts
Normal file
126
packages/opencode/src/plugin/github-copilot/models.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
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(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
export async function get(
|
||||
baseURL: string,
|
||||
headers: HeadersInit = {},
|
||||
existing: Record<string, Model> = {},
|
||||
): Promise<Record<string, Model>> {
|
||||
const models = 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 parsed: Record<string, Model> = {}
|
||||
|
||||
models.data.forEach((model) => {
|
||||
if (!model.model_picker_enabled) return
|
||||
|
||||
parsed[model.id] = {
|
||||
id: model.id,
|
||||
providerID: "github-copilot",
|
||||
api: {
|
||||
id: model.id,
|
||||
url: baseURL,
|
||||
npm: "@ai-sdk/github-copilot",
|
||||
},
|
||||
name: model.name,
|
||||
family: model.capabilities.family,
|
||||
capabilities: {
|
||||
temperature: existing[model.id]?.capabilities.temperature ?? true,
|
||||
reasoning:
|
||||
!!model.capabilities.supports.adaptive_thinking ||
|
||||
!!model.capabilities.supports.reasoning_effort?.length ||
|
||||
model.capabilities.supports.max_thinking_budget !== undefined ||
|
||||
model.capabilities.supports.min_thinking_budget !== undefined,
|
||||
attachment:
|
||||
(model.capabilities.supports.vision ?? false) ||
|
||||
(model.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/")),
|
||||
toolcall: model.capabilities.supports.tool_calls,
|
||||
input: {
|
||||
text: true,
|
||||
audio: false,
|
||||
image:
|
||||
(model.capabilities.supports.vision ?? false) ||
|
||||
(model.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/")),
|
||||
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: model.capabilities.limits.max_context_window_tokens,
|
||||
input: model.capabilities.limits.max_prompt_tokens,
|
||||
output: model.capabilities.limits.max_output_tokens,
|
||||
},
|
||||
options: {},
|
||||
headers: {},
|
||||
release_date: model.version.startsWith(`${model.id}-`)
|
||||
? model.version.slice(model.id.length + 1)
|
||||
: model.version,
|
||||
variants: {},
|
||||
status: "active",
|
||||
}
|
||||
})
|
||||
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
@@ -1177,6 +1177,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)) {
|
||||
@@ -1221,22 +1264,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,
|
||||
|
||||
117
packages/opencode/test/plugin/github-copilot-models.test.ts
Normal file
117
packages/opencode/test/plugin/github-copilot-models.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
@@ -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<Record<string, ModelV2>>
|
||||
}
|
||||
|
||||
/** @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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user