Files
openclaw/extensions/openai/usage.ts
2026-07-26 21:28:53 -04:00

263 lines
9.1 KiB
TypeScript

import type {
ProviderFetchUsageSnapshotContext,
ProviderResolveUsageAuthContext,
ProviderResolvedUsageAuth,
} from "openclaw/plugin-sdk/plugin-entry";
import {
addProviderUsageModel,
asProviderUsageObject,
buildProviderUsageHistorySnapshot,
buildUsageHttpErrorSnapshot,
cleanProviderUsageCredential,
createProviderUsageDailyAccumulator,
decodeProviderUsageAdminToken,
encodeProviderUsageAdminToken,
fetchProviderUsagePages,
fetchCodexUsage,
parseProviderUsageNonNegativeInteger,
parseProviderUsageNumber,
resolveProviderUsageDailyPeriod,
resolveProviderUsageDisplayName,
type ProviderUsageSnapshot,
} from "openclaw/plugin-sdk/provider-usage";
import { resolveCodexAuthIdentity } from "./openai-chatgpt-auth-identity.js";
const OPENAI_COSTS_URL = "https://api.openai.com/v1/organization/costs";
const OPENAI_COMPLETIONS_USAGE_URL = "https://api.openai.com/v1/organization/usage/completions";
const OPENAI_ADMIN_TOKEN_PREFIX = "openclaw:openai-admin:v1:";
const OPENAI_USAGE_RESPONSE_MAX_BYTES = 4 * 1024 * 1024;
const OPENAI_USAGE_HISTORY_DAYS = 30;
function encodeAdminToken(token: string): string {
return encodeProviderUsageAdminToken(OPENAI_ADMIN_TOKEN_PREFIX, token);
}
function decodeAdminToken(raw: string): string | undefined {
return decodeProviderUsageAdminToken(OPENAI_ADMIN_TOKEN_PREFIX, raw);
}
function utcDay(epochSeconds: number): string {
return new Date(epochSeconds * 1000).toISOString().slice(0, 10);
}
async function fetchPages(params: {
baseUrl: string;
groupBy: "line_item" | "model";
apiKey: string;
projectId?: string;
startTime: number;
endTime: number;
periodDays: number;
timeoutMs: number;
fetchFn: typeof fetch;
}): Promise<{ ok: true; data: unknown[] } | { ok: false; status?: number }> {
return await fetchProviderUsagePages({
responseLabel: "OpenAI usage",
responseMaxBytes: OPENAI_USAGE_RESPONSE_MAX_BYTES,
timeoutMs: params.timeoutMs,
fetchFn: params.fetchFn,
buildRequest: (page) => {
const url = new URL(params.baseUrl);
url.searchParams.set("start_time", String(params.startTime));
url.searchParams.set("end_time", String(params.endTime));
url.searchParams.set("bucket_width", "1d");
url.searchParams.set("limit", String(params.periodDays));
url.searchParams.set("group_by", params.groupBy);
if (params.projectId) {
url.searchParams.set("project_ids", params.projectId);
}
if (page) {
url.searchParams.set("page", page);
}
return {
url,
headers: {
Accept: "application/json",
Authorization: `Bearer ${params.apiKey}`,
},
};
},
});
}
function aggregateHistory(params: {
costs: unknown[];
completions: unknown[];
periodDays: number;
projectId?: string;
}): ProviderUsageSnapshot {
const daily = new Map<number, ReturnType<typeof createProviderUsageDailyAccumulator>>();
const getDaily = (startTime: number) => {
const current =
daily.get(startTime) ?? createProviderUsageDailyAccumulator(utcDay(startTime), true);
daily.set(startTime, current);
return current;
};
for (const rawBucket of params.costs) {
const bucket = asProviderUsageObject(rawBucket);
const startTime = parseProviderUsageNumber(bucket?.start_time);
if (startTime === undefined || !Array.isArray(bucket?.results)) {
continue;
}
const accumulator = getDaily(startTime);
for (const rawResult of bucket.results) {
const result = asProviderUsageObject(rawResult);
const amount = parseProviderUsageNumber(asProviderUsageObject(result?.amount)?.value) ?? 0;
const category = resolveProviderUsageDisplayName(result?.line_item, "API");
accumulator.amount += amount;
accumulator.categories.set(category, (accumulator.categories.get(category) ?? 0) + amount);
}
}
for (const rawBucket of params.completions) {
const bucket = asProviderUsageObject(rawBucket);
const startTime = parseProviderUsageNumber(bucket?.start_time);
if (startTime === undefined || !Array.isArray(bucket?.results)) {
continue;
}
const accumulator = getDaily(startTime);
for (const rawResult of bucket.results) {
const result = asProviderUsageObject(rawResult);
if (!result) {
continue;
}
const requests = parseProviderUsageNonNegativeInteger(result.num_model_requests);
const textInputTokens = parseProviderUsageNonNegativeInteger(result.input_tokens);
const audioInputTokens = parseProviderUsageNonNegativeInteger(result.input_audio_tokens);
const cacheReadTokens = parseProviderUsageNonNegativeInteger(result.input_cached_tokens);
const inputTokens = Math.max(0, textInputTokens - cacheReadTokens) + audioInputTokens;
const outputTokens =
parseProviderUsageNonNegativeInteger(result.output_tokens) +
parseProviderUsageNonNegativeInteger(result.output_audio_tokens);
const totalTokens = textInputTokens + audioInputTokens + outputTokens;
accumulator.requests = (accumulator.requests ?? 0) + requests;
accumulator.inputTokens += inputTokens;
accumulator.cacheReadTokens += cacheReadTokens;
accumulator.outputTokens += outputTokens;
accumulator.totalTokens += totalTokens;
addProviderUsageModel(
accumulator,
resolveProviderUsageDisplayName(result.model, "Responses and Chat Completions"),
{
requests,
inputTokens,
cacheReadTokens,
cacheWriteTokens: 0,
outputTokens,
totalTokens,
},
);
}
}
return buildProviderUsageHistorySnapshot({
provider: "openai",
displayName: "OpenAI",
plan: params.projectId ? `Admin API · ${params.projectId}` : "Admin API",
periodDays: params.periodDays,
unit: "USD",
...(params.projectId ? { scope: `Project ${params.projectId}` } : {}),
daily: [...daily.entries()].toSorted(([a], [b]) => a - b).map(([, entry]) => entry),
formatSummary: ({ requests, totalTokens }) =>
`${requests.toLocaleString("en-US")} requests · ${totalTokens.toLocaleString("en-US")} tokens`,
});
}
async function fetchOpenAIAdminUsage(params: {
apiKey: string;
projectId?: string;
timeoutMs: number;
fetchFn: typeof fetch;
now?: number;
periodDays?: number;
}): Promise<ProviderUsageSnapshot> {
const period = resolveProviderUsageDailyPeriod({
now: params.now ?? Date.now(),
periodDays: params.periodDays,
defaultPeriodDays: OPENAI_USAGE_HISTORY_DAYS,
});
const common = {
apiKey: params.apiKey,
projectId: params.projectId,
startTime: Math.floor(period.startMs / 1000),
endTime: Math.floor(period.endMs / 1000),
periodDays: period.periodDays,
timeoutMs: params.timeoutMs,
fetchFn: params.fetchFn,
};
const [costs, completions] = await Promise.all([
fetchPages({ ...common, baseUrl: OPENAI_COSTS_URL, groupBy: "line_item" }),
fetchPages({ ...common, baseUrl: OPENAI_COMPLETIONS_USAGE_URL, groupBy: "model" }),
]);
if (!costs.ok || !completions.ok) {
const failedStatus = !costs.ok
? costs.status
: !completions.ok
? completions.status
: undefined;
if (failedStatus === 401 || failedStatus === 403) {
return {
provider: "openai",
displayName: "OpenAI",
windows: [],
error: "Admin API key required",
};
}
return failedStatus
? buildUsageHttpErrorSnapshot({ provider: "openai", status: failedStatus })
: {
provider: "openai",
displayName: "OpenAI",
windows: [],
error: "Usage unavailable",
};
}
return aggregateHistory({
costs: costs.data,
completions: completions.data,
periodDays: period.periodDays,
projectId: params.projectId,
});
}
export async function resolveOpenAIUsageAuth(
ctx: ProviderResolveUsageAuthContext,
): Promise<ProviderResolvedUsageAuth> {
const explicitAdminKey = cleanProviderUsageCredential(ctx.env.OPENAI_ADMIN_KEY);
if (explicitAdminKey) {
return { token: encodeAdminToken(explicitAdminKey) };
}
const oauth = await ctx.resolveOAuthToken();
if (oauth) {
return oauth;
}
// Inference keys may belong to custom, Azure, or agent-local endpoints. Only
// the dedicated admin credential may cross to api.openai.com organization APIs.
return { handled: true };
}
export async function fetchOpenAIUsage(
ctx: ProviderFetchUsageSnapshotContext,
): Promise<ProviderUsageSnapshot> {
const adminKey = decodeAdminToken(ctx.token);
if (!adminKey) {
const snapshot = await fetchCodexUsage(ctx.token, ctx.accountId, ctx.timeoutMs, ctx.fetchFn);
if (snapshot.error) {
return snapshot;
}
// ChatGPT access tokens carry the account email as a JWT claim; the auth
// profile stores no email for these logins.
const { token: accessToken, email: profileEmail } = ctx;
const accountEmail =
resolveCodexAuthIdentity({ accessToken, email: profileEmail }).email ?? profileEmail;
return accountEmail ? { ...snapshot, accountEmail } : snapshot;
}
return await fetchOpenAIAdminUsage({
apiKey: adminKey,
projectId: cleanProviderUsageCredential(ctx.env.OPENAI_PROJECT_ID),
timeoutMs: ctx.timeoutMs,
fetchFn: ctx.fetchFn,
});
}