feat(core): name Copilot sessions with the free utility model (#51237)

This commit is contained in:
Aiden Cline
2026-09-25 14:53:16 -05:00
committed by GitHub
parent 6585bb7105
commit 6ec8ca920f
2 changed files with 161 additions and 1 deletions
@@ -1,5 +1,5 @@
import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration"
import type { SessionRequestKind } from "@opencode/plugin/effect/session"
import type { SessionRequestKind, SessionTitle } from "@opencode/plugin/effect/session"
import { Effect, Option, Schema, Semaphore, Stream } from "effect"
import { IntegrationConnection } from "../../integration/connection.js"
import { Credential } from "../../credential.js"
@@ -8,6 +8,7 @@ import { CopilotModels } from "../../github-copilot/models.js"
import { App } from "../../app.js"
import { Integration } from "../../integration.js"
import { Model } from "../../model.js"
import { Agent } from "../../agent.js"
import { define } from "@opencode/plugin/effect/plugin"
import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js"
@@ -162,6 +163,7 @@ export const GithubCopilotPlugin = define({
const loading = Semaphore.makeUnsafe(1)
const loaded: {
baseURL?: string
token?: string
models?: CopilotModels.Snapshot
connection?: Effect.Success<ReturnType<typeof ctx.integration.connection.active>>
} = {}
@@ -173,6 +175,7 @@ export const GithubCopilotPlugin = define({
: undefined
if (credential?.type !== "oauth") {
loaded.baseURL = undefined
loaded.token = undefined
loaded.models = undefined
loaded.connection = undefined
return
@@ -200,6 +203,7 @@ export const GithubCopilotPlugin = define({
)
return
loaded.baseURL = url
loaded.token = credential.refresh
loaded.models = remote
loaded.connection = connection
})
@@ -273,6 +277,29 @@ export const GithubCopilotPlugin = define({
}),
{ providerID: Provider.ID.githubCopilot },
)
// GitHub's integration guide designates session naming as a utility scenario served by
// free, rate-limited utility models. Any failure leaves the result unset so the normal
// billable title path still runs.
yield* ctx.session.hook(
"title",
(evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.githubCopilot) return
if (!loaded.baseURL || !loaded.token || !loaded.models) return
const agent = yield* ctx.agent.get({ agentID: Agent.ID.make("title") }).pipe(Effect.orElseSucceed(() => undefined))
if (agent?.data.model) return
const model = utilityTitleModels.find((id) => loaded.models?.has(id))
if (!model) return
evt.result = yield* utilityTitle(
{ baseURL: loaded.baseURL, token: loaded.token, model, app: ctx.app },
evt,
).pipe(
Effect.tapError((cause) => Effect.logDebug("Copilot utility title failed", { model, cause })),
Effect.orElseSucceed(() => undefined),
)
}),
{ providerID: Provider.ID.githubCopilot },
)
yield* ctx.session.hook(
"http.request",
(evt) =>
@@ -369,6 +396,70 @@ function request(url: string, init: RequestInit) {
type Fetch = (input: Parameters<typeof fetch>[0], init?: RequestInit) => Promise<Response>
// Matches the Copilot client: gpt-4o-mini is the "small utility" model; when an account
// lacks it the request falls through to the regular title path instead of another guess.
export const utilityTitleModels = ["gpt-4o-mini"]
const UtilityCompletion = Schema.Struct({
choices: Schema.Array(Schema.Struct({ message: Schema.Struct({ content: Schema.NullOr(Schema.String) }) })),
})
const decodeUtilityCompletion = Schema.decodeUnknownEffect(Schema.fromJsonString(UtilityCompletion))
// This issues its own /chat/completions call instead of steering core's title request.
// The `session.title` hook exposes `model` read-only, so pointing core at another model
// would mean widening that contract and re-resolving inside SessionModelRequest; rewriting
// the body in `http.request` is worse still, since the endpoint (/responses for GPT-5,
// /v1/messages for Claude) is fixed before that hook runs and gpt-4o-mini only serves
// /chat/completions. Utility titles are Copilot-specific, stateless, non-streaming, and
// free, so a small request here costs less than a core seam. Trade-offs: no session
// usage record for the title step (the call bills nothing) and no `http.*` hook visibility.
export function utilityTitle(
input: { baseURL: string; token: string; model: string; app: App.Info; fetch?: Fetch },
request: Pick<SessionTitle, "sessionID" | "system" | "messages" | "options">,
) {
const send = input.fetch ?? fetch
const text = (parts: ReadonlyArray<{ type: string; text?: string | null }>) =>
parts.flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : [])).join("\n")
const messages = [
...(request.system.length ? [{ role: "system", content: text(request.system) }] : []),
...request.messages.map((message) => ({ role: message.role, content: text(message.content) })),
]
return Effect.tryPromise({
try: async (signal) => {
const response = await send(`${input.baseURL}/chat/completions`, {
method: "POST",
signal,
headers: {
Authorization: `Bearer ${input.token}`,
"Content-Type": "application/json",
"User-Agent": App.useragent(input.app),
"X-GitHub-Api-Version": apiVersion,
"Openai-Intent": "conversation-edits",
"X-Interaction-Type": "agent-session-name-generation",
"X-Interaction-Id": request.sessionID,
"x-initiator": "agent",
},
body: JSON.stringify({
model: input.model,
messages,
stream: false,
...(typeof request.options.maxTokens === "number" ? { max_tokens: request.options.maxTokens } : {}),
}),
})
if (!response.ok) throw new Error(`Utility title request failed: ${response.status}`)
return response.text()
},
catch: (cause) => cause,
}).pipe(
Effect.timeout("20 seconds"),
Effect.flatMap(decodeUtilityCompletion),
Effect.flatMap((completion) => {
const title = completion.choices[0]?.message.content?.trim()
return title ? Effect.succeed(title) : Effect.fail(new Error("Utility title response was empty"))
}),
)
}
export function copilotFetch(token: string | undefined, upstream: Fetch | undefined, app: App.Info): Fetch {
const send = upstream ?? fetch
return async (input, init) => {
@@ -15,7 +15,9 @@ import {
copilotEntitlementError,
copilotFetch,
GithubCopilotPlugin,
utilityTitle,
} from "@opencode/core/plugin/provider/github-copilot"
import { Message, SystemPart } from "@opencode/ai"
import { Provider } from "@opencode/core/provider"
import { Integration } from "@opencode/core/integration"
import type { SessionRequestKind } from "@opencode/plugin/effect/session"
@@ -237,6 +239,73 @@ describe("GithubCopilotPlugin", () => {
}),
)
const titleRequest = {
sessionID: Session.ID.make("ses_title"),
system: [SystemPart.make("You are a title generator.")],
messages: [Message.user("how do I make my python script faster")],
options: { maxTokens: 32 },
}
const utilityInput = (send: (init?: RequestInit) => Response | Promise<Response>) => ({
baseURL: "https://api.individual.githubcopilot.com",
token: "token",
model: "gpt-4o-mini",
app: App.make({ name: "test", version: "1.2.3", channel: "beta" }),
fetch: async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => send(init),
})
it.live("names sessions with the utility model as a free background request", () =>
Effect.gen(function* () {
const seen: Array<{ url?: string; init?: RequestInit }> = []
const title = yield* utilityTitle(
{
...utilityInput(() => Response.json({ choices: [{ message: { content: " Speed Up Python Script\n" } }] })),
fetch: async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
seen.push({ url: String(input), init })
return Response.json({ choices: [{ message: { content: " Speed Up Python Script\n" } }] })
},
},
titleRequest,
)
expect(title).toBe("Speed Up Python Script")
expect(seen[0]?.url).toBe("https://api.individual.githubcopilot.com/chat/completions")
const headers = new Headers(seen[0]?.init?.headers)
expect(headers.get("authorization")).toBe("Bearer token")
expect(headers.get("x-interaction-type")).toBe("agent-session-name-generation")
expect(headers.get("x-interaction-id")).toBe("ses_title")
expect(headers.get("x-initiator")).toBe("agent")
expect(headers.get("x-github-api-version")).toBe("2026-08-01")
expect(JSON.parse(String(seen[0]?.init?.body))).toEqual({
model: "gpt-4o-mini",
stream: false,
max_tokens: 32,
messages: [
{ role: "system", content: "You are a title generator." },
{ role: "user", content: "how do I make my python script faster" },
],
})
}),
)
it.live("fails the utility title on rate limits so the billable path can run", () =>
Effect.gen(function* () {
const error = yield* utilityTitle(
utilityInput(() => new Response("slow down", { status: 429 })),
titleRequest,
).pipe(Effect.flip)
expect(String(error)).toContain("429")
}),
)
it.live("fails the utility title on an empty completion", () =>
Effect.gen(function* () {
const error = yield* utilityTitle(
utilityInput(() => Response.json({ choices: [{ message: { content: null } }] })),
titleRequest,
).pipe(Effect.flip)
expect(String(error)).toContain("empty")
}),
)
it.live("keeps a declared agent initiator when the body looks user-initiated", () =>
Effect.gen(function* () {
const requests: Headers[] = []