fix(gateway): preserve Responses usage details (#117533)

* fix(gateway): preserve Responses usage details

* fix(gateway): keep Responses usage type internal
This commit is contained in:
Peter Steinberger
2026-08-01 10:37:02 -07:00
committed by GitHub
parent 5118b0b915
commit fb6f60a704
6 changed files with 149 additions and 73 deletions

View File

@@ -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;

View File

@@ -279,7 +279,14 @@ export type OutputItem = z.infer<typeof OutputItemSchema>;
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(),
});

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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<string, unknown>;
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<string, unknown>;
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<Record<string, unknown>>;
@@ -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) => {

View File

@@ -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 };