Files
puter/tools/lib/configMigration.mjs
T
404oopsandClaude Fable 5.1 6146ab9dd3 feat(ai): refresh txt2vid catalogs, retire Sora, default to Veo 3.1 Lite (#3828)
OpenAI shuts the Sora Videos API down on 2026-09-24 and sora-2 was the
default txt2vid model, so the default moves to Veo 3.1 Lite on Gemini
and the OpenAI video provider goes. While there, the video catalogs are
brought in line with what each vendor serves today, the request options
are unified across providers, and the txt2vid docs are rewritten.

- driver: default provider gemini-video-generation with
  veo-3.1-lite-generate-preview; a request under the generic `ai-video`
  driver name lands on the default instead of the first-registered
  provider; `WIDTHxHEIGHT` sizes map onto tier catalogs by the shorter
  side and fill width/height
- openai video provider, the `openai-video-generation` alias, its
  config template and migration entries, and Together's openai/sora-2*
  rows removed
- gemini: Veo 3.1 Fast rates 10/12/30 cents per second for
  720p/1080p/4K, Veo 3.1 Lite accepts reference images, URL image
  inputs are fetched server-side through the SSRF-guarded fetch
- together: drop nine models retired upstream, add eighteen from the
  live listing; per-second models are estimated from the catalog rate,
  clamped to remaining credit and billed at the cost Together reports
  on the job; tier-sized models take resolution/ratio;
  input_reference/last_frame map onto keyframes; generate_audio is
  forwarded
- byteplus: Seedance 2.5 (dreamina-seedance-2-5-260628) with per-model
  reference-image caps
- util/imageInput: string-level image helpers shared by the image and
  video drivers; ai-image/inputImage re-exports them unchanged
- puter.js types: provider and generate_audio options; docs: txt2vid
  page rewritten with per-provider model tables, unified options and
  four new playground examples

Known follow-up: Veo returns a key-protected Google file URL, so the
default clip cannot be played directly by a browser until the provider
fetches it server-side.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 14:56:17 -07:00

499 lines
23 KiB
JavaScript

// Shared helpers for migrateConfig.mjs / migrateServers.mjs.
// ── JSON loading ──────────────────────────────────────────────────────────
// Old files may be multiple JSON objects glued together with `//` comments.
// Strip line-comments + trailing commas, then walk brace depth to split.
const stripLineComments = (src) => src.replace(/^\s*\/\/.*$/gm, '');
const stripTrailingCommas = (src) => src.replace(/,(\s*[}\]])/g, '$1');
const splitJsonDocs = (src) => {
const docs = [];
let depth = 0;
let start = -1;
let inStr = false;
let esc = false;
for ( let i = 0; i < src.length; i++ ) {
const c = src[i];
if ( inStr ) {
if ( esc ) esc = false;
else if ( c === '\\' ) esc = true;
else if ( c === '"' ) inStr = false;
continue;
}
if ( c === '"' ) { inStr = true; continue; }
if ( c === '{' ) {
if ( depth === 0 ) start = i;
depth++;
} else if ( c === '}' ) {
depth--;
if ( depth === 0 && start !== -1 ) {
docs.push(src.slice(start, i + 1));
start = -1;
}
}
}
return docs;
};
// Strip v1 JSON-extension conventions at every depth:
// • `$`-prefixed keys → v1 directives (`$preserve`, `$requires`, `$version`)
// • `__`-prefixed keys → v1 comment-out convention (disabled entries)
// • empty-string keys → trailing-comma placeholder (`"": null,`)
const stripDollarKeys = (value) => {
if ( Array.isArray(value) ) return value.map(stripDollarKeys);
if ( value && typeof value === 'object' ) {
const out = {};
for ( const [k, v] of Object.entries(value) ) {
if ( k === '' || k.startsWith('$') || k.startsWith('__') ) continue;
out[k] = stripDollarKeys(v);
}
return out;
}
return value;
};
export const loadDocs = (raw) => {
const cleaned = stripTrailingCommas(stripLineComments(raw));
const texts = splitJsonDocs(cleaned);
return texts.map((text, idx) => {
try { return stripDollarKeys(JSON.parse(text)); }
catch ( e ) {
throw new Error(`Failed to parse JSON document #${idx + 1}: ${e.message}`);
}
});
};
// ── Doc classification ───────────────────────────────────────────────────
export const pickServersDoc = (docs) => docs.find(d => Array.isArray(d?.servers));
export const pickBaseDoc = (docs) => {
// Prefer prod base (has `services` and no `servers`), then OSS default,
// then any non-servers doc.
const prodBase = docs.find(d => d && !Array.isArray(d.servers) && d.services && d.config_name && d.env !== 'dev');
if ( prodBase ) return prodBase;
const ossDefault = docs.find(d => d && !Array.isArray(d.servers) && (d.nginx_mode || d.env === 'dev'));
if ( ossDefault ) return ossDefault;
return docs.find(d => d && !Array.isArray(d.servers)) ?? null;
};
// ── Deep merge ───────────────────────────────────────────────────────────
// Plain deep merge: objects recurse, arrays + primitives replace.
export const deepMerge = (base, override) => {
if ( override === undefined ) return base;
if ( base === undefined ) return override;
if ( base === null || override === null ) return override;
if ( typeof base !== 'object' || typeof override !== 'object' ) return override;
if ( Array.isArray(base) || Array.isArray(override) ) return override;
const out = { ...base };
for ( const [k, v] of Object.entries(override) ) {
out[k] = deepMerge(base[k], v);
}
return out;
};
// ── v1 → v2 transformation ───────────────────────────────────────────────
const copyIfSet = (src, sk, dst, dk = sk) => {
if ( src[sk] !== undefined ) dst[dk] = src[sk];
};
export const transformToV2 = (source) => {
const out = {};
// Scalar + renamed top-level keys.
copyIfSet(source, 'config_name', out);
copyIfSet(source, 'env', out);
copyIfSet(source, 'server_id', out, 'serverId');
copyIfSet(source, 'id', out, 'serverId');
copyIfSet(source, 'region', out);
copyIfSet(source, 'domain', out);
copyIfSet(source, 'protocol', out);
copyIfSet(source, 'pub_port', out);
copyIfSet(source, 'cookie_name', out);
copyIfSet(source, 'jwt_secret', out);
copyIfSet(source, 'url_signature_secret', out);
copyIfSet(source, 'blocked_email_domains', out, 'blockedEmailDomains');
copyIfSet(source, 'enable_public_folders', out);
copyIfSet(source, 'is_storage_limited', out);
copyIfSet(source, 'storage_capacity', out);
copyIfSet(source, 'static_hosting_domain', out);
copyIfSet(source, 'static_hosting_domain_alt', out);
copyIfSet(source, 'private_app_hosting_domain', out);
copyIfSet(source, 'private_app_hosting_domain_alt', out);
copyIfSet(source, 'min_pass_length', out);
copyIfSet(source, 'allow_system_login', out);
copyIfSet(source, 'allow_all_host_values', out);
copyIfSet(source, 'allow_no_host_header', out);
copyIfSet(source, 'allow_nipio_domains', out);
copyIfSet(source, 'custom_domains_enabled', out);
copyIfSet(source, 'enable_ip_validation', out);
copyIfSet(source, 'default_user_group', out);
copyIfSet(source, 'default_temp_group', out);
copyIfSet(source, 'api_base_url', out);
copyIfSet(source, 'origin', out);
copyIfSet(source, 'contact_email', out, 'support_email');
// `extensions` in v1 was overloaded — array form = scan dirs, object form
// = per-extension config bag. JSON.parse keeps only the last declaration
// per key, so in prod files where both appear we typically only see the
// object. Promote object-shape entries onto top-level keys (scoped npm
// names → camelCase: `@heyputer/app-store-and-purchases` → `appStoreAndPurchases`).
// v1 also had `mod_directories: string[]` with `{repo}/...` placeholders.
// v2 uses `extensions: string[]` of plain directory paths; post-cutover the
// only dir that survives is the repo-root `./extensions`, so synthesize that
// when only the config-bag (object) or mod_directories form is present.
// Some v1 configs used `extension` (singular) as the per-extension config
// bag alongside (or instead of) `extensions`. Accept either.
const extBag = (source.extensions && typeof source.extensions === 'object' && !Array.isArray(source.extensions))
? source.extensions
: (source.extension && typeof source.extension === 'object')
? source.extension
: null;
if ( Array.isArray(source.extensions) ) {
out.extensions = source.extensions;
} else if ( extBag ) {
for ( const [k, v] of Object.entries(extBag) ) {
const bare = k.split('/').pop() ?? k;
const camel = bare.replace(/-([a-z])/g, (_, ch) => ch.toUpperCase());
if ( out[camel] === undefined ) out[camel] = v;
}
}
if ( out.extensions === undefined && (
Array.isArray(source.mod_directories) || extBag
) ) {
out.extensions = ['./extensions'];
}
// Port: http_port → port. Drop string "auto" (v2 requires numeric).
if ( source.http_port !== undefined && source.http_port !== 'auto' ) {
out.port = source.http_port;
} else if ( source.port !== undefined ) {
out.port = source.port;
}
// S3: old flat keys → `s3.s3Config`.
if ( source.s3_access_key || source.s3_secret_key ) {
out.s3 = {
s3Config: {
endpoint: source.s3_endpoint ?? '',
accessKeyId: source.s3_access_key,
secretAccessKey: source.s3_secret_key,
...(source.s3_region ? { region: source.s3_region } : {}),
},
};
}
copyIfSet(source, 's3_bucket', out);
copyIfSet(source, 's3_region', out);
// Database: prefer services.database.{primary, engine}; else db_* flat.
const svc = source.services ?? {};
if ( svc.database ) {
const db = {};
if ( svc.database.engine ) db.engine = svc.database.engine;
if ( svc.database.primary ) {
for ( const k of ['host', 'port', 'user', 'password', 'database'] ) {
if ( svc.database.primary[k] !== undefined ) db[k] = svc.database.primary[k];
}
}
if ( svc.database.path ) db.path = svc.database.path;
if ( Object.keys(db).length ) out.database = db;
}
if ( ! out.database && source.db_host ) {
out.database = {
engine: 'mysql',
host: source.db_host,
port: source.db_port,
user: source.db_user,
password: source.db_password,
database: source.db_database,
};
}
if ( source.read_replica_db ) {
out.database = out.database ?? { engine: 'mysql' };
const r = source.read_replica_db;
out.database.replica = {
host: r.host, port: r.port, user: r.user, password: r.password, database: r.database,
};
}
// Dynamo (services.dynamo → top-level)
if ( svc.dynamo ) out.dynamo = svc.dynamo;
// Email (services.email → email; drop `engine` adapter switch). Fallback
// to old flat smtp_* fields.
if ( svc.email ) {
const { engine: _engine, ...rest } = svc.email;
out.email = rest;
} else if ( source.smtp_server || source.smtp_host ) {
out.email = {
host: source.smtp_server ?? source.smtp_host,
port: source.smtp_port ?? source.smpt_port,
secure: true,
auth: { user: source.smtp_username, pass: source.smtp_password },
};
}
// Pager: routing_key → routingKey.
if ( source.pager?.pagerduty ) {
const pd = source.pager.pagerduty;
out.pager = {
pagerduty: {
enabled: pd.enabled,
...(pd.routing_key ? { routingKey: pd.routing_key } : {}),
},
};
}
// Captcha
if ( svc.captcha ) out.captcha = svc.captcha;
// Homepage GUI bundle promotion (services.puter-homepage.* → top-level).
if ( svc['puter-homepage'] ) {
const h = svc['puter-homepage'];
copyIfSet(h, 'gui_bundle', out);
copyIfSet(h, 'gui_puterjs_bundle', out);
copyIfSet(h, 'gui_css', out);
}
// Billing / Stripe consolidation. v1 split these across `stripe`,
// `offerings`, and `__subs-serve`; v2 lands everything under the
// `appStoreAndPurchases` extension (puter-paid subscriptions, the
// marketplace, and webhook handling all share one Stripe client).
const appStoreAndPurchases = (out.appStoreAndPurchases ?? {});
if ( svc.stripe ) {
if ( svc.stripe.api_secret ) appStoreAndPurchases.api_secret = svc.stripe.api_secret;
if ( svc.stripe.endpoint_secret ) appStoreAndPurchases.endpoint_secret = svc.stripe.endpoint_secret;
}
if ( svc['__subs-serve']?.stripe_publishable_key ) {
appStoreAndPurchases.stripe_publishable_key = svc['__subs-serve'].stripe_publishable_key;
}
if ( svc.offerings?.price_ids ) {
appStoreAndPurchases.puterPaid = appStoreAndPurchases.puterPaid ?? {};
appStoreAndPurchases.puterPaid.price_ids = svc.offerings.price_ids;
}
// Forward an already-migrated `legacyBilling` block too, in case the
// input config was migrated against an older version of this tool.
if ( source.legacyBilling ) {
if ( source.legacyBilling.api_secret ) appStoreAndPurchases.api_secret ??= source.legacyBilling.api_secret;
if ( source.legacyBilling.endpoint_secret ) appStoreAndPurchases.endpoint_secret ??= source.legacyBilling.endpoint_secret;
if ( source.legacyBilling.stripe_publishable_key ) {
appStoreAndPurchases.stripe_publishable_key ??= source.legacyBilling.stripe_publishable_key;
}
if ( source.legacyBilling.price_ids ) {
appStoreAndPurchases.puterPaid = appStoreAndPurchases.puterPaid ?? {};
appStoreAndPurchases.puterPaid.price_ids ??= source.legacyBilling.price_ids;
}
}
if ( Object.keys(appStoreAndPurchases).length ) out.appStoreAndPurchases = appStoreAndPurchases;
// Abuse / clickhouse / cf_file_cache pass through if already top-level.
if ( source.abuse ) out.abuse = source.abuse;
if ( source.clickhouse ) out.clickhouse = source.clickhouse;
if ( source.cf_file_cache ) out.cf_file_cache = source.cf_file_cache;
// Redis: v1 shape was `redis.config: [{host,port},…]`; v2 IRedisConfig
// expects `redis.startupNodes: [{host,port},…]`.
if ( source.redis && typeof source.redis === 'object' ) {
const { config: nodes, ...rest } = source.redis;
out.redis = { ...rest };
if ( Array.isArray(nodes) ) out.redis.startupNodes = nodes;
else if ( Array.isArray(source.redis.startupNodes) ) out.redis.startupNodes = source.redis.startupNodes;
}
// v1 services that became top-level IConfig entries (some with renames).
if ( svc.oidc ) out.oidc = svc.oidc;
if ( svc.wisp ) out.wisp = svc.wisp;
if ( svc.peer ) out.peers = svc.peer;
if ( svc.broadcast ) out.broadcast = svc.broadcast;
if ( svc['worker-service'] ) out.workers = svc['worker-service'];
if ( svc['entri-service'] ) out.entri = svc['entri-service'];
// v1's services.thumbnails wrapped the bucket config in `.bucket` and also
// carried an unrelated `engine`/`host` pointer to the thumbnail HTTP
// service. v2's IThumbnailStoreConfig is strictly the bucket — unwrap.
if ( svc.thumbnails?.bucket ) out.thumbnailStore = svc.thumbnails.bucket;
// v2 onlyoffice extension reads `config.onlyoffice` (same field names as
// v1's `services.onlyoffice-app`), so it's a straight rename.
if ( svc['onlyoffice-app'] ) out.onlyoffice = svc['onlyoffice-app'];
// Cloudflare Turnstile: v2 GUI renders the challenge widget when
// `gui_params.turnstileSiteKey` is set (see initgui.js / UIWindowSignup).
// Preserve the full block at `turnstile` for whenever the backend verifier
// is ported, and surface the site key into gui_params so the widget works.
if ( svc['cloudflare-turnstile'] ) {
out.turnstile = svc['cloudflare-turnstile'];
if ( svc['cloudflare-turnstile'].site_key ) {
out.gui_params = out.gui_params ?? {};
out.gui_params.turnstileSiteKey = svc['cloudflare-turnstile'].site_key;
}
}
// AI / integration providers: v1 kept each under `services.<id>` (plus a
// top-level `openai` shortcut in some configs). v2 unifies them under
// `providers[<id>]` and accepts only the canonical camelCase field names
// on IAIProviderConfig, so we rename the common snake_case aliases here.
const PROVIDER_IDS = [
'openai', 'claude', 'gemini', 'mistral', 'groq', 'deepseek',
'xai', 'openrouter', 'together-ai', 'ollama',
'elevenlabs', 'aws-polly', 'aws-textract', 'mistral-ocr', 'cloudflare',
'openai-completion', 'openai-responses',
'openai-image-generation',
'gemini-image-generation', 'gemini-video-generation',
'together-image-generation', 'together-video-generation',
'cloudflare-image-generation', 'xai-image-generation',
'replicate-image-generation',
];
const PROVIDER_RENAMES = [
['api_key', 'apiKey'], ['secret_key', 'apiKey'], ['key', 'apiKey'],
['api_token', 'apiToken'],
['api_base_url', 'apiBaseUrl'],
['account_id', 'accountId'],
['default_voice_id', 'defaultVoiceId'],
['speech_to_speech_model_id', 'speechToSpeechModelId'],
];
const normalizeProvider = (raw) => {
if ( ! raw || typeof raw !== 'object' ) return raw;
const p = { ...raw };
for ( const [from, to] of PROVIDER_RENAMES ) {
if ( p[from] !== undefined && p[to] === undefined ) p[to] = p[from];
delete p[from];
}
return p;
};
const providers = {};
for ( const id of PROVIDER_IDS ) {
if ( svc[id] ) providers[id] = normalizeProvider(svc[id]);
}
// v1 AWS aliases: some configs shortened `aws-polly` → `polly`,
// `aws-textract` → `textract`. v2 provider ids keep the prefix.
if ( svc.polly && providers['aws-polly'] === undefined ) {
providers['aws-polly'] = normalizeProvider(svc.polly);
}
if ( svc.textract && providers['aws-textract'] === undefined ) {
providers['aws-textract'] = normalizeProvider(svc.textract);
}
if ( source.openai && providers.openai === undefined ) {
providers.openai = normalizeProvider(source.openai);
}
// v1 had a single `services.replicate` that the image driver keyed on;
// v2 splits providers by capability, so the image one lands at
// `providers['replicate-image-generation']`.
if ( svc.replicate && providers['replicate-image-generation'] === undefined ) {
providers['replicate-image-generation'] = normalizeProvider(svc.replicate);
}
// Backward-compat fan-out: v1 had a single `openai` / `gemini` / `together-ai`
// / `xai` entry used for chat + image + video. v2 drivers look up split ids
// (e.g. `openai-completion`, `openai-image-generation`), so seed each
// split id from the base id when the split key isn't already set.
const FAN_OUT = {
openai: ['openai-completion', 'openai-responses', 'openai-image-generation'],
gemini: ['gemini-image-generation', 'gemini-video-generation'],
'together-ai': ['together-image-generation', 'together-video-generation'],
xai: ['xai-image-generation'],
};
for ( const [base, splits] of Object.entries(FAN_OUT) ) {
if ( ! providers[base] ) continue;
for ( const split of splits ) {
if ( providers[split] === undefined ) providers[split] = providers[base];
}
}
if ( Object.keys(providers).length ) out.providers = providers;
// Anything left in `services` that we didn't claim above is promoted to
// top-level (v2's IConfig has no `services` bag). Known consumers that
// live outside `services` in v2 are listed in `consumedServiceKeys` so
// we don't double-emit; known-dead v1 services are listed in
// `droppedServiceKeys` so their data is intentionally discarded.
const consumedServiceKeys = new Set([
'database', 'dynamo', 'email', 'captcha', 'puter-homepage',
'stripe', 'offerings', '__subs-serve',
'oidc', 'wisp', 'peer', 'broadcast', 'worker-service', 'entri-service',
'thumbnails', 'onlyoffice-app', 'cloudflare-turnstile', 'replicate',
'polly', 'textract',
...PROVIDER_IDS,
]);
const droppedServiceKeys = new Set([
// v1 services with no v2 equivalent — data intentionally discarded.
'heap-monitor', 'file-cache', 'telemetry', 'monitor', 'spending',
'judge0', 'convert-api',
// `auth.uuid_fpe_key` — v2 AuthService uses plain session UUIDs
// (see services/auth/types.ts: "not FPE-encrypted").
'auth',
// SNS bounce handler not ported to v2 — no v2 SNSService.
'sns',
// v1 config-only orphans: never actually read by v1 backend
// (confirmed via git grep on puter@main), no v2 consumers.
'ipgeo', 'newsdata', 'weather', 'user-send-mail',
// `ai-chat.concurrentRequests` — v1 concurrency limiter not ported
// yet; see TODO in v2 drivers/ai-chat/ChatCompletionDriver.ts. Config
// shape should move under `rate_limit.*` in IConfig when reintroduced,
// so dropping avoids migrating a soon-to-be-renamed shape.
'ai-chat',
// v1-only services with no v2 analogue.
'web-server', // `disable_ip_validate_event` — flag gone
'puter-kvstore', // v2 uses `dynamo` for system KV
]);
for ( const [k, v] of Object.entries(svc) ) {
if ( consumedServiceKeys.has(k) || droppedServiceKeys.has(k) ) continue;
if ( out[k] === undefined ) out[k] = v;
}
// Top-level v1 keys that v2 OSS doesn't read and no extension claims.
// Silently dropped — everything else falls through to the preservation
// loop below in case it belongs to a prod extension we're not aware of.
const droppedTopKeys = new Set([
// `puter_hosted_data.puter_versions` — set in v1 config.js but never
// read. Planted for a version-check feature that never shipped.
'puter_hosted_data',
// v1 dev-only toggles with no v2 equivalent. Dev vs prod behaviour
// in v2 branches off `env === 'dev'` and `config.abuse.enabled`.
'disable_abuse_checks',
'undefined_origin_allowed',
]);
for ( const k of droppedTopKeys ) delete out[k];
// Preserve any other top-level keys we haven't explicitly translated
// (custom extension configs etc.).
const handledTop = new Set([
'config_name', 'env', 'http_port', 'port', 'pub_port', 'domain', 'protocol',
'blocked_email_domains', 'toConsole', 'is_storage_limited',
'legacy_token_migrate', 'forwarded', 'cross_origin_isolation',
'enable_public_folders', 'cookie_name', 'jwt_secret', 'url_signature_secret',
'extensions', 'mod_directories',
'db_host', 'db_port', 'db_user', 'db_password', 'db_database',
'db_waitForConnections', 'db_connectionLimit', 'db_enableKeepAlive',
'db_queueLimit', 'db_read_replica_wait', 'read_replica_db',
's3_access_key', 's3_secret_key', 's3_bucket', 's3_region', 's3_endpoint',
'mailchimp', 'cloudwatch', 'monitor',
'smtp_server', 'smtp_host', 'smtp_port', 'smpt_port', 'smtp_username', 'smtp_password',
'max_subdomains_per_user',
'storage_capacity',
'static_hosting_domain', 'static_hosting_domain_alt',
'private_app_hosting_domain', 'private_app_hosting_domain_alt',
'openai',
'pager',
'defaultjs_asset_path',
'services',
'server_id', 'id', 'region', 'host',
'nginx_mode', 'contact_email',
'api_base_url', 'origin',
'min_pass_length', 'allow_system_login', 'allow_all_host_values',
'allow_no_host_header', 'allow_nipio_domains', 'custom_domains_enabled',
'enable_ip_validation',
'default_user_group', 'default_temp_group',
'abuse', 'clickhouse', 'cf_file_cache', 'legacyBilling', 'appStoreAndPurchases',
'providers', 'thumbnailStore',
'redis', 'extension',
...droppedTopKeys,
]);
for ( const [k, v] of Object.entries(source) ) {
if ( handledTop.has(k) || k === '' ) continue;
out[k] = v;
}
return out;
};