diff --git a/docs/en/reference/server-api.md b/docs/en/reference/server-api.md index 96fe9f9b66c..dfca2d3396b 100644 --- a/docs/en/reference/server-api.md +++ b/docs/en/reference/server-api.md @@ -187,7 +187,7 @@ These endpoints drive the managed Kimi OAuth login lifecycle and expose account- | Method and path | Description | | --- | --- | -| `GET /api/v1/auth` | Auth readiness snapshot | +| `GET /api/v1/auth` | Auth snapshot | | `POST /api/v1/oauth/login` | Start the OAuth device-code login flow | | `GET /api/v1/oauth/login` | Poll the login flow state | | `DELETE /api/v1/oauth/login` | Cancel a pending login flow | @@ -198,9 +198,9 @@ These endpoints drive the managed Kimi OAuth login lifecycle and expose account- #### `GET /api/v1/auth` -Auth readiness snapshot: whether the server has a usable model configuration, plus the managed provider's login state. `ready` is `true` when at least one provider is configured, a default model is set, and the managed provider (when present) is not revoked. +Auth snapshot: whether the default model resolves to a usable provider configuration, plus the managed provider's login state. `models_ready` is `true` when the global `default_model` alias exists in the model table and resolves to a configured provider — including providerless flat models carrying their own `base_url` and models injected through `KIMI_MODEL_*` environment variables. It does not verify credentials, so a prompt can still fail afterwards with `40111` / `40112`. -On success, `data` carries `ready` (boolean), `providers_count` (number of configured providers), `default_model` (the global default model alias, or `null`), and `managed_provider` (`null`, or `{ name, status }` with `status` one of `authenticated` / `expired` / `revoked` / `unauthenticated`). +On success, `data` carries `models_ready` (boolean), `providers_count` (number of configured providers), and `managed_provider` (`null`, or `{ name, status }` with `status` one of `authenticated` / `expired` / `revoked` / `unauthenticated`). The global default model alias itself is read from `GET /api/v1/config` (`default_model`), not from this endpoint. #### `POST /api/v1/oauth/login` @@ -308,7 +308,9 @@ On success, `data` is the config object; its fields mirror the top-level domains #### `POST /api/v1/config` -Merge-patches the global configuration: each top-level domain in the body is deep-merged into that domain, and domains absent from the body are left untouched. Setting `yolo` to `true` is shorthand for `default_permission_mode: "yolo"`. After a successful update the server broadcasts the global `event.config.changed` event with the changed field names and the full updated config; a rejected patch (invalid value or persistence failure) returns `40001` with the underlying message. +Merge-patches the global configuration: each top-level domain in the body is deep-merged into that domain, and domains absent from the body are left untouched. Setting `yolo` to `true` is shorthand for `default_permission_mode: "yolo"`; a rejected patch (invalid value or persistence failure) returns `40001` with the underlying message. + +Every config change — a successful update through this endpoint, an external edit of `config.toml`, or a server-side write such as an OAuth login refresh — is broadcast as the global `event.config.changed` event. Changes inside a short window are merged into one event carrying the affected domain names in `changedFields` (camelCase config domains, for example `defaultModel`) and the full current config projection in `config` (same shape as the `GET /api/v1/config` response). The body is a partial config object — any subset of the response domains above except `raw`, all optional: @@ -2149,7 +2151,7 @@ A next-generation session query for list views — filtering, sorting, and field | `page_token` | Pagination token from the previous page | | `page` | Stateless 1-based page number; mutually exclusive with `page_token` (`40001` when combined) | -Every response item carries the `workspace`, `meta`, and `activity` groups, plus `git` when `include=git` — or just `{ id, archived }` under `fields=id,archived`. Every page additionally carries `total`, the size of the filtered set. The page token binds the first page's query conditions (including the projection); changing them mid-pagination returns `40922`. `page` mode is a stateless alternative for jumping to arbitrary pages: every request is an independent snapshot, no token is minted, and `next_page_token` is always `null`. +Every response item carries the `workspace`, `meta`, and `activity` groups, plus `git` when `include=git` — or just `{ id, archived }` under `fields=id,archived`. The `activity` group also reports `model`: the session's bound model alias while it is live in this process, `null` for cold (not currently loaded) sessions. Every page additionally carries `total`, the size of the filtered set. The page token binds the first page's query conditions (including the projection); changing them mid-pagination returns `40922`. `page` mode is a stateless alternative for jumping to arbitrary pages: every request is an independent snapshot, no token is minted, and `next_page_token` is always `null`. With `view=by_workspace` the same filtered, sorted set is re-projected into per-workspace groups, so an overview client replaces one polling loop per workspace with a single request: @@ -2161,7 +2163,7 @@ With `view=by_workspace` the same filtered, sorted set is re-projected into per- "groups": [ { "workspace": { "id": "wd_my-app_a1b2c3d4e5f6", "cwd": "/Users/dev/my-app" }, - "sessions": [ { "id": "session_...", "workspace": { "id": "wd_my-app_a1b2c3d4e5f6", "cwd": "/Users/dev/my-app" }, "meta": { "title": "Fix the login page", "last_prompt": "adjust the button spacing", "created_at": 1787000000000, "updated_at": 1787000100000, "archived": false, "archived_at": null }, "activity": { "status": "idle" } } ], + "sessions": [ { "id": "session_...", "workspace": { "id": "wd_my-app_a1b2c3d4e5f6", "cwd": "/Users/dev/my-app" }, "meta": { "title": "Fix the login page", "last_prompt": "adjust the button spacing", "created_at": 1787000000000, "updated_at": 1787000100000, "archived": false, "archived_at": null }, "activity": { "status": "idle", "model": "kimi-for-coding" } } ], "total": 42 } ], @@ -2337,7 +2339,7 @@ Clients send JSON frames `{ "type", "id"?, "payload" }`; every request frame get Event frames look like `{ "type", "seq", "epoch"?, "volatile"?, "offset"?, "session_id"?, "timestamp", "payload" }`, where `type` is the event type itself. Two delivery scopes: -- **Global events**: sent to every established connection, no subscription needed — `session.meta.updated`, `event.session.created`, `event.session.archived`, `event.session.work_changed`, `event.session.status_changed`, `event.workspace.*`, `event.config.*`. +- **Global events**: sent to every established connection, no subscription needed — `session.meta.updated`, `event.session.created`, `event.session.archived`, `event.session.work_changed`, `event.session.status_changed`, `event.workspace.*`, `event.config.*`, `event.model_catalog.*`. - **Session events**: sent only to connections subscribed to that session, subject to `agent_filter`. Main families: | Family | Main events | diff --git a/docs/zh/reference/server-api.md b/docs/zh/reference/server-api.md index 95f1ff3a065..8e09e981b13 100644 --- a/docs/zh/reference/server-api.md +++ b/docs/zh/reference/server-api.md @@ -187,7 +187,7 @@ curl -s -H "Authorization: Bearer $TOKEN" \ | 方法与路径 | 说明 | | --- | --- | -| `GET /api/v1/auth` | 鉴权就绪状态快照 | +| `GET /api/v1/auth` | 鉴权状态快照 | | `POST /api/v1/oauth/login` | 发起 OAuth device-code 登录流程 | | `GET /api/v1/oauth/login` | 轮询登录流程状态 | | `DELETE /api/v1/oauth/login` | 取消进行中的登录流程 | @@ -198,9 +198,9 @@ curl -s -H "Authorization: Bearer $TOKEN" \ #### `GET /api/v1/auth` -鉴权就绪状态快照:服务是否具备可用的模型配置,以及托管供应商的登录状态。当至少配置了一个供应商、设置了默认模型、且托管供应商(如存在)未被吊销时,`ready` 为 `true`。 +鉴权状态快照:默认模型能否解析到可用的供应商配置,以及托管供应商的登录状态。当全局 `default_model` 别名存在于模型表中且能解析到已配置的供应商时,`models_ready` 为 `true`——包括自带 `base_url` 的平铺(providerless)模型,以及通过 `KIMI_MODEL_*` 环境变量注入的模型。它不做凭据校验,因此此后的对话请求仍可能以 `40111` / `40112` 失败。 -成功时 `data` 携带 `ready`(布尔值)、`providers_count`(已配置供应商数量)、`default_model`(全局默认模型别名,或 `null`)与 `managed_provider`(`null`,或 `{ name, status }`,其中 `status` 为 `authenticated` / `expired` / `revoked` / `unauthenticated` 之一)。 +成功时 `data` 携带 `models_ready`(布尔值)、`providers_count`(已配置供应商数量)与 `managed_provider`(`null`,或 `{ name, status }`,其中 `status` 为 `authenticated` / `expired` / `revoked` / `unauthenticated` 之一)。全局默认模型别名本身改从 `GET /api/v1/config` 的 `default_model` 读取,本端点不再携带。 #### `POST /api/v1/oauth/login` @@ -308,7 +308,9 @@ curl -s -H "Authorization: Bearer $TOKEN" \ #### `POST /api/v1/config` -合并式更新全局配置:请求体中的每个顶层域被深合并进对应域,未出现在请求体中的域保持不动。把 `yolo` 设为 `true` 是 `default_permission_mode: "yolo"` 的简写。更新成功后,服务会广播全局 `event.config.changed` 事件,携带变更的字段名与完整的更新后配置;被拒绝的补丁(值非法或持久化失败)返回 `40001` 与底层错误信息。 +合并式更新全局配置:请求体中的每个顶层域被深合并进对应域,未出现在请求体中的域保持不动。把 `yolo` 设为 `true` 是 `default_permission_mode: "yolo"` 的简写;被拒绝的补丁(值非法或持久化失败)返回 `40001` 与底层错误信息。 + +每一次配置变更——经本端点成功更新、在进程外编辑 `config.toml`,或服务端内部写入(如 OAuth 登录刷新)——都会广播全局 `event.config.changed` 事件。短时间窗内的多次变更会合并为一个事件,其 `changedFields` 携带受影响的域名(camelCase 配置域,例如 `defaultModel`),`config` 携带当前完整的配置投影(与 `GET /api/v1/config` 响应同形状)。 请求体是部分配置对象——上述响应域中除 `raw` 外的任意子集,均为可选: @@ -2149,7 +2151,7 @@ PTY 终端接口;仅在 loopback 绑定时挂载(非 loopback 绑定会跳 | `page_token` | 上一页返回的翻页令牌 | | `page` | 无状态的 1 起始页码;与 `page_token` 互斥(同传返回 `40001`) | -响应每项固定包含 `workspace`、`meta`、`activity` 三组,`include=git` 时附加 `git` 组;`fields=id,archived` 时仅返回 `{ id, archived }`。每页额外携带 `total`,即过滤后的集合大小。翻页令牌绑定首页查询条件(含投影),中途改条件返回 `40922`。`page` 模式是跳页用的无状态替代:每次请求都是独立快照,不签发令牌,`next_page_token` 恒为 `null`。 +响应每项固定包含 `workspace`、`meta`、`activity` 三组,`include=git` 时附加 `git` 组;`fields=id,archived` 时仅返回 `{ id, archived }`。`activity` 组还会带上 `model`:会话仍加载在当前进程时为其绑定的模型别名,冷会话(未加载)为 `null`。每页额外携带 `total`,即过滤后的集合大小。翻页令牌绑定首页查询条件(含投影),中途改条件返回 `40922`。`page` 模式是跳页用的无状态替代:每次请求都是独立快照,不签发令牌,`next_page_token` 恒为 `null`。 `view=by_workspace` 时,同一份过滤、排序后的集合会重新投影为按工作区分组的形态,概览页因此可以用一次请求替代「每个工作区各一轮询」: @@ -2161,7 +2163,7 @@ PTY 终端接口;仅在 loopback 绑定时挂载(非 loopback 绑定会跳 "groups": [ { "workspace": { "id": "wd_my-app_a1b2c3d4e5f6", "cwd": "/Users/dev/my-app" }, - "sessions": [ { "id": "session_...", "workspace": { "id": "wd_my-app_a1b2c3d4e5f6", "cwd": "/Users/dev/my-app" }, "meta": { "title": "Fix the login page", "last_prompt": "adjust the button spacing", "created_at": 1787000000000, "updated_at": 1787000100000, "archived": false, "archived_at": null }, "activity": { "status": "idle" } } ], + "sessions": [ { "id": "session_...", "workspace": { "id": "wd_my-app_a1b2c3d4e5f6", "cwd": "/Users/dev/my-app" }, "meta": { "title": "Fix the login page", "last_prompt": "adjust the button spacing", "created_at": 1787000000000, "updated_at": 1787000100000, "archived": false, "archived_at": null }, "activity": { "status": "idle", "model": "kimi-for-coding" } } ], "total": 42 } ], @@ -2337,7 +2339,7 @@ locator 寻址的目录(脱敏配置),外加对每个 OAuth 候选的批 事件帧形状为 `{ "type", "seq", "epoch"?, "volatile"?, "offset"?, "session_id"?, "timestamp", "payload" }`,`type` 即事件类型。按投递范围分两类: -- **全局事件**:发送到每个已建立连接,无需订阅——`session.meta.updated`、`event.session.created`、`event.session.archived`、`event.session.work_changed`、`event.session.status_changed`、`event.workspace.*`、`event.config.*`。 +- **全局事件**:发送到每个已建立连接,无需订阅——`session.meta.updated`、`event.session.created`、`event.session.archived`、`event.session.work_changed`、`event.session.status_changed`、`event.workspace.*`、`event.config.*`、`event.model_catalog.*`。 - **会话事件**:只发给订阅了该会话的连接,受 `agent_filter` 过滤。主要事件族: | 事件族 | 主要事件 | diff --git a/packages/agent-core-v2/src/app/auth/authService.ts b/packages/agent-core-v2/src/app/auth/authService.ts index 7960271daa2..fed1f066cd4 100644 --- a/packages/agent-core-v2/src/app/auth/authService.ts +++ b/packages/agent-core-v2/src/app/auth/authService.ts @@ -41,10 +41,12 @@ import { IConfigService } from '#/app/config/config'; import { IEventService } from '#/app/event/event'; import { ILogService } from '#/_base/log/log'; import { - deriveProviderId, effectiveModelConfig, nonEmpty, resolveModelAuthMaterial, + resolveModelForReady, + providerNameFromFlatModel, + type ModelReadyFailureReason, } from '#/kosong/model/modelAuth'; import { IModelService, type ModelRecord } from '#/kosong/model/model'; import { @@ -327,7 +329,22 @@ export class OAuthService extends Disposable implements IOAuthService { return { changed, unchanged, failed }; } - const next = structuredClone(current); + await this.config.reload(); + const fresh = this.readUserConfigShape(); + const freshProvider = fresh.providers[KIMI_CODE_PROVIDER_NAME]; + if (!isOAuthCatalogProvider(freshProvider)) { + return { changed, unchanged, failed }; + } + if ( + freshProvider.baseUrl !== provider.baseUrl || + freshProvider.oauth.storage !== provider.oauth.storage || + freshProvider.oauth.key !== provider.oauth.key || + freshProvider.oauth.oauthHost !== provider.oauth.oauthHost + ) { + return { changed, unchanged, failed }; + } + + const next = structuredClone(fresh); applyManagedKimiCodeConfig(next, { models, baseUrl: auth.baseUrl, @@ -336,23 +353,23 @@ export class OAuthService extends Disposable implements IOAuthService { preserveDefaultModel: true, }); const refreshedAliasKeys = providerRefreshAliasKeys( - current, + fresh, next, KIMI_CODE_PROVIDER_NAME, `${KIMI_CODE_PLATFORM_ID}/`, ); restoreProviderAliases( next, - preserveUserProviderAliases(current, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys), + preserveUserProviderAliases(fresh, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys), ); - restoreDefaultSelection(next, current.defaultModel, current.thinking?.enabled); + restoreDefaultSelection(next, fresh.defaultModel, fresh.thinking?.enabled); clampDanglingDefault(next); - if (providerModelsEqual(current, next, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys)) { + if (providerModelsEqual(fresh, next, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys)) { unchanged.push(KIMI_CODE_PROVIDER_NAME); } else { const { added, removed } = computeChanges( - collectModelIdsForAliases(current, refreshedAliasKeys), + collectModelIdsForAliases(fresh, refreshedAliasKeys), collectModelIdsForAliases(next, refreshedAliasKeys), ); await this.config.replace(PROVIDERS_SECTION, next.providers); @@ -646,27 +663,18 @@ export class AuthSummaryService implements IAuthSummaryService { if (Object.keys(providers).length === 0 && !isProviderlessModel(configured)) { throw new AuthProvisioningRequiredError(); } - if (modelId === undefined || modelId === '') { - throw new AuthModelNotResolvedError(undefined); - } - if (configured === undefined) { - throw new AuthModelNotResolvedError(modelId); + const resolution = resolveModelForReady(modelId, models, providers, this.providerService.getDefaultProvider()); + if (!resolution.resolved) { + throw unresolvedModelError(modelId, resolution.reason, configured); } - const model = effectiveModelConfig(configured); - const providerId = model.providerId ?? model.provider; + const model = effectiveModelConfig(configured as ModelRecord); + const providerId = model.providerId ?? model.provider ?? this.providerService.getDefaultProvider(); const provider = providerId === undefined ? undefined : this.providerService.get(providerId); - if (providerId !== undefined && provider === undefined) { - throw new AuthModelNotResolvedError(modelId, providerId); - } - - const providerName = providerId ?? providerNameFromFlatModel(model); - if (providerName === undefined) { - throw new AuthModelNotResolvedError(modelId); - } + const providerName = (providerId ?? providerNameFromFlatModel(model)) as string; const auth = resolveModelAuthMaterial({ - modelId, + modelId: modelId as string, model, provider, providerName, @@ -682,6 +690,21 @@ export class AuthSummaryService implements IAuthSummaryService { } } +function unresolvedModelError( + modelId: string | undefined, + reason: ModelReadyFailureReason, + configured: ModelRecord | undefined, +): AuthModelNotResolvedError { + if (reason === 'no-default') { + return new AuthModelNotResolvedError(undefined); + } + if (reason === 'provider-missing' && configured !== undefined) { + const model = effectiveModelConfig(configured); + return new AuthModelNotResolvedError(modelId, model.providerId ?? model.provider); + } + return new AuthModelNotResolvedError(modelId); +} + function classifyFailure(err: unknown): OAuthFlowStatus { if (err instanceof DeviceCodeTimeoutError) return 'expired'; if (err instanceof OAuthError) { @@ -700,11 +723,6 @@ function isProviderlessModel(model: ModelRecord | undefined): boolean { ); } -function providerNameFromFlatModel(model: ModelRecord): string | undefined { - const baseUrl = nonEmpty(model.baseUrl); - return baseUrl === undefined ? undefined : deriveProviderId(baseUrl); -} - interface ManagedModel { readonly provider: string; readonly model: string; @@ -804,7 +822,7 @@ function providerModelSnapshot( }); } snapshots.sort((a, b) => a.alias.localeCompare(b.alias)); - return JSON.stringify(snapshots); + return JSON.stringify({ defaultModel: config.defaultModel ?? null, models: snapshots }); } function providerRefreshAliasKeys( diff --git a/packages/agent-core-v2/src/app/authLegacy/authLegacy.ts b/packages/agent-core-v2/src/app/authLegacy/authLegacy.ts index fafcef3732a..321de8e9a49 100644 --- a/packages/agent-core-v2/src/app/authLegacy/authLegacy.ts +++ b/packages/agent-core-v2/src/app/authLegacy/authLegacy.ts @@ -17,9 +17,8 @@ export const managedProviderSummarySchema = z.object({ export type ManagedProviderSummary = z.infer; export const authSummarySchema = z.object({ - ready: z.boolean(), + models_ready: z.boolean(), providers_count: z.number().int().nonnegative(), - default_model: z.string().nullable(), managed_provider: managedProviderSummarySchema.nullable(), }); export type AuthSummary = z.infer; diff --git a/packages/agent-core-v2/src/app/authLegacy/authLegacyService.ts b/packages/agent-core-v2/src/app/authLegacy/authLegacyService.ts index b80e2c9d031..e61886ed5d3 100644 --- a/packages/agent-core-v2/src/app/authLegacy/authLegacyService.ts +++ b/packages/agent-core-v2/src/app/authLegacy/authLegacyService.ts @@ -3,8 +3,16 @@ import type { AuthSummary } from './authLegacy'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IOAuthService } from '#/app/auth/auth'; -import { IModelService } from '#/kosong/model/model'; -import { IProviderService } from '#/kosong/provider/provider'; +import { IConfigService } from '#/app/config/config'; +import { + DEFAULT_MODEL_SECTION, + DEFAULT_PROVIDER_SECTION, + MODELS_SECTION, + PROVIDERS_SECTION, +} from '#/app/kosongConfig/configSection'; +import { resolveModelForReady } from '#/kosong/model/modelAuth'; +import type { ModelRecord } from '#/kosong/model/model'; +import type { ProviderConfig } from '#/kosong/provider/provider'; import { IAuthLegacyService } from './authLegacy'; @@ -14,17 +22,22 @@ export class AuthLegacyService implements IAuthLegacyService { declare readonly _serviceBrand: undefined; constructor( - @IProviderService private readonly providerService: IProviderService, - @IModelService private readonly modelService: IModelService, + @IConfigService private readonly config: IConfigService, @IOAuthService private readonly oauth: IOAuthService, ) {} async get(): Promise { - await this.modelService.ready; + await this.config.ready; - const providers = this.providerService.list(); + const snapshot = this.config.getAll(); + const providers = (snapshot[PROVIDERS_SECTION] ?? {}) as Readonly< + Record + >; + const models = (snapshot[MODELS_SECTION] ?? {}) as Readonly>; + const defaultModel = snapshot[DEFAULT_MODEL_SECTION] as string | undefined; + const defaultProvider = snapshot[DEFAULT_PROVIDER_SECTION] as string | undefined; const providers_count = Object.keys(providers).length; - const default_model = nonEmpty(this.modelService.getDefaultModel()); + const models_ready = resolveModelForReady(defaultModel, models, providers, defaultProvider).resolved; let managed_provider: AuthSummary['managed_provider'] = null; if (providers[MANAGED_PROVIDER_NAME] !== undefined) { @@ -35,12 +48,7 @@ export class AuthLegacyService implements IAuthLegacyService { }; } - const ready = - providers_count >= 1 && - default_model !== null && - (managed_provider === null || managed_provider.status !== 'revoked'); - - return { ready, providers_count, default_model, managed_provider }; + return { models_ready, providers_count, managed_provider }; } private async managedLoggedIn(): Promise { @@ -52,12 +60,6 @@ export class AuthLegacyService implements IAuthLegacyService { } } -function nonEmpty(value: string | undefined): string | null { - if (value === undefined) return null; - const trimmed = value.trim(); - return trimmed.length === 0 ? null : trimmed; -} - registerScopedService( LifecycleScope.App, IAuthLegacyService, diff --git a/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts b/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts index ef5815b8221..82304b1df01 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts @@ -164,9 +164,15 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService { const defaultModel = this.config.inspect(DEFAULT_MODEL_SECTION).userValue; const thinking = this.config.inspect(THINKING_SECTION).userValue; + const visibleModels = withoutKeys(models, exclusion.models); + const excludedDefaultModel = exclusion.defaultModel; + const excludedDefaultRecord = + excludedDefaultModel !== undefined ? models[excludedDefaultModel] : undefined; return { providers: withoutKeys(providers, exclusion.providers) as ManagedKimiConfigShape['providers'], - models: withoutKeys(models, exclusion.models) as ManagedKimiConfigShape['models'], + models: (excludedDefaultModel !== undefined && excludedDefaultRecord !== undefined + ? { ...visibleModels, [excludedDefaultModel]: excludedDefaultRecord } + : visibleModels) as ManagedKimiConfigShape['models'], defaultModel, thinking: thinking === undefined ? undefined : { ...thinking }, }; diff --git a/packages/agent-core-v2/src/kosong/model/catalogService.ts b/packages/agent-core-v2/src/kosong/model/catalogService.ts index eec2673161c..903ebceea3d 100644 --- a/packages/agent-core-v2/src/kosong/model/catalogService.ts +++ b/packages/agent-core-v2/src/kosong/model/catalogService.ts @@ -9,7 +9,6 @@ import type { ProviderRequestAuth } from '#/kosong/contract/provider'; import type { TokenUsage } from '#/kosong/contract/usage'; import { IProtocolAdapterRegistry, - ProtocolSchema, type Protocol, type ProtocolProviderOptions, } from '#/kosong/protocol/protocol'; @@ -25,7 +24,6 @@ import { type ProviderConfig, } from '../provider/provider'; import { - explainProviderEndpoint, getProviderDefinition, resolveProviderEndpoint, } from '../provider/providerDefinition'; @@ -59,7 +57,9 @@ import { deriveProviderId, effectiveModelConfig, nonEmpty, + resolveEndpointBaseUrl, resolveModelAuthMaterial, + resolveModelProtocol, } from './modelAuth'; import { IModelOAuthTokens } from './modelOAuth'; import type { ResolvedModelAuthMaterial } from './model.types'; @@ -400,38 +400,11 @@ export class ModelCatalog extends Disposable implements IModelCatalog { `Provider "${providerId}" referenced by model "${id}" is not configured.`, ); } - const fromModel = nonEmpty(model.baseUrl); - const fromProvider = nonEmpty(providerConfig.baseUrl); - let baseUrl: string | undefined; - if (fromModel !== undefined) { - baseUrl = fromModel; - trace.record('resolved.baseUrl', { kind: 'config', detail: 'model.baseUrl' }); - } else if (fromProvider !== undefined) { - baseUrl = fromProvider; - trace.record('resolved.baseUrl', { - kind: 'config', - detail: `provider '${providerId}' baseUrl`, - }); - } else { - const endpointType = providerConfig.type ?? model.protocol; - const endpoint = - endpointType === undefined - ? {} - : explainProviderEndpoint(endpointType, providerConfig.env ?? {}); - baseUrl = nonEmpty(endpoint.baseUrl); - if (endpoint.baseUrlEnvName !== undefined) { - trace.record('resolved.baseUrl', { - kind: 'env', - detail: `${endpoint.baseUrlEnvName} (provider '${providerId}' env bag)`, - }); - } else if (endpoint.baseUrlIsDefault === true) { - trace.record('resolved.baseUrl', { - kind: 'builtin', - detail: `provider definition '${endpointType}' defaultBaseUrl`, - }); - } + const endpoint = resolveEndpointBaseUrl(model, providerConfig, providerId); + if (endpoint.source !== undefined) { + trace.record('resolved.baseUrl', endpoint.source); } - return { providerConfig, providerName: providerId, resolvedBaseUrl: baseUrl }; + return { providerConfig, providerName: providerId, resolvedBaseUrl: endpoint.baseUrl }; } const modelBaseUrl = nonEmpty(model.baseUrl); @@ -461,33 +434,15 @@ export class ModelCatalog extends Disposable implements IModelCatalog { provider: ProviderConfig | undefined, trace: ResolutionTraceCollector, ): Protocol { - if (model.protocol !== undefined) { - trace.record('resolved.protocol', { kind: 'config', detail: 'model.protocol' }); - return model.protocol; - } - const providerType = provider?.type; - if (providerType !== undefined) { - const asProtocol = ProtocolSchema.safeParse(providerType); - if (asProtocol.success) { - trace.record('resolved.protocol', { - kind: 'config', - detail: `provider type '${providerType}' is itself a wire protocol`, - }); - return asProtocol.data; - } - const definition = getProviderDefinition(providerType); - if (definition !== undefined) { - trace.record('resolved.protocol', { - kind: 'builtin', - detail: `vendor '${providerType}' declared baseProtocol`, - }); - return definition.baseProtocol; - } + const resolution = resolveModelProtocol(model, provider); + if (resolution === undefined) { + throw new Error2( + CONFIG_INVALID_ERROR_CODE, + `Model "${id}" must declare a wire protocol (config: models..protocol).`, + ); } - throw new Error2( - CONFIG_INVALID_ERROR_CODE, - `Model "${id}" must declare a wire protocol (config: models..protocol).`, - ); + trace.record('resolved.protocol', resolution.source); + return resolution.protocol; } private buildAuthProvider(providerName: string, auth: ResolvedModelAuthMaterial): AuthProvider { diff --git a/packages/agent-core-v2/src/kosong/model/modelAuth.ts b/packages/agent-core-v2/src/kosong/model/modelAuth.ts index b53d9a0213a..5fda0a9ee0d 100644 --- a/packages/agent-core-v2/src/kosong/model/modelAuth.ts +++ b/packages/agent-core-v2/src/kosong/model/modelAuth.ts @@ -1,6 +1,7 @@ import { Error2 } from '#/_base/errors/errors'; import { CONFIG_INVALID_ERROR_CODE } from '#/kosong/contract/errors'; -import type { ResolutionTrace } from '#/kosong/contract/inspection'; +import type { InspectionSource, ResolutionTrace } from '#/kosong/contract/inspection'; +import { ProtocolSchema, type Protocol } from '#/kosong/protocol/protocol'; import { BUDGET_THINKING_EFFORTS, @@ -8,7 +9,7 @@ import { matchUnknownClaudeProfile, } from '../provider/bases/anthropic/anthropic-profile'; import type { ProviderConfig } from '../provider/provider'; -import { explainProviderEndpoint } from '../provider/providerDefinition'; +import { explainProviderEndpoint, getProviderDefinition } from '../provider/providerDefinition'; import type { ModelRecord } from './model'; import type { ResolvedModelAuthMaterial } from './model.types'; @@ -136,6 +137,137 @@ export function deriveProviderId(baseUrl: string): string { } } +export function providerNameFromFlatModel(model: ModelRecord): string | undefined { + const baseUrl = nonEmpty(model.baseUrl); + return baseUrl === undefined ? undefined : deriveProviderId(baseUrl); +} + +export interface ModelProtocolResolution { + readonly protocol: Protocol; + readonly source: InspectionSource; +} + +export function resolveModelProtocol( + model: ModelRecord, + provider: ProviderConfig | undefined, +): ModelProtocolResolution | undefined { + if (model.protocol !== undefined) { + return { protocol: model.protocol, source: { kind: 'config', detail: 'model.protocol' } }; + } + const providerType = provider?.type; + if (providerType !== undefined) { + const asProtocol = ProtocolSchema.safeParse(providerType); + if (asProtocol.success) { + return { + protocol: asProtocol.data, + source: { + kind: 'config', + detail: `provider type '${providerType}' is itself a wire protocol`, + }, + }; + } + const definition = getProviderDefinition(providerType); + if (definition !== undefined) { + return { + protocol: definition.baseProtocol, + source: { kind: 'builtin', detail: `vendor '${providerType}' declared baseProtocol` }, + }; + } + } + return undefined; +} + +export interface EndpointBaseUrlResolution { + readonly baseUrl: string | undefined; + readonly source?: InspectionSource; +} + +export function resolveEndpointBaseUrl( + model: ModelRecord, + provider: ProviderConfig, + providerId: string, +): EndpointBaseUrlResolution { + const fromModel = nonEmpty(model.baseUrl); + if (fromModel !== undefined) { + return { baseUrl: fromModel, source: { kind: 'config', detail: 'model.baseUrl' } }; + } + const fromProvider = nonEmpty(provider.baseUrl); + if (fromProvider !== undefined) { + return { + baseUrl: fromProvider, + source: { kind: 'config', detail: `provider '${providerId}' baseUrl` }, + }; + } + const endpointType = provider.type ?? model.protocol; + const endpoint = + endpointType === undefined ? {} : explainProviderEndpoint(endpointType, provider.env ?? {}); + const baseUrl = nonEmpty(endpoint.baseUrl); + if (endpoint.baseUrlEnvName !== undefined) { + return { + baseUrl, + source: { + kind: 'env', + detail: `${endpoint.baseUrlEnvName} (provider '${providerId}' env bag)`, + }, + }; + } + if (endpoint.baseUrlIsDefault === true) { + return { + baseUrl, + source: { kind: 'builtin', detail: `provider definition '${endpointType}' defaultBaseUrl` }, + }; + } + return { baseUrl }; +} + +export type ModelReadyFailureReason = + | 'no-default' + | 'dangling-alias' + | 'provider-missing' + | 'unresolvable'; + +export type ModelReadyResolution = + | { readonly resolved: true } + | { readonly resolved: false; readonly reason: ModelReadyFailureReason }; + +export function resolveModelForReady( + modelId: string | undefined, + models: Readonly>, + providers: Readonly>, + defaultProvider?: string, +): ModelReadyResolution { + if (modelId === undefined || modelId.trim().length === 0) { + return { resolved: false, reason: 'no-default' }; + } + const configured = models[modelId]; + if (configured === undefined) { + return { resolved: false, reason: 'dangling-alias' }; + } + const model = effectiveModelConfig(configured); + const fallbackProvider = + defaultProvider === undefined || defaultProvider.trim().length === 0 ? undefined : defaultProvider; + const providerId = model.providerId ?? model.provider ?? fallbackProvider; + const provider = providerId === undefined ? undefined : providers[providerId]; + if (providerId !== undefined && provider === undefined) { + return { resolved: false, reason: 'provider-missing' }; + } + const providerName = providerId ?? providerNameFromFlatModel(model); + if (providerName === undefined) { + return { resolved: false, reason: 'unresolvable' }; + } + if (nonEmpty(model.name ?? model.model) === undefined) { + return { resolved: false, reason: 'unresolvable' }; + } + const maxContextSize = model.maxContextSize; + if (maxContextSize === undefined || maxContextSize <= 0) { + return { resolved: false, reason: 'unresolvable' }; + } + if (resolveModelProtocol(model, provider) === undefined) { + return { resolved: false, reason: 'unresolvable' }; + } + return { resolved: true }; +} + export function nonEmpty(value: string | undefined): string | undefined { const trimmed = value?.trim(); return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; diff --git a/packages/agent-core-v2/test/app/auth/auth.test.ts b/packages/agent-core-v2/test/app/auth/auth.test.ts index 2ff59f96ef3..d192aa1e237 100644 --- a/packages/agent-core-v2/test/app/auth/auth.test.ts +++ b/packages/agent-core-v2/test/app/auth/auth.test.ts @@ -100,7 +100,7 @@ describe('OAuthService', () => { let toolkit: FakeToolkit; let providerSet: ReturnType; let configSet: ReturnType; - let configReplace: ReturnType; + let configReplace: ReturnType Promise>>; let events: Event2[]; let providerChangedEmitter: Emitter; @@ -235,6 +235,62 @@ describe('OAuthService', () => { return fetchMock; } + const managedK2Alias: ModelRecord = { + provider: OAUTH_PROVIDER, + model: 'kimi-k2', + maxContextSize: 131072, + capabilities: ['thinking', 'tool_use'], + displayName: 'Kimi K2', + }; + + const managedK25Alias: ModelRecord = { + provider: OAUTH_PROVIDER, + model: 'kimi-k2.5', + maxContextSize: 262144, + capabilities: ['thinking', 'tool_use'], + displayName: 'Kimi K2.5', + }; + + function stubGatedManagedModelsFetch(): { + fetchMock: ReturnType; + releaseFetch: () => void; + } { + let releaseFetch!: () => void; + const gate = new Promise((resolve) => { + releaseFetch = resolve; + }); + const fetchMock = vi.fn().mockImplementation(async () => { + await gate; + return { + ok: true, + json: async () => ({ + data: [ + { + id: 'kimi-k2', + context_length: 131072, + supports_reasoning: true, + display_name: 'Kimi K2', + }, + { + id: 'kimi-k2.5', + context_length: 262144, + supports_reasoning: true, + display_name: 'Kimi K2.5', + }, + { + id: 'kimi-k3', + context_length: 1048576, + supports_reasoning: true, + display_name: 'Kimi K3', + }, + ], + }), + }; + }); + vi.stubGlobal('fetch', fetchMock); + return { fetchMock, releaseFetch }; + } + it('startLogin resolves a device-code flow and flips to authenticated on success', async () => { stubManagedModelsFetch(); toolkit.login.mockImplementation((_provider, options) => { @@ -924,6 +980,171 @@ describe('OAuthService', () => { expect(maxInFlight).toBe(1); expect(fetchMock).toHaveBeenCalledTimes(2); }); + + it('aborts the refresh write when the managed provider was edited mid-fetch', async () => { + let resolveFetch!: (value: unknown) => void; + const fetchMock = vi.fn(() => new Promise((resolve) => { resolveFetch = resolve; })); + vi.stubGlobal('fetch', fetchMock); + const svc = createService(); + + const pending = svc.refreshOAuthProviderModels(); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled()); + providers = { + ...providers, + [OAUTH_PROVIDER]: { ...providers[OAUTH_PROVIDER]!, baseUrl: 'https://api.changed.example.com' }, + }; + resolveFetch({ + ok: true, + json: async () => ({ + data: [ + { + id: 'kimi-k2', + context_length: 131072, + supports_reasoning: true, + display_name: 'Kimi K2', + }, + ], + }), + }); + + await expect(pending).resolves.toEqual({ changed: [], unchanged: [], failed: [] }); + expect(configReplace).not.toHaveBeenCalled(); + expect(providers[OAUTH_PROVIDER]?.baseUrl).toBe('https://api.changed.example.com'); + }); + + it('rewrites a lost default model on refresh even when the catalog is unchanged', async () => { + stubManagedModelsFetch(); + const svc = createService(); + + const first = await svc.refreshOAuthProviderModels(); + expect(first.changed).toHaveLength(1); + expect(defaultModel).toBe('kimi-code/kimi-k2'); + + configReplace.mockClear(); + events.length = 0; + defaultModel = undefined; + + const second = await svc.refreshOAuthProviderModels(); + + expect(second.failed).toEqual([]); + expect(second.unchanged).toEqual([]); + expect(second.changed).toEqual([ + { + provider_id: OAUTH_PROVIDER, + provider_name: 'Kimi Code', + added: 0, + removed: 0, + }, + ]); + expect(configReplace).toHaveBeenCalledWith('defaultModel', 'kimi-code/kimi-k2'); + expect(defaultModel).toBe('kimi-code/kimi-k2'); + expect(events).toEqual([ + expect.objectContaining({ + type: 'event.model_catalog.changed', + payload: second, + }), + ]); + }); + + it('reports unchanged on refresh when the catalog and the default model are both intact', async () => { + stubManagedModelsFetch(); + const svc = createService(); + + await svc.refreshOAuthProviderModels(); + expect(defaultModel).toBe('kimi-code/kimi-k2'); + + configReplace.mockClear(); + events.length = 0; + + const second = await svc.refreshOAuthProviderModels(); + + expect(second).toEqual({ changed: [], unchanged: [OAUTH_PROVIDER], failed: [] }); + expect(configReplace).not.toHaveBeenCalled(); + expect(events).toEqual([]); + }); + + it('keeps the default model the user selects while a refresh is in flight', async () => { + const { fetchMock, releaseFetch } = stubGatedManagedModelsFetch(); + models = { + 'kimi-code/kimi-k2': managedK2Alias, + 'kimi-code/kimi-k2.5': managedK25Alias, + }; + defaultModel = 'kimi-code/kimi-k2'; + const svc = createService(); + + const refresh = svc.refreshOAuthProviderModels(); + await vi.waitFor(() => { expect(fetchMock).toHaveBeenCalled(); }); + await configReplace('defaultModel', 'kimi-code/kimi-k2.5'); + releaseFetch(); + const result = await refresh; + + expect(result.failed).toEqual([]); + expect(result.changed).toEqual([ + { + provider_id: OAUTH_PROVIDER, + provider_name: 'Kimi Code', + added: 1, + removed: 0, + }, + ]); + expect(configReplace).toHaveBeenCalledWith('defaultModel', 'kimi-code/kimi-k2.5'); + expect(defaultModel).toBe('kimi-code/kimi-k2.5'); + }); + + it('writes back the refreshed catalog and default when the user does not intervene mid-flight', async () => { + const { fetchMock, releaseFetch } = stubGatedManagedModelsFetch(); + models = { + 'kimi-code/kimi-k2': managedK2Alias, + 'kimi-code/kimi-k2.5': managedK25Alias, + }; + defaultModel = 'kimi-code/kimi-k2'; + const svc = createService(); + + const refresh = svc.refreshOAuthProviderModels(); + await vi.waitFor(() => { expect(fetchMock).toHaveBeenCalled(); }); + releaseFetch(); + const result = await refresh; + + expect(result.failed).toEqual([]); + expect(result.changed).toEqual([ + { + provider_id: OAUTH_PROVIDER, + provider_name: 'Kimi Code', + added: 1, + removed: 0, + }, + ]); + expect(configReplace).toHaveBeenCalledWith( + 'models', + expect.objectContaining({ + 'kimi-code/kimi-k3': expect.objectContaining({ model: 'kimi-k3' }), + }), + ); + expect(configReplace).toHaveBeenCalledWith('defaultModel', 'kimi-code/kimi-k2'); + expect(defaultModel).toBe('kimi-code/kimi-k2'); + }); + + it('keeps the thinking selection the user makes while a refresh is in flight', async () => { + const { fetchMock, releaseFetch } = stubGatedManagedModelsFetch(); + models = { + 'kimi-code/kimi-k2': managedK2Alias, + 'kimi-code/kimi-k2.5': managedK25Alias, + }; + defaultModel = 'kimi-code/kimi-k2'; + thinking = { enabled: true }; + const svc = createService(); + + const refresh = svc.refreshOAuthProviderModels(); + await vi.waitFor(() => { expect(fetchMock).toHaveBeenCalled(); }); + await configReplace('thinking', { enabled: false }); + releaseFetch(); + const result = await refresh; + + expect(result.failed).toEqual([]); + expect(result.changed).toHaveLength(1); + expect(configReplace).toHaveBeenCalledWith('thinking', { enabled: false }); + expect(thinking).toEqual({ enabled: false }); + }); }); describe('WebSearchProviderService', () => { @@ -1292,12 +1513,14 @@ describe('AuthSummaryService', () => { let providers: Record; let models: Record; let defaultModel: string | undefined; + let defaultProvider: string | undefined; let oauthStatus: ReturnType; let getCachedAccessToken: ReturnType; let reload: ReturnType; beforeEach(() => { disposables = new DisposableStore(); + defaultProvider = undefined; providers = { [OAUTH_PROVIDER]: { type: 'kimi', @@ -1328,6 +1551,7 @@ describe('AuthSummaryService', () => { reg.definePartialInstance(IProviderService, { get: ((name: string) => providers[name]) as IProviderService['get'], list: (() => providers) as IProviderService['list'], + getDefaultProvider: (() => defaultProvider) as IProviderService['getDefaultProvider'], }); reg.definePartialInstance(IModelService, { get: ((id: string) => models[id]) as IModelService['get'], @@ -1444,6 +1668,17 @@ describe('AuthSummaryService', () => { expect(getCachedAccessToken).not.toHaveBeenCalled(); }); + it('ensureReady resolves a providerless model through the configured defaultProvider', async () => { + models = { + flat: { model: 'gpt-4.1', protocol: 'openai', maxContextSize: 128000 }, + }; + defaultModel = 'flat'; + defaultProvider = NON_OAUTH_PROVIDER; + + await expect(createSummary().ensureReady()).resolves.toBeUndefined(); + expect(getCachedAccessToken).not.toHaveBeenCalled(); + }); + it('ensureReady accepts cached oauth tokens', async () => { getCachedAccessToken.mockResolvedValue('access-token'); await expect(createSummary().ensureReady('kimi')).resolves.toBeUndefined(); @@ -1458,27 +1693,30 @@ describe('AuthLegacyService', () => { let disposables: DisposableStore; let ix: TestInstantiationService; let providers: Record; + let models: Record; let defaultModel: string | undefined; let oauthStatus: ReturnType; + let configReady: Promise; + let configReload: ReturnType; beforeEach(() => { disposables = new DisposableStore(); providers = {}; + models = {}; defaultModel = undefined; oauthStatus = vi.fn(); + configReady = Promise.resolve(); + configReload = vi.fn().mockResolvedValue(undefined); ix = createServices(disposables, { additionalServices: (reg) => { - reg.definePartialInstance(IProviderService, { - list: (() => providers) as IProviderService['list'], - }); - reg.definePartialInstance(IModelService, { - ready: Promise.resolve(), - getDefaultModel: (() => defaultModel) as IModelService['getDefaultModel'], - }); reg.definePartialInstance(IConfigService, { - ready: Promise.resolve(), - get: ((domain: string) => - domain === 'defaultModel' ? defaultModel : undefined) as IConfigService['get'], + ready: configReady, + getAll: (() => ({ + providers, + models, + defaultModel, + })) as IConfigService['getAll'], + reload: configReload as unknown as IConfigService['reload'], }); reg.definePartialInstance(IOAuthService, { status: oauthStatus as unknown as IOAuthService['status'], @@ -1495,9 +1733,8 @@ describe('AuthLegacyService', () => { it('returns an empty snapshot when no providers are configured', async () => { await expect(createService().get()).resolves.toEqual({ - ready: false, + models_ready: false, providers_count: 0, - default_model: null, managed_provider: null, }); expect(oauthStatus).not.toHaveBeenCalled(); @@ -1513,22 +1750,53 @@ describe('AuthLegacyService', () => { expect(summary.providers_count).toBe(2); }); - it('reflects the configured default model', async () => { + it('reports models_ready when the default model resolves to a configured provider', async () => { providers = { [NON_OAUTH_PROVIDER]: { type: 'kimi', apiKey: 'sk-test' } }; + models = { k2: { provider: NON_OAUTH_PROVIDER, model: 'kimi-k2', maxContextSize: 128000 } }; defaultModel = 'k2'; const summary = await createService().get(); - expect(summary.default_model).toBe('k2'); + expect(summary.models_ready).toBe(true); expect(summary.managed_provider).toBeNull(); - expect(summary.ready).toBe(true); }); - it('is not ready when a provider exists but no default model is set', async () => { + it('is not models_ready when a provider exists but no default model is set', async () => { providers = { [NON_OAUTH_PROVIDER]: { type: 'kimi', apiKey: 'sk-test' } }; + models = { k2: { provider: NON_OAUTH_PROVIDER, model: 'kimi-k2' } }; const summary = await createService().get(); expect(summary.providers_count).toBe(1); - expect(summary.default_model).toBeNull(); + expect(summary.models_ready).toBe(false); expect(summary.managed_provider).toBeNull(); - expect(summary.ready).toBe(false); + }); + + it('is not models_ready when the default model dangles', async () => { + providers = { [NON_OAUTH_PROVIDER]: { type: 'kimi', apiKey: 'sk-test' } }; + models = { k2: { provider: NON_OAUTH_PROVIDER, model: 'kimi-k2' } }; + defaultModel = 'gone'; + const summary = await createService().get(); + expect(summary.models_ready).toBe(false); + }); + + it('is not models_ready when the default model points at a missing provider', async () => { + providers = { [NON_OAUTH_PROVIDER]: { type: 'kimi', apiKey: 'sk-test' } }; + models = { k2: { provider: 'ghost', model: 'kimi-k2' } }; + defaultModel = 'k2'; + const summary = await createService().get(); + expect(summary.models_ready).toBe(false); + }); + + it('reports models_ready for a providerless flat default model', async () => { + models = { + flat: { + baseUrl: 'https://api.example.test/v1', + model: 'gpt', + protocol: 'openai', + maxContextSize: 128000, + apiKey: 'sk-x', + }, + }; + defaultModel = 'flat'; + const summary = await createService().get(); + expect(summary.models_ready).toBe(true); }); it('surfaces managed_provider.unauthenticated when configured without a cached token', async () => { @@ -1541,13 +1809,14 @@ describe('AuthLegacyService', () => { name: OAUTH_PROVIDER, status: 'unauthenticated', }); - expect(summary.ready).toBe(false); + expect(summary.models_ready).toBe(false); }); it('surfaces managed_provider.authenticated when a cached token exists', async () => { providers = { [OAUTH_PROVIDER]: { type: 'kimi', oauth: { storage: 'file', key: 'oauth/kimi-code' } }, }; + models = { k2: { provider: OAUTH_PROVIDER, model: 'kimi-k2', maxContextSize: 128000 } }; defaultModel = 'k2'; oauthStatus.mockResolvedValue({ loggedIn: true, provider: OAUTH_PROVIDER }); const summary = await createService().get(); @@ -1555,7 +1824,7 @@ describe('AuthLegacyService', () => { name: OAUTH_PROVIDER, status: 'authenticated', }); - expect(summary.ready).toBe(true); + expect(summary.models_ready).toBe(true); }); it('treats a throwing oauth status as unauthenticated', async () => { @@ -1567,4 +1836,40 @@ describe('AuthLegacyService', () => { managed_provider: { name: OAUTH_PROVIDER, status: 'unauthenticated' }, }); }); + + it('waits for config readiness before reading the snapshot', async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const svc = new AuthLegacyService( + { + ready: gate, + getAll: () => ({ providers, models, defaultModel }), + } as unknown as IConfigService, + { status: oauthStatus } as unknown as IOAuthService, + ); + const pending = svc.get(); + let settled = false; + void pending.then(() => { + settled = true; + }); + await flush(); + expect(settled).toBe(false); + providers = { [NON_OAUTH_PROVIDER]: { type: 'openai', apiKey: 'sk-test' } }; + models = { k2: { provider: NON_OAUTH_PROVIDER, model: 'kimi-k2', maxContextSize: 128000 } }; + defaultModel = 'k2'; + release(); + await expect(pending).resolves.toMatchObject({ models_ready: true }); + }); + + it('re-reads the snapshot on every call without forcing a reload', async () => { + providers = { [NON_OAUTH_PROVIDER]: { type: 'openai', apiKey: 'sk-test' } }; + const svc = createService(); + await expect(svc.get()).resolves.toMatchObject({ models_ready: false }); + models = { k2: { provider: NON_OAUTH_PROVIDER, model: 'kimi-k2', maxContextSize: 128000 } }; + defaultModel = 'k2'; + await expect(svc.get()).resolves.toMatchObject({ models_ready: true }); + expect(configReload).not.toHaveBeenCalled(); + }); }); diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index aa9e5f5f412..343b93a1f8e 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -67,6 +67,10 @@ import { THINKING_SECTION, } from '#/app/kosongConfig/configSection'; import '#/app/kosongConfig/envOverlay'; +import '#/kosong/provider/providers/kimi/kimi.contrib'; +import { IOAuthService } from '#/app/auth/auth'; +import { IAuthLegacyService } from '#/app/authLegacy/authLegacy'; +import { AuthLegacyService } from '#/app/authLegacy/authLegacyService'; import { type ThinkingConfig } from '#/kosong/model/thinking'; import { KEEP_ALIVE_ON_EXIT_ENV, @@ -590,6 +594,30 @@ describe('ConfigService env overlay (live)', () => { disposables.dispose(); }); + + it('marks the env-injected flat model ready in the auth legacy summary', async () => { + const env: Record = { KIMI_MODEL_NAME: 'kimi-for-coding' }; + const disposables = new DisposableStore(); + const ix = disposables.add(new TestInstantiationService()); + ix.stub(ILogService, stubLog()); + ix.stub(IBootstrapService, stubBootstrap('/tmp/kimi-cfg', env)); + ix.stub(IFileSystemStorageService, new InMemoryStorageService()); + ix.stub(IOAuthService, { status: vi.fn() } as unknown as IOAuthService); + ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); + ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); + ix.set(IConfigService, new SyncDescriptor(ConfigService)); + ix.set(IAuthLegacyService, new SyncDescriptor(AuthLegacyService)); + + const summary = await ix.get(IAuthLegacyService).get(); + + expect(summary).toEqual({ + models_ready: true, + providers_count: 1, + managed_provider: null, + }); + + disposables.dispose(); + }); }); describe('services config section env bindings', () => { diff --git a/packages/agent-core-v2/test/app/kosongConfig/discovery.test.ts b/packages/agent-core-v2/test/app/kosongConfig/discovery.test.ts index 042dfdedbd7..a4663c220ad 100644 --- a/packages/agent-core-v2/test/app/kosongConfig/discovery.test.ts +++ b/packages/agent-core-v2/test/app/kosongConfig/discovery.test.ts @@ -565,6 +565,203 @@ describe('refreshProviderModels write behavior', () => { }); }); +describe('refreshProviderModels defaultModel self-heal', () => { + const managedProviders = { + [KIMI_CODE_PROVIDER_NAME]: { + type: 'kimi', + baseUrl: 'https://api.example.test/v1', + oauth: { storage: 'file', key: 'oauth/kimi-code' }, + }, + }; + + const managedModels = { + 'kimi-code/kimi-k2': { + provider: KIMI_CODE_PROVIDER_NAME, + model: 'kimi-k2', + maxContextSize: 131072, + capabilities: ['thinking', 'tool_use'], + displayName: 'Kimi K2', + }, + }; + + function stubManagedCatalogFetch(): void { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response( + JSON.stringify({ + data: [ + { + id: 'kimi-k2', + context_length: 131072, + supports_reasoning: true, + display_name: 'Kimi K2', + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ), + ); + } + + it('rewrites a missing defaultModel even when the catalog is unchanged', async () => { + stubManagedCatalogFetch(); + const { host, config, discovery, events, models } = await createHost( + { + providers: managedProviders, + models: managedModels, + }, + stubOAuthService(stubTokenProvider(['access-token'])), + ); + try { + const replaceSections = vi.spyOn(config, 'replaceSections'); + const result = await discovery.refreshProviderModels({ scope: 'all' }); + + expect(result.failed).toEqual([]); + expect(result.unchanged).toEqual([]); + expect(result.changed).toEqual([ + { provider_id: KIMI_CODE_PROVIDER_NAME, provider_name: 'Kimi Code', added: 0, removed: 0 }, + ]); + expect(replaceSections).toHaveBeenCalledTimes(1); + expect(config.get('defaultModel')).toBe('kimi-code/kimi-k2'); + expect(config.get('thinking')).toEqual({ enabled: true }); + expect(models.list()['kimi-code/kimi-k2']).toBeDefined(); + expect(events.published).toEqual([ + expect.objectContaining({ type: 'event.model_catalog.changed' }), + ]); + } finally { + host.dispose(); + } + }); + + it('keeps a default model the user selected while the catalog fetch was in flight', async () => { + const twoModels = { + 'kimi-code/kimi-k2': { + provider: KIMI_CODE_PROVIDER_NAME, + model: 'kimi-k2', + maxContextSize: 131072, + capabilities: ['thinking', 'tool_use'], + displayName: 'Kimi K2', + }, + 'kimi-code/kimi-k3': { + provider: KIMI_CODE_PROVIDER_NAME, + model: 'kimi-k3', + maxContextSize: 131072, + capabilities: ['thinking', 'tool_use'], + displayName: 'Kimi K3', + }, + }; + const { host, config, discovery, events } = await createHost( + { + providers: managedProviders, + models: twoModels, + }, + stubOAuthService(stubTokenProvider(['access-token'])), + ); + try { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => { + await config.set('defaultModel', 'kimi-code/kimi-k3'); + return new Response( + JSON.stringify({ + data: [ + { + id: 'kimi-k2', + context_length: 131072, + supports_reasoning: true, + display_name: 'Kimi K2', + }, + { + id: 'kimi-k3', + context_length: 131072, + supports_reasoning: true, + display_name: 'Kimi K3', + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }, + ), + ); + const replaceSections = vi.spyOn(config, 'replaceSections'); + const result = await discovery.refreshProviderModels({ scope: 'all' }); + + expect(result).toEqual({ + changed: [], + unchanged: [KIMI_CODE_PROVIDER_NAME], + failed: [], + }); + expect(replaceSections).not.toHaveBeenCalled(); + expect(events.published).toEqual([]); + expect(config.get('defaultModel')).toBe('kimi-code/kimi-k3'); + } finally { + host.dispose(); + } + }); + + it('reports unchanged and skips writes when the catalog and defaultModel are intact', async () => { + stubManagedCatalogFetch(); + const { host, config, discovery, events } = await createHost( + { + providers: managedProviders, + models: managedModels, + defaultModel: 'kimi-code/kimi-k2', + thinking: { enabled: true }, + }, + stubOAuthService(stubTokenProvider(['access-token'])), + ); + try { + const replaceSections = vi.spyOn(config, 'replaceSections'); + const result = await discovery.refreshProviderModels({ scope: 'all' }); + + expect(result).toEqual({ + changed: [], + unchanged: [KIMI_CODE_PROVIDER_NAME], + failed: [], + }); + expect(replaceSections).not.toHaveBeenCalled(); + expect(events.published).toEqual([]); + expect(config.get('defaultModel')).toBe('kimi-code/kimi-k2'); + } finally { + host.dispose(); + } + }); + + it('reports unchanged when the defaultModel belongs to a static provider', async () => { + stubManagedCatalogFetch(); + const { host, config, discovery, events } = await createHost( + { + providers: { ...staticProviders, ...managedProviders }, + models: { ...staticModels, ...managedModels }, + defaultModel: 's1', + thinking: { enabled: false }, + }, + stubOAuthService(stubTokenProvider(['access-token'])), + ); + try { + const replaceSections = vi.spyOn(config, 'replaceSections'); + const result = await discovery.refreshProviderModels({ scope: 'all' }); + + expect(result).toEqual({ + changed: [], + unchanged: [KIMI_CODE_PROVIDER_NAME], + failed: [], + }); + expect(replaceSections).not.toHaveBeenCalled(); + expect(events.published).toEqual([]); + expect(config.get('defaultModel')).toBe('s1'); + expect(config.get('thinking')).toEqual({ enabled: false }); + } finally { + host.dispose(); + } + }); +}); + describe('modelCatalog config section', () => { it('self-registers and validates', () => { const registry = new ConfigRegistry(); diff --git a/packages/agent-core-v2/test/kosong/model/modelAuth.test.ts b/packages/agent-core-v2/test/kosong/model/modelAuth.test.ts index 7536c048219..d5acf400a43 100644 --- a/packages/agent-core-v2/test/kosong/model/modelAuth.test.ts +++ b/packages/agent-core-v2/test/kosong/model/modelAuth.test.ts @@ -9,6 +9,7 @@ import { deriveProviderId, effectiveModelConfig, resolveModelAuthMaterial, + resolveModelForReady, } from '#/kosong/model/modelAuth'; function authMaterial(args: { @@ -134,3 +135,179 @@ describe('deriveProviderId', () => { expect(deriveProviderId('not-a-url')).toBe('not-a-url'); }); }); + +describe('resolveModelForReady', () => { + const providers: Readonly> = { + 'prov-a': { type: 'openai', apiKey: 'sk-a' }, + '__kimi_env__': { type: 'kimi', baseUrl: 'https://api.example.test/coding/v1' }, + }; + + it('reports no-default when the model id is missing or empty', () => { + expect(resolveModelForReady(undefined, {}, providers)).toEqual({ + resolved: false, + reason: 'no-default', + }); + expect(resolveModelForReady('', {}, providers)).toEqual({ + resolved: false, + reason: 'no-default', + }); + expect(resolveModelForReady(' ', {}, providers)).toEqual({ + resolved: false, + reason: 'no-default', + }); + }); + + it('reports dangling-alias when the alias is absent from the models table', () => { + expect(resolveModelForReady('ghost', {}, providers)).toEqual({ + resolved: false, + reason: 'dangling-alias', + }); + }); + + it('looks up the configured id as an exact key, trimming only to reject blanks', () => { + const models = { m: { providerId: 'prov-a', model: 'gpt', maxContextSize: 4096 } }; + expect(resolveModelForReady(' m ', models, providers)).toEqual({ + resolved: false, + reason: 'dangling-alias', + }); + const padded = { ' m ': { providerId: 'prov-a', model: 'gpt', maxContextSize: 4096 } }; + expect(resolveModelForReady(' m ', padded, providers)).toEqual({ resolved: true }); + }); + + it('resolves a providerId pointing at an existing provider', () => { + const models = { m: { providerId: 'prov-a', model: 'gpt', maxContextSize: 4096 } }; + expect(resolveModelForReady('m', models, providers)).toEqual({ resolved: true }); + }); + + it('resolves a provider field pointing at an existing provider', () => { + const models = { m: { provider: 'prov-a', model: 'gpt', maxContextSize: 4096 } }; + expect(resolveModelForReady('m', models, providers)).toEqual({ resolved: true }); + }); + + it('reports provider-missing when a named provider is absent from the providers table', () => { + expect( + resolveModelForReady('m', { m: { providerId: 'gone', model: 'gpt' } }, providers), + ).toEqual({ resolved: false, reason: 'provider-missing' }); + expect( + resolveModelForReady('m', { m: { provider: 'gone', model: 'gpt' } }, providers), + ).toEqual({ resolved: false, reason: 'provider-missing' }); + }); + + it('resolves a providerless flat model through its baseUrl', () => { + const models = { + m: { + baseUrl: 'https://api.example.test/v1', + model: 'gpt', + protocol: 'openai' as const, + maxContextSize: 4096, + apiKey: 'sk-x', + }, + }; + expect(resolveModelForReady('m', models, {})).toEqual({ resolved: true }); + }); + + it('resolves a model omitting provider fields through the configured defaultProvider', () => { + const models = { m: { model: 'gpt', maxContextSize: 4096 } }; + expect(resolveModelForReady('m', models, providers, 'prov-a')).toEqual({ resolved: true }); + }); + + it('reports provider-missing when the configured defaultProvider is absent from the providers table', () => { + const models = { m: { model: 'gpt', maxContextSize: 4096 } }; + expect(resolveModelForReady('m', models, providers, 'gone')).toEqual({ + resolved: false, + reason: 'provider-missing', + }); + }); + + it('looks up the default provider as an exact key, trimming only to reject blanks', () => { + const models = { m: { model: 'gpt', maxContextSize: 4096 } }; + expect(resolveModelForReady('m', models, providers, ' prov-a ')).toEqual({ + resolved: false, + reason: 'provider-missing', + }); + const paddedProviders = { ' prov-a ': { type: 'openai', apiKey: 'sk-a' } }; + expect(resolveModelForReady('m', models, paddedProviders, ' prov-a ')).toEqual({ + resolved: true, + }); + expect(resolveModelForReady('m', models, providers, ' ')).toEqual({ + resolved: false, + reason: 'unresolvable', + }); + }); + + it('reports unresolvable when provider id, provider field, and baseUrl are all absent', () => { + expect(resolveModelForReady('m', { m: { model: 'gpt' } }, providers)).toEqual({ + resolved: false, + reason: 'unresolvable', + }); + }); + + it('resolves the env-overlay injected model against the env provider', () => { + const models = { + '__kimi_env_model__': { + provider: '__kimi_env__', + model: 'kimi-for-coding', + maxContextSize: 262144, + }, + }; + expect(resolveModelForReady('__kimi_env_model__', models, providers)).toEqual({ + resolved: true, + }); + }); + + it('reports unresolvable when the provider exists but the wire name is missing', () => { + const models = { m: { provider: 'prov-a' } }; + expect(resolveModelForReady('m', models, providers)).toEqual({ + resolved: false, + reason: 'unresolvable', + }); + }); + + it('resolves through the effective config with overrides merged', () => { + const models = { + m: { + provider: 'prov-a', + model: 'gpt', + overrides: { maxContextSize: 4096, displayName: 'G' }, + }, + }; + expect(resolveModelForReady('m', models, providers)).toEqual({ resolved: true }); + }); + + it('reports unresolvable when the provider-backed model lacks maxContextSize', () => { + const models = { m: { provider: 'prov-a', model: 'gpt' } }; + expect(resolveModelForReady('m', models, providers)).toEqual({ + resolved: false, + reason: 'unresolvable', + }); + }); + + it('reports unresolvable when maxContextSize is not positive', () => { + const models = { m: { provider: 'prov-a', model: 'gpt', maxContextSize: 0 } }; + expect(resolveModelForReady('m', models, providers)).toEqual({ + resolved: false, + reason: 'unresolvable', + }); + }); + + it('reports unresolvable when a providerless flat model lacks a protocol', () => { + const models = { + m: { baseUrl: 'https://api.example.test/v1', model: 'gpt', maxContextSize: 4096 }, + }; + expect(resolveModelForReady('m', models, {})).toEqual({ + resolved: false, + reason: 'unresolvable', + }); + }); + + it('reports unresolvable when neither endpoint nor protocol is derivable from the provider', () => { + const models = { m: { provider: 'prov-x', model: 'gpt', maxContextSize: 4096 } }; + const unknownVendors: Readonly> = { + 'prov-x': { type: 'my-vendor', apiKey: 'sk-x' }, + }; + expect(resolveModelForReady('m', models, unknownVendors)).toEqual({ + resolved: false, + reason: 'unresolvable', + }); + }); +}); diff --git a/packages/agent-core/src/services/authSummary/authSummary.ts b/packages/agent-core/src/services/authSummary/authSummary.ts index ae4f7c214cf..79c0df68d29 100644 --- a/packages/agent-core/src/services/authSummary/authSummary.ts +++ b/packages/agent-core/src/services/authSummary/authSummary.ts @@ -9,11 +9,12 @@ * classes below; daemon route layers map them to envelope codes * `40110 / 40111 / 40112 / 40113`. * - * Why centralized: the same "is there a usable provider + model + token?" - * computation is needed by both the read probe and every write-side entry that - * could surface 50001 "internal" today. Co-locating it keeps the - * logic in one place + makes it cheap to add new gated entries (PATCH session - * model, etc.). + * Orthogonal signals: `get()` reports config-side readiness + * (`models_ready` — the configured default model resolves) plus the managed + * credential status (`managed_provider`), while `ensureReady()` is the + * write-side credential gate. Co-locating them keeps the config/model + * resolution logic in one place + makes it cheap to add new gated entries + * (PATCH session model, etc.). * * Status mapping note: we only return `'authenticated'` (token cached) or * `'unauthenticated'` (no token). The `'expired' / 'revoked'` states require @@ -34,8 +35,12 @@ export interface IAuthSummaryService { readonly _serviceBrand: undefined; /** - * Compute the current readiness snapshot. Cheap (one config read + one - * cached-token lookup); safe to call on every `GET /v1/auth`. + * Compute the current auth snapshot: `models_ready` (config-side — the + * default model resolves, no credential check) and `managed_provider` + * (managed OAuth login status from the cached token). The two are + * orthogonal: after logout, third-party defaults keep `models_ready: true`. + * Cheap (one config read + one cached-token lookup); safe to call on every + * `GET /v1/auth`. */ get(): Promise; diff --git a/packages/agent-core/src/services/authSummary/authSummaryService.ts b/packages/agent-core/src/services/authSummary/authSummaryService.ts index b9a3c5af135..b3840dbbf79 100644 --- a/packages/agent-core/src/services/authSummary/authSummaryService.ts +++ b/packages/agent-core/src/services/authSummary/authSummaryService.ts @@ -37,7 +37,6 @@ export class AuthSummaryService const config = await this._readConfig(); const providers = config.providers ?? {}; const providers_count = Object.keys(providers).length; - const default_model = nonEmpty(config.defaultModel); let managed_provider: AuthSummary['managed_provider'] = null; if (providers[MANAGED_PROVIDER_NAME] !== undefined) { @@ -48,12 +47,11 @@ export class AuthSummaryService }; } - const ready = - providers_count >= 1 && - default_model !== null && - (managed_provider === null || managed_provider.status !== 'revoked'); - - return { ready, providers_count, default_model, managed_provider }; + return { + models_ready: resolvesReadyModel(config), + providers_count, + managed_provider, + }; } async ensureReady(modelOverride?: string): Promise { @@ -116,7 +114,7 @@ export class AuthSummaryService // future RW endpoints land on disk via `writeConfigFile`, but // KimiCore's `this.config` only refreshes when something explicitly // asks for `reload`. Without this flag, `GET /v1/auth` would stay - // `ready:false` for the entire daemon lifetime after first login. + // `models_ready:false` for the entire daemon lifetime after first login. return this.core.rpc.getKimiConfig({ reload: true }); } @@ -139,6 +137,19 @@ function nonEmpty(value: string | undefined): string | null { return trimmed.length === 0 ? null : trimmed; } +// Mirrors the resolution segment of `ensureReady` below (alias → provider +// lookup, no credential probe) so the read probe and the write gate agree on +// what "a usable model" means. +function resolvesReadyModel(config: KimiConfig): boolean { + const modelId = config.defaultModel; + if (modelId === undefined || modelId === '') return false; + const alias = config.models?.[modelId]; + if (alias === undefined) return false; + const providerName = alias.provider ?? config.defaultProvider; + if (providerName === undefined || providerName === '') return false; + return (config.providers ?? {})[providerName] !== undefined; +} + // Self-register under the global singleton registry. All ctor deps are // `@I…`-injected (@IEnvironmentService / @ICoreProcessService); // `staticArguments = []`. `supportsDelayedInstantiation = false` preserves diff --git a/packages/agent-core/src/services/oauth/oauth.ts b/packages/agent-core/src/services/oauth/oauth.ts index 58871bac772..d244142dd1e 100644 --- a/packages/agent-core/src/services/oauth/oauth.ts +++ b/packages/agent-core/src/services/oauth/oauth.ts @@ -21,7 +21,8 @@ * **Token + config** land via the toolkit's provisioning path: on success, * the `managed:kimi-code` provider + models entry are written to * `config.toml`, and the cached token is saved to credentials. Frontend - * follow-up: hit `GET /v1/auth` to confirm `ready: true`. + * follow-up: hit `GET /v1/auth` to confirm + * `managed_provider.status: 'authenticated'` and `models_ready: true`. * * **Architecture**: * @@ -97,8 +98,9 @@ export interface IOAuthService { /** * Logout — delete the stored token + strip the managed provider's - * `apply` config entries (provider + models). After this, `GET /v1/auth` - * flips to `ready: false`. + * `apply` config entries (provider + models). Afterwards `managed_provider` + * reports `unauthenticated`; `models_ready` only drops when the default + * model itself was managed — third-party defaults keep it `true`. */ logout(providerName?: string): Promise; } diff --git a/packages/agent-core/test/services/model-catalog-service.test.ts b/packages/agent-core/test/services/model-catalog-service.test.ts index 9c93d864796..c36e1b36234 100644 --- a/packages/agent-core/test/services/model-catalog-service.test.ts +++ b/packages/agent-core/test/services/model-catalog-service.test.ts @@ -479,6 +479,7 @@ describe('ModelCatalogService', () => { oauth: { storage: 'file', key: 'oauth/kimi-code' }, }, }, + defaultModel: 'kimi-code/kimi-for-coding', models: { 'kimi-code/kimi-for-coding': { provider: KIMI_CODE_PROVIDER_NAME, diff --git a/packages/agent-core/test/services/prompt-service.test.ts b/packages/agent-core/test/services/prompt-service.test.ts index 449f28b5146..cfb67a60664 100644 --- a/packages/agent-core/test/services/prompt-service.test.ts +++ b/packages/agent-core/test/services/prompt-service.test.ts @@ -282,9 +282,8 @@ function makeBus(): { function makeAuth(opts: { ensureReadyError?: Error } = {}): IAuthSummaryService { return { get: vi.fn().mockResolvedValue({ - ready: true, + models_ready: true, providers_count: 1, - default_model: 'kimi-k2', managed_provider: null, }), ensureReady: vi.fn().mockImplementation(async () => { diff --git a/packages/kap-server/AGENTS.md b/packages/kap-server/AGENTS.md index 0f2de6212c9..05ec5708bbe 100644 --- a/packages/kap-server/AGENTS.md +++ b/packages/kap-server/AGENTS.md @@ -14,7 +14,7 @@ No comments — no file headers, no section banners, no statement-level narratio ## `/api/v2` surface -`GET /api/v2/sessions` (`src/routes/v2/sessions.ts`, mounted by `src/routes/registerApiV2Routes.ts`) is the first endpoint of the v2 API. The v2 surface shares v1's wire conventions: every response is wrapped in the `{ code, msg, data, request_id }` envelope with the business outcome in `code` (`40001` invalid query params with `details`, `40922` page_token mismatch), and the HTTP status only reports server-/transport-level outcomes (401 from the global auth hook, `50001` via the catch-all error hook). Pagination is an opaque `page_token` (base64url JSON: version + sha256 query-condition fingerprint + keyset position) — any condition flip mid-pagination fails 40922. Response domains are grouped (`workspace` / `meta` / `activity` always; `git` opt-in via `include=git`, deduped per unique cwd with a 60s TTL cache over `IGitService`, all git/gh failures degrading to cached null fields). Sorts/filters are applied at the edge over the index's canonical `updatedAt desc, id desc` drain, so all three sort orders share one comparator + cursor encoding; `activity.status` maps the core `ISessionActivityView` facts (pending interaction > active turn > failed last turn > idle; cold sessions are always `idle`). `meta.has_prompt=true|false` filters by prompt presence (the v1 `exclude_empty` equivalent). `view=by_workspace` re-projects the same filtered/sorted drain into per-workspace groups — each group carries the workspace's first `group.page_size` sessions (default 5) plus its full matching `total`, groups are ordered by their latest session's sort key (ties break on workspace id), and `page`/`page_token` page over groups — so an overview client replaces N per-workspace v1 polls with one call. +`GET /api/v2/sessions` (`src/routes/v2/sessions.ts`, mounted by `src/routes/registerApiV2Routes.ts`) is the first endpoint of the v2 API. The v2 surface shares v1's wire conventions: every response is wrapped in the `{ code, msg, data, request_id }` envelope with the business outcome in `code` (`40001` invalid query params with `details`, `40922` page_token mismatch), and the HTTP status only reports server-/transport-level outcomes (401 from the global auth hook, `50001` via the catch-all error hook). Pagination is an opaque `page_token` (base64url JSON: version + sha256 query-condition fingerprint + keyset position) — any condition flip mid-pagination fails 40922. Response domains are grouped (`workspace` / `meta` / `activity` always; `git` opt-in via `include=git`, deduped per unique cwd with a 60s TTL cache over `IGitService`, all git/gh failures degrading to cached null fields). Sorts/filters are applied at the edge over the index's canonical `updatedAt desc, id desc` drain, so all three sort orders share one comparator + cursor encoding; `activity.status` maps the core `ISessionActivityView` facts (pending interaction > active turn > failed last turn > idle; cold sessions are always `idle`), and `activity.model` carries the live session's bound model alias from the same per-id facts (`null` while the session is cold). `meta.has_prompt=true|false` filters by prompt presence (the v1 `exclude_empty` equivalent). `view=by_workspace` re-projects the same filtered/sorted drain into per-workspace groups — each group carries the workspace's first `group.page_size` sessions (default 5) plus its full matching `total`, groups are ordered by their latest session's sort key (ties break on workspace id), and `page`/`page_token` page over groups — so an overview client replaces N per-workspace v1 polls with one call. `/api/v2/mcp/*` (`src/routes/v2/mcp.ts`) exposes the agent-core-v2 `mcpManagement` plane: CRUD on the user-level `mcp.json` (`GET/POST/PUT/DELETE /mcp/servers[/{name}]`; `PUT` takes a name-less config body, the path owns the identity), a connection-test probe and the locator-addressed inspection catalog (`POST /mcp/servers:test` / `:inspect`, declared with the doubled-colon static-segment convention), the auth-status surface (`GET /mcp/auth-statuses?verify=`), and the locator-addressed OAuth flow operations (`POST /mcp/auth:begin|complete|cancel|reset`). engine `Error2`s map `mcp.server_not_found` → `40408`, `request.invalid` / `config.invalid` → `40001`, and `mcp.oauth_failed` → `40929`. `auth:complete` is a long poll (up to the flow's `timeoutMs`): the server disables Node's default `requestTimeout` (`start.ts`), and the handler aborts the engine wait when the client connection closes early. The klient facade mirrors the same surface as `global.mcp.*` with identical wire codes, plus the name-only `global.mcp.resolveByName` helper (REST clients compose locators from the `GET /mcp/servers` catalog instead). @@ -31,7 +31,7 @@ Implements the op-batch sequencing contract: ## Session events - The session's work aggregate behind `event.session.work_changed` (`busy` / `main_turn_active` / `pending_interaction` / `last_turn_reason`) is owned by the core's `ISessionActivityView` (`sessionActivity` domain, Session scope): the broadcaster only schedules the wire emission around turn frames (`busy:false` lands after `turn.ended`), and `resolveSessionFacts` (`src/routes/sessions.ts`) reads the same view — never fold per-agent activity at the edge. -- Delivery split on `/api/v1/ws`: global events (`session.meta.updated` and the `event.session.*` / `event.workspace.*` / `event.config.*` / `event.di.*` families, including every activated session's `event.session.work_changed`) fan out to EVERY established connection — `WsConnectionV1` registers itself via `broadcaster.addGlobalTarget` on construction and unregisters on close — while session/agent-grained events only reach connections subscribed to that session (subject to `agent_filter` and the transcript suppression above); transcript frames are a separate channel governed by the per-agent grades alone and bypass `agent_filter` entirely. One exception: the high-churn `event.di.*` debug feed only reaches connections opted in via `broadcaster.addDiEventTarget` — a temporary gate until a client-declared event whitelist exists, currently keyed on `client_hello` carrying `client_id: 'kimi-inspect'`. +- Delivery split on `/api/v1/ws`: global events (`session.meta.updated` and the `event.session.*` / `event.workspace.*` / `event.config.*` / `event.model_catalog.*` / `event.di.*` families, including every activated session's `event.session.work_changed`) fan out to EVERY established connection — `WsConnectionV1` registers itself via `broadcaster.addGlobalTarget` on construction and unregisters on close — while session/agent-grained events only reach connections subscribed to that session (subject to `agent_filter` and the transcript suppression above); transcript frames are a separate channel governed by the per-agent grades alone and bypass `agent_filter` entirely. One exception: the high-churn `event.di.*` debug feed only reaches connections opted in via `broadcaster.addDiEventTarget` — a temporary gate until a client-declared event whitelist exists, currently keyed on `client_hello` carrying `client_id: 'kimi-inspect'`. ## Global search diff --git a/packages/kap-server/src/protocol/events-zod.ts b/packages/kap-server/src/protocol/events-zod.ts index 98b1206579f..95ecad53a2c 100644 --- a/packages/kap-server/src/protocol/events-zod.ts +++ b/packages/kap-server/src/protocol/events-zod.ts @@ -625,7 +625,7 @@ export const sessionStatusChangedEventSchema = z.object({ export const configChangedEventSchema = z.object({ type: z.literal('event.config.changed'), - changedFields: z.array(z.string()), + changedFields: z.array(z.string().min(1)), config: configResponseSchema, }); @@ -639,6 +639,25 @@ export const configWarningEventSchema = z.object({ ), }); +export const modelCatalogChangedEventSchema = z.object({ + type: z.literal('event.model_catalog.changed'), + changed: z.array( + z.object({ + provider_id: z.string().min(1), + provider_name: z.string().min(1), + added: z.number().int().min(0), + removed: z.number().int().min(0), + }), + ), + unchanged: z.array(z.string().min(1)), + failed: z.array( + z.object({ + provider: z.string().min(1), + reason: z.string().min(1), + }), + ), +}); + export const pluginChangedEventSchema = z.object({ type: z.literal('event.plugin.changed'), }); @@ -1030,6 +1049,9 @@ export const agentEventSchema = z.discriminatedUnion('type', [ workspaceDeletedEventSchema, sessionWorkChangedEventSchema, sessionStatusChangedEventSchema, + configChangedEventSchema, + configWarningEventSchema, + modelCatalogChangedEventSchema, diUnitChangedEventSchema, pluginChangedEventSchema, capabilityChangedEventSchema, diff --git a/packages/kap-server/src/protocol/rest-config.ts b/packages/kap-server/src/protocol/rest-config.ts index f52b268d4df..aca15275a6f 100644 --- a/packages/kap-server/src/protocol/rest-config.ts +++ b/packages/kap-server/src/protocol/rest-config.ts @@ -8,29 +8,31 @@ export const providerConfigResponseSchema = z.object({ }); export type ProviderConfigResponse = z.infer; -export const configResponseSchema = z.object({ - providers: z.record(z.string(), providerConfigResponseSchema).default({}), - default_provider: z.string().optional(), - default_model: z.string().optional(), - models: z.record(z.string(), z.unknown()).optional(), - thinking: z.unknown().optional(), - plan_mode: z.boolean().optional(), - yolo: z.boolean().optional(), - default_permission_mode: z.string().optional(), - default_plan_mode: z.boolean().optional(), - permission: z.unknown().optional(), - hooks: z.array(z.unknown()).optional(), - services: z.unknown().optional(), - merge_all_available_skills: z.boolean().optional(), - extra_skill_dirs: z.array(z.string()).optional(), - loop_control: z.unknown().optional(), - background: z.unknown().optional(), - subagent: z.unknown().optional(), - secondary_model: z.unknown().optional(), - experimental: z.record(z.string(), z.boolean()).optional(), - telemetry: z.boolean().optional(), - raw: z.record(z.string(), z.unknown()).optional(), -}); +export const configResponseSchema = z + .object({ + providers: z.record(z.string(), providerConfigResponseSchema).default({}), + default_provider: z.string().optional(), + default_model: z.string().optional(), + models: z.record(z.string(), z.unknown()).optional(), + thinking: z.unknown().optional(), + plan_mode: z.boolean().optional(), + yolo: z.boolean().optional(), + default_permission_mode: z.string().optional(), + default_plan_mode: z.boolean().optional(), + permission: z.unknown().optional(), + hooks: z.array(z.unknown()).optional(), + services: z.unknown().optional(), + merge_all_available_skills: z.boolean().optional(), + extra_skill_dirs: z.array(z.string()).optional(), + loop_control: z.unknown().optional(), + background: z.unknown().optional(), + subagent: z.unknown().optional(), + secondary_model: z.unknown().optional(), + experimental: z.record(z.string(), z.boolean()).optional(), + telemetry: z.boolean().optional(), + raw: z.record(z.string(), z.unknown()).optional(), + }) + .passthrough(); export type ConfigResponse = z.infer; export const patchConfigRequestSchema = z.object({ diff --git a/packages/kap-server/src/routes/config.ts b/packages/kap-server/src/routes/config.ts index 1c59d57998a..7ebc4e0b7e9 100644 --- a/packages/kap-server/src/routes/config.ts +++ b/packages/kap-server/src/routes/config.ts @@ -1,9 +1,4 @@ -import { - ConfigChanged, - IConfigService, - IEventService, - type Scope, -} from '@moonshot-ai/agent-core-v2'; +import { IConfigService, type Scope } from '@moonshot-ai/agent-core-v2'; import { errEnvelope, okEnvelope } from '../envelope'; import { requestLog } from '../lib/requestLog'; @@ -76,9 +71,6 @@ export function registerConfigRoutes(app: ConfigRouteHost, core: Scope): void { } const response = toConfigResponse(config.getAll()); const changedFields = Object.keys(req.body as Record); - core.accessor.get(IEventService).publish( - new ConfigChanged({ payload: { changedFields, config: response } }), - ); requestLog(req)?.info({ changedFields }, 'config updated'); reply.send(okEnvelope(response, req.id)); } catch (error) { @@ -91,10 +83,17 @@ export function registerConfigRoutes(app: ConfigRouteHost, core: Scope): void { app.post(setRoute.path, setRoute.options, setRoute.handler as Parameters[2]); } -function toConfigResponse(resolved: Record): ConfigResponse { +export function toConfigResponse(resolved: Record): ConfigResponse { const wire: Record = {}; for (const [domain, value] of Object.entries(resolved)) { - wire[camelToSnake(domain)] = domain === 'providers' ? toProviderResponses(value) : value; + wire[camelToSnake(domain)] = + domain === 'providers' + ? toProviderResponses(value) + : domain === 'models' + ? toModelResponses(value) + : domain === 'services' + ? toServiceResponses(value) + : value; } const defaultPermissionMode = resolved['defaultPermissionMode']; if (typeof defaultPermissionMode === 'string') { @@ -135,6 +134,51 @@ function hasProviderCredential(provider: ProviderLike): boolean { return false; } +interface ModelLike { + readonly apiKey?: unknown; + readonly oauth?: unknown; +} + +function toModelResponses(value: unknown): Record { + const result: Record = {}; + if (!isPlainObject(value)) return result; + for (const [id, raw] of Object.entries(value)) { + if (!isPlainObject(raw)) { + result[id] = raw; + continue; + } + const { apiKey: _apiKey, oauth: _oauth, ...rest } = raw as ModelLike & Record; + result[id] = { ...rest, has_api_key: hasModelCredential(raw as ModelLike) }; + } + return result; +} + +function hasModelCredential(model: ModelLike): boolean { + if (nonEmpty(model.apiKey) !== undefined) return true; + if (model.oauth !== undefined) return true; + return false; +} + +function toServiceResponses(value: unknown): Record { + const result: Record = {}; + if (!isPlainObject(value)) return result; + for (const [id, raw] of Object.entries(value)) { + if (!isPlainObject(raw)) { + result[id] = raw; + continue; + } + const { apiKey: _apiKey, oauth: _oauth, customHeaders, ...rest } = raw as ModelLike & { + customHeaders?: unknown; + } & Record; + result[id] = { + ...rest, + has_api_key: hasModelCredential(raw as ModelLike), + custom_header_keys: isPlainObject(customHeaders) ? Object.keys(customHeaders) : undefined, + }; + } + return result; +} + function nonEmpty(value: unknown): string | undefined { if (typeof value !== 'string') return undefined; const trimmed = value.trim(); diff --git a/packages/kap-server/src/routes/prompts.ts b/packages/kap-server/src/routes/prompts.ts index 3d558cca73c..9fc36bf703b 100644 --- a/packages/kap-server/src/routes/prompts.ts +++ b/packages/kap-server/src/routes/prompts.ts @@ -234,7 +234,13 @@ export function registerPromptsRoutes(app: PromptRouteHost, core: Scope): void { ); const resolved = await resolvePromptFromSession(session, req.body.agent_id); reservation = reservePrompt(resolved.prompt, req.body.prompt_id); - await resolved.auth.ensureReady(); + const sessionModel = resolved.profile.getModel(); + const switchingProfile = + req.body.profile !== undefined && + req.body.profile !== resolved.profile.data().profileName; + await resolved.auth.ensureReady( + req.body.model ?? (switchingProfile ? undefined : sessionModel || undefined), + ); const telemetry = core.accessor.get(ITelemetryService).withContext({ sessionId: session_id }); preparedMedia = await resolvePromptMediaFiles( diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index bc373274793..7230c2cdfbc 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -4,6 +4,7 @@ import { IAgentProfileService, IAgentConversationUndoService, IAgentFullCompactionService, + IAgentLifecycleService, IAgentLoopService, IAuthSummaryService, ISessionActivityView, @@ -26,6 +27,7 @@ import { Error2, type ContextMessage, type IAgentScopeHandle, + type ISessionScopeHandle, type Scope, type SessionSummary, } from '@moonshot-ai/agent-core-v2'; @@ -62,7 +64,8 @@ import { z } from 'zod'; import { errEnvelope, okEnvelope } from '../envelope'; import { requestLog } from '../lib/requestLog'; import { defineRoute } from '../middleware/defineRoute'; -import { ensureMainAgent } from '../transport/mainAgent'; +import { readLegacyStatus } from '../services/legacyStatus/legacyStatus'; +import { ensureMainAgent, MAIN_AGENT_ID } from '../transport/mainAgent'; import { type ActionTable, dispatchAction } from './action-dispatch'; import { applySessionAgentConfig } from './sessionAgentConfig'; import { updateSessionProfile } from './sessionProfile'; @@ -944,7 +947,9 @@ async function btwSessionAction(ctx: SessionActionCtx): Promise { if (session === undefined) { throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${id} does not exist`); } - await core.accessor.get(IAuthSummaryService).ensureReady(); + const agent = await ensureMainAgent(session); + const sessionModel = agent.accessor.get(IAgentProfileService).getModel(); + await core.accessor.get(IAuthSummaryService).ensureReady(sessionModel || undefined); const agentId = await session.accessor.get(ISessionBtwService).start(); reply.send(okEnvelope({ agent_id: agentId }, req.id)); } @@ -1011,7 +1016,7 @@ export function toWireSession( archived: fields.archived, last_prompt: fields.lastPrompt, metadata: buildWireMetadata(fields.custom, cwd), - agent_config: { model: '' }, + agent_config: { model: facts.model ?? '' }, usage: emptySessionUsage(), permission_rules: [], message_count: 0, @@ -1025,6 +1030,7 @@ export interface SessionFacts { readonly pendingInteraction: SessionPendingInteraction; readonly lastTurnReason?: 'completed' | 'cancelled' | 'failed'; readonly live?: boolean; + readonly model?: string; } export function resolveSessionFacts(core: Scope, sessionId: string): SessionFacts { @@ -1037,7 +1043,17 @@ export function resolveSessionFacts(core: Scope, sessionId: string): SessionFact live: false, }; } - return { ...handle.accessor.get(ISessionActivityView).state(), live: true }; + return { + ...handle.accessor.get(ISessionActivityView).state(), + live: true, + model: readLiveSessionModel(handle), + }; +} + +function readLiveSessionModel(session: ISessionScopeHandle): string | undefined { + const main = session.accessor.get(IAgentLifecycleService).handleOf(MAIN_AGENT_ID); + if (main === undefined) return undefined; + return readLegacyStatus(main)?.model; } async function resolveMainAgent(core: Scope, sessionId: string): Promise { diff --git a/packages/kap-server/src/routes/v2/sessions.ts b/packages/kap-server/src/routes/v2/sessions.ts index f569bb960d2..e5e3b979765 100644 --- a/packages/kap-server/src/routes/v2/sessions.ts +++ b/packages/kap-server/src/routes/v2/sessions.ts @@ -228,7 +228,7 @@ const v2SessionSchema = z.object({ archived: z.boolean(), archived_at: z.number().int().nullable(), }), - activity: z.object({ status: v2ActivityStatusSchema }), + activity: z.object({ status: v2ActivityStatusSchema, model: z.string().nullable() }), git: v2GitDomainSchema.optional(), }); @@ -472,7 +472,7 @@ export function registerV2SessionsRoutes(app: V2SessionsRouteHost, core: Scope): [ErrorCode.PAGE_TOKEN_MISMATCH]: {}, }, description: - "List sessions with domain-grouped metadata (workspace / meta / activity; git via include=git). Paginate with the opaque page_token (binds the first page’s query conditions) or with the stateless 1-based page parameter; every page carries total. fields=id,archived trims each item to the lightweight ids projection (select-all-matching flows; page_size ceiling relaxed to 10000). meta.has_prompt=true|false filters sessions by whether they carry a prompt. view=by_workspace groups the matching set per workspace — each group carries that workspace's first group.page_size sessions (default 5) under the requested sort plus the group's full matching total; page/page_token then page over groups.", + "List sessions with domain-grouped metadata (workspace / meta / activity; git via include=git). activity.model carries the live session's bound model alias (null while the session is cold). Paginate with the opaque page_token (binds the first page’s query conditions) or with the stateless 1-based page parameter; every page carries total. fields=id,archived trims each item to the lightweight ids projection (select-all-matching flows; page_size ceiling relaxed to 10000). meta.has_prompt=true|false filters sessions by whether they carry a prompt. view=by_workspace groups the matching set per workspace — each group carries that workspace's first group.page_size sessions (default 5) under the requested sort plus the group's full matching total; page/page_token then page over groups.", tags: ['v2-sessions'], }, async (req, reply) => { @@ -584,6 +584,7 @@ export function registerV2SessionsRoutes(app: V2SessionsRouteHost, core: Scope): } return summaries.map((summary) => { const cwd = cwdOf(summary); + const facts = factsOf(summary.id); return { id: summary.id, workspace: { id: summary.workspaceId, cwd }, @@ -595,7 +596,10 @@ export function registerV2SessionsRoutes(app: V2SessionsRouteHost, core: Scope): archived: summary.archived, archived_at: summary.archivedAt ?? null, }, - activity: { status: mapActivityStatus(factsOf(summary.id), summary.lastTurnReason) }, + activity: { + status: mapActivityStatus(facts, summary.lastTurnReason), + model: facts.model ?? null, + }, git: gitByCwd === undefined ? undefined diff --git a/packages/kap-server/src/services/config/configChangedPublisher.ts b/packages/kap-server/src/services/config/configChangedPublisher.ts new file mode 100644 index 00000000000..9899c6fe786 --- /dev/null +++ b/packages/kap-server/src/services/config/configChangedPublisher.ts @@ -0,0 +1,48 @@ +import { ConfigChanged, IConfigService, IEventService, type Scope } from '@moonshot-ai/agent-core-v2'; + +import { toConfigResponse } from '../../routes/config'; + +export interface ConfigChangedPublisher { + close(): void; +} + +const FLUSH_DELAY_MS = 10; + +export function startConfigChangedPublisher(core: Scope): ConfigChangedPublisher { + const config = core.accessor.get(IConfigService); + const events = core.accessor.get(IEventService); + let closed = false; + let timer: ReturnType | undefined; + const pending = new Set(); + + const flush = (): void => { + timer = undefined; + if (closed || pending.size === 0) return; + const changedFields = [...pending].toSorted(); + pending.clear(); + events.publish( + new ConfigChanged({ + payload: { changedFields, config: toConfigResponse(config.getAll()) }, + }), + ); + }; + + const subscription = config.onDidSectionChange((event) => { + if (closed) return; + pending.add(event.domain); + if (timer !== undefined) clearTimeout(timer); + timer = setTimeout(flush, FLUSH_DELAY_MS); + }); + + return { + close: () => { + closed = true; + if (timer !== undefined) { + clearTimeout(timer); + timer = undefined; + } + pending.clear(); + subscription.dispose(); + }, + }; +} diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index ec5a2741263..a1c3cb0e334 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -79,6 +79,7 @@ import { } from './services/telemetry'; import { TranscriptService } from './services/transcript/transcriptService'; import { ModelCatalogRefreshScheduler } from './services/modelCatalog/modelCatalogRefreshScheduler'; +import { startConfigChangedPublisher } from './services/config/configChangedPublisher'; import { createAuthFailureLimiter } from './middleware/rateLimit'; import { createAuthTokenService, @@ -291,6 +292,7 @@ export async function startServer(opts: ServerStartOptions): Promise => { + configChangedPublisher.close(); await app.close(); configWarningSubscription.dispose(); pluginChangeSubscription.dispose(); @@ -351,6 +353,7 @@ export async function startServer(opts: ServerStartOptions): Promise { diff --git a/packages/kap-server/src/transport/ws/v1/events.ts b/packages/kap-server/src/transport/ws/v1/events.ts index f2f989e4f62..9353b80ef79 100644 --- a/packages/kap-server/src/transport/ws/v1/events.ts +++ b/packages/kap-server/src/transport/ws/v1/events.ts @@ -102,6 +102,25 @@ export interface ConfigWarningEvent { readonly warnings: readonly ConfigWarningItem[]; } +export interface ModelCatalogRefreshChange { + readonly provider_id: string; + readonly provider_name: string; + readonly added: number; + readonly removed: number; +} + +export interface ModelCatalogRefreshFailure { + readonly provider: string; + readonly reason: string; +} + +export interface ModelCatalogChangedEvent { + readonly type: 'event.model_catalog.changed'; + readonly changed: readonly ModelCatalogRefreshChange[]; + readonly unchanged: readonly string[]; + readonly failed: readonly ModelCatalogRefreshFailure[]; +} + export interface PluginChangedEvent { readonly type: 'event.plugin.changed'; } @@ -206,6 +225,7 @@ export type AgentEvent = | SessionStatusChangedEvent | ConfigChangedEvent | ConfigWarningEvent + | ModelCatalogChangedEvent | PluginChangedEvent | CapabilityChangedEvent | DiUnitChangedEvent diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts index 6888ef22de1..33918356260 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts @@ -26,12 +26,18 @@ import { import type { ConfigWarningItem, DiUnitChangedEvent, + ModelCatalogRefreshChange, + ModelCatalogRefreshFailure, SessionCreatedEvent, SessionMetaUpdatedEvent, Event, } from './events'; import { isVolatileEventType } from './events'; import type { SessionCursor } from '../../../protocol/ws-control'; +import { + configChangedEventSchema, + modelCatalogChangedEventSchema, +} from '../../../protocol/events-zod'; import type { InFlightTurn, SnapshotSubagent } from '../../../protocol/rest-snapshot'; import { detachGrades, @@ -715,6 +721,35 @@ export class SessionEventBroadcaster { ); return; } + if (event.type === 'event.config.changed') { + const payload = configChangedPayload(corePayload); + if (payload === undefined) return; + void this.dispatchGlobal({ + type: 'event.config.changed', + changedFields: payload.changedFields, + config: payload.config, + agentId: 'main', + sessionId: GLOBAL_SESSION_ID, + } as Event).catch((error: unknown) => + this.logDispatchError(GLOBAL_SESSION_ID, 'event.config.changed', error), + ); + return; + } + if (event.type === 'event.model_catalog.changed') { + const payload = modelCatalogChangedPayload(corePayload); + if (payload === undefined) return; + void this.dispatchGlobal({ + type: 'event.model_catalog.changed', + changed: payload.changed, + unchanged: payload.unchanged, + failed: payload.failed, + agentId: 'main', + sessionId: GLOBAL_SESSION_ID, + } as Event).catch((error: unknown) => + this.logDispatchError(GLOBAL_SESSION_ID, 'event.model_catalog.changed', error), + ); + return; + } if (event.type === 'event.di.unit_changed') { const payload = diUnitChangedPayload(corePayload); if (payload === undefined) return; @@ -1085,6 +1120,7 @@ function isGlobalEvent(type: string): boolean { type.startsWith('event.session.') || type.startsWith('event.workspace.') || type.startsWith('event.config.') || + type.startsWith('event.model_catalog.') || type.startsWith('event.plugin.') || type.startsWith('event.capability.') || type.startsWith('event.di.') @@ -1405,3 +1441,28 @@ function configWarningPayload(payload: unknown): { warnings: ConfigWarningItem[] } return { warnings: items }; } + +const configChangedPayloadSchema = configChangedEventSchema.omit({ type: true }); +const modelCatalogChangedPayloadSchema = modelCatalogChangedEventSchema.omit({ type: true }); + +function configChangedPayload( + payload: unknown, +): { changedFields: string[]; config: unknown } | undefined { + const parsed = configChangedPayloadSchema.safeParse(payload); + if (!parsed.success) return undefined; + return { changedFields: parsed.data.changedFields, config: parsed.data.config }; +} + +function modelCatalogChangedPayload( + payload: unknown, +): + | { + changed: ModelCatalogRefreshChange[]; + unchanged: string[]; + failed: ModelCatalogRefreshFailure[]; + } + | undefined { + const parsed = modelCatalogChangedPayloadSchema.safeParse(payload); + if (!parsed.success) return undefined; + return parsed.data; +} diff --git a/packages/kap-server/test/auth.test.ts b/packages/kap-server/test/auth.test.ts index 83879c64788..f5e10e64521 100644 --- a/packages/kap-server/test/auth.test.ts +++ b/packages/kap-server/test/auth.test.ts @@ -58,17 +58,16 @@ describe('server-v2 GET /api/v1/auth', () => { return authSummarySchema.parse(body.data); } - it('returns ready=false with an empty snapshot on empty config', async () => { + it('returns models_ready=false with an empty snapshot on empty config', async () => { await boot(); expect(await getAuth()).toEqual({ - ready: false, + models_ready: false, providers_count: 0, - default_model: null, managed_provider: null, }); }); - it('returns ready=true when provider + api_key + default_model are set', async () => { + it('returns models_ready=true when the default model resolves to a configured provider', async () => { await boot( [ 'default_model = "x"', @@ -85,14 +84,13 @@ describe('server-v2 GET /api/v1/auth', () => { ].join('\n'), ); expect(await getAuth()).toEqual({ - ready: true, + models_ready: true, providers_count: 1, - default_model: 'x', managed_provider: null, }); }); - it('returns ready=false when a provider exists but default_model is missing', async () => { + it('returns models_ready=false when a provider exists but default_model is missing', async () => { await boot( [ '[providers.x]', @@ -107,12 +105,51 @@ describe('server-v2 GET /api/v1/auth', () => { ].join('\n'), ); const summary = await getAuth(); - expect(summary.ready).toBe(false); + expect(summary.models_ready).toBe(false); expect(summary.providers_count).toBe(1); - expect(summary.default_model).toBeNull(); expect(summary.managed_provider).toBeNull(); }); + it('returns models_ready=false when the default model dangles', async () => { + await boot( + [ + 'default_model = "gone"', + '', + '[providers.x]', + 'type = "kimi"', + 'api_key = "sk-test"', + '', + '[models.x]', + 'provider = "x"', + 'model = "x"', + 'max_context_size = 1000', + '', + ].join('\n'), + ); + const summary = await getAuth(); + expect(summary.models_ready).toBe(false); + expect(summary.providers_count).toBe(1); + }); + + it('returns models_ready=true for a providerless flat default model', async () => { + await boot( + [ + 'default_model = "flat"', + '', + '[models.flat]', + 'base_url = "https://example.test/v1"', + 'model = "x"', + 'protocol = "openai"', + 'max_context_size = 1000', + 'api_key = "sk-test"', + '', + ].join('\n'), + ); + const summary = await getAuth(); + expect(summary.models_ready).toBe(true); + expect(summary.providers_count).toBe(0); + }); + it('surfaces managed_provider.unauthenticated without a cached token', async () => { await boot( [ @@ -131,6 +168,6 @@ describe('server-v2 GET /api/v1/auth', () => { name: 'managed:kimi-code', status: 'unauthenticated', }); - expect(summary.ready).toBe(false); + expect(summary.models_ready).toBe(false); }); }); diff --git a/packages/kap-server/test/boot.test.ts b/packages/kap-server/test/boot.test.ts index 40432055ca8..4f66a7895cd 100644 --- a/packages/kap-server/test/boot.test.ts +++ b/packages/kap-server/test/boot.test.ts @@ -74,10 +74,10 @@ describe('server-v2 boot', () => { expect(auth.status).toBe(200); const authBody = await auth.json() as { code: number; - data: { ready: boolean; providers_count: number; default_model: string | null }; + data: { models_ready: boolean; providers_count: number }; }; expect(authBody.code).toBe(0); - expect(typeof authBody.data.ready).toBe('boolean'); + expect(typeof authBody.data.models_ready).toBe('boolean'); expect(authBody.data.providers_count).toBeGreaterThanOrEqual(0); const oauthPoll = await authedFetch(server, base, '/api/v1/oauth/login'); diff --git a/packages/kap-server/test/config.test.ts b/packages/kap-server/test/config.test.ts index bb9a07bc865..61ba89503a0 100644 --- a/packages/kap-server/test/config.test.ts +++ b/packages/kap-server/test/config.test.ts @@ -2,13 +2,23 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { + ConfigChanged, + IConfigService, + IEventService, + type ConfigSectionChangedEvent, + type Event2, + type Scope, +} from '@moonshot-ai/agent-core-v2'; import { configResponseSchema, type ConfigResponse } from '../src/protocol/rest-config'; import { ErrorCode } from '../src/protocol/error-codes'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { WebSocket } from 'ws'; import { type RunningServer, startServer } from '../src/start'; +import { startConfigChangedPublisher } from '../src/services/config/configChangedPublisher'; import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; -import { authedFetch } from './helpers/auth'; +import { authedFetch, bearerToken } from './helpers/auth'; interface Envelope { code: number; @@ -32,7 +42,7 @@ describe('server-v2 /api/v1/config', () => { server = undefined; } if (home !== undefined) { - await rm(home, { recursive: true, force: true }); + await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 } as never); home = undefined; } }); @@ -174,3 +184,296 @@ describe('server-v2 /api/v1/config', () => { expect(body.code).toBe(0); }); }); + +describe('server-v2 config changed WS notifications', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + const sockets: WebSocket[] = []; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-config-ws-')); + }); + + afterEach(async () => { + for (const ws of sockets.splice(0)) ws.close(); + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + async function boot(toml?: string): Promise { + if (toml !== undefined) { + await writeFile(join(home as string, 'config.toml'), toml, 'utf-8'); + } + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + base = `http://127.0.0.1:${server.port}`; + } + + interface ConfigChangedFrame { + type: 'event.config.changed'; + payload: { + changedFields: string[]; + config: Record; + }; + } + + async function openWs(): Promise { + const live = server as RunningServer; + const ws = new WebSocket(`ws://127.0.0.1:${live.port}/api/v1/ws`, [ + `kimi-code.bearer.${bearerToken(live)}`, + ]); + sockets.push(ws); + const frames: ConfigChangedFrame[] = []; + ws.on('message', (data) => { + const frame = JSON.parse((data as Buffer).toString()) as { type?: string }; + if (frame.type === 'event.config.changed') frames.push(frame as ConfigChangedFrame); + }); + await new Promise((resolve) => ws.on('open', resolve)); + ws.send(JSON.stringify({ type: 'client_hello', payload: { client_id: 'config-ws-test' } })); + return frames; + } + + async function patchConfig(patch: Record): Promise { + const res = await authedFetch(server as RunningServer, base, '/api/v1/config', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(patch), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as Envelope; + expect(body.code).toBe(0); + } + + it('publishes a trailing event with camelCase changedFields and the full snapshot after POST /config', async () => { + await boot('default_permission_mode = "auto"\n'); + const frames = await openWs(); + + await patchConfig({ yolo: true }); + + await vi.waitFor(() => expect(frames.length).toBeGreaterThanOrEqual(1)); + const last = frames.at(-1) as ConfigChangedFrame; + expect(last.payload.changedFields).toEqual(['defaultPermissionMode']); + expect(last.payload.config['default_permission_mode']).toBe('yolo'); + expect(last.payload.config['yolo']).toBe(true); + expect(last.payload.config).toHaveProperty('providers'); + }); + + it('publishes camelCase changedFields on the engine write path used by OAuth refreshes', async () => { + await boot(); + const frames = await openWs(); + + const config = (server as RunningServer).core.accessor.get(IConfigService); + await config.ready; + await config.replace('defaultModel', 'k2'); + + await vi.waitFor(() => expect(frames.length).toBeGreaterThanOrEqual(1)); + const last = frames.at(-1) as ConfigChangedFrame; + expect(last.payload.changedFields).toEqual(['defaultModel']); + expect(last.payload.config['default_model']).toBe('k2'); + }); + + it('publishes an event when config.toml is edited outside the process and reloaded', async () => { + await boot('default_permission_mode = "auto"\n'); + const frames = await openWs(); + + await writeFile( + join(home as string, 'config.toml'), + 'default_permission_mode = "yolo"\n', + 'utf-8', + ); + const config = (server as RunningServer).core.accessor.get(IConfigService); + await config.ready; + await config.reload(); + + await vi.waitFor(() => expect(frames.length).toBeGreaterThanOrEqual(1), { timeout: 10000 }); + const last = frames.at(-1) as ConfigChangedFrame; + expect(last.payload.changedFields).toContain('defaultPermissionMode'); + expect(last.payload.config['default_permission_mode']).toBe('yolo'); + }); + + it('closes the config publisher before the app, so a pending change is never delivered during shutdown', async () => { + await boot(); + const published: string[] = []; + const events = (server as RunningServer).core.accessor.get(IEventService); + const subscription = events.subscribe((event) => { + if (event.type === 'event.config.changed') published.push(event.type); + }); + + const config = (server as RunningServer).core.accessor.get(IConfigService); + await config.ready; + await config.replace('defaultModel', 'k2'); + for (const ws of sockets.splice(0)) ws.close(); + const live = server as RunningServer; + server = undefined; + await live.close(); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(published).toHaveLength(0); + subscription.dispose(); + }); +}); + +describe('configChangedPublisher', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + function setup() { + const listeners = new Set<(event: ConfigSectionChangedEvent) => void>(); + let backing: Record = {}; + const published: ConfigChanged[] = []; + let disposed = false; + const core = { + accessor: { + get: (token: unknown) => { + if (token === IConfigService) { + return { + onDidSectionChange: (listener: (event: ConfigSectionChangedEvent) => void) => { + listeners.add(listener); + return { + dispose: () => { + disposed = true; + listeners.delete(listener); + }, + }; + }, + getAll: () => backing, + }; + } + return { + publish: (event: Event2) => published.push(event as ConfigChanged), + }; + }, + }, + } as unknown as Scope; + const publisher = startConfigChangedPublisher(core); + const fire = (domain: string): void => { + for (const listener of listeners) { + listener({ domain, source: 'set', value: undefined, previousValue: undefined }); + } + }; + return { + publisher, + published, + fire, + isDisposed: () => disposed, + setBacking: (value: Record) => { + backing = value; + }, + }; + } + + it('merges section events inside one flush window into a single ConfigChanged', () => { + vi.useFakeTimers(); + const { published, fire } = setup(); + + fire('defaultPermissionMode'); + fire('defaultModel'); + vi.advanceTimersByTime(50); + + expect(published).toHaveLength(1); + expect(published[0]?.payload.changedFields).toEqual(['defaultModel', 'defaultPermissionMode']); + }); + + it('projects the full config snapshot at flush time', () => { + vi.useFakeTimers(); + const { published, fire, setBacking } = setup(); + + fire('defaultModel'); + setBacking({ defaultModel: 'k2', providers: {} }); + vi.advanceTimersByTime(50); + + expect(published).toHaveLength(1); + expect(published[0]?.payload.config).toEqual({ default_model: 'k2', providers: {} }); + }); + + it('redacts inline model credentials from the published config projection', () => { + vi.useFakeTimers(); + const { published, fire, setBacking } = setup(); + setBacking({ + providers: {}, + models: { + 'p/m': { provider: 'p', model: 'm', maxContextSize: 4096, apiKey: 'sk-secret' }, + 'p/flat': { baseUrl: 'https://x.test', model: 'm', oauth: { storage: 'file', key: 'oauth/x' } }, + }, + }); + + fire('models'); + vi.advanceTimersByTime(50); + + expect(published).toHaveLength(1); + const config = published[0]?.payload.config as Record; + const models = config['models'] as Record>; + expect(models['p/m']).toEqual({ provider: 'p', model: 'm', maxContextSize: 4096, has_api_key: true }); + expect(models['p/flat']).toEqual({ baseUrl: 'https://x.test', model: 'm', has_api_key: true }); + expect(JSON.stringify(models)).not.toContain('sk-secret'); + }); + + it('redacts inline service credentials from the published config projection', () => { + vi.useFakeTimers(); + const { published, fire, setBacking } = setup(); + setBacking({ + providers: {}, + services: { + moonshotSearch: { + baseUrl: 'https://s.test', + apiKey: 'sk-svc', + customHeaders: { Authorization: 'Bearer abc', 'x-team': 'core' }, + }, + }, + }); + + fire('services'); + vi.advanceTimersByTime(50); + + expect(published).toHaveLength(1); + const config = published[0]?.payload.config as Record; + const services = config['services'] as Record>; + expect(services['moonshotSearch']).toEqual({ + baseUrl: 'https://s.test', + has_api_key: true, + custom_header_keys: ['Authorization', 'x-team'], + }); + expect(JSON.stringify(services)).not.toContain('sk-svc'); + expect(JSON.stringify(services)).not.toContain('Bearer abc'); + }); + + it('always delivers a trailing event for late-arriving changes', () => { + vi.useFakeTimers(); + const { published, fire } = setup(); + + fire('defaultPermissionMode'); + vi.advanceTimersByTime(12); + fire('models'); + vi.advanceTimersByTime(12); + + expect(published).toHaveLength(2); + expect(published[1]?.payload.changedFields).toEqual(['models']); + }); + + it('drops pending changes on close and never publishes afterwards', () => { + vi.useFakeTimers(); + const { published, publisher, fire, isDisposed } = setup(); + + fire('defaultModel'); + publisher.close(); + vi.advanceTimersByTime(50); + fire('models'); + vi.advanceTimersByTime(50); + + expect(published).toHaveLength(0); + expect(isDisposed()).toBe(true); + }); +}); diff --git a/packages/kap-server/test/modelCatalog.test.ts b/packages/kap-server/test/modelCatalog.test.ts index a5358f7e1c5..da7e1f4f26c 100644 --- a/packages/kap-server/test/modelCatalog.test.ts +++ b/packages/kap-server/test/modelCatalog.test.ts @@ -206,7 +206,7 @@ describe('server-v2 /api/v1 model/provider catalog', () => { expect(noKey.body.data).not.toHaveProperty('api_key'); }); - it('sets the global default model and reflects it in /auth', async () => { + it('sets the global default model and reflects it in /config', async () => { await boot(CATALOG_TOML); const { body } = await postJson('/api/v1/models/turbo:set_default', {}); expect(body.code).toBe(0); @@ -220,9 +220,9 @@ describe('server-v2 /api/v1 model/provider catalog', () => { }, }); - const auth = await getJson<{ default_model: string | null }>('/api/v1/auth'); - expect(auth.body.code).toBe(0); - expect(auth.body.data.default_model).toBe('turbo'); + const config = await getJson<{ default_model: string | null }>('/api/v1/config'); + expect(config.body.code).toBe(0); + expect(config.body.data.default_model).toBe('turbo'); }); it('maps unknown provider and model ids to catalog not-found codes', async () => { diff --git a/packages/kap-server/test/modelCatalogProviderWrite.test.ts b/packages/kap-server/test/modelCatalogProviderWrite.test.ts index bfbcd85b144..489bfd3e182 100644 --- a/packages/kap-server/test/modelCatalogProviderWrite.test.ts +++ b/packages/kap-server/test/modelCatalogProviderWrite.test.ts @@ -290,8 +290,10 @@ describe('server-v2 /api/v1 provider write endpoints', () => { const onDisk = await readConfigToml(); expect(onDisk['default_model']).toBe('my-openai/gpt-4.1'); - const auth = await getJson<{ ready: boolean; default_model: string | null }>('/api/v1/auth'); - expect(auth.body.data).toMatchObject({ ready: true, default_model: 'my-openai/gpt-4.1' }); + const config = await getJson<{ default_model: string | null }>('/api/v1/config'); + expect(config.body.data.default_model).toBe('my-openai/gpt-4.1'); + const auth = await getJson<{ models_ready: boolean }>('/api/v1/auth'); + expect(auth.body.data).toMatchObject({ models_ready: true }); }); it('seeds the first model when the create body names no provider default', async () => { diff --git a/packages/kap-server/test/prompts.test.ts b/packages/kap-server/test/prompts.test.ts index dac151b8f2d..80cbdde01bd 100644 --- a/packages/kap-server/test/prompts.test.ts +++ b/packages/kap-server/test/prompts.test.ts @@ -63,6 +63,26 @@ const PROMPT_TOML = [ '', ].join('\n'); +const PROMPT_TOML_NO_DEFAULT = PROMPT_TOML.replace('default_model = "stub"\n\n', ''); +const PROMPT_TOML_DANGLING_DEFAULT = PROMPT_TOML.replace( + 'default_model = "stub"', + 'default_model = "missing"', +); +const PROMPT_TOML_OTHER_DEFAULT = [ + 'default_model = "other"', + '', + '[providers.stub]', + 'type = "openai"', + 'base_url = "http://127.0.0.1:9999"', + 'api_key = "stub"', + '', + '[models.other]', + 'provider = "stub"', + 'model = "other"', + 'max_context_size = 1000', + '', +].join('\n'); + const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const CRC32_TABLE = makeCrc32Table(); @@ -217,6 +237,14 @@ describe('server-v2 /api/v1 prompts', () => { await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' }); } + async function setSessionModel(sessionId: string, model: string): Promise { + const session = getLiveSessionById(server!.core.accessor, sessionId); + if (session === undefined) throw new Error(`session ${sessionId} not found`); + const agent = session.accessor.get(IAgentLifecycleService).handleOf('main'); + if (agent === undefined) throw new Error(`main agent of session ${sessionId} not found`); + await agent.accessor.get(IAgentProfileService).setModel(model); + } + it('submits a prompt and lists it as active', async () => { const id = await createSession(home as string); await createMainAgent(id); @@ -240,6 +268,93 @@ describe('server-v2 /api/v1 prompts', () => { expect(Array.isArray(list.body.data.queued)).toBe(true); }); + it('accepts a prompt-carried model when default_model is not configured', async () => { + await writeFile(join(home as string, 'config.toml'), PROMPT_TOML_NO_DEFAULT, 'utf-8'); + const id = await createSession(home as string); + await createMainAgent(id); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'hello' }], + model: 'stub', + }); + expect(submitted.body.code).toBe(0); + }); + + it('accepts the session-bound model when default_model is not configured', async () => { + await writeFile(join(home as string, 'config.toml'), PROMPT_TOML_NO_DEFAULT, 'utf-8'); + const id = await createSession(home as string); + await createMainAgent(id); + await setSessionModel(id, 'stub'); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'hello' }], + }); + expect(submitted.body.code).toBe(0); + }); + + it('accepts the session-bound model when default_model dangles', async () => { + await writeFile(join(home as string, 'config.toml'), PROMPT_TOML_DANGLING_DEFAULT, 'utf-8'); + const id = await createSession(home as string); + await createMainAgent(id); + await setSessionModel(id, 'stub'); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'hello' }], + }); + expect(submitted.body.code).toBe(0); + }); + + it('rejects when neither prompt, session, nor default_model resolves a model', async () => { + await writeFile(join(home as string, 'config.toml'), PROMPT_TOML_NO_DEFAULT, 'utf-8'); + const id = await createSession(home as string); + await createMainAgent(id); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'hello' }], + }); + expect(submitted.body.code).toBe(40113); + }); + + it('rejects a bound profile switch with 40001 even when the session model is stale', async () => { + await mkdir(join(home as string, 'agents'), { recursive: true }); + await writeFile( + join(home as string, 'agents', 'route-reviewer.md'), + [ + '---', + 'name: route-reviewer', + 'description: reviewer defined by a user-level agent file', + '---', + '', + 'You are a route-test reviewer.', + '', + ].join('\n'), + 'utf-8', + ); + const id = await createSession(home as string); + await createMainAgent(id); + await setSessionModel(id, 'stub'); + await writeFile(join(home as string, 'config.toml'), PROMPT_TOML_OTHER_DEFAULT, 'utf-8'); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'hello' }], + profile: 'route-reviewer', + }); + expect(submitted.body.code).toBe(40001); + expect(submitted.body.msg).toContain('already bound'); + }); + + it('rejects a stale session model when no profile switch is requested', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + await setSessionModel(id, 'stub'); + await writeFile(join(home as string, 'config.toml'), PROMPT_TOML_OTHER_DEFAULT, 'utf-8'); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'text', text: 'hello' }], + }); + expect(submitted.body.code).toBe(40113); + }); + it('submits a bundled skill prompt through the skills field', async () => { const id = await createSession(home as string); await createMainAgent(id); diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 6b308e2aba2..d86dc69447f 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -45,6 +45,7 @@ import { TurnStarted } from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; import type { AgentEvent } from '../src/transport/ws/v1/events'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { sessionEventMessageSchema } from '../src/protocol/ws-control'; import { type BroadcastDelivery, type BroadcastTarget, @@ -1555,6 +1556,170 @@ describe('SessionEventBroadcaster', () => { payload: { warnings }, }); }); + + it('delivers event.config.changed to a global-only target that never subscribed', async () => { + const globalView = collectingTarget(); + bc.addGlobalTarget(globalView.target); + + const config = { default_model: 'k2', providers: {} }; + eventBus.emit({ + type: 'event.config.changed', + payload: { changedFields: ['defaultModel'], config }, + }); + + await vi.waitFor(() => expect(globalView.envelopes).toHaveLength(1)); + expect(globalView.envelopes[0]).toMatchObject({ + type: 'event.config.changed', + session_id: '__global__', + payload: { changedFields: ['defaultModel'], config }, + }); + expect(globalView.deliveries).toEqual(['immediate']); + }); + + it('preserves unlisted config domains through event validation', async () => { + const globalView = collectingTarget(); + bc.addGlobalTarget(globalView.target); + + const config = { default_model: 'k2', providers: {}, mcp: { servers: { fs: { command: 'npx' } } } }; + eventBus.emit({ + type: 'event.config.changed', + payload: { changedFields: ['mcp'], config }, + }); + + await vi.waitFor(() => expect(globalView.envelopes).toHaveLength(1)); + expect(globalView.envelopes[0]).toMatchObject({ + type: 'event.config.changed', + session_id: '__global__', + payload: { changedFields: ['mcp'], config }, + }); + }); + + it('drops malformed event.config.changed payloads', async () => { + const globalView = collectingTarget(); + bc.addGlobalTarget(globalView.target); + + eventBus.emit({ type: 'event.config.changed', payload: null }); + eventBus.emit({ type: 'event.config.changed', payload: { changedFields: 'defaultModel' } }); + eventBus.emit({ + type: 'event.config.changed', + payload: { changedFields: ['defaultModel', 7], config: {} }, + }); + eventBus.emit({ + type: 'event.config.changed', + payload: { changedFields: ['defaultModel'], config: null }, + }); + eventBus.emit({ + type: 'event.config.changed', + payload: { changedFields: ['defaultModel'], config: [] }, + }); + eventBus.emit({ + type: 'event.config.changed', + payload: { changedFields: [''], config: {} }, + }); + + eventBus.emit({ + type: 'event.config.changed', + payload: { changedFields: ['models'], config: { models: {} } }, + }); + + await vi.waitFor(() => expect(globalView.envelopes).toHaveLength(1)); + expect(globalView.envelopes[0]).toMatchObject({ + type: 'event.config.changed', + payload: { changedFields: ['models'], config: { models: {} } }, + }); + }); + + it('delivers event.model_catalog.changed to a global-only target that never subscribed', async () => { + const globalView = collectingTarget(); + bc.addGlobalTarget(globalView.target); + + const changed = [ + { provider_id: 'managed:kimi-code', provider_name: 'Kimi Code', added: 2, removed: 1 }, + ]; + const failed = [{ provider: 'managed:kimi-code', reason: 'network disabled' }]; + eventBus.emit({ + type: 'event.model_catalog.changed', + payload: { changed, unchanged: ['openai-main'], failed }, + }); + + await vi.waitFor(() => expect(globalView.envelopes).toHaveLength(1)); + expect(globalView.envelopes[0]).toMatchObject({ + type: 'event.model_catalog.changed', + session_id: '__global__', + payload: { changed, unchanged: ['openai-main'], failed }, + }); + expect(globalView.deliveries).toEqual(['immediate']); + }); + + it('drops malformed event.model_catalog.changed payloads', async () => { + const globalView = collectingTarget(); + bc.addGlobalTarget(globalView.target); + + eventBus.emit({ type: 'event.model_catalog.changed', payload: null }); + eventBus.emit({ type: 'event.model_catalog.changed', payload: { changed: [] } }); + eventBus.emit({ + type: 'event.model_catalog.changed', + payload: { + changed: [{ provider_id: 'p', provider_name: 'P', added: '1', removed: 0 }], + unchanged: [], + failed: [], + }, + }); + eventBus.emit({ + type: 'event.model_catalog.changed', + payload: { + changed: [{ provider_id: 'p', provider_name: 'P', added: -1, removed: 0 }], + unchanged: [], + failed: [], + }, + }); + eventBus.emit({ + type: 'event.model_catalog.changed', + payload: { + changed: [{ provider_id: 'p', provider_name: 'P', added: 0.5, removed: 0 }], + unchanged: [], + failed: [], + }, + }); + eventBus.emit({ + type: 'event.model_catalog.changed', + payload: { + changed: [{ provider_id: '', provider_name: 'P', added: 1, removed: 0 }], + unchanged: [], + failed: [], + }, + }); + eventBus.emit({ + type: 'event.model_catalog.changed', + payload: { changed: [], unchanged: ['ok', 7], failed: [] }, + }); + eventBus.emit({ + type: 'event.model_catalog.changed', + payload: { changed: [], unchanged: ['ok', ''], failed: [] }, + }); + eventBus.emit({ + type: 'event.model_catalog.changed', + payload: { changed: [], unchanged: [], failed: [{ provider: 'p' }] }, + }); + eventBus.emit({ + type: 'event.model_catalog.changed', + payload: { changed: [], unchanged: [], failed: [{ provider: 'p', reason: '' }] }, + }); + + const changed = [ + { provider_id: 'managed:kimi-code', provider_name: 'Kimi Code', added: 1, removed: 0 }, + ]; + eventBus.emit({ + type: 'event.model_catalog.changed', + payload: { changed, unchanged: [], failed: [] }, + }); + + await vi.waitFor(() => expect(globalView.envelopes).toHaveLength(1)); + expect(globalView.envelopes[0]).toMatchObject({ + type: 'event.model_catalog.changed', + payload: { changed, unchanged: [], failed: [] }, + }); + }); }); it('emits a durable event.session.work_changed(busy) trailing turn.started', async () => { @@ -2874,3 +3039,92 @@ describe('SessionEventBroadcaster', () => { }); }); }); + +describe('sessionEventMessageSchema', () => { + const timestamp = '2026-08-27T00:00:00.000Z'; + + function envelope(payload: Record): Record { + return { + type: payload['type'], + seq: 3, + session_id: '__global__', + timestamp, + payload: { agentId: 'main', sessionId: '__global__', ...payload }, + }; + } + + it('accepts config changed, config warning, and model catalog changed envelopes', () => { + expect( + sessionEventMessageSchema.safeParse( + envelope({ + type: 'event.config.changed', + changedFields: ['defaultModel'], + config: { default_model: 'k2', providers: {} }, + }), + ).success, + ).toBe(true); + + expect( + sessionEventMessageSchema.safeParse( + envelope({ + type: 'event.config.warning', + warnings: [{ domain: 'loopControl', message: 'deprecated key' }, { message: 'other' }], + }), + ).success, + ).toBe(true); + + expect( + sessionEventMessageSchema.safeParse( + envelope({ + type: 'event.model_catalog.changed', + changed: [ + { provider_id: 'managed:kimi-code', provider_name: 'Kimi Code', added: 2, removed: 1 }, + ], + unchanged: ['openai-main'], + failed: [{ provider: 'managed:kimi-code', reason: 'network disabled' }], + }), + ).success, + ).toBe(true); + }); + + it('rejects malformed config and model catalog event envelopes', () => { + expect( + sessionEventMessageSchema.safeParse( + envelope({ type: 'event.config.changed', changedFields: 'defaultModel', config: {} }), + ).success, + ).toBe(false); + + expect( + sessionEventMessageSchema.safeParse( + envelope({ type: 'event.config.changed', changedFields: [], config: [] }), + ).success, + ).toBe(false); + + expect( + sessionEventMessageSchema.safeParse( + envelope({ type: 'event.config.warning', warnings: [{ domain: 'loopControl' }] }), + ).success, + ).toBe(false); + + expect( + sessionEventMessageSchema.safeParse( + envelope({ + type: 'event.model_catalog.changed', + changed: [], + failed: [], + }), + ).success, + ).toBe(false); + + expect( + sessionEventMessageSchema.safeParse( + envelope({ + type: 'event.model_catalog.changed', + changed: [], + unchanged: [], + failed: [{ provider: 'managed:kimi-code', reason: 42 }], + }), + ).success, + ).toBe(false); + }); +}); diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index dd2a2846787..16997fef89f 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -405,6 +405,56 @@ describe('server-v2 /api/v1/sessions', () => { expect(typeof body.data.has_more).toBe('boolean'); }); + it('fills agent_config.model from the live session profile', async () => { + await server?.close(); + server = undefined; + const cwd = home as string; + await writeFile( + join(cwd, 'config.toml'), + [ + 'default_model = "stub"', + '', + '[providers.stub]', + 'type = "openai"', + 'base_url = "http://127.0.0.1:9999"', + 'api_key = "stub"', + '', + '[models.stub]', + 'provider = "stub"', + 'model = "stub"', + 'max_context_size = 1000', + '', + ].join('\n'), + 'utf-8', + ); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + debugEndpoints: true, + }); + base = `http://127.0.0.1:${server.port}`; + + const created = await postJson('/api/v1/sessions', { metadata: { cwd } }); + const id = created.body.data.id; + expect(created.body.data.agent_config).toEqual({ model: '' }); + + const updated = await postJson(`/api/v1/sessions/${id}/profile`, { + agent_config: { model: 'stub' }, + }); + expect(updated.body.code).toBe(0); + expect(updated.body.data.agent_config).toEqual({ model: 'stub' }); + + const listed = await getJson('/api/v1/sessions'); + const item = listed.body.data.items.find((s) => s.id === id); + expect(item?.agent_config).toEqual({ model: 'stub' }); + + const got = await getJson(`/api/v1/sessions/${id}`); + expect(got.body.data.agent_config).toEqual({ model: 'stub' }); + }); + it('supports exclude_empty when listing sessions', async () => { const cwd = home as string; const created = await postJson('/api/v1/sessions', { metadata: { cwd } }); diff --git a/packages/kap-server/test/v2Sessions.test.ts b/packages/kap-server/test/v2Sessions.test.ts index 842aa1a65b9..7fe815dda87 100644 --- a/packages/kap-server/test/v2Sessions.test.ts +++ b/packages/kap-server/test/v2Sessions.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -37,7 +37,7 @@ interface SessionWireV2 { updated_at: number; archived: boolean; }; - activity: { status: 'running' | 'approval' | 'question' | 'failed' | 'idle' }; + activity: { status: 'running' | 'approval' | 'question' | 'failed' | 'idle'; model: string | null }; git?: { branch: string | null; pull_request: { number: number; state: 'open' | 'closed' | 'merged'; url: string } | null; @@ -228,7 +228,7 @@ describe('server /api/v2/sessions', () => { archived: false, archived_at: null, }); - expect(first.activity).toEqual({ status: 'idle' }); + expect(first.activity).toEqual({ status: 'idle', model: null }); expect('git' in first).toBe(false); const second = page.items[1] as SessionWireV2; @@ -237,6 +237,56 @@ describe('server /api/v2/sessions', () => { expect(third.meta.last_prompt).toBeNull(); }); + it('exposes the live session model in the activity domain', async () => { + await (server as RunningServer).close(); + await writeFile( + join(home as string, 'config.toml'), + [ + 'default_model = "stub"', + '', + '[providers.stub]', + 'type = "openai"', + 'base_url = "http://127.0.0.1:9999"', + 'api_key = "stub"', + '', + '[models.stub]', + 'provider = "stub"', + 'model = "stub"', + 'max_context_size = 1000', + '', + ].join('\n'), + 'utf-8', + ); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + base = `http://127.0.0.1:${server.port}`; + + const created = await authedFetch(server, base, '/api/v1/sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ metadata: { cwd: home } }), + }); + const createdBody = (await created.json()) as { code: number; data: { id: string } }; + expect(createdBody.code).toBe(0); + const id = createdBody.data.id; + + const bound = await authedFetch(server, base, `/api/v1/sessions/${id}/profile`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ agent_config: { model: 'stub' } }), + }); + expect(((await bound.json()) as { code: number }).code).toBe(0); + + const page = await getData(); + const item = page.items.find((entry) => entry.id === id); + expect(item?.activity).toEqual({ status: 'idle', model: 'stub' }); + }); + it('filters by workspace.id (single, repeated OR, unknown)', async () => { const single = await getData(`?workspace.id=${WS_A}`); expect(single.items.map((item) => item.id)).toEqual(['s1', 's2']); @@ -563,7 +613,7 @@ describe('server /api/v2/sessions', () => { const first = a.sessions[0] as SessionWireV2; expect(first.meta.last_prompt).toBe('do alpha'); - expect(first.activity).toEqual({ status: 'idle' }); + expect(first.activity).toEqual({ status: 'idle', model: null }); expect('git' in first).toBe(false); }); diff --git a/packages/klient/test/e2e/legacy/client.test.ts b/packages/klient/test/e2e/legacy/client.test.ts index 7792d42bc8f..4ab6345f036 100644 --- a/packages/klient/test/e2e/legacy/client.test.ts +++ b/packages/klient/test/e2e/legacy/client.test.ts @@ -437,9 +437,8 @@ describe('DaemonClient session action helpers', () => { fetchImpl: recordingFetchSequence( [ okEnvelope({ - ready: true, + models_ready: true, providers_count: 1, - default_model: model.model, managed_provider: null, }), okEnvelope({ items: [model] }), @@ -451,7 +450,7 @@ describe('DaemonClient session action helpers', () => { ), }); - await expect(client.getAuth()).resolves.toMatchObject({ default_model: model.model }); + await expect(client.getAuth()).resolves.toMatchObject({ models_ready: true }); await expect(client.listModels()).resolves.toEqual({ items: [model] }); await expect(client.setDefaultModel(model.model)).resolves.toEqual({ default_model: model.model, diff --git a/packages/klient/test/e2e/legacy/refresh-replay.test.ts b/packages/klient/test/e2e/legacy/refresh-replay.test.ts index 6d6feb45082..5a001114b2d 100644 --- a/packages/klient/test/e2e/legacy/refresh-replay.test.ts +++ b/packages/klient/test/e2e/legacy/refresh-replay.test.ts @@ -188,13 +188,12 @@ describeLive('refresh-replay (live server required)', () => { it('phase 0: /auth returns AuthSummary shape', async () => { const log = createCaseLogger('refresh: auth'); const auth = await getEnvelope<{ - ready: boolean; + models_ready: boolean; providers_count: number; - default_model: string | null; managed_provider: { name: string; status: string } | null; }>('/auth', log); log('data', auth); - expect(typeof auth.ready).toBe('boolean'); + expect(typeof auth.models_ready).toBe('boolean'); expect(typeof auth.providers_count).toBe('number'); }); diff --git a/packages/node-sdk/test/session-event-types.test.ts b/packages/node-sdk/test/session-event-types.test.ts index 4e59030510b..2c0a460870d 100644 --- a/packages/node-sdk/test/session-event-types.test.ts +++ b/packages/node-sdk/test/session-event-types.test.ts @@ -75,6 +75,7 @@ describe('Event public types', () => { case 'event.workspace.updated': case 'event.workspace.deleted': case 'event.config.changed': + case 'event.config.warning': case 'event.model_catalog.changed': case 'event.plugin.changed': case 'event.capability.changed': diff --git a/packages/oauth/src/refreshProviderModels.ts b/packages/oauth/src/refreshProviderModels.ts index 49c521fd631..2bd78037ef4 100644 --- a/packages/oauth/src/refreshProviderModels.ts +++ b/packages/oauth/src/refreshProviderModels.ts @@ -216,7 +216,8 @@ interface ProviderModelSnapshot { // a registry can change capabilities (e.g. enabling reasoning) without changing // any model ID. Spreading the whole alias keeps this in sync with the schema // automatically; only `capabilities` needs normalizing because its order is not -// meaningful. +// meaningful. `defaultModel` joins the snapshot so a lost selection flips the +// provider to changed and the re-selected default is written back. function providerModelSnapshot( config: ManagedKimiConfigShape, providerId: string, @@ -235,7 +236,7 @@ function providerModelSnapshot( }); } snapshots.sort((a, b) => a.alias.localeCompare(b.alias)); - return JSON.stringify(snapshots); + return JSON.stringify({ defaultModel: config.defaultModel ?? null, models: snapshots }); } function providerModelsEqual( @@ -314,6 +315,14 @@ function restoreDefaultSelection( } } +async function rebaseSelectionAfterFetch( + host: RefreshProviderHost, + config: ManagedKimiConfigShape, +): Promise { + const fresh = await host.getConfig(); + return { ...config, defaultModel: fresh.defaultModel, thinking: fresh.thinking }; +} + // `apply*` may leave `defaultModel` pointing at an alias that no longer exists // (e.g. the previously-selected model was dropped from the registry). The host's // `setConfig` deep-merge cannot clear a key, so the matching `removeProvider` @@ -408,6 +417,7 @@ export async function refreshProviderModels( baseUrl: auth.baseUrl, }); if (models.length > 0) { + config = await rebaseSelectionAfterFetch(host, config); const next = structuredClone(config); applyManagedKimiCodeConfig(next, { models, @@ -487,6 +497,7 @@ export async function refreshProviderModels( models = filterModelsByPrefix(models, platform); if (models.length === 0) continue; + config = await rebaseSelectionAfterFetch(host, config); const selectedModelId = pickDefaultModel(config, providerId, models); const selectedModel = models.find((m) => m.id === selectedModelId); if (selectedModel === undefined) continue; @@ -566,6 +577,7 @@ export async function refreshProviderModels( }); if (models.length === 0) continue; + config = await rebaseSelectionAfterFetch(host, config); // A hand-written `managed:kimi-code` shares the OAuth branch's // `kimi-code/` alias prefix so the two shapes merge cleanly if the user // later logs in via OAuth; ordinary providers use their own id. @@ -658,6 +670,7 @@ export async function refreshProviderModels( if (targetId !== undefined && !providerIds.includes(targetId)) continue; try { const { entries, source } = await fetchCustomRegistryFromSources(sources, host.userAgent); + config = await rebaseSelectionAfterFetch(host, config); // Build the whole batch on one clone so that several changed providers // from the same source do not overwrite each other's aliases, and so the // config we compare is exactly the config we persist. diff --git a/packages/protocol/src/__tests__/events.test.ts b/packages/protocol/src/__tests__/events.test.ts index 6b97ab7751d..799ae7f3673 100644 --- a/packages/protocol/src/__tests__/events.test.ts +++ b/packages/protocol/src/__tests__/events.test.ts @@ -122,6 +122,104 @@ describe('events / display re-exports', () => { expect((parsed as { promptId?: string }).promptId).toBe('prompt_1'); }); + it('validates global config and model catalog events through the full event union', () => { + expect( + eventSchema.safeParse({ + type: 'event.config.changed', + agentId: 'main', + sessionId: '__global__', + changedFields: ['defaultModel'], + config: { default_model: 'k2', providers: {} }, + }).success, + ).toBe(true); + + expect( + eventSchema.safeParse({ + type: 'event.config.warning', + agentId: 'main', + sessionId: '__global__', + warnings: [{ domain: 'loopControl', message: 'deprecated key' }, { message: 'other' }], + }).success, + ).toBe(true); + + expect( + eventSchema.safeParse({ + type: 'event.model_catalog.changed', + agentId: 'main', + sessionId: '__global__', + changed: [ + { provider_id: 'managed:kimi-code', provider_name: 'Kimi Code', added: 2, removed: 1 }, + ], + unchanged: ['openai-main'], + failed: [{ provider: 'managed:kimi-code', reason: 'network disabled' }], + }).success, + ).toBe(true); + + const parsed = eventSchema.parse({ + type: 'event.config.changed', + agentId: 'main', + sessionId: '__global__', + changedFields: ['mcp'], + config: { providers: {}, mcp: { servers: { fs: { command: 'mcp-fs' } } } }, + }); + expect((parsed as { config: Record }).config['mcp']).toEqual({ + servers: { fs: { command: 'mcp-fs' } }, + }); + }); + + it('rejects malformed config and model catalog events through the full event union', () => { + expect( + eventSchema.safeParse({ + type: 'event.config.changed', + agentId: 'main', + sessionId: '__global__', + changedFields: 'defaultModel', + config: {}, + }).success, + ).toBe(false); + + expect( + eventSchema.safeParse({ + type: 'event.config.changed', + agentId: 'main', + sessionId: '__global__', + changedFields: [], + config: [], + }).success, + ).toBe(false); + + expect( + eventSchema.safeParse({ + type: 'event.config.warning', + agentId: 'main', + sessionId: '__global__', + warnings: [{ domain: 'loopControl' }], + }).success, + ).toBe(false); + + expect( + eventSchema.safeParse({ + type: 'event.model_catalog.changed', + agentId: 'main', + sessionId: '__global__', + changed: [{ provider_id: '', provider_name: 'Kimi Code', added: 2, removed: 1 }], + unchanged: [], + failed: [], + }).success, + ).toBe(false); + + expect( + eventSchema.safeParse({ + type: 'event.model_catalog.changed', + agentId: 'main', + sessionId: '__global__', + changed: [{ provider_id: 'p', provider_name: 'P', added: -1, removed: 0 }], + unchanged: [], + failed: [], + }).success, + ).toBe(false); + }); + it('validates prompt.submitted events', () => { const parsed = eventSchema.parse({ type: 'prompt.submitted', diff --git a/packages/protocol/src/__tests__/rest-auth.test.ts b/packages/protocol/src/__tests__/rest-auth.test.ts index b826c71a0d8..24c9c170c94 100644 --- a/packages/protocol/src/__tests__/rest-auth.test.ts +++ b/packages/protocol/src/__tests__/rest-auth.test.ts @@ -8,16 +8,14 @@ import { describe('authSummarySchema', () => { const emptyState: AuthSummary = { - ready: false, + models_ready: false, providers_count: 0, - default_model: null, managed_provider: null, }; const readyState: AuthSummary = { - ready: true, + models_ready: true, providers_count: 1, - default_model: 'kimi-k2', managed_provider: { name: 'kimi-code-oauth', status: 'authenticated', @@ -26,17 +24,15 @@ describe('authSummarySchema', () => { it('round-trips an empty (unprovisioned) state', () => { const parsed = authSummarySchema.parse(emptyState); - expect(parsed.ready).toBe(false); + expect(parsed.models_ready).toBe(false); expect(parsed.providers_count).toBe(0); - expect(parsed.default_model).toBeNull(); expect(parsed.managed_provider).toBeNull(); }); it('round-trips a ready state with managed provider', () => { const parsed = authSummarySchema.parse(readyState); - expect(parsed.ready).toBe(true); + expect(parsed.models_ready).toBe(true); expect(parsed.providers_count).toBe(1); - expect(parsed.default_model).toBe('kimi-k2'); expect(parsed.managed_provider).toEqual({ name: 'kimi-code-oauth', status: 'authenticated', @@ -59,8 +55,8 @@ describe('authSummarySchema', () => { expect(authSummarySchema.safeParse(bad).success).toBe(false); }); - it('rejects missing ready', () => { - const { ready: _omit, ...rest } = emptyState; + it('rejects missing models_ready', () => { + const { models_ready: _omit, ...rest } = emptyState; expect(authSummarySchema.safeParse(rest).success).toBe(false); }); @@ -69,11 +65,6 @@ describe('authSummarySchema', () => { expect(authSummarySchema.safeParse(rest).success).toBe(false); }); - it('rejects missing default_model', () => { - const { default_model: _omit, ...rest } = emptyState; - expect(authSummarySchema.safeParse(rest).success).toBe(false); - }); - it('rejects missing managed_provider', () => { const { managed_provider: _omit, ...rest } = emptyState; expect(authSummarySchema.safeParse(rest).success).toBe(false); diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index 8e22d7c85f6..66938a1a658 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -611,6 +611,20 @@ export interface ConfigChangedEvent { readonly config: ConfigResponse; } +export interface ConfigWarningItem { + readonly domain?: string; + readonly message: string; +} + +/** + * Pushed when the daemon's config validation produces warnings (deprecated + * keys, unknown fields). Bare global fan-out alongside `event.config.changed`. + */ +export interface ConfigWarningEvent { + readonly type: 'event.config.warning'; + readonly warnings: readonly ConfigWarningItem[]; +} + /** * Pushed when the daemon refreshes provider model metadata (manual or * scheduled) and the effective catalog changed. Carries the per-provider @@ -1015,6 +1029,7 @@ export type AgentEvent = | SessionWorkChangedEvent | SessionStatusChangedEvent | ConfigChangedEvent + | ConfigWarningEvent | ModelCatalogChangedEvent | PluginChangedEvent | CapabilityChangedEvent @@ -1601,6 +1616,16 @@ export const configChangedEventSchema = z.object({ config: configResponseSchema, }) satisfies z.ZodType; +export const configWarningEventSchema = z.object({ + type: z.literal('event.config.warning'), + warnings: z.array( + z.object({ + domain: z.string().optional(), + message: z.string(), + }), + ), +}) satisfies z.ZodType; + export const modelCatalogChangedEventSchema = z.object({ type: z.literal('event.model_catalog.changed'), changed: z.array(providerRefreshChangeSchema), @@ -1954,6 +1979,8 @@ export const agentEventSchema = z.discriminatedUnion('type', [ workspaceDeletedEventSchema, sessionWorkChangedEventSchema, sessionStatusChangedEventSchema, + configChangedEventSchema, + configWarningEventSchema, modelCatalogChangedEventSchema, pluginChangedEventSchema, capabilityChangedEventSchema, diff --git a/packages/protocol/src/rest/auth.ts b/packages/protocol/src/rest/auth.ts index 8edb0888408..ab7e3a19bed 100644 --- a/packages/protocol/src/rest/auth.ts +++ b/packages/protocol/src/rest/auth.ts @@ -1,9 +1,8 @@ /** * GET /v1/auth * Reply: AuthSummary { - * ready, + * models_ready, * providers_count, - * default_model, * managed_provider * } */ @@ -24,9 +23,8 @@ export const managedProviderSummarySchema = z.object({ export type ManagedProviderSummary = z.infer; export const authSummarySchema = z.object({ - ready: z.boolean(), + models_ready: z.boolean(), providers_count: z.number().int().nonnegative(), - default_model: z.string().nullable(), managed_provider: managedProviderSummarySchema.nullable(), }); export type AuthSummary = z.infer; diff --git a/packages/protocol/src/rest/config.ts b/packages/protocol/src/rest/config.ts index 5dcb7d45afd..8342cbbe8d7 100644 --- a/packages/protocol/src/rest/config.ts +++ b/packages/protocol/src/rest/config.ts @@ -8,27 +8,29 @@ export const providerConfigResponseSchema = z.object({ }); export type ProviderConfigResponse = z.infer; -export const configResponseSchema = z.object({ - providers: z.record(z.string(), providerConfigResponseSchema).default({}), - default_provider: z.string().optional(), - default_model: z.string().optional(), - models: z.record(z.string(), z.unknown()).optional(), - thinking: z.unknown().optional(), - plan_mode: z.boolean().optional(), - yolo: z.boolean().optional(), - default_permission_mode: z.string().optional(), - default_plan_mode: z.boolean().optional(), - permission: z.unknown().optional(), - hooks: z.array(z.unknown()).optional(), - services: z.unknown().optional(), - merge_all_available_skills: z.boolean().optional(), - extra_skill_dirs: z.array(z.string()).optional(), - loop_control: z.unknown().optional(), - background: z.unknown().optional(), - experimental: z.record(z.string(), z.boolean()).optional(), - telemetry: z.boolean().optional(), - raw: z.record(z.string(), z.unknown()).optional(), -}); +export const configResponseSchema = z + .object({ + providers: z.record(z.string(), providerConfigResponseSchema).default({}), + default_provider: z.string().optional(), + default_model: z.string().optional(), + models: z.record(z.string(), z.unknown()).optional(), + thinking: z.unknown().optional(), + plan_mode: z.boolean().optional(), + yolo: z.boolean().optional(), + default_permission_mode: z.string().optional(), + default_plan_mode: z.boolean().optional(), + permission: z.unknown().optional(), + hooks: z.array(z.unknown()).optional(), + services: z.unknown().optional(), + merge_all_available_skills: z.boolean().optional(), + extra_skill_dirs: z.array(z.string()).optional(), + loop_control: z.unknown().optional(), + background: z.unknown().optional(), + experimental: z.record(z.string(), z.boolean()).optional(), + telemetry: z.boolean().optional(), + raw: z.record(z.string(), z.unknown()).optional(), + }) + .passthrough(); export type ConfigResponse = z.infer; export const patchConfigRequestSchema = z.object({