From 69084ab683af4ea95d0b5ce291d1e6535bc3e090 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Tue, 30 Jun 2026 13:36:47 +0800 Subject: [PATCH] refactor(agent-core-v2): move managed OAuth model refresh into OAuthService - IOAuthService/OAuthService now owns refreshOAuthProviderModels: the managed OAuth provider's credential provisioning and server-side model refresh live together in the auth domain. - modelCatalog is back to a read-only catalog projection (listModels, listProviders, getProvider, setDefaultModel); the OAuth refresh method and its helpers are removed. - server-v2 /providers:refresh_oauth route and the oauth example resolve IOAuthService for the refresh. - Move the refresh tests into test/auth and add the auth --> config edge to the DI scope diagram. --- .../agent-core-v2/docs/di-scope-domains.puml | 25 +- .../agent-core-v2/examples/oauth.example.ts | 245 +++++++++++ packages/agent-core-v2/src/auth/auth.ts | 32 +- .../agent-core-v2/src/auth/authService.ts | 385 ++++++++++++++++-- .../src/modelCatalog/modelCatalog.ts | 8 +- .../src/modelCatalog/modelCatalogService.ts | 280 +------------ packages/agent-core-v2/test/auth/auth.test.ts | 186 ++++++++- .../test/modelCatalog/modelCatalog.test.ts | 88 +--- packages/server-v2/src/routes/modelCatalog.ts | 18 +- 9 files changed, 848 insertions(+), 419 deletions(-) create mode 100644 packages/agent-core-v2/examples/oauth.example.ts 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)); }, );