Browse Source

feat(core): port GitHub Copilot OAuth (#36336)

Co-authored-by: Dax Raad <d@ironbay.co>
opencode-agent[bot] 1 month ago
parent
commit
d1550cb599

+ 214 - 0
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<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) } },
+    },
+  ]
+}

+ 311 - 9
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<Credential.OAuth, unknown> =>
+        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<ModelV2.ID, ModelV2.Info>
+    } = {}
+
+    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<typeof fetch>[0], init?: RequestInit) => Promise<Response>
+
+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<string, unknown> {
+  return typeof input === "object" && input !== null && !Array.isArray(input)
+}

+ 76 - 0
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)
+  }
+})

+ 42 - 1
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<typeof fetch>[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