diff --git a/packages/core/src/github-copilot/models.ts b/packages/core/src/github-copilot/models.ts new file mode 100644 index 0000000000..8790cefba2 --- /dev/null +++ b/packages/core/src/github-copilot/models.ts @@ -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 & { + 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) } }, + }, + ] +} diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index 139e6efd2d..324a937a5c 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -1,7 +1,115 @@ -import { Effect } from "effect" +import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" +import { Effect, Option, Schema, Semaphore, Stream } from "effect" +import { Catalog } from "../../catalog" +import { Credential } from "../../credential" +import { EventV2 } from "../../event" +import { CopilotModels } from "../../github-copilot/models" +import { InstallationVersion } from "../../installation/version" +import { Integration } from "../../integration" import { ModelV2 } from "../../model" import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { ProviderV2 } from "../../provider" +import type { PluginInternal } from "../internal" + +const clientID = "Ov23li8tweQw6odWQebz" +const apiVersion = "2026-06-01" +const pollingSafetyMargin = 3000 +const methodID = Integration.MethodID.make("device") + +const Device = Schema.Struct({ + verification_uri: Schema.String, + user_code: Schema.String, + device_code: Schema.String, + interval: Schema.Number, +}) +const Token = Schema.Struct({ + access_token: Schema.optional(Schema.String), + error: Schema.optional(Schema.String), + interval: Schema.optional(Schema.Number), +}) +const JsonBody = Schema.UnknownFromJsonString +const decodeBody = Schema.decodeUnknownOption(JsonBody) + +const oauth = { + integrationID: Integration.ID.make("github-copilot"), + method: { + id: methodID, + type: "oauth", + label: "Login with GitHub Copilot", + prompts: [ + { + type: "select", + key: "deploymentType", + message: "Select GitHub deployment type", + options: [ + { label: "GitHub.com", value: "github.com", hint: "Public" }, + { label: "GitHub Enterprise", value: "enterprise", hint: "Data residency or self-hosted" }, + ], + }, + { + type: "text", + key: "enterpriseUrl", + message: "Enter your GitHub Enterprise URL or domain", + placeholder: "company.ghe.com or https://company.ghe.com", + when: { key: "deploymentType", op: "eq", value: "enterprise" }, + }, + ], + }, + authorize: (inputs) => + Effect.gen(function* () { + const enterprise = inputs.deploymentType === "enterprise" + if (enterprise && !inputs.enterpriseUrl) return yield* Effect.fail(new Error("Enterprise URL is required")) + const domain = enterprise ? normalizeDomain(inputs.enterpriseUrl ?? "") : "github.com" + const urls = oauthURLs(domain) + const device = yield* request(urls.device, { + method: "POST", + headers: headers(), + body: JSON.stringify({ client_id: clientID, scope: "read:user" }), + }).pipe(Effect.map(Schema.decodeUnknownSync(Device))) + const interval = Math.max(device.interval, 1) * 1000 + + const poll = (wait: number): Effect.Effect => + request(urls.token, { + method: "POST", + headers: headers(), + body: JSON.stringify({ + client_id: clientID, + device_code: device.device_code, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }).pipe( + Effect.map(Schema.decodeUnknownSync(Token)), + Effect.flatMap((token) => { + if (token.access_token) { + return Effect.succeed( + Credential.OAuth.make({ + type: "oauth", + methodID, + refresh: token.access_token, + access: token.access_token, + expires: 0, + ...(enterprise ? { metadata: { enterpriseUrl: domain } } : {}), + }), + ) + } + if (token.error === "authorization_pending") + return Effect.sleep(wait + pollingSafetyMargin).pipe(Effect.andThen(poll(wait))) + if (token.error === "slow_down") { + const next = token.interval && token.interval > 0 ? token.interval * 1000 : wait + 5000 + return Effect.sleep(next + pollingSafetyMargin).pipe(Effect.andThen(poll(next))) + } + return Effect.fail(new Error(`Device authorization failed${token.error ? `: ${token.error}` : ""}`)) + }), + ) + + return { + mode: "auto" as const, + url: device.verification_uri, + instructions: `Enter code: ${device.user_code}`, + callback: poll(interval), + } + }), +} satisfies IntegrationOAuthMethodRegistration function shouldUseResponses(modelID: string) { // Copilot supports Responses for GPT-5 class models, except mini variants @@ -14,19 +122,103 @@ function shouldUseResponses(modelID: string) { export const GithubCopilotPlugin = define({ id: "opencode.provider.github-copilot", effect: Effect.fn(function* (ctx) { + const catalog = yield* Catalog.Service + const events = yield* EventV2.Service + const loading = Semaphore.makeUnsafe(1) + const loaded: { + baseURL?: string + models?: Map + } = {} + + const load = Effect.fn("GithubCopilotPlugin.load")(function* () { + const connection = yield* ctx.integration.connection.active("github-copilot") + const credential = connection + ? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined))) + : undefined + if (credential?.type !== "oauth") { + loaded.baseURL = undefined + loaded.models = undefined + return + } + + const enterprise = credential.metadata?.enterpriseUrl + loaded.baseURL = baseURL(typeof enterprise === "string" ? enterprise : undefined) + const provider = yield* catalog.provider.get(ProviderV2.ID.githubCopilot) + const existing = (yield* catalog.model.all()).filter((model) => model.providerID === ProviderV2.ID.githubCopilot) + loaded.models = yield* Effect.tryPromise({ + try: () => + CopilotModels.get( + loaded.baseURL ?? baseURL(), + { + ...provider?.headers, + Authorization: `Bearer ${credential.refresh}`, + "User-Agent": `opencode/${InstallationVersion}`, + "X-GitHub-Api-Version": apiVersion, + }, + existing, + ), + catch: (cause) => cause, + }).pipe( + Effect.catch((cause) => + Effect.logWarning("failed to sync GitHub Copilot models", { cause }).pipe(Effect.as(undefined)), + ), + ) + }) + + yield* ctx.integration.transform((draft) => { + draft.method.update(oauth) + }) yield* ctx.catalog.transform((evt) => { const item = evt.provider.get(ProviderV2.ID.githubCopilot) - if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return - evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => { - // This chat-only alias conflicts with the Copilot GPT-5 Responses route, - // so hide it only for Copilot rather than for every provider catalog. - model.enabled = false - }) + if (!item) return + if (loaded.models) { + for (const id of item.models.keys()) { + if (!loaded.models.has(ModelV2.ID.make(id))) evt.model.remove(item.provider.id, id) + } + for (const [id, model] of loaded.models) { + evt.model.update(item.provider.id, id, (draft) => Object.assign(draft, structuredClone(model))) + } + } else if (loaded.baseURL) { + for (const id of item.models.keys()) { + evt.model.update(item.provider.id, id, (model) => { + model.settings = ProviderV2.mergeOverlay(model.settings, { baseURL: loaded.baseURL }) + }) + } + } + if (item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) { + evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => { + // This chat-only alias conflicts with the Copilot GPT-5 Responses route, + // so hide it only for Copilot rather than for every provider catalog. + model.enabled = false + }) + } }) + const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload()))) + yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe( + Stream.filter((event) => event.data.integrationID === Integration.ID.make("github-copilot")), + Stream.runForEach(refresh), + Effect.forkScoped({ startImmediately: true }), + ) + yield* refresh().pipe(Effect.forkScoped) yield* ctx.aisdk.hook( "sdk", Effect.fn(function* (evt) { - if (evt.package !== "@ai-sdk/github-copilot") return + if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return + if (evt.package !== "@ai-sdk/github-copilot" && evt.package !== "@ai-sdk/anthropic") return + evt.options.fetch = copilotFetch( + typeof evt.options.apiKey === "string" ? evt.options.apiKey : undefined, + evt.options.fetch, + evt.package === "@ai-sdk/anthropic", + ) + if (evt.package === "@ai-sdk/anthropic") { + evt.options.headers = { + ...evt.options.headers, + "anthropic-beta": "interleaved-thinking-2025-05-14", + } + const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic")) + evt.sdk = mod.createAnthropic(evt.options) + return + } const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider")) evt.sdk = mod.createOpenaiCompatible(evt.options) }), @@ -52,4 +244,114 @@ export const GithubCopilotPlugin = define({ }), ) }), -}) +} satisfies PluginInternal.InternalPlugin) + +function normalizeDomain(input: string) { + return input.replace(/^https?:\/\//, "").replace(/\/$/, "") +} + +function oauthURLs(domain: string) { + return { + device: `https://${domain}/login/device/code`, + token: `https://${domain}/login/oauth/access_token`, + } +} + +function baseURL(enterprise?: string) { + return enterprise ? `https://copilot-api.${normalizeDomain(enterprise)}` : "https://api.githubcopilot.com" +} + +function headers() { + return { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": `opencode/${InstallationVersion}`, + } +} + +function request(url: string, init: RequestInit) { + return Effect.tryPromise({ + try: async (signal) => { + const response = await fetch(url, { ...init, signal }) + if (!response.ok) throw new Error(`Request failed: ${response.status}`) + return response.json() + }, + catch: (cause) => cause, + }) +} + +type Fetch = (input: Parameters[0], init?: RequestInit) => Promise + +export function copilotFetch(token: string | undefined, upstream: Fetch | undefined, anthropic: boolean): Fetch { + const send = upstream ?? fetch + return async (input, init) => { + const requestHeaders = new Headers(init?.headers) + if (token) { + requestHeaders.delete("authorization") + requestHeaders.delete("x-api-key") + requestHeaders.set("Authorization", `Bearer ${token}`) + } + requestHeaders.set("User-Agent", `opencode/${InstallationVersion}`) + requestHeaders.set("Openai-Intent", "conversation-edits") + requestHeaders.set("X-GitHub-Api-Version", apiVersion) + if (anthropic) requestHeaders.set("anthropic-beta", "interleaved-thinking-2025-05-14") + + const url = input instanceof URL ? input.href : typeof input === "string" ? input : input.url + const body = typeof init?.body === "string" ? Option.getOrUndefined(decodeBody(init.body)) : undefined + const metadata = requestMetadata(url, body) + requestHeaders.set("x-initiator", metadata.agent ? "agent" : "user") + if (metadata.vision) requestHeaders.set("Copilot-Vision-Request", "true") + return send(input, { ...init, headers: requestHeaders }) + } +} + +function requestMetadata(url: string, body: unknown) { + if (!record(body)) return { agent: false, vision: false } + if (Array.isArray(body.input)) { + const last = body.input.at(-1) + return { + agent: !record(last) || last.role !== "user", + vision: body.input.some( + (item) => + record(item) && + Array.isArray(item.content) && + item.content.some((part) => record(part) && part.type === "input_image"), + ), + } + } + if (!Array.isArray(body.messages)) return { agent: false, vision: false } + const last = body.messages.at(-1) + if (url.includes("completions")) { + return { + agent: !record(last) || last.role !== "user", + vision: body.messages.some( + (message) => + record(message) && + Array.isArray(message.content) && + message.content.some((part) => record(part) && part.type === "image_url"), + ), + } + } + const content = record(last) && Array.isArray(last.content) ? last.content : [] + return { + agent: + !record(last) || last.role !== "user" || !content.some((part) => record(part) && part.type !== "tool_result"), + vision: body.messages.some( + (message) => + record(message) && + Array.isArray(message.content) && + message.content.some( + (part) => + record(part) && + (part.type === "image" || + (part.type === "tool_result" && + Array.isArray(part.content) && + part.content.some((nested) => record(nested) && nested.type === "image"))), + ), + ), + } +} + +function record(input: unknown): input is Record { + return typeof input === "object" && input !== null && !Array.isArray(input) +} diff --git a/packages/core/test/github-copilot/models.test.ts b/packages/core/test/github-copilot/models.test.ts new file mode 100644 index 0000000000..31d8f710cd --- /dev/null +++ b/packages/core/test/github-copilot/models.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from "bun:test" +import { CopilotModels } from "@opencode-ai/core/github-copilot/models" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" + +test("defensively syncs advertised Copilot models", async () => { + const server = Bun.serve({ + port: 0, + fetch: () => + Response.json({ + data: [ + { + model_picker_enabled: true, + id: "gpt-5", + name: "GPT-5 remote", + version: "gpt-5-2026-06-01", + supported_endpoints: ["/responses"], + billing: { + token_prices: { + batch_size: 0, + default: { input_price: 10, output_price: 20, cache_price: 5 }, + }, + }, + capabilities: { + family: "gpt", + limits: { + max_context_window_tokens: 200000, + max_output_tokens: 16384, + max_prompt_tokens: 180000, + }, + supports: { tool_calls: true, reasoning_effort: ["low", "high"] }, + }, + }, + { + model_picker_enabled: false, + id: "utility", + name: "Utility", + version: "utility-2026-06-01", + capabilities: { + family: "utility", + limits: { max_output_tokens: 1000, max_prompt_tokens: 8000 }, + supports: { tool_calls: false }, + }, + }, + { model_picker_enabled: true, id: "incomplete" }, + ], + }), + }) + + try { + const existing = ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.githubCopilot, ModelV2.ID.make("gpt-5")), + modelID: ModelV2.ID.make("gpt-5"), + name: "GPT-5 local", + }) + const stale = ModelV2.Info.make({ + ...ModelV2.Info.empty(ProviderV2.ID.githubCopilot, ModelV2.ID.make("stale")), + modelID: ModelV2.ID.make("stale"), + }) + const models = await CopilotModels.get(server.url.origin, {}, [existing, stale]) + const model = models.get(ModelV2.ID.make("gpt-5")) + + expect(model?.name).toBe("GPT-5 local") + expect(model?.settings).toMatchObject({ baseURL: server.url.origin, endpoint: "responses" }) + expect(model?.cost[0]).toMatchObject({ input: 0, output: 0, cache: { read: 0, write: 0 } }) + expect(model?.variants.map((variant) => variant.id)).toEqual([ + ModelV2.VariantID.make("low"), + ModelV2.VariantID.make("high"), + ]) + expect(models.get(ModelV2.ID.make("utility"))?.enabled).toBe(false) + expect(models.has(ModelV2.ID.make("stale"))).toBe(false) + expect(models.has(ModelV2.ID.make("incomplete"))).toBe(false) + } finally { + await server.stop(true) + } +}) diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index b1e6c9c772..b850d9ac73 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -5,8 +5,9 @@ import { Catalog } from "@opencode-ai/core/catalog" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" -import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot" +import { copilotFetch, GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot" import { ProviderV2 } from "@opencode-ai/core/provider" +import { Integration } from "@opencode-ai/core/integration" import type { LanguageModelV3 } from "@ai-sdk/provider" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" @@ -39,6 +40,46 @@ function fakeSelectorSdk(calls: string[]) { } describe("GithubCopilotPlugin", () => { + it.effect("registers GitHub Copilot device OAuth", () => + Effect.gen(function* () { + yield* addPlugin() + expect((yield* (yield* Integration.Service).get(Integration.ID.make("github-copilot")))?.methods).toContainEqual({ + id: Integration.MethodID.make("device"), + type: "oauth", + label: "Login with GitHub Copilot", + prompts: expect.any(Array), + }) + }), + ) + + it.live("adds Copilot authentication and request metadata headers", () => + Effect.gen(function* () { + const requests: Headers[] = [] + const send = copilotFetch( + "token", + async (_input: Parameters[0], init?: RequestInit) => { + requests.push(new Headers(init?.headers)) + return Response.json({ ok: true }) + }, + false, + ) + yield* Effect.promise(() => + send("https://api.githubcopilot.com/chat/completions", { + method: "POST", + headers: { "x-api-key": "old" }, + body: JSON.stringify({ + messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "data:image/png" } }] }], + }), + }), + ) + expect(requests[0]?.get("authorization")).toBe("Bearer token") + expect(requests[0]?.has("x-api-key")).toBe(false) + expect(requests[0]?.get("x-initiator")).toBe("user") + expect(requests[0]?.get("copilot-vision-request")).toBe("true") + expect(requests[0]?.get("x-github-api-version")).toBe("2026-06-01") + }), + ) + it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service