mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-22 15:16:07 +00:00
fix: enforce v2 auth readiness checks
This commit is contained in:
parent
07f3f1cc36
commit
7b041a8e03
8 changed files with 442 additions and 155 deletions
|
|
@ -39,6 +39,7 @@ package "App scope (process-wide)" #EAF3FB {
|
|||
rectangle "<b>web</b>\n<size:9><i>App</i></size>\n IWebFetchService" as web #D6EAF8
|
||||
rectangle "<b>edit</b>\n<size:9><i>App</i></size>\n IFileEditService" as edit_app #D6EAF8
|
||||
rectangle "<b>provider</b>\n<size:9><i>App</i></size>\n IProviderService" as provider #D6EAF8
|
||||
rectangle "<b>platform</b>\n<size:9><i>App</i></size>\n IPlatformService" as platform #D6EAF8
|
||||
rectangle "<b>flag</b>\n<size:9><i>App</i></size>\n IFlagService\n IFlagRegistry" as flag #D6EAF8
|
||||
rectangle "<b>config</b>\n<size:9><i>App</i></size>\n IConfigRegistry\n IConfigService" as config #D6EAF8
|
||||
rectangle "<b>plugin</b>\n<size:9><i>App</i></size>\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
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 266 KiB After Width: | Height: | Size: 267 KiB |
|
|
@ -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<readonly AuthStatus[]>;
|
||||
ensureReady(): Promise<void>;
|
||||
ensureReady(modelOverride?: string): Promise<void>;
|
||||
}
|
||||
|
||||
export const IAuthSummaryService: ServiceIdentifier<IAuthSummaryService> =
|
||||
createDecorator<IAuthSummaryService>('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<string, unknown> = {};
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<OAuthFlowStart> {
|
||||
|
|
@ -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<void> {
|
||||
return Promise.resolve();
|
||||
async ensureReady(modelOverride?: string): Promise<void> {
|
||||
await this.config.reload();
|
||||
const providers = this.providerService.list();
|
||||
const models = this.config.get<Record<string, ModelAlias> | undefined>(MODELS_SECTION) ?? {};
|
||||
const modelId = modelOverride ?? this.config.get<string | undefined>(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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
131
packages/agent-core-v2/src/app/model/modelAuth.ts
Normal file
131
packages/agent-core-v2/src/app/model/modelAuth.ts
Normal file
|
|
@ -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<string, string> | 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.`,
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string, string> | undefined,
|
||||
): string | undefined {
|
||||
if (protocol === undefined) return undefined;
|
||||
switch (protocol) {
|
||||
case 'anthropic':
|
||||
return envValue(env, 'ANTHROPIC_API_KEY');
|
||||
case 'openai':
|
||||
case 'openai_responses':
|
||||
return envValue(env, 'OPENAI_API_KEY');
|
||||
case 'kimi':
|
||||
return envValue(env, 'KIMI_API_KEY');
|
||||
case 'google-genai':
|
||||
return envValue(env, 'GOOGLE_API_KEY');
|
||||
case 'vertexai':
|
||||
return envValue(env, 'VERTEXAI_API_KEY') ?? envValue(env, 'GOOGLE_API_KEY');
|
||||
default: {
|
||||
const exhaustive: never = protocol;
|
||||
return exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function vertexAIProject(provider: ProviderConfig | undefined): string | undefined {
|
||||
return envValue(provider?.env, 'GOOGLE_CLOUD_PROJECT');
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string, ProviderConfig>;
|
||||
let platforms: Record<string, PlatformConfig>;
|
||||
let models: Record<string, ModelAlias>;
|
||||
let defaultModel: string | undefined;
|
||||
let oauthStatus: ReturnType<typeof vi.fn>;
|
||||
let getCachedAccessToken: ReturnType<typeof vi.fn>;
|
||||
let reload: ReturnType<typeof vi.fn>;
|
||||
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue