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 @@
-
\ No newline at end of file
+
\ 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}`;
});