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:
parent
b1d12d2f37
commit
19dc1c6d1d
8 changed files with 80 additions and 14 deletions
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue