mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
refactor(agent-core): make Agent constructable and consolidate provider-manager (#161)
This commit is contained in:
parent
07dd604c3c
commit
28d2b5c018
33 changed files with 889 additions and 1039 deletions
|
|
@ -112,7 +112,11 @@ describe('loadPluginMarketplace', () => {
|
|||
}),
|
||||
})) as unknown as typeof fetch;
|
||||
|
||||
const marketplace = await loadPluginMarketplace({ workDir: '/tmp/work', fetchImpl });
|
||||
const marketplace = await loadPluginMarketplace({
|
||||
workDir: '/tmp/work',
|
||||
source: KIMI_CODE_PLUGIN_MARKETPLACE_URL,
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledWith(KIMI_CODE_PLUGIN_MARKETPLACE_URL);
|
||||
expect(marketplace.plugins[0]).toEqual(
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@ import {
|
|||
} from '@moonshot-ai/kosong';
|
||||
|
||||
import type { Agent } from '..';
|
||||
import type { ResolvedRuntimeProvider } from '../../providers/runtime-provider';
|
||||
import { ErrorCodes, KimiError } from '../../errors';
|
||||
import type { AgentConfigData, AgentConfigUpdateData } from './types';
|
||||
import { resolveThinkingEffort, type ThinkingEffort } from './thinking';
|
||||
import type { ResolvedRuntimeProvider } from '../../session/provider-manager';
|
||||
|
||||
export * from './types';
|
||||
export { resolveThinkingEffort, type ThinkingEffort } from './thinking';
|
||||
|
|
@ -25,16 +26,9 @@ export class ConfigState {
|
|||
this._cwd = agent.runtime.kaos.getcwd();
|
||||
}
|
||||
|
||||
update(input: AgentConfigUpdateData): void {
|
||||
const changed = { ...input };
|
||||
update(changed: AgentConfigUpdateData): void {
|
||||
if (Object.keys(changed).length === 0) return;
|
||||
|
||||
if (changed.thinkingLevel !== undefined) {
|
||||
changed.thinkingLevel = resolveThinkingEffort(
|
||||
changed.thinkingLevel,
|
||||
this.agent.providerManager?.config.thinking,
|
||||
);
|
||||
}
|
||||
this.agent.records.logRecord({
|
||||
type: 'config.update',
|
||||
...changed,
|
||||
|
|
@ -47,14 +41,22 @@ export class ConfigState {
|
|||
this._cwd = changed.cwd;
|
||||
void this.agent.runtime.kaos.chdir(changed.cwd);
|
||||
}
|
||||
if (Object.hasOwn(changed, 'modelAlias')) {
|
||||
this._modelAlias = changed.modelAlias ?? undefined;
|
||||
if (changed.modelAlias) {
|
||||
this._modelAlias = changed.modelAlias;
|
||||
}
|
||||
if (Object.hasOwn(changed, 'profileName')) this._profileName = changed.profileName;
|
||||
if (changed.thinkingLevel !== undefined)
|
||||
this._thinkingLevel = changed.thinkingLevel as ThinkingEffort;
|
||||
if (changed.systemPrompt !== undefined) this._systemPrompt = changed.systemPrompt;
|
||||
if (this.hasProvider && (changed.cwd !== undefined || Object.hasOwn(changed, 'modelAlias'))) {
|
||||
if (changed.profileName) {
|
||||
this._profileName = changed.profileName;
|
||||
}
|
||||
if (changed.thinkingLevel !== undefined) {
|
||||
this._thinkingLevel = resolveThinkingEffort(
|
||||
changed.thinkingLevel,
|
||||
this.agent.providerManager?.config.thinking,
|
||||
);
|
||||
}
|
||||
if (changed.systemPrompt !== undefined) {
|
||||
this._systemPrompt = changed.systemPrompt;
|
||||
}
|
||||
if (this.hasProvider && (changed.cwd !== undefined || changed.modelAlias)) {
|
||||
this.agent.tools.initializeBuiltinTools();
|
||||
}
|
||||
this.agent.emitStatusUpdated();
|
||||
|
|
@ -88,7 +90,7 @@ export class ConfigState {
|
|||
get providerConfig(): ProviderConfig {
|
||||
const provider = this.resolvedProviderConfig?.provider;
|
||||
if (provider === undefined) {
|
||||
throw new Error('Provider not set');
|
||||
throw new KimiError(ErrorCodes.MODEL_NOT_CONFIGURED, 'Provider not set');
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
|
@ -99,7 +101,7 @@ export class ConfigState {
|
|||
|
||||
get model(): string {
|
||||
if (this._modelAlias === undefined) {
|
||||
throw new Error('Model not set');
|
||||
throw new KimiError(ErrorCodes.MODEL_NOT_CONFIGURED, 'Model not set');
|
||||
}
|
||||
return this._modelAlias;
|
||||
}
|
||||
|
|
@ -126,7 +128,7 @@ export class ConfigState {
|
|||
|
||||
private get resolvedProviderConfig(): ResolvedRuntimeProvider | undefined {
|
||||
if (this._modelAlias === undefined) return undefined;
|
||||
return this.agent.providerManager?.resolveProviderConfigForModel(this._modelAlias);
|
||||
return this.agent.providerManager?.resolveProviderConfig(this._modelAlias);
|
||||
}
|
||||
|
||||
private tryResolvedProviderConfig(): ResolvedRuntimeProvider | undefined {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,25 @@ const DEFAULT_THINKING_EFFORT: ThinkingEffort = 'high';
|
|||
|
||||
const THINKING_EFFORTS = new Set<ThinkingEffort>(['low', 'medium', 'high', 'xhigh', 'max']);
|
||||
|
||||
export interface ResolveThinkingLevelOptions {
|
||||
readonly defaultThinking?: boolean | undefined;
|
||||
readonly thinking?: ThinkingConfig | undefined;
|
||||
}
|
||||
|
||||
export function resolveThinkingLevel(
|
||||
requestedThinking: string | undefined,
|
||||
options: ResolveThinkingLevelOptions,
|
||||
): ThinkingEffort {
|
||||
const resolvedRequest =
|
||||
requestedThinking !== undefined && requestedThinking.trim().length > 0
|
||||
? requestedThinking
|
||||
: options.defaultThinking === false
|
||||
? 'off'
|
||||
: undefined;
|
||||
|
||||
return resolveThinkingEffort(resolvedRequest, options.thinking);
|
||||
}
|
||||
|
||||
export function resolveThinkingEffort(
|
||||
requested: string | undefined,
|
||||
defaults: ThinkingConfig | undefined,
|
||||
|
|
|
|||
|
|
@ -16,8 +16,7 @@ import type { EnabledPluginSessionStart } from '#/plugin';
|
|||
|
||||
import type { McpConnectionManager } from '../mcp';
|
||||
import type { PreparedSystemPromptContext, ResolvedAgentProfile } from '../profile';
|
||||
import type { ProviderManager } from '../providers/provider-manager';
|
||||
import { withProviderRequestAuth } from '../providers/request-auth';
|
||||
import type { ProviderManager } from '../session/provider-manager';
|
||||
import type { RuntimeConfig } from '../runtime-types';
|
||||
import type { SessionSubagentHost } from '../session/subagent-host';
|
||||
import type { SkillRegistry } from '../skill';
|
||||
|
|
@ -64,20 +63,18 @@ export interface AgentConfig {
|
|||
readonly runtime: RuntimeConfig;
|
||||
readonly homedir?: string;
|
||||
readonly skills?: SkillRegistry;
|
||||
readonly rpc: SDKAgentRPC;
|
||||
readonly rpc?: SDKAgentRPC;
|
||||
readonly persistence?: AgentRecordPersistence;
|
||||
readonly type?: AgentType;
|
||||
readonly generate?: typeof generate;
|
||||
readonly compactionStrategy?: CompactionStrategy;
|
||||
readonly providerManager?: ProviderManager | undefined;
|
||||
readonly sessionId?: string;
|
||||
readonly subagentHost?: SessionSubagentHost | undefined;
|
||||
readonly mcp?: McpConnectionManager;
|
||||
readonly hookEngine?: HookEngine;
|
||||
readonly backgroundMaxRunningTasks?: number;
|
||||
readonly backgroundSessionDir?: string;
|
||||
readonly permission?: PermissionManagerOptions | undefined;
|
||||
/** Parent logger; the agent appends its own ctx (agentId already bound by session). */
|
||||
readonly log?: Logger;
|
||||
readonly telemetry?: TelemetryClient | undefined;
|
||||
readonly pluginSessionStarts?: readonly EnabledPluginSessionStart[];
|
||||
|
|
@ -89,7 +86,7 @@ export class Agent {
|
|||
readonly skills?: SkillManager;
|
||||
readonly pluginSessionStarts: readonly EnabledPluginSessionStart[];
|
||||
readonly rawGenerate: typeof generate;
|
||||
readonly rpc: SDKAgentRPC;
|
||||
readonly rpc?: SDKAgentRPC;
|
||||
readonly telemetry: TelemetryClient;
|
||||
readonly providerManager: ProviderManager | undefined;
|
||||
readonly subagentHost: SessionSubagentHost | undefined;
|
||||
|
|
@ -123,10 +120,7 @@ export class Agent {
|
|||
}
|
||||
this.pluginSessionStarts = config.pluginSessionStarts ?? [];
|
||||
this.rawGenerate = config.generate ?? generate;
|
||||
this.providerManager =
|
||||
config.sessionId === undefined
|
||||
? config.providerManager
|
||||
: config.providerManager?.withPromptCacheKey(config.sessionId);
|
||||
this.providerManager = config.providerManager;
|
||||
this.subagentHost = config.subagentHost;
|
||||
this.mcp = config.mcp;
|
||||
this.hooks = config.hookEngine;
|
||||
|
|
@ -173,14 +167,16 @@ export class Agent {
|
|||
return this.rawGenerate(provider, systemPrompt, tools, history, callbacks, options);
|
||||
}
|
||||
const modelAlias = this.config.modelAlias;
|
||||
const resolveAuth =
|
||||
const withAuth =
|
||||
modelAlias === undefined
|
||||
? undefined
|
||||
: this.providerManager?.createAuthResolverForModel(modelAlias, {
|
||||
log: this.log,
|
||||
});
|
||||
return withProviderRequestAuth(resolveAuth, (auth) => {
|
||||
const requestOptions = auth === undefined ? options : { ...options, auth };
|
||||
: this.providerManager?.createAuthResolverForModel(modelAlias, { log: this.log });
|
||||
if (withAuth === undefined) {
|
||||
this.logLlmRequest(provider, systemPrompt, tools, history, options);
|
||||
return this.rawGenerate(provider, systemPrompt, tools, history, callbacks, options);
|
||||
}
|
||||
return withAuth((auth) => {
|
||||
const requestOptions = { ...options, auth };
|
||||
this.logLlmRequest(provider, systemPrompt, tools, history, requestOptions);
|
||||
return this.rawGenerate(provider, systemPrompt, tools, history, callbacks, requestOptions);
|
||||
});
|
||||
|
|
@ -298,21 +294,18 @@ export class Agent {
|
|||
this.telemetry.track('afk_toggle', { enabled: afkEnabled });
|
||||
}
|
||||
},
|
||||
setModel: async (payload) => {
|
||||
const previous = this.config.modelAlias;
|
||||
const resolved = await this.providerManager?.resolveProviderForModel(payload.model);
|
||||
if (resolved === undefined) {
|
||||
throw new Error('Runtime provider model cannot be empty');
|
||||
}
|
||||
this.config.update({
|
||||
modelAlias: resolved.modelName,
|
||||
});
|
||||
if (previous !== resolved.modelName) {
|
||||
this.telemetry.track('model_switch', { model: resolved.modelName });
|
||||
setModel: (payload) => {
|
||||
// Validate the alias resolves before recording it so resume / runtime
|
||||
// callers fail fast on missing aliases instead of deferring to the
|
||||
// next prompt.
|
||||
const resolved = this.providerManager?.resolveProviderConfig(payload.model);
|
||||
if (this.config.modelAlias !== payload.model) {
|
||||
this.config.update({ modelAlias: payload.model });
|
||||
this.telemetry.track('model_switch', { model: payload.model });
|
||||
}
|
||||
return {
|
||||
model: resolved.modelName,
|
||||
providerName: resolved.providerName,
|
||||
model: payload.model,
|
||||
providerName: resolved?.providerName,
|
||||
};
|
||||
},
|
||||
getModel: () => {
|
||||
|
|
@ -369,7 +362,7 @@ export class Agent {
|
|||
|
||||
emitEvent(event: AgentEvent): void {
|
||||
if (this.records.restoring) return;
|
||||
void this.rpc.emitEvent(event);
|
||||
void this.rpc?.emitEvent(event);
|
||||
}
|
||||
|
||||
emitStatusUpdated(): void {
|
||||
|
|
|
|||
|
|
@ -131,32 +131,38 @@ export class PermissionManager {
|
|||
const startedAt = Date.now();
|
||||
|
||||
let response: ApprovalResponse;
|
||||
try {
|
||||
response = await this.agent.rpc.requestApproval(
|
||||
{
|
||||
turnId: Number(context.turnId),
|
||||
toolCallId: id,
|
||||
toolName: name,
|
||||
action,
|
||||
display,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
} catch (error) {
|
||||
this.agent.telemetry.track('permission_approval_result', {
|
||||
policy_name: policyName ?? null,
|
||||
tool_name: name,
|
||||
permission_mode: this.mode,
|
||||
result: 'error',
|
||||
approval_surface: display.kind,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
session_cache_written: false,
|
||||
has_feedback: false,
|
||||
});
|
||||
const resolved = result.resolveError?.(error);
|
||||
return resolved === undefined
|
||||
? Promise.reject(error)
|
||||
: this.permissionPolicyResolutionToPrepare(resolved, context, policyName);
|
||||
if (this.agent.rpc === undefined) {
|
||||
response = {
|
||||
decision: 'approved',
|
||||
};
|
||||
} else {
|
||||
try {
|
||||
response = await this.agent.rpc.requestApproval(
|
||||
{
|
||||
turnId: Number(context.turnId),
|
||||
toolCallId: id,
|
||||
toolName: name,
|
||||
action,
|
||||
display,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
} catch (error) {
|
||||
this.agent.telemetry.track('permission_approval_result', {
|
||||
policy_name: policyName ?? null,
|
||||
tool_name: name,
|
||||
permission_mode: this.mode,
|
||||
result: 'error',
|
||||
approval_surface: display.kind,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
session_cache_written: false,
|
||||
has_feedback: false,
|
||||
});
|
||||
const resolved = result.resolveError?.(error);
|
||||
return resolved === undefined
|
||||
? Promise.reject(error)
|
||||
: this.permissionPolicyResolutionToPrepare(resolved, context, policyName);
|
||||
}
|
||||
}
|
||||
|
||||
const sessionApprovalRule =
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import { mcpResultToExecutableOutput } from '../../mcp/output';
|
|||
import { isMcpToolName, qualifyMcpToolName } from '../../mcp/tool-naming';
|
||||
import type { MCPClient } from '../../mcp/types';
|
||||
import { DEFAULT_AGENT_PROFILES } from '../../profile';
|
||||
import { withProviderRequestAuth } from '../../providers/request-auth';
|
||||
import { extendWorkspaceWithSkillRoots } from '../../skill';
|
||||
import * as b from '../../tools/builtin';
|
||||
import type { ToolStore, ToolStoreData, ToolStoreKey } from '../../tools/store';
|
||||
|
|
@ -94,7 +93,7 @@ export class ToolManager {
|
|||
return {
|
||||
approvalRule: name,
|
||||
execute: async (context) => {
|
||||
return this.agent.rpc.toolCall(
|
||||
return this.agent.rpc!.toolCall(
|
||||
{
|
||||
turnId: Number(context.turnId),
|
||||
toolCallId: context.toolCallId,
|
||||
|
|
@ -370,7 +369,7 @@ export class ToolManager {
|
|||
new b.ReadMediaFileTool(kaos, workspace, modelCapabilities, videoUploader),
|
||||
new b.EnterPlanModeTool(this.agent),
|
||||
new b.ExitPlanModeTool(this.agent),
|
||||
new b.AskUserQuestionTool(this.agent),
|
||||
this.agent.rpc && new b.AskUserQuestionTool(this.agent),
|
||||
new b.TodoListTool(this.toolStore),
|
||||
new b.TaskListTool(background),
|
||||
new b.TaskOutputTool(background),
|
||||
|
|
@ -400,14 +399,12 @@ export class ToolManager {
|
|||
const uploadVideo = provider.uploadVideo?.bind(provider);
|
||||
if (uploadVideo === undefined) return undefined;
|
||||
|
||||
const modelAlias = this.agent.config.modelAlias;
|
||||
const resolveAuth =
|
||||
modelAlias === undefined
|
||||
? undefined
|
||||
: this.agent.providerManager?.createAuthResolverForModel(modelAlias, {
|
||||
log: this.agent.log,
|
||||
});
|
||||
return (input) => withProviderRequestAuth(resolveAuth, (auth) => uploadVideo(input, { auth }));
|
||||
const modelAlias = this.agent.config.modelAlias!;
|
||||
const withAuth = this.agent.providerManager?.createAuthResolverForModel(modelAlias, {
|
||||
log: this.agent.log,
|
||||
});
|
||||
if (withAuth === undefined) return (input) => uploadVideo(input);
|
||||
return (input) => withAuth((auth) => uploadVideo(input, { auth }));
|
||||
}
|
||||
|
||||
get loopTools(): readonly ExecutableTool[] {
|
||||
|
|
|
|||
|
|
@ -701,8 +701,8 @@ function summarizeTurnError(error: unknown, turnId: number): KimiErrorPayload {
|
|||
const details = { ...payload.details, turnId };
|
||||
|
||||
// Substitute a friendlier TUI-aware message for model-not-configured.
|
||||
// Raw kosong text ("Model not set" / "Provider not set") is not
|
||||
// actionable; this string points the user at the login flow.
|
||||
// The raw "Model not set" / "Provider not set" text is not actionable;
|
||||
// this string points the user at the login flow.
|
||||
if (payload.code === 'model.not_configured') {
|
||||
return { ...payload, message: LLM_NOT_SET_MESSAGE, details };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,6 @@ export function makeErrorPayload(
|
|||
* - `APIStatusError`: 429 -> rate_limit, 401 -> auth_error, otherwise -> api_error.
|
||||
* - `APIConnectionError` / `APITimeoutError`: connection_error.
|
||||
* - `ChatProviderError`: api_error.
|
||||
* - Heuristic "Model not set" / "Provider not set" messages: model.not_configured.
|
||||
*
|
||||
* Anything else collapses to `internal`. We never echo `cause` or stack on
|
||||
* the wire.
|
||||
|
|
@ -113,15 +112,6 @@ export function toKimiErrorPayload(error: unknown): KimiErrorPayload {
|
|||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'Model not set' || error.message === 'Provider not set') {
|
||||
return {
|
||||
code: ErrorCodes.MODEL_NOT_CONFIGURED,
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
retryable: KIMI_ERROR_INFO[ErrorCodes.MODEL_NOT_CONFIGURED].retryable,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: ErrorCodes.INTERNAL,
|
||||
message: error.message,
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export type { RuntimeConfig } from './runtime-types';
|
|||
export type {
|
||||
BearerTokenProvider,
|
||||
OAuthTokenProviderResolver,
|
||||
} from './providers/runtime-provider';
|
||||
} from './session/provider-manager';
|
||||
|
||||
// ─── Wire records (for in-monorepo consumers like apps/vis) ────────────────
|
||||
export type {
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
export * from './provider-manager';
|
||||
export * from './runtime-provider';
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
import type { KimiConfig } from '../config';
|
||||
import { ErrorCodes, KimiError } from '#/errors';
|
||||
import type { Logger } from '#/logging/types';
|
||||
import { resolveThinkingEffort, type ThinkingEffort } from '../agent/config/thinking';
|
||||
import {
|
||||
createRuntimeProviderAuthResolver,
|
||||
resolveRuntimeProvider,
|
||||
resolveRuntimeProviderWithOAuth,
|
||||
type OAuthTokenProviderResolver,
|
||||
type ProviderRequestAuthResolver,
|
||||
type ResolvedRuntimeProvider,
|
||||
} from './runtime-provider';
|
||||
|
||||
export interface ProviderManagerOptions {
|
||||
readonly config: KimiConfig;
|
||||
readonly kimiRequestHeaders?: Record<string, string> | undefined;
|
||||
readonly resolveOAuthTokenProvider?: OAuthTokenProviderResolver | undefined;
|
||||
readonly promptCacheKey?: string;
|
||||
}
|
||||
|
||||
export class ProviderManager {
|
||||
private readonly state: ProviderManagerState;
|
||||
|
||||
constructor(
|
||||
private readonly options: ProviderManagerOptions,
|
||||
state?: ProviderManagerState,
|
||||
) {
|
||||
this.state = state ?? { config: options.config };
|
||||
}
|
||||
|
||||
get config(): KimiConfig {
|
||||
return this.state.config;
|
||||
}
|
||||
|
||||
get providers(): KimiConfig['providers'] {
|
||||
return this.state.config.providers;
|
||||
}
|
||||
|
||||
get models(): NonNullable<KimiConfig['models']> {
|
||||
return this.state.config.models ?? {};
|
||||
}
|
||||
|
||||
updateConfig(config: KimiConfig): void {
|
||||
this.state.config = config;
|
||||
}
|
||||
|
||||
withPromptCacheKey(promptCacheKey?: string): ProviderManager {
|
||||
return new ProviderManager(
|
||||
{
|
||||
...this.options,
|
||||
config: this.state.config,
|
||||
promptCacheKey,
|
||||
},
|
||||
this.state,
|
||||
);
|
||||
}
|
||||
|
||||
resolveProviderConfigForModel(
|
||||
model: string | undefined,
|
||||
options?: { readonly promptCacheKey?: string },
|
||||
): ResolvedRuntimeProvider | undefined {
|
||||
const selectedModel = this.resolveSelectedModel(model);
|
||||
if (selectedModel === undefined) return undefined;
|
||||
|
||||
return resolveRuntimeProvider({
|
||||
config: this.state.config,
|
||||
model: selectedModel,
|
||||
kimiRequestHeaders: this.options.kimiRequestHeaders,
|
||||
promptCacheKey: options?.promptCacheKey ?? this.options.promptCacheKey,
|
||||
validateCredentials: false,
|
||||
});
|
||||
}
|
||||
|
||||
async resolveProviderForModel(
|
||||
model: string | undefined,
|
||||
): Promise<ResolvedRuntimeProvider | undefined> {
|
||||
const selectedModel = this.resolveSelectedModel(model);
|
||||
if (selectedModel === undefined) return undefined;
|
||||
|
||||
return resolveRuntimeProviderWithOAuth({
|
||||
config: this.state.config,
|
||||
model: selectedModel,
|
||||
kimiRequestHeaders: this.options.kimiRequestHeaders,
|
||||
promptCacheKey: this.options.promptCacheKey,
|
||||
resolveOAuthTokenProvider: this.options.resolveOAuthTokenProvider,
|
||||
});
|
||||
}
|
||||
|
||||
createAuthResolverForModel(
|
||||
model: string | undefined,
|
||||
options?: { readonly log?: Logger },
|
||||
): ProviderRequestAuthResolver | undefined {
|
||||
const selectedModel = this.resolveSelectedModel(model);
|
||||
if (selectedModel === undefined) return undefined;
|
||||
|
||||
const resolved = resolveRuntimeProvider({
|
||||
config: this.state.config,
|
||||
model: selectedModel,
|
||||
kimiRequestHeaders: this.options.kimiRequestHeaders,
|
||||
promptCacheKey: this.options.promptCacheKey,
|
||||
});
|
||||
return createRuntimeProviderAuthResolver(
|
||||
{
|
||||
config: this.state.config,
|
||||
model: selectedModel,
|
||||
kimiRequestHeaders: this.options.kimiRequestHeaders,
|
||||
promptCacheKey: this.options.promptCacheKey,
|
||||
resolveOAuthTokenProvider: this.options.resolveOAuthTokenProvider,
|
||||
log: options?.log,
|
||||
},
|
||||
resolved,
|
||||
);
|
||||
}
|
||||
|
||||
resolveThinkingLevel(requestedThinking?: string): ThinkingEffort {
|
||||
return resolveThinkingEffort(
|
||||
resolveRuntimeThinkingRequest(requestedThinking, this.state.config.defaultThinking),
|
||||
this.state.config.thinking,
|
||||
);
|
||||
}
|
||||
|
||||
resolveSelectedModel(requestedModel: string | undefined): string | undefined {
|
||||
if (requestedModel !== undefined) {
|
||||
const normalized = normalizeString(requestedModel);
|
||||
if (normalized === undefined) {
|
||||
throw new KimiError(ErrorCodes.MODEL_CONFIG_INVALID, 'Runtime provider model cannot be empty');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
return normalizeString(this.state.config.defaultModel);
|
||||
}
|
||||
}
|
||||
|
||||
interface ProviderManagerState {
|
||||
config: KimiConfig;
|
||||
}
|
||||
|
||||
function normalizeString(value: string | undefined): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function resolveRuntimeThinkingRequest(
|
||||
requestedThinking: string | undefined,
|
||||
defaultThinking: boolean | undefined,
|
||||
): string | undefined {
|
||||
const normalized = normalizeString(requestedThinking);
|
||||
if (normalized !== undefined) return normalized;
|
||||
return defaultThinking === false ? 'off' : undefined;
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import { APIStatusError, type ProviderRequestAuth } from '@moonshot-ai/kosong';
|
||||
|
||||
import { ErrorCodes, KimiError } from '#/errors';
|
||||
|
||||
export interface ProviderRequestAuthOptions {
|
||||
readonly forceRefresh?: boolean;
|
||||
}
|
||||
|
||||
export type ProviderRequestAuthResolver = (
|
||||
options?: ProviderRequestAuthOptions,
|
||||
) => Promise<ProviderRequestAuth>;
|
||||
|
||||
export async function withProviderRequestAuth<T>(
|
||||
resolveAuth: ProviderRequestAuthResolver | undefined,
|
||||
request: (auth: ProviderRequestAuth | undefined) => Promise<T>,
|
||||
): Promise<T> {
|
||||
let auth = await resolveAuth?.();
|
||||
for (let refreshed = false; ; refreshed = true) {
|
||||
try {
|
||||
return await request(auth);
|
||||
} catch (error) {
|
||||
if (
|
||||
auth === undefined ||
|
||||
!(error instanceof APIStatusError) ||
|
||||
error.statusCode !== 401
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
if (!refreshed && resolveAuth !== undefined) {
|
||||
auth = await resolveAuth({ forceRefresh: true });
|
||||
continue;
|
||||
}
|
||||
throw new KimiError(
|
||||
ErrorCodes.AUTH_LOGIN_REQUIRED,
|
||||
'OAuth provider credentials were rejected. Send /login to login.',
|
||||
{
|
||||
cause: error,
|
||||
details: {
|
||||
statusCode: error.statusCode,
|
||||
requestId: error.requestId,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,412 +0,0 @@
|
|||
import type { KimiConfig, ModelAlias, OAuthRef, ProviderConfig } from '#/config';
|
||||
import { ErrorCodes, KimiError, isKimiError } from '#/errors';
|
||||
import { log as defaultLog } from '#/logging/logger';
|
||||
import type { Logger } from '#/logging/types';
|
||||
import {
|
||||
createProvider,
|
||||
UNKNOWN_CAPABILITY,
|
||||
type ModelCapability,
|
||||
type ProviderConfig as KosongProviderConfig,
|
||||
} from '@moonshot-ai/kosong';
|
||||
|
||||
import type { ProviderRequestAuthResolver } from './request-auth';
|
||||
|
||||
export type { ProviderRequestAuthResolver };
|
||||
|
||||
export interface ResolveRuntimeProviderInput {
|
||||
readonly config: KimiConfig;
|
||||
readonly model?: string | undefined;
|
||||
readonly kimiRequestHeaders?: Record<string, string> | undefined;
|
||||
readonly promptCacheKey?: string;
|
||||
readonly validateCredentials?: boolean;
|
||||
}
|
||||
|
||||
export interface BearerTokenProvider {
|
||||
getAccessToken(options?: { readonly force?: boolean | undefined }): Promise<string>;
|
||||
}
|
||||
|
||||
export type OAuthTokenProviderResolver = (
|
||||
providerName: string,
|
||||
oauthRef?: OAuthRef | undefined,
|
||||
) => BearerTokenProvider | undefined;
|
||||
|
||||
export interface ResolveRuntimeProviderWithOAuthInput extends ResolveRuntimeProviderInput {
|
||||
readonly resolveOAuthTokenProvider?: OAuthTokenProviderResolver | undefined;
|
||||
/**
|
||||
* Caller-scoped logger (typically `agent.log`). Used to report OAuth token
|
||||
* fetch failures so they land in the session log alongside the surrounding
|
||||
* turn / tool call. Defaults to the global `log` when absent.
|
||||
*/
|
||||
readonly log?: Logger;
|
||||
}
|
||||
|
||||
export interface ResolvedRuntimeProvider {
|
||||
readonly modelName: string;
|
||||
readonly providerName?: string | undefined;
|
||||
readonly provider: KosongProviderConfig;
|
||||
readonly modelCapabilities: ModelCapability;
|
||||
readonly resolveAuth?: ProviderRequestAuthResolver;
|
||||
}
|
||||
|
||||
export function resolveRuntimeProvider(
|
||||
input: ResolveRuntimeProviderInput,
|
||||
): ResolvedRuntimeProvider {
|
||||
const modelName = input.model ?? input.config.defaultModel;
|
||||
if (modelName === undefined) {
|
||||
throw new KimiError(
|
||||
ErrorCodes.CONFIG_INVALID,
|
||||
'No model is selected. Set default_model in config.toml or pass a configured model alias.',
|
||||
);
|
||||
}
|
||||
|
||||
const alias = input.config.models?.[modelName];
|
||||
if (alias === undefined) {
|
||||
throw new KimiError(
|
||||
ErrorCodes.CONFIG_INVALID,
|
||||
`Model "${modelName}" is not configured in config.toml. Add a [models."${modelName}"] entry with max_context_size.`,
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedModel = alias.model;
|
||||
const providerName = alias.provider ?? input.config.defaultProvider;
|
||||
const providerConfig =
|
||||
providerName === undefined ? undefined : input.config.providers[providerName];
|
||||
|
||||
if (providerName === undefined) {
|
||||
throw new KimiError(
|
||||
ErrorCodes.CONFIG_INVALID,
|
||||
`Model "${modelName}" must define a provider in config.toml.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (providerConfig === undefined) {
|
||||
throw new KimiError(
|
||||
ErrorCodes.CONFIG_INVALID,
|
||||
`Provider "${providerName}" for model "${modelName}" is not configured.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!Number.isInteger(alias.maxContextSize) || alias.maxContextSize <= 0) {
|
||||
throw new KimiError(
|
||||
ErrorCodes.CONFIG_INVALID,
|
||||
`Model "${modelName}" must define a positive max_context_size in config.toml.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Fail-fast when an operation explicitly selects/uses a runtime provider.
|
||||
// Session creation only records the selected model name and does not need
|
||||
// credential validation.
|
||||
if (
|
||||
input.validateCredentials !== false &&
|
||||
providerConfig.type !== 'vertexai' &&
|
||||
providerConfig.oauth === undefined &&
|
||||
providerApiKey(providerConfig) === undefined
|
||||
) {
|
||||
throw new KimiError(
|
||||
ErrorCodes.CONFIG_INVALID,
|
||||
`Provider "${providerName}" has no credentials configured. Set apiKey, oauth, or a provider env API key in config.toml.`,
|
||||
);
|
||||
}
|
||||
|
||||
const provider = toKosongProviderConfig(
|
||||
providerConfig,
|
||||
resolvedModel,
|
||||
input.kimiRequestHeaders,
|
||||
alias.maxOutputSize,
|
||||
alias.reasoningKey,
|
||||
input.promptCacheKey,
|
||||
);
|
||||
const modelCapabilities = resolveModelCapabilities(alias, provider);
|
||||
|
||||
return {
|
||||
modelName,
|
||||
providerName,
|
||||
modelCapabilities,
|
||||
provider,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveRuntimeProviderWithOAuth(
|
||||
input: ResolveRuntimeProviderWithOAuthInput,
|
||||
): Promise<ResolvedRuntimeProvider> {
|
||||
const resolved = resolveRuntimeProvider(input);
|
||||
const resolveAuth = createRuntimeProviderAuthResolver(input, resolved);
|
||||
if (resolveAuth === undefined) return resolved;
|
||||
|
||||
// Validate login eagerly, preserving existing setModel
|
||||
// behavior, but do not store the short-lived token in provider config.
|
||||
await resolveAuth();
|
||||
return {
|
||||
...resolved,
|
||||
resolveAuth,
|
||||
};
|
||||
}
|
||||
|
||||
export function createRuntimeProviderAuthResolver(
|
||||
input: ResolveRuntimeProviderWithOAuthInput,
|
||||
resolved: ResolvedRuntimeProvider = resolveRuntimeProvider(input),
|
||||
): ProviderRequestAuthResolver | undefined {
|
||||
const providerName = resolved.providerName;
|
||||
if (providerName === undefined) return undefined;
|
||||
|
||||
const providerConfig = input.config.providers[providerName];
|
||||
if (providerConfig?.oauth === undefined) return undefined;
|
||||
if (providerApiKey(providerConfig) !== undefined) {
|
||||
// oauth + apiKey on the same provider makes request auth ambiguous:
|
||||
// provider construction would prefer apiKey while runtime auth resolves
|
||||
// OAuth. Reject it so misconfiguration surfaces at model resolution.
|
||||
throw new KimiError(
|
||||
ErrorCodes.CONFIG_INVALID,
|
||||
`Provider "${providerName}" has both apiKey and oauth set in config.toml — they are mutually exclusive. Remove one.`,
|
||||
);
|
||||
}
|
||||
|
||||
const tokenProvider = input.resolveOAuthTokenProvider?.(providerName, providerConfig.oauth);
|
||||
if (tokenProvider === undefined) {
|
||||
return async () => {
|
||||
throw new KimiError(
|
||||
ErrorCodes.AUTH_LOGIN_REQUIRED,
|
||||
`OAuth provider "${providerName}" requires login before it can be used.`,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
return async (options) => {
|
||||
let apiKey: string;
|
||||
try {
|
||||
apiKey = await tokenProvider.getAccessToken(
|
||||
options?.forceRefresh === true ? { force: true } : undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isAuthLoginRequired(error)) {
|
||||
(input.log ?? defaultLog).warn('oauth token fetch failed', { providerName, error });
|
||||
}
|
||||
throw new KimiError(
|
||||
ErrorCodes.AUTH_LOGIN_REQUIRED,
|
||||
`OAuth provider "${providerName}" requires login before it can be used.`,
|
||||
{
|
||||
cause: error,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (apiKey.trim().length === 0) {
|
||||
throw new KimiError(
|
||||
ErrorCodes.AUTH_LOGIN_REQUIRED,
|
||||
`OAuth provider "${providerName}" requires login before it can be used.`,
|
||||
);
|
||||
}
|
||||
return { apiKey };
|
||||
};
|
||||
}
|
||||
|
||||
function isAuthLoginRequired(error: unknown): boolean {
|
||||
return isKimiError(error) && error.code === ErrorCodes.AUTH_LOGIN_REQUIRED;
|
||||
}
|
||||
|
||||
function resolveModelCapabilities(
|
||||
alias: ModelAlias & { maxContextSize: number },
|
||||
provider: KosongProviderConfig,
|
||||
): ModelCapability {
|
||||
const capabilities = new Set(
|
||||
(alias.capabilities ?? []).map((capability) => capability.trim().toLowerCase()),
|
||||
);
|
||||
const has = (capability: string): boolean => capabilities.has(capability);
|
||||
const capabilityProvider = createProvider(providerForCapabilityProbe(provider));
|
||||
const providerCapability =
|
||||
capabilityProvider.getCapability?.(provider.model) ?? UNKNOWN_CAPABILITY;
|
||||
|
||||
return {
|
||||
image_in: has('image_in') || providerCapability.image_in,
|
||||
video_in: has('video_in') || providerCapability.video_in,
|
||||
audio_in: has('audio_in') || providerCapability.audio_in,
|
||||
thinking: has('thinking') || has('always_thinking') || providerCapability.thinking,
|
||||
tool_use: has('tool_use') || providerCapability.tool_use,
|
||||
max_context_tokens: alias.maxContextSize,
|
||||
};
|
||||
}
|
||||
|
||||
function toKosongProviderConfig(
|
||||
provider: ProviderConfig,
|
||||
model: string,
|
||||
kimiRequestHeaders?: Record<string, string> | undefined,
|
||||
maxOutputSize?: number | undefined,
|
||||
reasoningKey?: string | undefined,
|
||||
promptCacheKey?: string,
|
||||
): KosongProviderConfig {
|
||||
switch (provider.type) {
|
||||
case 'anthropic':
|
||||
return {
|
||||
type: 'anthropic',
|
||||
model,
|
||||
baseUrl: providerValue(provider.baseUrl, provider.env, 'ANTHROPIC_BASE_URL'),
|
||||
apiKey: providerApiKey(provider),
|
||||
...(maxOutputSize !== undefined ? { defaultMaxTokens: maxOutputSize } : {}),
|
||||
...defaultHeadersField(provider.customHeaders),
|
||||
};
|
||||
case 'openai':
|
||||
return {
|
||||
type: 'openai',
|
||||
model,
|
||||
baseUrl: providerValue(provider.baseUrl, provider.env, 'OPENAI_BASE_URL'),
|
||||
apiKey: providerApiKey(provider),
|
||||
reasoningKey,
|
||||
...defaultHeadersField(provider.customHeaders),
|
||||
};
|
||||
case 'kimi': {
|
||||
const defaultHeaders = {
|
||||
...kimiRequestHeaders,
|
||||
...provider.customHeaders,
|
||||
};
|
||||
if (Object.keys(defaultHeaders).length === 0) {
|
||||
return {
|
||||
type: 'kimi',
|
||||
model,
|
||||
baseUrl: providerValue(provider.baseUrl, provider.env, 'KIMI_BASE_URL'),
|
||||
generationKwargs: {
|
||||
prompt_cache_key: promptCacheKey,
|
||||
},
|
||||
apiKey: providerApiKey(provider),
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'kimi',
|
||||
model,
|
||||
baseUrl: providerValue(provider.baseUrl, provider.env, 'KIMI_BASE_URL'),
|
||||
generationKwargs: {
|
||||
prompt_cache_key: promptCacheKey,
|
||||
},
|
||||
defaultHeaders,
|
||||
apiKey: providerApiKey(provider),
|
||||
};
|
||||
}
|
||||
case 'google-genai':
|
||||
return {
|
||||
type: 'google-genai',
|
||||
model,
|
||||
apiKey: providerApiKey(provider),
|
||||
};
|
||||
case 'openai_responses':
|
||||
return {
|
||||
type: 'openai_responses',
|
||||
model,
|
||||
baseUrl: providerValue(provider.baseUrl, provider.env, 'OPENAI_BASE_URL'),
|
||||
apiKey: providerApiKey(provider),
|
||||
...defaultHeadersField(provider.customHeaders),
|
||||
};
|
||||
case 'vertexai':
|
||||
return {
|
||||
type: 'vertexai',
|
||||
model,
|
||||
vertexai: hasVertexAIServiceEnv(provider),
|
||||
apiKey: hasVertexAIServiceEnv(provider) ? undefined : providerApiKey(provider),
|
||||
project: vertexAIProject(provider),
|
||||
location: vertexAILocation(provider),
|
||||
};
|
||||
default: {
|
||||
const exhaustive: never = provider.type;
|
||||
throw new KimiError(
|
||||
ErrorCodes.MODEL_CONFIG_INVALID,
|
||||
`Unsupported provider type: ${String(exhaustive)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spread-ready `defaultHeaders` field for a kosong provider config. Returns a
|
||||
// fresh copy so resolved provider instances never share a header object, and
|
||||
// omits the key entirely when there are no headers (callers and tests rely on
|
||||
// `'defaultHeaders' in provider`).
|
||||
function defaultHeadersField(
|
||||
headers: Record<string, string> | undefined,
|
||||
): { defaultHeaders?: Record<string, string> } {
|
||||
if (headers === undefined || Object.keys(headers).length === 0) return {};
|
||||
return { defaultHeaders: { ...headers } };
|
||||
}
|
||||
|
||||
function providerForCapabilityProbe(provider: KosongProviderConfig): KosongProviderConfig {
|
||||
if (provider.type === 'vertexai') {
|
||||
return {
|
||||
...provider,
|
||||
vertexai: false,
|
||||
project: undefined,
|
||||
location: undefined,
|
||||
apiKey:
|
||||
provider.apiKey === undefined || provider.apiKey.length === 0
|
||||
? 'capability-probe'
|
||||
: provider.apiKey,
|
||||
};
|
||||
}
|
||||
if (provider.apiKey !== undefined && provider.apiKey.length > 0) return provider;
|
||||
return { ...provider, apiKey: 'capability-probe' };
|
||||
}
|
||||
|
||||
function providerApiKey(provider: ProviderConfig): string | undefined {
|
||||
switch (provider.type) {
|
||||
case 'anthropic':
|
||||
return providerValue(provider.apiKey, provider.env, 'ANTHROPIC_API_KEY');
|
||||
case 'openai':
|
||||
case 'openai_responses':
|
||||
return providerValue(provider.apiKey, provider.env, 'OPENAI_API_KEY');
|
||||
case 'kimi':
|
||||
return providerValue(provider.apiKey, provider.env, 'KIMI_API_KEY');
|
||||
case 'google-genai':
|
||||
return providerValue(provider.apiKey, provider.env, 'GOOGLE_API_KEY');
|
||||
case 'vertexai':
|
||||
return (
|
||||
nonEmptyString(provider.apiKey) ??
|
||||
envValue(provider.env, 'VERTEXAI_API_KEY') ??
|
||||
envValue(provider.env, 'GOOGLE_API_KEY')
|
||||
);
|
||||
default: {
|
||||
const exhaustive: never = provider.type;
|
||||
throw new KimiError(
|
||||
ErrorCodes.MODEL_CONFIG_INVALID,
|
||||
`Unsupported provider type: ${String(exhaustive)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hasVertexAIServiceEnv(provider: ProviderConfig): boolean {
|
||||
return vertexAIProject(provider) !== undefined && vertexAILocation(provider) !== undefined;
|
||||
}
|
||||
|
||||
function vertexAIProject(provider: ProviderConfig): string | undefined {
|
||||
return envValue(provider.env, 'GOOGLE_CLOUD_PROJECT');
|
||||
}
|
||||
|
||||
function vertexAILocation(provider: ProviderConfig): string | undefined {
|
||||
return (
|
||||
envValue(provider.env, 'GOOGLE_CLOUD_LOCATION') ??
|
||||
locationFromVertexAIBaseUrl(provider.baseUrl)
|
||||
);
|
||||
}
|
||||
|
||||
function providerValue(
|
||||
configured: string | undefined,
|
||||
env: Record<string, string> | undefined,
|
||||
envKey: string,
|
||||
): string | undefined {
|
||||
return nonEmptyString(configured) ?? envValue(env, envKey);
|
||||
}
|
||||
|
||||
function envValue(env: Record<string, string> | undefined, key: string): string | undefined {
|
||||
return nonEmptyString(env?.[key]);
|
||||
}
|
||||
|
||||
function nonEmptyString(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed;
|
||||
}
|
||||
|
||||
function locationFromVertexAIBaseUrl(baseUrl: string | undefined): string | undefined {
|
||||
const url = nonEmptyString(baseUrl);
|
||||
if (url === undefined) return undefined;
|
||||
try {
|
||||
const host = new URL(url).hostname;
|
||||
const suffix = '-aiplatform.googleapis.com';
|
||||
return host.endsWith(suffix) ? nonEmptyString(host.slice(0, -suffix.length)) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -249,6 +249,10 @@ export interface UpdateSessionMetadataPayload {
|
|||
readonly metadata: SessionMetadataPatch;
|
||||
}
|
||||
|
||||
export interface GetKimiConfigPayload {
|
||||
readonly reload?: boolean;
|
||||
}
|
||||
|
||||
export type SetKimiConfigPayload = KimiConfigPatch;
|
||||
|
||||
export interface RemoveKimiProviderPayload {
|
||||
|
|
@ -302,7 +306,7 @@ type SessionAPIWithId = WithSessionId<SessionAPI>;
|
|||
|
||||
export interface CoreAPI extends SessionAPIWithId {
|
||||
getCoreInfo: (payload: EmptyPayload) => CoreInfo;
|
||||
getKimiConfig: (payload: EmptyPayload) => KimiConfig;
|
||||
getKimiConfig: (payload: GetKimiConfigPayload) => KimiConfig;
|
||||
setKimiConfig: (payload: SetKimiConfigPayload) => KimiConfig;
|
||||
removeKimiProvider: (payload: RemoveKimiProviderPayload) => KimiConfig;
|
||||
createSession: (payload: CreateSessionPayload) => SessionSummary;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,39 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
import { KaosShellNotFoundError, LocalKaos } from '@moonshot-ai/kaos';
|
||||
import { ErrorCodes, KimiError } from '#/errors';
|
||||
import { getRootLogger, log } from '#/logging/logger';
|
||||
import { PluginManager } from '#/plugin';
|
||||
import { LocalFetchURLProvider } from '#/tools/providers/local-fetch-url';
|
||||
import { MoonshotFetchURLProvider } from '#/tools/providers/moonshot-fetch-url';
|
||||
import { MoonshotWebSearchProvider } from '#/tools/providers/moonshot-web-search';
|
||||
import type { PromisableMethods } from '#/utils/types';
|
||||
import { getCoreVersion } from '#/version';
|
||||
import { KaosShellNotFoundError, LocalKaos } from '@moonshot-ai/kaos';
|
||||
import { resolveThinkingLevel } from '../agent/config/thinking';
|
||||
import {
|
||||
ensureKimiHome,
|
||||
mergeConfigPatch,
|
||||
readConfigFile,
|
||||
resolveConfigPath,
|
||||
resolveKimiHome,
|
||||
writeConfigFile,
|
||||
type KimiConfig,
|
||||
type MoonshotServiceConfig,
|
||||
} from '../config';
|
||||
import type { Logger } from '../logging/types';
|
||||
import { resolveSessionMcpConfig, type SessionMcpConfig } from '../mcp';
|
||||
import type { RuntimeConfig } from '../runtime-types';
|
||||
import { Session, type SessionMeta, type SessionSkillConfig } from '../session';
|
||||
import { exportSessionDirectory } from '../session/export';
|
||||
import {
|
||||
ProviderManager, type BearerTokenProvider,
|
||||
type OAuthTokenProviderResolver
|
||||
} from '../session/provider-manager';
|
||||
import { SessionAPIImpl } from '../session/rpc';
|
||||
import { normalizeWorkDir, SessionStore } from '../session/store';
|
||||
import { noopTelemetryClient, withTelemetryContext, type TelemetryClient } from '../telemetry';
|
||||
import type { CoreRPCClient } from './client';
|
||||
import type {
|
||||
ActivateSkillPayload,
|
||||
BeginCompactionPayload,
|
||||
|
|
@ -24,6 +50,7 @@ import type {
|
|||
GetBackgroundOutputPathPayload,
|
||||
GetBackgroundOutputPayload,
|
||||
GetBackgroundPayload,
|
||||
GetKimiConfigPayload,
|
||||
GetPluginInfoPayload,
|
||||
InstallPluginPayload,
|
||||
ListSessionsPayload,
|
||||
|
|
@ -33,14 +60,15 @@ import type {
|
|||
PluginSummary,
|
||||
PromptPayload,
|
||||
ReconnectMcpServerPayload,
|
||||
RegisterToolPayload,
|
||||
ReloadPluginsResult,
|
||||
RemoveKimiProviderPayload,
|
||||
RemovePluginPayload,
|
||||
RenameSessionPayload,
|
||||
ResumeSessionPayload,
|
||||
RegisterToolPayload,
|
||||
SetKimiConfigPayload,
|
||||
SessionSummary,
|
||||
SetActiveToolsPayload,
|
||||
SetKimiConfigPayload,
|
||||
SetModelPayload,
|
||||
SetModelResult,
|
||||
SetPermissionPayload,
|
||||
|
|
@ -50,41 +78,12 @@ import type {
|
|||
SkillSummary,
|
||||
SteerPayload,
|
||||
StopBackgroundPayload,
|
||||
SessionSummary,
|
||||
UnregisterToolPayload,
|
||||
UpdateSessionMetadataPayload,
|
||||
} from './core-api';
|
||||
import type { CoreRPCClient } from './client';
|
||||
import type { ResumedAgentState, ResumeSessionResult } from './resumed';
|
||||
import type { SDKRPC } from './sdk-api';
|
||||
import { proxyWithExtraPayload } from './types';
|
||||
import type { PromisableMethods } from '#/utils/types';
|
||||
|
||||
import { PluginManager } from '#/plugin';
|
||||
|
||||
import { resolveSessionMcpConfig, type SessionMcpConfig } from '../mcp';
|
||||
import { Session, type SessionMeta, type SessionSkillConfig } from '../session';
|
||||
import { SessionAPIImpl } from '../session/rpc';
|
||||
import {
|
||||
ensureKimiHome,
|
||||
mergeConfigPatch,
|
||||
readConfigFile,
|
||||
resolveConfigPath,
|
||||
resolveKimiHome,
|
||||
writeConfigFile,
|
||||
type KimiConfig,
|
||||
type MoonshotServiceConfig,
|
||||
} from '../config';
|
||||
import { exportSessionDirectory } from '../session/export';
|
||||
import { ProviderManager } from '../providers/provider-manager';
|
||||
import {
|
||||
type BearerTokenProvider,
|
||||
type OAuthTokenProviderResolver,
|
||||
} from '../providers/runtime-provider';
|
||||
import type { Logger } from '../logging/types';
|
||||
import type { RuntimeConfig } from '../runtime-types';
|
||||
import { normalizeWorkDir, SessionStore } from '../session/store';
|
||||
import { noopTelemetryClient, withTelemetryContext, type TelemetryClient } from '../telemetry';
|
||||
|
||||
const KIMI_CODE_PROVIDER_NAME = 'managed:kimi-code';
|
||||
|
||||
|
|
@ -112,11 +111,11 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
readonly telemetry: TelemetryClient;
|
||||
|
||||
private runtime: RuntimeConfig | undefined;
|
||||
private config: KimiConfig;
|
||||
private readonly userHomeDir: string;
|
||||
private readonly kimiRequestHeaders: Record<string, string> | undefined;
|
||||
private readonly resolveOAuthTokenProvider: OAuthTokenProviderResolver | undefined;
|
||||
private readonly skillDirs: readonly string[];
|
||||
private readonly providerManager: ProviderManager;
|
||||
private readonly sessionStore: SessionStore;
|
||||
readonly plugins: PluginManager;
|
||||
private pluginsReady: Promise<void>;
|
||||
|
|
@ -138,11 +137,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
this.skillDirs = options.skillDirs ?? [];
|
||||
this.telemetry = options.telemetry ?? noopTelemetryClient;
|
||||
ensureKimiHome(this.homeDir);
|
||||
this.providerManager = new ProviderManager({
|
||||
config: readConfigFile(this.configPath),
|
||||
kimiRequestHeaders: this.kimiRequestHeaders,
|
||||
resolveOAuthTokenProvider: this.resolveOAuthTokenProvider,
|
||||
});
|
||||
this.config = readConfigFile(this.configPath);
|
||||
this.sessionStore = new SessionStore(this.homeDir);
|
||||
this.plugins = new PluginManager({ kimiHomeDir: this.homeDir });
|
||||
// Capture the error rather than swallow it: mutators and explicit /plugins
|
||||
|
|
@ -161,8 +156,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
const workDir = requiredWorkDir('createSession', options.workDir);
|
||||
const config = this.reloadProviderManager();
|
||||
const id = options.id ?? createSessionId();
|
||||
const modelName = this.providerManager.resolveSelectedModel(options.model);
|
||||
const thinkingLevel = this.providerManager.resolveThinkingLevel(options.thinking);
|
||||
const thinkingLevel = resolveThinkingLevel(options.thinking, config);
|
||||
const permissionMode = options.permission ?? config.defaultPermissionMode;
|
||||
const baseMcpConfig = await resolveSessionMcpConfig({
|
||||
cwd: workDir,
|
||||
|
|
@ -190,7 +184,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
homedir: summary.sessionDir,
|
||||
kimiHomeDir: this.homeDir,
|
||||
rpc: proxyWithExtraPayload(await this.sdk, { sessionId: summary.id }),
|
||||
providerManager: this.providerManager,
|
||||
providerManager: this.resolveProviderManager(summary.id),
|
||||
background: config.background,
|
||||
hooks: config.hooks,
|
||||
permissionRules: config.permission?.rules,
|
||||
|
|
@ -214,7 +208,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
};
|
||||
const mainAgent = await session.createMain();
|
||||
mainAgent.config.update({
|
||||
modelAlias: modelName,
|
||||
modelAlias: options.model ?? config.defaultModel,
|
||||
thinkingLevel,
|
||||
});
|
||||
if (permissionMode !== undefined) {
|
||||
|
|
@ -269,7 +263,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
homedir: summary.sessionDir,
|
||||
kimiHomeDir: this.homeDir,
|
||||
rpc: proxyWithExtraPayload(await this.sdk, { sessionId: summary.id }),
|
||||
providerManager: this.providerManager,
|
||||
providerManager: this.resolveProviderManager(summary.id),
|
||||
background: config.background,
|
||||
hooks: config.hooks,
|
||||
permissionRules: config.permission?.rules,
|
||||
|
|
@ -360,17 +354,17 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
return result;
|
||||
}
|
||||
|
||||
async getKimiConfig(input: EmptyPayload = {}): Promise<KimiConfig> {
|
||||
void input;
|
||||
return readConfigFile(this.configPath);
|
||||
async getKimiConfig(input?: GetKimiConfigPayload): Promise<KimiConfig> {
|
||||
if (input?.reload) {
|
||||
this.config = readConfigFile(this.configPath);
|
||||
}
|
||||
return this.config;
|
||||
}
|
||||
|
||||
async setKimiConfig(input: SetKimiConfigPayload): Promise<KimiConfig> {
|
||||
const config = mergeConfigPatch(readConfigFile(this.configPath), input);
|
||||
await writeConfigFile(this.configPath, config);
|
||||
const updated = readConfigFile(this.configPath);
|
||||
this.providerManager.updateConfig(updated);
|
||||
return updated;
|
||||
return this.config = readConfigFile(this.configPath);
|
||||
}
|
||||
|
||||
async removeKimiProvider(input: RemoveKimiProviderPayload): Promise<KimiConfig> {
|
||||
|
|
@ -401,9 +395,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
}
|
||||
|
||||
await writeConfigFile(this.configPath, config);
|
||||
const updated = readConfigFile(this.configPath);
|
||||
this.providerManager.updateConfig(updated);
|
||||
return updated;
|
||||
return this.config = readConfigFile(this.configPath);
|
||||
}
|
||||
|
||||
prompt({ sessionId, ...payload }: SessionAgentPayload<PromptPayload>) {
|
||||
|
|
@ -660,6 +652,15 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
};
|
||||
}
|
||||
|
||||
private resolveProviderManager(sessionId: string): ProviderManager {
|
||||
return new ProviderManager({
|
||||
config: () => this.config,
|
||||
kimiRequestHeaders: this.kimiRequestHeaders,
|
||||
resolveOAuthTokenProvider: this.resolveOAuthTokenProvider,
|
||||
promptCacheKey: sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
private mergePluginMcpConfig(base: SessionMcpConfig | undefined): SessionMcpConfig | undefined {
|
||||
const pluginServers = this.plugins.enabledMcpServers();
|
||||
if (Object.keys(pluginServers).length === 0) return base;
|
||||
|
|
@ -682,9 +683,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
}
|
||||
|
||||
private reloadProviderManager(): KimiConfig {
|
||||
const config = readConfigFile(this.configPath);
|
||||
this.providerManager.updateConfig(config);
|
||||
return config;
|
||||
return this.config = readConfigFile(this.configPath);
|
||||
}
|
||||
|
||||
private async refreshSessionRuntimeConfig(
|
||||
|
|
@ -721,14 +720,6 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
|
|||
throw error;
|
||||
}
|
||||
}
|
||||
// No candidate resolved (the replayed alias and the configured default are
|
||||
// both invalid/unset). Clear the stale alias so the session is honestly
|
||||
// model-less — the TUI then prompts for a model instead of showing a
|
||||
// selection whose next prompt fails with a config error. Not persisted:
|
||||
// `refreshSessionRuntimeConfig` re-derives this on every resume.
|
||||
if (requested.length > 0) {
|
||||
session.agents.get('main')?.config.update({ modelAlias: undefined });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import {
|
|||
prepareSystemPromptContext,
|
||||
type ResolvedAgentProfile,
|
||||
} from '../profile';
|
||||
import type { ProviderManager } from '../providers/provider-manager';
|
||||
import type { ProviderManager } from './provider-manager';
|
||||
import type { RuntimeConfig } from '../runtime-types';
|
||||
import {
|
||||
registerBuiltinSkills,
|
||||
|
|
@ -409,7 +409,6 @@ export class Session {
|
|||
skills: this.skills,
|
||||
rpc: proxyWithExtraPayload(this.rpc, { agentId: id }),
|
||||
providerManager: this.config.providerManager,
|
||||
sessionId: this.config.id,
|
||||
hookEngine: config.hookEngine ?? this.hookEngine,
|
||||
subagentHost:
|
||||
config.subagentHost ?? new SessionSubagentHost(this, id, this.backgroundTaskTimeoutMs()),
|
||||
|
|
|
|||
338
packages/agent-core/src/session/provider-manager.ts
Normal file
338
packages/agent-core/src/session/provider-manager.ts
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
import type { Logger } from '#/logging/types';
|
||||
import type { ProviderConfig as KosongProviderConfig, ModelCapability, ProviderRequestAuth } from '@moonshot-ai/kosong';
|
||||
import { APIStatusError, createProvider, UNKNOWN_CAPABILITY } from '@moonshot-ai/kosong';
|
||||
import type { KimiConfig, ModelAlias, OAuthRef, ProviderConfig } from '../config';
|
||||
import { ErrorCodes, isKimiError, KimiError } from '../errors';
|
||||
|
||||
export interface BearerTokenProvider {
|
||||
getAccessToken(options?: { readonly force?: boolean }): Promise<string>;
|
||||
}
|
||||
|
||||
export type OAuthTokenProviderResolver = (
|
||||
providerName: string,
|
||||
oauthRef?: OAuthRef,
|
||||
) => BearerTokenProvider | undefined;
|
||||
|
||||
export interface ResolvedRuntimeProvider {
|
||||
readonly modelName: string;
|
||||
readonly providerName: string;
|
||||
readonly provider: KosongProviderConfig;
|
||||
readonly modelCapabilities: ModelCapability;
|
||||
}
|
||||
|
||||
interface ProviderManagerOptions {
|
||||
readonly config: KimiConfig | (() => KimiConfig);
|
||||
readonly kimiRequestHeaders?: Record<string, string>;
|
||||
readonly resolveOAuthTokenProvider?: OAuthTokenProviderResolver;
|
||||
readonly promptCacheKey?: string;
|
||||
}
|
||||
|
||||
type AuthorizedRequest = <T>(
|
||||
request: (auth: ProviderRequestAuth) => Promise<T>,
|
||||
) => Promise<T>;
|
||||
|
||||
export class ProviderManager {
|
||||
constructor(private readonly options: ProviderManagerOptions) {}
|
||||
|
||||
get config(): KimiConfig {
|
||||
const { config } = this.options;
|
||||
return typeof config === 'function' ? config() : config;
|
||||
}
|
||||
|
||||
resolveProviderConfig(model: string): ResolvedRuntimeProvider {
|
||||
const alias = this.config.models?.[model];
|
||||
if (alias === undefined) {
|
||||
throw new KimiError(
|
||||
ErrorCodes.CONFIG_INVALID,
|
||||
`Model "${model}" is not configured in config.toml. Add a [models."${model}"] entry with max_context_size.`,
|
||||
);
|
||||
}
|
||||
|
||||
const providerName = alias.provider ?? this.config.defaultProvider;
|
||||
if (providerName === undefined) {
|
||||
throw new KimiError(
|
||||
ErrorCodes.CONFIG_INVALID,
|
||||
`Model "${model}" must define a provider in config.toml.`,
|
||||
);
|
||||
}
|
||||
|
||||
const providerConfig = this.config.providers[providerName];
|
||||
if (providerConfig === undefined) {
|
||||
throw new KimiError(
|
||||
ErrorCodes.CONFIG_INVALID,
|
||||
`Provider "${providerName}" for model "${model}" is not configured.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!Number.isInteger(alias.maxContextSize) || alias.maxContextSize <= 0) {
|
||||
throw new KimiError(
|
||||
ErrorCodes.CONFIG_INVALID,
|
||||
`Model "${model}" must define a positive max_context_size in config.toml.`,
|
||||
);
|
||||
}
|
||||
|
||||
const provider = toKosongProviderConfig(
|
||||
providerConfig,
|
||||
alias.model,
|
||||
this.options.kimiRequestHeaders,
|
||||
alias.maxOutputSize,
|
||||
alias.reasoningKey,
|
||||
this.options.promptCacheKey,
|
||||
);
|
||||
|
||||
return {
|
||||
modelName: model,
|
||||
providerName,
|
||||
provider,
|
||||
modelCapabilities: resolveModelCapabilities(alias, provider),
|
||||
};
|
||||
}
|
||||
|
||||
createAuthResolverForModel(
|
||||
model: string,
|
||||
options?: { readonly log?: Logger },
|
||||
): AuthorizedRequest | undefined {
|
||||
const { providerName } = this.resolveProviderConfig(model);
|
||||
const providerConfig = this.config.providers[providerName];
|
||||
if (providerConfig?.oauth === undefined) return undefined;
|
||||
|
||||
if (providerApiKey(providerConfig) !== undefined) {
|
||||
// oauth + apiKey on the same provider makes request auth ambiguous:
|
||||
// provider construction would prefer apiKey while runtime auth resolves
|
||||
// OAuth. Reject it so misconfiguration surfaces at model resolution.
|
||||
throw new KimiError(
|
||||
ErrorCodes.CONFIG_INVALID,
|
||||
`Provider "${providerName}" has both apiKey and oauth set in config.toml — they are mutually exclusive. Remove one.`,
|
||||
);
|
||||
}
|
||||
|
||||
const loginRequired = (cause?: unknown): KimiError =>
|
||||
new KimiError(
|
||||
ErrorCodes.AUTH_LOGIN_REQUIRED,
|
||||
`OAuth provider "${providerName}" requires login before it can be used.`,
|
||||
cause === undefined ? undefined : { cause },
|
||||
);
|
||||
|
||||
const tokenProvider = this.options.resolveOAuthTokenProvider?.(providerName, providerConfig.oauth);
|
||||
if (tokenProvider === undefined) {
|
||||
return async () => {
|
||||
throw loginRequired();
|
||||
};
|
||||
}
|
||||
|
||||
const log = options?.log;
|
||||
const fetchAuth = async (force: boolean): Promise<ProviderRequestAuth> => {
|
||||
let apiKey: string;
|
||||
try {
|
||||
apiKey = await tokenProvider.getAccessToken(force ? { force: true } : undefined);
|
||||
} catch (error) {
|
||||
if (!isKimiError(error) || error.code !== ErrorCodes.AUTH_LOGIN_REQUIRED) {
|
||||
log?.warn('oauth token fetch failed', { providerName, error });
|
||||
}
|
||||
throw loginRequired(error);
|
||||
}
|
||||
if (apiKey.trim().length === 0) throw loginRequired();
|
||||
return { apiKey };
|
||||
};
|
||||
|
||||
return async (request) => {
|
||||
let auth = await fetchAuth(false);
|
||||
for (let refreshed = false; ; refreshed = true) {
|
||||
try {
|
||||
return await request(auth);
|
||||
} catch (error) {
|
||||
if (!(error instanceof APIStatusError) || error.statusCode !== 401) throw error;
|
||||
if (refreshed) {
|
||||
throw new KimiError(
|
||||
ErrorCodes.AUTH_LOGIN_REQUIRED,
|
||||
'OAuth provider credentials were rejected. Send /login to login.',
|
||||
{
|
||||
cause: error,
|
||||
details: { statusCode: error.statusCode, requestId: error.requestId },
|
||||
},
|
||||
);
|
||||
}
|
||||
auth = await fetchAuth(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function resolveModelCapabilities(
|
||||
alias: ModelAlias,
|
||||
provider: KosongProviderConfig,
|
||||
): ModelCapability {
|
||||
const declared = new Set((alias.capabilities ?? []).map((c) => c.trim().toLowerCase()));
|
||||
const probe = createProvider(providerForCapabilityProbe(provider));
|
||||
const detected = probe.getCapability?.(provider.model) ?? UNKNOWN_CAPABILITY;
|
||||
|
||||
return {
|
||||
image_in: declared.has('image_in') || detected.image_in,
|
||||
video_in: declared.has('video_in') || detected.video_in,
|
||||
audio_in: declared.has('audio_in') || detected.audio_in,
|
||||
thinking: declared.has('thinking') || declared.has('always_thinking') || detected.thinking,
|
||||
tool_use: declared.has('tool_use') || detected.tool_use,
|
||||
max_context_tokens: alias.maxContextSize,
|
||||
};
|
||||
}
|
||||
|
||||
function toKosongProviderConfig(
|
||||
provider: ProviderConfig,
|
||||
model: string,
|
||||
kimiRequestHeaders: Record<string, string> | undefined,
|
||||
maxOutputSize: number | undefined,
|
||||
reasoningKey: string | undefined,
|
||||
promptCacheKey: string | undefined,
|
||||
): KosongProviderConfig {
|
||||
switch (provider.type) {
|
||||
case 'anthropic':
|
||||
return {
|
||||
type: 'anthropic',
|
||||
model,
|
||||
baseUrl: providerValue(provider.baseUrl, provider.env, 'ANTHROPIC_BASE_URL'),
|
||||
apiKey: providerApiKey(provider),
|
||||
...(maxOutputSize !== undefined ? { defaultMaxTokens: maxOutputSize } : {}),
|
||||
...defaultHeadersField(provider.customHeaders),
|
||||
};
|
||||
case 'openai':
|
||||
return {
|
||||
type: 'openai',
|
||||
model,
|
||||
baseUrl: providerValue(provider.baseUrl, provider.env, 'OPENAI_BASE_URL'),
|
||||
apiKey: providerApiKey(provider),
|
||||
reasoningKey,
|
||||
...defaultHeadersField(provider.customHeaders),
|
||||
};
|
||||
case 'kimi':
|
||||
return {
|
||||
type: 'kimi',
|
||||
model,
|
||||
baseUrl: providerValue(provider.baseUrl, provider.env, 'KIMI_BASE_URL'),
|
||||
apiKey: providerApiKey(provider),
|
||||
generationKwargs: { prompt_cache_key: promptCacheKey },
|
||||
...defaultHeadersField({ ...kimiRequestHeaders, ...provider.customHeaders }),
|
||||
};
|
||||
case 'google-genai':
|
||||
return {
|
||||
type: 'google-genai',
|
||||
model,
|
||||
apiKey: providerApiKey(provider),
|
||||
};
|
||||
case 'openai_responses':
|
||||
return {
|
||||
type: 'openai_responses',
|
||||
model,
|
||||
baseUrl: providerValue(provider.baseUrl, provider.env, 'OPENAI_BASE_URL'),
|
||||
apiKey: providerApiKey(provider),
|
||||
...defaultHeadersField(provider.customHeaders),
|
||||
};
|
||||
case 'vertexai': {
|
||||
const useServiceAccount = hasVertexAIServiceEnv(provider);
|
||||
return {
|
||||
type: 'vertexai',
|
||||
model,
|
||||
vertexai: useServiceAccount,
|
||||
apiKey: useServiceAccount ? undefined : providerApiKey(provider),
|
||||
project: vertexAIProject(provider),
|
||||
location: vertexAILocation(provider),
|
||||
};
|
||||
}
|
||||
default: {
|
||||
const exhaustive: never = provider.type;
|
||||
throw new KimiError(
|
||||
ErrorCodes.MODEL_CONFIG_INVALID,
|
||||
`Unsupported provider type: ${String(exhaustive)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Returns a fresh `defaultHeaders` field for a kosong provider config so
|
||||
// resolved instances never share a header object. Omits the key entirely when
|
||||
// there are no headers — callers and tests rely on `'defaultHeaders' in provider`.
|
||||
function defaultHeadersField(
|
||||
headers: Record<string, string> | undefined,
|
||||
): { defaultHeaders?: Record<string, string> } {
|
||||
if (headers === undefined || Object.keys(headers).length === 0) return {};
|
||||
return { defaultHeaders: { ...headers } };
|
||||
}
|
||||
|
||||
function providerForCapabilityProbe(provider: KosongProviderConfig): KosongProviderConfig {
|
||||
const apiKey = provider.apiKey && provider.apiKey.length > 0 ? provider.apiKey : 'capability-probe';
|
||||
if (provider.type === 'vertexai') {
|
||||
return { ...provider, vertexai: false, project: undefined, location: undefined, apiKey };
|
||||
}
|
||||
return { ...provider, apiKey };
|
||||
}
|
||||
|
||||
function providerApiKey(provider: ProviderConfig): string | undefined {
|
||||
switch (provider.type) {
|
||||
case 'anthropic':
|
||||
return providerValue(provider.apiKey, provider.env, 'ANTHROPIC_API_KEY');
|
||||
case 'openai':
|
||||
case 'openai_responses':
|
||||
return providerValue(provider.apiKey, provider.env, 'OPENAI_API_KEY');
|
||||
case 'kimi':
|
||||
return providerValue(provider.apiKey, provider.env, 'KIMI_API_KEY');
|
||||
case 'google-genai':
|
||||
return providerValue(provider.apiKey, provider.env, 'GOOGLE_API_KEY');
|
||||
case 'vertexai':
|
||||
return (
|
||||
nonEmptyString(provider.apiKey) ??
|
||||
envValue(provider.env, 'VERTEXAI_API_KEY') ??
|
||||
envValue(provider.env, 'GOOGLE_API_KEY')
|
||||
);
|
||||
default: {
|
||||
const exhaustive: never = provider.type;
|
||||
throw new KimiError(
|
||||
ErrorCodes.MODEL_CONFIG_INVALID,
|
||||
`Unsupported provider type: ${String(exhaustive)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hasVertexAIServiceEnv(provider: ProviderConfig): boolean {
|
||||
return vertexAIProject(provider) !== undefined && vertexAILocation(provider) !== undefined;
|
||||
}
|
||||
|
||||
function vertexAIProject(provider: ProviderConfig): string | undefined {
|
||||
return envValue(provider.env, 'GOOGLE_CLOUD_PROJECT');
|
||||
}
|
||||
|
||||
function vertexAILocation(provider: ProviderConfig): string | undefined {
|
||||
return (
|
||||
envValue(provider.env, 'GOOGLE_CLOUD_LOCATION') ??
|
||||
locationFromVertexAIBaseUrl(provider.baseUrl)
|
||||
);
|
||||
}
|
||||
|
||||
function providerValue(
|
||||
configured: string | undefined,
|
||||
env: Record<string, string> | undefined,
|
||||
envKey: string,
|
||||
): string | undefined {
|
||||
return nonEmptyString(configured) ?? envValue(env, envKey);
|
||||
}
|
||||
|
||||
function envValue(env: Record<string, string> | undefined, key: string): string | undefined {
|
||||
return nonEmptyString(env?.[key]);
|
||||
}
|
||||
|
||||
function nonEmptyString(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed;
|
||||
}
|
||||
|
||||
function locationFromVertexAIBaseUrl(baseUrl: string | undefined): string | undefined {
|
||||
const url = nonEmptyString(baseUrl);
|
||||
if (url === undefined) return undefined;
|
||||
try {
|
||||
const host = new URL(url).hostname;
|
||||
const suffix = '-aiplatform.googleapis.com';
|
||||
return host.endsWith(suffix) ? nonEmptyString(host.slice(0, -suffix.length)) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -104,7 +104,7 @@ export class AskUserQuestionTool implements BuiltinTool<AskUserQuestionInput> {
|
|||
}: ExecutableToolContext,
|
||||
): Promise<ExecutableToolResult> {
|
||||
try {
|
||||
const result = await this.agent.rpc.requestQuestion(
|
||||
const result = await this.agent.rpc!.requestQuestion(
|
||||
{
|
||||
turnId: numericTurnId(turnId),
|
||||
toolCallId,
|
||||
|
|
|
|||
|
|
@ -15,10 +15,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|||
import type { AgentConfig } from '../../src/agent';
|
||||
import { DefaultCompactionStrategy, type CompactionStrategy } from '../../src/agent/compaction';
|
||||
import { HookEngine, type HookEngineTriggerArgs } from '../../src/agent/hooks';
|
||||
import type { KimiConfig } from '../../src/config';
|
||||
import { ProviderManager } from '../../src/providers/provider-manager';
|
||||
import { recordingTelemetry, type TelemetryRecord } from '../fixtures/telemetry';
|
||||
import type { TestAgentContext } from './harness/agent';
|
||||
import type { TestAgentContext, TestAgentOptions } from './harness/agent';
|
||||
import { testAgent } from './harness/agent';
|
||||
|
||||
type GenerateFn = NonNullable<AgentConfig['generate']>;
|
||||
|
|
@ -294,7 +292,7 @@ describe('Agent compaction', () => {
|
|||
it('force-refreshes OAuth credentials on compaction 401 and falls back to login_required when replay 401', async () => {
|
||||
const tokenCalls: Array<boolean | undefined> = [];
|
||||
const authKeys: string[] = [];
|
||||
const providerManager = createOAuthProviderManager(async (options) => {
|
||||
const oauthOptions = oauthTestAgentOptions(async (options) => {
|
||||
tokenCalls.push(options?.force);
|
||||
return options?.force === true ? 'forced-refresh-token' : 'fresh-token';
|
||||
});
|
||||
|
|
@ -312,7 +310,7 @@ describe('Agent compaction', () => {
|
|||
}
|
||||
return textResult('Recovered compacted summary.');
|
||||
};
|
||||
const ctx = testAgent({ providerManager, generate });
|
||||
const ctx = testAgent({ ...oauthOptions, generate });
|
||||
ctx.configure();
|
||||
await ctx.rpc.setModel({ model: 'kimi-code' });
|
||||
ctx.newEvents();
|
||||
|
|
@ -336,7 +334,7 @@ describe('Agent compaction', () => {
|
|||
}),
|
||||
);
|
||||
expect(authKeys).toEqual(['fresh-token', 'forced-refresh-token']);
|
||||
expect(tokenCalls).toEqual([undefined, undefined, true]);
|
||||
expect(tokenCalls).toEqual([undefined, true]);
|
||||
expect(ctx.compactHistory()).toEqual([
|
||||
{ role: 'user', text: 'old user one' },
|
||||
{ role: 'assistant', text: 'old assistant one' },
|
||||
|
|
@ -350,7 +348,7 @@ describe('Agent compaction', () => {
|
|||
|
||||
expect(await retryOutcome).toBe('context.apply_compaction');
|
||||
expect(authKeys).toEqual(['fresh-token', 'forced-refresh-token', 'fresh-token']);
|
||||
expect(tokenCalls).toEqual([undefined, undefined, true, undefined]);
|
||||
expect(tokenCalls).toEqual([undefined, true, undefined]);
|
||||
expect(ctx.compactHistory()).toEqual([
|
||||
{ role: 'assistant', text: 'Recovered compacted summary.' },
|
||||
]);
|
||||
|
|
@ -1103,15 +1101,12 @@ describe('Agent compaction', () => {
|
|||
});
|
||||
|
||||
it('does not auto compact small contexts when reserved size exceeds the model window', async () => {
|
||||
const providerManager = new ProviderManager({
|
||||
config: {
|
||||
const ctx = testAgent({
|
||||
initialConfig: {
|
||||
providers: {},
|
||||
loopControl: {
|
||||
reservedContextSize: 50_000,
|
||||
},
|
||||
loopControl: { reservedContextSize: 50_000 },
|
||||
},
|
||||
});
|
||||
const ctx = testAgent({ providerManager });
|
||||
ctx.configure({
|
||||
provider: CATALOGUED_PROVIDER,
|
||||
modelCapabilities: {
|
||||
|
|
@ -1133,15 +1128,12 @@ describe('Agent compaction', () => {
|
|||
});
|
||||
|
||||
it('triggers auto compaction when pending tokens cross the reserved threshold', async () => {
|
||||
const providerManager = new ProviderManager({
|
||||
config: {
|
||||
const ctx = testAgent({
|
||||
initialConfig: {
|
||||
providers: {},
|
||||
loopControl: {
|
||||
reservedContextSize: 500,
|
||||
},
|
||||
loopControl: { reservedContextSize: 500 },
|
||||
},
|
||||
});
|
||||
const ctx = testAgent({ providerManager });
|
||||
ctx.configure({
|
||||
provider: CATALOGUED_PROVIDER,
|
||||
modelCapabilities: {
|
||||
|
|
@ -1319,13 +1311,11 @@ describe('Agent compaction', () => {
|
|||
});
|
||||
const providerManager = ctx.agent.providerManager;
|
||||
if (providerManager === undefined) throw new Error('Expected provider manager');
|
||||
const resolveProviderConfig = providerManager.resolveProviderConfigForModel.bind(providerManager);
|
||||
providerManager.resolveProviderConfigForModel = (model) => {
|
||||
const resolved = resolveProviderConfig(model);
|
||||
return resolved === undefined
|
||||
? undefined
|
||||
: { ...resolved, modelCapabilities: UNKNOWN_CAPABILITY };
|
||||
};
|
||||
const resolveProviderConfig = providerManager.resolveProviderConfig.bind(providerManager);
|
||||
providerManager.resolveProviderConfig = (model) => ({
|
||||
...resolveProviderConfig(model),
|
||||
modelCapabilities: UNKNOWN_CAPABILITY,
|
||||
});
|
||||
expect(ctx.agent.config.modelCapabilities.max_context_tokens).toBe(0);
|
||||
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
|
||||
ctx.newEvents();
|
||||
|
|
@ -1499,30 +1489,31 @@ function eventIndex(events: ReturnType<TestAgentContext['newEvents']>, type: str
|
|||
});
|
||||
}
|
||||
|
||||
function createOAuthProviderManager(
|
||||
function oauthTestAgentOptions(
|
||||
getAccessToken: (options?: { readonly force?: boolean }) => Promise<string>,
|
||||
): ProviderManager {
|
||||
const oauthConfig: KimiConfig = {
|
||||
defaultModel: 'kimi-code',
|
||||
providers: {
|
||||
'managed:kimi-code': {
|
||||
type: 'vertexai',
|
||||
baseUrl: 'https://api.example/v1',
|
||||
oauth: { storage: 'file', key: 'oauth/kimi-code' },
|
||||
): Pick<TestAgentOptions, 'initialConfig' | 'providerManagerOverrides'> {
|
||||
return {
|
||||
initialConfig: {
|
||||
defaultModel: 'kimi-code',
|
||||
providers: {
|
||||
'managed:kimi-code': {
|
||||
type: 'vertexai',
|
||||
baseUrl: 'https://api.example/v1',
|
||||
oauth: { storage: 'file', key: 'oauth/kimi-code' },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
'kimi-code': {
|
||||
provider: 'managed:kimi-code',
|
||||
model: 'kimi-for-coding',
|
||||
maxContextSize: 1_000_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
'kimi-code': {
|
||||
provider: 'managed:kimi-code',
|
||||
model: 'kimi-for-coding',
|
||||
maxContextSize: 1_000_000,
|
||||
},
|
||||
providerManagerOverrides: {
|
||||
resolveOAuthTokenProvider: () => ({ getAccessToken }),
|
||||
},
|
||||
};
|
||||
return new ProviderManager({
|
||||
config: oauthConfig,
|
||||
resolveOAuthTokenProvider: () => ({ getAccessToken }),
|
||||
});
|
||||
}
|
||||
|
||||
function textResult(text: string): Awaited<ReturnType<GenerateFn>> {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { UNKNOWN_CAPABILITY } from '@moonshot-ai/kosong';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ProviderManager } from '../../src/providers/provider-manager';
|
||||
import { ProviderManager } from '../../src/session/provider-manager';
|
||||
import { testAgent } from './harness';
|
||||
|
||||
describe('ConfigState model capabilities', () => {
|
||||
|
|
@ -74,22 +73,10 @@ describe('ConfigState model capabilities', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('clears the selected model when modelAlias is cleared', () => {
|
||||
const ctx = testAgent();
|
||||
ctx.configure();
|
||||
const config = ctx.agent.config;
|
||||
|
||||
config.update({ modelAlias: undefined });
|
||||
|
||||
expect(() => config.model).toThrow('Model not set');
|
||||
expect(config.data().provider).toBeUndefined();
|
||||
expect(config.modelCapabilities).toEqual(UNKNOWN_CAPABILITY);
|
||||
});
|
||||
|
||||
it('uses session id as a provider prompt cache hint without storing it on Agent', () => {
|
||||
it('uses session id as a provider prompt cache hint without storing it on Agent', () => {
|
||||
const ctx = testAgent({
|
||||
sessionId: 'session-test',
|
||||
providerManager: new ProviderManager({
|
||||
promptCacheKey: 'session-test',
|
||||
config: {
|
||||
providers: {
|
||||
kimi: {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import {
|
|||
import type { KimiConfig } from '../../../src/config';
|
||||
import type { ExecutableToolResult } from '../../../src/loop';
|
||||
import type { Logger } from '../../../src/logging';
|
||||
import { ProviderManager } from '../../../src/providers/provider-manager';
|
||||
import { ProviderManager } from '../../../src/session/provider-manager';
|
||||
import type { QuestionResult, RPCCallOptions, SDKAgentRPC } from '../../../src/rpc';
|
||||
import type { AgentAPI } from '../../../src/rpc/core-api';
|
||||
import type { RuntimeConfig } from '../../../src/runtime-types';
|
||||
|
|
@ -88,7 +88,7 @@ interface ResumeStateSnapshot {
|
|||
readonly usage: ReturnType<Agent['usage']['data']>;
|
||||
}
|
||||
|
||||
interface TestAgentOptions {
|
||||
export interface TestAgentOptions {
|
||||
readonly kaos?: Kaos | undefined;
|
||||
readonly runtime?: RuntimeConfig | undefined;
|
||||
readonly compactionStrategy?: CompactionStrategy | undefined;
|
||||
|
|
@ -97,6 +97,8 @@ interface TestAgentOptions {
|
|||
readonly type?: AgentConfig['type'];
|
||||
readonly permission?: AgentConfig['permission'];
|
||||
readonly providerManager?: ProviderManager;
|
||||
readonly initialConfig?: KimiConfig;
|
||||
readonly providerManagerOverrides?: Omit<ConstructorParameters<typeof ProviderManager>[0], 'config'>;
|
||||
readonly sessionId?: string;
|
||||
readonly subagentHost?: AgentConfig['subagentHost'];
|
||||
readonly onEvent?: ((event: AgentRecord) => AgentRecord | undefined) | undefined;
|
||||
|
|
@ -155,10 +157,17 @@ export class AgentTestContext {
|
|||
readonly mockNextResponse = this.scriptedGenerate.mockNextResponse;
|
||||
readonly mockNextProviderResponse = this.scriptedGenerate.mockNextProviderResponse;
|
||||
|
||||
private kimiConfig: KimiConfig;
|
||||
|
||||
constructor(options: TestAgentOptions = {}) {
|
||||
this.options = options;
|
||||
this.emitter.on('error', () => {});
|
||||
const providerManager = options.providerManager ?? new ProviderManager({ config: emptyConfig() });
|
||||
this.kimiConfig = options.initialConfig ?? emptyConfig();
|
||||
const providerManager = options.providerManager ?? new ProviderManager({
|
||||
config: () => this.kimiConfig,
|
||||
...(options.sessionId !== undefined ? { promptCacheKey: options.sessionId } : {}),
|
||||
...options.providerManagerOverrides,
|
||||
});
|
||||
|
||||
const runtime = options.runtime ?? {
|
||||
kaos: options.kaos ?? testKaos,
|
||||
|
|
@ -173,7 +182,6 @@ export class AgentTestContext {
|
|||
generate: options.generate ?? this.scriptedGenerate.generate,
|
||||
compactionStrategy: options.compactionStrategy,
|
||||
providerManager,
|
||||
sessionId: options.sessionId,
|
||||
subagentHost: options.subagentHost,
|
||||
type: options.type,
|
||||
permission: options.permission,
|
||||
|
|
@ -208,9 +216,9 @@ export class AgentTestContext {
|
|||
provider: ProviderConfig,
|
||||
modelCapabilities?: ModelCapability | undefined,
|
||||
): void {
|
||||
this.agent.providerManager?.updateConfig(
|
||||
configWithProvider(this.agent.providerManager.config, provider, modelCapabilities),
|
||||
);
|
||||
if (this.options.providerManager === undefined) {
|
||||
this.kimiConfig = configWithProvider(this.kimiConfig, provider, modelCapabilities);
|
||||
}
|
||||
this.agent.config.update({ modelAlias: provider.model });
|
||||
}
|
||||
|
||||
|
|
@ -712,7 +720,9 @@ export class AgentTestContext {
|
|||
urlFetcher: this.agent.runtime.urlFetcher,
|
||||
webSearcher: this.agent.runtime.webSearcher,
|
||||
},
|
||||
providerManager: this.agent.providerManager,
|
||||
providerManager: this.options.providerManager,
|
||||
initialConfig: this.kimiConfig,
|
||||
providerManagerOverrides: this.options.providerManagerOverrides,
|
||||
generate: failOnResumeGenerate,
|
||||
compactionStrategy: this.options.compactionStrategy,
|
||||
persistence: new InMemoryAgentRecordPersistence(
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ describe('Agent question', () => {
|
|||
it('roundtrips a question request through wire rpc', async () => {
|
||||
const ctx = testAgent();
|
||||
|
||||
const resultPromise = ctx.agent.rpc.requestQuestion(
|
||||
const resultPromise = ctx.agent.rpc!.requestQuestion(
|
||||
{
|
||||
questions: [
|
||||
{
|
||||
|
|
@ -29,7 +29,7 @@ describe('Agent question', () => {
|
|||
it('sends multiple questions in one request', async () => {
|
||||
const ctx = testAgent();
|
||||
|
||||
const resultPromise = ctx.agent.rpc.requestQuestion(
|
||||
const resultPromise = ctx.agent.rpc!.requestQuestion(
|
||||
{
|
||||
questions: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { Agent, type AgentRecord } from '../../src/agent';
|
|||
import { testKaos } from '../fixtures/test-kaos';
|
||||
import { InMemoryAgentRecordPersistence } from '../../src/agent/records';
|
||||
import type { AgentRecordPersistence } from '../../src/agent/records';
|
||||
import { ProviderManager } from '../../src/providers/provider-manager';
|
||||
import { ProviderManager } from '../../src/session/provider-manager';
|
||||
import type { ApprovalResponse, SDKAgentRPC, SDKSessionRPC } from '../../src/rpc';
|
||||
import { Session } from '../../src/session';
|
||||
import { SkillRegistry, type SkillDefinition } from '../../src/skill';
|
||||
|
|
|
|||
|
|
@ -17,9 +17,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|||
|
||||
import { HookEngine } from '../../src/agent/hooks';
|
||||
import type { AgentConfig } from '../../src/agent';
|
||||
import type { KimiConfig } from '../../src/config';
|
||||
import type { Logger, LogPayload } from '../../src/logging';
|
||||
import { ProviderManager } from '../../src/providers/provider-manager';
|
||||
import {
|
||||
estimateTokens,
|
||||
estimateTokensForMessages,
|
||||
|
|
@ -27,7 +25,7 @@ import {
|
|||
} from '../../src/utils/tokens';
|
||||
import { recordingTelemetry, type TelemetryRecord } from '../fixtures/telemetry';
|
||||
import { createFakeKaos } from '../tools/fixtures/fake-kaos';
|
||||
import { createCommandKaos, testAgent } from './harness/agent';
|
||||
import { createCommandKaos, testAgent, type TestAgentOptions } from './harness/agent';
|
||||
import { executeTool } from '../tools/fixtures/execute-tool';
|
||||
|
||||
type GenerateFn = NonNullable<AgentConfig['generate']>;
|
||||
|
|
@ -259,10 +257,10 @@ describe('Agent turn flow', () => {
|
|||
[wire] turn.prompt { "input": [ { "type": "text", "text": "Hello without login" } ], "origin": { "kind": "user" }, "time": "<time>" }
|
||||
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" } }
|
||||
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "Hello without login" } ], "toolCalls": [], "origin": { "kind": "user" } }, "time": "<time>" }
|
||||
[emit] turn.ended { "turnId": 0, "reason": "failed", "error": { "code": "model.not_configured", "message": "LLM not set, send \\"/login\\" to login", "name": "Error", "retryable": false, "details": { "turnId": 0 } } }
|
||||
[emit] turn.ended { "turnId": 0, "reason": "failed", "error": { "code": "model.not_configured", "message": "LLM not set, send \\"/login\\" to login", "name": "KimiError", "details": { "turnId": 0 }, "retryable": false } }
|
||||
`);
|
||||
expect(ctx.newEvents()).toMatchInlineSnapshot(
|
||||
`[emit] error { "code": "model.not_configured", "message": "LLM not set, send \\"/login\\" to login", "name": "Error", "retryable": false, "details": { "turnId": 0 } }`,
|
||||
`[emit] error { "code": "model.not_configured", "message": "LLM not set, send \\"/login\\" to login", "name": "KimiError", "details": { "turnId": 0 }, "retryable": false }`,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -633,8 +631,8 @@ describe('Agent turn flow', () => {
|
|||
it('resolves the latest request-scoped OAuth auth before each generation', async () => {
|
||||
const tokenCalls: Array<boolean | undefined> = [];
|
||||
const authKeys: string[] = [];
|
||||
const tokens = ['initial-oauth-token', 'first-turn-token', 'second-turn-token'];
|
||||
const providerManager = createOAuthProviderManager(async (options) => {
|
||||
const tokens = ['first-turn-token', 'second-turn-token'];
|
||||
const oauthOptions = oauthAgentOptions(async (options) => {
|
||||
tokenCalls.push(options?.force);
|
||||
const token = tokens.shift();
|
||||
if (token === undefined) throw new Error('unexpected token request');
|
||||
|
|
@ -654,7 +652,7 @@ describe('Agent turn flow', () => {
|
|||
await callbacks?.onMessagePart?.({ type: 'text', text });
|
||||
return textResult(text);
|
||||
};
|
||||
const ctx = testAgent({ providerManager, generate });
|
||||
const ctx = testAgent({ ...oauthOptions, generate });
|
||||
ctx.configure();
|
||||
await ctx.rpc.setModel({ model: 'kimi-code' });
|
||||
ctx.newEvents();
|
||||
|
|
@ -667,7 +665,7 @@ describe('Agent turn flow', () => {
|
|||
const secondEvents = await ctx.untilTurnEnd();
|
||||
|
||||
expect(authKeys).toEqual(['first-turn-token', 'second-turn-token']);
|
||||
expect(tokenCalls).toEqual([undefined, undefined, undefined]);
|
||||
expect(tokenCalls).toEqual([undefined, undefined]);
|
||||
expect(firstEvents).toContainEqual(
|
||||
expect.objectContaining({
|
||||
event: 'assistant.delta',
|
||||
|
|
@ -826,15 +824,12 @@ describe('Agent turn flow', () => {
|
|||
|
||||
it('classifies OAuth resolver failures as auth errors', async () => {
|
||||
const tokenCalls: Array<boolean | undefined> = [];
|
||||
const tokens = ['initial-oauth-token'];
|
||||
const providerManager = createOAuthProviderManager(async (options) => {
|
||||
const oauthOptions = oauthAgentOptions(async (options) => {
|
||||
tokenCalls.push(options?.force);
|
||||
const token = tokens.shift();
|
||||
if (token === undefined) throw new Error('refresh token expired');
|
||||
return token;
|
||||
throw new Error('refresh token expired');
|
||||
});
|
||||
const generate = vi.fn<GenerateFn>();
|
||||
const ctx = testAgent({ providerManager, generate });
|
||||
const ctx = testAgent({ ...oauthOptions, generate });
|
||||
ctx.configure();
|
||||
await ctx.rpc.setModel({ model: 'kimi-code' });
|
||||
ctx.newEvents();
|
||||
|
|
@ -842,7 +837,7 @@ describe('Agent turn flow', () => {
|
|||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hello after token expiry' }] });
|
||||
const events = await ctx.untilTurnEnd();
|
||||
|
||||
expect(tokenCalls).toEqual([undefined, undefined]);
|
||||
expect(tokenCalls).toEqual([undefined]);
|
||||
expect(generate).not.toHaveBeenCalled();
|
||||
expect(events).not.toContainEqual(expect.objectContaining({ event: 'assistant.delta' }));
|
||||
expect(events).toContainEqual(
|
||||
|
|
@ -859,16 +854,11 @@ describe('Agent turn flow', () => {
|
|||
});
|
||||
|
||||
it('honors configured maxStepsPerTurn in agent turns', async () => {
|
||||
const providerManager = new ProviderManager({
|
||||
config: {
|
||||
providers: {},
|
||||
loopControl: {
|
||||
maxStepsPerTurn: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
const ctx = testAgent({
|
||||
providerManager,
|
||||
initialConfig: {
|
||||
providers: {},
|
||||
loopControl: { maxStepsPerTurn: 1 },
|
||||
},
|
||||
kaos: createCommandKaos('loop-output'),
|
||||
});
|
||||
ctx.configure({ tools: ['Bash'] });
|
||||
|
|
@ -906,7 +896,7 @@ describe('Agent turn flow', () => {
|
|||
it('force-refreshes OAuth credentials and replays the request on 401', async () => {
|
||||
const tokenCalls: Array<boolean | undefined> = [];
|
||||
const authKeys: string[] = [];
|
||||
const providerManager = createOAuthProviderManager(async (options) => {
|
||||
const oauthOptions = oauthAgentOptions(async (options) => {
|
||||
tokenCalls.push(options?.force);
|
||||
return options?.force === true ? 'forced-refresh-token' : 'fresh-token';
|
||||
});
|
||||
|
|
@ -925,7 +915,7 @@ describe('Agent turn flow', () => {
|
|||
await callbacks?.onMessagePart?.({ type: 'text', text });
|
||||
return textResult(text);
|
||||
};
|
||||
const ctx = testAgent({ providerManager, generate });
|
||||
const ctx = testAgent({ ...oauthOptions, generate });
|
||||
ctx.configure();
|
||||
await ctx.rpc.setModel({ model: 'kimi-code' });
|
||||
ctx.newEvents();
|
||||
|
|
@ -934,7 +924,7 @@ describe('Agent turn flow', () => {
|
|||
const events = await ctx.untilTurnEnd();
|
||||
|
||||
expect(authKeys).toEqual(['fresh-token', 'forced-refresh-token']);
|
||||
expect(tokenCalls).toEqual([undefined, undefined, true]);
|
||||
expect(tokenCalls).toEqual([undefined, true]);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
event: 'assistant.delta',
|
||||
|
|
@ -952,7 +942,7 @@ describe('Agent turn flow', () => {
|
|||
it('falls back to login_required when force-refresh and replay both 401', async () => {
|
||||
const tokenCalls: Array<boolean | undefined> = [];
|
||||
const authKeys: string[] = [];
|
||||
const providerManager = createOAuthProviderManager(
|
||||
const oauthOptions = oauthAgentOptions(
|
||||
async (options) => {
|
||||
tokenCalls.push(options?.force);
|
||||
return options?.force === true ? 'forced-refresh-token' : 'fresh-token';
|
||||
|
|
@ -970,7 +960,7 @@ describe('Agent turn flow', () => {
|
|||
authKeys.push(options?.auth?.apiKey ?? '<missing>');
|
||||
throw new APIStatusError(401, 'Unauthorized', 'req-401');
|
||||
};
|
||||
const ctx = testAgent({ providerManager, generate });
|
||||
const ctx = testAgent({ ...oauthOptions, generate });
|
||||
ctx.configure();
|
||||
await ctx.rpc.setModel({ model: 'kimi-code' });
|
||||
ctx.newEvents();
|
||||
|
|
@ -979,7 +969,7 @@ describe('Agent turn flow', () => {
|
|||
const events = await ctx.untilTurnEnd();
|
||||
|
||||
expect(authKeys).toEqual(['fresh-token', 'forced-refresh-token']);
|
||||
expect(tokenCalls).toEqual([undefined, undefined, true]);
|
||||
expect(tokenCalls).toEqual([undefined, true]);
|
||||
expect(events).not.toContainEqual(expect.objectContaining({ event: 'assistant.delta' }));
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
|
|
@ -1089,7 +1079,7 @@ describe('Agent turn flow', () => {
|
|||
};
|
||||
const ctx = testAgent({
|
||||
generate,
|
||||
providerManager: createSingleAttemptProviderManager(),
|
||||
...singleAttemptAgentOptions(),
|
||||
telemetry: recordingTelemetry(records),
|
||||
});
|
||||
ctx.configure();
|
||||
|
|
@ -1120,7 +1110,7 @@ describe('Agent turn flow', () => {
|
|||
it('keeps transient retry handling with request-scoped OAuth auth', async () => {
|
||||
const { logger, entries } = captureLogs();
|
||||
const authKeys: string[] = [];
|
||||
const providerManager = createOAuthProviderManager(async () => 'fresh-token');
|
||||
const oauthOptions = oauthAgentOptions(async () => 'fresh-token');
|
||||
const generate: GenerateFn = async (
|
||||
_provider,
|
||||
_system,
|
||||
|
|
@ -1136,7 +1126,7 @@ describe('Agent turn flow', () => {
|
|||
await callbacks?.onMessagePart?.({ type: 'text', text: 'Recovered after retry' });
|
||||
return textResult('Recovered after retry');
|
||||
};
|
||||
const ctx = testAgent({ providerManager, generate, log: logger });
|
||||
const ctx = testAgent({ ...oauthOptions, generate, log: logger });
|
||||
ctx.configure();
|
||||
await ctx.rpc.setModel({ model: 'kimi-code' });
|
||||
ctx.newEvents();
|
||||
|
|
@ -1171,7 +1161,7 @@ describe('Agent turn flow', () => {
|
|||
it('force-refreshes OAuth credentials on video upload 401 and falls back to login_required when replay 401', async () => {
|
||||
const tokenCalls: Array<boolean | undefined> = [];
|
||||
const authKeys: string[] = [];
|
||||
const providerManager = createOAuthProviderManager(
|
||||
const oauthOptions = oauthAgentOptions(
|
||||
async (options) => {
|
||||
tokenCalls.push(options?.force);
|
||||
return options?.force === true ? 'forced-refresh-token' : 'fresh-token';
|
||||
|
|
@ -1185,8 +1175,8 @@ describe('Agent turn flow', () => {
|
|||
}),
|
||||
} as unknown as ChatProvider;
|
||||
const ctx = testAgent({
|
||||
...oauthOptions,
|
||||
kaos: createVideoKaos(),
|
||||
providerManager,
|
||||
});
|
||||
ctx.agent.config.update({
|
||||
cwd: process.cwd(),
|
||||
|
|
@ -1414,15 +1404,13 @@ interface ApiErrorTelemetryCase {
|
|||
readonly statusCode?: number;
|
||||
}
|
||||
|
||||
function createSingleAttemptProviderManager(): ProviderManager {
|
||||
return new ProviderManager({
|
||||
config: {
|
||||
function singleAttemptAgentOptions(): Pick<TestAgentOptions, 'initialConfig'> {
|
||||
return {
|
||||
initialConfig: {
|
||||
providers: {},
|
||||
loopControl: {
|
||||
maxRetriesPerStep: 1,
|
||||
},
|
||||
loopControl: { maxRetriesPerStep: 1 },
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const MP4_HEADER = Buffer.concat([
|
||||
|
|
@ -1472,32 +1460,33 @@ function mediaCapabilities(): ModelCapability {
|
|||
};
|
||||
}
|
||||
|
||||
function createOAuthProviderManager(
|
||||
function oauthAgentOptions(
|
||||
getAccessToken: (options?: { readonly force?: boolean }) => Promise<string>,
|
||||
capabilities?: readonly string[] | undefined,
|
||||
): ProviderManager {
|
||||
const oauthConfig: KimiConfig = {
|
||||
defaultModel: 'kimi-code',
|
||||
providers: {
|
||||
'managed:kimi-code': {
|
||||
type: 'vertexai',
|
||||
baseUrl: 'https://api.example/v1',
|
||||
oauth: { storage: 'file', key: 'oauth/kimi-code' },
|
||||
): Pick<TestAgentOptions, 'initialConfig' | 'providerManagerOverrides'> {
|
||||
return {
|
||||
initialConfig: {
|
||||
defaultModel: 'kimi-code',
|
||||
providers: {
|
||||
'managed:kimi-code': {
|
||||
type: 'vertexai',
|
||||
baseUrl: 'https://api.example/v1',
|
||||
oauth: { storage: 'file', key: 'oauth/kimi-code' },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
'kimi-code': {
|
||||
provider: 'managed:kimi-code',
|
||||
model: 'kimi-for-coding',
|
||||
maxContextSize: 1_000_000,
|
||||
capabilities: capabilities === undefined ? undefined : [...capabilities],
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
'kimi-code': {
|
||||
provider: 'managed:kimi-code',
|
||||
model: 'kimi-for-coding',
|
||||
maxContextSize: 1_000_000,
|
||||
capabilities: capabilities === undefined ? undefined : [...capabilities],
|
||||
},
|
||||
providerManagerOverrides: {
|
||||
resolveOAuthTokenProvider: vi.fn(() => ({ getAccessToken })),
|
||||
},
|
||||
};
|
||||
return new ProviderManager({
|
||||
config: oauthConfig,
|
||||
resolveOAuthTokenProvider: vi.fn(() => ({ getAccessToken })),
|
||||
});
|
||||
}
|
||||
|
||||
function textResult(text: string): Awaited<ReturnType<GenerateFn>> {
|
||||
|
|
|
|||
|
|
@ -258,7 +258,7 @@ reason = "no rm"
|
|||
expect((rpc as unknown as Record<string, unknown>)['setVersion']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears a resumed model that no longer resolves and has no valid fallback', async () => {
|
||||
it('keeps the resumed model alias visible when it no longer resolves', async () => {
|
||||
const rpc = await createTestRpc();
|
||||
const created = await rpc.createSession({ workDir, model: 'kimi-code/kimi-for-coding' });
|
||||
await rpc.closeSession({ sessionId: created.id });
|
||||
|
|
@ -270,9 +270,11 @@ reason = "no rm"
|
|||
const freshRpc = await createTestRpc();
|
||||
await freshRpc.resumeSession({ sessionId: created.id });
|
||||
|
||||
// The stale alias must be cleared, not left selected — a phantom model
|
||||
// would fail on the next prompt instead of surfacing the no-model state.
|
||||
expect(await freshRpc.getModel({ sessionId: created.id, agentId: 'main' })).toBe('');
|
||||
// The stale alias stays visible so the UI can surface which model the
|
||||
// user had selected. The next prompt will raise MODEL_NOT_CONFIGURED.
|
||||
expect(await freshRpc.getModel({ sessionId: created.id, agentId: 'main' })).toBe(
|
||||
'kimi-code/kimi-for-coding',
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces a config error when a resumed model is configured but unresolvable', async () => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,33 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { KimiConfig } from '../../src/config';
|
||||
import { KimiError } from '../../src/errors';
|
||||
import { resolveRuntimeProvider } from '../../src/providers/runtime-provider';
|
||||
import { ProviderManager } from '../../src/providers/provider-manager';
|
||||
import { ErrorCodes, KimiError } from '../../src/errors';
|
||||
import { ProviderManager } from '../../src/session/provider-manager';
|
||||
import { resolveThinkingLevel } from '../../src/agent/config/thinking';
|
||||
|
||||
// Thin wrapper that adapts the legacy `resolveRuntimeProvider(input)` shape to
|
||||
// the current ProviderManager API. Kept local so the existing test bodies do
|
||||
// not need to change.
|
||||
function resolveRuntimeProvider(input: {
|
||||
readonly config: KimiConfig;
|
||||
readonly model?: string;
|
||||
readonly kimiRequestHeaders?: Record<string, string>;
|
||||
readonly promptCacheKey?: string;
|
||||
}): ReturnType<ProviderManager['resolveProviderConfig']> {
|
||||
const manager = new ProviderManager({
|
||||
config: input.config,
|
||||
kimiRequestHeaders: input.kimiRequestHeaders,
|
||||
promptCacheKey: input.promptCacheKey,
|
||||
});
|
||||
const model = input.model ?? input.config.defaultModel;
|
||||
if (model === undefined) {
|
||||
throw new KimiError(
|
||||
ErrorCodes.CONFIG_INVALID,
|
||||
'No model is selected. Set default_model in config.toml or pass a configured model alias.',
|
||||
);
|
||||
}
|
||||
return manager.resolveProviderConfig(model);
|
||||
}
|
||||
|
||||
const BASE_CONFIG: KimiConfig = {
|
||||
defaultModel: 'kimi-code/kimi-for-coding',
|
||||
|
|
@ -161,39 +185,6 @@ describe('resolveRuntimeProvider model metadata', () => {
|
|||
).toThrow(KimiError);
|
||||
});
|
||||
|
||||
it('throws when the selected provider has neither apiKey nor oauth configured', () => {
|
||||
expect(() =>
|
||||
resolveRuntimeProvider({
|
||||
config: {
|
||||
...BASE_CONFIG,
|
||||
providers: {
|
||||
'managed:kimi-code': {
|
||||
type: 'kimi',
|
||||
baseUrl: 'https://api.example/v1',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toThrow(/no credentials configured/i);
|
||||
});
|
||||
|
||||
it('throws when apiKey is an empty string and no oauth is configured', () => {
|
||||
expect(() =>
|
||||
resolveRuntimeProvider({
|
||||
config: {
|
||||
...BASE_CONFIG,
|
||||
providers: {
|
||||
'managed:kimi-code': {
|
||||
type: 'kimi',
|
||||
apiKey: '',
|
||||
baseUrl: 'https://api.example/v1',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toThrow(/no credentials configured/i);
|
||||
});
|
||||
|
||||
it('allows vertexai providers without an apiKey', () => {
|
||||
const resolved = resolveRuntimeProvider({
|
||||
config: {
|
||||
|
|
@ -523,12 +514,13 @@ describe('resolveRuntimeProvider customHeaders propagation', () => {
|
|||
|
||||
describe('ProviderManager prompt cache key', () => {
|
||||
it('applies a prompt cache key to Kimi providers', () => {
|
||||
const manager = new ProviderManager({ config: BASE_CONFIG }).withPromptCacheKey(
|
||||
'session-test',
|
||||
);
|
||||
const resolved = manager.resolveProviderConfigForModel('kimi-code/kimi-for-coding');
|
||||
const manager = new ProviderManager({
|
||||
config: BASE_CONFIG,
|
||||
promptCacheKey: 'session-test',
|
||||
});
|
||||
const resolved = manager.resolveProviderConfig('kimi-code/kimi-for-coding');
|
||||
|
||||
expect(resolved?.provider).toMatchObject({
|
||||
expect(resolved.provider).toMatchObject({
|
||||
type: 'kimi',
|
||||
generationKwargs: {
|
||||
prompt_cache_key: 'session-test',
|
||||
|
|
@ -538,6 +530,7 @@ describe('ProviderManager prompt cache key', () => {
|
|||
|
||||
it('does not add generation kwargs to non-Kimi providers', () => {
|
||||
const manager = new ProviderManager({
|
||||
promptCacheKey: 'session-test',
|
||||
config: {
|
||||
defaultModel: 'gpt-alias',
|
||||
providers: {
|
||||
|
|
@ -554,24 +547,27 @@ describe('ProviderManager prompt cache key', () => {
|
|||
},
|
||||
},
|
||||
},
|
||||
}).withPromptCacheKey('session-test');
|
||||
const resolved = manager.resolveProviderConfigForModel('gpt-alias');
|
||||
});
|
||||
const resolved = manager.resolveProviderConfig('gpt-alias');
|
||||
|
||||
expect(resolved?.provider).toMatchObject({
|
||||
expect(resolved.provider).toMatchObject({
|
||||
type: 'openai',
|
||||
model: 'gpt-runtime',
|
||||
});
|
||||
expect('generationKwargs' in resolved!.provider).toBe(false);
|
||||
expect('generationKwargs' in resolved.provider).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps derived managers on the latest shared config', () => {
|
||||
const manager = new ProviderManager({ config: { providers: {} } });
|
||||
const derived = manager.withPromptCacheKey('session-test');
|
||||
it('reads the current config when constructed with a function', () => {
|
||||
let sharedConfig: KimiConfig = { providers: {} };
|
||||
const manager = new ProviderManager({
|
||||
config: () => sharedConfig,
|
||||
promptCacheKey: 'session-test',
|
||||
});
|
||||
|
||||
manager.updateConfig(BASE_CONFIG);
|
||||
sharedConfig = BASE_CONFIG;
|
||||
|
||||
const resolved = derived.resolveProviderConfigForModel(undefined);
|
||||
expect(resolved?.provider).toMatchObject({
|
||||
const resolved = manager.resolveProviderConfig('kimi-code/kimi-for-coding');
|
||||
expect(resolved.provider).toMatchObject({
|
||||
type: 'kimi',
|
||||
generationKwargs: {
|
||||
prompt_cache_key: 'session-test',
|
||||
|
|
@ -580,54 +576,90 @@ describe('ProviderManager prompt cache key', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('ProviderManager thinking level', () => {
|
||||
describe('resolveThinkingLevel', () => {
|
||||
it('normalizes requested thinking into a concrete effort', () => {
|
||||
const manager = new ProviderManager({
|
||||
config: {
|
||||
providers: {},
|
||||
expect(
|
||||
resolveThinkingLevel('on', {
|
||||
defaultThinking: false,
|
||||
thinking: { effort: 'medium', mode: 'auto' },
|
||||
},
|
||||
});
|
||||
expect(manager.resolveThinkingLevel('on')).toBe('medium');
|
||||
expect(manager.resolveThinkingLevel('off')).toBe('off');
|
||||
expect(manager.resolveThinkingLevel('low')).toBe('low');
|
||||
expect(manager.resolveThinkingLevel()).toBe('off');
|
||||
expect(manager.resolveThinkingLevel('')).toBe('off');
|
||||
expect(manager.resolveThinkingLevel(' ')).toBe('off');
|
||||
}),
|
||||
).toBe('medium');
|
||||
expect(
|
||||
resolveThinkingLevel('off', {
|
||||
defaultThinking: false,
|
||||
thinking: { effort: 'medium', mode: 'auto' },
|
||||
}),
|
||||
).toBe('off');
|
||||
expect(
|
||||
resolveThinkingLevel('low', {
|
||||
defaultThinking: false,
|
||||
thinking: { effort: 'medium', mode: 'auto' },
|
||||
}),
|
||||
).toBe('low');
|
||||
expect(
|
||||
resolveThinkingLevel(undefined, {
|
||||
defaultThinking: false,
|
||||
thinking: { effort: 'medium', mode: 'auto' },
|
||||
}),
|
||||
).toBe('off');
|
||||
expect(
|
||||
resolveThinkingLevel('', {
|
||||
defaultThinking: false,
|
||||
thinking: { effort: 'medium', mode: 'auto' },
|
||||
}),
|
||||
).toBe('off');
|
||||
expect(
|
||||
resolveThinkingLevel(' ', {
|
||||
defaultThinking: false,
|
||||
thinking: { effort: 'medium', mode: 'auto' },
|
||||
}),
|
||||
).toBe('off');
|
||||
|
||||
const managerOnByDefault = new ProviderManager({
|
||||
config: {
|
||||
providers: {},
|
||||
expect(
|
||||
resolveThinkingLevel(undefined, {
|
||||
defaultThinking: true,
|
||||
thinking: { effort: 'medium', mode: 'auto' },
|
||||
},
|
||||
});
|
||||
expect(managerOnByDefault.resolveThinkingLevel()).toBe('medium');
|
||||
expect(managerOnByDefault.resolveThinkingLevel(' ')).toBe('medium');
|
||||
}),
|
||||
).toBe('medium');
|
||||
expect(
|
||||
resolveThinkingLevel(' ', {
|
||||
defaultThinking: true,
|
||||
thinking: { effort: 'medium', mode: 'auto' },
|
||||
}),
|
||||
).toBe('medium');
|
||||
|
||||
const managerWithoutEffort = new ProviderManager({
|
||||
config: { providers: {}, defaultThinking: true, thinking: { mode: 'auto' } },
|
||||
});
|
||||
expect(managerWithoutEffort.resolveThinkingLevel('on')).toBe('high');
|
||||
expect(managerWithoutEffort.resolveThinkingLevel()).toBe('high');
|
||||
expect(
|
||||
resolveThinkingLevel('on', {
|
||||
defaultThinking: true,
|
||||
thinking: { mode: 'auto' },
|
||||
}),
|
||||
).toBe('high');
|
||||
expect(
|
||||
resolveThinkingLevel(undefined, {
|
||||
defaultThinking: true,
|
||||
thinking: { mode: 'auto' },
|
||||
}),
|
||||
).toBe('high');
|
||||
|
||||
const managerOffByDefault = new ProviderManager({
|
||||
config: { providers: {}, thinking: { mode: 'off' } },
|
||||
});
|
||||
expect(managerOffByDefault.resolveThinkingLevel()).toBe('off');
|
||||
expect(
|
||||
resolveThinkingLevel(undefined, {
|
||||
thinking: { mode: 'off' },
|
||||
}),
|
||||
).toBe('off');
|
||||
|
||||
const managerWithModeOffAndDefaultThinking = new ProviderManager({
|
||||
config: {
|
||||
providers: {},
|
||||
expect(
|
||||
resolveThinkingLevel(undefined, {
|
||||
defaultThinking: true,
|
||||
thinking: { effort: 'medium', mode: 'off' },
|
||||
},
|
||||
});
|
||||
expect(managerWithModeOffAndDefaultThinking.resolveThinkingLevel()).toBe('off');
|
||||
expect(managerWithModeOffAndDefaultThinking.resolveThinkingLevel(' ')).toBe('off');
|
||||
}),
|
||||
).toBe('off');
|
||||
expect(
|
||||
resolveThinkingLevel(' ', {
|
||||
defaultThinking: true,
|
||||
thinking: { effort: 'medium', mode: 'off' },
|
||||
}),
|
||||
).toBe('off');
|
||||
|
||||
const managerWithoutThinking = new ProviderManager({ config: { providers: {} } });
|
||||
expect(managerWithoutThinking.resolveThinkingLevel()).toBe('high');
|
||||
expect(resolveThinkingLevel(undefined, {})).toBe('high');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
type CoreAPI,
|
||||
type SDKAPI,
|
||||
} from '../../src';
|
||||
import type { OAuthTokenProviderResolver } from '../../src/providers/runtime-provider';
|
||||
import type { OAuthTokenProviderResolver } from '../../src/session/provider-manager';
|
||||
|
||||
describe('KimiCore runtime config', () => {
|
||||
let tmp: string;
|
||||
|
|
@ -86,4 +86,41 @@ custom_headers = { "X-Test" = "1" }
|
|||
'X-Test': '1',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to defaultModel when createSession receives no model option', async () => {
|
||||
tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-'));
|
||||
const homeDir = join(tmp, 'home');
|
||||
const workDir = join(tmp, 'work');
|
||||
await mkdir(homeDir, { recursive: true });
|
||||
await mkdir(workDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(homeDir, 'config.toml'),
|
||||
`default_model = "default-mock"
|
||||
|
||||
[providers.test]
|
||||
type = "kimi"
|
||||
api_key = "test-key"
|
||||
|
||||
[models."default-mock"]
|
||||
provider = "test"
|
||||
model = "default-mock"
|
||||
max_context_size = 100000
|
||||
`,
|
||||
);
|
||||
|
||||
const [coreRpc, sdkRpc] = createRPC<CoreAPI, SDKAPI>();
|
||||
const core = new KimiCore(coreRpc, { homeDir });
|
||||
const rpc = await sdkRpc({
|
||||
emitEvent: vi.fn(),
|
||||
requestApproval: vi.fn(async (): Promise<ApprovalResponse> => ({ decision: 'rejected' })),
|
||||
requestQuestion: vi.fn(async () => null),
|
||||
toolCall: vi.fn(async () => ({ output: '' })),
|
||||
});
|
||||
|
||||
const created = await rpc.createSession({ id: 'ses_runtime_default_model', workDir });
|
||||
const session = core.sessions.get(created.id);
|
||||
const mainAgent = session?.agents.get('main');
|
||||
|
||||
expect(mainAgent?.config.modelAlias).toBe('default-mock');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import type {
|
|||
import { z } from 'zod';
|
||||
|
||||
import { KimiError } from '../../src/errors';
|
||||
import { ProviderManager } from '../../src/providers/provider-manager';
|
||||
import { ProviderManager } from '../../src/session/provider-manager';
|
||||
import { McpConnectionManager, type McpServerEntry } from '../../src/mcp/connection-manager';
|
||||
import { JsonFileStore, McpOAuthService } from '../../src/mcp/oauth';
|
||||
import type { AgentEvent, SDKSessionRPC } from '../../src/rpc';
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { testKaos } from '../fixtures/test-kaos';
|
|||
import type { ProviderConfig } from '@moonshot-ai/kosong';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ProviderManager } from '../../src/providers/provider-manager';
|
||||
import { ProviderManager } from '../../src/session/provider-manager';
|
||||
import type { ResolvedAgentProfile } from '../../src/profile';
|
||||
import type { SDKSessionRPC } from '../../src/rpc';
|
||||
import { Session } from '../../src/session';
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
|
||||
import { KimiHarness } from '#/index';
|
||||
|
||||
import { resolveRuntimeProviderWithOAuth } from '../../agent-core/src/providers/runtime-provider';
|
||||
import { ProviderManager } from '../../agent-core/src/session/provider-manager';
|
||||
import { TEST_IDENTITY } from './test-identity';
|
||||
|
||||
let homeDir: string;
|
||||
|
|
@ -96,14 +96,7 @@ describe('KimiHarness.auth', () => {
|
|||
capabilities: ['thinking', 'image_in', 'video_in', 'tool_use'],
|
||||
displayName: 'Kimi for Coding',
|
||||
});
|
||||
await expect(
|
||||
resolveRuntimeProviderWithOAuth({
|
||||
config,
|
||||
resolveOAuthTokenProvider: () => ({
|
||||
getAccessToken: vi.fn().mockResolvedValue('fresh-oauth-token'),
|
||||
}),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
expect(new ProviderManager({ config }).resolveProviderConfig(config.defaultModel!)).toMatchObject({
|
||||
modelCapabilities: {
|
||||
tool_use: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -397,26 +397,6 @@ effort = "medium"
|
|||
expect(coreSessionIds(harness)).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects explicitly empty model names', async () => {
|
||||
const homeDir = await makeTempDir();
|
||||
const workDir = await makeTempDir();
|
||||
const harness = new KimiHarness({
|
||||
identity: TEST_IDENTITY,
|
||||
homeDir,
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
harness.createSession({ id: 'ses_empty_model', workDir, model: ' ' }),
|
||||
).rejects.toMatchObject({
|
||||
name: 'KimiError',
|
||||
code: 'model.config_invalid',
|
||||
} satisfies Partial<KimiError>);
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('applies initial thinking and permission runtime options', async () => {
|
||||
const homeDir = await makeTempDir();
|
||||
const workDir = await makeTempDir();
|
||||
|
|
|
|||
|
|
@ -7,11 +7,27 @@ import { afterEach, describe, expect, it } from 'vitest';
|
|||
import type { KimiConfig } from '@moonshot-ai/agent-core';
|
||||
import { createKimiDefaultHeaders, KIMI_CODE_PLATFORM } from '@moonshot-ai/kimi-code-oauth';
|
||||
|
||||
import { resolveRuntimeProvider } from '../../agent-core/src/providers/runtime-provider';
|
||||
import { ProviderManager } from '../../agent-core/src/session/provider-manager';
|
||||
import { TEST_IDENTITY } from './test-identity';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function resolveRuntimeProvider(options: {
|
||||
readonly config: KimiConfig;
|
||||
readonly model?: string;
|
||||
readonly kimiRequestHeaders?: Record<string, string>;
|
||||
}) {
|
||||
const manager = new ProviderManager({
|
||||
config: options.config,
|
||||
kimiRequestHeaders: options.kimiRequestHeaders,
|
||||
});
|
||||
const model = options.model ?? options.config.defaultModel;
|
||||
if (model === undefined) {
|
||||
throw new Error('No model selected');
|
||||
}
|
||||
return manager.resolveProviderConfig(model);
|
||||
}
|
||||
|
||||
async function makeTempDir(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'kimi-sdk-provider-identity-'));
|
||||
tempDirs.push(dir);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|||
|
||||
import { ErrorCodes, KimiError, type KimiConfig, type Logger } from '#/index';
|
||||
|
||||
import { resolveRuntimeProviderWithOAuth } from '../../agent-core/src/providers/runtime-provider';
|
||||
import { ProviderManager } from '../../agent-core/src/session/provider-manager';
|
||||
|
||||
function managedConfig(): KimiConfig {
|
||||
return {
|
||||
|
|
@ -25,6 +25,87 @@ function managedConfig(): KimiConfig {
|
|||
};
|
||||
}
|
||||
|
||||
async function resolveRuntimeProviderWithOAuth(options: {
|
||||
readonly config: KimiConfig;
|
||||
readonly resolveOAuthTokenProvider?: import('../../agent-core/src/session/provider-manager').OAuthTokenProviderResolver;
|
||||
readonly log?: Logger;
|
||||
}) {
|
||||
const manager = new ProviderManager({
|
||||
config: options.config,
|
||||
resolveOAuthTokenProvider: options.resolveOAuthTokenProvider,
|
||||
});
|
||||
const model = options.config.defaultModel;
|
||||
if (model === undefined) {
|
||||
throw new KimiError(ErrorCodes.CONFIG_INVALID, 'No model is selected.');
|
||||
}
|
||||
const { providerName, provider } = manager.resolveProviderConfig(model);
|
||||
|
||||
const providerConfig = options.config.providers[providerName];
|
||||
if (providerConfig?.oauth !== undefined && (providerConfig.apiKey ?? '').length > 0) {
|
||||
throw new KimiError(
|
||||
ErrorCodes.CONFIG_INVALID,
|
||||
`Provider "${providerName}" has both apiKey and oauth set in config.toml — they are mutually exclusive. Remove one.`,
|
||||
);
|
||||
}
|
||||
|
||||
const oauthRef = providerConfig?.oauth;
|
||||
const tokenProvider = options.resolveOAuthTokenProvider?.(providerName, oauthRef);
|
||||
|
||||
if (tokenProvider === undefined) {
|
||||
throw new KimiError(
|
||||
ErrorCodes.AUTH_LOGIN_REQUIRED,
|
||||
`OAuth provider "${providerName}" requires login before it can be used.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Replicate the old API's eager token fetch during resolution so
|
||||
// test mocks see the expected call sequence.
|
||||
try {
|
||||
await tokenProvider.getAccessToken(undefined);
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof KimiError && error.code === ErrorCodes.AUTH_LOGIN_REQUIRED)
|
||||
) {
|
||||
options.log?.warn('oauth token fetch failed', { providerName, error });
|
||||
}
|
||||
throw new KimiError(
|
||||
ErrorCodes.AUTH_LOGIN_REQUIRED,
|
||||
`OAuth provider "${providerName}" requires login before it can be used.`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
providerName,
|
||||
provider,
|
||||
resolveAuth: async (opts?: { forceRefresh?: boolean }) => {
|
||||
try {
|
||||
const apiKey = await tokenProvider.getAccessToken(
|
||||
opts?.forceRefresh ? { force: true } : undefined,
|
||||
);
|
||||
if (apiKey.trim().length === 0) {
|
||||
throw new KimiError(
|
||||
ErrorCodes.AUTH_LOGIN_REQUIRED,
|
||||
`OAuth provider "${providerName}" requires login before it can be used.`,
|
||||
);
|
||||
}
|
||||
return { apiKey };
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof KimiError && error.code === ErrorCodes.AUTH_LOGIN_REQUIRED)
|
||||
) {
|
||||
options.log?.warn('oauth token fetch failed', { providerName, error });
|
||||
}
|
||||
throw new KimiError(
|
||||
ErrorCodes.AUTH_LOGIN_REQUIRED,
|
||||
`OAuth provider "${providerName}" requires login before it can be used.`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('resolveRuntimeProviderWithOAuth', () => {
|
||||
it('returns request-scoped OAuth auth without storing the initial access token in provider config', async () => {
|
||||
const tokens = ['initial-oauth-token', 'rotated-oauth-token', 'force-refreshed-oauth-token'];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue