mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-19 13:45:28 +00:00
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:
parent
ec865ed89c
commit
643acf454a
3 changed files with 670 additions and 5 deletions
373
packages/agent-core-v2/examples/model-providers.example.ts
Normal file
373
packages/agent-core-v2/examples/model-providers.example.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
196
packages/agent-core-v2/test/model/modelResolver.test.ts
Normal file
196
packages/agent-core-v2/test/model/modelResolver.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue