kimi-code/packages/agent-core/test/harness/runtime-provider.test.ts
Petrichor cdbd33c13c
fix(kosong): fail fast on quota-exhausted 429 instead of retrying (#1857)
* fix(kosong): fail fast on quota-exhausted 429 instead of retrying

A 429 caused by an exhausted account quota or insufficient balance
(Moonshot error.type "exceeded_current_quota_error", OpenAI
"insufficient_quota") can never succeed on retry, yet it was classified
as APIProviderRateLimitError and silently retried for the whole budget
(10 attempts, ~3 minutes of backoff) with no UI feedback — the session
appeared frozen on every request.

Introduce APIProviderQuotaExhaustedError, minted in
normalizeAPIStatusError from the structured body error.type/error.code
forwarded by convertOpenAIError, with billing-anchored message patterns
as a fallback for gateways that flatten the body to text. The new class
is excluded from isRetryableGenerateError (fail fast, even when a
retry-after header is present) and from isProviderRateLimitError (no
swarm requeue/suspend). toKimiErrorPayload and translateProviderError
map it to provider.api_error (retryable: false) instead of
provider.rate_limit, and classifyApiError reports it as
quota_exhausted in telemetry. agent-core-v2 mirrors the same fix.

Transient rate-limit 429s keep the existing retry, backoff, and
Retry-After behavior (verified end-to-end against a mock provider:
quota body fails after attempt 1/10; rate-limit body still walks the
full 10-attempt ladder).

Behavior changes to note: quota-failed swarm subagents now fail
instead of suspending indefinitely as "Rate limited...", and quota
errors cross the wire as provider.api_error rather than
provider.rate_limit.

* fix(kosong): classify quota exhaustion in OpenAI Responses stream errors

Responses response.failed / error SSE events carry no HTTP status and
were minted by errorFromOpenAIResponsesEvent as either a rate-limit
error (rate_limit_exceeded / embedded status_code=429) or a base
ChatProviderError — and the base class falls into the retryable
unclassified-failure fallback, so an insufficient_quota event still
burned the whole retry budget on the openai_responses path. Route the
event code and message through the same quota-exhausted check before
the rate-limit branch, in kosong and the agent-core-v2 mirror. Covers
all three entry paths (error events, response.failed, nested gateway
frames) since they share the single converter.

* style(agent-core-v2): drop inline comments per AGENTS.md header-only rule

agent-core-v2 comments live solely in the top-of-file block, never
beside functions or statements; the kosong twins keep the full
rationale.

* refactor(kosong,agent-core-v2): move quota-429 checks to vendor hook

Per review on #1857: the knowledge of how a backend signals quota
exhaustion is vendor-specific and must not run for every
OpenAI-compatible provider from the shared conversion layer.

- Add a convertError hook: ProtocolTrait.convertError in agent-core-v2
  (single-value, last-declarer-wins, bound by composeOpenAIChatHooks /
  composeAnthropicHooks / traitConvertError) and an equivalent optional
  hook parameter on convertOpenAIError / convertAnthropicError. Bases
  consult it with the raw failure (SDK error on HTTP paths, raw event on
  the Responses in-stream path) after the abort guard, before their own
  rules.
- Declare Moonshot's quota signals (exceeded_current_quota_error,
  billing wordings) on the Kimi side: kimiOpenAITrait and
  kimiAnthropicTrait in v2, the KimiChatProvider and KimiFiles catch
  sites in kosong, all through the new classifyKimiQuotaError.
- Drop the options parameter from normalizeAPIStatusError and the
  shared quota code/pattern tables: the contract layer keeps only the
  vendor-neutral APIProviderQuotaExhaustedError type and its retry /
  rate-limit / wire-mapping semantics.
- The OpenAI bases keep recognizing only OpenAI's own documented
  insufficient_quota code (HTTP and Responses stream events) as
  protocol knowledge of that wire.

Behavior: kimi and openai provider types classify exactly as before;
an unregistered vendor speaking Moonshot billing wordings through a
plain openai transport now stays a retryable rate limit by design.

* fix(kosong,agent-core,agent-core-v2): wire kimi quota hook fully

Follow-up to the second review round on #1857, all four findings:

- Kimi-over-Anthropic (legacy engine): AnthropicOptions gains the same
  optional convertError hook as the OpenAI bases, threaded through
  AnthropicStreamedMessage and every catch site, and the provider
  manager's anthropic route now passes classifyKimiQuotaError for
  provider type kimi — a quota-exhausted 429 over this transport
  previously still burned the retry budget. classifyKimiQuotaError now
  also walks error -> .error -> .error.error for the code/type, since
  the Anthropic SDK keeps the full body on .error instead of hoisting.
- v2 telemetry: ApiErrorKind gains 'quota_exhausted' and
  classifyApiError checks APIProviderQuotaExhaustedError before the
  generic 429 branch, matching the legacy engine's reporting.
- Hook contract: converted ChatProviderErrors now pass through before
  the vendor hook is consulted in convertOpenAIError /
  convertAnthropicError (both engines), so the hook sees each raw
  failure exactly once even when a stream-minted error crosses an
  outer catch; tests assert the single consult.
- protocolTrait: the convertError member doc shrinks to the concise
  style and the consult contract moves into the file header's
  composition rules.

* test(kosong,agent-core,agent-core-v2): lock quota hook assembly paths

Third review round on #1857:

- Fix the v2 anthropic base header and AnthropicHooks doc still claiming
  withThinking is the only hook.
- Drop the two remaining non-header JSDoc blocks in protocolTrait.ts per
  the AGENTS.md header-only rule; the consult contract already lives in
  the file header.
- Update the ProtocolTrait contract test to the seventeen-hook shape
  (convertError included) and cover the traitConvertError binding.
- Add real-assembly regression probes: the v2 registry composes a
  (kimi, anthropic) provider whose mocked SDK client throws a Moonshot
  quota 429 and generate rejects with the non-retryable
  APIProviderQuotaExhaustedError (a plain anthropic composition keeps
  the same 429 retryable); the legacy ProviderManager routing test
  asserts convertError is classifyKimiQuotaError on the kimi-anthropic
  route and absent for plain anthropic; the legacy provider threads
  options.convertError to its generate catch.

* test(kosong,agent-core-v2): cover KimiFiles quota 429 and drop stale docs

Fourth review round on #1857:

- Drop the AnthropicHooks member JSDoc (its content already lives in the
  anthropic.ts and anthropicHooks.ts file headers) and fix the anthropic
  contrib header still calling the hook set single-hook.
- Add the missing KimiFiles regression in both engines: a mocked files
  client rejecting with a Moonshot quota 429 makes uploadVideo reject
  with the non-retryable APIProviderQuotaExhaustedError, locking the
  classifyKimiQuotaError argument at the upload catch sites.
2026-07-28 14:35:12 +08:00

1329 lines
39 KiB
TypeScript

import { classifyKimiQuotaError } from '@moonshot-ai/kosong';
import { describe, expect, it } from 'vitest';
import type { KimiConfig, ModelAlias } from '../../src/config';
import { ErrorCodes, KimiError } from '../../src/errors';
import { ProviderManager } from '../../src/session/provider-manager';
import { resolveThinkingEffort } from '../../src/agent/config/thinking';
// Thin wrapper that adapts the legacy `resolveRuntimeProvider(input)` shape to
// the current ProviderManager API. Kept local so the existing test bodies do
// not need to change.
function resolveRuntimeProvider(input: {
readonly config: KimiConfig;
readonly model?: string;
readonly kimiRequestHeaders?: Record<string, string>;
readonly promptCacheKey?: string;
}): ReturnType<ProviderManager['resolveProviderConfig']> {
const manager = new ProviderManager({
config: input.config,
kimiRequestHeaders: input.kimiRequestHeaders,
promptCacheKey: input.promptCacheKey,
});
const model = input.model ?? input.config.defaultModel;
if (model === undefined) {
throw new KimiError(
ErrorCodes.CONFIG_INVALID,
'No model is selected. Set default_model in config.toml or pass a configured model alias.',
);
}
return manager.resolveProviderConfig(model);
}
const BASE_CONFIG: KimiConfig = {
defaultModel: 'kimi-code/kimi-for-coding',
providers: {
'managed:kimi-code': {
type: 'kimi',
apiKey: 'test-key',
baseUrl: 'https://api.example/v1',
},
},
models: {
'kimi-code/kimi-for-coding': {
provider: 'managed:kimi-code',
model: 'kimi-for-coding',
maxContextSize: 1_000_000,
capabilities: ['thinking', 'image_in', 'video_in', 'tool_use'],
},
},
};
const TEST_KIMI_HEADERS = {
'User-Agent': 'kimi-code-cli/0.0.0-test',
'X-Msh-Platform': 'kimi_code_cli',
'X-Msh-Version': '0.0.0-test',
};
describe('resolveRuntimeProvider model metadata', () => {
it('uses config model metadata as the source of truth', () => {
const resolved = resolveRuntimeProvider({
config: BASE_CONFIG,
});
expect(resolved.modelCapabilities).toMatchObject({
image_in: true,
video_in: true,
thinking: true,
tool_use: true,
max_context_tokens: 1_000_000,
});
expect(resolved.provider.model).toBe('kimi-for-coding');
});
it('resolves requested aliases to the configured provider and provider model', () => {
const resolved = resolveRuntimeProvider({
config: {
...BASE_CONFIG,
providers: {
...BASE_CONFIG.providers,
openai: {
type: 'openai',
apiKey: 'sk-openai',
baseUrl: 'https://openai.example/v1',
},
},
models: {
...BASE_CONFIG.models!,
'gpt-alias': {
provider: 'openai',
model: 'gpt-runtime',
maxContextSize: 200000,
capabilities: ['tool_use'],
},
},
},
model: 'gpt-alias',
});
expect(resolved.providerName).toBe('openai');
expect(resolved.provider).toMatchObject({
type: 'openai',
model: 'gpt-runtime',
apiKey: 'sk-openai',
baseUrl: 'https://openai.example/v1',
});
expect(resolved.modelCapabilities).toMatchObject({
tool_use: true,
max_context_tokens: 200000,
});
});
it('uses config Kimi capabilities without requiring an api key during OAuth setup', () => {
const resolved = resolveRuntimeProvider({
config: {
...BASE_CONFIG,
providers: {
'managed:kimi-code': {
type: 'kimi',
apiKey: '',
baseUrl: 'https://api.example/v1',
oauth: { storage: 'file', key: 'oauth/kimi-code' },
},
},
},
});
expect(resolved.modelCapabilities).toMatchObject({
image_in: true,
video_in: true,
thinking: true,
tool_use: true,
max_context_tokens: 1_000_000,
});
});
it('does not infer Kimi capabilities from the provider model name', () => {
const resolved = resolveRuntimeProvider({
config: {
...BASE_CONFIG,
models: {
'kimi-code/kimi-for-coding': {
provider: 'managed:kimi-code',
model: 'kimi-for-coding',
maxContextSize: 1_000_000,
},
},
},
});
expect(resolved.modelCapabilities).toMatchObject({
image_in: false,
video_in: false,
thinking: false,
tool_use: false,
max_context_tokens: 1_000_000,
});
});
it('rejects provider model names that are not configured aliases', () => {
expect(() =>
resolveRuntimeProvider({
config: BASE_CONFIG,
model: 'kimi-for-coding',
}),
).toThrow(/not configured in config.toml/);
});
it('throws when no model is selected', () => {
expect(() =>
resolveRuntimeProvider({
config: {
providers: {},
},
}),
).toThrow(/No model is selected/);
});
it('throws when the selected model is not configured as an alias', () => {
expect(() =>
resolveRuntimeProvider({
config: BASE_CONFIG,
model: 'kimi-code',
}),
).toThrow(KimiError);
});
it('allows vertexai providers without an apiKey', () => {
const resolved = resolveRuntimeProvider({
config: {
defaultModel: 'gemini',
providers: {
vertex: {
type: 'vertexai',
},
},
models: {
gemini: {
provider: 'vertex',
model: 'gemini-1.5-pro',
maxContextSize: 1_000_000,
},
},
},
});
expect(resolved.provider).toMatchObject({ type: 'vertexai' });
});
it('throws when the selected model alias has no maxContextSize', () => {
const config = {
...BASE_CONFIG,
models: {
broken: {
provider: 'managed:kimi-code',
model: 'kimi-for-coding',
capabilities: ['thinking'],
},
},
} as unknown as KimiConfig;
expect(() =>
resolveRuntimeProvider({
config,
model: 'broken',
}),
).toThrow(/max_context_size/);
});
});
describe('resolveRuntimeProvider maxOutputSize forwarding', () => {
it('returns alias.maxOutputSize for request completion budgeting', () => {
const resolved = resolveRuntimeProvider({
config: {
...BASE_CONFIG,
providers: {
...BASE_CONFIG.providers,
openai: {
type: 'openai',
apiKey: 'sk-openai',
baseUrl: 'https://openai.example/v1',
},
},
models: {
...BASE_CONFIG.models!,
'deepseek-alias': {
provider: 'openai',
model: 'deepseek-v4-flash',
maxContextSize: 1_000_000,
maxOutputSize: 384000,
},
},
},
model: 'deepseek-alias',
});
expect(resolved.maxOutputSize).toBe(384000);
});
it('forwards alias.maxOutputSize to the anthropic provider config as defaultMaxTokens', () => {
const resolved = resolveRuntimeProvider({
config: {
...BASE_CONFIG,
providers: {
...BASE_CONFIG.providers,
anthropic: { type: 'anthropic', apiKey: 'sk-anthropic' },
},
models: {
...BASE_CONFIG.models!,
'opus-alias': {
provider: 'anthropic',
model: 'claude-opus-4-7',
maxContextSize: 200000,
maxOutputSize: 24000,
},
},
},
model: 'opus-alias',
});
expect(resolved.provider).toMatchObject({
type: 'anthropic',
model: 'claude-opus-4-7',
defaultMaxTokens: 24000,
});
});
it('forwards alias.offEffort to the openai and openai_responses provider configs', () => {
const config = {
...BASE_CONFIG,
providers: {
...BASE_CONFIG.providers,
gateway: { type: 'openai', apiKey: 'sk-gateway' } as const,
responses: { type: 'openai_responses', apiKey: 'sk-responses' } as const,
},
models: {
...BASE_CONFIG.models!,
'gateway/grok': {
provider: 'gateway',
model: 'grok-4',
maxContextSize: 256000,
supportEfforts: ['low', 'medium', 'high'],
offEffort: 'none',
},
'responses/grok': {
provider: 'responses',
model: 'grok-4',
maxContextSize: 256000,
offEffort: 'none',
},
},
} as KimiConfig;
expect(resolveRuntimeProvider({ config, model: 'gateway/grok' }).provider).toMatchObject({
type: 'openai',
offEffort: 'none',
});
expect(resolveRuntimeProvider({ config, model: 'responses/grok' }).provider).toMatchObject({
type: 'openai_responses',
offEffort: 'none',
});
});
it('maps alias.maxInputSize onto the resolved capability while keeping the total window', () => {
const resolved = resolveRuntimeProvider({
config: {
...BASE_CONFIG,
providers: {
...BASE_CONFIG.providers,
gateway: { type: 'openai', apiKey: 'sk-gateway' } as const,
},
models: {
...BASE_CONFIG.models!,
'gateway/gpt5': {
provider: 'gateway',
model: 'gpt-5',
maxContextSize: 400000,
maxInputSize: 272000,
},
},
},
model: 'gateway/gpt5',
});
expect(resolved.modelCapabilities).toMatchObject({
max_context_tokens: 400000,
max_input_tokens: 272000,
});
});
it('prefers alias.baseUrl over the provider base URL for the openai wire', () => {
// Catalog gateway shape: a model whose same-wire override endpoint
// differs from the provider's default.
const config = {
...BASE_CONFIG,
providers: {
...BASE_CONFIG.providers,
gateway: {
type: 'openai',
apiKey: 'sk-gateway',
baseUrl: 'https://gateway.example.test/api/v1',
} as const,
},
models: {
...BASE_CONFIG.models!,
'gateway/tenant-model': {
provider: 'gateway',
model: 'vendor/tenant-model',
maxContextSize: 1000,
baseUrl: 'https://tenant.example.test/v1',
},
'gateway/shared-model': {
provider: 'gateway',
model: 'vendor/shared-model',
maxContextSize: 1000,
},
},
} as KimiConfig;
expect(resolveRuntimeProvider({ config, model: 'gateway/tenant-model' }).provider).toMatchObject(
{ type: 'openai', baseUrl: 'https://tenant.example.test/v1' },
);
expect(resolveRuntimeProvider({ config, model: 'gateway/shared-model' }).provider).toMatchObject(
{ type: 'openai', baseUrl: 'https://gateway.example.test/api/v1' },
);
});
it('prefers alias.baseUrl over the provider base URL on the kimi, google-genai, and openai_responses wires', () => {
const config = {
...BASE_CONFIG,
providers: {
...BASE_CONFIG.providers,
kimi: { type: 'kimi', apiKey: 'sk-kimi', baseUrl: 'https://kimi.example.test/v1' } as const,
google: {
type: 'google-genai',
apiKey: 'sk-google',
baseUrl: 'https://google.example.test',
} as const,
responses: { type: 'openai_responses', apiKey: 'sk-responses' } as const,
},
models: {
...BASE_CONFIG.models!,
'kimi/tenant': {
provider: 'kimi',
model: 'kimi-k2',
maxContextSize: 1000,
baseUrl: 'https://tenant.example.test/v1',
},
'google/tenant': {
provider: 'google',
model: 'gemini-2.5-flash',
maxContextSize: 1000,
baseUrl: 'https://tenant.example.test/v1',
},
'responses/tenant': {
provider: 'responses',
model: 'gpt-5.5',
maxContextSize: 1000,
baseUrl: 'https://tenant.example.test/v1',
},
},
} as KimiConfig;
expect(resolveRuntimeProvider({ config, model: 'kimi/tenant' }).provider).toMatchObject({
type: 'kimi',
baseUrl: 'https://tenant.example.test/v1',
});
expect(resolveRuntimeProvider({ config, model: 'google/tenant' }).provider).toMatchObject({
type: 'google-genai',
baseUrl: 'https://tenant.example.test/v1',
});
expect(resolveRuntimeProvider({ config, model: 'responses/tenant' }).provider).toMatchObject({
type: 'openai_responses',
baseUrl: 'https://tenant.example.test/v1',
});
});
it('prefers alias.baseUrl over the provider base URL for the anthropic wire', () => {
// Catalog gateway shape: provider default is the OpenAI wire, one model
// carries an Anthropic protocol + endpoint override.
const resolved = resolveRuntimeProvider({
config: {
...BASE_CONFIG,
providers: {
...BASE_CONFIG.providers,
gateway: {
type: 'openai',
apiKey: 'sk-gateway',
baseUrl: 'https://gateway.example.test/api/v1',
},
},
models: {
...BASE_CONFIG.models!,
'gateway/claude-model': {
provider: 'gateway',
model: 'vendor/claude-model',
maxContextSize: 200000,
protocol: 'anthropic',
baseUrl: 'https://gateway.example.test/api/anthropic',
},
'gateway/plain-model': {
provider: 'gateway',
model: 'vendor/plain-model',
maxContextSize: 1000,
protocol: 'anthropic',
},
},
},
model: 'gateway/claude-model',
});
expect(resolved.provider).toMatchObject({
type: 'anthropic',
baseUrl: 'https://gateway.example.test/api/anthropic',
});
const fallback = resolveRuntimeProvider({
config: {
...BASE_CONFIG,
providers: {
...BASE_CONFIG.providers,
gateway: {
type: 'openai',
apiKey: 'sk-gateway',
baseUrl: 'https://gateway.example.test/api/v1',
},
},
models: {
...BASE_CONFIG.models!,
'gateway/plain-model': {
provider: 'gateway',
model: 'vendor/plain-model',
maxContextSize: 1000,
protocol: 'anthropic',
},
},
},
model: 'gateway/plain-model',
});
// Without an alias endpoint the provider base URL applies (stripped of
// the trailing /v1 for the Anthropic SDK, as before).
expect(fallback.provider).toMatchObject({
type: 'anthropic',
baseUrl: 'https://gateway.example.test/api',
});
});
it('omits defaultMaxTokens when alias.maxOutputSize is unset', () => {
const resolved = resolveRuntimeProvider({
config: {
...BASE_CONFIG,
providers: {
...BASE_CONFIG.providers,
anthropic: { type: 'anthropic', apiKey: 'sk-anthropic' },
},
models: {
...BASE_CONFIG.models!,
'opus-alias': {
provider: 'anthropic',
model: 'claude-opus-4-7',
maxContextSize: 200000,
},
},
},
model: 'opus-alias',
});
expect(resolved.provider).toMatchObject({
type: 'anthropic',
model: 'claude-opus-4-7',
});
expect('defaultMaxTokens' in resolved.provider).toBe(false);
});
it('forwards alias.adaptiveThinking to the anthropic provider config', () => {
const resolved = resolveRuntimeProvider({
config: {
...BASE_CONFIG,
providers: {
...BASE_CONFIG.providers,
anthropic: { type: 'anthropic', apiKey: 'sk-anthropic' },
},
models: {
...BASE_CONFIG.models!,
'okapi-alias': {
provider: 'anthropic',
model: 'coding-model-okapi-0527-vibe',
maxContextSize: 200000,
adaptiveThinking: true,
},
},
},
model: 'okapi-alias',
});
expect(resolved.provider).toMatchObject({
type: 'anthropic',
model: 'coding-model-okapi-0527-vibe',
adaptiveThinking: true,
});
});
it('forwards alias.supportEfforts to the anthropic provider config', () => {
const resolved = resolveRuntimeProvider({
config: {
...BASE_CONFIG,
providers: {
...BASE_CONFIG.providers,
anthropic: { type: 'anthropic', apiKey: 'sk-anthropic' },
},
models: {
...BASE_CONFIG.models!,
'compatible-alias': {
provider: 'anthropic',
model: 'compatible-model',
maxContextSize: 200000,
supportEfforts: ['low', 'high', 'max'],
},
},
},
model: 'compatible-alias',
});
expect(resolved.provider).toMatchObject({
type: 'anthropic',
model: 'compatible-model',
supportEfforts: ['low', 'high', 'max'],
});
});
it('forwards alias.betaApi to the anthropic provider config', () => {
const resolved = resolveRuntimeProvider({
config: {
...BASE_CONFIG,
providers: {
...BASE_CONFIG.providers,
anthropic: { type: 'anthropic', apiKey: 'sk-anthropic' },
},
models: {
...BASE_CONFIG.models!,
'kimi-alias': {
provider: 'anthropic',
model: 'kimi-for-coding',
maxContextSize: 200000,
protocol: 'anthropic',
betaApi: true,
},
},
},
model: 'kimi-alias',
});
expect(resolved.provider).toMatchObject({
type: 'anthropic',
model: 'kimi-for-coding',
betaApi: true,
});
});
it('omits adaptiveThinking when alias.adaptiveThinking is unset', () => {
const resolved = resolveRuntimeProvider({
config: {
...BASE_CONFIG,
providers: {
...BASE_CONFIG.providers,
anthropic: { type: 'anthropic', apiKey: 'sk-anthropic' },
},
models: {
...BASE_CONFIG.models!,
'opus-alias': {
provider: 'anthropic',
model: 'claude-opus-4-7',
maxContextSize: 200000,
},
},
},
model: 'opus-alias',
});
expect('adaptiveThinking' in resolved.provider).toBe(false);
});
});
describe('resolveRuntimeProvider Kimi request headers', () => {
it('does not set defaultHeaders when no kimiRequestHeaders or customHeaders exist', () => {
const resolved = resolveRuntimeProvider({ config: BASE_CONFIG });
expect(resolved.provider).toMatchObject({
type: 'kimi',
model: 'kimi-for-coding',
});
expect('defaultHeaders' in resolved.provider).toBe(false);
});
it('uses only customHeaders when kimiRequestHeaders are missing', () => {
const resolved = resolveRuntimeProvider({
config: {
...BASE_CONFIG,
providers: {
'managed:kimi-code': {
type: 'kimi',
apiKey: 'test-key',
baseUrl: 'https://api.example/v1',
customHeaders: {
'User-Agent': 'Custom/1',
},
},
},
},
});
expect(resolved.provider).toMatchObject({
type: 'kimi',
defaultHeaders: {
'User-Agent': 'Custom/1',
},
});
});
it('passes kimiRequestHeaders through to Kimi provider defaultHeaders', () => {
const resolved = resolveRuntimeProvider({
config: BASE_CONFIG,
kimiRequestHeaders: TEST_KIMI_HEADERS,
});
expect(resolved.provider).toMatchObject({
type: 'kimi',
defaultHeaders: TEST_KIMI_HEADERS,
});
});
it('passes the prompt cache key to Kimi generation kwargs', () => {
const resolved = resolveRuntimeProvider({
config: BASE_CONFIG,
promptCacheKey: 'session-test',
});
expect(resolved.provider).toMatchObject({
type: 'kimi',
generationKwargs: {
prompt_cache_key: 'session-test',
},
});
});
it('lets provider customHeaders override kimiRequestHeaders', () => {
const resolved = resolveRuntimeProvider({
config: {
...BASE_CONFIG,
providers: {
'managed:kimi-code': {
type: 'kimi',
apiKey: 'test-key',
baseUrl: 'https://api.example/v1',
customHeaders: {
'User-Agent': 'Custom/1',
'X-Msh-Version': 'override-version',
},
},
},
},
kimiRequestHeaders: TEST_KIMI_HEADERS,
});
expect(resolved.provider).toMatchObject({
type: 'kimi',
defaultHeaders: {
'User-Agent': 'Custom/1',
'X-Msh-Platform': 'kimi_code_cli',
'X-Msh-Version': 'override-version',
},
});
});
it('applies only the User-Agent from kimiRequestHeaders to non-Kimi providers', () => {
const resolved = resolveRuntimeProvider({
config: {
defaultModel: 'gpt-alias',
providers: {
openai: {
type: 'openai',
apiKey: 'sk-openai',
},
},
models: {
'gpt-alias': {
provider: 'openai',
model: 'gpt-runtime',
maxContextSize: 200000,
},
},
},
kimiRequestHeaders: TEST_KIMI_HEADERS,
promptCacheKey: 'session-test',
});
expect(resolved.provider).toMatchObject({
type: 'openai',
model: 'gpt-runtime',
apiKey: 'sk-openai',
defaultHeaders: {
'User-Agent': TEST_KIMI_HEADERS['User-Agent'],
},
});
// Device identity headers (`X-Msh-*`) stay Kimi-only — they must not leak
// to third-party providers.
const headers = (resolved.provider as { defaultHeaders?: Record<string, string> })
.defaultHeaders;
expect(headers).toBeDefined();
expect('X-Msh-Platform' in headers!).toBe(false);
});
});
describe('resolveRuntimeProvider customHeaders propagation', () => {
it('forwards customHeaders to an anthropic provider', () => {
const resolved = resolveRuntimeProvider({
config: {
defaultModel: 'claude-alias',
providers: {
anthropic: {
type: 'anthropic',
apiKey: 'sk-anthropic',
customHeaders: { 'X-Custom': 'value' },
},
},
models: {
'claude-alias': { provider: 'anthropic', model: 'claude-runtime', maxContextSize: 200000 },
},
},
});
expect(resolved.provider).toMatchObject({
type: 'anthropic',
defaultHeaders: { 'X-Custom': 'value' },
});
});
it('passes the prompt cache key to Anthropic metadata.user_id', () => {
const resolved = resolveRuntimeProvider({
config: {
defaultModel: 'claude-alias',
providers: {
anthropic: {
type: 'anthropic',
apiKey: 'sk-anthropic',
},
},
models: {
'claude-alias': { provider: 'anthropic', model: 'claude-runtime', maxContextSize: 200000 },
},
},
promptCacheKey: 'session-test',
});
expect(resolved.provider).toMatchObject({
type: 'anthropic',
metadata: { user_id: 'session-test' },
});
});
it('omits Anthropic metadata when no prompt cache key is set', () => {
const resolved = resolveRuntimeProvider({
config: {
defaultModel: 'claude-alias',
providers: {
anthropic: {
type: 'anthropic',
apiKey: 'sk-anthropic',
},
},
models: {
'claude-alias': { provider: 'anthropic', model: 'claude-runtime', maxContextSize: 200000 },
},
},
});
expect(resolved.provider).toMatchObject({ type: 'anthropic' });
expect('metadata' in resolved.provider).toBe(false);
});
it('forwards customHeaders to an openai provider', () => {
const resolved = resolveRuntimeProvider({
config: {
defaultModel: 'gpt-alias',
providers: {
openai: {
type: 'openai',
apiKey: 'sk-openai',
customHeaders: { 'X-Custom': 'value' },
},
},
models: {
'gpt-alias': { provider: 'openai', model: 'gpt-runtime', maxContextSize: 200000 },
},
},
});
expect(resolved.provider).toMatchObject({
type: 'openai',
defaultHeaders: { 'X-Custom': 'value' },
});
});
it('forwards customHeaders to an openai_responses provider', () => {
const resolved = resolveRuntimeProvider({
config: {
defaultModel: 'resp-alias',
providers: {
openai_responses: {
type: 'openai_responses',
apiKey: 'sk-openai',
customHeaders: { 'X-Custom': 'value' },
},
},
models: {
'resp-alias': {
provider: 'openai_responses',
model: 'gpt-runtime',
maxContextSize: 200000,
},
},
},
});
expect(resolved.provider).toMatchObject({
type: 'openai_responses',
defaultHeaders: { 'X-Custom': 'value' },
});
});
it('keeps customHeaders isolated between resolved provider instances', () => {
const config: KimiConfig = {
defaultModel: 'gpt-alias',
providers: {
openai: {
type: 'openai',
apiKey: 'sk-openai',
customHeaders: { 'X-Custom': 'original' },
},
},
models: {
'gpt-alias': { provider: 'openai', model: 'gpt-runtime', maxContextSize: 200000 },
},
};
const first = resolveRuntimeProvider({ config });
const second = resolveRuntimeProvider({ config });
const firstHeaders = (first.provider as { defaultHeaders?: Record<string, string> })
.defaultHeaders;
expect(firstHeaders).toEqual({ 'X-Custom': 'original' });
firstHeaders!['X-Custom'] = 'mutated';
expect(
(second.provider as { defaultHeaders?: Record<string, string> }).defaultHeaders,
).toEqual({ 'X-Custom': 'original' });
expect(config.providers['openai']?.customHeaders).toEqual({ 'X-Custom': 'original' });
});
});
describe('ProviderManager prompt cache key', () => {
it('applies a prompt cache key to Kimi providers', () => {
const manager = new ProviderManager({
config: BASE_CONFIG,
promptCacheKey: 'session-test',
});
const resolved = manager.resolveProviderConfig('kimi-code/kimi-for-coding');
expect(resolved.provider).toMatchObject({
type: 'kimi',
generationKwargs: {
prompt_cache_key: 'session-test',
},
});
});
it('applies a prompt cache key to OpenAI providers (chat completions + responses)', () => {
for (const type of ['openai', 'openai_responses'] as const) {
const manager = new ProviderManager({
promptCacheKey: 'session-test',
config: {
defaultModel: 'gpt-alias',
providers: {
openai: {
type,
apiKey: 'sk-openai',
},
},
models: {
'gpt-alias': {
provider: 'openai',
model: 'gpt-runtime',
maxContextSize: 200000,
},
},
},
});
const resolved = manager.resolveProviderConfig('gpt-alias');
// Same session-affinity intent as the Kimi branch above: every request
// of the session routes through the same provider-side prompt cache.
expect(resolved.provider).toMatchObject({
type,
model: 'gpt-runtime',
generationKwargs: {
prompt_cache_key: 'session-test',
},
});
}
});
it('reads the current config when constructed with a function', () => {
let sharedConfig: KimiConfig = { providers: {} };
const manager = new ProviderManager({
config: () => sharedConfig,
promptCacheKey: 'session-test',
});
sharedConfig = BASE_CONFIG;
const resolved = manager.resolveProviderConfig('kimi-code/kimi-for-coding');
expect(resolved.provider).toMatchObject({
type: 'kimi',
generationKwargs: {
prompt_cache_key: 'session-test',
},
});
});
});
describe('ProviderManager OAuth auth', () => {
function oauthConfig(): KimiConfig {
return {
...BASE_CONFIG,
providers: {
'managed:kimi-code': {
type: 'kimi',
apiKey: '',
baseUrl: 'https://api.example/v1',
oauth: { storage: 'file', key: 'oauth/kimi-code' },
},
},
};
}
it('preserves non-Kimi token fetch failures instead of guessing their category', async () => {
const tokenError = new Error('token storage permission denied');
const manager = new ProviderManager({
config: oauthConfig(),
resolveOAuthTokenProvider: () => ({
async getAccessToken() {
throw tokenError;
},
}),
});
const resolveAuth = manager.resolveAuth('kimi-code/kimi-for-coding');
expect(resolveAuth).toBeDefined();
await expect(resolveAuth!(async () => 'ok')).rejects.toBe(tokenError);
});
it('keeps explicit login-required token failures as login-required errors', async () => {
const manager = new ProviderManager({
config: oauthConfig(),
resolveOAuthTokenProvider: () => ({
async getAccessToken() {
throw new KimiError(ErrorCodes.AUTH_LOGIN_REQUIRED, 'not logged in');
},
}),
});
const resolveAuth = manager.resolveAuth('kimi-code/kimi-for-coding');
expect(resolveAuth).toBeDefined();
await expect(resolveAuth!(async () => 'ok')).rejects.toMatchObject({
code: ErrorCodes.AUTH_LOGIN_REQUIRED,
});
});
});
describe('resolveThinkingEffort', () => {
const booleanModel: ModelAlias = {
provider: 'p',
model: 'm',
maxContextSize: 1,
capabilities: ['thinking'],
};
const effortModel: ModelAlias = {
provider: 'p',
model: 'm',
maxContextSize: 1,
capabilities: ['thinking'],
supportEfforts: ['low', 'medium', 'high'],
};
const alwaysThinkingModel: ModelAlias = {
provider: 'p',
model: 'm',
maxContextSize: 1,
capabilities: ['thinking', 'always_thinking'],
};
it('returns the requested effort (normalized) when one is provided', () => {
expect(resolveThinkingEffort('on', { effort: 'medium' }, booleanModel)).toBe('on');
expect(resolveThinkingEffort('off', { effort: 'medium' }, booleanModel)).toBe('off');
expect(resolveThinkingEffort('low', { effort: 'medium' }, booleanModel)).toBe('low');
expect(resolveThinkingEffort(' Off ', { effort: 'medium' }, booleanModel)).toBe('off');
// Empty / whitespace requests read as absent and fall through to config.
expect(resolveThinkingEffort('', { enabled: false, effort: 'medium' }, booleanModel)).toBe(
'off',
);
expect(resolveThinkingEffort(' ', { enabled: false, effort: 'medium' }, booleanModel)).toBe(
'off',
);
});
it('treats config.enabled=false as off when no effort is requested', () => {
expect(
resolveThinkingEffort(undefined, { enabled: false, effort: 'medium' }, booleanModel),
).toBe('off');
expect(resolveThinkingEffort(undefined, { enabled: false }, booleanModel)).toBe('off');
});
it('uses config.effort as the default effort when enabled', () => {
expect(resolveThinkingEffort(undefined, { effort: 'medium' }, booleanModel)).toBe('medium');
expect(resolveThinkingEffort(undefined, { enabled: true, effort: 'medium' }, booleanModel)).toBe(
'medium',
);
});
it('falls back to the model default effort when no effort is set', () => {
// boolean thinking model -> 'on'
expect(resolveThinkingEffort(undefined, {}, booleanModel)).toBe('on');
// effort-capable model -> middle supportEfforts entry
expect(resolveThinkingEffort(undefined, {}, effortModel)).toBe('medium');
// no / non-thinking model -> 'off'
expect(resolveThinkingEffort(undefined, {}, undefined)).toBe('off');
});
it('forces always-thinking models back on even when off is requested', () => {
expect(resolveThinkingEffort('off', { enabled: false }, alwaysThinkingModel, true)).toBe('on');
expect(resolveThinkingEffort(undefined, { enabled: false }, alwaysThinkingModel, true)).toBe(
'on',
);
});
});
describe('google base URL forwarding', () => {
it('forwards base_url to the google-genai provider config', () => {
const resolved = resolveRuntimeProvider({
config: {
defaultModel: 'gemini',
providers: {
gemini: {
type: 'google-genai',
apiKey: 'g-key',
baseUrl: 'https://qianxun.example/v1beta',
},
},
models: {
gemini: { provider: 'gemini', model: 'gemini-2.5-pro', maxContextSize: 1_000_000 },
},
},
});
expect(resolved.provider).toMatchObject({
type: 'google-genai',
model: 'gemini-2.5-pro',
baseUrl: 'https://qianxun.example/v1beta',
});
});
it('reads GOOGLE_GEMINI_BASE_URL from provider env as a fallback', () => {
const resolved = resolveRuntimeProvider({
config: {
defaultModel: 'gemini',
providers: {
gemini: {
type: 'google-genai',
apiKey: 'g-key',
env: { GOOGLE_GEMINI_BASE_URL: 'https://env.example/v1beta' },
},
},
models: {
gemini: { provider: 'gemini', model: 'gemini-2.5-pro', maxContextSize: 1_000_000 },
},
},
});
expect(resolved.provider).toMatchObject({
type: 'google-genai',
baseUrl: 'https://env.example/v1beta',
});
});
it('forwards a custom proxy base_url to the vertexai provider config', () => {
const resolved = resolveRuntimeProvider({
config: {
defaultModel: 'gemini',
providers: {
vertex: {
type: 'vertexai',
apiKey: 'v-key',
baseUrl: 'https://qianxun.example/vertex',
},
},
models: {
gemini: { provider: 'vertex', model: 'gemini-1.5-pro', maxContextSize: 1_000_000 },
},
},
});
expect(resolved.provider).toMatchObject({
type: 'vertexai',
model: 'gemini-1.5-pro',
baseUrl: 'https://qianxun.example/vertex',
});
});
it('forwards base_url to vertexai while still deriving location from an aiplatform host', () => {
// Backward compatibility: an aiplatform host must keep populating `location`
// (existing GCP behavior) while the base URL is now also forwarded so the
// SDK targets the configured endpoint verbatim.
const resolved = resolveRuntimeProvider({
config: {
defaultModel: 'gemini',
providers: {
vertex: {
type: 'vertexai',
apiKey: 'v-key',
baseUrl: 'https://us-central1-aiplatform.googleapis.com',
},
},
models: {
gemini: { provider: 'vertex', model: 'gemini-1.5-pro', maxContextSize: 1_000_000 },
},
},
});
expect(resolved.provider).toMatchObject({
type: 'vertexai',
baseUrl: 'https://us-central1-aiplatform.googleapis.com',
location: 'us-central1',
});
});
it('derives vertex location from the GOOGLE_VERTEX_BASE_URL env fallback so ADC mode is selected', () => {
// The env fallback must behave exactly like config `base_url`: when the
// regional endpoint is supplied via GOOGLE_VERTEX_BASE_URL (with a project
// but no explicit GOOGLE_CLOUD_LOCATION), location derivation must still see
// it, so the provider resolves to service-account (ADC) mode rather than
// silently downgrading to API-key Gemini routing.
const resolved = resolveRuntimeProvider({
config: {
defaultModel: 'gemini',
providers: {
vertex: {
type: 'vertexai',
env: {
GOOGLE_CLOUD_PROJECT: 'my-proj',
GOOGLE_VERTEX_BASE_URL: 'https://us-central1-aiplatform.googleapis.com',
},
},
},
models: {
gemini: { provider: 'vertex', model: 'gemini-1.5-pro', maxContextSize: 1_000_000 },
},
},
});
expect(resolved.provider).toMatchObject({
type: 'vertexai',
vertexai: true,
baseUrl: 'https://us-central1-aiplatform.googleapis.com',
project: 'my-proj',
location: 'us-central1',
});
});
});
describe('per-model protocol routing', () => {
it('routes a protocol:anthropic model on a kimi provider through the anthropic transport with the REST base stripped of /v1', () => {
const resolved = resolveRuntimeProvider({
config: {
...BASE_CONFIG,
models: {
'kimi-code/kimi-for-coding': {
...BASE_CONFIG.models!['kimi-code/kimi-for-coding']!,
protocol: 'anthropic',
},
},
},
});
expect(resolved.providerName).toBe('managed:kimi-code');
expect(resolved.provider).toMatchObject({
type: 'anthropic',
model: 'kimi-for-coding',
baseUrl: 'https://api.example',
});
// Kimi over the Anthropic transport keeps its vendor error classifier —
// a Moonshot quota 429 must fail fast on this route too.
expect(
(resolved.provider as { convertError?: (error: unknown) => unknown }).convertError,
).toBe(classifyKimiQuotaError);
});
it('keeps a model without protocol on the provider wire type and leaves the REST base intact', () => {
const resolved = resolveRuntimeProvider({ config: BASE_CONFIG });
expect(resolved.provider).toMatchObject({
type: 'kimi',
model: 'kimi-for-coding',
baseUrl: 'https://api.example/v1',
});
});
it('does not strip the baseUrl of a provider that is itself typed anthropic', () => {
const resolved = resolveRuntimeProvider({
config: {
defaultModel: 'claude',
providers: {
anthropic: {
type: 'anthropic',
apiKey: 'sk-anthropic',
baseUrl: 'https://api.anthropic.example/v1',
},
},
models: {
claude: {
provider: 'anthropic',
model: 'claude-sonnet-4-5',
maxContextSize: 200_000,
},
},
},
});
expect(resolved.provider).toMatchObject({
type: 'anthropic',
model: 'claude-sonnet-4-5',
baseUrl: 'https://api.anthropic.example/v1',
});
// A plain anthropic provider carries no Kimi vendor classifier.
expect(
(resolved.provider as { convertError?: (error: unknown) => unknown }).convertError,
).toBeUndefined();
});
});
describe('resolveRuntimeProvider model overrides', () => {
it('keeps supportEfforts out of the kimi provider config', () => {
const resolved = resolveRuntimeProvider({
config: {
...BASE_CONFIG,
models: {
'kimi-code/kimi-for-coding': {
...BASE_CONFIG.models!['kimi-code/kimi-for-coding']!,
supportEfforts: ['low', 'high', 'max'],
overrides: { supportEfforts: ['low', 'high'] },
},
},
},
});
expect(resolved.provider).toMatchObject({ type: 'kimi' });
expect(resolved.provider).not.toHaveProperty('supportEfforts');
});
});