Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/config/providers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,27 @@ Azure OpenAI env vars configure the OpenAI provider with Azure backend.

{/* END PROVIDER_ENV_VARS */}

## AI Calls from Bash Commands

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`).

| Variable | Value |
| ------------------------------------------------------------- | ------------------------------------- |
| `ANTHROPIC_BASE_URL` | `http://127.0.0.1:<port>/anthropic` |
| `OPENAI_BASE_URL` | `http://127.0.0.1:<port>/openai/v1` |
| `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `OPENAI_API_KEY` | `xum-proxy-…` (one key per workspace) |

- Xum sends the calls with the provider keys from this page. The command never sees the real key.
- Only Local and Worktree commands get the variables. SSH, Coder, Docker and devcontainer commands keep their environment.
- Bash commands in `xum run` and `xum workflow` sessions do not get the variables.
- 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.
- 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`.
- The proxy allows Messages, Responses, Chat Completions, token counting and model listing. Other paths get 404.
- The port and keys change when Xum restarts. A background command started before the restart gets connection errors. Start it again.
- A key stops working when its workspace is removed.
- 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.
- 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.

## Advanced: Manual Configuration

For advanced options not exposed in the UI, edit `~/.xum/providers.jsonc` directly:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,3 +218,24 @@ export const CoderModelRouting: Story = {
await canvas.findByText(/openai-removed is not a known OpenAI provider/);
},
};

/** The Bash commands switch saves through the API and stays on. */
export const BashCommandsSwitch: Story = {
render: () => (
<SettingsSectionStory setup={() => setupSettingsStory({ providersConfig: {} })}>
<ProvidersSection />
</SettingsSectionStory>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const toggle = await canvas.findByRole(
"switch",
{ name: "Count AI calls from bash commands" },
{ timeout: 5000 }
);
await waitFor(() => expect(toggle).toHaveAttribute("aria-checked", "false"));
await userEvent.click(toggle);
// A failed save rolls the switch back.
await waitFor(() => expect(toggle).toHaveAttribute("aria-checked", "true"));
},
};
19 changes: 19 additions & 0 deletions src/browser/features/Settings/Sections/ProvidersSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext";
import { ProviderIcon, ProviderWithIcon } from "@/browser/components/ProviderIcon/ProviderIcon";
import { getStoredAuthToken } from "@/browser/components/AuthTokenModal/AuthTokenModal";
import { useAPI } from "@/browser/contexts/API";
import { ConfigSwitchSetting, type SettingsApi } from "./ConfigSwitchSetting";
import { useSettings } from "@/browser/contexts/SettingsContext";
import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig";
import {
Expand Down Expand Up @@ -463,6 +464,11 @@ function GatewayRoutePriorityList({
);
}

const loadBashAiProxyEnabled = async (api: SettingsApi) =>
(await api.config.getConfig()).bashAiProxyEnabled;
const saveBashAiProxyEnabled = (api: SettingsApi, enabled: boolean) =>
api.config.updateBashAiProxyEnabled({ enabled });

export function ProvidersSection() {
const {
providersExpandedProvider,
Expand Down Expand Up @@ -3444,6 +3450,19 @@ export function ProvidersSection() {
</div>
)}

<div className="space-y-2 pt-2">
<div className="text-muted text-xs font-medium tracking-wide uppercase">Bash commands</div>
<ConfigSwitchSetting
title="Count AI calls from bash commands"
Comment thread
ThomasK33 marked this conversation as resolved.
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."
ariaLabel="Count AI calls from bash commands"
placeholderChecked={false}
load={loadBashAiProxyEnabled}
save={saveBashAiProxyEnabled}
saveErrorMessage="Failed to update the bash AI proxy setting"
Comment thread
ThomasK33 marked this conversation as resolved.
/>
</div>

{config && !hasAnyConfiguredProvider && (
<div className="border-warning/40 bg-warning/10 text-warning rounded-md border px-3 py-2 text-xs">
No providers are currently enabled. You won&apos;t be able to send messages until you
Expand Down
7 changes: 7 additions & 0 deletions src/browser/stories/mocks/orpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
let chatTranscriptFullWidth = initialChatTranscriptFullWidth;
let keepScreenAwake = initialKeepScreenAwake;
let toolSearchEnabled = true;
let bashAiProxyEnabled = false;
let agentHeartbeatsEnabled = false;
let runtimeEnablement: Record<string, boolean> = initialRuntimeEnablement ?? {
local: true,
Expand Down Expand Up @@ -850,6 +851,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
llmDebugLogs: false,
keepScreenAwake,
toolSearchEnabled,
bashAiProxyEnabled,
agentHeartbeatsEnabled,
}),
saveConfig: (input: {
Expand Down Expand Up @@ -963,6 +965,11 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
notifyConfigChanged();
return Promise.resolve(undefined);
},
updateBashAiProxyEnabled: (input: { enabled: boolean }) => {
bashAiProxyEnabled = input.enabled;
notifyConfigChanged();
return Promise.resolve(undefined);
},
updateAgentHeartbeatsEnabled: (input: { enabled: boolean }) => {
agentHeartbeatsEnabled = input.enabled;
notifyConfigChanged();
Expand Down
1 change: 1 addition & 0 deletions src/browser/testUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ export function createTestConfig(overrides: Partial<TestClientConfig> = {}): Tes
llmDebugLogs: false,
keepScreenAwake: false,
toolSearchEnabled: true,
bashAiProxyEnabled: false,
agentHeartbeatsEnabled: false,
goalDefaults: DEFAULT_GOAL_DEFAULTS,
...overrides,
Expand Down
1 change: 1 addition & 0 deletions src/browser/utils/commandIds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export const CommandIds = {
settingsOpen: () => "settings:open" as const,
settingsOpenSection: (section: string) => `settings:open:${section}` as const,
settingsToggleKeepScreenAwake: () => "settings:toggle-keep-screen-awake" as const,
settingsToggleBashAiProxy: () => "settings:toggle-bash-ai-proxy" as const,
openServerWindow: () => "remote-connection:open-server-window" as const,
coderDisconnect: () => "providers:coder:disconnect" as const,
coderRefreshModels: () => "providers:coder:refresh-models" as const,
Expand Down
24 changes: 24 additions & 0 deletions src/browser/utils/commands/sources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1352,6 +1352,30 @@ test("toggle keep screen awake command inverts the persisted config flag", async
}
});

test("toggle bash AI proxy command inverts the persisted config flag", async () => {
let bashAiProxyEnabled = false;
const updateBashAiProxyEnabled = mock((input: { enabled: boolean }) => {
bashAiProxyEnabled = input.enabled;
return Promise.resolve();
});
const actions = getActions({
api: createTestApiClient({
config: {
getConfig: () => Promise.resolve(createTestConfig({ bashAiProxyEnabled })),
updateBashAiProxyEnabled,
},
}),
});
const toggleAction = actions.find((a) => a.id === "settings:toggle-bash-ai-proxy");

expect(toggleAction).toBeDefined();
await toggleAction!.run();
expect(updateBashAiProxyEnabled).toHaveBeenLastCalledWith({ enabled: true });
await toggleAction!.run();
expect(updateBashAiProxyEnabled).toHaveBeenLastCalledWith({ enabled: false });
expect(bashAiProxyEnabled).toBe(false);
});

test("analytics rebuild command calls route and dispatches toast feedback", async () => {
const rebuildDatabase = mock(() => Promise.resolve({ success: true, workspacesIngested: 4 }));

Expand Down
17 changes: 17 additions & 0 deletions src/browser/utils/commands/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2043,6 +2043,23 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi
]
);

// Keyboard route for the Settings β†’ Providers β†’ Bash commands switch.
actions.push(() => [
{
id: CommandIds.settingsToggleBashAiProxy(),
title: "Toggle Count AI Calls from Bash Commands",
subtitle: "Route bash commands' Anthropic and OpenAI calls through Xum to count their cost",
section: section.settings,
keywords: ["bash", "proxy", "cost", "usage", "anthropic", "openai", "billing"],
run: async () => {
if (!p.api) return;
// The flag lives in config.json (not localStorage), so read the current value first.
const cfg = await p.api.config.getConfig();
await p.api.config.updateBashAiProxyEnabled({ enabled: !cfg.bashAiProxyEnabled });
},
},
]);

// Settings
if (p.onOpenSettings) {
const openSettings = p.onOpenSettings;
Expand Down
2 changes: 2 additions & 0 deletions src/common/config/schemas/appConfigOnDisk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,8 @@ export const AppConfigOnDiskSchema = z
keepScreenAwake: z.boolean().optional(),
/** Defer MCP tool definitions behind tool_catalog_search. Absent = on. */
toolSearchEnabled: z.boolean().optional(),
/** Route AI calls from bash tool commands through Xum's cost-tracking proxy. Absent = off. */
bashAiProxyEnabled: z.boolean().optional(),
/** Expose the `heartbeat` tool so agents can schedule their own recurring turns. Absent = off. */
agentHeartbeatsEnabled: z.boolean().optional(),
heartbeatDefaultPrompt: z.string().optional(),
Expand Down
2 changes: 2 additions & 0 deletions src/common/orpc/schemas/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3149,6 +3149,7 @@ export const config = {
llmDebugLogs: z.boolean(),
keepScreenAwake: z.boolean(),
toolSearchEnabled: z.boolean(),
bashAiProxyEnabled: z.boolean(),
agentHeartbeatsEnabled: z.boolean(),
heartbeatDefaultPrompt: z.string().optional(),
heartbeatDefaultIntervalMs: z.number().optional(),
Expand Down Expand Up @@ -3287,6 +3288,7 @@ export const config = {
updateLlmDebugLogs: booleanToggleRoute,
updateKeepScreenAwake: booleanToggleRoute,
updateToolSearchEnabled: booleanToggleRoute,
updateBashAiProxyEnabled: booleanToggleRoute,
updateAgentHeartbeatsEnabled: booleanToggleRoute,
updateHeartbeatDefaultPrompt: {
input: z
Expand Down
6 changes: 6 additions & 0 deletions src/common/types/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ export interface ProjectsConfig {
* discovers them via tool_catalog_search. Absent = on; only `false` disables it.
*/
toolSearchEnabled?: boolean;
/**
* Route Anthropic/OpenAI calls from bash tool commands through Xum's local proxy so their
* spend shows in the Costs tab and Analytics. Absent = off: with it on, agent CLIs such as
* `claude -p` bill the Xum API key instead of a subscription login.
*/
bashAiProxyEnabled?: boolean;
/**
* Expose the `heartbeat` tool so agents can schedule their own recurring (paid) turns.
* Absent = off; users opt in from Settings.
Expand Down
17 changes: 17 additions & 0 deletions src/node/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,23 @@ describe("Config", () => {
});
});

describe("bash AI proxy setting", () => {
// Off by default: with the proxy vars set, `claude -p` bills the API key, not a subscription.
it("is off until the user opts in, and opting out removes the key", async () => {
expect(config.loadConfigOrDefault().bashAiProxyEnabled).toBeUndefined();

await config.updateBashAiProxyEnabled(true);
expect(new Config(tempDir).loadConfigOrDefault().bashAiProxyEnabled).toBe(true);

await config.updateBashAiProxyEnabled(false);
const persisted = JSON.parse(fs.readFileSync(path.join(tempDir, "config.json"), "utf-8")) as {
bashAiProxyEnabled?: boolean;
};
expect(persisted.bashAiProxyEnabled).toBeUndefined();
expect(new Config(tempDir).loadConfigOrDefault().bashAiProxyEnabled).toBeUndefined();
});
});

describe("persistent sub-agent retention migration", () => {
it.each([
["missing", undefined],
Expand Down
15 changes: 15 additions & 0 deletions src/node/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2284,6 +2284,7 @@ export class Config {
llmDebugLogs: parseOptionalBoolean(parsed.llmDebugLogs),
keepScreenAwake: parseOptionalBoolean(parsed.keepScreenAwake),
toolSearchEnabled: parseOptionalBoolean(parsed.toolSearchEnabled),
bashAiProxyEnabled: parseOptionalBoolean(parsed.bashAiProxyEnabled),
agentHeartbeatsEnabled: parseOptionalBoolean(parsed.agentHeartbeatsEnabled),
heartbeatDefaultPrompt: parseOptionalNonEmptyString(parsed.heartbeatDefaultPrompt),
heartbeatDefaultIntervalMs: parseOptionalHeartbeatIntervalMs(
Expand Down Expand Up @@ -2406,6 +2407,11 @@ export class Config {
data.toolSearchEnabled = false;
}

// Default-off flag: only the opt-in is written.
if (parseOptionalBoolean(config.bashAiProxyEnabled) === true) {
data.bashAiProxyEnabled = true;
}

if (parseOptionalBoolean(config.agentHeartbeatsEnabled) === true) {
data.agentHeartbeatsEnabled = true;
}
Expand Down Expand Up @@ -2833,6 +2839,7 @@ export class Config {
llmDebugLogs: config.llmDebugLogs === true,
keepScreenAwake: config.keepScreenAwake === true,
toolSearchEnabled: config.toolSearchEnabled !== false,
bashAiProxyEnabled: config.bashAiProxyEnabled === true,
agentHeartbeatsEnabled: config.agentHeartbeatsEnabled === true,
heartbeatDefaultPrompt: config.heartbeatDefaultPrompt ?? undefined,
heartbeatDefaultIntervalMs: config.heartbeatDefaultIntervalMs ?? undefined,
Expand Down Expand Up @@ -2887,6 +2894,14 @@ export class Config {
});
}

async updateBashAiProxyEnabled(enabled: boolean): Promise<void> {
await this.editConfig((config) => {
if (enabled) config.bashAiProxyEnabled = true;
else delete config.bashAiProxyEnabled;
return config;
});
}

async updateAgentHeartbeatsEnabled(enabled: boolean): Promise<void> {
await this.editConfig((config) => {
if (enabled) config.agentHeartbeatsEnabled = true;
Expand Down
10 changes: 10 additions & 0 deletions src/node/orpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,16 @@ export const router = (authToken?: string) => {
yield* atomicPromise(async () => context.config.updateToolSearchEnabled(input.enabled));
})
),
updateBashAiProxyEnabled: t
.input(schemas.config.updateBashAiProxyEnabled.input)
.output(schemas.config.updateBashAiProxyEnabled.output)
.handler(
handlerGen(function* ({ context }, input) {
yield* atomicPromise(async () =>
context.config.updateBashAiProxyEnabled(input.enabled)
);
})
),
updateAgentHeartbeatsEnabled: t
.input(schemas.config.updateAgentHeartbeatsEnabled.input)
.output(schemas.config.updateAgentHeartbeatsEnabled.output)
Expand Down
21 changes: 21 additions & 0 deletions src/node/services/agentSkills/builtInSkillContent.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5014,6 +5014,27 @@ export const BUILTIN_SKILL_FILES: Record<string, Record<string, string>> = {
"",
"{/* END PROVIDER_ENV_VARS */}",
"",
"## AI Calls from Bash Commands",
"",
"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`).",
"",
"| Variable | Value |",
"| ------------------------------------------------------------- | ------------------------------------- |",
"| `ANTHROPIC_BASE_URL` | `http://127.0.0.1:<port>/anthropic` |",
"| `OPENAI_BASE_URL` | `http://127.0.0.1:<port>/openai/v1` |",
"| `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `OPENAI_API_KEY` | `xum-proxy-…` (one key per workspace) |",
"",
"- Xum sends the calls with the provider keys from this page. The command never sees the real key.",
"- Only Local and Worktree commands get the variables. SSH, Coder, Docker and devcontainer commands keep their environment.",
"- Bash commands in `xum run` and `xum workflow` sessions do not get the variables.",
"- 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.",
"- 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`.",
"- The proxy allows Messages, Responses, Chat Completions, token counting and model listing. Other paths get 404.",
"- The port and keys change when Xum restarts. A background command started before the restart gets connection errors. Start it again.",
"- A key stops working when its workspace is removed.",
"- 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.",
"- 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.",
"",
"## Advanced: Manual Configuration",
"",
"For advanced options not exposed in the UI, edit `~/.xum/providers.jsonc` directly:",
Expand Down
Loading
Loading