mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-22 07:05:41 +00:00
* feat: support multi-level thinking effort switching
- kimi provider: emit thinking.effort in the new wire format; keep reasoning_effort mirrored during the transition
- model catalog: thread support_efforts / default_effort from oauth through to /models
- config schema: add supportEfforts / defaultEffort on model aliases
- TUI: multi-segment thinking control in /model, new /effort command, footer effort display
- switch status uses displayName and distinguishes model vs effort-only changes
* docs: add thinking effort design plans
- thinking-effort-switching.md: implemented multi-level effort switching
- thinking-model-overhaul.md: follow-up refactor plan for the thinking state model
* docs: collapse thinking overhaul plan into a single PR
* refactor!: overhaul thinking config and effort resolution
Replace default_thinking and thinking.mode with a single [thinking] enabled/effort table. ThinkingEffort is now an open string ('off' | 'on' | model-declared effort); effort levels come from each model's support_efforts instead of a fixed enum.
Centralize default and always_thinking clamp logic in resolveThinkingEffort/defaultThinkingEffortFor, and honor an explicitly configured effort when an always_thinking model is forced back on.
TUI keeps a single thinkingEffort field instead of the boolean + level pair; 'on' is normalized to the model default at the UI boundary.
BREAKING CHANGE: default_thinking and thinking.mode are removed from config; migrate to [thinking] enabled/effort.
* refactor: rename residual thinking level wording to effort
Rename comments, error messages, parameter names, the SetThinkingPayload wire field (level -> effort), and TUI local variables so the thinking effort naming is consistent throughout. No behavior change.
* refactor: rename remaining camelCase thinking level identifiers to effort
Rename liveLevel/prevLevel/levelChanged/commitLevel/effectiveLevel to liveEffort/prevEffort/effortChanged/commitEffort/effectiveEffort in the TUI model picker and config commands.
* refactor: eliminate remaining thinking level wording in comments and tests
Rename levelLabel -> effortLabel, EffortSelectorOptions.levels -> efforts, and 'effort level(s)' / 'default level' / 'requested level' wording in comments, error messages, slash-command description, and test titles to effort. Also restore the withThinking(effort) parameter rename in the Kimi provider that was accidentally reverted.
* fix: address codex review feedback on thinking effort handling
- OpenAI thinkingEffortToReasoningEffort and Anthropic clampEffort now normalize 'on' / unrecognized efforts instead of throwing, so boolean non-Kimi models no longer crash on session start.
- ACP resolveCurrentThinkingEnabled treats a non-empty thinking.effort as enabled, matching agent-core's resolveThinkingEffort.
- REST promptThinkingSchema accepts any non-empty effort string so model-declared efforts are not rejected at the API boundary.
* test: align kimi e2e expectations with supportEfforts-gated reasoning_effort
The kimi provider now sends reasoning_effort only when the model declares support_efforts; boolean models (no support_efforts) send only thinking.type. Update the kimi e2e tests to drop the stale reasoning_effort expectation for the boolean test model.
* test: cover [thinking] effort parsing in config.test
Add effort = "high" to the documented [thinking] table in the config parse test and assert config.thinking.effort is resolved, so the new [thinking] effort field has direct parse coverage.
* docs: add thinking test coverage gap analysis
Capture the explore agent's test coverage review for the thinking overhaul PR, including P1/P2 gaps and the two open design questions, for follow-up test additions.
* feat(oauth): parse nested think_efforts from /models response
The /models endpoint now returns effort levels under a nested think_efforts object ({ support, valid_efforts, default_effort }). Parse it preferentially in both managed-kimi-code and open-platform model parsing, falling back to the legacy flat support_efforts / default_effort fields for older servers.
* refactor(oauth): only read nested think_efforts; gate on support=true
Drop the legacy flat support_efforts / default_effort fallback. The think_efforts object is now the single source, and its support flag gates the whole object — when support is not true, valid_efforts and default_effort are ignored entirely.
* chore: remove unused parseStringArray import in open-platform
* docs: finalize thinking effort release notes
Downgrade the changeset to minor with an English summary, drop the version-specific 'added in 1.0.0' info block, and present the deprecated config fields as a table (field / deprecated in 0.21.0 / description).
* refactor: drop temporary refresh toggles and kimi reasoning_effort mirror
Remove the always-true REFRESH_MODELS_ON_PICKER_OPEN / REFRESH_PROVIDER_MODELS_ON_STARTUP toggles and their stale re-enable TODOs, and stop sending reasoning_effort from the kimi provider (thinking.effort is the only wire field now).
* fix(tui): avoid persisting "on" as thinking effort
* fix: preserve persisted thinking effort across login and provider setup
* fix(tui): show actual thinking effort in /status and footer
* test(tui): align message-flow expectations with effort persistence and /status display
* fix(vis): rename thinkingLevel to thinkingEffort in config.update analysis
1390 lines
43 KiB
TypeScript
1390 lines
43 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
applyManagedKimiCodeLogoutConfig,
|
|
applyManagedKimiCodeConfig,
|
|
clearManagedKimiCodeConfig,
|
|
fetchManagedKimiCodeModels,
|
|
KIMI_CODE_OAUTH_KEY,
|
|
KIMI_CODE_PROVIDER_NAME,
|
|
ManagedKimiCodeModelsAuthError,
|
|
provisionManagedKimiCodeConfig,
|
|
resolveKimiCodeLoginAuth,
|
|
resolveKimiCodeOAuthKey,
|
|
resolveKimiCodeOAuthRef,
|
|
resolveKimiCodeRuntimeAuth,
|
|
type ManagedKimiCodeModelInfo,
|
|
type ManagedKimiConfigShape,
|
|
} from '../src/managed-kimi-code';
|
|
import { OAuthUnauthorizedError } from '../src/errors';
|
|
|
|
function makeModelsResponse(): Response {
|
|
return 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',
|
|
},
|
|
{
|
|
id: 'kimi-k2.5',
|
|
context_length: 250000,
|
|
supports_reasoning: false,
|
|
supports_image_in: false,
|
|
supports_video_in: false,
|
|
supports_tool_use: false,
|
|
},
|
|
],
|
|
}),
|
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
);
|
|
}
|
|
|
|
describe('provisionManagedKimiCodeConfig', () => {
|
|
it('keeps the legacy credential key for the default production environment', () => {
|
|
expect(
|
|
resolveKimiCodeOAuthKey({
|
|
oauthHost: 'https://auth.kimi.com/',
|
|
baseUrl: 'https://api.kimi.com/coding/v1/',
|
|
}),
|
|
).toBe(KIMI_CODE_OAUTH_KEY);
|
|
});
|
|
|
|
it('scopes credential keys for non-default OAuth hosts and API base URLs', () => {
|
|
const devKey = resolveKimiCodeOAuthKey({
|
|
oauthHost: 'https://auth.dev.example.test',
|
|
baseUrl: 'https://api.dev.example.test/coding/v1',
|
|
});
|
|
|
|
expect(devKey).not.toBe(KIMI_CODE_OAUTH_KEY);
|
|
expect(devKey).toMatch(/^oauth\/kimi-code-env-[a-f0-9]{16}$/);
|
|
expect(
|
|
resolveKimiCodeOAuthKey({
|
|
oauthHost: 'https://auth.dev.example.test/',
|
|
baseUrl: 'https://api.dev.example.test/coding/v1/',
|
|
}),
|
|
).toBe(devKey);
|
|
});
|
|
|
|
it('derives a full OAuth ref whose key and persisted host stay in sync', () => {
|
|
// Default environment collapses to the legacy ref (no persisted host), so
|
|
// existing production credentials keep resolving to `kimi-code.json`.
|
|
expect(
|
|
resolveKimiCodeOAuthRef({
|
|
oauthHost: 'https://auth.kimi.com/',
|
|
baseUrl: 'https://api.kimi.com/coding/v1/',
|
|
}),
|
|
).toEqual({ storage: 'file', key: KIMI_CODE_OAUTH_KEY, oauthHost: undefined });
|
|
|
|
const defaultAuthCustomApiRef = resolveKimiCodeOAuthRef({
|
|
baseUrl: 'https://api.example.test/coding/v1',
|
|
});
|
|
expect(defaultAuthCustomApiRef).toEqual({
|
|
storage: 'file',
|
|
key: resolveKimiCodeOAuthKey({
|
|
oauthHost: 'https://auth.kimi.com',
|
|
baseUrl: 'https://api.example.test/coding/v1',
|
|
}),
|
|
oauthHost: 'https://auth.kimi.com',
|
|
});
|
|
|
|
// A non-default environment yields a scoped key AND the normalized host,
|
|
// both derived from the same input — login and runtime cannot drift apart.
|
|
const devRef = resolveKimiCodeOAuthRef({
|
|
oauthHost: 'https://auth.dev.example.test/',
|
|
baseUrl: 'https://api.dev.example.test/coding/v1',
|
|
});
|
|
expect(devRef).toEqual({
|
|
storage: 'file',
|
|
key: resolveKimiCodeOAuthKey({
|
|
oauthHost: 'https://auth.dev.example.test',
|
|
baseUrl: 'https://api.dev.example.test/coding/v1',
|
|
}),
|
|
oauthHost: 'https://auth.dev.example.test',
|
|
});
|
|
});
|
|
|
|
it('resolves runtime auth from environment overrides over persisted config', () => {
|
|
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 configuredOAuthRef = resolveKimiCodeOAuthRef({
|
|
baseUrl: configuredBaseUrl,
|
|
});
|
|
|
|
const auth = resolveKimiCodeRuntimeAuth({
|
|
configuredBaseUrl,
|
|
configuredOAuthRef,
|
|
env: {
|
|
KIMI_CODE_BASE_URL: envBaseUrl,
|
|
KIMI_CODE_OAUTH_HOST: envOauthHost,
|
|
},
|
|
});
|
|
|
|
expect(auth.baseUrl).toBe('https://api.env.example.test/coding/v1');
|
|
expect(auth.oauthRef).toEqual({
|
|
storage: 'file',
|
|
key: resolveKimiCodeOAuthKey({
|
|
oauthHost: 'https://auth.env.example.test',
|
|
baseUrl: 'https://api.env.example.test/coding/v1',
|
|
}),
|
|
oauthHost: 'https://auth.env.example.test',
|
|
});
|
|
});
|
|
|
|
it('preserves a matching configured runtime OAuth ref when env is not overridden', () => {
|
|
const baseUrl = 'https://api.dev.example.test/coding/v1';
|
|
const configuredOAuthRef = {
|
|
storage: 'keyring' as const,
|
|
key: resolveKimiCodeOAuthKey({
|
|
oauthHost: 'https://auth.dev.example.test',
|
|
baseUrl,
|
|
}),
|
|
oauthHost: 'https://auth.dev.example.test',
|
|
};
|
|
|
|
expect(
|
|
resolveKimiCodeRuntimeAuth({
|
|
configuredBaseUrl: baseUrl,
|
|
configuredOAuthRef,
|
|
env: {},
|
|
}),
|
|
).toEqual({
|
|
baseUrl,
|
|
oauthRef: configuredOAuthRef,
|
|
});
|
|
});
|
|
|
|
it('resolves login auth without reusing persisted refs under explicit or env overrides', () => {
|
|
const configuredBaseUrl = 'https://api.configured.example.test/coding/v1';
|
|
const configuredOAuthRef = resolveKimiCodeOAuthRef({ baseUrl: configuredBaseUrl });
|
|
|
|
expect(
|
|
resolveKimiCodeLoginAuth({
|
|
configuredBaseUrl,
|
|
configuredOAuthRef,
|
|
requestedBaseUrl: 'https://api.requested.example.test/coding/v1/',
|
|
env: {},
|
|
}),
|
|
).toEqual({
|
|
baseUrl: 'https://api.requested.example.test/coding/v1',
|
|
oauthHost: undefined,
|
|
});
|
|
|
|
expect(
|
|
resolveKimiCodeLoginAuth({
|
|
configuredBaseUrl,
|
|
configuredOAuthRef,
|
|
env: {},
|
|
}),
|
|
).toEqual({
|
|
baseUrl: configuredBaseUrl,
|
|
oauthHost: undefined,
|
|
oauthRef: configuredOAuthRef,
|
|
});
|
|
});
|
|
|
|
it('writes the managed provider, models, services, and default model through an adapter', async () => {
|
|
const config: ManagedKimiConfigShape = {
|
|
providers: {
|
|
custom: {
|
|
type: 'kimi',
|
|
apiKey: 'sk-existing',
|
|
baseUrl: 'https://example.test/v1',
|
|
},
|
|
},
|
|
models: {
|
|
'kimi-code/stale': {
|
|
provider: KIMI_CODE_PROVIDER_NAME,
|
|
model: 'stale',
|
|
},
|
|
'custom-default': {
|
|
provider: 'custom',
|
|
model: 'custom-model',
|
|
},
|
|
},
|
|
};
|
|
const write = vi.fn();
|
|
const fetchMock = vi.fn(async () => makeModelsResponse());
|
|
|
|
const result = await provisionManagedKimiCodeConfig({
|
|
accessToken: 'oauth-access-token',
|
|
fetchImpl: fetchMock as unknown as typeof fetch,
|
|
adapter: {
|
|
configPath: '/tmp/config.toml',
|
|
read: () => config,
|
|
write,
|
|
apply: applyManagedKimiCodeConfig,
|
|
},
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
providerName: KIMI_CODE_PROVIDER_NAME,
|
|
defaultModel: 'kimi-code/kimi-for-coding',
|
|
defaultThinking: true,
|
|
configPath: '/tmp/config.toml',
|
|
});
|
|
expect(result.models[0]?.supportsToolUse).toBe(true);
|
|
expect(result.models[1]?.supportsToolUse).toBe(false);
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://api.kimi.com/coding/v1/models',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
Authorization: 'Bearer oauth-access-token',
|
|
Accept: 'application/json',
|
|
}),
|
|
}),
|
|
);
|
|
const calls = fetchMock.mock.calls as unknown as [string, RequestInit?][];
|
|
const init = calls[0]?.[1] ?? {};
|
|
const headers = new Headers((init.headers ?? {}) as Record<string, string>);
|
|
expect(headers.get('user-agent')).toBeNull();
|
|
expect(headers.get('x-msh-platform')).toBeNull();
|
|
expect(write).toHaveBeenCalledWith(config);
|
|
|
|
expect(config.providers['custom']).toMatchObject({
|
|
apiKey: 'sk-existing',
|
|
});
|
|
expect(config.models?.['custom-default']?.provider).toBe('custom');
|
|
expect(config.models?.['kimi-code/stale']).toBeUndefined();
|
|
expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({
|
|
type: 'kimi',
|
|
baseUrl: 'https://api.kimi.com/coding/v1',
|
|
apiKey: '',
|
|
oauth: { storage: 'file', key: 'oauth/kimi-code' },
|
|
});
|
|
expect(config.models?.['kimi-code/kimi-for-coding']).toMatchObject({
|
|
provider: KIMI_CODE_PROVIDER_NAME,
|
|
model: 'kimi-for-coding',
|
|
maxContextSize: 262144,
|
|
capabilities: ['thinking', 'image_in', 'video_in', 'tool_use'],
|
|
displayName: 'Kimi for Coding',
|
|
});
|
|
expect(config.models?.['kimi-code/kimi-k2.5']?.capabilities).toBeUndefined();
|
|
expect(config.services?.moonshotSearch).toMatchObject({
|
|
baseUrl: 'https://api.kimi.com/coding/v1/search',
|
|
apiKey: '',
|
|
oauth: { storage: 'file', key: 'oauth/kimi-code' },
|
|
});
|
|
expect(Object.keys(config.services ?? {})).toEqual(['moonshotSearch', 'moonshotFetch']);
|
|
});
|
|
|
|
it('writes scoped OAuth refs when provisioning against a non-default environment', async () => {
|
|
const config: ManagedKimiConfigShape = {
|
|
providers: {},
|
|
};
|
|
const oauthKey = resolveKimiCodeOAuthKey({
|
|
oauthHost: 'https://auth.dev.example.test',
|
|
baseUrl: 'https://api.dev.example.test/coding/v1',
|
|
});
|
|
|
|
await provisionManagedKimiCodeConfig({
|
|
accessToken: 'oauth-access-token',
|
|
baseUrl: 'https://api.dev.example.test/coding/v1',
|
|
oauthKey,
|
|
oauthHost: 'https://auth.dev.example.test',
|
|
fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch,
|
|
adapter: {
|
|
read: () => config,
|
|
write: vi.fn(),
|
|
apply: applyManagedKimiCodeConfig,
|
|
},
|
|
});
|
|
|
|
expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({
|
|
baseUrl: 'https://api.dev.example.test/coding/v1',
|
|
oauth: {
|
|
storage: 'file',
|
|
key: oauthKey,
|
|
oauthHost: 'https://auth.dev.example.test',
|
|
},
|
|
});
|
|
expect(config.services?.moonshotSearch?.oauth).toEqual({
|
|
storage: 'file',
|
|
key: oauthKey,
|
|
oauthHost: 'https://auth.dev.example.test',
|
|
});
|
|
expect(config.services?.moonshotFetch?.oauth).toEqual({
|
|
storage: 'file',
|
|
key: oauthKey,
|
|
oauthHost: 'https://auth.dev.example.test',
|
|
});
|
|
});
|
|
|
|
it('persists the default OAuth host when only the API base URL is scoped', async () => {
|
|
const config: ManagedKimiConfigShape = {
|
|
providers: {},
|
|
};
|
|
const baseUrl = 'https://api.example.test/coding/v1';
|
|
const oauthKey = resolveKimiCodeOAuthKey({ baseUrl });
|
|
|
|
await provisionManagedKimiCodeConfig({
|
|
accessToken: 'oauth-access-token',
|
|
baseUrl,
|
|
fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch,
|
|
adapter: {
|
|
read: () => config,
|
|
write: vi.fn(),
|
|
apply: applyManagedKimiCodeConfig,
|
|
},
|
|
});
|
|
|
|
expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({
|
|
baseUrl,
|
|
oauth: {
|
|
storage: 'file',
|
|
key: oauthKey,
|
|
oauthHost: 'https://auth.kimi.com',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('preserves an existing valid default model during refresh', async () => {
|
|
const config: ManagedKimiConfigShape = {
|
|
providers: {
|
|
custom: {
|
|
type: 'kimi',
|
|
apiKey: 'sk-existing',
|
|
baseUrl: 'https://example.test/v1',
|
|
},
|
|
[KIMI_CODE_PROVIDER_NAME]: {
|
|
type: 'kimi',
|
|
apiKey: '',
|
|
},
|
|
},
|
|
defaultModel: 'custom-default',
|
|
thinking: { enabled: false },
|
|
models: {
|
|
'custom-default': {
|
|
provider: 'custom',
|
|
model: 'custom-model',
|
|
maxContextSize: 1000,
|
|
},
|
|
'kimi-code/stale': {
|
|
provider: KIMI_CODE_PROVIDER_NAME,
|
|
model: 'stale',
|
|
maxContextSize: 1000,
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await provisionManagedKimiCodeConfig({
|
|
accessToken: 'oauth-access-token',
|
|
fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch,
|
|
preserveDefaultModel: true,
|
|
adapter: {
|
|
read: () => config,
|
|
write: vi.fn(),
|
|
apply: applyManagedKimiCodeConfig,
|
|
},
|
|
});
|
|
|
|
expect(result.defaultModel).toBe('custom-default');
|
|
expect(result.defaultThinking).toBe(false);
|
|
expect(config.defaultModel).toBe('custom-default');
|
|
expect(config.thinking?.enabled).toBe(false);
|
|
expect(config.models?.['kimi-code/stale']).toBeUndefined();
|
|
expect(config.models?.['kimi-code/kimi-for-coding']?.displayName).toBe('Kimi for Coding');
|
|
});
|
|
|
|
it('infers default_thinking from fresh managed model capabilities', async () => {
|
|
const config: ManagedKimiConfigShape = {
|
|
providers: {
|
|
[KIMI_CODE_PROVIDER_NAME]: {
|
|
type: 'kimi',
|
|
apiKey: '',
|
|
},
|
|
},
|
|
defaultModel: 'kimi-code/kimi-for-coding',
|
|
models: {
|
|
'kimi-code/kimi-for-coding': {
|
|
provider: KIMI_CODE_PROVIDER_NAME,
|
|
model: 'kimi-for-coding',
|
|
maxContextSize: 1000,
|
|
capabilities: [],
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await provisionManagedKimiCodeConfig({
|
|
accessToken: 'oauth-access-token',
|
|
fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch,
|
|
preserveDefaultModel: true,
|
|
adapter: {
|
|
read: () => config,
|
|
write: vi.fn(),
|
|
apply: applyManagedKimiCodeConfig,
|
|
},
|
|
});
|
|
|
|
expect(result.defaultModel).toBe('kimi-code/kimi-for-coding');
|
|
expect(result.defaultThinking).toBe(true);
|
|
expect(config.thinking?.enabled).toBe(true);
|
|
});
|
|
|
|
it('preserves explicit default_thinking when preserving a custom default without capabilities', async () => {
|
|
const config: ManagedKimiConfigShape = {
|
|
providers: {
|
|
custom: {
|
|
type: 'kimi',
|
|
apiKey: 'sk-existing',
|
|
},
|
|
},
|
|
defaultModel: 'custom-default',
|
|
thinking: { enabled: true },
|
|
models: {
|
|
'custom-default': {
|
|
provider: 'custom',
|
|
model: 'custom-model',
|
|
maxContextSize: 1000,
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await provisionManagedKimiCodeConfig({
|
|
accessToken: 'oauth-access-token',
|
|
fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch,
|
|
preserveDefaultModel: true,
|
|
adapter: {
|
|
read: () => config,
|
|
write: vi.fn(),
|
|
apply: applyManagedKimiCodeConfig,
|
|
},
|
|
});
|
|
|
|
expect(result.defaultModel).toBe('custom-default');
|
|
expect(result.defaultThinking).toBe(true);
|
|
expect(config.thinking?.enabled).toBe(true);
|
|
});
|
|
|
|
it('defaults default_thinking to false when a preserved custom default has no signal', async () => {
|
|
const config: ManagedKimiConfigShape = {
|
|
providers: {
|
|
custom: {
|
|
type: 'kimi',
|
|
apiKey: 'sk-existing',
|
|
},
|
|
},
|
|
defaultModel: 'custom-default',
|
|
models: {
|
|
'custom-default': {
|
|
provider: 'custom',
|
|
model: 'custom-model',
|
|
maxContextSize: 1000,
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await provisionManagedKimiCodeConfig({
|
|
accessToken: 'oauth-access-token',
|
|
fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch,
|
|
preserveDefaultModel: true,
|
|
adapter: {
|
|
read: () => config,
|
|
write: vi.fn(),
|
|
apply: applyManagedKimiCodeConfig,
|
|
},
|
|
});
|
|
|
|
expect(result.defaultModel).toBe('custom-default');
|
|
expect(result.defaultThinking).toBe(false);
|
|
expect(config.thinking?.enabled).toBe(false);
|
|
});
|
|
|
|
it('does not infer default_thinking from preserved custom default capabilities', async () => {
|
|
const config: ManagedKimiConfigShape = {
|
|
providers: {
|
|
custom: {
|
|
type: 'kimi',
|
|
apiKey: 'sk-existing',
|
|
},
|
|
},
|
|
defaultModel: 'custom-default',
|
|
models: {
|
|
'custom-default': {
|
|
provider: 'custom',
|
|
model: 'custom-model',
|
|
maxContextSize: 1000,
|
|
capabilities: [],
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await provisionManagedKimiCodeConfig({
|
|
accessToken: 'oauth-access-token',
|
|
fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch,
|
|
preserveDefaultModel: true,
|
|
adapter: {
|
|
read: () => config,
|
|
write: vi.fn(),
|
|
apply: applyManagedKimiCodeConfig,
|
|
},
|
|
});
|
|
|
|
expect(result.defaultModel).toBe('custom-default');
|
|
expect(result.defaultThinking).toBe(false);
|
|
expect(config.thinking?.enabled).toBe(false);
|
|
});
|
|
|
|
it('keeps default_thinking off even when preserved custom default has thinking capability', async () => {
|
|
const config: ManagedKimiConfigShape = {
|
|
providers: {
|
|
custom: {
|
|
type: 'kimi',
|
|
apiKey: 'sk-existing',
|
|
},
|
|
},
|
|
defaultModel: 'custom-default',
|
|
models: {
|
|
'custom-default': {
|
|
provider: 'custom',
|
|
model: 'custom-model',
|
|
maxContextSize: 1000,
|
|
capabilities: ['thinking'],
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await provisionManagedKimiCodeConfig({
|
|
accessToken: 'oauth-access-token',
|
|
fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch,
|
|
preserveDefaultModel: true,
|
|
adapter: {
|
|
read: () => config,
|
|
write: vi.fn(),
|
|
apply: applyManagedKimiCodeConfig,
|
|
},
|
|
});
|
|
|
|
expect(result.defaultModel).toBe('custom-default');
|
|
expect(result.defaultThinking).toBe(false);
|
|
expect(config.thinking?.enabled).toBe(false);
|
|
});
|
|
|
|
it('falls back to the first fetched model when the preserved default was removed', async () => {
|
|
const config: ManagedKimiConfigShape = {
|
|
providers: {
|
|
[KIMI_CODE_PROVIDER_NAME]: {
|
|
type: 'kimi',
|
|
apiKey: '',
|
|
},
|
|
},
|
|
defaultModel: 'kimi-code/stale',
|
|
thinking: { enabled: false },
|
|
models: {
|
|
'kimi-code/stale': {
|
|
provider: KIMI_CODE_PROVIDER_NAME,
|
|
model: 'stale',
|
|
maxContextSize: 1000,
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await provisionManagedKimiCodeConfig({
|
|
accessToken: 'oauth-access-token',
|
|
fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch,
|
|
preserveDefaultModel: true,
|
|
adapter: {
|
|
read: () => config,
|
|
write: vi.fn(),
|
|
apply: applyManagedKimiCodeConfig,
|
|
},
|
|
});
|
|
|
|
expect(result.defaultModel).toBe('kimi-code/kimi-for-coding');
|
|
expect(result.defaultThinking).toBe(false);
|
|
expect(config.defaultModel).toBe('kimi-code/kimi-for-coding');
|
|
expect(config.thinking?.enabled).toBe(false);
|
|
});
|
|
|
|
it('removes managed provider, models, services, and default model on logout', () => {
|
|
const config: ManagedKimiConfigShape = {
|
|
providers: {
|
|
[KIMI_CODE_PROVIDER_NAME]: {
|
|
type: 'kimi',
|
|
apiKey: '',
|
|
},
|
|
custom: {
|
|
type: 'kimi',
|
|
apiKey: 'sk-existing',
|
|
},
|
|
},
|
|
defaultModel: 'kimi-code/kimi-for-coding',
|
|
thinking: { enabled: true },
|
|
models: {
|
|
'kimi-code/kimi-for-coding': {
|
|
provider: KIMI_CODE_PROVIDER_NAME,
|
|
model: 'kimi-for-coding',
|
|
maxContextSize: 262144,
|
|
},
|
|
'custom-default': {
|
|
provider: 'custom',
|
|
model: 'custom-model',
|
|
maxContextSize: 1000,
|
|
},
|
|
},
|
|
services: {
|
|
moonshotSearch: { baseUrl: 'https://api.kimi.com/coding/v1/search' },
|
|
moonshotFetch: { baseUrl: 'https://api.kimi.com/coding/v1/fetch' },
|
|
customService: { baseUrl: 'https://service.example.test' },
|
|
},
|
|
raw: {
|
|
default_model: 'kimi-code/kimi-for-coding',
|
|
providers: {
|
|
[KIMI_CODE_PROVIDER_NAME]: { type: 'kimi' },
|
|
custom: { type: 'kimi' },
|
|
},
|
|
models: {
|
|
'kimi-code/kimi-for-coding': {
|
|
provider: KIMI_CODE_PROVIDER_NAME,
|
|
model: 'kimi-for-coding',
|
|
},
|
|
'custom-default': {
|
|
provider: 'custom',
|
|
model: 'custom-model',
|
|
},
|
|
},
|
|
services: {
|
|
moonshot_search: { base_url: 'https://api.kimi.com/coding/v1/search' },
|
|
moonshot_fetch: { base_url: 'https://api.kimi.com/coding/v1/fetch' },
|
|
},
|
|
},
|
|
};
|
|
|
|
applyManagedKimiCodeLogoutConfig(config);
|
|
|
|
expect(config.defaultModel).toBeUndefined();
|
|
expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toBeUndefined();
|
|
expect(config.providers['custom']).toBeDefined();
|
|
expect(config.models?.['kimi-code/kimi-for-coding']).toBeUndefined();
|
|
expect(config.models?.['custom-default']).toBeDefined();
|
|
expect(config.services?.moonshotSearch).toBeUndefined();
|
|
expect(config.services?.moonshotFetch).toBeUndefined();
|
|
expect(config.services?.['customService']).toEqual({
|
|
baseUrl: 'https://service.example.test',
|
|
});
|
|
});
|
|
|
|
it('rejects managed models that do not include a positive context_length', async () => {
|
|
const fetchImpl = vi.fn(
|
|
async () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
data: [{ id: 'kimi-for-coding', supports_reasoning: true }],
|
|
}),
|
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
),
|
|
) as unknown as typeof fetch;
|
|
|
|
await expect(
|
|
fetchManagedKimiCodeModels({
|
|
accessToken: 'oauth-access-token',
|
|
fetchImpl,
|
|
}),
|
|
).rejects.toThrow(/positive context_length/);
|
|
});
|
|
|
|
it('surfaces API error messages from model listing failures', async () => {
|
|
const fetchImpl = vi.fn(
|
|
async () =>
|
|
new Response(JSON.stringify({ error: { message: 'quota exceeded' } }), {
|
|
status: 429,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}),
|
|
) as unknown as typeof fetch;
|
|
|
|
await expect(
|
|
fetchManagedKimiCodeModels({
|
|
accessToken: 'oauth-access-token',
|
|
fetchImpl,
|
|
}),
|
|
).rejects.toThrow('quota exceeded');
|
|
});
|
|
|
|
it('classifies model listing 401 responses as OAuth unauthorized', async () => {
|
|
const fetchImpl = vi.fn(
|
|
async () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
error: { message: 'The API Key appears to be invalid or may have expired.' },
|
|
}),
|
|
{
|
|
status: 401,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
),
|
|
) as unknown as typeof fetch;
|
|
|
|
await expect(
|
|
fetchManagedKimiCodeModels({
|
|
accessToken: 'oauth-access-token',
|
|
fetchImpl,
|
|
}),
|
|
).rejects.toBeInstanceOf(OAuthUnauthorizedError);
|
|
});
|
|
|
|
it('classifies membership-check 402 responses as OAuth unauthorized', async () => {
|
|
const fetchImpl = vi.fn(
|
|
async () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
error: {
|
|
message:
|
|
"We're unable to verify your membership benefits at this time. Please ensure your membership is active.",
|
|
},
|
|
}),
|
|
{
|
|
status: 402,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
),
|
|
) as unknown as typeof fetch;
|
|
|
|
const promise = fetchManagedKimiCodeModels({
|
|
accessToken: 'oauth-access-token',
|
|
baseUrl: 'https://api.dev.example.test/coding/v1',
|
|
fetchImpl,
|
|
});
|
|
|
|
await expect(promise).rejects.toThrow(
|
|
"Kimi Code models endpoint https://api.dev.example.test/coding/v1 rejected OAuth credentials: We're unable to verify your membership benefits at this time. Please ensure your membership is active.",
|
|
);
|
|
await expect(
|
|
fetchManagedKimiCodeModels({
|
|
accessToken: 'oauth-access-token',
|
|
baseUrl: 'https://api.dev.example.test/coding/v1',
|
|
fetchImpl,
|
|
}),
|
|
).rejects.toMatchObject({
|
|
status: 402,
|
|
baseUrl: 'https://api.dev.example.test/coding/v1',
|
|
});
|
|
await expect(
|
|
fetchManagedKimiCodeModels({
|
|
accessToken: 'oauth-access-token',
|
|
fetchImpl,
|
|
}),
|
|
).rejects.toBeInstanceOf(OAuthUnauthorizedError);
|
|
await expect(
|
|
fetchManagedKimiCodeModels({
|
|
accessToken: 'oauth-access-token',
|
|
fetchImpl,
|
|
}),
|
|
).rejects.toBeInstanceOf(ManagedKimiCodeModelsAuthError);
|
|
});
|
|
|
|
it('clears managed provider, models, default model, and services on logout', () => {
|
|
const config: ManagedKimiConfigShape = {
|
|
providers: {
|
|
[KIMI_CODE_PROVIDER_NAME]: {
|
|
type: 'kimi',
|
|
apiKey: '',
|
|
oauth: { storage: 'file', key: 'oauth/kimi-code' },
|
|
},
|
|
custom: {
|
|
type: 'kimi',
|
|
apiKey: 'sk-existing',
|
|
},
|
|
},
|
|
defaultModel: 'kimi-code/kimi-for-coding',
|
|
models: {
|
|
'kimi-code/kimi-for-coding': {
|
|
provider: KIMI_CODE_PROVIDER_NAME,
|
|
model: 'kimi-for-coding',
|
|
maxContextSize: 262144,
|
|
},
|
|
'custom-default': {
|
|
provider: 'custom',
|
|
model: 'custom-model',
|
|
maxContextSize: 128000,
|
|
},
|
|
},
|
|
services: {
|
|
moonshotSearch: {
|
|
baseUrl: 'https://api.kimi.com/coding/v1/search',
|
|
apiKey: '',
|
|
oauth: { storage: 'file', key: 'oauth/kimi-code' },
|
|
},
|
|
moonshotFetch: {
|
|
baseUrl: 'https://api.kimi.com/coding/v1/fetch',
|
|
apiKey: '',
|
|
oauth: { storage: 'file', key: 'oauth/kimi-code' },
|
|
},
|
|
otherService: { baseUrl: 'https://service.example.test' },
|
|
},
|
|
};
|
|
|
|
const result = clearManagedKimiCodeConfig(config);
|
|
|
|
expect(result).toMatchObject({
|
|
providerName: KIMI_CODE_PROVIDER_NAME,
|
|
removedProvider: true,
|
|
removedModels: ['kimi-code/kimi-for-coding'],
|
|
defaultModelCleared: true,
|
|
removedServices: ['moonshotSearch', 'moonshotFetch'],
|
|
});
|
|
expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toBeUndefined();
|
|
expect(config.providers['custom']).toMatchObject({ apiKey: 'sk-existing' });
|
|
expect(config.defaultModel).toBeUndefined();
|
|
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();
|
|
expect(config.services?.['otherService']).toMatchObject({
|
|
baseUrl: 'https://service.example.test',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('supports_thinking_type', () => {
|
|
function makeThinkingTypeModelsResponse(): Response {
|
|
return new Response(
|
|
JSON.stringify({
|
|
data: [
|
|
{
|
|
id: 'kimi-for-coding',
|
|
context_length: 262144,
|
|
supports_reasoning: true,
|
|
supports_image_in: true,
|
|
supports_video_in: true,
|
|
supports_thinking_type: 'only',
|
|
display_name: 'Kimi For Coding',
|
|
},
|
|
{
|
|
// 'no' is the authoritative declaration and overrides the legacy
|
|
// supports_reasoning boolean.
|
|
id: 'kimi-plain',
|
|
context_length: 128000,
|
|
supports_reasoning: true,
|
|
supports_thinking_type: 'no',
|
|
},
|
|
{
|
|
id: 'kimi-toggle',
|
|
context_length: 128000,
|
|
supports_reasoning: true,
|
|
supports_thinking_type: 'both',
|
|
},
|
|
],
|
|
}),
|
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
);
|
|
}
|
|
|
|
it('parses supports_thinking_type from the models endpoint', async () => {
|
|
const models = await fetchManagedKimiCodeModels({
|
|
accessToken: 'oauth-access-token',
|
|
fetchImpl: vi.fn(async () => makeThinkingTypeModelsResponse()) as unknown as typeof fetch,
|
|
});
|
|
|
|
expect(models[0]?.supportsThinkingType).toBe('only');
|
|
expect(models[1]?.supportsThinkingType).toBe('no');
|
|
expect(models[2]?.supportsThinkingType).toBe('both');
|
|
});
|
|
|
|
it('leaves supportsThinkingType undefined when the field is absent or invalid', async () => {
|
|
const absent = await fetchManagedKimiCodeModels({
|
|
accessToken: 'oauth-access-token',
|
|
fetchImpl: vi.fn(async () => makeModelsResponse()) as unknown as typeof fetch,
|
|
});
|
|
expect(absent[0]?.supportsThinkingType).toBeUndefined();
|
|
|
|
const invalid = await fetchManagedKimiCodeModels({
|
|
accessToken: 'oauth-access-token',
|
|
fetchImpl: vi.fn(
|
|
async () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
data: [
|
|
{
|
|
id: 'kimi-for-coding',
|
|
context_length: 262144,
|
|
supports_reasoning: true,
|
|
supports_thinking_type: 'maybe',
|
|
},
|
|
],
|
|
}),
|
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
),
|
|
) as unknown as typeof fetch,
|
|
});
|
|
expect(invalid[0]?.supportsThinkingType).toBeUndefined();
|
|
});
|
|
|
|
it('maps the three states onto capabilities, overriding supports_reasoning', async () => {
|
|
const config: ManagedKimiConfigShape = { providers: {} };
|
|
|
|
await provisionManagedKimiCodeConfig({
|
|
accessToken: 'oauth-access-token',
|
|
fetchImpl: vi.fn(async () => makeThinkingTypeModelsResponse()) as unknown as typeof fetch,
|
|
adapter: {
|
|
read: () => config,
|
|
write: vi.fn(),
|
|
apply: applyManagedKimiCodeConfig,
|
|
},
|
|
});
|
|
|
|
// 'only' → thinking locked on.
|
|
expect(config.models?.['kimi-code/kimi-for-coding']?.capabilities).toEqual([
|
|
'thinking',
|
|
'always_thinking',
|
|
'image_in',
|
|
'video_in',
|
|
'tool_use',
|
|
]);
|
|
// 'no' → no thinking capability despite supports_reasoning=true.
|
|
expect(config.models?.['kimi-code/kimi-plain']?.capabilities).toEqual(['tool_use']);
|
|
// 'both' → plain toggleable thinking.
|
|
expect(config.models?.['kimi-code/kimi-toggle']?.capabilities).toEqual([
|
|
'thinking',
|
|
'tool_use',
|
|
]);
|
|
});
|
|
|
|
it('forces default thinking on when the selected default model is thinking-only', async () => {
|
|
const config: ManagedKimiConfigShape = { providers: {}, thinking: { enabled: false } };
|
|
|
|
const result = await provisionManagedKimiCodeConfig({
|
|
accessToken: 'oauth-access-token',
|
|
fetchImpl: vi.fn(async () => makeThinkingTypeModelsResponse()) as unknown as typeof fetch,
|
|
adapter: {
|
|
read: () => config,
|
|
write: vi.fn(),
|
|
apply: applyManagedKimiCodeConfig,
|
|
},
|
|
});
|
|
|
|
expect(result.defaultModel).toBe('kimi-code/kimi-for-coding');
|
|
expect(result.defaultThinking).toBe(true);
|
|
expect(config.thinking?.enabled).toBe(true);
|
|
});
|
|
|
|
it('forces default thinking on when preserving a thinking-only managed default', async () => {
|
|
const config: ManagedKimiConfigShape = {
|
|
providers: {
|
|
[KIMI_CODE_PROVIDER_NAME]: {
|
|
type: 'kimi',
|
|
apiKey: '',
|
|
},
|
|
},
|
|
defaultModel: 'kimi-code/kimi-for-coding',
|
|
thinking: { enabled: false },
|
|
models: {
|
|
'kimi-code/kimi-for-coding': {
|
|
provider: KIMI_CODE_PROVIDER_NAME,
|
|
model: 'kimi-for-coding',
|
|
maxContextSize: 262144,
|
|
capabilities: ['thinking'],
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await provisionManagedKimiCodeConfig({
|
|
accessToken: 'oauth-access-token',
|
|
fetchImpl: vi.fn(async () => makeThinkingTypeModelsResponse()) as unknown as typeof fetch,
|
|
preserveDefaultModel: true,
|
|
adapter: {
|
|
read: () => config,
|
|
write: vi.fn(),
|
|
apply: applyManagedKimiCodeConfig,
|
|
},
|
|
});
|
|
|
|
expect(result.defaultModel).toBe('kimi-code/kimi-for-coding');
|
|
expect(result.defaultThinking).toBe(true);
|
|
expect(config.thinking?.enabled).toBe(true);
|
|
});
|
|
|
|
it('forces default thinking off when preserving a no-thinking managed default', async () => {
|
|
const config: ManagedKimiConfigShape = {
|
|
providers: {
|
|
[KIMI_CODE_PROVIDER_NAME]: {
|
|
type: 'kimi',
|
|
apiKey: '',
|
|
},
|
|
},
|
|
defaultModel: 'kimi-code/kimi-plain',
|
|
thinking: { enabled: true },
|
|
models: {
|
|
'kimi-code/kimi-plain': {
|
|
provider: KIMI_CODE_PROVIDER_NAME,
|
|
model: 'kimi-plain',
|
|
maxContextSize: 128000,
|
|
capabilities: ['thinking'],
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await provisionManagedKimiCodeConfig({
|
|
accessToken: 'oauth-access-token',
|
|
fetchImpl: vi.fn(async () => makeThinkingTypeModelsResponse()) as unknown as typeof fetch,
|
|
preserveDefaultModel: true,
|
|
adapter: {
|
|
read: () => config,
|
|
write: vi.fn(),
|
|
apply: applyManagedKimiCodeConfig,
|
|
},
|
|
});
|
|
|
|
expect(result.defaultModel).toBe('kimi-code/kimi-plain');
|
|
expect(result.defaultThinking).toBe(false);
|
|
expect(config.thinking?.enabled).toBe(false);
|
|
});
|
|
|
|
it('keeps a preserved non-managed default thinking selection untouched', async () => {
|
|
const config: ManagedKimiConfigShape = {
|
|
providers: {
|
|
custom: {
|
|
type: 'kimi',
|
|
apiKey: 'sk-existing',
|
|
},
|
|
},
|
|
defaultModel: 'custom-default',
|
|
thinking: { enabled: false },
|
|
models: {
|
|
'custom-default': {
|
|
provider: 'custom',
|
|
model: 'custom-model',
|
|
maxContextSize: 1000,
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await provisionManagedKimiCodeConfig({
|
|
accessToken: 'oauth-access-token',
|
|
fetchImpl: vi.fn(async () => makeThinkingTypeModelsResponse()) as unknown as typeof fetch,
|
|
preserveDefaultModel: true,
|
|
adapter: {
|
|
read: () => config,
|
|
write: vi.fn(),
|
|
apply: applyManagedKimiCodeConfig,
|
|
},
|
|
});
|
|
|
|
expect(result.defaultModel).toBe('custom-default');
|
|
expect(result.defaultThinking).toBe(false);
|
|
expect(config.thinking?.enabled).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('support_efforts / default_effort', () => {
|
|
function makeEffortModelsResponse(): Response {
|
|
return new Response(
|
|
JSON.stringify({
|
|
data: [
|
|
{
|
|
id: 'kimi-for-coding',
|
|
context_length: 262144,
|
|
supports_reasoning: true,
|
|
supports_thinking_type: 'both',
|
|
think_efforts: {
|
|
support: true,
|
|
valid_efforts: ['low', 'high', 'max'],
|
|
default_effort: 'high',
|
|
},
|
|
display_name: 'Kimi For Coding',
|
|
},
|
|
{
|
|
// Empty / non-string entries are filtered; absent fields stay undefined.
|
|
id: 'kimi-plain',
|
|
context_length: 128000,
|
|
supports_reasoning: true,
|
|
think_efforts: { support: true, valid_efforts: ['low', '', 42] },
|
|
},
|
|
],
|
|
}),
|
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
);
|
|
}
|
|
|
|
it('parses think_efforts from the models endpoint', async () => {
|
|
const models = await fetchManagedKimiCodeModels({
|
|
accessToken: 'oauth-access-token',
|
|
fetchImpl: vi.fn(async () => makeEffortModelsResponse()) as unknown as typeof fetch,
|
|
});
|
|
|
|
expect(models[0]?.supportEfforts).toEqual(['low', 'high', 'max']);
|
|
expect(models[0]?.defaultEffort).toBe('high');
|
|
// The empty string and number are filtered out of valid_efforts.
|
|
expect(models[1]?.supportEfforts).toEqual(['low']);
|
|
expect(models[1]?.defaultEffort).toBeUndefined();
|
|
});
|
|
|
|
it('ignores think_efforts entirely when support is not true', async () => {
|
|
const models = await fetchManagedKimiCodeModels({
|
|
accessToken: 'oauth-access-token',
|
|
fetchImpl: async () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
data: [
|
|
{
|
|
id: 'kimi-no-effort',
|
|
context_length: 128000,
|
|
supports_reasoning: true,
|
|
think_efforts: {
|
|
support: false,
|
|
valid_efforts: ['low', 'high'],
|
|
default_effort: 'high',
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
),
|
|
});
|
|
|
|
// support !== true gates the whole object — valid_efforts / default_effort
|
|
// are ignored.
|
|
expect(models[0]?.supportEfforts).toBeUndefined();
|
|
expect(models[0]?.defaultEffort).toBeUndefined();
|
|
});
|
|
|
|
it('ignores legacy flat fields even when think_efforts is absent', async () => {
|
|
// The legacy support_efforts / default_effort fields are no longer read;
|
|
// only the nested think_efforts object is honored.
|
|
const models = await fetchManagedKimiCodeModels({
|
|
accessToken: 'oauth-access-token',
|
|
fetchImpl: async () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
data: [
|
|
{
|
|
id: 'kimi-k2',
|
|
context_length: 128000,
|
|
supports_reasoning: true,
|
|
support_efforts: ['low', 'high'],
|
|
default_effort: 'high',
|
|
},
|
|
],
|
|
}),
|
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
),
|
|
});
|
|
|
|
expect(models[0]?.supportEfforts).toBeUndefined();
|
|
expect(models[0]?.defaultEffort).toBeUndefined();
|
|
});
|
|
|
|
it('writes supportEfforts and defaultEffort onto the provisioned model entry', async () => {
|
|
const config: ManagedKimiConfigShape = { providers: {} };
|
|
|
|
await provisionManagedKimiCodeConfig({
|
|
accessToken: 'oauth-access-token',
|
|
fetchImpl: vi.fn(async () => makeEffortModelsResponse()) as unknown as typeof fetch,
|
|
adapter: {
|
|
read: () => config,
|
|
write: vi.fn(),
|
|
apply: applyManagedKimiCodeConfig,
|
|
},
|
|
});
|
|
|
|
const alias = config.models?.['kimi-code/kimi-for-coding'];
|
|
expect(alias?.['supportEfforts']).toEqual(['low', 'high', 'max']);
|
|
expect(alias?.['defaultEffort']).toBe('high');
|
|
});
|
|
});
|
|
|
|
describe('selective merge', () => {
|
|
const baseOptions = {
|
|
baseUrl: 'https://api.example.test/coding/v1',
|
|
oauthKey: 'test-key',
|
|
};
|
|
|
|
it('preserves hand-edited fields that upstream does not declare', () => {
|
|
const config: ManagedKimiConfigShape = {
|
|
providers: {},
|
|
models: {
|
|
'kimi-code/kimi-k2': {
|
|
provider: 'kimi-code',
|
|
model: 'kimi-k2',
|
|
maxContextSize: 262144,
|
|
capabilities: ['thinking'],
|
|
maxOutputSize: 4096,
|
|
supportEfforts: ['low', 'high', 'max'],
|
|
} as Record<string, unknown>,
|
|
},
|
|
};
|
|
|
|
applyManagedKimiCodeConfig(config, {
|
|
...baseOptions,
|
|
models: [
|
|
{
|
|
id: 'kimi-k2',
|
|
contextLength: 262144,
|
|
supportsReasoning: true,
|
|
supportsImageIn: false,
|
|
supportsVideoIn: false,
|
|
supportsThinkingType: 'both',
|
|
},
|
|
],
|
|
});
|
|
|
|
const alias = config.models?.['kimi-code/kimi-k2'];
|
|
expect(alias?.['maxOutputSize']).toBe(4096);
|
|
expect(alias?.['supportEfforts']).toEqual(['low', 'high', 'max']);
|
|
expect(alias?.['maxContextSize']).toBe(262144);
|
|
});
|
|
|
|
it('overwrites hand-edited fields when upstream declares them', () => {
|
|
const config: ManagedKimiConfigShape = {
|
|
providers: {},
|
|
models: {
|
|
'kimi-code/kimi-k2': {
|
|
provider: 'kimi-code',
|
|
model: 'kimi-k2',
|
|
maxContextSize: 262144,
|
|
supportEfforts: ['low'],
|
|
} as Record<string, unknown>,
|
|
},
|
|
};
|
|
|
|
applyManagedKimiCodeConfig(config, {
|
|
...baseOptions,
|
|
models: [
|
|
{
|
|
id: 'kimi-k2',
|
|
contextLength: 262144,
|
|
supportsReasoning: true,
|
|
supportsImageIn: false,
|
|
supportsVideoIn: false,
|
|
supportEfforts: ['low', 'high', 'max'],
|
|
defaultEffort: 'high',
|
|
},
|
|
],
|
|
});
|
|
|
|
const alias = config.models?.['kimi-code/kimi-k2'];
|
|
expect(alias?.['supportEfforts']).toEqual(['low', 'high', 'max']);
|
|
expect(alias?.['defaultEffort']).toBe('high');
|
|
});
|
|
|
|
it('removes managed models that upstream no longer lists', () => {
|
|
const config: ManagedKimiConfigShape = {
|
|
providers: {},
|
|
models: {
|
|
'kimi-code/kimi-k2': {
|
|
provider: KIMI_CODE_PROVIDER_NAME,
|
|
model: 'kimi-k2',
|
|
maxContextSize: 262144,
|
|
},
|
|
'kimi-code/removed': {
|
|
provider: KIMI_CODE_PROVIDER_NAME,
|
|
model: 'removed',
|
|
maxContextSize: 128000,
|
|
},
|
|
},
|
|
};
|
|
|
|
applyManagedKimiCodeConfig(config, {
|
|
...baseOptions,
|
|
models: [
|
|
{
|
|
id: 'kimi-k2',
|
|
contextLength: 262144,
|
|
supportsReasoning: true,
|
|
supportsImageIn: false,
|
|
supportsVideoIn: false,
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(config.models?.['kimi-code/kimi-k2']).toBeDefined();
|
|
expect(config.models?.['kimi-code/removed']).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
function makeModelInfo(
|
|
id: string,
|
|
overrides: Partial<ManagedKimiCodeModelInfo> = {},
|
|
): ManagedKimiCodeModelInfo {
|
|
return {
|
|
id,
|
|
contextLength: 200000,
|
|
supportsReasoning: false,
|
|
supportsImageIn: false,
|
|
supportsVideoIn: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const KIMI_BASE_URL = 'https://api.kimi.com/coding/v1';
|
|
|
|
describe('managed protocol routing', () => {
|
|
it('reads protocol from the /models response', async () => {
|
|
const fetchImpl = vi.fn(
|
|
async () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
data: [{ id: 'kimi-for-coding', context_length: 262144, protocol: 'anthropic' }],
|
|
}),
|
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
),
|
|
) as unknown as typeof fetch;
|
|
|
|
const models = await fetchManagedKimiCodeModels({ accessToken: 't', fetchImpl });
|
|
expect(models).toHaveLength(1);
|
|
expect(models[0]?.protocol).toBe('anthropic');
|
|
});
|
|
|
|
it('keeps the provider on the kimi REST base and records the model protocol when anthropic', () => {
|
|
const config: ManagedKimiConfigShape = { providers: {} };
|
|
applyManagedKimiCodeConfig(config, {
|
|
baseUrl: KIMI_BASE_URL,
|
|
models: [makeModelInfo('kimi-for-coding', { protocol: 'anthropic' })],
|
|
});
|
|
|
|
// The provider stays on the kimi wire + REST base; the anthropic transport
|
|
// is resolved per-model at runtime, not baked into the provider config, so
|
|
// the REST base keeps flowing to OAuth key derivation and plugin env.
|
|
expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({
|
|
type: 'kimi',
|
|
baseUrl: KIMI_BASE_URL,
|
|
apiKey: '',
|
|
});
|
|
expect(config.models?.['kimi-code/kimi-for-coding']).toMatchObject({
|
|
provider: KIMI_CODE_PROVIDER_NAME,
|
|
protocol: 'anthropic',
|
|
betaApi: true,
|
|
});
|
|
});
|
|
|
|
it('keeps the kimi protocol and baseUrl when the model has no anthropic protocol', () => {
|
|
const config: ManagedKimiConfigShape = { providers: {} };
|
|
applyManagedKimiCodeConfig(config, {
|
|
baseUrl: KIMI_BASE_URL,
|
|
models: [makeModelInfo('kimi-for-coding')],
|
|
});
|
|
|
|
expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({
|
|
type: 'kimi',
|
|
baseUrl: KIMI_BASE_URL,
|
|
apiKey: '',
|
|
});
|
|
expect(config.models?.['kimi-code/kimi-for-coding']?.provider).toBe(KIMI_CODE_PROVIDER_NAME);
|
|
expect(config.models?.['kimi-code/kimi-for-coding']?.protocol).toBeUndefined();
|
|
});
|
|
|
|
it('drops the model protocol on refresh when the server stops declaring anthropic', () => {
|
|
const config: ManagedKimiConfigShape = { providers: {} };
|
|
applyManagedKimiCodeConfig(config, {
|
|
baseUrl: KIMI_BASE_URL,
|
|
models: [makeModelInfo('kimi-for-coding', { protocol: 'anthropic' })],
|
|
});
|
|
expect(config.models?.['kimi-code/kimi-for-coding']?.protocol).toBe('anthropic');
|
|
|
|
applyManagedKimiCodeConfig(config, {
|
|
baseUrl: KIMI_BASE_URL,
|
|
models: [makeModelInfo('kimi-for-coding')],
|
|
});
|
|
// The provider never leaves the kimi wire / REST base across refreshes —
|
|
// only the per-model protocol annotation changes.
|
|
expect(config.providers[KIMI_CODE_PROVIDER_NAME]).toMatchObject({
|
|
type: 'kimi',
|
|
baseUrl: KIMI_BASE_URL,
|
|
});
|
|
expect(config.models?.['kimi-code/kimi-for-coding']?.protocol).toBeUndefined();
|
|
});
|
|
});
|