feat(apiproxy): make the default model a user setting the picker writes
The route a new session starts from was frozen into the gateway's composition entry, so switching models in a conversation reached only that conversation and every later session went back to the shipped default. The gateway now owns an `api-gateway` settings section: the entry is the base layer and the user document layers over it, so `session.selectModel` records an accepted switch as the default for the next session. The write is wholesale rather than a merge — switching to a model with no reasoning effort has to clear a stored one — and a storage failure is reported without undoing the switch, which already applies to its own session. `targetFor` now resolves its tiers on every read instead of seeding once: an explicit selection, else the session's own logged request header, else the live default. That is what keeps a session that has run a turn deriving its route from its log forever after, while a session still blank — New Session reuses one rather than minting another — starts from a default saved after it was created.
This commit is contained in:
@@ -125,7 +125,7 @@ describe('Web session model selection', () => {
|
||||
model: 'private-preview',
|
||||
reasoningEffort: ReasoningEffortId('max'),
|
||||
})
|
||||
const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
|
||||
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
|
||||
expect(catalog.current).toEqual({
|
||||
@@ -160,7 +160,7 @@ describe('Web session model selection', () => {
|
||||
|
||||
it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => {
|
||||
const { ctx, agent, sessionId } = await harness()
|
||||
const api = createApiProxy(ctx, { provider: 'deepseek-official', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' })
|
||||
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
|
||||
const signal = new AbortController().signal
|
||||
|
||||
@@ -225,4 +225,101 @@ describe('Web session model selection', () => {
|
||||
.toEqual({ provider: 'deepseek-official', model: 'private-preview', reasoningEffort: 'max' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('reads the host default live for a session whose log names no route', async () => {
|
||||
const { ctx, sessionId } = await harness()
|
||||
let stored = { provider: 'deepseek-official', model: 'deepseek-chat' }
|
||||
const api = createApiProxy(ctx, {
|
||||
defaultTarget: () => stored,
|
||||
cwd: '/tmp',
|
||||
workspaceRoot: '/tmp',
|
||||
})
|
||||
|
||||
expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
|
||||
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
|
||||
// The default moving after the session exists still reaches it: New
|
||||
// Session reuses a blank session rather than minting another, so a seed
|
||||
// captured at creation would show the superseded model there.
|
||||
stored = { provider: 'deepseek-official', model: 'deepseek-reasoner' }
|
||||
expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
|
||||
.toEqual({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
|
||||
expect(expectValue(await api.host.describe(request({}))))
|
||||
.toMatchObject({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps a session that logged a route on it when the host default moves', async () => {
|
||||
const { ctx, sessionId } = await harness({
|
||||
provider: 'deepseek-official',
|
||||
model: 'deepseek-chat',
|
||||
})
|
||||
let stored = { provider: 'deepseek-official', model: 'deepseek-chat' }
|
||||
const api = createApiProxy(ctx, {
|
||||
defaultTarget: () => stored,
|
||||
cwd: '/tmp',
|
||||
workspaceRoot: '/tmp',
|
||||
})
|
||||
|
||||
stored = { provider: 'duplicate', model: 'same' }
|
||||
expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
|
||||
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('saves an accepted selection as the default and survives a storage failure', async () => {
|
||||
const { ctx, sessionId } = await harness()
|
||||
const saved: unknown[] = []
|
||||
let reject = false
|
||||
const api = createApiProxy(ctx, {
|
||||
defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
|
||||
persistDefaultTarget: (target) => {
|
||||
saved.push(target)
|
||||
return reject ? Promise.reject(new Error('read-only document')) : Promise.resolve()
|
||||
},
|
||||
cwd: '/tmp',
|
||||
workspaceRoot: '/tmp',
|
||||
})
|
||||
|
||||
expectValue(await api.sessions.selectModel(request({
|
||||
sessionId, provider: 'deepseek-official', model: 'deepseek-reasoner', reasoningEffort: 'max',
|
||||
})))
|
||||
expect(saved).toEqual([
|
||||
{ provider: 'deepseek-official', model: 'deepseek-reasoner', reasoningEffort: 'max' },
|
||||
])
|
||||
|
||||
// A refused selection never becomes anyone's default.
|
||||
await api.sessions.selectModel(request({ sessionId, provider: 'missing', model: 'model' }))
|
||||
expect(saved).toHaveLength(1)
|
||||
|
||||
// Storage failing is not the selection failing: the switch already applies
|
||||
// to this session, so the call still succeeds.
|
||||
reject = true
|
||||
const stillAccepted = expectValue(await api.sessions.selectModel(request({
|
||||
sessionId, provider: 'deepseek-official', model: 'deepseek-chat',
|
||||
})))
|
||||
expect(stillAccepted.selected).toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' })
|
||||
expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
|
||||
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('serves a session and its catalog when the stored default names a route that is gone', async () => {
|
||||
const { ctx, sessionId } = await harness()
|
||||
const api = createApiProxy(ctx, {
|
||||
// What a Models-page removal leaves behind: the settings document still
|
||||
// names the route the user last picked, and nothing serves it.
|
||||
defaultTarget: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
|
||||
cwd: '/tmp',
|
||||
workspaceRoot: '/tmp',
|
||||
})
|
||||
|
||||
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
|
||||
// Passed through rather than repaired: matching no group is precisely what
|
||||
// makes the composer seat prompt for a selection instead of naming a model
|
||||
// the deployment cannot reach.
|
||||
expect(catalog.current).toEqual({ provider: 'deleted-gateway', model: 'deleted-model' })
|
||||
expect(catalog.groups.flatMap(group => group.models.map(model => `${group.id}/${model.id}`)))
|
||||
.not.toContain('deleted-gateway/deleted-model')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user