diff --git a/packages/agent-core-v2/examples/model-providers.example.ts b/packages/agent-core-v2/examples/model-providers.example.ts new file mode 100644 index 000000000..b6d2357e4 --- /dev/null +++ b/packages/agent-core-v2/examples/model-providers.example.ts @@ -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>, + 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>, +): 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( + items: readonly T[], + size: number, + fn: (item: T) => Promise, +): Promise { + const results: R[] = new Array(items.length); + let next = 0; + const worker = async (): Promise => { + 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; +} diff --git a/packages/agent-core-v2/src/app/model/modelResolverService.ts b/packages/agent-core-v2/src/app/model/modelResolverService.ts index c6449a3d9..923c9ef9e 100644 --- a/packages/agent-core-v2/src/app/model/modelResolverService.ts +++ b/packages/agent-core-v2/src/app/model/modelResolverService.ts @@ -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('defaultThinking'); + const thinking = this.config.get('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> | undefined { + const extras: Record = {}; + if (model.adaptiveThinking !== undefined) { + extras['adaptiveThinking'] = model.adaptiveThinking; + } + const betaApi = (model as Record)['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 diff --git a/packages/agent-core-v2/test/model/modelResolver.test.ts b/packages/agent-core-v2/test/model/modelResolver.test.ts new file mode 100644 index 000000000..d23ffe0e6 --- /dev/null +++ b/packages/agent-core-v2/test/model/modelResolver.test.ts @@ -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; + let models: Record; + let configValues: Record; + let resolveTokenProvider: ReturnType; + + 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', + ); + }); + }); +});