fix(agent-core-v2): align ModelResolver with production v1 behavior

- return auth as { apiKey } (kosong reads apiKey, not headers)
- apply default thinking from defaultThinking/thinking config + always_thinking clamp
- treat empty apiKey as absent so OAuth is not short-circuited
- strip trailing /v1 from anthropic baseUrl to avoid double /v1/v1/messages
- add model-providers example (sandboxed ~/.kimi-code ping-pong) and resolver tests
This commit is contained in:
haozhe.yang 2026-07-02 18:42:13 +08:00
parent ec865ed89c
commit 643acf454a
3 changed files with 670 additions and 5 deletions

View file

@ -0,0 +1,373 @@
/**
* Scenario: the **Provider / Platform / Protocol / Model** slice, driven from
* a real `~/.kimi-code/config.toml` and its credentials, and exercised through
* the new `IModelResolver` `Model` god-object path introduced in the
* "Model god-object and protocol domains" change.
*
* Goals of this example:
* 1. **Sandbox the real config.** At runtime, copy `~/.kimi-code/config.toml`
* and `~/.kimi-code/credentials/` into the per-run `KIMI_CODE_HOME` the
* example harness provisions (`.vitest-results/kimi-code-{ts}/`). The real
* home is never read or written directly even an OAuth token refresh
* lands in the sandbox copy.
* 2. **List everything.** Enumerate every `[providers.*]`, `[platforms.*]`,
* supported `Protocol`, and `[models.*]` entry, then resolve each Model id
* through `IModelResolver` and report whether it produces a runnable
* `Model` (protocol, base URL, auth mode) a concrete compatibility
* matrix for the new god-object resolver.
* 3. **Ping every Model.** Send a "ping" expect a streamed response
* against **every** Model that resolved (bounded concurrency, per-request
* timeout), and report which ones actually answer an end-to-end reachability
* check for the whole configured catalogue, not just the default model.
*
* All Services come from `src/`; nothing here defines a new Service.
*/
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
import '#/index';
import { type Scope, type ScopeSeed } from '#/_base/di/scope';
import { bootstrap } from '#/app/bootstrap';
import { IConfigService } from '#/app/config';
import { createUserMessage, isContentPart, type TokenUsage } from '#/app/llmProtocol';
import { IModelResolver, IModelService, type Model, type ModelConfig } from '#/app/model';
import { IPlatformService } from '#/app/platform';
import { IProviderService, type ProviderConfig } from '#/app/provider';
import { IProtocolAdapterRegistry } from '#/app/protocol';
import { ILogOptions, resolveLoggingConfig } from '#/app/log/logConfig';
const PER_REQUEST_TIMEOUT_MS = 30_000;
const PING_CONCURRENCY = 4;
const ALL_MODELS_TEST_TIMEOUT_MS = 300_000;
interface ModelReport {
readonly id: string;
readonly name?: string;
readonly protocol?: string;
readonly baseUrl?: string;
readonly authMode: string;
readonly resolved: boolean;
readonly error?: string;
}
describe('model / provider / platform / protocol slice (resolved from a sandboxed ~/.kimi-code)', () => {
let app: Scope | undefined;
let sandboxHome = '';
let configCopied = false;
let credentialsCopied = 0;
let reports: readonly ModelReport[] = [];
let useRealHome = false;
beforeAll(() => {
useRealHome = process.env['KIMI_CODE_EXAMPLE_USE_REAL_HOME'] === '1';
sandboxHome = useRealHome ? join(homedir(), '.kimi-code') : resolveSandboxHome();
if (useRealHome) {
// Run directly against the real home so OAuth token refresh can read AND
// write back the real credentials. Read-only for config — this example
// never calls IConfigService.set/replace.
configCopied = true;
credentialsCopied = 0;
} else {
const mirror = mirrorRealKimiHome(sandboxHome);
configCopied = mirror.configCopied;
credentialsCopied = mirror.credentialsCopied;
}
const logSeed: ScopeSeed = [
[ILogOptions, resolveLoggingConfig({ homeDir: sandboxHome, env: process.env })],
];
app = bootstrap({ homeDir: sandboxHome }, logSeed).app;
});
afterAll(() => app?.dispose());
test('lists every Provider / Platform / Protocol and resolves every Model', async () => {
const host = requireApp(app);
const config = host.accessor.get(IConfigService);
await config.ready;
const providers = host.accessor.get(IProviderService);
const platforms = host.accessor.get(IPlatformService);
const models = host.accessor.get(IModelService);
const resolver = host.accessor.get(IModelResolver);
const protocols = host.accessor.get(IProtocolAdapterRegistry);
// Touch each registry so its config section is registered before we read.
const providerMap = providers.list();
const platformMap = platforms.list();
const modelMap = models.list();
const supportedProtocols = protocols.supportedProtocols();
console.log(`\nhome: ${sandboxHome}${useRealHome ? ' (REAL ~/.kimi-code)' : ' (sandbox copy)'}`);
console.log(`config.toml copied: ${useRealHome ? 'n/a (using real)' : configCopied}`);
console.log(`credentials copied: ${useRealHome ? 'n/a (using real)' : credentialsCopied}`);
console.log(`\nsupported protocols: ${supportedProtocols.join(', ') || '(none)'}`);
console.log(`\n[providers.*] (${Object.keys(providerMap).length}):`);
for (const [id, p] of Object.entries(providerMap)) {
console.log(` - ${id}: type=${p.type ?? '-'} baseUrl=${p.baseUrl ?? '-'} auth=${providerAuthMode(p)} platform=${p.platformId ?? '-'}`);
}
console.log(`\n[platforms.*] (${Object.keys(platformMap).length}):`);
if (Object.keys(platformMap).length === 0) console.log(' (none configured)');
for (const [id, pl] of Object.entries(platformMap)) {
const auth = pl.auth?.apiKey !== undefined ? 'apiKey' : pl.auth?.oauth !== undefined ? 'oauth' : pl.auth?.env !== undefined ? 'env' : '-';
console.log(` - ${id}: auth=${auth} displayName=${pl.displayName ?? '-'}`);
}
reports = Object.entries(modelMap).map(([id, m]) => resolveOne(id, m, providerMap, resolver));
console.log(`\n[models.*] (${reports.length}) — resolve compatibility:`);
for (const r of reports) {
const head = r.resolved ? 'OK ' : 'FAIL';
const detail = r.resolved
? `protocol=${r.protocol} baseUrl=${r.baseUrl} auth=${r.authMode} name=${r.name}`
: `auth=${r.authMode} error=${r.error}`;
console.log(` [${head}] ${r.id}${detail}`);
}
// The example is meaningful even on a machine without the real config: it
// simply reports an empty registry instead of failing.
if (!configCopied) {
console.log('\n(no ~/.kimi-code/config.toml found — reporting an empty registry)');
return;
}
expect(Object.keys(providerMap).length).toBeGreaterThan(0);
expect(reports.length).toBeGreaterThan(0);
// Every configured Model must at least resolve into a god-object; a
// resolution failure here is a real compatibility regression.
const failures = reports.filter((r) => !r.resolved);
expect(
failures,
`models that failed to resolve: ${failures.map((f) => `${f.id}(${f.error})`).join(', ')}`,
).toEqual([]);
});
test('sends a ping → pong request through EVERY resolvable Model', async () => {
const host = requireApp(app);
if (!configCopied || reports.length === 0) {
console.log('skipped: no ~/.kimi-code/config.toml or no models configured');
return;
}
const resolver = host.accessor.get(IModelResolver);
const candidates = reports.filter((r) => r.resolved);
if (candidates.length === 0) {
console.log('skipped: no resolvable models');
return;
}
console.log(
`\npinging ${candidates.length} resolvable models ` +
`(concurrency=${PING_CONCURRENCY}, per-request timeout=${PER_REQUEST_TIMEOUT_MS}ms):`,
);
const outcomes = await mapPool(candidates, PING_CONCURRENCY, async (report) => {
const model = resolver.resolve(report.id);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), PER_REQUEST_TIMEOUT_MS);
try {
const result = await collectResponse(model, controller.signal);
return {
report,
ok: true as const,
text: result.text,
finishReason: result.finishReason,
};
} catch (error) {
return {
report,
ok: false as const,
error: error instanceof Error ? error.message : String(error),
};
} finally {
clearTimeout(timer);
}
});
for (const o of outcomes) {
if (o.ok) {
console.log(
` [OK ] ${o.report.id}${JSON.stringify(truncate(o.text, 40))} ` +
`(finish=${o.finishReason ?? '-'})`,
);
} else {
console.log(` [FAIL] ${o.report.id}${truncate(o.error, 140)}`);
}
}
const passed = outcomes.filter((o) => o.ok).length;
console.log(`\nping-pong summary: ${passed}/${outcomes.length} models responded.`);
const failed = outcomes.filter((o) => !o.ok);
expect(
failed,
`models that failed to respond: ${failed.map((f) => `${f.report.id}(${f.error})`).join(', ')}`,
).toEqual([]);
}, ALL_MODELS_TEST_TIMEOUT_MS);
});
function resolveOne(
id: string,
model: ModelConfig,
providers: Readonly<Record<string, ProviderConfig>>,
resolver: IModelResolver,
): ModelReport {
const authMode = modelAuthMode(model, providers);
try {
const resolved = resolver.resolve(id);
return {
id,
name: resolved.name,
protocol: resolved.protocol,
baseUrl: resolved.baseUrl,
authMode,
resolved: true,
};
} catch (error) {
return {
id,
name: model.name ?? model.model,
authMode,
resolved: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/** Mirror the resolver's auth-precedence to label where each Model's
* credential comes from, without ever reading the secret itself. */
function modelAuthMode(
model: ModelConfig,
providers: Readonly<Record<string, ProviderConfig>>,
): string {
if (model.apiKey !== undefined && model.apiKey.length > 0) return 'model.apiKey';
if (model.oauth !== undefined) return 'model.oauth';
const providerId = model.providerId ?? model.provider;
const provider = providerId === undefined ? undefined : providers[providerId];
const platformId = provider?.platformId;
if (platformId !== undefined && platformId !== '__unknown__') {
return `platform(${platformId})`;
}
if (provider?.apiKey !== undefined && provider.apiKey.length > 0) return 'provider.apiKey';
if (provider?.oauth !== undefined) return 'provider.oauth';
return 'none';
}
function providerAuthMode(provider: ProviderConfig): string {
if (provider.apiKey !== undefined && provider.apiKey.length > 0) return 'apiKey';
if (provider.oauth !== undefined) return 'oauth';
if (provider.platformId !== undefined) return `platform(${provider.platformId})`;
if (provider.env !== undefined) return 'env';
return 'none';
}
async function collectResponse(
model: Model,
signal: AbortSignal,
): Promise<{ text: string; finishReason?: string; usage?: TokenUsage }> {
let text = '';
let think = '';
let finishReason: string | undefined;
let usage: TokenUsage | undefined;
const stream = model.request(
{
systemPrompt:
'You are a connectivity check. The user will say "ping". Reply with the single word: pong',
tools: [],
messages: [createUserMessage('ping')],
},
signal,
);
for await (const event of stream) {
if (event.type === 'part') {
const part = event.part;
if (isContentPart(part) && part.type === 'text') text += part.text;
else if (isContentPart(part) && part.type === 'think') think += part.think;
} else if (event.type === 'usage') {
usage = event.usage;
} else if (event.type === 'finish') {
finishReason = event.rawFinishReason ?? event.providerFinishReason;
}
}
// Thinking models may put the answer in `think`; surface whichever carried
// content so the report shows what came back.
return { text: text.trim().length > 0 ? text : think, finishReason, usage };
}
/** Run `fn` over `items` with at most `size` in flight, preserving order. */
async function mapPool<T, R>(
items: readonly T[],
size: number,
fn: (item: T) => Promise<R>,
): Promise<R[]> {
const results: R[] = new Array(items.length);
let next = 0;
const worker = async (): Promise<void> => {
while (next < items.length) {
const i = next++;
results[i] = await fn(items[i] as T);
}
};
await Promise.all(Array.from({ length: Math.min(size, items.length) }, worker));
return results;
}
function truncate(s: string, max: number): string {
const oneLine = s.replaceAll(/\s+/g, ' ').trim();
return oneLine.length > max ? `${oneLine.slice(0, max)}` : oneLine;
}
function resolveSandboxHome(): string {
const fromEnv = process.env['KIMI_CODE_HOME'];
if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv;
// Fallback for running this file outside the example harness: mirror into a
// fresh temp dir so the real home is still never touched.
const dir = join(homedir(), '.kimi-code-example-sandbox');
mkdirSync(dir, { recursive: true });
process.env['KIMI_CODE_HOME'] = dir;
return dir;
}
/** Copy `~/.kimi-code/config.toml` and `~/.kimi-code/credentials/*` into the
* sandbox home. Never reads credential contents only copies bytes. */
function mirrorRealKimiHome(sandboxHome: string): {
configCopied: boolean;
credentialsCopied: number;
} {
const realHome = join(homedir(), '.kimi-code');
let configCopied = false;
const srcConfig = join(realHome, 'config.toml');
if (existsSync(srcConfig)) {
copyFileSync(srcConfig, join(sandboxHome, 'config.toml'));
configCopied = true;
}
let credentialsCopied = 0;
const srcCreds = join(realHome, 'credentials');
if (existsSync(srcCreds) && statSync(srcCreds).isDirectory()) {
const dstCreds = join(sandboxHome, 'credentials');
mkdirSync(dstCreds, { recursive: true });
for (const entry of readdirSync(srcCreds)) {
const src = join(srcCreds, entry);
if (statSync(src).isFile()) {
copyFileSync(src, join(dstCreds, entry));
credentialsCopied++;
}
}
}
return { configCopied, credentialsCopied };
}
function requireApp(app: Scope | undefined): Scope {
if (app === undefined) throw new Error('App scope was not initialized in beforeAll');
return app;
}

View file

@ -25,6 +25,7 @@ import {
UNKNOWN_CAPABILITY,
type ModelCapability,
type ProviderRequestAuth,
type ThinkingEffort,
} from '#/app/llmProtocol';
import { IPlatformService, UNKNOWN_PLATFORM_KEY } from '#/app/platform';
import type { OAuthRef, ProviderConfig } from '#/app/provider';
@ -38,6 +39,23 @@ import type { AuthProvider, Model } from './modelInstance';
import { IModelResolver } from './modelResolver';
import { ModelImpl, StaticAuthProvider } from './modelImpl';
/**
* Default thinking effort applied when the user has not disabled thinking
* (matches `profile`'s `DEFAULT_THINKING_EFFORT`). Read here rather than
* imported so `model` (L2) does not depend on `profile` (L4); the source of
* truth for the value is the `thinking` / `defaultThinking` config sections,
* which are shared via `IConfigService`.
*/
const DEFAULT_THINKING_EFFORT: ThinkingEffort = 'high';
const THINKING_EFFORTS: readonly ThinkingEffort[] = ['low', 'medium', 'high', 'xhigh', 'max'];
/** Shape of the `thinking` config section (owned by `profile`); only the
* fields the resolver needs to mirror the production default are read here. */
interface ThinkingSection {
readonly mode?: string;
readonly effort?: string;
}
interface ResolvedAuthMaterial {
readonly apiKey?: string;
readonly oauth?: OAuthRef;
@ -68,11 +86,16 @@ export class ModelResolverService extends Disposable implements IModelResolver {
);
}
const { providerConfig, providerName, resolvedBaseUrl } = this.resolveProviderContext(id, model);
const { providerConfig, providerName, resolvedBaseUrl: rawBaseUrl } = this.resolveProviderContext(id, model);
const auth = this.resolveAuth(model, providerConfig);
const authProvider = this.buildAuthProvider(providerName, auth);
const protocol = this.resolveProtocol(id, model, providerConfig);
// The Anthropic SDK appends `/v1/messages` to the baseUrl, so a provider
// whose baseUrl already ends in `/v1` (e.g. the managed Kimi endpoint) would
// otherwise produce a double `/v1/v1/messages` → 404. Match production v1
// (`provider-manager` strips a trailing `/v1` for the anthropic transport).
const resolvedBaseUrl = protocol === 'anthropic' ? stripTrailingV1(rawBaseUrl) : rawBaseUrl;
const wireName = model.name ?? model.model;
if (wireName === undefined) {
throw new KimiError(
@ -97,7 +120,7 @@ export class ModelResolverService extends Disposable implements IModelResolver {
};
const alwaysThinking = declared.has('always_thinking');
return new ModelImpl({
const impl = new ModelImpl({
id,
name: wireName,
aliases: model.aliases ?? [],
@ -113,7 +136,35 @@ export class ModelResolverService extends Disposable implements IModelResolver {
providerName,
authProvider,
protocolRegistry: this.protocolRegistry as ProtocolAdapterRegistry,
extras: buildProviderExtras(model),
});
// Apply the production default thinking effort so a plain `model.request()`
// behaves like the agent path (which routes through `profile` and reads the
// same `thinking` / `defaultThinking` config). Required for models whose
// endpoint rejects a request that omits thinking (e.g. kimi-k2.7 over the
// Anthropic protocol returns 400 unless `thinking.type === 'enabled'`).
const effort = this.resolveDefaultThinking(alwaysThinking);
return effort === 'off' ? impl : impl.withThinking(effort);
}
/**
* Mirror `profile`'s `resolveThinkingLevel` / `resolveThinkingEffort` so the
* god-object's default matches the production agent path:
* - an explicit `defaultThinking === false` or `thinking.mode === 'off'`
* turns thinking off;
* - otherwise the configured `thinking.effort` (default 'high') is used;
* - an `always_thinking` model clamps an explicit "off" back to on.
*/
private resolveDefaultThinking(alwaysThinking: boolean): ThinkingEffort {
const defaultThinking = this.config.get<boolean | undefined>('defaultThinking');
const thinking = this.config.get<ThinkingSection | undefined>('thinking');
const turnedOff = defaultThinking === false || thinking?.mode === 'off';
const configured = parseThinkingEffort(thinking?.effort) ?? DEFAULT_THINKING_EFFORT;
if (turnedOff && !alwaysThinking) {
return 'off';
}
return configured;
}
findByName(name: string): readonly string[] {
@ -198,12 +249,18 @@ export class ModelResolverService extends Disposable implements IModelResolver {
* 1. Model-inline `apiKey` / `oauth` (flat-case override).
* 2. Provider.platformId Platform.auth (structured shared auth).
* 3. Provider-legacy `apiKey` / `oauth` (pre-migration configs).
*
* An empty / whitespace `apiKey` is treated as absent (matching production's
* `nonEmptyString`), so a provider that carries both `api_key = ""` and an
* `oauth` block correctly falls through to OAuth instead of producing an
* empty bearer token.
*/
private resolveAuth(
model: ModelConfig,
provider: ProviderConfig | undefined,
): ResolvedAuthMaterial {
if (model.apiKey !== undefined) return { apiKey: model.apiKey };
const modelApiKey = nonEmpty(model.apiKey);
if (modelApiKey !== undefined) return { apiKey: modelApiKey };
if (model.oauth !== undefined) {
return { oauth: model.oauth, oauthProviderKey: model.providerId ?? model.provider };
}
@ -211,7 +268,8 @@ export class ModelResolverService extends Disposable implements IModelResolver {
const platformId = provider?.platformId;
if (platformId !== undefined && platformId !== UNKNOWN_PLATFORM_KEY) {
const platform = this.platforms.get(platformId);
if (platform?.auth?.apiKey !== undefined) return { apiKey: platform.auth.apiKey };
const platformApiKey = nonEmpty(platform?.auth?.apiKey);
if (platformApiKey !== undefined) return { apiKey: platformApiKey };
if (platform?.auth?.oauth !== undefined) {
return {
oauth: platform.auth.oauth,
@ -221,7 +279,8 @@ export class ModelResolverService extends Disposable implements IModelResolver {
}
// Legacy: provider carried auth directly (pre-Phase 4 migration).
if (provider?.apiKey !== undefined) return { apiKey: provider.apiKey };
const providerApiKey = nonEmpty(provider?.apiKey);
if (providerApiKey !== undefined) return { apiKey: providerApiKey };
if (provider?.oauth !== undefined) {
return { oauth: provider.oauth, oauthProviderKey: model.providerId ?? model.provider };
}
@ -250,6 +309,43 @@ export class ModelResolverService extends Disposable implements IModelResolver {
}
}
function parseThinkingEffort(value: string | undefined): ThinkingEffort | undefined {
const normalized = value?.trim().toLowerCase();
return normalized !== undefined && (THINKING_EFFORTS as readonly string[]).includes(normalized)
? (normalized as ThinkingEffort)
: undefined;
}
/** Treat an empty / whitespace string as absent (matches production's
* `nonEmptyString` used by the session resolver). */
function nonEmpty(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed;
}
/** Strip a trailing `/v1` (with optional trailing slash) from a baseUrl, matching
* production v1's anthropic-transport normalization so the Anthropic SDK's
* `/v1/messages` suffix does not produce a double `/v1/v1/messages`. */
function stripTrailingV1(baseUrl: string): string {
return baseUrl.replace(/\/v1\/?$/, '');
}
/** Provider knobs the wire adapter needs that aren't first-class ModelImpl
* fields. `adaptiveThinking` changes how the Anthropic adapter encodes the
* thinking param, so it must reach the provider for the default-thinking
* transform to produce the right shape on adaptive models. */
function buildProviderExtras(model: ModelConfig): Readonly<Record<string, unknown>> | undefined {
const extras: Record<string, unknown> = {};
if (model.adaptiveThinking !== undefined) {
extras['adaptiveThinking'] = model.adaptiveThinking;
}
const betaApi = (model as Record<string, unknown>)['betaApi'];
if (betaApi !== undefined) {
extras['betaApi'] = betaApi;
}
return Object.keys(extras).length > 0 ? extras : undefined;
}
/**
* Derive a synthetic Provider id from a Model's flat baseUrl. Uses only the
* origin (host, optionally port) per Phase 2 decision "a=origin only" two

View file

@ -0,0 +1,196 @@
/**
* `model` domain `ModelResolverService` regression tests.
*
* Covers two resolver responsibilities:
* 1. Auth shape the resolved `Model` god-object drives real requests through
* kosong, which reads the bearer/api token from `ProviderRequestAuth.apiKey`
* (`requireProviderApiKey`). The resolver's `AuthProvider` must return the
* token as `apiKey` (not wrapped in `headers`), so a resolved Model can
* authenticate against its endpoint.
* 2. Default thinking the resolver reads the `thinking` / `defaultThinking`
* config sections and applies the same default effort the production agent
* path (via `profile`) does, so a plain `model.request()` behaves
* identically (some endpoints reject a request that omits thinking).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DisposableStore } from '#/_base/di/lifecycle';
import { createServices, type TestInstantiationService } from '#/_base/di/test';
import { IOAuthService } from '#/app/auth';
import { IConfigService } from '#/app/config/config';
import { type ModelConfig, IModelResolver, IModelService } from '#/app/model';
import { ModelResolverService } from '#/app/model/modelResolverService';
import { IPlatformService } from '#/app/platform';
import { type ProviderConfig, IProviderService } from '#/app/provider';
import { IProtocolAdapterRegistry } from '#/app/protocol';
describe('ModelResolverService', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
let providers: Record<string, ProviderConfig>;
let models: Record<string, ModelConfig>;
let configValues: Record<string, unknown>;
let resolveTokenProvider: ReturnType<typeof vi.fn>;
beforeEach(() => {
disposables = new DisposableStore();
providers = {};
models = {};
configValues = {};
resolveTokenProvider = vi.fn();
ix = createServices(disposables, {
additionalServices: (reg) => {
reg.definePartialInstance(IConfigService, {
get: ((domain: string) => configValues[domain]) as unknown as IConfigService['get'],
});
reg.definePartialInstance(IProviderService, {
get: ((name: string) => providers[name]) as IProviderService['get'],
list: (() => providers) as IProviderService['list'],
});
reg.definePartialInstance(IPlatformService, {
get: (() => undefined) as IPlatformService['get'],
list: (() => ({})) as IPlatformService['list'],
});
reg.definePartialInstance(IModelService, {
get: ((id: string) => models[id]) as IModelService['get'],
list: (() => models) as IModelService['list'],
});
reg.definePartialInstance(IOAuthService, {
resolveTokenProvider: resolveTokenProvider as unknown as IOAuthService['resolveTokenProvider'],
});
reg.definePartialInstance(IProtocolAdapterRegistry, {
supportedProtocols: () => [],
});
reg.define(IModelResolver, ModelResolverService);
},
});
});
afterEach(() => disposables.dispose());
it('returns the provider apiKey as ProviderRequestAuth.apiKey', async () => {
providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk-test' };
models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000 };
const auth = await ix.get(IModelResolver).resolve('m').authProvider.getAuth();
expect(auth).toEqual({ apiKey: 'sk-test' });
});
it('prefers a model-inline apiKey override as ProviderRequestAuth.apiKey', async () => {
providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk-provider' };
models['m'] = {
provider: 'p',
model: 'wire-name',
maxContextSize: 1000,
apiKey: 'sk-model',
};
const auth = await ix.get(IModelResolver).resolve('m').authProvider.getAuth();
expect(auth).toEqual({ apiKey: 'sk-model' });
});
it('returns an OAuth access token as ProviderRequestAuth.apiKey', async () => {
providers['p'] = {
type: 'kimi',
baseUrl: 'https://example.test/v1',
oauth: { storage: 'file', key: 'oauth/test' },
};
models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000 };
resolveTokenProvider.mockReturnValue({ getAccessToken: async () => 'oauth-token' });
const auth = await ix.get(IModelResolver).resolve('m').authProvider.getAuth();
expect(auth).toEqual({ apiKey: 'oauth-token' });
expect(resolveTokenProvider).toHaveBeenCalledWith('p', { storage: 'file', key: 'oauth/test' });
});
it('returns undefined when the model carries no auth material', async () => {
providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1' };
models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000 };
const auth = await ix.get(IModelResolver).resolve('m').authProvider.getAuth();
expect(auth).toBeUndefined();
});
it('falls through an empty-string provider apiKey to OAuth', async () => {
providers['p'] = {
type: 'kimi',
baseUrl: 'https://example.test/v1',
apiKey: '',
oauth: { storage: 'file', key: 'oauth/test' },
};
models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000 };
resolveTokenProvider.mockReturnValue({ getAccessToken: async () => 'oauth-token' });
const auth = await ix.get(IModelResolver).resolve('m').authProvider.getAuth();
expect(auth).toEqual({ apiKey: 'oauth-token' });
});
describe('default thinking', () => {
function resolveEffort(capabilities?: string[]): string | null {
providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk' };
models['m'] = {
provider: 'p',
model: 'wire-name',
maxContextSize: 1000,
...(capabilities === undefined ? {} : { capabilities }),
};
return ix.get(IModelResolver).resolve('m').thinkingEffort;
}
it('defaults to "high" when thinking is not disabled', () => {
expect(resolveEffort()).toBe('high');
});
it('is off (null) when defaultThinking is false', () => {
configValues['defaultThinking'] = false;
expect(resolveEffort()).toBeNull();
});
it('is off (null) when thinking.mode is "off"', () => {
configValues['thinking'] = { mode: 'off' };
expect(resolveEffort()).toBeNull();
});
it('uses the configured thinking.effort', () => {
configValues['thinking'] = { effort: 'medium' };
expect(resolveEffort()).toBe('medium');
});
it('clamps an explicit off back to on for always_thinking models', () => {
configValues['defaultThinking'] = false;
expect(resolveEffort(['always_thinking'])).toBe('high');
});
});
describe('baseUrl normalization', () => {
function resolveBaseUrl(protocol: string, providerType: string, baseUrl: string): string {
providers['p'] = { type: providerType, baseUrl, apiKey: 'sk' } as ProviderConfig;
models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000, protocol } as ModelConfig;
return ix.get(IModelResolver).resolve('m').baseUrl;
}
it('strips a trailing /v1 for the anthropic protocol', () => {
expect(resolveBaseUrl('anthropic', 'kimi', 'https://example.test/coding/v1')).toBe(
'https://example.test/coding',
);
});
it('strips a trailing /v1/ (with slash) for the anthropic protocol', () => {
expect(resolveBaseUrl('anthropic', 'kimi', 'https://example.test/coding/v1/')).toBe(
'https://example.test/coding',
);
});
it('does not strip /v1 for non-anthropic protocols', () => {
expect(resolveBaseUrl('kimi', 'kimi', 'https://example.test/coding/v1')).toBe(
'https://example.test/coding/v1',
);
});
});
});