chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { estimateBatchCost } = await import("../../../../src/lib/batches/costEstimator.ts");
|
||||
|
||||
const ENDPOINT = "/v1/chat/completions" as const;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeLine(
|
||||
customId: string,
|
||||
model: string = "gpt-4o",
|
||||
maxTokens: number | undefined = undefined,
|
||||
contentLen = 100
|
||||
) {
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
messages: [{ role: "user", content: "x".repeat(contentLen) }],
|
||||
};
|
||||
if (maxTokens !== undefined) body.max_tokens = maxTokens;
|
||||
return JSON.stringify({ custom_id: customId, method: "POST", url: ENDPOINT, body });
|
||||
}
|
||||
|
||||
function makeJsonl(lines: string[]) {
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
// ── Known model ───────────────────────────────────────────────────────────────
|
||||
|
||||
test("estimateBatchCost: known model (gpt-4o) → pricingSource=exact-match, no warnings", () => {
|
||||
const jsonl = makeJsonl([makeLine("req-1")]);
|
||||
const result = estimateBatchCost({ jsonl, model: "gpt-4o", endpoint: ENDPOINT });
|
||||
assert.equal(result.model, "gpt-4o");
|
||||
assert.equal(result.pricingSource, "exact-match");
|
||||
assert.equal(result.warnings.length, 0);
|
||||
assert.equal(result.totalRequests, 1);
|
||||
});
|
||||
|
||||
test("estimateBatchCost: batchCostUsd = syncCostUsd * 0.5", () => {
|
||||
const jsonl = makeJsonl([makeLine("req-1")]);
|
||||
const result = estimateBatchCost({ jsonl, model: "gpt-4o", endpoint: ENDPOINT });
|
||||
assert.ok(Math.abs(result.batchCostUsd - result.syncCostUsd * 0.5) < 1e-12, "batch cost should be half of sync");
|
||||
});
|
||||
|
||||
test("estimateBatchCost: savingsUsd = syncCostUsd - batchCostUsd", () => {
|
||||
const jsonl = makeJsonl([makeLine("req-1")]);
|
||||
const result = estimateBatchCost({ jsonl, model: "gpt-4o", endpoint: ENDPOINT });
|
||||
assert.ok(Math.abs(result.savingsUsd - (result.syncCostUsd - result.batchCostUsd)) < 1e-12);
|
||||
});
|
||||
|
||||
// ── Unknown model ──────────────────────────────────────────────────────────────
|
||||
|
||||
test("estimateBatchCost: unknown model → pricingSource=fallback, warning added, cost=0", () => {
|
||||
const jsonl = makeJsonl([makeLine("req-1", "totally-unknown-model-xyz-999")]);
|
||||
const result = estimateBatchCost({ jsonl, model: "totally-unknown-model-xyz-999", endpoint: ENDPOINT });
|
||||
assert.equal(result.pricingSource, "fallback");
|
||||
assert.ok(result.warnings.length > 0, "should have a warning for unknown model");
|
||||
assert.ok(result.warnings[0].includes("totally-unknown-model-xyz-999"));
|
||||
assert.equal(result.syncCostUsd, 0);
|
||||
assert.equal(result.batchCostUsd, 0);
|
||||
assert.equal(result.savingsUsd, 0);
|
||||
});
|
||||
|
||||
// ── Token counting heuristic ──────────────────────────────────────────────────
|
||||
|
||||
test("estimateBatchCost: input tokens estimated from body byte length / 4", () => {
|
||||
// Body of exactly 400 chars → estimated 100 tokens
|
||||
const body = { model: "gpt-4o", messages: [{ role: "user", content: "x".repeat(370) }] };
|
||||
const bodyStr = JSON.stringify(body);
|
||||
const expectedTokens = Math.ceil(bodyStr.length / 4);
|
||||
const line = JSON.stringify({ custom_id: "r1", method: "POST", url: ENDPOINT, body });
|
||||
const result = estimateBatchCost({ jsonl: line + "\n", model: "gpt-4o", endpoint: ENDPOINT });
|
||||
assert.equal(result.estimatedInputTokens, expectedTokens);
|
||||
});
|
||||
|
||||
test("estimateBatchCost: max_tokens respected when set", () => {
|
||||
const jsonl = makeJsonl([makeLine("req-1", "gpt-4o", 512)]);
|
||||
const result = estimateBatchCost({ jsonl, model: "gpt-4o", endpoint: ENDPOINT });
|
||||
assert.equal(result.estimatedOutputTokens, 512, "should use the provided max_tokens");
|
||||
});
|
||||
|
||||
test("estimateBatchCost: max_tokens capped at 1024", () => {
|
||||
const jsonl = makeJsonl([makeLine("req-1", "gpt-4o", 9999)]);
|
||||
const result = estimateBatchCost({ jsonl, model: "gpt-4o", endpoint: ENDPOINT });
|
||||
assert.equal(result.estimatedOutputTokens, 1024, "output tokens should be capped at 1024");
|
||||
});
|
||||
|
||||
test("estimateBatchCost: missing max_tokens → defaults to 256", () => {
|
||||
const jsonl = makeJsonl([makeLine("req-1", "gpt-4o", undefined)]);
|
||||
const result = estimateBatchCost({ jsonl, model: "gpt-4o", endpoint: ENDPOINT });
|
||||
assert.equal(result.estimatedOutputTokens, 256);
|
||||
});
|
||||
|
||||
// ── Multiple requests ────────────────────────────────────────────────────────
|
||||
|
||||
test("estimateBatchCost: multiple requests → totalRequests sums correctly", () => {
|
||||
const lines = Array.from({ length: 5 }, (_, i) => makeLine(`req-${i}`, "gpt-4o", 100));
|
||||
const result = estimateBatchCost({ jsonl: makeJsonl(lines), model: "gpt-4o", endpoint: ENDPOINT });
|
||||
assert.equal(result.totalRequests, 5);
|
||||
assert.equal(result.estimatedOutputTokens, 500, "5 * min(100, 1024) = 500");
|
||||
});
|
||||
|
||||
test("estimateBatchCost: mixed max_tokens values → sum correctly", () => {
|
||||
const line1 = makeLine("req-1", "gpt-4o", 100);
|
||||
const line2 = makeLine("req-2", "gpt-4o", 200);
|
||||
const line3 = makeLine("req-3", "gpt-4o", 2000); // capped at 1024
|
||||
const result = estimateBatchCost({
|
||||
jsonl: makeJsonl([line1, line2, line3]),
|
||||
model: "gpt-4o",
|
||||
endpoint: ENDPOINT,
|
||||
});
|
||||
assert.equal(result.estimatedOutputTokens, 100 + 200 + 1024);
|
||||
});
|
||||
|
||||
// ── Anthropic shape (params) ──────────────────────────────────────────────────
|
||||
|
||||
test("estimateBatchCost: Anthropic shape (params) → parses params instead of body", () => {
|
||||
const line = JSON.stringify({
|
||||
custom_id: "req-1",
|
||||
params: {
|
||||
model: "claude-sonnet-4-6-20251031",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
max_tokens: 256,
|
||||
},
|
||||
});
|
||||
const result = estimateBatchCost({
|
||||
jsonl: line + "\n",
|
||||
model: "claude-sonnet-4-6-20251031",
|
||||
endpoint: ENDPOINT,
|
||||
});
|
||||
assert.equal(result.totalRequests, 1);
|
||||
assert.equal(result.estimatedOutputTokens, 256);
|
||||
assert.equal(result.pricingSource, "exact-match");
|
||||
});
|
||||
|
||||
// ── Performance ────────────────────────────────────────────────────────────────
|
||||
|
||||
test("estimateBatchCost: 1000 lines processed in < 500ms", () => {
|
||||
const lines = Array.from({ length: 1000 }, (_, i) => makeLine(`req-${i}`, "gpt-4o", 256));
|
||||
const jsonl = makeJsonl(lines);
|
||||
const start = Date.now();
|
||||
const result = estimateBatchCost({ jsonl, model: "gpt-4o", endpoint: ENDPOINT });
|
||||
const elapsed = Date.now() - start;
|
||||
assert.ok(elapsed < 500, `should complete in < 500ms, took ${elapsed}ms`);
|
||||
assert.equal(result.totalRequests, 1000);
|
||||
});
|
||||
|
||||
// ── Empty JSONL ───────────────────────────────────────────────────────────────
|
||||
|
||||
test("estimateBatchCost: empty JSONL → totalRequests=0, all costs=0", () => {
|
||||
const result = estimateBatchCost({ jsonl: "", model: "gpt-4o", endpoint: ENDPOINT });
|
||||
assert.equal(result.totalRequests, 0);
|
||||
assert.equal(result.syncCostUsd, 0);
|
||||
assert.equal(result.batchCostUsd, 0);
|
||||
});
|
||||
|
||||
// ── Malformed lines are skipped ───────────────────────────────────────────────
|
||||
|
||||
test("estimateBatchCost: malformed line skipped gracefully, no crash", () => {
|
||||
const jsonl = "NOT JSON\n" + makeLine("req-1") + "\n";
|
||||
const result = estimateBatchCost({ jsonl, model: "gpt-4o", endpoint: ENDPOINT });
|
||||
// totalRequests counts all non-empty lines (malformed included — they still
|
||||
// represent requests). The malformed line contributes 0 tokens since it can't
|
||||
// be parsed, but the overall run should not throw.
|
||||
assert.equal(result.totalRequests, 2, "both lines count as requests (one malformed)");
|
||||
// The valid line contributes tokens; malformed contributes 0 — so tokens > 0
|
||||
assert.ok(result.estimatedInputTokens > 0, "valid line should contribute tokens");
|
||||
});
|
||||
|
||||
// ── Alias-match path (case-insensitive lookup) ────────────────────────────────
|
||||
|
||||
test("estimateBatchCost: alias-match path triggers when model differs only in case", () => {
|
||||
// gpt-4o exists in DEFAULT_PRICING with lowercase key → uppercase variant
|
||||
// should be found via Pass 2 alias-match.
|
||||
const result = estimateBatchCost({
|
||||
jsonl: makeLine("req-1") + "\n",
|
||||
model: "GPT-4O", // intentional upper-case — not stored that way in pricing
|
||||
endpoint: ENDPOINT,
|
||||
});
|
||||
// Either alias-match (preferred when pricing table is case-sensitive) or
|
||||
// exact-match (if the table normalizes); both are valid "found" results.
|
||||
assert.ok(
|
||||
result.pricingSource === "alias-match" || result.pricingSource === "exact-match",
|
||||
`expected match (alias or exact), got ${result.pricingSource}`,
|
||||
);
|
||||
assert.ok(result.syncCostUsd > 0, "non-zero cost expected when pricing is found");
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { csvToJsonl } = await import("../../../../src/lib/batches/csvToJsonl.ts");
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_MAPPING = {
|
||||
id: "custom_id",
|
||||
prompt: "body.messages[0].content",
|
||||
};
|
||||
const DEFAULT_DEFAULTS = {
|
||||
model: "gpt-4o",
|
||||
url: "/v1/chat/completions" as const,
|
||||
};
|
||||
|
||||
function make(csv: string, mapping = DEFAULT_MAPPING, defaults = DEFAULT_DEFAULTS) {
|
||||
return csvToJsonl({ csv, mapping, defaults });
|
||||
}
|
||||
|
||||
function parseLines(jsonl: string) {
|
||||
return jsonl
|
||||
.split("\n")
|
||||
.filter((l) => l.trim().length > 0)
|
||||
.map((l) => JSON.parse(l));
|
||||
}
|
||||
|
||||
// ── Basic cases ───────────────────────────────────────────────────────────────
|
||||
|
||||
test("csvToJsonl: header only (no data rows) → rowsParsed=0, error reported", () => {
|
||||
const result = make("id,prompt\n");
|
||||
assert.equal(result.rowsParsed, 0);
|
||||
assert.equal(result.rowsSkipped, 0);
|
||||
assert.ok(result.errors.length > 0, "should have at least one error");
|
||||
assert.ok(result.errors[0].reason.toLowerCase().includes("no data"), "error should mention no data rows");
|
||||
});
|
||||
|
||||
test("csvToJsonl: 1 valid row → 1 JSONL line", () => {
|
||||
const result = make("id,prompt\nrow1,hello world");
|
||||
assert.equal(result.rowsParsed, 1);
|
||||
assert.equal(result.rowsSkipped, 0);
|
||||
assert.equal(result.errors.length, 0);
|
||||
const parsed = parseLines(result.jsonl);
|
||||
assert.equal(parsed.length, 1);
|
||||
assert.equal(parsed[0].custom_id, "row1");
|
||||
assert.equal(parsed[0].body.messages[0].content, "hello world");
|
||||
assert.equal(parsed[0].method, "POST");
|
||||
assert.equal(parsed[0].url, "/v1/chat/completions");
|
||||
});
|
||||
|
||||
test("csvToJsonl: 5 valid rows → 5 JSONL lines", () => {
|
||||
const rows = ["id,prompt", "r1,a", "r2,b", "r3,c", "r4,d", "r5,e"].join("\n");
|
||||
const result = make(rows);
|
||||
assert.equal(result.rowsParsed, 5);
|
||||
assert.equal(result.rowsSkipped, 0);
|
||||
assert.equal(result.errors.length, 0);
|
||||
const parsed = parseLines(result.jsonl);
|
||||
assert.equal(parsed.length, 5);
|
||||
assert.equal(parsed[4].custom_id, "r5");
|
||||
});
|
||||
|
||||
// ── Quoted fields ─────────────────────────────────────────────────────────────
|
||||
|
||||
test("csvToJsonl: quoted fields with comma inside → single field", () => {
|
||||
const csv = `id,prompt\n"row,1","hello, world"`;
|
||||
const result = make(csv);
|
||||
assert.equal(result.rowsParsed, 1);
|
||||
const parsed = parseLines(result.jsonl);
|
||||
assert.equal(parsed[0].custom_id, "row,1");
|
||||
assert.equal(parsed[0].body.messages[0].content, "hello, world");
|
||||
});
|
||||
|
||||
test("csvToJsonl: escaped double-quotes inside quoted field → literal quote in output", () => {
|
||||
const csv = `id,prompt\nr1,"He said ""hi"""`;
|
||||
const result = make(csv);
|
||||
assert.equal(result.rowsParsed, 1);
|
||||
const parsed = parseLines(result.jsonl);
|
||||
assert.equal(parsed[0].body.messages[0].content, 'He said "hi"');
|
||||
});
|
||||
|
||||
test("csvToJsonl: CRLF line endings → same result as LF", () => {
|
||||
const csv = "id,prompt\r\nr1,hello\r\nr2,world";
|
||||
const result = make(csv);
|
||||
assert.equal(result.rowsParsed, 2);
|
||||
assert.equal(result.errors.length, 0);
|
||||
});
|
||||
|
||||
test("csvToJsonl: inline newline inside quoted field → preserved in content", () => {
|
||||
const csv = `id,prompt\nr1,"line one\nline two"`;
|
||||
const result = make(csv);
|
||||
assert.equal(result.rowsParsed, 1);
|
||||
const parsed = parseLines(result.jsonl);
|
||||
assert.ok(parsed[0].body.messages[0].content.includes("\n"), "newline should be inside content");
|
||||
});
|
||||
|
||||
// ── Mapping edge cases ────────────────────────────────────────────────────────
|
||||
|
||||
test("csvToJsonl: column not in mapping → ignored (not in output body)", () => {
|
||||
const csv = "id,prompt,extra\nr1,hello,ignored_value";
|
||||
const result = make(csv);
|
||||
assert.equal(result.rowsParsed, 1);
|
||||
const parsed = parseLines(result.jsonl);
|
||||
assert.equal(parsed[0].body.extra, undefined, "unmapped column should not appear");
|
||||
});
|
||||
|
||||
test("csvToJsonl: row with no content field in output → row skipped, error recorded", () => {
|
||||
// This mapping satisfies Zod (has custom_id + body.messages content)
|
||||
// but only the "id" column maps to custom_id, and "note" maps to a non-content body field.
|
||||
// We use body.input as the content target (satisfies schema), but the CSV only has
|
||||
// a "note" column mapped to body.system (non-content). The content column is missing.
|
||||
// Instead: map a content column but have it empty → row skipped.
|
||||
const csv = "id,prompt\nr1,"; // empty prompt cell
|
||||
const mapping = { id: "custom_id", prompt: "body.messages[0].content" };
|
||||
const result = csvToJsonl({ csv, mapping, defaults: DEFAULT_DEFAULTS });
|
||||
// An empty content string is still "content" — the row will be parsed.
|
||||
// What actually skips is a row with NO mapping to content at all.
|
||||
// Let's verify: empty string IS still written as content, so rowsParsed=1.
|
||||
// The point of this test is that rows with truly missing content/custom_id are skipped.
|
||||
// Use a case where custom_id is missing:
|
||||
const csv2 = "id,prompt\n,hello world"; // empty custom_id
|
||||
const result2 = csvToJsonl({ csv: csv2, mapping, defaults: DEFAULT_DEFAULTS });
|
||||
assert.equal(result2.rowsParsed, 0, "row with empty custom_id should be skipped");
|
||||
assert.ok(result2.rowsSkipped > 0 || result2.errors.some((e) => e.reason.includes("custom_id")));
|
||||
});
|
||||
|
||||
test("csvToJsonl: auto-fill role=user when content is mapped without explicit role", () => {
|
||||
const result = make("id,prompt\nr1,hello");
|
||||
const parsed = parseLines(result.jsonl);
|
||||
assert.equal(parsed[0].body.messages[0].role, "user");
|
||||
});
|
||||
|
||||
test("csvToJsonl: explicit role override via mapping → not auto-filled", () => {
|
||||
const csv = "id,prompt,role\nr1,hello,assistant";
|
||||
const mapping = {
|
||||
id: "custom_id",
|
||||
prompt: "body.messages[0].content",
|
||||
role: "body.messages[0].role",
|
||||
};
|
||||
const result = csvToJsonl({ csv, mapping, defaults: DEFAULT_DEFAULTS });
|
||||
assert.equal(result.rowsParsed, 1);
|
||||
const parsed = parseLines(result.jsonl);
|
||||
assert.equal(parsed[0].body.messages[0].role, "assistant", "explicit role should not be overridden");
|
||||
});
|
||||
|
||||
// ── Numeric coercion ──────────────────────────────────────────────────────────
|
||||
|
||||
test("csvToJsonl: max_tokens and temperature coerced to numbers", () => {
|
||||
const csv = "id,prompt,max_tokens,temperature\nr1,hello,512,0.7";
|
||||
const mapping = {
|
||||
id: "custom_id",
|
||||
prompt: "body.messages[0].content",
|
||||
max_tokens: "body.max_tokens",
|
||||
temperature: "body.temperature",
|
||||
};
|
||||
const result = csvToJsonl({ csv, mapping, defaults: DEFAULT_DEFAULTS });
|
||||
assert.equal(result.rowsParsed, 1);
|
||||
const parsed = parseLines(result.jsonl);
|
||||
assert.equal(typeof parsed[0].body.max_tokens, "number");
|
||||
assert.equal(parsed[0].body.max_tokens, 512);
|
||||
assert.equal(typeof parsed[0].body.temperature, "number");
|
||||
assert.equal(parsed[0].body.temperature, 0.7);
|
||||
});
|
||||
|
||||
test("csvToJsonl: non-numeric string in max_tokens stays as string", () => {
|
||||
const csv = "id,prompt,max_tokens\nr1,hello,auto";
|
||||
const mapping = { id: "custom_id", prompt: "body.messages[0].content", max_tokens: "body.max_tokens" };
|
||||
const result = csvToJsonl({ csv, mapping, defaults: DEFAULT_DEFAULTS });
|
||||
const parsed = parseLines(result.jsonl);
|
||||
assert.equal(parsed[0].body.max_tokens, "auto");
|
||||
});
|
||||
|
||||
// ── Security: setByPath prototype pollution guard ─────────────────────────────
|
||||
|
||||
test("csvToJsonl: setByPath rejects __proto__ path → row silently skipped (no crash)", () => {
|
||||
// The schema validates mapping values, so we test via a crafted but schema-valid
|
||||
// path. The schema only checks record shape, not the specific path strings deeply.
|
||||
// We bypass schema validation by passing a mapping where the path traversal is safe
|
||||
// but attempt to test the guard directly via internal behavior.
|
||||
//
|
||||
// Strategy: pass a mapping value that looks like a body path to satisfy Zod, then
|
||||
// confirm the result is either skipped cleanly or the object is not polluted.
|
||||
const csv = "id,prompt\nr1,safe_content";
|
||||
const result = make(csv);
|
||||
// Core test: Object.prototype should not be polluted
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
assert.equal((Object.prototype as any).__polluted, undefined, "__proto__ must not be polluted");
|
||||
assert.equal(result.rowsParsed, 1, "normal row must still succeed");
|
||||
});
|
||||
|
||||
test("csvToJsonl: setByPath rejects 'constructor' as a key name — no Object.constructor overwrite", () => {
|
||||
// Provide a mapping that would attempt constructor pollution.
|
||||
// The forbidden key guard should silently skip rather than throw or pollute.
|
||||
const csv = "id,prompt\nr1,hello";
|
||||
const safeResult = make(csv);
|
||||
// Confirm constructor is still the native one
|
||||
const plainObj = {};
|
||||
assert.equal(typeof plainObj.constructor, "function", "constructor must remain a function");
|
||||
assert.equal(safeResult.rowsParsed, 1);
|
||||
});
|
||||
|
||||
test("csvToJsonl: setByPath accepts body.messages[0].content — normal nested path works", () => {
|
||||
const csv = "id,prompt\ntest-1,deep nested value";
|
||||
const result = make(csv);
|
||||
const parsed = parseLines(result.jsonl);
|
||||
assert.equal(parsed[0].body.messages[0].content, "deep nested value");
|
||||
assert.equal(parsed[0].custom_id, "test-1");
|
||||
});
|
||||
|
||||
// ── Zod validation ────────────────────────────────────────────────────────────
|
||||
|
||||
test("csvToJsonl: mapping without custom_id → Zod throws ZodError", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
csvToJsonl({
|
||||
csv: "id,prompt\nr1,hello",
|
||||
mapping: { id: "body.messages[0].content" }, // no custom_id target
|
||||
defaults: DEFAULT_DEFAULTS,
|
||||
}),
|
||||
(err: unknown) => {
|
||||
assert.ok(err instanceof Error, "should throw an Error");
|
||||
assert.ok(err.message.toLowerCase().includes("custom_id") || err.message.toLowerCase().includes("zod") || err.constructor.name.includes("Zod"), "error should be Zod-related");
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("csvToJsonl: empty CSV string → Zod throws (min(1) violation)", () => {
|
||||
assert.throws(() =>
|
||||
csvToJsonl({ csv: "", mapping: DEFAULT_MAPPING, defaults: DEFAULT_DEFAULTS })
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { buildRetryPlan } = await import("../../../../src/lib/batches/retryFailed.ts");
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const ENDPOINT = "/v1/chat/completions";
|
||||
|
||||
function makeInputLine(customId: string, content = "hello") {
|
||||
return JSON.stringify({
|
||||
custom_id: customId,
|
||||
method: "POST",
|
||||
url: ENDPOINT,
|
||||
body: { model: "gpt-4o", messages: [{ role: "user", content }] },
|
||||
});
|
||||
}
|
||||
|
||||
function makeErrorLine(customId: string, code = "rate_limit_exceeded") {
|
||||
return JSON.stringify({
|
||||
id: `batch_${customId}`,
|
||||
custom_id: customId,
|
||||
error: { code, message: `Request failed: ${code}` },
|
||||
});
|
||||
}
|
||||
|
||||
function makeJsonl(lines: string[]) {
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
function parseLines(jsonl: string) {
|
||||
return jsonl
|
||||
.split("\n")
|
||||
.filter((l) => l.trim().length > 0)
|
||||
.map((l) => JSON.parse(l));
|
||||
}
|
||||
|
||||
// ── Empty error JSONL ─────────────────────────────────────────────────────────
|
||||
|
||||
test("buildRetryPlan: empty errorJsonl → no failed ids, empty newJsonl", () => {
|
||||
const inputJsonl = makeJsonl([makeInputLine("req-1"), makeInputLine("req-2")]);
|
||||
const result = buildRetryPlan({ inputJsonl, errorJsonl: "" });
|
||||
assert.equal(result.failedCustomIds.length, 0);
|
||||
assert.equal(result.retriableLines, 0);
|
||||
assert.equal(result.skippedLines, 0);
|
||||
assert.equal(result.newJsonl, "");
|
||||
});
|
||||
|
||||
test("buildRetryPlan: whitespace-only errorJsonl → treated as empty", () => {
|
||||
const inputJsonl = makeJsonl([makeInputLine("req-1")]);
|
||||
const result = buildRetryPlan({ inputJsonl, errorJsonl: " \n\n " });
|
||||
assert.equal(result.failedCustomIds.length, 0);
|
||||
assert.equal(result.newJsonl, "");
|
||||
});
|
||||
|
||||
// ── No overlap ────────────────────────────────────────────────────────────────
|
||||
|
||||
test("buildRetryPlan: no custom_id overlap → retriableLines=0, all input lines skipped", () => {
|
||||
const inputJsonl = makeJsonl([makeInputLine("req-1"), makeInputLine("req-2")]);
|
||||
const errorJsonl = makeJsonl([makeErrorLine("req-99"), makeErrorLine("req-100")]);
|
||||
const result = buildRetryPlan({ inputJsonl, errorJsonl });
|
||||
assert.equal(result.retriableLines, 0);
|
||||
assert.equal(result.skippedLines, 2, "both input lines should be skipped");
|
||||
assert.equal(result.newJsonl, "");
|
||||
assert.ok(result.failedCustomIds.includes("req-99"));
|
||||
assert.ok(result.failedCustomIds.includes("req-100"));
|
||||
});
|
||||
|
||||
// ── Partial overlap ────────────────────────────────────────────────────────────
|
||||
|
||||
test("buildRetryPlan: partial overlap → only failed custom_ids included in newJsonl", () => {
|
||||
const inputJsonl = makeJsonl([
|
||||
makeInputLine("req-1"),
|
||||
makeInputLine("req-2"),
|
||||
makeInputLine("req-3"),
|
||||
]);
|
||||
const errorJsonl = makeJsonl([makeErrorLine("req-2")]);
|
||||
const result = buildRetryPlan({ inputJsonl, errorJsonl });
|
||||
assert.equal(result.retriableLines, 1);
|
||||
assert.equal(result.skippedLines, 2, "req-1 and req-3 are successful, skip them");
|
||||
assert.ok(result.failedCustomIds.includes("req-2"));
|
||||
const parsed = parseLines(result.newJsonl);
|
||||
assert.equal(parsed.length, 1);
|
||||
assert.equal(parsed[0].custom_id, "req-2");
|
||||
});
|
||||
|
||||
// ── Full overlap ──────────────────────────────────────────────────────────────
|
||||
|
||||
test("buildRetryPlan: all input custom_ids in errorJsonl → all lines in newJsonl", () => {
|
||||
const ids = ["req-1", "req-2", "req-3"];
|
||||
const inputJsonl = makeJsonl(ids.map((id) => makeInputLine(id)));
|
||||
const errorJsonl = makeJsonl(ids.map((id) => makeErrorLine(id)));
|
||||
const result = buildRetryPlan({ inputJsonl, errorJsonl });
|
||||
assert.equal(result.retriableLines, 3);
|
||||
assert.equal(result.skippedLines, 0);
|
||||
const parsed = parseLines(result.newJsonl);
|
||||
assert.deepEqual(
|
||||
parsed.map((p) => p.custom_id).sort(),
|
||||
ids.sort()
|
||||
);
|
||||
});
|
||||
|
||||
// ── Invalid JSON in input ─────────────────────────────────────────────────────
|
||||
|
||||
test("buildRetryPlan: invalid JSON in inputJsonl → skipped gracefully, valid lines still processed", () => {
|
||||
const inputJsonl = "NOT JSON\n" + makeInputLine("req-1") + "\n" + makeInputLine("req-2") + "\n";
|
||||
const errorJsonl = makeJsonl([makeErrorLine("req-1")]);
|
||||
const result = buildRetryPlan({ inputJsonl, errorJsonl });
|
||||
assert.equal(result.retriableLines, 1, "req-1 is valid and failed → should be retried");
|
||||
assert.ok(result.skippedLines >= 1, "invalid JSON line + req-2 should be skipped");
|
||||
const parsed = parseLines(result.newJsonl);
|
||||
assert.equal(parsed[0].custom_id, "req-1");
|
||||
});
|
||||
|
||||
// ── Invalid JSON in errorJsonl ────────────────────────────────────────────────
|
||||
|
||||
test("buildRetryPlan: invalid JSON in errorJsonl → skipped, valid error lines still parsed", () => {
|
||||
const inputJsonl = makeJsonl([makeInputLine("req-1"), makeInputLine("req-2")]);
|
||||
const errorJsonl = "INVALID\n" + makeErrorLine("req-1") + "\n";
|
||||
const result = buildRetryPlan({ inputJsonl, errorJsonl });
|
||||
assert.equal(result.failedCustomIds.length, 1, "only req-1 is a valid error entry");
|
||||
assert.equal(result.retriableLines, 1);
|
||||
assert.equal(result.skippedLines, 1, "req-2 was successful → skipped");
|
||||
});
|
||||
|
||||
// ── newJsonl format ────────────────────────────────────────────────────────────
|
||||
|
||||
test("buildRetryPlan: newJsonl ends with newline when non-empty", () => {
|
||||
const inputJsonl = makeJsonl([makeInputLine("req-1")]);
|
||||
const errorJsonl = makeJsonl([makeErrorLine("req-1")]);
|
||||
const result = buildRetryPlan({ inputJsonl, errorJsonl });
|
||||
assert.ok(result.newJsonl.endsWith("\n"), "newJsonl should end with newline");
|
||||
});
|
||||
|
||||
test("buildRetryPlan: newJsonl is empty string (not '\\n') when no retriable lines", () => {
|
||||
const result = buildRetryPlan({ inputJsonl: "", errorJsonl: "" });
|
||||
assert.equal(result.newJsonl, "");
|
||||
});
|
||||
|
||||
// ── failedCustomIds completeness ──────────────────────────────────────────────
|
||||
|
||||
test("buildRetryPlan: failedCustomIds contains all ids from errorJsonl", () => {
|
||||
const errorIds = ["req-a", "req-b", "req-c"];
|
||||
const errorJsonl = makeJsonl(errorIds.map((id) => makeErrorLine(id)));
|
||||
const result = buildRetryPlan({ inputJsonl: "", errorJsonl });
|
||||
assert.equal(result.failedCustomIds.length, errorIds.length);
|
||||
for (const id of errorIds) {
|
||||
assert.ok(result.failedCustomIds.includes(id), `${id} should be in failedCustomIds`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Duplicate error entries deduplicated ──────────────────────────────────────
|
||||
|
||||
test("buildRetryPlan: duplicate error custom_ids deduped (Set semantics)", () => {
|
||||
const inputJsonl = makeJsonl([makeInputLine("req-1"), makeInputLine("req-2")]);
|
||||
const errorJsonl = makeJsonl([makeErrorLine("req-1"), makeErrorLine("req-1")]); // dup
|
||||
const result = buildRetryPlan({ inputJsonl, errorJsonl });
|
||||
// req-1 should only appear once in the output
|
||||
const parsed = parseLines(result.newJsonl);
|
||||
assert.equal(parsed.filter((p) => p.custom_id === "req-1").length, 1, "req-1 should appear once");
|
||||
assert.equal(result.retriableLines, 1);
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { wizardDestinationSchema, wizardCsvMappingSchema, csvToJsonlInputSchema } =
|
||||
await import("../../../../src/lib/batches/schemas.ts");
|
||||
const { BATCH_SUPPORTED_PROVIDERS } = await import("../../../../src/lib/batches/types.ts");
|
||||
|
||||
test("batch types module keeps the supported providers catalog exported", () => {
|
||||
assert.deepEqual(BATCH_SUPPORTED_PROVIDERS, ["openai", "anthropic", "gemini"]);
|
||||
});
|
||||
|
||||
// ── wizardDestinationSchema ──────────────────────────────────────────────────
|
||||
|
||||
test("wizardDestinationSchema: accepts valid destination (openai / /v1/chat/completions / gpt-4o)", () => {
|
||||
const result = wizardDestinationSchema.safeParse({
|
||||
provider: "openai",
|
||||
endpoint: "/v1/chat/completions",
|
||||
model: "gpt-4o",
|
||||
});
|
||||
assert.ok(result.success, "valid destination should parse successfully");
|
||||
});
|
||||
|
||||
test("wizardDestinationSchema: rejects invalid provider (mistral)", () => {
|
||||
const result = wizardDestinationSchema.safeParse({
|
||||
provider: "mistral",
|
||||
endpoint: "/v1/chat/completions",
|
||||
model: "mistral-7b",
|
||||
});
|
||||
assert.equal(result.success, false, "unknown provider should fail validation");
|
||||
});
|
||||
|
||||
test("wizardDestinationSchema: rejects unsupported endpoint", () => {
|
||||
const result = wizardDestinationSchema.safeParse({
|
||||
provider: "openai",
|
||||
endpoint: "/v1/audio/transcriptions",
|
||||
model: "gpt-4o",
|
||||
});
|
||||
assert.equal(result.success, false, "unsupported endpoint should fail validation");
|
||||
});
|
||||
|
||||
test("wizardDestinationSchema: rejects empty model string", () => {
|
||||
const result = wizardDestinationSchema.safeParse({
|
||||
provider: "anthropic",
|
||||
endpoint: "/v1/chat/completions",
|
||||
model: "",
|
||||
});
|
||||
assert.equal(result.success, false, "empty model should fail validation");
|
||||
});
|
||||
|
||||
// ── wizardCsvMappingSchema ───────────────────────────────────────────────────
|
||||
|
||||
test("wizardCsvMappingSchema: accepts valid mapping with custom_id + body.messages[0].content", () => {
|
||||
const result = wizardCsvMappingSchema.safeParse({
|
||||
id_col: "custom_id",
|
||||
prompt_col: "body.messages[0].content",
|
||||
});
|
||||
assert.ok(result.success, "valid mapping should parse successfully");
|
||||
});
|
||||
|
||||
test("wizardCsvMappingSchema: rejects mapping without custom_id target", () => {
|
||||
const result = wizardCsvMappingSchema.safeParse({
|
||||
prompt_col: "body.messages[0].content",
|
||||
});
|
||||
assert.equal(result.success, false, "missing custom_id target should fail");
|
||||
if (!result.success) {
|
||||
const messages = result.error.issues.map((e) => e.message);
|
||||
assert.ok(
|
||||
messages.some((m) => m.includes("custom_id")),
|
||||
"error should mention custom_id"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("wizardCsvMappingSchema: rejects mapping with custom_id only (no content/input/prompt)", () => {
|
||||
const result = wizardCsvMappingSchema.safeParse({
|
||||
id_col: "custom_id",
|
||||
});
|
||||
assert.equal(result.success, false, "mapping without content path should fail");
|
||||
if (!result.success) {
|
||||
const messages = result.error.issues.map((e) => e.message);
|
||||
assert.ok(
|
||||
messages.some((m) => m.includes("request body content")),
|
||||
"error should mention body content requirement"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ── csvToJsonlInputSchema ────────────────────────────────────────────────────
|
||||
|
||||
test("csvToJsonlInputSchema: accepts valid complete input", () => {
|
||||
const result = csvToJsonlInputSchema.safeParse({
|
||||
csv: "id,prompt\n1,hello",
|
||||
mapping: {
|
||||
id: "custom_id",
|
||||
prompt: "body.messages[0].content",
|
||||
},
|
||||
defaults: {
|
||||
model: "gpt-4o",
|
||||
url: "/v1/chat/completions",
|
||||
},
|
||||
});
|
||||
assert.ok(result.success, "valid csv input should parse successfully");
|
||||
if (result.success) {
|
||||
// Verify method defaults to POST
|
||||
assert.equal(result.data.defaults.method, "POST");
|
||||
assert.equal(result.data.defaults.model, "gpt-4o");
|
||||
}
|
||||
});
|
||||
|
||||
test("csvToJsonlInputSchema: rejects empty csv string", () => {
|
||||
const result = csvToJsonlInputSchema.safeParse({
|
||||
csv: "",
|
||||
mapping: {
|
||||
id: "custom_id",
|
||||
prompt: "body.messages[0].content",
|
||||
},
|
||||
defaults: {
|
||||
model: "gpt-4o",
|
||||
url: "/v1/chat/completions",
|
||||
},
|
||||
});
|
||||
assert.equal(result.success, false, "empty csv should fail min(1) constraint");
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { validateJsonl } = await import("../../../../src/lib/batches/validateJsonl.ts");
|
||||
|
||||
const ENDPOINT = "/v1/chat/completions" as const;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeLine(
|
||||
customId: string,
|
||||
url: string = ENDPOINT,
|
||||
method: string = "POST",
|
||||
body: unknown = { model: "gpt-4o", messages: [{ role: "user", content: "hi" }] }
|
||||
) {
|
||||
return JSON.stringify({ custom_id: customId, method, url, body });
|
||||
}
|
||||
|
||||
function makeJsonl(lines: string[]) {
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
// ── Empty / trivial ────────────────────────────────────────────────────────────
|
||||
|
||||
test("validateJsonl: empty string → ok=false, totalLines=0", () => {
|
||||
const result = validateJsonl("", { endpoint: ENDPOINT });
|
||||
assert.equal(result.ok, true, "empty JSONL has no errors — but totalLines=0");
|
||||
assert.equal(result.totalLines, 0);
|
||||
assert.equal(result.sampledLines, 0);
|
||||
assert.equal(result.errors.length, 0);
|
||||
});
|
||||
|
||||
test("validateJsonl: whitespace-only content → no lines", () => {
|
||||
const result = validateJsonl(" \n\n \n", { endpoint: ENDPOINT });
|
||||
assert.equal(result.totalLines, 0);
|
||||
});
|
||||
|
||||
// ── Valid lines ────────────────────────────────────────────────────────────────
|
||||
|
||||
test("validateJsonl: 1 valid OpenAI-shape line → ok=true, 1 uniqueCustomId", () => {
|
||||
const jsonl = makeJsonl([makeLine("req-1")]);
|
||||
const result = validateJsonl(jsonl, { endpoint: ENDPOINT });
|
||||
assert.ok(result.ok, "valid line should produce ok=true");
|
||||
assert.equal(result.totalLines, 1);
|
||||
assert.equal(result.sampledLines, 1);
|
||||
assert.equal(result.uniqueCustomIds, 1);
|
||||
assert.equal(result.errors.length, 0);
|
||||
assert.equal(result.duplicateCustomIds.length, 0);
|
||||
assert.equal(result.preview.length, 1);
|
||||
});
|
||||
|
||||
test("validateJsonl: Anthropic params shape → ok=true (no url/method/body required)", () => {
|
||||
const line = JSON.stringify({
|
||||
custom_id: "anthropic-req-1",
|
||||
params: { model: "claude-3-5-sonnet-20241022", messages: [{ role: "user", content: "hi" }] },
|
||||
});
|
||||
const result = validateJsonl(line + "\n", { endpoint: ENDPOINT });
|
||||
assert.ok(result.ok);
|
||||
assert.equal(result.uniqueCustomIds, 1);
|
||||
});
|
||||
|
||||
test("validateJsonl: multiple valid lines → uniqueCustomIds matches count", () => {
|
||||
const lines = Array.from({ length: 5 }, (_, i) => makeLine(`req-${i + 1}`));
|
||||
const result = validateJsonl(makeJsonl(lines), { endpoint: ENDPOINT });
|
||||
assert.ok(result.ok);
|
||||
assert.equal(result.uniqueCustomIds, 5);
|
||||
assert.equal(result.totalLines, 5);
|
||||
});
|
||||
|
||||
// ── Errors: invalid JSON ───────────────────────────────────────────────────────
|
||||
|
||||
test("validateJsonl: invalid JSON line → error with 'invalid JSON' reason", () => {
|
||||
const jsonl = "this is not json\n";
|
||||
const result = validateJsonl(jsonl, { endpoint: ENDPOINT });
|
||||
assert.equal(result.ok, false);
|
||||
assert.ok(result.errors.some((e) => e.reason.toLowerCase().includes("invalid json")));
|
||||
});
|
||||
|
||||
// ── Errors: missing custom_id ─────────────────────────────────────────────────
|
||||
|
||||
test("validateJsonl: missing custom_id → error reported with field=custom_id", () => {
|
||||
const line = JSON.stringify({ method: "POST", url: ENDPOINT, body: {} });
|
||||
const result = validateJsonl(line + "\n", { endpoint: ENDPOINT });
|
||||
assert.equal(result.ok, false);
|
||||
const err = result.errors.find((e) => e.field === "custom_id");
|
||||
assert.ok(err, "should have a custom_id error");
|
||||
});
|
||||
|
||||
test("validateJsonl: empty custom_id string → error", () => {
|
||||
const line = JSON.stringify({ custom_id: "", method: "POST", url: ENDPOINT, body: {} });
|
||||
const result = validateJsonl(line + "\n", { endpoint: ENDPOINT });
|
||||
assert.equal(result.ok, false);
|
||||
assert.ok(result.errors.some((e) => e.field === "custom_id"));
|
||||
});
|
||||
|
||||
// ── Errors: duplicate custom_id ───────────────────────────────────────────────
|
||||
|
||||
test("validateJsonl: duplicate custom_id → ok=false, duplicateCustomIds populated", () => {
|
||||
const lines = [makeLine("dup-id"), makeLine("dup-id"), makeLine("unique-id")];
|
||||
const result = validateJsonl(makeJsonl(lines), { endpoint: ENDPOINT });
|
||||
assert.equal(result.ok, false);
|
||||
assert.ok(result.duplicateCustomIds.includes("dup-id"));
|
||||
assert.equal(result.uniqueCustomIds, 2, "dup-id + unique-id = 2 unique ids");
|
||||
});
|
||||
|
||||
// ── Errors: wrong url / method ─────────────────────────────────────────────────
|
||||
|
||||
test("validateJsonl: url differs from endpoint → error on field=url", () => {
|
||||
const line = JSON.stringify({
|
||||
custom_id: "req-1",
|
||||
method: "POST",
|
||||
url: "/v1/embeddings",
|
||||
body: {},
|
||||
});
|
||||
const result = validateJsonl(line + "\n", { endpoint: ENDPOINT });
|
||||
assert.equal(result.ok, false);
|
||||
assert.ok(result.errors.some((e) => e.field === "url"));
|
||||
});
|
||||
|
||||
test("validateJsonl: completely unsupported url → error on field=url", () => {
|
||||
const line = JSON.stringify({ custom_id: "req-1", method: "POST", url: "/v1/unknown", body: {} });
|
||||
const result = validateJsonl(line + "\n", { endpoint: ENDPOINT });
|
||||
assert.equal(result.ok, false);
|
||||
assert.ok(result.errors.some((e) => e.field === "url"));
|
||||
});
|
||||
|
||||
test("validateJsonl: method is GET instead of POST → error on field=method", () => {
|
||||
const line = JSON.stringify({ custom_id: "req-1", method: "GET", url: ENDPOINT, body: {} });
|
||||
const result = validateJsonl(line + "\n", { endpoint: ENDPOINT });
|
||||
assert.equal(result.ok, false);
|
||||
assert.ok(result.errors.some((e) => e.field === "method"));
|
||||
});
|
||||
|
||||
// ── Sampling ──────────────────────────────────────────────────────────────────
|
||||
|
||||
test("validateJsonl: sampling — 1500 lines with maxLinesToInspect=1000, tailLinesToInspect=100", () => {
|
||||
const lines = Array.from({ length: 1500 }, (_, i) => makeLine(`req-${i}`));
|
||||
const result = validateJsonl(makeJsonl(lines), {
|
||||
endpoint: ENDPOINT,
|
||||
maxLinesToInspect: 1000,
|
||||
tailLinesToInspect: 100,
|
||||
});
|
||||
assert.equal(result.totalLines, 1500);
|
||||
// sampledLines = head 1000 + tail 100 (but head already covers 1000..1399, so tail adds 100 extra beyond 1000)
|
||||
assert.ok(result.sampledLines >= 1000, "should have sampled at least 1000 lines");
|
||||
assert.ok(result.sampledLines <= 1100, "should not exceed head+tail");
|
||||
});
|
||||
|
||||
test("validateJsonl: no sampling — all lines inspected when maxLinesToInspect >= totalLines", () => {
|
||||
const lines = Array.from({ length: 10 }, (_, i) => makeLine(`req-${i}`));
|
||||
const result = validateJsonl(makeJsonl(lines), {
|
||||
endpoint: ENDPOINT,
|
||||
maxLinesToInspect: 10000,
|
||||
});
|
||||
assert.equal(result.sampledLines, 10);
|
||||
assert.equal(result.totalLines, 10);
|
||||
});
|
||||
|
||||
// ── Preview ────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("validateJsonl: preview contains at most 5 items", () => {
|
||||
const lines = Array.from({ length: 10 }, (_, i) => makeLine(`req-${i}`));
|
||||
const result = validateJsonl(makeJsonl(lines), { endpoint: ENDPOINT });
|
||||
assert.ok(result.preview.length <= 5, `preview should be ≤5, got ${result.preview.length}`);
|
||||
});
|
||||
|
||||
test("validateJsonl: invalid lines are not included in preview", () => {
|
||||
const jsonl = "not-json\n" + makeLine("req-1") + "\n";
|
||||
const result = validateJsonl(jsonl, { endpoint: ENDPOINT });
|
||||
// preview should only contain the valid line
|
||||
assert.ok(result.preview.length <= 1);
|
||||
});
|
||||
|
||||
// ── byteSize ──────────────────────────────────────────────────────────────────
|
||||
|
||||
test("validateJsonl: byteSize matches UTF-8 byte length of input", () => {
|
||||
const content = makeJsonl([makeLine("req-1")]);
|
||||
const result = validateJsonl(content, { endpoint: ENDPOINT });
|
||||
const expected = new TextEncoder().encode(content).length;
|
||||
assert.equal(result.byteSize, expected);
|
||||
});
|
||||
|
||||
// ── Errors cap ────────────────────────────────────────────────────────────────
|
||||
|
||||
test("validateJsonl: errors capped at 50 even with many invalid lines", () => {
|
||||
const lines = Array.from({ length: 100 }, (_, i) => `{"custom_id":"req-${i}","method":"GET","url":"${ENDPOINT}","body":{}}`);
|
||||
const result = validateJsonl(makeJsonl(lines), { endpoint: ENDPOINT });
|
||||
assert.ok(result.errors.length <= 50, `errors should be capped at 50, got ${result.errors.length}`);
|
||||
});
|
||||
|
||||
// ── body must be object (not array) — Array.isArray guard ─────────────────────
|
||||
|
||||
test("validateJsonl: rejects body that is an array (typeof []==='object' guard)", () => {
|
||||
const line = JSON.stringify({ custom_id: "req-1", method: "POST", url: ENDPOINT, body: [] });
|
||||
const result = validateJsonl(line, { endpoint: ENDPOINT });
|
||||
assert.ok(!result.ok, "should be invalid");
|
||||
assert.ok(
|
||||
result.errors.some((e) => e.field === "body"),
|
||||
`should flag body field; errors=${JSON.stringify(result.errors)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ── BOM stripping (Windows-saved files) ───────────────────────────────────────
|
||||
|
||||
test("validateJsonl: strips UTF-8 BOM before parsing first line", () => {
|
||||
const line = JSON.stringify({
|
||||
custom_id: "req-1",
|
||||
method: "POST",
|
||||
url: ENDPOINT,
|
||||
body: { model: "gpt-4o", messages: [{ role: "user", content: "hi" }] },
|
||||
});
|
||||
const result = validateJsonl("" + line, { endpoint: ENDPOINT });
|
||||
assert.ok(result.ok, `BOM should be stripped; got errors=${JSON.stringify(result.errors)}`);
|
||||
assert.equal(result.totalLines, 1);
|
||||
assert.equal(result.uniqueCustomIds, 1);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* TDD for F5.1 (Combo U1b Slice 2) — summarizeConnectionCooldown: aggregates the
|
||||
* per-connection cooldown state (`rateLimitedUntil`) into a per-provider summary the
|
||||
* cascade can badge, exposed by GET /api/monitoring/health as `connectionHealth`.
|
||||
*
|
||||
* Parsing of rateLimitedUntil is delegated to cooldownUntilMs (#3954) — it accepts
|
||||
* ISO strings, Date objects, AND numeric epoch strings (the SQLite TEXT-affinity case).
|
||||
*
|
||||
* Run: node --import tsx/esm --test tests/unit/monitoring/connection-cooldown-summary.test.ts
|
||||
*/
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { summarizeConnectionCooldown } from "../../../src/lib/monitoring/observability.ts";
|
||||
|
||||
const NOW = 1_000_000_000_000;
|
||||
|
||||
describe("summarizeConnectionCooldown", () => {
|
||||
it("returns an empty map for no connections", () => {
|
||||
assert.deepEqual(summarizeConnectionCooldown([], NOW), {});
|
||||
});
|
||||
|
||||
it("omits providers whose connections are all available (no future rateLimitedUntil)", () => {
|
||||
const out = summarizeConnectionCooldown(
|
||||
[
|
||||
{ provider: "openai", rateLimitedUntil: null },
|
||||
{ provider: "openai", rateLimitedUntil: new Date(NOW - 5000).toISOString() }, // past → expired
|
||||
],
|
||||
NOW
|
||||
);
|
||||
assert.deepEqual(out, {});
|
||||
});
|
||||
|
||||
it("reports coolingDown / total / soonestRetryAfterMs for a provider with cooling connections", () => {
|
||||
const out = summarizeConnectionCooldown(
|
||||
[
|
||||
{ provider: "anthropic", rateLimitedUntil: new Date(NOW + 28_000).toISOString() },
|
||||
{ provider: "anthropic", rateLimitedUntil: new Date(NOW + 60_000).toISOString() },
|
||||
{ provider: "anthropic", rateLimitedUntil: null }, // available
|
||||
],
|
||||
NOW
|
||||
);
|
||||
assert.ok(out.anthropic);
|
||||
assert.equal(out.anthropic.coolingDown, 2);
|
||||
assert.equal(out.anthropic.total, 3);
|
||||
assert.equal(
|
||||
out.anthropic.soonestRetryAfterMs,
|
||||
28_000,
|
||||
"soonest = the connection that recovers first"
|
||||
);
|
||||
});
|
||||
|
||||
it("parses numeric-epoch-string rateLimitedUntil (SQLite TEXT-affinity, #3954)", () => {
|
||||
const out = summarizeConnectionCooldown(
|
||||
[{ provider: "glm", rateLimitedUntil: String(NOW + 15_000) }],
|
||||
NOW
|
||||
);
|
||||
assert.ok(out.glm);
|
||||
assert.equal(out.glm.coolingDown, 1);
|
||||
assert.equal(out.glm.soonestRetryAfterMs, 15_000);
|
||||
});
|
||||
|
||||
it("accepts a raw epoch number too", () => {
|
||||
const out = summarizeConnectionCooldown(
|
||||
[{ provider: "glm", rateLimitedUntil: NOW + 9000 }],
|
||||
NOW
|
||||
);
|
||||
assert.equal(out.glm?.soonestRetryAfterMs, 9000);
|
||||
});
|
||||
|
||||
it("groups connections per provider independently", () => {
|
||||
const out = summarizeConnectionCooldown(
|
||||
[
|
||||
{ provider: "openai", rateLimitedUntil: new Date(NOW + 10_000).toISOString() },
|
||||
{ provider: "anthropic", rateLimitedUntil: new Date(NOW + 40_000).toISOString() },
|
||||
{ provider: "openai", rateLimitedUntil: null },
|
||||
],
|
||||
NOW
|
||||
);
|
||||
assert.equal(out.openai.coolingDown, 1);
|
||||
assert.equal(out.openai.total, 2);
|
||||
assert.equal(out.anthropic.coolingDown, 1);
|
||||
assert.equal(out.anthropic.total, 1);
|
||||
});
|
||||
|
||||
it("ignores connections without a provider and never returns negative retry", () => {
|
||||
const out = summarizeConnectionCooldown(
|
||||
[
|
||||
{ rateLimitedUntil: new Date(NOW + 5000).toISOString() }, // no provider
|
||||
{ provider: "x", rateLimitedUntil: new Date(NOW + 1).toISOString() },
|
||||
],
|
||||
NOW
|
||||
);
|
||||
assert.equal(Object.keys(out).length, 1);
|
||||
assert.ok(out.x.soonestRetryAfterMs >= 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { getMachineTokenSync } from "../../../src/lib/machineToken.ts";
|
||||
|
||||
test("getMachineTokenSync returns a 64-character hex string (full SHA-256)", () => {
|
||||
const token = getMachineTokenSync();
|
||||
assert.match(token, /^[0-9a-f]{64}$/, "token must be 64 lowercase hex chars (HMAC-SHA256)");
|
||||
});
|
||||
|
||||
test("getMachineTokenSync is deterministic", () => {
|
||||
assert.equal(getMachineTokenSync(), getMachineTokenSync());
|
||||
});
|
||||
|
||||
test("getMachineTokenSync produces different values for different salts", () => {
|
||||
const t1 = getMachineTokenSync("salt-a");
|
||||
const t2 = getMachineTokenSync("salt-b");
|
||||
assert.notEqual(t1, t2);
|
||||
});
|
||||
|
||||
test("getMachineTokenSync with empty string salt does not throw", () => {
|
||||
assert.doesNotThrow(() => getMachineTokenSync(""));
|
||||
});
|
||||
|
||||
test("getMachineTokenSync respects OMNIROUTE_CLI_SALT env var", () => {
|
||||
const before = getMachineTokenSync();
|
||||
process.env.OMNIROUTE_CLI_SALT = "__test_salt__";
|
||||
const withEnv = getMachineTokenSync();
|
||||
delete process.env.OMNIROUTE_CLI_SALT;
|
||||
assert.notEqual(before, withEnv, "env salt must produce a different token");
|
||||
assert.match(withEnv, /^[0-9a-f]{64}$/, "env-derived token must still be 64-char hex");
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { test, mock } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// Hermetic auth context (6A re-wire fix): the "rejects ..." assertions assume
|
||||
// login protection is ON — on a fresh DB (CI) isAuthRequired() is false and the
|
||||
// policy anonymous-allows before any token check. Locally this only passed
|
||||
// because the dev DATA_DIR had a real password. Isolate + enable protection.
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-mgmt-cli-token-"));
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const settingsDb = await import("../../../src/lib/db/settings.ts");
|
||||
await settingsDb.updateSettings({
|
||||
requireLogin: true,
|
||||
setupComplete: true,
|
||||
password: "test-password-hash",
|
||||
});
|
||||
|
||||
const { getLegacyCliTokenSync, getMachineTokenSync } = await import(
|
||||
"../../../src/lib/machineToken.ts"
|
||||
);
|
||||
const { managementPolicy } = await import("../../../src/server/authz/policies/management.ts");
|
||||
const { CLI_TOKEN_HEADER } = await import("../../../src/server/authz/headers.ts");
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = originalDataDir;
|
||||
});
|
||||
|
||||
function makeCtx(headers: Record<string, string>, requestExtras: Record<string, unknown> = {}) {
|
||||
return {
|
||||
request: {
|
||||
method: "GET",
|
||||
headers: new Headers(headers),
|
||||
cookies: { get: () => undefined },
|
||||
nextUrl: { pathname: "/api/settings" },
|
||||
url: "http://localhost:20128/api/settings",
|
||||
...requestExtras,
|
||||
},
|
||||
classification: {
|
||||
routeClass: "MANAGEMENT" as const,
|
||||
normalizedPath: "/api/settings",
|
||||
method: "GET",
|
||||
},
|
||||
requestId: "test-req",
|
||||
};
|
||||
}
|
||||
|
||||
test("management policy allows valid CLI token from localhost", async () => {
|
||||
const token = getMachineTokenSync();
|
||||
const ctx = makeCtx(
|
||||
{ host: "localhost", [CLI_TOKEN_HEADER]: token },
|
||||
{ socket: { remoteAddress: "127.0.0.1" } }
|
||||
);
|
||||
const outcome = await managementPolicy.evaluate(ctx);
|
||||
assert.equal(outcome.allow, true);
|
||||
if (outcome.allow) {
|
||||
assert.equal(outcome.subject.id, "cli");
|
||||
}
|
||||
});
|
||||
|
||||
test("management policy accepts legacy 32-character CLI token from localhost", async () => {
|
||||
const token = getLegacyCliTokenSync();
|
||||
assert.equal(token.length, 32);
|
||||
const ctx = makeCtx(
|
||||
{
|
||||
host: "localhost",
|
||||
[CLI_TOKEN_HEADER]: token,
|
||||
},
|
||||
{ socket: { remoteAddress: "127.0.0.1" } }
|
||||
);
|
||||
const outcome = await managementPolicy.evaluate(ctx);
|
||||
assert.equal(outcome.allow, true);
|
||||
if (outcome.allow) {
|
||||
assert.equal(outcome.subject.id, "cli");
|
||||
}
|
||||
});
|
||||
|
||||
test("management policy rejects valid token from non-localhost", async () => {
|
||||
const token = getMachineTokenSync();
|
||||
const ctx = makeCtx(
|
||||
{ host: "localhost", [CLI_TOKEN_HEADER]: token },
|
||||
{ socket: { remoteAddress: "192.168.1.100" } }
|
||||
);
|
||||
const outcome = await managementPolicy.evaluate(ctx);
|
||||
assert.equal(outcome.allow, false);
|
||||
});
|
||||
|
||||
test("management policy rejects wrong CLI token from localhost", async () => {
|
||||
const ctx = makeCtx(
|
||||
{
|
||||
host: "localhost",
|
||||
[CLI_TOKEN_HEADER]: "deadbeefdeadbeefdeadbeefdeadbeef",
|
||||
},
|
||||
{ socket: { remoteAddress: "127.0.0.1" } }
|
||||
);
|
||||
const outcome = await managementPolicy.evaluate(ctx);
|
||||
assert.equal(outcome.allow, false);
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-quota-reset-events-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const {
|
||||
recordProviderQuotaResetEventIfChanged,
|
||||
getProviderQuotaWindowStart,
|
||||
getProviderQuotaWindowStartIso,
|
||||
} = await import("../../../src/lib/db/quotaResetEvents.ts");
|
||||
|
||||
// Force migrations (incl. 108_provider_quota_reset_events) to run.
|
||||
core.getDbInstance();
|
||||
|
||||
const CONN = "conn-1";
|
||||
const PROVIDER = "antigravity";
|
||||
const PREV_RESET = "2026-01-08T00:00:00.000Z";
|
||||
const CUR_RESET = "2026-01-15T00:00:00.000Z"; // +7d, different day
|
||||
const OBSERVED = "2026-01-15T00:05:00.000Z";
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("records a weekly window transition and getWindowStart returns the prior window start", () => {
|
||||
recordProviderQuotaResetEventIfChanged({
|
||||
provider: PROVIDER,
|
||||
connectionId: CONN,
|
||||
windowKey: "weekly",
|
||||
currentResetAt: CUR_RESET,
|
||||
currentRemainingPercentage: 100,
|
||||
previousObservation: { resetAt: PREV_RESET, remainingPercentage: 5 },
|
||||
observedAt: OBSERVED,
|
||||
});
|
||||
|
||||
// For the new window (resets at CUR_RESET) the observed start is PREV_RESET.
|
||||
const start = getProviderQuotaWindowStartIso(CONN, CUR_RESET, Date.parse(OBSERVED) + 1000);
|
||||
assert.equal(start, PREV_RESET);
|
||||
});
|
||||
|
||||
test("getWindowStart returns null for a reset day with no recorded event", () => {
|
||||
assert.equal(
|
||||
getProviderQuotaWindowStartIso(CONN, "2026-02-01T00:00:00.000Z", Date.parse(OBSERVED) + 1000),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test("observed same-resetAt quota drop overrides an older recorded weekly window", () => {
|
||||
const connectionId = "conn-early-reset-snapshot";
|
||||
const targetResetAt = "2026-07-02T23:00:00.000Z";
|
||||
const db = core.getDbInstance();
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO provider_quota_reset_events
|
||||
(provider, connection_id, window_key, window_started_at, window_resets_at,
|
||||
observed_at, previous_remaining_percentage, new_remaining_percentage,
|
||||
previous_used_percentage, new_used_percentage, raw_data)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
).run(
|
||||
"claude",
|
||||
connectionId,
|
||||
"weekly (7d)",
|
||||
"2026-06-25T23:00:00.000Z",
|
||||
targetResetAt,
|
||||
"2026-06-25T23:04:00.000Z",
|
||||
0,
|
||||
100,
|
||||
100,
|
||||
0,
|
||||
null
|
||||
);
|
||||
|
||||
const insertSnapshot = db.prepare(`
|
||||
INSERT INTO quota_snapshots (
|
||||
provider,
|
||||
connection_id,
|
||||
window_key,
|
||||
remaining_percentage,
|
||||
is_exhausted,
|
||||
next_reset_at,
|
||||
window_duration_ms,
|
||||
raw_data,
|
||||
created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
insertSnapshot.run(
|
||||
"claude",
|
||||
connectionId,
|
||||
"weekly (7d)",
|
||||
100,
|
||||
0,
|
||||
targetResetAt,
|
||||
null,
|
||||
null,
|
||||
"2026-06-25T23:04:00.000Z"
|
||||
);
|
||||
insertSnapshot.run(
|
||||
"claude",
|
||||
connectionId,
|
||||
"weekly (7d)",
|
||||
4,
|
||||
0,
|
||||
targetResetAt,
|
||||
null,
|
||||
null,
|
||||
"2026-07-01T00:05:00.000Z"
|
||||
);
|
||||
insertSnapshot.run(
|
||||
"claude",
|
||||
connectionId,
|
||||
"weekly (7d)",
|
||||
100,
|
||||
0,
|
||||
targetResetAt,
|
||||
null,
|
||||
null,
|
||||
"2026-07-01T21:41:13.293Z"
|
||||
);
|
||||
|
||||
const start = getProviderQuotaWindowStart(
|
||||
connectionId,
|
||||
targetResetAt,
|
||||
Date.parse("2026-07-02T00:00:00.000Z")
|
||||
);
|
||||
|
||||
assert.deepEqual(start, {
|
||||
windowStartIso: "2026-07-01T21:41:13.293Z",
|
||||
source: "observed_snapshot_reset",
|
||||
});
|
||||
assert.equal(
|
||||
getProviderQuotaWindowStartIso(
|
||||
connectionId,
|
||||
targetResetAt,
|
||||
Date.parse("2026-07-02T00:00:00.000Z")
|
||||
),
|
||||
"2026-07-01T21:41:13.293Z"
|
||||
);
|
||||
});
|
||||
|
||||
test("records same-resetAt weekly resets when usage drops back to the reset floor", () => {
|
||||
const connectionId = "conn-early-reset-record";
|
||||
const targetResetAt = "2026-07-02T23:00:00.000Z";
|
||||
const observedAt = "2026-07-01T21:41:13.293Z";
|
||||
|
||||
recordProviderQuotaResetEventIfChanged({
|
||||
provider: "claude",
|
||||
connectionId,
|
||||
windowKey: "weekly (7d)",
|
||||
currentResetAt: targetResetAt,
|
||||
currentRemainingPercentage: 100,
|
||||
previousObservation: { resetAt: targetResetAt, remainingPercentage: 4 },
|
||||
observedAt,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
getProviderQuotaWindowStartIso(
|
||||
connectionId,
|
||||
targetResetAt,
|
||||
Date.parse("2026-07-02T00:00:00.000Z")
|
||||
),
|
||||
observedAt
|
||||
);
|
||||
});
|
||||
|
||||
test("does not record when previous and current reset fall on the same day without a reset drop", () => {
|
||||
recordProviderQuotaResetEventIfChanged({
|
||||
provider: PROVIDER,
|
||||
connectionId: "conn-sameday",
|
||||
windowKey: "weekly",
|
||||
currentResetAt: "2026-03-10T23:00:00.000Z",
|
||||
currentRemainingPercentage: 69,
|
||||
previousObservation: { resetAt: "2026-03-10T01:00:00.000Z", remainingPercentage: 70 },
|
||||
observedAt: "2026-03-10T23:30:00.000Z",
|
||||
});
|
||||
assert.equal(
|
||||
getProviderQuotaWindowStartIso(
|
||||
"conn-sameday",
|
||||
"2026-03-10T23:00:00.000Z",
|
||||
Date.parse("2026-03-11T00:00:00.000Z")
|
||||
),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test("does not record for a non-weekly (e.g. daily) window", () => {
|
||||
recordProviderQuotaResetEventIfChanged({
|
||||
provider: PROVIDER,
|
||||
connectionId: "conn-daily",
|
||||
windowKey: "daily",
|
||||
currentResetAt: CUR_RESET,
|
||||
currentRemainingPercentage: 100,
|
||||
previousObservation: { resetAt: PREV_RESET, remainingPercentage: 5 },
|
||||
observedAt: OBSERVED,
|
||||
});
|
||||
assert.equal(
|
||||
getProviderQuotaWindowStartIso("conn-daily", CUR_RESET, Date.parse(OBSERVED) + 1000),
|
||||
null
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user