diff --git a/packages/agent-core-v2/docs/di-scope-domains.puml b/packages/agent-core-v2/docs/di-scope-domains.puml
index a772f113d..8d5b11832 100644
--- a/packages/agent-core-v2/docs/di-scope-domains.puml
+++ b/packages/agent-core-v2/docs/di-scope-domains.puml
@@ -31,6 +31,7 @@ package "Core scope (process-wide)" #EAF3FB {
rectangle "hostFs\nCore\n IHostFileSystem" as hostFs #D6EAF8
rectangle "workspaceRegistry\nCore\n IWorkspaceRegistry" as workspaceRegistry #D6EAF8
rectangle "hostFolderBrowser\nCore\n IHostFolderBrowser" as hostFolderBrowser #D6EAF8
+ rectangle "kaos\nCore\n IKaosFactory" as kaos_core #D6EAF8
rectangle "auth\nCore\n IOAuthService\n IAuthSummaryService" as auth #D6EAF8
rectangle "provider\nCore\n IProviderService" as provider #D6EAF8
rectangle "flag\nCore\n IFlagService\n IFlagRegistry" as flag #D6EAF8
@@ -48,11 +49,12 @@ package "Session scope (per session)" #EAFAF1 {
rectangle "agent-lifecycle\nSession\n IAgentLifecycleService" as agent_lifecycle #D5F5E3
rectangle "interaction\nSession\n IInteractionService" as interaction #D5F5E3
rectangle "workspaceContext\nSession\n IWorkspaceContext" as workspaceContext #D5F5E3
- rectangle "agentFs\nSession\n IAgentFileSystem\n IFileSystemBackend" as agentFs #D5F5E3
+ rectangle "kaos\nSession\n IKaos (seed)" as kaos #D5F5E3
+ rectangle "agentFs\nSession\n IAgentFileSystem\n IFsService" as agentFs #D5F5E3
rectangle "approval\nSession\n IApprovalService" as approval #D5F5E3
rectangle "question\nSession\n IQuestionService" as question #D5F5E3
rectangle "subagentHost\nSession\n ISubagentHost" as subagentHost #D5F5E3
- rectangle "process\nSession\n IProcessRunner\n IProcessBackend" as process #D5F5E3
+ rectangle "process\nSession\n IProcessRunner\n IProcess" as process #D5F5E3
rectangle "terminal\nSession\n ITerminalService\n ITerminalBackend" as terminal #D5F5E3
rectangle "modelProvider\nSession\n IModelProvider (seed)" as modelProvider #D5F5E3
}
@@ -95,6 +97,8 @@ package "Agent scope (per agent)" #FDF5E6 {
rectangle "todoList\nAgent\n ITodoListService" as todoList #FDEBD0
rectangle "usage\nAgent\n IUsageService" as usage #FDEBD0
rectangle "rpc\nAgent\n IAgentRPCService" as rpc #FDEBD0
+ rectangle "fileTools\nAgent\n IFileToolsService" as fileTools #FDEBD0
+ rectangle "shellTools\nAgent\n IShellToolsService" as shellTools #FDEBD0
}
' ---- DI injection (solid) ----
@@ -104,8 +108,10 @@ gateway --> eventSink #34495E
sessionIndex --> bootstrap #34495E
sessionIndex --> storage #34495E
session_lifecycle --> bootstrap #34495E
+session_lifecycle --> kaos_core #34495E
hostFolderBrowser --> hostFs #34495E
auth --> provider #34495E
+auth --> config #34495E
auth --> bootstrap #34495E
auth --> telemetry #34495E
auth --> log #34495E
@@ -121,8 +127,9 @@ modelCatalog --> model #34495E
modelCatalog --> config #34495E
modelCatalog --> auth #34495E
agentFs --> workspaceContext #34495E
-agentFs --> hostFs #34495E
-process --> workspaceContext #34495E
+agentFs --> kaos #34495E
+agentFs --> process #34495E
+process --> kaos #34495E
terminal --> workspaceContext #34495E
terminal --> session_context #34495E
approval --> interaction #34495E
@@ -302,6 +309,16 @@ rpc --> subagentHost #34495E
rpc --> usage #34495E
rpc --> telemetry #34495E
rpc --> goal #34495E
+rpc --> fileTools #34495E
+rpc --> shellTools #34495E
+fileTools --> toolRegistry #34495E
+fileTools --> agentFs #34495E
+fileTools --> kaos #34495E
+fileTools --> workspaceContext #34495E
+shellTools --> toolRegistry #34495E
+shellTools --> process #34495E
+shellTools --> kaos #34495E
+shellTools --> background #34495E
' ---- event-driven (dashed) ----
gateway ..> event #16A085 : subscribe
diff --git a/packages/agent-core-v2/examples/oauth.example.ts b/packages/agent-core-v2/examples/oauth.example.ts
new file mode 100644
index 000000000..d6ecf5ff7
--- /dev/null
+++ b/packages/agent-core-v2/examples/oauth.example.ts
@@ -0,0 +1,245 @@
+/**
+ * Scenario: the **auth → modelCatalog** slice — a device-code OAuth login
+ * followed by a managed `/models` refresh, with both steps observed through
+ * `config.onDidChange`.
+ *
+ * This example exists to make one design point concrete: **the caller never
+ * hand-rolls a `/models` request.** The flow is split into two internal,
+ * config-driven steps, and the caller reacts to config changes instead of
+ * plumbing model lists around:
+ *
+ * 1. **Login writes a credential, not models.** `IOAuthService.startLogin`
+ * drives the device-code flow; on success `OAuthService` only provisions
+ * the provider credential (the OAuth ref) into the `providers` config
+ * section. That write fires `config.onDidChange('providers')`, which the
+ * `provider` domain forwards as `providerService.onDidChange`. `auth` does
+ * not know about `modelCatalog` — dependency direction stays one-way
+ * (`modelCatalog` → `auth`, never the reverse).
+ * 2. **Refresh pulls `/models` internally and merges it into config.**
+ * `IOAuthService.refreshOAuthProviderModels` resolves the OAuth
+ * token through `IOAuthService`, fetches the managed model list, and
+ * writes the result into the `models` / `providers` / `defaultModel`
+ * sections through `IConfigService` — each firing `onDidChange`. The caller
+ * *triggers* the refresh explicitly (it is not auto-chained inside login),
+ * then observes the new aliases arrive through config.
+ *
+ * Everything runs against the real Core-scope Services **and** the real OAuth
+ * clients — `KimiOAuthToolkit` (device-code protocol + token persistence) and
+ * `fetchManagedKimiCodeModels` (the `/models` request) are not stubbed. The
+ * only thing faked is the wire itself: `globalThis.fetch` is replaced with a
+ * tiny URL/method router that answers the OAuth device-code endpoints and the
+ * `/models` endpoint. No server listens on any port; the clients construct real
+ * requests and read real `Response` objects, so the request / response shapes
+ * (headers, snake_case wire, status-code branches) are exercised for real.
+ *
+ * All Services come from `src/`; nothing here defines a new Service.
+ */
+
+import { randomUUID } from 'node:crypto';
+import { mkdirSync } from 'node:fs';
+import { join } from 'node:path';
+
+import { KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth';
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+
+import type { Scope } from '#/_base/di/scope';
+import { IOAuthService } from '#/auth';
+import { bootstrap } from '#/bootstrap/bootstrap';
+import { IConfigService } from '#/config';
+import { logSeed, resolveLoggingConfig } from '#/log/logConfig';
+import { IModelService } from '#/model';
+import { IProviderService } from '#/provider';
+import '#/storage';
+import '#/telemetry';
+
+const STUB_ACCESS_TOKEN = 'stub-access-token';
+
+function jsonResponse(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ });
+}
+
+/**
+ * Replace `globalThis.fetch` with a router that answers exactly the three
+ * requests this slice issues: device authorization, device-code token polling,
+ * and the managed `/models` listing. Anything else throws so an unexpected call
+ * is loud instead of silently hitting the network.
+ */
+function installFetchMock(): void {
+ const router = async (input: RequestInfo | URL, init?: RequestInit): Promise => {
+ const url =
+ typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
+ const method = (
+ init?.method ?? (input instanceof Request ? input.method : 'GET')
+ ).toUpperCase();
+ const path = new URL(url).pathname;
+
+ if (method === 'POST' && path.endsWith('/api/oauth/device_authorization')) {
+ return jsonResponse(200, {
+ user_code: 'STUB-USER-CODE',
+ device_code: 'stub-device-code',
+ verification_uri: 'https://example.com/device',
+ verification_uri_complete: 'https://example.com/device?code=STUB-USER-CODE',
+ expires_in: 900,
+ interval: 0,
+ });
+ }
+
+ if (method === 'POST' && path.endsWith('/api/oauth/token')) {
+ return jsonResponse(200, {
+ access_token: STUB_ACCESS_TOKEN,
+ refresh_token: 'stub-refresh-token',
+ expires_in: 3600,
+ token_type: 'Bearer',
+ scope: '',
+ });
+ }
+
+ if (method === 'GET' && path.endsWith('/models')) {
+ return jsonResponse(200, {
+ data: [
+ {
+ id: 'k2-thinking',
+ context_length: 262_144,
+ supports_reasoning: true,
+ supports_image_in: false,
+ supports_video_in: false,
+ supports_thinking_type: 'both',
+ display_name: 'K2 Thinking',
+ },
+ {
+ id: 'k2',
+ context_length: 131_072,
+ supports_reasoning: false,
+ supports_image_in: false,
+ supports_video_in: false,
+ },
+ ],
+ });
+ }
+
+ throw new Error(`unexpected fetch: ${method} ${url}`);
+ };
+
+ vi.stubGlobal('fetch', router);
+}
+
+async function waitUntil(predicate: () => boolean, timeoutMs = 2000): Promise {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ if (predicate()) return;
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+ if (!predicate()) throw new Error('waitUntil timed out');
+}
+
+describe('oauth → modelCatalog slice (request-layer fetch mock, real clients)', () => {
+ let homeDir: string;
+ let caseDir: string;
+ let core: Scope | undefined;
+
+ beforeEach(() => {
+ const resolved = process.env['KIMI_CODE_HOME'];
+ if (resolved === undefined) {
+ throw new Error('KIMI_CODE_HOME is not set; globalSetup should have initialized it');
+ }
+ homeDir = resolved;
+ // The real `KimiOAuthToolkit` persists tokens to `{homeDir}/credentials`;
+ // give each test its own home so a token saved by one test cannot make the
+ // next test look "already authenticated" and skip the device-code flow.
+ caseDir = join(homeDir, randomUUID());
+ mkdirSync(caseDir, { recursive: true });
+ installFetchMock();
+ });
+
+ afterEach(() => {
+ core?.dispose();
+ core = undefined;
+ vi.unstubAllGlobals();
+ });
+
+ function buildCore(): Scope {
+ return bootstrap(
+ { homeDir: caseDir },
+ logSeed(resolveLoggingConfig({ homeDir: caseDir, env: process.env })),
+ ).core;
+ }
+
+ test('device-code login provisions the provider credential through config.onDidChange', async () => {
+ core = buildCore();
+ const config = core.accessor.get(IConfigService);
+ const oauth = core.accessor.get(IOAuthService);
+ const providers = core.accessor.get(IProviderService);
+ await config.ready;
+ providers.list();
+
+ const changed: string[] = [];
+ const sub = config.onDidChange((e) => changed.push(e.domain));
+
+ const start = await oauth.startLogin();
+ console.log('device code issued:', start.user_code, '→', start.verification_uri);
+ expect(start.status).toBe('pending');
+
+ // The credential lands asynchronously: handleSuccess provisions the
+ // provider after the device-code promise resolves. Wait for the OAuth ref
+ // to appear in config rather than for the flow status, so the config write
+ // is guaranteed to have committed.
+ await waitUntil(() => providers.get(KIMI_CODE_PROVIDER_NAME)?.oauth !== undefined);
+ sub.dispose();
+
+ const provider = providers.get(KIMI_CODE_PROVIDER_NAME);
+ console.log('provisioned provider:', JSON.stringify(provider));
+ console.log('config domains changed by login:', changed);
+
+ expect(provider?.oauth).toBeDefined();
+ expect(changed).toContain('providers');
+ expect(await oauth.status()).toEqual({ loggedIn: true, provider: KIMI_CODE_PROVIDER_NAME });
+ });
+
+ test('refreshOAuthProviderModels fetches /models internally and lands aliases through config.onDidChange', async () => {
+ core = buildCore();
+ const config = core.accessor.get(IConfigService);
+ const oauth = core.accessor.get(IOAuthService);
+ const providers = core.accessor.get(IProviderService);
+ const models = core.accessor.get(IModelService);
+ await config.ready;
+ providers.list();
+
+ // Login first so the provider holds an OAuth ref; the refresh resolves the
+ // token from that ref. The caller triggers the refresh explicitly — login
+ // does not auto-fetch models.
+ await oauth.startLogin();
+ await waitUntil(() => providers.get(KIMI_CODE_PROVIDER_NAME)?.oauth !== undefined);
+
+ const changed: string[] = [];
+ const sub = config.onDidChange((e) => changed.push(e.domain));
+ const result = await oauth.refreshOAuthProviderModels();
+ sub.dispose();
+
+ const aliases = models.list();
+ console.log('refresh result:', JSON.stringify(result));
+ console.log('config domains changed by refresh:', changed);
+ console.log('model aliases after refresh:', Object.keys(aliases));
+ console.log('defaultModel:', config.get('defaultModel'));
+
+ expect(result.failed).toEqual([]);
+ expect(result.unchanged).toEqual([]);
+ expect(result.changed).toHaveLength(1);
+ expect(result.changed[0]).toMatchObject({ provider_id: KIMI_CODE_PROVIDER_NAME, added: 2 });
+
+ // `applyManagedKimiCodeConfig` keys aliases as `kimi-code/`.
+ expect(aliases['kimi-code/k2-thinking']).toMatchObject({
+ provider: KIMI_CODE_PROVIDER_NAME,
+ model: 'k2-thinking',
+ displayName: 'K2 Thinking',
+ });
+ expect(aliases['kimi-code/k2']).toBeDefined();
+
+ // Models arrived through config, not through a caller-threaded return value.
+ expect(changed).toContain('models');
+ expect(changed).toContain('defaultModel');
+ expect(config.get('defaultModel')).toBe('kimi-code/k2-thinking');
+ });
+});
diff --git a/packages/agent-core-v2/src/auth/auth.ts b/packages/agent-core-v2/src/auth/auth.ts
index 8ccc080cd..1ca4a36ed 100644
--- a/packages/agent-core-v2/src/auth/auth.ts
+++ b/packages/agent-core-v2/src/auth/auth.ts
@@ -2,18 +2,27 @@
* `auth` domain (cross-cutting) — core-scope OAuth + auth summary contracts.
*
* Defines the public contracts of authentication: the `AuthStatus` model, the
- * `IOAuthService` used to drive device-code login / logout / flow inspection
- * and to resolve a per-provider `BearerTokenProvider`, and the
- * `IAuthSummaryService` used to summarize auth state and assert readiness.
+ * `IOAuthService` used to drive device-code login / logout / flow inspection,
+ * to resolve a per-provider `BearerTokenProvider`, and to refresh a managed
+ * OAuth provider's server-side model configuration, the `IOAuthToolkit`
+ * device-code client that `IOAuthService` delegates the OAuth protocol to, and
+ * the `IAuthSummaryService` used to summarize auth state and assert readiness.
* Core-scoped — shared across the application.
*/
-import type { BearerTokenProvider } from '@moonshot-ai/kimi-code-oauth';
+import type {
+ BearerTokenProvider,
+ KimiOAuthLoginOptions,
+ KimiOAuthLoginResult,
+ KimiOAuthLogoutResult,
+ KimiOAuthTokenRef,
+} from '@moonshot-ai/kimi-code-oauth';
import type {
OAuthFlowSnapshot,
OAuthFlowStart,
OAuthLoginCancelResponse,
OAuthLogoutResponse,
+ RefreshOAuthProviderModelsResponse,
} from '@moonshot-ai/protocol';
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
@@ -32,6 +41,7 @@ export interface IOAuthService {
cancelLogin(provider?: string): Promise;
logout(provider?: string): Promise;
status(provider?: string): Promise;
+ refreshOAuthProviderModels(): Promise;
resolveTokenProvider(provider: string, oauthRef?: OAuthRef): BearerTokenProvider | undefined;
getCachedAccessToken(provider: string, oauthRef?: OAuthRef): Promise;
}
@@ -39,6 +49,20 @@ export interface IOAuthService {
export const IOAuthService: ServiceIdentifier =
createDecorator('oauthService');
+export interface IOAuthToolkit {
+ readonly _serviceBrand: undefined;
+ login(providerName?: string, options?: KimiOAuthLoginOptions): Promise;
+ logout(providerName?: string, oauthRef?: KimiOAuthTokenRef): Promise;
+ getCachedAccessToken(
+ providerName?: string,
+ oauthRef?: KimiOAuthTokenRef,
+ ): Promise;
+ tokenProvider(providerName?: string, oauthRef?: KimiOAuthTokenRef): BearerTokenProvider;
+}
+
+export const IOAuthToolkit: ServiceIdentifier =
+ createDecorator('oauthToolkit');
+
export interface IAuthSummaryService {
readonly _serviceBrand: undefined;
summarize(): Promise;
diff --git a/packages/agent-core-v2/src/auth/authService.ts b/packages/agent-core-v2/src/auth/authService.ts
index fcbc48cb9..66964d9ae 100644
--- a/packages/agent-core-v2/src/auth/authService.ts
+++ b/packages/agent-core-v2/src/auth/authService.ts
@@ -3,22 +3,30 @@
* implementation.
*
* Owns the device-code OAuth flows and the auth readiness view; reads and
- * writes provider configuration through `provider`, locates token storage
- * through `bootstrap`, reports through `telemetry`, logs through `log`, and
- * delegates token storage, refresh, and the device-code protocol to
- * `@moonshot-ai/kimi-code-oauth`. Bound at Core scope.
+ * writes provider configuration through `provider`, refreshes the managed
+ * OAuth provider's server-side model configuration through `config`, reports
+ * through `telemetry`, logs through `log`, and delegates the device-code
+ * protocol, token storage, and token refresh to `IOAuthToolkit` (provided by
+ * `OAuthToolkitService` over `@moonshot-ai/kimi-code-oauth`, which locates
+ * token storage through `bootstrap`). Bound at Core scope.
*/
import { randomUUID } from 'node:crypto';
import {
DeviceCodeTimeoutError,
+ KIMI_CODE_PLATFORM_ID,
KIMI_CODE_PROVIDER_NAME,
KimiOAuthToolkit,
kimiCodeBaseUrl,
OAuthError,
+ applyManagedKimiCodeConfig,
+ fetchManagedKimiCodeModels,
+ resolveKimiCodeOAuthRef,
+ resolveKimiCodeRuntimeAuth,
type BearerTokenProvider,
type DeviceAuthorization,
+ type ManagedKimiConfigShape,
} from '@moonshot-ai/kimi-code-oauth';
import type {
OAuthFlowSnapshot,
@@ -26,6 +34,7 @@ import type {
OAuthFlowStatus,
OAuthLoginCancelResponse,
OAuthLogoutResponse,
+ RefreshOAuthProviderModelsResponse,
} from '@moonshot-ai/protocol';
import { InstantiationType } from '#/_base/di/extensions';
@@ -33,19 +42,24 @@ import { Disposable } from '#/_base/di/lifecycle';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { ErrorCodes, KimiError } from '#/errors';
import { IBootstrapService } from '#/bootstrap';
+import { IConfigService } from '#/config/config';
import { ILogService } from '#/log/log';
-import { IProviderService, type OAuthRef } from '#/provider/provider';
+import { type ModelAlias, MODELS_SECTION } from '#/model/model';
+import { IProviderService, type OAuthRef, type ProviderConfig, PROVIDERS_SECTION } from '#/provider/provider';
import { ITelemetryService } from '#/telemetry/telemetry';
-import { type AuthStatus, IAuthSummaryService, IOAuthService } from './auth';
+import { type AuthStatus, IAuthSummaryService, IOAuthService, IOAuthToolkit } from './auth';
const TERMINAL_RETENTION_MS = 5 * 60 * 1000;
const DEFAULT_DEVICE_EXPIRES_IN_SEC = 15 * 60;
+const DEFAULT_MODEL_SECTION = 'defaultModel';
+const DEFAULT_THINKING_SECTION = 'defaultThinking';
interface FlowState {
readonly flowId: string;
readonly provider: string;
readonly controller: AbortController;
+ readonly oauthRef: OAuthRef | undefined;
device: DeviceAuthorization | undefined;
status: OAuthFlowStatus;
expiresAt: number;
@@ -56,29 +70,33 @@ interface FlowState {
export class OAuthService extends Disposable implements IOAuthService {
declare readonly _serviceBrand: undefined;
- private readonly toolkit: KimiOAuthToolkit;
private readonly flows = new Map();
constructor(
- toolkit: KimiOAuthToolkit | undefined = undefined,
+ @IOAuthToolkit private readonly toolkit: IOAuthToolkit,
@IProviderService private readonly providerService: IProviderService,
- @IBootstrapService bootstrap: IBootstrapService,
+ @IConfigService private readonly config: IConfigService,
@ITelemetryService private readonly telemetry: ITelemetryService,
@ILogService private readonly log: ILogService,
) {
super();
- this.toolkit = toolkit ?? new KimiOAuthToolkit({ homeDir: bootstrap.homeDir });
this._register(providerService.onDidChange(() => this.invalidateFlows()));
}
async startLogin(provider = KIMI_CODE_PROVIDER_NAME): Promise {
- const oauthRef = this.readOAuthRef(provider);
+ this.log.info('oauth startLogin: enter', { provider });
+ const oauthRef = this.resolveOAuthRef(provider);
+ this.log.info('oauth startLogin: resolved oauthRef', {
+ provider,
+ hasOAuthRef: oauthRef !== undefined,
+ });
this.abortExisting(provider);
const state: FlowState = {
flowId: `oauth_${randomUUID()}`,
provider,
controller: new AbortController(),
+ oauthRef,
device: undefined,
status: 'pending',
expiresAt: Date.now() + DEFAULT_DEVICE_EXPIRES_IN_SEC * 1000,
@@ -89,14 +107,18 @@ export class OAuthService extends Disposable implements IOAuthService {
this.flows.set(provider, state);
let resolveDevice!: (auth: DeviceAuthorization) => void;
- const deviceReady = new Promise((resolve) => {
+ let rejectDevice!: (error: unknown) => void;
+ const deviceReady = new Promise((resolve, reject) => {
resolveDevice = resolve;
+ rejectDevice = reject;
});
+ this.log.info('oauth startLogin: calling toolkit.login', { provider });
const loginPromise = this.toolkit.login(provider, {
signal: state.controller.signal,
oauthRef,
onDeviceCode: (auth) => {
+ this.log.info('oauth startLogin: onDeviceCode fired', { provider });
state.device = auth;
if (auth.expiresIn !== null) {
state.expiresAt = Date.now() + auth.expiresIn * 1000;
@@ -105,11 +127,33 @@ export class OAuthService extends Disposable implements IOAuthService {
},
});
loginPromise.then(
- () => this.handleSuccess(state),
- (error) => this.handleFailure(state, error),
+ () => {
+ this.log.info('oauth startLogin: toolkit.login resolved', {
+ provider,
+ deviceArrived: state.device !== undefined,
+ });
+ if (state.device === undefined) {
+ this.flows.delete(provider);
+ rejectDevice(
+ new Error('OAuth login completed without issuing a device code (already authenticated).'),
+ );
+ return;
+ }
+ this.handleSuccess(state);
+ },
+ (error) => {
+ this.log.warn('oauth startLogin: toolkit.login rejected', {
+ provider,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ this.handleFailure(state, error);
+ rejectDevice(error);
+ },
);
+ this.log.info('oauth startLogin: awaiting deviceReady', { provider });
const device = await deviceReady;
+ this.log.info('oauth startLogin: deviceReady resolved', { provider });
return this.toFlowStart(state, device);
}
@@ -131,15 +175,25 @@ export class OAuthService extends Disposable implements IOAuthService {
async logout(provider = KIMI_CODE_PROVIDER_NAME): Promise {
const oauthRef = this.readOAuthRefOptional(provider);
- await this.toolkit.logout(provider, oauthRef);
+ const result = await this.toolkit.logout(provider, oauthRef);
this.abortExisting(provider);
- return { logged_out: true, provider };
+ return { logged_out: true, provider: result.providerName };
}
async status(provider = KIMI_CODE_PROVIDER_NAME): Promise {
+ this.log.info('oauth status: enter', { provider });
const oauthRef = this.readOAuthRefOptional(provider);
- const token = await this.toolkit.getCachedAccessToken(provider, oauthRef);
- return token === undefined ? { loggedIn: false } : { loggedIn: true, provider };
+ try {
+ const token = await this.toolkit.getCachedAccessToken(provider, oauthRef);
+ this.log.info('oauth status: got token', { provider, hasToken: token !== undefined });
+ return token === undefined ? { loggedIn: false } : { loggedIn: true, provider };
+ } catch (error) {
+ this.log.warn('oauth status: getCachedAccessToken threw', {
+ provider,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ throw error;
+ }
}
resolveTokenProvider(provider: string, oauthRef?: OAuthRef): BearerTokenProvider | undefined {
@@ -150,15 +204,104 @@ export class OAuthService extends Disposable implements IOAuthService {
return this.toolkit.getCachedAccessToken(provider, oauthRef);
}
- private readOAuthRef(provider: string): OAuthRef {
- const oauth = this.providerService.get(provider)?.oauth;
- if (oauth === undefined) {
- throw new KimiError(
- ErrorCodes.AUTH_LOGIN_REQUIRED,
- `Provider "${provider}" is not configured for OAuth.`,
- );
+ async refreshOAuthProviderModels(): Promise {
+ const changed: RefreshOAuthProviderModelsResponse['changed'] = [];
+ const unchanged: string[] = [];
+ const failed: RefreshOAuthProviderModelsResponse['failed'] = [];
+
+ await this.config.reload();
+ const current = this.readUserConfigShape();
+ const provider = current.providers[KIMI_CODE_PROVIDER_NAME];
+ if (!isKimiOAuthProvider(provider)) {
+ return { changed, unchanged, failed };
}
- return oauth;
+
+ try {
+ const auth = resolveKimiCodeRuntimeAuth({
+ configuredBaseUrl: provider.baseUrl,
+ configuredOAuthRef: provider.oauth,
+ });
+ const tokenProvider = this.resolveTokenProvider(KIMI_CODE_PROVIDER_NAME, auth.oauthRef);
+ if (tokenProvider === undefined) {
+ throw new Error('OAuth token provider is not configured.');
+ }
+ const token = await tokenProvider.getAccessToken();
+ const models = await fetchManagedKimiCodeModels({
+ accessToken: token,
+ baseUrl: auth.baseUrl,
+ });
+ if (models.length === 0) {
+ return { changed, unchanged, failed };
+ }
+
+ const next = structuredClone(current);
+ applyManagedKimiCodeConfig(next, {
+ models,
+ baseUrl: auth.baseUrl,
+ oauthKey: auth.oauthRef.key,
+ oauthHost: auth.oauthRef.oauthHost,
+ preserveDefaultModel: true,
+ });
+ const refreshedAliasKeys = providerRefreshAliasKeys(
+ current,
+ next,
+ KIMI_CODE_PROVIDER_NAME,
+ `${KIMI_CODE_PLATFORM_ID}/`,
+ );
+ restoreProviderAliases(
+ next,
+ preserveUserProviderAliases(current, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys),
+ );
+ restoreDefaultSelection(next, current.defaultModel, current.defaultThinking);
+ clampDanglingDefault(next);
+
+ if (providerModelsEqual(current, next, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys)) {
+ unchanged.push(KIMI_CODE_PROVIDER_NAME);
+ } else {
+ const { added, removed } = computeChanges(
+ collectModelIdsForAliases(current, refreshedAliasKeys),
+ collectModelIdsForAliases(next, refreshedAliasKeys),
+ );
+ await this.config.replace(PROVIDERS_SECTION, next.providers);
+ await this.config.replace(MODELS_SECTION, next.models ?? {});
+ await this.config.set(DEFAULT_MODEL_SECTION, next.defaultModel);
+ await this.config.set(DEFAULT_THINKING_SECTION, next.defaultThinking);
+ changed.push({
+ provider_id: KIMI_CODE_PROVIDER_NAME,
+ provider_name: 'Kimi Code',
+ added,
+ removed,
+ });
+ }
+ } catch (err) {
+ failed.push({
+ provider: KIMI_CODE_PROVIDER_NAME,
+ reason: err instanceof Error ? err.message : String(err),
+ });
+ }
+
+ return { changed, unchanged, failed };
+ }
+
+ private readUserConfigShape(): ManagedKimiConfigShape {
+ const providers =
+ this.config.inspect>(PROVIDERS_SECTION).userValue ?? {};
+ const models = this.config.inspect>(MODELS_SECTION).userValue ?? {};
+ const defaultModel = this.config.inspect(DEFAULT_MODEL_SECTION).userValue;
+ const defaultThinking = this.config.inspect(DEFAULT_THINKING_SECTION).userValue;
+ return {
+ providers: { ...providers } as ManagedKimiConfigShape['providers'],
+ models: { ...models } as ManagedKimiConfigShape['models'],
+ defaultModel,
+ defaultThinking,
+ };
+ }
+
+ private resolveOAuthRef(provider: string): OAuthRef | undefined {
+ const config = this.providerService.get(provider);
+ if (config?.oauth !== undefined) return config.oauth;
+ if (provider !== KIMI_CODE_PROVIDER_NAME) return undefined;
+ return resolveKimiCodeOAuthRef({ baseUrl: config?.baseUrl });
}
private readOAuthRefOptional(provider: string): OAuthRef | undefined {
@@ -188,7 +331,7 @@ export class OAuthService extends Disposable implements IOAuthService {
private handleSuccess(state: FlowState): void {
if (state.status !== 'pending') return;
this.setTerminal(state, 'authenticated');
- void this.provisionProvider(state.provider, this.readOAuthRefOptional(state.provider));
+ void this.provisionProvider(state.provider, state.oauthRef);
}
private async provisionProvider(provider: string, oauthRef: OAuthRef | undefined): Promise {
@@ -258,14 +401,27 @@ export class AuthSummaryService implements IAuthSummaryService {
constructor(
@IProviderService private readonly providerService: IProviderService,
@IOAuthService private readonly oauth: IOAuthService,
+ @ILogService private readonly log: ILogService,
) {}
async summarize(): Promise {
const providers = this.providerService.list();
+ const oauthProviders = Object.entries(providers).filter(
+ ([, config]) => config.oauth !== undefined,
+ );
+ this.log.info('auth summarize: enter', {
+ total: Object.keys(providers).length,
+ oauthProviders: oauthProviders.map(([name]) => name),
+ });
const statuses: AuthStatus[] = [];
- for (const [name, providerConfig] of Object.entries(providers)) {
- if (providerConfig.oauth !== undefined) {
+ for (const [name] of oauthProviders) {
+ try {
statuses.push(await this.oauth.status(name));
+ } catch (error) {
+ this.log.warn('auth summarize: status threw', {
+ provider: name,
+ error: error instanceof Error ? error.message : String(error),
+ });
}
}
return statuses;
@@ -290,5 +446,176 @@ function classifyFailure(err: unknown): OAuthFlowStatus {
return 'denied';
}
+/** Structural view of a managed-config model alias (the fields the refresh reads/writes). */
+interface ManagedModel {
+ readonly provider: string;
+ readonly model: string;
+ readonly maxContextSize: number;
+ readonly capabilities?: readonly string[];
+ readonly displayName?: string;
+}
+
+function isKimiOAuthProvider(
+ provider: ProviderConfig | Record | undefined,
+): provider is ProviderConfig & { oauth: OAuthRef } {
+ return (
+ provider !== undefined &&
+ (provider as ProviderConfig).type === 'kimi' &&
+ (provider as ProviderConfig).oauth !== undefined
+ );
+}
+
+function collectModelIdsForAliases(
+ config: ManagedKimiConfigShape,
+ aliasKeys: ReadonlySet,
+): Set {
+ const ids = new Set();
+ for (const aliasKey of aliasKeys) {
+ const alias = managedModel(config, aliasKey);
+ if (alias !== undefined && alias.model.length > 0) ids.add(alias.model);
+ }
+ return ids;
+}
+
+function providerAliasKeys(config: ManagedKimiConfigShape, providerId: string): Set {
+ const keys = new Set();
+ for (const [alias, model] of Object.entries(config.models ?? {})) {
+ if ((model as ManagedModel).provider === providerId) keys.add(alias);
+ }
+ return keys;
+}
+
+function generatedProviderAliasKeys(
+ config: ManagedKimiConfigShape,
+ providerId: string,
+ aliasPrefix: string,
+): Set {
+ const keys = new Set();
+ for (const [alias, model] of Object.entries(config.models ?? {})) {
+ if ((model as ManagedModel).provider === providerId && alias.startsWith(aliasPrefix)) {
+ keys.add(alias);
+ }
+ }
+ return keys;
+}
+
+function computeChanges(
+ oldIds: Set,
+ newIds: Set,
+): { added: number; removed: number } {
+ let added = 0;
+ for (const id of newIds) {
+ if (!oldIds.has(id)) added++;
+ }
+ let removed = 0;
+ for (const id of oldIds) {
+ if (!newIds.has(id)) removed++;
+ }
+ return { added, removed };
+}
+
+function providerModelsEqual(
+ config: ManagedKimiConfigShape,
+ nextConfig: ManagedKimiConfigShape,
+ providerId: string,
+ aliasKeys: ReadonlySet,
+): boolean {
+ return (
+ providerModelSnapshot(config, providerId, aliasKeys) ===
+ providerModelSnapshot(nextConfig, providerId, aliasKeys)
+ );
+}
+
+function providerModelSnapshot(
+ config: ManagedKimiConfigShape,
+ providerId: string,
+ aliasKeys: ReadonlySet,
+): string {
+ const snapshots: Array<{ alias: string; model: ManagedModel }> = [];
+ for (const alias of aliasKeys) {
+ const model = managedModel(config, alias);
+ if (model === undefined || model.provider !== providerId) continue;
+ snapshots.push({
+ alias,
+ model: {
+ ...model,
+ capabilities:
+ model.capabilities === undefined ? undefined : [...model.capabilities].sort(),
+ },
+ });
+ }
+ snapshots.sort((a, b) => a.alias.localeCompare(b.alias));
+ return JSON.stringify(snapshots);
+}
+
+function providerRefreshAliasKeys(
+ config: ManagedKimiConfigShape,
+ nextConfig: ManagedKimiConfigShape,
+ providerId: string,
+ aliasPrefix: string,
+): Set {
+ const keys = generatedProviderAliasKeys(config, providerId, aliasPrefix);
+ for (const key of providerAliasKeys(nextConfig, providerId)) keys.add(key);
+ return keys;
+}
+
+function preserveUserProviderAliases(
+ config: ManagedKimiConfigShape,
+ providerId: string,
+ refreshedAliasKeys: ReadonlySet,
+): Record {
+ const preserved: Record = {};
+ for (const [alias, model] of Object.entries(config.models ?? {})) {
+ const entry = model as ManagedModel;
+ if (entry.provider !== providerId || refreshedAliasKeys.has(alias)) continue;
+ preserved[alias] = structuredClone(entry);
+ }
+ return preserved;
+}
+
+function restoreProviderAliases(
+ config: ManagedKimiConfigShape,
+ aliases: Record,
+): void {
+ if (Object.keys(aliases).length === 0) return;
+ config.models = {
+ ...config.models,
+ ...aliases,
+ } as ManagedKimiConfigShape['models'];
+}
+
+function restoreDefaultSelection(
+ config: ManagedKimiConfigShape,
+ defaultModel: string | undefined,
+ defaultThinking: boolean | undefined,
+): void {
+ if (defaultModel === undefined || config.models?.[defaultModel] === undefined) return;
+ config.defaultModel = defaultModel;
+ const capabilities = managedModel(config, defaultModel)?.capabilities ?? [];
+ config.defaultThinking = capabilities.includes('always_thinking') ? true : defaultThinking;
+}
+
+function clampDanglingDefault(config: ManagedKimiConfigShape): void {
+ if (config.defaultModel !== undefined && config.models?.[config.defaultModel] === undefined) {
+ config.defaultModel = undefined;
+ config.defaultThinking = undefined;
+ }
+}
+
+function managedModel(
+ config: ManagedKimiConfigShape,
+ alias: string,
+): ManagedModel | undefined {
+ return config.models?.[alias] as ManagedModel | undefined;
+}
+
+class OAuthToolkitService extends KimiOAuthToolkit implements IOAuthToolkit {
+ declare readonly _serviceBrand: undefined;
+ constructor(@IBootstrapService bootstrap: IBootstrapService) {
+ super({ homeDir: bootstrap.homeDir });
+ }
+}
+
registerScopedService(LifecycleScope.Core, IOAuthService, OAuthService, InstantiationType.Delayed, 'auth');
+registerScopedService(LifecycleScope.Core, IOAuthToolkit, OAuthToolkitService, InstantiationType.Delayed, 'auth');
registerScopedService(LifecycleScope.Core, IAuthSummaryService, AuthSummaryService, InstantiationType.Delayed, 'auth');
diff --git a/packages/agent-core-v2/src/modelCatalog/modelCatalog.ts b/packages/agent-core-v2/src/modelCatalog/modelCatalog.ts
index d4b7990dd..39ef9dca1 100644
--- a/packages/agent-core-v2/src/modelCatalog/modelCatalog.ts
+++ b/packages/agent-core-v2/src/modelCatalog/modelCatalog.ts
@@ -1,20 +1,19 @@
/**
* `modelCatalog` domain (L3) — read-only catalog over configured providers and
- * model aliases, plus the global default-model selection and the Kimi Code
- * OAuth model refresh.
+ * model aliases, plus the global default-model selection.
*
* Projects the `provider` / `model` configuration registries into the
* protocol `ProviderCatalogItem` / `ModelCatalogItem` wire shapes that the
* edge (`server-v2` `/api/v1` routes) serves. Core-scoped — provider and
* model configuration is global and shared across sessions. This domain is a
* thin facade over `provider`, `model`, `config`, and `auth`; it owns no
- * persistence of its own.
+ * persistence of its own. The OAuth-provider model refresh lives in
+ * The OAuth-provider model refresh lives in `auth` (`IOAuthService`), not here.
*/
import type {
ModelCatalogItem,
ProviderCatalogItem,
- RefreshOAuthProviderModelsResponse,
SetDefaultModelResponse,
} from '@moonshot-ai/protocol';
@@ -30,7 +29,6 @@ export interface IModelCatalogService {
listProviders(): Promise;
getProvider(providerId: string): Promise;
setDefaultModel(modelId: string): Promise;
- refreshOAuthProviderModels(): Promise;
}
export const IModelCatalogService: ServiceIdentifier =
diff --git a/packages/agent-core-v2/src/modelCatalog/modelCatalogService.ts b/packages/agent-core-v2/src/modelCatalog/modelCatalogService.ts
index 61c8b5c99..2375d5510 100644
--- a/packages/agent-core-v2/src/modelCatalog/modelCatalogService.ts
+++ b/packages/agent-core-v2/src/modelCatalog/modelCatalogService.ts
@@ -2,23 +2,14 @@
* `modelCatalog` domain (L3) — `IModelCatalogService` implementation.
*
* Projects the `provider` / `model` registries into protocol catalog items,
- * resolves credential state through `config` and `auth`, persists the global
- * default-model selection through `config`, and drives the Kimi Code OAuth
- * model refresh against the user-layer config sections. Bound at Core scope.
+ * resolves credential state through `config` and `auth`, and persists the
+ * global default-model selection through `config`. Bound at Core scope. The
+ * The managed OAuth-provider refresh lives in `auth` (`IOAuthService`), not here.
*/
-import {
- KIMI_CODE_PLATFORM_ID,
- KIMI_CODE_PROVIDER_NAME,
- applyManagedKimiCodeConfig,
- fetchManagedKimiCodeModels,
- resolveKimiCodeRuntimeAuth,
- type ManagedKimiConfigShape,
-} from '@moonshot-ai/kimi-code-oauth';
import type {
ModelCatalogItem,
ProviderCatalogItem,
- RefreshOAuthProviderModelsResponse,
SetDefaultModelResponse,
} from '@moonshot-ai/protocol';
@@ -28,7 +19,7 @@ import { IOAuthService } from '#/auth/auth';
import { IConfigService } from '#/config/config';
import { ErrorCodes, KimiError } from '#/errors';
import { IModelService, type ModelAlias } from '#/model/model';
-import { IProviderService, type OAuthRef, type ProviderConfig } from '#/provider/provider';
+import { IProviderService, type ProviderConfig } from '#/provider/provider';
import {
type ProviderCredentialState,
@@ -39,18 +30,6 @@ import {
} from './modelCatalog';
const DEFAULT_MODEL_SECTION = 'defaultModel';
-const DEFAULT_THINKING_SECTION = 'defaultThinking';
-const MODELS_SECTION = 'models';
-const PROVIDERS_SECTION = 'providers';
-
-/** Structural view of a managed-config model alias (the fields the refresh reads/writes). */
-interface ManagedModel {
- readonly provider: string;
- readonly model: string;
- readonly maxContextSize: number;
- readonly capabilities?: readonly string[];
- readonly displayName?: string;
-}
export class ModelCatalogService implements IModelCatalogService {
declare readonly _serviceBrand: undefined;
@@ -101,85 +80,6 @@ export class ModelCatalogService implements IModelCatalogService {
};
}
- async refreshOAuthProviderModels(): Promise {
- const changed: RefreshOAuthProviderModelsResponse['changed'] = [];
- const unchanged: string[] = [];
- const failed: RefreshOAuthProviderModelsResponse['failed'] = [];
-
- await this.config.reload();
- const current = this.readUserConfigShape();
- const provider = current.providers[KIMI_CODE_PROVIDER_NAME];
- if (!isKimiOAuthProvider(provider)) {
- return { changed, unchanged, failed };
- }
-
- try {
- const auth = resolveKimiCodeRuntimeAuth({
- configuredBaseUrl: provider.baseUrl,
- configuredOAuthRef: provider.oauth,
- });
- const tokenProvider = this.oauth.resolveTokenProvider(KIMI_CODE_PROVIDER_NAME, auth.oauthRef);
- if (tokenProvider === undefined) {
- throw new Error('OAuth token provider is not configured.');
- }
- const token = await tokenProvider.getAccessToken();
- const models = await fetchManagedKimiCodeModels({
- accessToken: token,
- baseUrl: auth.baseUrl,
- });
- if (models.length === 0) {
- return { changed, unchanged, failed };
- }
-
- const next = structuredClone(current);
- applyManagedKimiCodeConfig(next, {
- models,
- baseUrl: auth.baseUrl,
- oauthKey: auth.oauthRef.key,
- oauthHost: auth.oauthRef.oauthHost,
- preserveDefaultModel: true,
- });
- const refreshedAliasKeys = providerRefreshAliasKeys(
- current,
- next,
- KIMI_CODE_PROVIDER_NAME,
- `${KIMI_CODE_PLATFORM_ID}/`,
- );
- restoreProviderAliases(
- next,
- preserveUserProviderAliases(current, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys),
- );
- restoreDefaultSelection(next, current.defaultModel, current.defaultThinking);
- clampDanglingDefault(next);
-
- if (providerModelsEqual(current, next, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys)) {
- unchanged.push(KIMI_CODE_PROVIDER_NAME);
- } else {
- const { added, removed } = computeChanges(
- collectModelIdsForAliases(current, refreshedAliasKeys),
- collectModelIdsForAliases(next, refreshedAliasKeys),
- );
- await this.config.replace(PROVIDERS_SECTION, next.providers);
- await this.config.replace(MODELS_SECTION, next.models ?? {});
- await this.config.set(DEFAULT_MODEL_SECTION, next.defaultModel);
- await this.config.set(DEFAULT_THINKING_SECTION, next.defaultThinking);
- changed.push({
- provider_id: KIMI_CODE_PROVIDER_NAME,
- provider_name: 'Kimi Code',
- added,
- removed,
- });
- }
- } catch (err) {
- failed.push({
- provider: KIMI_CODE_PROVIDER_NAME,
- reason: err instanceof Error ? err.message : String(err),
- });
- }
-
- return { changed, unchanged, failed };
- }
-
private async toCatalogProvider(
providerId: string,
provider: ProviderConfig,
@@ -209,32 +109,6 @@ export class ModelCatalogService implements IModelCatalogService {
return false;
}
}
-
- /** Assemble a v1-style flat config shape from the user-layer config sections. */
- private readUserConfigShape(): ManagedKimiConfigShape {
- const providers =
- this.config.inspect>(PROVIDERS_SECTION).userValue ?? {};
- const models =
- this.config.inspect>(MODELS_SECTION).userValue ?? {};
- const defaultModel = this.config.inspect(DEFAULT_MODEL_SECTION).userValue;
- const defaultThinking = this.config.inspect(DEFAULT_THINKING_SECTION).userValue;
- return {
- providers: { ...providers } as ManagedKimiConfigShape['providers'],
- models: { ...models } as ManagedKimiConfigShape['models'],
- defaultModel,
- defaultThinking,
- };
- }
-}
-
-function isKimiOAuthProvider(
- provider: ProviderConfig | Record | undefined,
-): provider is ProviderConfig & { oauth: OAuthRef } {
- return (
- provider !== undefined &&
- (provider as ProviderConfig).type === 'kimi' &&
- (provider as ProviderConfig).oauth !== undefined
- );
}
function hasConfiguredApiKey(provider: ProviderConfig): boolean {
@@ -258,154 +132,10 @@ function hasConfiguredApiKey(provider: ProviderConfig): boolean {
return false;
}
-function collectModelIdsForAliases(
- config: ManagedKimiConfigShape,
- aliasKeys: ReadonlySet,
-): Set {
- const ids = new Set();
- for (const aliasKey of aliasKeys) {
- const alias = managedModel(config, aliasKey);
- if (alias !== undefined && alias.model.length > 0) ids.add(alias.model);
- }
- return ids;
-}
-
-function providerAliasKeys(config: ManagedKimiConfigShape, providerId: string): Set {
- const keys = new Set();
- for (const [alias, model] of Object.entries(config.models ?? {})) {
- if ((model as ManagedModel).provider === providerId) keys.add(alias);
- }
- return keys;
-}
-
-function generatedProviderAliasKeys(
- config: ManagedKimiConfigShape,
- providerId: string,
- aliasPrefix: string,
-): Set {
- const keys = new Set();
- for (const [alias, model] of Object.entries(config.models ?? {})) {
- if ((model as ManagedModel).provider === providerId && alias.startsWith(aliasPrefix)) {
- keys.add(alias);
- }
- }
- return keys;
-}
-
-function computeChanges(
- oldIds: Set,
- newIds: Set,
-): { added: number; removed: number } {
- let added = 0;
- for (const id of newIds) {
- if (!oldIds.has(id)) added++;
- }
- let removed = 0;
- for (const id of oldIds) {
- if (!newIds.has(id)) removed++;
- }
- return { added, removed };
-}
-
-function providerModelsEqual(
- config: ManagedKimiConfigShape,
- nextConfig: ManagedKimiConfigShape,
- providerId: string,
- aliasKeys: ReadonlySet,
-): boolean {
- return (
- providerModelSnapshot(config, providerId, aliasKeys) ===
- providerModelSnapshot(nextConfig, providerId, aliasKeys)
- );
-}
-
-function providerModelSnapshot(
- config: ManagedKimiConfigShape,
- providerId: string,
- aliasKeys: ReadonlySet,
-): string {
- const snapshots: Array<{ alias: string; model: ManagedModel }> = [];
- for (const alias of aliasKeys) {
- const model = managedModel(config, alias);
- if (model === undefined || model.provider !== providerId) continue;
- snapshots.push({
- alias,
- model: {
- ...model,
- capabilities:
- model.capabilities === undefined ? undefined : [...model.capabilities].sort(),
- },
- });
- }
- snapshots.sort((a, b) => a.alias.localeCompare(b.alias));
- return JSON.stringify(snapshots);
-}
-
-function providerRefreshAliasKeys(
- config: ManagedKimiConfigShape,
- nextConfig: ManagedKimiConfigShape,
- providerId: string,
- aliasPrefix: string,
-): Set {
- const keys = generatedProviderAliasKeys(config, providerId, aliasPrefix);
- for (const key of providerAliasKeys(nextConfig, providerId)) keys.add(key);
- return keys;
-}
-
-function preserveUserProviderAliases(
- config: ManagedKimiConfigShape,
- providerId: string,
- refreshedAliasKeys: ReadonlySet,
-): Record {
- const preserved: Record = {};
- for (const [alias, model] of Object.entries(config.models ?? {})) {
- const entry = model as ManagedModel;
- if (entry.provider !== providerId || refreshedAliasKeys.has(alias)) continue;
- preserved[alias] = structuredClone(entry);
- }
- return preserved;
-}
-
-function restoreProviderAliases(
- config: ManagedKimiConfigShape,
- aliases: Record,
-): void {
- if (Object.keys(aliases).length === 0) return;
- config.models = {
- ...config.models,
- ...aliases,
- } as ManagedKimiConfigShape['models'];
-}
-
-function restoreDefaultSelection(
- config: ManagedKimiConfigShape,
- defaultModel: string | undefined,
- defaultThinking: boolean | undefined,
-): void {
- if (defaultModel === undefined || config.models?.[defaultModel] === undefined) return;
- config.defaultModel = defaultModel;
- const capabilities = managedModel(config, defaultModel)?.capabilities ?? [];
- config.defaultThinking = capabilities.includes('always_thinking') ? true : defaultThinking;
-}
-
-function clampDanglingDefault(config: ManagedKimiConfigShape): void {
- if (config.defaultModel !== undefined && config.models?.[config.defaultModel] === undefined) {
- config.defaultModel = undefined;
- config.defaultThinking = undefined;
- }
-}
-
-function managedModel(
- config: ManagedKimiConfigShape,
- alias: string,
-): ManagedModel | undefined {
- return config.models?.[alias] as ManagedModel | undefined;
-}
-
function nonEmpty(value: string | undefined): string | undefined {
if (value === undefined) return undefined;
const trimmed = value.trim();
- return trimmed.length === 0 ? undefined : trimmed;
+ return trimmed.length > 0 ? trimmed : undefined;
}
registerScopedService(
diff --git a/packages/agent-core-v2/test/auth/auth.test.ts b/packages/agent-core-v2/test/auth/auth.test.ts
index 1d62e6035..264e90037 100644
--- a/packages/agent-core-v2/test/auth/auth.test.ts
+++ b/packages/agent-core-v2/test/auth/auth.test.ts
@@ -1,19 +1,20 @@
/**
- * `auth` domain tests — covers the `OAuthService` device-code orchestration
- * and its dependency on the `provider` domain, using a fake
- * `KimiOAuthToolkit` so no real network or token storage is exercised.
+ * `auth` domain tests — covers the `OAuthService` device-code orchestration,
+ * its dependency on the `provider` domain, and the managed OAuth provider
+ * model refresh, using a fake `IOAuthToolkit` so no real network or token
+ * storage is exercised.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import type { KimiOAuthToolkit } from '@moonshot-ai/kimi-code-oauth';
-
import { DisposableStore } from '#/_base/di/lifecycle';
import { createServices, type TestInstantiationService } from '#/_base/di/test';
import { ErrorCodes, KimiError } from '#/errors';
-import { IAuthSummaryService, IOAuthService } from '#/auth/auth';
+import { IAuthSummaryService, IOAuthService, IOAuthToolkit } from '#/auth/auth';
import { AuthSummaryService, OAuthService } from '#/auth/authService';
+import { IConfigService } from '#/config/config';
import { ILogService } from '#/log/log';
+import { type ModelAlias } from '#/model/model';
import { IProviderService, type ProviderConfig } from '#/provider/provider';
import { registerBootstrapServices } from '../bootstrap/stubs';
@@ -44,8 +45,13 @@ describe('OAuthService', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
let providers: Record;
+ let models: Record;
+ let defaultModel: string | undefined;
+ let defaultThinking: boolean | undefined;
let toolkit: FakeToolkit;
let providerSet: ReturnType;
+ let configSet: ReturnType;
+ let configReplace: ReturnType;
beforeEach(() => {
disposables = new DisposableStore();
@@ -58,6 +64,17 @@ describe('OAuthService', () => {
[NON_OAUTH_PROVIDER]: { type: 'openai', apiKey: 'sk-test' },
};
providerSet = vi.fn().mockResolvedValue(undefined);
+ models = {};
+ defaultModel = undefined;
+ defaultThinking = undefined;
+ configSet = vi.fn().mockResolvedValue(undefined);
+ configReplace = vi.fn().mockResolvedValue(undefined);
+ toolkit = {
+ login: vi.fn(),
+ logout: vi.fn().mockResolvedValue({ providerName: OAUTH_PROVIDER, ok: true }),
+ getCachedAccessToken: vi.fn().mockResolvedValue(undefined),
+ tokenProvider: vi.fn().mockReturnValue({ getAccessToken: async () => 'access-token' }),
+ };
ix = createServices(disposables, {
base: [registerBootstrapServices, registerTelemetryServices],
additionalServices: (reg) => {
@@ -67,20 +84,41 @@ describe('OAuthService', () => {
set: providerSet as unknown as IProviderService['set'],
onDidChange: (() => ({ dispose: () => {} })) as IProviderService['onDidChange'],
});
- reg.definePartialInstance(ILogService, { warn: vi.fn() });
+ reg.definePartialInstance(IConfigService, {
+ get: ((domain: string) => configBacking()[domain]) as IConfigService['get'],
+ inspect: ((domain: string) => ({
+ value: configBacking()[domain],
+ defaultValue: undefined,
+ userValue: configBacking()[domain],
+ memoryValue: undefined,
+ })) as IConfigService['inspect'],
+ set: configSet as unknown as IConfigService['set'],
+ replace: configReplace as unknown as IConfigService['replace'],
+ reload: vi.fn().mockResolvedValue(undefined) as unknown as IConfigService['reload'],
+ onDidChange: (() => ({ dispose: () => {} })) as IConfigService['onDidChange'],
+ onDidSectionChange: (() => ({ dispose: () => {} })) as IConfigService['onDidSectionChange'],
+ });
+ reg.definePartialInstance(ILogService, {
+ info: vi.fn(),
+ warn: vi.fn(),
+ debug: vi.fn(),
+ error: vi.fn(),
+ });
+ reg.defineInstance(IOAuthToolkit, toolkit as unknown as IOAuthToolkit);
},
});
- toolkit = {
- login: vi.fn(),
- logout: vi.fn().mockResolvedValue({ providerName: OAUTH_PROVIDER, ok: true }),
- getCachedAccessToken: vi.fn().mockResolvedValue(undefined),
- tokenProvider: vi.fn().mockReturnValue({ getAccessToken: async () => 'access-token' }),
- };
});
- afterEach(() => disposables.dispose());
+ afterEach(() => {
+ disposables.dispose();
+ vi.unstubAllGlobals();
+ });
function createService(): IOAuthService {
- return ix.createInstance(OAuthService, toolkit as unknown as KimiOAuthToolkit);
+ return ix.createInstance(OAuthService);
+ }
+
+ function configBacking(): Record {
+ return { providers, models, defaultModel, defaultThinking };
}
it('startLogin resolves a device-code flow and flips to authenticated on success', async () => {
@@ -128,13 +166,44 @@ describe('OAuthService', () => {
);
});
- it('startLogin rejects with AUTH_LOGIN_REQUIRED when provider has no oauth config', async () => {
- const svc = createService();
- await expect(svc.startLogin(NON_OAUTH_PROVIDER)).rejects.toThrow(KimiError);
- await expect(svc.startLogin(NON_OAUTH_PROVIDER)).rejects.toMatchObject({
- code: ErrorCodes.AUTH_LOGIN_REQUIRED,
+ it('startLogin resolves a default oauth ref for the managed provider without oauth config', async () => {
+ providers[OAUTH_PROVIDER] = { type: 'kimi', baseUrl: 'https://api.example.com' };
+ toolkit.login.mockImplementation(async (_provider, options) => {
+ options.onDeviceCode(deviceAuth);
+ return { providerName: OAUTH_PROVIDER, ok: true };
});
- expect(toolkit.login).not.toHaveBeenCalled();
+ const svc = createService();
+ await svc.startLogin(OAUTH_PROVIDER);
+
+ expect(toolkit.login).toHaveBeenCalledWith(
+ OAUTH_PROVIDER,
+ expect.objectContaining({
+ oauthRef: expect.objectContaining({ storage: 'file', key: expect.any(String) }),
+ }),
+ );
+ await flush();
+ expect(providerSet).toHaveBeenCalledWith(
+ OAUTH_PROVIDER,
+ expect.objectContaining({
+ type: 'kimi',
+ oauth: expect.objectContaining({ storage: 'file', key: expect.any(String) }),
+ }),
+ );
+ });
+
+ it('startLogin rejects when the device authorization fails before onDeviceCode', async () => {
+ toolkit.login.mockRejectedValue(new Error('device authorization request failed'));
+ const svc = createService();
+ await expect(svc.startLogin(OAUTH_PROVIDER)).rejects.toThrow(
+ 'device authorization request failed',
+ );
+ });
+
+ it('startLogin rejects when login completes without issuing a device code', async () => {
+ toolkit.login.mockResolvedValue({ providerName: OAUTH_PROVIDER, ok: true });
+ const svc = createService();
+ await expect(svc.startLogin(OAUTH_PROVIDER)).rejects.toThrow('already authenticated');
+ expect(svc.getFlow(OAUTH_PROVIDER)).toBeUndefined();
});
it('cancelLogin aborts a pending flow and marks it cancelled', async () => {
@@ -189,6 +258,59 @@ describe('OAuthService', () => {
key: 'k',
});
});
+
+ it('refreshOAuthProviderModels returns an empty result when no Kimi Code provider is configured', async () => {
+ providers = { [NON_OAUTH_PROVIDER]: { type: 'openai', apiKey: 'sk-test' } };
+ const svc = createService();
+
+ await expect(svc.refreshOAuthProviderModels()).resolves.toEqual({
+ changed: [],
+ unchanged: [],
+ failed: [],
+ });
+ expect(toolkit.tokenProvider).not.toHaveBeenCalled();
+ });
+
+ it('refreshOAuthProviderModels fetches models and writes back the changed sections', async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ data: [
+ {
+ id: 'kimi-k2',
+ context_length: 131072,
+ supports_reasoning: true,
+ display_name: 'Kimi K2',
+ },
+ ],
+ }),
+ });
+ vi.stubGlobal('fetch', fetchMock);
+ const svc = createService();
+
+ const result = await svc.refreshOAuthProviderModels();
+
+ expect(result.failed).toEqual([]);
+ expect(result.changed).toEqual([
+ {
+ provider_id: OAUTH_PROVIDER,
+ provider_name: 'Kimi Code',
+ added: 1,
+ removed: 0,
+ },
+ ]);
+ expect(configReplace).toHaveBeenCalledWith(
+ 'providers',
+ expect.objectContaining({ [OAUTH_PROVIDER]: expect.objectContaining({ type: 'kimi' }) }),
+ );
+ expect(configReplace).toHaveBeenCalledWith(
+ 'models',
+ expect.objectContaining({
+ 'kimi-code/kimi-k2': expect.objectContaining({ model: 'kimi-k2' }),
+ }),
+ );
+ expect(configSet).toHaveBeenCalledWith('defaultModel', 'kimi-code/kimi-k2');
+ });
});
describe('AuthSummaryService', () => {
@@ -215,6 +337,12 @@ describe('AuthSummaryService', () => {
reg.definePartialInstance(IOAuthService, {
status: oauthStatus as unknown as IOAuthService['status'],
});
+ reg.definePartialInstance(ILogService, {
+ info: vi.fn(),
+ warn: vi.fn(),
+ debug: vi.fn(),
+ error: vi.fn(),
+ });
},
});
});
@@ -232,6 +360,22 @@ describe('AuthSummaryService', () => {
expect(oauthStatus).not.toHaveBeenCalledWith(NON_OAUTH_PROVIDER);
});
+ it('summarize skips providers whose status throws', async () => {
+ const OTHER_OAUTH = 'kimi-code-anthropic';
+ providers[OTHER_OAUTH] = {
+ type: 'kimi',
+ oauth: { storage: 'file', key: 'oauth/kimi-code' },
+ };
+ oauthStatus.mockImplementation(async (name: string) => {
+ if (name === OTHER_OAUTH) throw new Error('No OAuth manager configured');
+ return { loggedIn: true, provider: name };
+ });
+ const result = await createSummary().summarize();
+ expect(result).toEqual([{ loggedIn: true, provider: OAUTH_PROVIDER }]);
+ expect(oauthStatus).toHaveBeenCalledWith(OAUTH_PROVIDER);
+ expect(oauthStatus).toHaveBeenCalledWith(OTHER_OAUTH);
+ });
+
it('ensureReady rejects with AUTH_LOGIN_REQUIRED when the provider is logged out', async () => {
oauthStatus.mockResolvedValue({ loggedIn: false });
await expect(createSummary().ensureReady(OAUTH_PROVIDER)).rejects.toMatchObject({
diff --git a/packages/agent-core-v2/test/modelCatalog/modelCatalog.test.ts b/packages/agent-core-v2/test/modelCatalog/modelCatalog.test.ts
index 7c556d041..ceca3219a 100644
--- a/packages/agent-core-v2/test/modelCatalog/modelCatalog.test.ts
+++ b/packages/agent-core-v2/test/modelCatalog/modelCatalog.test.ts
@@ -1,10 +1,11 @@
/**
* `modelCatalog` domain tests — covers the catalog projection, default-model
- * selection, coded not-found errors, and the Kimi Code OAuth model refresh.
+ * selection, and coded not-found errors.
*
* Uses the flat `TestInstantiationService` harness with real `ModelService` /
* `ProviderService` collaborators over an in-memory config stub, a stubbed
- * `IOAuthService`, and the SUT registered by interface.
+ * `IOAuthService`, and the SUT registered by interface. The managed-provider
+ * refresh is covered in `auth/auth.test.ts`.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -64,46 +65,38 @@ describe('ModelCatalogService', () => {
let ix: TestInstantiationService;
let backing: Backing;
let configSet: ReturnType;
- let configReplace: ReturnType;
let getCachedAccessToken: ReturnType;
- let resolveTokenProvider: ReturnType;
beforeEach(() => {
disposables = new DisposableStore();
backing = seedBacking();
configSet = vi.fn().mockImplementation(async (domain: string, patch: unknown) => {
- const current = (backing as Record)[domain];
- (backing as Record)[domain] =
+ const current = (backing as unknown as Record)[domain];
+ (backing as unknown as Record)[domain] =
current !== null && typeof current === 'object' && typeof patch === 'object' && patch !== null
? { ...(current as object), ...(patch as object) }
: patch;
});
- configReplace = vi.fn().mockImplementation(async (domain: string, value: unknown) => {
- (backing as Record)[domain] = value;
- });
- getCachedAccessToken = vi.fn().mockResolvedValue(undefined);
- resolveTokenProvider = vi.fn();
+ getCachedAccessToken = vi.fn().mockResolvedValue(undefined);
ix = createServices(disposables, {
additionalServices: (reg) => {
reg.defineInstance(IConfigRegistry, new ConfigRegistry());
reg.definePartialInstance(IConfigService, {
- get: ((domain: string) => (backing as Record)[domain]) as IConfigService['get'],
+ get: ((domain: string) => (backing as unknown as Record)[domain]) as IConfigService['get'],
inspect: ((domain: string) => ({
- value: (backing as Record)[domain],
+ value: (backing as unknown as Record)[domain],
defaultValue: undefined,
- userValue: (backing as Record)[domain],
+ userValue: (backing as unknown as Record)[domain],
memoryValue: undefined,
})) as IConfigService['inspect'],
set: configSet as unknown as IConfigService['set'],
- replace: configReplace as unknown as IConfigService['replace'],
reload: vi.fn().mockResolvedValue(undefined) as unknown as IConfigService['reload'],
onDidChange: (() => ({ dispose: () => {} })) as IConfigService['onDidChange'],
onDidSectionChange: (() => ({ dispose: () => {} })) as IConfigService['onDidSectionChange'],
});
reg.definePartialInstance(IOAuthService, {
- getCachedAccessToken,
- resolveTokenProvider,
+ getCachedAccessToken: getCachedAccessToken as unknown as IOAuthService['getCachedAccessToken'],
});
reg.define(IModelService, ModelService);
reg.define(IProviderService, ProviderService);
@@ -221,65 +214,4 @@ describe('ModelCatalogService', () => {
const [provider] = await catalog().listProviders();
expect(provider).toMatchObject({ id: 'acme', has_api_key: false, status: 'connected' });
});
-
- it('returns an empty refresh result when no Kimi Code provider is configured', async () => {
- await expect(catalog().refreshOAuthProviderModels()).resolves.toEqual({
- changed: [],
- unchanged: [],
- failed: [],
- });
- expect(resolveTokenProvider).not.toHaveBeenCalled();
- });
-
- it('refreshes Kimi Code models and writes back the changed sections', async () => {
- backing.providers = {
- 'managed:kimi-code': {
- type: 'kimi',
- baseUrl: 'https://api.example.test/v1',
- apiKey: '',
- oauth: { storage: 'file', key: 'oauth/kimi-code' },
- },
- };
- backing.models = {};
- resolveTokenProvider.mockReturnValue({
- getAccessToken: vi.fn().mockResolvedValue('access-token'),
- });
- const fetchMock = vi.fn().mockResolvedValue({
- ok: true,
- json: async () => ({
- data: [
- {
- id: 'kimi-k2',
- context_length: 131072,
- supports_reasoning: true,
- display_name: 'Kimi K2',
- },
- ],
- }),
- });
- vi.stubGlobal('fetch', fetchMock);
-
- const result = await catalog().refreshOAuthProviderModels();
-
- expect(result.failed).toEqual([]);
- expect(result.changed).toEqual([
- {
- provider_id: 'managed:kimi-code',
- provider_name: 'Kimi Code',
- added: 1,
- removed: 0,
- },
- ]);
- expect(configReplace).toHaveBeenCalledWith(
- 'providers',
- expect.objectContaining({ 'managed:kimi-code': expect.objectContaining({ type: 'kimi' }) }),
- );
- expect(configReplace).toHaveBeenCalledWith(
- 'models',
- expect.objectContaining({
- 'kimi-code/kimi-k2': expect.objectContaining({ model: 'kimi-k2' }),
- }),
- );
- expect(configSet).toHaveBeenCalledWith('defaultModel', 'kimi-code/kimi-k2');
- });
});
diff --git a/packages/server-v2/src/routes/modelCatalog.ts b/packages/server-v2/src/routes/modelCatalog.ts
index db9d48359..131c2b0c7 100644
--- a/packages/server-v2/src/routes/modelCatalog.ts
+++ b/packages/server-v2/src/routes/modelCatalog.ts
@@ -2,7 +2,8 @@
* `/models` + `/providers` catalog route handlers — server-v2 port.
*
* Implements the v1 model/provider catalog wire contract on top of
- * `agent-core-v2`'s `IModelCatalogService`:
+ * `agent-core-v2`'s `IModelCatalogService` (and the managed-provider refresh
+ * on top of `IOAuthService`):
* GET /models — list configured model aliases
* GET /providers — list configured providers
* GET /providers/{provider_id} — get a configured provider by id
@@ -17,7 +18,13 @@
* edge maps them to the numeric protocol codes by `code` (never `instanceof`).
*/
-import { IConfigService, IModelCatalogService, isKimiError, type Scope } from '@moonshot-ai/agent-core-v2';
+import {
+ IConfigService,
+ IModelCatalogService,
+ IOAuthService,
+ isKimiError,
+ type Scope,
+} from '@moonshot-ai/agent-core-v2';
import {
ErrorCode,
getProviderResponseSchema,
@@ -70,6 +77,11 @@ async function loadCatalog(core: Scope): Promise {
return core.accessor.get(IModelCatalogService);
}
+async function loadOAuth(core: Scope): Promise {
+ await core.accessor.get(IConfigService).ready;
+ return core.accessor.get(IOAuthService);
+}
+
export function registerModelCatalogRoutes(app: ModelCatalogRouteHost, core: Scope): void {
const listModelsRoute = defineRoute(
{
@@ -161,7 +173,7 @@ export function registerModelCatalogRoutes(app: ModelCatalogRouteHost, core: Sco
operationId: 'refreshOAuthProviderModels',
},
async (req, reply) => {
- const result = await (await loadCatalog(core)).refreshOAuthProviderModels();
+ const result = await (await loadOAuth(core)).refreshOAuthProviderModels();
reply.send(okEnvelope(result, req.id));
},
);