refactor(agent-core): make Agent constructable and consolidate provider-manager (#161)

This commit is contained in:
_Kerman 2026-05-28 20:33:10 +08:00 committed by GitHub
parent 07dd604c3c
commit 28d2b5c018
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 889 additions and 1039 deletions

View file

@ -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(

View file

@ -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 {

View file

@ -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,

View file

@ -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 {

View file

@ -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 =

View file

@ -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[] {

View file

@ -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 };
}

View file

@ -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,

View file

@ -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 {

View file

@ -1,2 +0,0 @@
export * from './provider-manager';
export * from './runtime-provider';

View file

@ -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;
}

View file

@ -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,
},
},
);
}
}
}

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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 });
}
}
}

View file

@ -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()),

View 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;
}
}

View file

@ -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,

View file

@ -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>> {

View file

@ -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: {

View file

@ -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(

View file

@ -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: [
{

View file

@ -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';

View file

@ -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>> {

View file

@ -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 () => {

View file

@ -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');
});
});

View file

@ -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');
});
});

View file

@ -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';

View file

@ -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';

View file

@ -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,
},

View file

@ -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();

View file

@ -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);

View file

@ -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'];