From ed38466a9d5ccfe22fcf4adb8f1aeedc73cc0043 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 21 Aug 2026 00:36:43 -0500 Subject: [PATCH] feat(core): use copilot utility models for titles --- .../src/plugin/provider/github-copilot.ts | 35 +++++++- .../plugin/provider-github-copilot.test.ts | 90 ++++++++++++++++++- 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index 927bd82ba3b..f76136e9b3e 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -148,6 +148,7 @@ const oauth = (app: App.Info) => export const GithubCopilotPlugin = define({ id: "opencode.provider.github.copilot", effect: Effect.fn(function* (ctx) { + const agents = yield* Agent.Service const catalog = yield* Catalog.Service const bus = yield* Bus.Service const loading = Semaphore.makeUnsafe(1) @@ -246,8 +247,14 @@ export const GithubCopilotPlugin = define({ (evt) => Effect.gen(function* () { if (evt.model.providerID !== Provider.ID.githubCopilot) return - if (evt.agent === Agent.ID.make("title")) + if (evt.agent === Agent.ID.make("title")) { evt.request.headers.set("X-Interaction-Type", "conversation-background") + // Auto-selected title models come from the regular catalog and consume + // premium request quota, so reroute them to a utility model unless the + // user explicitly configured a title model. + if (!(yield* agents.get(Agent.ID.make("title")))?.model) + evt.request = (yield* utilityTitleRequest(catalog, evt.request)) ?? evt.request + } if (evt.agent === Agent.ID.make("compaction")) evt.request.headers.set("X-Interaction-Type", "conversation-compaction") const token = evt.request.headers.get("x-api-key") @@ -283,6 +290,32 @@ export const GithubCopilotPlugin = define({ }), } satisfies PluginInternal.InternalPlugin) +// GitHub exposes utility models for background work such as title generation +// without including them in the model picker or premium request quota. +const utilityModels = ["gpt-5.4-nano", "gpt-4.1", "gpt-4o", "gpt-4o-mini"].map((id) => Model.ID.make(id)) + +const utilityTitleRequest = Effect.fn("GithubCopilotPlugin.utilityTitleRequest")(function* ( + catalog: Catalog.Interface, + request: Request, +) { + const endpoint = request.url.includes("/chat/completions") + ? "chat" + : request.url.includes("/responses") + ? "responses" + : undefined + if (!endpoint) return + const target = (yield* Effect.forEach(utilityModels, (id) => catalog.model.get(Provider.ID.githubCopilot, id))) + .filter((model) => model !== undefined) + .find((model) => (model.settings?.endpoint ?? "chat") === endpoint) + if (!target) return + const body = Option.getOrUndefined(decodeBody(yield* Effect.promise(() => request.clone().text()))) + if (!record(body) || typeof body.model !== "string" || body.model === target.modelID) return + const rewritten = new Request(request, { body: JSON.stringify({ ...body, model: target.modelID }) }) + // The stale length would no longer match the rewritten body. + rewritten.headers.delete("content-length") + return rewritten +}) + function normalizeDomain(input: string) { return input.replace(/^https?:\/\//, "").replace(/\/$/, "") } diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index 6088965aa38..e9ff717e811 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -1,6 +1,6 @@ import { AISDK } from "@opencode-ai/core/aisdk" import { App } from "@opencode-ai/core/app" -import { Agent } from "@opencode-ai/schema/agent" +import { Agent } from "@opencode-ai/core/agent" import { Session } from "@opencode-ai/schema/session" import { describe, expect, test } from "bun:test" import { Effect } from "effect" @@ -154,6 +154,94 @@ describe("GithubCopilotPlugin", () => { }), ) + it.effect("reroutes auto-selected title requests to a utility model", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + yield* addPlugin() + yield* catalog.transform((evt) => { + evt.model.update(Provider.ID.githubCopilot, Model.ID.make("gpt-5.4-nano"), (model) => { + model.enabled = false + model.settings = { endpoint: "responses" } + }) + }) + const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", { + sessionID: Session.ID.make("ses_title"), + agent: Agent.ID.make("title"), + model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.6-luna") }), + request: new Request("https://api.githubcopilot.com/responses", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "gpt-5.6-luna", input: [{ role: "user", content: "hello" }] }), + }), + }) + expect(yield* Effect.promise(() => event.request.clone().json())).toEqual({ + model: "gpt-5.4-nano", + input: [{ role: "user", content: "hello" }], + }) + expect(event.request.headers.get("x-interaction-type")).toBe("conversation-background") + }), + ) + + it.effect("keeps an explicitly configured title model", () => + Effect.gen(function* () { + const agents = yield* Agent.Service + const catalog = yield* Catalog.Service + yield* addPlugin() + yield* agents.transform((draft) => { + draft.update(Agent.ID.make("title"), (agent) => { + agent.model = Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.6-luna") }) + }) + }) + yield* catalog.transform((evt) => { + evt.model.update(Provider.ID.githubCopilot, Model.ID.make("gpt-5.4-nano"), (model) => { + model.enabled = false + model.settings = { endpoint: "responses" } + }) + }) + const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", { + sessionID: Session.ID.make("ses_title"), + agent: Agent.ID.make("title"), + model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.6-luna") }), + request: new Request("https://api.githubcopilot.com/responses", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "gpt-5.6-luna", input: [{ role: "user", content: "hello" }] }), + }), + }) + expect(yield* Effect.promise(() => event.request.clone().json())).toEqual({ + model: "gpt-5.6-luna", + input: [{ role: "user", content: "hello" }], + }) + }), + ) + + it.effect("keeps title requests on an endpoint without a utility model", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + yield* addPlugin() + yield* catalog.transform((evt) => { + evt.model.update(Provider.ID.githubCopilot, Model.ID.make("gpt-5.4-nano"), (model) => { + model.enabled = false + model.settings = { endpoint: "responses" } + }) + }) + const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", { + sessionID: Session.ID.make("ses_title"), + agent: Agent.ID.make("title"), + model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("claude-haiku-4.5") }), + request: new Request("https://api.githubcopilot.com/v1/messages", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "claude-haiku-4.5", messages: [{ role: "user", content: "hello" }] }), + }), + }) + expect(yield* Effect.promise(() => event.request.clone().json())).toEqual({ + model: "claude-haiku-4.5", + messages: [{ role: "user", content: "hello" }], + }) + }), + ) + it.effect("classifies compaction requests", () => Effect.gen(function* () { yield* addPlugin()