mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-21 14:47:17 +00:00
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:
parent
2fc1fbc648
commit
05053ee845
8 changed files with 377 additions and 22 deletions
|
|
@ -134,6 +134,7 @@ const DOMAIN_LAYER = new Map([
|
|||
['rpc', 7],
|
||||
['promptLegacy', 7],
|
||||
['sessionLegacy', 7],
|
||||
['authLegacy', 7],
|
||||
]);
|
||||
|
||||
const V1_PACKAGE = '@moonshot-ai/agent-core';
|
||||
|
|
|
|||
28
packages/agent-core-v2/src/authLegacy/authLegacy.ts
Normal file
28
packages/agent-core-v2/src/authLegacy/authLegacy.ts
Normal 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');
|
||||
85
packages/agent-core-v2/src/authLegacy/authLegacyService.ts
Normal file
85
packages/agent-core-v2/src/authLegacy/authLegacyService.ts
Normal 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',
|
||||
);
|
||||
9
packages/agent-core-v2/src/authLegacy/index.ts
Normal file
9
packages/agent-core-v2/src/authLegacy/index.ts
Normal 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';
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
},
|
||||
);
|
||||
|
|
|
|||
134
packages/server-v2/test/auth.test.ts
Normal file
134
packages/server-v2/test/auth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue