mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-26 10:43:35 +00:00
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,
|
||||
compactAfterOverflow: compaction.compactAfterOverflow,
|
||||
compactManual: Effect.fn("SessionCompaction.compactManual")(function* (input) {
|
||||
const model = yield* models.resolve(input.session).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!model) return false
|
||||
const resolved = yield* models.resolve(input.session).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!resolved) return false
|
||||
return yield* compaction.compactManual({
|
||||
sessionID: input.session.id,
|
||||
messages: input.messages,
|
||||
model,
|
||||
model: resolved.model,
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,8 +14,6 @@ import { Config } from "../../config"
|
|||
import { Database } from "../../database/database"
|
||||
import { EventV2 } from "../../event"
|
||||
import { Location } from "../../location"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import { QuestionV2 } from "../../question"
|
||||
import { SystemContext } from "../../system-context/index"
|
||||
import { SystemContextBuiltIns } from "../../system-context/builtins"
|
||||
|
|
@ -217,7 +215,8 @@ const layer = Layer.effect(
|
|||
}
|
||||
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 context = entries.map((entry) => entry.message)
|
||||
const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
|
||||
|
|
@ -244,11 +243,9 @@ const layer = Layer.effect(
|
|||
const publisher = createLLMEventPublisher(events, {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: {
|
||||
id: ModelV2.ID.make(model.id),
|
||||
providerID: ProviderV2.ID.make(model.provider),
|
||||
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
|
||||
},
|
||||
// The selected catalog identity, not model.id: route-level ids are provider API
|
||||
// model ids (for example gpt-5.5-fast resolves to api id gpt-5.5).
|
||||
model: resolved.ref,
|
||||
snapshot: startSnapshot,
|
||||
})
|
||||
const publication = Semaphore.makeUnsafe(1)
|
||||
|
|
|
|||
|
|
@ -72,8 +72,15 @@ export type Error =
|
|||
| UnsupportedApiError
|
||||
| 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 {
|
||||
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") {}
|
||||
|
|
@ -81,6 +88,16 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
|||
/** Test or embedding seam for supplying a model resolver directly. */
|
||||
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) => {
|
||||
if (credential?.type === "key") return Auth.value(credential.key)
|
||||
if (credential?.type === "oauth") return Auth.value(credential.access)
|
||||
|
|
@ -233,11 +250,19 @@ const layer = Layer.effect(
|
|||
const connection = yield* integrations.connection.active(
|
||||
provider?.integrationID ?? Integration.ID.make(selected.providerID),
|
||||
)
|
||||
return yield* resolve(
|
||||
const model = yield* resolve(
|
||||
session,
|
||||
selected,
|
||||
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
|
||||
const agent = yield* dependencies.agents.get(AgentV2.ID.make("title"))
|
||||
if (!agent) return
|
||||
const model = yield* (agent.model
|
||||
const resolved = yield* (agent.model
|
||||
? dependencies.models.resolve({ ...session, model: agent.model })
|
||||
: dependencies.models.resolve(session)
|
||||
).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!model) return
|
||||
if (!resolved) return
|
||||
const chunks: string[] = []
|
||||
let failed = false
|
||||
const streamed = yield* dependencies.llm
|
||||
.stream(
|
||||
LLM.request({
|
||||
model,
|
||||
model: resolved.model,
|
||||
system: agent.system,
|
||||
messages: [Message.user(firstUser.text)],
|
||||
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", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ const client = Layer.mock(LLMClient.Service)({
|
|||
generate: () => Effect.die("unused"),
|
||||
})
|
||||
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(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
|
|
|
|||
|
|
@ -38,7 +38,9 @@ const client = Layer.mock(LLMClient.Service)({
|
|||
generate: () => Effect.die("unused"),
|
||||
})
|
||||
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(
|
||||
AppNodeBuilder.build(
|
||||
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 },
|
||||
})
|
||||
.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 instructionContext = Layer.mock(InstructionContext.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 currentModel = model
|
||||
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")
|
||||
let systemBaseline = "Initial context"
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ const client = Layer.mock(LLMClient.Service)({
|
|||
},
|
||||
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(
|
||||
AppNodeBuilder.build(
|
||||
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