Files
makelore/electron/coding-runtime/pi/provider-config.ts
brother7 0f7093d173 fix: allow replacing unavailable conversation models
Persist the selected model before opening dormant sessions, and reconfigure crashed workers on the same account. Preserve session history and report model removal through the model-unavailable contract instead of a generic runtime failure.
2026-09-21 12:23:04 +08:00

605 lines
22 KiB
TypeScript

import type {
ModelSummary,
ProviderAccount,
ProviderModelEntry,
ProviderProtocol,
ProviderSecret,
} from '../../shared/providers/types';
import {
getProviderBackendConfig,
getProviderDefaultModel,
getProviderDefinition,
} from '../../shared/providers/registry';
import type { ProductModelRef } from '../contracts';
import { atomicWriteJson } from '../../coding-projects/atomic-json';
import {
NIANCODE_USER_MODEL_ACCOUNT_ID,
normalizeImportedUserModelId,
selectUserModelRuntimeAccounts,
} from '../../../shared/user-model-config';
import {
getImportedModelProfile,
thinkingLevelMapForImportedModelCapability,
} from '../../../shared/imported-model-profile';
const PI_ENV_PREFIX = 'MAKELORE_PI';
const WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE = 'works_square_ai_gateway';
const WORKS_SQUARE_AI_GATEWAY_PROXY_CREDENTIAL_MODE = 'works_square_ai_gateway_proxy';
const providerCatalogWriteTails = new Map<string, Promise<void>>();
export const PI_PROVIDER_APIS = [
'openai-completions',
'openai-responses',
'anthropic-messages',
'google-generative-ai',
] as const;
export type PiProviderApi = (typeof PI_PROVIDER_APIS)[number];
import { unknownManagedModelCapability } from '../../../shared/managed-model-capabilities';
export interface PiProviderModelDescriptor {
managedCapability?: import('../../../shared/managed-model-capabilities').ManagedModelCapability;
id: string;
name: string;
input: Array<'text' | 'image'>;
reasoning: boolean;
contextWindow?: number;
maxOutputTokens?: number;
compat?: {
thinkingFormat?: 'deepseek' | 'openrouter' | 'qwen';
sessionAffinityFormat?: 'openrouter';
supportsDeveloperRole?: boolean;
supportsReasoningEffort?: boolean;
supportsStore?: boolean;
requiresReasoningContentOnAssistantMessages?: boolean;
};
thinkingLevelMap?: Partial<Record<ProductModelRef['thinkingLevel'], string | null>>;
}
export interface PiProviderDescriptor {
accountId: string;
runtimeProviderId: string;
api: PiProviderApi;
baseUrl?: string;
headers: Record<string, string>;
apiKeyEnv?: string;
models: PiProviderModelDescriptor[];
}
export interface PiModelsFile {
providers: Record<string, {
baseUrl?: string;
api: PiProviderApi;
apiKey?: string;
headers?: Record<string, string>;
models: Array<{
id: string;
name: string;
reasoning: boolean;
input: Array<'text' | 'image'>;
contextWindow?: number;
maxTokens?: number;
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
compat?: PiProviderModelDescriptor['compat'];
thinkingLevelMap?: PiProviderModelDescriptor['thinkingLevelMap'];
}>;
}>;
}
export interface PiProviderCatalogSummary {
providerCount: number;
providers: Array<{
runtimeProviderId: string;
api: PiProviderApi;
baseUrl?: string;
modelIds: string[];
imageInputModelIds: string[];
headerNames: string[];
hasCredentialReference: boolean;
}>;
}
export interface PiProviderCatalogResult {
descriptors: PiProviderDescriptor[];
modelsFile: PiModelsFile;
summary: PiProviderCatalogSummary;
}
export interface BuildPiProviderCatalogOptions {
accounts: readonly ProviderAccount[];
modelSummaries?: readonly ModelSummary[];
}
export async function buildPiProviderCatalogFromProviderService(
modelSummaries: readonly ModelSummary[] = [],
): Promise<PiProviderCatalogResult> {
const { getProviderService } = await import('../../services/providers/provider-service');
return buildPiProviderCatalog({
accounts: await getProviderService().listAccounts(),
modelSummaries,
});
}
export interface PiProviderSelection {
managedCapability?: import('../../../shared/managed-model-capabilities').ManagedModelCapability;
accountId: string;
runtimeProviderId: string;
modelId: string;
thinkingLevel: ProductModelRef['thinkingLevel'];
input: Array<'text' | 'image'>;
contextWindow?: number;
maxOutputTokens?: number;
}
export interface BuildPiWorkerCredentialProjectionOptions {
account: ProviderAccount;
descriptor: PiProviderDescriptor;
resolveCredential: (account: ProviderAccount) => Promise<string | null>;
localProxyCredential?: string;
}
export interface PiWorkerCredentialProjection {
env: Record<string, string>;
sensitiveValues: string[];
}
export interface PiWorkerCredentialProjectionSummary {
envKeys: string[];
sensitiveValueCount: number;
}
export class PiProviderConfigError extends Error {
readonly publicError?: { code: 'CODING_MODEL_UNAVAILABLE'; message: string; recoverable: true };
constructor(
public readonly code: 'PROVIDER_INVALID' | 'PROVIDER_AUTH_REQUIRED' | 'MODEL_UNAVAILABLE',
message: string,
) {
super(message);
if (code === 'MODEL_UNAVAILABLE') {
this.publicError = {
code: 'CODING_MODEL_UNAVAILABLE',
message: '所选模型当前不可用,请重新选择。',
recoverable: true,
};
}
}
}
export function credentialValueForProviderSecret(secret: ProviderSecret | null): string | null {
if (!secret) return null;
if (secret.type === 'api_key') return secret.apiKey.trim() || null;
if (secret.type === 'oauth') return secret.accessToken.trim() || null;
return secret.apiKey?.trim() || null;
}
export async function resolvePiProviderCredentialFromSecretStore(
account: ProviderAccount,
): Promise<string | null> {
const { getProviderSecret } = await import('../../services/secrets/secret-store');
return credentialValueForProviderSecret(await getProviderSecret(account.id));
}
function accountHex(accountId: string): string {
const normalized = accountId.trim();
if (!normalized) throw new PiProviderConfigError('PROVIDER_INVALID', 'Provider account id is required');
return Buffer.from(normalized, 'utf8').toString('hex');
}
export function resolvePiRuntimeProviderId(accountId: string): string {
return `makelore-account-${accountHex(accountId)}`;
}
function apiKeyEnvForAccount(accountId: string): string {
return `${PI_ENV_PREFIX}_ACCOUNT_${accountHex(accountId).toUpperCase()}_API_KEY`;
}
function headerEnvForAccount(accountId: string, headerName: string): string {
const nameHex = Buffer.from(headerName.toLowerCase(), 'utf8').toString('hex').toUpperCase();
return `${PI_ENV_PREFIX}_ACCOUNT_${accountHex(accountId).toUpperCase()}_HEADER_${nameHex}`;
}
function normalizeBaseUrl(value: string | undefined): string | undefined {
const normalized = value?.trim().replace(/\/+$/, '');
return normalized || undefined;
}
function normalizeWorksGatewayBaseUrl(value: string | undefined): string | undefined {
const normalized = normalizeBaseUrl(value);
if (!normalized) return undefined;
try {
const parsed = new URL(normalized);
const pathname = parsed.pathname.replace(/\/+$/, '');
if (!pathname || pathname === '/') parsed.pathname = '/v1';
return parsed.toString().replace(/\/+$/, '');
} catch {
return normalized;
}
}
function defaultBaseUrlForVendor(vendorId: string): string | undefined {
if (vendorId === 'anthropic') return 'https://api.anthropic.com';
if (vendorId === 'google') return 'https://generativelanguage.googleapis.com/v1beta';
return getProviderBackendConfig(vendorId)?.baseUrl
?? getProviderDefinition(vendorId)?.defaultBaseUrl;
}
function baseUrlForAccount(account: ProviderAccount): string | undefined {
const baseUrl = account.baseUrl
?? account.metadata?.worksSquareOneApiBaseUrl
?? defaultBaseUrlForVendor(account.vendorId);
return account.metadata?.worksSquareCredentialMode === WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE
? normalizeWorksGatewayBaseUrl(baseUrl)
: normalizeBaseUrl(baseUrl);
}
function protocolForAccount(account: ProviderAccount): ProviderProtocol | 'google-generative-ai' | undefined {
if (account.apiProtocol) return account.apiProtocol;
const configured = getProviderBackendConfig(account.vendorId)?.api;
if (configured) return configured;
if (account.vendorId === 'anthropic') return 'anthropic-messages';
if (account.vendorId === 'google') return 'google-generative-ai';
if (account.vendorId === 'openai') return 'openai-responses';
if (account.vendorId === 'openrouter') return 'openrouter';
return undefined;
}
function mapProtocol(protocol: ReturnType<typeof protocolForAccount>): {
api: PiProviderApi;
compat?: PiProviderModelDescriptor['compat'];
} {
if (protocol === 'openrouter') {
return {
api: 'openai-completions',
compat: {
thinkingFormat: 'openrouter',
sessionAffinityFormat: 'openrouter',
},
};
}
if (protocol && PI_PROVIDER_APIS.includes(protocol as PiProviderApi)) {
return { api: protocol as PiProviderApi };
}
throw new PiProviderConfigError('PROVIDER_INVALID', 'Provider protocol is not supported by Pi');
}
function normalizeModelId(rawModelId: string | undefined, account: ProviderAccount): string | undefined {
const modelId = rawModelId?.trim();
if (!modelId) return undefined;
const runtimePrefix = `${resolvePiRuntimeProviderId(account.id)}/`;
const unqualified = modelId.startsWith(runtimePrefix)
? modelId.slice(runtimePrefix.length)
: modelId;
return account.id === NIANCODE_USER_MODEL_ACCOUNT_ID && !account.metadata?.worksSquareModelCapabilitiesV2
? normalizeImportedUserModelId(unqualified)
: unqualified;
}
function backendModelEntries(account: ProviderAccount): Map<string, ProviderModelEntry> {
return new Map((getProviderBackendConfig(account.vendorId)?.models ?? []).map((model) => [model.id, model]));
}
function modelIdsForAccount(account: ProviderAccount): string[] {
const seen = new Set<string>();
const result: string[] = [];
const configuredModels = getProviderBackendConfig(account.vendorId)?.models ?? [];
for (const rawModelId of [
account.model ?? getProviderDefaultModel(account.vendorId),
...(account.fallbackModels ?? []),
...(account.metadata?.customModels ?? []),
...configuredModels.map((model) => model.id),
]) {
const modelId = normalizeModelId(rawModelId, account);
if (!modelId || seen.has(modelId)) continue;
seen.add(modelId);
result.push(modelId);
}
return result;
}
function finitePositiveInteger(value: unknown): number | undefined {
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0
? value
: undefined;
}
function modelDescriptor(
account: ProviderAccount,
modelId: string,
summaries: readonly ModelSummary[],
compat: PiProviderModelDescriptor['compat'],
backendModels: Map<string, ProviderModelEntry>,
enforcedCompat: PiProviderModelDescriptor['compat'],
): PiProviderModelDescriptor {
const summary = summaries.find((candidate) => (
candidate.id === modelId
&& (candidate.accountId === account.id || (!candidate.accountId && candidate.vendorId === account.vendorId))
));
const backend = backendModels.get(modelId);
const profile = getImportedModelProfile(modelId);
const serverCapability = account.metadata?.worksSquareModelCapabilities?.[modelId];
const managed = account.id === 'niancode-user-models'
? account.metadata?.worksSquareModelCapabilitiesV2?.models[modelId] ?? unknownManagedModelCapability()
: undefined;
const backendInput = Array.isArray(backend?.input)
? backend.input.filter((input): input is 'text' | 'image' => input === 'text' || input === 'image')
: [];
const supportsImage = managed ? managed.inputModalities?.includes('image') === true : Boolean(
summary?.supportsVision
|| profile?.modalities.input.includes('image')
|| backendInput.includes('image'),
);
const contextWindow = finitePositiveInteger(managed?.limits?.contextWindow)
?? finitePositiveInteger(summary?.contextWindow)
?? finitePositiveInteger(profile?.limit?.context)
?? finitePositiveInteger(backend?.contextWindow);
const maxOutputTokens = finitePositiveInteger(managed?.limits?.maxOutputTokens)
?? finitePositiveInteger(profile?.limit?.output)
?? finitePositiveInteger(backend?.maxTokens);
return {
id: modelId,
name: summary?.name || (typeof backend?.name === 'string' && backend.name.trim()) || modelId,
input: supportsImage ? ['text', 'image'] : ['text'],
...(managed ? { managedCapability: managed } : {}),
reasoning: managed ? managed.reasoning.supported === true : serverCapability
? serverCapability.reasoningEfforts.length > 0
: summary?.supportsReasoning === true
|| profile?.pi?.reasoning === true
|| backend?.reasoning === true,
...(contextWindow ? { contextWindow } : {}),
...(maxOutputTokens ? { maxOutputTokens } : {}),
...(compat || profile?.pi?.compat || enforcedCompat || serverCapability || managed
? {
compat: {
...compat,
...profile?.pi?.compat,
...enforcedCompat,
...(serverCapability ? { supportsReasoningEffort: true } : {}),
...(managed ? {
supportsReasoningEffort: false,
thinkingFormat: managed.reasoning.controlFormat ?? undefined,
requiresReasoningContentOnAssistantMessages: managed.reasoning.controlFormat !== null,
} : {}),
},
}
: {}),
...(managed ? {} : serverCapability
? { thinkingLevelMap: thinkingLevelMapForImportedModelCapability(serverCapability) }
: profile?.pi?.thinkingLevelMap
? { thinkingLevelMap: { ...profile.pi.thinkingLevelMap } }
: {}),
};
}
function rawHeadersForAccount(account: ProviderAccount): Record<string, string> {
const headers: Record<string, string> = {};
for (const [name, value] of Object.entries({
...(getProviderBackendConfig(account.vendorId)?.headers ?? {}),
...(account.headers ?? {}),
})) {
const duplicate = Object.keys(headers).find((candidate) => candidate.toLowerCase() === name.toLowerCase());
if (duplicate) delete headers[duplicate];
headers[name] = value;
}
return headers;
}
function headerNamesForAccount(account: ProviderAccount): string[] {
const names = Object.keys(rawHeadersForAccount(account));
if (
account.metadata?.worksSquareCredentialMode === WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE
&& !names.some((name) => name.toLowerCase() === 'authorization')
) {
names.push('Authorization');
}
return names.sort((left, right) => left.toLowerCase().localeCompare(right.toLowerCase()));
}
function descriptorForAccount(
account: ProviderAccount,
summaries: readonly ModelSummary[],
): PiProviderDescriptor {
const runtimeProviderId = resolvePiRuntimeProviderId(account.id);
const mapping = mapProtocol(protocolForAccount(account));
const baseUrl = baseUrlForAccount(account);
if (!baseUrl) {
throw new PiProviderConfigError(
'PROVIDER_INVALID',
`Provider account ${account.id} has no runtime base URL`,
);
}
const apiKeyEnv = apiKeyEnvForAccount(account.id);
const credentialMode = account.metadata?.worksSquareCredentialMode;
const enforcedCompat: PiProviderModelDescriptor['compat'] = mapping.api === 'openai-completions'
&& (credentialMode === WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE
|| credentialMode === WORKS_SQUARE_AI_GATEWAY_PROXY_CREDENTIAL_MODE)
? { supportsDeveloperRole: false }
: undefined;
const models = modelIdsForAccount(account).map((modelId) => modelDescriptor(
account,
modelId,
summaries,
mapping.compat,
backendModelEntries(account),
enforcedCompat,
));
if (models.length === 0) {
throw new PiProviderConfigError(
'PROVIDER_INVALID',
`Provider account ${account.id} has no configured model`,
);
}
const headers = Object.fromEntries(headerNamesForAccount(account).map((headerName) => [
headerName,
`$${headerEnvForAccount(account.id, headerName)}`,
]));
return {
accountId: account.id,
runtimeProviderId,
api: mapping.api,
baseUrl,
headers,
apiKeyEnv,
models,
};
}
function modelsFileForDescriptors(descriptors: readonly PiProviderDescriptor[]): PiModelsFile {
return {
providers: Object.fromEntries(descriptors.map((descriptor) => [
descriptor.runtimeProviderId,
{
...(descriptor.baseUrl ? { baseUrl: descriptor.baseUrl } : {}),
api: descriptor.api,
...(descriptor.apiKeyEnv ? { apiKey: `$${descriptor.apiKeyEnv}` } : {}),
...(Object.keys(descriptor.headers).length > 0 ? { headers: { ...descriptor.headers } } : {}),
models: descriptor.models.map((model) => ({
id: model.id,
name: model.name,
reasoning: model.reasoning,
input: [...model.input],
...(model.contextWindow ? { contextWindow: model.contextWindow } : {}),
...(model.maxOutputTokens ? { maxTokens: model.maxOutputTokens } : {}),
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
...(model.compat ? { compat: { ...model.compat } } : {}),
...(model.thinkingLevelMap
? { thinkingLevelMap: { ...model.thinkingLevelMap } }
: {}),
})),
},
])),
};
}
export function buildPiProviderCatalog(
options: BuildPiProviderCatalogOptions,
): PiProviderCatalogResult {
const accounts = selectUserModelRuntimeAccounts([...options.accounts]).filter((account) => account.enabled);
const accountIds = new Set<string>();
const descriptors = accounts.map((account) => {
if (accountIds.has(account.id)) {
throw new PiProviderConfigError('PROVIDER_INVALID', `Duplicate Provider account id: ${account.id}`);
}
accountIds.add(account.id);
return descriptorForAccount(account, options.modelSummaries ?? []);
});
return {
descriptors,
modelsFile: modelsFileForDescriptors(descriptors),
summary: {
providerCount: descriptors.length,
providers: descriptors.map((descriptor) => ({
runtimeProviderId: descriptor.runtimeProviderId,
api: descriptor.api,
...(descriptor.baseUrl ? { baseUrl: descriptor.baseUrl } : {}),
modelIds: descriptor.models.map((model) => model.id),
imageInputModelIds: descriptor.models
.filter((model) => model.input.includes('image'))
.map((model) => model.id),
headerNames: Object.keys(descriptor.headers).sort(),
hasCredentialReference: Boolean(descriptor.apiKeyEnv),
})),
},
};
}
export function selectPiProviderModel(
catalog: PiProviderCatalogResult,
modelRef: ProductModelRef,
): PiProviderSelection {
const descriptor = catalog.descriptors.find((candidate) => candidate.accountId === modelRef.accountId);
const model = descriptor?.models.find((candidate) => candidate.id === modelRef.modelId);
if (!descriptor || !model) {
throw new PiProviderConfigError(
'MODEL_UNAVAILABLE',
'The selected Provider account or model is unavailable',
);
}
return {
accountId: descriptor.accountId,
runtimeProviderId: descriptor.runtimeProviderId,
modelId: model.id,
thinkingLevel: modelRef.thinkingLevel,
input: [...model.input],
...(model.managedCapability ? { managedCapability: model.managedCapability } : {}),
...(model.contextWindow ? { contextWindow: model.contextWindow } : {}),
...(model.maxOutputTokens ? { maxOutputTokens: model.maxOutputTokens } : {}),
};
}
export async function writePiProviderCatalog(
filePath: string,
catalog: PiProviderCatalogResult,
modelRef?: ProductModelRef,
): Promise<void> {
if (modelRef) selectPiProviderModel(catalog, modelRef);
const previous = providerCatalogWriteTails.get(filePath) ?? Promise.resolve();
const write = previous
.catch(() => undefined)
.then(async () => await atomicWriteJson(filePath, catalog.modelsFile));
providerCatalogWriteTails.set(filePath, write);
try {
await write;
} finally {
if (providerCatalogWriteTails.get(filePath) === write) {
providerCatalogWriteTails.delete(filePath);
}
}
}
function replaceCredentialEnvReferences(value: string, credential: string | null): string {
if (!value.includes('{env:')) return value;
if (!credential) {
throw new PiProviderConfigError('PROVIDER_AUTH_REQUIRED', 'Provider credential is unavailable');
}
return value.replace(/\{env:[^}]+\}/g, credential);
}
export async function buildPiWorkerCredentialProjection(
options: BuildPiWorkerCredentialProjectionOptions,
): Promise<PiWorkerCredentialProjection> {
if (options.account.id !== options.descriptor.accountId) {
throw new PiProviderConfigError('PROVIDER_INVALID', 'Provider account does not match descriptor');
}
const useLocalProxy = options.account.metadata?.worksSquareCredentialMode
=== WORKS_SQUARE_AI_GATEWAY_PROXY_CREDENTIAL_MODE;
const resolved = useLocalProxy
? options.localProxyCredential?.trim() || null
: await options.resolveCredential(options.account);
const credential = resolved?.trim()
|| (options.account.authMode === 'local' ? 'local-provider' : null);
if (!credential) {
throw new PiProviderConfigError('PROVIDER_AUTH_REQUIRED', 'Provider credential is unavailable');
}
const env: Record<string, string> = {};
if (options.descriptor.apiKeyEnv) env[options.descriptor.apiKeyEnv] = credential;
const accountHeaders = rawHeadersForAccount(options.account);
for (const headerName of Object.keys(options.descriptor.headers)) {
const sourceEntry = Object.entries(accountHeaders).find(([name]) => (
name.toLowerCase() === headerName.toLowerCase()
));
let value = sourceEntry?.[1];
if (!value && headerName.toLowerCase() === 'authorization'
&& options.account.metadata?.worksSquareCredentialMode === WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE) {
value = `Bearer ${credential}`;
}
if (value === undefined) {
throw new PiProviderConfigError('PROVIDER_INVALID', `Provider header is unavailable: ${headerName}`);
}
env[headerEnvForAccount(options.account.id, headerName)] = replaceCredentialEnvReferences(value, credential);
}
return {
env,
sensitiveValues: [...new Set(Object.values(env).filter(Boolean))],
};
}
export function summarizePiWorkerCredentialProjection(
projection: PiWorkerCredentialProjection,
): PiWorkerCredentialProjectionSummary {
return {
envKeys: Object.keys(projection.env).sort(),
sensitiveValueCount: projection.sensitiveValues.length,
};
}