From 53cb760fc7aab5719a5638b1f64b36d5db64658d Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:14:02 -0700 Subject: [PATCH 1/2] fix(provisioner): agent LiteLLM keys carry a $30 monthly budget (D111) Since the GPT aliases were repointed at DeepSeek V4 Flash, calls through the per-agent virtual keys are paid. The deepseek-v4-flash group budget sits on that model name, which alias callers never touch, and both issuers minted keys with no max_budget, so a runaway agent had no spend cap. Both now stamp max_budget 30 with a 30d window. Reused keys keep the budget they carry; the four live keys are capped operator-side. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DS3E4dzoHKdxtgQqyz --- .../services/agentProvisionerServiceK8s.test.js | 13 +++++++++++++ backend/services/agentProvisionerServiceK8s.ts | 12 ++++++++++++ 2 files changed, 25 insertions(+) diff --git a/backend/__tests__/unit/services/agentProvisionerServiceK8s.test.js b/backend/__tests__/unit/services/agentProvisionerServiceK8s.test.js index 20ea517be6..5611706fcf 100644 --- a/backend/__tests__/unit/services/agentProvisionerServiceK8s.test.js +++ b/backend/__tests__/unit/services/agentProvisionerServiceK8s.test.js @@ -560,6 +560,19 @@ 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'); + }); }); diff --git a/backend/services/agentProvisionerServiceK8s.ts b/backend/services/agentProvisionerServiceK8s.ts index 54cc206114..5b0cd2feb2 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 }, From f9f726e1a55466bc5240b91603e9af5fbbbb5aef Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:50:13 -0700 Subject: [PATCH 2/2] test(litellm): pin the monthly budget on the community key issuer too Sable's gate: the OpenRouter (community) issuer had no test, so removing its max_budget lines left the suite green. The new case fails without them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DS3E4dzoHKdxtgQqyz --- .../services/agentProvisionerServiceK8s.test.js | 14 +++++++++++++- backend/services/agentProvisionerServiceK8s.ts | 3 ++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/backend/__tests__/unit/services/agentProvisionerServiceK8s.test.js b/backend/__tests__/unit/services/agentProvisionerServiceK8s.test.js index 5611706fcf..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'); @@ -573,6 +573,18 @@ describe('issueLiteLLMVirtualKey never sends the master key to LiteLLM', () => { 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 5b0cd2feb2..2a7ce0523c 100644 --- a/backend/services/agentProvisionerServiceK8s.ts +++ b/backend/services/agentProvisionerServiceK8s.ts @@ -3547,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,