@theaiteam/promptdiff 1.0.0-rc.1
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/CHANGELOG.md +48 -0
- package/LICENSE +21 -0
- package/README.md +697 -0
- package/SPEC.md +314 -0
- package/package.json +54 -0
- package/promptdiff +4 -0
- package/src/args.ts +83 -0
- package/src/cli.ts +685 -0
- package/src/engine/cache.ts +150 -0
- package/src/engine/compare.ts +563 -0
- package/src/engine/config.ts +502 -0
- package/src/engine/grader.ts +149 -0
- package/src/engine/json-assert.ts +277 -0
- package/src/engine/judge.ts +388 -0
- package/src/engine/receipt.ts +150 -0
- package/src/engine/render.ts +59 -0
- package/src/engine/report.ts +49 -0
- package/src/engine/sandbox.ts +97 -0
- package/src/engine/skill-install.ts +112 -0
- package/src/engine/stats.ts +41 -0
- package/src/prompt.ts +16 -0
- package/src/runner/claude-p.ts +156 -0
- package/src/runner/index.ts +31 -0
- package/src/runner/openai-compat.ts +228 -0
- package/src/types.ts +65 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Runner } from "../types";
|
|
2
|
+
import { ClaudePrintRunner } from "./claude-p";
|
|
3
|
+
import { OpenAiCompatRunner, type ModelPricing } from "./openai-compat";
|
|
4
|
+
|
|
5
|
+
export const RUNNER_NAMES = ["claude-p", "openai"] as const;
|
|
6
|
+
|
|
7
|
+
export type RunnerName = (typeof RUNNER_NAMES)[number];
|
|
8
|
+
|
|
9
|
+
export interface CreateRunnerOptions {
|
|
10
|
+
/** OpenAI-compatible endpoint base URL; ignored by claude-p. */
|
|
11
|
+
baseUrl?: string;
|
|
12
|
+
/** Transient-failure retries for the openai runner; ignored by claude-p. */
|
|
13
|
+
retries?: number;
|
|
14
|
+
/** USD-per-M-token pricing for the openai runner; claude-p prices itself. */
|
|
15
|
+
pricing?: ModelPricing;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createRunner(name: RunnerName, options: CreateRunnerOptions = {}): Runner {
|
|
19
|
+
switch (name) {
|
|
20
|
+
case "claude-p":
|
|
21
|
+
return new ClaudePrintRunner();
|
|
22
|
+
case "openai":
|
|
23
|
+
return new OpenAiCompatRunner({ baseUrl: options.baseUrl, retries: options.retries, pricing: options.pricing });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function runnerNameValue(value: unknown, fallback: RunnerName): RunnerName {
|
|
28
|
+
if (value === undefined) return fallback;
|
|
29
|
+
if (value === "claude-p" || value === "openai") return value;
|
|
30
|
+
throw new Error(`runner must be one of: ${RUNNER_NAMES.join(", ")}`);
|
|
31
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { extname } from "node:path";
|
|
3
|
+
import type { RunResult, Runner, RunnerRunOptions } from "../types";
|
|
4
|
+
|
|
5
|
+
/** USD per million input/output tokens for one model. */
|
|
6
|
+
export interface ModelPricing {
|
|
7
|
+
input: number;
|
|
8
|
+
output: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface OpenAiCompatRunnerConfig {
|
|
12
|
+
/** Defaults to $OPENAI_BASE_URL, then https://api.openai.com/v1. */
|
|
13
|
+
baseUrl?: string;
|
|
14
|
+
/** Defaults to $OPENAI_API_KEY. Optional — local servers often need none. */
|
|
15
|
+
apiKey?: string;
|
|
16
|
+
/** Extra attempts after a transient failure (timeout, connect error, 429/5xx). Default 2. */
|
|
17
|
+
retries?: number;
|
|
18
|
+
/** Backoff before retry attempt N (1-based); injectable for tests. */
|
|
19
|
+
backoffMs?: (attempt: number) => number;
|
|
20
|
+
/**
|
|
21
|
+
* Prices responses from usage tokens and makes maxBudgetUsd enforce.
|
|
22
|
+
* Unset, cost reports 0 — true for local servers, decorative for paid ones.
|
|
23
|
+
*/
|
|
24
|
+
pricing?: ModelPricing;
|
|
25
|
+
fetchFn?: typeof fetch;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Failure modes worth retrying: the request produced nothing durable and the cause is time-bound. */
|
|
29
|
+
class TransientError extends Error {}
|
|
30
|
+
|
|
31
|
+
export type ChatContentPart =
|
|
32
|
+
| { type: "text"; text: string }
|
|
33
|
+
| { type: "image_url"; image_url: { url: string } };
|
|
34
|
+
|
|
35
|
+
export interface ChatRequest {
|
|
36
|
+
model: string;
|
|
37
|
+
messages: Array<{ role: "system" | "user"; content: string | ChatContentPart[] }>;
|
|
38
|
+
[param: string]: unknown;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const IMAGE_MIME: Record<string, string> = {
|
|
42
|
+
".jpg": "image/jpeg",
|
|
43
|
+
".jpeg": "image/jpeg",
|
|
44
|
+
".png": "image/png",
|
|
45
|
+
".webp": "image/webp",
|
|
46
|
+
".gif": "image/gif",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** File path → data URI. Throws on unsupported extensions before any paid run. */
|
|
50
|
+
export function imageDataUri(path: string): string {
|
|
51
|
+
const mime = IMAGE_MIME[extname(path).toLowerCase()];
|
|
52
|
+
if (!mime) {
|
|
53
|
+
throw new Error(`unsupported image type "${extname(path)}" (${path}); supported: ${Object.keys(IMAGE_MIME).join(", ")}`);
|
|
54
|
+
}
|
|
55
|
+
return `data:${mime};base64,${readFileSync(path).toString("base64")}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface ChatCompletion {
|
|
59
|
+
model?: unknown;
|
|
60
|
+
choices?: unknown;
|
|
61
|
+
usage?: unknown;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function buildChatRequest(options: RunnerRunOptions): ChatRequest {
|
|
65
|
+
if (options.systemPromptMode === "append") {
|
|
66
|
+
throw new Error(
|
|
67
|
+
'runner "openai" has no default harness prompt to append to (install delivery is claude-p only)',
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
const messages: ChatRequest["messages"] = [];
|
|
71
|
+
if (options.systemPrompt.trim().length > 0) {
|
|
72
|
+
messages.push({ role: "system", content: options.systemPrompt });
|
|
73
|
+
}
|
|
74
|
+
const images = options.images ?? [];
|
|
75
|
+
if (images.length > 0) {
|
|
76
|
+
messages.push({
|
|
77
|
+
role: "user",
|
|
78
|
+
content: [
|
|
79
|
+
...images.map((path): ChatContentPart => ({ type: "image_url", image_url: { url: imageDataUri(path) } })),
|
|
80
|
+
{ type: "text", text: options.userPrompt },
|
|
81
|
+
],
|
|
82
|
+
});
|
|
83
|
+
} else {
|
|
84
|
+
messages.push({ role: "user", content: options.userPrompt });
|
|
85
|
+
}
|
|
86
|
+
// Spread first so extra params can never clobber model or messages.
|
|
87
|
+
return { ...(options.requestParams ?? {}), model: options.model, messages };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Single-shot chat-completion runner for any OpenAI-compatible endpoint
|
|
92
|
+
* (OpenAI, ollama, vLLM, llama.cpp, OpenRouter, ...). Text mode only: no
|
|
93
|
+
* tools, no sandbox execution, no skill registry.
|
|
94
|
+
*/
|
|
95
|
+
export class OpenAiCompatRunner implements Runner {
|
|
96
|
+
readonly name = "openai";
|
|
97
|
+
readonly capabilities = { sandboxTools: false, skillRegistry: false, images: true };
|
|
98
|
+
|
|
99
|
+
private readonly baseUrl: string;
|
|
100
|
+
private readonly apiKey: string | undefined;
|
|
101
|
+
private readonly retries: number;
|
|
102
|
+
private readonly backoffMs: (attempt: number) => number;
|
|
103
|
+
private readonly pricing: ModelPricing | undefined;
|
|
104
|
+
private readonly fetchFn: typeof fetch;
|
|
105
|
+
|
|
106
|
+
constructor(config: OpenAiCompatRunnerConfig = {}) {
|
|
107
|
+
this.baseUrl = (config.baseUrl ?? process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1").replace(/\/+$/, "");
|
|
108
|
+
this.apiKey = config.apiKey ?? process.env.OPENAI_API_KEY;
|
|
109
|
+
this.retries = config.retries ?? 2;
|
|
110
|
+
this.backoffMs = config.backoffMs ?? ((attempt) => Math.min(4_000, 500 * 2 ** (attempt - 1)));
|
|
111
|
+
this.pricing = config.pricing;
|
|
112
|
+
this.fetchFn = config.fetchFn ?? fetch;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async run(options: RunnerRunOptions): Promise<RunResult> {
|
|
116
|
+
// Transient failures (timeout, connect error, 429/5xx) retry with backoff —
|
|
117
|
+
// one 503 at run 4-of-5 must not throw away a whole paid compare. Anything
|
|
118
|
+
// deterministic (4xx, bad JSON, malformed completion) still throws at once.
|
|
119
|
+
let lastError: Error | undefined;
|
|
120
|
+
for (let attempt = 0; attempt <= this.retries; attempt += 1) {
|
|
121
|
+
if (attempt > 0) {
|
|
122
|
+
await Bun.sleep(this.backoffMs(attempt));
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
return await this.runOnce(options);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (!(error instanceof TransientError)) throw error;
|
|
128
|
+
lastError = error;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
throw new Error(`${lastError?.message} (after ${this.retries + 1} attempts)`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private async runOnce(options: RunnerRunOptions): Promise<RunResult> {
|
|
135
|
+
const url = `${this.baseUrl}/chat/completions`;
|
|
136
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
137
|
+
if (this.apiKey) {
|
|
138
|
+
headers.authorization = `Bearer ${this.apiKey}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const started = Date.now();
|
|
142
|
+
let response: Response;
|
|
143
|
+
try {
|
|
144
|
+
response = await this.fetchFn(url, {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers,
|
|
147
|
+
body: JSON.stringify(buildChatRequest(options)),
|
|
148
|
+
signal: AbortSignal.timeout(options.timeoutMs),
|
|
149
|
+
});
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (error instanceof DOMException && error.name === "TimeoutError") {
|
|
152
|
+
throw new TransientError(`${url} timed out after ${options.timeoutMs}ms`);
|
|
153
|
+
}
|
|
154
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
155
|
+
throw new TransientError(`request to ${url} failed: ${reason}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const body = await response.text();
|
|
159
|
+
if (!response.ok) {
|
|
160
|
+
const message = `${url} returned ${response.status}: ${body.slice(0, 1_500)}`;
|
|
161
|
+
// 429/5xx are load conditions; other statuses are wrong requests.
|
|
162
|
+
if (response.status === 429 || response.status >= 500) {
|
|
163
|
+
throw new TransientError(message);
|
|
164
|
+
}
|
|
165
|
+
throw new Error(message);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let parsed: ChatCompletion;
|
|
169
|
+
try {
|
|
170
|
+
parsed = JSON.parse(body) as ChatCompletion;
|
|
171
|
+
} catch {
|
|
172
|
+
throw new Error(`${url} returned invalid JSON: ${body.slice(0, 1_500)}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const run = normalizeChatCompletion(parsed, options.model, Date.now() - started, this.pricing);
|
|
176
|
+
// A single completion can't be aborted mid-request, so the cap enforces
|
|
177
|
+
// post-hoc, like claude-p's between-turns check. Not a TransientError:
|
|
178
|
+
// retrying an over-budget run would spend the overage again.
|
|
179
|
+
if (this.pricing && run.costUsd > options.maxBudgetUsd) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`run cost $${run.costUsd.toFixed(4)} exceeded the $${options.maxBudgetUsd} max budget — raise maxBudgetUsd or cap max_tokens via requestParams`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
return run;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function normalizeChatCompletion(
|
|
189
|
+
result: ChatCompletion,
|
|
190
|
+
requestedModel: string,
|
|
191
|
+
durationMs: number,
|
|
192
|
+
pricing: ModelPricing | undefined,
|
|
193
|
+
): RunResult {
|
|
194
|
+
const choice = Array.isArray(result.choices) ? result.choices[0] : undefined;
|
|
195
|
+
const message = isRecord(choice) && isRecord(choice.message) ? choice.message : undefined;
|
|
196
|
+
const content = message?.content;
|
|
197
|
+
if (typeof content !== "string") {
|
|
198
|
+
throw new Error(`chat completion has no choices[0].message.content string: ${JSON.stringify(result).slice(0, 1_500)}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
output: content,
|
|
203
|
+
costUsd: computeCostUsd(result.usage, pricing),
|
|
204
|
+
turns: 1,
|
|
205
|
+
durationMs,
|
|
206
|
+
models: [typeof result.model === "string" ? result.model : requestedModel],
|
|
207
|
+
raw: result,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function computeCostUsd(usage: unknown, pricing: ModelPricing | undefined): number {
|
|
212
|
+
// Unpriced endpoints report 0 — true for local servers; usage stays in raw.
|
|
213
|
+
if (!pricing) return 0;
|
|
214
|
+
const promptTokens = isRecord(usage) ? usage.prompt_tokens : undefined;
|
|
215
|
+
const completionTokens = isRecord(usage) ? usage.completion_tokens : undefined;
|
|
216
|
+
if (typeof promptTokens !== "number" || typeof completionTokens !== "number") {
|
|
217
|
+
// Silent $0 with pricing configured is exactly the decorative-budget bug;
|
|
218
|
+
// an endpoint that won't report usage cannot be budget-enforced.
|
|
219
|
+
throw new Error(
|
|
220
|
+
`pricing is configured but the endpoint returned no usage.prompt_tokens/completion_tokens: ${JSON.stringify(usage).slice(0, 300)}`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return (promptTokens * pricing.input + completionTokens * pricing.output) / 1_000_000;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
227
|
+
return typeof value === "object" && value !== null;
|
|
228
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export type RunMode = "text" | "artifact";
|
|
2
|
+
|
|
3
|
+
export interface RunResult {
|
|
4
|
+
output: string;
|
|
5
|
+
/** USD cost when the runner reports one; 0 when the provider only reports tokens. */
|
|
6
|
+
costUsd: number;
|
|
7
|
+
/** Agentic turns; plain completion runners always report 1. */
|
|
8
|
+
turns: number;
|
|
9
|
+
durationMs: number;
|
|
10
|
+
models: string[];
|
|
11
|
+
raw: unknown;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What a runner can do beyond "system prompt + user prompt in, text out".
|
|
16
|
+
* The engine validates a scenario's demands against these before any paid run.
|
|
17
|
+
*/
|
|
18
|
+
export interface RunnerCapabilities {
|
|
19
|
+
/**
|
|
20
|
+
* The runner executes tools inside the sandbox cwd — required for artifact
|
|
21
|
+
* mode, command graders, and any non-empty `tools` value.
|
|
22
|
+
*/
|
|
23
|
+
sandboxTools: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* The runner has a harness-managed skill registry and a default system
|
|
26
|
+
* prompt that can be appended to — required for install delivery.
|
|
27
|
+
*/
|
|
28
|
+
skillRegistry: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* The runner can attach image files to the user message (vision models) —
|
|
31
|
+
* required for scenarios with `images`.
|
|
32
|
+
*/
|
|
33
|
+
images: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RunnerRunOptions {
|
|
37
|
+
systemPrompt: string;
|
|
38
|
+
/**
|
|
39
|
+
* "replace" (default) swaps the whole system prompt — right for controlled
|
|
40
|
+
* inline evals. "append" layers the text on top of the runner's default
|
|
41
|
+
* harness prompt, preserving harness machinery like the skill registry;
|
|
42
|
+
* required for install-delivery evals (skillRegistry runners only).
|
|
43
|
+
*/
|
|
44
|
+
systemPromptMode?: "replace" | "append";
|
|
45
|
+
userPrompt: string;
|
|
46
|
+
/** Image file paths attached to the user message (vision evals). */
|
|
47
|
+
images?: string[];
|
|
48
|
+
/** Extra request-body fields for completion runners (e.g. max_tokens, temperature). */
|
|
49
|
+
requestParams?: Record<string, unknown>;
|
|
50
|
+
model: string;
|
|
51
|
+
/** Sandbox directory; graders run here. Agentic runners also execute in it. */
|
|
52
|
+
cwd: string;
|
|
53
|
+
timeoutMs: number;
|
|
54
|
+
// The remaining fields only apply to sandboxTools runners; the engine
|
|
55
|
+
// guarantees they hold their defaults ("", [], any budget) otherwise.
|
|
56
|
+
tools: string;
|
|
57
|
+
addDirs: string[];
|
|
58
|
+
maxBudgetUsd: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface Runner {
|
|
62
|
+
readonly name: string;
|
|
63
|
+
readonly capabilities: RunnerCapabilities;
|
|
64
|
+
run(options: RunnerRunOptions): Promise<RunResult>;
|
|
65
|
+
}
|