@crewhaus/prompt-optimizer-claude 0.1.0 → 0.1.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/prompt-optimizer-claude",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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.0.0",
17
- "@crewhaus/errors": "0.0.0",
18
- "@crewhaus/prompt-optimizer": "0.0.0",
16
+ "@crewhaus/adapter-anthropic": "0.1.2",
17
+ "@crewhaus/errors": "0.1.2",
18
+ "@crewhaus/prompt-optimizer": "0.1.2",
19
19
  "zod": "^3.23.8"
20
20
  },
21
21
  "devDependencies": {
22
- "@crewhaus/eval-dataset": "0.0.0"
22
+ "@crewhaus/eval-dataset": "0.1.2"
23
23
  },
24
24
  "license": "Apache-2.0",
25
25
  "author": {
26
26
  "name": "Max Meier",
27
- "email": "max@studiomax.io",
28
- "url": "https://studiomax.io"
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": "restricted"
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 { ClaudeMutationProvider } from "./index";
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(content: string): ProviderAdapter {
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
- yield { kind: "message_start", usage: { input: 0, output: 0 } };
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 { kind: "message_delta", stopReason: "end_turn" };
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). A cost-gate
26
- * (`--budget-usd`) backed by the §27 `cost-tracker` running spend
27
- * total is a planned follow-up; today the only safety rail is the
28
- * orchestrator's `iterations` cap.
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(state: OptimizerState, reason: string): ProviderMutation {
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
  }