Skip to content

Commit 4fcd80a

Browse files
Apply PR #11274: feat: add copilot fetch adapter
2 parents bfa34ab + 7bf2fe7 commit 4fcd80a

File tree

4 files changed

+236
-39
lines changed

4 files changed

+236
-39
lines changed

packages/opencode/src/provider/provider.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { createVertexAnthropic } from "@ai-sdk/google-vertex/anthropic"
2424
import { createOpenAI } from "@ai-sdk/openai"
2525
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
2626
import { createOpenRouter, type LanguageModelV2 } from "@openrouter/ai-sdk-provider"
27-
import { createOpenaiCompatible as createGitHubCopilotOpenAICompatible } from "./sdk/openai-compatible/src"
27+
import { createCopilot } from "./sdk/copilot"
2828
import { createXai } from "@ai-sdk/xai"
2929
import { createMistral } from "@ai-sdk/mistral"
3030
import { createGroq } from "@ai-sdk/groq"
@@ -74,8 +74,8 @@ export namespace Provider {
7474
"@ai-sdk/perplexity": createPerplexity,
7575
"@ai-sdk/vercel": createVercel,
7676
"@gitlab/gitlab-ai-provider": createGitLab,
77-
// @ts-ignore (TODO: kill this code so we dont have to maintain it)
78-
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
77+
// @ts-ignore
78+
"@ai-sdk/github-copilot": createCopilot,
7979
}
8080

8181
type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
@@ -976,6 +976,12 @@ export namespace Provider {
976976
...options["headers"],
977977
...model.headers,
978978
}
979+
if (model.providerID.startsWith("github-copilot") && model.id.toLowerCase().includes("claude")) {
980+
options["headers"] = {
981+
...options["headers"],
982+
"anthropic-beta": "interleaved-thinking-2025-05-14",
983+
}
984+
}
979985

980986
const key = Bun.hash.xxHash32(JSON.stringify({ providerID: model.providerID, npm: model.api.npm, options }))
981987
const existing = s.sdk.get(key)
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { OpenAICompatibleChatLanguageModel } from "@ai-sdk/openai-compatible"
2+
import type { LanguageModelV2, LanguageModelV2StreamPart, SharedV2ProviderMetadata } from "@ai-sdk/provider"
3+
import { type FetchFunction, withoutTrailingSlash, withUserAgentSuffix } from "@ai-sdk/provider-utils"
4+
import { OpenAIResponsesLanguageModel } from "../openai-compatible/src/responses/openai-responses-language-model"
5+
import { ProviderTransform } from "../../transform"
6+
7+
type RawChunk = {
8+
choices?: Array<{
9+
message?: { reasoning_opaque?: string }
10+
delta?: { reasoning_opaque?: string }
11+
}>
12+
}
13+
14+
const extractor = {
15+
async extractMetadata({ parsedBody }: { parsedBody: unknown }): Promise<SharedV2ProviderMetadata | undefined> {
16+
const body = parsedBody as RawChunk
17+
const opaque = body?.choices?.[0]?.message?.reasoning_opaque
18+
if (!opaque) return undefined
19+
return { openaiCompatible: { reasoning_opaque: opaque } }
20+
},
21+
createStreamExtractor: () => ({ processChunk() {}, buildMetadata: () => undefined }),
22+
}
23+
24+
function wrapStream(stream: ReadableStream<LanguageModelV2StreamPart>) {
25+
const state = { opaque: undefined as string | undefined }
26+
return stream.pipeThrough(
27+
new TransformStream<LanguageModelV2StreamPart, LanguageModelV2StreamPart>({
28+
transform(chunk, controller) {
29+
if (chunk.type === "raw") {
30+
const raw = chunk.rawValue as RawChunk
31+
state.opaque ??= raw?.choices?.[0]?.delta?.reasoning_opaque
32+
}
33+
if (chunk.type === "reasoning-end" && state.opaque) {
34+
controller.enqueue({
35+
...chunk,
36+
providerMetadata: { ...chunk.providerMetadata, openaiCompatible: { reasoning_opaque: state.opaque } },
37+
})
38+
return
39+
}
40+
controller.enqueue(chunk)
41+
},
42+
}),
43+
)
44+
}
45+
46+
function createFetchAdapter(base?: FetchFunction, modelId?: string): FetchFunction {
47+
const fetcher = base ?? globalThis.fetch
48+
const isGemini = modelId?.toLowerCase().includes("gemini")
49+
50+
return (async (url, init) => {
51+
// catch MCP tools not sanitized in transform.ts
52+
if (isGemini && init?.body && url.toString().includes("/chat/completions")) {
53+
const body = JSON.parse(init.body as string)
54+
if (body.tools) {
55+
body.tools = body.tools.map((t: any) => ({
56+
...t,
57+
function: { ...t.function, parameters: ProviderTransform.sanitizeGeminiSchema(t.function.parameters) },
58+
}))
59+
init = { ...init, body: JSON.stringify(body) }
60+
}
61+
}
62+
63+
const response = await fetcher(url, init)
64+
if (!url.toString().includes("/chat/completions")) return response
65+
66+
const contentType = response.headers.get("content-type") ?? ""
67+
68+
if (contentType.includes("text/event-stream")) {
69+
return new Response(
70+
response.body!.pipeThrough(
71+
new TransformStream({
72+
transform(chunk, controller) {
73+
const text = new TextDecoder().decode(chunk)
74+
controller.enqueue(new TextEncoder().encode(text.replace(/"reasoning_text":/g, '"reasoning_content":')))
75+
},
76+
}),
77+
),
78+
{ status: response.status, headers: response.headers },
79+
)
80+
}
81+
82+
const text = await response.text()
83+
return new Response(text.replace(/"reasoning_text":/g, '"reasoning_content":'), {
84+
status: response.status,
85+
headers: response.headers,
86+
})
87+
}) as FetchFunction
88+
}
89+
90+
export function createCopilot(
91+
options: {
92+
apiKey?: string
93+
baseURL?: string
94+
name?: string
95+
headers?: Record<string, string>
96+
fetch?: FetchFunction
97+
} = {},
98+
) {
99+
const baseURL = withoutTrailingSlash(options.baseURL ?? "https://api.openai.com/v1")
100+
const headers = {
101+
...(options.apiKey && { Authorization: `Bearer ${options.apiKey}` }),
102+
...options.headers,
103+
}
104+
const getHeaders = () => withUserAgentSuffix(headers, "opencode/copilot")
105+
106+
const createChatModel = (id: string): LanguageModelV2 => {
107+
const copilotFetch = createFetchAdapter(options.fetch, id)
108+
const model = new OpenAICompatibleChatLanguageModel(id, {
109+
provider: "openai.chat",
110+
headers: getHeaders,
111+
url: ({ path }) => `${baseURL}${path}`,
112+
fetch: copilotFetch,
113+
metadataExtractor: extractor,
114+
})
115+
116+
return {
117+
specificationVersion: model.specificationVersion,
118+
modelId: model.modelId,
119+
provider: model.provider,
120+
get supportedUrls() {
121+
return model.supportedUrls
122+
},
123+
doGenerate: model.doGenerate.bind(model),
124+
async doStream(opts) {
125+
const result = await model.doStream({ ...opts, includeRawChunks: true })
126+
return { ...result, stream: wrapStream(result.stream) }
127+
},
128+
}
129+
}
130+
131+
const createResponsesModel = (id: string): LanguageModelV2 => {
132+
return new OpenAIResponsesLanguageModel(id, {
133+
provider: `${options.name ?? "copilot"}.responses`,
134+
headers: getHeaders,
135+
url: ({ path }) => `${baseURL}${path}`,
136+
fetch: options.fetch,
137+
})
138+
}
139+
140+
return Object.assign((id: string) => createChatModel(id), {
141+
languageModel: createChatModel,
142+
chat: createChatModel,
143+
responses: createResponsesModel,
144+
})
145+
}

packages/opencode/src/provider/transform.ts

Lines changed: 81 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,28 @@ export namespace ProviderTransform {
6666
.filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "")
6767
}
6868

69+
// extract copilot's reasoning_opaque while preserving reasoning text
70+
if (model.providerID.startsWith("github-copilot")) {
71+
msgs = msgs.map((msg) => {
72+
if (msg.role !== "assistant" || !Array.isArray(msg.content)) return msg
73+
74+
const opaque = msg.content
75+
.filter((part: any) => part.type === "reasoning")
76+
.map((part: any) => part.providerOptions?.openaiCompatible?.reasoning_opaque)
77+
.find(Boolean)
78+
79+
if (!opaque) return msg
80+
81+
return {
82+
...msg,
83+
providerOptions: {
84+
...msg.providerOptions,
85+
openaiCompatible: { ...(msg.providerOptions as any)?.openaiCompatible, reasoning_opaque: opaque },
86+
},
87+
}
88+
})
89+
}
90+
6991
if (model.api.id.includes("claude")) {
7092
return msgs.map((msg) => {
7193
if ((msg.role === "assistant" || msg.role === "tool") && Array.isArray(msg.content)) {
@@ -354,6 +376,14 @@ export namespace ProviderTransform {
354376
return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
355377

356378
case "@ai-sdk/github-copilot":
379+
// Claude models on Copilot use thinking_budget (token count) instead of reasoningEffort
380+
if (model.id.includes("claude")) {
381+
return {
382+
high: { thinking_budget: Math.min(16_000, Math.floor(model.limit.output / 2 - 1)) },
383+
max: { thinking_budget: Math.min(31_999, model.limit.output - 1) },
384+
}
385+
}
386+
// Non-Claude models use OpenAI-style reasoningEffort
357387
const copilotEfforts = iife(() => {
358388
if (id.includes("5.1-codex-max") || id.includes("5.2")) return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"]
359389
return WIDELY_SUPPORTED_EFFORTS
@@ -672,6 +702,13 @@ export namespace ProviderTransform {
672702
}
673703
}
674704

705+
if (npm === "@ai-sdk/github-copilot") {
706+
const budget = typeof options?.["thinking_budget"] === "number" ? options["thinking_budget"] : 0
707+
if (budget > 0) {
708+
return Math.max(standardLimit, budget + 1)
709+
}
710+
}
711+
675712
return standardLimit
676713
}
677714

@@ -694,49 +731,58 @@ export namespace ProviderTransform {
694731
}
695732
*/
696733

697-
// Convert integer enums to string enums for Google/Gemini
698-
if (model.providerID === "google" || model.api.id.includes("gemini")) {
699-
const sanitizeGemini = (obj: any): any => {
700-
if (obj === null || typeof obj !== "object") {
701-
return obj
702-
}
734+
const isGemini = model.providerID === "google" || model.id.toLowerCase().includes("gemini")
735+
if (isGemini) {
736+
schema = sanitizeGeminiSchema(schema)
737+
}
703738

704-
if (Array.isArray(obj)) {
705-
return obj.map(sanitizeGemini)
706-
}
739+
return schema
740+
}
707741

708-
const result: any = {}
709-
for (const [key, value] of Object.entries(obj)) {
710-
if (key === "enum" && Array.isArray(value)) {
711-
// Convert all enum values to strings
712-
result[key] = value.map((v) => String(v))
713-
// If we have integer type with enum, change type to string
714-
if (result.type === "integer" || result.type === "number") {
715-
result.type = "string"
716-
}
717-
} else if (typeof value === "object" && value !== null) {
718-
result[key] = sanitizeGemini(value)
719-
} else {
720-
result[key] = value
721-
}
742+
export function sanitizeGeminiSchema(obj: any): any {
743+
if (obj === null || typeof obj !== "object") return obj
744+
if (Array.isArray(obj)) return obj.map(sanitizeGeminiSchema)
745+
746+
const result: any = {}
747+
for (const [key, value] of Object.entries(obj)) {
748+
if (key === "type" && Array.isArray(value)) {
749+
// gemini will 400 on union types with null
750+
const types = value as string[]
751+
const hasNull = types.includes("null")
752+
const nonNullTypes = types.filter((t) => t !== "null")
753+
754+
if (hasNull && nonNullTypes.length === 1) {
755+
result.type = nonNullTypes[0]
756+
result.nullable = true
757+
} else if (nonNullTypes.length === 1) {
758+
result.type = nonNullTypes[0]
759+
} else {
760+
result.type = value
722761
}
723-
724-
// Filter required array to only include fields that exist in properties
725-
if (result.type === "object" && result.properties && Array.isArray(result.required)) {
726-
result.required = result.required.filter((field: any) => field in result.properties)
762+
} else if (key === "enum" && Array.isArray(value)) {
763+
// Convert all enum values to strings
764+
result[key] = value.map((v) => String(v))
765+
// If we have integer type with enum, change type to string
766+
if (result.type === "integer" || result.type === "number") {
767+
result.type = "string"
727768
}
728-
729-
if (result.type === "array" && result.items == null) {
730-
result.items = {}
731-
}
732-
733-
return result
769+
} else if (typeof value === "object" && value !== null) {
770+
result[key] = sanitizeGeminiSchema(value)
771+
} else {
772+
result[key] = value
734773
}
774+
}
735775

736-
schema = sanitizeGemini(schema)
776+
// Filter required array to only include fields that exist in properties
777+
if (result.type === "object" && result.properties && Array.isArray(result.required)) {
778+
result.required = result.required.filter((field: any) => field in result.properties)
737779
}
738780

739-
return schema
781+
if (result.type === "array" && result.items == null) {
782+
result.items = {}
783+
}
784+
785+
return result
740786
}
741787

742788
export function error(providerID: string, error: APICallError) {

packages/opencode/test/provider/transform.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -959,7 +959,7 @@ describe("ProviderTransform.message - providerOptions key remapping", () => {
959959
expect(result[0].providerOptions?.openai).toBeUndefined()
960960
})
961961

962-
test("openai with github-copilot npm remaps providerID to 'openai'", () => {
962+
test("github-copilot npm remaps providerID to 'openai' key", () => {
963963
const model = createModel("github-copilot", "@ai-sdk/github-copilot")
964964
const msgs = [
965965
{

0 commit comments

Comments
 (0)