|
1 | 1 | import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration" |
2 | 2 | import type { SessionRequestKind, SessionTitle } from "@opencode/plugin/effect/session" |
3 | 3 | import { isRecord } from "@opencode/util/record" |
4 | | -import { Effect, Option, Schema } from "effect" |
| 4 | +import { Clock, Effect, Option, Schema } from "effect" |
5 | 5 | import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http" |
6 | 6 | import { IntegrationConnection } from "../../integration/connection.js" |
7 | 7 | import { Credential } from "../../credential.js" |
8 | 8 | import { CopilotModels } from "../../github-copilot/models.js" |
9 | 9 | import { App } from "../../app.js" |
| 10 | +import { Flag } from "../../flag.js" |
10 | 11 | import { Integration } from "../../integration.js" |
11 | 12 | import { Model } from "../../model.js" |
12 | 13 | import { Agent } from "../../agent.js" |
13 | 14 | import { define } from "@opencode/plugin/effect/plugin" |
14 | 15 | import { Provider } from "../../provider.js" |
15 | 16 | import { SessionAffinity } from "../../session/affinity.js" |
| 17 | +import type { Session } from "@opencode/schema/session" |
16 | 18 | import { SharedCache } from "../../shared-cache.js" |
17 | 19 | import { activeCredential } from "./credential-settings.js" |
18 | 20 | import { ProviderRefresh } from "./refresh.js" |
@@ -45,6 +47,11 @@ const User = Schema.Struct({ |
45 | 47 | ), |
46 | 48 | }) |
47 | 49 | 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 | +}) |
48 | 55 | const JsonBody = Schema.fromJsonString(Schema.Unknown) |
49 | 56 | const decodeBody = Schema.decodeUnknownOption(JsonBody) |
50 | 57 |
|
@@ -164,6 +171,10 @@ export const GithubCopilotPlugin = define({ |
164 | 171 | const cache = yield* SharedCache.Service |
165 | 172 | const http = yield* HttpClient.HttpClient |
166 | 173 | 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 }>() |
167 | 178 | const discovered = yield* ProviderRefresh.start({ |
168 | 179 | integrationID: "github-copilot", |
169 | 180 | initial: {} as { |
@@ -246,11 +257,14 @@ export const GithubCopilotPlugin = define({ |
246 | 257 | if (!item) return |
247 | 258 | const loaded = discovered.current |
248 | 259 | if (loaded.models) { |
| 260 | + const url = loaded.baseURL ?? baseURL() |
| 261 | + const router = auto ? CopilotModels.autoModel(url, loaded.models) : undefined |
249 | 262 | evt.add({ |
250 | 263 | 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 | + ], |
254 | 268 | sourceConnection: loaded.connection, |
255 | 269 | }) |
256 | 270 | return |
@@ -294,6 +308,82 @@ export const GithubCopilotPlugin = define({ |
294 | 308 | evt.headers["X-Interaction-Type"] = interaction |
295 | 309 | evt.headers["X-Interaction-Id"] = session ? SessionAffinity.get(session) : evt.sessionID |
296 | 310 | 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 |
297 | 387 | }), |
298 | 388 | { providerID: Provider.ID.githubCopilot }, |
299 | 389 | ) |
@@ -328,8 +418,7 @@ export const GithubCopilotPlugin = define({ |
328 | 418 | Effect.gen(function* () { |
329 | 419 | // The Messages route sends the token as `x-api-key`, the OpenAI routes as a bearer token. |
330 | 420 | 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, "") |
333 | 422 | if (!token) return |
334 | 423 | const text = yield* Effect.promise(() => evt.request.clone().text()) |
335 | 424 | const body = Option.getOrUndefined(decodeBody(text)) |
|
0 commit comments