mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 09:21:35 +00:00
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:
committed by
GitHub
parent
5118b0b915
commit
fb6f60a704
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
27
src/gateway/openai-agent-run-usage.ts
Normal file
27
src/gateway/openai-agent-run-usage.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user