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>
This commit is contained in:
Devin Foley 2026-03-24 17:07:46 -07:00
parent b1d12d2f37
commit 19dc1c6d1d
8 changed files with 80 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") : []; 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 { export function parseJson(value: string): Record<string, unknown> | null {
try { try {
return JSON.parse(value) as Record<string, unknown>; return JSON.parse(value) as Record<string, unknown>;

View file

@ -20,6 +20,7 @@ import {
ensurePathInEnv, ensurePathInEnv,
renderTemplate, renderTemplate,
runChildProcess, runChildProcess,
applyBillingModeOverride,
} from "@paperclipai/adapter-utils/server-utils"; } from "@paperclipai/adapter-utils/server-utils";
import { import {
parseClaudeStreamJson, parseClaudeStreamJson,
@ -97,9 +98,10 @@ function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean
return typeof raw === "string" && raw.trim().length > 0; 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. // 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> { 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", (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); const skillsDir = await buildSkillsDir(config);
// When instructionsFilePath is configured, create a combined temp file that // When instructionsFilePath is configured, create a combined temp file that

View file

@ -19,6 +19,7 @@ import {
renderTemplate, renderTemplate,
joinPromptSections, joinPromptSections,
runChildProcess, runChildProcess,
applyBillingModeOverride,
} from "@paperclipai/adapter-utils/server-utils"; } from "@paperclipai/adapter-utils/server-utils";
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js"; import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
import { pathExists, prepareManagedCodexHome, resolveManagedCodexHomeDir } from "./codex-home.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; 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. // 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 { 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", (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); const runtimeEnv = ensurePathInEnv(effectiveEnv);
await ensureCommandResolvable(command, cwd, runtimeEnv); await ensureCommandResolvable(command, cwd, runtimeEnv);

View file

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

View file

@ -22,6 +22,7 @@ import {
redactEnvForLogs, redactEnvForLogs,
renderTemplate, renderTemplate,
runChildProcess, runChildProcess,
applyBillingModeOverride,
} from "@paperclipai/adapter-utils/server-utils"; } from "@paperclipai/adapter-utils/server-utils";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
import { import {
@ -40,10 +41,11 @@ function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean
return typeof raw === "string" && raw.trim().length > 0; return typeof raw === "string" && raw.trim().length > 0;
} }
function resolveGeminiBillingType(env: Record<string, string>): "api" | "subscription" { function resolveGeminiBillingType(env: Record<string, string>, billingMode: string): "api" | "subscription" {
return hasNonEmptyEnvValue(env, "GEMINI_API_KEY") || hasNonEmptyEnvValue(env, "GOOGLE_API_KEY") const autoDetected = hasNonEmptyEnvValue(env, "GEMINI_API_KEY") || hasNonEmptyEnvValue(env, "GOOGLE_API_KEY")
? "api" ? "api"
: "subscription"; : "subscription";
return applyBillingModeOverride(autoDetected, billingMode);
} }
function renderPaperclipEnvNote(env: Record<string, string>): string { 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", (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); const runtimeEnv = ensurePathInEnv(effectiveEnv);
await ensureCommandResolvable(command, cwd, runtimeEnv); await ensureCommandResolvable(command, cwd, runtimeEnv);

View file

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

View file

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

View file

@ -0,0 +1,39 @@
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");
});
});