fix(copilot): honor advertised model endpoints (#34958)
This commit is contained in:
@@ -1,19 +1,11 @@
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { ModelV2 } from "../../model"
|
import { ModelV2 } from "../../model"
|
||||||
import { define } from "../internal"
|
|
||||||
import { ProviderV2 } from "../../provider"
|
import { ProviderV2 } from "../../provider"
|
||||||
|
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||||
|
|
||||||
function shouldUseResponses(modelID: string) {
|
export const GithubCopilotPlugin = {
|
||||||
// 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({
|
|
||||||
id: "github-copilot",
|
id: "github-copilot",
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx: PluginContext) {
|
||||||
yield* ctx.catalog.transform(
|
yield* ctx.catalog.transform(
|
||||||
Effect.fn(function* (evt) {
|
Effect.fn(function* (evt) {
|
||||||
const item = evt.provider.get(ProviderV2.ID.githubCopilot)
|
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)
|
evt.language = evt.sdk.languageModel(evt.model.api.id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
evt.language = shouldUseResponses(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.responses(evt.model.api.id)
|
||||||
: evt.sdk.chat(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", () =>
|
it.effect("uses the API model ID when selecting responses or chat", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ export const id = ProviderID.make("github-copilot")
|
|||||||
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||||
ProviderAuthOption<"optional"> & {
|
ProviderAuthOption<"optional"> & {
|
||||||
readonly baseURL: string
|
readonly baseURL: string
|
||||||
|
readonly endpoint?: "chat" | "responses"
|
||||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
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 model = String(modelID)
|
||||||
const match = /^gpt-(\d+)/.exec(model)
|
const match = /^gpt-(\d+)/.exec(model)
|
||||||
if (!match) return false
|
if (!match) return false
|
||||||
@@ -28,7 +30,7 @@ const chatRoute = OpenAIChat.route.with({ provider: id })
|
|||||||
const responsesRoute = OpenAIResponses.route.with({ provider: id })
|
const responsesRoute = OpenAIResponses.route.with({ provider: id })
|
||||||
|
|
||||||
const defaults = (options: ModelOptions) => {
|
const defaults = (options: ModelOptions) => {
|
||||||
const { apiKey: _, auth: _auth, baseURL: _baseURL, ...rest } = options
|
const { apiKey: _, auth: _auth, baseURL: _baseURL, endpoint: _endpoint, ...rest } = options
|
||||||
return rest
|
return rest
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +55,8 @@ export const configure = (options: ModelOptions) => {
|
|||||||
chatRoute.with(withOpenAIOptions(modelID, defaults(options))).model({ id: modelID })
|
chatRoute.with(withOpenAIOptions(modelID, defaults(options))).model({ id: modelID })
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
model: (modelID: string | ModelID) => (shouldUseResponsesApi(modelID) ? responses(modelID) : chat(modelID)),
|
model: (modelID: string | ModelID) =>
|
||||||
|
shouldUseResponsesApi(modelID, options.endpoint) ? responses(modelID) : chat(modelID),
|
||||||
responses,
|
responses,
|
||||||
chat,
|
chat,
|
||||||
configure,
|
configure,
|
||||||
|
|||||||
@@ -50,6 +50,20 @@ describe("public exports", () => {
|
|||||||
expect(
|
expect(
|
||||||
GitHubCopilot.configure({ baseURL: "https://api.githubcopilot.test", apiKey: "fixture" }).model,
|
GitHubCopilot.configure({ baseURL: "https://api.githubcopilot.test", apiKey: "fixture" }).model,
|
||||||
).toBeFunction()
|
).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", () => {
|
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 decodeModels = Schema.decodeUnknownSync(schema)
|
||||||
const decodeItem = Schema.decodeUnknownOption(item)
|
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/"))
|
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
|
||||||
|
|
||||||
const isMsgApi = remote.supported_endpoints?.includes("/v1/messages")
|
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
|
const prices = remote.billing?.token_prices
|
||||||
// Copilot prices are AIC per billing batch; OpenCode stores USD per million tokens.
|
// Copilot prices are AIC per billing batch; OpenCode stores USD per million tokens.
|
||||||
const usdPerMillion = prices ? 10_000 / prices.batch_size : 0
|
const usdPerMillion = prices ? 10_000 / prices.batch_size : 0
|
||||||
|
|
||||||
const model: Model = {
|
const model: CopilotModel = {
|
||||||
id: key,
|
id: key,
|
||||||
providerID: "github-copilot",
|
providerID: "github-copilot",
|
||||||
api: {
|
api: {
|
||||||
id: remote.id,
|
id: remote.id,
|
||||||
url: isMsgApi ? `${url}/v1` : url,
|
url: isMsgApi ? `${url}/v1` : url,
|
||||||
npm: isMsgApi ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot",
|
npm: isMsgApi ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot",
|
||||||
|
...(endpoint ? { endpoint } : {}),
|
||||||
},
|
},
|
||||||
// API response wins
|
// API response wins
|
||||||
status: "active",
|
status: "active",
|
||||||
|
|||||||
@@ -218,8 +218,12 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
|||||||
"github-copilot": () =>
|
"github-copilot": () =>
|
||||||
Effect.succeed({
|
Effect.succeed({
|
||||||
autoload: false,
|
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 (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)
|
const match = /^gpt-(\d+)/.exec(modelID)
|
||||||
if (match && Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")) return sdk.responses(modelID)
|
if (match && Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")) return sdk.responses(modelID)
|
||||||
return sdk.chat(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()
|
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 () => {
|
test("clears existing variants so refreshed models calculate provider-specific variants", async () => {
|
||||||
globalThis.fetch = mock(() =>
|
globalThis.fetch = mock(() =>
|
||||||
Promise.resolve(
|
Promise.resolve(
|
||||||
|
|||||||
Reference in New Issue
Block a user