kimi-code/packages/agent-core/test/harness/model-alias-session.test.ts
Kai b5efba7abc
fix: consume the model metadata declared by the models.dev catalog (#2015)
* fix: stop advertising Claude thinking efforts for non-Claude models

Models served over the Anthropic protocol whose names carry no Claude
marker (e.g. a catalog-imported Kimi K3) no longer inherit the latest
Opus effort list, so the model selector stops offering levels the model
does not accept. The models.dev catalog import now also parses
reasoning_options and records the declared effort levels on the model
alias, so K3 offers its real levels (low / high / max).

* fix: consume deprecated, override, and input-limit metadata from the models.dev catalog

- Models declared status=deprecated in the catalog are no longer
  offered for import.
- Per-model provider overrides on gateway providers (an npm package
  targeting an Anthropic SDK plus a usable endpoint) now land as alias
  protocol and base_url, so those models are served over the right
  protocol and endpoint; overrides without a usable URL are skipped.
- A declared limit.input now sizes the context budget instead of the
  larger total context window (e.g. gpt-5: 272k instead of 400k).

The model alias schema gains an optional base_url field (not accepted
in overrides) that Anthropic wire resolution prefers over the
provider-level base URL.

* fix: honor thinking-disable semantics and the OpenAI-compatible fallback in catalog imports

- reasoning_options 'none' is the model's off encoding: off_effort flows
  from the catalog through the model alias to the OpenAI wire providers,
  so turning thinking off sends 'none' instead of omitting the effort
  field; models with effort levels but no way to disable thinking are
  imported as always_thinking and no longer offer an Off option.
- Bare Claude family aliases (e.g. sonnet-latest) recover the inferred
  Anthropic effort profile; v2 comment conventions restored.
- Providers whose SDK the catalog does not type now fall back to the
  OpenAI-compatible wire (with a visible "guessed" note) instead of
  being refused; imports lacking a usable endpoint ask for one
  (--base-url on the CLI, a prompt in the TUI). Proprietary SDKs
  (Amazon Bedrock), unrecognized explicit types, and env-placeholder
  URLs are refused with a clear reason.

* fix: align catalog imports with the reference models.dev consumer

- A JSON null tier in declared effort values is now read as the 'none'
  off-encoding (previously such models were wrongly imported as
  always-thinking with no way to turn reasoning off).
- Alpha-status models are filtered out alongside deprecated ones.
- Models whose per-model provider override targets a wire that cannot be
  expressed per-model (e.g. Claude on google-vertex, whose wire here is
  Gemini-mode Vertex, or gpt entries on an Anthropic provider) are
  skipped instead of being imported under the silently wrong protocol.
- interleaved: true no longer pins reasoning_content: the provider's
  default three-field scan is wider and the pinned key only narrowed
  reasoning parsing for gateways answering with another field name.

* fix: require endpoints for Anthropic-compatible catalog imports and honor --base-url

- catalogProviderNeedsBaseUrl now covers the Anthropic wire: a
  non-official Anthropic-compatible vendor without a concrete catalog
  endpoint (e.g. google-vertex-anthropic) must supply --base-url / the
  TUI prompt instead of silently falling back to the default Anthropic
  endpoint.
- --base-url now takes precedence over the catalog-declared endpoint,
  and an empty --base-url is rejected instead of persisting a blank
  endpoint.

* fix: enforce always-on thinking on every wire and refuse Cohere at import

A model that declares always_thinking (e.g. a catalog-imported gpt-5)
no longer resolves to a dishonest off state via thinking.enabled=false
or an SDK/ACP off request: resolution clamps to the model's default
effort on every wire instead of letting upstream keep reasoning while
the UI reports Off. The Anthropic warn-and-send path for unlisted
effort levels is unchanged. Cohere's proprietary SDK joins Amazon
Bedrock on the import-refusal list instead of being guessed as
OpenAI-compatible.

* fix: harden catalog import edge cases

- An explicit but unrecognized catalog type is now refused before
  npm/id inference, so a future catalog protocol is never silently
  miswired through the OpenAI fallback.
- User-supplied --base-url values for Anthropic-wire providers get the
  same trailing-/v1 normalization as catalog endpoints, avoiding
  /v1/v1/messages requests.
- The TUI import prompt rejects env-placeholder base URLs like the CLI
  does.

* fix: await the floating assertion promise in the catalog add CLI test

* refactor: unify catalog import resolution into a single decision function

Wire-type inference, the OpenAI-compatible fallback, proprietary-SDK
refusal, endpoint adaptation, and the base-URL requirement are now
produced together by resolveCatalogImport, one pure resolver consumed
by both the CLI and the TUI — replacing the cooperating predicates
(inferWireType, isGuessedWireType, catalogProviderNeedsBaseUrl) whose
permutations kept producing edge cases. No behavior change.

* fix: close configured-off clamp hole, keep inferWireType compat, carry same-wire override endpoints

- A configured thinking.effort = "off" no longer bypasses the
  always-on clamp: it is treated as absent and the model default
  applies, mirrored on both engines.
- The previously public inferWireType stays as a deprecated
  compatibility wrapper over resolveCatalogImport so existing SDK
  consumers do not break on a patch release.
- Catalog model overrides that stay on the provider's wire but declare
  their own endpoint now persist it on the alias (and the v1 OpenAI
  wire branches honor alias-level base URLs like the Anthropic branch).

* fix: split total window from input cap and close override/endpoint gaps

- max_context_tokens once again means the total context window (used by
  completion budgeting); a model's declared input limit is tracked as
  max_input_tokens, which compaction, context-splice and usage-ratio
  checks prefer — fixing the over-clamping introduced when the input
  cap was stored as the context budget.
- A catalog endpoint declared only as an env placeholder now always
  produces needs-base-url (official SDK included), so credentials are
  never sent to the public vendor host by default.
- api-only per-model overrides are honored as same-wire endpoint
  changes; overrides targeting another known but inexpressible wire
  (e.g. google-genai on an OpenAI gateway) are skipped; same-wire
  models whose declared endpoint is an unusable placeholder are
  skipped instead of silently rerouted.

* style: drop a function-level comment from the v2 thinking resolver

* chore: consolidate the PR's changesets into two user-facing entries
2026-07-22 01:23:31 +08:00

608 lines
20 KiB
TypeScript

import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'pathe';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
createRPC,
KimiCore,
type CoreAPI,
type SDKAPI,
type TelemetryClient,
} from '../../src';
import {
__resetRootLoggerForTest,
getRootLogger,
} from '../../src/logging/logger';
import { resolveLoggingConfig } from '../../src/logging/resolve-config';
import {
recordingContextTelemetry,
type TelemetryContextRecord,
} from '../fixtures/telemetry';
const CONFIG = `
default_model = "kimi-code/kimi-for-coding"
[providers."managed:kimi-code"]
type = "kimi"
api_key = "test-key"
base_url = "https://api.example/v1"
[models."kimi-code/kimi-for-coding"]
provider = "managed:kimi-code"
model = "kimi-for-coding"
max_context_size = 1000000
capabilities = ["thinking"]
support_efforts = ["low", "medium", "high"]
default_effort = "high"
`;
describe('HarnessAPI session model aliases', () => {
let tmp: string;
let homeDir: string;
let workDir: string;
let configPath: string;
beforeEach(async () => {
tmp = await mkdtemp(join(tmpdir(), 'kimi-model-alias-'));
homeDir = join(tmp, 'home');
workDir = join(tmp, 'work');
configPath = join(tmp, 'config.toml');
await mkdir(workDir, { recursive: true });
await writeFile(configPath, CONFIG);
});
afterEach(async () => {
await __resetRootLoggerForTest();
await rm(tmp, { recursive: true, force: true });
});
const compatibleConfig = (supportEfforts: string, defaultEffort: string) => `
default_model = "compatible/model"
[providers.compatible]
type = "kimi"
api_key = "test-key"
base_url = "https://api.example.test"
[models."compatible/model"]
provider = "compatible"
model = "compatible-model"
protocol = "anthropic"
max_context_size = 128000
capabilities = ["thinking"]
support_efforts = [${supportEfforts}]
default_effort = "${defaultEffort}"
`;
async function createEffortReplaySession(): Promise<string> {
await writeFile(configPath, compatibleConfig('"high", "max"', 'high'));
const rpc = await createTestRpc();
const created = await rpc.createSession({ workDir, model: 'compatible/model' });
await rpc.setThinking({ sessionId: created.id, agentId: 'main', effort: 'max' });
await rpc.closeSession({ sessionId: created.id });
await writeFile(configPath, compatibleConfig('"max"', 'max'));
return created.id;
}
it('keeps the configured alias separate from the provider model across create, setModel, and resume', async () => {
const rpc = await createTestRpc();
const created = await rpc.createSession({
workDir,
model: 'kimi-code/kimi-for-coding',
});
expect(await rpc.getModel({ sessionId: created.id, agentId: 'main' })).toBe(
'kimi-code/kimi-for-coding',
);
const config = await rpc.getConfig({ sessionId: created.id, agentId: 'main' });
expect(config.modelAlias).toBe('kimi-code/kimi-for-coding');
expect(config.provider?.model).toBe('kimi-for-coding');
expect(config.modelCapabilities?.max_context_tokens).toBe(1_000_000);
await rpc.setModel({
sessionId: created.id,
agentId: 'main',
model: 'kimi-code/kimi-for-coding',
});
const freshRpc = await createTestRpc();
await freshRpc.resumeSession({ sessionId: created.id });
expect(await freshRpc.getModel({ sessionId: created.id, agentId: 'main' })).toBe(
'kimi-code/kimi-for-coding',
);
});
it('resolves the initial effort with provider context for an Anthropic-typed provider', async () => {
// The model name is unknown to the Anthropic profile matrix but still
// carries a Claude marker, and the alias declares no protocol/capabilities
// itself; the provider's `type = "anthropic"` must still route the
// default resolution through the inferred profile (default effort
// "high"), not fall back to "off".
await writeFile(
configPath,
`
default_model = "compat/custom"
[providers.compat]
type = "anthropic"
api_key = "test-key"
base_url = "https://api.example.test"
[models."compat/custom"]
provider = "compat"
model = "joint-claude-0714-vibe"
max_context_size = 200000
`,
);
const rpc = await createTestRpc();
const created = await rpc.createSession({ workDir });
const config = await rpc.getConfig({ sessionId: created.id, agentId: 'main' });
expect(config.thinkingEffort).toBe('high');
// The recorded bootstrap effort must survive resume unchanged.
await rpc.closeSession({ sessionId: created.id });
const freshRpc = await createTestRpc();
await freshRpc.resumeSession({ sessionId: created.id });
const restored = await freshRpc.getConfig({ sessionId: created.id, agentId: 'main' });
expect(restored.thinkingEffort).toBe('high');
});
it('honors an explicit session effort for an Anthropic-typed provider', async () => {
await writeFile(
configPath,
`
default_model = "compat/custom"
[providers.compat]
type = "anthropic"
api_key = "test-key"
base_url = "https://api.example.test"
[models."compat/custom"]
provider = "compat"
model = "joint-model-0714-vibe"
max_context_size = 200000
`,
);
const rpc = await createTestRpc();
const created = await rpc.createSession({ workDir, thinking: 'low' });
const config = await rpc.getConfig({ sessionId: created.id, agentId: 'main' });
expect(config.thinkingEffort).toBe('low');
});
it('restores the final effort after replaying an earlier unlisted Anthropic effort', async () => {
const sessionId = await createEffortReplaySession();
// The current catalog no longer lists the earlier `high` state. Replay
// must continue to the following `max` record instead of validating and
// aborting at the transient state.
const events: Array<Parameters<SDKAPI['emitEvent']>[0]> = [];
const freshRpc = await createTestRpc({ emitEvent: (event) => events.push(event) });
await expect(freshRpc.resumeSession({ sessionId })).resolves.toBeDefined();
expect(events).toContainEqual({
sessionId,
agentId: 'main',
type: 'warning',
code: 'anthropic-thinking-effort-not-listed',
message:
'Thinking effort "high" is not listed for model "compatible-model" (known: max). The configured value will be sent unchanged to the Anthropic-compatible backend.',
});
const restored = await freshRpc.getConfig({ sessionId, agentId: 'main' });
expect(restored.modelAlias).toBe('compatible/model');
expect(restored.thinkingEffort).toBe('max');
});
it('does not block resume when the warning sink fails', async () => {
const sessionId = await createEffortReplaySession();
const throwingRpc = await createTestRpc({
emitEvent: () => {
throw new Error('warning sink failed');
},
});
await expect(throwingRpc.resumeSession({ sessionId })).resolves.toBeDefined();
});
it('re-bootstraps profile and model when resuming a session whose wire has no config.update', async () => {
// A migrated session ships a wire.jsonl with only `metadata` and message
// records — none of the `config.update` / `tools.set_active_tools`
// bootstrap events a natively-created session writes. Resuming it must
// still yield a usable agent (model + system prompt), not an empty config.
const rpc = await createTestRpc();
const created = await rpc.createSession({
workDir,
model: 'kimi-code/kimi-for-coding',
});
await rpc.closeSession({ sessionId: created.id });
const wirePath = await findWireFile(homeDir);
const kept = (await readFile(wirePath, 'utf-8'))
.split('\n')
.filter((line) => line.trim().length > 0)
.filter((line) => {
const type = (JSON.parse(line) as { type?: string }).type;
return type !== 'config.update' && type !== 'tools.set_active_tools';
});
await writeFile(wirePath, `${kept.join('\n')}\n`);
const freshRpc = await createTestRpc();
await freshRpc.resumeSession({ sessionId: created.id });
expect(await freshRpc.getModel({ sessionId: created.id, agentId: 'main' })).toBe(
'kimi-code/kimi-for-coding',
);
const config = await freshRpc.getConfig({ sessionId: created.id, agentId: 'main' });
expect(config.modelAlias).toBe('kimi-code/kimi-for-coding');
expect(config.systemPrompt.length).toBeGreaterThan(0);
});
it('applies RPC config updates to later agent model changes', async () => {
const rpc = await createTestRpc();
const created = await rpc.createSession({
workDir,
model: 'kimi-code/kimi-for-coding',
});
const updatedConfig = await rpc.setKimiConfig({
defaultModel: 'gpt-alias',
providers: {
openai: {
type: 'openai',
apiKey: 'sk-openai',
baseUrl: 'https://openai.example/v1',
},
},
models: {
'gpt-alias': {
provider: 'openai',
model: 'gpt-runtime',
maxContextSize: 200000,
capabilities: ['tool_use'],
},
},
});
expect(updatedConfig.defaultModel).toBe('gpt-alias');
await expect(
rpc.setModel({
sessionId: created.id,
agentId: 'main',
model: 'gpt-alias',
}),
).resolves.toEqual({
model: 'gpt-alias',
providerName: 'openai',
});
const config = await rpc.getConfig({ sessionId: created.id, agentId: 'main' });
expect(config.modelAlias).toBe('gpt-alias');
expect(config.provider).toMatchObject({
type: 'openai',
model: 'gpt-runtime',
apiKey: 'sk-openai',
baseUrl: 'https://openai.example/v1',
});
expect(config.modelCapabilities).toMatchObject({
tool_use: true,
max_context_tokens: 200000,
});
});
it('can create an unconfigured session when no model is selected', async () => {
await writeFile(configPath, '');
const rpc = await createTestRpc();
const created = await rpc.createSession({ workDir });
expect(created.id.startsWith('session_')).toBe(true);
expect(await rpc.getModel({ sessionId: created.id, agentId: 'main' })).toBe('');
});
it('loads configured permission rules into created and resumed sessions', async () => {
await writeFile(
configPath,
`${CONFIG}
[[permission.deny]]
tool = "Bash"
match = "rm *"
reason = "no rm"
`,
);
const rpc = await createTestRpc();
const created = await rpc.createSession({ workDir });
await expect(rpc.getPermission({ sessionId: created.id, agentId: 'main' })).resolves.toEqual({
mode: 'manual',
rules: [
{
decision: 'deny',
scope: 'user',
pattern: 'Bash(rm *)',
reason: 'no rm',
},
],
});
const freshRpc = await createTestRpc();
await freshRpc.resumeSession({ sessionId: created.id });
await expect(
freshRpc.getPermission({ sessionId: created.id, agentId: 'main' }),
).resolves.toEqual({
mode: 'manual',
rules: [
{
decision: 'deny',
scope: 'user',
pattern: 'Bash(rm *)',
reason: 'no rm',
},
],
});
});
it('uses configured default permission mode for fresh sessions', async () => {
await writeFile(
configPath,
CONFIG.replace(
'default_model = "kimi-code/kimi-for-coding"',
'default_model = "kimi-code/kimi-for-coding"\ndefault_permission_mode = "auto"',
),
);
const rpc = await createTestRpc();
const created = await rpc.createSession({ workDir });
await expect(rpc.getPermission({ sessionId: created.id, agentId: 'main' })).resolves.toEqual({
mode: 'auto',
rules: [],
});
const explicit = await rpc.createSession({ workDir, permission: 'manual' });
await expect(rpc.getPermission({ sessionId: explicit.id, agentId: 'main' })).resolves.toEqual({
mode: 'manual',
rules: [],
});
});
it('does not expose raw provider switching through the core RPC surface', async () => {
const rpc = (await createTestRpc()) as unknown as Record<string, unknown>;
expect(rpc['setProvider']).toBeUndefined();
});
it('exposes the core package version as read-only metadata', async () => {
const rpc = await createTestRpc();
const pkg = JSON.parse(
await readFile(new URL('../../package.json', import.meta.url), 'utf-8'),
) as { version: string };
await expect(rpc.getCoreInfo({})).resolves.toEqual({
version: pkg.version,
});
expect((rpc as unknown as Record<string, unknown>)['setVersion']).toBeUndefined();
});
it('keeps the resumed model alias visible when it no longer resolves', async () => {
const rpc = await createTestRpc();
const created = await rpc.createSession({ workDir, model: 'kimi-code/kimi-for-coding' });
await rpc.closeSession({ sessionId: created.id });
// The config now has no models and no default model — the alias replayed
// from the session is invalid and there is no fallback to resolve.
await writeFile(configPath, '');
const freshRpc = await createTestRpc();
await freshRpc.resumeSession({ sessionId: created.id });
// The stale alias stays visible so the UI can surface which model the
// user had selected. The next prompt will raise MODEL_NOT_CONFIGURED.
expect(await freshRpc.getModel({ sessionId: created.id, agentId: 'main' })).toBe(
'kimi-code/kimi-for-coding',
);
});
it('logs app_version when resuming a session', async () => {
await getRootLogger().configure(resolveLoggingConfig({ homeDir }));
const rpc = await createTestRpc();
const created = await rpc.createSession({ workDir, model: 'kimi-code/kimi-for-coding' });
await rpc.closeSession({ sessionId: created.id });
const freshRpc = await createTestRpc({ appVersion: '1.2.3-test' });
await freshRpc.resumeSession({ sessionId: created.id });
await getRootLogger().flushSession(created.id);
const logText = await readFile(join(created.sessionDir, 'logs', 'kimi-code.log'), 'utf-8');
expect(logText).toContain('session resume');
expect(logText).toContain('app_version=1.2.3-test');
});
it('surfaces a config error when a resumed model is configured but unresolvable', async () => {
const rpc = await createTestRpc();
const created = await rpc.createSession({ workDir, model: 'kimi-code/kimi-for-coding' });
await rpc.closeSession({ sessionId: created.id });
// The model alias is still in config, but it now references a provider
// that does not exist. That is an actionable config error — resume must
// surface it, not silently fall back to another model or clear it.
await writeFile(
configPath,
`
default_model = "kimi-code/kimi-for-coding"
[providers."managed:kimi-code"]
type = "kimi"
api_key = "test-key"
base_url = "https://api.example/v1"
[models."kimi-code/kimi-for-coding"]
provider = "ghost-provider"
model = "kimi-for-coding"
max_context_size = 1000000
`,
);
const freshRpc = await createTestRpc();
await expect(freshRpc.resumeSession({ sessionId: created.id })).rejects.toThrow();
});
it('scopes agent telemetry events to the owning session', async () => {
const createRecords: TelemetryContextRecord[] = [];
const createRpc = await createTestRpc({ telemetry: recordingContextTelemetry(createRecords) });
const created = await createRpc.createSession({
workDir,
model: 'kimi-code/kimi-for-coding',
});
await createRpc.setPermission({ sessionId: created.id, agentId: 'main', mode: 'yolo' });
expect(createRecords).toContainEqual({
event: 'yolo_toggle',
sessionId: created.id,
properties: { enabled: true, agent_id: 'main' },
});
await createRpc.setPermission({ sessionId: created.id, agentId: 'main', mode: 'auto' });
expect(createRecords).toContainEqual({
event: 'afk_toggle',
sessionId: created.id,
properties: { enabled: true, agent_id: 'main' },
});
await createRpc.setKimiConfig({
defaultModel: 'gpt-alias',
providers: {
openai: {
type: 'openai',
apiKey: 'sk-openai',
baseUrl: 'https://openai.example/v1',
},
},
models: {
'gpt-alias': {
provider: 'openai',
model: 'gpt-runtime',
maxContextSize: 200000,
},
},
});
await createRpc.setModel({
sessionId: created.id,
agentId: 'main',
model: 'gpt-alias',
});
expect(createRecords).toContainEqual({
event: 'model_switch',
sessionId: created.id,
properties: { model: 'gpt-alias', agent_id: 'main' },
});
const resumeRecords: TelemetryContextRecord[] = [];
const resumeRpc = await createTestRpc({ telemetry: recordingContextTelemetry(resumeRecords) });
await resumeRpc.resumeSession({ sessionId: created.id });
await resumeRpc.setThinking({ sessionId: created.id, agentId: 'main', effort: 'off' });
expect(resumeRecords).toContainEqual({
event: 'thinking_toggle',
sessionId: created.id,
properties: { enabled: false, effort: 'off', from: 'high', agent_id: 'main' },
});
});
it('tracks session_load_failed with the attempted session context', async () => {
const rpc = await createTestRpc();
const created = await rpc.createSession({ workDir });
await writeFile(join(created.sessionDir, 'state.json'), '{bad json', 'utf-8');
const records: TelemetryContextRecord[] = [];
const freshRpc = await createTestRpc({ telemetry: recordingContextTelemetry(records) });
await expect(freshRpc.resumeSession({ sessionId: created.id })).rejects.toThrow();
expect(records).toContainEqual({
event: 'session_load_failed',
sessionId: created.id,
properties: { reason: 'SyntaxError' },
});
});
it('adds web client metadata to new-session telemetry', async () => {
const records: TelemetryContextRecord[] = [];
const rpc = await createTestRpc({ telemetry: recordingContextTelemetry(records) });
const created = await rpc.createSession({
workDir,
client: {
id: 'web_test_client',
name: 'kimi-code-web',
version: '0.1.1',
uiMode: 'web',
},
});
expect(records).toContainEqual({
event: 'session_started',
sessionId: created.id,
properties: {
client_id: 'web_test_client',
client_name: 'kimi-code-web',
client_version: '0.1.1',
ui_mode: 'web',
resumed: false,
},
});
await rpc.setPermission({ sessionId: created.id, agentId: 'main', mode: 'yolo' });
expect(records).toContainEqual({
event: 'yolo_toggle',
sessionId: created.id,
properties: {
client_id: 'web_test_client',
client_name: 'kimi-code-web',
client_version: '0.1.1',
ui_mode: 'web',
enabled: true,
agent_id: 'main',
},
});
});
async function findWireFile(root: string): Promise<string> {
const suffix = join('agents', 'main', 'wire.jsonl');
const entries = await readdir(root, { recursive: true });
const match = entries.find((entry) => entry.replaceAll('\\', '/').endsWith(suffix));
if (match === undefined) {
throw new Error('wire.jsonl not found under session home');
}
return join(root, match);
}
async function createTestRpc(
options: {
readonly appVersion?: string;
readonly emitEvent?: (event: Parameters<SDKAPI['emitEvent']>[0]) => void;
readonly telemetry?: TelemetryClient;
} = {},
) {
const [coreRpc, sdkRpc] = createRPC<CoreAPI, SDKAPI>();
void new KimiCore(coreRpc, {
homeDir,
configPath,
appVersion: options.appVersion,
telemetry: options.telemetry,
});
return sdkRpc({
emitEvent: options.emitEvent ?? vi.fn(),
requestApproval: vi.fn(async () => ({ decision: 'rejected' as const })),
requestQuestion: vi.fn(async () => null),
toolCall: vi.fn(async () => ({ output: '' })),
});
}
});