97 lines
5.7 KiB
JavaScript
97 lines
5.7 KiB
JavaScript
import {existsSync, readFileSync} from 'node:fs';
|
||
import path from 'node:path';
|
||
import {fileURLToPath} from 'node:url';
|
||
|
||
export const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||
|
||
export function readEnvFile(file) {
|
||
if (!existsSync(file)) return {};
|
||
const values = {};
|
||
for (const line of readFileSync(file, 'utf8').replace(/^\uFEFF/u, '').split(/\r?\n/u)) {
|
||
const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/u);
|
||
if (!match) continue;
|
||
let value = match[2];
|
||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||
value = value.slice(1, -1);
|
||
} else value = value.replace(/\s+#.*$/u, '');
|
||
values[match[1]] = value;
|
||
}
|
||
return values;
|
||
}
|
||
|
||
export function loadConfig({configFile, envFile, env = process.env} = {}) {
|
||
const file = path.resolve(configFile || path.join(projectRoot, 'configs', 'astra-qwen.json'));
|
||
const config = JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/u, ''));
|
||
if (config.schema_version !== 1 || !config.providers || !config.stages) {
|
||
throw new Error('配置必须声明 schema_version: 1、providers 和 stages');
|
||
}
|
||
return {config, file, env: {...readEnvFile(envFile || path.join(projectRoot, '.env')), ...env}};
|
||
}
|
||
|
||
export function resolveStage(loaded, name, overrides = {}, {requireKey = true} = {}) {
|
||
const stage = loaded.config.stages[name];
|
||
if (!stage) throw new Error(`没有配置阶段:${name}`);
|
||
const provider = loaded.config.providers[stage.provider];
|
||
if (!provider) throw new Error(`没有配置提供方:${stage.provider}`);
|
||
const model = String(overrides.model || stage.model || loaded.env[stage.model_env] || stage.default_model || '').trim();
|
||
if (!model) throw new Error(`缺少 ${name} 的模型 ID`);
|
||
const baseUrl = String(loaded.env[provider.base_url_env] || '').trim().replace(/\/+$/u, '');
|
||
const apiKey = String(loaded.env[provider.api_key_env] || '').trim();
|
||
if (requireKey && (!baseUrl || !apiKey)) throw new Error(`需要配置 ${provider.base_url_env} 和 ${provider.api_key_env}`);
|
||
if (baseUrl) {
|
||
const url = new URL(baseUrl);
|
||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
|
||
throw new Error('提供方 base URL 必须是无用户名、密码、查询参数的 HTTP(S) 地址');
|
||
}
|
||
}
|
||
const protocol = overrides.protocol || provider.protocol;
|
||
const allowed = name === 'understanding' ? ['chat-completions', 'responses'] : ['images-edits'];
|
||
if (!allowed.includes(protocol)) throw new Error(`${name} 不支持协议:${protocol}`);
|
||
const params = {...stage.params, ...overrides.params};
|
||
// Semantic fields are owned by adapters, never overridden via opaque params.
|
||
for (const key of ['model', 'messages', 'input', 'instructions', 'prompt', 'image', 'image[]', 'mask', 'response_format', 'text', 'stream', 'Authorization', 'api_key', 'apiKey']) {
|
||
if (Object.hasOwn(params, key)) throw new Error(`params 不能覆盖保留字段:${key}`);
|
||
}
|
||
const timeoutMs = provider.timeout_ms ?? 180000;
|
||
const maxRetries = provider.max_retries ?? 2;
|
||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || !Number.isInteger(maxRetries) || maxRetries < 0 || maxRetries > 5) {
|
||
throw new Error('timeout_ms 必须为正数,max_retries 必须为 0~5 的整数');
|
||
}
|
||
const outputMode = overrides.outputMode || stage.output_mode || 'text-json';
|
||
if (!['text-json', 'json-object', 'json-schema'].includes(outputMode)) throw new Error(`未知 output_mode:${outputMode}`);
|
||
const stream = overrides.stream ?? stage.stream ?? false;
|
||
if (typeof stream !== 'boolean') throw new Error('stream 必须为 boolean');
|
||
const promptPath = path.resolve(path.dirname(loaded.file), overrides.prompt || stage.prompt);
|
||
const capabilities = {multiple_images: false, mask: false, ...provider.capabilities};
|
||
if (typeof capabilities.mask !== 'boolean' || typeof capabilities.multiple_images !== 'boolean') {
|
||
throw new Error('capabilities.mask 和 multiple_images 必须为 boolean');
|
||
}
|
||
const imageField = provider.image_field || 'image';
|
||
if (!['image', 'image[]'].includes(imageField)) throw new Error('image_field 必须是 image 或 image[]');
|
||
return {
|
||
name, provider: stage.provider, protocol, model, baseUrl, apiKey, timeoutMs, maxRetries,
|
||
params, outputMode, stream, promptPath, prompt: readFileSync(promptPath, 'utf8'), capabilities,
|
||
imageField, maskEditable: provider.mask_editable || 'transparent',
|
||
};
|
||
}
|
||
|
||
export function publicStage(stage) {
|
||
return {
|
||
provider: stage.provider, protocol: stage.protocol, model: stage.model,
|
||
params: stage.params, output_mode: stage.outputMode, stream: stage.stream, timeout_ms: stage.timeoutMs,
|
||
max_retries: stage.maxRetries, capabilities: stage.capabilities,
|
||
image_field: stage.imageField, mask_editable: stage.maskEditable,
|
||
};
|
||
}
|
||
|
||
export function samInfo(loaded, overrides = {}) {
|
||
const stage = loaded.config.stages.segmentation || {};
|
||
const modelDir = overrides.samDir || loaded.env[stage.model_dir_env] || stage.default_model_dir || '';
|
||
const checkpoint = overrides.samCheckpoint || (modelDir ? [path.join(modelDir, 'sam3.1checkpoint', 'sam3.1_multiplex.pt'), path.join(modelDir, 'sam3.1_multiplex.pt')].find(existsSync) || path.join(modelDir, 'sam3.1checkpoint', 'sam3.1_multiplex.pt') : '');
|
||
const codeDir = overrides.samCodeDir || loaded.env.SAM_CODE_DIR || (modelDir ? path.join(modelDir, 'sam3') : '');
|
||
return {model_dir: modelDir, code_dir: codeDir, checkpoint,
|
||
checkpoint_exists: Boolean(checkpoint && existsSync(checkpoint)),
|
||
code_exists: Boolean(codeDir && existsSync(path.join(codeDir, 'sam3', 'model_builder.py'))),
|
||
runtime_status: 'local_subprocess_not_probed'};
|
||
}
|