mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 02:06:00 +00:00
fix(copilot): honor advertised model endpoints (#34958)
This commit is contained in:
@@ -1,19 +1,11 @@
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { define } from "../internal"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
|
||||
function shouldUseResponses(modelID: string) {
|
||||
// Copilot supports Responses for GPT-5 class models, except mini variants
|
||||
// which still need the chat-completions endpoint.
|
||||
const match = /^gpt-(\d+)/.exec(modelID)
|
||||
if (!match) return false
|
||||
return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")
|
||||
}
|
||||
|
||||
export const GithubCopilotPlugin = define({
|
||||
export const GithubCopilotPlugin = {
|
||||
id: "github-copilot",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
effect: Effect.fn(function* (ctx: PluginContext) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
const item = evt.provider.get(ProviderV2.ID.githubCopilot)
|
||||
@@ -39,10 +31,22 @@ export const GithubCopilotPlugin = define({
|
||||
evt.language = evt.sdk.languageModel(evt.model.api.id)
|
||||
return
|
||||
}
|
||||
evt.language = shouldUseResponses(evt.model.api.id)
|
||||
? evt.sdk.responses(evt.model.api.id)
|
||||
: evt.sdk.chat(evt.model.api.id)
|
||||
if (evt.options.endpoint === "responses" && evt.sdk.responses) {
|
||||
evt.language = evt.sdk.responses(evt.model.api.id)
|
||||
return
|
||||
}
|
||||
if (evt.options.endpoint === "chat" && evt.sdk.chat) {
|
||||
evt.language = evt.sdk.chat(evt.model.api.id)
|
||||
return
|
||||
}
|
||||
const match = /^gpt-(\d+)/.exec(evt.model.api.id)
|
||||
// Copilot supports Responses for GPT-5 class models, except mini variants
|
||||
// which still need the chat-completions endpoint.
|
||||
evt.language =
|
||||
match && Number(match[1]) >= 5 && !evt.model.api.id.startsWith("gpt-5-mini") && evt.sdk.responses
|
||||
? evt.sdk.responses(evt.model.api.id)
|
||||
: evt.sdk.chat(evt.model.api.id)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -157,6 +157,42 @@ describe("GithubCopilotPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses advertised Copilot endpoint metadata before model ID fallbacks", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("mai-code-1-flash-picker")),
|
||||
api: {
|
||||
id: ModelV2.ID.make("mai-code-1-flash-picker"),
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
settings: { endpoint: "responses" },
|
||||
},
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: { endpoint: "responses" },
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
|
||||
api: {
|
||||
id: ModelV2.ID.make("gpt-5"),
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
settings: { endpoint: "chat" },
|
||||
},
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: { endpoint: "chat" },
|
||||
})
|
||||
expect(calls).toEqual(["responses:mai-code-1-flash-picker", "chat:gpt-5"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the API model ID when selecting responses or chat", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
|
||||
@@ -12,10 +12,12 @@ export const id = ProviderID.make("github-copilot")
|
||||
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL: string
|
||||
readonly endpoint?: "chat" | "responses"
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const shouldUseResponsesApi = (modelID: string | ModelID) => {
|
||||
export const shouldUseResponsesApi = (modelID: string | ModelID, endpoint?: ModelOptions["endpoint"]) => {
|
||||
if (endpoint) return endpoint === "responses"
|
||||
const model = String(modelID)
|
||||
const match = /^gpt-(\d+)/.exec(model)
|
||||
if (!match) return false
|
||||
@@ -28,7 +30,7 @@ const chatRoute = OpenAIChat.route.with({ provider: id })
|
||||
const responsesRoute = OpenAIResponses.route.with({ provider: id })
|
||||
|
||||
const defaults = (options: ModelOptions) => {
|
||||
const { apiKey: _, auth: _auth, baseURL: _baseURL, ...rest } = options
|
||||
const { apiKey: _, auth: _auth, baseURL: _baseURL, endpoint: _endpoint, ...rest } = options
|
||||
return rest
|
||||
}
|
||||
|
||||
@@ -53,7 +55,8 @@ export const configure = (options: ModelOptions) => {
|
||||
chatRoute.with(withOpenAIOptions(modelID, defaults(options))).model({ id: modelID })
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => (shouldUseResponsesApi(modelID) ? responses(modelID) : chat(modelID)),
|
||||
model: (modelID: string | ModelID) =>
|
||||
shouldUseResponsesApi(modelID, options.endpoint) ? responses(modelID) : chat(modelID),
|
||||
responses,
|
||||
chat,
|
||||
configure,
|
||||
|
||||
@@ -50,6 +50,20 @@ describe("public exports", () => {
|
||||
expect(
|
||||
GitHubCopilot.configure({ baseURL: "https://api.githubcopilot.test", apiKey: "fixture" }).model,
|
||||
).toBeFunction()
|
||||
expect(
|
||||
GitHubCopilot.configure({
|
||||
baseURL: "https://api.githubcopilot.test",
|
||||
apiKey: "fixture",
|
||||
endpoint: "responses",
|
||||
}).model("mai-code-1-flash-picker").route.id,
|
||||
).toBe("openai-responses")
|
||||
expect(
|
||||
GitHubCopilot.configure({
|
||||
baseURL: "https://api.githubcopilot.test",
|
||||
apiKey: "fixture",
|
||||
endpoint: "chat",
|
||||
}).model("gpt-5").route.id,
|
||||
).toBe("openai-chat")
|
||||
})
|
||||
|
||||
test("protocol barrels expose supported low-level routes", () => {
|
||||
|
||||
@@ -72,6 +72,10 @@ type SelectableItem = Item & {
|
||||
}
|
||||
}
|
||||
}
|
||||
type CopilotEndpoint = "chat" | "responses" | "messages"
|
||||
type CopilotModel = Omit<Model, "api"> & {
|
||||
api: Model["api"] & { endpoint?: CopilotEndpoint }
|
||||
}
|
||||
const decodeModels = Schema.decodeUnknownSync(schema)
|
||||
const decodeItem = Schema.decodeUnknownOption(item)
|
||||
|
||||
@@ -86,17 +90,25 @@ function build(key: string, remote: SelectableItem, url: string, prev?: Model):
|
||||
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
|
||||
|
||||
const isMsgApi = remote.supported_endpoints?.includes("/v1/messages")
|
||||
const endpoint: CopilotEndpoint | undefined = isMsgApi
|
||||
? "messages"
|
||||
: remote.supported_endpoints?.includes("/responses")
|
||||
? "responses"
|
||||
: remote.supported_endpoints?.includes("/chat/completions")
|
||||
? "chat"
|
||||
: undefined
|
||||
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 = {
|
||||
const model: CopilotModel = {
|
||||
id: key,
|
||||
providerID: "github-copilot",
|
||||
api: {
|
||||
id: remote.id,
|
||||
url: isMsgApi ? `${url}/v1` : url,
|
||||
npm: isMsgApi ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot",
|
||||
...(endpoint ? { endpoint } : {}),
|
||||
},
|
||||
// API response wins
|
||||
status: "active",
|
||||
|
||||
@@ -218,8 +218,12 @@ 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>, model?: Model) {
|
||||
if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(modelID)
|
||||
if (model && "endpoint" in model.api) {
|
||||
if (model.api.endpoint === "responses" && sdk.responses) return sdk.responses(modelID)
|
||||
if (model.api.endpoint === "chat" && sdk.chat) return sdk.chat(modelID)
|
||||
}
|
||||
const match = /^gpt-(\d+)/.exec(modelID)
|
||||
if (match && Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")) return sdk.responses(modelID)
|
||||
return sdk.chat(modelID)
|
||||
|
||||
@@ -187,6 +187,44 @@ test("converts Copilot AIC token prices to USD per million tokens", async () =>
|
||||
expect(models["ignored-non-chat-record"]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("records Copilot advertised responses endpoint for non-GPT model IDs", async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
model_picker_enabled: true,
|
||||
id: "mai-code-1-flash-picker",
|
||||
name: "MAI-Code-1-Flash",
|
||||
version: "mai-code-1-flash-picker",
|
||||
supported_endpoints: ["/responses"],
|
||||
capabilities: {
|
||||
family: "oswe-vscode-modelD",
|
||||
limits: {
|
||||
max_context_window_tokens: 256000,
|
||||
max_output_tokens: 128000,
|
||||
max_prompt_tokens: 128000,
|
||||
},
|
||||
supports: {
|
||||
streaming: true,
|
||||
structured_outputs: true,
|
||||
tool_calls: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
) as unknown as typeof fetch
|
||||
|
||||
const model = (await CopilotModels.get("https://api.githubcopilot.com")).models["mai-code-1-flash-picker"]
|
||||
|
||||
expect("endpoint" in model.api ? model.api.endpoint : undefined).toBe("responses")
|
||||
})
|
||||
|
||||
test("clears existing variants so refreshed models calculate provider-specific variants", async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
|
||||
Reference in New Issue
Block a user