mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 04:27:20 +03:00
feat(core): add GitHub Copilot Auto and a model resolve hook (#62997)
This commit is contained in:
committed by
GitHub
Unverified
parent
fc7e9223de
commit
37729b1df1
@@ -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" &&
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user