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.
This commit is contained in:
haozhe.yang 2026-06-30 13:36:47 +08:00
parent 632e0a7c9f
commit 69084ab683
9 changed files with 848 additions and 419 deletions

View file

@ -31,6 +31,7 @@ package "Core scope (process-wide)" #EAF3FB {
rectangle "<b>hostFs</b>\n<size:9><i>Core</i></size>\n IHostFileSystem" as hostFs #D6EAF8
rectangle "<b>workspaceRegistry</b>\n<size:9><i>Core</i></size>\n IWorkspaceRegistry" as workspaceRegistry #D6EAF8
rectangle "<b>hostFolderBrowser</b>\n<size:9><i>Core</i></size>\n IHostFolderBrowser" as hostFolderBrowser #D6EAF8
rectangle "<b>kaos</b>\n<size:9><i>Core</i></size>\n IKaosFactory" as kaos_core #D6EAF8
rectangle "<b>auth</b>\n<size:9><i>Core</i></size>\n IOAuthService\n IAuthSummaryService" as auth #D6EAF8
rectangle "<b>provider</b>\n<size:9><i>Core</i></size>\n IProviderService" as provider #D6EAF8
rectangle "<b>flag</b>\n<size:9><i>Core</i></size>\n IFlagService\n IFlagRegistry" as flag #D6EAF8
@ -48,11 +49,12 @@ package "Session scope (per session)" #EAFAF1 {
rectangle "<b>agent-lifecycle</b>\n<size:9><i>Session</i></size>\n IAgentLifecycleService" as agent_lifecycle #D5F5E3
rectangle "<b>interaction</b>\n<size:9><i>Session</i></size>\n IInteractionService" as interaction #D5F5E3
rectangle "<b>workspaceContext</b>\n<size:9><i>Session</i></size>\n IWorkspaceContext" as workspaceContext #D5F5E3
rectangle "<b>agentFs</b>\n<size:9><i>Session</i></size>\n IAgentFileSystem\n IFileSystemBackend" as agentFs #D5F5E3
rectangle "<b>kaos</b>\n<size:9><i>Session</i></size>\n IKaos (seed)" as kaos #D5F5E3
rectangle "<b>agentFs</b>\n<size:9><i>Session</i></size>\n IAgentFileSystem\n IFsService" as agentFs #D5F5E3
rectangle "<b>approval</b>\n<size:9><i>Session</i></size>\n IApprovalService" as approval #D5F5E3
rectangle "<b>question</b>\n<size:9><i>Session</i></size>\n IQuestionService" as question #D5F5E3
rectangle "<b>subagentHost</b>\n<size:9><i>Session</i></size>\n ISubagentHost" as subagentHost #D5F5E3
rectangle "<b>process</b>\n<size:9><i>Session</i></size>\n IProcessRunner\n IProcessBackend" as process #D5F5E3
rectangle "<b>process</b>\n<size:9><i>Session</i></size>\n IProcessRunner\n IProcess" as process #D5F5E3
rectangle "<b>terminal</b>\n<size:9><i>Session</i></size>\n ITerminalService\n ITerminalBackend" as terminal #D5F5E3
rectangle "<b>modelProvider</b>\n<size:9><i>Session</i></size>\n IModelProvider (seed)" as modelProvider #D5F5E3
}
@ -95,6 +97,8 @@ package "Agent scope (per agent)" #FDF5E6 {
rectangle "<b>todoList</b>\n<size:9><i>Agent</i></size>\n ITodoListService" as todoList #FDEBD0
rectangle "<b>usage</b>\n<size:9><i>Agent</i></size>\n IUsageService" as usage #FDEBD0
rectangle "<b>rpc</b>\n<size:9><i>Agent</i></size>\n IAgentRPCService" as rpc #FDEBD0
rectangle "<b>fileTools</b>\n<size:9><i>Agent</i></size>\n IFileToolsService" as fileTools #FDEBD0
rectangle "<b>shellTools</b>\n<size:9><i>Agent</i></size>\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

View file

@ -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<Response> => {
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<void> {
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/<model id>`.
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');
});
});

View file

@ -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<OAuthLoginCancelResponse>;
logout(provider?: string): Promise<OAuthLogoutResponse>;
status(provider?: string): Promise<AuthStatus>;
refreshOAuthProviderModels(): Promise<RefreshOAuthProviderModelsResponse>;
resolveTokenProvider(provider: string, oauthRef?: OAuthRef): BearerTokenProvider | undefined;
getCachedAccessToken(provider: string, oauthRef?: OAuthRef): Promise<string | undefined>;
}
@ -39,6 +49,20 @@ export interface IOAuthService {
export const IOAuthService: ServiceIdentifier<IOAuthService> =
createDecorator<IOAuthService>('oauthService');
export interface IOAuthToolkit {
readonly _serviceBrand: undefined;
login(providerName?: string, options?: KimiOAuthLoginOptions): Promise<KimiOAuthLoginResult>;
logout(providerName?: string, oauthRef?: KimiOAuthTokenRef): Promise<KimiOAuthLogoutResult>;
getCachedAccessToken(
providerName?: string,
oauthRef?: KimiOAuthTokenRef,
): Promise<string | undefined>;
tokenProvider(providerName?: string, oauthRef?: KimiOAuthTokenRef): BearerTokenProvider;
}
export const IOAuthToolkit: ServiceIdentifier<IOAuthToolkit> =
createDecorator<IOAuthToolkit>('oauthToolkit');
export interface IAuthSummaryService {
readonly _serviceBrand: undefined;
summarize(): Promise<readonly AuthStatus[]>;

View file

@ -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<string, FlowState>();
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<OAuthFlowStart> {
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<DeviceAuthorization>((resolve) => {
let rejectDevice!: (error: unknown) => void;
const deviceReady = new Promise<DeviceAuthorization>((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<OAuthLogoutResponse> {
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<AuthStatus> {
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<RefreshOAuthProviderModelsResponse> {
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<Record<string, ProviderConfig>>(PROVIDERS_SECTION).userValue ?? {};
const models = this.config.inspect<Record<string, ModelAlias>>(MODELS_SECTION).userValue ?? {};
const defaultModel = this.config.inspect<string>(DEFAULT_MODEL_SECTION).userValue;
const defaultThinking = this.config.inspect<boolean>(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<void> {
@ -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<readonly AuthStatus[]> {
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<string, unknown> | 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<string>,
): Set<string> {
const ids = new Set<string>();
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<string> {
const keys = new Set<string>();
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<string> {
const keys = new Set<string>();
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<string>,
newIds: Set<string>,
): { 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<string>,
): boolean {
return (
providerModelSnapshot(config, providerId, aliasKeys) ===
providerModelSnapshot(nextConfig, providerId, aliasKeys)
);
}
function providerModelSnapshot(
config: ManagedKimiConfigShape,
providerId: string,
aliasKeys: ReadonlySet<string>,
): 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<string> {
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<string>,
): Record<string, ManagedModel> {
const preserved: Record<string, ManagedModel> = {};
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<string, ManagedModel>,
): 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');

View file

@ -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<readonly ProviderCatalogItem[]>;
getProvider(providerId: string): Promise<ProviderCatalogItem>;
setDefaultModel(modelId: string): Promise<SetDefaultModelResponse>;
refreshOAuthProviderModels(): Promise<RefreshOAuthProviderModelsResponse>;
}
export const IModelCatalogService: ServiceIdentifier<IModelCatalogService> =

View file

@ -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<RefreshOAuthProviderModelsResponse> {
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<Record<string, ProviderConfig>>(PROVIDERS_SECTION).userValue ?? {};
const models =
this.config.inspect<Record<string, ModelAlias>>(MODELS_SECTION).userValue ?? {};
const defaultModel = this.config.inspect<string>(DEFAULT_MODEL_SECTION).userValue;
const defaultThinking = this.config.inspect<boolean>(DEFAULT_THINKING_SECTION).userValue;
return {
providers: { ...providers } as ManagedKimiConfigShape['providers'],
models: { ...models } as ManagedKimiConfigShape['models'],
defaultModel,
defaultThinking,
};
}
}
function isKimiOAuthProvider(
provider: ProviderConfig | Record<string, unknown> | 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<string>,
): Set<string> {
const ids = new Set<string>();
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<string> {
const keys = new Set<string>();
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<string> {
const keys = new Set<string>();
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<string>,
newIds: Set<string>,
): { 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<string>,
): boolean {
return (
providerModelSnapshot(config, providerId, aliasKeys) ===
providerModelSnapshot(nextConfig, providerId, aliasKeys)
);
}
function providerModelSnapshot(
config: ManagedKimiConfigShape,
providerId: string,
aliasKeys: ReadonlySet<string>,
): 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<string> {
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<string>,
): Record<string, ManagedModel> {
const preserved: Record<string, ManagedModel> = {};
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<string, ManagedModel>,
): 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(

View file

@ -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<string, ProviderConfig>;
let models: Record<string, ModelAlias>;
let defaultModel: string | undefined;
let defaultThinking: boolean | undefined;
let toolkit: FakeToolkit;
let providerSet: ReturnType<typeof vi.fn>;
let configSet: ReturnType<typeof vi.fn>;
let configReplace: ReturnType<typeof vi.fn>;
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<string, unknown> {
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({

View file

@ -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<typeof vi.fn>;
let configReplace: ReturnType<typeof vi.fn>;
let getCachedAccessToken: ReturnType<typeof vi.fn>;
let resolveTokenProvider: ReturnType<typeof vi.fn>;
beforeEach(() => {
disposables = new DisposableStore();
backing = seedBacking();
configSet = vi.fn().mockImplementation(async (domain: string, patch: unknown) => {
const current = (backing as Record<string, unknown>)[domain];
(backing as Record<string, unknown>)[domain] =
const current = (backing as unknown as Record<string, unknown>)[domain];
(backing as unknown as Record<string, unknown>)[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<string, unknown>)[domain] = value;
});
getCachedAccessToken = vi.fn().mockResolvedValue(undefined);
resolveTokenProvider = vi.fn();
getCachedAccessToken = vi.fn<IOAuthService['getCachedAccessToken']>().mockResolvedValue(undefined);
ix = createServices(disposables, {
additionalServices: (reg) => {
reg.defineInstance(IConfigRegistry, new ConfigRegistry());
reg.definePartialInstance(IConfigService, {
get: ((domain: string) => (backing as Record<string, unknown>)[domain]) as IConfigService['get'],
get: ((domain: string) => (backing as unknown as Record<string, unknown>)[domain]) as IConfigService['get'],
inspect: ((domain: string) => ({
value: (backing as Record<string, unknown>)[domain],
value: (backing as unknown as Record<string, unknown>)[domain],
defaultValue: undefined,
userValue: (backing as Record<string, unknown>)[domain],
userValue: (backing as unknown as Record<string, unknown>)[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');
});
});

View file

@ -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<IModelCatalogService> {
return core.accessor.get(IModelCatalogService);
}
async function loadOAuth(core: Scope): Promise<IOAuthService> {
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));
},
);