mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-06 15:26:26 +00:00
* feat(oauth): return structured managed usage rows
Stop formatting plan-usage labels and reset hints into English strings
at the oauth layer. The parser still absorbs backend field drift, but
now emits a stable structured row (name / window{duration,unit} / used
/ limit / resetAt) that kap-server passes through and clients localize
themselves:
- window: normalized from duration/timeUnit (minute/hour/day/week),
whole-hour minute windows fold to hours, unnamed summaries are the
weekly limit
- resetAt: absolute ISO timestamp; relative reset_in/ttl seconds are
converted at parse time
- TUI /usage panel formats labels and reset hints locally
* refactor(oauth): parse managed usage strictly to the current payload shape
Drop the defensive drift tolerance (alternate reset-time spellings,
reset_in/ttl seconds, remaining-derived used, title/scope names,
top-level duration/timeUnit, fuzzy time-unit matching) and parse only
what the platform actually sends: numeric strings, resetTime, nested
detail/window records, TIME_UNIT_* enums.
* fix(node-sdk): update managed usage smoke example and facade tests for structured rows
945 lines
32 KiB
TypeScript
945 lines
32 KiB
TypeScript
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
import {
|
|
FileTokenStorage,
|
|
KIMI_CODE_PROVIDER_NAME,
|
|
KimiOAuthToolkit,
|
|
OAuthConnectionError,
|
|
OAuthError,
|
|
RetryableRefreshError,
|
|
resolveKimiCodeOAuthKey,
|
|
resolveKimiTokenStorageName,
|
|
type TokenInfo,
|
|
} from '@moonshot-ai/kimi-code-oauth';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { createKimiHarness, ErrorCodes, KimiError } from '#/index';
|
|
|
|
import { ProviderManager } from '../../agent-core/src/session/provider-manager';
|
|
import { TEST_IDENTITY } from './test-identity';
|
|
|
|
let homeDir: string;
|
|
|
|
type FetchMock = (
|
|
input: Parameters<typeof fetch>[0],
|
|
init?: Parameters<typeof fetch>[1],
|
|
) => Promise<Response>;
|
|
|
|
function fetchInputUrl(input: Parameters<typeof fetch>[0]): string {
|
|
if (typeof input === 'string') return input;
|
|
if (input instanceof URL) return input.href;
|
|
return input.url;
|
|
}
|
|
|
|
function freshToken(): TokenInfo {
|
|
return {
|
|
accessToken: 'oauth-access-token',
|
|
refreshToken: 'oauth-refresh-token',
|
|
expiresAt: Math.floor(Date.now() / 1000) + 3600,
|
|
scope: '',
|
|
tokenType: 'Bearer',
|
|
expiresIn: 3600,
|
|
};
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-auth-'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.unstubAllGlobals();
|
|
vi.unstubAllEnvs();
|
|
await rm(homeDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('KimiHarness.auth', () => {
|
|
it('can construct auth facade without host identity', () => {
|
|
expect(() => createKimiHarness({ homeDir })).not.toThrow();
|
|
});
|
|
|
|
it('exposes a cached access token without refreshing auth state', async () => {
|
|
await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken());
|
|
const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY });
|
|
|
|
await expect(harness.auth.getCachedAccessToken()).resolves.toBe('oauth-access-token');
|
|
});
|
|
|
|
it('maps missing runtime OAuth tokens to login-required errors', async () => {
|
|
const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY });
|
|
|
|
await expect(
|
|
harness.auth.resolveOAuthTokenProvider(KIMI_CODE_PROVIDER_NAME).getAccessToken(),
|
|
).rejects.toMatchObject({
|
|
code: ErrorCodes.AUTH_LOGIN_REQUIRED,
|
|
});
|
|
});
|
|
|
|
it('maps transient OAuth token failures to provider connection errors', async () => {
|
|
const tokenErrors = [
|
|
new OAuthConnectionError('OAuth request failed: fetch failed'),
|
|
new RetryableRefreshError('Token refresh failed (HTTP 503).'),
|
|
];
|
|
|
|
for (const tokenError of tokenErrors) {
|
|
const tokenProviderSpy = vi
|
|
.spyOn(KimiOAuthToolkit.prototype, 'tokenProvider')
|
|
.mockReturnValue({
|
|
async getAccessToken() {
|
|
throw tokenError;
|
|
},
|
|
});
|
|
try {
|
|
const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY });
|
|
|
|
const error = await harness.auth
|
|
.resolveOAuthTokenProvider(KIMI_CODE_PROVIDER_NAME)
|
|
.getAccessToken()
|
|
.catch((caught: unknown) => caught);
|
|
|
|
expect(error).toBeInstanceOf(KimiError);
|
|
expect(error).toMatchObject({
|
|
code: ErrorCodes.PROVIDER_CONNECTION_ERROR,
|
|
message: expect.stringContaining(tokenError.message),
|
|
cause: tokenError,
|
|
});
|
|
} finally {
|
|
tokenProviderSpy.mockRestore();
|
|
}
|
|
}
|
|
});
|
|
|
|
it('preserves non-retryable OAuth refresh failures', async () => {
|
|
const oauthError = new OAuthError('bad client id');
|
|
const tokenProviderSpy = vi
|
|
.spyOn(KimiOAuthToolkit.prototype, 'tokenProvider')
|
|
.mockReturnValue({
|
|
async getAccessToken() {
|
|
throw oauthError;
|
|
},
|
|
});
|
|
try {
|
|
const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY });
|
|
|
|
await expect(
|
|
harness.auth.resolveOAuthTokenProvider(KIMI_CODE_PROVIDER_NAME).getAccessToken(),
|
|
).rejects.toBe(oauthError);
|
|
} finally {
|
|
tokenProviderSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('resolves managed auth from a partially invalid config without throwing', async () => {
|
|
await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken());
|
|
await writeFile(
|
|
join(homeDir, 'config.toml'),
|
|
`
|
|
[providers."managed:kimi-code"]
|
|
type = "kimi"
|
|
api_key = ""
|
|
|
|
[loop_control]
|
|
max_steps_per_turn = "abc"
|
|
`,
|
|
);
|
|
const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY });
|
|
|
|
// Token resolution is a read path: a broken section elsewhere in
|
|
// config.toml must degrade, not break OAuth-backed sessions.
|
|
await expect(harness.auth.getCachedAccessToken()).resolves.toBe('oauth-access-token');
|
|
await expect(harness.auth.status()).resolves.toMatchObject({
|
|
providers: [{ providerName: KIMI_CODE_PROVIDER_NAME, hasToken: true }],
|
|
});
|
|
});
|
|
|
|
it('resolves cached access tokens from the configured scoped OAuth ref', async () => {
|
|
const oauthKey = resolveKimiCodeOAuthKey({
|
|
oauthHost: 'https://auth.dev.example.test',
|
|
baseUrl: 'https://api.dev.example.test/coding/v1',
|
|
});
|
|
const storageName = resolveKimiTokenStorageName({ oauthKey });
|
|
const storage = new FileTokenStorage(join(homeDir, 'credentials'));
|
|
await storage.save('kimi-code', freshToken());
|
|
await storage.save(storageName, { ...freshToken(), accessToken: 'dev-access-token' });
|
|
await writeFile(
|
|
join(homeDir, 'config.toml'),
|
|
`
|
|
[providers."managed:kimi-code"]
|
|
type = "kimi"
|
|
base_url = "https://api.dev.example.test/coding/v1"
|
|
api_key = ""
|
|
oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev.example.test" }
|
|
`,
|
|
);
|
|
const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY });
|
|
|
|
await expect(harness.auth.getCachedAccessToken()).resolves.toBe('dev-access-token');
|
|
});
|
|
|
|
it('reports auth status from the configured scoped OAuth ref', async () => {
|
|
const oauthKey = resolveKimiCodeOAuthKey({
|
|
oauthHost: 'https://auth.dev.example.test',
|
|
baseUrl: 'https://api.dev.example.test/coding/v1',
|
|
});
|
|
await new FileTokenStorage(join(homeDir, 'credentials')).save(
|
|
resolveKimiTokenStorageName({ oauthKey }),
|
|
{ ...freshToken(), accessToken: 'dev-access-token' },
|
|
);
|
|
await writeFile(
|
|
join(homeDir, 'config.toml'),
|
|
`
|
|
[providers."managed:kimi-code"]
|
|
type = "kimi"
|
|
base_url = "https://api.dev.example.test/coding/v1"
|
|
api_key = ""
|
|
oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev.example.test" }
|
|
`,
|
|
);
|
|
const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY });
|
|
|
|
await expect(harness.auth.status()).resolves.toEqual({
|
|
providers: [{ providerName: KIMI_CODE_PROVIDER_NAME, hasToken: true }],
|
|
});
|
|
});
|
|
|
|
it('provisions SDK config using an existing Kimi OAuth token', async () => {
|
|
await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken());
|
|
const fetchMock = vi.fn<FetchMock>(
|
|
async (_input, _init) =>
|
|
new Response(
|
|
JSON.stringify({
|
|
data: [
|
|
{
|
|
id: 'kimi-for-coding',
|
|
context_length: 262144,
|
|
supports_reasoning: true,
|
|
supports_image_in: true,
|
|
supports_video_in: true,
|
|
display_name: 'Kimi for Coding',
|
|
},
|
|
],
|
|
}),
|
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY });
|
|
const result = await harness.auth.login();
|
|
const config = await harness.getConfig({ reload: true });
|
|
|
|
expect(result).toMatchObject({
|
|
providerName: KIMI_CODE_PROVIDER_NAME,
|
|
ok: true,
|
|
defaultModel: 'kimi-code/kimi-for-coding',
|
|
defaultThinking: true,
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://api.kimi.com/coding/v1/models',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
Authorization: 'Bearer oauth-access-token',
|
|
}),
|
|
}),
|
|
);
|
|
expect(config.defaultModel).toBe('kimi-code/kimi-for-coding');
|
|
expect(config.models?.['kimi-code/kimi-for-coding']).toMatchObject({
|
|
capabilities: ['thinking', 'image_in', 'video_in', 'tool_use'],
|
|
displayName: 'Kimi for Coding',
|
|
});
|
|
expect(new ProviderManager({ config }).resolveProviderConfig(config.defaultModel!)).toMatchObject({
|
|
modelCapabilities: {
|
|
tool_use: true,
|
|
},
|
|
});
|
|
expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({
|
|
type: 'kimi',
|
|
apiKey: '',
|
|
oauth: { storage: 'file', key: 'oauth/kimi-code' },
|
|
});
|
|
expect(config.services?.moonshotSearch?.oauth).toEqual({
|
|
storage: 'file',
|
|
key: 'oauth/kimi-code',
|
|
});
|
|
});
|
|
|
|
it('logs in against the configured scoped OAuth host and base URL when env is absent', async () => {
|
|
const baseUrl = 'https://api.dev.example.test/coding/v1';
|
|
const oauthHost = 'https://auth.dev.example.test';
|
|
const oauthKey = resolveKimiCodeOAuthKey({ oauthHost, baseUrl });
|
|
const storageName = resolveKimiTokenStorageName({ oauthKey });
|
|
const storage = new FileTokenStorage(join(homeDir, 'credentials'));
|
|
await storage.save(storageName, {
|
|
...freshToken(),
|
|
accessToken: 'expired-dev-access-token',
|
|
refreshToken: 'dev-refresh-token',
|
|
expiresAt: 1,
|
|
});
|
|
await writeFile(
|
|
join(homeDir, 'config.toml'),
|
|
`
|
|
[providers."managed:kimi-code"]
|
|
type = "kimi"
|
|
base_url = "${baseUrl}"
|
|
api_key = ""
|
|
oauth = { storage = "file", key = "${oauthKey}", oauth_host = "${oauthHost}" }
|
|
`,
|
|
);
|
|
const fetchMock = vi.fn<FetchMock>(async (input, init) => {
|
|
const url = fetchInputUrl(input);
|
|
if (url === `${oauthHost}/api/oauth/token`) {
|
|
if (typeof init?.body !== 'string') throw new TypeError('expected form body');
|
|
const body = new URLSearchParams(init.body);
|
|
expect(body.get('grant_type')).toBe('refresh_token');
|
|
expect(body.get('refresh_token')).toBe('dev-refresh-token');
|
|
return new Response(
|
|
JSON.stringify({
|
|
access_token: 'rotated-dev-access-token',
|
|
refresh_token: 'rotated-dev-refresh-token',
|
|
expires_in: 3600,
|
|
scope: '',
|
|
token_type: 'Bearer',
|
|
}),
|
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
);
|
|
}
|
|
if (url === `${baseUrl}/models`) {
|
|
expect(new Headers(init?.headers).get('authorization')).toBe(
|
|
'Bearer rotated-dev-access-token',
|
|
);
|
|
return new Response(
|
|
JSON.stringify({
|
|
data: [
|
|
{
|
|
id: 'kimi-for-coding',
|
|
context_length: 262144,
|
|
supports_reasoning: true,
|
|
},
|
|
],
|
|
}),
|
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
);
|
|
}
|
|
throw new Error(`unexpected request: ${url}`);
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY });
|
|
|
|
await expect(harness.auth.login()).resolves.toMatchObject({
|
|
providerName: KIMI_CODE_PROVIDER_NAME,
|
|
ok: true,
|
|
defaultModel: 'kimi-code/kimi-for-coding',
|
|
});
|
|
await expect(storage.load(storageName)).resolves.toMatchObject({
|
|
accessToken: 'rotated-dev-access-token',
|
|
});
|
|
const config = await harness.getConfig({ reload: true });
|
|
expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({
|
|
baseUrl,
|
|
oauth: { storage: 'file', key: oauthKey, oauthHost },
|
|
});
|
|
expect(fetchMock.mock.calls.map((call) => fetchInputUrl(call[0]))).toEqual([
|
|
`${oauthHost}/api/oauth/token`,
|
|
`${baseUrl}/models`,
|
|
]);
|
|
});
|
|
|
|
it('recomputes legacy managed OAuth refs during login for non-default base URLs', async () => {
|
|
const baseUrl = 'https://api.example.test/coding/v1';
|
|
const oauthKey = resolveKimiCodeOAuthKey({ baseUrl });
|
|
const scopedStorageName = resolveKimiTokenStorageName({ oauthKey });
|
|
const storage = new FileTokenStorage(join(homeDir, 'credentials'));
|
|
await storage.save('kimi-code', { ...freshToken(), accessToken: 'legacy-access-token' });
|
|
await storage.save(scopedStorageName, {
|
|
...freshToken(),
|
|
accessToken: 'scoped-access-token',
|
|
});
|
|
await writeFile(
|
|
join(homeDir, 'config.toml'),
|
|
`
|
|
[providers."managed:kimi-code"]
|
|
type = "kimi"
|
|
base_url = "${baseUrl}"
|
|
api_key = ""
|
|
oauth = { storage = "file", key = "oauth/kimi-code" }
|
|
`,
|
|
);
|
|
const fetchMock = vi.fn<FetchMock>(async (input, init) => {
|
|
expect(fetchInputUrl(input)).toBe(`${baseUrl}/models`);
|
|
expect(new Headers(init?.headers).get('authorization')).toBe('Bearer scoped-access-token');
|
|
return new Response(
|
|
JSON.stringify({
|
|
data: [
|
|
{
|
|
id: 'kimi-for-coding',
|
|
context_length: 262144,
|
|
supports_reasoning: true,
|
|
},
|
|
],
|
|
}),
|
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
);
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY });
|
|
|
|
await expect(harness.auth.login()).resolves.toMatchObject({
|
|
providerName: KIMI_CODE_PROVIDER_NAME,
|
|
ok: true,
|
|
defaultModel: 'kimi-code/kimi-for-coding',
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const config = await harness.getConfig({ reload: true });
|
|
expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({
|
|
baseUrl,
|
|
oauth: { storage: 'file', key: oauthKey, oauthHost: 'https://auth.kimi.com' },
|
|
});
|
|
});
|
|
|
|
it('logs in against environment OAuth host and base URL over persisted config', async () => {
|
|
const configuredBaseUrl = 'https://api.configured.example.test/coding/v1';
|
|
const envBaseUrl = 'https://api.env.example.test/coding/v1';
|
|
const envOauthHost = 'https://auth.env.example.test';
|
|
const configuredOauthKey = resolveKimiCodeOAuthKey({ baseUrl: configuredBaseUrl });
|
|
const envOauthKey = resolveKimiCodeOAuthKey({ oauthHost: envOauthHost, baseUrl: envBaseUrl });
|
|
const storage = new FileTokenStorage(join(homeDir, 'credentials'));
|
|
await storage.save(resolveKimiTokenStorageName({ oauthKey: configuredOauthKey }), {
|
|
...freshToken(),
|
|
accessToken: 'configured-access-token',
|
|
});
|
|
await storage.save(resolveKimiTokenStorageName({ oauthKey: envOauthKey }), {
|
|
...freshToken(),
|
|
accessToken: 'env-access-token',
|
|
});
|
|
await writeFile(
|
|
join(homeDir, 'config.toml'),
|
|
`
|
|
[providers."managed:kimi-code"]
|
|
type = "kimi"
|
|
base_url = "${configuredBaseUrl}"
|
|
api_key = ""
|
|
oauth = { storage = "file", key = "${configuredOauthKey}", oauth_host = "https://auth.kimi.com" }
|
|
`,
|
|
);
|
|
vi.stubEnv('KIMI_CODE_BASE_URL', envBaseUrl);
|
|
vi.stubEnv('KIMI_CODE_OAUTH_HOST', envOauthHost);
|
|
const fetchMock = vi.fn<FetchMock>(async (input, init) => {
|
|
expect(fetchInputUrl(input)).toBe(`${envBaseUrl}/models`);
|
|
expect(new Headers(init?.headers).get('authorization')).toBe('Bearer env-access-token');
|
|
return new Response(
|
|
JSON.stringify({
|
|
data: [
|
|
{
|
|
id: 'kimi-for-coding',
|
|
context_length: 262144,
|
|
supports_reasoning: true,
|
|
},
|
|
],
|
|
}),
|
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
);
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY });
|
|
|
|
await expect(harness.auth.login()).resolves.toMatchObject({
|
|
providerName: KIMI_CODE_PROVIDER_NAME,
|
|
ok: true,
|
|
defaultModel: 'kimi-code/kimi-for-coding',
|
|
});
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const config = await harness.getConfig({ reload: true });
|
|
expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({
|
|
baseUrl: envBaseUrl,
|
|
oauth: { storage: 'file', key: envOauthKey, oauthHost: envOauthHost },
|
|
});
|
|
});
|
|
|
|
it('starts degraded when a configured model alias does not have max_context_size', async () => {
|
|
await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken());
|
|
await writeFile(
|
|
join(homeDir, 'config.toml'),
|
|
`
|
|
default_model = "kimi-code/kimi-for-coding"
|
|
|
|
[providers."managed:kimi-code"]
|
|
type = "kimi"
|
|
api_key = ""
|
|
|
|
[models."kimi-code/kimi-for-coding"]
|
|
provider = "managed:kimi-code"
|
|
model = "kimi-for-coding"
|
|
`,
|
|
);
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(
|
|
async () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
data: [
|
|
{
|
|
id: 'kimi-for-coding',
|
|
context_length: 262144,
|
|
supports_reasoning: true,
|
|
supports_image_in: true,
|
|
supports_video_in: true,
|
|
},
|
|
],
|
|
}),
|
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
),
|
|
),
|
|
);
|
|
|
|
// A broken config must not prevent startup: the invalid model alias is
|
|
// dropped, the rest of the config survives, and a warning is reported.
|
|
const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY });
|
|
const config = await harness.getConfig();
|
|
expect(config.models?.['kimi-code/kimi-for-coding']).toBeUndefined();
|
|
expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toBeDefined();
|
|
const { warnings } = await harness.getConfigDiagnostics();
|
|
expect(warnings.some((w) => w.includes('models.kimi-code/kimi-for-coding'))).toBe(true);
|
|
});
|
|
|
|
it('removes managed Kimi config on logout', async () => {
|
|
await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken());
|
|
await writeFile(
|
|
join(homeDir, 'config.toml'),
|
|
`
|
|
default_model = "kimi-code/kimi-for-coding"
|
|
|
|
[providers."managed:kimi-code"]
|
|
type = "kimi"
|
|
api_key = ""
|
|
oauth = { storage = "file", key = "oauth/kimi-code" }
|
|
|
|
[providers.custom]
|
|
type = "kimi"
|
|
api_key = "sk-existing"
|
|
|
|
[models."kimi-code/kimi-for-coding"]
|
|
provider = "managed:kimi-code"
|
|
model = "kimi-for-coding"
|
|
max_context_size = 262144
|
|
|
|
[models.custom-default]
|
|
provider = "custom"
|
|
model = "custom-model"
|
|
max_context_size = 1000
|
|
|
|
[services.moonshot_search]
|
|
base_url = "https://api.kimi.com/coding/v1/search"
|
|
api_key = ""
|
|
oauth = { storage = "file", key = "oauth/kimi-code" }
|
|
|
|
[services.moonshot_fetch]
|
|
base_url = "https://api.kimi.com/coding/v1/fetch"
|
|
api_key = ""
|
|
oauth = { storage = "file", key = "oauth/kimi-code" }
|
|
`,
|
|
);
|
|
|
|
const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY });
|
|
|
|
await expect(harness.auth.logout()).resolves.toMatchObject({
|
|
providerName: KIMI_CODE_PROVIDER_NAME,
|
|
ok: true,
|
|
});
|
|
|
|
const config = await harness.getConfig({ reload: true });
|
|
expect(config.defaultModel).toBeUndefined();
|
|
expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toBeUndefined();
|
|
expect(config.providers['custom']).toMatchObject({ apiKey: 'sk-existing' });
|
|
expect(config.models?.['kimi-code/kimi-for-coding']).toBeUndefined();
|
|
expect(config.models?.['custom-default']).toMatchObject({ provider: 'custom' });
|
|
expect(config.services?.moonshotSearch).toBeUndefined();
|
|
expect(config.services?.moonshotFetch).toBeUndefined();
|
|
await expect(
|
|
new FileTokenStorage(join(homeDir, 'credentials')).load('kimi-code'),
|
|
).resolves.toBeUndefined();
|
|
|
|
const text = await readFile(join(homeDir, 'config.toml'), 'utf-8');
|
|
expect(text).not.toContain('managed:kimi-code');
|
|
expect(text).not.toContain('kimi-code/kimi-for-coding');
|
|
expect(text).not.toContain('moonshot_search');
|
|
});
|
|
|
|
it('removes the configured scoped OAuth token on logout without touching the production token', async () => {
|
|
const oauthKey = resolveKimiCodeOAuthKey({
|
|
oauthHost: 'https://auth.dev.example.test',
|
|
baseUrl: 'https://api.dev.example.test/coding/v1',
|
|
});
|
|
const storageName = resolveKimiTokenStorageName({ oauthKey });
|
|
const storage = new FileTokenStorage(join(homeDir, 'credentials'));
|
|
await storage.save('kimi-code', freshToken());
|
|
await storage.save(storageName, { ...freshToken(), accessToken: 'dev-access-token' });
|
|
await writeFile(
|
|
join(homeDir, 'config.toml'),
|
|
`
|
|
default_model = "kimi-code/kimi-for-coding"
|
|
|
|
[providers."managed:kimi-code"]
|
|
type = "kimi"
|
|
base_url = "https://api.dev.example.test/coding/v1"
|
|
api_key = ""
|
|
oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev.example.test" }
|
|
|
|
[models."kimi-code/kimi-for-coding"]
|
|
provider = "managed:kimi-code"
|
|
model = "kimi-for-coding"
|
|
max_context_size = 262144
|
|
`,
|
|
);
|
|
const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY });
|
|
|
|
await expect(harness.auth.logout()).resolves.toMatchObject({
|
|
providerName: KIMI_CODE_PROVIDER_NAME,
|
|
ok: true,
|
|
});
|
|
|
|
await expect(storage.load(storageName)).resolves.toBeUndefined();
|
|
await expect(storage.load('kimi-code')).resolves.toMatchObject({
|
|
accessToken: 'oauth-access-token',
|
|
});
|
|
});
|
|
|
|
it('recomputes legacy managed OAuth refs during logout for non-default base URLs', async () => {
|
|
const baseUrl = 'https://api.example.test/coding/v1';
|
|
const oauthKey = resolveKimiCodeOAuthKey({ baseUrl });
|
|
const scopedStorageName = resolveKimiTokenStorageName({ oauthKey });
|
|
const storage = new FileTokenStorage(join(homeDir, 'credentials'));
|
|
await storage.save('kimi-code', freshToken());
|
|
await storage.save(scopedStorageName, {
|
|
...freshToken(),
|
|
accessToken: 'scoped-access-token',
|
|
});
|
|
await writeFile(
|
|
join(homeDir, 'config.toml'),
|
|
`
|
|
default_model = "kimi-code/kimi-for-coding"
|
|
|
|
[providers."managed:kimi-code"]
|
|
type = "kimi"
|
|
base_url = "${baseUrl}"
|
|
api_key = ""
|
|
oauth = { storage = "file", key = "oauth/kimi-code" }
|
|
|
|
[models."kimi-code/kimi-for-coding"]
|
|
provider = "managed:kimi-code"
|
|
model = "kimi-for-coding"
|
|
max_context_size = 262144
|
|
`,
|
|
);
|
|
const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY });
|
|
|
|
await expect(harness.auth.logout()).resolves.toMatchObject({
|
|
providerName: KIMI_CODE_PROVIDER_NAME,
|
|
ok: true,
|
|
});
|
|
|
|
await expect(storage.load(scopedStorageName)).resolves.toBeUndefined();
|
|
await expect(storage.load('kimi-code')).resolves.toMatchObject({
|
|
accessToken: 'oauth-access-token',
|
|
});
|
|
});
|
|
|
|
it('gets managed usage without host identity and sends only auth headers', async () => {
|
|
await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken());
|
|
const fetchMock = vi.fn<FetchMock>(
|
|
async (_input, _init) =>
|
|
new Response(
|
|
JSON.stringify({
|
|
usage: { used: 1, limit: 10, name: 'Weekly limit' },
|
|
}),
|
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const harness = createKimiHarness({ homeDir });
|
|
const result = await harness.auth.getManagedUsage();
|
|
|
|
expect(result).toMatchObject({
|
|
kind: 'ok',
|
|
summary: { name: 'Weekly limit', used: 1, limit: 10 },
|
|
});
|
|
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
|
|
const headers = new Headers((init.headers ?? {}) as Record<string, string>);
|
|
expect(headers.get('authorization')).toBe('Bearer oauth-access-token');
|
|
expect(headers.get('accept')).toBe('application/json');
|
|
expect(headers.get('user-agent')).toBeNull();
|
|
expect(headers.get('x-msh-platform')).toBeNull();
|
|
});
|
|
|
|
it('uses configured scoped OAuth refs and base URLs for managed usage and feedback', async () => {
|
|
const baseUrl = 'https://api.dev.example.test/coding/v1';
|
|
const oauthKey = resolveKimiCodeOAuthKey({
|
|
oauthHost: 'https://auth.dev.example.test',
|
|
baseUrl,
|
|
});
|
|
const storageName = resolveKimiTokenStorageName({ oauthKey });
|
|
await new FileTokenStorage(join(homeDir, 'credentials')).save(storageName, {
|
|
...freshToken(),
|
|
accessToken: 'dev-access-token',
|
|
});
|
|
await writeFile(
|
|
join(homeDir, 'config.toml'),
|
|
`
|
|
[providers."managed:kimi-code"]
|
|
type = "kimi"
|
|
base_url = "${baseUrl}"
|
|
api_key = ""
|
|
oauth = { storage = "file", key = "${oauthKey}", oauth_host = "https://auth.dev.example.test" }
|
|
`,
|
|
);
|
|
const fetchMock = vi.fn<FetchMock>(async (input) => {
|
|
const url = fetchInputUrl(input);
|
|
if (url.endsWith('/usages')) {
|
|
return new Response(
|
|
JSON.stringify({ usage: { used: 2, limit: 10, name: 'Dev limit' } }),
|
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
);
|
|
}
|
|
return new Response(JSON.stringify({ feedback_id: 3 }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const harness = createKimiHarness({ homeDir });
|
|
|
|
await expect(harness.auth.getManagedUsage()).resolves.toMatchObject({
|
|
kind: 'ok',
|
|
summary: { name: 'Dev limit', used: 2, limit: 10 },
|
|
});
|
|
await expect(
|
|
harness.auth.submitFeedback({
|
|
content: 'dev feedback',
|
|
sessionId: 'sess-dev',
|
|
version: 'kimi-code-0.1.1',
|
|
os: 'Darwin 25.3.0',
|
|
model: 'kimi-code/kimi-for-coding',
|
|
}),
|
|
).resolves.toEqual({ kind: 'ok', feedbackId: 3 });
|
|
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe(`${baseUrl}/usages`);
|
|
expect(fetchMock.mock.calls[1]?.[0]).toBe(`${baseUrl}/feedback`);
|
|
for (const call of fetchMock.mock.calls) {
|
|
const init = call[1];
|
|
expect(new Headers(init?.headers).get('authorization')).toBe('Bearer dev-access-token');
|
|
}
|
|
});
|
|
|
|
it('uses environment managed endpoints for usage and feedback over persisted config', async () => {
|
|
const configuredBaseUrl = 'https://api.configured.example.test/coding/v1';
|
|
const envBaseUrl = 'https://api.env.example.test/coding/v1';
|
|
const envOauthHost = 'https://auth.env.example.test';
|
|
const configuredOauthKey = resolveKimiCodeOAuthKey({ baseUrl: configuredBaseUrl });
|
|
const envOauthKey = resolveKimiCodeOAuthKey({
|
|
oauthHost: envOauthHost,
|
|
baseUrl: envBaseUrl,
|
|
});
|
|
const storage = new FileTokenStorage(join(homeDir, 'credentials'));
|
|
await storage.save(resolveKimiTokenStorageName({ oauthKey: configuredOauthKey }), {
|
|
...freshToken(),
|
|
accessToken: 'configured-access-token',
|
|
});
|
|
await storage.save(resolveKimiTokenStorageName({ oauthKey: envOauthKey }), {
|
|
...freshToken(),
|
|
accessToken: 'env-access-token',
|
|
});
|
|
await writeFile(
|
|
join(homeDir, 'config.toml'),
|
|
`
|
|
[providers."managed:kimi-code"]
|
|
type = "kimi"
|
|
base_url = "${configuredBaseUrl}"
|
|
api_key = ""
|
|
oauth = { storage = "file", key = "${configuredOauthKey}", oauth_host = "https://auth.kimi.com" }
|
|
`,
|
|
);
|
|
vi.stubEnv('KIMI_CODE_BASE_URL', envBaseUrl);
|
|
vi.stubEnv('KIMI_CODE_OAUTH_HOST', envOauthHost);
|
|
const fetchMock = vi.fn<FetchMock>(async (input) => {
|
|
const url = fetchInputUrl(input);
|
|
if (url.endsWith('/usages')) {
|
|
return new Response(
|
|
JSON.stringify({ usage: { used: 3, limit: 10, name: 'Env limit' } }),
|
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
);
|
|
}
|
|
return new Response(JSON.stringify({ feedback_id: 3 }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const harness = createKimiHarness({ homeDir });
|
|
|
|
await expect(harness.auth.status()).resolves.toEqual({
|
|
providers: [{ providerName: KIMI_CODE_PROVIDER_NAME, hasToken: true }],
|
|
});
|
|
await expect(harness.auth.getCachedAccessToken()).resolves.toBe('env-access-token');
|
|
await expect(
|
|
harness.auth.resolveOAuthTokenProvider(KIMI_CODE_PROVIDER_NAME).getAccessToken(),
|
|
).resolves.toBe('env-access-token');
|
|
await expect(
|
|
harness.auth
|
|
.resolveOAuthTokenProvider(KIMI_CODE_PROVIDER_NAME, {
|
|
storage: 'file',
|
|
key: configuredOauthKey,
|
|
oauthHost: 'https://auth.kimi.com',
|
|
})
|
|
.getAccessToken(),
|
|
).resolves.toBe('env-access-token');
|
|
await expect(harness.auth.getManagedUsage()).resolves.toMatchObject({
|
|
kind: 'ok',
|
|
summary: { name: 'Env limit', used: 3, limit: 10 },
|
|
});
|
|
await expect(
|
|
harness.auth.submitFeedback({
|
|
content: 'env feedback',
|
|
sessionId: 'sess-env',
|
|
version: 'kimi-code-0.1.1',
|
|
os: 'Darwin 25.3.0',
|
|
model: 'kimi-code/kimi-for-coding',
|
|
}),
|
|
).resolves.toEqual({ kind: 'ok', feedbackId: 3 });
|
|
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe(`${envBaseUrl}/usages`);
|
|
expect(fetchMock.mock.calls[1]?.[0]).toBe(`${envBaseUrl}/feedback`);
|
|
for (const call of fetchMock.mock.calls) {
|
|
expect(new Headers(call[1]?.headers).get('authorization')).toBe('Bearer env-access-token');
|
|
}
|
|
});
|
|
|
|
it('submitFeedback maps camelCase input to snake_case body and posts with bearer auth', async () => {
|
|
await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken());
|
|
const fetchMock = vi.fn<FetchMock>(async () =>
|
|
new Response(JSON.stringify({ feedback_id: 3 }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const harness = createKimiHarness({ homeDir });
|
|
const result = await harness.auth.submitFeedback({
|
|
content: 'great tool',
|
|
sessionId: 'sess-42',
|
|
version: 'kimi-code-0.1.1',
|
|
os: 'Darwin 25.3.0',
|
|
model: 'kimi-code/kimi-for-coding',
|
|
contact: 'test@example.com',
|
|
info: { codebase: { file_name: 'repo.zip' } },
|
|
});
|
|
|
|
expect(result).toEqual({ kind: 'ok', feedbackId: 3 });
|
|
|
|
const calls = fetchMock.mock.calls as unknown as [string, RequestInit?][];
|
|
const [url, init] = calls[0]!;
|
|
expect(url).toBe('https://api.kimi.com/coding/v1/feedback');
|
|
expect(init?.method).toBe('POST');
|
|
|
|
const headers = new Headers((init?.headers ?? {}) as Record<string, string>);
|
|
expect(headers.get('authorization')).toBe('Bearer oauth-access-token');
|
|
expect(headers.get('content-type')).toBe('application/json');
|
|
|
|
expect(JSON.parse(init?.body as string)).toEqual({
|
|
session_id: 'sess-42',
|
|
content: 'great tool',
|
|
version: 'kimi-code-0.1.1',
|
|
os: 'Darwin 25.3.0',
|
|
model: 'kimi-code/kimi-for-coding',
|
|
contact: 'test@example.com',
|
|
info: { codebase: { file_name: 'repo.zip' } },
|
|
});
|
|
});
|
|
|
|
it('createFeedbackUploadUrl maps SDK input and returns camelCase upload parts', async () => {
|
|
await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken());
|
|
const fetchMock = vi.fn<FetchMock>(async () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
upload: {
|
|
id: 28,
|
|
parts: [
|
|
{
|
|
part_number: 1,
|
|
url: 'https://upload.example.test/part-1',
|
|
method: 'PUT',
|
|
size: 1024,
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const harness = createKimiHarness({ homeDir });
|
|
const result = await harness.auth.createFeedbackUploadUrl({
|
|
feedbackId: 3,
|
|
filename: 'session.zip',
|
|
size: 1024,
|
|
sha256: 'abc123',
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
kind: 'ok',
|
|
uploadId: 28,
|
|
parts: [
|
|
{
|
|
partNumber: 1,
|
|
url: 'https://upload.example.test/part-1',
|
|
method: 'PUT',
|
|
size: 1024,
|
|
},
|
|
],
|
|
});
|
|
|
|
const calls = fetchMock.mock.calls as unknown as [string, RequestInit?][];
|
|
const [url, init] = calls[0]!;
|
|
expect(url).toBe('https://api.kimi.com/coding/v1/feedback/upload_url');
|
|
expect(init?.method).toBe('POST');
|
|
expect(JSON.parse(init?.body as string)).toEqual({
|
|
feedback_id: 3,
|
|
file_name: 'session.zip',
|
|
file_size: 1024,
|
|
file_hash: 'abc123',
|
|
});
|
|
});
|
|
|
|
it('submitFeedback surfaces HTTP errors without throwing', async () => {
|
|
await new FileTokenStorage(join(homeDir, 'credentials')).save('kimi-code', freshToken());
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn<FetchMock>(
|
|
async () =>
|
|
new Response(JSON.stringify({ message: 'feedback API rejected the request' }), {
|
|
status: 401,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}),
|
|
),
|
|
);
|
|
|
|
const harness = createKimiHarness({ homeDir });
|
|
const result = await harness.auth.submitFeedback({
|
|
content: 'x',
|
|
sessionId: 's',
|
|
version: 'kimi-code-0.0.0',
|
|
os: 'Darwin 25.3.0',
|
|
model: null,
|
|
});
|
|
|
|
expect(result.kind).toBe('error');
|
|
if (result.kind !== 'error') return;
|
|
expect(result.status).toBe(401);
|
|
expect(result.message).toBe('feedback API rejected the request');
|
|
});
|
|
});
|