From 37729b1df10cd0e3d0163a8ee6deba589503342c Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:46:05 -0600 Subject: [PATCH] feat(core): add GitHub Copilot Auto and a model resolve hook (#62997) --- packages/core/src/github-copilot/models.ts | 25 ++++ packages/core/src/model-resolver.ts | 22 ++- .../src/plugin/provider/github-copilot.ts | 99 ++++++++++++- packages/core/src/session/compaction.ts | 5 +- packages/core/src/session/context.ts | 32 +++-- packages/core/src/session/generate.ts | 2 +- packages/core/src/session/runner/llm.ts | 5 +- packages/core/src/session/runner/model.ts | 71 +++++++--- packages/core/test/location-layer.test.ts | 70 +++++++++ packages/core/test/model-free.test.ts | 3 +- .../plugin/provider-github-copilot.test.ts | 134 ++++++++++++++++++ packages/plugin/src/effect/session.ts | 15 ++ packages/plugin/src/promise/session.ts | 15 ++ .../src/docs/content/build/plugins/server.mdx | 27 ++++ 14 files changed, 468 insertions(+), 57 deletions(-) diff --git a/packages/core/src/github-copilot/models.ts b/packages/core/src/github-copilot/models.ts index 9b8f10ffd9d..6c823e42995 100644 --- a/packages/core/src/github-copilot/models.ts +++ b/packages/core/src/github-copilot/models.ts @@ -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" && diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts index 8e06c4762d0..b3d3c8fe183 100644 --- a/packages/core/src/model-resolver.ts +++ b/packages/core/src/model-resolver.ts @@ -147,6 +147,8 @@ export type MediaError = export interface Interface { readonly resolve: (requested?: Ref) => Effect.Effect + /** The model used when nothing is selected: the configured default, or the first available text model. */ + readonly fallback: () => Effect.Effect readonly resolveModel: (model: Info, variant?: VariantID) => Effect.Effect /** An enabled image model by ref, or undefined when no image model has that ref. */ readonly resolveImage: (ref: Ref) => Effect.Effect @@ -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) diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index 20cfa10af5a..089ba89bca6 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -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() 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)) diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 6751a7de6af..e1df47e7eaf 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -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) diff --git a/packages/core/src/session/context.ts b/packages/core/src/session/context.ts index 99c54114942..eea20c76fb2 100644 --- a/packages/core/src/session/context.ts +++ b/packages/core/src/session/context.ts @@ -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 readonly resolveModel: ( session: SessionSchema.Info, + request: { readonly kind: SessionRequestKind; readonly agent: Agent.ID }, ) => Effect.Effect /** 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, diff --git a/packages/core/src/session/generate.ts b/packages/core/src/session/generate.ts index e0fe5ee51f9..a9ab0fd3557 100644 --- a/packages/core/src/session/generate.ts +++ b/packages/core/src/session/generate.ts @@ -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, diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index b52b20e42f6..85aa164ba13 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -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({ diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 46bbaf2850e..7a8803f8510 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -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()( @@ -38,6 +40,7 @@ export interface Interface { readonly resolve: ( session: SessionSchema.Info, available: () => Effect.Effect>, + request: { readonly kind: SessionRequestKind; readonly agent: Agent.ID }, ) => Effect.Effect } @@ -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>, + ) { + 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], +}) diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index a54da0db7ee..d814c881267 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -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", + }) + }), + ), + ), + ) }) diff --git a/packages/core/test/model-free.test.ts b/packages/core/test/model-free.test.ts index cc61728c21e..596a4e83344 100644 --- a/packages/core/test/model-free.test.ts +++ b/packages/core/test/model-free.test.ts @@ -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. diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index aa6ae09e3e6..021a38b96bd 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -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 = {}) => ({ + 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 diff --git a/packages/plugin/src/effect/session.ts b/packages/plugin/src/effect/session.ts index a0c25467b09..6f6a5328da3 100644 --- a/packages/plugin/src/effect/session.ts +++ b/packages/plugin/src/effect/session.ts @@ -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 diff --git a/packages/plugin/src/promise/session.ts b/packages/plugin/src/promise/session.ts index ee62ae35543..b00e87479d5 100644 --- a/packages/plugin/src/promise/session.ts +++ b/packages/plugin/src/promise/session.ts @@ -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 diff --git a/services/site/src/docs/content/build/plugins/server.mdx b/services/site/src/docs/content/build/plugins/server.mdx index b46f375775b..82ac22ba07f 100644 --- a/services/site/src/docs/content/build/plugins/server.mdx +++ b/services/site/src/docs/content/build/plugins/server.mdx @@ -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