Skip to content

Commit 6745f9a

Browse files
authored
fix: provider usage accounting, retry/overflow classification, and output clamping (#109796)
* fix(ai): provider usage accounting, retry and overflow classification, output clamping * ci: refresh pull-request validation * test(ai): live-probe overflow classification and output clamping
1 parent 907f555 commit 6745f9a

18 files changed

Lines changed: 900 additions & 83 deletions
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
d6856bc1215580fb139b591b4b5b8aa34d7283ce136b24b358c686638a298eaa plugin-sdk-api-baseline.json
2-
205ff66234e0bf7f9c5f83ef7bd6222b696d544b6db7defec1162174ba28b5b2 plugin-sdk-api-baseline.jsonl
1+
a630c0cbe46727a2dbfdfff05ac195c74d6cabe80eb36a95ceda85186768999f plugin-sdk-api-baseline.json
2+
fcc48ec840e97e8159da2e02434430a4f39baa1984c89f98506b7252fcf352b8 plugin-sdk-api-baseline.jsonl

packages/ai/src/model-utils.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@ import type { Api, Model, ModelThinkingLevel, Usage } from "./types.js";
77

88
/** Calculates and stores model cost fields from token usage and per-million pricing. */
99
export function calculateCost<TApi extends Api>(model: Model<TApi>, usage: Usage): Usage["cost"] {
10+
const cacheWrite1h = Math.min(usage.cacheWrite, Math.max(0, usage.cacheWrite1h ?? 0));
11+
const cacheWrite5m = usage.cacheWrite - cacheWrite1h;
1012
usage.cost.input = (model.cost.input / 1000000) * usage.input;
1113
usage.cost.output = (model.cost.output / 1000000) * usage.output;
1214
usage.cost.cacheRead = (model.cost.cacheRead / 1000000) * usage.cacheRead;
13-
usage.cost.cacheWrite = (model.cost.cacheWrite / 1000000) * usage.cacheWrite;
15+
usage.cost.cacheWrite =
16+
(model.cost.cacheWrite * cacheWrite5m + model.cost.input * 2 * cacheWrite1h) / 1000000;
1417
usage.cost.total =
1518
usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
1619
return usage.cost;

packages/ai/src/providers/anthropic-usage.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,33 @@
11
import { describe, expect, it } from "vitest";
2-
import { readLastAnthropicIterationUsage } from "./anthropic-usage.js";
2+
import {
3+
readAnthropicCacheWriteUsage,
4+
readLastAnthropicIterationUsage,
5+
} from "./anthropic-usage.js";
6+
7+
describe("readAnthropicCacheWriteUsage", () => {
8+
it("reads independent 5-minute and 1-hour cache-write buckets", () => {
9+
expect(
10+
readAnthropicCacheWriteUsage({
11+
cache_creation: {
12+
ephemeral_5m_input_tokens: 600_000,
13+
ephemeral_1h_input_tokens: 400_000,
14+
},
15+
}),
16+
).toEqual({ cacheWrite5m: 600_000, cacheWrite1h: 400_000 });
17+
});
18+
19+
it("keeps a valid bucket when its sibling is absent or malformed", () => {
20+
expect(
21+
readAnthropicCacheWriteUsage({
22+
cache_creation: {
23+
ephemeral_5m_input_tokens: "malformed",
24+
ephemeral_1h_input_tokens: 12,
25+
},
26+
}),
27+
).toEqual({ cacheWrite1h: 12 });
28+
expect(readAnthropicCacheWriteUsage({})).toEqual({});
29+
});
30+
});
331

432
describe("readLastAnthropicIterationUsage", () => {
533
it.each(["message", "compaction", "advisor_message"])(

packages/ai/src/providers/anthropic-usage.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,15 @@ type AnthropicUsagePayload = {
33
output_tokens?: unknown;
44
cache_read_input_tokens?: unknown;
55
cache_creation_input_tokens?: unknown;
6+
cache_creation?: unknown;
67
iterations?: unknown;
78
};
89

10+
export type AnthropicCacheWriteUsage = {
11+
cacheWrite5m?: number;
12+
cacheWrite1h?: number;
13+
};
14+
915
export type AnthropicPromptUsageSnapshot = {
1016
input: number;
1117
cacheRead: number;
@@ -26,6 +32,21 @@ export function readAnthropicUsageTokenCount(value: unknown): number | undefined
2632
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
2733
}
2834

35+
export function readAnthropicCacheWriteUsage(
36+
usage: AnthropicUsagePayload,
37+
): AnthropicCacheWriteUsage {
38+
if (!usage.cache_creation || typeof usage.cache_creation !== "object") {
39+
return {};
40+
}
41+
const cacheCreation = usage.cache_creation as Record<string, unknown>;
42+
const cacheWrite5m = readAnthropicUsageTokenCount(cacheCreation.ephemeral_5m_input_tokens);
43+
const cacheWrite1h = readAnthropicUsageTokenCount(cacheCreation.ephemeral_1h_input_tokens);
44+
return {
45+
...(cacheWrite5m !== undefined ? { cacheWrite5m } : {}),
46+
...(cacheWrite1h !== undefined ? { cacheWrite1h } : {}),
47+
};
48+
}
49+
2950
export function readAnthropicPromptUsageSnapshot(
3051
usage: AnthropicUsagePayload,
3152
): AnthropicPromptUsageSnapshot | undefined {
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { Context, Model } from "../types.js";
3+
import { isContextOverflow } from "../utils/overflow.js";
4+
import { streamAnthropic, streamSimpleAnthropic } from "./anthropic.js";
5+
6+
const apiKey = process.env.ANTHROPIC_API_KEY?.trim() ?? "";
7+
const live = process.env.OPENCLAW_LIVE_TEST === "1" && apiKey.length > 0;
8+
const describeLive = live ? describe : describe.skip;
9+
const timeoutMs = 120_000;
10+
11+
const model: Model<"anthropic-messages"> = {
12+
id: "claude-haiku-4-5",
13+
name: "Claude Haiku 4.5",
14+
api: "anthropic-messages",
15+
provider: "anthropic",
16+
baseUrl: "https://api.anthropic.com",
17+
reasoning: true,
18+
input: ["text"],
19+
cost: { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
20+
contextWindow: 200_000,
21+
maxTokens: 8_192,
22+
};
23+
24+
describeLive("Anthropic provider live", () => {
25+
it(
26+
"streams a basic response with usage",
27+
async () => {
28+
const result = await streamSimpleAnthropic(
29+
model,
30+
{ messages: [{ role: "user", content: "Reply with the single word ok.", timestamp: 0 }] },
31+
{ apiKey, maxTokens: 32, reasoning: "off" },
32+
).result();
33+
34+
expect(result.stopReason).toBe("stop");
35+
expect(result.usage.output).toBeGreaterThan(0);
36+
},
37+
timeoutMs,
38+
);
39+
40+
it(
41+
"parses long-retention cache-write usage",
42+
async () => {
43+
const context: Context = {
44+
systemPrompt: "Stable cacheable provider context. ".repeat(2_000),
45+
messages: [{ role: "user", content: "Reply briefly with ok.", timestamp: 0 }],
46+
};
47+
const result = await streamSimpleAnthropic(model, context, {
48+
apiKey,
49+
cacheRetention: "long",
50+
maxTokens: 32,
51+
reasoning: "off",
52+
}).result();
53+
54+
expect(result.stopReason).toBe("stop");
55+
expect(result.usage.cacheWrite).toBeGreaterThanOrEqual(0);
56+
if (result.usage.cacheWrite1h !== undefined) {
57+
expect(result.usage.cacheWrite1h).toBeGreaterThanOrEqual(0);
58+
expect(Number.isFinite(result.usage.cost.cacheWrite)).toBe(true);
59+
}
60+
},
61+
timeoutMs,
62+
);
63+
64+
it(
65+
"keeps the signature on a streamed thinking block",
66+
async () => {
67+
const result = await streamSimpleAnthropic(
68+
model,
69+
{
70+
messages: [
71+
{
72+
role: "user",
73+
content: "Think through whether 17 is prime, then answer in one sentence.",
74+
timestamp: 0,
75+
},
76+
],
77+
},
78+
{ apiKey, maxTokens: 128, reasoning: "low" },
79+
).result();
80+
81+
expect(result.stopReason).toBe("stop");
82+
const thinking = result.content.find((block) => block.type === "thinking");
83+
expect(thinking?.thinkingSignature?.length).toBeGreaterThan(0);
84+
},
85+
timeoutMs,
86+
);
87+
88+
it(
89+
"classifies the provider's current context-overflow error",
90+
async () => {
91+
const result = await streamAnthropic(
92+
model,
93+
{
94+
messages: [
95+
{
96+
role: "user",
97+
content: "x ".repeat(Math.ceil(model.contextWindow * 1.1)),
98+
timestamp: 0,
99+
},
100+
],
101+
},
102+
{ apiKey, maxTokens: 1, maxRetries: 0 },
103+
).result();
104+
105+
expect(result.stopReason).toBe("error");
106+
expect(isContextOverflow(result)).toBe(true);
107+
},
108+
timeoutMs,
109+
);
110+
111+
it(
112+
"clamps an excessive output request to the model limit",
113+
async () => {
114+
let sentMaxTokens: number | undefined;
115+
const context: Context = {
116+
messages: [{ role: "user", content: "Reply briefly with ok.", timestamp: 0 }],
117+
};
118+
const requestedMaxTokens = model.maxTokens * 100;
119+
120+
const result = await streamSimpleAnthropic(model, context, {
121+
apiKey,
122+
maxTokens: requestedMaxTokens,
123+
maxRetries: 0,
124+
reasoning: "off",
125+
onPayload: (payload) => {
126+
sentMaxTokens = (payload as { max_tokens?: number }).max_tokens;
127+
},
128+
}).result();
129+
130+
expect(sentMaxTokens).toBe(model.maxTokens);
131+
expect(result.errorMessage).toBeUndefined();
132+
expect(["stop", "length"]).toContain(result.stopReason);
133+
},
134+
timeoutMs,
135+
);
136+
});

0 commit comments

Comments
 (0)