From f2d1a29636cd0f818468342ad7e16827f7c9bb0f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 13:23:19 +0800 Subject: [PATCH 01/10] feat(apiproxy): make the default model a user setting the picker writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/host/apiproxy/src/api-proxy.ts | 80 ++++++++++---- packages/host/apiproxy/src/index.ts | 89 +++++++++++++-- .../apiproxy/tests/api-proxy-approval.spec.ts | 4 +- .../apiproxy/tests/api-proxy-blank.spec.ts | 2 +- .../apiproxy/tests/api-proxy-cold.spec.ts | 22 ++-- .../apiproxy/tests/api-proxy-commands.spec.ts | 2 +- .../apiproxy/tests/api-proxy-config.spec.ts | 2 +- .../apiproxy/tests/api-proxy-fork.spec.ts | 3 +- .../apiproxy/tests/api-proxy-models.spec.ts | 101 +++++++++++++++++- .../tests/api-proxy-projections.spec.ts | 2 +- .../apiproxy/tests/api-proxy-question.spec.ts | 2 +- .../apiproxy/tests/api-proxy-rename.spec.ts | 2 +- .../apiproxy/tests/api-proxy-search.spec.ts | 2 +- .../tests/api-proxy-subagents.spec.ts | 2 +- .../apiproxy/tests/api-proxy-view.spec.ts | 10 +- .../tests/api-proxy-workspace.spec.ts | 3 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 2 +- .../todo/tool-todo/tests/projection.spec.ts | 2 +- 18 files changed, 274 insertions(+), 58 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 19fb0fe8a2..709c63e4d0 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/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 /** 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() /** 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, })) }, diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index e279575ff4..34ce49fc77 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/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]: z } { + return { + provider: z.string().required(), + model: z.string().required(), + reasoningEffort: z.string(), + } +} + +/** Schema of the settings section. */ +const DefaultRouteSchema: z = 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 = 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), }) diff --git a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts index 4833667583..e6555898cd 100644 --- a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts +++ b/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() diff --git a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts index 4f8637068e..4943c051bb 100644 --- a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts +++ b/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) }, diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index 78a67ef642..4b6337ede8 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/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) diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 1ab33897e3..55781a3e77 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/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

(payload: P): RpcRequest

{ return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload } diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index 54235c0218..c13a66eaec 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/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

(payload: P): RpcRequest

{ diff --git a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts index 83955f2d8b..fb6f8cdfed 100644 --- a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts +++ b/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', }) diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index c2dfdae7a7..7a9f2b2f86 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/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() + }) }) diff --git a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts index a1775a8025..c9cb212004 100644 --- a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts +++ b/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 () => { diff --git a/packages/host/apiproxy/tests/api-proxy-question.spec.ts b/packages/host/apiproxy/tests/api-proxy-question.spec.ts index e8eaae813f..ee5747039f 100644 --- a/packages/host/apiproxy/tests/api-proxy-question.spec.ts +++ b/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' }), } } diff --git a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts index 15c7361024..2f93cdd9b3 100644 --- a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts +++ b/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 () => { diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/host/apiproxy/tests/api-proxy-search.spec.ts index 57bb05df4f..15a4ae3bf3 100644 --- a/packages/host/apiproxy/tests/api-proxy-search.spec.ts +++ b/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 } } diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts index c761484da5..feb9ecb073 100644 --- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts +++ b/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 } } diff --git a/packages/host/apiproxy/tests/api-proxy-view.spec.ts b/packages/host/apiproxy/tests/api-proxy-view.spec.ts index 43083545db..4490c71bc2 100644 --- a/packages/host/apiproxy/tests/api-proxy-view.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-view.spec.ts @@ -105,7 +105,7 @@ async function collect(iterable: AsyncIterable>, 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) diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index af315ffcd0..aa560bdf58 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/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 }, diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index b65861c1ae..040fe56ff5 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/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() }) diff --git a/packages/todo/tool-todo/tests/projection.spec.ts b/packages/todo/tool-todo/tests/projection.spec.ts index f1932b9955..08cb7d3216 100644 --- a/packages/todo/tool-todo/tests/projection.spec.ts +++ b/packages/todo/tool-todo/tests/projection.spec.ts @@ -45,7 +45,7 @@ async function harness(withTodoTool: boolean): Promise { 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, From e0f9f7a6e66de81c2cc4fdff2a707212e73575f1 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 13:25:46 +0800 Subject: [PATCH 02/10] fix(ui-models): let a hand-declared route set its reasoning effort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The create card omitted the provider-level effort the editor card offers for the same namespace, so a route declared through 添加自定义提供方 gained a setting the moment it was reopened for editing — one the creating user was never shown. Both cards now render one shared control. The field, its vocabulary, and the inherit-means-absent rule live with the control rather than in the editor, which is what stops the two from drifting apart again. --- .../src/client/CustomProviderCard.tsx | 14 ++++ .../ui-models/src/client/ProviderEditor.tsx | 42 +++-------- .../src/client/ReasoningEffortField.tsx | 71 +++++++++++++++++++ .../ui-models/tests/provider-form.spec.tsx | 34 +++++++++ 4 files changed, 130 insertions(+), 31 deletions(-) create mode 100644 packages/client/ui-models/src/client/ReasoningEffortField.tsx diff --git a/packages/client/ui-models/src/client/CustomProviderCard.tsx b/packages/client/ui-models/src/client/CustomProviderCard.tsx index b4c655472a..4bd14d1179 100644 --- a/packages/client/ui-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-models/src/client/CustomProviderCard.tsx @@ -22,6 +22,7 @@ import { EditorFooter } from './EditorFooter.tsx' import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx' import { ModelListEditor } from './ModelListEditor.tsx' import type { ModelDraft } from './ModelListEditor.tsx' +import { EFFORT_FIELD, ReasoningEffortField } from './ReasoningEffortField.tsx' import { deriveKeyRef, messageOf } from './store.ts' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -69,6 +70,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { const [baseURL, setBaseURL] = useState('') const [protocol, setProtocol] = useState(protocols[0] ?? '') const [keyDraft, setKeyDraft] = useState('') + const [effort, setEffort] = useState(undefined) const [models, setModels] = useState([]) const [busy, setBusy] = useState(false) const [failure, setFailure] = useState(undefined) @@ -101,6 +103,9 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { apiKeyEnv: keyRef, api: protocol, baseURL, + // Inherit is the field being absent, not an empty string: the schema + // types it as an effort name, and an empty one would fail the write. + ...effort === undefined ? {} : { [EFFORT_FIELD['pi-ai']]: effort }, models: models.map(model => ({ ...model })), } const response = await api.settings.mutate({ @@ -209,6 +214,15 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { onChange={(event) => { setKeyDraft(event.target.value) }} /> + {/* The same control the editor card shows for this namespace: a route + declared here and edited there must offer the same profile. */} + = { - deepseek: ['off', 'high', 'max'], - 'pi-ai': ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'], -} - -/** The draft key the effort select edits, per layout. */ -const EFFORT_FIELD: Record<'deepseek' | 'pi-ai', string> = { - deepseek: 'reasoningEffort', - 'pi-ai': 'reasoning', -} +type EditorLayout = EffortFamily | 'unknown' /** The public DeepSeek endpoint shown as the deepseek base-URL placeholder. */ const DEEPSEEK_PUBLIC_BASE_URL = 'https://api.deepseek.com' @@ -279,7 +269,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { * family as a parameter is what makes `EFFORT_FIELD` total here: an * unknown namespace never reaches this body. */ - const curatedFields = (family: 'deepseek' | 'pi-ai'): ReactNode => { + const curatedFields = (family: EffortFamily): ReactNode => { const effortField = EFFORT_FIELD[family] const customModels = getPath(draft, ['models']) const modelsOverridden = hasPath(draft, ['models']) @@ -333,23 +323,13 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { }} /> -

- {t('effort')} - -
+ { setField(effortField, effort) }} + t={t} + disabled={disabled} + /> {/* Both families edit the same rows through the same contract; only the extras differ — DeepSeek's inherited capacities, pi-ai's endpoint interrogation. */} diff --git a/packages/client/ui-models/src/client/ReasoningEffortField.tsx b/packages/client/ui-models/src/client/ReasoningEffortField.tsx new file mode 100644 index 0000000000..10b696a4ea --- /dev/null +++ b/packages/client/ui-models/src/client/ReasoningEffortField.tsx @@ -0,0 +1,71 @@ +/** + * The provider-level reasoning-effort select, shared by every card that writes + * a provider profile. It lives here rather than inside one card because both + * write the SAME field of the same profile: a route declared without this + * control and then edited with it would offer a setting the creating user was + * never given, which is exactly the drift that put it here. + * + * The value is the profile's own default effort, applied to every model on the + * route unless a request names one; the empty option means "inherit", which on + * the wire is the field being absent rather than an empty string. + */ + +import type { ReactNode } from 'react' +import type { en } from './locales.ts' +import styles from './ModelsSection.module.css' + +/** The adapter families that expose a provider-level effort, and their vocabularies. */ +export type EffortFamily = 'deepseek' | 'pi-ai' + +/** Reasoning vocabularies per family; the empty option means "inherit". */ +export const EFFORT_CHOICES: Record = { + deepseek: ['off', 'high', 'max'], + 'pi-ai': ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'], +} + +/** The profile key each family's effort lives under. */ +export const EFFORT_FIELD: Record = { + deepseek: 'reasoningEffort', + 'pi-ai': 'reasoning', +} + +/** Props of {@link ReasoningEffortField}. */ +export interface ReasoningEffortFieldProps { + /** Which vocabulary to offer. */ + family: EffortFamily + /** Current value; the empty string is the inherit option. */ + value: string + /** Receives the chosen effort, or undefined for inherit. */ + onChange: (effort: string | undefined) => void + /** Section copy. */ + t: (key: keyof typeof en) => string + /** Disable the control (busy or read-only). */ + disabled: boolean +} + +/** + * Render the provider-level reasoning-effort select. + * @param props - family vocabulary, current value, change sink, copy, and disabled state. + * @returns the labelled select. + */ +export function ReasoningEffortField( + { family, value, onChange, t, disabled }: ReasoningEffortFieldProps, +): ReactNode { + return ( +
+ {t('effort')} + +
+ ) +} diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 99e85b0d10..b35302f78a 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -652,6 +652,40 @@ describe('hand-declared providers', () => { expect(set).toHaveBeenCalledWith({ ref: 'ACME_GATEWAY_API_KEY', value: 'gw-key' }) }) + it('offers the same reasoning effort the editor does, and omits it when inherited', async () => { + const { mutate, onClose } = mountCard() + const declare = (): void => { + fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } }) + } + declare() + + // The vocabulary is the namespace's, not DeepSeek's — a route declared + // here is edited by the pi-ai layout, which offers exactly these. + const select = screen.getByLabelText(en.effort) as HTMLSelectElement + expect([...select.options].map(option => option.value)) + .toEqual(['', 'off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']) + + fireEvent.change(select, { target: { value: 'high' } }) + fireEvent.click(screen.getByText(en.create)) + await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) }) + expect(firstMutate(mutate).ops[0]).toMatchObject({ + path: ['providers', 'acme'], + value: { reasoning: 'high' }, + }) + + // Inherit is the field being absent: an empty string would fail the schema + // that types this as an effort name. + cleanup() + const second = mountCard() + declare() + fireEvent.click(screen.getByText(en.create)) + await waitFor(() => { expect(second.onClose).toHaveBeenCalledWith(true) }) + expect(firstMutate(second.mutate).ops[0].value).not.toHaveProperty('reasoning') + }) + it('names the blocked gate under the form, and nothing once it is satisfied', () => { mountCard() fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) From 96795972040151c756323d8bf05504d387aadb8a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 13:49:47 +0800 Subject: [PATCH 03/10] feat(ui-models): tag the provider rows this deployment declared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A row's stored profile could not tell a hand-declared gateway from a shipped provider whose models someone narrowed — both look identical from outside the adapter — so the Models page had no way to mark the routes a deployment added itself. The directory entry now carries `declared`, answered by the owning adapter against its own installed catalog, and the page renders a Custom tag from it. Absence stays "this adapter draws no such distinction" rather than "shipped", so a route no adapter claims is labelled neither way. Also records the default-route work's Agent Note and the e2e evidence for all three changes: the composer switch writing the section, and the Models page declaring a route with its own reasoning effort. --- ...default-model-follows-the-picker.i18n.yaml | 6 + ...-08-07-default-model-follows-the-picker.md | 33 +++++ ...-07-default-model-follows-the-picker.zh.md | 33 +++++ apps/web/tests/default-model.e2e.ts | 115 ++++++++++++++++++ apps/web/tests/models-settings.e2e.ts | 45 ++++++- .../models-settings/declared.expected.md | 30 +++++ apps/web/tsconfig.json | 1 + docs/config-catalog.md | 22 +++- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 9 ++ docs/core-data-structures/core.zh.md | 9 ++ .../client/connection/src/client/fixture.ts | 7 +- packages/client/ui-models/README.i18n.yaml | 4 +- packages/client/ui-models/README.md | 4 +- packages/client/ui-models/README.zh.md | 4 +- .../src/client/ModelsSection.module.css | 14 +++ .../ui-models/src/client/ModelsSection.tsx | 6 + .../client/ui-models/src/client/locales.ts | 2 + .../ui-models/tests/provider-form.spec.tsx | 52 +++++++- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 12 +- packages/host/apiproxy/README.zh.md | 12 +- packages/host/apiproxy/src/api-proxy.ts | 9 +- packages/host/apiproxy/src/api/llm.schema.ts | 1 + packages/host/apiproxy/src/api/llm.ts | 6 + packages/host/apiproxy/src/index.ts | 37 +++--- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 2 +- packages/llm/llm-pi-ai/README.zh.md | 2 +- packages/llm/llm-pi-ai/src/index.ts | 14 ++- packages/llm/llm-pi-ai/tests/catalog.spec.ts | 11 +- .../llm-pi-ai/tests/dynamic-config.spec.ts | 1 + packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 2 +- packages/llm/llm/README.zh.md | 2 +- packages/llm/llm/src/types.ts | 9 ++ tsconfig.host.json | 1 + 38 files changed, 479 insertions(+), 56 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md create mode 100644 .agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md create mode 100644 apps/web/tests/default-model.e2e.ts create mode 100644 apps/web/tests/snapshots/models-settings/declared.expected.md diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml new file mode 100644 index 0000000000..ba3917c8b7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md +2026-08-07-default-model-follows-the-picker.md: 5174b224a17728b65f7fd69d7f72388d50e8e825 +2026-08-07-default-model-follows-the-picker.zh.md: 6ead561b928572a479f2c7b19e409b3845363ed6 diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md new file mode 100644 index 0000000000..5174b224a1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md @@ -0,0 +1,33 @@ +# Agent Note: the default model follows the picker + +Status: implemented + +English | [中文](2026-08-07-default-model-follows-the-picker.zh.md) + +## Problem + +The route a new session started from was frozen into the gateway's composition entry (`api-gateway` in the web-app bundle patch). Switching models in a conversation reached that conversation only: the next session went back to the shipped default, and the only way to change it was to hand-edit a `cordis.yml` row and restart. There was no user-settings tier between the composition and the per-session choice. + +## Decision + +`ApiProxyService` registers its `{provider, model, reasoningEffort?}` slice as the `api-gateway` settings section: the composition entry is the `base` layer and `settings.yaml` layers the user's choice over it. `workspaceRoot` stays outside the section — a launcher fact, not a preference. The section schema is picked out of `static Config` rather than restated, because the configuration-catalog generator reads that literal statically and a spread breaks it. + +`session.selectModel` records an accepted switch as the new default. There is no separate gesture: switching models in the composer IS how the default is chosen. The write is `replace`, not `update` — switching to a model with no reasoning effort has to clear a stored one, and a merged patch would strand it for the next session to fail on. A storage failure is logged without undoing the switch, which already applies to its own session, and a deployment with no settings provider keeps the entry with the switch staying process-local. + +`ApiProxyDefaults` carries `defaultTarget()` and `persistDefaultTarget()` closures instead of flat `provider`/`model` fields, so `createApiProxy` needs no knowledge of the settings seam. + +`targetFor` resolves its tiers on **every** read rather than seeding a ref once: an explicit selection in this process, else the session's own latest logged `request/header`, else the live default. Both directions depend on the re-read. A session that has run a turn derives from its log forever after, so changing the default never retargets it. A session still blank starts from a default saved after it was created — which matters because New Session reuses a blank session rather than minting another, so a creation-time seed would show the superseded model in exactly the flow the feature exists for. + +The stored route is not validated against the registry. A default naming a route the Models page has since removed still reaches `session.models` as `current`, matching no advertised group — which is what makes the composer seat's existing fallback prompt for a selection instead of naming a model the deployment cannot reach. + +## Consequences + +`ApiProxyDefaults` changed shape, updating ~40 test construction sites. `host.describe` now reports the live default rather than a captured one, which is what it always meant. `settings.yaml` gains an `api-gateway:` section the moment a user switches models; the `api-gateway` namespace is deliberately NOT added to the gateway's exposed-namespace allowlist, so the Settings page neither reads nor writes it — the model picker is its editor. + +## Alternatives considered + +- **Falling back to the composition entry when the stored route is unregistered.** Rejected: the composer would then name the shipped DeepSeek model instead of prompting, which is both a silent switch to a provider the user did not pick and the opposite of the requested behavior. +- **Validating and clearing a stale default.** Rejected: catalog membership is advisory by design (`buildModelCatalog` documents it), so an adapter may serve a model its own catalog stopped advertising; self-healing would break that deliberate case. +- **A `settings.update` merge patch.** Rejected: it cannot clear `reasoningEffort`, so a switch from a reasoning model to a plain one leaves an effort the next session fails on. +- **Persisting only from blank sessions.** Rejected: the most informative switch is the one made mid-conversation after seeing a model underperform, and that one would never be saved. +- **A separate "set as default" affordance.** Rejected for now: it adds a second gesture for what every comparable product infers from the switch itself. The cost is that a temporary switch in an old session also moves the default. diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md new file mode 100644 index 0000000000..6ead561b92 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 默认模型跟随选择器 + +Status: implemented + +[English](2026-08-07-default-model-follows-the-picker.md) | 中文 + +## 问题 + +新会话的起始路由被冻结在网关的组合条目里(web-app bundle patch 中的 `api-gateway` 行)。在一段对话里切换模型只影响这段对话:下一个会话又回到出厂默认,而要改这个默认值,唯一的办法是手工编辑一条 `cordis.yml` 行并重启。组合层与每会话选择之间没有用户设置这一层。 + +## 决定 + +`ApiProxyService` 把自己的 `{provider, model, reasoningEffort?}` 切片注册为 `api-gateway` 设置段:组合条目是 `base` 层,`settings.yaml` 把用户的选择叠加其上。`workspaceRoot` 留在段外——它是启动器事实,不是偏好。段 schema 从 `static Config` 里挑出来而不是重述一遍,因为配置目录生成器是静态读取那个字面量的,展开语法会让它失败。 + +`session.selectModel` 把被接受的切换记录为新的默认值。没有另一个单独的手势:在输入框切模型**就是**选定默认值的方式。写入用 `replace` 而非 `update`——切到一个不支持推理的模型必须清掉已存的等级,而合并补丁会把它滞留下来,让下一个会话在它上面失败。存储失败只记日志,不撤销这次切换(它对自己所在的会话已经生效);没有设置提供方的部署保留组合条目,切换只停留在进程内。 + +`ApiProxyDefaults` 改为携带 `defaultTarget()` 与 `persistDefaultTarget()` 两个闭包,而不是扁平的 `provider`/`model` 字段,这样 `createApiProxy` 不需要知道设置这条缝的存在。 + +`targetFor` 在**每一次**读取时解析各级,而不是只在创建时种一次 ref:本进程内的显式选择,其次是该会话自己最新记录的 `request/header`,最后才是活的默认值。两个方向都依赖这次重新读取。已经跑过一轮的会话此后永远从自己的日志推导,改默认值不会重定向它;而仍然空白的会话会用上它创建之后才保存的默认值——这一点很关键,因为新建会话是复用空白会话而不是再开一个,创建时种下的值恰好会在这个功能存在的意义所在的流程里显示已被取代的模型。 + +存下来的路由不做注册表校验。默认值指向一条模型页已经删除的路由时,它照样作为 `current` 送到 `session.models`,匹配不到任何已公布的分组——而这正是让输入框选择器已有的回退提示重新选择、而不是显示一个部署根本够不着的模型的原因。 + +## 影响 + +`ApiProxyDefaults` 形状变了,约 40 处测试构造点随之更新。`host.describe` 现在报告的是活的默认值而非捕获的快照,这本就是它一直想表达的含义。用户一旦切换模型,`settings.yaml` 就会多出一个 `api-gateway:` 段;`api-gateway` 这个 namespace 刻意**没有**加进网关的暴露名单,因此设置页既不读也不写它——模型选择器就是它的编辑器。 + +## 考虑过的替代方案 + +- **存下来的路由未注册时回落到组合条目。** 否决:那样输入框会显示出厂的 DeepSeek 模型而不是提示选择,既是静默切到用户没选的提供方,也与要求的行为正好相反。 +- **校验并清空失效的默认值。** 否决:目录成员关系按设计是咨询性的(`buildModelCatalog` 有注释说明),适配器可以服务一个自己目录已不再公布的模型;自动修复会破坏这个刻意保留的情形。 +- **用 `settings.update` 合并补丁。** 否决:它清不掉 `reasoningEffort`,于是从推理模型切到普通模型会留下一个等级,让下一个会话在它上面失败。 +- **只在空白会话里持久化。** 否决:最有信息量的切换恰恰是对话到一半发现模型不行时做的那一次,而它永远存不下来。 +- **单独做一个「设为默认」的入口。** 目前否决:同类产品都从切换本身推断的事情,它却要多一个手势。代价是在老会话里的临时切换也会移动默认值。 diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts new file mode 100644 index 0000000000..0ba81e3ca0 --- /dev/null +++ b/apps/web/tests/default-model.e2e.ts @@ -0,0 +1,115 @@ +// Web e2e scenario: switching models in the composer is how this deployment's +// default is chosen. The gesture writes the `api-gateway` settings section, a +// session created afterwards starts from it, and a session that already logged +// a route keeps deriving from its own log — the tier order the gateway +// resolves on every read. +// Zero model calls: the switch is settings/llm-domain traffic only, so there +// is no fixture and a stray stream would fail loud on the open seam. A second +// route is declared host-side (not through the UI, which has its own +// scenario) purely so the picker has somewhere to switch to: the keyless +// replay catalog publishes a single model. +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' +import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' +import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts' + +/** The route declared for this scenario, and the model the switch lands on. */ +const ROUTE = 'acme-gateway' +const MODEL = 'acme-large' + +describe('web e2e: the composer model switch is the default for later sessions', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + /** Create one session and its agent through the same wire face the browser uses. */ + const createSession = async (sessionId: string): Promise => { + const response = await scaffold.ctx.apiProxy.sessions.create({ + rpcId: `default-model-create-${sessionId}` as never, + payload: { sessionId: SessionId(sessionId), cwd: scaffold.workspaceCwd }, + }) + if (!response.result.ok) throw new Error(`session.create failed: ${response.result.error.message}`) + return response.result.value.sessionId + } + + /** The route the gateway reports for one session, through the real wire face. */ + const currentOf = async (sessionId: string): Promise => { + const response = await scaffold.ctx.apiProxy.sessions.models({ + rpcId: `default-model-${sessionId}` as never, + payload: { sessionId: SessionId(sessionId) }, + }) + if (!response.result.ok) throw new Error(`session.models failed: ${response.result.error.message}`) + return response.result.value.current + } + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + // A second route so the picker has two models. Declared through the + // settings seam rather than the Models page: this scenario is about the + // composer, and the declaring flow is covered by models-settings.e2e. + await scaffold.ctx.settings.update(settingsNamespace('llm-pi-ai'), { + providers: { + [ROUTE]: { + displayName: 'Acme Gateway', + api: 'openai-completions', + baseURL: 'https://gateway.acme.example/v1', + models: [{ id: MODEL, name: 'Acme Large' }], + }, + }, + }) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + // The composer's seats only exist once a workspace is connected: without + // one the input is the locked placeholder and no session scope is open. + await connectFreshWorkspaceZh(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('writes the switched model as the default and leaves a logged session alone', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-default-model')) + // A session that has already run a turn, spelled as the fact a turn + // leaves behind: its own logged route. + const loggedId = await createSession('default-model-logged') + scaffold.ctx.sessions.get(SessionId(loggedId))?.append('request/header', { + header: { config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }, + reason: 'initial', + }) + + const trigger = page.getByRole('button', { name: /^选择模型/ }) + await trigger.waitFor({ timeout: 15_000 }) + await trigger.click() + await page.getByRole('menuitem', { name: /模型/ }).click() + await page.getByRole('menuitemradio', { name: 'Acme Large' }).click() + + // The switch is what sets the default: the gateway's own settings section + // now names it, beside the provider profiles the Models page writes. + await expect.poll( + async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), + { timeout: 10_000 }, + ).toContain('api-gateway:') + const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') + expect(document).toContain(`provider: ${ROUTE}`) + expect(document).toContain(`model: ${MODEL}`) + + // A session created after the switch starts from it... + expect(await currentOf(await createSession('default-model-after'))) + .toEqual({ provider: ROUTE, model: MODEL }) + // ...while the one holding a logged route keeps deriving from its log. + expect(await currentOf(loggedId)) + .toEqual({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) +}) diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 1d9117dc85..7fc31fab7c 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -25,6 +25,7 @@ import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import.meta.url)) const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md') const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md') +const DECLARED_EXPECTED = join(SNAPSHOT_DIR, 'declared.expected.md') const DELETE_EXPECTED = join(SNAPSHOT_DIR, 'delete.expected.md') const MODE = webSnapshotMode() @@ -114,10 +115,47 @@ describe('web e2e: Models settings page configures a dormant provider', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('declares a route the adapter does not ship, with its own reasoning effort', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-models-declare')) + const dialog = page.getByRole('dialog', { name: '设置' }) + const declare = dialog.getByRole('button', { name: '添加自定义提供方' }) + await expect.poll(async () => declare.isEnabled(), { timeout: 10_000 }).toBe(true) + await declare.click() + await dialog.getByLabel('Provider ID').fill('acme-gateway') + await dialog.getByLabel('显示名称').fill('Acme Gateway') + await dialog.getByLabel('API 地址').fill('https://gateway.acme.example/v1') + // The create card offers the same provider-level effort the editor card + // does for this namespace; a route declared without it would gain the + // control only on reopening. + await dialog.getByLabel('推理强度').selectOption('high') + await dialog.getByRole('button', { name: '添加模型' }).click() + await dialog.getByLabel('模型 ID 1').fill('acme-large') + await dialog.getByRole('button', { name: '创建提供方', exact: true }).click() + + const row = dialog.getByText('Acme Gateway', { exact: true }).first() + await row.waitFor({ timeout: 10_000 }) + const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') + expect(document).toContain('acme-gateway:') + expect(document).toContain('reasoning: high') + + // The tag follows the adapter's installed catalog: this route is in no + // catalog, while minimax-cn is — even though both now have profiles. + const rowCard = (name: string) => dialog.locator('li').filter({ hasText: name }).first() + await expect.poll(async () => rowCard('Acme Gateway').getByText('自定义').count(), { timeout: 10_000 }).toBe(1) + expect(await rowCard('minimax-cn').getByText('自定义').count()).toBe(0) + + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(DECLARED_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + it('confirms provider deletion before removing its settings profile', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete')) const settingsDialog = page.getByRole('dialog', { name: '设置' }) - await settingsDialog.getByRole('button', { name: '删除', exact: true }).click() + // Two rows carry a delete action now that a route is also declared; this + // scenario is about minimax-cn, so it names its own row. + const minimaxRow = settingsDialog.locator('li').filter({ hasText: 'minimax-cn' }).first() + await minimaxRow.getByRole('button', { name: '删除', exact: true }).click() const deleteDialog = page.getByRole('dialog', { name: '删除模型提供方?' }) await deleteDialog.waitFor({ timeout: 10_000 }) const snapshot = await captureStableAria( @@ -129,7 +167,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await deleteDialog.getByRole('button', { name: '取消', exact: true }).click() expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('minimax-cn:') - await settingsDialog.getByRole('button', { name: '删除', exact: true }).click() + await minimaxRow.getByRole('button', { name: '删除', exact: true }).click() await page.getByRole('dialog', { name: '删除模型提供方?' }) .getByRole('button', { name: '删除提供方', exact: true }).click() await expect.poll( @@ -147,6 +185,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { }, 60_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['configured.expected.md', 'delete.expected.md', 'empty.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, + ['configured.expected.md', 'declared.expected.md', 'delete.expected.md', 'empty.expected.md']) }) }) diff --git a/apps/web/tests/snapshots/models-settings/declared.expected.md b/apps/web/tests/snapshots/models-settings/declared.expected.md new file mode 100644 index 0000000000..3aa3d64cc9 --- /dev/null +++ b/apps/web/tests/snapshots/models-settings/declared.expected.md @@ -0,0 +1,30 @@ +- dialog "设置": + - navigation: + - text: 设置 + - button "通用设置": + - img + - text: 通用设置 + - button "模型": + - img + - text: 模型 + - button "打开配置文件" + - button "关闭": + - img + - text: 关闭 + - heading "模型" [level=2] + - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - list: + - listitem: + - text: minimax-cn + - button "编辑" + - button "删除" + - listitem: + - text: Acme Gateway 自定义 + - button "编辑" + - button "删除" + - button "添加提供方": + - img + - text: 添加提供方 + - button "添加自定义提供方": + - img + - text: 添加自定义提供方 diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 7d509957ad..b74eacdeb0 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -37,6 +37,7 @@ "tests/details-session-lifecycle.e2e.ts", "tests/settings-chrome.e2e.ts", "tests/models-settings.e2e.ts", + "tests/default-model.e2e.ts", "tests/onboarding-deepseek-config.e2e.ts", "tests/remote-welcome.e2e.ts", "tests/workspace-management.e2e.ts", diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ad27aac3b9..f1c37f97c4 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -578,17 +578,27 @@ Requires: `agents` · `directoryPicker` · `llm` · `sessions` · `subagents` · ```ts config-catalog /** Gateway plugin config: host-level agent routing and Workspace creation root. */ -export interface Config { - /** Default provider route for created/resumed agents. */ - provider: string - /** Default model id. */ - model: string +export interface Config extends DefaultRouteSettings { /** Parent directory for name-created Workspaces; defaults to the Host cwd. */ workspaceRoot?: string } + +/** + * 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 +} ``` -Source: [`packages/host/apiproxy/src/index.ts:33`](../packages/host/apiproxy/src/index.ts) +Source: [`packages/host/apiproxy/src/index.ts:64`](../packages/host/apiproxy/src/index.ts) ## `@deepseek-ai/dsh-host-directory-picker-browse` diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 5600da9e54..a048f4e43d 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md -core.md: 52e77be89d939eefa2b42ef5586c5798e194a303 -core.zh.md: 5f0134a4c8b3dead49830b41f62e8b7238327cfa +core.md: eb96988abe096455c4f24ac220a6da3f266e690d +core.zh.md: 7334b3d3a5bd088f5467a72d7357f87c4c745487 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 52e77be89d..eb96988abe 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -313,6 +313,15 @@ interface LlmConfigurableProvider { * object; empty when the whole section is the profile. */ settingsPath: readonly string[] + /** + * Whether the owning adapter knows this route only because configuration + * declared it — a gateway or self-hosted server it ships nothing about. + * Absent means the adapter draws no such distinction; false means it does + * and this route is one of its own. Only the adapter can answer: a stored + * profile is how a user-added route AND a corrected shipped one both look + * from outside. + */ + declared?: boolean } ``` diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 5f0134a4c8..7334b3d3a5 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -319,6 +319,15 @@ interface LlmConfigurableProvider { * object; empty when the whole section is the profile. */ settingsPath: readonly string[] + /** + * Whether the owning adapter knows this route only because configuration + * declared it — a gateway or self-hosted server it ships nothing about. + * Absent means the adapter draws no such distinction; false means it does + * and this route is one of its own. Only the adapter can answer: a stored + * profile is how a user-added route AND a corrected shipped one both look + * from outside. + */ + declared?: boolean } ``` diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index dc2f8c5967..9559b5e198 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2500,8 +2500,11 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { providers: request => ok(request, { providers: [ { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true }, - { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true }, - { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false }, + { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true, declared: false }, + { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false, declared: false }, + // One hand-declared route, so a surface reading this fixture meets + // the tagged shape rather than only the shipped one. + { provider: 'acme-gateway', displayName: 'Acme Gateway', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'acme-gateway'], active: true, declared: true }, ], }), models: request => ok(request, { groups: fixtureModelGroups(), failures: [] }), diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index ae296a91aa..ba7816421b 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-models/README.md -README.md: b55914197e472edec8a8b6d4d3e02036d1697728 -README.zh.md: ca93c3d5a2a85fffb22707f8389f1e979468e2ec +README.md: ea3efd5b0a7ee3599fda74cd9a361222170c473d +README.zh.md: 96290d6e56f36d485ea3e0b661197eda4cc40c09 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index b55914197e..ea3efd5b0a 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status. -Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and the direct DeepSeek adapter's advisory model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset. +Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and the direct DeepSeek adapter's advisory model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped. The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. @@ -16,7 +16,7 @@ A pi-ai profile's `models` list is edited on the card: one row per model showing **Fetch available models** asks `llm.discoverModels` about the endpoint the form **currently shows**, including a base URL edited but not yet saved and a key typed but not yet stored, so adding a provider is one pass instead of save-then-return. The reply opens a picker rather than being written: candidates already configured start unchecked, so adopting a selection never overwrites a capacity the user corrected. A provider that cannot be interrogated is a detour, not a dead end — the adapter's own message appears beside the rows, which stay editable by hand. -**Add a custom provider** declares a route pi-ai does not ship. It is its own card rather than the editor with extra fields, because the route id is being chosen here and the settings address does not exist until it is: one `settings.mutate` sets the whole profile at `providers.`, and the key travels separately through `credentials.set` under the same `_API_KEY` derivation an existing provider uses. What a hand-declared route cannot default gates the create button — a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model — so the failure names the field while the user is still looking at it. Capacities do not gate it: the adapter's fallbacks size a model the endpoint described by id alone, which is what most listings return. The protocol choices are read out of the namespace's own schema rather than a wire field or a constant, so they cannot drift from the ones the adapter accepts. +**Add a custom provider** declares a route pi-ai does not ship. It is its own card rather than the editor with extra fields, because the route id is being chosen here and the settings address does not exist until it is: one `settings.mutate` sets the whole profile at `providers.`, and the key travels separately through `credentials.set` under the same `_API_KEY` derivation an existing provider uses. What a hand-declared route cannot default gates the create button — a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model — so the failure names the field while the user is still looking at it. Capacities do not gate it: the adapter's fallbacks size a model the endpoint described by id alone, which is what most listings return. The protocol choices are read out of the namespace's own schema rather than a wire field or a constant, so they cannot drift from the ones the adapter accepts. The card offers the same provider-level reasoning effort the editor card does for this namespace, from one shared control: both write the same profile field, so a route declared without it would have gained the setting only on being reopened. ## Model Experience diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index ca93c3d5a2..96290d6e56 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -4,7 +4,7 @@ 模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。 -行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另有 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai),以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset。 +行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另有 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai),以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。 前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 @@ -16,7 +16,7 @@ pi-ai profile 的 `models` 列表就在卡片上编辑:一行一个模型, **获取可用模型**会针对表单**当前显示**的端点调用 `llm.discoverModels`,包括已修改但尚未保存的 API 地址和已键入但尚未存储的密钥,因此新增一个提供方是一趟走完,而不是「先保存再回来」。回复会打开一个选择框而不是直接写入:已配置过的候选默认不勾选,因此采纳一次选择绝不会覆盖用户已更正的容量。无法被询问的提供方只是绕路而非死路——适配器自己的消息会显示在各行旁边,而这些行仍可手工编辑。 -**添加自定义提供方**用来声明 pi-ai 未提供的路由。它是独立的一张卡片而非在编辑器上加字段,因为路由 id 正是在这里被*选定*的,而在选定之前 settings 地址并不存在:一次 `settings.mutate` 在 `providers.` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个由唯一标识的模型——因此失败会在用户仍看着该字段时点名它。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema,而非某个协议字段或常量,因此它们不会与适配器实际接受的集合发生漂移。 +**添加自定义提供方**用来声明 pi-ai 未提供的路由。它是独立的一张卡片而非在编辑器上加字段,因为路由 id 正是在这里被*选定*的,而在选定之前 settings 地址并不存在:一次 `settings.mutate` 在 `providers.` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个由唯一标识的模型——因此失败会在用户仍看着该字段时点名它。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema,而非某个协议字段或常量,因此它们不会与适配器实际接受的集合发生漂移。这张卡片提供与编辑器卡片在该 namespace 下相同的提供方级推理等级,两者共用同一个控件:它们写的是同一个 profile 字段,若声明时没有它,这个设置就会等到重新打开编辑时才凭空出现。 ## 模型体验 diff --git a/packages/client/ui-models/src/client/ModelsSection.module.css b/packages/client/ui-models/src/client/ModelsSection.module.css index a4d4d04121..ca99d6d4e9 100644 --- a/packages/client/ui-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-models/src/client/ModelsSection.module.css @@ -72,6 +72,20 @@ color: var(--dsw-alias-label-primary); } +/* Reads as an annotation on the name, not as a second name: caption size and + the secondary label tone, so it never competes with the row's own title. + `rowActions` keeps the `margin-left: auto`, which is what holds the tag + beside the name instead of letting it drift across the row. */ +.rowTag { + flex: none; + padding: 1px 6px; + border: 1px solid var(--dsw-alias-border-l3); + border-radius: 4px; + font-size: 11px; + line-height: 16px; + color: var(--dsw-alias-label-secondary); +} + .rowActions { display: inline-flex; align-items: center; diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index a69d11dd6c..a883bb293b 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -205,6 +205,12 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
  • {row.entry.displayName} + {/* Only the adapter can tell a hand-declared route from a + shipped one it also has a stored profile for, so the tag + follows its answer and stays off when it gives none. */} + {row.entry.declared === true + ? {t('customTag')} + : null}
    @@ -178,7 +194,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { value={displayName} placeholder={route.length === 0 ? t('customDisplayName') : route} aria-label={t('customDisplayName')} - disabled={disabled} + disabled={profileDisabled} onChange={(event) => { setDisplayName(event.target.value) }} /> @@ -190,7 +206,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { value={baseURL} placeholder="https://gateway.example/v1" aria-label={t('baseUrl')} - disabled={disabled} + disabled={profileDisabled} onChange={(event) => { setBaseURL(event.target.value) }} /> @@ -200,7 +216,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { className={styles['input']} value={protocol} aria-label={t('customApi')} - disabled={disabled} + disabled={profileDisabled} onChange={(event) => { setProtocol(event.target.value) }} > {protocols.map(choice => )} @@ -226,7 +242,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { value={effort ?? ''} onChange={setEffort} t={t} - disabled={disabled} + disabled={profileDisabled} /> {failure !== undefined ?

    {failure}

    : null} {/* Only the gates with something to say render; the route-id gate has its @@ -251,7 +267,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { submitDisabled={disabled || !ready} submitLabel="create" submitBusyLabel="creating" - onCancel={() => { props.onClose(false) }} + onCancel={() => { props.onClose(committed) }} onSubmit={() => { void create() }} /> diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 67def367bc..5d359c2476 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -650,8 +650,11 @@ describe('provider rows', () => { }) describe('hand-declared providers', () => { - function mountCard(overrides: Partial[0]> = {}) { - const scripted = scriptedFace() + function mountCard( + overrides: Partial[0]> = {}, + wire: Parameters[0] = {}, + ) { + const scripted = scriptedFace(wire) const onClose = vi.fn() render( { expect(firstMutate(second.mutate).ops[0]).not.toHaveProperty('value.reasoning') }) + it('retries only the key after the profile landed, and reports the provider on cancel', async () => { + const set = vi.fn() + .mockResolvedValueOnce(fail('credential store is read-only', 'credential-rejected')) + .mockResolvedValueOnce(ok({})) + const { mutate, onClose } = mountCard({}, { set }) + + fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' gw-key ' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } }) + fireEvent.click(screen.getByText(en.create)) + + // The profile landed; only the key failed. The card says so and stays open. + await waitFor(() => { expect(screen.getByText('credential store is read-only')).toBeTruthy() }) + expect(onClose).not.toHaveBeenCalled() + expect(mutate).toHaveBeenCalledTimes(1) + // The key is stored trimmed, matching the editor. + expect(set).toHaveBeenNthCalledWith(1, { ref: 'ACME_API_KEY', value: 'gw-key' }) + + // The provider exists now, so the fields describing it are settled and + // only the key can still be corrected. + expect(screen.getByLabelText(en.customRoute).disabled).toBe(true) + expect(screen.getByLabelText(en.baseUrl).disabled).toBe(true) + expect(screen.getByLabelText(en.keyInput).disabled).toBe(false) + + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'gw-key-2' } }) + fireEvent.click(screen.getByText(en.create)) + await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) }) + // Re-running the profile write would carry the revision this card's own + // first write superseded, so the Host would answer settings-conflict and + // the key could never be stored from here at all. + expect(mutate).toHaveBeenCalledTimes(1) + expect(set).toHaveBeenNthCalledWith(2, { ref: 'ACME_API_KEY', value: 'gw-key-2' }) + }) + + it('reports the created provider when cancelled after its profile landed', async () => { + const set = vi.fn().mockResolvedValue(fail('nope', 'credential-rejected')) + const { onClose } = mountCard({}, { set }) + + fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) + fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'gw-key' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } }) + fireEvent.click(screen.getByText(en.create)) + await waitFor(() => { expect(screen.getByText('nope')).toBeTruthy() }) + + // Walking away leaves a real provider behind; reporting no change would + // leave the page without the row it now has. + fireEvent.click(screen.getByText(en.cancel)) + expect(onClose).toHaveBeenCalledWith(true) + }) + it('names the blocked gate under the form, and nothing once it is satisfied', () => { mountCard() fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index a5e9fd2aef..cc4fff8e24 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 38f18995f2982db2c5a48971d7d044448e5adc8c -README.zh.md: c444ed6b7485b6ddca059c5edf7f30828bd96ab7 +README.md: 9e01423a36803477cb07d944e058fc388b5e72fd +README.zh.md: d4df79d7d8850c466f1ccc4c53097a15739013ea diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 38f18995f2..9e01423a36 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -10,7 +10,9 @@ The API gateway every client shape shares: the TS contract (`src/api/`, zero Nod A session resolves its route from three tiers, re-read on every access rather than seeded once: a selection made in this process, else the session's own latest logged `request/header`, else this default. Re-reading is what makes both directions hold — a session that has run a turn derives its route from its log forever after, so changing the default never retargets it, while a session still blank (New Session reuses one rather than minting another) starts from a default saved after it was created. -`session.selectModel` records an accepted switch as the new default, which is how the default is chosen in practice: there is no separate gesture. The write replaces the section wholesale rather than merging, because switching to a model with no reasoning effort has to clear a stored one; a storage failure is logged without undoing the switch, which already applies to its own session. A deployment with no settings provider keeps the composition entry and a switch stays process-local. +`session.selectModel` records an accepted switch as the new default, which is how the default is chosen in practice: there is no separate gesture. What it stores is the RESOLVED target, so an adapter-materialized default effort is pinned as the user saw it and a later adapter-default change does not silently move stored defaults. The write replaces the section wholesale rather than merging, because switching to a model with no reasoning effort has to clear a stored one; a storage failure is logged without undoing the switch, which already applies to its own session. A deployment with no settings provider keeps the composition entry and a switch stays process-local. + +The section's `reasoningEffort` has no counterpart in the plugin config, deliberately: the seam merges the user layer over the composition entry per field, so an absent key cannot override a present one and a composition-set effort would survive every later switch to a model without one. A deployment default for effort belongs on the adapter profile, which resolves per model. The stored route is not validated against the registry, in either direction. A default naming a route the Models page has since removed still reaches `session.models` as the session's `current` — matching no advertised group, which is precisely what makes a selector prompt for a replacement instead of naming a model the deployment cannot reach. Repairing it silently would also break the deliberate converse: an adapter may serve a model its catalog does not advertise. @@ -30,7 +32,7 @@ Session titles ride the generic projection pair like every other domain — the `session.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged provider/model/reasoning target, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) owns the boundary rationale. -Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target separately from provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. The current target may be absent from the groups and is never injected as a synthetic row; clients can prompt for a replacement without turning the directory into a routing whitelist. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`. +Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target separately from provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. The current target may be absent from the groups and is never injected as a synthetic row; clients can prompt for a replacement without turning the directory into a routing whitelist. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`. `session.models` additionally reports `routable`: whether an adapter currently serves the current target's route, which is deliberately NOT derivable from the groups — a route serving a model it stopped advertising is absent from them yet perfectly usable, while a route whose adapter is gone can serve nothing. `session.prompt` refuses on that same fact with `model-unavailable` rather than spending the pre-step path to fail inside an adapter; a client that disables its composer is an affordance, and this method stays callable regardless. Pending queued input is a live control-plane contract, not conversation history. The gateway derives the complete `next-turn` queue from durable `agent/inbox/spliced` mutations and broadcasts authoritative `session/queue` snapshots after each change and on reconnect; pending `next-step` steering stays outside this Web projection. Within `next-step`, user-origin messages carry the `steering` placement while injected context (approval notices, task completion, attached snapshots) carries `context` and is not surfaced until claimed. The message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications remain available to lifecycle observers but do not build the queue view. `session.updateQueue` addresses one `MessageId`; edit and remove mutate the attached Agent through `Inbox.splice()`. A claim's pure deletion splice wins races before pre-step admission, so a later operation returns `queue-item-not-found`. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking message in FIFO order, and the browser never resends or promotes it. Queue operations never resume a cold session, and the client never infers retirement from turn or status events. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index c444ed6b74..d4df79d7d8 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -10,7 +10,9 @@ 会话按三级解析自己的路由,且每次读取都重新解析,而不是只在创建时种一次:本进程内的显式选择,其次是该会话自己最新记录的 `request/header`,最后才是这个默认值。重新解析正是让两个方向都成立的原因——已经跑过一轮的会话此后永远从自己的日志推导路由,改默认值不会重定向它;而仍然空白的会话(新建会话会复用一个,而不是再开一个)则会用上它创建之后才保存的默认值。 -`session.selectModel` 会把被接受的切换记录为新的默认值,实践中默认值就是这样选定的,没有另一个单独的手势。写入是整段替换而非合并,因为切到一个不支持推理的模型必须清掉已存的等级;存储失败只记日志,不会撤销这次切换——它对自己所在的会话已经生效。没有设置提供方的部署保留组合条目,切换只停留在进程内。 +`session.selectModel` 会把被接受的切换记录为新的默认值,实践中默认值就是这样选定的,没有另一个单独的手势。它存下来的是**解析后**的目标,因此适配器实体化出来的默认推理等级会按用户当时看到的样子钉住,日后适配器改了自己的默认值也不会悄悄移动已存的默认路由。写入是整段替换而非合并,因为切到一个不支持推理的模型必须清掉已存的等级;存储失败只记日志,不会撤销这次切换——它对自己所在的会话已经生效。没有设置提供方的部署保留组合条目,切换只停留在进程内。 + +设置段里的 `reasoningEffort` 在插件配置中刻意没有对应字段:seam 是按字段把用户层合并到组合条目之上的,缺席的键覆盖不了存在的键,因此组合层设的推理等级会在此后每一次切到不支持推理的模型时继续存活。推理等级的部署级默认值属于适配器 profile,那里是按模型解析的。 存下来的路由不做注册表校验,两个方向都不做。默认值指向一个已在模型页删除的路由时,它照样作为会话的 `current` 送到 `session.models`——匹配不到任何已公布的分组,而这恰恰是让选择器提示重新选择、而不是显示一个部署根本够不着的模型的原因。静默修复它还会破坏刻意保留的反面情形:适配器可以服务一个自己目录未公布的模型。 @@ -30,7 +32,7 @@ `session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的提供方/模型/推理(reasoning)目标及谱系,再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)给出边界设计的理由。 -会话模型路由属于会话领域契约。`session.models` 将选中的提供方/模型/推理目标,与按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录分开返回。当前目标可能不在这些分组中,也绝不会作为合成行注入;客户端可以提示用户选择替代目标,而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。 +会话模型路由属于会话领域契约。`session.models` 将选中的提供方/模型/推理目标,与按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录分开返回。当前目标可能不在这些分组中,也绝不会作为合成行注入;客户端可以提示用户选择替代目标,而无需把目录变成路由白名单。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。`session.models` 还会报告 `routable`:当前目标的路由是否有适配器在服务。这一点刻意不由分组推导——一条仍在服务、只是不再公布该模型的路由不在分组里,却完全可用;而适配器已经消失的路由什么都服务不了。`session.prompt` 依据同一个事实以 `model-unavailable` 拒绝,而不是把整条 pre-step 路径走完再在适配器内部失败;客户端禁用输入框只是提示性设计,这个方法始终可被调用。 待处理的 queued 输入属于实时控制平面契约,而非对话历史。网关根据持久 `agent/inbox/spliced` 变更派生完整的 `next-turn` 队列,并在每次变更后及重连时广播权威 `session/queue` 快照;待处理的 `next-step` steering(中途引导)不进入此 Web 投影。在 `next-step` 内,用户来源的消息携带 `steering` placement,而注入上下文(审批通知、任务完成、附加快照)携带 `context`,领取前不对外呈现。面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知仍供生命周期观察方使用,但不用于构建队列视图。`session.updateQueue` 通过 `MessageId` 寻址单个项;编辑和移除经已挂载 Agent 的 `Inbox.splice()` 修改队列。claim 的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found`。`session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index aaa2d68a44..2a54e2c113 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -74,6 +74,14 @@ import { openNativePath, openNativeTextFile } from './native-path-opener.ts' /** Page size when history is called without maxMessages. */ const DEFAULT_MAX_MESSAGES = 50 +/** + * 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') + /** Non-model settings namespaces intentionally served to the Web client. */ const WEB_SETTINGS_NAMESPACES = ['permission'] as const @@ -337,9 +345,11 @@ export interface ApiProxyDefaults { */ 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, + * Record a selection as the new default. Either absent, or a closure that + * may itself decline — the gateway plugin always passes one, and it no-ops + * when the deployment mounts no settings provider or when the write races + * service teardown. A switch then 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 @@ -1330,6 +1340,19 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } } + /** + * Whether an adapter currently serves this route, and therefore whether a + * session pointed at it can start a turn. Catalog membership cannot answer + * it: an adapter may serve a model its own catalog stopped advertising, so + * a route missing from the groups is not the same as one nothing serves. + * A composition with no llm registry at all cannot judge and says yes — + * the dispatch it would have refused fails on its own terms. + */ + function routeServed(provider: string): boolean { + const llm = ctx.get('llm') + return llm === undefined || llm.listProviders().some(entry => entry.id === provider) + } + /** Missing-service report shared by the settings domain (skills-domain stance). */ function settingsAbsent(): RpcError { return { code: 'internal', message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-local) in its composition', details: {} } @@ -1700,7 +1723,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if ('error' in found) return err(request, found.error) const current = targetFor(found.agent).current const { groups, failures } = await buildModelCatalog(ctx) - return ok(request, { current: { ...current }, groups, failures }) + const routable = routeServed(current.provider) + return ok(request, { current: { ...current }, routable, groups, failures }) }, async selectModel(request) { @@ -1868,6 +1892,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const found = await agentFor(sessionId) if ('error' in found) return err(request, found.error) const agent = found.agent + // A route no adapter serves cannot start a turn, and letting it try + // spends the whole pre-step path to fail inside the adapter with a + // message about registration. Refusing here names the model the + // session is pointed at while the draft is still in the composer. + // This is the enforcement boundary: a client that disables its input + // is an affordance, and this method stays callable regardless. + const target = targetFor(agent).current + if (!routeServed(target.provider)) { + return err(request, { + code: 'model-unavailable', + message: `no adapter serves provider "${target.provider}"; select a model for this session`, + details: { provider: target.provider, model: target.model }, + }) + } // The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation). const source: MessageSource = { kind: 'user', rpcId: request.rpcId } try { @@ -2758,8 +2796,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro queue.push(frame({ type: 'host/settings-changed', ns: name })) // A provider's own settings carry its model catalog and endpoint, // so a change there invalidates the model list even when the route - // set is untouched — `llm/adapters-updated` alone misses it. - if (modelProviderNamespaces().has(name)) queue.push(frame({ type: 'host/models-changed' })) + // set is untouched — `llm/adapters-updated` alone misses it. The + // gateway's own section is the other such source: it names the + // route every session with no logged one resolves to, so an + // externally edited default (another tab, a hand-edited + // settings.yaml) has to reach an open selector too. + if (modelProviderNamespaces().has(name) || name === String(API_GATEWAY_SETTINGS_NAMESPACE)) { + queue.push(frame({ type: 'host/models-changed' })) + } }), ctx.on('credentials/updated', (ref) => { queue.push(frame({ type: 'host/credentials-changed', ref: String(ref) })) diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 9f9c4329e6..80e64fb12a 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -225,6 +225,7 @@ export const sessionModelsRequestSchema = z.object({ /** session.models response value. */ export const sessionModelsValueSchema = z.object({ current: modelTargetSchema, + routable: z.boolean(), groups: z.array(modelProviderGroupSchema), failures: z.array(modelCatalogFailureSchema), }) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 18315eef19..2e795928ec 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -117,6 +117,15 @@ export interface ModelCatalogFailure { export interface SessionModels { /** Target selected for the session's next assembled step. */ current: ModelTarget + /** + * Whether an adapter currently serves `current.provider`, and therefore + * whether this session can start a turn at all. Deliberately NOT derivable + * from `groups`: catalog membership is advisory, so a route serving a model + * it stopped advertising is absent from the groups yet perfectly usable, + * while a route whose adapter is gone can serve nothing. A surface that + * blocks input must read this rather than the groups. + */ + routable: boolean /** Successfully loaded provider groups. */ groups: ModelProviderGroup[] /** Provider-local failures; successful groups remain usable. */ diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index cb2f88f436..1a6e0be281 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -19,16 +19,16 @@ 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 { installSettingsSection } from '@deepseek-ai/dsh-settings' import type { ApiProxy } from './api/index.ts' -import { createApiProxy } from './api-proxy.ts' +import { API_GATEWAY_SETTINGS_NAMESPACE, createApiProxy } from './api-proxy.ts' export type * from './api/index.ts' export { RpcId } from './api/rpc.ts' export { toFetchHandler } from './fetch/handler.ts' export { AbstractApiClient, InProcessApiClient } from './fetch/client.ts' export type { IApiClient } from './fetch/client.ts' -export { createApiProxy } from './api-proxy.ts' +export { API_GATEWAY_SETTINGS_NAMESPACE, createApiProxy } from './api-proxy.ts' export type { ApiProxyDefaults } from './api-proxy.ts' declare module 'cordis' { @@ -39,17 +39,9 @@ declare module 'cordis' { } /** - * 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. + * The `api-gateway` settings section: 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. */ @@ -60,29 +52,36 @@ export interface DefaultRouteSettings { reasoningEffort?: string } -/** Gateway plugin config: host-level agent routing and Workspace creation root. */ -export interface Config extends DefaultRouteSettings { +/** + * Gateway plugin config: host-level agent routing and Workspace creation root. + * + * `reasoningEffort` is deliberately absent, so the section carries one field + * the composition cannot. The seam resolves a section by MERGING the user + * layer over the composition entry per field, and an absent key cannot + * override a present one — so a composition-set effort would survive every + * later switch to a model that has none, and strand it for the next session + * to fail on. Effort is a per-model fact anyway: a deployment default belongs + * on the adapter profile (`llm-pi-ai`'s `reasoning`, `llm-deepseek`'s own), + * which resolves per model rather than per gateway. + */ +export interface Config { + /** Default provider route for created agents. */ + provider: string + /** Default model id. */ + model: string /** Parent directory for name-created Workspaces; defaults to the Host cwd. */ workspaceRoot?: string } -/** The config fields the settings section carries; the rest stay launcher-owned. */ -const DEFAULT_ROUTE_FIELDS = ['provider', 'model', 'reasoningEffort'] as const - /** - * The settings section's schema, picked out of the plugin config rather than - * restated. The config stays a plain literal because the configuration-catalog - * generator reads it statically; picking from it is what keeps the section a - * subset of it as both evolve. - * @param config - the plugin config schema to pick from. - * @returns the section schema over {@link DEFAULT_ROUTE_FIELDS}. + * Schema of the `api-gateway` section, exported because it IS that section's + * contract — the shape anything reading or writing `settings.yaml` addresses. */ -function defaultRouteSchema(config: z): z { - const fields = Object.fromEntries( - DEFAULT_ROUTE_FIELDS.map(field => [field, config.dict?.[field]]), - ) - return z.object(fields) as z -} +export const DEFAULT_ROUTE_SCHEMA: z = z.object({ + provider: z.string().required(), + model: z.string().required(), + reasoningEffort: z.string(), +}) /** Project the stored/composed section onto the agent-facing target shape. */ function routeTarget(settings: DefaultRouteSettings): AgentLlmTarget { @@ -109,7 +108,6 @@ export class ApiProxyService extends Service implements ApiProxy { static Config: z = z.object({ provider: z.string().required(), model: z.string().required(), - reasoningEffort: z.string(), workspaceRoot: z.string(), }) @@ -132,13 +130,9 @@ export class ApiProxyService extends Service implements ApiProxy { // 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 }, - } + const entry: DefaultRouteSettings = { provider: config.provider, model: config.model } let route: () => DefaultRouteSettings = () => entry - installSettingsSection(ctx, API_GATEWAY_SETTINGS_NAMESPACE, defaultRouteSchema(ApiProxyService.Config), entry, { + installSettingsSection(ctx, API_GATEWAY_SETTINGS_NAMESPACE, DEFAULT_ROUTE_SCHEMA, entry, { setSource: (current) => { route = current }, @@ -150,8 +144,10 @@ export class ApiProxyService extends Service implements ApiProxy { 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. + // for the next session to fail on. This clears it because the entry + // below the user layer carries no effort to re-inherit — the reason + // `Config` deliberately has no such field. 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) }, diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index c13a66eaec..86a77f4af2 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -22,7 +22,7 @@ import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepsee import type { HostFrame } from '../src/api/index.ts' import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts' import { RpcId } from '../src/api/rpc.ts' -import { createApiProxy } from '../src/api-proxy.ts' +import { API_GATEWAY_SETTINGS_NAMESPACE, createApiProxy } from '../src/api-proxy.ts' const DEFAULTS = { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' } @@ -398,6 +398,25 @@ describe('settings domain', () => { expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'permission' }]) }) + it('invalidates the model catalog when the gateway default route changes', async () => { + const ctx = await harness() + const route = ctx.settings.register(API_GATEWAY_SETTINGS_NAMESPACE, z.object({ + provider: z.string().required(), + model: z.string().required(), + }), { base: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }) + const api = createApiProxy(ctx, DEFAULTS) + // The gateway's own section names the route every session with no logged + // one resolves to, so an externally edited default — another tab, a + // hand-edited settings.yaml — has to reach an open selector as well. + const frames = await collectHost(api, ['host/settings-changed', 'host/models-changed'], 2, async () => { + await route.replace({ provider: 'deepseek-official', model: 'deepseek-reasoner' }) + }) + expect(frames).toEqual([ + { type: 'host/settings-changed', ns: 'api-gateway' }, + { type: 'host/models-changed' }, + ]) + }) + it('maps a stale expectedRevision to settings-conflict carrying both revisions', async () => { const ctx = await harness() ctx.settings.register(NS, AdapterConfig) diff --git a/packages/host/apiproxy/tests/api-proxy-default-route.spec.ts b/packages/host/apiproxy/tests/api-proxy-default-route.spec.ts new file mode 100644 index 0000000000..996cea5da2 --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-default-route.spec.ts @@ -0,0 +1,108 @@ +/** + * The `api-gateway` settings section over a REAL settings provider: the + * composition entry as the base layer, the wholesale replace the gateway + * persists with, and the fallback when the provider detaches. The other model + * specs drive hand-rolled `defaultTarget`/`persistDefaultTarget` closures, so + * this is the only place the layering itself is exercised. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { Settings, installSettingsSection } from '@deepseek-ai/dsh-settings' +import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' +import { API_GATEWAY_SETTINGS_NAMESPACE, DEFAULT_ROUTE_SCHEMA } from '../src/index.ts' +import type { DefaultRouteSettings } from '../src/index.ts' + +/** The smallest real provider: one in-memory document, always writable. */ +class MemorySettings extends Settings { + doc: Record = {} + + get writable(): boolean { + return true + } + + protected load(): Promise> { + return Promise.resolve(structuredClone(this.doc)) + } + + protected persist(ns: SettingsNamespace, section: Record): Promise { + this.doc = { ...this.doc, [ns]: structuredClone(section) } + return Promise.resolve() + } +} + +/** Mount the gateway's own section wiring over a live provider. */ +async function boot(entry: DefaultRouteSettings) { + const ctx = new Context() + const fiber = ctx.plugin(MemorySettings) + await fiber.await() + let route: () => DefaultRouteSettings = () => entry + const consumer = ctx.plugin(function section(child: Context) { + installSettingsSection(child, API_GATEWAY_SETTINGS_NAMESPACE, DEFAULT_ROUTE_SCHEMA, entry, { + setSource: (current) => { route = current }, + onChange: () => {}, + }) + }) + await consumer.await() + const settings = ctx.get('settings') + if (settings === undefined) throw new Error('settings provider did not mount') + return { ctx, fiber, consumer, settings, read: () => route() } +} + +describe('the api-gateway default-route section', () => { + it('resolves the composition entry until the user layer overrides it', async () => { + const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + expect(bench.read()).toEqual({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + + await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, { + provider: 'acme-gateway', model: 'acme-large', reasoningEffort: 'high', + }) + expect(bench.read()).toEqual({ + provider: 'acme-gateway', model: 'acme-large', reasoningEffort: 'high', + }) + await bench.ctx.fiber.dispose() + }) + + it('clears a stored effort when the next switch has none', async () => { + const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, { + provider: 'acme-gateway', model: 'acme-large', reasoningEffort: 'high', + }) + expect(bench.read().reasoningEffort).toBe('high') + + // The whole reason the gateway persists with `replace` rather than a merge + // patch — and the reason `Config` carries no effort for the base layer to + // re-inherit here. A stranded effort would fail the next session's first + // request against a model that does not support it. + await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, { + provider: 'acme-gateway', model: 'acme-plain', + }) + expect(bench.read()).toEqual({ provider: 'acme-gateway', model: 'acme-plain' }) + await bench.ctx.fiber.dispose() + }) + + it('layers a hand-written partial section over the entry', async () => { + const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + // Someone editing settings.yaml by hand may name only the model. The + // entry supplies the provider, which is what makes this legal — and is + // exactly why an effort in the entry could never be cleared, so there + // is none to inherit. + await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, { model: 'deepseek-reasoner' }) + expect(bench.read()).toEqual({ provider: 'deepseek-official', model: 'deepseek-reasoner' }) + await bench.ctx.fiber.dispose() + }) + + it('falls back to the composition entry when the provider detaches', async () => { + const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, { + provider: 'acme-gateway', model: 'acme-large', + }) + expect(bench.read().provider).toBe('acme-gateway') + + // A deployment that loses its settings provider keeps serving the route it + // was composed with rather than the one it can no longer read. + await bench.fiber.dispose() + expect(bench.read()).toEqual({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + await bench.ctx.fiber.dispose() + }) +}) diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index 7a9f2b2f86..818260504c 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -303,6 +303,37 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) + it('refuses a prompt no adapter can route, and reports it on the directory', async () => { + const { ctx, sessionId } = await harness() + const api = createApiProxy(ctx, { + defaultTarget: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }), + cwd: '/tmp', + workspaceRoot: '/tmp', + }) + + // The client disabling its input is an affordance; this method stays + // callable, so the refusal has to live here. + const refused = await api.sessions.prompt(request({ + sessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'hi' }], + })) + expect(refused.result).toMatchObject({ + ok: false, + error: { code: 'model-unavailable', details: { provider: 'deleted-gateway', model: 'deleted-model' } }, + }) + expect(expectValue(await api.sessions.models(request({ sessionId }))).routable).toBe(false) + + // An advisory-unlisted model on a live route is NOT this: the route + // serves it, so the prompt goes through and nothing blocks. + expectValue(await api.sessions.selectModel(request({ + sessionId, provider: 'deepseek-official', model: 'unlisted-but-served', + }))) + const catalog = expectValue(await api.sessions.models(request({ sessionId }))) + expect(catalog.routable).toBe(true) + expect(catalog.groups.flatMap(group => group.models.map(model => model.id))) + .not.toContain('unlisted-but-served') + 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, { diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 490e0ad7f1..ebd56ee551 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -45,6 +45,7 @@ function scriptedApi(overrides: { }), models: r => ok(r, { current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + routable: true, groups: [], failures: [], }), diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index bcccfdd52e..22e1650f5b 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -64,6 +64,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra ok: true, value: { current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + routable: true, groups: [], failures: [], }, diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 040fe56ff5..28f9138502 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -197,6 +197,7 @@ describe('sessions domain schemas', () => { expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') expect(sessionModelsValueSchema.parse({ current: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'max' }, + routable: true, groups: [{ id: 'deepseek-official', name: 'DeepSeek', @@ -274,8 +275,10 @@ 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', defaultTarget: () => ({ provider: 'p', model: 'm' }), attachedSessions: 2 }) - expect(value.attachedSessions).toBe(2) + const value = hostDescribeValueSchema.parse({ + version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2, + }) + expect(value).toMatchObject({ provider: 'p', model: 'm', attachedSessions: 2 }) expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0 }).provider).toBeUndefined() }) From 5a90eb41fb1bc83417dc6de1573507b8b497dc25 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 16:45:50 +0800 Subject: [PATCH 06/10] fix(ui-models): three faults the running app surfaced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **A hand-declared route must not offer a reasoning effort.** The earlier commit read the create card's missing control as drift and added one. It is the other way round: such a model has no reasoning capability — pi-ai's installed catalog is what supplies one, and it ships nothing under the route — so `resolveModel` throws UNSUPPORTED_REASONING_EFFORT for every model on it and the whole provider drops out of the picker. Verified against the adapter, not inferred. The create card no longer offers it and the editor withholds it on the directory's `declared` bit, which is the real bug: that control has always been wrong for these routes. **A blocked composer locked the way out of the block.** Reusing the no-workspace inert posture disabled the model seat along with everything else, so the bar asked for a model while preventing the one control that picks one. A block now rides its own `blocked` owner prop: the textarea, send, commands, plan seat, and access chip all lock, and the model seat alone stays live. **A Provider ID could derive an illegal credential reference.** The card accepted a digit-leading id, whose derived `123_API_KEY` then failed at the credential seam with a raw regular expression the user cannot act on. The id must now start with a letter, and a test pins the relation between the two rules rather than the regex. --- ...default-model-follows-the-picker.i18n.yaml | 4 +- ...-08-07-default-model-follows-the-picker.md | 2 +- ...-07-default-model-follows-the-picker.zh.md | 2 +- apps/web/tests/default-model.e2e.ts | 9 ++ apps/web/tests/models-settings.e2e.ts | 11 ++- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/contract/slots.ts | 8 ++ .../src/client/skeleton/ConversationRoot.tsx | 5 +- .../src/client/skeleton/InputBar.tsx | 12 ++- .../ui-conversation/tests/skeleton.spec.tsx | 21 ++++- packages/client/ui-models/README.i18n.yaml | 4 +- packages/client/ui-models/README.md | 2 +- packages/client/ui-models/README.zh.md | 2 +- .../src/client/CustomProviderCard.tsx | 33 ++++---- .../ui-models/src/client/ModelsSection.tsx | 4 + .../ui-models/src/client/ProviderEditor.tsx | 35 ++++++-- .../src/client/ReasoningEffortField.tsx | 17 ++-- .../client/ui-models/src/client/locales.ts | 8 +- .../ui-models/tests/provider-form.spec.tsx | 83 ++++++++++++------- 21 files changed, 179 insertions(+), 91 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml index 40eb59e98f..f513e90666 100644 --- a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md -2026-08-07-default-model-follows-the-picker.md: 4142b3aea6a807001831df62c2038ddf57bbd6ad -2026-08-07-default-model-follows-the-picker.zh.md: c3566567781edac12cd9269d63f86528139c8796 +2026-08-07-default-model-follows-the-picker.md: d20f0ab8b8c8bd19f596e6ef73f0a58d96c24d38 +2026-08-07-default-model-follows-the-picker.zh.md: 0d2821cb63407fe766e6fe3d36de31d9fc6f1c13 diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md index 4142b3aea6..d20f0ab8b8 100644 --- a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md @@ -30,7 +30,7 @@ A default naming a route the Models page has since removed leaves the composer s The Host refuses. `session.prompt` checks whether an adapter serves the session's route and answers `model-unavailable` before opening a turn. This is the enforcement boundary: a client that disables its composer is an affordance, and the method stays callable regardless. -The composer goes inert. `session.models` reports `routable`, and ui-model pushes a block through the new `ctx.conversation.blocks` registry; the bar renders the same disabled textarea it already renders without a workspace, with the blocker's own localized reason as the placeholder. The push direction is forced — ui-model already depends on ui-conversation, so ui-conversation cannot read it back. +The composer goes inert. `session.models` reports `routable`, and ui-model pushes a block through the new `ctx.conversation.blocks` registry; the bar renders the same disabled textarea it already renders without a workspace, with the blocker's own localized reason as the placeholder — except the model seat, which a block deliberately leaves live, because choosing a model is how the user clears it. The push direction is forced — ui-model already depends on ui-conversation, so ui-conversation cannot read it back. The gate is `routable`, NOT "the current target matches no advertised group". Catalog membership is advisory by design: a route serving a model it stopped advertising is absent from the groups yet perfectly usable, and blocking there would break a supported configuration (a narrowed `models` list over a live route). `routable` is also three-valued on the client — `null` before the first load or after a failed one never blocks, so a slow or unreachable Host cannot lock a working composer. diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md index c356656778..0d2821cb63 100644 --- a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md @@ -30,7 +30,7 @@ Status: implemented 宿主拒绝。`session.prompt` 检查是否有适配器服务该会话的路由,在开启轮次之前就以 `model-unavailable` 应答。这是执行边界:客户端禁用编辑器只是提示性设计,这个方法始终可被调用。 -编辑器变惰性。`session.models` 报告 `routable`,ui-model 经新的 `ctx.conversation.blocks` 注册表推送一个 block;输入栏渲染的仍是它在没有 Workspace 时就会渲染的那个禁用 textarea,只是把抬起方自己的本地化理由作为 placeholder。推送方向是被迫的——ui-model 本就依赖 ui-conversation,因此 ui-conversation 读不回去。 +编辑器变惰性。`session.models` 报告 `routable`,ui-model 经新的 `ctx.conversation.blocks` 注册表推送一个 block;输入栏渲染的仍是它在没有 Workspace 时就会渲染的那个禁用 textarea,只是把抬起方自己的本地化理由作为 placeholder——唯独模型 seat 被 block 刻意保留可用,因为用户正是靠选模型来解除它。推送方向是被迫的——ui-model 本就依赖 ui-conversation,因此 ui-conversation 读不回去。 闸门是 `routable`,**不是**「当前目标匹配不到任何已公布分组」。目录成员关系按设计是咨询性的:一条仍在服务、只是不再公布该模型的路由不在分组里,却完全可用,在那里阻断会破坏一种受支持的配置(对一条活着的路由收窄 `models` 列表)。`routable` 在客户端还是三值的——首次加载之前或加载失败之后的 `null` 绝不阻断,因此慢的或够不着的宿主锁不死一个本来能用的编辑器。 diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index 9ff6198c3a..24ee5a1616 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -153,6 +153,15 @@ describe('web e2e: the composer model switch is the default for later sessions', }, }) expect(refused.result).toMatchObject({ ok: false, error: { code: 'model-unavailable' } }) + + // The way out stays open. Locking the model seat with everything else + // would leave the composer asking for the one thing it prevents. + const seat = page.getByRole('button', { name: /^选择模型/ }) + expect(await seat.isEnabled()).toBe(true) + await seat.click() + await page.getByRole('menuitem', { name: /模型/ }).click() + await page.getByRole('menuitemradio').first().click() + await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(true) expect(tripwire.pageErrors).toEqual([]) }, 60_000) }) diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 49b177c66d..6ebdbc1d3a 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -175,7 +175,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('declares a route the adapter does not ship, with its own reasoning effort', async () => { + it('declares a route the adapter does not ship, without a reasoning control', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-declare')) const dialog = page.getByRole('dialog', { name: '设置' }) const declare = dialog.getByRole('button', { name: '添加自定义提供方' }) @@ -184,10 +184,10 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await dialog.getByLabel('Provider ID').fill('acme-gateway') await dialog.getByLabel('显示名称').fill('Acme Gateway') await dialog.getByLabel('API 地址').fill('https://gateway.acme.example/v1') - // The create card offers the same provider-level effort the editor card - // does for this namespace; a route declared without it would gain the - // control only on reopening. - await dialog.getByLabel('推理强度').selectOption('high') + // No reasoning effort anywhere for a hand-declared route: its models carry + // no reasoning capability, so a profile effort would make every model on + // the route fail to resolve and drop the provider out of the picker. + expect(await dialog.getByLabel('推理强度').count()).toBe(0) await dialog.getByRole('button', { name: '添加模型' }).click() await dialog.getByLabel('模型 ID 1').fill('acme-large') await dialog.getByRole('button', { name: '创建提供方', exact: true }).click() @@ -196,7 +196,6 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await row.waitFor({ timeout: 10_000 }) const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') expect(document).toContain('acme-gateway:') - expect(document).toContain('reasoning: high') // The tag follows the adapter's installed catalog: this route is in no // catalog, while minimax-cn is — even though both now have profiles. diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 3af7b2fd75..40a885262e 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 392f9956b33df88a5e9664a58de27d85fc0457d1 -README.zh.md: 6b0429a302475f84a7ce9b1cdc9fd47d90d6dba3 +README.md: ee8a4d240cdc326d158749ae8935ec99bb420d9f +README.zh.md: 64ac1d15e20a8b60a39a8beb9ae7695543250026 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 392f9956b3..ee8a4d240c 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -8,7 +8,7 @@ Compaction renders as one collapsed row at the checkpoint's flow position withou The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. -Another plugin can make one session's composer inert through `ctx.conversation.blocks`: it sets a block carrying its own localized reason, and the bar renders the same disabled textarea with that reason as the placeholder — the no-workspace posture, reused. The push direction is the constraint, not a preference: the plugins that know a session cannot send (ui-model, when no adapter serves its route) already depend on this package, so this package cannot read them. A block is an affordance only; the Host refuses a prompt it cannot route regardless of what any client disables. The no-workspace state wins when both hold, because picking a workspace is the earlier prerequisite. +Another plugin can make one session's composer inert through `ctx.conversation.blocks`: it sets a block carrying its own localized reason, and the bar renders the same disabled textarea with that reason as the placeholder — the no-workspace posture, reused. The push direction is the constraint, not a preference: the plugins that know a session cannot send (ui-model, when no adapter serves its route) already depend on this package, so this package cannot read them. The model seat is the one control a block leaves live — every block this contract has is cleared by choosing a model, so locking it too would leave the composer asking for the only thing it prevents. A block is an affordance only; the Host refuses a prompt it cannot route regardless of what any client disables. The no-workspace state wins when both hold, because picking a workspace is the earlier prerequisite. The view ring is a slot: the strict session-body registration declares the session-scoped `'conversation.view'` list in its `children` table, that body renders the active entry through its renderSlot share (`only: `), and view tabs project from registration options (`id`/`order`/`label`). The chat view is this package's own entry; plugins such as ui-trajectory contribute tabs through `ctx.slots.register`, and each view owns its chrome. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 6b0429a302..64ac1d15e2 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -8,7 +8,7 @@ 常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace 选择器、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 -别的插件可以经 `ctx.conversation.blocks` 让某个会话的编辑器变为惰性:它设置一个携带自己本地化理由的 block,输入栏就渲染同一个禁用的 textarea,并把该理由作为 placeholder——复用无 Workspace 时的那套姿态。推送方向是约束而非偏好:知道某会话发不出消息的插件(ui-model,在没有适配器服务其路由时)本就依赖本包,因此本包读不到它们。block 只是提示性设计;无论客户端禁用了什么,宿主都会拒绝一个它路由不了的 prompt。两者同时成立时以无 Workspace 姿态为准,因为选 Workspace 是更靠前的前提。 +别的插件可以经 `ctx.conversation.blocks` 让某个会话的编辑器变为惰性:它设置一个携带自己本地化理由的 block,输入栏就渲染同一个禁用的 textarea,并把该理由作为 placeholder——复用无 Workspace 时的那套姿态。推送方向是约束而非偏好:知道某会话发不出消息的插件(ui-model,在没有适配器服务其路由时)本就依赖本包,因此本包读不到它们。模型 seat 是 block 唯一保留可用的控件——这份契约里的每个 block 都靠选模型来解除,把它一起锁上会让编辑器索要它自己拦下的那件事。block 只是提示性设计;无论客户端禁用了什么,宿主都会拒绝一个它路由不了的 prompt。两者同时成立时以无 Workspace 姿态为准,因为选 Workspace 是更靠前的前提。 视图环是一个 slot:严格会话主体注册在 `children` 表中声明 Session scope 的 `'conversation.view'` 列表,并通过自身的 renderSlot share 渲染活跃配置项(`only: `);视图标签页则从注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的配置项;ui-trajectory 等插件通过 `ctx.slots.register` 贡献标签页,每个视图负责自己的 chrome。 diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 4c3a1546c9..84fb39cec8 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -265,6 +265,14 @@ export interface ConversationSessionHeaderInjected { export interface ComposerBarOwnerProps { /** Hero = empty-state centered card; composer = resident bottom bar. */ variant: 'hero' | 'composer' + /** + * A block another plugin raised for this session: the bar refuses input and + * shows the blocker's reason as the placeholder, but — unlike `disabled` — + * keeps the model seat live. Every block this contract has is one the user + * clears by choosing a model, so locking that seat too would leave the + * composer telling them to do the one thing it prevents. + */ + blocked?: { readonly reason: string } /** * Inert no-workspace state: the bar renders its normal DOM fully disabled * (textarea, add, send) so the workspace pick transitions in place instead diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 377b4de3ec..8440dacd94 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -138,7 +138,10 @@ export function ConversationRoot({ ...(inert ? { disabled: true, placeholder: t('placeholder.workspace') } : blocked - ? { disabled: true, placeholder: composerBlock.reason } + // `blocked`, not `disabled`: the bar refuses input either way, but a + // block keeps the model seat live because choosing a model is how the + // user clears it. + ? { blocked: composerBlock, placeholder: composerBlock.reason } : hero ? { placeholder: t('placeholder.hero') } : {}), overlay: renderSlot('conversation.input.overlay', {}), leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone), diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 131f63c49d..7b24c09684 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -37,7 +37,8 @@ export type InputBarProps = ComposerBarProps export function InputBar({ useSession, useInput, inputActions, keyboard, resolveSubmitMode, toggleCommandMenu, stop, command, t, renderSlot, useNotices, useLexicon, useMenuLauncher, - useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer, + useProjection, sessionId, variant, disabled: inert = false, blocked, placeholder, + accessory, overlay, leftItems, rightItems, footer, }: InputBarProps) { const input = useInput(s => s) const notice = useNotices(s => s) @@ -86,8 +87,13 @@ export function InputBar({ // inert no-workspace state, or the machine faces absent (no session). The // transient machine locks (adjudicating pending / submitting) render // read-only — the draft stays visible and focused, keystrokes drop. - const disabled = removed || inert || !live + const disabled = removed || inert || !live || blocked !== undefined const locked = disabled + // The model seat is the ONE control a block leaves live: every block this + // contract has is cleared by choosing a model, so locking it too would leave + // the composer asking for the only thing it prevents. The other reasons to + // be disabled do lock it — there is no session to choose a model for. + const modelSeatLocked = removed || inert || !live const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting' // Scroll the draft scrollport the minimum that brings `caret` into view — the @@ -512,7 +518,7 @@ export function InputBar({
    {rightItems} - {renderSlot('conversation.input.model', { locked })} + {renderSlot('conversation.input.model', { locked: modelSeatLocked })} {/* {machineBusy && } */} diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index e0872f1539..bcd8f25e73 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -120,9 +120,14 @@ function mount( const stop = vi.fn() const open = vi.fn() const slotCalls: string[] = [] + /** Owner share handed to the two composer tool-row seats, per render. */ + const seatOwners: { key: string; owner: unknown }[] = [] let pickerOwner: unknown const renderSlot = ((key: string, owner: object, opts?: { only?: string }) => { slotCalls.push(key) + if (key === 'conversation.input.model' || key === 'conversation.input.plan') { + seatOwners.push({ key, owner }) + } if (key === 'conversation.hero.workspace') { pickerOwner = owner; return null } if (key === 'conversation.session.header') { return ( @@ -200,7 +205,12 @@ function mount( stop={stop} command={() => Promise.resolve(true)} t={t} - renderSlot={(() => null) as InputBarProps['renderSlot']} + renderSlot={((key: string, seatOwner: object) => { + // The bar's own seats: recorded so a case can assert what share + // each tool-row control received. + seatOwners.push({ key, owner: seatOwner }) + return null + }) as InputBarProps['renderSlot']} {...bar} /> ) @@ -236,7 +246,7 @@ function mount( } const view = render() return { - view, chat, sink, retargetWorkspace, session, slotCalls, open, + view, chat, sink, retargetWorkspace, session, slotCalls, seatOwners, open, pickerOwner: () => pickerOwner, rerender: () => { view.rerender() }, } @@ -262,6 +272,13 @@ describe('ConversationRoot resident composer', () => { expect(box.placeholder).toBe('select a model first') fireEvent.keyDown(box, { key: 'Enter' }) expect(b.sink).not.toHaveBeenCalled() + + // The model seat stays live. Locking it too would leave the composer + // asking for the one thing it prevents — every block this contract has is + // cleared by choosing a model. + const seat = (key: string) => b.seatOwners.filter(call => call.key === key).at(-1)?.owner + expect(seat('conversation.input.model')).toEqual({ locked: false }) + expect(seat('conversation.input.plan')).toEqual({ locked: true }) }) it('lets the no-workspace posture win over a block', () => { diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index 0dbea8d48c..e2b9e45f9d 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-models/README.md -README.md: 1d9c98dfd1e0cec0fa4cb33df9ffe2640be8be05 -README.zh.md: d3437c6f13be49ea73d6b3a51bee664b32521e01 +README.md: dec43de43899ef99e74b1fd73ffb4bf3c4e97b3e +README.zh.md: c17eb611f071c4054d12d36acb2de8a94fa95a20 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index 1d9c98dfd1..dec43de438 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -16,7 +16,7 @@ A pi-ai profile's `models` list is edited on the card: one row per model showing **Fetch available models** asks `llm.discoverModels` about the endpoint the form **currently shows**, including a base URL edited but not yet saved and a key typed but not yet stored, so adding a provider is one pass instead of save-then-return. The reply opens a picker rather than being written: candidates already configured start unchecked, so adopting a selection never overwrites a capacity the user corrected. A provider that cannot be interrogated is a detour, not a dead end — the adapter's own message appears beside the rows, which stay editable by hand. -**Add a custom provider** declares a route pi-ai does not ship. It is its own card rather than the editor with extra fields, because the route id is being chosen here and the settings address does not exist until it is: one `settings.mutate` sets the whole profile at `providers.`, and the key travels separately through `credentials.set` under the same `_API_KEY` derivation an existing provider uses. What a hand-declared route cannot default gates the create button — a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model — so the failure names the field while the user is still looking at it. Capacities do not gate it: the adapter's fallbacks size a model the endpoint described by id alone, which is what most listings return. The protocol choices are read out of the namespace's own schema rather than a wire field or a constant, so they cannot drift from the ones the adapter accepts. The card records the conventional `apiKeyEnv` reference only when a key is typed, the same rule the editor applies, so a route declared for provider-native authentication is not born pointing at a reference nothing will ever set. When the profile write lands but the key write fails, the provider already exists: the card settles the fields describing it, retries the credential alone — re-running the profile write would carry the revision that write just superseded, so the Host would answer `settings-conflict` and the key could never be stored from here — and reports the created provider even if the user then cancels. The card offers the same provider-level reasoning effort the editor card does for this namespace, from one shared control: both write the same profile field, so a route declared without it would have gained the setting only on being reopened. +**Add a custom provider** declares a route pi-ai does not ship. It is its own card rather than the editor with extra fields, because the route id is being chosen here and the settings address does not exist until it is: one `settings.mutate` sets the whole profile at `providers.`, and the key travels separately through `credentials.set` under the same `_API_KEY` derivation an existing provider uses. What a hand-declared route cannot default gates the create button — a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model — so the failure names the field while the user is still looking at it. The id must start with a lowercase letter, because it is also the stem of the derived credential reference and a reference is a POSIX shell identifier: a digit-leading id otherwise passes every check this card makes and then fails at the credential seam with a raw regular expression. Capacities do not gate it: the adapter's fallbacks size a model the endpoint described by id alone, which is what most listings return. The protocol choices are read out of the namespace's own schema rather than a wire field or a constant, so they cannot drift from the ones the adapter accepts. The card records the conventional `apiKeyEnv` reference only when a key is typed, the same rule the editor applies, so a route declared for provider-native authentication is not born pointing at a reference nothing will ever set. When the profile write lands but the key write fails, the provider already exists: the card settles the fields describing it, retries the credential alone — re-running the profile write would carry the revision that write just superseded, so the Host would answer `settings-conflict` and the key could never be stored from here — and reports the created provider even if the user then cancels. Neither this card nor the editor offers a reasoning effort for such a route: a hand-declared model carries no reasoning capability — pi-ai's installed catalog is what supplies one, and it ships nothing under this route — so a profile effort makes `resolveModel` throw for every model on the route and drops the whole provider out of the picker. The editor withholds the control on the directory's `declared` bit for exactly that reason; a route the adapter ships keeps it. ## Model Experience diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index d3437c6f13..c17eb611f0 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -16,7 +16,7 @@ pi-ai profile 的 `models` 列表就在卡片上编辑:一行一个模型, **获取可用模型**会针对表单**当前显示**的端点调用 `llm.discoverModels`,包括已修改但尚未保存的 API 地址和已键入但尚未存储的密钥,因此新增一个提供方是一趟走完,而不是「先保存再回来」。回复会打开一个选择框而不是直接写入:已配置过的候选默认不勾选,因此采纳一次选择绝不会覆盖用户已更正的容量。无法被询问的提供方只是绕路而非死路——适配器自己的消息会显示在各行旁边,而这些行仍可手工编辑。 -**添加自定义提供方**用来声明 pi-ai 未提供的路由。它是独立的一张卡片而非在编辑器上加字段,因为路由 id 正是在这里被*选定*的,而在选定之前 settings 地址并不存在:一次 `settings.mutate` 在 `providers.` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个由唯一标识的模型——因此失败会在用户仍看着该字段时点名它。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema,而非某个协议字段或常量,因此它们不会与适配器实际接受的集合发生漂移。只有键入了密钥,这张卡片才记录约定的 `apiKeyEnv` 引用,与编辑器同一条规则,因此一条为提供方原生认证声明的路由不会一出生就指向一个永远不会被设置的引用。当 profile 写入成功而密钥写入失败时,提供方其实已经存在:卡片会把描述它的字段定住,只重试凭据——再跑一次 profile 写入会带着刚被自己这次写入取代的 revision,宿主将以 `settings-conflict` 应答,密钥就再也无法从这里存下——并且即使用户随后取消,也照实报告提供方已创建。这张卡片提供与编辑器卡片在该 namespace 下相同的提供方级推理等级,两者共用同一个控件:它们写的是同一个 profile 字段,若声明时没有它,这个设置就会等到重新打开编辑时才凭空出现。 +**添加自定义提供方**用来声明 pi-ai 未提供的路由。它是独立的一张卡片而非在编辑器上加字段,因为路由 id 正是在这里被*选定*的,而在选定之前 settings 地址并不存在:一次 `settings.mutate` 在 `providers.` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个由唯一标识的模型——因此失败会在用户仍看着该字段时点名它。该 id 必须以小写字母开头,因为它同时是派生凭据引用的词干,而引用是 POSIX shell 标识符:数字开头的 id 否则会通过这张卡片的每一项检查,然后在凭据 seam 上以一条用户无从下手的原始正则失败。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema,而非某个协议字段或常量,因此它们不会与适配器实际接受的集合发生漂移。只有键入了密钥,这张卡片才记录约定的 `apiKeyEnv` 引用,与编辑器同一条规则,因此一条为提供方原生认证声明的路由不会一出生就指向一个永远不会被设置的引用。当 profile 写入成功而密钥写入失败时,提供方其实已经存在:卡片会把描述它的字段定住,只重试凭据——再跑一次 profile 写入会带着刚被自己这次写入取代的 revision,宿主将以 `settings-conflict` 应答,密钥就再也无法从这里存下——并且即使用户随后取消,也照实报告提供方已创建。这类路由在两张卡片上都不提供推理等级:手工声明的模型没有推理能力——能力来自 pi-ai 的已安装 catalog,而它在这条路由下什么都没有——因此 profile 级等级会让该路由上每个模型的 `resolveModel` 抛错,整个提供方从选择器里消失。编辑器正是依据目录的 `declared` 位收起这个控件;适配器自带的路由则保留它。 ## 模型体验 diff --git a/packages/client/ui-models/src/client/CustomProviderCard.tsx b/packages/client/ui-models/src/client/CustomProviderCard.tsx index 14511864ad..f8dd6ca3f8 100644 --- a/packages/client/ui-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-models/src/client/CustomProviderCard.tsx @@ -13,6 +13,14 @@ * The three fields a hand-declared route cannot default — endpoint, protocol, * and at least one model — are required here rather than at load, so the * failure names the field while the user is still looking at it. + * + * There is deliberately no reasoning-effort control. A hand-declared model + * carries no reasoning capability — pi-ai's installed catalog is what supplies + * one, and it has nothing under this route — so a profile effort here makes + * `resolveModel` throw UNSUPPORTED_REASONING_EFFORT for every model on the + * route, which drops the whole provider out of the model picker. The editor + * card hides the control for the same reason once the directory reports the + * route as declared. */ import { useState } from 'react' @@ -23,7 +31,6 @@ import { EditorFooter } from './EditorFooter.tsx' import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx' import { ModelListEditor } from './ModelListEditor.tsx' import type { ModelDraft } from './ModelListEditor.tsx' -import { EFFORT_FIELD, ReasoningEffortField } from './ReasoningEffortField.tsx' import { deriveKeyRef, messageOf } from './store.ts' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -31,8 +38,15 @@ import styles from './ModelsSection.module.css' /** The settings namespace a hand-declared provider is written into. */ const NS = 'llm-pi-ai' -/** A route id usable as a settings key and as the stem of a credential name. */ -const ROUTE_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ +/** + * A route id usable as a settings key AND as the stem of a credential name. + * The leading letter is the second half of that: `deriveKeyRef` uppercases the + * id and replaces every non-alphanumeric run with `_`, and a credential + * reference is a POSIX shell identifier, which cannot start with a digit. A + * digit-leading id passes every check this card makes and then fails at the + * credential seam with a raw regular expression the user cannot act on. + */ +const ROUTE_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/ /** Props of {@link CustomProviderCard}. */ export interface CustomProviderCardProps { @@ -71,7 +85,6 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { const [baseURL, setBaseURL] = useState('') const [protocol, setProtocol] = useState(protocols[0] ?? '') const [keyDraft, setKeyDraft] = useState('') - const [effort, setEffort] = useState(undefined) const [models, setModels] = useState([]) const [busy, setBusy] = useState(false) const [failure, setFailure] = useState(undefined) @@ -128,9 +141,6 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { ...storesKey ? { apiKeyEnv: keyRef } : {}, api: protocol, baseURL, - // Inherit is the field being absent, not an empty string: the schema - // types it as an effort name, and an empty one would fail the write. - ...effort === undefined ? {} : { [EFFORT_FIELD['pi-ai']]: effort }, models: models.map(model => ({ ...model })), } const response = await api.settings.mutate({ @@ -251,15 +261,6 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { ? null :

    {t(keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure)}

    }
    - {/* The same control the editor card shows for this namespace: a route - declared here and edited there must offer the same profile. */} - ) @@ -137,6 +140,7 @@ function targetOf(row: ProviderRow): EditorTarget { settingsNs: row.entry.settingsNs, settingsPath: row.entry.settingsPath, ...credentialRef === undefined ? {} : { credentialRef }, + ...row.entry.declared === undefined ? {} : { declared: row.entry.declared }, } } diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index 9b86db062a..3e6b26658f 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -7,8 +7,10 @@ * a key is entered; a blank key materializes a reference-free profile for * provider-native authentication); * the collapsed 自定义设置 area carries the per-family extras (`baseURL` for - * both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, and - * DeepSeek's id/name/context-window model catalog). Everything else stays + * both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai — + * withheld for a hand-declared route, whose models have no reasoning + * capability to configure — and DeepSeek's id/name/context-window model + * catalog). Everything else stays * owned by `settings.yaml`. Profile edits land as minimal `settings.mutate` * path ops against the stored section — the card reads the redacted * descriptor, so it names only the fields it can see and a stored literal @@ -55,6 +57,13 @@ export interface ProviderEditorProps { api: Pick /** Section copy. */ t: (key: keyof typeof en) => string + /** + * Whether the owning adapter knows this route only because configuration + * declared it. Such a route's models carry no reasoning capability, so the + * effort control is withheld; absent means the adapter draws no such + * distinction and the control shows. + */ + declared?: boolean /** Disable writes (read-only settings provider). */ readOnly: boolean /** Close the editor; `changed` reports whether an Apply committed. */ @@ -352,13 +361,21 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { }} /> - { setField(effortField, effort) }} - t={t} - disabled={disabled} - /> + {/* A hand-declared route's models carry no reasoning capability + (pi-ai's installed catalog is what supplies one, and it has + nothing under such a route), so a profile effort would make + `resolveModel` throw for every model on it and drop the whole + provider out of the picker. Offering the control at all would + be offering a way to break the route. */} + {props.declared === true ? null : ( + { setField(effortField, effort) }} + t={t} + disabled={disabled} + /> + )} {/* Both families edit the same rows through the same contract; only the extras differ — DeepSeek's inherited capacities, pi-ai's endpoint interrogation. */} diff --git a/packages/client/ui-models/src/client/ReasoningEffortField.tsx b/packages/client/ui-models/src/client/ReasoningEffortField.tsx index 10b696a4ea..a129637135 100644 --- a/packages/client/ui-models/src/client/ReasoningEffortField.tsx +++ b/packages/client/ui-models/src/client/ReasoningEffortField.tsx @@ -1,13 +1,14 @@ /** - * The provider-level reasoning-effort select, shared by every card that writes - * a provider profile. It lives here rather than inside one card because both - * write the SAME field of the same profile: a route declared without this - * control and then edited with it would offer a setting the creating user was - * never given, which is exactly the drift that put it here. + * The provider-level reasoning-effort select: the profile's own default + * effort, applied to every model on the route unless a request names one. The + * empty option means "inherit", which on the wire is the field being absent + * rather than an empty string. * - * The value is the profile's own default effort, applied to every model on the - * route unless a request names one; the empty option means "inherit", which on - * the wire is the field being absent rather than an empty string. + * It carries the per-family vocabulary and field name so the editor's two + * layouts cannot spell them differently. Only routes the adapter ships get + * this control at all — a hand-declared model has no reasoning capability to + * configure, and a profile effort over one makes its whole route fail to + * resolve — so the create card renders nothing here by construction. */ import type { ReactNode } from 'react' diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index 67e48c3890..9809c61283 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -77,8 +77,8 @@ export const en = { customTitle: 'Custom provider', customTag: 'Custom', customRoute: 'Provider ID', - customRouteHint: 'Lowercase identifier that uniquely names this provider in requests and as its credential name.', - customRouteInvalid: 'Use lowercase letters, digits, and dashes.', + customRouteHint: 'Lowercase identifier, starting with a letter, that uniquely names this provider in requests and as its credential name.', + customRouteInvalid: 'Start with a lowercase letter; then lowercase letters, digits, and dashes.', customRouteTaken: 'A provider already uses this ID.', customDisplayName: 'Display name', customApi: 'API protocol', @@ -172,8 +172,8 @@ export const zh: typeof en = { customTitle: '自定义提供方', customTag: '自定义', customRoute: 'Provider ID', - customRouteHint: '小写标识,在请求中唯一标识该提供方,并用于派生凭据名。', - customRouteInvalid: '只能使用小写字母、数字和短横线。', + customRouteHint: '以小写字母开头的标识,在请求中唯一标识该提供方,并用于派生凭据名。', + customRouteInvalid: '需以小写字母开头,之后可用小写字母、数字和短横线。', customRouteTaken: '已有提供方使用了这个 ID。', customDisplayName: '显示名称', customApi: 'API 协议', diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 95bfda4bb0..831353bc5c 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -9,7 +9,7 @@ import { ModelsSection } from '../src/client/ModelsSection.tsx' import type { ModelsSectionInjected } from '../src/client/ModelsSection.tsx' import { CustomProviderCard } from '../src/client/CustomProviderCard.tsx' import { formatCapacity, parseCapacity } from '../src/client/DeepSeekModelsEditor.tsx' -import { ModelsSettingsStore, protocolChoices } from '../src/client/store.ts' +import { ModelsSettingsStore, deriveKeyRef, protocolChoices } from '../src/client/store.ts' import { en } from '../src/client/locales.ts' afterEach(cleanup) @@ -705,38 +705,35 @@ describe('hand-declared providers', () => { expect(set).toHaveBeenCalledWith({ ref: 'ACME_GATEWAY_API_KEY', value: 'gw-key' }) }) - it('offers the same reasoning effort the editor does, and omits it when inherited', async () => { - const { mutate, onClose } = mountCard() - const declare = (): void => { - fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) - fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) - fireEvent.click(screen.getByRole('button', { name: en.addModel })) - fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } }) - } - declare() + it('offers no reasoning effort at all, in either card, for a hand-declared route', async () => { + mountCard() + fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) + // A hand-declared model carries no reasoning capability — pi-ai's + // installed catalog is what supplies one, and it ships nothing under this + // route — so a profile effort makes `resolveModel` throw + // UNSUPPORTED_REASONING_EFFORT for every model on it and drops the whole + // provider out of the picker. Offering the control would be offering a way + // to break the route. + expect(screen.queryByLabelText(en.effort)).toBeNull() + cleanup() - // The vocabulary is the namespace's, not DeepSeek's — a route declared - // here is edited by the pi-ai layout, which offers exactly these. - const select = screen.getByLabelText(en.effort) as HTMLSelectElement + // The editor card withholds it for the same route for the same reason... + await mountSection({ + providers: { 'acme-gateway': { apiKeyEnv: 'ACME_GATEWAY_API_KEY', baseURL: 'https://acme.test/v1' } }, + declaredRoutes: ['acme-gateway'], + }) + openEditor('acme-gateway') + expect(screen.queryByLabelText(en.effort)).toBeNull() + cleanup() + + // ...and keeps it for a route the adapter actually ships, whose models do + // carry the capability. + await mountSection({ providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } }) + openEditor('openai') + const select = screen.getByLabelText(en.effort) expect([...select.options].map(option => option.value)) .toEqual(['', 'off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']) - - fireEvent.change(select, { target: { value: 'high' } }) - fireEvent.click(screen.getByText(en.create)) - await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) }) - expect(firstMutate(mutate).ops[0]).toMatchObject({ - path: ['providers', 'acme'], - value: { reasoning: 'high' }, - }) - - // Inherit is the field being absent: an empty string would fail the schema - // that types this as an effort name. - cleanup() - const second = mountCard() - declare() - fireEvent.click(screen.getByText(en.create)) - await waitFor(() => { expect(second.onClose).toHaveBeenCalledWith(true) }) - expect(firstMutate(second.mutate).ops[0]).not.toHaveProperty('value.reasoning') }) it('retries only the key after the profile landed, and reports the provider on cancel', async () => { @@ -793,6 +790,32 @@ describe('hand-declared providers', () => { expect(onClose).toHaveBeenCalledWith(true) }) + it('refuses a route id whose derived credential reference would be illegal', () => { + mountCard() + const routeField = screen.getByLabelText(en.customRoute) + fireEvent.change(routeField, { target: { value: 'https://acme.test/v1' } }) + + // A digit-leading id used to pass every check this card makes and then + // fail at the credential seam with a raw regular expression: the + // reference derives as `123_API_KEY`, and a credential reference is a + // POSIX shell identifier, which cannot start with a digit. + fireEvent.change(routeField, { target: { value: '123' } }) + expect(screen.getByText(en.customRouteInvalid)).toBeTruthy() + expect(buttonNamed(en.create).disabled).toBe(true) + + fireEvent.change(routeField, { target: { value: 'a1' } }) + expect(screen.queryByText(en.customRouteInvalid)).toBeNull() + }) + + it('derives a reference the credential seam accepts for every id it admits', () => { + // The two rules have to stay in step; this is the relation, checked + // directly rather than through the DOM. + const CREDENTIAL_REF = /^[A-Za-z_][A-Za-z0-9_]*$/ + for (const id of ['a', 'ds', 'a1', 'acme-gateway', 'x-1-y', 'zz9']) { + expect(CREDENTIAL_REF.test(deriveKeyRef(id))).toBe(true) + } + }) + it('names the blocked gate under the form, and nothing once it is satisfied', () => { mountCard() fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) From 135064c8314dc7875bb1d1a17bb2c85a7ab1448e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 17:02:05 +0800 Subject: [PATCH 07/10] fix(ui-models): stop the shared hint contradicting a filled-in field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The line under the create form names the one blocked gate worth naming, and its fallback arm reads "no models yet". An unmet Provider ID gate fell through to that arm, so a card with two models listed right above it was told it needed one. The key gate was already excluded for this reason; the route gate was assumed excluded because its field explains itself, and was not. Tightening the route rule in the previous commit is what made this easy to hit — a digit-leading id now fails the gate — but the fallthrough predates it and fires for an empty or taken id just the same. --- .../src/client/CustomProviderCard.tsx | 7 +++++-- .../ui-models/tests/provider-form.spec.tsx | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-models/src/client/CustomProviderCard.tsx b/packages/client/ui-models/src/client/CustomProviderCard.tsx index f8dd6ca3f8..e27bd3c6bd 100644 --- a/packages/client/ui-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-models/src/client/CustomProviderCard.tsx @@ -112,14 +112,17 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { const ready = route.length > 0 && !routeInvalid && !routeTaken && baseURL.length > 0 && models.length > 0 && modelFailure === undefined && keyFailure === undefined - // The one blocked gate worth a line under the form. The route id is omitted - // because its own field already explains itself, and a satisfied card says + // The one blocked gate worth a line under the form. A satisfied card says // nothing at all rather than printing an empty paragraph. const hint = failure !== undefined || ready // The key field prints its own failure directly beneath itself, so a card // blocked only by the key stays silent here rather than answering with the // next unmet gate — which is satisfied, and reads as a second, false fault. || keyFailure !== undefined + // Same for the route id, and it must be tested rather than assumed: the + // fallback arm below reads "no models yet", so an unmet route gate used to + // fall through to it and contradict the filled-in list right above. + || route.length === 0 || routeInvalid || routeTaken ? undefined : baseURL.length === 0 ? t('customNeedsBaseUrl') diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 831353bc5c..7d8f2efe27 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -790,6 +790,26 @@ describe('hand-declared providers', () => { expect(onClose).toHaveBeenCalledWith(true) }) + it('never contradicts a filled-in field with the next gate\u2019s copy', () => { + mountCard() + const routeField = screen.getByLabelText(en.customRoute) + fireEvent.change(routeField, { target: { value: '2' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } }) + + // The route field explains itself right under the input; the shared line + // must stay silent rather than falling through to "no models yet" while + // the list above plainly has one. + expect(screen.getByText(en.customRouteInvalid)).toBeTruthy() + expect(screen.queryByText(en.customNeedsModels)).toBeNull() + + // Fixing the route hands the line back to the gate that is actually unmet. + fireEvent.change(routeField, { target: { value: 'acme' } }) + expect(screen.queryByText(en.customNeedsModels)).toBeNull() + expect(buttonNamed(en.create).disabled).toBe(false) + }) + it('refuses a route id whose derived credential reference would be illegal', () => { mountCard() const routeField = screen.getByLabelText(en.customRoute) From 2dc1406dfdd67b11fbfec1aca2f485f2cd6f71f6 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 17:36:08 +0800 Subject: [PATCH 08/10] feat(ui-models): drop the provider-scoped reasoning effort, and red-flag a bad route id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Reasoning effort leaves the provider cards entirely.** It is a per-MODEL capability and the models under one provider disagree about which levels they accept: setting `anthropic` to `max` made six of its eight models throw UNSUPPORTED_REASONING_EFFORT, and because the catalog build catches per provider, the whole provider vanished from the picker behind one error row. A provider-scoped control can only ever be set to a value some of its models reject. The composer's model picker already offers each model its own levels, and a switch there now records provider, model, and effort together as the next session's default — so the setting has a better home at the right granularity. The profile field stays in `settings.yaml` for a deployment that knows its route; only the control is gone, from both cards and both adapter families. Two `components.spec` cases used the control as the vehicle for their op assertions and now use `baseURL`, which is what they were actually testing. **A rejected Provider ID now reads as a fault.** It shared the neutral hint paragraph with the field's guidance, so the copy telling the user what they got wrong looked like advice. Reuses the existing `.error` style, matching the split the key field already makes. --- apps/web/tests/models-settings.e2e.ts | 18 ++--- .../models.expected.md | 6 -- packages/client/ui-models/README.i18n.yaml | 4 +- packages/client/ui-models/README.md | 4 +- packages/client/ui-models/README.zh.md | 4 +- .../src/client/CustomProviderCard.tsx | 20 +++--- .../ui-models/src/client/ModelsSection.tsx | 4 -- .../ui-models/src/client/ProviderEditor.tsx | 39 +++------- .../src/client/ReasoningEffortField.tsx | 72 ------------------- .../client/ui-models/src/client/locales.ts | 4 -- .../ui-models/tests/components.spec.tsx | 24 +++---- .../ui-models/tests/provider-form.spec.tsx | 49 +++++++------ 12 files changed, 70 insertions(+), 178 deletions(-) delete mode 100644 packages/client/ui-models/src/client/ReasoningEffortField.tsx diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 6ebdbc1d3a..d539c391c0 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -159,23 +159,23 @@ describe('web e2e: Models settings page configures a dormant provider', () => { const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click() await dialog.getByText('自定义设置').click() - const effort = dialog.getByLabel('推理强度') - await effort.waitFor({ timeout: 10_000 }) - await effort.selectOption('high') + const url = dialog.getByLabel('API 地址') + await url.waitFor({ timeout: 10_000 }) + await url.fill('https://gateway.minimax.example/v1') await dialog.getByRole('button', { name: '保存', exact: true }).click() // The editor closes back to the row; the fold's write merged into the // stored profile beside the reference. - await expect.poll(async () => dialog.getByLabel('推理强度').count(), { timeout: 10_000 }).toBe(0) + await expect.poll(async () => dialog.getByLabel('API 地址').count(), { timeout: 10_000 }).toBe(0) await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 }) const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') - expect(document).toContain('reasoning: high') + expect(document).toContain('baseURL: https://gateway.minimax.example/v1') expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY') const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(CONFIGURED_EXPECTED, snapshot, MODE) expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('declares a route the adapter does not ship, without a reasoning control', async () => { + it('declares a route the adapter does not ship', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-declare')) const dialog = page.getByRole('dialog', { name: '设置' }) const declare = dialog.getByRole('button', { name: '添加自定义提供方' }) @@ -184,9 +184,9 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await dialog.getByLabel('Provider ID').fill('acme-gateway') await dialog.getByLabel('显示名称').fill('Acme Gateway') await dialog.getByLabel('API 地址').fill('https://gateway.acme.example/v1') - // No reasoning effort anywhere for a hand-declared route: its models carry - // no reasoning capability, so a profile effort would make every model on - // the route fail to resolve and drop the provider out of the picker. + // No reasoning effort on a provider card at all: effort is a per-model + // capability, the models under one provider disagree about it, and a + // switch in the composer already records provider+model+effort together. expect(await dialog.getByLabel('推理强度').count()).toBe(0) await dialog.getByRole('button', { name: '添加模型' }).click() await dialog.getByLabel('模型 ID 1').fill('acme-large') diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md index 45790a8f33..931caf0acb 100644 --- a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md @@ -25,12 +25,6 @@ - text: 自定义设置 API 地址 - textbox "API 地址": - /placeholder: https://api.deepseek.com - - text: 推理强度 - - combobox "推理强度": - - option "默认" [selected] - - option "off" - - option "high" - - option "max" - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index e2b9e45f9d..5d579d3b51 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-models/README.md -README.md: dec43de43899ef99e74b1fd73ffb4bf3c4e97b3e -README.zh.md: c17eb611f071c4054d12d36acb2de8a94fa95a20 +README.md: cf4e50630339c4055e9ae2df37246b814af06966 +README.zh.md: 2b9158fa4419bf07f496fce47c938744f2a4233f diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index dec43de438..cf4e506303 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status. -Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a literal key or referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and each adapter's model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped. +Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a literal key or referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint) and each adapter's model catalog. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which took the whole provider out of the model picker. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped. The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. @@ -16,7 +16,7 @@ A pi-ai profile's `models` list is edited on the card: one row per model showing **Fetch available models** asks `llm.discoverModels` about the endpoint the form **currently shows**, including a base URL edited but not yet saved and a key typed but not yet stored, so adding a provider is one pass instead of save-then-return. The reply opens a picker rather than being written: candidates already configured start unchecked, so adopting a selection never overwrites a capacity the user corrected. A provider that cannot be interrogated is a detour, not a dead end — the adapter's own message appears beside the rows, which stay editable by hand. -**Add a custom provider** declares a route pi-ai does not ship. It is its own card rather than the editor with extra fields, because the route id is being chosen here and the settings address does not exist until it is: one `settings.mutate` sets the whole profile at `providers.`, and the key travels separately through `credentials.set` under the same `_API_KEY` derivation an existing provider uses. What a hand-declared route cannot default gates the create button — a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model — so the failure names the field while the user is still looking at it. The id must start with a lowercase letter, because it is also the stem of the derived credential reference and a reference is a POSIX shell identifier: a digit-leading id otherwise passes every check this card makes and then fails at the credential seam with a raw regular expression. Capacities do not gate it: the adapter's fallbacks size a model the endpoint described by id alone, which is what most listings return. The protocol choices are read out of the namespace's own schema rather than a wire field or a constant, so they cannot drift from the ones the adapter accepts. The card records the conventional `apiKeyEnv` reference only when a key is typed, the same rule the editor applies, so a route declared for provider-native authentication is not born pointing at a reference nothing will ever set. When the profile write lands but the key write fails, the provider already exists: the card settles the fields describing it, retries the credential alone — re-running the profile write would carry the revision that write just superseded, so the Host would answer `settings-conflict` and the key could never be stored from here — and reports the created provider even if the user then cancels. Neither this card nor the editor offers a reasoning effort for such a route: a hand-declared model carries no reasoning capability — pi-ai's installed catalog is what supplies one, and it ships nothing under this route — so a profile effort makes `resolveModel` throw for every model on the route and drops the whole provider out of the picker. The editor withholds the control on the directory's `declared` bit for exactly that reason; a route the adapter ships keeps it. +**Add a custom provider** declares a route pi-ai does not ship. It is its own card rather than the editor with extra fields, because the route id is being chosen here and the settings address does not exist until it is: one `settings.mutate` sets the whole profile at `providers.`, and the key travels separately through `credentials.set` under the same `_API_KEY` derivation an existing provider uses. What a hand-declared route cannot default gates the create button — a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model — so the failure names the field while the user is still looking at it. The id must start with a lowercase letter, because it is also the stem of the derived credential reference and a reference is a POSIX shell identifier: a digit-leading id otherwise passes every check this card makes and then fails at the credential seam with a raw regular expression. Capacities do not gate it: the adapter's fallbacks size a model the endpoint described by id alone, which is what most listings return. The protocol choices are read out of the namespace's own schema rather than a wire field or a constant, so they cannot drift from the ones the adapter accepts. The card records the conventional `apiKeyEnv` reference only when a key is typed, the same rule the editor applies, so a route declared for provider-native authentication is not born pointing at a reference nothing will ever set. When the profile write lands but the key write fails, the provider already exists: the card settles the fields describing it, retries the credential alone — re-running the profile write would carry the revision that write just superseded, so the Host would answer `settings-conflict` and the key could never be stored from here — and reports the created provider even if the user then cancels. ## Model Experience diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index c17eb611f0..2b9158fa44 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -4,7 +4,7 @@ 模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。 -行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。只有确认字面密钥或引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另有 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai),以及各适配器自己的模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。 +行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。只有确认字面密钥或引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),以及各适配器自己的模型目录。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会让整个提供方从模型选择器里消失。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。 前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 @@ -16,7 +16,7 @@ pi-ai profile 的 `models` 列表就在卡片上编辑:一行一个模型, **获取可用模型**会针对表单**当前显示**的端点调用 `llm.discoverModels`,包括已修改但尚未保存的 API 地址和已键入但尚未存储的密钥,因此新增一个提供方是一趟走完,而不是「先保存再回来」。回复会打开一个选择框而不是直接写入:已配置过的候选默认不勾选,因此采纳一次选择绝不会覆盖用户已更正的容量。无法被询问的提供方只是绕路而非死路——适配器自己的消息会显示在各行旁边,而这些行仍可手工编辑。 -**添加自定义提供方**用来声明 pi-ai 未提供的路由。它是独立的一张卡片而非在编辑器上加字段,因为路由 id 正是在这里被*选定*的,而在选定之前 settings 地址并不存在:一次 `settings.mutate` 在 `providers.` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个由唯一标识的模型——因此失败会在用户仍看着该字段时点名它。该 id 必须以小写字母开头,因为它同时是派生凭据引用的词干,而引用是 POSIX shell 标识符:数字开头的 id 否则会通过这张卡片的每一项检查,然后在凭据 seam 上以一条用户无从下手的原始正则失败。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema,而非某个协议字段或常量,因此它们不会与适配器实际接受的集合发生漂移。只有键入了密钥,这张卡片才记录约定的 `apiKeyEnv` 引用,与编辑器同一条规则,因此一条为提供方原生认证声明的路由不会一出生就指向一个永远不会被设置的引用。当 profile 写入成功而密钥写入失败时,提供方其实已经存在:卡片会把描述它的字段定住,只重试凭据——再跑一次 profile 写入会带着刚被自己这次写入取代的 revision,宿主将以 `settings-conflict` 应答,密钥就再也无法从这里存下——并且即使用户随后取消,也照实报告提供方已创建。这类路由在两张卡片上都不提供推理等级:手工声明的模型没有推理能力——能力来自 pi-ai 的已安装 catalog,而它在这条路由下什么都没有——因此 profile 级等级会让该路由上每个模型的 `resolveModel` 抛错,整个提供方从选择器里消失。编辑器正是依据目录的 `declared` 位收起这个控件;适配器自带的路由则保留它。 +**添加自定义提供方**用来声明 pi-ai 未提供的路由。它是独立的一张卡片而非在编辑器上加字段,因为路由 id 正是在这里被*选定*的,而在选定之前 settings 地址并不存在:一次 `settings.mutate` 在 `providers.` 上设置整个 profile,密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个由唯一标识的模型——因此失败会在用户仍看着该字段时点名它。该 id 必须以小写字母开头,因为它同时是派生凭据引用的词干,而引用是 POSIX shell 标识符:数字开头的 id 否则会通过这张卡片的每一项检查,然后在凭据 seam 上以一条用户无从下手的原始正则失败。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema,而非某个协议字段或常量,因此它们不会与适配器实际接受的集合发生漂移。只有键入了密钥,这张卡片才记录约定的 `apiKeyEnv` 引用,与编辑器同一条规则,因此一条为提供方原生认证声明的路由不会一出生就指向一个永远不会被设置的引用。当 profile 写入成功而密钥写入失败时,提供方其实已经存在:卡片会把描述它的字段定住,只重试凭据——再跑一次 profile 写入会带着刚被自己这次写入取代的 revision,宿主将以 `settings-conflict` 应答,密钥就再也无法从这里存下——并且即使用户随后取消,也照实报告提供方已创建。 ## 模型体验 diff --git a/packages/client/ui-models/src/client/CustomProviderCard.tsx b/packages/client/ui-models/src/client/CustomProviderCard.tsx index e27bd3c6bd..f055b325a5 100644 --- a/packages/client/ui-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-models/src/client/CustomProviderCard.tsx @@ -14,13 +14,11 @@ * and at least one model — are required here rather than at load, so the * failure names the field while the user is still looking at it. * - * There is deliberately no reasoning-effort control. A hand-declared model - * carries no reasoning capability — pi-ai's installed catalog is what supplies - * one, and it has nothing under this route — so a profile effort here makes - * `resolveModel` throw UNSUPPORTED_REASONING_EFFORT for every model on the - * route, which drops the whole provider out of the model picker. The editor - * card hides the control for the same reason once the directory reports the - * route as declared. + * There is deliberately no reasoning-effort control, here or on the editor + * card: effort is a per-MODEL capability, and the models under one provider + * disagree about it, so a provider-scoped control can only be set to a value + * some of them reject. The composer's model picker offers each model its own + * levels instead. */ import { useState } from 'react' @@ -206,9 +204,11 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { onChange={(event) => { setRoute(event.target.value) }} /> -

    - {routeInvalid ? t('customRouteInvalid') : routeTaken ? t('customRouteTaken') : t('customRouteHint')} -

    + {/* A rejected id reads as a fault, not as guidance — the same split the + key field below already makes between its failure and its hint. */} + {routeInvalid || routeTaken + ?

    {t(routeInvalid ? 'customRouteInvalid' : 'customRouteTaken')}

    + :

    {t('customRouteHint')}

    }
    {t('customDisplayName')} ) @@ -140,7 +137,6 @@ function targetOf(row: ProviderRow): EditorTarget { settingsNs: row.entry.settingsNs, settingsPath: row.entry.settingsPath, ...credentialRef === undefined ? {} : { credentialRef }, - ...row.entry.declared === undefined ? {} : { declared: row.entry.declared }, } } diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index 3e6b26658f..ff23e35b63 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -7,10 +7,12 @@ * a key is entered; a blank key materializes a reference-free profile for * provider-native authentication); * the collapsed 自定义设置 area carries the per-family extras (`baseURL` for - * both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai — - * withheld for a hand-declared route, whose models have no reasoning - * capability to configure — and DeepSeek's id/name/context-window model - * catalog). Everything else stays + * both families and DeepSeek's id/name/context-window model catalog). + * Reasoning effort is deliberately absent: it is a per-MODEL capability, and + * the models under one provider disagree about it, so a provider-scoped + * control can only be set to a value some of them reject. The composer's + * model picker offers each model its own levels; `settings.yaml` keeps the + * profile field for a deployment that knows its route. Everything else stays * owned by `settings.yaml`. Profile edits land as minimal `settings.mutate` * path ops against the stored section — the card reads the redacted * descriptor, so it names only the fields it can see and a stored literal @@ -29,14 +31,12 @@ import { import { apiKeyFailure } from './apiKey.ts' import { EditorFooter } from './EditorFooter.tsx' import { ModelListEditor } from './ModelListEditor.tsx' -import { EFFORT_FIELD, ReasoningEffortField } from './ReasoningEffortField.tsx' -import type { EffortFamily } from './ReasoningEffortField.tsx' import { deriveKeyRef, messageOf } from './store.ts' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' /** Per-adapter-family curated field sets (unknown namespaces get the hint alone). */ -type EditorLayout = EffortFamily | 'unknown' +type EditorLayout = 'deepseek' | 'pi-ai' | 'unknown' /** The public DeepSeek endpoint shown as the deepseek base-URL placeholder. */ const DEEPSEEK_PUBLIC_BASE_URL = 'https://api.deepseek.com' @@ -57,13 +57,6 @@ export interface ProviderEditorProps { api: Pick /** Section copy. */ t: (key: keyof typeof en) => string - /** - * Whether the owning adapter knows this route only because configuration - * declared it. Such a route's models carry no reasoning capability, so the - * effort control is withheld; absent means the adapter draws no such - * distinction and the control shows. - */ - declared?: boolean /** Disable writes (read-only settings provider). */ readOnly: boolean /** Close the editor; `changed` reports whether an Apply committed. */ @@ -303,8 +296,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { * family as a parameter is what makes `EFFORT_FIELD` total here: an * unknown namespace never reaches this body. */ - const curatedFields = (family: EffortFamily): ReactNode => { - const effortField = EFFORT_FIELD[family] + const curatedFields = (family: 'deepseek' | 'pi-ai'): ReactNode => { const customModels = getPath(draft, ['models']) const modelsOverridden = hasPath(draft, ['models']) const models = modelDrafts(modelsOverridden ? customModels : inheritedModels()) @@ -361,21 +353,6 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { }} />
    - {/* A hand-declared route's models carry no reasoning capability - (pi-ai's installed catalog is what supplies one, and it has - nothing under such a route), so a profile effort would make - `resolveModel` throw for every model on it and drop the whole - provider out of the picker. Offering the control at all would - be offering a way to break the route. */} - {props.declared === true ? null : ( - { setField(effortField, effort) }} - t={t} - disabled={disabled} - /> - )} {/* Both families edit the same rows through the same contract; only the extras differ — DeepSeek's inherited capacities, pi-ai's endpoint interrogation. */} diff --git a/packages/client/ui-models/src/client/ReasoningEffortField.tsx b/packages/client/ui-models/src/client/ReasoningEffortField.tsx deleted file mode 100644 index a129637135..0000000000 --- a/packages/client/ui-models/src/client/ReasoningEffortField.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/** - * The provider-level reasoning-effort select: the profile's own default - * effort, applied to every model on the route unless a request names one. The - * empty option means "inherit", which on the wire is the field being absent - * rather than an empty string. - * - * It carries the per-family vocabulary and field name so the editor's two - * layouts cannot spell them differently. Only routes the adapter ships get - * this control at all — a hand-declared model has no reasoning capability to - * configure, and a profile effort over one makes its whole route fail to - * resolve — so the create card renders nothing here by construction. - */ - -import type { ReactNode } from 'react' -import type { en } from './locales.ts' -import styles from './ModelsSection.module.css' - -/** The adapter families that expose a provider-level effort, and their vocabularies. */ -export type EffortFamily = 'deepseek' | 'pi-ai' - -/** Reasoning vocabularies per family; the empty option means "inherit". */ -export const EFFORT_CHOICES: Record = { - deepseek: ['off', 'high', 'max'], - 'pi-ai': ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'], -} - -/** The profile key each family's effort lives under. */ -export const EFFORT_FIELD: Record = { - deepseek: 'reasoningEffort', - 'pi-ai': 'reasoning', -} - -/** Props of {@link ReasoningEffortField}. */ -export interface ReasoningEffortFieldProps { - /** Which vocabulary to offer. */ - family: EffortFamily - /** Current value; the empty string is the inherit option. */ - value: string - /** Receives the chosen effort, or undefined for inherit. */ - onChange: (effort: string | undefined) => void - /** Section copy. */ - t: (key: keyof typeof en) => string - /** Disable the control (busy or read-only). */ - disabled: boolean -} - -/** - * Render the provider-level reasoning-effort select. - * @param props - family vocabulary, current value, change sink, copy, and disabled state. - * @returns the labelled select. - */ -export function ReasoningEffortField( - { family, value, onChange, t, disabled }: ReasoningEffortFieldProps, -): ReactNode { - return ( -
    - {t('effort')} - -
    - ) -} diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index 9809c61283..7d75e1de11 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -35,8 +35,6 @@ export const en = { customized: 'Customized settings', baseUrl: 'Base URL', baseUrlDefault: 'Provider default', - effort: 'Reasoning effort', - effortInherit: 'Default', models: 'Models', modelsInherited: 'Using the adapter defaults', modelsCustomized: 'Customized model catalog', @@ -130,8 +128,6 @@ export const zh: typeof en = { customized: '自定义设置', baseUrl: 'API 地址', baseUrlDefault: '提供方默认', - effort: '推理强度', - effortInherit: '默认', models: '模型目录', modelsInherited: '正在使用适配器默认模型', modelsCustomized: '已自定义模型目录', diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index 931410fb35..88eab4b998 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -92,13 +92,12 @@ function wireNamespaces(): SettingsNamespaceView[] { value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base', - reasoningEffort: 'high', defaultContextWindow: 1_000_000, maxTokens: 256_000, models: DEFAULT_DEEPSEEK_MODELS, }, base: { defaultContextWindow: 1_000_000, maxTokens: 256_000, models: DEFAULT_DEEPSEEK_MODELS }, - user: { reasoningEffort: 'high' }, + user: { baseURL: 'https://base' }, applies: 'live', secrets: [{ path: ['apiKey'], set: false }], revision: 0, @@ -729,16 +728,16 @@ describe('ModelsSection', () => { // user layer and replaced it wholesale, deleting any stored literal key. const { replace, update, mutate } = await mountSection() fireEvent.click(screen.getByText(en.customized)) - const effort = screen.getByLabelText(en.effort) - expect(effort.value).toBe('high') - fireEvent.change(effort, { target: { value: '' } }) + const url = screen.getByLabelText(en.baseUrl) + expect(url.value).toBe('https://base') + fireEvent.change(url, { target: { value: '' } }) fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) expect(replace).not.toHaveBeenCalled() expect(update).not.toHaveBeenCalled() expect(mutate.mock.calls[0]?.[0]).toEqual({ ns: 'llm-deepseek', - ops: [{ op: 'unset', path: ['reasoningEffort'] }], + ops: [{ op: 'unset', path: ['baseURL'] }], expectedRevision: 0, }) }) @@ -795,17 +794,16 @@ describe('ModelsSection', () => { const urls = screen.getAllByLabelText(en.baseUrl) expect(urls).toHaveLength(2) expect((urls[1] as HTMLInputElement).value).toBe('https://proxy') - const effort = screen.getAllByLabelText(en.effort) - fireEvent.change(effort[effort.length - 1] as HTMLSelectElement, { target: { value: 'xhigh' } }) + fireEvent.change(urls[1] as HTMLInputElement, { target: { value: 'https://proxy/v2' } }) fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) - // Only the edited field travels: apiKeyEnv, baseURL and headers were - // already stored with these values, so no op restates them — and the - // profile's stored literal apiKey, absent from the redacted view the card - // read, is named by nothing at all. + // Only the edited field travels: apiKeyEnv and headers were already stored + // with these values, so no op restates them — and the profile's stored + // literal apiKey, absent from the redacted view the card read, is named by + // nothing at all. expect(mutate.mock.calls[0]?.[0]).toEqual({ ns: 'llm-pi-ai', - ops: [{ op: 'set', path: ['providers', 'openai', 'reasoning'], value: 'xhigh' }], + ops: [{ op: 'set', path: ['providers', 'openai', 'baseURL'], value: 'https://proxy/v2' }], expectedRevision: 0, }) }) diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 7d8f2efe27..7e5ef5f36e 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -705,35 +705,24 @@ describe('hand-declared providers', () => { expect(set).toHaveBeenCalledWith({ ref: 'ACME_GATEWAY_API_KEY', value: 'gw-key' }) }) - it('offers no reasoning effort at all, in either card, for a hand-declared route', async () => { + it('scopes each card to fields a provider can actually own', async () => { + // Reasoning effort used to sit here. It is a per-MODEL capability and the + // models under one provider disagree about it, so a provider-scoped + // control could only be set to a value some of them reject — which took + // the whole provider out of the picker. The composer's model picker owns + // the choice, and a switch there records provider+model+effort together. + const fields = () => [...document.querySelectorAll('input,select')] + .map(el => el.getAttribute('aria-label')).filter(Boolean) + mountCard() fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) - fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) - // A hand-declared model carries no reasoning capability — pi-ai's - // installed catalog is what supplies one, and it ships nothing under this - // route — so a profile effort makes `resolveModel` throw - // UNSUPPORTED_REASONING_EFFORT for every model on it and drops the whole - // provider out of the picker. Offering the control would be offering a way - // to break the route. - expect(screen.queryByLabelText(en.effort)).toBeNull() + expect(fields()).toEqual([en.customRoute, en.customDisplayName, en.baseUrl, en.customApi, en.keyInput]) cleanup() - // The editor card withholds it for the same route for the same reason... - await mountSection({ - providers: { 'acme-gateway': { apiKeyEnv: 'ACME_GATEWAY_API_KEY', baseURL: 'https://acme.test/v1' } }, - declaredRoutes: ['acme-gateway'], - }) - openEditor('acme-gateway') - expect(screen.queryByLabelText(en.effort)).toBeNull() - cleanup() - - // ...and keeps it for a route the adapter actually ships, whose models do - // carry the capability. await mountSection({ providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } }) openEditor('openai') - const select = screen.getByLabelText(en.effort) - expect([...select.options].map(option => option.value)) - .toEqual(['', 'off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']) + fireEvent.click(screen.getByText(en.customized)) + expect(fields()).toEqual([en.keyInput, en.baseUrl]) }) it('retries only the key after the profile landed, and reports the provider on cancel', async () => { @@ -827,6 +816,20 @@ describe('hand-declared providers', () => { expect(screen.queryByText(en.customRouteInvalid)).toBeNull() }) + it('styles a rejected route id as a fault and its guidance as a hint', () => { + mountCard() + const routeField = screen.getByLabelText(en.customRoute) + // Same split the key field makes: what the user got wrong reads as a + // fault, what they have yet to do reads as guidance. + expect(screen.getByText(en.customRouteHint).className).toMatch(/advancedHint/) + + fireEvent.change(routeField, { target: { value: '2' } }) + expect(screen.getByText(en.customRouteInvalid).className).toMatch(/error/) + + fireEvent.change(routeField, { target: { value: 'openai' } }) + expect(screen.getByText(en.customRouteTaken).className).toMatch(/error/) + }) + it('derives a reference the credential seam accepts for every id it admits', () => { // The two rules have to stay in step; this is the relation, checked // directly rather than through the DOM. From f3049e5663c74c9a33ea4934049ec5438d2e259f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 18:07:15 +0800 Subject: [PATCH 09/10] fix(llm-pi-ai): describing a model must not fail on a bad profile level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolveModel` validated the profile's reasoning level against the exact model and threw when it did not fit. That call builds the model catalog, and the catalog build catches per PROVIDER — so one mis-set field took the whole provider out of every picker behind a single error row, hiding even the models that do support the level. Measured: `anthropic` set to `max` threw for six of its eight models. Describing what a model can do now reports an unusable profile level as no default rather than throwing; the request path still refuses it, which is where a bad configuration belongs. The existing spec asserted the old throw and now asserts both halves of that split. Known gap, left deliberately: a model that cannot take the route's level still fails its first request while the picker shows 「Default」 for it, because the request path keeps using the profile level as the fallback. Reaching that needs a hand-written `settings.yaml` — the Models page no longer writes the field — and the error names the model and the level, so selecting a supported level is a way out. Closing it properly means giving `AgentOptions` a `reasoningEffort` so compositions without a model picker keep an entry point, then dropping the provider-scoped field altogether; that is its own change. --- packages/llm/llm-pi-ai/README.i18n.yaml | 4 ++-- packages/llm/llm-pi-ai/README.md | 2 +- packages/llm/llm-pi-ai/README.zh.md | 2 +- packages/llm/llm-pi-ai/src/adapter.ts | 25 +++++++++++++++++++- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 15 ++++++++++-- 5 files changed, 41 insertions(+), 7 deletions(-) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 6ea82781de..b57043a84d 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md -README.md: a8686aa6f26095a9dd40c447aa0d0f61f7bc5412 -README.zh.md: 8f190097b543fe1d0324daa37a162cdc91d3e2dc +README.md: 97bd629adedda9d63fee730bc31129b0c22cc704 +README.zh.md: 71d45b590f48f4b8162ae329b58b5ff4a9eb13b1 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index a8686aa6f2..97bd629ade 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -73,7 +73,7 @@ The adapter exposes each configured route's models through `ctx.llm.listModels(p A model that carries reasoning metadata exposes pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. -A model **without** that metadata — every hand-declared one, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and any explicit value absent from the exact model capability fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. pi-ai's common stream options represent `off` by omitting `reasoning`. +A model **without** that metadata — every hand-declared one, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`. Supported profile fields are `apiKey`, `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `defaultContextWindow`, `defaultMaxTokens`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 8f190097b5..71d45b590f 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -73,7 +73,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 携带推理元数据的模型会公开 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh` 或 `max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID;提供方/模型在协议格式中的表示仍保留在 pi-ai 的 `thinkingLevelMap` 中。 -**没有**这份元数据的模型——每一个手工声明的模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;任何未出现在确切模型能力中的显式值都会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 +**没有**这份元数据的模型——每一个手工声明的模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。 受支持的 profile 字段是 `apiKey`、`apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`defaultContextWindow`、`defaultMaxTokens`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。 diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 365c3901a5..e974cdff7c 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -94,6 +94,29 @@ function profileOptions( } } +/** + * The profile default this exact model can actually take, for DESCRIBING it. + * A configured level the model does not support yields none rather than + * throwing: `resolveModel` builds the model catalog, and a catalog that fails + * takes its whole provider out of every picker — so one mis-set profile field + * would hide every model on the route, including the ones that support the + * level. The request path still refuses, which is where a bad configuration + * belongs: describing what a model can do must not fail because a deployment + * asked it for something it cannot. + * @param model - the resolved model descriptor. + * @param effort - the profile's configured level, if any. + * @returns the level when this model supports it, otherwise undefined. + */ +function describableReasoningLevel( + model: Model, + effort: ReasoningEffortIdType | ModelThinkingLevel | undefined, +): ModelThinkingLevel | undefined { + if (effort === undefined) return undefined + return getSupportedThinkingLevels(model).some(level => level === effort) + ? effort as ModelThinkingLevel + : undefined +} + /** Validate an explicit Harness/profile effort without invoking pi-ai's clamp. */ function resolveReasoningLevel( model: Model, @@ -229,7 +252,7 @@ export class PiAiAdapter extends LlmAdapter { const snapshot = this.current() const profile = this.profileOf(snapshot, provider) const resolvedModel = this.modelOf(snapshot, provider, model) - const defaultLevel = resolveReasoningLevel(resolvedModel, profile.reasoning) + const defaultLevel = describableReasoningLevel(resolvedModel, profile.reasoning) // Only a cap the deployment configured is a request default; the // catalog's `maxTokens` sizes the model and stops there. const configuredMaxTokens = profile.configuredMaxTokens.get(model) diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index a9c4335a92..0184ca05cc 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -372,13 +372,24 @@ describe('provider profile lifecycle', () => { await expect(supported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) .resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('max') } }) + // A profile level this model cannot take DESCRIBES as no default rather + // than failing: resolveModelInfo builds the model catalog, and a catalog + // that throws takes its whole provider out of every picker — one mis-set + // field would hide every model on the route, including the ones that do + // support the level. The request path below is where it is refused. const unsupported = new Context() await unsupported.plugin(LlmService) await unsupported.plugin(LlmPiAi, { providers: { deepseek: { reasoning: 'medium' } }, }) - await expect(unsupported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash')) - .rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' }) + const described = await unsupported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash') + expect(described.reasoning?.defaultEffort).toBeUndefined() + expect(described.reasoning?.efforts.length).toBeGreaterThan(0) + await expect(assemble(unsupported, { + provider: 'deepseek', model: 'deepseek-v4-flash', messages: [], + })).resolves.toMatchObject({ + finish: { kind: 'error', failure: { code: 'UNSUPPORTED_REASONING_EFFORT' } }, + }) const disabled = new Context() await disabled.plugin(LlmService) From b1074e60ab64f7a99e21ab3cd0bb655d62a9f3c1 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 7 Aug 2026 18:54:50 +0800 Subject: [PATCH 10/10] test(web): re-record the skill-tool-row golden for the resolved seat label Master added this scenario while this branch was open, so its golden froze the composer seat's "Select model" fallback. The scaffold's route-only adapter (added here for fixture-less scenarios) makes the seat resolve the model those scenarios actually route to, which is what the other eight goldens on this branch already show. Only the two seat lines move. --- apps/web/tests/snapshots/skill-tool-row/ui.expected.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/snapshots/skill-tool-row/ui.expected.md b/apps/web/tests/snapshots/skill-tool-row/ui.expected.md index fc1f23d484..15ddf45a0d 100644 --- a/apps/web/tests/snapshots/skill-tool-row/ui.expected.md +++ b/apps/web/tests/snapshots/skill-tool-row/ui.expected.md @@ -38,8 +38,8 @@ - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model": - - text: Select model +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] - text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 280 tok · Output 30 tok