From 19dc1c6d1d601843ee1cd79ba84833f5c3ba7684 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 24 Mar 2026 17:07:46 -0700 Subject: [PATCH] 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 --- packages/adapter-utils/src/server-utils.ts | 17 ++++++++ .../claude-local/src/server/execute.ts | 8 ++-- .../codex-local/src/server/execute.ts | 8 ++-- .../cursor-local/src/server/execute.ts | 8 ++-- .../gemini-local/src/server/execute.ts | 8 ++-- .../opencode-local/src/server/execute.ts | 3 +- .../adapters/pi-local/src/server/execute.ts | 3 +- .../__tests__/billing-mode-override.test.ts | 39 +++++++++++++++++++ 8 files changed, 80 insertions(+), 14 deletions(-) create mode 100644 server/src/__tests__/billing-mode-override.test.ts diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 12989f72..b706ec5f 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -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 | null { try { return JSON.parse(value) as Record; diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 8ac1d7ee..7cd020bd 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -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, key: string): boolean return typeof raw === "string" && raw.trim().length > 0; } -function resolveClaudeBillingType(env: Record): "api" | "subscription" { +function resolveClaudeBillingType(env: Record, 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 { @@ -338,7 +340,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 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 diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 8fa0d72c..e5121a5d 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -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, key: string): boolean return typeof raw === "string" && raw.trim().length > 0; } -function resolveCodexBillingType(env: Record): "api" | "subscription" { +function resolveCodexBillingType(env: Record, 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, billingType: "api" | "subscription"): string { @@ -378,7 +380,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 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); diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index df339690..f457c176 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -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, key: string): boolean return typeof raw === "string" && raw.trim().length > 0; } -function resolveCursorBillingType(env: Record): "api" | "subscription" { - return hasNonEmptyEnvValue(env, "CURSOR_API_KEY") || hasNonEmptyEnvValue(env, "OPENAI_API_KEY") +function resolveCursorBillingType(env: Record, 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 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); diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index 36b28ad8..0c89e54f 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -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, key: string): boolean return typeof raw === "string" && raw.trim().length > 0; } -function resolveGeminiBillingType(env: Record): "api" | "subscription" { - return hasNonEmptyEnvValue(env, "GEMINI_API_KEY") || hasNonEmptyEnvValue(env, "GOOGLE_API_KEY") +function resolveGeminiBillingType(env: Record, 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 { @@ -217,7 +219,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 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); diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 09db7337..94d7f967 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -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,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + 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"); + }); +});