fix: enforce v2 auth readiness checks

This commit is contained in:
7Sageer 2026-07-07 22:27:05 +08:00
parent 07f3f1cc36
commit 7b041a8e03
8 changed files with 442 additions and 155 deletions

View file

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

Before After
Before After

View file

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

View file

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

View 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.`,
);
}

View file

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

View file

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

View file

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