Skip to content

Commit 37f9a19

Browse files
committed
feat(core): add GitHub Copilot Auto and a model resolve hook
1 parent 6b37010 commit 37f9a19

14 files changed

Lines changed: 427 additions & 44 deletions

File tree

‎packages/core/src/github-copilot/models.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Provider } from "../provider.js"
88

99
const RemoteModel = Schema.Struct({
1010
model_picker_enabled: Schema.Boolean,
11+
is_chat_fallback: Schema.optional(Schema.Boolean),
1112
id: Schema.String,
1213
name: Schema.String,
1314
version: Schema.String,
@@ -123,6 +124,15 @@ export function derive(baseURL: string, remote: Snapshot, existing: readonly Mod
123124
return result
124125
}
125126

127+
/** Copilot's Auto router. The router picks the model each session runs on; unrouted requests reach the chat fallback. */
128+
export const auto = Model.ID.make("auto")
129+
130+
export function autoModel(baseURL: string, remote: Snapshot) {
131+
const fallback = Array.from(remote.values()).find((model) => model.is_chat_fallback)
132+
if (!fallback) return
133+
return Model.Info.make({ ...build(auto, fallback, baseURL), name: "Auto", variants: [], enabled: true })
134+
}
135+
126136
function usable(model: RemoteModel): model is UsableModel {
127137
return (
128138
model.policy?.state !== "disabled" &&

‎packages/core/src/plugin/provider/github-copilot.ts‎

Lines changed: 95 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration"
22
import type { SessionRequestKind, SessionTitle } from "@opencode/plugin/effect/session"
33
import { isRecord } from "@opencode/util/record"
4-
import { Effect, Option, Schema } from "effect"
4+
import { Clock, Effect, Option, Schema } from "effect"
55
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"
66
import { IntegrationConnection } from "../../integration/connection.js"
77
import { Credential } from "../../credential.js"
88
import { CopilotModels } from "../../github-copilot/models.js"
99
import { App } from "../../app.js"
10+
import { Flag } from "../../flag.js"
1011
import { Integration } from "../../integration.js"
1112
import { Model } from "../../model.js"
1213
import { Agent } from "../../agent.js"
1314
import { define } from "@opencode/plugin/effect/plugin"
1415
import { Provider } from "../../provider.js"
1516
import { SessionAffinity } from "../../session/affinity.js"
17+
import type { Session } from "@opencode/schema/session"
1618
import { SharedCache } from "../../shared-cache.js"
1719
import { activeCredential } from "./credential-settings.js"
1820
import { ProviderRefresh } from "./refresh.js"
@@ -45,6 +47,11 @@ const User = Schema.Struct({
4547
),
4648
})
4749
const decodeUser = Schema.decodeUnknownOption(User)
50+
const AutoRoute = Schema.Struct({
51+
session_token: Schema.String,
52+
expires_at: Schema.Number,
53+
selected_model: Schema.Struct({ id: Schema.String }),
54+
})
4855
const JsonBody = Schema.fromJsonString(Schema.Unknown)
4956
const decodeBody = Schema.decodeUnknownOption(JsonBody)
5057

@@ -164,6 +171,10 @@ export const GithubCopilotPlugin = define({
164171
const cache = yield* SharedCache.Service
165172
const http = yield* HttpClient.HttpClient
166173
const outbound = yield* FetchHttpClient.Fetch
174+
const auto = yield* Flag.enabled("copilot_auto")
175+
// Each Session keeps the model Copilot's Auto router picked until the pick's token nears expiry or the Session is
176+
// compacted. The token unlocks the picked model and its Auto discount, and Copilot rejects it for any other model.
177+
const routes = new Map<Session.ID, { model: string; token?: string; until: number; stale?: boolean }>()
167178
const discovered = yield* ProviderRefresh.start({
168179
integrationID: "github-copilot",
169180
initial: {} as {
@@ -246,11 +257,14 @@ export const GithubCopilotPlugin = define({
246257
if (!item) return
247258
const loaded = discovered.current
248259
if (loaded.models) {
260+
const url = loaded.baseURL ?? baseURL()
261+
const router = auto ? CopilotModels.autoModel(url, loaded.models) : undefined
249262
evt.add({
250263
info: item.provider,
251-
models: Array.from(
252-
CopilotModels.derive(loaded.baseURL ?? baseURL(), loaded.models, Array.from(item.models.values())).values(),
253-
),
264+
models: [
265+
...CopilotModels.derive(url, loaded.models, Array.from(item.models.values())).values(),
266+
...(router ? [router] : []),
267+
],
254268
sourceConnection: loaded.connection,
255269
})
256270
return
@@ -294,6 +308,82 @@ export const GithubCopilotPlugin = define({
294308
evt.headers["X-Interaction-Type"] = interaction
295309
evt.headers["X-Interaction-Id"] = session ? SessionAffinity.get(session) : evt.sessionID
296310
if (interaction !== "conversation-agent") evt.headers["x-initiator"] = "agent"
311+
const route = routes.get(evt.sessionID)
312+
if (route?.token && route.model === evt.model.id) evt.headers["Copilot-Session-Token"] = route.token
313+
}),
314+
{ providerID: Provider.ID.githubCopilot },
315+
)
316+
const route = Effect.fn("GithubCopilotPlugin.route")(function* (sessionID: Session.ID) {
317+
const loaded = discovered.current
318+
if (!loaded.baseURL || !loaded.token || !loaded.models) return
319+
const user = yield* ctx.session.context({ sessionID }).pipe(
320+
Effect.map((messages) => messages.findLast((message) => message.type === "user")),
321+
Effect.orElseSucceed(() => undefined),
322+
)
323+
const headers = (yield* providers.get(Provider.ID.githubCopilot))?.headers ?? {}
324+
const picked = yield* HttpClient.filterStatusOk(http)
325+
.execute(
326+
HttpClientRequest.post(`${loaded.baseURL}/auto`).pipe(
327+
HttpClientRequest.setHeaders({
328+
...headers,
329+
Authorization: `Bearer ${loaded.token}`,
330+
"User-Agent": App.useragent(ctx.app),
331+
"X-GitHub-Api-Version": apiVersion,
332+
}),
333+
HttpClientRequest.bodyJsonUnsafe({
334+
prompt: user?.text ?? "",
335+
...(user?.files?.some((file) => file.mime.startsWith("image/")) ? { has_image: true } : {}),
336+
}),
337+
),
338+
)
339+
.pipe(
340+
Effect.flatMap(HttpClientResponse.schemaBodyJson(AutoRoute)),
341+
Effect.timeout("5 seconds"),
342+
Effect.tapError((cause) => Effect.logWarning("Copilot Auto routing failed", { cause })),
343+
Effect.option,
344+
)
345+
const now = yield* Clock.currentTimeMillis
346+
if (Option.isSome(picked) && loaded.models.has(picked.value.selected_model.id))
347+
return {
348+
model: picked.value.selected_model.id,
349+
token: picked.value.session_token,
350+
// Route again before the token can expire mid-request.
351+
until: picked.value.expires_at * 1000 - 5 * 60_000,
352+
}
353+
const fallback = Array.from(loaded.models.values()).find((model) => model.is_chat_fallback)
354+
// Retry routing soon when the router fails or picks a model this client cannot serve.
355+
return fallback && { model: fallback.id, until: now + 5 * 60_000 }
356+
})
357+
yield* ctx.session.hook(
358+
"experimental.model.resolve",
359+
(evt) =>
360+
Effect.gen(function* () {
361+
// Titles use Copilot's utility model, so they never route.
362+
if (evt.model.id !== CopilotModels.auto || evt.kind === "title") return
363+
const now = yield* Clock.currentTimeMillis
364+
const cached = routes.get(evt.sessionID)
365+
const current =
366+
cached && cached.until > now && !(cached.stale && evt.kind === "primary")
367+
? cached
368+
: yield* route(evt.sessionID)
369+
if (!current) return
370+
if (current !== cached) {
371+
routes.delete(evt.sessionID)
372+
routes.set(evt.sessionID, current)
373+
const oldest = routes.keys().next().value
374+
if (routes.size > 500 && oldest) routes.delete(oldest)
375+
}
376+
evt.target = Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make(current.model) })
377+
}),
378+
{ providerID: Provider.ID.githubCopilot },
379+
)
380+
// Compaction rewrites the prompt, so the next request routes again, like Copilot's own clients.
381+
yield* ctx.session.hook(
382+
"compaction",
383+
(evt) =>
384+
Effect.sync(() => {
385+
const route = routes.get(evt.sessionID)
386+
if (route) route.stale = true
297387
}),
298388
{ providerID: Provider.ID.githubCopilot },
299389
)
@@ -328,8 +418,7 @@ export const GithubCopilotPlugin = define({
328418
Effect.gen(function* () {
329419
// The Messages route sends the token as `x-api-key`, the OpenAI routes as a bearer token.
330420
const token =
331-
evt.request.headers.get("x-api-key") ??
332-
evt.request.headers.get("authorization")?.replace(/^Bearer\s+/i, "")
421+
evt.request.headers.get("x-api-key") ?? evt.request.headers.get("authorization")?.replace(/^Bearer\s+/i, "")
333422
if (!token) return
334423
const text = yield* Effect.promise(() => evt.request.clone().text())
335424
const body = Option.getOrUndefined(decodeBody(text))

‎packages/core/src/session/compaction.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,10 @@ export const layer = Layer.effect(
284284
const model =
285285
(agent?.model &&
286286
(yield* models
287-
.resolve({ ...context.session, model: agent.model }, catalog.available)
287+
.resolve({ ...context.session, model: agent.model }, catalog.available, {
288+
kind: "compaction",
289+
agent: agent.id,
290+
})
288291
.pipe(Effect.orElseSucceed(() => undefined)))) ??
289292
context.model
290293
const ceiling = calculateCeiling(model.limit, buffer)

‎packages/core/src/session/context.ts‎

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Model } from "../model.js"
44
import { Permission } from "../permission.js"
55
import { Context, Effect, Layer } from "effect"
66
import { Agent } from "../agent.js"
7+
import type { SessionRequestKind } from "@opencode/plugin/effect/session"
78
import { CodeModeInstructions } from "../codemode/instructions.js"
89
import { Database } from "../database/database.js"
910
import { makeLocationNode } from "@opencode/util/effect/app-node"
@@ -54,6 +55,7 @@ export interface Interface {
5455
readonly load: (selection: Selection) => Effect.Effect<Loaded, SessionRunnerModel.Error>
5556
readonly resolveModel: (
5657
session: SessionSchema.Info,
58+
request: { readonly kind: SessionRequestKind; readonly agent: Agent.ID },
5759
) => Effect.Effect<SessionRunnerModel.Resolved, SessionRunnerModel.Error>
5860
/** Selects auxiliary title capabilities without instruction or tool preflight. */
5961
readonly selectTitle: (session: SessionSchema.Info) => Effect.Effect<
@@ -89,12 +91,17 @@ const layer = Layer.effect(
8991
const store = yield* SessionStore.Service
9092
const registry = yield* Tool.Service
9193

92-
const resolveModel = (session: SessionSchema.Info) => models.resolve(session, model.available)
94+
const resolveModel = (
95+
session: SessionSchema.Info,
96+
request: { readonly kind: SessionRequestKind; readonly agent: Agent.ID },
97+
) => models.resolve(session, model.available, request)
9398

9499
const selectTitle = Effect.fn("SessionContext.selectTitle")(function* (session: SessionSchema.Info) {
95100
const agent = yield* agents.get(Agent.ID.make("title"))
96101
if (!agent) return
97-
const primary = yield* resolveModel(session).pipe(Effect.orElseSucceed(() => undefined))
102+
const primary = yield* resolveModel(session, { kind: "title", agent: agent.id }).pipe(
103+
Effect.orElseSucceed(() => undefined),
104+
)
98105
const candidate = yield* Effect.gen(function* () {
99106
if (agent.model) {
100107
const info = yield* model.get(agent.model.providerID, agent.model.id)
@@ -110,14 +117,17 @@ const layer = Layer.effect(
110117
})
111118
const preferred =
112119
candidate &&
113-
(yield* resolveModel({
114-
...session,
115-
model: Model.Ref.make({
116-
providerID: candidate.info.providerID,
117-
id: candidate.info.id,
118-
...(candidate.variant ? { variant: candidate.variant } : {}),
119-
}),
120-
}).pipe(Effect.orElseSucceed(() => undefined)))
120+
(yield* resolveModel(
121+
{
122+
...session,
123+
model: Model.Ref.make({
124+
providerID: candidate.info.providerID,
125+
id: candidate.info.id,
126+
...(candidate.variant ? { variant: candidate.variant } : {}),
127+
}),
128+
},
129+
{ kind: "title", agent: agent.id },
130+
).pipe(Effect.orElseSucceed(() => undefined)))
121131
const selected = preferred ?? primary
122132
if (!selected) return
123133
return { agent, primary, selected }
@@ -166,7 +176,7 @@ const layer = Layer.effect(
166176
})
167177

168178
const load = Effect.fn("SessionContext.load")(function* (selection: Selection) {
169-
const model = yield* resolveModel(selection.session)
179+
const model = yield* resolveModel(selection.session, { kind: "primary", agent: selection.agent.id })
170180
const history = yield* SessionHistory.entriesForRunner(
171181
db,
172182
selection.session.id,

‎packages/core/src/session/generate.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export const generate = Effect.fn("SessionGenerate.generate")(function* (input:
3838
yield* Plugin.awaitActivation
3939
const context = yield* SessionContext.Service
4040
const selection = yield* context.select(input.session.id)
41-
const model = yield* context.resolveModel(selection.session)
41+
const model = yield* context.resolveModel(selection.session, { kind: "generate", agent: selection.agent.id })
4242
const history = yield* SessionHistory.preview(database.db, selection.session.id, selection.instructions, model.ref)
4343
const transcript = SessionModelRequest.baseTranscript({
4444
agent: selection.agent.info,

‎packages/core/src/session/runner/llm.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,10 @@ const layer = Layer.effect(
132132
Effect.gen(function* () {
133133
yield* plugins.awaitActivation
134134
const selected = yield* context.select(sessionID)
135-
const model = yield* context.resolveModel(selected.session)
135+
const model = yield* context.resolveModel(selected.session, {
136+
kind: "compaction",
137+
agent: selected.agent.id,
138+
})
136139
// Preview updates without admitting them after the already-delivered compaction marker.
137140
const history = yield* SessionHistory.preview(db, sessionID, selected.instructions, model.ref)
138141
return yield* compaction.compact({

‎packages/core/src/session/runner/model.ts‎

Lines changed: 49 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import { LanguageModel } from "@opencode/ai/effect"
55
import { Model } from "@opencode/schema/model"
66
import { Provider } from "@opencode/schema/provider"
77
import { Context, Effect, Layer, Schema } from "effect"
8+
import type { SessionRequestKind } from "@opencode/plugin/effect/session"
89
import { Agent } from "../../agent.js"
910
import { ModelResolver } from "../../model-resolver.js"
11+
import { PluginHooks } from "../../plugin/hooks.js"
1012
import { SessionSchema } from "../schema.js"
1113

1214
export class ModelNotSelectedError extends Schema.TaggedError<ModelNotSelectedError>()(
@@ -38,6 +40,7 @@ export interface Interface {
3840
readonly resolve: (
3941
session: SessionSchema.Info,
4042
available: () => Effect.Effect<ReadonlyArray<Model.Info>>,
43+
request: { readonly kind: SessionRequestKind; readonly agent: Agent.ID },
4144
) => Effect.Effect<Resolved, Error>
4245
}
4346

@@ -73,32 +76,55 @@ const layer = Layer.effect(
7376
Effect.gen(function* () {
7477
const resolver = yield* ModelResolver.Service
7578
const agents = yield* Agent.Service
79+
const hooks = yield* PluginHooks.Service
80+
const select = Effect.fn("SessionRunnerModel.select")(function* (
81+
session: SessionSchema.Info,
82+
available: () => Effect.Effect<ReadonlyArray<Model.Info>>,
83+
) {
84+
// Location plugins populate and filter the catalog asynchronously during layer startup.
85+
if (!session.model) {
86+
const agent = (yield* agents.select(session.agent)).info?.model
87+
const preferred =
88+
agent && (yield* available()).find((model) => model.providerID === agent.providerID && model.id === agent.id)
89+
const resolved = preferred ? yield* resolver.resolveModel(preferred, agent.variant) : yield* resolver.resolve()
90+
if (resolved) return resolved
91+
return yield* new ModelNotSelectedError({ sessionID: session.id })
92+
}
93+
const selected = (yield* available()).find(
94+
(model) => model.providerID === session.model?.providerID && model.id === session.model.id,
95+
)
96+
if (!selected)
97+
return yield* new ModelUnavailableError({
98+
providerID: session.model.providerID,
99+
modelID: session.model.id,
100+
})
101+
return yield* resolver.resolveModel(selected, session.model.variant)
102+
})
76103
return Service.of({
77-
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session, available) {
78-
// Location plugins populate and filter the catalog asynchronously during layer startup.
79-
if (!session.model) {
80-
const agent = (yield* agents.select(session.agent)).info?.model
81-
const preferred =
82-
agent &&
83-
(yield* available()).find((model) => model.providerID === agent.providerID && model.id === agent.id)
84-
const resolved = preferred
85-
? yield* resolver.resolveModel(preferred, agent.variant)
86-
: yield* resolver.resolve()
87-
if (resolved) return resolved
88-
return yield* new ModelNotSelectedError({ sessionID: session.id })
89-
}
90-
const selected = (yield* available()).find(
91-
(model) => model.providerID === session.model?.providerID && model.id === session.model.id,
92-
)
93-
if (!selected)
94-
return yield* new ModelUnavailableError({
95-
providerID: session.model.providerID,
96-
modelID: session.model.id,
97-
})
98-
return yield* resolver.resolveModel(selected, session.model.variant)
104+
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session, available, request) {
105+
const selected = yield* select(session, available)
106+
const event = yield* hooks.trigger("session", "experimental.model.resolve", {
107+
sessionID: session.id,
108+
agent: request.agent,
109+
kind: request.kind,
110+
model: selected.ref,
111+
})
112+
const target = event.target
113+
if (!target) return selected
114+
// Same provider only: the target may be hidden from the picker, and its credentials and hooks stay the selection's.
115+
if (target.providerID !== selected.ref.providerID)
116+
return yield* new ModelUnavailableError({ providerID: target.providerID, modelID: target.id })
117+
if (target.id === selected.ref.id && target.variant === selected.ref.variant) return selected
118+
const resolved = yield* resolver.resolve(target)
119+
if (resolved) return resolved
120+
return yield* new ModelUnavailableError({ providerID: target.providerID, modelID: target.id })
99121
}),
100122
})
101123
}),
102124
)
103125

104-
export const node = makeLocationNode({ service: Service, layer, deps: [ModelResolver.node, Agent.node] })
126+
export const node = makeLocationNode({
127+
service: Service,
128+
layer,
129+
deps: [ModelResolver.node, Agent.node, PluginHooks.node],
130+
})

0 commit comments

Comments
 (0)