Skip to content

Commit 056349e

Browse files
authored
🤖 feat: count AI calls from bash commands in workspace costs (opt-in) (#5772)
## Summary Bash tool commands can now send their Anthropic and OpenAI calls through Xum, so the spend shows up in the workspace's Cost tab and in Analytics. Turn on **Settings → Providers → Bash commands** (off by default). Each Local or Worktree bash command then gets `ANTHROPIC_BASE_URL`, `OPENAI_BASE_URL` and a per-workspace `xum-proxy-…` key that point at a loopback proxy. The proxy forwards with the provider keys from Xum's settings and records the reported usage. This is PR 1 of 4 in a stack. PR 2 keeps the port and keys stable across restarts, PR 3 adds SSH and Coder hosts, and PR 4 adds a bug-bash scenario for the feature. ## Background Test harnesses (`make bug-bash`, e2e explorers, SDK scripts) that the agent runs in bash call providers directly. Today that spend never reaches Xum's ledger, so the Cost tab and Analytics undercount. ## Implementation - `src/node/services/bashAiProxy/bashAiProxyService.ts`: a loopback HTTP proxy. Allowed paths: Messages, Responses, Chat Completions, token counting and model listing. It replaces the proxy key with the real key and provider headers, adds `stream_options.include_usage` to streamed Chat Completions, drops `Expect`, and does not follow redirects. It builds the upstream URL from the configured base URL (normalized like the chat path, query kept) and keeps headers configured in providers.jsonc, such as `OpenAI-Project`. - `usageExtract.ts` reads usage from JSON and SSE bodies (Anthropic and OpenAI). The proxy records usage through `SessionUsageService.recordHeadlessUsage` with analytics source `bash_proxy`, and emits a live `session-usage-delta`. - `TurnRequestBuilder` adds the variables to `xumEnv` on a best-effort basis. It adds none when: - the switch is off, - a project secret names any of that provider's variables, - Xum has no API key for that provider, - the workspace is not trusted for shared execution (untrusted project or `XUM_DISABLE_PROJECT_AUTOMATION=1`). The bash tool blanks provider keys there, so a proxy URL would pair with a blank key. - The proxy checks each request. If the switch is off, it returns 503. If the key's workspace was removed, it returns 401. If the project lost trust, it returns 403. So commands that already hold a key lose access at once. ## Validation - Unit tests with a fake upstream: both Anthropic SDK path styles, OpenAI chat and responses, SSE usage, header handling, the refusals above, and the env rules. `aiService.test.ts` covers the trust gating for trusted, untrusted and kill-switch workspaces. I mutation-checked it and the 403 test. - Dogfood with a fake provider and no real spend: a bash `curl` through the proxy shows up in the Cost tab rows next to the chat turns. ![Cost tab with bash proxy usage](https://github.com/user-attachments/assets/5fe78f3b-b4ea-4916-9b9c-0d8e6eac5087) - Four agent bug-bash rounds (Opus 5.5 and Sonnet 5.5 explorers, effort high, PR 4 scenario) against the top of the stack. The last round found no defect in branch code. Earlier rounds found unclear setting copy and two trust gaps. This stack fixes them. ## Risks - With the switch on, agent CLIs such as `claude -p` bill the Xum API key instead of a subscription login. That is why the switch is opt-in. The docs and the setting text say so. - Usage comes from what the provider reports. If a client disconnects mid-stream, Xum records only the usage seen before the disconnect. Related issues filed from the bug bashes (not fixed here): #5766, #5767, #5768, #5769, #5771. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `high` • Cost: `$32.63`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=high costs=32.63 -->
1 parent ffac032 commit 056349e

24 files changed

Lines changed: 1606 additions & 2 deletions

‎docs/config/providers.mdx‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,27 @@ Azure OpenAI env vars configure the OpenAI provider with Azure backend.
7373

7474
{/* END PROVIDER_ENV_VARS */}
7575

76+
## AI Calls from Bash Commands
77+
78+
Scripts that the agent runs in the bash tool (test harnesses, `make bug-bash`, SDK scripts) can call Anthropic and OpenAI directly. Turn on **Settings → Providers → Bash commands** to count that spend (it is off by default). Each bash command then gets a local endpoint and a workspace key, and Xum adds the usage to that workspace's Costs tab and to Analytics (source `headless:bash_proxy`).
79+
80+
| Variable | Value |
81+
| ------------------------------------------------------------- | ------------------------------------- |
82+
| `ANTHROPIC_BASE_URL` | `http://127.0.0.1:<port>/anthropic` |
83+
| `OPENAI_BASE_URL` | `http://127.0.0.1:<port>/openai/v1` |
84+
| `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `OPENAI_API_KEY` | `xum-proxy-…` (one key per workspace) |
85+
86+
- Xum sends the calls with the provider keys from this page. The command never sees the real key.
87+
- Only Local and Worktree commands get the variables. SSH, Coder, Docker and devcontainer commands keep their environment.
88+
- Bash commands in `xum run` and `xum workflow` sessions do not get the variables.
89+
- Commands in untrusted projects, and every command while `XUM_DISABLE_PROJECT_AUTOMATION=1` is set, get no variables: Xum blanks provider keys there, so repo code cannot spend through the proxy. Revoking a project's trust also refuses calls from its commands that are still running.
90+
- A provider without a Xum API key (for example OpenAI with Codex OAuth only) gets no variables. A provider that your [project secrets](/config/project-secrets) configure keeps the secret values. This includes `OPENAI_ORG_ID` and `OPENAI_PROJECT_ID`.
91+
- The proxy allows Messages, Responses, Chat Completions, token counting and model listing. Other paths get 404.
92+
- The port and keys change when Xum restarts. A background command started before the restart gets connection errors. Start it again.
93+
- A key stops working when its workspace is removed.
94+
- A script that exports its own `ANTHROPIC_API_KEY` but keeps the proxy URL gets 401. Set both the key and the base URL, or neither.
95+
- Agent CLIs that read these variables also go through the proxy. `claude -p` in a bash command then bills the Xum Anthropic key instead of a Claude subscription login.
96+
7697
## Advanced: Manual Configuration
7798

7899
For advanced options not exposed in the UI, edit `~/.xum/providers.jsonc` directly:

‎src/browser/features/Settings/Sections/ProvidersSection.stories.tsx‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,3 +218,24 @@ export const CoderModelRouting: Story = {
218218
await canvas.findByText(/openai-removed is not a known OpenAI provider/);
219219
},
220220
};
221+
222+
/** The Bash commands switch saves through the API and stays on. */
223+
export const BashCommandsSwitch: Story = {
224+
render: () => (
225+
<SettingsSectionStory setup={() => setupSettingsStory({ providersConfig: {} })}>
226+
<ProvidersSection />
227+
</SettingsSectionStory>
228+
),
229+
play: async ({ canvasElement }) => {
230+
const canvas = within(canvasElement);
231+
const toggle = await canvas.findByRole(
232+
"switch",
233+
{ name: "Count AI calls from bash commands" },
234+
{ timeout: 5000 }
235+
);
236+
await waitFor(() => expect(toggle).toHaveAttribute("aria-checked", "false"));
237+
await userEvent.click(toggle);
238+
// A failed save rolls the switch back.
239+
await waitFor(() => expect(toggle).toHaveAttribute("aria-checked", "true"));
240+
},
241+
};

‎src/browser/features/Settings/Sections/ProvidersSection.tsx‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext";
3333
import { ProviderIcon, ProviderWithIcon } from "@/browser/components/ProviderIcon/ProviderIcon";
3434
import { getStoredAuthToken } from "@/browser/components/AuthTokenModal/AuthTokenModal";
3535
import { useAPI } from "@/browser/contexts/API";
36+
import { ConfigSwitchSetting, type SettingsApi } from "./ConfigSwitchSetting";
3637
import { useSettings } from "@/browser/contexts/SettingsContext";
3738
import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig";
3839
import {
@@ -463,6 +464,11 @@ function GatewayRoutePriorityList({
463464
);
464465
}
465466

467+
const loadBashAiProxyEnabled = async (api: SettingsApi) =>
468+
(await api.config.getConfig()).bashAiProxyEnabled;
469+
const saveBashAiProxyEnabled = (api: SettingsApi, enabled: boolean) =>
470+
api.config.updateBashAiProxyEnabled({ enabled });
471+
466472
export function ProvidersSection() {
467473
const {
468474
providersExpandedProvider,
@@ -3444,6 +3450,19 @@ export function ProvidersSection() {
34443450
</div>
34453451
)}
34463452

3453+
<div className="space-y-2 pt-2">
3454+
<div className="text-muted text-xs font-medium tracking-wide uppercase">Bash commands</div>
3455+
<ConfigSwitchSetting
3456+
title="Count AI calls from bash commands"
3457+
description="Local and Worktree bash commands get ANTHROPIC_BASE_URL, OPENAI_BASE_URL and a workspace key that point at Xum. Xum forwards their calls with the API keys above and adds the tokens and cost to the workspace's Cost tab and to Analytics. Agent CLIs that you start in bash, such as Claude Code, then bill these API keys instead of your subscription. When off, new commands get no proxy variables, and Xum refuses calls from commands that still hold a workspace key."
3458+
ariaLabel="Count AI calls from bash commands"
3459+
placeholderChecked={false}
3460+
load={loadBashAiProxyEnabled}
3461+
save={saveBashAiProxyEnabled}
3462+
saveErrorMessage="Failed to update the bash AI proxy setting"
3463+
/>
3464+
</div>
3465+
34473466
{config && !hasAnyConfiguredProvider && (
34483467
<div className="border-warning/40 bg-warning/10 text-warning rounded-md border px-3 py-2 text-xs">
34493468
No providers are currently enabled. You won&apos;t be able to send messages until you

‎src/browser/stories/mocks/orpc.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
600600
let chatTranscriptFullWidth = initialChatTranscriptFullWidth;
601601
let keepScreenAwake = initialKeepScreenAwake;
602602
let toolSearchEnabled = true;
603+
let bashAiProxyEnabled = false;
603604
let agentHeartbeatsEnabled = false;
604605
let runtimeEnablement: Record<string, boolean> = initialRuntimeEnablement ?? {
605606
local: true,
@@ -850,6 +851,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
850851
llmDebugLogs: false,
851852
keepScreenAwake,
852853
toolSearchEnabled,
854+
bashAiProxyEnabled,
853855
agentHeartbeatsEnabled,
854856
}),
855857
saveConfig: (input: {
@@ -963,6 +965,11 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
963965
notifyConfigChanged();
964966
return Promise.resolve(undefined);
965967
},
968+
updateBashAiProxyEnabled: (input: { enabled: boolean }) => {
969+
bashAiProxyEnabled = input.enabled;
970+
notifyConfigChanged();
971+
return Promise.resolve(undefined);
972+
},
966973
updateAgentHeartbeatsEnabled: (input: { enabled: boolean }) => {
967974
agentHeartbeatsEnabled = input.enabled;
968975
notifyConfigChanged();

‎src/browser/testUtils.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ export function createTestConfig(overrides: Partial<TestClientConfig> = {}): Tes
161161
llmDebugLogs: false,
162162
keepScreenAwake: false,
163163
toolSearchEnabled: true,
164+
bashAiProxyEnabled: false,
164165
agentHeartbeatsEnabled: false,
165166
goalDefaults: DEFAULT_GOAL_DEFAULTS,
166167
...overrides,

‎src/browser/utils/commandIds.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ export const CommandIds = {
105105
settingsOpen: () => "settings:open" as const,
106106
settingsOpenSection: (section: string) => `settings:open:${section}` as const,
107107
settingsToggleKeepScreenAwake: () => "settings:toggle-keep-screen-awake" as const,
108+
settingsToggleBashAiProxy: () => "settings:toggle-bash-ai-proxy" as const,
108109
openServerWindow: () => "remote-connection:open-server-window" as const,
109110
coderDisconnect: () => "providers:coder:disconnect" as const,
110111
coderRefreshModels: () => "providers:coder:refresh-models" as const,

‎src/browser/utils/commands/sources.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1352,6 +1352,30 @@ test("toggle keep screen awake command inverts the persisted config flag", async
13521352
}
13531353
});
13541354

1355+
test("toggle bash AI proxy command inverts the persisted config flag", async () => {
1356+
let bashAiProxyEnabled = false;
1357+
const updateBashAiProxyEnabled = mock((input: { enabled: boolean }) => {
1358+
bashAiProxyEnabled = input.enabled;
1359+
return Promise.resolve();
1360+
});
1361+
const actions = getActions({
1362+
api: createTestApiClient({
1363+
config: {
1364+
getConfig: () => Promise.resolve(createTestConfig({ bashAiProxyEnabled })),
1365+
updateBashAiProxyEnabled,
1366+
},
1367+
}),
1368+
});
1369+
const toggleAction = actions.find((a) => a.id === "settings:toggle-bash-ai-proxy");
1370+
1371+
expect(toggleAction).toBeDefined();
1372+
await toggleAction!.run();
1373+
expect(updateBashAiProxyEnabled).toHaveBeenLastCalledWith({ enabled: true });
1374+
await toggleAction!.run();
1375+
expect(updateBashAiProxyEnabled).toHaveBeenLastCalledWith({ enabled: false });
1376+
expect(bashAiProxyEnabled).toBe(false);
1377+
});
1378+
13551379
test("analytics rebuild command calls route and dispatches toast feedback", async () => {
13561380
const rebuildDatabase = mock(() => Promise.resolve({ success: true, workspacesIngested: 4 }));
13571381

‎src/browser/utils/commands/sources.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2043,6 +2043,23 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi
20432043
]
20442044
);
20452045

2046+
// Keyboard route for the Settings → Providers → Bash commands switch.
2047+
actions.push(() => [
2048+
{
2049+
id: CommandIds.settingsToggleBashAiProxy(),
2050+
title: "Toggle Count AI Calls from Bash Commands",
2051+
subtitle: "Route bash commands' Anthropic and OpenAI calls through Xum to count their cost",
2052+
section: section.settings,
2053+
keywords: ["bash", "proxy", "cost", "usage", "anthropic", "openai", "billing"],
2054+
run: async () => {
2055+
if (!p.api) return;
2056+
// The flag lives in config.json (not localStorage), so read the current value first.
2057+
const cfg = await p.api.config.getConfig();
2058+
await p.api.config.updateBashAiProxyEnabled({ enabled: !cfg.bashAiProxyEnabled });
2059+
},
2060+
},
2061+
]);
2062+
20462063
// Settings
20472064
if (p.onOpenSettings) {
20482065
const openSettings = p.onOpenSettings;

‎src/common/config/schemas/appConfigOnDisk.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ export const AppConfigOnDiskSchema = z
177177
keepScreenAwake: z.boolean().optional(),
178178
/** Defer MCP tool definitions behind tool_catalog_search. Absent = on. */
179179
toolSearchEnabled: z.boolean().optional(),
180+
/** Route AI calls from bash tool commands through Xum's cost-tracking proxy. Absent = off. */
181+
bashAiProxyEnabled: z.boolean().optional(),
180182
/** Expose the `heartbeat` tool so agents can schedule their own recurring turns. Absent = off. */
181183
agentHeartbeatsEnabled: z.boolean().optional(),
182184
heartbeatDefaultPrompt: z.string().optional(),

‎src/common/orpc/schemas/api.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3149,6 +3149,7 @@ export const config = {
31493149
llmDebugLogs: z.boolean(),
31503150
keepScreenAwake: z.boolean(),
31513151
toolSearchEnabled: z.boolean(),
3152+
bashAiProxyEnabled: z.boolean(),
31523153
agentHeartbeatsEnabled: z.boolean(),
31533154
heartbeatDefaultPrompt: z.string().optional(),
31543155
heartbeatDefaultIntervalMs: z.number().optional(),
@@ -3287,6 +3288,7 @@ export const config = {
32873288
updateLlmDebugLogs: booleanToggleRoute,
32883289
updateKeepScreenAwake: booleanToggleRoute,
32893290
updateToolSearchEnabled: booleanToggleRoute,
3291+
updateBashAiProxyEnabled: booleanToggleRoute,
32903292
updateAgentHeartbeatsEnabled: booleanToggleRoute,
32913293
updateHeartbeatDefaultPrompt: {
32923294
input: z

0 commit comments

Comments
 (0)