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

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

View File

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