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") : [];
}
/**
* 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

@@ -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,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
provider: parseModelProvider(modelId),
biller: resolveOpenCodeBiller(runtimeEnv, parseModelProvider(modelId)),
model: modelId,
billingType: "unknown",
billingType: applyBillingModeOverride("api", asString(config.billingMode, "auto")) === "subscription" ? "subscription" : "unknown",
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,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
provider: provider,
biller: resolvePiBiller(runtimeEnv, provider),
model: model,
billingType: "unknown",
billingType: applyBillingModeOverride("api", asString(config.billingMode, "auto")) === "subscription" ? "subscription" : "unknown",
costUsd: attempt.parsed.usage.costUsd,
resultJson: {
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");
});
});