mirror of
https://github.com/paperclipai/paperclip
synced 2026-03-25 11:21:48 +00:00
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:
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
39
server/src/__tests__/billing-mode-override.test.ts
Normal file
39
server/src/__tests__/billing-mode-override.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user