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
This commit is contained in:
haozhe.yang 2026-06-30 19:54:43 +08:00
parent 2fc1fbc648
commit 05053ee845
8 changed files with 377 additions and 22 deletions

View file

@ -134,6 +134,7 @@ const DOMAIN_LAYER = new Map([
['rpc', 7],
['promptLegacy', 7],
['sessionLegacy', 7],
['authLegacy', 7],
]);
const V1_PACKAGE = '@moonshot-ai/agent-core';

View file

@ -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<AuthSummary>;
}
export const IAuthLegacyService: ServiceIdentifier<IAuthLegacyService> =
createDecorator<IAuthLegacyService>('authLegacyService');

View file

@ -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<AuthSummary> {
// 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<string>(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<boolean> {
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',
);

View file

@ -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';

View file

@ -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';

View file

@ -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<string, ProviderConfig>;
let defaultModel: string | undefined;
let oauthStatus: ReturnType<typeof vi.fn>;
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' },
});
});
});

View file

@ -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));
},
);

View file

@ -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<T> {
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<void> {
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<AuthSummary> {
const res = await fetch(`${base}/api/v1/auth`);
expect(res.status).toBe(200);
const body = (await res.json()) as Envelope<AuthSummary>;
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);
});
});