Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/managed-userinfo-endpoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Add an account profile endpoint to the server REST API so the web UI can show the signed-in user's profile.
6 changes: 6 additions & 0 deletions packages/agent-core-v2/src/app/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/

import type {
AuthManagedUserInfoResult,
AuthManagedUsageResult,
BearerTokenProvider,
KimiOAuthLoginOptions,
Expand Down Expand Up @@ -47,6 +48,7 @@ export interface IOAuthService {
status(provider?: string): Promise<AuthStatus>;
refreshOAuthProviderModels(): Promise<RefreshOAuthProviderModelsResponse>;
getManagedUsage(provider?: string): Promise<AuthManagedUsageResult>;
getManagedUserInfo(provider?: string): Promise<AuthManagedUserInfoResult>;
resolveTokenProvider(provider: string, oauthRef?: OAuthRef): BearerTokenProvider | undefined;
getCachedAccessToken(provider: string, oauthRef?: OAuthRef): Promise<string | undefined>;
}
Expand All @@ -68,6 +70,10 @@ export interface IOAuthToolkit {
providerName?: string,
options?: { readonly oauthRef?: KimiOAuthTokenRef; readonly baseUrl?: string },
): Promise<AuthManagedUsageResult>;
getManagedUserInfo(
providerName?: string,
options?: { readonly oauthRef?: KimiOAuthTokenRef; readonly baseUrl?: string },
): Promise<AuthManagedUserInfoResult>;
}

export const IOAuthToolkit: ServiceIdentifier<IOAuthToolkit> =
Expand Down
13 changes: 13 additions & 0 deletions packages/agent-core-v2/src/app/auth/authService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
resolveKimiCodeLoginAuth,
resolveKimiCodeOAuthRef,
resolveKimiCodeRuntimeAuth,
type AuthManagedUserInfoResult,
type AuthManagedUsageResult,
type BearerTokenProvider,
type DeviceAuthorization,
Expand Down Expand Up @@ -279,6 +280,18 @@ export class OAuthService extends Disposable implements IOAuthService {
});
}

getManagedUserInfo(provider = KIMI_CODE_PROVIDER_NAME): Promise<AuthManagedUserInfoResult> {
const configured = this.providerService.get(provider);
const auth = resolveKimiCodeRuntimeAuth({
configuredBaseUrl: configured?.baseUrl,
configuredOAuthRef: configured?.oauth,
});
return this.toolkit.getManagedUserInfo(provider, {
oauthRef: auth.oauthRef,
baseUrl: auth.baseUrl,
});
}

refreshOAuthProviderModels(): Promise<RefreshOAuthProviderModelsResponse> {
const run = this.refreshChain.then(() => this.doRefreshOAuthProviderModels());
this.refreshChain = run.then(
Expand Down
14 changes: 14 additions & 0 deletions packages/agent-core-v2/src/app/auth/oauthProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
* Request/response shapes of the v1 `/oauth/*` endpoints plus the managed
* OAuth provider model-refresh response, defined as zod schemas so the
* transports validate against the same contract the `IOAuthService` returns.
* New endpoints use the camelCase domain contract owned by the oauth
* package (re-exported below); legacy snake_case schemas stay local.
*/

import { z } from 'zod';
Expand Down Expand Up @@ -158,3 +160,15 @@ export const managedUsageResultSchema = z.discriminatedUnion('kind', [
managedUsageErrorSchema,
]);
export type ManagedUsageResult = z.infer<typeof managedUsageResultSchema>;

// ---------------------------------------------------------------------------
// Managed-account profile (`GET /v1/oauth/userinfo`) — camelCase domain
// contract owned by `@moonshot-ai/kimi-code-oauth` (its zod schemas are the
// single source of truth); re-exported here so transports keep one import
// path. Legacy snake_case endpoints keep their local schemas above.
// ---------------------------------------------------------------------------

export {
managedUserInfoResultSchema,
type ManagedUserInfoResult,
} from '@moonshot-ai/kimi-code-oauth';
26 changes: 26 additions & 0 deletions packages/agent-core-v2/test/app/auth/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ interface FakeToolkit {
readonly getCachedAccessToken: ReturnType<typeof vi.fn>;
readonly tokenProvider: ReturnType<typeof vi.fn>;
readonly getManagedUsage: ReturnType<typeof vi.fn>;
readonly getManagedUserInfo: ReturnType<typeof vi.fn>;
}

describe('OAuthService', () => {
Expand Down Expand Up @@ -155,6 +156,7 @@ describe('OAuthService', () => {
getCachedAccessToken: vi.fn().mockResolvedValue(undefined),
tokenProvider: vi.fn().mockReturnValue({ getAccessToken: async () => 'access-token' }),
getManagedUsage: vi.fn().mockResolvedValue({ kind: 'error', message: 'not configured' }),
getManagedUserInfo: vi.fn().mockResolvedValue({ kind: 'error', message: 'not configured' }),
};
ix = createServices(disposables, {
base: [registerBootstrapServices, registerTelemetryServices],
Expand Down Expand Up @@ -693,6 +695,30 @@ describe('OAuthService', () => {
});
});

it('getManagedUserInfo resolves the managed runtime auth and delegates to the toolkit', async () => {
const userInfo = {
kind: 'ok' as const,
userInfo: {
userId: 'u_1',
nickname: 'moonwalker',
status: 'USER_STATUS_NORMAL',
region: 'REGION_CN',
userLevel: 30,
userLevelName: 'Vivace',
domain: 1,
domainName: 'DOMAIN_EXAMPLE',
},
};
toolkit.getManagedUserInfo.mockResolvedValue(userInfo);
const svc = createService();

await expect(svc.getManagedUserInfo(OAUTH_PROVIDER)).resolves.toBe(userInfo);
expect(toolkit.getManagedUserInfo).toHaveBeenCalledWith(OAUTH_PROVIDER, {
oauthRef: EXAMPLE_COM_SCOPED_REF,
baseUrl: 'https://api.example.com',
});
});

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();
Expand Down
31 changes: 27 additions & 4 deletions packages/kap-server/src/routes/oauth.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
/**
* `/oauth/*` REST routes.
*
* POST /oauth/login start a device-code flow → OAuthFlowStart
* GET /oauth/login poll current flow state → OAuthFlowSnapshot | null
* DELETE /oauth/login cancel pending flow → { cancelled, status }
* POST /oauth/logout logout → { logged_out, provider }
* POST /oauth/login start a device-code flow → OAuthFlowStart
* GET /oauth/login poll current flow state → OAuthFlowSnapshot | null
* DELETE /oauth/login cancel pending flow → { cancelled, status }
* POST /oauth/logout logout → { logged_out, provider }
* GET /oauth/userinfo managed-account profile → ManagedUserInfoResult
*
* Backed by the v2 `IOAuthService` (Core scope), which already returns the
* protocol wire types, so the handlers only swap the v1 accessor
Expand All @@ -13,6 +14,7 @@

import { IOAuthService, type Scope } from '@moonshot-ai/agent-core-v2';
import {
managedUserInfoResultSchema,
managedUsageResultSchema,
oauthFlowSnapshotSchema,
oauthFlowStartSchema,
Expand Down Expand Up @@ -175,6 +177,27 @@ export function registerOAuthRoutes(app: RouteHost, core: Scope): void {
usageRoute.options,
usageRoute.handler as Parameters<RouteHost['get']>[2],
);

// GET /oauth/userinfo — managed-account profile ------------------------------
const userInfoRoute = defineRoute(
{
method: 'GET',
path: '/oauth/userinfo',
querystring: oauthLoginQuerySchema,
success: { data: managedUserInfoResultSchema },
description: 'Get the managed account profile',
tags: ['auth'],
},
async (req, reply) => {
const result = await core.accessor.get(IOAuthService).getManagedUserInfo(req.query.provider);
reply.send(okEnvelope(result, req.id));
},
);
app.get(
userInfoRoute.path,
userInfoRoute.options,
userInfoRoute.handler as Parameters<RouteHost['get']>[2],
);
}

/** Domain (camelCase) → wire (snake_case) mapping for the usage payload. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e
"GET",
"/api/v1/oauth/usage",
],
[
"GET",
"/api/v1/oauth/userinfo",
],
[
"GET",
"/api/v1/providers",
Expand Down
1 change: 1 addition & 0 deletions packages/kap-server/test/modelCatalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ describe('server-v2 /api/v1 model/provider catalog', () => {
status: async () => ({ loggedIn: false }),
refreshOAuthProviderModels,
getManagedUsage: async () => ({ kind: 'error' as const, message: 'unused' }),
getManagedUserInfo: async () => ({ kind: 'error' as const, message: 'unused' }),
resolveTokenProvider: () => undefined,
getCachedAccessToken: async () => undefined,
};
Expand Down
128 changes: 128 additions & 0 deletions packages/kap-server/test/oauthUsage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import {
type ScopeSeed,
} from '@moonshot-ai/agent-core-v2';
import {
managedUserInfoResultSchema,
managedUsageResultSchema,
type ManagedUserInfoResult,
type ManagedUsageResult,
} from '@moonshot-ai/agent-core-v2/app/auth/oauthProtocol';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
Expand Down Expand Up @@ -59,6 +61,7 @@ describe('server-v2 GET /api/v1/oauth/usage', () => {
status: async () => ({ loggedIn: false }),
refreshOAuthProviderModels: async () => ({ changed: [], unchanged: [], failed: [] }),
getManagedUsage,
getManagedUserInfo: async () => ({ kind: 'error' as const, message: 'unused' }),
resolveTokenProvider: () => undefined,
getCachedAccessToken: async () => undefined,
};
Expand Down Expand Up @@ -150,3 +153,128 @@ describe('server-v2 GET /api/v1/oauth/usage', () => {
expect(getManagedUsage).toHaveBeenCalledWith('managed:kimi-code');
});
});

describe('server-v2 GET /api/v1/oauth/userinfo', () => {
let server: RunningServer | undefined;
let home: string | undefined;
let base: string;

beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-oauth-userinfo-'));
});

afterEach(async () => {
if (server !== undefined) {
await server.close();
server = undefined;
}
if (home !== undefined) {
await rm(home, { recursive: true, force: true });
home = undefined;
}
});

function oauthStub(getManagedUserInfo: IOAuthServiceType['getManagedUserInfo']): IOAuthServiceType {
return {
_serviceBrand: undefined,
startLogin: async () => {
throw new Error('unused');
},
getFlow: () => undefined,
cancelLogin: async () => {
throw new Error('unused');
},
logout: async () => {
throw new Error('unused');
},
status: async () => ({ loggedIn: false }),
refreshOAuthProviderModels: async () => ({ changed: [], unchanged: [], failed: [] }),
getManagedUsage: async () => ({ kind: 'error' as const, message: 'unused' }),
getManagedUserInfo,
resolveTokenProvider: () => undefined,
getCachedAccessToken: async () => undefined,
};
}

async function boot(seeds: ScopeSeed): Promise<void> {
server = await startServer({
host: '127.0.0.1',
port: 0,
homeDir: home,
logLevel: 'silent',
seeds,
});
base = `http://127.0.0.1:${server.port}`;
}

async function getUserInfo(query = ''): Promise<ManagedUserInfoResult> {
const res = await fetch(`${base}/api/v1/oauth/userinfo${query}`, {
headers: authHeaders(server as RunningServer),
} as never);
expect(res.status).toBe(200);
const body = (await res.json()) as Envelope<ManagedUserInfoResult>;
expect(body.code).toBe(0);
return managedUserInfoResultSchema.parse(body.data);
}

it('returns the ok profile payload in the camelCase domain shape', async () => {
const getManagedUserInfo = vi.fn(async () => ({
kind: 'ok' as const,
userInfo: {
userId: 'u_123',
nickname: 'moonwalker',
status: 'USER_STATUS_NORMAL',
region: 'REGION_CN',
userLevel: 30,
userLevelName: 'Vivace',
domain: 1,
domainName: 'DOMAIN_EXAMPLE',
globalId: 'u_123',
avatar: 'https://example.com/avatar.png',
username: 'moonwalker2333',
email: 'user@example.com',
phone: { countryCode: '86', number: '176****0000' },
createdTime: '2026-06-11T13:26:47.561184Z',
lastLoginTime: '2026-07-16T03:12:03.033412Z',
},
}));
await boot([[IOAuthService, oauthStub(getManagedUserInfo)]] as unknown as ScopeSeed);

expect(await getUserInfo()).toEqual({
kind: 'ok',
userInfo: {
userId: 'u_123',
nickname: 'moonwalker',
status: 'USER_STATUS_NORMAL',
region: 'REGION_CN',
userLevel: 30,
userLevelName: 'Vivace',
domain: 1,
domainName: 'DOMAIN_EXAMPLE',
globalId: 'u_123',
avatar: 'https://example.com/avatar.png',
username: 'moonwalker2333',
email: 'user@example.com',
phone: { countryCode: '86', number: '176****0000' },
createdTime: '2026-06-11T13:26:47.561184Z',
lastLoginTime: '2026-07-16T03:12:03.033412Z',
},
});
});

it('passes through the error payload and forwards the provider query', async () => {
const getManagedUserInfo = vi.fn(async (_provider?: string) => ({
kind: 'error' as const,
message: 'Authorization failed.',
status: 401,
}));
await boot([[IOAuthService, oauthStub(getManagedUserInfo)]] as unknown as ScopeSeed);

expect(await getUserInfo('?provider=managed%3Akimi-code')).toEqual({
kind: 'error',
message: 'Authorization failed.',
status: 401,
});
expect(getManagedUserInfo).toHaveBeenCalledWith('managed:kimi-code');
});
});
3 changes: 2 additions & 1 deletion packages/oauth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@
"clean": "rm -rf dist"
},
"dependencies": {
"proper-lockfile": "^4.1.2"
"proper-lockfile": "^4.1.2",
"zod": "catalog:"
},
"devDependencies": {
"@types/proper-lockfile": "^4.1.4"
Expand Down
17 changes: 17 additions & 0 deletions packages/oauth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,22 @@ export type {
ProvisionManagedKimiCodeConfigOptions,
} from './managed-kimi-code';

export {
fetchManagedUserInfo,
kimiCodeUserInfoUrl,
managedUserInfoPhoneSchema,
managedUserInfoResultSchema,
managedUserInfoSchema,
parseManagedUserInfoPayload,
} from './managed-userinfo';
export type {
FetchManagedUserInfoError,
FetchManagedUserInfoResult,
ManagedUserInfo,
ManagedUserInfoPhone,
ManagedUserInfoResult,
} from './managed-userinfo';

export {
fetchManagedUsage,
formatDuration,
Expand Down Expand Up @@ -153,6 +169,7 @@ export type {

export { KimiOAuthToolkit, resolveKimiTokenStorageName } from './toolkit';
export type {
AuthManagedUserInfoResult,
AuthManagedUsageResult,
AuthProviderStatus,
AuthStatus,
Expand Down
Loading
Loading