mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
fix: align v2 model thinking and mcp startup
This commit is contained in:
parent
d9d4a413f7
commit
669bb8e04d
31 changed files with 1648 additions and 244 deletions
5
.changeset/mcp-initial-load-race.md
Normal file
5
.changeset/mcp-initial-load-race.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Fix MCP tools being unavailable on the first turn after session startup.
|
||||
|
|
@ -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<KimiModelOverrides>('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<ThinkingConfig>(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<ThinkingConfig>(THINKING_SECTION));
|
||||
return resolveThinkingEffort(
|
||||
'on',
|
||||
this.config.get<ThinkingConfig>(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);
|
||||
|
|
|
|||
|
|
@ -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<ThinkingEffort>(['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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<ThinkingEffort, 'on'>;
|
||||
|
||||
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.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string> | 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<string, string> | 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<string, unknown> {
|
||||
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,15 @@ export const modelsFromToml = (rawSnake: unknown): unknown => {
|
|||
if (!isPlainObject(rawSnake)) return rawSnake;
|
||||
const out: Record<string, unknown> = {};
|
||||
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<string, unknown>,
|
||||
rawSnake: unknown,
|
||||
): Record<string, unknown> {
|
||||
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,
|
||||
});;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,3 +20,4 @@ export * from './modelOverrides';
|
|||
export * from './modelResolver';
|
||||
export * from './modelResolverService';
|
||||
export * from './modelService';
|
||||
export * from './thinking';
|
||||
|
|
|
|||
|
|
@ -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<typeof ModelSchema>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Record<string, unknown>>;
|
||||
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<Record<string, unknown>>;
|
||||
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<ModelImpl>,
|
||||
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<T>(
|
||||
run: (auth: ProviderRequestAuth | undefined) => Promise<T>,
|
||||
): Promise<T> {
|
||||
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<ProviderRequestAuth | undefined> {
|
||||
if (this.apiKey === undefined || this.apiKey.trim().length === 0) return undefined;
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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<boolean | undefined>('defaultThinking');
|
||||
const thinking = this.config.get<ThinkingSection | undefined>('thinking');
|
||||
const turnedOff = defaultThinking === false || thinking?.mode === 'off';
|
||||
const configured = parseThinkingEffort(thinking?.effort) ?? DEFAULT_THINKING_EFFORT;
|
||||
if (turnedOff && !alwaysThinking) {
|
||||
return 'off';
|
||||
}
|
||||
return configured;
|
||||
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<ProviderRequestAuth | undefined> {
|
||||
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<Record<string, unknown>> | undefined {
|
||||
const extras: Record<string, unknown> = {};
|
||||
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<string, unknown>)['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<string, string> | 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<string, string> | 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<string, string> | 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
111
packages/agent-core-v2/src/app/model/thinking.ts
Normal file
111
packages/agent-core-v2/src/app/model/thinking.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -30,11 +30,24 @@ export const ProtocolSchema = z.enum([
|
|||
|
||||
export type Protocol = z.infer<typeof ProtocolSchema>;
|
||||
|
||||
export interface ProtocolProviderOptions {
|
||||
readonly reasoningKey?: string;
|
||||
readonly defaultMaxTokens?: number;
|
||||
readonly adaptiveThinking?: boolean;
|
||||
readonly betaApi?: boolean;
|
||||
readonly metadata?: Readonly<Record<string, string>>;
|
||||
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<Record<string, string>>;
|
||||
/** Escape hatch for per-protocol tuning that doesn't fit the common shape. */
|
||||
readonly extras?: Readonly<Record<string, unknown>>;
|
||||
readonly providerOptions?: ProtocolProviderOptions;
|
||||
}
|
||||
|
||||
export interface IProtocolAdapterRegistry {
|
||||
|
|
|
|||
|
|
@ -59,11 +59,19 @@ function toKosongProviderConfig(input: ProtocolAdapterConfig): KosongProviderCon
|
|||
baseUrl: input.baseUrl,
|
||||
apiKey: input.apiKey,
|
||||
defaultHeaders: input.defaultHeaders as Record<string, string> | undefined,
|
||||
...(input.extras ?? {}),
|
||||
...definedOptions(input.providerOptions ?? {}),
|
||||
};
|
||||
return base as KosongProviderConfig;
|
||||
}
|
||||
|
||||
function definedOptions(options: object): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(options)) {
|
||||
if (value !== undefined) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.App,
|
||||
IProtocolAdapterRegistry,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,12 @@ export interface IAgentLifecycleService {
|
|||
readonly onDidDispose: Event<string>;
|
||||
/** Create an agent from zero (empty context). */
|
||||
create(opts?: CreateAgentOptions): Promise<IAgentScopeHandle>;
|
||||
/**
|
||||
* 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<void>;
|
||||
/**
|
||||
* Fire {@link onDidCreateMain} for the given handle. Called exactly once by
|
||||
* the main-agent bootstrapper (`ensureMainAgent`) after main-only wirings
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
|
|||
private readonly onDidCreateMainEmitter = this._register(new Emitter<IAgentScopeHandle>());
|
||||
private readonly onDidDisposeEmitter = this._register(new Emitter<string>());
|
||||
private mcpManager: McpConnectionManager | undefined;
|
||||
private mcpInitialLoad: Promise<void> | undefined;
|
||||
|
||||
get onDidCreate() {
|
||||
return this.onDidCreateEmitter.event;
|
||||
|
|
@ -99,6 +100,8 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
|
|||
|
||||
async create(opts: CreateAgentOptions = {}): Promise<IAgentScopeHandle> {
|
||||
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
|
||||
// `<sessionDir>/agents/<id>`; 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<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<string, McpServerConfig>) => void)
|
||||
| undefined;
|
||||
const pluginServers = new Promise<Record<string, McpServerConfig>>((resolve) => {
|
||||
resolvePluginServers = resolve;
|
||||
});
|
||||
ix.stub(IPluginService, {
|
||||
...pluginServiceStub,
|
||||
enabledMcpServers: () => pluginServers,
|
||||
} as unknown as IPluginService);
|
||||
|
||||
let resolveConnect: (() => void) | undefined;
|
||||
const connected = new Promise<void>((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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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<string, string> | undefined,
|
||||
...(input.extras ?? {}),
|
||||
...(input.providerOptions ?? {}),
|
||||
} as ProviderConfig;
|
||||
return input.protocol === 'kimi'
|
||||
? new GenerateBackedKimiChatProvider(
|
||||
|
|
|
|||
|
|
@ -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<Record<string, string | undefined>>;
|
||||
|
||||
function applyKimiModelEnvOverlay(
|
||||
|
|
|
|||
|
|
@ -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<ChatProvider['uploadVideo']> | undefined;
|
||||
|
||||
describe('ModelResolverService', () => {
|
||||
let disposables: DisposableStore;
|
||||
let ix: TestInstantiationService;
|
||||
let providers: Record<string, ProviderConfig>;
|
||||
let platforms: Record<string, PlatformConfig>;
|
||||
let models: Record<string, ModelConfig>;
|
||||
let configValues: Record<string, unknown>;
|
||||
let resolveTokenProvider: ReturnType<typeof vi.fn>;
|
||||
|
|
@ -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<Record<string, unknown>> {
|
||||
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 ?? '<missing>');
|
||||
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 ?? '<missing>');
|
||||
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 ?? '<missing>');
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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>(): 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<string, unknown>;
|
||||
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' } }]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<void>((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(),
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue