Q63: model calls are priced and agent and department budgets limit real spend
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
acbafcd6de
commit
168572dd76
21 files changed
+454
-12
No files matched your search
@@ -211,6 +211,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
ipcMain.handle(IPC_CHANNELS.saveProviderCredential, (_event, input) => mutate(() => providers.saveCredential(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.revokeProviderCredential, (_event, input) => mutate(() => providers.revokeCredential(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.updateProviderPolicy, (_event, input) => mutate(() => providers.updatePolicy(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.setProviderPrices, (_event, input) => mutate(() => providers.setPrices(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.checkProviderHealth, async (_event, input) => {
|
||||
try {
|
||||
await providers.checkHealth(input.orgId, input.providerId);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export const spendSchema = `
|
||||
alter table providers add column input_cents_per_mtok real;
|
||||
alter table providers add column cached_input_cents_per_mtok real;
|
||||
alter table providers add column output_cents_per_mtok real;
|
||||
|
||||
alter table provider_requests add column agent_id text references agents(id) on delete set null;
|
||||
alter table provider_requests add column task_run_id text references task_runs(id) on delete set null;
|
||||
alter table provider_requests add column department text;
|
||||
alter table provider_requests add column input_tokens integer;
|
||||
alter table provider_requests add column output_tokens integer;
|
||||
alter table provider_requests add column cached_input_tokens integer;
|
||||
alter table provider_requests add column reasoning_tokens integer;
|
||||
alter table provider_requests add column cost_cents real;
|
||||
create index if not exists idx_provider_requests_agent_created on provider_requests(agent_id, created_at);
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (72, 'spend', datetime('now'));
|
||||
`;
|
||||
@@ -70,6 +70,7 @@ import { taskReviewLinkSchema } from "./migrations/068-task-review-link";
|
||||
import { goalStallSchema } from "./migrations/069-goal-stall";
|
||||
import { runActionsSchema } from "./migrations/070-run-actions";
|
||||
import { backgroundPollFailuresSchema } from "./migrations/071-background-poll-failures";
|
||||
import { spendSchema } from "./migrations/072-spend";
|
||||
|
||||
interface Migration {
|
||||
version: number;
|
||||
@@ -149,6 +150,7 @@ const MIGRATIONS: Migration[] = [
|
||||
{ version: 69, name: "goal_stall", sql: goalStallSchema },
|
||||
{ version: 70, name: "run_actions", sql: runActionsSchema },
|
||||
{ version: 71, name: "background_poll_failures", sql: backgroundPollFailuresSchema },
|
||||
{ version: 72, name: "spend", sql: spendSchema },
|
||||
];
|
||||
|
||||
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createId } from "../../shared/ids";
|
||||
import type { ProviderCooldowns } from "./provider-cooldowns";
|
||||
import { assertExecutionApproval, createIsolationContext, loadWorkspace, requireAgentProvider, requireAssignedAgent, requireTask } from "./task-run-preparation";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { budgetBlock, isPriced, providerPrices } from "./spend";
|
||||
|
||||
/** Sessions that made no progress before a task stalls. */
|
||||
const TASK_RETRY_BUDGET = 3;
|
||||
@@ -14,6 +15,8 @@ const TASK_SESSION_CEILING = 12;
|
||||
export class HeartbeatScheduler {
|
||||
/** Providers currently parked on their monthly quota, for transition logging. */
|
||||
private quotaBlockedProviders = new Map<string, string>();
|
||||
/** Agents parked on a spent monthly budget, for log-once. */
|
||||
private readonly budgetParkedAgents = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private readonly db: DatabaseSync,
|
||||
@@ -61,6 +64,13 @@ export class HeartbeatScheduler {
|
||||
for (const row of rows) {
|
||||
if (cooling.has(row.provider_id)) continue;
|
||||
if (row.interval_sec > 0 && pacedAgents.has(row.agent_id)) continue;
|
||||
const overBudget = budgetBlock(this.db, row.agent_id, isPriced(providerPrices(this.db, row.provider_id)), new Date(this.now()));
|
||||
if (overBudget) {
|
||||
if (!this.budgetParkedAgents.has(row.agent_id)) console.info(`[task-queue] ${overBudget} Its work waits.`);
|
||||
this.budgetParkedAgents.add(row.agent_id);
|
||||
continue;
|
||||
}
|
||||
this.budgetParkedAgents.delete(row.agent_id);
|
||||
const sessions = (sessionsOf.all(row.task_id) as Array<{ error_message: string | null; progressed: number }>)
|
||||
.filter((run) => !isTransientProviderFailure(run.error_message));
|
||||
const stuck = sessions.filter((run) => run.progressed === 0).length;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { PROVIDER_EXECUTION_MODE, PROVIDER_REQUEST_STATE, type ProviderExecutionMode } from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import type { TokenUsage } from "./spend";
|
||||
import { currentProviderUsageWindow } from "../../shared/provider-runtime-domain";
|
||||
|
||||
export interface ProviderRequestOwner {
|
||||
@@ -19,6 +20,11 @@ export interface CreateProviderRequestInput {
|
||||
inputCharCount: number;
|
||||
executionMode: ProviderExecutionMode;
|
||||
createdAt: string;
|
||||
/** The agent and task run the call works for, when it is part of a run. */
|
||||
agentId?: string | null;
|
||||
taskRunId?: string | null;
|
||||
/** The agent's department at call time, which its spend is charged to. */
|
||||
department?: string | null;
|
||||
}
|
||||
|
||||
export interface ProviderRequestProgressInput {
|
||||
@@ -104,9 +110,9 @@ export class ProviderRequestStore {
|
||||
id, org_id, provider_id, provider_key, workspace_id, model, prompt_sha256,
|
||||
response_text, raw_response_json, state, error_message, input_char_count,
|
||||
output_char_count, execution_mode, policy_decision, external_response_id,
|
||||
stream_event_count, cancelled_at, created_at, completed_at
|
||||
stream_event_count, cancelled_at, created_at, completed_at, agent_id, task_run_id, department
|
||||
)
|
||||
values (?, ?, ?, ?, ?, ?, ?, null, null, ?, null, ?, 0, ?, ?, null, 0, null, ?, null)
|
||||
values (?, ?, ?, ?, ?, ?, ?, null, null, ?, null, ?, 0, ?, ?, null, 0, null, ?, null, ?, ?, ?)
|
||||
`).run(
|
||||
requestId,
|
||||
input.orgId,
|
||||
@@ -120,10 +126,21 @@ export class ProviderRequestStore {
|
||||
input.executionMode,
|
||||
policyDecision(input.provider, input.workspaceId),
|
||||
input.createdAt,
|
||||
input.agentId ?? null,
|
||||
input.taskRunId ?? null,
|
||||
input.department ?? null,
|
||||
);
|
||||
return requestId;
|
||||
}
|
||||
|
||||
/** Records the tokens a call used and what it cost (null when its provider has no prices). */
|
||||
recordSpend(requestId: string, usage: TokenUsage, costCents: number | null): void {
|
||||
this.db.prepare(`
|
||||
update provider_requests set input_tokens = ?, output_tokens = ?, cached_input_tokens = ?, reasoning_tokens = ?, cost_cents = ?
|
||||
where id = ?
|
||||
`).run(usage.inputTokens, usage.outputTokens, usage.cachedInputTokens, usage.reasoningTokens, costCents, requestId);
|
||||
}
|
||||
|
||||
/** Terminal states (succeeded, failed, cancelled) are final: these transitions return false once one is reached. */
|
||||
markStreaming(input: ProviderRequestProgressInput): boolean {
|
||||
return this.db.prepare(`
|
||||
|
||||
@@ -16,6 +16,8 @@ import { hashPrompt, readProviderResponseId, requireStreamText } from "./provide
|
||||
import { type AgentTurnMessage, type AgentTurnResult, type ToolDefinition, type ToolTurnFn } from "./provider-tool-turn";
|
||||
import { selectToolTurn, supportsToolTurn } from "./provider-tool-turns/router";
|
||||
import { resolveTurnCredential } from "./provider-tool-turns/credential-resolver";
|
||||
import { currentRunId } from "./run-control";
|
||||
import { budgetBlock, isPriced, providerPrices, readTokenUsage, requestCostCents } from "./spend";
|
||||
import {
|
||||
HEALTH_STATE_HEALTHY,
|
||||
WORKSPACE_SCOPES,
|
||||
@@ -47,6 +49,9 @@ export interface ProviderResponseRunnerDeps {
|
||||
}
|
||||
|
||||
export class ProviderResponseRunner {
|
||||
/** Providers already logged as not reporting token usage. */
|
||||
private readonly unreportedUsage = new Set<string>();
|
||||
|
||||
constructor(private readonly deps: ProviderResponseRunnerDeps) {}
|
||||
|
||||
async createResponse(input: ProviderResponseCreateInput): Promise<string> {
|
||||
@@ -116,9 +121,19 @@ export class ProviderResponseRunner {
|
||||
if (!model) throw new Error("Provider model is required.");
|
||||
const owner: ProviderRow & { model: string } = { ...provider, model };
|
||||
const turn = this.deps.toolTurn ?? selectToolTurn(provider.provider_key);
|
||||
// A call made for a run is spent by its agent: refuse before calling once its budget is gone.
|
||||
const taskRunId = currentRunId();
|
||||
const agent = taskRunId
|
||||
? this.deps.db.prepare("select a.id, a.department from task_runs r join agents a on a.id = r.agent_id where r.id = ?").get(taskRunId) as { id: string; department: string }
|
||||
: null;
|
||||
const blocked = agent ? budgetBlock(this.deps.db, agent.id, isPriced(providerPrices(this.deps.db, provider.id)), new Date()) : null;
|
||||
if (blocked) throw new Error(blocked);
|
||||
const credential = await resolveTurnCredential({ authBroker: this.deps.authBroker, integrationKeys: this.deps.integrationKeys, orgId: input.orgId, provider });
|
||||
const promptChars = JSON.stringify(input.messages).length;
|
||||
const requestId = this.deps.requestStore.create({
|
||||
agentId: agent?.id ?? null,
|
||||
taskRunId,
|
||||
department: agent?.department ?? null,
|
||||
orgId: input.orgId,
|
||||
provider: owner,
|
||||
model,
|
||||
@@ -149,6 +164,7 @@ export class ProviderResponseRunner {
|
||||
action: "response_created",
|
||||
});
|
||||
this.deps.requestStore.recordUsage(input.orgId, provider.id, promptChars, result.text.length);
|
||||
this.recordSpend(requestId, owner, result.rawResponseJson);
|
||||
return { ...result, requestId };
|
||||
} catch (error) {
|
||||
this.deps.responseObserver.recordFailure({
|
||||
@@ -164,6 +180,16 @@ export class ProviderResponseRunner {
|
||||
}
|
||||
}
|
||||
|
||||
private recordSpend(requestId: string, provider: { id: string; provider_key: string }, rawResponseJson: string): void {
|
||||
const usage = readTokenUsage(rawResponseJson);
|
||||
if (!usage) {
|
||||
if (!this.unreportedUsage.has(provider.provider_key)) console.warn(`[spend] ${provider.provider_key} returned no token usage; its calls cost nothing on record.`);
|
||||
this.unreportedUsage.add(provider.provider_key);
|
||||
return;
|
||||
}
|
||||
this.deps.requestStore.recordSpend(requestId, usage, requestCostCents(usage, providerPrices(this.deps.db, provider.id)));
|
||||
}
|
||||
|
||||
async streamResponse(input: StreamProviderResponseInput, onEvent: (event: ProviderStreamRendererEvent) => void): Promise<string> {
|
||||
const execution = this.prepareExecution(input);
|
||||
const requestId = this.createRequestRecord(execution);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import type { CancelProviderResponseInput, LaunchProviderSignInInput, ListProviderModelsInput, RevokeProviderCredentialInput, SaveProviderCredentialInput, StreamProviderResponseInput, UpdateProviderPolicyInput } from "../../shared/commands";
|
||||
import type { CancelProviderResponseInput, LaunchProviderSignInInput, ListProviderModelsInput, RevokeProviderCredentialInput, SaveProviderCredentialInput, StreamProviderResponseInput, UpdateProviderPolicyInput, SetProviderPricesInput } from "../../shared/commands";
|
||||
import type { ProviderModelEntry } from "../../shared/provider-models";
|
||||
import { PROVIDER_AUTH_TYPE, PROVIDER_DATA_POLICY, type ProviderAuthType } from "../../shared/domain";
|
||||
import type { ProviderStreamRendererEvent } from "../../shared/provider-request-domain";
|
||||
@@ -259,6 +259,27 @@ export class ProviderService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Sets the per-million-token prices that turn recorded usage into spend. */
|
||||
setPrices(input: SetProviderPricesInput): void {
|
||||
const cents = (dollars: number | null, label: string): number | null => {
|
||||
if (dollars === null) return null;
|
||||
if (!Number.isFinite(dollars) || dollars < 0) throw new Error(`${label} price must be a number of dollars per million tokens, zero or more.`);
|
||||
return dollars * 100;
|
||||
};
|
||||
const inputCents = cents(input.inputDollarsPerMtok, "Input");
|
||||
const outputCents = cents(input.outputDollarsPerMtok, "Output");
|
||||
if ((inputCents === null) !== (outputCents === null)) throw new Error("Set both the input and the output price, or neither.");
|
||||
const result = this.db.prepare(`
|
||||
update providers set input_cents_per_mtok = ?, cached_input_cents_per_mtok = ?, output_cents_per_mtok = ?
|
||||
where id = ? and org_id = ?
|
||||
`).run(inputCents, cents(input.cachedInputDollarsPerMtok, "Cached input"), outputCents, input.providerId, input.orgId);
|
||||
if (result.changes === 0) throw new Error("Provider was not found.");
|
||||
this.audit.record({
|
||||
orgId: input.orgId, entityType: "provider", entityId: input.providerId, actorType: "employee", actorId: null,
|
||||
action: "prices_updated", payload: { input: input.inputDollarsPerMtok, cachedInput: input.cachedInputDollarsPerMtok, output: input.outputDollarsPerMtok },
|
||||
});
|
||||
}
|
||||
|
||||
updatePolicy(input: UpdateProviderPolicyInput): void {
|
||||
const monthlyRequestLimit = Math.trunc(input.monthlyRequestLimit);
|
||||
if (monthlyRequestLimit < 1) throw new Error("Monthly request limit must be at least 1.");
|
||||
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
const TURNS: Record<string, ToolTurnFn> = {
|
||||
openai: runOpenAiResponsesTurn,
|
||||
anthropic: runAnthropicTurn,
|
||||
xai: makeChatCompletionsTurn("https://api.x.ai/v1/chat/completions", "xAI"),
|
||||
groq: makeChatCompletionsTurn("https://api.groq.com/openai/v1/chat/completions", "Groq"),
|
||||
xai: makeChatCompletionsTurn("https://api.x.ai/v1/chat/completions", "xAI", true),
|
||||
groq: makeChatCompletionsTurn("https://api.groq.com/openai/v1/chat/completions", "Groq", true),
|
||||
mistral: makeChatCompletionsTurn("https://api.mistral.ai/v1/chat/completions", "Mistral"),
|
||||
cerebras: makeChatCompletionsTurn("https://api.cerebras.ai/v1/chat/completions", "Cerebras"),
|
||||
openrouter: makeChatCompletionsTurn("https://openrouter.ai/api/v1/chat/completions", "OpenRouter"),
|
||||
|
||||
@@ -60,20 +60,22 @@ export async function runOpenAiResponsesTurn(input: ProviderToolTurnInput): Prom
|
||||
* "Provider request timed out." even though it is working. Streamed turns
|
||||
* emit chunks continuously and only a truly dead connection times out.
|
||||
*/
|
||||
async function runChatCompletionsStream(endpoint: string, headers: Record<string, string>, input: ProviderToolTurnInput): Promise<AgentTurnResult> {
|
||||
async function runChatCompletionsStream(endpoint: string, headers: Record<string, string>, input: ProviderToolTurnInput, requestUsage = false): Promise<AgentTurnResult> {
|
||||
const body: JsonRecord = {
|
||||
model: input.model,
|
||||
messages: toChatMessages(input.instructions, input.messages),
|
||||
tools: chatToolDefs(input.tools),
|
||||
tool_choice: "auto",
|
||||
stream: true,
|
||||
// Some providers only report a stream's token usage when asked; the others send it unasked and may reject the field.
|
||||
...(requestUsage ? { stream_options: { include_usage: true } } : {}),
|
||||
};
|
||||
const response = await postEventStream(endpoint, headers, body);
|
||||
return accumulateChatStream(response.messages);
|
||||
}
|
||||
|
||||
export function makeChatCompletionsTurn(endpoint: string, label: string): (input: ProviderToolTurnInput) => Promise<AgentTurnResult> {
|
||||
return async (input) => runChatCompletionsStream(endpoint, { authorization: `Bearer ${requireApiKey(input.credential, label)}` }, input);
|
||||
export function makeChatCompletionsTurn(endpoint: string, label: string, requestUsage = false): (input: ProviderToolTurnInput) => Promise<AgentTurnResult> {
|
||||
return async (input) => runChatCompletionsStream(endpoint, { authorization: `Bearer ${requireApiKey(input.credential, label)}` }, input, requestUsage);
|
||||
}
|
||||
|
||||
// ---- Anthropic Messages (api key) ----
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
||||
/** The error message a run ends with when the operator stops it. */
|
||||
export const RUN_STOPPED_MESSAGE = "Stopped by the operator.";
|
||||
|
||||
const runSignals = new AsyncLocalStorage<AbortSignal>();
|
||||
const runScope = new AsyncLocalStorage<{ runId: string; signal: AbortSignal }>();
|
||||
const controllers = new Map<string, AbortController>();
|
||||
const settling = new Map<string, Promise<unknown>>();
|
||||
|
||||
@@ -11,7 +11,7 @@ const settling = new Map<string, Promise<unknown>>();
|
||||
export async function withRunControl<T>(runId: string, operation: () => Promise<T>): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
controllers.set(runId, controller);
|
||||
const running = runSignals.run(controller.signal, operation);
|
||||
const running = runScope.run({ runId, signal: controller.signal }, operation);
|
||||
settling.set(runId, running.catch(() => undefined));
|
||||
try {
|
||||
return await running;
|
||||
@@ -49,7 +49,12 @@ export function stopAllRuns(): number {
|
||||
|
||||
/** The stop signal of the run this code executes in, if any. */
|
||||
export function currentRunSignal(): AbortSignal | undefined {
|
||||
return runSignals.getStore();
|
||||
return runScope.getStore()?.signal;
|
||||
}
|
||||
|
||||
/** The task run this code executes in, if any. */
|
||||
export function currentRunId(): string | null {
|
||||
return runScope.getStore()?.runId ?? null;
|
||||
}
|
||||
|
||||
/** Why the current run was stopped, or null while it may continue. */
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
/** Tokens one model call consumed, as the provider reported them. */
|
||||
export interface TokenUsage {
|
||||
/** All input tokens, cached ones included. */
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
reasoningTokens: number;
|
||||
}
|
||||
|
||||
/** Prices in cents per million tokens; null when the operator has not set them. */
|
||||
export interface TokenPrices {
|
||||
inputCentsPerMtok: number | null;
|
||||
cachedInputCentsPerMtok: number | null;
|
||||
outputCentsPerMtok: number | null;
|
||||
}
|
||||
|
||||
/** Reads token usage from a provider's raw response: one JSON body, or the list of a stream's event payloads. */
|
||||
export function readTokenUsage(rawResponseJson: string): TokenUsage | null {
|
||||
const parsed = JSON.parse(rawResponseJson) as unknown;
|
||||
const bodies = Array.isArray(parsed) ? parsed.map(parseEvent) : [parsed];
|
||||
for (let index = bodies.length - 1; index >= 0; index -= 1) {
|
||||
const usage = usageIn(bodies[index]);
|
||||
if (usage) return usage;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True when calls to this provider cost money on record. */
|
||||
export function isPriced(prices: TokenPrices): boolean {
|
||||
return prices.inputCentsPerMtok !== null && prices.outputCentsPerMtok !== null;
|
||||
}
|
||||
|
||||
/** What a call cost in cents, or null when its provider has no prices. */
|
||||
export function requestCostCents(usage: TokenUsage, prices: TokenPrices): number | null {
|
||||
if (prices.inputCentsPerMtok === null || prices.outputCentsPerMtok === null) return null;
|
||||
const cachedPrice = prices.cachedInputCentsPerMtok ?? prices.inputCentsPerMtok;
|
||||
const fresh = usage.inputTokens - usage.cachedInputTokens;
|
||||
return (fresh * prices.inputCentsPerMtok + usage.cachedInputTokens * cachedPrice + usage.outputTokens * prices.outputCentsPerMtok) / 1_000_000;
|
||||
}
|
||||
|
||||
export function providerPrices(db: DatabaseSync, providerId: string): TokenPrices {
|
||||
const row = db.prepare("select input_cents_per_mtok, cached_input_cents_per_mtok, output_cents_per_mtok from providers where id = ?").get(providerId) as
|
||||
{ input_cents_per_mtok: number | null; cached_input_cents_per_mtok: number | null; output_cents_per_mtok: number | null } | undefined;
|
||||
if (!row) throw new Error(`Provider ${providerId} was not found.`);
|
||||
return { inputCentsPerMtok: row.input_cents_per_mtok, cachedInputCentsPerMtok: row.cached_input_cents_per_mtok, outputCentsPerMtok: row.output_cents_per_mtok };
|
||||
}
|
||||
|
||||
export function monthStartIso(now: Date): string {
|
||||
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString();
|
||||
}
|
||||
|
||||
/** Why this agent may not make a paid call this month, or null when it may; free (unpriced) calls are never blocked. */
|
||||
export function budgetBlock(db: DatabaseSync, agentId: string, paid: boolean, now: Date): string | null {
|
||||
if (!paid) return null;
|
||||
const agent = db.prepare("select org_id, name, department, budget_cents from agents where id = ?").get(agentId) as
|
||||
{ org_id: string; name: string; department: string; budget_cents: number } | undefined;
|
||||
if (!agent) throw new Error(`Agent ${agentId} was not found.`);
|
||||
const since = monthStartIso(now);
|
||||
const agentSpend = (db.prepare("select coalesce(sum(cost_cents), 0) as cents from provider_requests where agent_id = ? and created_at >= ?")
|
||||
.get(agentId, since) as { cents: number }).cents;
|
||||
if (agentSpend >= agent.budget_cents) {
|
||||
return `${agent.name} has spent its monthly budget (${dollars(agentSpend)} of ${dollars(agent.budget_cents)}). Raise the budget or wait for next month.`;
|
||||
}
|
||||
const department = db.prepare("select monthly_budget_limit_cents from org_departments where org_id = ? and name = ?")
|
||||
.get(agent.org_id, agent.department) as { monthly_budget_limit_cents: number } | undefined;
|
||||
if (!department) return null;
|
||||
// Charges stay with the department that made them, even after the agent moves.
|
||||
const departmentSpend = (db.prepare(`
|
||||
select coalesce(sum(cost_cents), 0) as cents from provider_requests where org_id = ? and department = ? and created_at >= ?
|
||||
`).get(agent.org_id, agent.department, since) as { cents: number }).cents;
|
||||
if (departmentSpend >= department.monthly_budget_limit_cents) {
|
||||
return `The ${agent.department} department has spent its monthly budget (${dollars(departmentSpend)} of ${dollars(department.monthly_budget_limit_cents)}).`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function dollars(cents: number): string {
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function parseEvent(raw: unknown): unknown {
|
||||
if (typeof raw !== "string") return raw;
|
||||
try {
|
||||
return JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
// Stream sentinels such as [DONE] carry no usage.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function usageIn(body: unknown): TokenUsage | null {
|
||||
if (!isRecord(body)) return null;
|
||||
const nested = isRecord(body.response) ? body.response : null;
|
||||
const firstChoice = Array.isArray(body.choices) && isRecord(body.choices[0]) ? body.choices[0] : null;
|
||||
const openAiStyle = record(body.usage) ?? record(nested?.usage) ?? record(isRecord(body.x_groq) ? body.x_groq.usage : null) ?? record(firstChoice?.usage);
|
||||
if (openAiStyle) return fromOpenAiOrAnthropic(openAiStyle);
|
||||
const gemini = record(body.usageMetadata) ?? record(nested?.usageMetadata);
|
||||
if (gemini) return fromGemini(gemini);
|
||||
return null;
|
||||
}
|
||||
|
||||
function fromOpenAiOrAnthropic(usage: Record<string, unknown>): TokenUsage | null {
|
||||
// Bedrock Converse
|
||||
if (typeof usage.inputTokens === "number") {
|
||||
return {
|
||||
inputTokens: usage.inputTokens + count(usage.cacheReadInputTokens) + count(usage.cacheWriteInputTokens),
|
||||
outputTokens: count(usage.outputTokens),
|
||||
cachedInputTokens: count(usage.cacheReadInputTokens),
|
||||
reasoningTokens: 0,
|
||||
};
|
||||
}
|
||||
// Chat Completions
|
||||
if (typeof usage.prompt_tokens === "number") {
|
||||
return {
|
||||
inputTokens: usage.prompt_tokens,
|
||||
outputTokens: count(usage.completion_tokens),
|
||||
cachedInputTokens: count(record(usage.prompt_tokens_details)?.cached_tokens),
|
||||
reasoningTokens: count(record(usage.completion_tokens_details)?.reasoning_tokens),
|
||||
};
|
||||
}
|
||||
if (typeof usage.input_tokens !== "number") return null;
|
||||
// Anthropic reports cache reads and writes beside input_tokens; Responses reports them inside it.
|
||||
const anthropicCache = count(usage.cache_read_input_tokens) + count(usage.cache_creation_input_tokens);
|
||||
return {
|
||||
inputTokens: usage.input_tokens + anthropicCache,
|
||||
outputTokens: count(usage.output_tokens),
|
||||
cachedInputTokens: count(usage.cache_read_input_tokens) + count(record(usage.input_tokens_details)?.cached_tokens),
|
||||
reasoningTokens: count(record(usage.output_tokens_details)?.reasoning_tokens),
|
||||
};
|
||||
}
|
||||
|
||||
function fromGemini(usage: Record<string, unknown>): TokenUsage | null {
|
||||
if (typeof usage.promptTokenCount !== "number") return null;
|
||||
const thoughts = count(usage.thoughtsTokenCount);
|
||||
return {
|
||||
inputTokens: usage.promptTokenCount,
|
||||
outputTokens: count(usage.candidatesTokenCount) + thoughts,
|
||||
cachedInputTokens: count(usage.cachedContentTokenCount),
|
||||
reasoningTokens: thoughts,
|
||||
};
|
||||
}
|
||||
|
||||
function count(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return isRecord(value) ? value : null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -61,6 +61,7 @@ const api: DesktopApi = {
|
||||
checkProviderHealth: (input) => invoke(IPC_CHANNELS.checkProviderHealth, input),
|
||||
listProviderModels: (input) => invoke(IPC_CHANNELS.listProviderModels, input),
|
||||
updateProviderPolicy: (input) => invoke(IPC_CHANNELS.updateProviderPolicy, input),
|
||||
setProviderPrices: (input) => invoke(IPC_CHANNELS.setProviderPrices, input),
|
||||
openExternalUrl: (url) => invoke(IPC_CHANNELS.openExternalUrl, url),
|
||||
createProviderResponse: (input) => invoke(IPC_CHANNELS.createProviderResponse, input),
|
||||
streamProviderResponse: (input) => invoke(IPC_CHANNELS.streamProviderResponse, input),
|
||||
|
||||
@@ -32,6 +32,14 @@ export const zh: Record<string, string> = {
|
||||
"superseded": "已被取代",
|
||||
"stalled": "已停滞",
|
||||
"Resume": "恢复",
|
||||
"Prices (US$ per million tokens)": "价格(美元 / 百万 token)",
|
||||
"Cached input": "缓存输入",
|
||||
"Unpriced: spend is not counted": "未定价:不计入花费",
|
||||
"{label} {kind} price": "{label} {kind}价格",
|
||||
"input": "输入",
|
||||
"cached input": "缓存输入",
|
||||
"output": "输出",
|
||||
"Save {label} prices": "保存 {label} 价格",
|
||||
"Stop": "停止",
|
||||
"Stopped": "已停止",
|
||||
"stopped": "已停止",
|
||||
|
||||
@@ -26,6 +26,7 @@ interface ProvidersPanelProps {
|
||||
actions: SnapshotActions;
|
||||
}
|
||||
|
||||
const PRICE_GRID = "minmax(150px, 1.4fr) 110px 110px 110px 92px";
|
||||
const POLICY_GRID = "minmax(150px, 1.2fr) minmax(118px, 0.8fr) minmax(140px, 1fr) 112px 124px 124px 92px";
|
||||
type WorkspaceScope = "organization" | "workspace";
|
||||
|
||||
@@ -81,6 +82,7 @@ export function ProvidersPanel({ orgId, employeeId, providers, authProfiles, cat
|
||||
providerRateLimits={providerRateLimits}
|
||||
actions={actions}
|
||||
/>
|
||||
<ProviderPricesTable orgId={orgId} providers={connected} actions={actions} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -160,6 +162,51 @@ function ProviderPolicyTable({
|
||||
);
|
||||
}
|
||||
|
||||
/** Prices turn recorded token usage into spend, which agent and department budgets are checked against. */
|
||||
function ProviderPricesTable({ orgId, providers, actions }: { orgId: string; providers: ProviderRecord[]; actions: SnapshotActions }) {
|
||||
return (
|
||||
<>
|
||||
<div className="section-label" style={{ marginBottom: 10 }}>{t("Prices (US$ per million tokens)")}</div>
|
||||
<div className="card" style={{ padding: 0, overflowX: "auto", marginBottom: 28 }}>
|
||||
<div className="table-row head" style={{ gridTemplateColumns: PRICE_GRID, minWidth: 640 }}>
|
||||
<div>{t("Provider")}</div><div>{t("Input")}</div><div>{t("Cached input")}</div><div>{t("Output")}</div><div />
|
||||
</div>
|
||||
{providers.map((provider) => <ProviderPricesRow key={provider.id} orgId={orgId} provider={provider} actions={actions} />)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderPricesRow({ orgId, provider, actions }: { orgId: string; provider: ProviderRecord; actions: SnapshotActions }) {
|
||||
const asDollars = (cents: number | null) => (cents === null ? "" : String(cents / 100));
|
||||
const [input, setInput] = useState(asDollars(provider.inputCentsPerMtok));
|
||||
const [cached, setCached] = useState(asDollars(provider.cachedInputCentsPerMtok));
|
||||
const [output, setOutput] = useState(asDollars(provider.outputCentsPerMtok));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const price = (value: string) => (value.trim() === "" ? null : Number(value));
|
||||
const save = () => {
|
||||
setError(null);
|
||||
actions.run(() => window.agentCompany.setProviderPrices({
|
||||
orgId, providerId: provider.id, inputDollarsPerMtok: price(input), cachedInputDollarsPerMtok: price(cached), outputDollarsPerMtok: price(output),
|
||||
})).catch((failure: unknown) => setError(failure instanceof Error ? failure.message : String(failure)));
|
||||
};
|
||||
const field = (label: string, value: string, onChange: (next: string) => void) => (
|
||||
<input className="input" aria-label={t("{label} {kind} price", { label: provider.label, kind: label })} type="number" min={0} step="0.01" value={value} onChange={(event) => onChange(event.target.value)} />
|
||||
);
|
||||
return (
|
||||
<div className="table-row" style={{ gridTemplateColumns: PRICE_GRID, minWidth: 640 }}>
|
||||
<div style={{ fontWeight: 540 }}>{provider.label}{provider.inputCentsPerMtok === null && <div style={{ fontSize: 12, color: "var(--text-3)" }}>{t("Unpriced: spend is not counted")}</div>}</div>
|
||||
{field(t("input"), input, setInput)}
|
||||
{field(t("cached input"), cached, setCached)}
|
||||
{field(t("output"), output, setOutput)}
|
||||
<div>
|
||||
<button type="button" className="button small" aria-label={t("Save {label} prices", { label: provider.label })} onClick={save}>{t("Save")}</button>
|
||||
{error && <div className="form-error" role="alert">{error}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderPolicyRow({
|
||||
orgId,
|
||||
provider,
|
||||
|
||||
@@ -64,6 +64,15 @@ export interface UpdateProviderPolicyInput {
|
||||
contextWindowTokens: number;
|
||||
}
|
||||
|
||||
/** List prices in dollars per million tokens; null leaves a price unset. */
|
||||
export interface SetProviderPricesInput {
|
||||
orgId: string;
|
||||
providerId: string;
|
||||
inputDollarsPerMtok: number | null;
|
||||
cachedInputDollarsPerMtok: number | null;
|
||||
outputDollarsPerMtok: number | null;
|
||||
}
|
||||
|
||||
export interface CreateProviderResponseInput {
|
||||
orgId: string;
|
||||
providerId: string;
|
||||
|
||||
@@ -89,6 +89,7 @@ import type {
|
||||
StreamProviderResponseInput,
|
||||
SyncEmployeeIdentityInput,
|
||||
UpdateProviderPolicyInput,
|
||||
SetProviderPricesInput,
|
||||
UpdateTaskStatusInput,
|
||||
} from "./commands";
|
||||
import type { ProviderStreamRendererEvent } from "./provider-request-domain";
|
||||
@@ -164,6 +165,7 @@ export interface DesktopApi {
|
||||
checkProviderHealth(input: CheckProviderHealthInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
listProviderModels(input: ListProviderModelsInput): Promise<DesktopResult<ProviderModelEntry[]>>;
|
||||
updateProviderPolicy(input: UpdateProviderPolicyInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
setProviderPrices(input: SetProviderPricesInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
openExternalUrl(url: string): Promise<DesktopResult<null>>;
|
||||
createProviderResponse(input: CreateProviderResponseInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
streamProviderResponse(input: StreamProviderResponseInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
|
||||
@@ -296,6 +296,10 @@ export interface ProviderRecord {
|
||||
workspaceScope: string;
|
||||
/** Model context window the run budget is computed against. */
|
||||
contextWindowTokens: number;
|
||||
/** Prices in cents per million tokens; null until the operator sets them. */
|
||||
inputCentsPerMtok: number | null;
|
||||
cachedInputCentsPerMtok: number | null;
|
||||
outputCentsPerMtok: number | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ export const IPC_CHANNELS = {
|
||||
checkProviderHealth: "agentcompany:check-provider-health",
|
||||
listProviderModels: "agentcompany:list-provider-models",
|
||||
updateProviderPolicy: "agentcompany:update-provider-policy",
|
||||
setProviderPrices: "agentcompany:set-provider-prices",
|
||||
openExternalUrl: "agentcompany:open-external-url",
|
||||
createProviderResponse: "agentcompany:create-provider-response",
|
||||
streamProviderResponse: "agentcompany:stream-provider-response",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { OnboardingService } from "../../src/main/services/onboarding-service";
|
||||
import { ProviderService } from "../../src/main/services/provider-service";
|
||||
import { providerAdapters } from "../../src/main/services/provider-adapters";
|
||||
import { TaskExecutionService } from "../../src/main/services/task-execution-service";
|
||||
import { claimNextQueuedRun } from "../../src/main/app/task-queue-worker";
|
||||
import type { AgentTurnResult } from "../../src/main/services/provider-tool-turn";
|
||||
|
||||
const testVault = {
|
||||
encryptSecret: (secret: string) => Buffer.from(secret, "utf8"),
|
||||
decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8"),
|
||||
};
|
||||
|
||||
describe("money budgets", () => {
|
||||
test("a run's model calls cost real money, and an agent past its monthly budget is refused and parked", async () => {
|
||||
let calls = 0;
|
||||
const turn = async (): Promise<AgentTurnResult> => {
|
||||
calls += 1;
|
||||
return { text: "Worked.", toolCalls: [], rawResponseJson: JSON.stringify({ usage: { input_tokens: 2_000_000, output_tokens: 1_000_000 } }) };
|
||||
};
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-spend-${randomUUID()}.db`));
|
||||
const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, turn);
|
||||
const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) });
|
||||
const execution = new TaskExecutionService(connection.db, providers);
|
||||
let orgId = "";
|
||||
let ceoId = "";
|
||||
connection.transaction(() => {
|
||||
const summary = onboarding.bootstrap({
|
||||
companyName: "FlowPilot", companyDescription: "CSM analytics.", employeeName: "Yang", workspacePath: "",
|
||||
executionMode: "ai_ceo", autoApproveHires: true, reviewDeliverables: false,
|
||||
goalTitle: "Ship the MVP.", goalDescription: "A working product.",
|
||||
providerKey: "openai", providerSecret: "sk-test-key", providerModel: "gpt-5.4",
|
||||
});
|
||||
orgId = summary.orgId;
|
||||
ceoId = summary.ceoAgentId;
|
||||
});
|
||||
const providerId = (connection.db.prepare("select model_provider_id as id from agents where id = ?").get(ceoId) as { id: string }).id;
|
||||
providers.setPrices({ orgId, providerId, inputDollarsPerMtok: 2.5, cachedInputDollarsPerMtok: null, outputDollarsPerMtok: 10 });
|
||||
connection.db.prepare("update agents set budget_cents = 2000 where id = ?").run(ceoId);
|
||||
|
||||
const first = claimNextQueuedRun(connection)!;
|
||||
await execution.executeRun(orgId, first.id);
|
||||
expect(connection.db.prepare("select agent_id, task_run_id, input_tokens, output_tokens, cost_cents from provider_requests where task_run_id = ?").get(first.id))
|
||||
.toEqual({ agent_id: ceoId, task_run_id: first.id, input_tokens: 2_000_000, output_tokens: 1_000_000, cost_cents: 500 + 1000 });
|
||||
|
||||
const second = connection.transaction(() => execution.enqueueHeartbeatRuns());
|
||||
expect(second).toHaveLength(1);
|
||||
connection.db.prepare("update task_runs set state = 'running' where id = ?").run(second[0]);
|
||||
await execution.executeRun(orgId, second[0]);
|
||||
expect(calls).toBe(2);
|
||||
|
||||
// $30 spent of a $20 budget: the next call is refused before it is made, and the heartbeat parks the agent.
|
||||
connection.db.prepare("update task_runs set created_at = '2000-01-01T00:00:00.000Z'").run();
|
||||
expect(connection.transaction(() => execution.enqueueHeartbeatRuns())).toEqual([]);
|
||||
const third = execution.runTask({ orgId, taskId: (connection.db.prepare("select task_id from task_runs where id = ?").get(first.id) as { task_id: string }).task_id });
|
||||
await expect(third).rejects.toThrow("CEO has spent its monthly budget ($30.00 of $20.00). Raise the budget or wait for next month.");
|
||||
expect(calls).toBe(2);
|
||||
|
||||
// A $0 budget allows no paid call, and unpriced calls are never blocked by money.
|
||||
connection.db.prepare("update agents set budget_cents = 0 where id = ?").run(ceoId);
|
||||
connection.db.prepare("delete from provider_requests").run();
|
||||
const kickoffTask = (connection.db.prepare("select task_id from task_runs where id = ?").get(first.id) as { task_id: string }).task_id;
|
||||
connection.db.prepare("update task_runs set state = 'failed' where state in ('queued', 'running')").run();
|
||||
await expect(execution.runTask({ orgId, taskId: kickoffTask })).rejects.toThrow("CEO has spent its monthly budget ($0.00 of $0.00).");
|
||||
providers.setPrices({ orgId, providerId, inputDollarsPerMtok: null, cachedInputDollarsPerMtok: null, outputDollarsPerMtok: null });
|
||||
await execution.runTask({ orgId, taskId: kickoffTask });
|
||||
expect(calls).toBe(3);
|
||||
|
||||
// Department spend stays with the department that made it, even after the agent moves.
|
||||
providers.setPrices({ orgId, providerId, inputDollarsPerMtok: 2.5, cachedInputDollarsPerMtok: null, outputDollarsPerMtok: 10 });
|
||||
connection.db.prepare("update agents set budget_cents = 1000000 where id = ?").run(ceoId);
|
||||
connection.db.prepare("update org_departments set monthly_budget_limit_cents = 1000 where org_id = ? and name = 'Executive'").run(orgId);
|
||||
connection.db.prepare("update provider_requests set cost_cents = 1500").run();
|
||||
connection.db.prepare("update agents set department = 'Design' where id = ?").run(ceoId);
|
||||
expect(connection.db.prepare("select department from provider_requests").get()).toEqual({ department: "Executive" });
|
||||
connection.db.prepare("update agents set department = 'Executive' where id = ?").run(ceoId);
|
||||
await expect(execution.runTask({ orgId, taskId: kickoffTask })).rejects.toThrow("The Executive department has spent its monthly budget ($15.00 of $10.00).");
|
||||
connection.close();
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,7 @@ describe("domain services", () => {
|
||||
const connection = createConnection();
|
||||
|
||||
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
|
||||
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71]);
|
||||
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72]);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { readTokenUsage, requestCostCents } from "../../src/main/services/spend";
|
||||
|
||||
describe("token usage and cost", () => {
|
||||
test("reads usage from every provider shape, cached and reasoning tokens included", () => {
|
||||
expect(readTokenUsage(JSON.stringify({ usage: { input_tokens: 1000, output_tokens: 200, input_tokens_details: { cached_tokens: 400 }, output_tokens_details: { reasoning_tokens: 50 } } })))
|
||||
.toEqual({ inputTokens: 1000, outputTokens: 200, cachedInputTokens: 400, reasoningTokens: 50 });
|
||||
expect(readTokenUsage(JSON.stringify({ usage: { input_tokens: 600, output_tokens: 80, cache_read_input_tokens: 300, cache_creation_input_tokens: 100 } })))
|
||||
.toEqual({ inputTokens: 1000, outputTokens: 80, cachedInputTokens: 300, reasoningTokens: 0 });
|
||||
expect(readTokenUsage(JSON.stringify({ usage: { inputTokens: 500, outputTokens: 70, cacheReadInputTokens: 100 } })))
|
||||
.toEqual({ inputTokens: 600, outputTokens: 70, cachedInputTokens: 100, reasoningTokens: 0 });
|
||||
expect(readTokenUsage(JSON.stringify({ usageMetadata: { promptTokenCount: 900, candidatesTokenCount: 60, thoughtsTokenCount: 40, cachedContentTokenCount: 200 } })))
|
||||
.toEqual({ inputTokens: 900, outputTokens: 100, cachedInputTokens: 200, reasoningTokens: 40 });
|
||||
const chatStream = ["{\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}", "{\"choices\":[],\"usage\":{\"prompt_tokens\":120,\"completion_tokens\":30,\"prompt_tokens_details\":{\"cached_tokens\":20}}}", "[DONE]"];
|
||||
expect(readTokenUsage(JSON.stringify(chatStream))).toEqual({ inputTokens: 120, outputTokens: 30, cachedInputTokens: 20, reasoningTokens: 0 });
|
||||
const responsesStream = ["{\"type\":\"response.created\"}", "{\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}}"];
|
||||
expect(readTokenUsage(JSON.stringify(responsesStream))).toEqual({ inputTokens: 10, outputTokens: 5, cachedInputTokens: 0, reasoningTokens: 0 });
|
||||
const moonshotStream = ["{\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\",\"usage\":{\"prompt_tokens\":40,\"completion_tokens\":9}}]}"];
|
||||
expect(readTokenUsage(JSON.stringify(moonshotStream))).toEqual({ inputTokens: 40, outputTokens: 9, cachedInputTokens: 0, reasoningTokens: 0 });
|
||||
expect(readTokenUsage(JSON.stringify({ output: [] }))).toBeNull();
|
||||
});
|
||||
|
||||
test("cost prices fresh input, cached input and output separately; no prices, no cost", () => {
|
||||
const usage = { inputTokens: 1_000_000, outputTokens: 500_000, cachedInputTokens: 400_000, reasoningTokens: 0 };
|
||||
expect(requestCostCents(usage, { inputCentsPerMtok: 250, cachedInputCentsPerMtok: 125, outputCentsPerMtok: 1000 })).toBe(150 + 50 + 500);
|
||||
expect(requestCostCents(usage, { inputCentsPerMtok: 250, cachedInputCentsPerMtok: null, outputCentsPerMtok: 1000 })).toBe(250 + 500);
|
||||
expect(requestCostCents(usage, { inputCentsPerMtok: null, cachedInputCentsPerMtok: null, outputCentsPerMtok: 1000 })).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user