diff --git a/src/agents/usage.ts b/src/agents/usage.ts index c0a7e51b0e80..649666cc9368 100644 --- a/src/agents/usage.ts +++ b/src/agents/usage.ts @@ -73,6 +73,18 @@ export type OpenAiChatCompletionsUsage = { completion_tokens_details?: { reasoning_tokens: number }; }; +/** OpenAI Responses compatible usage shape. */ +type OpenAiResponsesUsage = { + input_tokens: number; + input_tokens_details: { + cached_tokens: number; + cache_write_tokens: number; + }; + output_tokens: number; + output_tokens_details: { reasoning_tokens: number }; + total_tokens: number; +}; + /** Assistant usage snapshot with token counts and computed cost buckets. */ export type AssistantUsageSnapshot = Usage; @@ -272,6 +284,35 @@ export function toOpenAiChatCompletionsUsage( }; } +/** + * Maps normalized usage to OpenAI Responses `usage` fields. + * + * Responses reports cache reads and writes as subsets of `input_tokens`, so + * recombine OpenClaw's separately priced buckets and retain their details. + * Reasoning tokens remain a detail of `output_tokens`, not an extra bucket. + */ +export function toOpenAiResponsesUsage(usage: NormalizedUsage | undefined): OpenAiResponsesUsage { + const input = Math.max(0, usage?.input ?? 0); + const output = Math.max(0, usage?.output ?? 0); + const cacheRead = Math.max(0, usage?.cacheRead ?? 0); + const cacheWrite = Math.max(0, usage?.cacheWrite ?? 0); + const reasoningTokens = Math.max(0, usage?.reasoningTokens ?? 0); + const inputTokens = input + cacheRead + cacheWrite; + const componentTotal = inputTokens + output; + const aggregateTotal = Math.max(0, usage?.total ?? 0); + + return { + input_tokens: inputTokens, + input_tokens_details: { + cached_tokens: cacheRead, + cache_write_tokens: cacheWrite, + }, + output_tokens: output, + output_tokens_details: { reasoning_tokens: reasoningTokens }, + total_tokens: Math.max(componentTotal, aggregateTotal), + }; +} + /** Derive prompt/context tokens from normalized input and cache buckets. */ export function derivePromptTokens(usage?: { input?: number; diff --git a/src/gateway/open-responses.schema.ts b/src/gateway/open-responses.schema.ts index 05bde4ece540..4a48e78b0145 100644 --- a/src/gateway/open-responses.schema.ts +++ b/src/gateway/open-responses.schema.ts @@ -279,7 +279,14 @@ export type OutputItem = z.infer; const UsageSchema = z.object({ input_tokens: z.number().int().nonnegative(), + input_tokens_details: z.object({ + cached_tokens: z.number().int().nonnegative(), + cache_write_tokens: z.number().int().nonnegative(), + }), output_tokens: z.number().int().nonnegative(), + output_tokens_details: z.object({ + reasoning_tokens: z.number().int().nonnegative(), + }), total_tokens: z.number().int().nonnegative(), }); diff --git a/src/gateway/openai-agent-run-usage.ts b/src/gateway/openai-agent-run-usage.ts new file mode 100644 index 000000000000..3ac384fa13e9 --- /dev/null +++ b/src/gateway/openai-agent-run-usage.ts @@ -0,0 +1,27 @@ +/** Shared agent-run usage selection for OpenAI-compatible Gateway endpoints. */ +import { + hasNonzeroUsage, + normalizeUsage, + type NormalizedUsage, + type UsageLike, +} from "../agents/usage.js"; + +type AgentRunUsageMeta = { + usage?: UsageLike; + lastCallUsage?: UsageLike; +}; + +/** Prefer a nonzero aggregate snapshot, then the latest model-call snapshot. */ +export function resolveAgentRunUsage(result: unknown): NormalizedUsage | undefined { + const agentMeta = (result as { meta?: { agentMeta?: AgentRunUsageMeta } } | null)?.meta + ?.agentMeta; + const aggregate = normalizeUsage(agentMeta?.usage); + if (hasNonzeroUsage(aggregate)) { + return aggregate; + } + const lastCall = normalizeUsage(agentMeta?.lastCallUsage); + if (hasNonzeroUsage(lastCall)) { + return lastCall; + } + return aggregate ?? lastCall; +} diff --git a/src/gateway/openai-http.ts b/src/gateway/openai-http.ts index 47e9c046acf5..b42d3213965e 100644 --- a/src/gateway/openai-http.ts +++ b/src/gateway/openai-http.ts @@ -13,13 +13,7 @@ import { isClientToolNameConflictError } from "../agents/agent-tool-definition-a import type { AgentStreamParams, ClientToolDefinition } from "../agents/command/shared-types.js"; import type { ImageContent } from "../agents/command/types.js"; import { STREAM_ERROR_FALLBACK_TEXT } from "../agents/stream-message-shared.js"; -import { - hasNonzeroUsage, - normalizeUsage, - toOpenAiChatCompletionsUsage, - type NormalizedUsage, - type OpenAiChatCompletionsUsage, -} from "../agents/usage.js"; +import { toOpenAiChatCompletionsUsage, type OpenAiChatCompletionsUsage } from "../agents/usage.js"; import { createDefaultDeps } from "../cli/deps.js"; import { agentCommandFromIngress } from "../commands/agent.js"; import type { GatewayHttpChatCompletionsConfig } from "../config/types.gateway.js"; @@ -67,6 +61,7 @@ import { resolveOpenAiCompatibleHttpSenderIsOwner, } from "./http-utils.js"; import { normalizeInputHostnameAllowlist } from "./input-allowlist.js"; +import { resolveAgentRunUsage } from "./openai-agent-run-usage.js"; import { resolveOpenAiCompatError, validateOpenAiSamplingParams } from "./openai-compat-errors.js"; import { isToolChoiceConstraintSatisfied, @@ -766,42 +761,12 @@ function resolveAgentResponseCommentary(result: unknown): string { .join("\n\n"); } -type AgentUsageMeta = { - input?: number; - output?: number; - cacheRead?: number; - cacheWrite?: number; - total?: number; -}; - type PendingToolCall = { id?: unknown; name?: unknown; arguments?: unknown; }; -function resolveAgentRunUsage(result: unknown): NormalizedUsage | undefined { - const agentMeta = ( - result as { - meta?: { - agentMeta?: { - usage?: AgentUsageMeta; - lastCallUsage?: AgentUsageMeta; - }; - }; - } | null - )?.meta?.agentMeta; - const primary = normalizeUsage(agentMeta?.usage); - if (hasNonzeroUsage(primary)) { - return primary; - } - const fallback = normalizeUsage(agentMeta?.lastCallUsage); - if (hasNonzeroUsage(fallback)) { - return fallback; - } - return primary ?? fallback; -} - function resolveStopReasonAndPendingToolCalls(meta: unknown): { stopReason: string | undefined; pendingToolCalls: Array<{ id: string; name: string; arguments: string }> | undefined; diff --git a/src/gateway/openresponses-http.test.ts b/src/gateway/openresponses-http.test.ts index 95a98e03cd93..9d7aad072a13 100644 --- a/src/gateway/openresponses-http.test.ts +++ b/src/gateway/openresponses-http.test.ts @@ -1135,7 +1135,15 @@ describe("OpenResponses HTTP API (e2e)", () => { mockAgentOnce([{ text: "ok" }], { agentMeta: { - usage: { input: 3, output: 5, cacheRead: 1, cacheWrite: 1 }, + usage: { + input: 3, + output: 5, + cacheRead: 1, + cacheWrite: 2, + reasoningTokens: 4, + total: 7, + }, + lastCallUsage: { input: 100, output: 100, total: 200 }, }, }); const resUsage = await postResponses(port, { @@ -1145,7 +1153,13 @@ describe("OpenResponses HTTP API (e2e)", () => { }); expect(resUsage.status).toBe(200); const usageJson = (await resUsage.json()) as Record; - expect(usageJson.usage).toEqual({ input_tokens: 3, output_tokens: 5, total_tokens: 10 }); + expect(usageJson.usage).toEqual({ + input_tokens: 6, + input_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 }, + output_tokens: 5, + output_tokens_details: { reasoning_tokens: 4 }, + total_tokens: 11, + }); await ensureResponseConsumed(resUsage); mockAgentOnce([{ text: "hello" }]); @@ -1158,6 +1172,13 @@ describe("OpenResponses HTTP API (e2e)", () => { const shapeJson = (await resShape.json()) as Record; expect(shapeJson.object).toBe("response"); expect(shapeJson.status).toBe("completed"); + expect(shapeJson.usage).toEqual({ + input_tokens: 0, + input_tokens_details: { cached_tokens: 0, cache_write_tokens: 0 }, + output_tokens: 0, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 0, + }); expect(Array.isArray(shapeJson.output)).toBe(true); const output = shapeJson.output as Array>; @@ -1281,6 +1302,51 @@ describe("OpenResponses HTTP API (e2e)", () => { } }); + it.each([ + { name: "missing aggregate", usage: undefined }, + { name: "zero aggregate", usage: { input: 0, output: 0, total: 0 } }, + ])("uses last-call usage in the terminal SSE response for $name", async ({ usage }) => { + agentCommand.mockClear(); + agentCommand.mockResolvedValueOnce({ + payloads: [{ text: "hello" }], + meta: { + agentMeta: { + ...(usage ? { usage } : {}), + lastCallUsage: { + input: 4, + output: 3, + cacheRead: 2, + cacheWrite: 1, + reasoningTokens: 2, + total: 9, + }, + }, + }, + } as never); + + const client = new OpenAI({ + apiKey: "test", + baseURL: `http://127.0.0.1:${enabledPort}/v1`, + defaultHeaders: { "x-openclaw-scopes": "operator.write" }, + maxRetries: 0, + }); + const response = await client.responses + .stream({ + model: "openclaw", + input: "hi", + }) + .finalResponse(); + + expect(response.status).toBe("completed"); + expect(response.usage).toEqual({ + input_tokens: 7, + input_tokens_details: { cached_tokens: 2, cache_write_tokens: 1 }, + output_tokens: 3, + output_tokens_details: { reasoning_tokens: 2 }, + total_tokens: 10, + }); + }); + it("flushes same-turn assistant microtasks before completing an official SDK stream", async () => { agentCommand.mockClear(); agentCommand.mockImplementationOnce(((opts: unknown) => { diff --git a/src/gateway/openresponses-http.ts b/src/gateway/openresponses-http.ts index 702aa0cb4c26..ef7782184cea 100644 --- a/src/gateway/openresponses-http.ts +++ b/src/gateway/openresponses-http.ts @@ -12,6 +12,7 @@ import { resolveIntegerOption } from "@openclaw/normalization-core/number-coerci import { isClientToolNameConflictError } from "../agents/agent-tool-definition-adapter.js"; import type { ImageContent } from "../agents/command/types.js"; import type { ClientToolDefinition } from "../agents/embedded-agent-runner/run/params.js"; +import { toOpenAiResponsesUsage } from "../agents/usage.js"; import { createDefaultDeps } from "../cli/deps.js"; import type { CliDeps } from "../cli/deps.types.js"; import { agentCommandFromIngress } from "../commands/agent.js"; @@ -70,6 +71,7 @@ import { type StreamingEvent, type Usage, } from "./open-responses.schema.js"; +import { resolveAgentRunUsage } from "./openai-agent-run-usage.js"; import { resolveOpenAiCompatError } from "./openai-compat-errors.js"; import { isToolChoiceConstraintSatisfied, @@ -342,43 +344,11 @@ function applyToolChoice(params: { export { buildAgentPrompt } from "./openresponses-prompt.js"; function createEmptyUsage(): Usage { - return { input_tokens: 0, output_tokens: 0, total_tokens: 0 }; -} - -function toUsage( - value: - | { - input?: number; - output?: number; - cacheRead?: number; - cacheWrite?: number; - total?: number; - } - | undefined, -): Usage { - if (!value) { - return createEmptyUsage(); - } - const input = value.input ?? 0; - const output = value.output ?? 0; - const cacheRead = value.cacheRead ?? 0; - const cacheWrite = value.cacheWrite ?? 0; - const total = value.total ?? input + output + cacheRead + cacheWrite; - return { - input_tokens: Math.max(0, input), - output_tokens: Math.max(0, output), - total_tokens: Math.max(0, total), - }; + return toOpenAiResponsesUsage(undefined); } function extractUsageFromResult(result: unknown): Usage { - const meta = (result as { meta?: { agentMeta?: { usage?: unknown } } } | null)?.meta; - const usage = meta && typeof meta === "object" ? meta.agentMeta?.usage : undefined; - return toUsage( - usage as - | { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; total?: number } - | undefined, - ); + return toOpenAiResponsesUsage(resolveAgentRunUsage(result)); } type PendingToolCall = { id: string; name: string; arguments: string };