diff --git a/.changeset/mcp-initial-load-race.md b/.changeset/mcp-initial-load-race.md new file mode 100644 index 000000000..38086ff23 --- /dev/null +++ b/.changeset/mcp-initial-load-race.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix MCP tools being unavailable on the first turn after session startup. diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 1d0c7edfc..34eb01dce 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -285,14 +285,30 @@ export class AgentProfileService implements IAgentProfileService { resolveModel(): Model | undefined { if (this.modelAlias === undefined) return undefined; let model: Model = this.modelFactory.resolve(this.modelAlias); - model = model.withThinking(this.thinkingLevel); + const thinkingLevel = this.thinkingLevel; + const kwargs: GenerationKwargs = {}; + if (model.protocol === 'kimi') { + kwargs.prompt_cache_key = this.sessionContext.sessionId; + } else if (model.protocol === 'anthropic') { + model = model.withProviderOptions({ + metadata: { user_id: this.sessionContext.sessionId }, + }); + } const overrides = this.config.get('modelOverrides'); if (overrides !== undefined) { - const kwargs: GenerationKwargs = {}; if (overrides.temperature !== undefined) kwargs.temperature = overrides.temperature; if (overrides.topP !== undefined) kwargs.top_p = overrides.topP; - if (Object.keys(kwargs).length > 0) model = model.withGenerationKwargs(kwargs); + if ( + model.protocol === 'kimi' && + thinkingLevel !== 'off' && + overrides.thinkingKeep !== undefined && + overrides.thinkingKeep.length > 0 + ) { + kwargs.extra_body = { thinking: { keep: overrides.thinkingKeep } }; + } } + if (Object.keys(kwargs).length > 0) model = model.withGenerationKwargs(kwargs); + model = model.withThinking(thinkingLevel); return model; } @@ -355,9 +371,11 @@ export class AgentProfileService implements IAgentProfileService { if (changed.modelAlias !== undefined) payload.modelAlias = changed.modelAlias; if (changed.profileName !== undefined) payload.profileName = changed.profileName; if (changed.thinkingLevel !== undefined) { + const model = this.resolveModelForThinking(changed.modelAlias); payload.thinkingLevel = resolveThinkingEffort( changed.thinkingLevel, this.config.get(THINKING_SECTION), + model, ); } if (changed.systemPrompt !== undefined) payload.systemPrompt = changed.systemPrompt; @@ -423,7 +441,11 @@ export class AgentProfileService implements IAgentProfileService { private get thinkingLevel(): ThinkingEffort { const stored = this.profileState.thinkingLevel; if (stored === 'off' && this.alwaysThinkingModel) { - return resolveThinkingEffort('on', this.config.get(THINKING_SECTION)); + return resolveThinkingEffort( + 'on', + this.config.get(THINKING_SECTION), + this.tryResolveRawModel(), + ); } return stored; } @@ -434,6 +456,10 @@ export class AgentProfileService implements IAgentProfileService { private tryResolveRawModel(): Model | undefined { const alias = this.modelAlias; + return this.resolveModelForThinking(alias); + } + + private resolveModelForThinking(alias: string | undefined): Model | undefined { if (alias === undefined) return undefined; try { return this.modelFactory.resolve(alias); diff --git a/packages/agent-core-v2/src/agent/profile/thinking.ts b/packages/agent-core-v2/src/agent/profile/thinking.ts index 3e361a722..a64c5e4ca 100644 --- a/packages/agent-core-v2/src/agent/profile/thinking.ts +++ b/packages/agent-core-v2/src/agent/profile/thinking.ts @@ -7,15 +7,17 @@ */ import type { ThinkingEffort } from '#/app/llmProtocol'; +import { + type ModelThinkingMetadata, + resolveThinkingEffortForModel, +} from '#/app/model/thinking'; import type { ThinkingConfig } from './configSection'; -const DEFAULT_THINKING_EFFORT: ThinkingEffort = 'high'; -const THINKING_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max']); - export interface ResolveThinkingLevelOptions { readonly defaultThinking?: boolean; readonly thinking?: ThinkingConfig; + readonly model?: ModelThinkingMetadata; } export function resolveThinkingLevel( @@ -29,27 +31,13 @@ export function resolveThinkingLevel( ? 'off' : undefined; - return resolveThinkingEffort(resolvedRequest, options.thinking); + return resolveThinkingEffort(resolvedRequest, options.thinking, options.model); } export function resolveThinkingEffort( requested: string | undefined, defaults: ThinkingConfig | undefined, + model?: ModelThinkingMetadata, ): ThinkingEffort { - const configEffort = parseEffort(defaults?.effort) ?? DEFAULT_THINKING_EFFORT; - const normalized = requested?.trim().toLowerCase(); - if (!normalized) { - if (defaults?.mode === 'off') return 'off'; - return configEffort; - } - if (normalized === 'off') return 'off'; - if (normalized === 'on') return configEffort; - return parseEffort(normalized) ?? configEffort; -} - -function parseEffort(value: string | undefined): ThinkingEffort | undefined { - const normalized = value?.trim().toLowerCase(); - return normalized !== undefined && THINKING_EFFORTS.has(normalized as ThinkingEffort) - ? (normalized as ThinkingEffort) - : undefined; + return resolveThinkingEffortForModel(requested, defaults, model); } diff --git a/packages/agent-core-v2/src/app/llmProtocol/provider.ts b/packages/agent-core-v2/src/app/llmProtocol/provider.ts index 2e08f6c24..fad76d196 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/provider.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/provider.ts @@ -5,13 +5,14 @@ import type { TokenUsage } from './usage'; /** * Normalized thinking effort level used across providers. * - * Values above `high` are provider/model-specific and may be clamped by the - * adapter when the native API has no matching level. OpenAI maps `max` to its - * `xhigh` ceiling; Kimi and Gemini cap `xhigh`/`max` at `high`; Anthropic - * supports `xhigh`/`max` only on selected models and otherwise clamps to - * `high`. + * `'on'` is the boolean-thinking signal for models that do not expose named + * efforts. Values above `high` are provider/model-specific and may be clamped + * by the adapter when the native API has no matching level. OpenAI maps `max` + * to its `xhigh` ceiling; Kimi and Gemini cap `xhigh`/`max` at `high`; + * Anthropic supports `xhigh`/`max` only on selected models and otherwise clamps + * to `high`. */ -export type ThinkingEffort = 'off' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'; +export type ThinkingEffort = 'off' | 'on' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'; /** * Optional context passed to {@link ChatProvider.withMaxCompletionTokens} so a diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts index c56d346da..d52e9a41d 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/anthropic.ts @@ -330,7 +330,17 @@ function supportsEffortParam(model: string, adaptive: boolean): boolean { return normalized.includes('opus-4-5') || normalized.includes('opus-4.5'); } -function clampEffort(effort: ThinkingEffort, model: string, adaptive: boolean): ThinkingEffort { +type AnthropicThinkingEffort = Exclude; + +function normalizeAnthropicEffort(effort: ThinkingEffort): AnthropicThinkingEffort { + return effort === 'on' ? 'high' : effort; +} + +function clampEffort( + effort: AnthropicThinkingEffort, + model: string, + adaptive: boolean, +): AnthropicThinkingEffort { if (effort === 'off') { return effort; } @@ -343,7 +353,7 @@ function clampEffort(effort: ThinkingEffort, model: string, adaptive: boolean): return effort; } -function budgetTokensForEffort(effort: ThinkingEffort): number { +function budgetTokensForEffort(effort: AnthropicThinkingEffort): number { switch (effort) { case 'low': return 1024; @@ -1222,7 +1232,7 @@ export class AnthropicChatProvider implements ChatProvider { return clone; } - const effectiveEffort = clampEffort(effort, this._model, adaptive); + const effectiveEffort = clampEffort(normalizeAnthropicEffort(effort), this._model, adaptive); if (effectiveEffort === 'off') { throw new Error('Non-off thinking effort unexpectedly clamped to off.'); } diff --git a/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts b/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts index ba33fdfd4..269c40253 100644 --- a/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts +++ b/packages/agent-core-v2/src/app/llmProtocol/providers/kimi.ts @@ -27,7 +27,6 @@ import { normalizeOpenAIFinishReason, type OpenAIContentPart, type OpenAIToolParam, - reasoningEffortToThinkingEffort, toolToOpenAI, } from './openai-common'; import { @@ -47,6 +46,7 @@ export interface KimiOptions { stream?: boolean | undefined; defaultHeaders?: Record | undefined; generationKwargs?: GenerationKwargs | undefined; + supportEfforts?: readonly string[]; clientFactory?: (auth: ProviderRequestAuth) => OpenAI; } @@ -365,6 +365,7 @@ export class KimiChatProvider implements ChatProvider { private _baseUrl: string; private _defaultHeaders: Record | undefined; private _generationKwargs: GenerationKwargs; + private readonly _supportEfforts: readonly string[]; private _client: OpenAI | undefined; private _clientFactory: ((auth: ProviderRequestAuth) => OpenAI) | undefined; private _files: KimiFiles | undefined; @@ -378,6 +379,7 @@ export class KimiChatProvider implements ChatProvider { this._model = options.model; this._stream = options.stream ?? true; this._generationKwargs = { ...options.generationKwargs }; + this._supportEfforts = options.supportEfforts ?? []; this._client = this._apiKey === undefined ? undefined @@ -414,7 +416,11 @@ export class KimiChatProvider implements ChatProvider { } get thinkingEffort(): ThinkingEffort | null { - return reasoningEffortToThinkingEffort(this._generationKwargs.reasoning_effort); + const thinking = this._generationKwargs.extra_body?.thinking; + if (thinking === undefined) return null; + if (thinking.type === 'disabled') return 'off'; + const effort = thinking['effort']; + return typeof effort === 'string' ? (effort as ThinkingEffort) : 'on'; } get modelParameters(): Record { @@ -500,28 +506,21 @@ export class KimiChatProvider implements ChatProvider { } withThinking(effort: ThinkingEffort): KimiChatProvider { - const thinking: ThinkingConfig = { - type: effort === 'off' ? 'disabled' : 'enabled', - }; - let reasoningEffort: string | undefined; - switch (effort) { - case 'off': - reasoningEffort = undefined; - break; - case 'low': - reasoningEffort = 'low'; - break; - case 'medium': - reasoningEffort = 'medium'; - break; - case 'high': - case 'xhigh': - case 'max': - reasoningEffort = 'high'; - break; + let thinking: ThinkingConfig; + if (effort === 'off') { + thinking = { type: 'disabled' }; + } else { + thinking = this._supportEfforts.includes(effort) + ? { type: 'enabled', effort } + : { type: 'enabled' }; } - return this._withGenerationKwargs({ reasoning_effort: reasoningEffort }).withExtraBody({ - thinking, + const oldExtra = this._generationKwargs.extra_body ?? {}; + const keep = oldExtra.thinking?.keep; + if (keep !== undefined) { + thinking = { ...thinking, keep }; + } + return this._withGenerationKwargs({ + extra_body: { ...oldExtra, thinking }, }); } diff --git a/packages/agent-core-v2/src/app/model/configSection.ts b/packages/agent-core-v2/src/app/model/configSection.ts index 418246fac..77e0eeb24 100644 --- a/packages/agent-core-v2/src/app/model/configSection.ts +++ b/packages/agent-core-v2/src/app/model/configSection.ts @@ -23,7 +23,15 @@ export const modelsFromToml = (rawSnake: unknown): unknown => { if (!isPlainObject(rawSnake)) return rawSnake; const out: Record = {}; for (const [alias, entry] of Object.entries(rawSnake)) { - out[alias] = isPlainObject(entry) ? transformPlainObject(entry) : entry; + if (!isPlainObject(entry)) { + out[alias] = entry; + continue; + } + const converted = transformPlainObject(entry); + if (isPlainObject(converted['overrides'])) { + converted['overrides'] = transformPlainObject(converted['overrides']); + } + out[alias] = converted; } return out; }; @@ -43,6 +51,8 @@ export const modelsToToml = (value: unknown, rawSnake: unknown): unknown => { for (const [key, field] of Object.entries(entry)) { if (key === 'capabilities' && Array.isArray(field)) { converted[camelToSnake(key)] = [...field]; + } else if (key === 'overrides' && isPlainObject(field)) { + converted['overrides'] = modelOverridesToToml(field, rawEntry['overrides']); } else { setDefined(converted, camelToSnake(key), field); } @@ -50,10 +60,25 @@ export const modelsToToml = (value: unknown, rawSnake: unknown): unknown => { out[alias] = { ...rawEntry, ...converted }; } return out; +}; + +function modelOverridesToToml( + overrides: Record, + rawSnake: unknown, +): Record { + const out = cloneRecord(rawSnake); + for (const [key, value] of Object.entries(overrides)) { + if (key === 'capabilities' && Array.isArray(value)) { + out[camelToSnake(key)] = [...value]; + } else { + setDefined(out, camelToSnake(key), value); + } + } + return out; } registerConfigSection(MODELS_SECTION, ModelsSectionSchema, { defaultValue: {}, fromToml: modelsFromToml, toToml: modelsToToml, -});; +}); diff --git a/packages/agent-core-v2/src/app/model/index.ts b/packages/agent-core-v2/src/app/model/index.ts index 179d42235..4849e62ce 100644 --- a/packages/agent-core-v2/src/app/model/index.ts +++ b/packages/agent-core-v2/src/app/model/index.ts @@ -20,3 +20,4 @@ export * from './modelOverrides'; export * from './modelResolver'; export * from './modelResolverService'; export * from './modelService'; +export * from './thinking'; diff --git a/packages/agent-core-v2/src/app/model/model.ts b/packages/agent-core-v2/src/app/model/model.ts index 55daeb793..d45a8c62a 100644 --- a/packages/agent-core-v2/src/app/model/model.ts +++ b/packages/agent-core-v2/src/app/model/model.ts @@ -33,42 +33,60 @@ import { ProtocolSchema } from '#/app/protocol'; export const MODELS_SECTION = 'models'; -export const ModelSchema = z - .object({ - // Structured path — reference a Provider (which references a Platform). - providerId: z.string().optional(), +const ModelBaseSchema = z.object({ + // Structured path — reference a Provider (which references a Platform). + providerId: z.string().optional(), - // Flat path — inline endpoint + optional inline auth overrides. When - // providerId is absent, the resolver synthesizes a Provider from the - // baseUrl origin. When both are present, providerId wins and baseUrl - // acts as a per-Model override. - baseUrl: z.string().optional(), - apiKey: z.string().optional(), - oauth: OAuthRefSchema.optional(), + // Flat path — inline endpoint + optional inline auth overrides. When + // providerId is absent, the resolver synthesizes a Provider from the + // baseUrl origin. When both are present, providerId wins and baseUrl + // acts as a per-Model override. + baseUrl: z.string().optional(), + apiKey: z.string().optional(), + oauth: OAuthRefSchema.optional(), - // Wire protocol. Every Model declares exactly one; if the same physical - // model is served over two protocols (e.g. Anthropic direct + OpenAI- - // compat), that is two Model entries with different ids and a shared - // `name` (via `aliases`). - protocol: ProtocolSchema.optional(), + // Wire protocol. Every Model declares exactly one; if the same physical + // model is served over two protocols (e.g. Anthropic direct + OpenAI- + // compat), that is two Model entries with different ids and a shared + // `name` (via `aliases`). + protocol: ProtocolSchema.optional(), - // Wire-facing model identifier and routing aliases. - name: z.string().optional(), - aliases: z.array(z.string()).optional(), + // Wire-facing model identifier and routing aliases. + name: z.string().optional(), + aliases: z.array(z.string()).optional(), - // Existing capability / budget knobs — carried forward unchanged so - // legacy configs continue to load. Phase 4 migration lifts the old - // `provider`+`model` pair into the new `providerId`+`name` shape. - provider: z.string().optional(), - model: z.string().optional(), - maxContextSize: z.number().int().min(1).optional(), - maxOutputSize: z.number().int().min(1).optional(), - capabilities: z.array(z.string()).optional(), - displayName: z.string().optional(), - reasoningKey: z.string().optional(), - adaptiveThinking: z.boolean().optional(), - }) - .passthrough(); + // Existing capability / budget knobs — carried forward unchanged so + // legacy configs continue to load. Phase 4 migration lifts the old + // `provider`+`model` pair into the new `providerId`+`name` shape. + provider: z.string().optional(), + model: z.string().optional(), + maxContextSize: z.number().int().min(1).optional(), + maxOutputSize: z.number().int().min(1).optional(), + capabilities: z.array(z.string()).optional(), + displayName: z.string().optional(), + reasoningKey: z.string().optional(), + adaptiveThinking: z.boolean().optional(), + betaApi: z.boolean().optional(), + supportEfforts: z.array(z.string()).optional(), + defaultEffort: z.string().optional(), +}); + +export const ModelOverrideSchema = ModelBaseSchema.omit({ + providerId: true, + baseUrl: true, + apiKey: true, + oauth: true, + protocol: true, + name: true, + aliases: true, + provider: true, + model: true, + betaApi: true, +}).partial(); + +export const ModelSchema = ModelBaseSchema.extend({ + overrides: ModelOverrideSchema.optional(), +}).passthrough(); export type ModelConfig = z.infer; diff --git a/packages/agent-core-v2/src/app/model/modelImpl.ts b/packages/agent-core-v2/src/app/model/modelImpl.ts index e3f4e512d..e99bd7c9c 100644 --- a/packages/agent-core-v2/src/app/model/modelImpl.ts +++ b/packages/agent-core-v2/src/app/model/modelImpl.ts @@ -18,20 +18,22 @@ */ import { AsyncEventQueue } from '#/_base/asyncEventQueue'; -import type { - GenerateCallbacks, - GenerationKwargs, - MaxCompletionTokensOptions, - ModelCapability, - ProviderRequestAuth, - StreamDecodeStats, - ThinkingEffort, - VideoUploadInput, - VideoURLPart, +import { + APIStatusError, + type GenerateCallbacks, + type GenerationKwargs, + type MaxCompletionTokensOptions, + type ModelCapability, + type ProviderRequestAuth, + type StreamDecodeStats, + type ThinkingEffort, + type VideoUploadInput, + type VideoURLPart, } from '#/app/llmProtocol'; -import type { ChatProvider, Protocol } from '#/app/protocol'; +import type { ChatProvider, Protocol, ProtocolProviderOptions } from '#/app/protocol'; import { generate } from '#/app/protocol'; import { type ProtocolAdapterRegistry } from '#/app/protocol/protocolAdapterRegistry'; +import { ErrorCodes, KimiError } from '#/errors'; import type { AuthProvider, LLMEvent, LLMRequestInput, Model } from './modelInstance'; @@ -47,12 +49,13 @@ export interface ModelImplInit { readonly maxOutputSize?: number; readonly displayName?: string; readonly reasoningKey?: string; + readonly supportEfforts?: readonly string[]; + readonly defaultEffort?: string; readonly alwaysThinking: boolean; readonly providerName: string; readonly authProvider: AuthProvider; readonly protocolRegistry: ProtocolAdapterRegistry; - /** Extra kosong-shaped config passed through when constructing the wire adapter. */ - readonly extras?: Readonly>; + readonly providerOptions?: ProtocolProviderOptions; } export class ModelImpl implements Model { @@ -67,13 +70,15 @@ export class ModelImpl implements Model { readonly maxOutputSize?: number; readonly displayName?: string; readonly reasoningKey?: string; + readonly supportEfforts?: readonly string[]; + readonly defaultEffort?: string; readonly authProvider: AuthProvider; readonly thinkingEffort: ThinkingEffort | null; readonly alwaysThinking: boolean; readonly providerName: string; private readonly protocolRegistry: ProtocolAdapterRegistry; - private readonly extras: Readonly>; + private readonly providerOptions: ProtocolProviderOptions; /** * Chain of transforms applied to the raw kosong `ChatProvider` before use. @@ -96,9 +101,11 @@ export class ModelImpl implements Model { this.maxOutputSize = init.maxOutputSize; this.displayName = init.displayName; this.reasoningKey = init.reasoningKey; + this.supportEfforts = init.supportEfforts; + this.defaultEffort = init.defaultEffort; this.authProvider = init.authProvider; this.protocolRegistry = init.protocolRegistry; - this.extras = init.extras ?? {}; + this.providerOptions = init.providerOptions ?? {}; this.transforms = transforms; this.alwaysThinking = init.alwaysThinking; this.providerName = init.providerName; @@ -109,8 +116,9 @@ export class ModelImpl implements Model { } private clone( - transform: (p: ChatProvider) => ChatProvider, + transform: ((p: ChatProvider) => ChatProvider) | undefined, fieldOverride?: Partial, + initOverride?: { readonly providerOptions?: ProtocolProviderOptions }, ): Model { const next = new ModelImpl( { @@ -125,13 +133,15 @@ export class ModelImpl implements Model { maxOutputSize: this.maxOutputSize, displayName: this.displayName, reasoningKey: this.reasoningKey, + supportEfforts: this.supportEfforts, + defaultEffort: this.defaultEffort, alwaysThinking: this.alwaysThinking, providerName: this.providerName, authProvider: this.authProvider, protocolRegistry: this.protocolRegistry, - extras: this.extras, + providerOptions: initOverride?.providerOptions ?? this.providerOptions, }, - [...this.transforms, transform], + transform === undefined ? this.transforms : [...this.transforms, transform], ); if (fieldOverride !== undefined) { Object.assign(next, fieldOverride); @@ -158,6 +168,12 @@ export class ModelImpl implements Model { }); } + withProviderOptions(options: ProtocolProviderOptions): Model { + return this.clone(undefined, undefined, { + providerOptions: mergeProviderOptions(this.providerOptions, options), + }); + } + /** Materialize the transformed kosong ChatProvider. Cached per Model instance. */ private resolveChatProvider(): ChatProvider { if (this.cachedChatProvider !== undefined) return this.cachedChatProvider; @@ -166,7 +182,7 @@ export class ModelImpl implements Model { baseUrl: this.baseUrl, modelName: this.name, defaultHeaders: this.headers, - extras: this.extras, + providerOptions: this.providerOptions, }); for (const transform of this.transforms) provider = transform(provider); this.cachedChatProvider = provider; @@ -192,7 +208,10 @@ export class ModelImpl implements Model { `Model "${this.id}" (protocol=${this.protocol}) does not support video upload`, ); } - return provider.uploadVideo(input, { signal: options?.signal }); + const uploadVideo = provider.uploadVideo; + return this.runWithAuthRefresh((auth) => + uploadVideo.call(provider, input, { signal: options?.signal, auth }), + ); } private async runRequest( @@ -218,30 +237,30 @@ export class ModelImpl implements Model { }, }; - const auth = await this.authProvider.getAuth(); - requestStartedAt = Date.now(); - - const result = await generate( - provider, - input.systemPrompt, - [...input.tools], - [...input.messages], - callbacks, - { - signal, - auth, - onRequestStart: () => { - requestStartedAt = Date.now(); + const result = await this.runWithAuthRefresh((auth) => { + requestStartedAt = Date.now(); + return generate( + provider, + input.systemPrompt, + [...input.tools], + [...input.messages], + callbacks, + { + signal, + auth, + onRequestStart: () => { + requestStartedAt = Date.now(); + }, + onRequestSent: () => { + requestSentAt = Date.now(); + }, + onStreamEnd: (stats) => { + streamEndedAt = Date.now(); + decodeStats = stats; + }, }, - onRequestSent: () => { - requestSentAt = Date.now(); - }, - onStreamEnd: (stats) => { - streamEndedAt = Date.now(); - decodeStats = stats; - }, - }, - ); + ); + }); // Non-streaming providers still populate `result.message`; surface its // content and tool calls as parts so downstream consumers see them. @@ -279,6 +298,61 @@ export class ModelImpl implements Model { }); } } + + private async runWithAuthRefresh( + run: (auth: ProviderRequestAuth | undefined) => Promise, + ): Promise { + const auth = await this.authProvider.getAuth(); + try { + return await run(auth); + } catch (error) { + if (!this.shouldForceRefresh(error)) throw error; + } + + const refreshedAuth = await this.authProvider.getAuth({ force: true }); + try { + return await run(refreshedAuth); + } catch (error) { + if (isUnauthorizedStatusError(error)) throw toLoginRequiredError(error); + throw error; + } + } + + private shouldForceRefresh(error: unknown): boolean { + return this.authProvider.canRefresh === true && isUnauthorizedStatusError(error); + } +} + +function isUnauthorizedStatusError(error: unknown): error is APIStatusError { + return error instanceof APIStatusError && error.statusCode === 401; +} + +function toLoginRequiredError(error: APIStatusError): KimiError { + return new KimiError( + ErrorCodes.AUTH_LOGIN_REQUIRED, + 'OAuth provider credentials were rejected. Send /login to login.', + { + cause: error, + details: { + statusCode: error.statusCode, + requestId: error.requestId, + }, + }, + ); +} + +function mergeProviderOptions( + base: ProtocolProviderOptions, + next: ProtocolProviderOptions, +): ProtocolProviderOptions { + return { + ...base, + ...next, + metadata: + base.metadata === undefined && next.metadata === undefined + ? undefined + : { ...base.metadata, ...next.metadata }, + }; } export function buildStreamTiming( @@ -325,6 +399,8 @@ export function buildStreamTiming( * refresh semantics. */ export class StaticAuthProvider implements AuthProvider { + readonly canRefresh = false; + constructor(private readonly apiKey: string | undefined) {} async getAuth(): Promise { if (this.apiKey === undefined || this.apiKey.trim().length === 0) return undefined; diff --git a/packages/agent-core-v2/src/app/model/modelInstance.ts b/packages/agent-core-v2/src/app/model/modelInstance.ts index a848f12f1..3a576f91a 100644 --- a/packages/agent-core-v2/src/app/model/modelInstance.ts +++ b/packages/agent-core-v2/src/app/model/modelInstance.ts @@ -31,7 +31,7 @@ import type { VideoUploadInput, VideoURLPart, } from '#/app/llmProtocol'; -import type { Protocol } from '#/app/protocol'; +import type { Protocol, ProtocolProviderOptions } from '#/app/protocol'; /** * Closure that produces a fresh `ProviderRequestAuth` on demand. Wraps an @@ -39,6 +39,9 @@ import type { Protocol } from '#/app/protocol'; * Reading it always returns the current material — callers must not cache. */ export interface AuthProvider { + /** Whether this auth source can force-refresh credentials after an upstream 401. */ + readonly canRefresh?: boolean; + /** * Get a `ProviderRequestAuth` for the next request. Returns `undefined` * when no auth material is available (anonymous endpoint, or the caller @@ -96,6 +99,8 @@ export interface Model { readonly maxOutputSize?: number; readonly displayName?: string; readonly reasoningKey?: string; + readonly supportEfforts?: readonly string[]; + readonly defaultEffort?: string; readonly thinkingEffort: ThinkingEffort | null; /** * True when this Model's capabilities include `always_thinking` — the @@ -125,6 +130,9 @@ export interface Model { /** Return a new Model wrapper with additional generation kwargs applied. */ withGenerationKwargs(kwargs: GenerationKwargs): Model; + /** Return a new Model wrapper with additional protocol-constructor options applied. */ + withProviderOptions(options: ProtocolProviderOptions): Model; + /** * Drive one LLM request end-to-end. Streams `LLMEvent`s until the stream * terminates (either normally with `usage`+`finish`, or with an error). diff --git a/packages/agent-core-v2/src/app/model/modelResolverService.ts b/packages/agent-core-v2/src/app/model/modelResolverService.ts index 983bd08e9..e40d5090a 100644 --- a/packages/agent-core-v2/src/app/model/modelResolverService.ts +++ b/packages/agent-core-v2/src/app/model/modelResolverService.ts @@ -30,7 +30,11 @@ import { getModelCapability } from '#/app/llmProtocol/providers'; import { IPlatformService, UNKNOWN_PLATFORM_KEY } from '#/app/platform'; import type { OAuthRef, ProviderConfig } from '#/app/provider'; import { IProviderService } from '#/app/provider'; -import { IProtocolAdapterRegistry, type Protocol } from '#/app/protocol'; +import { + IProtocolAdapterRegistry, + type Protocol, + type ProtocolProviderOptions, +} from '#/app/protocol'; import { type ProtocolAdapterRegistry } from '#/app/protocol/protocolAdapterRegistry'; import type { ModelConfig } from './model'; @@ -38,16 +42,7 @@ import { IModelService } from './model'; 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']; +import { resolveThinkingEffortForModel } from './thinking'; /** Shape of the `thinking` config section (owned by `profile`); only the * fields the resolver needs to mirror the production default are read here. */ @@ -62,6 +57,10 @@ interface ResolvedAuthMaterial { readonly oauthProviderKey?: string; } +type MutableProtocolProviderOptions = { + -readonly [K in keyof ProtocolProviderOptions]: ProtocolProviderOptions[K]; +}; + export class ModelResolverService extends Disposable implements IModelResolver { declare readonly _serviceBrand: undefined; @@ -78,24 +77,25 @@ export class ModelResolverService extends Disposable implements IModelResolver { } resolve(id: string): Model { - const model = this.models.get(id); - if (model === undefined) { + const configuredModel = this.models.get(id); + if (configuredModel === undefined) { throw new KimiError( ErrorCodes.CONFIG_INVALID, `Model "${id}" is not configured in config.toml.`, ); } + const model = effectiveModelConfig(configuredModel); const { providerConfig, providerName, resolvedBaseUrl: rawBaseUrl } = this.resolveProviderContext(id, model); - const auth = this.resolveAuth(model, providerConfig); + const auth = this.resolveAuth(id, model, providerConfig, providerName); 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; + // Match production v1: strip a trailing `/v1` only when the model explicitly + // overrides into the Anthropic transport. Native Anthropic providers keep + // their configured `/v1` because the old provider manager did too. + const resolvedBaseUrl = + model.protocol === 'anthropic' ? stripTrailingV1(rawBaseUrl) : rawBaseUrl; const wireName = model.name ?? model.model; if (wireName === undefined) { throw new KimiError( @@ -116,6 +116,12 @@ export class ModelResolverService extends Disposable implements IModelResolver { wireName, model.maxContextSize, ); + const providerOptions = buildProtocolProviderOptions( + model, + protocol, + providerConfig, + resolvedBaseUrl, + ); const declared = new Set((model.capabilities ?? []).map((c) => c.trim().toLowerCase())); const alwaysThinking = declared.has('always_thinking'); @@ -131,11 +137,13 @@ export class ModelResolverService extends Disposable implements IModelResolver { maxOutputSize: model.maxOutputSize, displayName: model.displayName, reasoningKey: model.reasoningKey, + supportEfforts: model.supportEfforts, + defaultEffort: model.defaultEffort, alwaysThinking, providerName, authProvider, protocolRegistry: this.protocolRegistry as ProtocolAdapterRegistry, - extras: buildProviderExtras(model), + providerOptions, }); // Apply the production default thinking effort so a plain `model.request()` @@ -143,7 +151,7 @@ export class ModelResolverService extends Disposable implements IModelResolver { // 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); + const effort = this.resolveDefaultThinking(model, alwaysThinking); return effort === 'off' ? impl : impl.withThinking(effort); } @@ -152,18 +160,25 @@ export class ModelResolverService extends Disposable implements IModelResolver { * 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; + * - otherwise the configured `thinking.effort` is used, falling back to the + * model's declared default effort / middle supported effort / boolean `on`; * - an `always_thinking` model clamps an explicit "off" back to on. */ - private resolveDefaultThinking(alwaysThinking: boolean): ThinkingEffort { + private resolveDefaultThinking( + model: ModelConfig, + 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; + return resolveThinkingEffortForModel( + undefined, + { + defaultThinking, + mode: thinking?.mode, + effort: thinking?.effort, + }, + { ...model, alwaysThinking }, + ); } findByName(name: string): readonly string[] { @@ -202,7 +217,13 @@ export class ModelResolverService extends Disposable implements IModelResolver { `Provider "${providerId}" referenced by model "${id}" is not configured.`, ); } - const baseUrl = model.baseUrl ?? providerConfig.baseUrl; + const baseUrl = + nonEmpty(model.baseUrl) ?? + nonEmpty(providerConfig.baseUrl) ?? + providerBaseUrlEnvFallback( + model.protocol ?? (providerConfig.type as Protocol | undefined), + providerConfig.env, + ); if (baseUrl === undefined || baseUrl.length === 0) { throw new KimiError( ErrorCodes.CONFIG_INVALID, @@ -214,17 +235,18 @@ export class ModelResolverService extends Disposable implements IModelResolver { // Flat path — Model carries its own baseUrl. Synthesize a Provider id // from the URL's origin so two flat Models on the same host converge. - if (model.baseUrl === undefined || model.baseUrl.length === 0) { + const modelBaseUrl = nonEmpty(model.baseUrl); + if (modelBaseUrl === undefined) { throw new KimiError( ErrorCodes.CONFIG_INVALID, `Model "${id}" must set either providerId or baseUrl in config.toml.`, ); } - const originName = deriveProviderId(model.baseUrl); + const originName = deriveProviderId(modelBaseUrl); return { providerConfig: undefined, providerName: originName, - resolvedBaseUrl: model.baseUrl, + resolvedBaseUrl: modelBaseUrl, }; } @@ -255,10 +277,15 @@ export class ModelResolverService extends Disposable implements IModelResolver { * empty bearer token. */ private resolveAuth( + id: string, model: ModelConfig, provider: ProviderConfig | undefined, + providerName: string, ): ResolvedAuthMaterial { const modelApiKey = nonEmpty(model.apiKey); + if (modelApiKey !== undefined && model.oauth !== undefined) { + throw authConflictError('Model', id); + } if (modelApiKey !== undefined) return { apiKey: modelApiKey }; if (model.oauth !== undefined) { return { oauth: model.oauth, oauthProviderKey: model.providerId ?? model.provider }; @@ -267,7 +294,13 @@ export class ModelResolverService extends Disposable implements IModelResolver { const platformId = provider?.platformId; if (platformId !== undefined && platformId !== UNKNOWN_PLATFORM_KEY) { const platform = this.platforms.get(platformId); - const platformApiKey = nonEmpty(platform?.auth?.apiKey); + const authType = provider?.type ?? model.protocol; + const platformApiKey = + nonEmpty(platform?.auth?.apiKey) ?? + providerApiKeyEnvFallback(authType, platform?.auth?.env); + if (platformApiKey !== undefined && platform?.auth?.oauth !== undefined) { + throw authConflictError('Platform', platformId); + } if (platformApiKey !== undefined) return { apiKey: platformApiKey }; if (platform?.auth?.oauth !== undefined) { return { @@ -278,7 +311,12 @@ export class ModelResolverService extends Disposable implements IModelResolver { } // Legacy: provider carried auth directly (pre-Phase 4 migration). - const providerApiKey = nonEmpty(provider?.apiKey); + const providerApiKey = + nonEmpty(provider?.apiKey) ?? + providerApiKeyEnvFallback(provider?.type ?? model.protocol, provider?.env); + if (providerApiKey !== undefined && provider?.oauth !== undefined) { + throw authConflictError('Provider', providerName); + } if (providerApiKey !== undefined) return { apiKey: providerApiKey }; if (provider?.oauth !== undefined) { return { oauth: provider.oauth, oauthProviderKey: model.providerId ?? model.provider }; @@ -294,12 +332,19 @@ export class ModelResolverService extends Disposable implements IModelResolver { const oauthRef = auth.oauth; const providerKey = auth.oauthProviderKey ?? providerName; const oauthService = this.oauth; + const loginRequired = (cause?: unknown): KimiError => + new KimiError( + ErrorCodes.AUTH_LOGIN_REQUIRED, + `OAuth provider "${providerKey}" requires login before it can be used.`, + cause === undefined ? undefined : { cause }, + ); return { + canRefresh: true, async getAuth(options): Promise { const tokenProvider = oauthService.resolveTokenProvider(providerKey, oauthRef); - if (tokenProvider === undefined) return undefined; + if (tokenProvider === undefined) throw loginRequired(); const apiKey = await tokenProvider.getAccessToken({ force: options?.force ?? false }); - if (apiKey.trim().length === 0) return undefined; + if (apiKey.trim().length === 0) throw loginRequired(); return { apiKey }; }, }; @@ -308,13 +353,6 @@ 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; -} - function resolveModelCapabilities( declaredCapabilities: readonly string[] | undefined, protocol: Protocol, @@ -347,20 +385,145 @@ 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; +function effectiveModelConfig(model: ModelConfig): ModelConfig { + const { overrides, ...base } = model; + if (overrides === undefined) return model; + const effective: ModelConfig = { ...base, ...overrides }; + if ( + overrides.supportEfforts !== undefined && + overrides.defaultEffort === undefined && + effective.defaultEffort !== undefined && + !overrides.supportEfforts.includes(effective.defaultEffort) + ) { + delete effective.defaultEffort; } - const betaApi = (model as Record)['betaApi']; - if (betaApi !== undefined) { - extras['betaApi'] = betaApi; + return effective; +} + +function authConflictError(kind: string, name: string): KimiError { + return new KimiError( + ErrorCodes.CONFIG_INVALID, + `${kind} "${name}" has both apiKey and oauth set in config.toml - they are mutually exclusive. Remove one.`, + ); +} + +function buildProtocolProviderOptions( + model: ModelConfig, + protocol: Protocol, + provider: ProviderConfig | undefined, + baseUrl: string, +): ProtocolProviderOptions | undefined { + const options: MutableProtocolProviderOptions = {}; + + switch (protocol) { + case 'anthropic': + if (model.maxOutputSize !== undefined) options.defaultMaxTokens = model.maxOutputSize; + if (model.adaptiveThinking !== undefined) options.adaptiveThinking = model.adaptiveThinking; + if (model.betaApi !== undefined) options.betaApi = model.betaApi; + break; + case 'openai': { + const reasoningKey = nonEmpty(model.reasoningKey); + if (reasoningKey !== undefined) options.reasoningKey = reasoningKey; + break; + } + case 'kimi': + if (model.supportEfforts !== undefined) options.supportEfforts = model.supportEfforts; + break; + case 'vertexai': { + const project = vertexAIProject(provider); + const location = vertexAILocation(provider, baseUrl); + options.vertexai = project !== undefined && location !== undefined; + if (project !== undefined) options.project = project; + if (location !== undefined) options.location = location; + break; + } + case 'google-genai': + case 'openai_responses': + break; + default: { + const exhaustive: never = protocol; + void exhaustive; + } + } + + return Object.values(options).some((value) => value !== undefined) + ? options + : undefined; +} + +function providerBaseUrlEnvFallback( + protocol: Protocol | undefined, + env: Record | undefined, +): string | undefined { + if (protocol === undefined) return undefined; + switch (protocol) { + case 'anthropic': + return envValue(env, 'ANTHROPIC_BASE_URL'); + case 'openai': + case 'openai_responses': + return envValue(env, 'OPENAI_BASE_URL'); + case 'kimi': + return envValue(env, 'KIMI_BASE_URL'); + case 'google-genai': + return envValue(env, 'GOOGLE_GEMINI_BASE_URL'); + case 'vertexai': + return envValue(env, 'GOOGLE_VERTEX_BASE_URL'); + default: { + const exhaustive: never = protocol; + return exhaustive; + } + } +} + +function providerApiKeyEnvFallback( + protocol: Protocol | undefined, + env: Record | undefined, +): string | undefined { + if (protocol === undefined) return undefined; + switch (protocol) { + case 'anthropic': + return envValue(env, 'ANTHROPIC_API_KEY'); + case 'openai': + case 'openai_responses': + return envValue(env, 'OPENAI_API_KEY'); + case 'kimi': + return envValue(env, 'KIMI_API_KEY'); + case 'google-genai': + return envValue(env, 'GOOGLE_API_KEY'); + case 'vertexai': + return envValue(env, 'VERTEXAI_API_KEY') ?? envValue(env, 'GOOGLE_API_KEY'); + default: { + const exhaustive: never = protocol; + return exhaustive; + } + } +} + +function vertexAIProject(provider: ProviderConfig | undefined): string | undefined { + return envValue(provider?.env, 'GOOGLE_CLOUD_PROJECT'); +} + +function vertexAILocation( + provider: ProviderConfig | undefined, + baseUrl: string | undefined, +): string | undefined { + return envValue(provider?.env, 'GOOGLE_CLOUD_LOCATION') ?? locationFromVertexAIBaseUrl(baseUrl); +} + +function envValue(env: Record | undefined, key: string): string | undefined { + return nonEmpty(env?.[key]); +} + +function locationFromVertexAIBaseUrl(baseUrl: string | undefined): string | undefined { + const url = nonEmpty(baseUrl); + if (url === undefined) return undefined; + try { + const host = new URL(url).hostname; + const suffix = '-aiplatform.googleapis.com'; + return host.endsWith(suffix) ? nonEmpty(host.slice(0, -suffix.length)) : undefined; + } catch { + return undefined; } - return Object.keys(extras).length > 0 ? extras : undefined; } /** diff --git a/packages/agent-core-v2/src/app/model/thinking.ts b/packages/agent-core-v2/src/app/model/thinking.ts new file mode 100644 index 000000000..e7bd86b47 --- /dev/null +++ b/packages/agent-core-v2/src/app/model/thinking.ts @@ -0,0 +1,111 @@ +/** + * `model` domain (L2) — model-aware thinking effort resolution. + * + * Resolves the effective thinking effort from request/config defaults plus the + * model's declared thinking metadata. Shared by `modelResolver` and the + * Agent-scope `profile` domain so both paths keep v1-compatible defaults. + */ + +import type { ModelCapability, ThinkingEffort } from '#/app/llmProtocol'; + +export interface ThinkingDefaults { + readonly defaultThinking?: boolean; + readonly mode?: string; + readonly effort?: string; +} + +export interface ModelThinkingMetadata { + readonly capabilities?: ModelCapability | readonly string[]; + readonly adaptiveThinking?: boolean; + readonly alwaysThinking?: boolean; + readonly supportEfforts?: readonly string[]; + readonly defaultEffort?: string; +} + +function nonEmpty(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; +} + +function hasCapability( + capabilities: ModelThinkingMetadata['capabilities'], + capability: string, +): boolean { + if (capabilities === undefined) return false; + if (isCapabilityList(capabilities)) { + return capabilities.some((candidate) => candidate.trim().toLowerCase() === capability); + } + switch (capability) { + case 'thinking': + return capabilities.thinking; + case 'always_thinking': + return false; + default: + return false; + } +} + +function isCapabilityList( + capabilities: ModelThinkingMetadata['capabilities'], +): capabilities is readonly string[] { + return Array.isArray(capabilities); +} + +function middleOf(values: readonly string[]): string { + return values[Math.floor(values.length / 2)]!; +} + +export function modelSupportsThinking(model: ModelThinkingMetadata | undefined): boolean { + if (model === undefined) return false; + return ( + model.alwaysThinking === true || + model.adaptiveThinking === true || + hasCapability(model.capabilities, 'thinking') || + hasCapability(model.capabilities, 'always_thinking') + ); +} + +export function defaultThinkingEffortForModel( + model: ModelThinkingMetadata | undefined, +): ThinkingEffort { + if (model === undefined || !modelSupportsThinking(model)) return 'off'; + const efforts = model.supportEfforts?.map(nonEmpty).filter((v): v is string => v !== undefined); + if (efforts !== undefined && efforts.length > 0) { + return (nonEmpty(model.defaultEffort) ?? middleOf(efforts)) as ThinkingEffort; + } + return 'on'; +} + +function enabledThinkingEffortForModel( + model: ModelThinkingMetadata | undefined, +): ThinkingEffort { + const effort = defaultThinkingEffortForModel(model); + return effort === 'off' ? 'on' : effort; +} + +export function resolveThinkingEffortForModel( + requested: string | undefined, + defaults: ThinkingDefaults | undefined, + model: ModelThinkingMetadata | undefined, +): ThinkingEffort { + const configured = nonEmpty(defaults?.effort) as ThinkingEffort | undefined; + const normalized = nonEmpty(requested)?.toLowerCase(); + let effort: ThinkingEffort; + if (normalized !== undefined) { + effort = + normalized === 'on' + ? configured ?? enabledThinkingEffortForModel(model) + : (normalized as ThinkingEffort); + } else if (defaults?.mode === 'on') { + effort = configured ?? enabledThinkingEffortForModel(model); + } else if (defaults?.defaultThinking === false || defaults?.mode === 'off') { + effort = 'off'; + } else { + effort = configured ?? defaultThinkingEffortForModel(model); + } + + if (effort === 'off' && model?.alwaysThinking === true) { + return configured ?? defaultThinkingEffortForModel(model); + } + return effort; +} diff --git a/packages/agent-core-v2/src/app/protocol/protocol.ts b/packages/agent-core-v2/src/app/protocol/protocol.ts index d142d6492..3bfb23438 100644 --- a/packages/agent-core-v2/src/app/protocol/protocol.ts +++ b/packages/agent-core-v2/src/app/protocol/protocol.ts @@ -30,11 +30,24 @@ export const ProtocolSchema = z.enum([ export type Protocol = z.infer; +export interface ProtocolProviderOptions { + readonly reasoningKey?: string; + readonly defaultMaxTokens?: number; + readonly adaptiveThinking?: boolean; + readonly betaApi?: boolean; + readonly metadata?: Readonly>; + readonly supportEfforts?: readonly string[]; + readonly vertexai?: boolean; + readonly project?: string; + readonly location?: string; +} + /** * Configuration passed to the protocol adapter to produce a request handler. * Keep this shape wire-agnostic: identity comes from `protocol` + `baseUrl`, * secrets come from `auth` (resolved by the caller from Platform / Model - * overrides), constructor-level headers come from `defaultHeaders`. + * overrides), constructor-level headers come from `defaultHeaders`, and + * provider-specific knobs are isolated under `providerOptions`. */ export interface ProtocolAdapterConfig { readonly protocol: Protocol; @@ -42,8 +55,7 @@ export interface ProtocolAdapterConfig { readonly modelName: string; readonly apiKey?: string; readonly defaultHeaders?: Readonly>; - /** Escape hatch for per-protocol tuning that doesn't fit the common shape. */ - readonly extras?: Readonly>; + readonly providerOptions?: ProtocolProviderOptions; } export interface IProtocolAdapterRegistry { diff --git a/packages/agent-core-v2/src/app/protocol/protocolAdapterRegistry.ts b/packages/agent-core-v2/src/app/protocol/protocolAdapterRegistry.ts index c28e089bf..96eead21f 100644 --- a/packages/agent-core-v2/src/app/protocol/protocolAdapterRegistry.ts +++ b/packages/agent-core-v2/src/app/protocol/protocolAdapterRegistry.ts @@ -59,11 +59,19 @@ function toKosongProviderConfig(input: ProtocolAdapterConfig): KosongProviderCon baseUrl: input.baseUrl, apiKey: input.apiKey, defaultHeaders: input.defaultHeaders as Record | undefined, - ...(input.extras ?? {}), + ...definedOptions(input.providerOptions ?? {}), }; return base as KosongProviderConfig; } +function definedOptions(options: object): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(options)) { + if (value !== undefined) out[key] = value; + } + return out; +} + registerScopedService( LifecycleScope.App, IProtocolAdapterRegistry, diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts index 3c8d1b07d..6c56632d2 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts @@ -120,6 +120,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec this.sessions.set(opts.sessionId, handle); await handle.accessor.get(ISessionMetadata).ready; void handle.accessor.get(ISessionSkillCatalog).ready; + await handle.accessor.get(IAgentLifecycleService).ensureMcpReady(); this._onDidCreateSession.fire({ sessionId: opts.sessionId, handle }); return handle; } diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts index e7c653b04..9df98e5cf 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts @@ -100,6 +100,12 @@ export interface IAgentLifecycleService { readonly onDidDispose: Event; /** Create an agent from zero (empty context). */ create(opts?: CreateAgentOptions): Promise; + /** + * Resolve the session/plugin MCP config and wait for the initial connection + * attempt to finish. Per-server failures are reflected in MCP status entries + * rather than rejecting this promise. + */ + ensureMcpReady(): Promise; /** * Fire {@link onDidCreateMain} for the given handle. Called exactly once by * the main-agent bootstrapper (`ensureMainAgent`) after main-only wirings diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index cd72d0cae..4336acf15 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -71,6 +71,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle private readonly onDidCreateMainEmitter = this._register(new Emitter()); private readonly onDidDisposeEmitter = this._register(new Emitter()); private mcpManager: McpConnectionManager | undefined; + private mcpInitialLoad: Promise | undefined; get onDidCreate() { return this.onDidCreateEmitter.event; @@ -99,6 +100,8 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle async create(opts: CreateAgentOptions = {}): Promise { const agentId = opts.agentId ?? `agent-${nextAgentId++}`; + const mcpManager = this.getMcpManager(); + const mcpReady = this.ensureMcpReady(); // Per-agent homedir → the wire-record persistence key (`hashKey(homedir)`). // Bootstrap computes it under the session dir, mirroring v1's // `/agents/`; business code never assembles the path itself. @@ -134,7 +137,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle IAgentMcpService, new SyncDescriptor(AgentMcpService, [ { - manager: this.getMcpManager(), + manager: mcpManager, originalsDir: sessionMediaOriginalsDir(this.ctx.sessionDir), }, ]), @@ -174,12 +177,23 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle // first turn — otherwise plugin/session MCP servers would connect but their // tools would never register until something explicitly requests the service. handle.accessor.get(IAgentMcpService); + await mcpReady; if (opts.binding !== undefined) { await handle.accessor.get(IAgentProfileService).bind(opts.binding); } return handle; } + ensureMcpReady(): Promise { + if (this.mcpInitialLoad !== undefined) return this.mcpInitialLoad; + const manager = this.getMcpManager(); + const initialLoad = this.connectMcpServers(manager).catch((error: unknown) => { + this.log.error('mcp initial load failed', { error }); + }); + this.mcpInitialLoad = initialLoad; + return initialLoad; + } + notifyMainCreated(handle: IAgentScopeHandle): void { this.onDidCreateMainEmitter.fire(handle); } @@ -235,9 +249,9 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle /** * One shared `McpConnectionManager` per session (built lazily, cached). All * agents in the session share it, matching v1's session-scoped MCP and - * avoiding a reconnect storm per agent. Connects the session-config - * servers merged with enabled plugin MCP servers (fire-and-forget; the - * manager's `initialLoad` gates tool use via `waitForInitialLoad`). + * avoiding a reconnect storm per agent. The initial connect is driven + * through `ensureMcpReady`, so session creation and first agent creation can + * await config resolution before tool execution starts. */ private getMcpManager(): McpConnectionManager { if (this.mcpManager !== undefined) return this.mcpManager; @@ -251,9 +265,6 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle }); this.mcpManager = manager; this._register({ dispose: () => void manager.shutdown() }); - void this.connectMcpServers(manager).catch((error: unknown) => { - this.log.error('mcp initial load failed', { error }); - }); return manager; } diff --git a/packages/agent-core-v2/test/_base/utils/completion-budget.test.ts b/packages/agent-core-v2/test/_base/utils/completion-budget.test.ts index fb3f0d641..84d771bdd 100644 --- a/packages/agent-core-v2/test/_base/utils/completion-budget.test.ts +++ b/packages/agent-core-v2/test/_base/utils/completion-budget.test.ts @@ -78,6 +78,7 @@ describe('applyCompletionBudget', () => { generate: vi.fn(), withThinking: vi.fn(), withMaxCompletionTokens: withMaxCompletionTokens as unknown as (n: number) => Model, + withProviderOptions: vi.fn(), } as unknown as Model; }); diff --git a/packages/agent-core-v2/test/agentLifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/agentLifecycle/agentLifecycle.test.ts index aa5046041..62abcc329 100644 --- a/packages/agent-core-v2/test/agentLifecycle/agentLifecycle.test.ts +++ b/packages/agent-core-v2/test/agentLifecycle/agentLifecycle.test.ts @@ -4,7 +4,8 @@ import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; import { TestInstantiationService } from '#/_base/di/test'; import { Event } from '#/_base/event'; -import { IAgentMcpService } from '#/agent/mcp'; +import { IAgentMcpService, type McpServerConfig } from '#/agent/mcp'; +import { McpConnectionManager } from '#/agent/mcp/connection-manager'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { AgentLifecycleService } from '#/session/agentLifecycle/agentLifecycleService'; import { IBootstrapService } from '#/app/bootstrap'; @@ -50,6 +51,10 @@ const pluginServiceStub = { enabledHooks: async () => [], } as unknown as IPluginService; +function tick(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + describe('AgentLifecycleService', () => { let disposables: DisposableStore; let ix: TestInstantiationService; @@ -199,6 +204,48 @@ describe('AgentLifecycleService', () => { ]); }); + it('waits for MCP config resolution and initial connect before returning an agent', async () => { + let resolvePluginServers: + | ((servers: Record) => void) + | undefined; + const pluginServers = new Promise>((resolve) => { + resolvePluginServers = resolve; + }); + ix.stub(IPluginService, { + ...pluginServiceStub, + enabledMcpServers: () => pluginServers, + } as unknown as IPluginService); + + let resolveConnect: (() => void) | undefined; + const connected = new Promise((resolve) => { + resolveConnect = resolve; + }); + const connectAll = vi + .spyOn(McpConnectionManager.prototype, 'connectAll') + .mockReturnValue(connected); + + const svc = ix.get(IAgentLifecycleService); + let settled = false; + const create = svc.create({ agentId: 'main' }).then(() => { + settled = true; + }); + + await tick(); + expect(settled).toBe(false); + expect(connectAll).not.toHaveBeenCalled(); + + resolvePluginServers?.({ + delayed: { transport: 'stdio', command: process.execPath }, + }); + await tick(); + expect(connectAll).toHaveBeenCalledTimes(1); + expect(settled).toBe(false); + + resolveConnect?.(); + await create; + expect(settled).toBe(true); + }); + it('fork throws when the source agent does not exist', async () => { const svc = ix.get(IAgentLifecycleService); await expect(svc.fork('missing')).rejects.toThrow('Source agent "missing" does not exist'); diff --git a/packages/agent-core-v2/test/gateway/gateway.test.ts b/packages/agent-core-v2/test/gateway/gateway.test.ts index 516ab917b..97c96c902 100644 --- a/packages/agent-core-v2/test/gateway/gateway.test.ts +++ b/packages/agent-core-v2/test/gateway/gateway.test.ts @@ -80,6 +80,7 @@ describe('RestGateway', () => { onDidCreateMain: () => ({ dispose: () => {} }), notifyMainCreated: () => {}, create: () => Promise.resolve(agentHandle), + ensureMcpReady: () => Promise.resolve(), fork: () => Promise.resolve(agentHandle), run: () => { throw new Error('not implemented in test'); diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 7019ecd96..a4f83f0d1 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -184,6 +184,8 @@ interface ModelConfigForConfig { readonly maxContextSize: number; readonly maxOutputSize?: number; readonly capabilities?: readonly string[]; + readonly supportEfforts?: readonly string[]; + readonly defaultEffort?: string; } interface ProviderConfigForConfig { @@ -2250,7 +2252,7 @@ function createGenerateBackedProtocolRegistry(generate: GenerateFn): IProtocolAd baseUrl: input.baseUrl, apiKey: input.apiKey, defaultHeaders: input.defaultHeaders as Record | undefined, - ...(input.extras ?? {}), + ...(input.providerOptions ?? {}), } as ProviderConfig; return input.protocol === 'kimi' ? new GenerateBackedKimiChatProvider( diff --git a/packages/agent-core-v2/test/model/model.test.ts b/packages/agent-core-v2/test/model/model.test.ts index f8388aaee..183ce7970 100644 --- a/packages/agent-core-v2/test/model/model.test.ts +++ b/packages/agent-core-v2/test/model/model.test.ts @@ -17,8 +17,8 @@ import { MODELS_SECTION, ModelsSectionSchema, } from '#/app/model/model'; +import { modelsFromToml, modelsToToml } from '#/app/model/configSection'; import { ModelService } from '#/app/model/modelService'; -import '#/app/model/configSection'; import { ENV_MODEL_PROVIDER_KEY } from '#/app/provider/provider'; describe('ModelService', () => { @@ -97,6 +97,65 @@ describe('ModelService', () => { }); }); +describe('models TOML transforms', () => { + it('camelCases nested model overrides from TOML', () => { + expect( + modelsFromToml({ + kimi: { + provider: 'p', + model: 'm', + max_context_size: 1000, + support_efforts: ['low', 'high', 'max'], + overrides: { + max_context_size: 500, + support_efforts: ['low', 'high'], + }, + }, + }), + ).toEqual({ + kimi: { + provider: 'p', + model: 'm', + maxContextSize: 1000, + supportEfforts: ['low', 'high', 'max'], + overrides: { + maxContextSize: 500, + supportEfforts: ['low', 'high'], + }, + }, + }); + }); + + it('snakeCases nested model overrides for TOML', () => { + expect( + modelsToToml( + { + kimi: { + provider: 'p', + model: 'm', + maxContextSize: 1000, + overrides: { + maxContextSize: 500, + supportEfforts: ['low', 'high'], + }, + }, + }, + {}, + ), + ).toEqual({ + kimi: { + provider: 'p', + model: 'm', + max_context_size: 1000, + overrides: { + max_context_size: 500, + support_efforts: ['low', 'high'], + }, + }, + }); + }); +}); + type EnvMap = Readonly>; function applyKimiModelEnvOverlay( diff --git a/packages/agent-core-v2/test/model/modelResolver.test.ts b/packages/agent-core-v2/test/model/modelResolver.test.ts index 73e9419cf..b7c4cdb99 100644 --- a/packages/agent-core-v2/test/model/modelResolver.test.ts +++ b/packages/agent-core-v2/test/model/modelResolver.test.ts @@ -19,9 +19,10 @@ 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 { APIStatusError } from '#/app/llmProtocol'; import { type ModelConfig, IModelResolver, IModelService } from '#/app/model'; import { ModelResolverService } from '#/app/model/modelResolverService'; -import { IPlatformService } from '#/app/platform'; +import { type PlatformConfig, IPlatformService } from '#/app/platform'; import { type ProviderConfig, IProviderService } from '#/app/provider'; import { type ChatProvider, @@ -29,10 +30,14 @@ import { type ProtocolAdapterConfig, } from '#/app/protocol'; +let generateImpl: ChatProvider['generate']; +let uploadVideoImpl: NonNullable | undefined; + describe('ModelResolverService', () => { let disposables: DisposableStore; let ix: TestInstantiationService; let providers: Record; + let platforms: Record; let models: Record; let configValues: Record; let resolveTokenProvider: ReturnType; @@ -41,10 +46,21 @@ describe('ModelResolverService', () => { beforeEach(() => { disposables = new DisposableStore(); providers = {}; + platforms = {}; models = {}; configValues = {}; resolveTokenProvider = vi.fn(); createdProtocolConfigs = []; + generateImpl = async () => ({ + id: null, + usage: null, + finishReason: 'completed', + rawFinishReason: null, + async *[Symbol.asyncIterator]() { + yield { type: 'text' as const, text: 'ok' }; + }, + }); + uploadVideoImpl = undefined; ix = createServices(disposables, { additionalServices: (reg) => { reg.definePartialInstance(IConfigService, { @@ -55,8 +71,8 @@ describe('ModelResolverService', () => { list: (() => providers) as IProviderService['list'], }); reg.definePartialInstance(IPlatformService, { - get: (() => undefined) as IPlatformService['get'], - list: (() => ({})) as IPlatformService['list'], + get: ((name: string) => platforms[name]) as IPlatformService['get'], + list: (() => platforms) as IPlatformService['list'], }); reg.definePartialInstance(IModelService, { get: ((id: string) => models[id]) as IModelService['get'], @@ -81,6 +97,15 @@ describe('ModelResolverService', () => { afterEach(() => disposables.dispose()); + async function resolveAndCreateProvider(modelId = 'm'): Promise> { + const model = ix.get(IModelResolver).resolve(modelId); + for await (const _event of model.request({ systemPrompt: '', tools: [], messages: [] })) { + void _event; + } + expect(createdProtocolConfigs).toHaveLength(1); + return createdProtocolConfigs[0]!; + } + 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 }; @@ -119,6 +144,34 @@ describe('ModelResolverService', () => { expect(resolveTokenProvider).toHaveBeenCalledWith('p', { storage: 'file', key: 'oauth/test' }); }); + it('throws login_required when an OAuth provider has no token provider', 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 }; + + await expect(ix.get(IModelResolver).resolve('m').authProvider.getAuth()).rejects.toMatchObject({ + code: 'auth.login_required', + }); + }); + + it('throws login_required when an OAuth token provider returns an empty token', 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 () => ' ' }); + + await expect(ix.get(IModelResolver).resolve('m').authProvider.getAuth()).rejects.toMatchObject({ + code: 'auth.login_required', + }); + }); + + 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 }; @@ -143,6 +196,220 @@ describe('ModelResolverService', () => { expect(auth).toEqual({ apiKey: 'oauth-token' }); }); + it.each([ + ['kimi', 'KIMI_API_KEY'], + ['openai', 'OPENAI_API_KEY'], + ['openai_responses', 'OPENAI_API_KEY'], + ['anthropic', 'ANTHROPIC_API_KEY'], + ['google-genai', 'GOOGLE_API_KEY'], + ['vertexai', 'VERTEXAI_API_KEY'], + ] as const)('uses %s provider env API key fallback', async (type, key) => { + providers['p'] = { + type, + baseUrl: 'https://example.test/v1', + env: { [key]: `${type}-token` }, + }; + models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000 }; + + const auth = await ix.get(IModelResolver).resolve('m').authProvider.getAuth(); + + expect(auth).toEqual({ apiKey: `${type}-token` }); + }); + + it('uses GOOGLE_API_KEY as the Vertex API key fallback when VERTEXAI_API_KEY is absent', async () => { + providers['p'] = { + type: 'vertexai', + baseUrl: 'https://example.test/v1', + env: { GOOGLE_API_KEY: 'google-token' }, + }; + models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000 }; + + const auth = await ix.get(IModelResolver).resolve('m').authProvider.getAuth(); + + expect(auth).toEqual({ apiKey: 'google-token' }); + }); + + it('uses platform auth env API key fallback before legacy provider auth', async () => { + platforms['shared'] = { auth: { env: { OPENAI_API_KEY: 'platform-token' } } }; + providers['p'] = { + type: 'openai', + baseUrl: 'https://example.test/v1', + platformId: 'shared', + env: { OPENAI_API_KEY: 'provider-token' }, + }; + models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000 }; + + const auth = await ix.get(IModelResolver).resolve('m').authProvider.getAuth(); + + expect(auth).toEqual({ apiKey: 'platform-token' }); + }); + + it('rejects provider oauth when an env API key also resolves', () => { + providers['p'] = { + type: 'kimi', + baseUrl: 'https://example.test/v1', + oauth: { storage: 'file', key: 'oauth/test' }, + env: { KIMI_API_KEY: 'env-token' }, + }; + models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000 }; + + expect(() => ix.get(IModelResolver).resolve('m')).toThrow( + 'Provider "p" has both apiKey and oauth set in config.toml', + ); + }); + + it('rejects platform oauth when an env API key also resolves', () => { + platforms['shared'] = { + auth: { + oauth: { storage: 'file', key: 'oauth/platform' }, + env: { OPENAI_API_KEY: 'platform-token' }, + }, + }; + providers['p'] = { + type: 'openai', + baseUrl: 'https://example.test/v1', + platformId: 'shared', + }; + models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000 }; + + expect(() => ix.get(IModelResolver).resolve('m')).toThrow( + 'Platform "shared" has both apiKey and oauth set in config.toml', + ); + }); + + describe('OAuth refresh replay', () => { + function configureOAuthModel(): void { + providers['p'] = { + type: 'kimi', + baseUrl: 'https://example.test/v1', + oauth: { storage: 'file', key: 'oauth/test' }, + }; + models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000 }; + } + + it('force-refreshes OAuth credentials and replays a request after 401', async () => { + configureOAuthModel(); + const tokenCalls: boolean[] = []; + const authKeys: string[] = []; + resolveTokenProvider.mockReturnValue({ + getAccessToken: async (options: { readonly force: boolean }) => { + tokenCalls.push(options.force); + return options.force ? 'forced-refresh-token' : 'fresh-token'; + }, + }); + generateImpl = async (_system, _tools, _history, options) => { + authKeys.push(options?.auth?.apiKey ?? ''); + if (authKeys.length === 1) { + throw new APIStatusError(401, 'Unauthorized', 'req-401'); + } + return { + id: null, + usage: null, + finishReason: 'completed', + rawFinishReason: null, + async *[Symbol.asyncIterator]() { + yield { type: 'text' as const, text: 'recovered' }; + }, + }; + }; + + const events = []; + for await (const event of ix.get(IModelResolver).resolve('m').request({ + systemPrompt: '', + tools: [], + messages: [], + })) { + events.push(event); + } + + expect(authKeys).toEqual(['fresh-token', 'forced-refresh-token']); + expect(tokenCalls).toEqual([false, true]); + expect(events).toContainEqual({ type: 'part', part: { type: 'text', text: 'recovered' } }); + }); + + it('throws login_required when force-refresh and replay both 401', async () => { + configureOAuthModel(); + const authKeys: string[] = []; + resolveTokenProvider.mockReturnValue({ + getAccessToken: async (options: { readonly force: boolean }) => + options.force ? 'forced-refresh-token' : 'fresh-token', + }); + generateImpl = async (_system, _tools, _history, options) => { + authKeys.push(options?.auth?.apiKey ?? ''); + throw new APIStatusError(401, 'Unauthorized', 'req-401'); + }; + + const events = ix.get(IModelResolver).resolve('m').request({ + systemPrompt: '', + tools: [], + messages: [], + }); + await expect(async () => { + for await (const _event of events) { + void _event; + } + }).rejects.toMatchObject({ + code: 'auth.login_required', + details: { + statusCode: 401, + requestId: 'req-401', + }, + }); + expect(authKeys).toEqual(['fresh-token', 'forced-refresh-token']); + }); + + it('keeps non-OAuth 401 as the provider status error', async () => { + providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk-test' }; + models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000 }; + generateImpl = async () => { + throw new APIStatusError(401, 'Unauthorized', 'req-api-key-401'); + }; + + const events = ix.get(IModelResolver).resolve('m').request({ + systemPrompt: '', + tools: [], + messages: [], + }); + await expect(async () => { + for await (const _event of events) { + void _event; + } + }).rejects.toMatchObject({ + name: 'APIStatusError', + statusCode: 401, + requestId: 'req-api-key-401', + }); + }); + + it('force-refreshes OAuth credentials and replays video upload after 401', async () => { + configureOAuthModel(); + const tokenCalls: boolean[] = []; + const authKeys: string[] = []; + resolveTokenProvider.mockReturnValue({ + getAccessToken: async (options: { readonly force: boolean }) => { + tokenCalls.push(options.force); + return options.force ? 'forced-refresh-token' : 'fresh-token'; + }, + }); + uploadVideoImpl = async (_input, options) => { + authKeys.push(options?.auth?.apiKey ?? ''); + if (authKeys.length === 1) { + throw new APIStatusError(401, 'Unauthorized', 'req-upload-401'); + } + return { type: 'video_url', videoUrl: { url: 'https://example.test/video' } }; + }; + + const result = await ix.get(IModelResolver).resolve('m').uploadVideo?.('clip.mp4'); + + expect(result).toEqual({ + type: 'video_url', + videoUrl: { url: 'https://example.test/video' }, + }); + expect(authKeys).toEqual(['fresh-token', 'forced-refresh-token']); + expect(tokenCalls).toEqual([false, true]); + }); + }); + describe('provider headers', () => { it('passes provider customHeaders to protocol adapters as defaultHeaders', async () => { providers['p'] = { @@ -167,6 +434,157 @@ describe('ModelResolverService', () => { }); }); + describe('provider options', () => { + it('passes an OpenAI reasoningKey through to the protocol adapter', async () => { + providers['p'] = { type: 'openai', baseUrl: 'https://example.test/v1', apiKey: 'sk' }; + models['m'] = { + provider: 'p', + model: 'deepseek-v4-flash', + maxContextSize: 1000, + reasoningKey: ' reasoning_content ', + }; + + const config = await resolveAndCreateProvider(); + + expect(config).toMatchObject({ + protocol: 'openai', + providerOptions: { reasoningKey: 'reasoning_content' }, + }); + }); + + it('passes Anthropic max-output and thinking knobs through to the protocol adapter', async () => { + providers['p'] = { type: 'anthropic', baseUrl: 'https://example.test/v1', apiKey: 'sk' }; + models['m'] = { + provider: 'p', + model: 'claude-opus-4-7', + maxContextSize: 200000, + maxOutputSize: 24000, + adaptiveThinking: false, + betaApi: true, + }; + + const config = await resolveAndCreateProvider(); + + expect(config).toMatchObject({ + protocol: 'anthropic', + providerOptions: { + defaultMaxTokens: 24000, + adaptiveThinking: false, + betaApi: true, + }, + }); + }); + + it('passes Anthropic metadata through to the protocol adapter', async () => { + providers['p'] = { type: 'anthropic', baseUrl: 'https://example.test/v1', apiKey: 'sk' }; + models['m'] = { + provider: 'p', + model: 'claude-sonnet-4-5', + maxContextSize: 200000, + }; + + const model = ix.get(IModelResolver).resolve('m').withProviderOptions({ + metadata: { user_id: 'session-test' }, + }); + for await (const _event of model.request({ systemPrompt: '', tools: [], messages: [] })) { + void _event; + } + + expect(createdProtocolConfigs).toHaveLength(1); + expect(createdProtocolConfigs[0]).toMatchObject({ + protocol: 'anthropic', + providerOptions: { metadata: { user_id: 'session-test' } }, + }); + }); + + it('passes Kimi supportEfforts through to the protocol adapter', async () => { + providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk' }; + models['m'] = { + provider: 'p', + model: 'kimi-for-coding', + maxContextSize: 1000, + supportEfforts: ['low', 'high', 'max'], + }; + + const config = await resolveAndCreateProvider(); + + expect(config).toMatchObject({ + protocol: 'kimi', + providerOptions: { supportEfforts: ['low', 'high', 'max'] }, + }); + }); + + it('passes overridden Kimi supportEfforts through to the protocol adapter', async () => { + providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk' }; + models['m'] = { + provider: 'p', + model: 'kimi-for-coding', + maxContextSize: 1000, + supportEfforts: ['low', 'high', 'max'], + overrides: { supportEfforts: ['low', 'high'] }, + }; + + const config = await resolveAndCreateProvider(); + + expect(config).toMatchObject({ + protocol: 'kimi', + providerOptions: { supportEfforts: ['low', 'high'] }, + }); + }); + + it('passes Vertex service-account options and derives location from the baseUrl', async () => { + providers['p'] = { + type: 'vertexai', + baseUrl: 'https://us-central1-aiplatform.googleapis.com', + env: { GOOGLE_CLOUD_PROJECT: 'my-project' }, + }; + models['m'] = { + provider: 'p', + model: 'gemini-1.5-pro', + maxContextSize: 1000000, + }; + + const config = await resolveAndCreateProvider(); + + expect(config).toMatchObject({ + protocol: 'vertexai', + baseUrl: 'https://us-central1-aiplatform.googleapis.com', + providerOptions: { + vertexai: true, + project: 'my-project', + location: 'us-central1', + }, + }); + }); + + it('uses GOOGLE_VERTEX_BASE_URL as the structured Vertex provider baseUrl fallback', async () => { + providers['p'] = { + type: 'vertexai', + env: { + GOOGLE_CLOUD_PROJECT: 'my-project', + GOOGLE_VERTEX_BASE_URL: 'https://europe-west4-aiplatform.googleapis.com', + }, + }; + models['m'] = { + provider: 'p', + model: 'gemini-1.5-pro', + maxContextSize: 1000000, + }; + + const config = await resolveAndCreateProvider(); + + expect(config).toMatchObject({ + protocol: 'vertexai', + baseUrl: 'https://europe-west4-aiplatform.googleapis.com', + providerOptions: { + vertexai: true, + project: 'my-project', + location: 'europe-west4', + }, + }); + }); + }); + describe('capabilities', () => { it('merges every declared capability with the model context window', () => { providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk' }; @@ -214,8 +632,39 @@ describe('ModelResolverService', () => { return ix.get(IModelResolver).resolve('m').thinkingEffort; } - it('defaults to "high" when thinking is not disabled', () => { - expect(resolveEffort()).toBe('high'); + it('defaults to off when the model does not declare thinking support', () => { + expect(resolveEffort()).toBeNull(); + }); + + it('defaults to boolean on when the model supports thinking without named efforts', () => { + expect(resolveEffort(['thinking'])).toBe('on'); + }); + + it('uses the model default effort when configured', () => { + providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk' }; + models['m'] = { + provider: 'p', + model: 'wire-name', + maxContextSize: 1000, + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'max'], + defaultEffort: 'max', + }; + + expect(ix.get(IModelResolver).resolve('m').thinkingEffort).toBe('max'); + }); + + it('uses the middle supported effort when no model default effort is configured', () => { + providers['p'] = { type: 'kimi', baseUrl: 'https://example.test/v1', apiKey: 'sk' }; + models['m'] = { + provider: 'p', + model: 'wire-name', + maxContextSize: 1000, + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'max'], + }; + + expect(ix.get(IModelResolver).resolve('m').thinkingEffort).toBe('medium'); }); it('is off (null) when defaultThinking is false', () => { @@ -235,11 +684,44 @@ describe('ModelResolverService', () => { it('clamps an explicit off back to on for always_thinking models', () => { configValues['defaultThinking'] = false; - expect(resolveEffort(['always_thinking'])).toBe('high'); + expect(resolveEffort(['always_thinking'])).toBe('on'); }); }); describe('baseUrl normalization', () => { + it.each([ + ['kimi', 'KIMI_BASE_URL'], + ['openai', 'OPENAI_BASE_URL'], + ['openai_responses', 'OPENAI_BASE_URL'], + ['anthropic', 'ANTHROPIC_BASE_URL'], + ['google-genai', 'GOOGLE_GEMINI_BASE_URL'], + ] as const)('uses %s provider env baseUrl fallback', (type, key) => { + providers['p'] = { + type, + apiKey: 'sk', + env: { [key]: `https://${type}.example.test/v1` }, + }; + models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000 }; + + expect(ix.get(IModelResolver).resolve('m').baseUrl).toBe( + `https://${type}.example.test/v1`, + ); + }); + + it('falls through an empty provider baseUrl to the provider env fallback', () => { + providers['p'] = { + type: 'kimi', + baseUrl: ' ', + apiKey: 'sk', + env: { KIMI_BASE_URL: 'https://kimi-env.example.test/v1' }, + }; + models['m'] = { provider: 'p', model: 'wire-name', maxContextSize: 1000 }; + + expect(ix.get(IModelResolver).resolve('m').baseUrl).toBe( + 'https://kimi-env.example.test/v1', + ); + }); + 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; @@ -258,6 +740,19 @@ describe('ModelResolverService', () => { ); }); + it('does not strip /v1 from a native Anthropic provider when model.protocol is unset', () => { + providers['p'] = { + type: 'anthropic', + baseUrl: 'https://api.anthropic.example/v1', + apiKey: 'sk', + }; + models['m'] = { provider: 'p', model: 'claude-sonnet', maxContextSize: 200000 }; + + expect(ix.get(IModelResolver).resolve('m').baseUrl).toBe( + 'https://api.anthropic.example/v1', + ); + }); + it('does not strip /v1 for non-anthropic protocols', () => { expect(resolveBaseUrl('kimi', 'kimi', 'https://example.test/coding/v1')).toBe( 'https://example.test/coding/v1', @@ -270,16 +765,12 @@ const fakeChatProvider: ChatProvider = { name: 'fake', modelName: 'wire-name', thinkingEffort: null, - async generate() { - return { - id: null, - usage: null, - finishReason: 'completed', - rawFinishReason: null, - async *[Symbol.asyncIterator]() { - yield { type: 'text' as const, text: 'ok' }; - }, - }; + generate(systemPrompt, tools, history, options) { + return generateImpl(systemPrompt, tools, history, options); + }, + uploadVideo(input, options) { + if (uploadVideoImpl === undefined) throw new Error('uploadVideo not configured'); + return uploadVideoImpl(input, options); }, withThinking() { return this; diff --git a/packages/agent-core-v2/test/profile/config-state.test.ts b/packages/agent-core-v2/test/profile/config-state.test.ts index 94107d3a2..83bc20514 100644 --- a/packages/agent-core-v2/test/profile/config-state.test.ts +++ b/packages/agent-core-v2/test/profile/config-state.test.ts @@ -211,6 +211,7 @@ describe('ConfigState thinking clamp for always-thinking models', () => { model: 'kimi-deep-coder', maxContextSize: 128_000, capabilities: ['thinking', 'always_thinking', 'tool_use'], + supportEfforts: ['low', 'high', 'max'], }, 'kimi-code/toggle': { provider: 'kimi', @@ -218,6 +219,14 @@ describe('ConfigState thinking clamp for always-thinking models', () => { maxContextSize: 128_000, capabilities: ['thinking'], }, + 'kimi-code/custom': { + provider: 'kimi', + model: 'kimi-custom-coder', + maxContextSize: 128_000, + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'max'], + defaultEffort: 'max', + }, }, }; ctx = createTestAgent(configServices(() => kimiConfig)); @@ -254,6 +263,12 @@ describe('ConfigState thinking clamp for always-thinking models', () => { expect(profile.data().thinkingLevel).toBe('off'); }); + it('maps thinking on to the model default effort', () => { + profile.update({ modelAlias: 'kimi-code/custom', thinkingLevel: 'on' }); + + expect(profile.data().thinkingLevel).toBe('max'); + }); + it('re-clamps when switching to an always-on model after thinking was off', () => { profile.update({ modelAlias: 'kimi-code/toggle', thinkingLevel: 'off' }); expect(profile.data().thinkingLevel).toBe('off'); diff --git a/packages/agent-core-v2/test/profile/profile-wire.test.ts b/packages/agent-core-v2/test/profile/profile-wire.test.ts index 18edae46a..3783f61f1 100644 --- a/packages/agent-core-v2/test/profile/profile-wire.test.ts +++ b/packages/agent-core-v2/test/profile/profile-wire.test.ts @@ -8,7 +8,8 @@ import { ProfileModel } from '#/agent/profile/profileOps'; import { DEFAULT_AGENT_PROFILE_NAME, IAgentProfileCatalogService } from '#/app/agentProfileCatalog'; import { IBootstrapService } from '#/app/bootstrap'; import { IConfigService } from '#/app/config'; -import { IModelResolver } from '#/app/model'; +import type { GenerationKwargs, ThinkingEffort } from '#/app/llmProtocol'; +import { IModelResolver, type Model } from '#/app/model'; import { ITelemetryService } from '#/app/telemetry'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; @@ -16,6 +17,7 @@ import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; +import { ISessionContext } from '#/session/sessionContext'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog'; import { ISessionWorkspaceContext } from '#/session/workspaceContext'; import { IAgentWireService, WireService, type IWireService, type PersistedRecord } from '#/wire'; @@ -33,7 +35,7 @@ function createTelemetryStub(): ITelemetryService { function createConfigStub(): IConfigService { return { _serviceBrand: undefined, - get: () => undefined, + get: ((key: string) => configValues[key]) as unknown as IConfigService['get'], } as unknown as IConfigService; } @@ -50,11 +52,28 @@ function stubUnused(): T { return { _serviceBrand: undefined } as unknown as T; } +function createSessionContextStub(): ISessionContext { + return { + _serviceBrand: undefined, + sessionId: 'session-test', + workspaceId: 'workspace-test', + sessionDir: '/tmp/session-test', + metaScope: 'sessions/workspace-test/session-test', + cwd: '/tmp', + scope: (subKey?: string) => + subKey === undefined || subKey.length === 0 + ? 'sessions/workspace-test/session-test' + : `sessions/workspace-test/session-test/${subKey}`, + }; +} + let disposables: DisposableStore; let ix: TestInstantiationService; let log: IAppendLogStore; let wire: IWireService; let svc: IAgentProfileService; +let configValues: Record; +let modelResolver: IModelResolver; function buildHost(key: string): { ix: TestInstantiationService; @@ -68,10 +87,11 @@ function buildHost(key: string): { host.set(IAgentWireService, new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey: key }])); host.stub(ITelemetryService, createTelemetryStub()); host.stub(IConfigService, createConfigStub()); - host.stub(IModelResolver, createModelResolverStub()); + host.stub(IModelResolver, modelResolver); host.stub(IHostEnvironment, stubUnused()); host.stub(IHostFileSystem, stubUnused()); host.stub(IBootstrapService, stubUnused()); + host.stub(ISessionContext, createSessionContextStub()); host.stub(ISessionWorkspaceContext, stubUnused()); host.stub(IAgentProfileCatalogService, stubUnused()); host.stub(ISessionSkillCatalog, stubUnused()); @@ -86,6 +106,8 @@ function buildHost(key: string): { beforeEach(() => { disposables = new DisposableStore(); + configValues = {}; + modelResolver = createModelResolverStub(); const host = buildHost(KEY); ix = host.ix; wire = host.wire; @@ -107,6 +129,52 @@ function modelOf(target: IWireService) { return target.getModel(ProfileModel); } +function createRecordingModel( + generationKwargs: GenerationKwargs[], + thinkingEfforts: ThinkingEffort[], + providerOptions: unknown[] = [], + protocol: Model['protocol'] = 'kimi', +): Model { + const build = (thinkingEffort: ThinkingEffort | null): Model => ({ + id: 'kimi-code', + name: 'kimi-for-coding', + aliases: [], + protocol, + baseUrl: 'https://example.test/v1', + headers: {}, + capabilities: { + image_in: false, + video_in: false, + audio_in: false, + thinking: true, + tool_use: false, + max_context_tokens: 1000, + }, + maxContextSize: 1000, + thinkingEffort, + alwaysThinking: false, + providerName: 'kimi', + authProvider: { getAuth: async () => undefined }, + withThinking: (effort) => { + thinkingEfforts.push(effort); + return build(effort); + }, + withMaxCompletionTokens: () => build(thinkingEffort), + withGenerationKwargs: (kwargs) => { + generationKwargs.push(kwargs); + return build(thinkingEffort); + }, + withProviderOptions: (options) => { + providerOptions.push(options); + return build(thinkingEffort); + }, + request: async function* () { + return; + }, + }); + return build(null); +} + describe('AgentProfileService (wire-backed config.update)', () => { it('update persists a flat config.update record and resolves thinkingLevel at the call site', async () => { svc.update({ profileName: DEFAULT_AGENT_PROFILE_NAME, systemPrompt: 'You are helpful.' }); @@ -115,8 +183,9 @@ describe('AgentProfileService (wire-backed config.update)', () => { const model = modelOf(wire); expect(model.profileName).toBe(DEFAULT_AGENT_PROFILE_NAME); expect(model.systemPrompt).toBe('You are helpful.'); - // 'on' resolves against the default effort (no thinking config section) → 'high'. - expect(model.thinkingLevel).toBe('high'); + // Explicit 'on' persists as the boolean-thinking signal when no model + // declares named efforts. + expect(model.thinkingLevel).toBe('on'); expect(svc.getSystemPrompt()).toBe('You are helpful.'); const records = await readRecords(); @@ -126,7 +195,7 @@ describe('AgentProfileService (wire-backed config.update)', () => { profileName: DEFAULT_AGENT_PROFILE_NAME, systemPrompt: 'You are helpful.', }, - { type: 'config.update', thinkingLevel: 'high' }, + { type: 'config.update', thinkingLevel: 'on' }, ]); expect(records.every((record) => 'payload' in record === false)).toBe(true); }); @@ -188,9 +257,93 @@ describe('AgentProfileService (wire-backed config.update)', () => { const records = await readRecords(); // Fresh host whose config section would resolve differently is irrelevant: - // the persisted resolved value ('high') is restored verbatim. + // the persisted resolved value ('on') is restored verbatim. const host = buildHost('profile-replay-thinking'); host.wire.replay(...records); - expect(modelOf(host.wire).thinkingLevel).toBe('high'); + expect(modelOf(host.wire).thinkingLevel).toBe('on'); + }); + + it('applies thinking.keep model override when thinking is enabled', () => { + const generationKwargs: GenerationKwargs[] = []; + const thinkingEfforts: ThinkingEffort[] = []; + modelResolver = { + _serviceBrand: undefined, + resolve: () => createRecordingModel(generationKwargs, thinkingEfforts), + findByName: () => [], + }; + const host = buildHost('profile-thinking-keep'); + host.svc.configure({ emitStatusUpdated: () => undefined }); + configValues['modelOverrides'] = { temperature: 0.3, thinkingKeep: 'all' }; + + host.svc.update({ modelAlias: 'kimi-code', thinkingLevel: 'high' }); + const model = host.svc.resolveModel(); + + expect(model?.thinkingEffort).toBe('high'); + expect(thinkingEfforts).toEqual(['high']); + expect(generationKwargs).toEqual([ + { + prompt_cache_key: 'session-test', + temperature: 0.3, + extra_body: { thinking: { keep: 'all' } }, + }, + ]); + }); + + it('does not apply thinking.keep model override when thinking is off', () => { + const generationKwargs: GenerationKwargs[] = []; + const thinkingEfforts: ThinkingEffort[] = []; + modelResolver = { + _serviceBrand: undefined, + resolve: () => createRecordingModel(generationKwargs, thinkingEfforts), + findByName: () => [], + }; + const host = buildHost('profile-thinking-keep-off'); + host.svc.configure({ emitStatusUpdated: () => undefined }); + configValues['modelOverrides'] = { temperature: 0.3, thinkingKeep: 'all' }; + + host.svc.update({ modelAlias: 'kimi-code', thinkingLevel: 'off' }); + host.svc.resolveModel(); + + expect(thinkingEfforts).toEqual(['off']); + expect(generationKwargs).toEqual([{ prompt_cache_key: 'session-test', temperature: 0.3 }]); + }); + + it('uses the session id as a Kimi prompt cache hint', () => { + const generationKwargs: GenerationKwargs[] = []; + const thinkingEfforts: ThinkingEffort[] = []; + modelResolver = { + _serviceBrand: undefined, + resolve: () => createRecordingModel(generationKwargs, thinkingEfforts), + findByName: () => [], + }; + const host = buildHost('profile-prompt-cache-key'); + host.svc.configure({ emitStatusUpdated: () => undefined }); + + host.svc.update({ modelAlias: 'kimi-code', thinkingLevel: 'high' }); + host.svc.resolveModel(); + + expect(thinkingEfforts).toEqual(['high']); + expect(generationKwargs).toEqual([{ prompt_cache_key: 'session-test' }]); + }); + + it('does not apply the Kimi prompt cache hint to other protocols', () => { + const generationKwargs: GenerationKwargs[] = []; + const thinkingEfforts: ThinkingEffort[] = []; + const providerOptions: unknown[] = []; + modelResolver = { + _serviceBrand: undefined, + resolve: () => + createRecordingModel(generationKwargs, thinkingEfforts, providerOptions, 'anthropic'), + findByName: () => [], + }; + const host = buildHost('profile-prompt-cache-key-anthropic'); + host.svc.configure({ emitStatusUpdated: () => undefined }); + + host.svc.update({ modelAlias: 'claude-sonnet', thinkingLevel: 'high' }); + host.svc.resolveModel(); + + expect(thinkingEfforts).toEqual(['high']); + expect(generationKwargs).toEqual([]); + expect(providerOptions).toEqual([{ metadata: { user_id: 'session-test' } }]); }); }); diff --git a/packages/agent-core-v2/test/profile/thinking.test.ts b/packages/agent-core-v2/test/profile/thinking.test.ts index c8ae40261..155b2d823 100644 --- a/packages/agent-core-v2/test/profile/thinking.test.ts +++ b/packages/agent-core-v2/test/profile/thinking.test.ts @@ -11,16 +11,48 @@ describe('profile/thinking', () => { expect(resolveThinkingEffort(undefined, { effort: 'low' })).toBe('low'); }); - it('defaults to high when nothing configured', () => { - expect(resolveThinkingEffort(undefined, undefined)).toBe('high'); + it('defaults to off when no model supports thinking', () => { + expect(resolveThinkingEffort(undefined, undefined)).toBe('off'); + }); + + it('uses the model default effort when configured', () => { + expect( + resolveThinkingEffort(undefined, undefined, { + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'max'], + defaultEffort: 'max', + }), + ).toBe('max'); + }); + + it('uses the middle supported effort when no default effort is configured', () => { + expect( + resolveThinkingEffort(undefined, undefined, { + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'max'], + }), + ).toBe('medium'); + }); + + it('uses boolean on for thinking models without named efforts', () => { + expect( + resolveThinkingEffort(undefined, undefined, { + capabilities: ['thinking'], + }), + ).toBe('on'); }); it('returns off when config mode is off and no request is provided', () => { expect(resolveThinkingEffort(undefined, { mode: 'off' })).toBe('off'); }); - it('returns high when config mode is on without explicit effort', () => { - expect(resolveThinkingEffort(undefined, { mode: 'on' })).toBe('high'); + it('returns model default when config mode is on without explicit effort', () => { + expect( + resolveThinkingEffort(undefined, { mode: 'on' }, { + capabilities: ['thinking'], + supportEfforts: ['low', 'high'], + }), + ).toBe('high'); }); it('returns explicit effort when both mode=on and effort are set', () => { @@ -39,30 +71,36 @@ describe('profile/thinking', () => { expect(resolveThinkingEffort('on', { effort: 'medium' })).toBe('medium'); }); - it('maps "on" to high when config has no effort', () => { - expect(resolveThinkingEffort('on', undefined)).toBe('high'); + it('maps "on" to the model default when config has no effort', () => { + expect( + resolveThinkingEffort('on', undefined, { + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'max'], + }), + ).toBe('medium'); }); it('parses a named effort', () => { expect(resolveThinkingEffort('xhigh', undefined)).toBe('xhigh'); }); - it('falls back to config effort for unknown value', () => { - expect(resolveThinkingEffort('bogus', { effort: 'low' })).toBe('low'); + it('carries custom requested efforts through', () => { + expect(resolveThinkingEffort('bogus', { effort: 'low' })).toBe('bogus'); }); - it('falls back to default high for unknown value with no config', () => { - expect(resolveThinkingEffort('bogus', undefined)).toBe('high'); - }); - - it('normalizes case and whitespace', () => { + it('normalizes requested effort case and whitespace', () => { expect(resolveThinkingEffort(' Medium ', undefined)).toBe('medium'); expect(resolveThinkingEffort('OFF', { mode: 'on' })).toBe('off'); }); - it('uses high as the concrete effort for the default-on state', () => { - expect(resolveThinkingEffort(undefined, undefined)).toBe('high'); - expect(resolveThinkingEffort('on', undefined)).toBe('high'); + it('clamps off to model default for always-thinking models', () => { + expect( + resolveThinkingEffort('off', undefined, { + capabilities: ['always_thinking'], + alwaysThinking: true, + supportEfforts: ['low', 'medium', 'max'], + }), + ).toBe('medium'); }); }); diff --git a/packages/agent-core-v2/test/protocol/protocolAdapterRegistry.test.ts b/packages/agent-core-v2/test/protocol/protocolAdapterRegistry.test.ts index 10ce52859..fd8715a26 100644 --- a/packages/agent-core-v2/test/protocol/protocolAdapterRegistry.test.ts +++ b/packages/agent-core-v2/test/protocol/protocolAdapterRegistry.test.ts @@ -19,4 +19,79 @@ describe('ProtocolAdapterRegistry', () => { expect(Reflect.get(provider, '_defaultHeaders')).toEqual({ 'X-Test': '1' }); }); + + it('maps providerOptions into OpenAI provider config', () => { + const provider = new ProtocolAdapterRegistry().createChatProvider({ + protocol: 'openai', + baseUrl: 'https://example.test/v1', + modelName: 'deepseek-v4-flash', + apiKey: 'sk', + providerOptions: { reasoningKey: 'reasoning_content' }, + }); + + expect(Reflect.get(provider, '_reasoningKey')).toBe('reasoning_content'); + }); + + it('maps providerOptions into Anthropic provider config', () => { + const provider = new ProtocolAdapterRegistry().createChatProvider({ + protocol: 'anthropic', + baseUrl: 'https://example.test/v1', + modelName: 'unknown-model', + apiKey: 'sk', + providerOptions: { + defaultMaxTokens: 12345, + adaptiveThinking: false, + betaApi: true, + metadata: { user_id: 'session-test' }, + }, + }); + + expect(Reflect.get(provider, '_generationKwargs')).toMatchObject({ max_tokens: 12345 }); + expect(Reflect.get(provider, '_adaptiveThinking')).toBe(false); + expect(Reflect.get(provider, '_betaApi')).toBe(true); + expect(Reflect.get(provider, '_metadata')).toEqual({ user_id: 'session-test' }); + }); + + it('maps providerOptions into Kimi provider config', () => { + const provider = new ProtocolAdapterRegistry().createChatProvider({ + protocol: 'kimi', + baseUrl: 'https://example.test/v1', + modelName: 'kimi-for-coding', + apiKey: 'sk', + providerOptions: { supportEfforts: ['low', 'high', 'max'] }, + }); + + expect(Reflect.get(provider, '_supportEfforts')).toEqual(['low', 'high', 'max']); + expect(Reflect.get(provider.withThinking('high'), '_generationKwargs')).toEqual({ + extra_body: { thinking: { type: 'enabled', effort: 'high' } }, + }); + expect(provider.withThinking('high').thinkingEffort).toBe('high'); + expect(Reflect.get(provider.withThinking('medium'), '_generationKwargs')).toEqual({ + extra_body: { thinking: { type: 'enabled' } }, + }); + expect(provider.withThinking('medium').thinkingEffort).toBe('on'); + expect( + Reflect.get(provider.withThinking('high').withThinking('off'), '_generationKwargs'), + ).toEqual({ + extra_body: { thinking: { type: 'disabled' } }, + }); + expect(provider.withThinking('high').withThinking('off').thinkingEffort).toBe('off'); + }); + + it('maps providerOptions into Vertex provider config', () => { + const provider = new ProtocolAdapterRegistry().createChatProvider({ + protocol: 'vertexai', + baseUrl: 'https://us-central1-aiplatform.googleapis.com', + modelName: 'gemini-1.5-pro', + providerOptions: { + vertexai: true, + project: 'my-project', + location: 'us-central1', + }, + }); + + expect(Reflect.get(provider, '_vertexai')).toBe(true); + expect(Reflect.get(provider, '_project')).toBe('my-project'); + expect(Reflect.get(provider, '_location')).toBe('us-central1'); + }); }); diff --git a/packages/agent-core-v2/test/sessionActivity/sessionActivity.test.ts b/packages/agent-core-v2/test/sessionActivity/sessionActivity.test.ts index fb633b48d..0d34fe32f 100644 --- a/packages/agent-core-v2/test/sessionActivity/sessionActivity.test.ts +++ b/packages/agent-core-v2/test/sessionActivity/sessionActivity.test.ts @@ -59,6 +59,7 @@ function lifecycle(handles: readonly IAgentScopeHandle[]): IAgentLifecycleServic onDidCreateMain: () => ({ dispose: () => {} }), notifyMainCreated: () => {}, create: () => Promise.resolve(handles[0]!), + ensureMcpReady: () => Promise.resolve(), fork: () => Promise.resolve(handles[0]!), run: () => { throw new Error('not implemented in test'); diff --git a/packages/agent-core-v2/test/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/sessionLifecycle/sessionLifecycle.test.ts index 14f415047..481e4e0b7 100644 --- a/packages/agent-core-v2/test/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/sessionLifecycle/sessionLifecycle.test.ts @@ -138,6 +138,29 @@ function atomicDocumentStoreStub(): IAtomicDocumentStore { }; } +function agentLifecycleStub(): IAgentLifecycleService { + return { + _serviceBrand: undefined, + onDidCreate: () => ({ dispose: () => {} }), + onDidCreateMain: () => ({ dispose: () => {} }), + onDidDispose: () => ({ dispose: () => {} }), + create: () => Promise.reject(new Error('not implemented')), + notifyMainCreated: () => {}, + ensureMcpReady: () => Promise.resolve(), + fork: () => Promise.reject(new Error('not implemented')), + run: () => { + throw new Error('not implemented'); + }, + getHandle: () => undefined, + list: () => [], + remove: () => Promise.resolve(), + }; +} + +function tick(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + describe('SessionLifecycleService', () => { let host: ScopedTestHost | undefined; @@ -168,6 +191,7 @@ describe('SessionLifecycleService', () => { stubPair(IAppendLogStore, appendLogStoreStub()), stubPair(IAtomicDocumentStore, atomicDocumentStoreStub()), stubPair(IEventService, eventStub()), + stubPair(IAgentLifecycleService, agentLifecycleStub()), ...extra, ]); return host.app.accessor.get(ISessionLifecycleService); @@ -211,6 +235,7 @@ describe('SessionLifecycleService', () => { }, }), stubPair(IAgentLifecycleService, { + ...agentLifecycleStub(), _serviceBrand: undefined, list: () => [agentHandle], remove: (id: string) => { @@ -245,6 +270,31 @@ describe('SessionLifecycleService', () => { expect(captured).toMatchObject({ sessionId: 's1', handle: h }); }); + it('waits for MCP initialization before create returns', async () => { + let resolveMcpReady: (() => void) | undefined; + const mcpReady = new Promise((resolve) => { + resolveMcpReady = resolve; + }); + const svc = build([ + stubPair(IAgentLifecycleService, { + ...agentLifecycleStub(), + ensureMcpReady: () => mcpReady, + }), + ]); + + let settled = false; + const create = svc.create({ sessionId: 's1', workDir: '/tmp/proj' }).then(() => { + settled = true; + }); + + await tick(); + expect(settled).toBe(false); + + resolveMcpReady?.(); + await create; + expect(settled).toBe(true); + }); + it('fires onDidCloseSession when a session is closed', async () => { const svc = build(); const closed: string[] = []; @@ -257,6 +307,7 @@ describe('SessionLifecycleService', () => { it('fires onDidArchiveSession when a session is archived', async () => { const svc = build([ stubPair(IAgentLifecycleService, { + ...agentLifecycleStub(), _serviceBrand: undefined, list: () => [], remove: () => Promise.resolve(), diff --git a/packages/agent-core-v2/test/todo/session-todo.test.ts b/packages/agent-core-v2/test/todo/session-todo.test.ts index 1342fe50b..a680f2d6d 100644 --- a/packages/agent-core-v2/test/todo/session-todo.test.ts +++ b/packages/agent-core-v2/test/todo/session-todo.test.ts @@ -148,6 +148,7 @@ function makeLifecycleStub(handles: readonly IAgentScopeHandle[] = []): Lifecycl create: async () => { throw new Error('not implemented'); }, + ensureMcpReady: () => Promise.resolve(), notifyMainCreated: () => {}, fork: async () => { throw new Error('not implemented');