Skip to content

Commit aeeb95a

Browse files
committed
refactor(core): resolve the routed model once and simplify Copilot Auto
1 parent 37f9a19 commit aeeb95a

6 files changed

Lines changed: 82 additions & 67 deletions

File tree

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

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const RemoteModel = Schema.Struct({
1616
policy: Schema.optional(Schema.Struct({ state: Schema.optional(Schema.String) })),
1717
billing: Schema.optional(
1818
Schema.Struct({
19+
auto_discount: Schema.optional(Schema.Number),
1920
token_prices: Schema.optional(
2021
Schema.Struct({
2122
batch_size: Schema.Number,
@@ -124,13 +125,27 @@ export function derive(baseURL: string, remote: Snapshot, existing: readonly Mod
124125
return result
125126
}
126127

127-
/** Copilot's Auto router. The router picks the model each session runs on; unrouted requests reach the chat fallback. */
128+
/** Copilot's Auto model. Copilot's router picks the model that serves each Session from the account's Auto pool. */
128129
export const auto = Model.ID.make("auto")
129130

130131
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 })
132+
const pool = Array.from(remote.values()).filter((model) => model.billing?.auto_discount !== undefined)
133+
if (pool.length === 0) return
134+
const route = routeFor(auto, "chat", baseURL)
135+
const limits = pool.map((model) => model.capabilities.limits)
136+
return Model.Info.make({
137+
...Model.Info.default(Provider.ID.githubCopilot, auto),
138+
name: "Auto",
139+
package: route.package,
140+
settings: { baseURL: route.baseURL },
141+
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
142+
// Any pool model may serve a Session, so show the smallest window.
143+
limit: {
144+
context: Math.min(...limits.map((limit) => limit.max_context_window_tokens ?? limit.max_prompt_tokens)),
145+
input: Math.min(...limits.map((limit) => limit.max_prompt_tokens)),
146+
output: Math.min(...limits.map((limit) => limit.max_output_tokens)),
147+
},
148+
})
134149
}
135150

136151
function usable(model: RemoteModel): model is UsableModel {

‎packages/core/src/model-resolver.ts‎

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ export type MediaError =
147147

148148
export interface Interface {
149149
readonly resolve: (requested?: Ref) => Effect.Effect<Resolved | undefined, Error>
150+
/** The model used when nothing is selected: the configured default, or the first available text model. */
151+
readonly fallback: () => Effect.Effect<Info | undefined>
150152
readonly resolveModel: (model: Info, variant?: VariantID) => Effect.Effect<Resolved, Error>
151153
/** An enabled image model by ref, or undefined when no image model has that ref. */
152154
readonly resolveImage: (ref: Ref) => Effect.Effect<ImageModel | undefined, MediaError>
@@ -518,24 +520,18 @@ export const layer = Layer.effect(
518520
const { credential, runtimeInfo } = yield* prepare(info, ref.variant)
519521
return yield* fromCatalogMedia(runtimeInfo, is, credential, dependencies)
520522
})
523+
const fallback = Effect.fn("ModelResolver.fallback")(function* () {
524+
const model = yield* models.default()
525+
if (model && hasPackage(model)) return model
526+
return (yield* models.available()).find((model) => hasPackage(model) && Model.supportsText(model))
527+
})
521528
return Service.of({
522529
resolve: Effect.fn("ModelResolver.resolve")(function* (requested) {
523-
const selected = requested
524-
? yield* models.get(requested.providerID, requested.id)
525-
: yield* models
526-
.default()
527-
.pipe(
528-
Effect.flatMap((model) =>
529-
model && hasPackage(model)
530-
? Effect.succeed(model)
531-
: Effect.map(models.available(), (models) =>
532-
models.find((model) => hasPackage(model) && Model.supportsText(model)),
533-
),
534-
),
535-
)
530+
const selected = requested ? yield* models.get(requested.providerID, requested.id) : yield* fallback()
536531
if (!selected) return undefined
537532
return yield* load(selected, requested?.variant)
538533
}),
534+
fallback,
539535
resolveModel: load,
540536
resolveImage: Effect.fn("ModelResolver.resolveImage")(function* (ref) {
541537
return yield* loadMedia(ref, "image", isImageModel)

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

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ 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"
1110
import { Integration } from "../../integration.js"
1211
import { Model } from "../../model.js"
1312
import { Agent } from "../../agent.js"
@@ -171,10 +170,13 @@ export const GithubCopilotPlugin = define({
171170
const cache = yield* SharedCache.Service
172171
const http = yield* HttpClient.HttpClient
173172
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 }>()
173+
// Each Session keeps the model Copilot's Auto router picked until its token nears expiry, the Session is compacted,
174+
// or the account switches. The token unlocks the pick and its Auto discount; Copilot rejects it for any other model.
175+
const routes = new Map<Session.ID, { model: string; token?: string; until: number }>()
176+
yield* ProviderRefresh.onSwitch(
177+
"github-copilot",
178+
Effect.sync(() => routes.clear()),
179+
)
178180
const discovered = yield* ProviderRefresh.start({
179181
integrationID: "github-copilot",
180182
initial: {} as {
@@ -258,7 +260,7 @@ export const GithubCopilotPlugin = define({
258260
const loaded = discovered.current
259261
if (loaded.models) {
260262
const url = loaded.baseURL ?? baseURL()
261-
const router = auto ? CopilotModels.autoModel(url, loaded.models) : undefined
263+
const router = CopilotModels.autoModel(url, loaded.models)
262264
evt.add({
263265
info: item.provider,
264266
models: [
@@ -362,28 +364,21 @@ export const GithubCopilotPlugin = define({
362364
if (evt.model.id !== CopilotModels.auto || evt.kind === "title") return
363365
const now = yield* Clock.currentTimeMillis
364366
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)
367+
const current = cached && cached.until > now ? cached : yield* route(evt.sessionID)
369368
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-
}
369+
routes.set(evt.sessionID, current)
376370
evt.target = Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make(current.model) })
377371
}),
378372
{ providerID: Provider.ID.githubCopilot },
379373
)
380-
// Compaction rewrites the prompt, so the next request routes again, like Copilot's own clients.
374+
// Compaction rewrites the prompt, so the request after it routes again, like Copilot's own clients. The compaction
375+
// request itself still carries the current pick's token.
381376
yield* ctx.session.hook(
382377
"compaction",
383378
(evt) =>
384379
Effect.sync(() => {
385380
const route = routes.get(evt.sessionID)
386-
if (route) route.stale = true
381+
if (route) route.until = 0
387382
}),
388383
{ providerID: Provider.ID.githubCopilot },
389384
)

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

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -77,28 +77,26 @@ const layer = Layer.effect(
7777
const resolver = yield* ModelResolver.Service
7878
const agents = yield* Agent.Service
7979
const hooks = yield* PluginHooks.Service
80+
// Location plugins populate and filter the catalog asynchronously during layer startup.
8081
const select = Effect.fn("SessionRunnerModel.select")(function* (
8182
session: SessionSchema.Info,
8283
available: () => Effect.Effect<ReadonlyArray<Model.Info>>,
8384
) {
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 })
85+
const find = (ref: Model.Ref) =>
86+
available().pipe(
87+
Effect.map((models) => models.find((model) => model.providerID === ref.providerID && model.id === ref.id)),
88+
)
89+
if (session.model) {
90+
const info = yield* find(session.model)
91+
if (info) return { info, variant: session.model.variant }
92+
return yield* new ModelUnavailableError({ providerID: session.model.providerID, modelID: session.model.id })
9293
}
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)
94+
const agent = (yield* agents.select(session.agent)).info?.model
95+
const preferred = agent && (yield* find(agent))
96+
if (preferred) return { info: preferred, variant: agent.variant }
97+
const info = yield* resolver.fallback()
98+
if (info) return { info, variant: undefined }
99+
return yield* new ModelNotSelectedError({ sessionID: session.id })
102100
})
103101
return Service.of({
104102
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session, available, request) {
@@ -107,15 +105,16 @@ const layer = Layer.effect(
107105
sessionID: session.id,
108106
agent: request.agent,
109107
kind: request.kind,
110-
model: selected.ref,
108+
model: Model.Ref.make({
109+
providerID: selected.info.providerID,
110+
id: selected.info.id,
111+
...(selected.variant === undefined ? {} : { variant: selected.variant }),
112+
}),
111113
})
112114
const target = event.target
113-
if (!target) return selected
115+
if (!target) return yield* resolver.resolveModel(selected.info, selected.variant)
114116
// 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)
117+
const resolved = target.providerID === selected.info.providerID ? yield* resolver.resolve(target) : undefined
119118
if (resolved) return resolved
120119
return yield* new ModelUnavailableError({ providerID: target.providerID, modelID: target.id })
121120
}),

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

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { Message, SystemPart } from "@opencode/ai/effect"
2121
import { Provider } from "@opencode/core/provider"
2222
import { Integration } from "@opencode/core/integration"
2323
import { Credential } from "@opencode/core/credential"
24-
import { Flag } from "@opencode/core/flag"
24+
import { Bus } from "@opencode/core/bus"
2525
import { advance, drain } from "../lib/clock"
2626
import type { SessionRequestKind } from "@opencode/plugin/effect/session"
2727
import { testEffect } from "../lib/effect"
@@ -647,7 +647,11 @@ describe("GithubCopilotPlugin", () => {
647647
return HttpClientResponse.fromWeb(
648648
request,
649649
Response.json({
650-
data: [remote("routed"), remote("other"), remote("fallback", { is_chat_fallback: true })],
650+
data: [
651+
remote("routed", { billing: { auto_discount: 0.1 } }),
652+
remote("other", { billing: { auto_discount: 0.1 } }),
653+
remote("fallback", { is_chat_fallback: true }),
654+
],
651655
}),
652656
)
653657
}
@@ -667,11 +671,7 @@ describe("GithubCopilotPlugin", () => {
667671
}),
668672
)
669673
yield* connect({ apiEndpoint: "https://copilot.test" })
670-
const flags = yield* Flag.Service
671-
yield* addPlugin().pipe(
672-
Effect.provideService(HttpClient.HttpClient, api),
673-
Effect.provideService(Flag.Service, { ...flags, enabled: (key) => Effect.succeed(key === "copilot_auto") }),
674-
)
674+
yield* addPlugin().pipe(Effect.provideService(HttpClient.HttpClient, api))
675675
const models = yield* Model.Service
676676
yield* advance(() => state.listed)
677677
yield* drain
@@ -722,12 +722,23 @@ describe("GithubCopilotPlugin", () => {
722722
options: {},
723723
tools: {},
724724
})
725+
// The compaction request itself keeps the pick's token; the request after it routes again.
725726
expect(yield* token("routed")).toBe("token-routed")
726-
expect(yield* resolve("compaction")).toBe("routed")
727727
expect(yield* resolve("primary")).toBe("other")
728728
expect(yield* token("other")).toBe("token-other")
729729
expect(picks).toHaveLength(2)
730730

731+
state.pick = "routed"
732+
const bus = yield* Bus.Service
733+
yield* bus.publish(
734+
Credential.Event.Switched,
735+
{ integrationID: Integration.ID.make("github-copilot"), credentialID: null },
736+
{ global: true },
737+
)
738+
yield* drain
739+
expect(yield* resolve("primary")).toBe("routed")
740+
expect(picks).toHaveLength(3)
741+
731742
state.available = false
732743
expect(yield* resolve("primary", ids.child)).toBe("fallback")
733744
}),

‎packages/schema/src/config/flag.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,5 @@ export const registry = {
1616
schedules: "Enable experimental scheduled tasks that wake a session with a prompt.",
1717
artifacts: "Enable experimental artifacts backed by pluggable providers.",
1818
codemode_vm: "Run the Code Mode execute tool on the experimental bytecode VM instead of the legacy interpreter.",
19-
copilot_auto: "Add GitHub Copilot's Auto model, which lets Copilot pick the model for each session.",
2019
} as const satisfies Record<string, string>
2120
export type Key = keyof typeof registry

0 commit comments

Comments
 (0)