fix(core): record selected catalog model identity on assistant steps (#34911)
This commit is contained in:
parent
0405518180
commit
cff2345c12
10 changed files with 106 additions and 21 deletions
|
|
@ -321,12 +321,12 @@ export const layer = Layer.effect(
|
||||||
compactIfNeeded: compaction.compactIfNeeded,
|
compactIfNeeded: compaction.compactIfNeeded,
|
||||||
compactAfterOverflow: compaction.compactAfterOverflow,
|
compactAfterOverflow: compaction.compactAfterOverflow,
|
||||||
compactManual: Effect.fn("SessionCompaction.compactManual")(function* (input) {
|
compactManual: Effect.fn("SessionCompaction.compactManual")(function* (input) {
|
||||||
const model = yield* models.resolve(input.session).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
const resolved = yield* models.resolve(input.session).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
if (!model) return false
|
if (!resolved) return false
|
||||||
return yield* compaction.compactManual({
|
return yield* compaction.compactManual({
|
||||||
sessionID: input.session.id,
|
sessionID: input.session.id,
|
||||||
messages: input.messages,
|
messages: input.messages,
|
||||||
model,
|
model: resolved.model,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@ import { Config } from "../../config"
|
||||||
import { Database } from "../../database/database"
|
import { Database } from "../../database/database"
|
||||||
import { EventV2 } from "../../event"
|
import { EventV2 } from "../../event"
|
||||||
import { Location } from "../../location"
|
import { Location } from "../../location"
|
||||||
import { ModelV2 } from "../../model"
|
|
||||||
import { ProviderV2 } from "../../provider"
|
|
||||||
import { QuestionV2 } from "../../question"
|
import { QuestionV2 } from "../../question"
|
||||||
import { SystemContext } from "../../system-context/index"
|
import { SystemContext } from "../../system-context/index"
|
||||||
import { SystemContextBuiltIns } from "../../system-context/builtins"
|
import { SystemContextBuiltIns } from "../../system-context/builtins"
|
||||||
|
|
@ -217,7 +215,8 @@ const layer = Layer.effect(
|
||||||
}
|
}
|
||||||
if (promoted > 0) currentStep = 1
|
if (promoted > 0) currentStep = 1
|
||||||
}
|
}
|
||||||
const model = yield* models.resolve(session)
|
const resolved = yield* models.resolve(session)
|
||||||
|
const model = resolved.model
|
||||||
const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq)
|
const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq)
|
||||||
const context = entries.map((entry) => entry.message)
|
const context = entries.map((entry) => entry.message)
|
||||||
const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
|
const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
|
||||||
|
|
@ -244,11 +243,9 @@ const layer = Layer.effect(
|
||||||
const publisher = createLLMEventPublisher(events, {
|
const publisher = createLLMEventPublisher(events, {
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
agent: agent.id,
|
agent: agent.id,
|
||||||
model: {
|
// The selected catalog identity, not model.id: route-level ids are provider API
|
||||||
id: ModelV2.ID.make(model.id),
|
// model ids (for example gpt-5.5-fast resolves to api id gpt-5.5).
|
||||||
providerID: ProviderV2.ID.make(model.provider),
|
model: resolved.ref,
|
||||||
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
|
|
||||||
},
|
|
||||||
snapshot: startSnapshot,
|
snapshot: startSnapshot,
|
||||||
})
|
})
|
||||||
const publication = Semaphore.makeUnsafe(1)
|
const publication = Semaphore.makeUnsafe(1)
|
||||||
|
|
|
||||||
|
|
@ -72,8 +72,15 @@ export type Error =
|
||||||
| UnsupportedApiError
|
| UnsupportedApiError
|
||||||
| Integration.AuthorizationError
|
| Integration.AuthorizationError
|
||||||
|
|
||||||
|
export interface Resolved {
|
||||||
|
/** Route-level model for provider requests; its id is the provider API model id, which may differ from the catalog id. */
|
||||||
|
readonly model: Model
|
||||||
|
/** Selected catalog identity. Durable records and displays must use this, never the API model id. */
|
||||||
|
readonly ref: ModelV2.Ref
|
||||||
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Model, Error>
|
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Resolved, Error>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRunnerModel") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRunnerModel") {}
|
||||||
|
|
@ -81,6 +88,16 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||||
/** Test or embedding seam for supplying a model resolver directly. */
|
/** Test or embedding seam for supplying a model resolver directly. */
|
||||||
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
|
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
|
||||||
|
|
||||||
|
/** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */
|
||||||
|
export const resolved = (model: Model, variant?: ModelV2.VariantID): Resolved => ({
|
||||||
|
model,
|
||||||
|
ref: ModelV2.Ref.make({
|
||||||
|
id: ModelV2.ID.make(model.id),
|
||||||
|
providerID: ProviderV2.ID.make(model.provider),
|
||||||
|
...(variant === undefined ? {} : { variant }),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => {
|
const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => {
|
||||||
if (credential?.type === "key") return Auth.value(credential.key)
|
if (credential?.type === "key") return Auth.value(credential.key)
|
||||||
if (credential?.type === "oauth") return Auth.value(credential.access)
|
if (credential?.type === "oauth") return Auth.value(credential.access)
|
||||||
|
|
@ -233,11 +250,19 @@ const layer = Layer.effect(
|
||||||
const connection = yield* integrations.connection.active(
|
const connection = yield* integrations.connection.active(
|
||||||
provider?.integrationID ?? Integration.ID.make(selected.providerID),
|
provider?.integrationID ?? Integration.ID.make(selected.providerID),
|
||||||
)
|
)
|
||||||
return yield* resolve(
|
const model = yield* resolve(
|
||||||
session,
|
session,
|
||||||
selected,
|
selected,
|
||||||
connection ? yield* integrations.connection.resolve(connection) : undefined,
|
connection ? yield* integrations.connection.resolve(connection) : undefined,
|
||||||
)
|
)
|
||||||
|
return {
|
||||||
|
model,
|
||||||
|
ref: ModelV2.Ref.make({
|
||||||
|
id: selected.id,
|
||||||
|
providerID: selected.providerID,
|
||||||
|
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
|
||||||
|
}),
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -42,17 +42,17 @@ const make = (dependencies: Dependencies) => {
|
||||||
if (!firstUser) return
|
if (!firstUser) return
|
||||||
const agent = yield* dependencies.agents.get(AgentV2.ID.make("title"))
|
const agent = yield* dependencies.agents.get(AgentV2.ID.make("title"))
|
||||||
if (!agent) return
|
if (!agent) return
|
||||||
const model = yield* (agent.model
|
const resolved = yield* (agent.model
|
||||||
? dependencies.models.resolve({ ...session, model: agent.model })
|
? dependencies.models.resolve({ ...session, model: agent.model })
|
||||||
: dependencies.models.resolve(session)
|
: dependencies.models.resolve(session)
|
||||||
).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
if (!model) return
|
if (!resolved) return
|
||||||
const chunks: string[] = []
|
const chunks: string[] = []
|
||||||
let failed = false
|
let failed = false
|
||||||
const streamed = yield* dependencies.llm
|
const streamed = yield* dependencies.llm
|
||||||
.stream(
|
.stream(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
model,
|
model: resolved.model,
|
||||||
system: agent.system,
|
system: agent.system,
|
||||||
messages: [Message.user(firstUser.text)],
|
messages: [Message.user(firstUser.text)],
|
||||||
tools: [],
|
tools: [],
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,58 @@ describe("LocationServiceMap", () => {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("preserves the selected catalog identity when the api model id differs", () =>
|
||||||
|
Effect.acquireRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||||
|
).pipe(
|
||||||
|
Effect.flatMap((dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
|
||||||
|
const resolved = yield* Effect.gen(function* () {
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
|
yield* catalog.transform((editor) => {
|
||||||
|
editor.provider.update(ProviderV2.ID.make("aliased"), (provider) => {
|
||||||
|
provider.api = { type: "aisdk", package: "@ai-sdk/openai", settings: {} }
|
||||||
|
})
|
||||||
|
editor.model.update(ProviderV2.ID.make("aliased"), ModelV2.ID.make("fast"), (model) => {
|
||||||
|
// Catalog id and provider API id intentionally differ, like gpt-5.5-fast -> gpt-5.5.
|
||||||
|
model.api = { ...model.api, id: ModelV2.ID.make("base") }
|
||||||
|
model.variants.push({ id: ModelV2.VariantID.make("high"), settings: {}, headers: {}, body: {} })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const models = yield* SessionRunnerModel.Service
|
||||||
|
return yield* models.resolve(
|
||||||
|
SessionV2.Info.make({
|
||||||
|
id: SessionV2.ID.make("ses_aliased_model"),
|
||||||
|
projectID: ProjectV2.ID.global,
|
||||||
|
title: "test",
|
||||||
|
model: {
|
||||||
|
id: ModelV2.ID.make("fast"),
|
||||||
|
providerID: ProviderV2.ID.make("aliased"),
|
||||||
|
variant: ModelV2.VariantID.make("high"),
|
||||||
|
},
|
||||||
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||||
|
location,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}).pipe(Effect.provide(LocationServiceMap.Service.get(location)))
|
||||||
|
|
||||||
|
expect(resolved.ref).toEqual(
|
||||||
|
ModelV2.Ref.make({
|
||||||
|
id: ModelV2.ID.make("fast"),
|
||||||
|
providerID: ProviderV2.ID.make("aliased"),
|
||||||
|
variant: ModelV2.VariantID.make("high"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(String(resolved.model.id)).toBe("base")
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("installs public plugins into a location", () =>
|
it.live("installs public plugins into a location", () =>
|
||||||
Effect.acquireRelease(
|
Effect.acquireRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ const client = Layer.mock(LLMClient.Service)({
|
||||||
generate: () => Effect.die("unused"),
|
generate: () => Effect.die("unused"),
|
||||||
})
|
})
|
||||||
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
|
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
|
||||||
const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
|
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
|
||||||
const locations = Layer.effect(
|
const locations = Layer.effect(
|
||||||
LocationServiceMap.Service,
|
LocationServiceMap.Service,
|
||||||
LayerMap.make(
|
LayerMap.make(
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,9 @@ const client = Layer.mock(LLMClient.Service)({
|
||||||
generate: () => Effect.die("unused"),
|
generate: () => Effect.die("unused"),
|
||||||
})
|
})
|
||||||
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
|
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
|
||||||
const models = Layer.mock(SessionRunnerModel.Service)({ resolve: () => Effect.succeed(model) })
|
const models = Layer.mock(SessionRunnerModel.Service)({
|
||||||
|
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model)),
|
||||||
|
})
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
AppNodeBuilder.build(
|
AppNodeBuilder.build(
|
||||||
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionCompaction.node]),
|
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionCompaction.node]),
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ const model = OpenAIChat.route
|
||||||
generation: { maxTokens: 20, temperature: 0 },
|
generation: { maxTokens: 20, temperature: 0 },
|
||||||
})
|
})
|
||||||
.model({ id: "gpt-4o-mini" })
|
.model({ id: "gpt-4o-mini" })
|
||||||
const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
|
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
|
||||||
const systemContext = Layer.mock(SystemContextBuiltIns.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
const systemContext = Layer.mock(SystemContextBuiltIns.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
||||||
const instructionContext = Layer.mock(InstructionContext.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
const instructionContext = Layer.mock(InstructionContext.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
||||||
const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,14 @@ const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: ec
|
||||||
let modelResolveHook = Effect.void
|
let modelResolveHook = Effect.void
|
||||||
let currentModel = model
|
let currentModel = model
|
||||||
const models = SessionRunnerModel.layerWith((session) =>
|
const models = SessionRunnerModel.layerWith((session) =>
|
||||||
modelResolveHook.pipe(Effect.as(session.model?.id === "replacement" ? replacementModel : currentModel)),
|
modelResolveHook.pipe(
|
||||||
|
Effect.as(
|
||||||
|
SessionRunnerModel.resolved(
|
||||||
|
session.model?.id === "replacement" ? replacementModel : currentModel,
|
||||||
|
session.model?.variant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
const systemContextKey = SystemContext.Key.make("test/context")
|
const systemContextKey = SystemContext.Key.make("test/context")
|
||||||
let systemBaseline = "Initial context"
|
let systemBaseline = "Initial context"
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,9 @@ const client = Layer.mock(LLMClient.Service)({
|
||||||
},
|
},
|
||||||
generate: () => Effect.die("unused"),
|
generate: () => Effect.die("unused"),
|
||||||
})
|
})
|
||||||
const models = Layer.mock(SessionRunnerModel.Service)({ resolve: () => Effect.succeed(model) })
|
const models = Layer.mock(SessionRunnerModel.Service)({
|
||||||
|
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model)),
|
||||||
|
})
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
AppNodeBuilder.build(
|
AppNodeBuilder.build(
|
||||||
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, AgentV2.node, SessionTitle.node]),
|
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, AgentV2.node, SessionTitle.node]),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue