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:
Yichen Jiang
2026-08-07 13:23:19 +08:00
parent 079d71d591
commit f2d1a29636
18 changed files with 274 additions and 58 deletions

View File

@@ -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()
})
})