mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-31 02:14:58 +00:00
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:
parent
632e0a7c9f
commit
69084ab683
9 changed files with 848 additions and 419 deletions
|
|
@ -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
|
||||
|
|
|
|||
245
packages/agent-core-v2/examples/oauth.example.ts
Normal file
245
packages/agent-core-v2/examples/oauth.example.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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[]>;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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> =
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
},
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue