@crewhaus/prompt-optimizer-claude 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -15
- package/src/index.test.ts +177 -4
- package/src/index.ts +90 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/prompt-optimizer-claude",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Pillar-2 model-driven MutationProvider — asks Claude to rewrite the prompt given dev-set failures. Closes the v0 prompt-optimizer's L91 'rule-based-only' gap.",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -13,19 +13,19 @@
|
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"@anthropic-ai/sdk": "^0.96.0",
|
|
16
|
-
"@crewhaus/adapter-anthropic": "0.1.
|
|
17
|
-
"@crewhaus/errors": "0.1.
|
|
18
|
-
"@crewhaus/prompt-optimizer": "0.1.
|
|
16
|
+
"@crewhaus/adapter-anthropic": "0.1.3",
|
|
17
|
+
"@crewhaus/errors": "0.1.3",
|
|
18
|
+
"@crewhaus/prompt-optimizer": "0.1.3",
|
|
19
19
|
"zod": "^3.23.8"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
|
-
"@crewhaus/eval-dataset": "0.1.
|
|
22
|
+
"@crewhaus/eval-dataset": "0.1.3"
|
|
23
23
|
},
|
|
24
24
|
"license": "Apache-2.0",
|
|
25
25
|
"author": {
|
|
26
26
|
"name": "Max Meier",
|
|
27
|
-
"email": "max@
|
|
28
|
-
"url": "https://
|
|
27
|
+
"email": "max@crewhaus.ai",
|
|
28
|
+
"url": "https://crewhaus.ai"
|
|
29
29
|
},
|
|
30
30
|
"repository": {
|
|
31
31
|
"type": "git",
|
|
@@ -37,12 +37,7 @@
|
|
|
37
37
|
"url": "https://github.com/crewhaus/factory/issues"
|
|
38
38
|
},
|
|
39
39
|
"publishConfig": {
|
|
40
|
-
"access": "
|
|
41
|
-
},
|
|
42
|
-
"files": [
|
|
43
|
-
"src",
|
|
44
|
-
"README.md",
|
|
45
|
-
"LICENSE",
|
|
46
|
-
"NOTICE"
|
|
47
|
-
]
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"files": ["src", "README.md", "LICENSE", "NOTICE"]
|
|
48
43
|
}
|
package/src/index.test.ts
CHANGED
|
@@ -2,7 +2,11 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import type { ProviderAdapter } from "@crewhaus/adapter-anthropic";
|
|
3
3
|
import type { Sample } from "@crewhaus/eval-dataset";
|
|
4
4
|
import type { OptimizerState } from "@crewhaus/prompt-optimizer";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
ClaudeMutationProvider,
|
|
7
|
+
ClaudeMutationProviderError,
|
|
8
|
+
createClaudeMutationProvider,
|
|
9
|
+
} from "./index";
|
|
6
10
|
|
|
7
11
|
const SAMPLE_TRAIN: ReadonlyArray<Sample> = [
|
|
8
12
|
{ id: "t1", input: "What is 2+2?", expected_output: "4" },
|
|
@@ -20,7 +24,10 @@ const SAMPLE_DEV: ReadonlyArray<Sample> = [
|
|
|
20
24
|
* Matches the canonical event protocol consumed by
|
|
21
25
|
* `consumeStream`/`collectFinalMessage`.
|
|
22
26
|
*/
|
|
23
|
-
function mockAdapter(
|
|
27
|
+
function mockAdapter(
|
|
28
|
+
content: string,
|
|
29
|
+
usage: { input: number; output: number; cacheRead?: number } = { input: 0, output: 0 },
|
|
30
|
+
): ProviderAdapter {
|
|
24
31
|
return {
|
|
25
32
|
id: "mock",
|
|
26
33
|
features: {
|
|
@@ -31,7 +38,16 @@ function mockAdapter(content: string): ProviderAdapter {
|
|
|
31
38
|
// biome-ignore lint/suspicious/noExplicitAny: minimal mock
|
|
32
39
|
stream(_params: any): AsyncIterable<any> {
|
|
33
40
|
return (async function* () {
|
|
34
|
-
|
|
41
|
+
// message_start carries input (+ cache) counts; message_delta
|
|
42
|
+
// carries the running output count — matches the real protocol.
|
|
43
|
+
yield {
|
|
44
|
+
kind: "message_start",
|
|
45
|
+
usage: {
|
|
46
|
+
input: usage.input,
|
|
47
|
+
output: 0,
|
|
48
|
+
...(usage.cacheRead !== undefined ? { cacheRead: usage.cacheRead } : {}),
|
|
49
|
+
},
|
|
50
|
+
};
|
|
35
51
|
yield {
|
|
36
52
|
kind: "content_block_start",
|
|
37
53
|
index: 0,
|
|
@@ -43,7 +59,11 @@ function mockAdapter(content: string): ProviderAdapter {
|
|
|
43
59
|
delta: { type: "text_delta", text: content },
|
|
44
60
|
};
|
|
45
61
|
yield { kind: "content_block_stop", index: 0 };
|
|
46
|
-
yield {
|
|
62
|
+
yield {
|
|
63
|
+
kind: "message_delta",
|
|
64
|
+
stopReason: "end_turn",
|
|
65
|
+
usage: { input: usage.input, output: usage.output },
|
|
66
|
+
};
|
|
47
67
|
yield { kind: "message_stop" };
|
|
48
68
|
})();
|
|
49
69
|
},
|
|
@@ -64,6 +84,16 @@ const baseState: OptimizerState = {
|
|
|
64
84
|
};
|
|
65
85
|
|
|
66
86
|
describe("ClaudeMutationProvider", () => {
|
|
87
|
+
test("exposes the adapter's providerId for the FR-003 cost-gate (provider-agnostic pricing)", () => {
|
|
88
|
+
const adapter = {
|
|
89
|
+
...mockAdapter("{}"),
|
|
90
|
+
providerId: "openai",
|
|
91
|
+
} as unknown as ProviderAdapter;
|
|
92
|
+
const provider = new ClaudeMutationProvider({ adapter, model: "gpt-4o-mini" });
|
|
93
|
+
expect(provider.providerId).toBe("openai");
|
|
94
|
+
expect(provider.modelId).toBe("gpt-4o-mini");
|
|
95
|
+
});
|
|
96
|
+
|
|
67
97
|
test("parses a clean JSON response and returns it as the rewrite", async () => {
|
|
68
98
|
const adapter = mockAdapter(
|
|
69
99
|
`{"rewrite": "Think step by step before answering. Be concise.", "rationale": "Adds explicit chain-of-thought scaffolding which helps on multi-step problems."}`,
|
|
@@ -148,4 +178,147 @@ describe("ClaudeMutationProvider", () => {
|
|
|
148
178
|
});
|
|
149
179
|
expect(provider.name).toBe("claude");
|
|
150
180
|
});
|
|
181
|
+
|
|
182
|
+
// FR-003 — the provider surfaces per-call usage + exposes pricing getters.
|
|
183
|
+
test("returns per-call usage from the response on the success path", async () => {
|
|
184
|
+
const adapter = mockAdapter(`{"rewrite": "Be concise.", "rationale": "Removes ambiguity."}`, {
|
|
185
|
+
input: 1234,
|
|
186
|
+
output: 567,
|
|
187
|
+
cacheRead: 89,
|
|
188
|
+
});
|
|
189
|
+
const provider = new ClaudeMutationProvider({ adapter, model: "claude-sonnet-4-5" });
|
|
190
|
+
const result = await provider.next(baseState);
|
|
191
|
+
expect(result.prompt).toBe("Be concise.");
|
|
192
|
+
expect(result.usage).toEqual({ input: 1234, output: 567, cacheRead: 89 });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("forwards consumed usage even when the response is unusable (JSON miss)", async () => {
|
|
196
|
+
const adapter = mockAdapter("not actually json", { input: 100, output: 20 });
|
|
197
|
+
const provider = new ClaudeMutationProvider({ adapter, model: "claude-sonnet-4-5" });
|
|
198
|
+
const result = await provider.next(baseState);
|
|
199
|
+
// Fell back to current best, but the spend the model DID incur is reported.
|
|
200
|
+
expect(result.prompt).toBe(baseState.best.prompt);
|
|
201
|
+
expect(result.rationale).toContain("claude-fallback");
|
|
202
|
+
expect(result.usage).toEqual({ input: 100, output: 20 });
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("omits usage when the model call never completes (stream error)", async () => {
|
|
206
|
+
const adapter = {
|
|
207
|
+
id: "mock",
|
|
208
|
+
features: { caching: "none", thinking: false, multimodal: { input: false, output: false } },
|
|
209
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal mock
|
|
210
|
+
stream(_params: any): AsyncIterable<any> {
|
|
211
|
+
return (async function* () {
|
|
212
|
+
throw new Error("model unavailable");
|
|
213
|
+
// biome-ignore lint/correctness/noUnreachable: keep typed as async generator
|
|
214
|
+
yield;
|
|
215
|
+
})();
|
|
216
|
+
},
|
|
217
|
+
} as unknown as ProviderAdapter;
|
|
218
|
+
const provider = new ClaudeMutationProvider({ adapter, model: "claude-sonnet-4-5" });
|
|
219
|
+
const result = await provider.next(baseState);
|
|
220
|
+
expect(result.prompt).toBe(baseState.best.prompt);
|
|
221
|
+
expect(result.usage).toBeUndefined();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("exposes modelId + maxOutputTokens getters for the cost-gate", () => {
|
|
225
|
+
const provider = new ClaudeMutationProvider({
|
|
226
|
+
adapter: mockAdapter(""),
|
|
227
|
+
model: "claude-opus-4-7",
|
|
228
|
+
maxTokens: 4096,
|
|
229
|
+
});
|
|
230
|
+
expect(provider.modelId).toBe("claude-opus-4-7");
|
|
231
|
+
expect(provider.maxOutputTokens).toBe(4096);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("maxOutputTokens getter defaults to 2048", () => {
|
|
235
|
+
const provider = new ClaudeMutationProvider({
|
|
236
|
+
adapter: mockAdapter(""),
|
|
237
|
+
model: "claude-sonnet-4-5",
|
|
238
|
+
});
|
|
239
|
+
expect(provider.maxOutputTokens).toBe(2048);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// FR-003 (input-estimate gap) — estimateInputChars must count the FULL
|
|
243
|
+
// serialized input (system block + rendered dev-set failure block), not
|
|
244
|
+
// just the candidate prompt, so the orchestrator's cost-gate cannot
|
|
245
|
+
// under-count input for a wide dev window.
|
|
246
|
+
test("estimateInputChars counts the system block + rendered failure block", () => {
|
|
247
|
+
const provider = new ClaudeMutationProvider({
|
|
248
|
+
adapter: mockAdapter(""),
|
|
249
|
+
model: "claude-sonnet-4-5",
|
|
250
|
+
});
|
|
251
|
+
const est = provider.estimateInputChars(baseState);
|
|
252
|
+
// Strictly greater than the prompt length alone — the deficit the naive
|
|
253
|
+
// `best.prompt.length` estimate would have missed.
|
|
254
|
+
expect(est).toBeGreaterThan(baseState.best.prompt.length);
|
|
255
|
+
// The estimate covers the rendered failure block: it exceeds the prompt
|
|
256
|
+
// length PLUS the combined dev-set input + expected_output text that
|
|
257
|
+
// next() serializes — exactly the content the naive `best.prompt.length`
|
|
258
|
+
// heuristic ignored.
|
|
259
|
+
const devTextChars = SAMPLE_DEV.reduce(
|
|
260
|
+
(acc, s) => acc + s.input.length + (s.expected_output?.length ?? 0),
|
|
261
|
+
0,
|
|
262
|
+
);
|
|
263
|
+
expect(est).toBeGreaterThan(baseState.best.prompt.length + devTextChars);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("createClaudeMutationProvider factory yields an equivalent provider", async () => {
|
|
267
|
+
const adapter = mockAdapter(
|
|
268
|
+
`{"rewrite": "Be concise and exact.", "rationale": "Tightens the instruction."}`,
|
|
269
|
+
);
|
|
270
|
+
const provider = createClaudeMutationProvider({
|
|
271
|
+
adapter,
|
|
272
|
+
model: "claude-sonnet-4-5",
|
|
273
|
+
maxTokens: 1024,
|
|
274
|
+
});
|
|
275
|
+
expect(provider).toBeInstanceOf(ClaudeMutationProvider);
|
|
276
|
+
expect(provider.name).toBe("claude");
|
|
277
|
+
expect(provider.modelId).toBe("claude-sonnet-4-5");
|
|
278
|
+
expect(provider.maxOutputTokens).toBe(1024);
|
|
279
|
+
// Behaves like the directly-constructed provider end-to-end.
|
|
280
|
+
const result = await provider.next(baseState);
|
|
281
|
+
expect(result.prompt).toBe("Be concise and exact.");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("estimateInputChars grows with the dev-set failure window", () => {
|
|
285
|
+
const wideDev: ReadonlyArray<Sample> = [
|
|
286
|
+
...SAMPLE_DEV,
|
|
287
|
+
{ id: "d3", input: "x".repeat(5_000), expected_output: "y".repeat(5_000) },
|
|
288
|
+
{ id: "d4", input: "z".repeat(5_000), expected_output: "w".repeat(5_000) },
|
|
289
|
+
];
|
|
290
|
+
const provider = new ClaudeMutationProvider({
|
|
291
|
+
adapter: mockAdapter(""),
|
|
292
|
+
model: "claude-sonnet-4-5",
|
|
293
|
+
maxFailuresInPrompt: 4,
|
|
294
|
+
});
|
|
295
|
+
const narrow = provider.estimateInputChars(baseState);
|
|
296
|
+
const wide = provider.estimateInputChars({ ...baseState, devSet: wideDev });
|
|
297
|
+
// A larger window serializes more failure text → a larger input estimate,
|
|
298
|
+
// exactly the case the original prompt-only heuristic under-counted.
|
|
299
|
+
expect(wide).toBeGreaterThan(narrow + 18_000);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe("ClaudeMutationProviderError", () => {
|
|
304
|
+
test("carries the adapter code, stable name, and cause chain", () => {
|
|
305
|
+
const cause = new Error("stream aborted");
|
|
306
|
+
const err = new ClaudeMutationProviderError("mutation provider failed", cause);
|
|
307
|
+
expect(err).toBeInstanceOf(Error);
|
|
308
|
+
expect(err.name).toBe("ClaudeMutationProviderError");
|
|
309
|
+
expect(err.code).toBe("adapter");
|
|
310
|
+
expect(err.message).toBe("mutation provider failed");
|
|
311
|
+
expect(err.cause).toBe(cause);
|
|
312
|
+
expect(err.toJSON()).toMatchObject({
|
|
313
|
+
name: "ClaudeMutationProviderError",
|
|
314
|
+
code: "adapter",
|
|
315
|
+
message: "mutation provider failed",
|
|
316
|
+
cause: { name: "Error", message: "stream aborted" },
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("constructs without a cause", () => {
|
|
321
|
+
const err = new ClaudeMutationProviderError("no cause");
|
|
322
|
+
expect(err.cause).toBeUndefined();
|
|
323
|
+
});
|
|
151
324
|
});
|
package/src/index.ts
CHANGED
|
@@ -22,10 +22,14 @@
|
|
|
22
22
|
* the search — see the rule-based provider as the deterministic
|
|
23
23
|
* fallback path).
|
|
24
24
|
*
|
|
25
|
-
* Cost: each call is one Claude request (~1-3K tokens).
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
25
|
+
* Cost: each call is one Claude request (~1-3K tokens). Each `next()`
|
|
26
|
+
* now surfaces the call's actual token `usage` on the returned
|
|
27
|
+
* `ProviderMutation`, and the provider exposes its `modelId` +
|
|
28
|
+
* `maxOutputTokens` getters, so the FR-003 `--budget-usd` cost-gate in
|
|
29
|
+
* `eval-optimizer-orchestrator` can price every call against the §27
|
|
30
|
+
* `cost-tracker` table and stop before a mutation call would exceed
|
|
31
|
+
* the budget. The gate composes with the orchestrator's `iterations`
|
|
32
|
+
* cap (whichever bound is hit first ends the run).
|
|
29
33
|
*
|
|
30
34
|
* Catalog layer: F-eval (active optimisation). Brief: 280.
|
|
31
35
|
*/
|
|
@@ -105,10 +109,70 @@ export class ClaudeMutationProvider implements MutationProvider {
|
|
|
105
109
|
this.systemBlock = opts.systemOverride ?? META_PROMPT_SYSTEM;
|
|
106
110
|
}
|
|
107
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The model id this provider calls, exposed read-only so the FR-003
|
|
114
|
+
* cost-gate can price each call via `resolvePricing(DEFAULT_PRICING,
|
|
115
|
+
* "anthropic", modelId)`. The `MutationProvider` interface only
|
|
116
|
+
* guarantees `name` + `next()`; the orchestrator feature-detects this
|
|
117
|
+
* getter and falls back to a zero-cost meter for providers that don't
|
|
118
|
+
* expose it.
|
|
119
|
+
*/
|
|
120
|
+
get modelId(): string {
|
|
121
|
+
return this.model;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The provider id of the injected adapter, exposed read-only so the
|
|
126
|
+
* FR-003 cost-gate prices calls against the REAL provider's pricing
|
|
127
|
+
* table instead of assuming "anthropic". Feature-detected by the
|
|
128
|
+
* orchestrator like `modelId` — providers without the getter price as
|
|
129
|
+
* Anthropic (the historical behaviour).
|
|
130
|
+
*/
|
|
131
|
+
get providerId(): string {
|
|
132
|
+
return this.adapter.providerId;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The output-token ceiling for each call, exposed read-only so the
|
|
137
|
+
* cost-gate can compute a worst-case pre-call estimate (the gate must
|
|
138
|
+
* decide BEFORE a call whether it would exceed the budget).
|
|
139
|
+
*/
|
|
140
|
+
get maxOutputTokens(): number {
|
|
141
|
+
return this.maxTokens;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* FR-003 — exact serialized INPUT character count this provider would
|
|
146
|
+
* transmit for `state`, so the cost-gate prices the *real* meta-prompt
|
|
147
|
+
* rather than just `best.prompt.length + a fixed overhead`. The naive
|
|
148
|
+
* estimate (prompt length only) under-counts the system block AND the
|
|
149
|
+
* rendered dev-set failure block (each failure's input + expected_output)
|
|
150
|
+
* that `next()` actually sends; with a large dev window + small maxTokens
|
|
151
|
+
* that deficit could let a gate-passing call exceed the budget after the
|
|
152
|
+
* fact. Returning the full system+user char count here makes the
|
|
153
|
+
* `chars/4` token estimate cover everything the model is billed for, so
|
|
154
|
+
* the orchestrator's estimate-before guarantee holds unconditionally
|
|
155
|
+
* (input is now bounded from above, output is already the ceiling).
|
|
156
|
+
*
|
|
157
|
+
* The orchestrator feature-detects this method (it is not part of the
|
|
158
|
+
* `MutationProvider` interface); providers that omit it fall back to the
|
|
159
|
+
* `best.prompt.length + metaOverheadChars` heuristic.
|
|
160
|
+
*/
|
|
161
|
+
estimateInputChars(state: OptimizerState): number {
|
|
162
|
+
const failures = this.selectFailures(state);
|
|
163
|
+
const userMessage = this.buildUserMessage(state.best.prompt, failures);
|
|
164
|
+
return this.systemBlock.length + userMessage.length;
|
|
165
|
+
}
|
|
166
|
+
|
|
108
167
|
async next(state: OptimizerState): Promise<ProviderMutation> {
|
|
109
168
|
const failures = this.selectFailures(state);
|
|
110
169
|
const userMessage = this.buildUserMessage(state.best.prompt, failures);
|
|
111
170
|
|
|
171
|
+
// The call's actual token usage, captured so the FR-003 cost-gate
|
|
172
|
+
// can fold it into a running spend total. Stays undefined until the
|
|
173
|
+
// model call round-trips; a failed/unusable response still records
|
|
174
|
+
// whatever was consumed (the model DID run) so spend is accounted.
|
|
175
|
+
let callUsage: ProviderMutation["usage"];
|
|
112
176
|
let rawText: string | undefined;
|
|
113
177
|
try {
|
|
114
178
|
const final = await collectFinalMessage(
|
|
@@ -119,22 +183,28 @@ export class ClaudeMutationProvider implements MutationProvider {
|
|
|
119
183
|
maxTokens: this.maxTokens,
|
|
120
184
|
}),
|
|
121
185
|
);
|
|
186
|
+
callUsage = {
|
|
187
|
+
input: final.usage.input,
|
|
188
|
+
output: final.usage.output,
|
|
189
|
+
...(final.usage.cacheRead !== undefined ? { cacheRead: final.usage.cacheRead } : {}),
|
|
190
|
+
};
|
|
122
191
|
rawText = extractFirstText(final);
|
|
123
192
|
} catch (err) {
|
|
124
193
|
// Mutator unavailability is not fatal — fall back to current best.
|
|
125
194
|
// The orchestrator's outer loop will record a degenerate iteration
|
|
126
|
-
// (score unchanged) and the search continues.
|
|
195
|
+
// (score unchanged) and the search continues. No usage is available
|
|
196
|
+
// on a stream error (the call did not complete), so spend is zero.
|
|
127
197
|
return this.fallback(state, `model error: ${(err as Error).message}`);
|
|
128
198
|
}
|
|
129
199
|
if (rawText === undefined) {
|
|
130
|
-
return this.fallback(state, "model returned no text block");
|
|
200
|
+
return this.fallback(state, "model returned no text block", callUsage);
|
|
131
201
|
}
|
|
132
202
|
|
|
133
203
|
// Extract JSON: tolerate ```json fences and leading prose. We
|
|
134
204
|
// search for the first balanced `{...}` substring.
|
|
135
205
|
const jsonMatch = rawText.match(/\{[\s\S]*\}/);
|
|
136
206
|
if (jsonMatch === null) {
|
|
137
|
-
return this.fallback(state, "model response did not contain a JSON object");
|
|
207
|
+
return this.fallback(state, "model response did not contain a JSON object", callUsage);
|
|
138
208
|
}
|
|
139
209
|
let parsed: { rewrite: string; rationale: string };
|
|
140
210
|
try {
|
|
@@ -144,6 +214,7 @@ export class ClaudeMutationProvider implements MutationProvider {
|
|
|
144
214
|
return this.fallback(
|
|
145
215
|
state,
|
|
146
216
|
`model response failed schema validation: ${(err as Error).message}`,
|
|
217
|
+
callUsage,
|
|
147
218
|
);
|
|
148
219
|
}
|
|
149
220
|
|
|
@@ -157,6 +228,7 @@ export class ClaudeMutationProvider implements MutationProvider {
|
|
|
157
228
|
prompt: parsed.rewrite,
|
|
158
229
|
mutations: [mutation],
|
|
159
230
|
rationale: parsed.rationale,
|
|
231
|
+
...(callUsage !== undefined ? { usage: callUsage } : {}),
|
|
160
232
|
};
|
|
161
233
|
}
|
|
162
234
|
|
|
@@ -198,13 +270,22 @@ export class ClaudeMutationProvider implements MutationProvider {
|
|
|
198
270
|
/**
|
|
199
271
|
* Fallback when the model can't produce a usable rewrite. Returns
|
|
200
272
|
* the current best verbatim so the search loop records a no-op
|
|
201
|
-
* iteration. The orchestrator logs the fallback reason.
|
|
273
|
+
* iteration. The orchestrator logs the fallback reason. When the
|
|
274
|
+
* model DID round-trip (returned text/JSON that turned out unusable),
|
|
275
|
+
* the `usage` actually consumed is forwarded so the cost-gate still
|
|
276
|
+
* accounts for the spend; a stream error (no completed call) passes
|
|
277
|
+
* no usage and is therefore charged zero.
|
|
202
278
|
*/
|
|
203
|
-
private fallback(
|
|
279
|
+
private fallback(
|
|
280
|
+
state: OptimizerState,
|
|
281
|
+
reason: string,
|
|
282
|
+
usage?: ProviderMutation["usage"],
|
|
283
|
+
): ProviderMutation {
|
|
204
284
|
return {
|
|
205
285
|
prompt: state.best.prompt,
|
|
206
286
|
mutations: [],
|
|
207
287
|
rationale: `claude-fallback: ${reason}`,
|
|
288
|
+
...(usage !== undefined ? { usage } : {}),
|
|
208
289
|
};
|
|
209
290
|
}
|
|
210
291
|
}
|