diff --git a/.github/workflows/public-sdk.yml b/.github/workflows/public-sdk.yml index 79fefad..a081a7c 100644 --- a/.github/workflows/public-sdk.yml +++ b/.github/workflows/public-sdk.yml @@ -23,7 +23,7 @@ jobs: cache: npm - run: npm ci - run: npm audit --omit=dev --audit-level=high - - run: node --test test/cloud-lite.test.mjs test/cloud-lite-provider.test.mjs test/public-sdk.test.mjs test/template-catalog.test.mjs test/factory-team-template.test.mjs test/flujo-workspace.test.mjs test/savia-case-plan.test.mjs test/savia-topology.test.mjs test/managed-fleet.test.mjs test/managed-fleet-real-module.test.mjs test/managed-fleet-provider-closure.test.mjs test/provider-cell-retirement.test.mjs test/presentation.test.mjs test/presentation-modal.test.mjs test/presentation-closure.test.mjs test/observatory-client.test.mjs test/workflows.test.mjs test/workflow-native-wake.test.mjs test/workflow-m0-lifecycle.test.mjs test/workflow-m1-portable-artifacts.test.mjs test/workflow-m2-ack-recovery.test.mjs test/workflow-m2-authority-recovery.test.mjs + - run: node --test test/cloud-lite.test.mjs test/cloud-lite-provider.test.mjs test/model-provider.test.mjs test/public-sdk.test.mjs test/template-catalog.test.mjs test/factory-team-template.test.mjs test/flujo-workspace.test.mjs test/savia-case-plan.test.mjs test/savia-topology.test.mjs test/managed-fleet.test.mjs test/managed-fleet-real-module.test.mjs test/managed-fleet-provider-closure.test.mjs test/provider-cell-retirement.test.mjs test/presentation.test.mjs test/presentation-modal.test.mjs test/presentation-closure.test.mjs test/observatory-client.test.mjs test/workflows.test.mjs test/workflow-native-wake.test.mjs test/workflow-m0-lifecycle.test.mjs test/workflow-m1-portable-artifacts.test.mjs test/workflow-m2-ack-recovery.test.mjs test/workflow-m2-authority-recovery.test.mjs - name: Install and exercise the packed public package shell: bash run: | diff --git a/PUBLIC_SDK.md b/PUBLIC_SDK.md index 6e81fdb..1af8ff8 100644 --- a/PUBLIC_SDK.md +++ b/PUBLIC_SDK.md @@ -191,6 +191,13 @@ those process options. The standalone `startLiteWorker({provider,database,...})` and `liteWorkerCommand({provider,database,...})` are also exported; the latter only builds the process specification. +Local lite workers can also run on a self-hosted model. Pass the same provider +description the cloud plan uses (see +[Self-hosted models](#self-hosted-models-and-operator-driven-jobs)) and its key: +`startLiteWorker({ provider: 'claude', modelProvider, providerKey })`. From the +command line, `factory-lite claude board.sqlite --model-provider endpoint.json` +reads the key from the variable the file names in `envKey`. + ## FLUJO worker dispatch ### Cloud Codex lite workers (development) @@ -279,11 +286,17 @@ await fleet.deploy({ await fleet.command('m0-a1', { id: 'job-1', action: 'message', message: 'First job ...' }); ``` -- **`modelProvider`**: `id`, `envKey` (the variable that carries the key) and - `https` `baseUrls` without credentials are required; `name`, `model`, - `contextWindow`, `autoCompactTokenLimit`, `toolOutputTokenLimit`, - `requestMaxRetries`, `streamMaxRetries` and `streamIdleTimeoutMs` are optional. - Each Codex gets a `config.toml` with the provider, the model's real context +- **`modelProvider`** is one description of the endpoint that each worker program + maps to its own settings. `id`, `envKey` (the variable that carries the key) and + `https` `baseUrls` (OpenAI-style, ending in `/v1`) without credentials are + required. `apis` lists the wire APIs the server offers: `responses` (Codex), + `anthropic` (Claude Code, `/v1/messages`) and `chat`. vLLM serves all three; the + default is `['responses']`. `name`, `model`, `contextWindow`, + `autoCompactTokenLimit`, `toolOutputTokenLimit`, `requestMaxRetries`, + `streamMaxRetries`, `streamIdleTimeoutMs` and `requestTimeoutMs` are optional. + `codexProviderConfig`, `codexProviderOverrides` and `claudeProviderEnvironment` + are exported mappings; `providerPrograms(provider)` says which programs can use + it. Cloud lite workers run Codex. Each Codex gets a `config.toml` with the provider, the model's real context window and its compaction point; web search, multi-agent, apps, plugins, goals, memories and skill instructions are off, and agents' shell commands inherit only the core environment. The key is a staged Fly app secret placed only in the @@ -291,6 +304,11 @@ await fleet.command('m0-a1', { id: 'job-1', action: 'message', message: 'First j Machine *i* uses `baseUrls[i % n]`, so each machine keeps its prompt cache on one server. Codex offers no `apply_patch` tool to an unknown model, so ask agents to edit files with shell commands. + Claude Code gets the server root as `ANTHROPIC_BASE_URL`, the key as a bearer + token, every model alias on the served model, the real context window and + compaction point, and no telemetry or per-turn token reminder. That reminder + would be an extra system message that templates move to the front of the + prompt, which defeats the server's prompt cache. - **`supervision: 'operator'`**: agents start idle. There is no automatic first turn, follow-up or wake; every turn comes from a durable `fleet.command(agent, { id, action: 'message' | 'stop' })`. The default, diff --git a/bin/factory-lite.mjs b/bin/factory-lite.mjs index cc40c60..a93a8e9 100644 --- a/bin/factory-lite.mjs +++ b/bin/factory-lite.mjs @@ -1,12 +1,28 @@ #!/usr/bin/env node +import { readFileSync } from 'node:fs'; import { Factory } from '../src/public-sdk.mjs'; -const [provider, database, ...words] = process.argv.slice(2); +const usage = 'Usage: factory-lite [--model-provider ] [prompt]\n' + + ' --model-provider use a self-hosted model (e.g. from `factory-selfhost endpoint`); its key is read from the\n' + + ' environment variable the file names in envKey.\n'; +const args = process.argv.slice(2); +const [provider, database] = args; if (provider === '--help' || provider === 'help' || !provider) { - process.stdout.write('Usage: factory-lite [prompt]\n'); + process.stdout.write(usage); } else { try { - const child = new Factory(database).startLiteWorker({ provider, prompt: words.length ? words.join(' ') : undefined }); + const words = args.slice(2); + let modelProvider, providerKey; + const flag = words.indexOf('--model-provider'); + if (flag !== -1) { + const file = words[flag + 1]; + if (!file) throw new Error('--model-provider needs an endpoint description file.'); + words.splice(flag, 2); + modelProvider = JSON.parse(readFileSync(file, 'utf8')); + providerKey = process.env[modelProvider.envKey]; + if (!providerKey) throw new Error(`Set ${modelProvider.envKey} to the endpoint key.`); + } + const child = new Factory(database).startLiteWorker({ provider, prompt: words.length ? words.join(' ') : undefined, modelProvider, providerKey }); child.on('error', error => { process.stderr.write(`${error.message}\n`); process.exitCode = 1; }); child.on('exit', (code, signal) => { process.exitCode = code ?? (signal ? 1 : 0); }); } catch (error) { diff --git a/scripts/verify-public-sdk.mjs b/scripts/verify-public-sdk.mjs index b5e2758..a205c64 100644 --- a/scripts/verify-public-sdk.mjs +++ b/scripts/verify-public-sdk.mjs @@ -93,3 +93,8 @@ const providerPlan = sdk.validateCloudLitePlan({ runId: 'package-smoke', app: 'p supervision: 'operator', sandbox: 'workspace-write', boardTools: false }); assert.equal(providerPlan.modelProvider.autoCompactTokenLimit, 80000); assert.throws(() => sdk.validateCloudLitePlan({ ...providerPlan, modelProvider: { ...providerPlan.modelProvider, baseUrls: ['http://model.example/v1'] } }), TypeError); +// One provider description maps to Codex and Claude Code. +const endpoint = { ...providerPlan.modelProvider, model: 'served-model', apis: ['responses', 'anthropic'] }; +assert.deepEqual(sdk.providerPrograms(endpoint), ['codex', 'claude']); +assert.match(sdk.codexProviderConfig(endpoint), /wire_api = "responses"/); +assert.equal(sdk.claudeProviderEnvironment(endpoint, { key: 'package-smoke-key' }).ANTHROPIC_BASE_URL, 'https://model.example'); diff --git a/src/cloud-lite/protocol.mjs b/src/cloud-lite/protocol.mjs index 0c5bc5d..f79f705 100644 --- a/src/cloud-lite/protocol.mjs +++ b/src/cloud-lite/protocol.mjs @@ -1,4 +1,7 @@ import { createHash, randomBytes } from 'node:crypto'; +import { validateModelProvider, providerApis } from '../model-provider.mjs'; + +export { validateModelProvider }; export const secret = () => randomBytes(32).toString('hex'); export const hash = value => createHash('sha256').update(typeof value === 'string' || Buffer.isBuffer(value) ? value : JSON.stringify(value)).digest('hex'); @@ -26,7 +29,11 @@ export function validateCloudLitePlan(input) { if(plan.shutdown){ if(!plan.collection || typeof plan.shutdown.deadlineUtc!=='string' || !Number.isFinite(Date.parse(plan.shutdown.deadlineUtc)))throw new TypeError('Automatic deadline shutdown requires evidence collection and an explicit UTC deadline.'); } - if (plan.modelProvider !== undefined) validateModelProvider(plan.modelProvider); + if (plan.modelProvider !== undefined) { + validateModelProvider(plan.modelProvider); + // Cloud lite workers run Codex, which talks to custom providers through the Responses API. + if (!providerApis(plan.modelProvider).includes('responses')) throw new TypeError('Cloud lite workers need a provider that offers the responses API.'); + } if (plan.supervision !== undefined && !['autonomous', 'operator'].includes(plan.supervision)) throw new TypeError('supervision must be autonomous or operator.'); if (plan.sandbox !== undefined && !CODEX_SANDBOXES.includes(plan.sandbox)) throw new TypeError(`sandbox must be one of ${CODEX_SANDBOXES.join(', ')}.`); for (const name of ['boardTools', 'traceDeltas']) if (plan[name] !== undefined && typeof plan[name] !== 'boolean') throw new TypeError(`${name} must be true or false.`); @@ -43,30 +50,6 @@ export function validateCloudLitePlan(input) { return plan; } export const CODEX_SANDBOXES = ['danger-full-access', 'workspace-write', 'read-only']; -// Variables the runtime sets for the Codex process itself; a provider key must not replace them. -const RESERVED_ENVIRONMENT = new Set(['PATH', 'HOME', 'CODEX_HOME', 'GH_TOKEN', 'GIT_TERMINAL_PROMPT']); -const credentialFreeHttps = value => { - try { const url = new URL(value); return url.protocol === 'https:' && !url.username && !url.password && !url.search && !url.hash; } - catch { return false; } -}; -/** A self-hosted OpenAI-compatible (Responses API) model provider for the Codex workers. */ -export function validateModelProvider(provider) { - if (!provider || typeof provider !== 'object' || Array.isArray(provider)) throw new TypeError('modelProvider must be an object.'); - if (!/^[a-z][a-z0-9_-]{1,40}$/.test(provider.id ?? '')) throw new TypeError('modelProvider.id must be a short lowercase name.'); - if (!/^[A-Z][A-Z0-9_]{2,60}$/.test(provider.envKey ?? '') || RESERVED_ENVIRONMENT.has(provider.envKey)) throw new TypeError('modelProvider.envKey must name a dedicated environment variable.'); - if (!Array.isArray(provider.baseUrls) || !provider.baseUrls.length || provider.baseUrls.length > 64 || !provider.baseUrls.every(credentialFreeHttps)) - throw new TypeError('modelProvider.baseUrls must list https URLs without credentials, query or fragment.'); - for (const name of ['name', 'model']) if (provider[name] !== undefined && (typeof provider[name] !== 'string' || !provider[name].trim() || provider[name].length > 200)) throw new TypeError(`modelProvider.${name} must be short text.`); - for (const name of ['contextWindow', 'autoCompactTokenLimit', 'toolOutputTokenLimit']) { - if (provider[name] !== undefined && (!Number.isSafeInteger(provider[name]) || provider[name] < 1000)) throw new TypeError(`modelProvider.${name} must be a token count of at least 1000.`); - } - if (provider.contextWindow && provider.autoCompactTokenLimit && provider.autoCompactTokenLimit >= provider.contextWindow) throw new TypeError('modelProvider.autoCompactTokenLimit must be below contextWindow.'); - for (const name of ['requestMaxRetries', 'streamMaxRetries']) { - if (provider[name] !== undefined && (!Number.isSafeInteger(provider[name]) || provider[name] < 0 || provider[name] > 100)) throw new TypeError(`modelProvider.${name} must be between 0 and 100.`); - } - if (provider.streamIdleTimeoutMs !== undefined && (!Number.isSafeInteger(provider.streamIdleTimeoutMs) || provider.streamIdleTimeoutMs < 1000)) throw new TypeError('modelProvider.streamIdleTimeoutMs must be at least 1000.'); - return provider; -} export function agentInventory(plan) { return Array.from({ length: plan.machines }, (_, machine) => Array.from({ length: plan.agentsPerMachine }, (_, agent) => ({ id: `m${machine}-a${agent}`, machine: `m${machine}`, role: agent === 0 ? 'orchestrator' : 'subagent', diff --git a/src/cloud-lite/runtime.mjs b/src/cloud-lite/runtime.mjs index e7ecaea..2b56b75 100644 --- a/src/cloud-lite/runtime.mjs +++ b/src/cloud-lite/runtime.mjs @@ -11,6 +11,7 @@ import { CloudLiteTrace } from './trace.mjs'; import { NativeOperatorStop } from './operator-stop.mjs'; import { NativeWorkflowWake } from './workflow-wake.mjs'; import { hash } from './protocol.mjs'; +import { codexProviderConfig } from '../model-provider.mjs'; const threadStatuses = new Set(['active','idle','archived','notLoaded','systemError','notFound']); const turnStatuses = new Set(['inProgress','completed','interrupted','failed']); @@ -401,47 +402,9 @@ export function nativeAgentConfig({ plan, slot, directory, board }) { return { sandbox, config }; } -const tomlString = value => JSON.stringify(String(value)); - -/** Codex configuration for a self-hosted OpenAI-compatible (Responses API) model provider. */ +/** Codex configuration for the plan's self-hosted provider on machine `index`. */ export function providerConfigToml(plan, index) { - const p = plan.modelProvider; - return [ - `model = ${tomlString(p.model ?? plan.model)}`, - `model_provider = ${tomlString(p.id)}`, - `model_reasoning_effort = ${tomlString(plan.reasoningEffort)}`, - ...(p.contextWindow ? [`model_context_window = ${Number(p.contextWindow)}`] : []), - ...(p.autoCompactTokenLimit ? [`model_auto_compact_token_limit = ${Number(p.autoCompactTokenLimit)}`] : []), - ...(p.toolOutputTokenLimit ? [`tool_output_token_limit = ${Number(p.toolOutputTokenLimit)}`] : []), - 'web_search = "disabled"', - 'check_for_update_on_startup = false', - 'cli_auth_credentials_store = "ephemeral"', - '', - '[features]', - 'multi_agent = false', - 'goals = false', - 'apps = false', - 'plugins = false', - 'memories = false', - '', - '[skills]', - 'include_instructions = false', - '', - // Agents' shell commands get only the basic variables, never the provider key. - '[shell_environment_policy]', - 'inherit = "core"', - '', - `[model_providers.${p.id}]`, - `name = ${tomlString(p.name ?? p.id)}`, - // Machines are spread over the endpoints, so each keeps its prompt cache on one server. - `base_url = ${tomlString(p.baseUrls[index % p.baseUrls.length])}`, - `env_key = ${tomlString(p.envKey)}`, - 'wire_api = "responses"', - `request_max_retries = ${Number(p.requestMaxRetries ?? 8)}`, - `stream_max_retries = ${Number(p.streamMaxRetries ?? 10)}`, - `stream_idle_timeout_ms = ${Number(p.streamIdleTimeoutMs ?? 900000)}`, - '', - ].join('\n'); + return codexProviderConfig(plan.modelProvider, { index, model: plan.model, reasoningEffort: plan.reasoningEffort }); } /** diff --git a/src/lite-worker.mjs b/src/lite-worker.mjs index f46e0c3..8d4205f 100644 --- a/src/lite-worker.mjs +++ b/src/lite-worker.mjs @@ -3,26 +3,43 @@ import { fileURLToPath } from 'node:url'; import { homedir } from 'node:os'; import { spawn } from 'node:child_process'; import { mkdirSync } from 'node:fs'; +import { applyProviderEnvironment, claudeProviderEnvironment, codexProviderOverrides, validateModelProvider } from './model-provider.mjs'; const mcp = fileURLToPath(new URL('../bin/factory-mcp.mjs', import.meta.url)); -/** Build a normal provider CLI command with its native tools and only FACTORY's MCP server. */ -export function liteWorkerCommand({ provider, database, prompt, agentId, cwd = process.cwd() } = {}) { +/** + * Build a normal provider CLI command with its native tools and only FACTORY's MCP server. + * With `modelProvider` (and its `providerKey`) the program talks to a self-hosted model + * instead of its vendor login: Codex through the Responses API, Claude Code through the + * Anthropic Messages API. `providerIndex` picks one of several endpoints. + */ +export function liteWorkerCommand({ provider, database, prompt, agentId, cwd = process.cwd(), modelProvider, providerKey, providerIndex = 0, env = process.env } = {}) { if (!['codex', 'claude'].includes(provider)) throw new TypeError('provider must be codex or claude'); if (typeof database !== 'string' || !database.trim()) throw new TypeError('database path is required'); if (prompt !== undefined && (typeof prompt !== 'string' || !prompt.trim())) throw new TypeError('prompt must be nonempty'); const db = resolve(database); if(agentId!==undefined && !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(agentId)) throw new TypeError('Invalid bound agent identity.'); + if (modelProvider !== undefined) { + validateModelProvider(modelProvider); + if (typeof providerKey !== 'string' || providerKey.length < 8) throw new TypeError('A model provider needs its API key.'); + if (!Number.isSafeInteger(providerIndex) || providerIndex < 0) throw new TypeError('providerIndex must be a nonnegative integer.'); + } const server = { command: process.execPath, args: [mcp, db, ...(agentId ? ['--actor',agentId] : [])] }; if (provider === 'claude') return { - command: 'claude', cwd, args: ['--dangerously-skip-permissions', '--strict-mcp-config', + command: 'claude', cwd, + ...(modelProvider ? { env: applyProviderEnvironment(env, claudeProviderEnvironment(modelProvider, { index: providerIndex, key: providerKey })) } : {}), + args: ['--dangerously-skip-permissions', '--strict-mcp-config', '--mcp-config', JSON.stringify({ mcpServers: { factory: server } }), ...(prompt === undefined ? [] : [prompt])], }; const toml = value => JSON.stringify(value); const config = `mcp_servers={factory={command=${toml(server.command)},args=[${server.args.map(toml).join(',')}]}}`; - return { command: 'codex', cwd, env: { ...process.env, CODEX_HOME: resolve(homedir(), '.factory', 'lite-codex') }, + // A provider gets its own Codex home, so no vendor login or earlier settings apply. + const home = resolve(homedir(), '.factory', modelProvider ? `lite-codex-${modelProvider.id}` : 'lite-codex'); + return { command: 'codex', cwd, env: { ...env, CODEX_HOME: home, ...(modelProvider ? { [modelProvider.envKey]: providerKey } : {}) }, args: ['--sandbox', 'danger-full-access', '--ask-for-approval', 'never', - '--config', config, ...(prompt === undefined ? [] : [prompt])] }; + '--config', config, + ...(modelProvider ? codexProviderOverrides(modelProvider, { index: providerIndex }).flatMap(entry => ['--config', entry]) : []), + ...(prompt === undefined ? [] : [prompt])] }; } /** Launch an interactive native CLI session and return its ChildProcess. */ diff --git a/src/model-provider.mjs b/src/model-provider.mjs new file mode 100644 index 0000000..6e03367 --- /dev/null +++ b/src/model-provider.mjs @@ -0,0 +1,152 @@ +// One description of a self-hosted model endpoint, mapped to each worker program's own +// configuration. vLLM serves the same model through the OpenAI Responses API (Codex), the +// Anthropic Messages API (Claude Code) and Chat Completions. + +/** Wire APIs an endpoint can offer. */ +export const MODEL_PROVIDER_APIS = ['responses', 'chat', 'anthropic']; +/** The wire API each worker program needs. */ +export const WORKER_PROGRAM_APIS = Object.freeze({ codex: 'responses', claude: 'anthropic' }); +// Variables the launchers set for the worker process itself; a provider key must not replace them. +const RESERVED_ENVIRONMENT = new Set(['PATH', 'HOME', 'CODEX_HOME', 'GH_TOKEN', 'GIT_TERMINAL_PROMPT', + 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', 'ANTHROPIC_BASE_URL', 'CLAUDE_CODE_OAUTH_TOKEN']); +const credentialFreeHttps = value => { + try { const url = new URL(value); return url.protocol === 'https:' && !url.username && !url.password && !url.search && !url.hash; } + catch { return false; } +}; + +/** + * A self-hosted, OpenAI-compatible model endpoint. `baseUrls` are OpenAI-style base URLs + * (usually ending in `/v1`); several URLs are separate servers of the same model. `apis` + * lists the wire APIs the server offers (default `['responses']`). + */ +export function validateModelProvider(provider) { + if (!provider || typeof provider !== 'object' || Array.isArray(provider)) throw new TypeError('modelProvider must be an object.'); + if (!/^[a-z][a-z0-9_-]{1,40}$/.test(provider.id ?? '')) throw new TypeError('modelProvider.id must be a short lowercase name.'); + if (!/^[A-Z][A-Z0-9_]{2,60}$/.test(provider.envKey ?? '') || RESERVED_ENVIRONMENT.has(provider.envKey)) throw new TypeError('modelProvider.envKey must name a dedicated environment variable.'); + if (!Array.isArray(provider.baseUrls) || !provider.baseUrls.length || provider.baseUrls.length > 64 || !provider.baseUrls.every(credentialFreeHttps)) + throw new TypeError('modelProvider.baseUrls must list https URLs without credentials, query or fragment.'); + for (const name of ['name', 'model']) if (provider[name] !== undefined && (typeof provider[name] !== 'string' || !provider[name].trim() || provider[name].length > 200)) throw new TypeError(`modelProvider.${name} must be short text.`); + if (provider.apis !== undefined && (!Array.isArray(provider.apis) || !provider.apis.length || !provider.apis.every(api => MODEL_PROVIDER_APIS.includes(api)))) + throw new TypeError(`modelProvider.apis must list ${MODEL_PROVIDER_APIS.join(', ')}.`); + for (const name of ['contextWindow', 'autoCompactTokenLimit', 'toolOutputTokenLimit']) { + if (provider[name] !== undefined && (!Number.isSafeInteger(provider[name]) || provider[name] < 1000)) throw new TypeError(`modelProvider.${name} must be a token count of at least 1000.`); + } + if (provider.contextWindow && provider.autoCompactTokenLimit && provider.autoCompactTokenLimit >= provider.contextWindow) throw new TypeError('modelProvider.autoCompactTokenLimit must be below contextWindow.'); + for (const name of ['requestMaxRetries', 'streamMaxRetries']) { + if (provider[name] !== undefined && (!Number.isSafeInteger(provider[name]) || provider[name] < 0 || provider[name] > 100)) throw new TypeError(`modelProvider.${name} must be between 0 and 100.`); + } + for (const name of ['streamIdleTimeoutMs', 'requestTimeoutMs']) { + if (provider[name] !== undefined && (!Number.isSafeInteger(provider[name]) || provider[name] < 1000)) throw new TypeError(`modelProvider.${name} must be at least 1000.`); + } + return provider; +} + +/** The wire APIs a provider offers (the default keeps 0.4.0 descriptions valid for Codex). */ +export const providerApis = provider => provider.apis ?? ['responses']; + +/** Which worker programs can use this provider. */ +export function providerPrograms(provider) { + const apis = providerApis(provider); + return Object.entries(WORKER_PROGRAM_APIS).filter(([, api]) => apis.includes(api)).map(([program]) => program); +} + +function requireApi(provider, program) { + validateModelProvider(provider); + if (!providerApis(provider).includes(WORKER_PROGRAM_APIS[program])) { + throw new TypeError(`${program} needs a provider that offers the ${WORKER_PROGRAM_APIS[program]} API.`); + } +} + +/** Spread workers over the endpoints so each keeps its prompt cache on one server. */ +export const providerBaseUrl = (provider, index = 0) => provider.baseUrls[Math.abs(index) % provider.baseUrls.length]; + +// Codex: settings as TOML-ready entries, rendered either as config.toml or as --config overrides. +function codexEntries(provider, { index = 0, model, reasoningEffort } = {}) { + requireApi(provider, 'codex'); + const top = [ + ['model', provider.model ?? model], + ['model_provider', provider.id], + ...(reasoningEffort ? [['model_reasoning_effort', reasoningEffort]] : []), + ...(provider.contextWindow ? [['model_context_window', provider.contextWindow]] : []), + ...(provider.autoCompactTokenLimit ? [['model_auto_compact_token_limit', provider.autoCompactTokenLimit]] : []), + ...(provider.toolOutputTokenLimit ? [['tool_output_token_limit', provider.toolOutputTokenLimit]] : []), + ['web_search', 'disabled'], + ['check_for_update_on_startup', false], + ['cli_auth_credentials_store', 'ephemeral'], + ]; + if (typeof top[0][1] !== 'string' || !top[0][1]) throw new TypeError('Select the served model name.'); + const tables = [ + ['features', [['multi_agent', false], ['goals', false], ['apps', false], ['plugins', false], ['memories', false]]], + ['skills', [['include_instructions', false]]], + // Agents' shell commands get only the basic variables, never the provider key. + ['shell_environment_policy', [['inherit', 'core']]], + [`model_providers.${provider.id}`, [ + ['name', provider.name ?? provider.id], + ['base_url', providerBaseUrl(provider, index)], + ['env_key', provider.envKey], + ['wire_api', 'responses'], + ['request_max_retries', provider.requestMaxRetries ?? 8], + ['stream_max_retries', provider.streamMaxRetries ?? 10], + ['stream_idle_timeout_ms', provider.streamIdleTimeoutMs ?? 900000], + ]], + ]; + return { top, tables }; +} +const tomlValue = value => typeof value === 'string' ? JSON.stringify(value) : String(value); + +/** Codex `config.toml` for a self-hosted provider (Responses API). The key is never written. */ +export function codexProviderConfig(provider, options = {}) { + const { top, tables } = codexEntries(provider, options); + return [...top.map(([key, value]) => `${key} = ${tomlValue(value)}`), '', + ...tables.flatMap(([table, entries]) => [`[${table}]`, ...entries.map(([key, value]) => `${key} = ${tomlValue(value)}`), '']), + ].join('\n'); +} + +/** The same Codex settings as `--config key=value` overrides (for an existing CODEX_HOME). */ +export function codexProviderOverrides(provider, options = {}) { + const { top, tables } = codexEntries(provider, options); + return [...top.map(([key, value]) => `${key}=${tomlValue(value)}`), + ...tables.flatMap(([table, entries]) => entries.map(([key, value]) => `${table}.${key}=${tomlValue(value)}`))]; +} + +/** + * Claude Code environment for a self-hosted provider (Anthropic Messages API at the server + * root). Every model alias maps to the served model; nonessential traffic is off. Keys whose + * value is null must be removed from the inherited environment. + */ +export function claudeProviderEnvironment(provider, { index = 0, key, model } = {}) { + requireApi(provider, 'claude'); + if (typeof key !== 'string' || key.length < 8) throw new TypeError('A model provider needs its API key.'); + const served = provider.model ?? model; + if (typeof served !== 'string' || !served) throw new TypeError('Select the served model name.'); + return { + ANTHROPIC_BASE_URL: providerBaseUrl(provider, index).replace(/\/+$/, '').replace(/\/v1$/, ''), + ANTHROPIC_AUTH_TOKEN: key, + ANTHROPIC_API_KEY: null, + CLAUDE_CODE_OAUTH_TOKEN: null, + ANTHROPIC_MODEL: served, + ANTHROPIC_DEFAULT_OPUS_MODEL: served, + ANTHROPIC_DEFAULT_SONNET_MODEL: served, + ANTHROPIC_DEFAULT_HAIKU_MODEL: served, + ANTHROPIC_SMALL_FAST_MODEL: served, + CLAUDE_CODE_SUBAGENT_MODEL: served, + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', + DISABLE_TELEMETRY: '1', + DISABLE_ERROR_REPORTING: '1', + DISABLE_AUTOUPDATER: '1', + // A shared GPU may queue a request for a while. + API_TIMEOUT_MS: String(provider.requestTimeoutMs ?? 1800000), + // The per-turn token reminder is an extra system message; chat templates that allow one + // system message move it to the front, which defeats the server's prompt cache. + CLAUDE_CODE_TOTAL_TOKENS_REMINDER: 'off', + ...(provider.contextWindow ? { CLAUDE_CODE_MAX_CONTEXT_TOKENS: String(provider.contextWindow) } : {}), + ...(provider.autoCompactTokenLimit ? { CLAUDE_CODE_AUTO_COMPACT_WINDOW: String(provider.autoCompactTokenLimit) } : {}), + }; +} + +/** Apply a mapping's environment to an inherited one (null removes a variable). */ +export function applyProviderEnvironment(base, overlay) { + const env = { ...base }; + for (const [name, value] of Object.entries(overlay)) { if (value === null) delete env[name]; else env[name] = value; } + return env; +} diff --git a/src/public-sdk.mjs b/src/public-sdk.mjs index c54f7b2..c75ef1a 100644 --- a/src/public-sdk.mjs +++ b/src/public-sdk.mjs @@ -97,6 +97,7 @@ export { observeSaviaCaseTopology } from './flujo-swarm/template/savia-topology. export { createPresentationServer, startPresentationServer } from './presentation.mjs'; export { createObservatoryClient } from './observatory-client.mjs'; export { liteWorkerCommand, startLiteWorker } from './lite-worker.mjs'; +export { MODEL_PROVIDER_APIS, WORKER_PROGRAM_APIS, validateModelProvider, providerPrograms, codexProviderConfig, codexProviderOverrides, claudeProviderEnvironment } from './model-provider.mjs'; export { FactoryCloudLiteFleet, CodexSubscriptionBroker } from './cloud-lite/fleet.mjs'; export { validateCloudLitePlan, agentInventory, machineConfig as cloudLiteMachineConfig } from './cloud-lite/protocol.mjs'; export { cloudLiteBootstrap } from './cloud-lite/bootstrap.mjs'; diff --git a/test/model-provider.test.mjs b/test/model-provider.test.mjs new file mode 100644 index 0000000..2054156 --- /dev/null +++ b/test/model-provider.test.mjs @@ -0,0 +1,85 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { claudeProviderEnvironment, codexProviderConfig, codexProviderOverrides, liteWorkerCommand, providerPrograms, validateModelProvider } from '../src/public-sdk.mjs'; +import { validateCloudLitePlan, secret } from '../src/cloud-lite/protocol.mjs'; +import { providerConfigToml } from '../src/cloud-lite/runtime.mjs'; + +// One description of a self-hosted vLLM endpoint that serves every wire API. +const endpoint = (extra = {}) => ({ id: 'selfhosted', name: 'Self-hosted model', model: 'served-model', envKey: 'SELF_HOSTED_MODEL_KEY', + baseUrls: ['https://model-a.example.test/v1', 'https://model-b.example.test/v1'], apis: ['responses', 'chat', 'anthropic'], + contextWindow: 262144, autoCompactTokenLimit: 100000, ...extra }); + +test('one provider description states which worker programs can use it', () => { + assert.deepEqual(providerPrograms(endpoint()), ['codex', 'claude']); + assert.deepEqual(providerPrograms(endpoint({ apis: undefined })), ['codex'], '0.4.0 descriptions keep working for Codex'); + assert.deepEqual(providerPrograms(endpoint({ apis: ['anthropic'] })), ['claude']); + for (const extra of [{ apis: [] }, { apis: ['grpc'] }, { envKey: 'ANTHROPIC_AUTH_TOKEN' }, { requestTimeoutMs: 10 }]) assert.throws(() => validateModelProvider(endpoint(extra)), TypeError, JSON.stringify(extra)); + assert.throws(() => validateCloudLitePlan({ runId: 'claude-only', app: 'claude-only-app', goal: 'x', modelProvider: endpoint({ apis: ['anthropic'] }) }), /responses API/); +}); + +test('the Codex mapping is shared by cloud and local workers and never carries the key', () => { + const plan = validateCloudLitePlan({ runId: 'mapping-run', app: 'mapping-app', goal: 'x', model: 'ignored-plan-model', reasoningEffort: 'high', modelProvider: endpoint() }); + assert.equal(providerConfigToml(plan, 1), codexProviderConfig(endpoint(), { index: 1, reasoningEffort: 'high' })); + const toml = codexProviderConfig(endpoint(), { index: 1, reasoningEffort: 'high' }); + assert.match(toml, /^model = "served-model"$/m); + assert.match(toml, /^model_auto_compact_token_limit = 100000$/m); + assert.match(toml, /^base_url = "https:\/\/model-b\.example\.test\/v1"$/m); + const overrides = codexProviderOverrides(endpoint(), { index: 2 }); + assert.ok(overrides.includes('model_provider="selfhosted"')); + assert.ok(overrides.includes('model_providers.selfhosted.base_url="https://model-a.example.test/v1"')); + assert.ok(overrides.includes('model_providers.selfhosted.wire_api="responses"')); + assert.ok(overrides.includes('shell_environment_policy.inherit="core"')); + assert.ok(overrides.includes('model_context_window=262144')); + assert.equal(overrides.some(entry => entry.startsWith('model_reasoning_effort')), false); + assert.throws(() => codexProviderConfig(endpoint({ apis: ['anthropic'] })), /responses API/); + assert.throws(() => codexProviderConfig(endpoint({ model: undefined })), /served model/); +}); + +test('the Claude Code mapping uses the Anthropic API at the server root with every alias on the served model', () => { + const key = secret(); + const env = claudeProviderEnvironment(endpoint(), { index: 1, key }); + assert.equal(env.ANTHROPIC_BASE_URL, 'https://model-b.example.test'); + assert.equal(env.ANTHROPIC_AUTH_TOKEN, key); + assert.equal(env.ANTHROPIC_API_KEY, null); assert.equal(env.CLAUDE_CODE_OAUTH_TOKEN, null); + for (const name of ['ANTHROPIC_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', 'ANTHROPIC_SMALL_FAST_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL']) assert.equal(env[name], 'served-model', name); + assert.equal(env.CLAUDE_CODE_MAX_CONTEXT_TOKENS, '262144'); + assert.equal(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW, '100000'); + assert.equal(env.CLAUDE_CODE_TOTAL_TOKENS_REMINDER, 'off'); + assert.equal(env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, '1'); + assert.throws(() => claudeProviderEnvironment(endpoint({ apis: ['responses'] }), { key }), /anthropic API/); + assert.throws(() => claudeProviderEnvironment(endpoint(), { key: 'short' }), /API key/); +}); + +test('local lite workers launch Codex and Claude Code against a self-hosted model', () => { + const key = secret(); const inherited = { PATH: '/bin', ANTHROPIC_API_KEY: 'vendor-key-value', CLAUDE_CODE_OAUTH_TOKEN: 'vendor-login' }; + const codex = liteWorkerCommand({ provider: 'codex', database: 'board.sqlite', modelProvider: endpoint(), providerKey: key, providerIndex: 1, env: inherited }); + assert.equal(codex.env.SELF_HOSTED_MODEL_KEY, key); + assert.match(codex.env.CODEX_HOME, /lite-codex-selfhosted$/); + assert.ok(codex.args.includes('model_providers.selfhosted.base_url="https://model-b.example.test/v1"')); + assert.equal(JSON.stringify(codex.args).includes(key), false); + const claude = liteWorkerCommand({ provider: 'claude', database: 'board.sqlite', modelProvider: endpoint(), providerKey: key, env: inherited }); + assert.equal(claude.env.ANTHROPIC_BASE_URL, 'https://model-a.example.test'); + assert.equal(claude.env.ANTHROPIC_AUTH_TOKEN, key); + assert.equal('ANTHROPIC_API_KEY' in claude.env, false); assert.equal('CLAUDE_CODE_OAUTH_TOKEN' in claude.env, false); + assert.equal(claude.env.PATH, '/bin'); + assert.equal(JSON.stringify(claude.args).includes(key), false); + assert.equal(liteWorkerCommand({ provider: 'claude', database: 'board.sqlite' }).env, undefined, 'without a provider Claude keeps its own login'); + assert.throws(() => liteWorkerCommand({ provider: 'codex', database: 'board.sqlite', modelProvider: endpoint() }), /API key/); + assert.throws(() => liteWorkerCommand({ provider: 'claude', database: 'board.sqlite', modelProvider: endpoint({ apis: ['responses'] }), providerKey: key }), /anthropic API/); +}); + +test('factory-lite reads an endpoint description and refuses to start without its key', () => { + const dir = mkdtempSync(join(tmpdir(), 'factory-lite-provider-')); + const file = join(dir, 'endpoint.json'); writeFileSync(file, JSON.stringify(endpoint())); + const cli = fileURLToPath(new URL('../bin/factory-lite.mjs', import.meta.url)); + const env = { ...process.env }; delete env.SELF_HOSTED_MODEL_KEY; + const result = spawnSync(process.execPath, [cli, 'claude', join(dir, 'board.sqlite'), '--model-provider', file, 'hello'], { env, encoding: 'utf8' }); + assert.equal(result.status, 1); + assert.match(result.stderr, /Set SELF_HOSTED_MODEL_KEY to the endpoint key/); + assert.match(spawnSync(process.execPath, [cli, '--help'], { encoding: 'utf8' }).stdout, /--model-provider/); +});