Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
c620856
feat(auth): split model readiness from sign-in state in /api/v1/auth
liruifengv Aug 27, 2026
7bf018d
fix(kap-server): expose the session model in session list projections
liruifengv Aug 27, 2026
6891020
fix(kap-server): gate prompt submission on the effective session model
liruifengv Aug 27, 2026
ccf2501
fix(agent-core-v2): honor defaultProvider in model readiness resolution
liruifengv Aug 27, 2026
dab3ac2
fix(kap-server): redact inline model credentials from config responses
liruifengv Aug 27, 2026
a68dcf9
fix(agent-core-v2): honor defaultProvider in ensureReady credential c…
liruifengv Aug 28, 2026
1185efb
fix(kap-server): validate the model a profile bind will select at the…
liruifengv Aug 28, 2026
3566081
fix(kap-server): redact inline service credentials from config responses
liruifengv Aug 28, 2026
0abb0de
fix(kap-server): keep unlisted config domains through event validation
liruifengv Aug 28, 2026
4e96fb4
fix(agent-core-v2): use the exact configured key for model readiness …
liruifengv Aug 28, 2026
5eeb378
fix(protocol): keep unlisted config domains in the shared event proje…
liruifengv Aug 28, 2026
d5dc506
fix(agent-core-v2): use the exact default_provider key in readiness c…
liruifengv Aug 28, 2026
9400a24
chore: sync web dist from code-app
liruifengv Aug 28, 2026
8af9566
Revert "chore: sync web dist from code-app"
liruifengv Aug 28, 2026
e602b37
fix(oauth): rebase the default selection after the refresh fetch
liruifengv Aug 28, 2026
4a5c2e9
style(kap-server): pass optional custom_header_keys without condition…
liruifengv Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions docs/en/reference/server-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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`

Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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:

Expand All @@ -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
}
],
Expand Down Expand Up @@ -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 |
Expand Down
16 changes: 9 additions & 7 deletions docs/zh/reference/server-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | 取消进行中的登录流程 |
Expand All @@ -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`

Expand Down Expand Up @@ -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` 外的任意子集,均为可选:

Expand Down Expand Up @@ -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` 时,同一份过滤、排序后的集合会重新投影为按工作区分组的形态,概览页因此可以用一次请求替代「每个工作区各一轮询」:

Expand All @@ -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
}
],
Expand Down Expand Up @@ -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` 过滤。主要事件族:

| 事件族 | 主要事件 |
Expand Down
76 changes: 47 additions & 29 deletions packages/agent-core-v2/src/app/auth/authService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Comment thread
liruifengv marked this conversation as resolved.

const auth = resolveModelAuthMaterial({
modelId,
modelId: modelId as string,
model,
provider,
providerName,
Expand All @@ -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);
Comment thread
liruifengv marked this conversation as resolved.
}
return new AuthModelNotResolvedError(modelId);
}

function classifyFailure(err: unknown): OAuthFlowStatus {
if (err instanceof DeviceCodeTimeoutError) return 'expired';
if (err instanceof OAuthError) {
Expand All @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 1 addition & 2 deletions packages/agent-core-v2/src/app/authLegacy/authLegacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,8 @@ export const managedProviderSummarySchema = z.object({
export type ManagedProviderSummary = z.infer<typeof managedProviderSummarySchema>;

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<typeof authSummarySchema>;
Expand Down
Loading
Loading