feat(core): add GitHub Copilot Auto and a model resolve hook (#62997)

This commit is contained in:
Aiden Cline
2026-11-09 20:46:05 -06:00
committed by GitHub
Unverified
parent fc7e9223de
commit 37729b1df1
14 changed files with 468 additions and 57 deletions
@@ -8,6 +8,7 @@ import { Provider } from "../provider.js"
const RemoteModel = Schema.Struct({
model_picker_enabled: Schema.Boolean,
is_chat_fallback: Schema.optional(Schema.Boolean),
id: Schema.String,
name: Schema.String,
version: Schema.String,
@@ -15,6 +16,7 @@ const RemoteModel = Schema.Struct({
policy: Schema.optional(Schema.Struct({ state: Schema.optional(Schema.String) })),
billing: Schema.optional(
Schema.Struct({
auto_discount: Schema.optional(Schema.Number),
token_prices: Schema.optional(
Schema.Struct({
batch_size: Schema.Number,
@@ -123,6 +125,29 @@ export function derive(baseURL: string, remote: Snapshot, existing: readonly Mod
return result
}
/** Copilot's Auto model. Copilot's router picks the model that serves each Session from the account's Auto pool. */
export const auto = Model.ID.make("auto")
export function autoModel(baseURL: string, remote: Snapshot) {
const pool = Array.from(remote.values()).filter((model) => model.billing?.auto_discount !== undefined)
if (pool.length === 0) return
const route = routeFor(auto, "chat", baseURL)
const limits = pool.map((model) => model.capabilities.limits)
return Model.Info.make({
...Model.Info.default(Provider.ID.githubCopilot, auto),
name: "Auto",
package: route.package,
settings: { baseURL: route.baseURL },
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
// Any pool model may serve a Session, so show the smallest window.
limit: {
context: Math.min(...limits.map((limit) => limit.max_context_window_tokens ?? limit.max_prompt_tokens)),
input: Math.min(...limits.map((limit) => limit.max_prompt_tokens)),
output: Math.min(...limits.map((limit) => limit.max_output_tokens)),
},
})
}
function usable(model: RemoteModel): model is UsableModel {
return (
model.policy?.state !== "disabled" &&
+9 -13
View File
@@ -147,6 +147,8 @@ export type MediaError =
export interface Interface {
readonly resolve: (requested?: Ref) => Effect.Effect<Resolved | undefined, Error>
/** The model used when nothing is selected: the configured default, or the first available text model. */
readonly fallback: () => Effect.Effect<Info | undefined>
readonly resolveModel: (model: Info, variant?: VariantID) => Effect.Effect<Resolved, Error>
/** An enabled image model by ref, or undefined when no image model has that ref. */
readonly resolveImage: (ref: Ref) => Effect.Effect<ImageModel | undefined, MediaError>
@@ -518,24 +520,18 @@ export const layer = Layer.effect(
const { credential, runtimeInfo } = yield* prepare(info, ref.variant)
return yield* fromCatalogMedia(runtimeInfo, is, credential, dependencies)
})
const fallback = Effect.fn("ModelResolver.fallback")(function* () {
const model = yield* models.default()
if (model && hasPackage(model)) return model
return (yield* models.available()).find((model) => hasPackage(model) && Model.supportsText(model))
})
return Service.of({
resolve: Effect.fn("ModelResolver.resolve")(function* (requested) {
const selected = requested
? yield* models.get(requested.providerID, requested.id)
: yield* models
.default()
.pipe(
Effect.flatMap((model) =>
model && hasPackage(model)
? Effect.succeed(model)
: Effect.map(models.available(), (models) =>
models.find((model) => hasPackage(model) && Model.supportsText(model)),
),
),
)
const selected = requested ? yield* models.get(requested.providerID, requested.id) : yield* fallback()
if (!selected) return undefined
return yield* load(selected, requested?.variant)
}),
fallback,
resolveModel: load,
resolveImage: Effect.fn("ModelResolver.resolveImage")(function* (ref) {
return yield* loadMedia(ref, "image", isImageModel)
@@ -1,7 +1,7 @@
import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration"
import type { SessionRequestKind, SessionTitle } from "@opencode/plugin/effect/session"
import { isRecord } from "@opencode/util/record"
import { Effect, Option, Schema } from "effect"
import { Clock, Effect, Option, Schema } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"
import { IntegrationConnection } from "../../integration/connection.js"
import { Credential } from "../../credential.js"
@@ -13,6 +13,7 @@ import { Agent } from "../../agent.js"
import { define } from "@opencode/plugin/effect/plugin"
import { Provider } from "../../provider.js"
import { SessionAffinity } from "../../session/affinity.js"
import type { Session } from "@opencode/schema/session"
import { SharedCache } from "../../shared-cache.js"
import { activeCredential } from "./credential-settings.js"
import { ProviderRefresh } from "./refresh.js"
@@ -45,6 +46,11 @@ const User = Schema.Struct({
),
})
const decodeUser = Schema.decodeUnknownOption(User)
const AutoRoute = Schema.Struct({
session_token: Schema.String,
expires_at: Schema.Number,
selected_model: Schema.Struct({ id: Schema.String }),
})
const JsonBody = Schema.fromJsonString(Schema.Unknown)
const decodeBody = Schema.decodeUnknownOption(JsonBody)
@@ -164,6 +170,9 @@ export const GithubCopilotPlugin = define({
const cache = yield* SharedCache.Service
const http = yield* HttpClient.HttpClient
const outbound = yield* FetchHttpClient.Fetch
// Each Session keeps the model Copilot's Auto router picked until its token nears expiry, the Session is compacted,
// or the account changes. The token unlocks the pick and its Auto discount; Copilot rejects it for any other model.
const routes = new Map<Session.ID, { model: string; account: string; token?: string; until: number }>()
const discovered = yield* ProviderRefresh.start({
integrationID: "github-copilot",
initial: {} as {
@@ -246,11 +255,14 @@ export const GithubCopilotPlugin = define({
if (!item) return
const loaded = discovered.current
if (loaded.models) {
const url = loaded.baseURL ?? baseURL()
const router = CopilotModels.autoModel(url, loaded.models)
evt.add({
info: item.provider,
models: Array.from(
CopilotModels.derive(loaded.baseURL ?? baseURL(), loaded.models, Array.from(item.models.values())).values(),
),
models: [
...CopilotModels.derive(url, loaded.models, Array.from(item.models.values())).values(),
...(router ? [router] : []),
],
sourceConnection: loaded.connection,
})
return
@@ -294,6 +306,82 @@ export const GithubCopilotPlugin = define({
evt.headers["X-Interaction-Type"] = interaction
evt.headers["X-Interaction-Id"] = session ? SessionAffinity.get(session) : evt.sessionID
if (interaction !== "conversation-agent") evt.headers["x-initiator"] = "agent"
const route = routes.get(evt.sessionID)
if (route?.token && route.model === evt.model.id) evt.headers["Copilot-Session-Token"] = route.token
}),
{ providerID: Provider.ID.githubCopilot },
)
const route = Effect.fn("GithubCopilotPlugin.route")(function* (sessionID: Session.ID) {
const loaded = discovered.current
if (!loaded.baseURL || !loaded.token || !loaded.models) return
const user = yield* ctx.session.context({ sessionID }).pipe(
Effect.map((messages) => messages.findLast((message) => message.type === "user")),
Effect.orElseSucceed(() => undefined),
)
const headers = (yield* providers.get(Provider.ID.githubCopilot))?.headers ?? {}
const picked = yield* HttpClient.filterStatusOk(http)
.execute(
HttpClientRequest.post(`${loaded.baseURL}/auto`).pipe(
HttpClientRequest.setHeaders({
...headers,
Authorization: `Bearer ${loaded.token}`,
"User-Agent": App.useragent(ctx.app),
"X-GitHub-Api-Version": apiVersion,
}),
HttpClientRequest.bodyJsonUnsafe({
prompt: user?.text ?? "",
...(user?.files?.some((file) => file.mime.startsWith("image/")) ? { has_image: true } : {}),
}),
),
)
.pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(AutoRoute)),
Effect.timeout("5 seconds"),
Effect.tapError((cause) => Effect.logWarning("Copilot Auto routing failed", { cause })),
Effect.option,
)
const now = yield* Clock.currentTimeMillis
if (Option.isSome(picked) && loaded.models.has(picked.value.selected_model.id))
return {
model: picked.value.selected_model.id,
account: loaded.token,
token: picked.value.session_token,
// Route again before the token can expire mid-request.
until: picked.value.expires_at * 1000 - 5 * 60_000,
}
const models = Array.from(loaded.models.values())
const fallback =
models.find((model) => model.is_chat_fallback) ?? models.find((model) => model.billing?.auto_discount)
// Retry routing soon when the router fails or picks a model this client cannot serve.
return fallback && { model: fallback.id, account: loaded.token, until: now + 5 * 60_000 }
})
yield* ctx.session.hook(
"experimental.model.resolve",
(evt) =>
Effect.gen(function* () {
// Titles use Copilot's utility model, so they never route.
if (evt.model.id !== CopilotModels.auto || evt.kind === "title") return
const now = yield* Clock.currentTimeMillis
const cached = routes.get(evt.sessionID)
const current =
cached && cached.until > now && cached.account === discovered.current.token
? cached
: yield* route(evt.sessionID)
if (!current) return
for (const [id, route] of routes) if (route.until <= now) routes.delete(id)
routes.set(evt.sessionID, current)
evt.target = Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make(current.model) })
}),
{ providerID: Provider.ID.githubCopilot },
)
// Compaction rewrites the prompt, so the request after it routes again, like Copilot's own clients. The compaction
// request itself still carries the current pick's token.
yield* ctx.session.hook(
"compaction",
(evt) =>
Effect.sync(() => {
const route = routes.get(evt.sessionID)
if (route) route.until = 0
}),
{ providerID: Provider.ID.githubCopilot },
)
@@ -328,8 +416,7 @@ export const GithubCopilotPlugin = define({
Effect.gen(function* () {
// The Messages route sends the token as `x-api-key`, the OpenAI routes as a bearer token.
const token =
evt.request.headers.get("x-api-key") ??
evt.request.headers.get("authorization")?.replace(/^Bearer\s+/i, "")
evt.request.headers.get("x-api-key") ?? evt.request.headers.get("authorization")?.replace(/^Bearer\s+/i, "")
if (!token) return
const text = yield* Effect.promise(() => evt.request.clone().text())
const body = Option.getOrUndefined(decodeBody(text))
+4 -1
View File
@@ -284,7 +284,10 @@ export const layer = Layer.effect(
const model =
(agent?.model &&
(yield* models
.resolve({ ...context.session, model: agent.model }, catalog.available)
.resolve({ ...context.session, model: agent.model }, catalog.available, {
kind: "compaction",
agent: agent.id,
})
.pipe(Effect.orElseSucceed(() => undefined)))) ??
context.model
const ceiling = calculateCeiling(model.limit, buffer)
+21 -11
View File
@@ -4,6 +4,7 @@ import { Model } from "../model.js"
import { Permission } from "../permission.js"
import { Context, Effect, Layer } from "effect"
import { Agent } from "../agent.js"
import type { SessionRequestKind } from "@opencode/plugin/effect/session"
import { CodeModeInstructions } from "../codemode/instructions.js"
import { Database } from "../database/database.js"
import { makeLocationNode } from "@opencode/util/effect/app-node"
@@ -54,6 +55,7 @@ export interface Interface {
readonly load: (selection: Selection) => Effect.Effect<Loaded, SessionRunnerModel.Error>
readonly resolveModel: (
session: SessionSchema.Info,
request: { readonly kind: SessionRequestKind; readonly agent: Agent.ID },
) => Effect.Effect<SessionRunnerModel.Resolved, SessionRunnerModel.Error>
/** Selects auxiliary title capabilities without instruction or tool preflight. */
readonly selectTitle: (session: SessionSchema.Info) => Effect.Effect<
@@ -89,12 +91,17 @@ const layer = Layer.effect(
const store = yield* SessionStore.Service
const registry = yield* Tool.Service
const resolveModel = (session: SessionSchema.Info) => models.resolve(session, model.available)
const resolveModel = (
session: SessionSchema.Info,
request: { readonly kind: SessionRequestKind; readonly agent: Agent.ID },
) => models.resolve(session, model.available, request)
const selectTitle = Effect.fn("SessionContext.selectTitle")(function* (session: SessionSchema.Info) {
const agent = yield* agents.get(Agent.ID.make("title"))
if (!agent) return
const primary = yield* resolveModel(session).pipe(Effect.orElseSucceed(() => undefined))
const primary = yield* resolveModel(session, { kind: "title", agent: agent.id }).pipe(
Effect.orElseSucceed(() => undefined),
)
const candidate = yield* Effect.gen(function* () {
if (agent.model) {
const info = yield* model.get(agent.model.providerID, agent.model.id)
@@ -110,14 +117,17 @@ const layer = Layer.effect(
})
const preferred =
candidate &&
(yield* resolveModel({
...session,
model: Model.Ref.make({
providerID: candidate.info.providerID,
id: candidate.info.id,
...(candidate.variant ? { variant: candidate.variant } : {}),
}),
}).pipe(Effect.orElseSucceed(() => undefined)))
(yield* resolveModel(
{
...session,
model: Model.Ref.make({
providerID: candidate.info.providerID,
id: candidate.info.id,
...(candidate.variant ? { variant: candidate.variant } : {}),
}),
},
{ kind: "title", agent: agent.id },
).pipe(Effect.orElseSucceed(() => undefined)))
const selected = preferred ?? primary
if (!selected) return
return { agent, primary, selected }
@@ -166,7 +176,7 @@ const layer = Layer.effect(
})
const load = Effect.fn("SessionContext.load")(function* (selection: Selection) {
const model = yield* resolveModel(selection.session)
const model = yield* resolveModel(selection.session, { kind: "primary", agent: selection.agent.id })
const history = yield* SessionHistory.entriesForRunner(
db,
selection.session.id,
+1 -1
View File
@@ -44,7 +44,7 @@ export const generate = Effect.fn("SessionGenerate.generate")(function* (input:
yield* Plugin.awaitActivation
const context = yield* SessionContext.Service
const selection = yield* context.select(input.session.id)
const model = yield* context.resolveModel(selection.session)
const model = yield* context.resolveModel(selection.session, { kind: "generate", agent: selection.agent.id })
const history = yield* SessionHistory.preview(database.db, selection.session.id, selection.instructions, model.ref)
const transcript = SessionModelRequest.baseTranscript({
agent: selection.agent.info,
+4 -1
View File
@@ -132,7 +132,10 @@ const layer = Layer.effect(
Effect.gen(function* () {
yield* plugins.awaitActivation
const selected = yield* context.select(sessionID)
const model = yield* context.resolveModel(selected.session)
const model = yield* context.resolveModel(selected.session, {
kind: "compaction",
agent: selected.agent.id,
})
// Preview updates without admitting them after the already-delivered compaction marker.
const history = yield* SessionHistory.preview(db, sessionID, selected.instructions, model.ref)
return yield* compaction.compact({
+48 -23
View File
@@ -5,8 +5,10 @@ import { LanguageModel } from "@opencode/ai/effect"
import { Model } from "@opencode/schema/model"
import { Provider } from "@opencode/schema/provider"
import { Context, Effect, Layer, Schema } from "effect"
import type { SessionRequestKind } from "@opencode/plugin/effect/session"
import { Agent } from "../../agent.js"
import { ModelResolver } from "../../model-resolver.js"
import { PluginHooks } from "../../plugin/hooks.js"
import { SessionSchema } from "../schema.js"
export class ModelNotSelectedError extends Schema.TaggedError<ModelNotSelectedError>()(
@@ -38,6 +40,7 @@ export interface Interface {
readonly resolve: (
session: SessionSchema.Info,
available: () => Effect.Effect<ReadonlyArray<Model.Info>>,
request: { readonly kind: SessionRequestKind; readonly agent: Agent.ID },
) => Effect.Effect<Resolved, Error>
}
@@ -73,32 +76,54 @@ const layer = Layer.effect(
Effect.gen(function* () {
const resolver = yield* ModelResolver.Service
const agents = yield* Agent.Service
return Service.of({
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session, available) {
// Location plugins populate and filter the catalog asynchronously during layer startup.
if (!session.model) {
const agent = (yield* agents.select(session.agent)).info?.model
const preferred =
agent &&
(yield* available()).find((model) => model.providerID === agent.providerID && model.id === agent.id)
const resolved = preferred
? yield* resolver.resolveModel(preferred, agent.variant)
: yield* resolver.resolve()
if (resolved) return resolved
return yield* new ModelNotSelectedError({ sessionID: session.id })
}
const selected = (yield* available()).find(
(model) => model.providerID === session.model?.providerID && model.id === session.model.id,
const hooks = yield* PluginHooks.Service
// Location plugins populate and filter the catalog asynchronously during layer startup.
const select = Effect.fn("SessionRunnerModel.select")(function* (
session: SessionSchema.Info,
available: () => Effect.Effect<ReadonlyArray<Model.Info>>,
) {
const find = (ref: Model.Ref) =>
available().pipe(
Effect.map((models) => models.find((model) => model.providerID === ref.providerID && model.id === ref.id)),
)
if (!selected)
return yield* new ModelUnavailableError({
providerID: session.model.providerID,
modelID: session.model.id,
})
return yield* resolver.resolveModel(selected, session.model.variant)
if (session.model) {
const info = yield* find(session.model)
if (info) return { info, variant: session.model.variant }
return yield* new ModelUnavailableError({ providerID: session.model.providerID, modelID: session.model.id })
}
const agent = (yield* agents.select(session.agent)).info?.model
const preferred = agent && (yield* find(agent))
if (preferred) return { info: preferred, variant: agent.variant }
const info = yield* resolver.fallback()
if (info) return { info, variant: undefined }
return yield* new ModelNotSelectedError({ sessionID: session.id })
})
return Service.of({
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session, available, request) {
const selected = yield* select(session, available)
const event = yield* hooks.trigger("session", "experimental.model.resolve", {
sessionID: session.id,
agent: request.agent,
kind: request.kind,
model: Model.Ref.make({
providerID: selected.info.providerID,
id: selected.info.id,
...(selected.variant === undefined ? {} : { variant: selected.variant }),
}),
})
const target = event.target
if (!target) return yield* resolver.resolveModel(selected.info, selected.variant)
// Same provider only: the target may be hidden from the picker, and its credentials and hooks stay the selection's.
const resolved = target.providerID === selected.info.providerID ? yield* resolver.resolve(target) : undefined
if (resolved) return resolved
return yield* new ModelUnavailableError({ providerID: target.providerID, modelID: target.id })
}),
})
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [ModelResolver.node, Agent.node] })
export const node = makeLocationNode({
service: Service,
layer,
deps: [ModelResolver.node, Agent.node, PluginHooks.node],
})
+70
View File
@@ -37,6 +37,7 @@ import { LocationWatcher } from "@opencode/core/filesystem/location-watcher"
import { Plugin } from "@opencode/core/plugin"
import { Model } from "@opencode/core/model"
import { Project } from "@opencode/core/project"
import { PluginHooks } from "@opencode/core/plugin/hooks"
import { Provider } from "@opencode/core/provider"
import { AbsolutePath } from "@opencode/core/schema"
import { Session } from "@opencode/core/session"
@@ -642,6 +643,7 @@ describe("LocationServiceMap", () => {
location,
}),
modelState.available,
{ kind: "primary", agent: Agent.ID.make("build") },
)
}).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip)
@@ -682,6 +684,7 @@ describe("LocationServiceMap", () => {
location,
}),
modelState.available,
{ kind: "primary", agent: Agent.ID.make("build") },
)
}).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip)
@@ -734,6 +737,7 @@ describe("LocationServiceMap", () => {
location,
}),
modelState.available,
{ kind: "primary", agent: Agent.ID.make("build") },
)
}).pipe(Effect.provide(LocationServiceMap.Service.get(location)))
@@ -749,4 +753,70 @@ describe("LocationServiceMap", () => {
),
),
)
it.live("lets plugins serve a request with another model from the same provider", () =>
tmpdirScoped().pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
const router = Provider.ID.make("router")
const result = yield* Effect.gen(function* () {
const providers = yield* Provider.Service
yield* providers.transform((editor) => {
for (const id of ["router", "other"])
editor.update(Provider.ID.make(id), (provider) => {
provider.package = "@opencode/ai/providers/openai"
})
editor.models.update(router, Model.ID.make("auto"), () => {})
editor.models.update(router, Model.ID.make("hidden"), (model) => {
model.enabled = false
})
editor.models.update(Provider.ID.make("other"), Model.ID.make("hidden"), () => {})
})
const seen: unknown[] = []
const target = { providerID: router }
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "experimental.model.resolve", (event) =>
Effect.sync(() => {
seen.push({ kind: event.kind, agent: event.agent, model: event.model.id })
if (event.model.id === "auto")
event.target = Model.Ref.make({ providerID: target.providerID, id: Model.ID.make("hidden") })
}),
)
const modelState = yield* Model.Service
const models = yield* SessionRunnerModel.Service
const session = Session.Info.make({
id: Session.ID.make("ses_routed_model"),
projectID: Project.ID.global,
title: "test",
model: { id: Model.ID.make("auto"), providerID: router },
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location,
})
const resolved = yield* models.resolve(session, modelState.available, {
kind: "compaction",
agent: Agent.ID.make("compaction"),
})
target.providerID = Provider.ID.make("other")
const crossed = yield* models
.resolve(session, modelState.available, { kind: "primary", agent: Agent.ID.make("build") })
.pipe(Effect.flip)
return { resolved, crossed, seen }
}).pipe(Effect.scoped, Effect.provide(LocationServiceMap.Service.get(location)))
expect(result.seen[0]).toEqual({ kind: "compaction", agent: "compaction", model: "auto" })
expect(result.resolved.ref).toEqual(Model.Ref.make({ id: Model.ID.make("hidden"), providerID: router }))
expect(String(result.resolved.model.id)).toBe("hidden")
// A target from another provider would bypass the selection's credentials and provider hooks.
expect(result.crossed).toMatchObject({
_tag: "SessionRunnerModel.ModelUnavailableError",
providerID: "other",
modelID: "hidden",
})
}),
),
),
)
})
+2 -1
View File
@@ -1,3 +1,4 @@
import { Agent } from "@opencode/core/agent"
import { expect } from "bun:test"
import { DateTime, Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
@@ -72,7 +73,7 @@ for (const flag of [undefined, "0", "1", "true"]) {
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: ref,
})
const failure = yield* runner.resolve(session, models.available).pipe(Effect.flip)
const failure = yield* runner.resolve(session, models.available, { kind: "primary", agent: Agent.ID.make("build") }).pipe(Effect.flip)
expect(failure.message).toBe(`Model unavailable: ${providerID}/big-pickle`)
expect(yield* resolver.resolve(Model.Ref.make({ providerID, id: free }))).toBeUndefined()
// Later transforms cannot restore a free model, even when all paid models are removed.
@@ -620,6 +620,140 @@ describe("GithubCopilotPlugin", () => {
}),
)
it.effect("routes Auto through Copilot's router and keeps the pick until compaction", () =>
Effect.gen(function* () {
const picks: unknown[] = []
const state = { pick: "routed", available: true, listed: false, auth: "" }
const remote = (id: string, extra: Record<string, unknown> = {}) => ({
model_picker_enabled: false,
id,
name: id,
version: id,
supported_endpoints: ["/chat/completions"],
capabilities: {
family: id,
limits: { max_prompt_tokens: 128_000, max_output_tokens: 64_000 },
supports: { tool_calls: true },
},
...extra,
})
const api = HttpClient.make((request) =>
Effect.gen(function* () {
if (request.url.endsWith("/copilot_internal/user"))
return HttpClientResponse.fromWeb(request, new Response(null, { status: 404 }))
if (request.url.endsWith("/models")) {
state.listed = true
return HttpClientResponse.fromWeb(
request,
Response.json({
data: [
remote("routed", { billing: { auto_discount: 0.1 } }),
remote("other", { billing: { auto_discount: 0.1 } }),
remote("fallback", { is_chat_fallback: true }),
],
}),
)
}
state.auth = request.headers.authorization
picks.push(
request.body._tag === "Uint8Array" ? JSON.parse(new TextDecoder().decode(request.body.body)) : null,
)
if (!state.available) return HttpClientResponse.fromWeb(request, new Response(null, { status: 503 }))
return HttpClientResponse.fromWeb(
request,
Response.json({
session_token: `token-${state.pick}`,
expires_at: 4_000_000_000,
selected_model: { id: state.pick },
}),
)
}),
)
yield* connect({ apiEndpoint: "https://copilot.test" })
yield* addPlugin().pipe(Effect.provideService(HttpClient.HttpClient, api))
const models = yield* Model.Service
yield* advance(() => state.listed)
yield* drain
// Free plans hide every model from the picker; Auto is the one they can select.
expect(
(yield* models.available()).filter((model) => model.providerID === Provider.ID.githubCopilot).map((m) => m.id),
).toEqual([Model.ID.make("auto")])
const hooks = yield* PluginHooks.Service
const ids = yield* sessions()
const resolve = (kind: SessionRequestKind, sessionID = ids.parent) =>
hooks
.trigger("session", "experimental.model.resolve", {
sessionID,
agent: Agent.ID.make("build"),
kind,
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("auto") }),
})
.pipe(Effect.map((event) => event.target && String(event.target.id)))
const token = (model: string) =>
hooks
.trigger("session", "model.request", {
sessionID: ids.parent,
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make(model) }),
kind: "primary",
headers: {},
})
.pipe(Effect.map((event) => event.headers["Copilot-Session-Token"]))
expect(yield* resolve("title")).toBeUndefined()
expect(picks).toEqual([])
expect(yield* resolve("primary")).toBe("routed")
expect(yield* resolve("primary")).toBe("routed")
expect(yield* resolve("generate")).toBe("routed")
expect(picks).toEqual([{ prompt: "" }])
expect(yield* token("routed")).toBe("token-routed")
// Copilot rejects the token for any model but the one it was issued for.
expect(yield* token("other")).toBeUndefined()
state.pick = "other"
yield* hooks.trigger("session", "compaction", {
sessionID: ids.parent,
agent: Agent.ID.make("compaction"),
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("routed") }),
system: [],
messages: [],
options: {},
tools: {},
})
// The compaction request itself keeps the pick's token; the request after it routes again.
expect(yield* token("routed")).toBe("token-routed")
expect(yield* resolve("primary")).toBe("other")
expect(yield* token("other")).toBe("token-other")
expect(picks).toHaveLength(2)
expect(state.auth).toBe("Bearer token")
// A pick belongs to the account that made it.
state.pick = "routed"
state.listed = false
const credentials = yield* Credential.Service
yield* credentials.create({
integrationID: Integration.ID.make("github-copilot"),
value: Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("device"),
access: "second",
refresh: "second",
expires: 0,
metadata: { apiEndpoint: "https://copilot.test" },
}),
})
yield* advance(() => state.listed)
yield* drain
expect(yield* resolve("primary")).toBe("routed")
expect(state.auth).toBe("Bearer second")
state.available = false
expect(yield* resolve("primary", ids.child)).toBe("fallback")
}),
)
it.effect("disables gpt-5-chat-latest before Copilot language selection", () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
+15
View File
@@ -65,6 +65,20 @@ export interface SessionTitle extends SessionRequest {
*/
export type SessionRequestKind = "primary" | "compaction" | "title" | "generate"
/**
* Runs whenever a Session resolves the model for a request, before anything is built for it. Set `target` to serve
* this request with another model from the same provider, including models hidden from the picker; the Session keeps
* its selection, and the request and its records use the target. Router models such as GitHub Copilot's Auto use it.
* Experimental.
*/
export interface SessionModelResolve {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly kind: SessionRequestKind
readonly model: Model.Ref
target?: Model.Ref
}
export interface SessionModelRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
@@ -188,6 +202,7 @@ export interface SessionHooks {
readonly compaction: SessionCompaction
readonly generate: SessionGenerate
readonly title: SessionTitle
readonly "experimental.model.resolve": SessionModelResolve
readonly "model.request": SessionModelRequest
readonly "http.request": SessionHttpRequest
readonly "http.response": SessionHttpResponse
+15
View File
@@ -65,6 +65,20 @@ export interface SessionTitle extends SessionRequest {
*/
export type SessionRequestKind = "primary" | "compaction" | "title" | "generate"
/**
* Runs whenever a Session resolves the model for a request, before anything is built for it. Set `target` to serve
* this request with another model from the same provider, including models hidden from the picker; the Session keeps
* its selection, and the request and its records use the target. Router models such as GitHub Copilot's Auto use it.
* Experimental.
*/
export interface SessionModelResolve {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly kind: SessionRequestKind
readonly model: Model.Ref
target?: Model.Ref
}
export interface SessionModelRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
@@ -188,6 +202,7 @@ export interface SessionHooks {
readonly compaction: SessionCompaction
readonly generate: SessionGenerate
readonly title: SessionTitle
readonly "experimental.model.resolve": SessionModelResolve
readonly "model.request": SessionModelRequest
readonly "http.request": SessionHttpRequest
readonly "http.response": SessionHttpResponse
@@ -1467,6 +1467,24 @@ Generation options depend on the selected protocol and model:
- `maxTokens` is the semantic output-token limit.
- Gemini supports `topK`; OpenAI Responses does not expose it.
#### Model resolve
Experimental. Runs whenever a Session resolves the model for a request, before anything is built for it. Set `target` to
serve this request with another model from the same provider, including one hidden from the model picker. The Session
keeps its selected model; the request and the assistant message use the target. `kind` says which request is resolving,
so a router can skip titles or route again after compaction. A target from another provider fails as unavailable.
```ts
await ctx.session.hook(
"experimental.model.resolve",
(event) => {
if (event.model.id === "auto" && event.kind !== "title")
event.target = { providerID: event.model.providerID, id: pick(event.sessionID) }
},
{ providerID: "acme" },
)
```
#### Model request
Modify model request settings and optionally scope the hook to one provider. The event carries the same `kind`
@@ -1594,6 +1612,7 @@ interface SessionHooks {
compaction: SessionContextHook & { result?: SessionCompactionResult }
generate: SessionContextHook
title: SessionRequestHook & { result?: string }
"experimental.model.resolve": SessionModelResolveHook
"model.request": SessionModelRequestHook
"http.request": SessionHttpRequestHook
"http.response": SessionHttpResponseHook
@@ -1603,6 +1622,14 @@ interface SessionHooks {
retry: SessionRetryHook
}
interface SessionModelResolveHook {
readonly sessionID: string
readonly agent: string
readonly kind: "primary" | "compaction" | "title" | "generate"
readonly model: { providerID: string; id: string; variant?: string }
target?: { providerID: string; id: string; variant?: string }
}
interface SessionWebSocketHandshakeHook {
readonly sessionID: string
readonly agent: string