diff --git a/CLAUDE.md b/CLAUDE.md index 2978e242aa..7252a5b69c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -574,7 +574,7 @@ Rule (unchanged): any new social-presence primitive (typing-indicator, read-rece - **Global Integrations UI changes require `reprovision-all`** to take effect — UI writes to DB, provisioner reads DB on each reprovision and writes to `/state/moltbot.json`. -- **Dev agents** (theo/nova/pixel/ops/aria) use `openai-codex/gpt-5.4-mini` for heartbeats via an explicit per-agent override. **Community agents** use `openrouter/nvidia/nemotron-3-super-120b-a12b:free` as primary — no Codex credentials are issued to them, so `openai-codex/*` is gated to dev agents only. **A hard assertion in `applyOpenClawModelDefaults` throws if any `openai-codex/*` model leaks into the community fallback chain** (PR #282) — so a future edit can't silently put community agents on Codex. Trinity removed 2026-05-03 (deregistered at OpenRouter). Gemini placeholders remain in the chain but are inert (`GEMINI_API_KEY` is for project 946211286881 where the API isn't enabled). LiteLLM router does ONE retry on 429 with a 1s delay (`num_retries: 1`, `retry_after: 1`) so the codex-auth-rotator has time to swap auth.json before the retry. +- **Dev agents** (theo/nova/pixel/ops/aria) use `openai-codex/gpt-5.4-mini` for heartbeats via an explicit per-agent override. **Community agents** use `openrouter/deepseek-v4-flash` as explicit primary through LiteLLM (the `openrouter` prefix is the OpenClaw transport label). Nemotron is excluded from the community chain until a probe shows it serving — no Codex credentials are issued to them, so `openai-codex/*` is gated to dev agents only. **A hard assertion in `applyOpenClawModelDefaults` throws if any `openai-codex/*` model leaks into the community fallback chain** (PR #282) — so a future edit can't silently put community agents on Codex. Trinity removed 2026-05-03 (deregistered at OpenRouter). Gemini placeholders remain in the chain but are inert (`GEMINI_API_KEY` is for project 946211286881 where the API isn't enabled). LiteLLM router does ONE retry on 429 with a 1s delay (`num_retries: 1`, `retry_after: 1`) so the codex-auth-rotator has time to swap auth.json before the retry. - **`registry.js` is the permanent source of truth** for heartbeat templates. PVC HEARTBEAT.md edits are overwritten on `reprovision-all`. diff --git a/backend/__tests__/unit/services/agentProvisionerServiceK8s.test.js b/backend/__tests__/unit/services/agentProvisionerServiceK8s.test.js index 20ea517be6..64e519eddc 100644 --- a/backend/__tests__/unit/services/agentProvisionerServiceK8s.test.js +++ b/backend/__tests__/unit/services/agentProvisionerServiceK8s.test.js @@ -34,6 +34,7 @@ const mock = { // these, so this is the only place a test can assert on the delivered file // rather than on the source that composes it. readNamespacedPodLog: jest.fn(), + execHandler: null, execCommands: [], execArgv: [], // What the next exec writes to its stdout sink; empty = no output. @@ -76,6 +77,8 @@ const mock = { ) { mock.execCommands.push(Array.isArray(_command) ? _command.join(' ') : String(_command)); mock.execArgv.push(_command); + const handled = mock.execHandler?.(_command, _stdout, statusCallback); + if (handled) return handled; if (mock.execStdout && _stdout && typeof _stdout.write === 'function') { _stdout.write(mock.execStdout); if (typeof statusCallback === 'function') setImmediate(() => statusCallback({ status: 'Success' })); @@ -99,7 +102,7 @@ const mock = { const k8s = require('@kubernetes/client-node'); const { restartAgentRuntime, provisionAgentRuntime, buildPvcVirtualKeyReadScript, issueLiteLLMVirtualKey, - readOpenClawIdentityFile, getAgentRuntimeLogs, + readOpenClawIdentityFile, getAgentRuntimeLogs, issueLiteLLMOpenRouterKey, } = require('../../../services/agentProvisionerServiceK8s'); const { CYCLES_DIRECTIVE_MARKER } = require('../../../routes/registry/presets'); @@ -388,14 +391,16 @@ describe('agentProvisionerServiceK8s', () => { expect(config.agents.defaults.contextPruning.ttl).toBe('90m'); expect(config.agents.defaults.contextPruning.keepLastAssistants).toBe(2); // Community agents (cuz is not in DEFAULT_DEV_AGENT_IDS) inherit the - // gateway-level default — nemotron, no openai-codex. Codex is gated to + // gateway-level default — DeepSeek, no openai-codex. Codex is gated to // dev agents via the per-agent override in provisionOpenClawAccount. - expect(config.agents.defaults.model.primary).toBe('openrouter/nvidia/nemotron-3-super-120b-a12b:free'); - // Trinity removed 2026-05-03 (deregistered at OpenRouter, 404). Gemini - // entries remain as inert placeholders until GEMINI_API_KEY is reinstated. - expect(config.agents.defaults.model.fallbacks).toEqual( - expect.arrayContaining(['google/gemini-2.5-flash']), - ); + expect(config.agents.defaults.model.primary).toBe('openrouter/deepseek-v4-flash'); + const communityChain = [config.agents.defaults.model.primary, ...config.agents.defaults.model.fallbacks]; + expect(communityChain.some((model) => model.includes('nemotron'))).toBe(false); + expect(config.models.providers.openrouter.models).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'deepseek-v4-flash' }), + ])); + // D81: no cross-vendor substitution after the hosted primary fails. + expect(config.agents.defaults.model.fallbacks).toEqual([]); expect(config.agents.defaults.model.fallbacks).not.toContain('openrouter/arcee-ai/trinity-large-preview:free'); expect(config.agents.defaults.model.fallbacks).not.toContain('openai-codex/gpt-5.4-mini'); expect(config.agents.defaults.model.fallbacks).not.toContain('openai-codex/gpt-5.4'); @@ -536,6 +541,7 @@ describe('issueLiteLLMVirtualKey never sends the master key to LiteLLM', () => { }); afterEach(() => { k8s.__mock.execStdout = ''; + k8s.__mock.execHandler = null; jest.restoreAllMocks(); if (saved.base === undefined) delete process.env.LITELLM_BASE_URL; else process.env.LITELLM_BASE_URL = saved.base; if (saved.master === undefined) delete process.env.LITELLM_MASTER_KEY; else process.env.LITELLM_MASTER_KEY = saved.master; @@ -582,3 +588,143 @@ describe('runtime account log filtering', () => { }, ); }); + +describe('TASK-004 community model isolation', () => { + it('keeps the dev per-agent override unchanged when provisioning the community default', async () => { + await provisionAgentRuntime({ + runtimeType: 'moltbot', agentName: 'openclaw', instanceId: 'theo', + runtimeToken: 'test', baseUrl: 'http://backend', heartbeat: null, + }); + const payload = k8s.__mock.replaceNamespacedConfigMap.mock.calls.at(-1)[2]; + const config = JSON.parse(payload.data['moltbot.json']); + const dev = config.agents.list.find((agent) => agent.id === 'theo'); + expect(dev.model).toEqual({ + primary: 'openai-codex/gpt-5.4-mini', + fallbacks: [ + 'openrouter/nvidia/nemotron-3-super-120b-a12b:free', + 'google/gemini-2.5-flash', 'google/gemini-2.5-flash-lite', 'google/gemini-2.0-flash', + ], + }); + expect(config.agents.defaults.model.primary).toBe('openrouter/deepseek-v4-flash'); + }); + + it.each(['claude-code', 'webhook'])('does not provision gateway models for BYO %s', async (runtimeType) => { + const writes = k8s.__mock.replaceNamespacedConfigMap.mock.calls.length; + await expect(provisionAgentRuntime({ runtimeType, instanceId: 'local-claude' })) + .resolves.toMatchObject({ external: true, runtimeType }); + expect(k8s.__mock.replaceNamespacedConfigMap.mock.calls).toHaveLength(writes); + }); +}); + +describe('community virtual key permits the explicit DeepSeek primary', () => { + const axios = require('axios'); + let saved; + beforeEach(() => { + saved = { base: process.env.LITELLM_BASE_URL, master: process.env.LITELLM_MASTER_KEY }; + process.env.LITELLM_BASE_URL = 'http://litellm:4000'; + process.env.LITELLM_MASTER_KEY = 'sk-master-test'; + k8s.__mock.execStdout = 'sk-community-existing'; + jest.spyOn(axios, 'get').mockResolvedValue({ data: { info: { + metadata: { agent_id: 'liz' }, models: ['gpt-5.4-mini'], + } } }); + jest.spyOn(axios, 'delete').mockResolvedValue({}); + jest.spyOn(axios, 'post').mockResolvedValue({ data: { key: 'sk-community-new' } }); + }); + afterEach(() => { + k8s.__mock.execStdout = ''; + k8s.__mock.execHandler = null; + jest.restoreAllMocks(); + if (saved.base === undefined) delete process.env.LITELLM_BASE_URL; else process.env.LITELLM_BASE_URL = saved.base; + if (saved.master === undefined) delete process.env.LITELLM_MASTER_KEY; else process.env.LITELLM_MASTER_KEY = saved.master; + }); + + it('refreshes an old key that lacks the primary alias and permits its wire model id', async () => { + await expect(issueLiteLLMOpenRouterKey('liz')).resolves.toEqual({ key: 'sk-community-new', staleOwnedKey: 'sk-community-existing' }); + expect(axios.delete).not.toHaveBeenCalled(); + expect(axios.post).toHaveBeenCalledWith('http://litellm:4000/key/generate', expect.objectContaining({ + models: expect.arrayContaining(['deepseek-v4-flash', 'openrouter/deepseek-v4-flash']), + }), expect.anything()); + }); + + it.each(['error', 'missing', 'master'])('preserves the old key and refuses community master fallback on %s issuance', async (mode) => { + if (mode === 'error') axios.post.mockRejectedValue(new Error('unavailable')); + else axios.post.mockResolvedValue({ data: mode === 'master' ? { key: 'sk-master-test' } : {} }); + k8s.__mock.execCommands = []; + const writes = k8s.__mock.replaceNamespacedConfigMap.mock.calls.length; + await expect(provisionAgentRuntime({ + runtimeType: 'moltbot', agentName: 'openclaw', instanceId: 'liz', + runtimeToken: 'test', baseUrl: 'http://backend', heartbeat: null, + })).rejects.toThrow('Community model key unavailable'); + expect(axios.delete).not.toHaveBeenCalled(); + expect(k8s.__mock.replaceNamespacedConfigMap.mock.calls).toHaveLength(writes); + expect(k8s.__mock.execCommands.join('\n')).not.toContain('sk-master-test'); + }); + + it.each(['success', 'skip', 'exec', 'no-pod'])('retires an old key only after a confirmed PVC write: %s', async (mode) => { + const fs = require('fs'); + const os = require('os'); + const path = require('path'); + const { spawnSync } = require('child_process'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'community-key-')); + const profilePath = path.join(dir, 'auth-profiles.json'); + fs.writeFileSync(profilePath, JSON.stringify({ profiles: { 'openrouter:default': { key: 'sk-community-existing' } } })); + const originalTimer = global.setTimeout; + jest.spyOn(global, 'setTimeout').mockImplementation((fn, delay, ...args) => originalTimer(fn, delay === 10000 ? 0 : delay, ...args)); + const podList = k8s.__mock.listNamespacedPod.getMockImplementation(); + let keyAtRetirement; + axios.delete.mockImplementation(async () => { + keyAtRetirement = JSON.parse(fs.readFileSync(profilePath, 'utf8')).profiles['openrouter:default'].key; + return {}; + }); + k8s.__mock.execHandler = (command, stdout, status) => { + if (command[0] !== 'node' || !command[2].includes("store.profiles['openrouter:default'] =")) return undefined; + if (mode === 'exec') { status({ status: 'Failure' }); return Promise.resolve(); } + const target = mode === 'skip' ? path.join(dir, 'missing.json') : profilePath; + const script = command[2].replace('/state/agents/liz/agent/auth-profiles.json', target); + const result = spawnSync(process.execPath, ['-e', script], { encoding: 'utf8' }); + stdout.write(result.stdout); + status({ status: result.status === 0 ? 'Success' : 'Failure' }); + return Promise.resolve(); + }; + if (mode === 'no-pod') { + axios.post.mockImplementation(async () => { + k8s.__mock.listNamespacedPod.mockResolvedValue({ body: { items: [] } }); + let now = Date.now(); + jest.spyOn(Date, 'now').mockImplementation(() => { now += 100000; return now; }); + return { data: { key: 'sk-community-new' } }; + }); + } + const writes = k8s.__mock.replaceNamespacedConfigMap.mock.calls.length; + try { + const provision = provisionAgentRuntime({ runtimeType: 'moltbot', agentName: 'openclaw', instanceId: 'liz', runtimeToken: 'test', baseUrl: 'http://backend', heartbeat: null }); + if (mode === 'success') { + await provision; + expect(axios.delete).toHaveBeenCalledTimes(1); + expect(keyAtRetirement).toBe('sk-community-new'); + } else { + await expect(provision).rejects.toThrow('Community model key was not written'); + expect(axios.delete).not.toHaveBeenCalled(); + expect(k8s.__mock.replaceNamespacedConfigMap.mock.calls).toHaveLength(writes); + expect(JSON.parse(fs.readFileSync(profilePath, 'utf8')).profiles['openrouter:default'].key).toBe('sk-community-existing'); + } + } finally { + k8s.__mock.listNamespacedPod.mockImplementation(podList); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('does not delete a key owned by another agent during replacement', async () => { + axios.get.mockResolvedValue({ data: { info: { metadata: { agent_id: 'other' }, models: [] } } }); + await expect(issueLiteLLMOpenRouterKey('liz')).resolves.toEqual({ key: 'sk-community-new', staleOwnedKey: null }); + expect(axios.delete).not.toHaveBeenCalled(); + }); + + it('reuses an owned key that already allows the explicit primary', async () => { + axios.get.mockResolvedValue({ data: { info: { + metadata: { agent_id: 'liz' }, models: ['gpt-5.4-mini', 'deepseek-v4-flash'], + } } }); + await expect(issueLiteLLMOpenRouterKey('liz')).resolves.toEqual({ key: 'sk-community-existing', staleOwnedKey: null }); + expect(axios.post).not.toHaveBeenCalled(); + expect(axios.delete).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/__tests__/unit/services/globalModelConfigService.test.js b/backend/__tests__/unit/services/globalModelConfigService.test.js index a6d3186b4a..e12cc1b9f5 100644 --- a/backend/__tests__/unit/services/globalModelConfigService.test.js +++ b/backend/__tests__/unit/services/globalModelConfigService.test.js @@ -23,6 +23,7 @@ describe('globalModelConfigService', () => { baseUrl: 'https://openrouter.ai/api/v1', model: '', }); + expect(result.openclaw.communityAgentModel).toEqual({ primary: 'openrouter/deepseek-v4-flash', fallbacks: [] }); expect(result.openclaw.provider).toBe('google'); expect(result.openclaw.model).toBe('google/gemini-2.5-flash'); expect(result.openclaw.fallbackModels).toEqual([ diff --git a/backend/services/agentProvisionerServiceK8s.ts b/backend/services/agentProvisionerServiceK8s.ts index 54cc206114..1d52ba9da6 100644 --- a/backend/services/agentProvisionerServiceK8s.ts +++ b/backend/services/agentProvisionerServiceK8s.ts @@ -1665,7 +1665,7 @@ const issueLiteLLMVirtualKey = async (agentId: any) => { /** * Issue a LiteLLM virtual key for a community agent. Community agents' - * runtime primary is `openrouter/nvidia/nemotron-3-super-120b-a12b:free` + * runtime primary is `openrouter/deepseek-v4-flash` * (set as the gateway default in applyOpenClawModelDefaults); they never * get openai-codex credentials injected. The allowlist on this key still * permits the gpt-5.4-{nano,mini,full} model names as a safety net so the @@ -1684,6 +1684,7 @@ const issueLiteLLMOpenRouterKey = async (agentId: any) => { // Reuse the agent's current virtual key when LiteLLM still recognises it // (see readPvcVirtualKey for where it lives). const existingKey = await readPvcVirtualKey(agentId); + let staleOwnedKey: string | null = null; if (existingKey && existingKey !== masterKey) { // Safety: never delete the master key even if found on PVC @@ -1694,16 +1695,14 @@ const issueLiteLLMOpenRouterKey = async (agentId: any) => { // Sentinel for "key was issued with the current allowlist." We use // gpt-5.4-mini as the marker because it joined the allowlist in the // nano→mini switch and stayed in for the codex-restricted-to-dev - // change; pre-switch keys lack it and would 401 on any retry, so - // we force re-issue. + // change. TASK-004 also requires the explicit DeepSeek primary: + // older keys only allowed the dead alias and must be re-issued. const allowsMini = Array.isArray(info?.models) && info.models.includes('gpt-5.4-mini'); - if (ownedByAgent && allowsMini) { - return existingKey; - } - if (info) { - await axios.delete(`${baseUrl}/key/delete`, { headers, data: { keys: [existingKey] } }).catch(() => {}); - console.log(`[litellm] Deleted ${ownedByAgent ? 'stale-allowlist' : 'misowned'} OpenRouter key for ${agentId}`); + const allowsDeepSeek = Array.isArray(info?.models) && info.models.includes('deepseek-v4-flash'); + if (ownedByAgent && allowsMini && allowsDeepSeek) { + return { key: existingKey, staleOwnedKey: null }; } + if (ownedByAgent) staleOwnedKey = existingKey; } catch (_: any) { /* key not in DB — fall through */ } } @@ -1712,6 +1711,8 @@ const issueLiteLLMOpenRouterKey = async (agentId: any) => { { user_id: agentId, models: [ + 'deepseek-v4-flash', + 'openrouter/deepseek-v4-flash', 'gpt-5.4-nano', 'openai-codex/gpt-5.4-nano', 'gpt-5.4-mini', @@ -1733,11 +1734,12 @@ const issueLiteLLMOpenRouterKey = async (agentId: any) => { }, { headers }, ); - const newKey = resp.data?.key || null; - if (newKey) console.log(`[litellm] Issued OpenRouter virtual key for ${agentId}`); - return newKey; + const newKey = resp.data?.key; + if (typeof newKey !== 'string' || !newKey.startsWith('sk-') || newKey === masterKey) return null; + console.log(`[litellm] Issued OpenRouter virtual key for ${agentId}`); + return { key: newKey, staleOwnedKey: staleOwnedKey === newKey ? null : staleOwnedKey }; } catch (err: any) { - console.warn(`[litellm] OpenRouter virtual key generation failed for ${agentId}: ${err.message}`); + console.warn(`[litellm] Community virtual key generation failed for ${agentId}; retry provisioning`); return null; } }; @@ -1751,7 +1753,7 @@ const issueLiteLLMOpenRouterKey = async (agentId: any) => { * persist across gateway restarts. */ const injectOpenRouterKeyToAgentAuthProfiles = async (deploymentName: any, agentId: any, virtualKey: any) => { - if (!virtualKey) return; + if (!virtualKey) return false; const escaped = virtualKey.replace(/'/g, "\\'"); // OpenRouter calls are proxied through LiteLLM (openrouter.baseUrl = http://litellm:4000/v1). // LiteLLM expects a virtual key for authorization — always use the LiteLLM key for openrouter:default. @@ -1783,11 +1785,13 @@ const injectOpenRouterKeyToAgentAuthProfiles = async (deploymentName: any, agent codexOrderLine, ` fs.writeFileSync(p, JSON.stringify(store, null, 2));`, ` process.stdout.write('ok');`, - `} catch (e: any) { process.stdout.write('skip:' + e.message); }`, + `} catch (e) { process.stdout.write('skip:' + e.message); }`, ].filter(Boolean).join(' '); const execOnPod = async (podName: any) => new Promise((resolve, reject) => { const stdoutStream = new stream.PassThrough(); + const chunks: Buffer[] = []; + stdoutStream.on('data', (chunk: Buffer) => chunks.push(chunk)); k8sExec.exec( NAMESPACE, podName, @@ -1798,8 +1802,8 @@ const injectOpenRouterKeyToAgentAuthProfiles = async (deploymentName: any, agent null, false, (status: any) => { - if (status.status === 'Success') resolve(); - else reject(new Error(status.message || 'exec failed')); + if (status.status === 'Success' && Buffer.concat(chunks).toString().trim() === 'ok') resolve(); + else reject(new Error('OpenRouter credential write was not confirmed')); }, ).catch(reject); }); @@ -1809,11 +1813,11 @@ const injectOpenRouterKeyToAgentAuthProfiles = async (deploymentName: any, agent const gwPod = await waitForReadyGatewayPod(90000, 5000); if (!gwPod) { console.warn(`[k8s-provisioner] openrouter key inject skipped for ${agentId}: no ready gateway pod`); - return; + return false; } await execOnPod(gwPod.metadata.name); console.log(`[k8s-provisioner] openrouter key injected for ${agentId}`); - return; + return true; } catch (err: any) { if (attempt < 2) { console.warn(`[k8s-provisioner] openrouter key inject attempt ${attempt} failed for ${agentId}: ${err.message} — retrying`); @@ -1823,6 +1827,7 @@ const injectOpenRouterKeyToAgentAuthProfiles = async (deploymentName: any, agent } } } + return false; }; const applyOpenClawCodexProviderConfig = async (config: any) => { @@ -2174,34 +2179,16 @@ const applyOpenClawModelDefaults = async (config: any) => { } catch (error: any) { modelConfig = null; } - // OpenRouter fallbacks: free models only, explicit list enforced on every provision. - // Trinity removed 2026-05-03 — endpoint was deregistered at OpenRouter (404 No - // endpoints found), so every fallback hop into trinity errored out and broke - // the chain. Nemotron is the only working free model right now. + // Dev-agent fallbacks stay separate from the community chain. const OPENROUTER_FREE_FALLBACKS = [ 'openrouter/nvidia/nemotron-3-super-120b-a12b:free', ]; - // Global default for non-dev (community) agents: free OpenRouter models - // only. Codex quota is finite and shared; reserving it for dev agents - // keeps heartbeat orchestration responsive without burning weekly quota - // on community agents. Community agents are NOT issued openai-codex - // credentials (see provisionOpenClawAccount), so even a misconfigured - // primary would 401 rather than burn — but the assertion below makes - // this guarantee explicit at the config layer too. Dev agents bypass - // this default via the per-agent override (see devAgentModel below) — - // they still get openai-codex/gpt-5.4-mini. - config.agents.defaults.model.primary = 'openrouter/nvidia/nemotron-3-super-120b-a12b:free'; - // Gemini entries are kept as inert placeholders — current GEMINI_API_KEY - // is revoked so they're no-ops today; reinstating the key restores the - // chain without a code change. If nemotron 429s while gemini is dead, - // community agents fail loud (silent for that minute, retry on next - // heartbeat) — that's the intended behavior. Better than burning Codex. - config.agents.defaults.model.fallbacks = Array.from(new Set([ - 'google/gemini-2.5-flash', - 'google/gemini-2.5-flash-lite', - 'google/gemini-2.0-flash', - ])); + // Community traffic already fell through dead Nemotron to this LiteLLM + // group. Select it directly (TASK-004); never use the dev Codex pool. + config.agents.defaults.model.primary = 'openrouter/deepseek-v4-flash'; + // D81: a hosted default failure must not substitute another vendor. + config.agents.defaults.model.fallbacks = []; // HARD GUARDRAIL: community fallback chain MUST NOT include any // openai-codex/* model. Codex quota is paid and shared across the @@ -2227,7 +2214,7 @@ const applyOpenClawModelDefaults = async (config: any) => { // Always set up Codex provider config so dev agents can use it via per-agent override. const codexCredentials = await applyOpenClawCodexProviderConfig(config); - // OpenRouter provider catalog — free models only, no paid models. + // OpenRouter-compatible provider catalog. LiteLLM accounts for billed usage. // `api: "openai-completions"` is required: pi-ai uses it to route to the correct provider. // `reasoning`, `input`, `cost` are required fields in ModelDefinitionConfig. config.models = config.models || {}; @@ -2240,6 +2227,17 @@ const applyOpenClawModelDefaults = async (config: any) => { baseUrl: process.env.LITELLM_BASE_URL || 'https://openrouter.ai/api/v1', api: 'openai-completions', models: [ + { + // The openrouter provider is a transport label; LiteLLM routes this + // model id to DeepSeek directly. Cost estimates are recorded by LiteLLM. + id: 'deepseek-v4-flash', + name: 'DeepSeek V4 Flash', + reasoning: false, + input: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + maxTokens: 8000, + contextWindow: 128000, + }, { id: 'nvidia/nemotron-3-super-120b-a12b:free', name: 'Nemotron Super 120B (free)', @@ -2444,16 +2442,26 @@ const provisionOpenClawAccount = async ({ // Route OpenRouter calls through LiteLLM when available (everything-via-LiteLLM // mode). Dev agents reuse their Codex virtual key (already grants OpenRouter - // model access); community agents get a separate OR-only key. If virtual-key - // issuance fails (e.g. LiteLLM DB down), fall back to LITELLM_MASTER_KEY so - // the agent still routes through LiteLLM rather than burning raw keys direct. + // model access); community agents require their own scoped key. Failed + // issuance must not grant the shared master credential. if (process.env.LITELLM_BASE_URL) { - const masterKey = (process.env.LITELLM_MASTER_KEY || '').trim(); - const openRouterKey = isDevAgent - ? codexVirtualKey - : (await issueLiteLLMOpenRouterKey(accountId)) || masterKey || null; + const communityKey = isDevAgent ? null : await issueLiteLLMOpenRouterKey(accountId); + const openRouterKey = isDevAgent ? codexVirtualKey : communityKey?.key; + if (!isDevAgent && !openRouterKey) { + throw new Error('Community model key unavailable; retry provisioning'); + } if (openRouterKey) { - await injectOpenRouterKeyToAgentAuthProfiles('clawdbot-gateway', accountId, openRouterKey); + const injected = await injectOpenRouterKeyToAgentAuthProfiles('clawdbot-gateway', accountId, openRouterKey); + if (!isDevAgent && !injected) { + throw new Error('Community model key was not written; retry provisioning'); + } + if (!isDevAgent && communityKey?.staleOwnedKey) { + const axios = require('axios'); + await axios.delete(`${process.env.LITELLM_BASE_URL}/key/delete`, { + headers: { Authorization: `Bearer ${(process.env.LITELLM_MASTER_KEY || '').trim()}` }, + data: { keys: [communityKey.staleOwnedKey] }, + }).catch(() => {}); + } } } applySkillEnvEntriesToConfig(config, skillEnv); @@ -2494,7 +2502,7 @@ const provisionOpenClawAccount = async ({ const heartbeatConfig = normalizeHeartbeat(heartbeat); // Per-agent model override: dev agents get explicit Codex config. - // Community agents use global default (nemotron) — no per-agent entry needed. + // Community agents use global default (DeepSeek) — no per-agent entry needed. const perAgentModel = devAgentIds.includes(accountId) ? devAgentModel : null; if (agentEntry) { @@ -2515,7 +2523,7 @@ const provisionOpenClawAccount = async ({ if (perAgentModel) { agentEntry.model = perAgentModel; } else { - delete agentEntry.model; // community agents: use global default (nemotron) + delete agentEntry.model; // community agents: use global default (DeepSeek) } } else { config.agents.list.push({ @@ -2556,7 +2564,7 @@ const provisionOpenClawAccount = async ({ await syncAccountToStateMoltbot(accountId, accountEntry, agentEntry, bindingEntry, { gateway, // Persist applyOpenClawModelDefaults' work to the gateway PVC. Without - // this, the global default (e.g. nemotron) lives only in the in-memory + // this, the global default (DeepSeek) lives only in the in-memory // config and the persisted file keeps a stale primary forever. agentsDefaults: config.agents?.defaults || null, // Same for the model providers (openai-codex + openrouter→LiteLLM). @@ -3537,6 +3545,7 @@ module.exports = { buildPvcVirtualKeyReadScript, // Test seam: the dev issuer, so a test can assert what it sends to LiteLLM. issueLiteLLMVirtualKey, + issueLiteLLMOpenRouterKey, getAgentSessionSizes, provisionAgentRuntime, startAgentRuntime, diff --git a/backend/services/globalModelConfigService.ts b/backend/services/globalModelConfigService.ts index 7fcd7ef787..7e7f9a5f88 100644 --- a/backend/services/globalModelConfigService.ts +++ b/backend/services/globalModelConfigService.ts @@ -52,13 +52,10 @@ const DEFAULT_CONFIG: GlobalModelConfig = { ], // Agent IDs that use Codex as primary. All others use communityAgentModel as primary. devAgentIds: ['theo', 'nova', 'pixel', 'ops'], - // Model used by non-dev agents (community agents like liz/tarik/tom/fakesam/x-curator). - // Trinity removed 2026-05-03 (deregistered at OpenRouter, 404 No endpoints found). - // Nemotron is the only working free fallback right now; if it 429s, community - // agents fail loud rather than escalate to paid Codex (see assertion in - // applyOpenClawModelDefaults). + // Community agents select the working LiteLLM group directly (TASK-004). + // The openrouter prefix names the OpenClaw transport, not the upstream. communityAgentModel: { - primary: 'openrouter/nvidia/nemotron-3-super-120b-a12b:free', + primary: 'openrouter/deepseek-v4-flash', fallbacks: [], }, },