From 7b041a8e032a439cec8d5d53760cba1ac1c1812b Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 7 Jul 2026 22:27:05 +0800 Subject: [PATCH] fix: enforce v2 auth readiness checks --- .../agent-core-v2/docs/di-scope-domains.puml | 2 + .../agent-core-v2/docs/di-scope-domains.svg | 2 +- packages/agent-core-v2/src/app/auth/auth.ts | 51 +++++- .../agent-core-v2/src/app/auth/authService.ts | 99 ++++++++++- .../agent-core-v2/src/app/model/modelAuth.ts | 131 +++++++++++++++ .../src/app/model/modelResolverService.ts | 159 +++--------------- packages/agent-core-v2/test/auth/auth.test.ts | 135 ++++++++++++++- packages/kap-server/test/prompts.test.ts | 18 +- 8 files changed, 442 insertions(+), 155 deletions(-) create mode 100644 packages/agent-core-v2/src/app/model/modelAuth.ts diff --git a/packages/agent-core-v2/docs/di-scope-domains.puml b/packages/agent-core-v2/docs/di-scope-domains.puml index fc5f3a3e4..3729840f5 100644 --- a/packages/agent-core-v2/docs/di-scope-domains.puml +++ b/packages/agent-core-v2/docs/di-scope-domains.puml @@ -39,6 +39,7 @@ package "App scope (process-wide)" #EAF3FB { rectangle "web\nApp\n IWebFetchService" as web #D6EAF8 rectangle "edit\nApp\n IFileEditService" as edit_app #D6EAF8 rectangle "provider\nApp\n IProviderService" as provider #D6EAF8 + rectangle "platform\nApp\n IPlatformService" as platform #D6EAF8 rectangle "flag\nApp\n IFlagService\n IFlagRegistry" as flag #D6EAF8 rectangle "config\nApp\n IConfigRegistry\n IConfigService" as config #D6EAF8 rectangle "plugin\nApp\n IPluginService" as plugin #D6EAF8 @@ -128,6 +129,7 @@ workspaceLocalConfig --> bootstrap #34495E workspaceLocalConfig --> hostFs #34495E hostFolderBrowser --> hostFs #34495E auth --> provider #34495E +auth --> platform #34495E auth --> config #34495E auth --> bootstrap #34495E auth --> telemetry #34495E diff --git a/packages/agent-core-v2/docs/di-scope-domains.svg b/packages/agent-core-v2/docs/di-scope-domains.svg index bfb2ae83e..8d08738cd 100644 --- a/packages/agent-core-v2/docs/di-scope-domains.svg +++ b/packages/agent-core-v2/docs/di-scope-domains.svg @@ -1 +1 @@ -App scope (process-wide)Session scope (per session)Agent scope (per agent)bootstrapAppIBootstrapServicelog_base · App bindingILogServicetelemetryAppITelemetryServiceeventAppIEventServicestorageAppIFileSystemStorageServiceIAppendLogStoreIAtomicDocumentStoreIAtomicTomlDocumentStorefilestoreAppIFileStoregatewayAppIRestGatewayIWSGatewaysessionLifecycleAppISessionLifecycleServicesessionExportAppISessionExportServicesessionIndexAppISessionIndexhostFsAppIHostFileSystemworkspaceRegistryAppIWorkspaceRegistryworkspaceLocalConfigAppIWorkspaceLocalConfigServicehostFolderBrowserAppIHostFolderBrowserhostEnvironmentAppIHostEnvironmenthostProcessAppIHostProcessServiceauthAppIOAuthServiceIAuthSummaryServiceIWebSearchProviderServicewebAppIWebFetchServiceeditAppIFileEditServiceproviderAppIProviderServiceflagAppIFlagServiceIFlagRegistryconfigAppIConfigRegistryIConfigServicepluginAppIPluginServicechatProviderAppIChatProviderFactorymodelAppIModelServicemodelCatalogAppIModelCatalogServiceskillCatalogAppISkillDiscoveryISkillSourceBuiltin/UserSkillSourcesessionContextSessionISessionContext (seed)sessionMetadataSessionISessionMetadatasessionActivitySessionISessionActivityagentLifecycleSessionIAgentLifecycleServiceinteractionSessionIInteractionServiceworkspaceContextSessionIWorkspaceContextworkspaceCommandSessionISessionWorkspaceCommandServicesessionLogSession bindingILogServicesessionSkillCatalogSessionISessionSkillCatalogISkillCatalogSinkWorkspace/PluginSkillSourceexternalHooksSessionISessionExternalHooksServicesessionFsSessionISessionFsServiceapprovalSessionIApprovalServicequestionSessionIQuestionServiceprocessSessionIProcessRunnerIProcessterminalAppIHostTerminalServicesessionTerminalSessionISessionTerminalServicemodelProviderSessionIModelProvider (seed)todoSessionISessionTodoServicewireRecordAgentIAgentWireRecordService (event hub)wireAgentIAgentWireServiceblobStoreAgentIAgentBlobStoreServicecontextMemoryAgentIAgentContextMemoryServicecontextProjectorAgentIAgentContextProjectorServicecontextInjectorAgentIAgentContextInjectorServicecontextSizeAgentIAgentContextSizeServicesystemReminderAgentIAgentSystemReminderServiceprofileAgentIAgentProfileServicepromptAgentIAgentPromptServicepromptLegacyAgentIAgentPromptLegacyServiceturnAgentIAgentTurnServiceloopAgentIAgentLoopServicellmRequesterAgentIAgentLLMRequesterServicetoolRegistryAgentIAgentToolRegistryServicetoolExecutorAgentIAgentToolExecutorServicetoolDedupeAgentIAgentToolDedupeServicepermissionGateAgentIAgentPermissionGatepermissionModeAgentIAgentPermissionModeServicepermissionPolicyAgentIAgentPermissionPolicyServicepermissionRulesAgentIAgentPermissionRulesServiceplanAgentIAgentPlanServicegoalAgentIAgentGoalServiceskillAgentIAgentSkillServicequestionToolsAgentIAgentQuestionToolsServiceuserToolAgentIAgentUserToolServicetaskAgentIAgentTaskServicecronAgentIAgentCronServiceswarmAgentIAgentSwarmServicemcpAgentIAgentMcpServicefullCompactionAgentIAgentFullCompactionServicemicroCompactionAgentIAgentMicroCompactionServiceexternalHooksAgentIAgentExternalHooksServiceusageAgentIAgentUsageServicerpcAgentIAgentRPCServicefileToolsAgentRead/Write/Grep/Glob toolseditAgentEditToolshellToolsAgentIAgentShellToolsServicescopeContextAgentIAgentScopeContext (seed)environmentrecordeventSinkonDidChangeConfigurationcontext_size.measuredconfig.update / tools.set_active_toolsagent.status.updated / warningturn.launchhooks.onSplicedpermission.set_modepermission.rules.add / record_approval_resultgoal.create/update/clearturn lifecycle hooksstep/usage hooksskill.activatetools.register_/unregister_user_toolhooks.onSplicedcron.add / delete / cursorswarm_mode.enter/exithooks.onErrormicro_compaction.applySessionStart/End hooksSubagentStart/Stop (observes run hooks)usage.recordcontext.splicetodo.set (main agent)full_compaction.begin/cancel/completehook.result / goal.updatedhooks.onEndedtask.started/terminatedonWillExecuteTool / onDidExecuteToolapproval hooksprompt/end hooksstop hookcompaction hooksnotification hookhooks.onResumeEndedplan_mode.enter/cancel/exitScope = node color      App (process-wide)      Session (per session)      Agent (per agent)Edgessolid: DI injection (ctor @IX)dashed: event-driven (subscribe/emit)direction: consumer ---> providerNotesGenerated from `node scripts/dep-graph.mjs` output;`_base` / seed / options deps are omitted. \ No newline at end of file +App scope (process-wide)Session scope (per session)Agent scope (per agent)bootstrapAppIBootstrapServicelog_base · App bindingILogServicetelemetryAppITelemetryServiceeventAppIEventServicestorageAppIFileSystemStorageServiceIAppendLogStoreIAtomicDocumentStoreIAtomicTomlDocumentStorefilestoreAppIFileStoregatewayAppIRestGatewayIWSGatewaysessionLifecycleAppISessionLifecycleServicesessionExportAppISessionExportServicesessionIndexAppISessionIndexhostFsAppIHostFileSystemworkspaceRegistryAppIWorkspaceRegistryworkspaceLocalConfigAppIWorkspaceLocalConfigServicehostFolderBrowserAppIHostFolderBrowserhostEnvironmentAppIHostEnvironmenthostProcessAppIHostProcessServiceauthAppIOAuthServiceIAuthSummaryServiceIWebSearchProviderServicewebAppIWebFetchServiceeditAppIFileEditServiceproviderAppIProviderServiceplatformAppIPlatformServiceflagAppIFlagServiceIFlagRegistryconfigAppIConfigRegistryIConfigServicepluginAppIPluginServicechatProviderAppIChatProviderFactorymodelAppIModelServicemodelCatalogAppIModelCatalogServiceskillCatalogAppISkillDiscoveryISkillSourceBuiltin/UserSkillSourcesessionContextSessionISessionContext (seed)sessionMetadataSessionISessionMetadatasessionActivitySessionISessionActivityagentLifecycleSessionIAgentLifecycleServiceinteractionSessionIInteractionServiceworkspaceContextSessionIWorkspaceContextworkspaceCommandSessionISessionWorkspaceCommandServicesessionLogSession bindingILogServicesessionSkillCatalogSessionISessionSkillCatalogISkillCatalogSinkWorkspace/PluginSkillSourceexternalHooksSessionISessionExternalHooksServicesessionFsSessionISessionFsServiceapprovalSessionIApprovalServicequestionSessionIQuestionServiceprocessSessionIProcessRunnerIProcessterminalAppIHostTerminalServicesessionTerminalSessionISessionTerminalServicemodelProviderSessionIModelProvider (seed)todoSessionISessionTodoServicewireRecordAgentIAgentWireRecordService (event hub)wireAgentIAgentWireServiceblobStoreAgentIAgentBlobStoreServicecontextMemoryAgentIAgentContextMemoryServicecontextProjectorAgentIAgentContextProjectorServicecontextInjectorAgentIAgentContextInjectorServicecontextSizeAgentIAgentContextSizeServicesystemReminderAgentIAgentSystemReminderServiceprofileAgentIAgentProfileServicepromptAgentIAgentPromptServicepromptLegacyAgentIAgentPromptLegacyServiceturnAgentIAgentTurnServiceloopAgentIAgentLoopServicellmRequesterAgentIAgentLLMRequesterServicetoolRegistryAgentIAgentToolRegistryServicetoolExecutorAgentIAgentToolExecutorServicetoolDedupeAgentIAgentToolDedupeServicepermissionGateAgentIAgentPermissionGatepermissionModeAgentIAgentPermissionModeServicepermissionPolicyAgentIAgentPermissionPolicyServicepermissionRulesAgentIAgentPermissionRulesServiceplanAgentIAgentPlanServicegoalAgentIAgentGoalServiceskillAgentIAgentSkillServicequestionToolsAgentIAgentQuestionToolsServiceuserToolAgentIAgentUserToolServicetaskAgentIAgentTaskServicecronAgentIAgentCronServiceswarmAgentIAgentSwarmServicemcpAgentIAgentMcpServicefullCompactionAgentIAgentFullCompactionServicemicroCompactionAgentIAgentMicroCompactionServiceexternalHooksAgentIAgentExternalHooksServiceusageAgentIAgentUsageServicerpcAgentIAgentRPCServicefileToolsAgentRead/Write/Grep/Glob toolseditAgentEditToolshellToolsAgentIAgentShellToolsServicescopeContextAgentIAgentScopeContext (seed)environmentrecordeventSinkonDidChangeConfigurationcontext_size.measuredconfig.update / tools.set_active_toolsagent.status.updated / warningturn.launchhooks.onSplicedpermission.set_modepermission.rules.add / record_approval_resultgoal.create/update/clearturn lifecycle hooksstep/usage hooksskill.activatetools.register_/unregister_user_toolhooks.onSplicedcron.add / delete / cursorswarm_mode.enter/exithooks.onErrormicro_compaction.applySessionStart/End hooksSubagentStart/Stop (observes run hooks)usage.recordcontext.splicetodo.set (main agent)full_compaction.begin/cancel/completehook.result / goal.updatedhooks.onEndedtask.started/terminatedonWillExecuteTool / onDidExecuteToolapproval hooksprompt/end hooksstop hookcompaction hooksnotification hookhooks.onResumeEndedplan_mode.enter/cancel/exitScope = node color      App (process-wide)      Session (per session)      Agent (per agent)Edgessolid: DI injection (ctor @IX)dashed: event-driven (subscribe/emit)direction: consumer ---> providerNotesGenerated from `node scripts/dep-graph.mjs` output;`_base` / seed / options deps are omitted. \ No newline at end of file diff --git a/packages/agent-core-v2/src/app/auth/auth.ts b/packages/agent-core-v2/src/app/auth/auth.ts index 80c5ed9e8..09e3017c7 100644 --- a/packages/agent-core-v2/src/app/auth/auth.ts +++ b/packages/agent-core-v2/src/app/auth/auth.ts @@ -26,9 +26,12 @@ import type { } from '@moonshot-ai/protocol'; import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { KimiError } from '#/_base/errors/errors'; import type { OAuthRef } from '#/app/provider/provider'; +import { AuthErrors } from './errors'; + export interface AuthStatus { readonly loggedIn: boolean; readonly provider?: string; @@ -69,8 +72,54 @@ export interface IAuthSummaryService { readonly _serviceBrand: undefined; summarize(): Promise; - ensureReady(): Promise; + ensureReady(modelOverride?: string): Promise; } export const IAuthSummaryService: ServiceIdentifier = createDecorator('authSummaryService'); + +export class AuthProvisioningRequiredError extends KimiError { + constructor() { + super( + AuthErrors.codes.AUTH_PROVISIONING_REQUIRED, + 'no provider configured; complete onboarding via /login or the providers endpoint', + { name: 'AuthProvisioningRequiredError' }, + ); + } +} + +export class AuthTokenMissingError extends KimiError { + readonly providerId: string; + + constructor(providerId: string) { + super( + AuthErrors.codes.AUTH_TOKEN_MISSING, + `provider ${providerId} has no credential configured`, + { details: { provider_id: providerId }, name: 'AuthTokenMissingError' }, + ); + this.providerId = providerId; + } +} + +export class AuthModelNotResolvedError extends KimiError { + readonly modelId: string | undefined; + readonly providerId: string | undefined; + + constructor(modelId: string | undefined, providerId?: string) { + const details: Record = {}; + if (modelId !== undefined) details['model_id'] = modelId; + if (providerId !== undefined) details['provider_id'] = providerId; + super( + AuthErrors.codes.AUTH_MODEL_NOT_RESOLVED, + modelId === undefined + ? 'no default model configured' + : `model ${modelId} does not resolve to a configured provider`, + { + details: Object.keys(details).length === 0 ? undefined : details, + name: 'AuthModelNotResolvedError', + }, + ); + this.modelId = modelId; + this.providerId = providerId; + } +} diff --git a/packages/agent-core-v2/src/app/auth/authService.ts b/packages/agent-core-v2/src/app/auth/authService.ts index ff64e9215..75f213513 100644 --- a/packages/agent-core-v2/src/app/auth/authService.ts +++ b/packages/agent-core-v2/src/app/auth/authService.ts @@ -6,7 +6,7 @@ * writes provider configuration through `provider`, refreshes the managed * OAuth provider's server-side model configuration through `config`, publishes * model-catalog changes through `event`, reports through `telemetry`, - * logs through `log`, and delegates + * logs through `log`, resolves shared auth through `platform`, and delegates * the device-code protocol, token storage, and token refresh to `IOAuthToolkit` * (provided by `OAuthToolkitService` over `@moonshot-ai/kimi-code-oauth`, * which locates token storage through `bootstrap`). Bound at App scope. @@ -47,11 +47,32 @@ import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import { IEventService } from '#/app/event/event'; import { ILogService } from '#/_base/log/log'; +import { + deriveProviderId, + effectiveModelConfig, + nonEmpty, + resolveModelAuthMaterial, +} from '#/app/model/modelAuth'; import { type ModelAlias, MODELS_SECTION } from '#/app/model/model'; -import { IProviderService, type OAuthRef, type ProviderConfig, type ProvidersChangedEvent, PROVIDERS_SECTION } from '#/app/provider/provider'; +import { IPlatformService } from '#/app/platform/platform'; +import { + IProviderService, + type OAuthRef, + type ProviderConfig, + type ProvidersChangedEvent, + PROVIDERS_SECTION, +} from '#/app/provider/provider'; import { ITelemetryService } from '#/app/telemetry/telemetry'; -import { type AuthStatus, IAuthSummaryService, IOAuthService, IOAuthToolkit } from './auth'; +import { + AuthModelNotResolvedError, + AuthProvisioningRequiredError, + AuthTokenMissingError, + type AuthStatus, + IAuthSummaryService, + IOAuthService, + IOAuthToolkit, +} from './auth'; const TERMINAL_RETENTION_MS = 5 * 60 * 1000; const DEFAULT_DEVICE_EXPIRES_IN_SEC = 15 * 60; @@ -89,7 +110,9 @@ export class OAuthService extends Disposable implements IOAuthService { @IEventService private readonly events: IEventService, ) { super(); - this._register(providerService.onDidChangeProviders((event) => this.invalidateFlows(event))); + this._register(providerService.onDidChangeProviders((event) => { + this.invalidateFlows(event); + })); } async startLogin(provider = KIMI_CODE_PROVIDER_NAME): Promise { @@ -299,10 +322,10 @@ export class OAuthService extends Disposable implements IOAuthService { removed, }); } - } catch (err) { + } catch (error) { failed.push({ provider: KIMI_CODE_PROVIDER_NAME, - reason: err instanceof Error ? err.message : String(err), + reason: error instanceof Error ? error.message : String(error), }); } @@ -505,6 +528,8 @@ export class AuthSummaryService implements IAuthSummaryService { constructor( @IProviderService private readonly providerService: IProviderService, + @IConfigService private readonly config: IConfigService, + @IPlatformService private readonly platforms: IPlatformService, @IOAuthService private readonly oauth: IOAuthService, @ILogService private readonly log: ILogService, ) {} @@ -532,8 +557,49 @@ export class AuthSummaryService implements IAuthSummaryService { return statuses; } - ensureReady(): Promise { - return Promise.resolve(); + async ensureReady(modelOverride?: string): Promise { + await this.config.reload(); + const providers = this.providerService.list(); + const models = this.config.get | undefined>(MODELS_SECTION) ?? {}; + const modelId = modelOverride ?? this.config.get(DEFAULT_MODEL_SECTION); + const configured = modelId === undefined || modelId === '' ? undefined : models[modelId]; + if (Object.keys(providers).length === 0 && !isProviderlessModel(configured)) { + throw new AuthProvisioningRequiredError(); + } + if (modelId === undefined || modelId === '') { + throw new AuthModelNotResolvedError(undefined); + } + if (configured === undefined) { + throw new AuthModelNotResolvedError(modelId); + } + + const model = effectiveModelConfig(configured); + const providerId = model.providerId ?? model.provider; + const provider = providerId === undefined ? undefined : this.providerService.get(providerId); + if (providerId !== undefined && provider === undefined) { + throw new AuthModelNotResolvedError(modelId, providerId); + } + + const providerName = providerId ?? providerNameFromFlatModel(model); + if (providerName === undefined) { + throw new AuthModelNotResolvedError(modelId); + } + + const auth = resolveModelAuthMaterial({ + modelId, + model, + provider, + providerName, + getPlatform: (platformId) => this.platforms.get(platformId), + }); + if (auth.apiKey !== undefined) return; + if (auth.oauth !== undefined) { + const providerKey = auth.oauthProviderKey ?? providerName; + const token = await this.oauth.getCachedAccessToken(providerKey, auth.oauth); + if (nonEmpty(token) !== undefined) return; + throw new AuthTokenMissingError(providerKey); + } + throw new AuthTokenMissingError(providerName); } } @@ -545,6 +611,21 @@ function classifyFailure(err: unknown): OAuthFlowStatus { return 'denied'; } +function isProviderlessModel(model: ModelAlias | undefined): boolean { + if (model === undefined) return false; + const effective = effectiveModelConfig(model); + return ( + effective.providerId === undefined && + effective.provider === undefined && + providerNameFromFlatModel(effective) !== undefined + ); +} + +function providerNameFromFlatModel(model: ModelAlias): string | undefined { + const baseUrl = nonEmpty(model.baseUrl); + return baseUrl === undefined ? undefined : deriveProviderId(baseUrl); +} + /** Structural view of a managed-config model alias (the fields the refresh reads/writes). */ interface ManagedModel { readonly provider: string; @@ -639,7 +720,7 @@ function providerModelSnapshot( model: { ...model, capabilities: - model.capabilities === undefined ? undefined : [...model.capabilities].sort(), + model.capabilities === undefined ? undefined : model.capabilities.toSorted(), }, }); } diff --git a/packages/agent-core-v2/src/app/model/modelAuth.ts b/packages/agent-core-v2/src/app/model/modelAuth.ts new file mode 100644 index 000000000..2b8fdb475 --- /dev/null +++ b/packages/agent-core-v2/src/app/model/modelAuth.ts @@ -0,0 +1,131 @@ +/** + * `model` domain (L2) — shared auth-material resolution. + * + * Resolves Model / Provider / Platform credential precedence for runtime + * model resolution and auth-readiness probes. Pure computation; callers + * supply the Platform lookup so this file stays outside the service graph. + */ + +import { ErrorCodes, KimiError } from '#/errors'; +import { type PlatformConfig, UNKNOWN_PLATFORM_KEY } from '#/app/platform/platform'; +import type { OAuthRef, ProviderConfig } from '#/app/provider/provider'; +import type { Protocol } from '#/app/protocol/protocol'; + +import type { ModelConfig } from './model'; + +export interface ResolvedModelAuthMaterial { + readonly apiKey?: string; + readonly oauth?: OAuthRef; + readonly oauthProviderKey?: string; +} + +export function resolveModelAuthMaterial(args: { + readonly modelId: string; + readonly model: ModelConfig; + readonly provider: ProviderConfig | undefined; + readonly providerName: string; + readonly getPlatform: (platformId: string) => PlatformConfig | undefined; +}): ResolvedModelAuthMaterial { + const modelApiKey = nonEmpty(args.model.apiKey); + if (modelApiKey !== undefined && args.model.oauth !== undefined) { + throw authConflictError('Model', args.modelId); + } + if (modelApiKey !== undefined) return { apiKey: modelApiKey }; + if (args.model.oauth !== undefined) { + return { + oauth: args.model.oauth, + oauthProviderKey: args.model.providerId ?? args.model.provider, + }; + } + + const platformId = args.provider?.platformId; + if (platformId !== undefined && platformId !== UNKNOWN_PLATFORM_KEY) { + const platform = args.getPlatform(platformId); + const authType = args.provider?.type ?? args.model.protocol; + const platformApiKey = + nonEmpty(platform?.auth?.apiKey) ?? + providerApiKeyEnvFallback(authType, platform?.auth?.env); + if (platformApiKey !== undefined && platform?.auth?.oauth !== undefined) { + throw authConflictError('Platform', platformId); + } + if (platformApiKey !== undefined) return { apiKey: platformApiKey }; + if (platform?.auth?.oauth !== undefined) { + return { oauth: platform.auth.oauth, oauthProviderKey: platformId }; + } + } + + const providerApiKey = + nonEmpty(args.provider?.apiKey) ?? + providerApiKeyEnvFallback(args.provider?.type ?? args.model.protocol, args.provider?.env); + if (providerApiKey !== undefined && args.provider?.oauth !== undefined) { + throw authConflictError('Provider', args.providerName); + } + if (providerApiKey !== undefined) return { apiKey: providerApiKey }; + if (args.provider?.oauth !== undefined) { + return { + oauth: args.provider.oauth, + oauthProviderKey: args.model.providerId ?? args.model.provider, + }; + } + return {}; +} + +export function effectiveModelConfig(model: ModelConfig): ModelConfig { + const { overrides, ...base } = model; + if (overrides === undefined) return model; + const effective: ModelConfig = { ...base, ...overrides }; + if ( + overrides.supportEfforts !== undefined && + overrides.defaultEffort === undefined && + effective.defaultEffort !== undefined && + !overrides.supportEfforts.includes(effective.defaultEffort) + ) { + delete effective.defaultEffort; + } + return effective; +} + +export function deriveProviderId(baseUrl: string): string { + try { + const url = new URL(baseUrl); + return url.host; + } catch { + return baseUrl; + } +} + +export function nonEmpty(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; +} + +function providerApiKeyEnvFallback( + protocol: Protocol | undefined, + env: Record | undefined, +): string | undefined { + if (protocol === undefined) return undefined; + switch (protocol) { + case 'anthropic': + return nonEmpty(env?.['ANTHROPIC_API_KEY']); + case 'openai': + case 'openai_responses': + return nonEmpty(env?.['OPENAI_API_KEY']); + case 'kimi': + return nonEmpty(env?.['KIMI_API_KEY']); + case 'google-genai': + return nonEmpty(env?.['GOOGLE_API_KEY']); + case 'vertexai': + return nonEmpty(env?.['VERTEXAI_API_KEY']) ?? nonEmpty(env?.['GOOGLE_API_KEY']); + default: { + const exhaustive: never = protocol; + return exhaustive; + } + } +} + +function authConflictError(kind: string, name: string): KimiError { + return new KimiError( + ErrorCodes.CONFIG_INVALID, + `${kind} "${name}" has both apiKey and oauth set in config.toml - they are mutually exclusive. Remove one.`, + ); +} diff --git a/packages/agent-core-v2/src/app/model/modelResolverService.ts b/packages/agent-core-v2/src/app/model/modelResolverService.ts index 0894e3fdf..f35d81099 100644 --- a/packages/agent-core-v2/src/app/model/modelResolverService.ts +++ b/packages/agent-core-v2/src/app/model/modelResolverService.ts @@ -25,14 +25,21 @@ import { type ModelCapability } from '#/app/llmProtocol/capability'; import { type ProviderRequestAuth } from '#/app/llmProtocol/request'; import { type ThinkingEffort } from '#/app/llmProtocol/thinkingEffort'; import { getModelCapability } from '#/app/llmProtocol/providers/providers'; -import { IPlatformService, UNKNOWN_PLATFORM_KEY } from '#/app/platform/platform'; -import type { OAuthRef, ProviderConfig } from '#/app/provider/provider'; +import { IPlatformService } from '#/app/platform/platform'; +import type { ProviderConfig } from '#/app/provider/provider'; import { IProviderService } from '#/app/provider/provider'; import { IProtocolAdapterRegistry, type Protocol, type ProtocolProviderOptions } from '#/app/protocol/protocol'; import { type ProtocolAdapterRegistry } from '#/app/protocol/protocolAdapterRegistry'; import type { ModelConfig } from './model'; import { IModelService } from './model'; +import { + deriveProviderId, + effectiveModelConfig, + nonEmpty, + resolveModelAuthMaterial, + type ResolvedModelAuthMaterial, +} from './modelAuth'; import type { AuthProvider, Model } from './modelInstance'; import { IModelResolver } from './modelResolver'; import { ModelImpl, StaticAuthProvider } from './modelImpl'; @@ -45,12 +52,6 @@ interface ThinkingSection { readonly effort?: string; } -interface ResolvedAuthMaterial { - readonly apiKey?: string; - readonly oauth?: OAuthRef; - readonly oauthProviderKey?: string; -} - type MutableProtocolProviderOptions = { -readonly [K in keyof ProtocolProviderOptions]: ProtocolProviderOptions[K]; }; @@ -81,7 +82,13 @@ export class ModelResolverService extends Disposable implements IModelResolver { const model = effectiveModelConfig(configuredModel); const { providerConfig, providerName, resolvedBaseUrl: rawBaseUrl } = this.resolveProviderContext(id, model); - const auth = this.resolveAuth(id, model, providerConfig, providerName); + const auth = resolveModelAuthMaterial({ + modelId: id, + model, + provider: providerConfig, + providerName, + getPlatform: (platformId) => this.platforms.get(platformId), + }); const authProvider = this.buildAuthProvider(providerName, auth); const protocol = this.resolveProtocol(id, model, providerConfig); @@ -215,7 +222,7 @@ export class ModelResolverService extends Disposable implements IModelResolver { nonEmpty(model.baseUrl) ?? nonEmpty(providerConfig.baseUrl) ?? providerBaseUrlEnvFallback( - model.protocol ?? (providerConfig.type as Protocol | undefined), + model.protocol ?? providerConfig.type, providerConfig.env, ); if (baseUrl === undefined || baseUrl.length === 0) { @@ -249,7 +256,7 @@ export class ModelResolverService extends Disposable implements IModelResolver { model: ModelConfig, provider: ProviderConfig | undefined, ): Protocol { - const explicit = model.protocol ?? (provider?.type as Protocol | undefined); + const explicit = model.protocol ?? provider?.type; if (explicit === undefined) { throw new KimiError( ErrorCodes.CONFIG_INVALID, @@ -259,66 +266,7 @@ export class ModelResolverService extends Disposable implements IModelResolver { return explicit; } - /** - * Resolve raw auth material for the Model. Precedence: - * 1. Model-inline `apiKey` / `oauth` (flat-case override). - * 2. Provider.platformId → Platform.auth (structured shared auth). - * 3. Provider-legacy `apiKey` / `oauth` (pre-migration configs). - * - * An empty / whitespace `apiKey` is treated as absent (matching production's - * `nonEmptyString`), so a provider that carries both `api_key = ""` and an - * `oauth` block correctly falls through to OAuth instead of producing an - * empty bearer token. - */ - private resolveAuth( - id: string, - model: ModelConfig, - provider: ProviderConfig | undefined, - providerName: string, - ): ResolvedAuthMaterial { - const modelApiKey = nonEmpty(model.apiKey); - if (modelApiKey !== undefined && model.oauth !== undefined) { - throw authConflictError('Model', id); - } - if (modelApiKey !== undefined) return { apiKey: modelApiKey }; - if (model.oauth !== undefined) { - return { oauth: model.oauth, oauthProviderKey: model.providerId ?? model.provider }; - } - - const platformId = provider?.platformId; - if (platformId !== undefined && platformId !== UNKNOWN_PLATFORM_KEY) { - const platform = this.platforms.get(platformId); - const authType = provider?.type ?? model.protocol; - const platformApiKey = - nonEmpty(platform?.auth?.apiKey) ?? - providerApiKeyEnvFallback(authType, platform?.auth?.env); - if (platformApiKey !== undefined && platform?.auth?.oauth !== undefined) { - throw authConflictError('Platform', platformId); - } - if (platformApiKey !== undefined) return { apiKey: platformApiKey }; - if (platform?.auth?.oauth !== undefined) { - return { - oauth: platform.auth.oauth, - oauthProviderKey: platformId, - }; - } - } - - // Legacy: provider carried auth directly (pre-Phase 4 migration). - const providerApiKey = - nonEmpty(provider?.apiKey) ?? - providerApiKeyEnvFallback(provider?.type ?? model.protocol, provider?.env); - if (providerApiKey !== undefined && provider?.oauth !== undefined) { - throw authConflictError('Provider', providerName); - } - if (providerApiKey !== undefined) return { apiKey: providerApiKey }; - if (provider?.oauth !== undefined) { - return { oauth: provider.oauth, oauthProviderKey: model.providerId ?? model.provider }; - } - return {}; - } - - private buildAuthProvider(providerName: string, auth: ResolvedAuthMaterial): AuthProvider { + private buildAuthProvider(providerName: string, auth: ResolvedModelAuthMaterial): AuthProvider { if (auth.apiKey !== undefined) { return new StaticAuthProvider(auth.apiKey); } @@ -366,13 +314,6 @@ function resolveModelCapabilities( }; } -/** Treat an empty / whitespace string as absent (matches production's - * `nonEmptyString` used by the session resolver). */ -function nonEmpty(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; -} - /** Strip a trailing `/v1` (with optional trailing slash) from a baseUrl, matching * production v1's anthropic-transport normalization so the Anthropic SDK's * `/v1/messages` suffix does not produce a double `/v1/v1/messages`. */ @@ -380,28 +321,6 @@ function stripTrailingV1(baseUrl: string): string { return baseUrl.replace(/\/v1\/?$/, ''); } -function effectiveModelConfig(model: ModelConfig): ModelConfig { - const { overrides, ...base } = model; - if (overrides === undefined) return model; - const effective: ModelConfig = { ...base, ...overrides }; - if ( - overrides.supportEfforts !== undefined && - overrides.defaultEffort === undefined && - effective.defaultEffort !== undefined && - !overrides.supportEfforts.includes(effective.defaultEffort) - ) { - delete effective.defaultEffort; - } - return effective; -} - -function authConflictError(kind: string, name: string): KimiError { - return new KimiError( - ErrorCodes.CONFIG_INVALID, - `${kind} "${name}" has both apiKey and oauth set in config.toml - they are mutually exclusive. Remove one.`, - ); -} - function buildProtocolProviderOptions( model: ModelConfig, protocol: Protocol, @@ -470,30 +389,6 @@ function providerBaseUrlEnvFallback( } } -function providerApiKeyEnvFallback( - protocol: Protocol | undefined, - env: Record | undefined, -): string | undefined { - if (protocol === undefined) return undefined; - switch (protocol) { - case 'anthropic': - return envValue(env, 'ANTHROPIC_API_KEY'); - case 'openai': - case 'openai_responses': - return envValue(env, 'OPENAI_API_KEY'); - case 'kimi': - return envValue(env, 'KIMI_API_KEY'); - case 'google-genai': - return envValue(env, 'GOOGLE_API_KEY'); - case 'vertexai': - return envValue(env, 'VERTEXAI_API_KEY') ?? envValue(env, 'GOOGLE_API_KEY'); - default: { - const exhaustive: never = protocol; - return exhaustive; - } - } -} - function vertexAIProject(provider: ProviderConfig | undefined): string | undefined { return envValue(provider?.env, 'GOOGLE_CLOUD_PROJECT'); } @@ -521,22 +416,6 @@ function locationFromVertexAIBaseUrl(baseUrl: string | undefined): string | unde } } -/** - * Derive a synthetic Provider id from a Model's flat baseUrl. Uses only the - * origin (host, optionally port) per Phase 2 decision "a=origin only" — two - * flat Models hitting the same host converge on one Provider identity. - */ -function deriveProviderId(baseUrl: string): string { - try { - const url = new URL(baseUrl); - return url.host; - } catch { - // Fall back to the raw string; malformed URLs will fail downstream at - // request time with a clearer error. - return baseUrl; - } -} - registerScopedService( LifecycleScope.App, IModelResolver, diff --git a/packages/agent-core-v2/test/auth/auth.test.ts b/packages/agent-core-v2/test/auth/auth.test.ts index 9c896df1e..2ff22ad57 100644 --- a/packages/agent-core-v2/test/auth/auth.test.ts +++ b/packages/agent-core-v2/test/auth/auth.test.ts @@ -20,7 +20,8 @@ import { AuthLegacyService } from '#/app/authLegacy/authLegacyService'; import { IConfigService } from '#/app/config/config'; import { type DomainEvent, IEventService } from '#/app/event/event'; import { ILogService } from '#/_base/log/log'; -import type { ModelAlias } from '#/app/model/model'; +import { MODELS_SECTION, type ModelAlias } from '#/app/model/model'; +import { IPlatformService, type PlatformConfig } from '#/app/platform/platform'; import { IProviderService, type ProviderConfig, type ProvidersChangedEvent } from '#/app/provider/provider'; import { registerBootstrapServices } from '../bootstrap/stubs'; @@ -695,7 +696,12 @@ describe('AuthSummaryService', () => { let disposables: DisposableStore; let ix: TestInstantiationService; let providers: Record; + let platforms: Record; + let models: Record; + let defaultModel: string | undefined; let oauthStatus: ReturnType; + let getCachedAccessToken: ReturnType; + let reload: ReturnType; beforeEach(() => { disposables = new DisposableStore(); @@ -706,14 +712,48 @@ describe('AuthSummaryService', () => { }, [NON_OAUTH_PROVIDER]: { type: 'openai', apiKey: 'sk-test' }, }; + platforms = {}; + models = { + kimi: { + provider: OAUTH_PROVIDER, + model: 'kimi-k2', + protocol: 'kimi', + maxContextSize: 128000, + }, + openai: { + provider: NON_OAUTH_PROVIDER, + model: 'gpt-4.1', + protocol: 'openai', + maxContextSize: 128000, + }, + }; + defaultModel = 'kimi'; oauthStatus = vi.fn(); + getCachedAccessToken = vi.fn().mockResolvedValue(undefined); + reload = vi.fn().mockResolvedValue(undefined); ix = createServices(disposables, { additionalServices: (reg) => { reg.definePartialInstance(IProviderService, { + get: ((name: string) => providers[name]) as IProviderService['get'], list: (() => providers) as IProviderService['list'], }); + reg.definePartialInstance(IPlatformService, { + get: ((name: string) => platforms[name]) as IPlatformService['get'], + list: (() => platforms) as IPlatformService['list'], + }); + reg.definePartialInstance(IConfigService, { + get: ((domain: string) => { + if (domain === MODELS_SECTION) return models; + if (domain === 'defaultModel') return defaultModel; + return undefined; + }) as IConfigService['get'], + reload: reload as unknown as IConfigService['reload'], + onDidChangeConfiguration: (() => ({ dispose: () => { } })) as IConfigService['onDidChangeConfiguration'], + onDidSectionChange: (() => ({ dispose: () => { } })) as IConfigService['onDidSectionChange'], + }); reg.definePartialInstance(IOAuthService, { status: oauthStatus as unknown as IOAuthService['status'], + getCachedAccessToken: getCachedAccessToken as unknown as IOAuthService['getCachedAccessToken'], }); reg.definePartialInstance(ILogService, { info: vi.fn(), @@ -755,10 +795,99 @@ describe('AuthSummaryService', () => { expect(oauthStatus).toHaveBeenCalledWith(OTHER_OAUTH); }); - it('ensureReady leaves model and credential readiness to the runtime resolver', async () => { + it('ensureReady throws provisioning_required when provider-backed config has no providers', async () => { providers = {}; - await expect(createSummary().ensureReady()).resolves.toBeUndefined(); + await expect(createSummary().ensureReady()).rejects.toMatchObject({ + code: 'auth.provisioning_required', + details: undefined, + }); expect(oauthStatus).not.toHaveBeenCalled(); + expect(getCachedAccessToken).not.toHaveBeenCalled(); + }); + + it('ensureReady throws model_not_resolved when the default model alias is missing', async () => { + defaultModel = 'missing'; + + await expect(createSummary().ensureReady()).rejects.toMatchObject({ + code: 'auth.model_not_resolved', + details: { model_id: 'missing' }, + }); + expect(getCachedAccessToken).not.toHaveBeenCalled(); + }); + + it('ensureReady throws model_not_resolved when the model provider is missing', async () => { + delete providers[OAUTH_PROVIDER]; + + await expect(createSummary().ensureReady()).rejects.toMatchObject({ + code: 'auth.model_not_resolved', + details: { model_id: 'kimi', provider_id: OAUTH_PROVIDER }, + }); + expect(getCachedAccessToken).not.toHaveBeenCalled(); + }); + + it('ensureReady throws token_missing when an oauth provider has no cached token', async () => { + await expect(createSummary().ensureReady()).rejects.toMatchObject({ + code: 'auth.token_missing', + details: { provider_id: OAUTH_PROVIDER }, + }); + expect(getCachedAccessToken).toHaveBeenCalledWith(OAUTH_PROVIDER, { + storage: 'file', + key: 'oauth/kimi-code', + }); + }); + + it('ensureReady propagates cached token read failures', async () => { + getCachedAccessToken.mockRejectedValue(new Error('token store unreadable')); + + await expect(createSummary().ensureReady()).rejects.toThrow('token store unreadable'); + expect(getCachedAccessToken).toHaveBeenCalledWith(OAUTH_PROVIDER, { + storage: 'file', + key: 'oauth/kimi-code', + }); + }); + + it('ensureReady accepts provider api keys', async () => { + await expect(createSummary().ensureReady('openai')).resolves.toBeUndefined(); + expect(getCachedAccessToken).not.toHaveBeenCalled(); + }); + + it('ensureReady accepts cached oauth tokens', async () => { + getCachedAccessToken.mockResolvedValue('access-token'); + await expect(createSummary().ensureReady('kimi')).resolves.toBeUndefined(); + expect(getCachedAccessToken).toHaveBeenCalledWith(OAUTH_PROVIDER, { + storage: 'file', + key: 'oauth/kimi-code', + }); + }); + + it('ensureReady accepts structured platform credentials', async () => { + providers = { + moonshot: { + type: 'kimi', + platformId: 'shared-kimi', + baseUrl: 'https://api.example.test/v1', + }, + }; + platforms = { + 'shared-kimi': { + auth: { oauth: { storage: 'file', key: 'oauth/shared-kimi' } }, + }, + }; + models = { + kimi: { + providerId: 'moonshot', + name: 'kimi-k2', + protocol: 'kimi', + maxContextSize: 128000, + }, + }; + getCachedAccessToken.mockResolvedValue('access-token'); + + await expect(createSummary().ensureReady()).resolves.toBeUndefined(); + expect(getCachedAccessToken).toHaveBeenCalledWith('shared-kimi', { + storage: 'file', + key: 'oauth/shared-kimi', + }); }); }); diff --git a/packages/kap-server/test/prompts.test.ts b/packages/kap-server/test/prompts.test.ts index c1d3a5703..0a6eb1af4 100644 --- a/packages/kap-server/test/prompts.test.ts +++ b/packages/kap-server/test/prompts.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -27,6 +27,21 @@ interface PromptItemWire { created_at: string; } +const PROMPT_TOML = [ + 'default_model = "stub"', + '', + '[providers.stub]', + 'type = "openai"', + 'base_url = "http://127.0.0.1:9999"', + 'api_key = "stub"', + '', + '[models.stub]', + 'provider = "stub"', + 'model = "stub"', + 'max_context_size = 1000', + '', +].join('\n'); + describe('server-v2 /api/v1 prompts', () => { let server: RunningServer | undefined; let home: string | undefined; @@ -34,6 +49,7 @@ describe('server-v2 /api/v1 prompts', () => { beforeEach(async () => { home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-prompts-')); + await writeFile(join(home, 'config.toml'), PROMPT_TOML, 'utf-8'); server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); base = `http://127.0.0.1:${server.port}`; });