feat: add Pi provider managed resources
This commit is contained in:
102
electron/coding-runtime/pi/managed-input-revision.ts
Normal file
102
electron/coding-runtime/pi/managed-input-revision.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
export interface PiManagedInputRevision {
|
||||
provider: number;
|
||||
resources: number;
|
||||
}
|
||||
|
||||
export type PiManagedInputAction =
|
||||
| { action: 'reuse'; revision: PiManagedInputRevision }
|
||||
| { action: 'rebuild-before-prompt'; revision: PiManagedInputRevision }
|
||||
| { action: 'rebuild-after-settled'; revision: PiManagedInputRevision }
|
||||
| { action: 'defer-until-settled'; revision: PiManagedInputRevision };
|
||||
|
||||
interface WorkerRevisionState {
|
||||
applied: PiManagedInputRevision;
|
||||
activeRun: PiManagedInputRevision | null;
|
||||
}
|
||||
|
||||
function copyRevision(revision: PiManagedInputRevision): PiManagedInputRevision {
|
||||
return { provider: revision.provider, resources: revision.resources };
|
||||
}
|
||||
|
||||
function revisionsEqual(left: PiManagedInputRevision, right: PiManagedInputRevision): boolean {
|
||||
return left.provider === right.provider && left.resources === right.resources;
|
||||
}
|
||||
|
||||
export class PiManagedInputRevisionCoordinator {
|
||||
private currentRevision: PiManagedInputRevision = { provider: 1, resources: 1 };
|
||||
private readonly workers = new Map<string, WorkerRevisionState>();
|
||||
|
||||
get current(): PiManagedInputRevision {
|
||||
return copyRevision(this.currentRevision);
|
||||
}
|
||||
|
||||
markProviderStale(): PiManagedInputRevision {
|
||||
this.currentRevision = {
|
||||
provider: this.currentRevision.provider + 1,
|
||||
resources: this.currentRevision.resources,
|
||||
};
|
||||
return this.current;
|
||||
}
|
||||
|
||||
markResourcesStale(): PiManagedInputRevision {
|
||||
this.currentRevision = {
|
||||
provider: this.currentRevision.provider,
|
||||
resources: this.currentRevision.resources + 1,
|
||||
};
|
||||
return this.current;
|
||||
}
|
||||
|
||||
registerWorker(workerId: string, applied: PiManagedInputRevision = this.currentRevision): void {
|
||||
if (!workerId.trim()) throw new Error('Worker id is required');
|
||||
if (this.workers.has(workerId)) throw new Error(`Worker is already registered: ${workerId}`);
|
||||
this.workers.set(workerId, { applied: copyRevision(applied), activeRun: null });
|
||||
}
|
||||
|
||||
removeWorker(workerId: string): void {
|
||||
this.workers.delete(workerId);
|
||||
}
|
||||
|
||||
beforePrompt(workerId: string): PiManagedInputAction {
|
||||
const worker = this.requireWorker(workerId);
|
||||
if (worker.activeRun) {
|
||||
return revisionsEqual(worker.applied, this.currentRevision)
|
||||
? { action: 'reuse', revision: copyRevision(worker.activeRun) }
|
||||
: { action: 'defer-until-settled', revision: copyRevision(worker.activeRun) };
|
||||
}
|
||||
return revisionsEqual(worker.applied, this.currentRevision)
|
||||
? { action: 'reuse', revision: copyRevision(worker.applied) }
|
||||
: { action: 'rebuild-before-prompt', revision: this.current };
|
||||
}
|
||||
|
||||
applyCurrentRevision(workerId: string): PiManagedInputRevision {
|
||||
const worker = this.requireWorker(workerId);
|
||||
if (worker.activeRun) throw new Error('Cannot rebuild managed input while a run is active');
|
||||
worker.applied = this.current;
|
||||
return copyRevision(worker.applied);
|
||||
}
|
||||
|
||||
beginRun(workerId: string): PiManagedInputRevision {
|
||||
const worker = this.requireWorker(workerId);
|
||||
if (worker.activeRun) throw new Error('Worker already has an active run');
|
||||
if (!revisionsEqual(worker.applied, this.currentRevision)) {
|
||||
throw new Error('Worker managed input is stale');
|
||||
}
|
||||
worker.activeRun = copyRevision(worker.applied);
|
||||
return copyRevision(worker.activeRun);
|
||||
}
|
||||
|
||||
settleRun(workerId: string): PiManagedInputAction {
|
||||
const worker = this.requireWorker(workerId);
|
||||
if (!worker.activeRun) throw new Error('Worker has no active run');
|
||||
worker.activeRun = null;
|
||||
return revisionsEqual(worker.applied, this.currentRevision)
|
||||
? { action: 'reuse', revision: copyRevision(worker.applied) }
|
||||
: { action: 'rebuild-after-settled', revision: this.current };
|
||||
}
|
||||
|
||||
private requireWorker(workerId: string): WorkerRevisionState {
|
||||
const worker = this.workers.get(workerId);
|
||||
if (!worker) throw new Error(`Worker is not registered: ${workerId}`);
|
||||
return worker;
|
||||
}
|
||||
}
|
||||
529
electron/coding-runtime/pi/provider-config.ts
Normal file
529
electron/coding-runtime/pi/provider-config.ts
Normal file
@@ -0,0 +1,529 @@
|
||||
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 } 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';
|
||||
|
||||
export const PI_PROVIDER_APIS = [
|
||||
'openai-completions',
|
||||
'openai-responses',
|
||||
'anthropic-messages',
|
||||
'google-generative-ai',
|
||||
] as const;
|
||||
|
||||
export type PiProviderApi = (typeof PI_PROVIDER_APIS)[number];
|
||||
|
||||
export interface PiProviderModelDescriptor {
|
||||
id: string;
|
||||
name: string;
|
||||
input: Array<'text' | 'image'>;
|
||||
reasoning: boolean;
|
||||
contextWindow?: number;
|
||||
maxOutputTokens?: number;
|
||||
compat?: {
|
||||
thinkingFormat: 'openrouter';
|
||||
sessionAffinityFormat: 'openrouter';
|
||||
};
|
||||
}
|
||||
|
||||
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'];
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
constructor(
|
||||
public readonly code: 'PROVIDER_INVALID' | 'PROVIDER_AUTH_REQUIRED' | 'MODEL_UNAVAILABLE',
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
? 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>,
|
||||
): 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 backendInput = Array.isArray(backend?.input)
|
||||
? backend.input.filter((input): input is 'text' | 'image' => input === 'text' || input === 'image')
|
||||
: [];
|
||||
const supportsImage = Boolean(
|
||||
summary?.supportsVision
|
||||
|| profile?.modalities.input.includes('image')
|
||||
|| backendInput.includes('image'),
|
||||
);
|
||||
const contextWindow = finitePositiveInteger(summary?.contextWindow)
|
||||
?? finitePositiveInteger(profile?.limit?.context)
|
||||
?? finitePositiveInteger(backend?.contextWindow);
|
||||
const 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'],
|
||||
reasoning: summary?.supportsReasoning === true || backend?.reasoning === true,
|
||||
...(contextWindow ? { contextWindow } : {}),
|
||||
...(maxOutputTokens ? { maxOutputTokens } : {}),
|
||||
...(compat ? { compat } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
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 models = modelIdsForAccount(account).map((modelId) => modelDescriptor(
|
||||
account,
|
||||
modelId,
|
||||
summaries,
|
||||
mapping.compat,
|
||||
backendModelEntries(account),
|
||||
));
|
||||
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 } } : {}),
|
||||
})),
|
||||
},
|
||||
])),
|
||||
};
|
||||
}
|
||||
|
||||
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.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);
|
||||
await atomicWriteJson(filePath, catalog.modelsFile);
|
||||
}
|
||||
|
||||
function replaceOpenCodeEnvReferences(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)] = replaceOpenCodeEnvReferences(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,
|
||||
};
|
||||
}
|
||||
44
electron/coding-runtime/pi/provider-refresh.ts
Normal file
44
electron/coding-runtime/pi/provider-refresh.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export interface PiProviderAuthRecoveryOptions<T> {
|
||||
accountId: string;
|
||||
operation: (attempt: 0 | 1) => Promise<T>;
|
||||
isAuthenticationError: (error: unknown) => boolean;
|
||||
refreshCredential: () => Promise<void>;
|
||||
reopenWorker: () => Promise<void>;
|
||||
}
|
||||
|
||||
export class PiProviderRefreshCoordinator {
|
||||
private readonly refreshes = new Map<string, Promise<void>>();
|
||||
|
||||
get pendingAccountCount(): number {
|
||||
return this.refreshes.size;
|
||||
}
|
||||
|
||||
async refreshAccount(accountId: string, refresh: () => Promise<void>): Promise<void> {
|
||||
const normalizedAccountId = accountId.trim();
|
||||
if (!normalizedAccountId) throw new Error('Provider account id is required');
|
||||
let pending = this.refreshes.get(normalizedAccountId);
|
||||
if (!pending) {
|
||||
pending = Promise.resolve()
|
||||
.then(refresh)
|
||||
.finally(() => {
|
||||
if (this.refreshes.get(normalizedAccountId) === pending) {
|
||||
this.refreshes.delete(normalizedAccountId);
|
||||
}
|
||||
});
|
||||
this.refreshes.set(normalizedAccountId, pending);
|
||||
}
|
||||
await pending;
|
||||
}
|
||||
|
||||
async withSingleAuthRecovery<T>(options: PiProviderAuthRecoveryOptions<T>): Promise<T> {
|
||||
try {
|
||||
return await options.operation(0);
|
||||
} catch (error) {
|
||||
if (!options.isAuthenticationError(error)) throw error;
|
||||
}
|
||||
|
||||
await this.refreshAccount(options.accountId, options.refreshCredential);
|
||||
await options.reopenWorker();
|
||||
return await options.operation(1);
|
||||
}
|
||||
}
|
||||
193
electron/coding-runtime/pi/resource-loader.ts
Normal file
193
electron/coding-runtime/pi/resource-loader.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { mkdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
BUNDLED_CODING_SKILL_IDS,
|
||||
type BundledCodingSkillId,
|
||||
} from '../../../shared/coding-skills';
|
||||
import { atomicWriteJson, atomicWriteText } from '../../coding-projects/atomic-json';
|
||||
import type { PiProviderSelection } from './provider-config';
|
||||
import type { PiManagedInputRevision } from './managed-input-revision';
|
||||
|
||||
const MANAGED_SEGMENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
||||
|
||||
export interface BundledCodingSkillsPathInput {
|
||||
isPackaged: boolean;
|
||||
resourcesPath: string;
|
||||
appPath: string;
|
||||
}
|
||||
|
||||
export interface PiManagedPaths {
|
||||
rootDir: string;
|
||||
configDir: string;
|
||||
modelsFile: string;
|
||||
sessionsDir: string;
|
||||
promptsDir: string;
|
||||
extensionsDir: string;
|
||||
logsDir: string;
|
||||
trashDir: string;
|
||||
}
|
||||
|
||||
export interface MaterializePiAgentResourcesOptions {
|
||||
userDataDir: string;
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
prompt: string;
|
||||
skillIds: readonly string[];
|
||||
bundledSkillsDir: string;
|
||||
revision: PiManagedInputRevision;
|
||||
}
|
||||
|
||||
export interface PiAgentResourceManifest {
|
||||
schemaVersion: 1;
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
promptFile: string;
|
||||
skillIds: BundledCodingSkillId[];
|
||||
revision: PiManagedInputRevision;
|
||||
}
|
||||
|
||||
export interface PiAgentResourceSnapshot {
|
||||
paths: PiManagedPaths;
|
||||
projectSessionsDir: string;
|
||||
promptPath: string;
|
||||
manifestPath: string;
|
||||
skillIds: BundledCodingSkillId[];
|
||||
skillPaths: string[];
|
||||
revision: PiManagedInputRevision;
|
||||
summary: {
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
skillIds: BundledCodingSkillId[];
|
||||
revision: PiManagedInputRevision;
|
||||
};
|
||||
}
|
||||
|
||||
function managedSegment(value: string, name: string): string {
|
||||
const normalized = value.trim();
|
||||
if (!MANAGED_SEGMENT_PATTERN.test(normalized)) {
|
||||
throw new Error(`${name} is not a valid managed resource segment`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function resolveBundledCodingSkillsDir(input: BundledCodingSkillsPathInput): string {
|
||||
return input.isPackaged
|
||||
? path.join(input.resourcesPath, 'resources', 'coding-skills')
|
||||
: path.join(input.appPath, 'resources', 'coding-skills');
|
||||
}
|
||||
|
||||
export function getPiManagedPaths(userDataDir: string): PiManagedPaths {
|
||||
const rootDir = path.join(path.resolve(userDataDir), 'coding-runtime', 'pi');
|
||||
const configDir = path.join(rootDir, 'config');
|
||||
return {
|
||||
rootDir,
|
||||
configDir,
|
||||
modelsFile: path.join(configDir, 'models.json'),
|
||||
sessionsDir: path.join(rootDir, 'sessions'),
|
||||
promptsDir: path.join(rootDir, 'prompts'),
|
||||
extensionsDir: path.join(rootDir, 'extensions'),
|
||||
logsDir: path.join(rootDir, 'logs'),
|
||||
trashDir: path.join(rootDir, 'trash'),
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensurePiManagedPaths(userDataDir: string): Promise<PiManagedPaths> {
|
||||
const paths = getPiManagedPaths(userDataDir);
|
||||
await Promise.all([
|
||||
mkdir(paths.configDir, { recursive: true }),
|
||||
mkdir(paths.sessionsDir, { recursive: true }),
|
||||
mkdir(paths.promptsDir, { recursive: true }),
|
||||
mkdir(paths.extensionsDir, { recursive: true }),
|
||||
mkdir(paths.logsDir, { recursive: true }),
|
||||
mkdir(paths.trashDir, { recursive: true }),
|
||||
]);
|
||||
return paths;
|
||||
}
|
||||
|
||||
function normalizeSkillIds(skillIds: readonly string[]): BundledCodingSkillId[] {
|
||||
const result: BundledCodingSkillId[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const rawSkillId of skillIds) {
|
||||
const skillId = rawSkillId.trim();
|
||||
if (!BUNDLED_CODING_SKILL_IDS.includes(skillId as BundledCodingSkillId)) {
|
||||
throw new Error(`Unknown bundled coding skill: ${skillId || '(empty)'}`);
|
||||
}
|
||||
if (seen.has(skillId)) continue;
|
||||
seen.add(skillId);
|
||||
result.push(skillId as BundledCodingSkillId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function resolveExplicitCodingSkillPaths(
|
||||
bundledSkillsDir: string,
|
||||
skillIds: readonly string[],
|
||||
): Promise<{ skillIds: BundledCodingSkillId[]; skillPaths: string[] }> {
|
||||
const normalizedSkillIds = normalizeSkillIds(skillIds);
|
||||
const root = path.resolve(bundledSkillsDir);
|
||||
const skillPaths = normalizedSkillIds.map((skillId) => path.join(root, skillId, 'SKILL.md'));
|
||||
await Promise.all(skillPaths.map(async (skillPath) => {
|
||||
const metadata = await stat(skillPath);
|
||||
if (!metadata.isFile()) throw new Error(`Bundled coding skill entry is not a file: ${skillPath}`);
|
||||
}));
|
||||
return { skillIds: normalizedSkillIds, skillPaths };
|
||||
}
|
||||
|
||||
export async function materializePiAgentResources(
|
||||
options: MaterializePiAgentResourcesOptions,
|
||||
): Promise<PiAgentResourceSnapshot> {
|
||||
const projectId = managedSegment(options.projectId, 'Project id');
|
||||
const agentId = managedSegment(options.agentId, 'Agent id');
|
||||
const paths = await ensurePiManagedPaths(options.userDataDir);
|
||||
const projectSessionsDir = path.join(paths.sessionsDir, projectId);
|
||||
const projectPromptsDir = path.join(paths.promptsDir, projectId);
|
||||
await Promise.all([
|
||||
mkdir(projectSessionsDir, { recursive: true }),
|
||||
mkdir(projectPromptsDir, { recursive: true }),
|
||||
]);
|
||||
const { skillIds, skillPaths } = await resolveExplicitCodingSkillPaths(
|
||||
options.bundledSkillsDir,
|
||||
options.skillIds,
|
||||
);
|
||||
const promptPath = path.join(projectPromptsDir, `${agentId}.md`);
|
||||
const manifestPath = path.join(projectPromptsDir, `${agentId}.manifest.json`);
|
||||
const manifest: PiAgentResourceManifest = {
|
||||
schemaVersion: 1,
|
||||
projectId,
|
||||
agentId,
|
||||
promptFile: path.basename(promptPath),
|
||||
skillIds: [...skillIds],
|
||||
revision: { ...options.revision },
|
||||
};
|
||||
await atomicWriteText(promptPath, options.prompt);
|
||||
await atomicWriteJson(manifestPath, manifest);
|
||||
return {
|
||||
paths,
|
||||
projectSessionsDir,
|
||||
promptPath,
|
||||
manifestPath,
|
||||
skillIds: [...skillIds],
|
||||
skillPaths,
|
||||
revision: { ...options.revision },
|
||||
summary: {
|
||||
projectId,
|
||||
agentId,
|
||||
skillIds: [...skillIds],
|
||||
revision: { ...options.revision },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPiManagedInputArgs(
|
||||
selection: PiProviderSelection,
|
||||
resources: PiAgentResourceSnapshot,
|
||||
): string[] {
|
||||
const args = [
|
||||
'--provider', selection.runtimeProviderId,
|
||||
'--model', selection.modelId,
|
||||
'--thinking', selection.thinkingLevel,
|
||||
'--system-prompt', resources.promptPath,
|
||||
];
|
||||
for (const skillPath of resources.skillPaths) args.push('--skill', skillPath);
|
||||
return args;
|
||||
}
|
||||
@@ -18,6 +18,27 @@ const DEFAULT_COMMAND_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_SHUTDOWN_GRACE_MS = 3_000;
|
||||
const DEFAULT_DIAGNOSTIC_BYTES = 16_000;
|
||||
const ANSI_COLOR_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g');
|
||||
const PI_INHERITED_ENV_KEYS = [
|
||||
'APPDATA',
|
||||
'COMSPEC',
|
||||
'HOME',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
'LD_LIBRARY_PATH',
|
||||
'LOCALAPPDATA',
|
||||
'NODE_EXTRA_CA_CERTS',
|
||||
'PATH',
|
||||
'PATHEXT',
|
||||
'SSL_CERT_DIR',
|
||||
'SSL_CERT_FILE',
|
||||
'SYSTEMROOT',
|
||||
'TEMP',
|
||||
'TMP',
|
||||
'TMPDIR',
|
||||
'TZ',
|
||||
'USERPROFILE',
|
||||
'WINDIR',
|
||||
] as const;
|
||||
|
||||
export type PiWorkerStopResult = {
|
||||
mode: 'not-started' | 'stdin-close' | 'forced-tree-kill';
|
||||
@@ -67,13 +88,50 @@ export function sanitizePiDiagnostic(
|
||||
.replace(ANSI_COLOR_PATTERN, '')
|
||||
.replace(/(authorization\s*[:=]\s*(?:bearer\s+)?)[^\s,;]+/gi, '$1[REDACTED]')
|
||||
.replace(/((?:x-api-key|api[_-]?key|token|secret)\s*[:=]\s*)[^\s,;]+/gi, '$1[REDACTED]');
|
||||
for (const value of sensitiveValues) {
|
||||
if (value.length < 4) continue;
|
||||
const uniqueSensitiveValues = [...new Set(sensitiveValues.filter(Boolean))]
|
||||
.sort((left, right) => right.length - left.length);
|
||||
for (const value of uniqueSensitiveValues) {
|
||||
sanitized = sanitized.split(value).join('[REDACTED]');
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function buildPiWorkerEnvironment(
|
||||
configDir: string,
|
||||
overlay: NodeJS.ProcessEnv = {},
|
||||
inherited: NodeJS.ProcessEnv = process.env,
|
||||
): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
for (const key of PI_INHERITED_ENV_KEYS) {
|
||||
const exact = inherited[key];
|
||||
if (exact !== undefined) {
|
||||
env[key] = exact;
|
||||
continue;
|
||||
}
|
||||
const matchingKey = Object.keys(inherited).find((candidate) => candidate.toUpperCase() === key);
|
||||
if (matchingKey && inherited[matchingKey] !== undefined) env[matchingKey] = inherited[matchingKey];
|
||||
}
|
||||
return {
|
||||
...env,
|
||||
...overlay,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
PI_CODING_AGENT_DIR: configDir,
|
||||
PI_OFFLINE: '1',
|
||||
PI_TELEMETRY: '0',
|
||||
};
|
||||
}
|
||||
|
||||
function assertSensitiveValuesAbsentFromArgs(
|
||||
args: readonly string[],
|
||||
sensitiveValues: readonly string[] = [],
|
||||
): void {
|
||||
for (const value of sensitiveValues) {
|
||||
if (value && args.some((argument) => argument.includes(value))) {
|
||||
throw new Error('Pi worker arguments contain a sensitive value');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function boundedUtf8Tail(source: string, maxBytes: number): string {
|
||||
const bytes = Buffer.from(source, 'utf8');
|
||||
if (bytes.length <= maxBytes) return source;
|
||||
@@ -158,19 +216,14 @@ export class PiWorkerProcess {
|
||||
async start(): Promise<this> {
|
||||
if (this.child) throw new Error('Pi worker process already started');
|
||||
const generation = this.generationValue;
|
||||
const args = buildPiRpcArgs(this.options.sessionDir, this.options.additionalArgs);
|
||||
assertSensitiveValuesAbsentFromArgs(args, this.options.sensitiveValues);
|
||||
const child = spawn(
|
||||
this.options.executablePath,
|
||||
[this.options.cliPath, ...buildPiRpcArgs(this.options.sessionDir, this.options.additionalArgs)],
|
||||
[this.options.cliPath, ...args],
|
||||
{
|
||||
cwd: this.options.cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...this.options.env,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
PI_CODING_AGENT_DIR: this.options.configDir,
|
||||
PI_OFFLINE: '1',
|
||||
PI_TELEMETRY: '0',
|
||||
},
|
||||
env: buildPiWorkerEnvironment(this.options.configDir, this.options.env),
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
detached: platform() !== 'win32',
|
||||
|
||||
Reference in New Issue
Block a user