Forráskód Böngészése

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.
Yichen Jiang 2 hónapja
szülő
commit
f2d1a29636

+ 62 - 18
packages/host/apiproxy/src/api-proxy.ts

@@ -8,7 +8,7 @@ import { mkdir, stat } from 'node:fs/promises'
 import { join } from 'node:path'
 import type { Context } from 'cordis'
 import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
-import type { Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus } from '@deepseek-ai/dsh-agent'
+import type { Agent, AgentLlmTarget, AgentLlmTargetRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent'
 import { createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
 import { errorChain } from '@deepseek-ai/dsh-llm'
 import type { MessageSource } from '@deepseek-ai/dsh-llm'
@@ -329,8 +329,19 @@ function directoryError(error: unknown): RpcError {
 
 /** Resolved Host routing and project-directory defaults consumed by the API implementation. */
 export interface ApiProxyDefaults {
-  provider: string
-  model: string
+  /**
+   * The route a session starts from when its own log names none. Read on
+   * every access rather than captured, so a default saved during this process
+   * reaches the sessions that have not run a turn yet.
+   */
+  defaultTarget: () => AgentLlmTarget
+  /**
+   * Record a selection as the new default. Absent when the deployment stores
+   * no user settings, in which case a switch stays process-local. A rejection
+   * is reported and swallowed: the switch already applies to its own session,
+   * and undoing it because storage failed would be the worse outcome.
+   */
+  persistDefaultTarget?: (target: AgentLlmTarget) => Promise<void>
   /** Default project directory for new sessions whose create request carries no cwd. */
   cwd: string
   /** Parent directory for name-created workspaces. */
@@ -720,7 +731,11 @@ function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceVie
  * @returns the ApiProxy implementation.
  */
 export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
-  const agentOptions = { provider: defaults.provider, model: defaults.model }
+  /** The seed route each create/resume declares; re-read so it never goes stale. */
+  const agentOptions = (): AgentOptions => {
+    const { provider, model } = defaults.defaultTarget()
+    return { provider, model }
+  }
   type WebLlmTargetRef = AgentLlmTargetRef & { current: AgentLlmTarget }
   const targets = new WeakMap<Agent, WebLlmTargetRef>()
   /** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
@@ -735,24 +750,39 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
 
   /**
    * Install or return the session-local target that prompt assembly snapshots.
-   * Seed order: latest logged request/header, else the host default routing.
-   * There is no create-time per-session override tier on this wire — if one
-   * returns (a create-options contribution), it must fold in between the two.
+   *
+   * Precedence, resolved on EVERY read rather than seeded once: a selection
+   * made in this process, else the session's own latest logged request/header,
+   * else the live host default. Re-reading is what keeps the two tiers honest
+   * in both directions — a session that has run a turn derives its route from
+   * its log forever after, so changing the default never retargets it; and a
+   * session still blank (New Session reuses one rather than minting another)
+   * starts from a default saved after it was created. There is no create-time
+   * per-session override tier on this wire — if one returns (a create-options
+   * contribution), it must fold in between the selection and the log.
    */
   function targetFor(agent: Agent): WebLlmTargetRef {
     const installed = targets.get(agent)
     if (installed !== undefined) return installed
-    const logged = agent.session.requestHeader()?.config
+    let picked: AgentLlmTarget | undefined
     const target: WebLlmTargetRef = {
-      current: logged === undefined
-        ? { provider: defaults.provider, model: defaults.model }
-        : {
+      get current(): AgentLlmTarget {
+        if (picked !== undefined) return picked
+        // Incrementally folded by the session, so a per-step read costs
+        // O(new events) rather than a rescan.
+        const logged = agent.session.requestHeader()?.config
+        if (logged === undefined) return defaults.defaultTarget()
+        return {
           provider: logged.provider,
           model: logged.model,
           ...logged.reasoningEffort === undefined
             ? {}
             : { reasoningEffort: logged.reasoningEffort },
-        },
+        }
+      },
+      set current(next: AgentLlmTarget) {
+        picked = next
+      },
       assembled: undefined,
     }
     installAgentLlmTarget(agent.ctx, target)
@@ -1023,7 +1053,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
           }
           const handle = await ctx.agents.resume({
             resumeSessionId: sessionId,
-            agentOptions,
+            agentOptions: agentOptions(),
             setup: installTarget,
           })
           return handle.agent
@@ -1140,7 +1170,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
           }
           return (await ctx.agents.resume({
             resumeSessionId: sessionId,
-            agentOptions,
+            agentOptions: agentOptions(),
             setup: installTarget,
           })).agent
         }
@@ -1152,7 +1182,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
         }
         return (await ctx.agents.create({
           sessionId,
-          agentOptions,
+          agentOptions: agentOptions(),
           meta: { cwd },
           setup: installTarget,
         })).agent
@@ -1692,6 +1722,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
               : { reasoningEffort: resolved.reasoningEffort },
           }
           targetFor(found.agent).current = selected
+          // A switch is also how this deployment's default is chosen: the next
+          // session created without one of its own starts here. Sessions that
+          // have already logged a route are unaffected — they derive from
+          // their own log (see targetFor).
+          try {
+            await defaults.persistDefaultTarget?.(selected)
+          } catch (error: unknown) {
+            ctx.logger.warn(
+              `api-proxy: the model switch applies to this session but was not saved as the default: ${String(error)}`,
+            )
+          }
           return ok(request, { selected: { ...selected } })
         } catch (error: unknown) {
           return err(request, {
@@ -1794,7 +1835,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
               parentSession: source.id,
               seedLength: cut,
             },
-            agentOptions,
+            agentOptions: agentOptions(),
             setup: installTarget,
           })
         } catch (error: unknown) {
@@ -2179,13 +2220,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
     host: {
       describe(request) {
         // TODO(step2): version should read apps/cli's package.json; placeholder for now.
+        const route = defaults.defaultTarget()
         return Promise.resolve(ok(request, {
           version: '0.0.1',
           // Same source as session.create's fallback: the UI's default project
           // must match where an unspecified-cwd session actually lands.
           cwd: defaults.cwd,
-          provider: defaults.provider,
-          model: defaults.model,
+          // Read live for the same reason: this is what the NEXT session will
+          // start from, so a saved default has to be what it reports.
+          provider: route.provider,
+          model: route.model,
           attachedSessions: ctx.agents.list().length,
         }))
       },

+ 83 - 6
packages/host/apiproxy/src/index.ts

@@ -6,11 +6,20 @@
  * (api-proxy.ts: createApiProxy + the ApiProxyService gateway plugin providing
  * `ctx.apiProxy`). Transport-agnostic by design: this package registers no
  * routes — physical carriers wrap `ctx.apiProxy` themselves.
+ *
+ * The gateway also owns the `api-gateway` settings section: the route a
+ * session starts from when its own log names none. The composition entry is
+ * the shipped default and the section layers the user's choice over it, so
+ * switching models in a conversation is what sets the default for the next
+ * one. Sessions that have already logged a route are never retargeted by it.
  */
 
 import { resolve } from 'node:path'
 import { Context, Service } from 'cordis'
 import z from 'schemastery'
+import type { AgentLlmTarget } from '@deepseek-ai/dsh-agent'
+import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
+import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
 import type { ApiProxy } from './api/index.ts'
 import { createApiProxy } from './api-proxy.ts'
 
@@ -29,16 +38,62 @@ declare module 'cordis' {
   }
 }
 
-/** Gateway plugin config: host-level agent routing and Workspace creation root. */
-export interface Config {
-  /** Default provider route for created/resumed agents. */
+/**
+ * The settings namespace carrying the user's default route. Named for the
+ * gateway rather than for the package, because this key is what a person reads
+ * and writes in `settings.yaml`; the row id in a composition happens to match
+ * but does not determine it.
+ */
+export const API_GATEWAY_SETTINGS_NAMESPACE = settingsNamespace('api-gateway')
+
+/**
+ * The user-settable slice of the gateway config: the route a session starts
+ * from when its own log names none. `workspaceRoot` is deliberately not part
+ * of it — that is a launcher fact, not a preference.
+ */
+export interface DefaultRouteSettings {
+  /** Default provider route for created agents. */
   provider: string
   /** Default model id. */
   model: string
+  /** Default reasoning effort; absence preserves the adapter/provider default. */
+  reasoningEffort?: string
+}
+
+/** Gateway plugin config: host-level agent routing and Workspace creation root. */
+export interface Config extends DefaultRouteSettings {
   /** Parent directory for name-created Workspaces; defaults to the Host cwd. */
   workspaceRoot?: string
 }
 
+/**
+ * The default-route fields, as fresh schema instances. Both the plugin config
+ * and the settings section are built from this one call, so the section stays
+ * a subset of the config structurally rather than by a comment two people have
+ * to keep true.
+ */
+function defaultRouteFields(): { [K in keyof Required<DefaultRouteSettings>]: z<string> } {
+  return {
+    provider: z.string().required(),
+    model: z.string().required(),
+    reasoningEffort: z.string(),
+  }
+}
+
+/** Schema of the settings section. */
+const DefaultRouteSchema: z<DefaultRouteSettings> = z.object(defaultRouteFields())
+
+/** Project the stored/composed section onto the agent-facing target shape. */
+function routeTarget(settings: DefaultRouteSettings): AgentLlmTarget {
+  return {
+    provider: settings.provider,
+    model: settings.model,
+    ...settings.reasoningEffort === undefined
+      ? {}
+      : { reasoningEffort: ReasoningEffortId(settings.reasoningEffort) },
+  }
+}
+
 /**
  * The API gateway service: implements the ApiProxy contract over the composed
  * host context and provides it as `ctx.apiProxy`. The Host cwd is the default
@@ -51,8 +106,7 @@ export class ApiProxyService extends Service implements ApiProxy {
   ]
 
   static Config: z<Config> = z.object({
-    provider: z.string().required(),
-    model: z.string().required(),
+    ...defaultRouteFields(),
     workspaceRoot: z.string(),
   })
 
@@ -72,9 +126,32 @@ export class ApiProxyService extends Service implements ApiProxy {
   constructor(ctx: Context, config: Config) {
     super(ctx, 'apiProxy')
     const cwd = process.cwd()
-    const api = createApiProxy(ctx, {
+    // The composition entry is the shipped default; the settings section
+    // layers the user's own choice over it, and a deployment without a
+    // settings provider simply keeps the entry.
+    const entry: DefaultRouteSettings = {
       provider: config.provider,
       model: config.model,
+      ...config.reasoningEffort === undefined ? {} : { reasoningEffort: config.reasoningEffort },
+    }
+    let route: () => DefaultRouteSettings = () => entry
+    installSettingsSection(ctx, API_GATEWAY_SETTINGS_NAMESPACE, DefaultRouteSchema, entry, {
+      setSource: (current) => {
+        route = current
+      },
+      // Nothing registration-level derives from the default: every consumer
+      // reads it through the thunk at the moment it needs a route.
+      onChange: () => {},
+    })
+    const api = createApiProxy(ctx, {
+      defaultTarget: () => routeTarget(route()),
+      // Wholesale, never a merge: switching to a model with no reasoning
+      // effort must clear a stored one, and a merged patch would strand it
+      // for the next session to fail on. The section holds no secrets, so
+      // there is nothing a replace can collaterally drop.
+      persistDefaultTarget: async (target) => {
+        await ctx.get('settings')?.replace(API_GATEWAY_SETTINGS_NAMESPACE, target)
+      },
       cwd,
       workspaceRoot: resolve(config.workspaceRoot ?? cwd),
     })

+ 2 - 2
packages/host/apiproxy/tests/api-proxy-approval.spec.ts

@@ -27,7 +27,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
   await ctx.plugin(UserInteractionService)
   await ctx.plugin(AgentRegistry)
   await ctx.plugin(ApprovalService)
-  const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+  const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
   return { ctx, api }
 }
 
@@ -217,7 +217,7 @@ describe('approval pending registry', () => {
     await ctx.plugin(ApprovalService)
     let api!: ApiProxy
     const fiber = ctx.plugin(Object.assign((fiberCtx: Context) => {
-      api = createApiProxy(fiberCtx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+      api = createApiProxy(fiberCtx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
     }, { inject: ['sessions', 'agents', 'userInteraction', 'approval'] }))
     await fiber.await()
     const abort = new AbortController()

+ 1 - 1
packages/host/apiproxy/tests/api-proxy-blank.spec.ts

@@ -35,7 +35,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy; attach: (sessio
   await ctx.plugin(AgentRegistry)
   return {
     ctx,
-    api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }),
+    api: createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }),
     attach: (session) => {
       ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
     },

+ 11 - 11
packages/host/apiproxy/tests/api-proxy-cold.spec.ts

@@ -62,7 +62,7 @@ describe('sessions.list cold merge', () => {
         return undefined
       },
     })
-    const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+    const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
 
     const response = await api.sessions.list(request({}))
     expect(response.result.ok).toBe(true)
@@ -90,7 +90,7 @@ describe('attached updatedAt excludes end-seed', () => {
     await ctx.plugin(SessionStore)
     await ctx.plugin(UserInteractionService)
     await ctx.plugin(AgentRegistry)
-    const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+    const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
 
     // Old work, resumed just now: the log tail would report the pickup.
     const worked = 1_000_000
@@ -148,7 +148,7 @@ describe('cold history recovery view', () => {
       inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal),
       locate: () => undefined,
     } as never)
-    const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+    const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
 
     const history = await api.sessions.history(request({ sessionId, beforeSeq: 2, maxMessages: 10 }))
     if (!history.result.ok) throw new Error('history failed')
@@ -216,7 +216,7 @@ describe('subagent ownership fence', () => {
       locate: () => undefined,
     } as never)
     const resume = vi.spyOn(ctx.agents, 'resume')
-    const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+    const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
 
     const history = await api.sessions.history(request({ sessionId }))
     expect(history.result.ok).toBe(true)
@@ -275,7 +275,7 @@ describe('subagent ownership fence', () => {
     // instead of answering `agent-busy`.
     const resume = vi.spyOn(ctx.agents, 'resume')
       .mockRejectedValue(new Error('registry unavailable in this bench'))
-    const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+    const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
 
     const prompt = await api.sessions.prompt(request({
       sessionId,
@@ -316,7 +316,7 @@ describe('subagent ownership fence', () => {
     })
     const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent
     ctx.agents.enter(startingChild, parent)
-    const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+    const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
 
     const stopped = await api.sessions.cancel(request({ sessionId: originChild.id }))
     expect(stopped.result.ok).toBe(false)
@@ -362,7 +362,7 @@ describe('subagent ownership fence', () => {
     const followup = vi.fn()
     const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent
     ctx.agents.register(agent)
-    const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+    const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
 
     const response = await api.sessions.prompt(request({
       sessionId: agent.id,
@@ -380,7 +380,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
     await ctx.plugin(SessionStore)
     await ctx.plugin(AgentRegistry)
     await ctx.plugin(UserInteractionService)
-    const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+    const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
 
     const listed = await api.sessions.list(request({}))
     expect(listed.result.ok).toBe(true)
@@ -405,7 +405,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
       list: () => Promise.resolve([]),
       inspect,
     } as never)
-    const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+    const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
 
     const response = await api.sessions.history(request({ sessionId: sid('session-missing') }))
     expect(response.result.ok).toBe(false)
@@ -431,7 +431,7 @@ describe('sessions.prompt synchronous rejection', () => {
       followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
       steer: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
     } as unknown as Agent)
-    const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+    const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
 
     for (const mode of ['queue', 'steer'] as const) {
       const response = await api.sessions.prompt(request({
@@ -475,7 +475,7 @@ describe('sessions.prompt synchronous rejection', () => {
       ctx.agents.register(child)
       throw new Error('session id already published')
     })
-    const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+    const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
 
     const models = await api.sessions.models(request({ sessionId }))
     expect(models.result.ok).toBe(false)

+ 1 - 1
packages/host/apiproxy/tests/api-proxy-commands.spec.ts

@@ -25,7 +25,7 @@ import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
 import { RpcId } from '../src/api/rpc.ts'
 import { createApiProxy } from '../src/api-proxy.ts'
 
-const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }
+const DEFAULTS = { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }
 
 function request<P>(payload: P): RpcRequest<P> {
   return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }

+ 1 - 1
packages/host/apiproxy/tests/api-proxy-config.spec.ts

@@ -24,7 +24,7 @@ import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
 import { RpcId } from '../src/api/rpc.ts'
 import { createApiProxy } from '../src/api-proxy.ts'
 
-const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }
+const DEFAULTS = { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }
 
 let nextRpc = 1
 function request<P>(payload: P): RpcRequest<P> {

+ 1 - 2
packages/host/apiproxy/tests/api-proxy-fork.spec.ts

@@ -82,8 +82,7 @@ function liveAgent(
 }
 
 const api = (ctx: Context) => createApiProxy(ctx, {
-  provider: 'default-provider',
-  model: 'default-model',
+  defaultTarget: () => ({ provider: 'default-provider', model: 'default-model' }),
   cwd: '/tmp',
   workspaceRoot: '/tmp',
 })

+ 99 - 2
packages/host/apiproxy/tests/api-proxy-models.spec.ts

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

+ 1 - 1
packages/host/apiproxy/tests/api-proxy-projections.spec.ts

@@ -68,7 +68,7 @@ function seedMessages(session: Session, count: number): void {
   }
 }
 
-const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+const api = (ctx: Context) => createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
 
 describe('session.history projections block', () => {
   it('serves the unit value on the tail page with asOfSeq = last event seq', async () => {

+ 1 - 1
packages/host/apiproxy/tests/api-proxy-question.spec.ts

@@ -13,7 +13,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
   await ctx.plugin(UserInteractionService)
   return {
     ctx,
-    api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }),
+    api: createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }),
   }
 }
 

+ 1 - 1
packages/host/apiproxy/tests/api-proxy-rename.spec.ts

@@ -68,7 +68,7 @@ function liveAgent(ctx: Context, id: string, turns: number): Session {
   return session
 }
 
-const api = (ctx: Context) => createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+const api = (ctx: Context) => createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
 
 describe('sessions.rename', () => {
   it('accepts through the composed title service: normalized user-source event, echoed seq', async () => {

+ 1 - 1
packages/host/apiproxy/tests/api-proxy-search.spec.ts

@@ -27,7 +27,7 @@ vi.mock('node:fs/promises', async (importOriginal) => {
 })
 
 const sid = (value: string): SessionId => value as SessionId
-const defaults = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }
+const defaults = { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }
 
 function request(query: string): RpcRequest<{ query: string }> {
   return { rpcId: RpcId(`search-${query}`), payload: { query } }

+ 1 - 1
packages/host/apiproxy/tests/api-proxy-subagents.spec.ts

@@ -88,7 +88,7 @@ function bench(options: {
   ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} })
   ctx.provide('userInteraction', { registerProvider: () => () => {} })
   const api = createApiProxy(ctx, {
-    provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp',
+    defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp',
   })
   return { api, getAgent, listChildren, inspect, snapshot, restore, followup, parent }
 }

+ 5 - 5
packages/host/apiproxy/tests/api-proxy-view.spec.ts

@@ -105,7 +105,7 @@ async function collect(iterable: AsyncIterable<RpcRequest<MuxFrame>>, count: num
 describe('mux live view computation', () => {
   it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => {
     const { ctx } = await harness()
-    const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+    const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
     const abort = new AbortController()
     const stream = api.events.mux({ rpcId: RpcId('t-mux'), payload: {} }, abort.signal)
     const collected = collect(stream, 9, abort)
@@ -170,7 +170,7 @@ describe('mux live view computation', () => {
 
   it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => {
     const { ctx } = await harness()
-    const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+    const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
     const session = ctx.sessions.create()
     // history resolves the agent first; a live structural stub is enough (only
     // .session is read on this path).
@@ -238,7 +238,7 @@ describe('mux live view computation', () => {
 
   it('counts only append-origin messages toward maxMessages and keeps compaction provenance whole', async () => {
     const { ctx } = await harness()
-    const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+    const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
     const session = ctx.sessions.create()
     ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
     session.append('turn/start', { turn: 1 })
@@ -287,7 +287,7 @@ describe('mux live view computation', () => {
 
   it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => {
     const { ctx } = await harness()
-    const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+    const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
     const abort = new AbortController()
     const stream = api.events.mux({ rpcId: RpcId('t-mux3'), payload: {} }, abort.signal)
 
@@ -308,7 +308,7 @@ describe('mux live view computation', () => {
 
   it('pairs a result after turn/end via the in-memory backscan fallback', async () => {
     const { ctx } = await harness()
-    const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+    const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
     const abort = new AbortController()
     const stream = api.events.mux({ rpcId: RpcId('t-mux2'), payload: {} }, abort.signal)
     const collected = collect(stream, 4, abort)

+ 1 - 2
packages/host/apiproxy/tests/api-proxy-workspace.spec.ts

@@ -100,8 +100,7 @@ async function harness(
   // object per harness mirrors the seam's stability contract.
   ctx.provide('directoryPicker', { capability: () => picker } as never)
   const api = createApiProxy(ctx, {
-    provider: 'test',
-    model: 'test-model',
+    defaultTarget: () => ({ provider: 'test', model: 'test-model' }),
     cwd: workspaceRoot,
     workspaceRoot,
     ...extras.openPath === undefined ? {} : { openPath: extras.openPath },

+ 1 - 1
packages/host/apiproxy/tests/rpc-schemas.spec.ts

@@ -274,7 +274,7 @@ describe('sessions domain schemas', () => {
 describe('host domain schemas', () => {
   it('validates describe request/value', () => {
     expect(hostDescribeRequestSchema.parse({})).toEqual({})
-    const value = hostDescribeValueSchema.parse({ version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2 })
+    const value = hostDescribeValueSchema.parse({ version: '1', cwd: '/x', defaultTarget: () => ({ provider: 'p', model: 'm' }), attachedSessions: 2 })
     expect(value.attachedSessions).toBe(2)
     expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0 }).provider).toBeUndefined()
   })

+ 1 - 1
packages/todo/tool-todo/tests/projection.spec.ts

@@ -45,7 +45,7 @@ async function harness(withTodoTool: boolean): Promise<Bench> {
   if (withTodoTool) await ctx.plugin(ToolTodo, { allowParallelInProgress: true })
   const session = ctx.sessions.create()
   ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
-  const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+  const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
   return {
     ctx,
     session,