mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-16 12:15:29 +00:00
* fix(agent-core-v2): honor explicit undefined field deletes in modelsToToml
The models TOML transform merged each entry as { ...oldRaw, ...converted },
so a field the new record carried with an explicit undefined — deleted from
'converted' by setDefined — was put right back from the old on-disk raw.
Field-level clears issued through config.replace (e.g. the provider write
routes dropping base_url, display_name, capabilities) only took effect in
memory and resurrected on the next boot. Merge via setDefined onto the old
raw directly, matching providerEntryToToml.
* feat(kap-server): add provider write endpoints and models.dev/registry import
Add the provider management write surface plus server-proxied catalogs so
API clients (the desktop app first) can manage providers without editing
config.toml by hand:
- POST/PUT/DELETE /providers: manual create, replace-style edit (new_id
rename with pointer migration, tri-state api_key, form-unknown fields
merged through), and delete with real alias removal.
- GET /providers/{id} additionally reveals the stored api_key for edit
prefill on the loopback+bearer transport; the list stays redacted.
- GET /catalog/providers[{id}]: pruned models.dev directory with import
eligibility resolved server-side (10-min cache, stale fallback, built-in
snapshot, shared in-flight fetch).
- POST /providers:import_catalog and :import_registry (collection actions
next to :refresh): catalog/registry imports with TUI-aligned
remove-then-apply refresh semantics, OAuth-managed guards, and the
registry source blob that scheduled refreshes rediscover.
Write-path consistency: field-level clears assign explicit undefined (the
TOML transforms only drop keys that way), import refreshes swap aliases in
two passes so stale on-disk fields cannot ride along, re-imports keep the
stored credential when api_key is omitted, and multi-step writes serialize
through a process-local chain. Global default_provider/default_model are
never modified by provider writes.
* fix(kap-server): cache the built-in catalog fallback and guard alias-key collisions on replace
- Cache the built-in snapshot on upstream failure, so an offline install no
longer pays the full upstream timeout on every catalog call.
- Reject a PUT whose rebuilt alias keys collide with an alias owned by
another provider (foreign-prefix keys are global), checked before any
write so a collision never lands half the edit.
* fix(kap-server): make toCatalogProviderItem's switch explicit for older TS control-flow
* chore(agent-core-v2): drop inline rationale per package comment conventions
* feat(kap-server): seed default_model on provider create/import when none is configured
A fresh setup has no global default model, so the first provider added
through the write endpoints left GET /auth permanently unready and new
sessions without a selected model. Seed default_model from the created
provider's default (or first) model on POST /providers, :import_catalog
and :import_registry when — and only when — no default is configured;
an existing pointer is never moved, not even a dangling one.
* refactor(agent-core-v2): own the models.dev provider import in the engine
kap-server's provider write endpoints imported @moonshot-ai/kosong and
@moonshot-ai/kimi-code-oauth directly for the models.dev browse/import
and custom-registry import. Move that capability behind a new App-scope
IModelsDevImportService so the server only talks to agent-core-v2:
- app/kosongConfig/modelsDev.ts mirrors the third-party api.json schema
and hosts the pure translation (wire inference, endpoint resolution,
model pruning, thinking/gateway overrides) ported from kosong's
catalog.ts; kosong's own type surface stays limited to built-in
vocabulary
- modelsDevUpstream.ts owns the directory fetch, 10-minute cache, and
built-in snapshot fallback; modelsDevImportService.ts owns the import
orchestration (tri-state api_key, two-pass alias swap, OAuth-managed
rejection, serialized writes) with coded modelsDev.* errors
- kap-server routes shrink to wire mapping (zod schemas + numeric error
code mapping) and drop the kosong/oauth dependencies
The /api/v1 surface is byte-identical: the apiSurface snapshot and all
existing modelCatalog tests pass unchanged.
* chore: drop the branch's changesets
* fix(agent-core-v2): route the models.dev always-thinking exemption through kosong
The zero-tolerance vendor-name gate forbids a 'kimi' compare outside the
kosong layer, and the engine-side models.dev port carried one inline.
Kosong (the vendor layer, owner of thinking semantics) now answers the
verdict — wireHasProtocolThinkingDisable — and kosongConfig/modelsDev.ts
asks it instead of comparing wire ids.
---------
Co-authored-by: haozhe.yang <yanghaozhe@moonshot.ai>
930 lines
32 KiB
TypeScript
930 lines
32 KiB
TypeScript
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
import { parse as parseToml } from 'smol-toml';
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
import { type RunningServer, startServer } from '../src/start';
|
|
import { authHeaders } from './helpers/auth';
|
|
|
|
interface Envelope<T> {
|
|
code: number;
|
|
msg: string;
|
|
data: T;
|
|
request_id: string;
|
|
details?: Array<{ path: string; message: string }>;
|
|
}
|
|
|
|
/** default_provider/default_model both point at the openai provider. */
|
|
const DEFAULTED_TOML = [
|
|
'default_provider = "openai"',
|
|
'default_model = "gpt4o"',
|
|
'',
|
|
'[providers.kimi]',
|
|
'type = "kimi"',
|
|
'api_key = "sk-test"',
|
|
'',
|
|
'[providers.openai]',
|
|
'type = "openai"',
|
|
'api_key = "sk-openai"',
|
|
'',
|
|
'[models.k2]',
|
|
'provider = "kimi"',
|
|
'model = "kimi-k2"',
|
|
'max_context_size = 131072',
|
|
'',
|
|
'[models.gpt4o]',
|
|
'provider = "openai"',
|
|
'model = "gpt-4o"',
|
|
'max_context_size = 128000',
|
|
'',
|
|
].join('\n');
|
|
|
|
/** Same providers/models, but the global default model belongs to kimi. */
|
|
const KEEP_DEFAULT_TOML = DEFAULTED_TOML.replace('default_provider = "openai"\n', '').replace(
|
|
'default_model = "gpt4o"',
|
|
'default_model = "k2"',
|
|
);
|
|
|
|
/** A default_model pointing at an alias that does not exist. */
|
|
const DANGLING_DEFAULT_TOML = [
|
|
'default_model = "gone"',
|
|
'',
|
|
'[providers.kimi]',
|
|
'type = "kimi"',
|
|
'api_key = "sk-test"',
|
|
'',
|
|
].join('\n');
|
|
|
|
const MANAGED_TOML = [
|
|
'[providers."managed:kimi-code"]',
|
|
'type = "kimi"',
|
|
'api_key = ""',
|
|
'base_url = "https://api.example.test/v1"',
|
|
'oauth = { storage = "file", key = "oauth/kimi-code" }',
|
|
'',
|
|
'[models."managed:kimi-code/kimi-k2"]',
|
|
'provider = "managed:kimi-code"',
|
|
'model = "kimi-k2"',
|
|
'max_context_size = 131072',
|
|
'',
|
|
].join('\n');
|
|
|
|
const CREATE_BODY = {
|
|
id: 'my-openai',
|
|
type: 'openai',
|
|
api_key: 'sk-test-openai',
|
|
base_url: 'https://api.openai.example/v1',
|
|
default_model: 'gpt-4.1',
|
|
models: [
|
|
{
|
|
model: 'gpt-4.1',
|
|
max_context_size: 1047576,
|
|
display_name: 'GPT-4.1',
|
|
capabilities: ['vision'],
|
|
max_output_size: 32768,
|
|
},
|
|
{ model: 'gpt-4o-mini', max_context_size: 128000 },
|
|
],
|
|
} as const;
|
|
|
|
/** Full "edit & save" form for the openai provider; api_key deliberately absent. */
|
|
const REPLACE_BODY = {
|
|
type: 'openai',
|
|
base_url: 'https://api.openai.example/v1',
|
|
default_model: 'gpt-4.1',
|
|
models: [
|
|
{ model: 'gpt-4.1', max_context_size: 1047576, display_name: 'GPT-4.1' },
|
|
{ model: 'gpt-4o-mini', max_context_size: 128000 },
|
|
],
|
|
} as const;
|
|
|
|
describe('server-v2 /api/v1 provider write endpoints', () => {
|
|
let server: RunningServer | undefined;
|
|
let home: string | undefined;
|
|
let base: string;
|
|
|
|
beforeEach(async () => {
|
|
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-provider-write-'));
|
|
// Disable the background refresh scheduler so it never rewrites config
|
|
// underneath the write-path assertions below.
|
|
process.env['KIMI_CODE_MODEL_CATALOG_REFRESH_ON_START'] = '0';
|
|
process.env['KIMI_CODE_MODEL_CATALOG_REFRESH_INTERVAL_MS'] = '0';
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (server !== undefined) {
|
|
await server.close();
|
|
server = undefined;
|
|
}
|
|
if (home !== undefined) {
|
|
await rm(home, { recursive: true, force: true });
|
|
home = undefined;
|
|
}
|
|
delete process.env['KIMI_CODE_MODEL_CATALOG_REFRESH_ON_START'];
|
|
delete process.env['KIMI_CODE_MODEL_CATALOG_REFRESH_INTERVAL_MS'];
|
|
});
|
|
|
|
async function boot(toml?: string): Promise<void> {
|
|
if (toml !== undefined) {
|
|
await writeFile(join(home as string, 'config.toml'), toml, 'utf-8');
|
|
}
|
|
server = await startServer({
|
|
host: '127.0.0.1',
|
|
port: 0,
|
|
homeDir: home,
|
|
logLevel: 'silent',
|
|
});
|
|
base = `http://127.0.0.1:${server.port}`;
|
|
}
|
|
|
|
async function getJson<T>(path: string): Promise<{ status: number; body: Envelope<T> }> {
|
|
const res = await fetch(`${base}${path}`, {
|
|
headers: authHeaders(server as RunningServer),
|
|
} as never);
|
|
return { status: res.status, body: (await res.json()) as Envelope<T> };
|
|
}
|
|
|
|
async function postJson<T>(
|
|
path: string,
|
|
body?: unknown,
|
|
): Promise<{ status: number; body: Envelope<T> }> {
|
|
const res = await fetch(`${base}${path}`, {
|
|
method: 'POST',
|
|
headers: authHeaders(
|
|
server as RunningServer,
|
|
body === undefined ? {} : { 'content-type': 'application/json' },
|
|
),
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
} as never);
|
|
return { status: res.status, body: (await res.json()) as Envelope<T> };
|
|
}
|
|
|
|
async function putJson<T>(
|
|
path: string,
|
|
body: unknown,
|
|
): Promise<{ status: number; body: Envelope<T> }> {
|
|
const res = await fetch(`${base}${path}`, {
|
|
method: 'PUT',
|
|
headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }),
|
|
body: JSON.stringify(body),
|
|
} as never);
|
|
return { status: res.status, body: (await res.json()) as Envelope<T> };
|
|
}
|
|
|
|
async function deleteJson<T>(
|
|
path: string,
|
|
): Promise<{ status: number; text: string; body: Envelope<T> | undefined }> {
|
|
const res = await fetch(`${base}${path}`, {
|
|
method: 'DELETE',
|
|
headers: authHeaders(server as RunningServer),
|
|
} as never);
|
|
const text = await res.text();
|
|
return {
|
|
status: res.status,
|
|
text,
|
|
body: text.length === 0 ? undefined : (JSON.parse(text) as Envelope<T>),
|
|
};
|
|
}
|
|
|
|
async function readConfigToml(): Promise<Record<string, unknown>> {
|
|
const text = await readFile(join(home as string, 'config.toml'), 'utf-8');
|
|
return parseToml(text) as Record<string, unknown>;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// POST /providers
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('creates a provider with model aliases and persists them to config.toml', async () => {
|
|
await boot();
|
|
const { status, body } = await postJson<unknown>('/api/v1/providers', CREATE_BODY);
|
|
expect(status).toBe(201);
|
|
expect(body.code).toBe(0);
|
|
expect(body.data).toEqual({
|
|
id: 'my-openai',
|
|
type: 'openai',
|
|
base_url: 'https://api.openai.example/v1',
|
|
default_model: 'my-openai/gpt-4.1',
|
|
has_api_key: true,
|
|
status: 'connected',
|
|
models: ['my-openai/gpt-4.1', 'my-openai/gpt-4o-mini'],
|
|
});
|
|
|
|
const onDisk = await readConfigToml();
|
|
expect(onDisk['providers']).toEqual({
|
|
'my-openai': {
|
|
type: 'openai',
|
|
api_key: 'sk-test-openai',
|
|
base_url: 'https://api.openai.example/v1',
|
|
default_model: 'my-openai/gpt-4.1',
|
|
},
|
|
});
|
|
expect(onDisk['models']).toEqual({
|
|
'my-openai/gpt-4.1': {
|
|
provider: 'my-openai',
|
|
model: 'gpt-4.1',
|
|
max_context_size: 1047576,
|
|
display_name: 'GPT-4.1',
|
|
capabilities: ['vision'],
|
|
max_output_size: 32768,
|
|
},
|
|
'my-openai/gpt-4o-mini': {
|
|
provider: 'my-openai',
|
|
model: 'gpt-4o-mini',
|
|
max_context_size: 128000,
|
|
},
|
|
});
|
|
|
|
const providers = await getJson<{ items: unknown[] }>('/api/v1/providers');
|
|
expect(providers.body.data.items).toEqual([body.data]);
|
|
|
|
const models = await getJson<{ items: unknown[] }>('/api/v1/models');
|
|
expect(models.body.data.items).toEqual([
|
|
{
|
|
provider: 'my-openai',
|
|
model: 'my-openai/gpt-4.1',
|
|
display_name: 'GPT-4.1',
|
|
max_context_size: 1047576,
|
|
capabilities: ['vision'],
|
|
},
|
|
{
|
|
provider: 'my-openai',
|
|
model: 'my-openai/gpt-4o-mini',
|
|
display_name: 'gpt-4o-mini',
|
|
max_context_size: 128000,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('creates a credential-less provider (env-resolved types may omit api_key)', async () => {
|
|
await boot();
|
|
const { status, body } = await postJson<unknown>('/api/v1/providers', {
|
|
id: 'vertex',
|
|
type: 'vertexai',
|
|
models: [{ model: 'gemini-2.5-pro', max_context_size: 1048576 }],
|
|
});
|
|
expect(status).toBe(201);
|
|
expect(body.code).toBe(0);
|
|
expect(body.data).toEqual({
|
|
id: 'vertex',
|
|
type: 'vertexai',
|
|
// No provider-level default, but the fresh-setup seeding made this
|
|
// model the global default — and the projection falls back to it.
|
|
default_model: 'vertex/gemini-2.5-pro',
|
|
has_api_key: false,
|
|
status: 'unconfigured',
|
|
models: ['vertex/gemini-2.5-pro'],
|
|
});
|
|
});
|
|
|
|
it('seeds the global default_model on a fresh setup (the provider default wins)', async () => {
|
|
await boot();
|
|
const { status } = await postJson<unknown>('/api/v1/providers', CREATE_BODY);
|
|
expect(status).toBe(201);
|
|
|
|
const onDisk = await readConfigToml();
|
|
expect(onDisk['default_model']).toBe('my-openai/gpt-4.1');
|
|
|
|
// End-to-end: the readiness probe now reports a usable daemon.
|
|
const auth = await getJson<{ ready: boolean; default_model: string | null }>('/api/v1/auth');
|
|
expect(auth.body.data).toMatchObject({ ready: true, default_model: 'my-openai/gpt-4.1' });
|
|
});
|
|
|
|
it('seeds the first model when the create body names no provider default', async () => {
|
|
await boot();
|
|
const { status } = await postJson<unknown>('/api/v1/providers', {
|
|
id: 'my-openai',
|
|
type: 'openai',
|
|
api_key: 'sk-test-openai',
|
|
models: [
|
|
{ model: 'gpt-4o-mini', max_context_size: 128000 },
|
|
{ model: 'gpt-4.1', max_context_size: 1047576 },
|
|
],
|
|
});
|
|
expect(status).toBe(201);
|
|
|
|
const onDisk = await readConfigToml();
|
|
expect(onDisk['default_model']).toBe('my-openai/gpt-4o-mini');
|
|
});
|
|
|
|
it('keeps an existing global default_model on create', async () => {
|
|
await boot(DEFAULTED_TOML);
|
|
const { status } = await postJson<unknown>('/api/v1/providers', CREATE_BODY);
|
|
expect(status).toBe(201);
|
|
|
|
const onDisk = await readConfigToml();
|
|
expect(onDisk['default_model']).toBe('gpt4o');
|
|
});
|
|
|
|
it('leaves even a dangling default_model untouched on create', async () => {
|
|
await boot(DANGLING_DEFAULT_TOML);
|
|
const { status } = await postJson<unknown>('/api/v1/providers', CREATE_BODY);
|
|
expect(status).toBe(201);
|
|
|
|
const onDisk = await readConfigToml();
|
|
expect(onDisk['default_model']).toBe('gone');
|
|
});
|
|
|
|
it('rejects a duplicate provider id with 40921', async () => {
|
|
await boot(KEEP_DEFAULT_TOML);
|
|
const { body } = await postJson<unknown>('/api/v1/providers', {
|
|
...CREATE_BODY,
|
|
id: 'openai',
|
|
});
|
|
expect(body.code).toBe(40921);
|
|
expect(body.data).toBeNull();
|
|
|
|
// The existing provider is left untouched.
|
|
const providers = await getJson<{ items: Array<{ id: string }> }>('/api/v1/providers');
|
|
expect(providers.body.data.items.map((p) => p.id)).toEqual(['kimi', 'openai']);
|
|
});
|
|
|
|
it('accepts a Unicode provider id (Chinese + space)', async () => {
|
|
await boot();
|
|
const { status, body } = await postJson<{ id: string }>('/api/v1/providers', {
|
|
...CREATE_BODY,
|
|
id: '测试 Kimi',
|
|
});
|
|
expect(status).toBe(201);
|
|
expect(body.code).toBe(0);
|
|
expect(body.data.id).toBe('测试 Kimi');
|
|
|
|
const onDisk = await readConfigToml();
|
|
expect(onDisk['providers']).toMatchObject({ '测试 Kimi': { type: 'openai' } });
|
|
expect(onDisk['models']).toMatchObject({
|
|
'测试 Kimi/gpt-4.1': { provider: '测试 Kimi', model: 'gpt-4.1' },
|
|
});
|
|
});
|
|
|
|
it('creates models with support_efforts and adaptive_thinking', async () => {
|
|
await boot();
|
|
const { status } = await postJson<unknown>('/api/v1/providers', {
|
|
...CREATE_BODY,
|
|
models: [
|
|
{
|
|
model: 'gpt-4.1',
|
|
max_context_size: 1047576,
|
|
support_efforts: ['low', 'max'],
|
|
adaptive_thinking: true,
|
|
},
|
|
],
|
|
});
|
|
expect(status).toBe(201);
|
|
|
|
const onDisk = await readConfigToml();
|
|
expect(onDisk['models']).toMatchObject({
|
|
'my-openai/gpt-4.1': {
|
|
support_efforts: ['low', 'max'],
|
|
adaptive_thinking: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('rejects invalid create bodies with 40001', async () => {
|
|
await boot();
|
|
const cases: Array<{ name: string; body: unknown; path?: string }> = [
|
|
{
|
|
name: 'id with illegal characters',
|
|
body: { ...CREATE_BODY, id: 'bad!id' },
|
|
path: 'id',
|
|
},
|
|
{ name: 'unknown wire type', body: { ...CREATE_BODY, type: 'custom' }, path: 'type' },
|
|
{ name: 'empty models list', body: { ...CREATE_BODY, models: [] }, path: 'models' },
|
|
{
|
|
name: 'default_model outside the models list',
|
|
body: { ...CREATE_BODY, default_model: 'gpt-5' },
|
|
path: 'default_model',
|
|
},
|
|
];
|
|
for (const { name, body, path } of cases) {
|
|
const { body: envelope } = await postJson('/api/v1/providers', body);
|
|
expect(envelope.code, name).toBe(40001);
|
|
expect(envelope.data, name).toBeNull();
|
|
if (path !== undefined) {
|
|
expect(
|
|
envelope.details?.some((detail) => detail.path === path),
|
|
name,
|
|
).toBe(true);
|
|
}
|
|
}
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// DELETE /providers/{provider_id}
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('deletes a provider and its model aliases, keeping unrelated defaults', async () => {
|
|
await boot(KEEP_DEFAULT_TOML);
|
|
const { status, text } = await deleteJson<unknown>('/api/v1/providers/openai');
|
|
expect(status).toBe(204);
|
|
expect(text).toBe('');
|
|
|
|
const onDisk = await readConfigToml();
|
|
expect(onDisk['providers']).toEqual({ kimi: { type: 'kimi', api_key: 'sk-test' } });
|
|
expect(onDisk['models']).toEqual({
|
|
k2: { provider: 'kimi', model: 'kimi-k2', max_context_size: 131072 },
|
|
});
|
|
expect(onDisk['default_model']).toBe('k2');
|
|
|
|
const providers = await getJson<{ items: Array<{ id: string }> }>('/api/v1/providers');
|
|
expect(providers.body.data.items.map((p) => p.id)).toEqual(['kimi']);
|
|
const models = await getJson<{ items: Array<{ model: string }> }>('/api/v1/models');
|
|
expect(models.body.data.items.map((m) => m.model)).toEqual(['k2']);
|
|
});
|
|
|
|
it('never touches default_provider/default_model when deleting their owner (204, pointers dangling)', async () => {
|
|
await boot(DEFAULTED_TOML);
|
|
const { status, text } = await deleteJson<unknown>('/api/v1/providers/openai');
|
|
expect(status).toBe(204);
|
|
expect(text).toBe('');
|
|
|
|
const onDisk = await readConfigToml();
|
|
// The pointers are the user's settings: they stay put even though the
|
|
// provider/aliases they reference are gone.
|
|
expect(onDisk['default_provider']).toBe('openai');
|
|
expect(onDisk['default_model']).toBe('gpt4o');
|
|
expect(onDisk['providers']).toEqual({ kimi: { type: 'kimi', api_key: 'sk-test' } });
|
|
expect(onDisk['models']).toEqual({
|
|
k2: { provider: 'kimi', model: 'kimi-k2', max_context_size: 131072 },
|
|
});
|
|
});
|
|
|
|
it('round-trips a created provider: delete removes every trace from config.toml', async () => {
|
|
await boot();
|
|
const created = await postJson<unknown>('/api/v1/providers', CREATE_BODY);
|
|
expect(created.status).toBe(201);
|
|
|
|
const { status } = await deleteJson<unknown>('/api/v1/providers/my-openai');
|
|
expect(status).toBe(204);
|
|
|
|
const onDisk = await readConfigToml();
|
|
// The last provider/model drops the whole TOML table, not an empty stub.
|
|
expect(onDisk['providers']).toBeUndefined();
|
|
expect(onDisk['models']).toBeUndefined();
|
|
// Except the seeded global default: pointers are the user's settings and
|
|
// deletes never garbage-collect them (it dangles until re-pointed).
|
|
expect(onDisk['default_model']).toBe('my-openai/gpt-4.1');
|
|
|
|
const providers = await getJson<{ items: unknown[] }>('/api/v1/providers');
|
|
expect(providers.body.data.items).toEqual([]);
|
|
});
|
|
|
|
it('rejects deleting an OAuth-managed provider with 40003', async () => {
|
|
await boot(MANAGED_TOML);
|
|
const { body } = await deleteJson<unknown>('/api/v1/providers/managed%3Akimi-code');
|
|
expect(body?.code).toBe(40003);
|
|
expect(body?.msg).toContain('/oauth/logout');
|
|
|
|
const providers = await getJson<{ items: Array<{ id: string }> }>('/api/v1/providers');
|
|
expect(providers.body.data.items.map((p) => p.id)).toEqual(['managed:kimi-code']);
|
|
});
|
|
|
|
it('maps an unknown provider id to 40412 on delete', async () => {
|
|
await boot(KEEP_DEFAULT_TOML);
|
|
const { body } = await deleteJson<unknown>('/api/v1/providers/missing');
|
|
expect(body?.code).toBe(40412);
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// PUT /providers/{provider_id}
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('replaces a provider, keeping the stored api_key and rebuilding its aliases', async () => {
|
|
await boot(KEEP_DEFAULT_TOML);
|
|
const { status, body } = await putJson<{
|
|
provider: Record<string, unknown>;
|
|
}>('/api/v1/providers/openai', REPLACE_BODY);
|
|
expect(status).toBe(200);
|
|
expect(body.code).toBe(0);
|
|
expect(body.data.provider).toEqual({
|
|
id: 'openai',
|
|
type: 'openai',
|
|
base_url: 'https://api.openai.example/v1',
|
|
default_model: 'openai/gpt-4.1',
|
|
has_api_key: true,
|
|
status: 'connected',
|
|
models: ['openai/gpt-4.1', 'openai/gpt-4o-mini'],
|
|
});
|
|
|
|
const onDisk = await readConfigToml();
|
|
// The removed alias (`gpt4o`) is really gone from config.toml; the other
|
|
// provider's alias (`k2`) and its global default pointer are untouched.
|
|
expect(onDisk['providers']).toEqual({
|
|
kimi: { type: 'kimi', api_key: 'sk-test' },
|
|
openai: {
|
|
type: 'openai',
|
|
api_key: 'sk-openai',
|
|
base_url: 'https://api.openai.example/v1',
|
|
default_model: 'openai/gpt-4.1',
|
|
},
|
|
});
|
|
expect(onDisk['models']).toEqual({
|
|
k2: { provider: 'kimi', model: 'kimi-k2', max_context_size: 131072 },
|
|
'openai/gpt-4.1': {
|
|
provider: 'openai',
|
|
model: 'gpt-4.1',
|
|
max_context_size: 1047576,
|
|
display_name: 'GPT-4.1',
|
|
},
|
|
'openai/gpt-4o-mini': {
|
|
provider: 'openai',
|
|
model: 'gpt-4o-mini',
|
|
max_context_size: 128000,
|
|
},
|
|
});
|
|
expect(onDisk['default_model']).toBe('k2');
|
|
|
|
const models = await getJson<{ items: Array<{ model: string }> }>('/api/v1/models');
|
|
expect(models.body.data.items.map((m) => m.model)).toEqual([
|
|
'k2',
|
|
'openai/gpt-4.1',
|
|
'openai/gpt-4o-mini',
|
|
]);
|
|
});
|
|
|
|
it('sets a new api_key when a non-empty one is sent', async () => {
|
|
await boot(KEEP_DEFAULT_TOML);
|
|
const { status, body } = await putJson<{ provider: { has_api_key: boolean } }>(
|
|
'/api/v1/providers/openai',
|
|
{ ...REPLACE_BODY, api_key: 'sk-new-openai' },
|
|
);
|
|
expect(status).toBe(200);
|
|
expect(body.data.provider.has_api_key).toBe(true);
|
|
|
|
const onDisk = await readConfigToml();
|
|
expect(onDisk['providers']).toEqual({
|
|
kimi: { type: 'kimi', api_key: 'sk-test' },
|
|
openai: {
|
|
type: 'openai',
|
|
api_key: 'sk-new-openai',
|
|
base_url: 'https://api.openai.example/v1',
|
|
default_model: 'openai/gpt-4.1',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('clears the stored api_key when an empty string is sent', async () => {
|
|
await boot(KEEP_DEFAULT_TOML);
|
|
const { status, body } = await putJson<{
|
|
provider: { has_api_key: boolean; status: string };
|
|
}>('/api/v1/providers/openai', { ...REPLACE_BODY, api_key: '' });
|
|
expect(status).toBe(200);
|
|
expect(body.data.provider.has_api_key).toBe(false);
|
|
expect(body.data.provider.status).toBe('unconfigured');
|
|
|
|
// Cleared persists as `api_key = ""` — the same form authService writes
|
|
// for keyless providers; runtime credential resolution treats it as no key.
|
|
const onDisk = await readConfigToml();
|
|
expect(onDisk['providers']).toEqual({
|
|
kimi: { type: 'kimi', api_key: 'sk-test' },
|
|
openai: {
|
|
type: 'openai',
|
|
api_key: '',
|
|
base_url: 'https://api.openai.example/v1',
|
|
default_model: 'openai/gpt-4.1',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('merges onto existing model records: unknown fields preserved, form fields authoritative', async () => {
|
|
const RICH_TOML = [
|
|
'[providers.openai]',
|
|
'type = "openai"',
|
|
'api_key = "sk-openai"',
|
|
'',
|
|
'[models."openai/gpt-4o"]',
|
|
'provider = "openai"',
|
|
'model = "gpt-4o"',
|
|
'max_context_size = 128000',
|
|
'beta_api = true',
|
|
'default_effort = "high"',
|
|
'',
|
|
].join('\n');
|
|
await boot(RICH_TOML);
|
|
const { status, body } = await putJson<unknown>('/api/v1/providers/openai', {
|
|
type: 'openai',
|
|
models: [
|
|
{
|
|
model: 'gpt-4o',
|
|
max_context_size: 256000,
|
|
capabilities: ['thinking', 'tool_use'],
|
|
support_efforts: ['low', 'high', 'max'],
|
|
adaptive_thinking: true,
|
|
},
|
|
],
|
|
});
|
|
expect(status).toBe(200);
|
|
expect(body.code).toBe(0);
|
|
|
|
const onDisk = await readConfigToml();
|
|
expect(onDisk['models']).toEqual({
|
|
'openai/gpt-4o': {
|
|
provider: 'openai',
|
|
model: 'gpt-4o',
|
|
max_context_size: 256000,
|
|
// Unknown to the form — preserved:
|
|
beta_api: true,
|
|
default_effort: 'high',
|
|
// Form-authoritative:
|
|
capabilities: ['thinking', 'tool_use'],
|
|
support_efforts: ['low', 'high', 'max'],
|
|
adaptive_thinking: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('never touches default_model when the rebuild drops its alias (no rename)', async () => {
|
|
await boot(DEFAULTED_TOML);
|
|
const { status, body } = await putJson<unknown>(
|
|
'/api/v1/providers/openai',
|
|
{ ...REPLACE_BODY, type: 'openai_responses' },
|
|
);
|
|
expect(status).toBe(200);
|
|
expect(body.code).toBe(0);
|
|
|
|
const onDisk = await readConfigToml();
|
|
// `gpt4o` is gone from the models section, yet the global default pointer
|
|
// stays — it is the user's setting, not this endpoint's to clear.
|
|
expect(onDisk['default_model']).toBe('gpt4o');
|
|
expect(onDisk['default_provider']).toBe('openai');
|
|
expect(onDisk['providers']).toEqual({
|
|
kimi: { type: 'kimi', api_key: 'sk-test' },
|
|
openai: {
|
|
type: 'openai_responses',
|
|
api_key: 'sk-openai',
|
|
base_url: 'https://api.openai.example/v1',
|
|
default_model: 'openai/gpt-4.1',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('renames a provider: providers key, aliases, default_provider and default_model all migrate', async () => {
|
|
await boot(DEFAULTED_TOML);
|
|
const { status, body } = await putJson<{
|
|
provider: Record<string, unknown>;
|
|
}>('/api/v1/providers/openai', {
|
|
...REPLACE_BODY,
|
|
new_id: 'my-openai',
|
|
models: [
|
|
{ model: 'gpt-4o', max_context_size: 128000 },
|
|
{ model: 'gpt-4.1', max_context_size: 1047576 },
|
|
],
|
|
});
|
|
expect(status).toBe(200);
|
|
expect(body.code).toBe(0);
|
|
expect(body.data.provider['id']).toBe('my-openai');
|
|
|
|
const onDisk2 = await readConfigToml();
|
|
expect(onDisk2['providers']).toEqual({
|
|
kimi: { type: 'kimi', api_key: 'sk-test' },
|
|
'my-openai': {
|
|
type: 'openai',
|
|
api_key: 'sk-openai',
|
|
base_url: 'https://api.openai.example/v1',
|
|
default_model: 'my-openai/gpt-4.1',
|
|
},
|
|
});
|
|
expect(onDisk2['models']).toEqual({
|
|
k2: { provider: 'kimi', model: 'kimi-k2', max_context_size: 131072 },
|
|
'my-openai/gpt-4o': { provider: 'my-openai', model: 'gpt-4o', max_context_size: 128000 },
|
|
'my-openai/gpt-4.1': { provider: 'my-openai', model: 'gpt-4.1', max_context_size: 1047576 },
|
|
});
|
|
expect(onDisk2['default_provider']).toBe('my-openai');
|
|
// The old default alias (`gpt4o`, record model gpt-4o) is repointed at the
|
|
// rebuilt alias under the new prefix.
|
|
expect(onDisk2['default_model']).toBe('my-openai/gpt-4o');
|
|
});
|
|
|
|
it('migrates default_provider on rename but leaves default_model alone when its model was dropped', async () => {
|
|
await boot(DEFAULTED_TOML);
|
|
const { status, body } = await putJson<unknown>(
|
|
'/api/v1/providers/openai',
|
|
{ ...REPLACE_BODY, new_id: 'my-openai' },
|
|
);
|
|
expect(status).toBe(200);
|
|
expect(body.code).toBe(0);
|
|
|
|
const onDisk = await readConfigToml();
|
|
expect(onDisk['default_provider']).toBe('my-openai');
|
|
// The dropped model's alias can no longer be repointed — and the pointer
|
|
// is still left dangling rather than cleared.
|
|
expect(onDisk['default_model']).toBe('gpt4o');
|
|
});
|
|
|
|
it('rejects a rename to an existing provider id with 40921', async () => {
|
|
await boot(KEEP_DEFAULT_TOML);
|
|
const { status, body } = await putJson<unknown>('/api/v1/providers/openai', {
|
|
...REPLACE_BODY,
|
|
new_id: 'kimi',
|
|
});
|
|
expect(status).toBe(200);
|
|
expect(body.code).toBe(40921);
|
|
|
|
const onDisk = await readConfigToml();
|
|
expect(onDisk['providers']).toEqual({
|
|
kimi: { type: 'kimi', api_key: 'sk-test' },
|
|
openai: { type: 'openai', api_key: 'sk-openai' },
|
|
});
|
|
});
|
|
|
|
it('rejects invalid replace bodies with 40001', async () => {
|
|
await boot(KEEP_DEFAULT_TOML);
|
|
const cases: Array<{ name: string; body: unknown; path?: string }> = [
|
|
{ name: 'unknown wire type', body: { ...REPLACE_BODY, type: 'custom' }, path: 'type' },
|
|
{ name: 'empty models list', body: { ...REPLACE_BODY, models: [] }, path: 'models' },
|
|
{
|
|
name: 'default_model outside the models list',
|
|
body: { ...REPLACE_BODY, default_model: 'gpt-5' },
|
|
path: 'default_model',
|
|
},
|
|
];
|
|
for (const { name, body, path } of cases) {
|
|
const { body: envelope } = await putJson('/api/v1/providers/openai', body);
|
|
expect(envelope.code, name).toBe(40001);
|
|
expect(envelope.data, name).toBeNull();
|
|
if (path !== undefined) {
|
|
expect(
|
|
envelope.details?.some((detail) => detail.path === path),
|
|
name,
|
|
).toBe(true);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('rejects replacing an OAuth-managed provider with 40003', async () => {
|
|
await boot(MANAGED_TOML);
|
|
const { body } = await putJson<unknown>(
|
|
'/api/v1/providers/managed%3Akimi-code',
|
|
REPLACE_BODY,
|
|
);
|
|
expect(body.code).toBe(40003);
|
|
expect(body.msg).toContain('/oauth/logout');
|
|
|
|
// The managed provider and its alias are left untouched.
|
|
const providers = await getJson<{ items: Array<{ id: string }> }>('/api/v1/providers');
|
|
expect(providers.body.data.items.map((p) => p.id)).toEqual(['managed:kimi-code']);
|
|
const models = await getJson<{ items: Array<{ model: string }> }>('/api/v1/models');
|
|
expect(models.body.data.items.map((m) => m.model)).toEqual(['managed:kimi-code/kimi-k2']);
|
|
});
|
|
|
|
it('maps an unknown provider id to 40412 on replace', async () => {
|
|
await boot(KEEP_DEFAULT_TOML);
|
|
const { body } = await putJson<unknown>('/api/v1/providers/missing', REPLACE_BODY);
|
|
expect(body.code).toBe(40412);
|
|
expect(body.data).toBeNull();
|
|
});
|
|
|
|
// Field-level clears must reach the disk: the TOML transform overlays each
|
|
// kept entry onto its old on-disk raw, so a field absent from the body
|
|
// would silently survive (and resurrect on the next boot) unless the route
|
|
// assigns an explicit undefined. These cases lock the disk state, not just
|
|
// the in-memory view.
|
|
it('clears omitted provider fields (base_url/default_model) from config.toml for real', async () => {
|
|
const FULL_TOML = [
|
|
'[providers.openai]',
|
|
'type = "openai"',
|
|
'api_key = "sk-openai"',
|
|
'base_url = "https://api.openai.example/v1"',
|
|
'default_model = "openai/gpt-4.1"',
|
|
'custom_headers = { "X-Org" = "acme" }',
|
|
'',
|
|
'[models."openai/gpt-4.1"]',
|
|
'provider = "openai"',
|
|
'model = "gpt-4.1"',
|
|
'max_context_size = 1047576',
|
|
'display_name = "GPT-4.1"',
|
|
'capabilities = ["tool_use"]',
|
|
'',
|
|
].join('\n');
|
|
await boot(FULL_TOML);
|
|
const { status } = await putJson<unknown>('/api/v1/providers/openai', {
|
|
type: 'openai',
|
|
models: [{ model: 'gpt-4.1', max_context_size: 1047576 }],
|
|
});
|
|
expect(status).toBe(200);
|
|
|
|
const onDisk = await readConfigToml();
|
|
// base_url/default_model are gone from the DISK too — while the
|
|
// form-unknown custom_headers rides along.
|
|
expect(onDisk['providers']).toEqual({
|
|
openai: {
|
|
type: 'openai',
|
|
api_key: 'sk-openai',
|
|
custom_headers: { 'X-Org': 'acme' },
|
|
},
|
|
});
|
|
// Alias-level form fields are cleared the same way.
|
|
expect(onDisk['models']).toEqual({
|
|
'openai/gpt-4.1': {
|
|
provider: 'openai',
|
|
model: 'gpt-4.1',
|
|
max_context_size: 1047576,
|
|
},
|
|
});
|
|
|
|
// The in-memory projection agrees (no base_url/default_model either).
|
|
const single = await getJson<Record<string, unknown>>('/api/v1/providers/openai');
|
|
expect(single.body.data).not.toHaveProperty('base_url');
|
|
expect(single.body.data).not.toHaveProperty('default_model');
|
|
});
|
|
|
|
it('does not reveal an empty-string api_key on the single GET', async () => {
|
|
await boot(KEEP_DEFAULT_TOML);
|
|
// Clear the key first (persists as api_key = "")…
|
|
await putJson<unknown>('/api/v1/providers/openai', { ...REPLACE_BODY, api_key: '' });
|
|
// …then the single GET must not surface the empty sentinel as a real key.
|
|
const { body } = await getJson<Record<string, unknown>>('/api/v1/providers/openai');
|
|
expect(body.data).not.toHaveProperty('api_key');
|
|
});
|
|
|
|
it('rejects duplicate model rows with 40001 on both create and replace', async () => {
|
|
await boot(KEEP_DEFAULT_TOML);
|
|
const duplicate = {
|
|
...CREATE_BODY,
|
|
models: [
|
|
{ model: 'gpt-4.1', max_context_size: 1047576 },
|
|
{ model: 'gpt-4.1', max_context_size: 128000 },
|
|
],
|
|
};
|
|
const created = await postJson<unknown>('/api/v1/providers', duplicate);
|
|
expect(created.body.code).toBe(40001);
|
|
expect(created.body.msg).toContain('duplicate model');
|
|
|
|
const replaced = await putJson<unknown>('/api/v1/providers/openai', {
|
|
type: 'openai',
|
|
models: [
|
|
{ model: 'gpt-4.1', max_context_size: 1047576 },
|
|
{ model: 'gpt-4.1', max_context_size: 128000 },
|
|
],
|
|
});
|
|
expect(replaced.body.code).toBe(40001);
|
|
});
|
|
|
|
it('rejects a base_url containing an env placeholder with 40001', async () => {
|
|
await boot(KEEP_DEFAULT_TOML);
|
|
const { body } = await postJson<unknown>('/api/v1/providers', {
|
|
...CREATE_BODY,
|
|
base_url: 'https://${HOST}/v1',
|
|
});
|
|
expect(body.code).toBe(40001);
|
|
expect(body.msg).toContain('base_url');
|
|
});
|
|
|
|
it('trims a padded base_url before persisting', async () => {
|
|
await boot(KEEP_DEFAULT_TOML);
|
|
const { status, body } = await postJson<{ base_url?: string }>('/api/v1/providers', {
|
|
...CREATE_BODY,
|
|
base_url: ' https://api.openai.example/v1 ',
|
|
});
|
|
expect(status).toBe(201);
|
|
expect(body.data.base_url).toBe('https://api.openai.example/v1');
|
|
});
|
|
|
|
it('rejects a rename/rebuild whose alias key is owned by another provider (40001, no writes)', async () => {
|
|
// A foreign-prefix alias: the key says openai/… but the record belongs to
|
|
// "other" (hand-edited config or a historical leftover).
|
|
const FOREIGN_TOML = [
|
|
'[providers.openai]',
|
|
'type = "openai"',
|
|
'api_key = "sk-openai"',
|
|
'',
|
|
'[providers.other]',
|
|
'type = ' + '"anthropic"',
|
|
'api_key = "sk-other"',
|
|
'',
|
|
'[models."openai/gpt-4.1"]',
|
|
'provider = "other"',
|
|
'model = "claude-thing"',
|
|
'max_context_size = 200000',
|
|
'',
|
|
'[models."other/claude-thing"]',
|
|
'provider = "other"',
|
|
'model = "claude-thing"',
|
|
'max_context_size = 200000',
|
|
'',
|
|
].join('\n');
|
|
await boot(FOREIGN_TOML);
|
|
const { status, body } = await putJson<unknown>('/api/v1/providers/openai', {
|
|
type: 'openai',
|
|
models: [{ model: 'gpt-4.1', max_context_size: 1047576 }],
|
|
});
|
|
expect(status).toBe(200);
|
|
expect(body.code).toBe(40001);
|
|
expect(body.msg).toContain('openai/gpt-4.1');
|
|
|
|
// No partial write: both providers and the foreign alias are untouched.
|
|
const onDisk = await readConfigToml();
|
|
expect(onDisk['providers']).toEqual({
|
|
openai: { type: 'openai', api_key: 'sk-openai' },
|
|
other: { type: 'anthropic', api_key: 'sk-other' },
|
|
});
|
|
const models = onDisk['models'] as Record<string, Record<string, unknown>>;
|
|
expect(models['openai/gpt-4.1']).toEqual({
|
|
provider: 'other',
|
|
model: 'claude-thing',
|
|
max_context_size: 200000,
|
|
});
|
|
});
|
|
});
|