diff --git a/backend/__tests__/unit/services/agentProvisionerServiceK8s.test.js b/backend/__tests__/unit/services/agentProvisionerServiceK8s.test.js index 20ea517be6..afa0d9badd 100644 --- a/backend/__tests__/unit/services/agentProvisionerServiceK8s.test.js +++ b/backend/__tests__/unit/services/agentProvisionerServiceK8s.test.js @@ -98,7 +98,7 @@ const mock = { const k8s = require('@kubernetes/client-node'); const { - restartAgentRuntime, provisionAgentRuntime, buildPvcVirtualKeyReadScript, issueLiteLLMVirtualKey, + restartAgentRuntime, provisionAgentRuntime, buildPvcVirtualKeyReadScript, issueLiteLLMVirtualKey, issueLiteLLMOpenRouterKey, readOpenClawIdentityFile, getAgentRuntimeLogs, } = require('../../../services/agentProvisionerServiceK8s'); const { CYCLES_DIRECTIVE_MARKER } = require('../../../routes/registry/presets'); @@ -560,6 +560,31 @@ describe('issueLiteLLMVirtualKey never sends the master key to LiteLLM', () => { expect(axios.get).toHaveBeenCalledWith(expect.stringContaining('/key/info?key=sk-virtual-theo'), expect.anything()); expect(key).toBe('sk-virtual-theo'); }); + + // D111: these keys now route to a paid model, and the $60 group budget is on + // the deepseek-v4-flash model name, which alias callers never touch. + it('mints a fresh key with a monthly spend budget', async () => { + k8s.__mock.execStdout = ''; + + await issueLiteLLMVirtualKey('theo'); + + const [url, body] = axios.post.mock.calls.find(([u]) => u.endsWith('/key/generate')); + expect(url).toBe('http://litellm:4000/key/generate'); + expect(body.max_budget).toBe(30); + expect(body.budget_duration).toBe('30d'); + }); + + it('mints a fresh community key with the same monthly spend budget', async () => { + k8s.__mock.execStdout = ''; + + await issueLiteLLMOpenRouterKey('community-agent'); + + const [url, body] = axios.post.mock.calls.find(([u]) => u.endsWith('/key/generate')); + expect(url).toBe('http://litellm:4000/key/generate'); + expect(body.user_id).toBe('community-agent'); + expect(body.max_budget).toBe(30); + expect(body.budget_duration).toBe('30d'); + }); }); diff --git a/backend/services/agentProvisionerServiceK8s.ts b/backend/services/agentProvisionerServiceK8s.ts index 54cc206114..2a7ce0523c 100644 --- a/backend/services/agentProvisionerServiceK8s.ts +++ b/backend/services/agentProvisionerServiceK8s.ts @@ -54,6 +54,14 @@ const AGENT_TOLERATIONS = (() => { // misbehaving agent can't monopolize the single-replica LiteLLM proxy. const LITELLM_KEY_MAX_PARALLEL_REQUESTS = 4; const LITELLM_KEY_RPM_LIMIT = 120; +// Monthly spend cap per issued key (D111, 2026-10-27). Unlike the breakers above +// this IS a usage quota: since the GPT aliases were repointed at DeepSeek, and dead +// routes fall back to it, calls through these keys are paid, while the +// deepseek-v4-flash group budget sits on that model name, which alias callers never +// touch. The whole proxy runs ~$15/mo, so $30 per agent only trips on a runaway. +// A reused key keeps whatever budget it already carries; this stamps new ones. +const LITELLM_KEY_MAX_BUDGET_USD = 30; +const LITELLM_KEY_BUDGET_DURATION = '30d'; /** * Resolve OpenClaw account ID from agent name and instance ID @@ -1650,6 +1658,8 @@ const issueLiteLLMVirtualKey = async (agentId: any) => { // trips only on runaway flooding (see constant comment). max_parallel_requests: LITELLM_KEY_MAX_PARALLEL_REQUESTS, rpm_limit: LITELLM_KEY_RPM_LIMIT, + max_budget: LITELLM_KEY_MAX_BUDGET_USD, + budget_duration: LITELLM_KEY_BUDGET_DURATION, metadata: { agent_id: agentId, provisioned_at: new Date().toISOString() }, }, { headers }, @@ -1729,6 +1739,8 @@ const issueLiteLLMOpenRouterKey = async (agentId: any) => { // trips only on runaway flooding (see constant comment). max_parallel_requests: LITELLM_KEY_MAX_PARALLEL_REQUESTS, rpm_limit: LITELLM_KEY_RPM_LIMIT, + max_budget: LITELLM_KEY_MAX_BUDGET_USD, + budget_duration: LITELLM_KEY_BUDGET_DURATION, metadata: { agent_id: agentId, key_type: 'openrouter', provisioned_at: new Date().toISOString() }, }, { headers }, @@ -3535,8 +3547,9 @@ const refreshCodexOAuthTokenIfNeeded = async ({ thresholdDays = 3 } = {}) => { module.exports = { buildPvcVirtualKeyReadScript, - // Test seam: the dev issuer, so a test can assert what it sends to LiteLLM. + // Test seams: the dev and community issuers, so a test can assert what each sends to LiteLLM. issueLiteLLMVirtualKey, + issueLiteLLMOpenRouterKey, getAgentSessionSizes, provisionAgentRuntime, startAgentRuntime,