feat(ui-models): tag the provider rows this deployment declared

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.
This commit is contained in:
Yichen Jiang
2026-08-07 13:49:47 +08:00
parent e0f9f7a6e6
commit 9679597204
38 changed files with 479 additions and 56 deletions

View File

@@ -26,7 +26,8 @@ import {
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
import type {} from '@deepseek-ai/dsh-tools'
import type {
ApiProxy, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup,
ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame,
ModelCatalogFailure, ModelProviderGroup,
ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView,
WorkspaceId, WorkspaceView,
@@ -2558,15 +2559,17 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const active = new Set(registered.map(provider => provider.id))
const directory = ctx.llm.listConfigurableProviders()
const declared = new Set(directory.map(entry => entry.provider))
const views = directory.map(entry => ({
const views: ConfigurableProviderView[] = directory.map(entry => ({
provider: entry.provider,
displayName: entry.displayName,
settingsNs: entry.settingsNs,
settingsPath: [...entry.settingsPath],
active: active.has(entry.provider),
...entry.declared === undefined ? {} : { declared: entry.declared },
}))
// Routes registered without a directory declaration still appear —
// they exist and serve models — just with no settings address.
// they exist and serve models — just with no settings address. No
// adapter claimed them, so nothing can say whether they are shipped.
for (const provider of registered) {
if (declared.has(provider.id)) continue
views.push({

View File

@@ -16,6 +16,7 @@ export const configurableProviderViewSchema = z.object({
settingsNs: z.string(),
settingsPath: z.array(z.string()),
active: z.boolean(),
declared: z.boolean().optional(),
}) satisfies z.ZodType<Wire<ConfigurableProviderView>>
/** llm.providers request payload. */

View File

@@ -22,6 +22,12 @@ export interface ConfigurableProviderView {
settingsPath: string[]
/** Whether the route is currently registered (its models are requestable). */
active: boolean
/**
* Whether the owning adapter knows this route only because configuration
* declared it. Absent when the adapter draws no such distinction, so a
* surface must treat absence as "unknown", not as "shipped".
*/
declared?: boolean
}
/** Llm-domain unary methods (the map keys llm.* of RpcMethodMap). */

View File

@@ -66,22 +66,23 @@ export interface Config extends DefaultRouteSettings {
workspaceRoot?: string
}
/**
* The default-route fields, as fresh schema instances. Both the plugin config
* and the settings section are built from this one call, so the section stays
* a subset of the config structurally rather than by a comment two people have
* to keep true.
*/
function defaultRouteFields(): { [K in keyof Required<DefaultRouteSettings>]: z<string> } {
return {
provider: z.string().required(),
model: z.string().required(),
reasoningEffort: z.string(),
}
}
/** The config fields the settings section carries; the rest stay launcher-owned. */
const DEFAULT_ROUTE_FIELDS = ['provider', 'model', 'reasoningEffort'] as const
/** Schema of the settings section. */
const DefaultRouteSchema: z<DefaultRouteSettings> = z.object(defaultRouteFields())
/**
* 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}.
*/
function defaultRouteSchema(config: z<Config>): z<DefaultRouteSettings> {
const fields = Object.fromEntries(
DEFAULT_ROUTE_FIELDS.map(field => [field, config.dict?.[field]]),
)
return z.object(fields) as z<DefaultRouteSettings>
}
/** Project the stored/composed section onto the agent-facing target shape. */
function routeTarget(settings: DefaultRouteSettings): AgentLlmTarget {
@@ -106,7 +107,9 @@ export class ApiProxyService extends Service implements ApiProxy {
]
static Config: z<Config> = z.object({
...defaultRouteFields(),
provider: z.string().required(),
model: z.string().required(),
reasoningEffort: z.string(),
workspaceRoot: z.string(),
})
@@ -135,7 +138,7 @@ export class ApiProxyService extends Service implements ApiProxy {
...config.reasoningEffort === undefined ? {} : { reasoningEffort: config.reasoningEffort },
}
let route: () => DefaultRouteSettings = () => entry
installSettingsSection(ctx, API_GATEWAY_SETTINGS_NAMESPACE, DefaultRouteSchema, entry, {
installSettingsSection(ctx, API_GATEWAY_SETTINGS_NAMESPACE, defaultRouteSchema(ApiProxyService.Config), entry, {
setSource: (current) => {
route = current
},