From 05053ee845d32c35c48d55c9770c94a3fd80897e Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Tue, 30 Jun 2026 19:54:43 +0800 Subject: [PATCH] fix(server-v2): align GET /api/v1/auth with v1 readiness summary The v2 readiness probe returned a simplified snapshot: default_model was hardcoded to null, providers_count counted only oauth providers, and managed_provider was synthesized from any authenticated provider. Mirror v1's AuthSummaryService.get() through a new L7 edge adapter. - add IAuthLegacyService projecting provider/config/oauth state into the v1 AuthSummary wire shape; the native IAuthSummaryService keeps serving /api/v2 untouched - default_model reads the configured defaultModel - providers_count counts every configured provider - managed_provider reflects managed:kimi-code cached-token state and is null when that provider is absent - ready matches v1 (providers >= 1, default model set, not revoked) - register authLegacy at L7 in the domain-layer map --- .../scripts/check-domain-layers.mjs | 1 + .../src/authLegacy/authLegacy.ts | 28 ++++ .../src/authLegacy/authLegacyService.ts | 85 +++++++++++ .../agent-core-v2/src/authLegacy/index.ts | 9 ++ packages/agent-core-v2/src/index.ts | 1 + packages/agent-core-v2/test/auth/auth.test.ts | 112 +++++++++++++++ packages/server-v2/src/routes/auth.ts | 29 +--- packages/server-v2/test/auth.test.ts | 134 ++++++++++++++++++ 8 files changed, 377 insertions(+), 22 deletions(-) create mode 100644 packages/agent-core-v2/src/authLegacy/authLegacy.ts create mode 100644 packages/agent-core-v2/src/authLegacy/authLegacyService.ts create mode 100644 packages/agent-core-v2/src/authLegacy/index.ts create mode 100644 packages/server-v2/test/auth.test.ts diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index 97cb8a9dd..ffbc38ac9 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -134,6 +134,7 @@ const DOMAIN_LAYER = new Map([ ['rpc', 7], ['promptLegacy', 7], ['sessionLegacy', 7], + ['authLegacy', 7], ]); const V1_PACKAGE = '@moonshot-ai/agent-core'; diff --git a/packages/agent-core-v2/src/authLegacy/authLegacy.ts b/packages/agent-core-v2/src/authLegacy/authLegacy.ts new file mode 100644 index 000000000..fb74562e2 --- /dev/null +++ b/packages/agent-core-v2/src/authLegacy/authLegacy.ts @@ -0,0 +1,28 @@ +/** + * `authLegacy` domain (L7 edge adapter) — v1-compatible auth readiness summary. + * + * Implements the `GET /api/v1/auth` `AuthSummary` wire contract on top of the + * native v2 services (`IProviderService`, `IConfigService`, `IOAuthService`). + * The native `IAuthSummaryService` keeps serving `/api/v2` (`auth:summarize` / + * `auth:ensureReady`) and is left untouched; this adapter exists only so v1 + * clients keep working against server-v2. Bound at Core scope — it is a + * stateless projector over the global provider / model / credential state. + */ + +import type { AuthSummary } from '@moonshot-ai/protocol'; + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IAuthLegacyService { + readonly _serviceBrand: undefined; + + /** + * Compute the v1 readiness snapshot (`GET /api/v1/auth`). Cheap (one provider + * list + one config read + one cached-token probe); safe to call on every + * request. Never throws on provider state — the probe returns 200 regardless. + */ + get(): Promise; +} + +export const IAuthLegacyService: ServiceIdentifier = + createDecorator('authLegacyService'); diff --git a/packages/agent-core-v2/src/authLegacy/authLegacyService.ts b/packages/agent-core-v2/src/authLegacy/authLegacyService.ts new file mode 100644 index 000000000..c1ace2b2d --- /dev/null +++ b/packages/agent-core-v2/src/authLegacy/authLegacyService.ts @@ -0,0 +1,85 @@ +/** + * `authLegacy` domain — `IAuthLegacyService` implementation. + * + * Stateless Core-scope projector: reads the configured providers through + * `provider`, the global default-model selection through `config`, and the + * managed OAuth provider's cached-token state through `auth`, then assembles + * the v1 `AuthSummary`. The computation mirrors v1's `AuthSummaryService.get()` + * so the `/api/v1/auth` envelope is byte-compatible. No business logic is + * duplicated; the native `IAuthSummaryService` (which serves `/api/v2`) is not + * involved. + */ + +import { KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth'; +import type { AuthSummary } from '@moonshot-ai/protocol'; + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IOAuthService } from '#/auth/auth'; +import { IConfigService } from '#/config/config'; +import { IProviderService } from '#/provider/provider'; + +import { IAuthLegacyService } from './authLegacy'; + +const DEFAULT_MODEL_SECTION = 'defaultModel'; +const MANAGED_PROVIDER_NAME = KIMI_CODE_PROVIDER_NAME; + +export class AuthLegacyService implements IAuthLegacyService { + declare readonly _serviceBrand: undefined; + + constructor( + @IProviderService private readonly providerService: IProviderService, + @IConfigService private readonly config: IConfigService, + @IOAuthService private readonly oauth: IOAuthService, + ) {} + + async get(): Promise { + // Config loads asynchronously during bootstrap; mirror the catalog route's + // guard so a first-paint probe never observes a not-yet-loaded snapshot. + await this.config.ready; + + const providers = this.providerService.list(); + const providers_count = Object.keys(providers).length; + const default_model = nonEmpty(this.config.get(DEFAULT_MODEL_SECTION)); + + let managed_provider: AuthSummary['managed_provider'] = null; + if (providers[MANAGED_PROVIDER_NAME] !== undefined) { + const loggedIn = await this.managedLoggedIn(); + managed_provider = { + name: MANAGED_PROVIDER_NAME, + status: loggedIn ? 'authenticated' : 'unauthenticated', + }; + } + + const ready = + providers_count >= 1 && + default_model !== null && + (managed_provider === null || managed_provider.status !== 'revoked'); + + return { ready, providers_count, default_model, managed_provider }; + } + + private async managedLoggedIn(): Promise { + try { + return (await this.oauth.status(MANAGED_PROVIDER_NAME)).loggedIn; + } catch { + // Token-storage failures must not block the readiness probe; treat any + // error as "no usable token" (matches v1's `_hasCachedToken`). + return false; + } + } +} + +function nonEmpty(value: string | undefined): string | null { + if (value === undefined) return null; + const trimmed = value.trim(); + return trimmed.length === 0 ? null : trimmed; +} + +registerScopedService( + LifecycleScope.Core, + IAuthLegacyService, + AuthLegacyService, + InstantiationType.Delayed, + 'authLegacy', +); diff --git a/packages/agent-core-v2/src/authLegacy/index.ts b/packages/agent-core-v2/src/authLegacy/index.ts new file mode 100644 index 000000000..f0048a334 --- /dev/null +++ b/packages/agent-core-v2/src/authLegacy/index.ts @@ -0,0 +1,9 @@ +/** + * `authLegacy` domain barrel — re-exports the v1 auth-readiness adapter + * contract (`authLegacy`) and its scoped service (`authLegacyService`). + * Importing this barrel registers the `IAuthLegacyService` binding into the + * scope registry. + */ + +export * from './authLegacy'; +export * from './authLegacyService'; diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 2e1b46f81..e1b2c5e7f 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -61,6 +61,7 @@ export * from './terminal/index'; export * from './storage/index'; export * from './filestore/index'; export * from './auth/index'; +export * from './authLegacy/index'; // Ported agent services. These keep the current service boundaries during the migration. export * from './blobStore/index'; diff --git a/packages/agent-core-v2/test/auth/auth.test.ts b/packages/agent-core-v2/test/auth/auth.test.ts index 1ac0d4cc4..dd59e57be 100644 --- a/packages/agent-core-v2/test/auth/auth.test.ts +++ b/packages/agent-core-v2/test/auth/auth.test.ts @@ -12,6 +12,7 @@ import { createServices, type TestInstantiationService } from '#/_base/di/test'; import { ErrorCodes, KimiError } from '#/errors'; import { IAuthSummaryService, IOAuthService, IOAuthToolkit } from '#/auth/auth'; import { AuthSummaryService, OAuthService } from '#/auth/authService'; +import { AuthLegacyService, IAuthLegacyService } from '#/authLegacy'; import { IConfigService } from '#/config/config'; import { ILogService } from '#/log/log'; import { type ModelAlias } from '#/model/model'; @@ -390,3 +391,114 @@ describe('AuthSummaryService', () => { await expect(createSummary().ensureReady(OAUTH_PROVIDER)).resolves.toBeUndefined(); }); }); + +describe('AuthLegacyService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + let providers: Record; + let defaultModel: string | undefined; + let oauthStatus: ReturnType; + + beforeEach(() => { + disposables = new DisposableStore(); + providers = {}; + defaultModel = undefined; + oauthStatus = vi.fn(); + ix = createServices(disposables, { + additionalServices: (reg) => { + reg.definePartialInstance(IProviderService, { + list: (() => providers) as IProviderService['list'], + }); + reg.definePartialInstance(IConfigService, { + ready: Promise.resolve(), + get: ((domain: string) => + domain === 'defaultModel' ? defaultModel : undefined) as IConfigService['get'], + }); + reg.definePartialInstance(IOAuthService, { + status: oauthStatus as unknown as IOAuthService['status'], + }); + reg.define(IAuthLegacyService, AuthLegacyService); + }, + }); + }); + afterEach(() => disposables.dispose()); + + function createService(): IAuthLegacyService { + return ix.get(IAuthLegacyService); + } + + it('returns an empty snapshot when no providers are configured', async () => { + await expect(createService().get()).resolves.toEqual({ + ready: false, + providers_count: 0, + default_model: null, + managed_provider: null, + }); + expect(oauthStatus).not.toHaveBeenCalled(); + }); + + it('counts every configured provider, not only oauth ones', async () => { + providers = { + [OAUTH_PROVIDER]: { type: 'kimi', oauth: { storage: 'file', key: 'oauth/kimi-code' } }, + [NON_OAUTH_PROVIDER]: { type: 'openai', apiKey: 'sk-test' }, + }; + oauthStatus.mockResolvedValue({ loggedIn: false }); + const summary = await createService().get(); + expect(summary.providers_count).toBe(2); + }); + + it('reflects the configured default model', async () => { + providers = { [NON_OAUTH_PROVIDER]: { type: 'kimi', apiKey: 'sk-test' } }; + defaultModel = 'k2'; + const summary = await createService().get(); + expect(summary.default_model).toBe('k2'); + expect(summary.managed_provider).toBeNull(); + expect(summary.ready).toBe(true); + }); + + it('is not ready when a provider exists but no default model is set', async () => { + providers = { [NON_OAUTH_PROVIDER]: { type: 'kimi', apiKey: 'sk-test' } }; + const summary = await createService().get(); + expect(summary.providers_count).toBe(1); + expect(summary.default_model).toBeNull(); + expect(summary.managed_provider).toBeNull(); + expect(summary.ready).toBe(false); + }); + + it('surfaces managed_provider.unauthenticated when configured without a cached token', async () => { + providers = { + [OAUTH_PROVIDER]: { type: 'kimi', oauth: { storage: 'file', key: 'oauth/kimi-code' } }, + }; + oauthStatus.mockResolvedValue({ loggedIn: false }); + const summary = await createService().get(); + expect(summary.managed_provider).toEqual({ + name: OAUTH_PROVIDER, + status: 'unauthenticated', + }); + expect(summary.ready).toBe(false); + }); + + it('surfaces managed_provider.authenticated when a cached token exists', async () => { + providers = { + [OAUTH_PROVIDER]: { type: 'kimi', oauth: { storage: 'file', key: 'oauth/kimi-code' } }, + }; + defaultModel = 'k2'; + oauthStatus.mockResolvedValue({ loggedIn: true, provider: OAUTH_PROVIDER }); + const summary = await createService().get(); + expect(summary.managed_provider).toEqual({ + name: OAUTH_PROVIDER, + status: 'authenticated', + }); + expect(summary.ready).toBe(true); + }); + + it('treats a throwing oauth status as unauthenticated', async () => { + providers = { + [OAUTH_PROVIDER]: { type: 'kimi', oauth: { storage: 'file', key: 'oauth/kimi-code' } }, + }; + oauthStatus.mockRejectedValue(new Error('token storage unavailable')); + await expect(createService().get()).resolves.toMatchObject({ + managed_provider: { name: OAUTH_PROVIDER, status: 'unauthenticated' }, + }); + }); +}); diff --git a/packages/server-v2/src/routes/auth.ts b/packages/server-v2/src/routes/auth.ts index 28559676e..870728997 100644 --- a/packages/server-v2/src/routes/auth.ts +++ b/packages/server-v2/src/routes/auth.ts @@ -5,18 +5,15 @@ * between onboarding vs. chat UI. Returns 200 + envelope regardless of provider * state. * - * v2's `IAuthSummaryService.summarize()` returns a per-provider `AuthStatus[]` - * (`{ loggedIn, provider? }`), which is a simpler model than the v1 - * `AuthSummary` wire shape (`{ ready, providers_count, default_model, - * managed_provider }`). This handler projects the v2 snapshot onto the v1 wire - * shape: `ready` reflects any authenticated provider, `providers_count` counts - * the snapshot entries, `default_model` is `null` (v2 has no model catalog - * yet), and `managed_provider` surfaces the authenticated provider when present. + * The handler is a thin adapter over `IAuthLegacyService`, which projects the + * v2 provider / model / credential state into the v1 `AuthSummary` wire shape + * (`{ ready, providers_count, default_model, managed_provider }`). The native + * `IAuthSummaryService` (which serves `/api/v2`) is intentionally not used here + * — its `AuthStatus[]` model is the v2 shape, not the v1 contract. */ -import { IAuthSummaryService, type Scope } from '@moonshot-ai/agent-core-v2'; +import { IAuthLegacyService, type Scope } from '@moonshot-ai/agent-core-v2'; import { authSummarySchema } from '@moonshot-ai/protocol'; -import type { AuthSummary } from '@moonshot-ai/protocol'; import { okEnvelope } from '../envelope'; import { defineRoute } from '../middleware/defineRoute'; @@ -42,19 +39,7 @@ export function registerAuthRoute(app: RouteHost, core: Scope): void { tags: ['auth'], }, async (req, reply) => { - const statuses = await core.accessor.get(IAuthSummaryService).summarize(); - const authenticated = statuses.find((s) => s.loggedIn); - const firstNamed = statuses.find((s) => s.provider !== undefined); - const summary: AuthSummary = { - ready: authenticated !== undefined, - providers_count: statuses.length, - default_model: null, - managed_provider: authenticated?.provider !== undefined - ? { name: authenticated.provider, status: 'authenticated' } - : firstNamed?.provider !== undefined - ? { name: firstNamed.provider, status: 'unauthenticated' } - : null, - }; + const summary = await core.accessor.get(IAuthLegacyService).get(); reply.send(okEnvelope(summary, req.id)); }, ); diff --git a/packages/server-v2/test/auth.test.ts b/packages/server-v2/test/auth.test.ts new file mode 100644 index 000000000..e9f748fe0 --- /dev/null +++ b/packages/server-v2/test/auth.test.ts @@ -0,0 +1,134 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { authSummarySchema, type AuthSummary } from '@moonshot-ai/protocol'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; + +interface Envelope { + code: number; + msg: string; + data: T; + request_id: string; +} + +describe('server-v2 GET /api/v1/auth', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-auth-')); + }); + + afterEach(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + async function boot(toml?: string): Promise { + if (toml !== undefined) { + await writeFile(join(home as string, 'config.toml'), toml, 'utf-8'); + } + server = await startServer({ + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + base = `http://127.0.0.1:${server.port}`; + } + + async function getAuth(): Promise { + const res = await fetch(`${base}/api/v1/auth`); + expect(res.status).toBe(200); + const body = (await res.json()) as Envelope; + expect(body.code).toBe(0); + return authSummarySchema.parse(body.data); + } + + it('returns ready=false with an empty snapshot on empty config', async () => { + await boot(); + expect(await getAuth()).toEqual({ + ready: false, + providers_count: 0, + default_model: null, + managed_provider: null, + }); + }); + + it('returns ready=true when provider + api_key + default_model are set', async () => { + await boot( + [ + 'default_model = "x"', + '', + '[providers.x]', + 'type = "kimi"', + 'api_key = "sk-test"', + '', + '[models.x]', + 'provider = "x"', + 'model = "x"', + 'max_context_size = 1000', + '', + ].join('\n'), + ); + expect(await getAuth()).toEqual({ + ready: true, + providers_count: 1, + default_model: 'x', + managed_provider: null, + }); + }); + + it('returns ready=false when a provider exists but default_model is missing', async () => { + await boot( + [ + '[providers.x]', + 'type = "kimi"', + 'api_key = "sk-test"', + '', + '[models.x]', + 'provider = "x"', + 'model = "x"', + 'max_context_size = 1000', + '', + ].join('\n'), + ); + const summary = await getAuth(); + expect(summary.ready).toBe(false); + expect(summary.providers_count).toBe(1); + expect(summary.default_model).toBeNull(); + expect(summary.managed_provider).toBeNull(); + }); + + it('surfaces managed_provider.unauthenticated without a cached token', async () => { + await boot( + [ + '[providers."managed:kimi-code"]', + 'type = "kimi"', + 'base_url = "https://example.test/v1"', + '', + '[providers."managed:kimi-code".oauth]', + 'storage = "file"', + 'key = "oauth/kimi-code"', + '', + ].join('\n'), + ); + const summary = await getAuth(); + expect(summary.managed_provider).toEqual({ + name: 'managed:kimi-code', + status: 'unauthenticated', + }); + // No default_model → still not ready, even though the provider exists. + expect(summary.ready).toBe(false); + }); +});