Compare commits

...
Sign in to create a new pull request.

3 commits

Author SHA1 Message Date
Devin Foley
4ad92799e1 Guard BillingModeField with isLocal check
Prevents the billing mode dropdown from rendering for non-local
adapters like openclaw_gateway where the server-side execute never
reads config.billingMode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 17:50:25 -07:00
Devin Foley
a222addb8c Fix pi/opencode billing mode ternary bug and add UI field
- Fix: pi-local and opencode-local now correctly return "api" when
  billingMode is "metered"/"api" instead of falling through to "unknown"
- Add tests covering the pi/opencode unknown-default pattern
- Add BillingModeField dropdown to the agent config form for all local
  adapters (Auto / Subscription / Metered API)
- Add billingMode to CreateConfigValues type

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 17:45:27 -07:00
Devin Foley
19dc1c6d1d feat(adapters): add billingMode config override for billing type detection
Adapters auto-detect billing type based on API key presence, but users
on subscription plans (e.g., Claude Max, GPT Plus) with API keys
configured see inflated spend. This adds a `billingMode` adapter config
field ("subscription", "metered", or "auto") that overrides the
heuristic when explicitly set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 17:07:46 -07:00
12 changed files with 156 additions and 14 deletions

View file

@ -144,6 +144,23 @@ export function asStringArray(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
}
/**
* Resolve an adapter billing type with an optional user-configured override.
*
* When `billingMode` is `"subscription"` or `"metered"`, the override takes
* precedence over the auto-detected value. Any other value (including the
* default `"auto"` or empty string) falls through to `autoDetected`.
*/
export function applyBillingModeOverride(
autoDetected: "api" | "subscription",
billingMode: string,
): "api" | "subscription" {
const normalized = billingMode.trim().toLowerCase();
if (normalized === "subscription") return "subscription";
if (normalized === "metered" || normalized === "api") return "api";
return autoDetected;
}
export function parseJson(value: string): Record<string, unknown> | null {
try {
return JSON.parse(value) as Record<string, unknown>;

View file

@ -344,6 +344,7 @@ export interface CreateConfigValues {
workspaceBranchTemplate?: string;
worktreeParentDir?: string;
runtimeServicesJson?: string;
billingMode?: string;
maxTurnsPerRun: number;
heartbeatEnabled: boolean;
intervalSec: number;

View file

@ -20,6 +20,7 @@ import {
ensurePathInEnv,
renderTemplate,
runChildProcess,
applyBillingModeOverride,
} from "@paperclipai/adapter-utils/server-utils";
import {
parseClaudeStreamJson,
@ -97,9 +98,10 @@ function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean
return typeof raw === "string" && raw.trim().length > 0;
}
function resolveClaudeBillingType(env: Record<string, string>): "api" | "subscription" {
function resolveClaudeBillingType(env: Record<string, string>, billingMode: string): "api" | "subscription" {
// Claude uses API-key auth when ANTHROPIC_API_KEY is present; otherwise rely on local login/session auth.
return hasNonEmptyEnvValue(env, "ANTHROPIC_API_KEY") ? "api" : "subscription";
const autoDetected = hasNonEmptyEnvValue(env, "ANTHROPIC_API_KEY") ? "api" : "subscription";
return applyBillingModeOverride(autoDetected, billingMode);
}
async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<ClaudeRuntimeConfig> {
@ -338,7 +340,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
const billingType = resolveClaudeBillingType(effectiveEnv);
const billingType = resolveClaudeBillingType(effectiveEnv, asString(config.billingMode, "auto"));
const skillsDir = await buildSkillsDir(config);
// When instructionsFilePath is configured, create a combined temp file that

View file

@ -19,6 +19,7 @@ import {
renderTemplate,
joinPromptSections,
runChildProcess,
applyBillingModeOverride,
} from "@paperclipai/adapter-utils/server-utils";
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
import { pathExists, prepareManagedCodexHome, resolveManagedCodexHomeDir } from "./codex-home.js";
@ -57,9 +58,10 @@ function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean
return typeof raw === "string" && raw.trim().length > 0;
}
function resolveCodexBillingType(env: Record<string, string>): "api" | "subscription" {
function resolveCodexBillingType(env: Record<string, string>, billingMode: string): "api" | "subscription" {
// Codex uses API-key auth when OPENAI_API_KEY is present; otherwise rely on local login/session auth.
return hasNonEmptyEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription";
const autoDetected = hasNonEmptyEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription";
return applyBillingModeOverride(autoDetected, billingMode);
}
function resolveCodexBiller(env: Record<string, string>, billingType: "api" | "subscription"): string {
@ -378,7 +380,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
const billingType = resolveCodexBillingType(effectiveEnv);
const billingType = resolveCodexBillingType(effectiveEnv, asString(config.billingMode, "auto"));
const runtimeEnv = ensurePathInEnv(effectiveEnv);
await ensureCommandResolvable(command, cwd, runtimeEnv);

View file

@ -20,6 +20,7 @@ import {
renderTemplate,
joinPromptSections,
runChildProcess,
applyBillingModeOverride,
} from "@paperclipai/adapter-utils/server-utils";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
import { parseCursorJsonl, isCursorUnknownSessionError } from "./parse.js";
@ -42,10 +43,11 @@ function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean
return typeof raw === "string" && raw.trim().length > 0;
}
function resolveCursorBillingType(env: Record<string, string>): "api" | "subscription" {
return hasNonEmptyEnvValue(env, "CURSOR_API_KEY") || hasNonEmptyEnvValue(env, "OPENAI_API_KEY")
function resolveCursorBillingType(env: Record<string, string>, billingMode: string): "api" | "subscription" {
const autoDetected = hasNonEmptyEnvValue(env, "CURSOR_API_KEY") || hasNonEmptyEnvValue(env, "OPENAI_API_KEY")
? "api"
: "subscription";
return applyBillingModeOverride(autoDetected, billingMode);
}
function resolveCursorBiller(
@ -268,7 +270,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
const billingType = resolveCursorBillingType(effectiveEnv);
const billingType = resolveCursorBillingType(effectiveEnv, asString(config.billingMode, "auto"));
const runtimeEnv = ensurePathInEnv(effectiveEnv);
await ensureCommandResolvable(command, cwd, runtimeEnv);

View file

@ -22,6 +22,7 @@ import {
redactEnvForLogs,
renderTemplate,
runChildProcess,
applyBillingModeOverride,
} from "@paperclipai/adapter-utils/server-utils";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
import {
@ -40,10 +41,11 @@ function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean
return typeof raw === "string" && raw.trim().length > 0;
}
function resolveGeminiBillingType(env: Record<string, string>): "api" | "subscription" {
return hasNonEmptyEnvValue(env, "GEMINI_API_KEY") || hasNonEmptyEnvValue(env, "GOOGLE_API_KEY")
function resolveGeminiBillingType(env: Record<string, string>, billingMode: string): "api" | "subscription" {
const autoDetected = hasNonEmptyEnvValue(env, "GEMINI_API_KEY") || hasNonEmptyEnvValue(env, "GOOGLE_API_KEY")
? "api"
: "subscription";
return applyBillingModeOverride(autoDetected, billingMode);
}
function renderPaperclipEnvNote(env: Record<string, string>): string {
@ -217,7 +219,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
const billingType = resolveGeminiBillingType(effectiveEnv);
const billingType = resolveGeminiBillingType(effectiveEnv, asString(config.billingMode, "auto"));
const runtimeEnv = ensurePathInEnv(effectiveEnv);
await ensureCommandResolvable(command, cwd, runtimeEnv);

View file

@ -19,6 +19,7 @@ import {
runChildProcess,
readPaperclipRuntimeSkillEntries,
resolvePaperclipDesiredSkillNames,
applyBillingModeOverride,
} from "@paperclipai/adapter-utils/server-utils";
import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js";
import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
@ -371,7 +372,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
provider: parseModelProvider(modelId),
biller: resolveOpenCodeBiller(runtimeEnv, parseModelProvider(modelId)),
model: modelId,
billingType: "unknown",
billingType: (() => {
const mode = asString(config.billingMode, "auto").trim().toLowerCase();
return mode === "auto" || mode === "" ? "unknown" : applyBillingModeOverride("api", mode);
})(),
costUsd: attempt.parsed.costUsd,
resultJson: {
stdout: attempt.proc.stdout,

View file

@ -20,6 +20,7 @@ import {
removeMaintainerOnlySkillSymlinks,
renderTemplate,
runChildProcess,
applyBillingModeOverride,
} from "@paperclipai/adapter-utils/server-utils";
import { isPiUnknownSessionError, parsePiJsonl } from "./parse.js";
import { ensurePiModelConfiguredAndAvailable } from "./models.js";
@ -460,7 +461,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
provider: provider,
biller: resolvePiBiller(runtimeEnv, provider),
model: model,
billingType: "unknown",
billingType: (() => {
const mode = asString(config.billingMode, "auto").trim().toLowerCase();
return mode === "auto" || mode === "" ? "unknown" : applyBillingModeOverride("api", mode);
})(),
costUsd: attempt.parsed.usage.costUsd,
resultJson: {
stdout: attempt.proc.stdout,

View file

@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { applyBillingModeOverride } from "@paperclipai/adapter-utils/server-utils";
describe("applyBillingModeOverride", () => {
it("returns auto-detected value when billingMode is 'auto'", () => {
expect(applyBillingModeOverride("api", "auto")).toBe("api");
expect(applyBillingModeOverride("subscription", "auto")).toBe("subscription");
});
it("returns auto-detected value when billingMode is empty", () => {
expect(applyBillingModeOverride("api", "")).toBe("api");
expect(applyBillingModeOverride("subscription", "")).toBe("subscription");
});
it("overrides to subscription when billingMode is 'subscription'", () => {
expect(applyBillingModeOverride("api", "subscription")).toBe("subscription");
expect(applyBillingModeOverride("subscription", "subscription")).toBe("subscription");
});
it("overrides to api when billingMode is 'metered'", () => {
expect(applyBillingModeOverride("subscription", "metered")).toBe("api");
expect(applyBillingModeOverride("api", "metered")).toBe("api");
});
it("overrides to api when billingMode is 'api'", () => {
expect(applyBillingModeOverride("subscription", "api")).toBe("api");
});
it("normalizes whitespace and casing", () => {
expect(applyBillingModeOverride("api", " Subscription ")).toBe("subscription");
expect(applyBillingModeOverride("subscription", " METERED ")).toBe("api");
expect(applyBillingModeOverride("api", " AUTO ")).toBe("api");
});
it("falls through for unrecognized values", () => {
expect(applyBillingModeOverride("api", "something_else")).toBe("api");
expect(applyBillingModeOverride("subscription", "credits")).toBe("subscription");
});
});
describe("pi/opencode billing mode pattern (unknown-default adapters)", () => {
// Mirrors the inline pattern used in pi-local and opencode-local where
// the default billing type is "unknown" (no auto-detection available).
function resolveUnknownDefault(billingMode: string): string {
const mode = billingMode.trim().toLowerCase();
return mode === "auto" || mode === "" ? "unknown" : applyBillingModeOverride("api", mode);
}
it("returns 'unknown' when billingMode is 'auto' (default)", () => {
expect(resolveUnknownDefault("auto")).toBe("unknown");
});
it("returns 'unknown' when billingMode is empty", () => {
expect(resolveUnknownDefault("")).toBe("unknown");
});
it("returns 'subscription' when billingMode is 'subscription'", () => {
expect(resolveUnknownDefault("subscription")).toBe("subscription");
});
it("returns 'api' when billingMode is 'metered'", () => {
expect(resolveUnknownDefault("metered")).toBe("api");
});
it("returns 'api' when billingMode is 'api'", () => {
expect(resolveUnknownDefault("api")).toBe("api");
});
});

View file

@ -0,0 +1,36 @@
import type { AdapterConfigFieldsProps } from "./types";
import { Field, help } from "../components/agent-config-primitives";
const selectClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono";
export function BillingModeField({
isCreate,
values,
set,
config,
eff,
mark,
}: Pick<AdapterConfigFieldsProps, "isCreate" | "values" | "set" | "config" | "eff" | "mark">) {
const value = isCreate
? String(values?.billingMode ?? "auto")
: eff("adapterConfig", "billingMode", String(config.billingMode ?? "auto"));
return (
<Field label="Billing mode" hint={help.billingMode}>
<select
value={value}
onChange={(e) =>
isCreate
? set?.({ billingMode: e.target.value })
: mark("adapterConfig", "billingMode", e.target.value === "auto" ? undefined : e.target.value)
}
className={selectClass}
>
<option value="auto">Auto (detect from API keys)</option>
<option value="subscription">Subscription (non-billable)</option>
<option value="metered">Metered API (billable)</option>
</select>
</Field>
);
}

View file

@ -41,6 +41,7 @@ import {
import { defaultCreateValues } from "./agent-config-defaults";
import { getUIAdapter } from "../adapters";
import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-fields";
import { BillingModeField } from "../adapters/billing-mode-field";
import { MarkdownEditor } from "./MarkdownEditor";
import { ChoosePathButton } from "./PathInstructionsModal";
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
@ -773,6 +774,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
<ClaudeLocalAdvancedFields {...adapterFieldProps} />
)}
{isLocal && <BillingModeField {...adapterFieldProps} />}
<Field label="Extra args (comma-separated)" hint={help.extraArgs}>
<DraftInput
value={

View file

@ -55,6 +55,7 @@ export const help: Record<string, string> = {
cooldownSec: "Minimum seconds between consecutive heartbeat runs.",
maxConcurrentRuns: "Maximum number of heartbeat runs that can execute simultaneously for this agent.",
budgetMonthlyCents: "Monthly spending limit in cents. 0 means no limit.",
billingMode: "Controls how runs are billed. Auto detects from API keys. Set to Subscription if you're on a plan (e.g. Claude Max, GPT Plus) so runs don't count toward spend.",
};
export const adapterLabels: Record<string, string> = {