feat(core): port GitHub Copilot OAuth (#36336)
Co-authored-by: Dax Raad <d@ironbay.co>
This commit is contained in:
parent
ed6c117184
commit
d1550cb599
4 changed files with 643 additions and 10 deletions
214
packages/core/src/github-copilot/models.ts
Normal file
214
packages/core/src/github-copilot/models.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
export * as CopilotModels from "./models"
|
||||
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Option, Schema } from "effect"
|
||||
import { ModelV2 } from "../model"
|
||||
import { ProviderV2 } from "../provider"
|
||||
|
||||
const RemoteModel = Schema.Struct({
|
||||
model_picker_enabled: Schema.Boolean,
|
||||
id: Schema.String,
|
||||
name: Schema.String,
|
||||
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({
|
||||
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),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
const Response = Schema.Struct({ data: Schema.Array(Schema.Unknown) })
|
||||
const decodeResponse = Schema.decodeUnknownSync(Response)
|
||||
const decodeModel = Schema.decodeUnknownOption(RemoteModel)
|
||||
|
||||
type RemoteModel = typeof RemoteModel.Type
|
||||
type UsableModel = RemoteModel & {
|
||||
capabilities: RemoteModel["capabilities"] & {
|
||||
limits: NonNullable<RemoteModel["capabilities"]["limits"]> & {
|
||||
max_output_tokens: number
|
||||
max_prompt_tokens: number
|
||||
}
|
||||
supports: RemoteModel["capabilities"]["supports"] & { tool_calls: boolean }
|
||||
}
|
||||
}
|
||||
|
||||
export async function get(baseURL: string, headers: RequestInit["headers"], existing: readonly ModelV2.Info[]) {
|
||||
const response = await fetch(`${baseURL}/models`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
})
|
||||
if (!response.ok) throw new Error(`Failed to fetch Copilot models: ${response.status}`)
|
||||
|
||||
const remote = new Map(
|
||||
decodeResponse(await response.json()).data.flatMap((raw) => {
|
||||
const model = Option.getOrUndefined(decodeModel(raw))
|
||||
return model && usable(model) ? ([[model.id, model]] as const) : []
|
||||
}),
|
||||
)
|
||||
const result = new Map(existing.map((model) => [model.id, model]))
|
||||
|
||||
// Keep aliases and local metadata, but only when their advertised API model
|
||||
// still exists. A partial or malformed item cannot create a broken model.
|
||||
for (const [id, model] of result) {
|
||||
const current = remote.get(model.modelID)
|
||||
if (!current) {
|
||||
result.delete(id)
|
||||
continue
|
||||
}
|
||||
result.set(id, build(id, current, baseURL, model))
|
||||
}
|
||||
|
||||
for (const [id, model] of remote) {
|
||||
const key = ModelV2.ID.make(id)
|
||||
if (result.has(key)) continue
|
||||
result.set(key, build(key, model, baseURL))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function usable(model: RemoteModel): model is UsableModel {
|
||||
return (
|
||||
model.policy?.state !== "disabled" &&
|
||||
model.capabilities.limits?.max_output_tokens !== undefined &&
|
||||
model.capabilities.limits.max_prompt_tokens !== undefined &&
|
||||
model.capabilities.supports.tool_calls !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
function build(id: ModelV2.ID, remote: UsableModel, baseURL: string, previous?: ModelV2.Info) {
|
||||
const messages = remote.supported_endpoints?.includes("/v1/messages") ?? false
|
||||
const endpoint = messages
|
||||
? "messages"
|
||||
: remote.supported_endpoints?.includes("/responses")
|
||||
? "responses"
|
||||
: remote.supported_endpoints?.includes("/chat/completions")
|
||||
? "chat"
|
||||
: undefined
|
||||
const image =
|
||||
(remote.capabilities.supports.vision ?? false) ||
|
||||
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
|
||||
const prices = remote.billing?.token_prices
|
||||
// Copilot reports AIC per billing batch; OpenCode stores USD per million tokens.
|
||||
const usdPerMillion = prices && prices.batch_size > 0 ? 10_000 / prices.batch_size : 0
|
||||
const version = remote.version.startsWith(`${remote.id}-`)
|
||||
? remote.version.slice(remote.id.length + 1)
|
||||
: remote.version
|
||||
const released = previous?.time.released || Date.parse(version)
|
||||
|
||||
return ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.githubCopilot, id),
|
||||
id,
|
||||
modelID: ModelV2.ID.make(remote.id),
|
||||
providerID: ProviderV2.ID.githubCopilot,
|
||||
family: previous?.family ?? ModelV2.Family.make(remote.capabilities.family),
|
||||
name: previous?.name ?? remote.name,
|
||||
package: ProviderV2.aisdk(messages ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot"),
|
||||
settings: ProviderV2.mergeOverlay(previous?.settings, {
|
||||
baseURL: messages ? `${baseURL}/v1` : baseURL,
|
||||
...(endpoint ? { endpoint } : {}),
|
||||
}),
|
||||
headers: previous?.headers,
|
||||
body: previous?.body,
|
||||
capabilities: {
|
||||
tools: remote.capabilities.supports.tool_calls,
|
||||
input: image ? ["text", "image"] : ["text"],
|
||||
output: ["text"],
|
||||
},
|
||||
variants: variants(remote, messages),
|
||||
time: { released: Number.isFinite(released) ? released : 0 },
|
||||
cost: [
|
||||
{
|
||||
input: Money.USDPerMillionTokens.make((prices?.default.input_price ?? 0) * usdPerMillion),
|
||||
output: Money.USDPerMillionTokens.make((prices?.default.output_price ?? 0) * usdPerMillion),
|
||||
cache: {
|
||||
read: Money.USDPerMillionTokens.make((prices?.default.cache_price ?? 0) * usdPerMillion),
|
||||
write: Money.USDPerMillionTokens.zero,
|
||||
},
|
||||
},
|
||||
],
|
||||
status: "active",
|
||||
enabled: remote.model_picker_enabled,
|
||||
limit: {
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function variants(remote: UsableModel, messages: boolean): ModelV2.Info["variants"] {
|
||||
const efforts = remote.capabilities.supports.reasoning_effort ?? []
|
||||
if (!messages && efforts.length) {
|
||||
return efforts.map((effort) => ({
|
||||
id: ModelV2.VariantID.make(effort),
|
||||
settings: {
|
||||
reasoningEffort: effort,
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
},
|
||||
}))
|
||||
}
|
||||
if (efforts.length && remote.capabilities.supports.adaptive_thinking) {
|
||||
return efforts.map((effort) => ({
|
||||
id: ModelV2.VariantID.make(effort),
|
||||
settings: {
|
||||
thinking: {
|
||||
type: "adaptive",
|
||||
...(remote.id.includes("opus-4.7") ? { display: "summarized" } : {}),
|
||||
},
|
||||
effort,
|
||||
},
|
||||
}))
|
||||
}
|
||||
const max = remote.capabilities.supports.max_thinking_budget
|
||||
if (max === undefined) return []
|
||||
return [
|
||||
{
|
||||
id: ModelV2.VariantID.make("max"),
|
||||
settings: { thinking: { type: "enabled", budgetTokens: max - 1 } },
|
||||
},
|
||||
{
|
||||
id: ModelV2.VariantID.make("high"),
|
||||
settings: { thinking: { type: "enabled", budgetTokens: Math.floor(max / 2) } },
|
||||
},
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue