mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-21 00:21:49 +00:00
An edit during the first run used to cancel it, and the edited run then skipped because the webhook payload had no needs:template yet, so a well-formed issue edited within the first minute was never labelled. Runs for one issue now queue, and the script decides an edited event against the live labels: a domain label means leave it alone, a gated issue is re-run, and an unlabelled issue is re-run for its first hour
482 lines
22 KiB
TypeScript
482 lines
22 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
|
|
import type { GitHubApi } from "./auto-close-duplicates";
|
|
import {
|
|
BODY_CAP_CHARS,
|
|
BUG_SECTIONS,
|
|
EDIT_WINDOW_MS,
|
|
FORM_HEADINGS,
|
|
SECTION_CAP_CHARS,
|
|
FEATURE_SECTIONS,
|
|
MIN_SECTION_CHARS,
|
|
buildRequest,
|
|
classifyIssue,
|
|
gate,
|
|
parseClassification,
|
|
readConfig,
|
|
routesOf,
|
|
sections,
|
|
shouldReclassify,
|
|
userMessage,
|
|
type ChatRequest,
|
|
type IssueForClassification,
|
|
type LlmClient,
|
|
type Schema,
|
|
} from "./classify-issue";
|
|
import { MANIFEST, NAMESPACES } from "./issue-labels";
|
|
import schemaJson from "../.github/prompts/issue-classifier.schema.json";
|
|
|
|
const schema = schemaJson as Schema;
|
|
const routes = routesOf(schema);
|
|
|
|
const section = (heading: string, text: string): string => `### ${heading}\n\n${text}\n\n`;
|
|
|
|
const bugBody = (overrides: Partial<Record<(typeof BUG_SECTIONS)[number] | "dropdown" | "deploy", string>> = {}): string =>
|
|
[
|
|
section("Description", overrides.Description ?? "Streaming responses from Bedrock drop the last chunk when tools are used."),
|
|
section("Config", overrides.Config ?? "```yaml\nmodel_list:\n - model_name: claude\n litellm_params:\n model: bedrock/claude\n```"),
|
|
section("LiteLLM Version", overrides["LiteLLM Version"] ?? "v1.100.0"),
|
|
section("Steps to Repro", overrides["Steps to Repro"] ?? "1. curl -X POST http://localhost:4000/v1/chat/completions -d '{...}'\n2. Response: 500"),
|
|
section("Which part of LiteLLM is this about?", overrides.dropdown ?? "LLM translation: a specific provider's request or response"),
|
|
section("How are you deploying?", overrides.deploy ?? "_No response_"),
|
|
].join("");
|
|
|
|
const featureBody = (): string =>
|
|
[
|
|
section("Check for existing issues", "- [X] I have searched the existing issues and checked that my issue is not a duplicate."),
|
|
section("The Feature", "Scope guardrail policies to specific MCP servers so one server is masked and another is not."),
|
|
section("User Flow", "Before this feature (today): the admin attaches the policy globally and both servers get masked."),
|
|
section("How far you got", "Config / setup the proxy ran with: two MCP servers and a Presidio guardrail; both calls come back raw."),
|
|
section("Which part of LiteLLM is this about?", "Guardrails: moderation, PII masking, policies"),
|
|
].join("");
|
|
|
|
const issue = (overrides: Partial<IssueForClassification> = {}): IssueForClassification => ({
|
|
number: 41700,
|
|
title: "[Bug]: Bedrock streaming drops the last chunk with tools",
|
|
body: bugBody(),
|
|
author_association: "NONE",
|
|
labels: [],
|
|
created_at: "2026-09-17T12:00:00Z",
|
|
...overrides,
|
|
});
|
|
|
|
const label = (...names: readonly string[]): readonly { readonly name: string }[] => names.map((name) => ({ name }));
|
|
|
|
const modelAnswer = (overrides: Record<string, unknown> = {}): string =>
|
|
JSON.stringify({
|
|
domain: "llm-translation",
|
|
provider: "bedrock",
|
|
kind: "bug",
|
|
priority: "p1",
|
|
lift: "medium",
|
|
route: "chat_completions",
|
|
version: "v1.100.0",
|
|
needs_repro: false,
|
|
reason: "Bedrock streaming with tools drops the final chunk and no param avoids it.",
|
|
...overrides,
|
|
});
|
|
|
|
describe("the schema and the manifest agree", () => {
|
|
test("every labelled enum in the schema is exactly the manifest's values", () => {
|
|
for (const namespace of NAMESPACES.filter((name) => name !== "needs")) {
|
|
const allowed = (schema.properties[namespace]?.enum ?? []).filter((value) => value !== null);
|
|
expect(new Set(allowed)).toEqual(new Set(Object.keys(MANIFEST[namespace])));
|
|
}
|
|
});
|
|
|
|
test("provider and route accept null, the labelled-exactly-once fields do not", () => {
|
|
expect(schema.properties.provider?.enum).toContain(null);
|
|
expect(schema.properties.route?.enum).toContain(null);
|
|
for (const field of ["domain", "kind", "priority", "lift"]) {
|
|
expect(schema.properties[field]?.enum).not.toContain(null);
|
|
}
|
|
});
|
|
|
|
test("every label description fits GitHub's 100 character limit", () => {
|
|
for (const namespace of NAMESPACES) {
|
|
for (const [value, spec] of Object.entries(MANIFEST[namespace])) {
|
|
expect(spec.description.length, `${namespace}:${value}`).toBeLessThanOrEqual(100);
|
|
expect(spec.color).toMatch(/^[0-9A-Fa-f]{6}$/);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("sections", () => {
|
|
test("splits an issue form body on its field headings and trims each block", () => {
|
|
const found = sections("preamble\n### Description\n\nIt broke.\n\n### Config\n\n_No response_\n");
|
|
expect([...found.entries()]).toEqual([
|
|
["Description", "It broke."],
|
|
["Config", "_No response_"],
|
|
]);
|
|
});
|
|
|
|
test("a heading the reporter typed inside a field stays inside that field", () => {
|
|
const found = sections(
|
|
"### Steps to Repro\n\n### Actual response\n\n500 from the proxy\n\n### Expected\n\n200\n\n### LiteLLM Version\n\nv1.100.0\n",
|
|
);
|
|
expect(found.get("Steps to Repro")).toBe("### Actual response\n\n500 from the proxy\n\n### Expected\n\n200");
|
|
expect(found.get("LiteLLM Version")).toBe("v1.100.0");
|
|
});
|
|
|
|
test("a repeated field heading does not overwrite the first value", () => {
|
|
const found = sections("### Description\n\nreal text\n\n### Config\n\n### Description\n\nnot a field\n");
|
|
expect(found.get("Description")).toBe("real text");
|
|
expect(found.get("Config")).toBe("### Description\n\nnot a field");
|
|
});
|
|
|
|
test("a body with no headings has no sections", () => {
|
|
expect(sections("just some prose with ### inside a line").size).toBe(0);
|
|
expect(sections("### Open question for OWNER\n\nnot a form field").size).toBe(0);
|
|
});
|
|
|
|
test("the known headings are exactly the field labels of the two issue forms", async () => {
|
|
const labels = await Promise.all(
|
|
["bug_report.yml", "feature_request.yml"].map(async (file) => {
|
|
const form = Bun.YAML.parse(await Bun.file(`${import.meta.dir}/../.github/ISSUE_TEMPLATE/${file}`).text()) as {
|
|
readonly body: readonly { readonly attributes?: { readonly label?: string } }[];
|
|
};
|
|
return form.body.flatMap((field) => (field.attributes?.label === undefined ? [] : [field.attributes.label.trim()]));
|
|
}),
|
|
);
|
|
expect(new Set(labels.flat())).toEqual(new Set(FORM_HEADINGS));
|
|
});
|
|
});
|
|
|
|
describe("gate", () => {
|
|
test("a filled bug template passes with the dropdown hint and the version", () => {
|
|
expect(gate(issue())).toEqual({
|
|
kind: "pass",
|
|
template: "bug",
|
|
domainHint: "LLM translation: a specific provider's request or response",
|
|
version: "v1.100.0",
|
|
});
|
|
});
|
|
|
|
test("a filled feature template passes as a feature", () => {
|
|
expect(gate(issue({ title: "[Feature]: scope guardrails", body: featureBody() }))).toMatchObject({
|
|
kind: "pass",
|
|
template: "feature",
|
|
domainHint: "Guardrails: moderation, PII masking, policies",
|
|
version: null,
|
|
});
|
|
});
|
|
|
|
test("an empty, placeholder, or too-short section is missing", () => {
|
|
expect(gate(issue({ body: bugBody({ Config: "_No response_" }) }))).toEqual({
|
|
kind: "template",
|
|
template: "bug",
|
|
missing: ["Config"],
|
|
});
|
|
expect(gate(issue({ body: bugBody({ "Steps to Repro": "n/a" }) }))).toMatchObject({ missing: ["Steps to Repro"] });
|
|
expect(gate(issue({ body: bugBody({ Description: "x".repeat(MIN_SECTION_CHARS - 1) }) }))).toMatchObject({
|
|
missing: ["Description"],
|
|
});
|
|
expect(gate(issue({ body: bugBody({ Description: "x".repeat(MIN_SECTION_CHARS) }) })).kind).toBe("pass");
|
|
});
|
|
|
|
test("a version has to carry a number", () => {
|
|
expect(gate(issue({ body: bugBody({ "LiteLLM Version": "latest" }) }))).toMatchObject({ missing: ["LiteLLM Version"] });
|
|
expect(gate(issue({ body: bugBody({ "LiteLLM Version": "main-v1.101.3-nightly" }) }))).toMatchObject({
|
|
kind: "pass",
|
|
version: "main-v1.101.3-nightly",
|
|
});
|
|
});
|
|
|
|
test("an issue filed without the form is missing every required section of its template", () => {
|
|
expect(gate(issue({ body: "It is broken, please fix." }))).toEqual({
|
|
kind: "template",
|
|
template: "bug",
|
|
missing: [...BUG_SECTIONS],
|
|
});
|
|
expect(gate(issue({ title: "[Feature]: add a thing", body: null }))).toEqual({
|
|
kind: "template",
|
|
template: "feature",
|
|
missing: [...FEATURE_SECTIONS],
|
|
});
|
|
});
|
|
|
|
test("the title prefix names the template, and the headings decide only without one", () => {
|
|
const oldBugShape = [section("What happened?", "Vertex AI rejects tools whose parameters use a top-level anyOf."), section("User Flow", "Before a fix: the request fails with a 400 from Vertex AI.")].join("");
|
|
expect(gate(issue({ title: "[Bug]: Vertex AI 400 on anyOf tool schemas", body: oldBugShape }))).toEqual({
|
|
kind: "template",
|
|
template: "bug",
|
|
missing: [...BUG_SECTIONS],
|
|
});
|
|
expect(gate(issue({ title: "Vertex AI 400 on anyOf tool schemas", body: oldBugShape }))).toMatchObject({
|
|
template: "feature",
|
|
});
|
|
expect(gate(issue({ title: "[feature]: scope guardrails", body: bugBody() }))).toMatchObject({ template: "feature" });
|
|
});
|
|
|
|
test("a maintainer's issue passes the gate whatever its shape, so the bot never nags the team", () => {
|
|
expect(gate(issue({ body: "internal note", author_association: "MEMBER" }))).toEqual({
|
|
kind: "pass",
|
|
template: "bug",
|
|
domainHint: null,
|
|
version: null,
|
|
});
|
|
expect(gate(issue({ body: "internal note", author_association: "CONTRIBUTOR" })).kind).toBe("template");
|
|
});
|
|
|
|
test("'Not sure' and an unanswered dropdown are no hint", () => {
|
|
expect(gate(issue({ body: bugBody({ dropdown: "Not sure" }) }))).toMatchObject({ domainHint: null });
|
|
expect(gate(issue({ body: bugBody({ dropdown: "_No response_" }) }))).toMatchObject({ domainHint: null });
|
|
});
|
|
});
|
|
|
|
describe("buildRequest", () => {
|
|
const passed = { kind: "pass" as const, template: "bug" as const, domainHint: "Caching: response cache", version: "v1.99.0" };
|
|
|
|
test("asks for strict JSON against the vendored schema with the prompt as the system message", () => {
|
|
const request = buildRequest("gpt-5.6-luna", "PROMPT", schema, issue(), passed);
|
|
expect(request.model).toBe("gpt-5.6-luna");
|
|
expect(request.messages[0]).toEqual({ role: "system", content: "PROMPT" });
|
|
expect(request.messages[1]?.role).toBe("user");
|
|
expect(request.response_format).toEqual({
|
|
type: "json_schema",
|
|
json_schema: { name: "issue_classification", strict: true, schema },
|
|
});
|
|
expect(Object.keys(request)).toEqual(["model", "messages", "response_format"]);
|
|
});
|
|
|
|
test("the user message carries the title, the template, the hint and the version above the body", () => {
|
|
const message = userMessage(issue(), passed);
|
|
expect(message.startsWith("Title: [Bug]: Bedrock streaming drops the last chunk with tools\nTemplate: bug\n")).toBe(true);
|
|
expect(message).toContain("Reporter's pick from the domain dropdown: Caching: response cache");
|
|
expect(message).toContain("LiteLLM Version (from the template): v1.99.0");
|
|
expect(message).toContain("### Steps to Repro");
|
|
});
|
|
|
|
test("each field is capped on its own, so a huge config cannot push the repro out of the message", () => {
|
|
const message = userMessage(issue({ body: bugBody({ Config: "y".repeat(SECTION_CAP_CHARS * 3) }) }), passed);
|
|
expect(message).toContain(`[section truncated at ${SECTION_CAP_CHARS} characters]`);
|
|
expect(message).toContain("### Steps to Repro\n\n1. curl -X POST http://localhost:4000/v1/chat/completions");
|
|
expect(message.length).toBeLessThan(SECTION_CAP_CHARS + 1500);
|
|
});
|
|
|
|
test("the hiring, contact and duplicate-check fields are left out of the message", () => {
|
|
const message = userMessage(issue({ title: "[Feature]: scope guardrails", body: featureBody() }), passed);
|
|
expect(message).toContain("### The Feature");
|
|
expect(message).not.toContain("Check for existing issues");
|
|
});
|
|
|
|
test("a body without form fields is sent whole, capped, and the version survives the cap", () => {
|
|
const body = "x".repeat(BODY_CAP_CHARS * 2);
|
|
const message = userMessage(issue({ body }), passed);
|
|
expect(message.length).toBeLessThan(BODY_CAP_CHARS + 500);
|
|
expect(message).toContain(`[body truncated at ${BODY_CAP_CHARS} characters]`);
|
|
expect(message).toContain("LiteLLM Version (from the template): v1.99.0");
|
|
});
|
|
|
|
test("no hint and no version are said plainly", () => {
|
|
const message = userMessage(issue({ body: null }), { ...passed, domainHint: null, version: null });
|
|
expect(message).toContain("Reporter's pick from the domain dropdown: none\n");
|
|
expect(message).not.toContain("LiteLLM Version (from the template)");
|
|
});
|
|
});
|
|
|
|
describe("parseClassification", () => {
|
|
test("accepts the schema's shape and turns it into labels plus needs", () => {
|
|
const parsed = parseClassification(modelAnswer(), MANIFEST, routes);
|
|
expect(parsed).toEqual({
|
|
kind: "classification",
|
|
classification: {
|
|
gate: "pass",
|
|
domain: "llm-translation",
|
|
provider: "bedrock",
|
|
kind: "bug",
|
|
priority: "p1",
|
|
lift: "medium",
|
|
route: "chat_completions",
|
|
version: "v1.100.0",
|
|
needs: [],
|
|
reason: "Bedrock streaming with tools drops the final chunk and no param avoids it.",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("a null version needs version, a bug without a repro needs repro, both can stack", () => {
|
|
const both = parseClassification(modelAnswer({ version: null, needs_repro: true }), MANIFEST, routes);
|
|
expect(both.kind === "classification" && both.classification.needs).toEqual(["version", "repro"]);
|
|
const none = parseClassification(modelAnswer({ provider: null, route: null }), MANIFEST, routes);
|
|
expect(none.kind === "classification" && none.classification).toMatchObject({ provider: null, route: null, needs: [] });
|
|
});
|
|
|
|
test("kind decides first: a feature or question is p3 whatever the model said, and never needs a repro", () => {
|
|
const feature = parseClassification(modelAnswer({ kind: "feature", priority: "p1", needs_repro: true }), MANIFEST, routes);
|
|
expect(feature.kind === "classification" && feature.classification).toMatchObject({ priority: "p3", needs: [] });
|
|
const question = parseClassification(modelAnswer({ kind: "question", priority: "p0" }), MANIFEST, routes);
|
|
expect(question.kind === "classification" && question.classification.priority).toBe("p3");
|
|
});
|
|
|
|
test("a value the manifest does not know is rejected instead of half-applied", () => {
|
|
expect(parseClassification(modelAnswer({ domain: "networking" }), MANIFEST, routes)).toMatchObject({ kind: "invalid" });
|
|
expect(parseClassification(modelAnswer({ provider: "groq" }), MANIFEST, routes)).toMatchObject({ kind: "invalid" });
|
|
expect(parseClassification(modelAnswer({ priority: "p4" }), MANIFEST, routes)).toMatchObject({ kind: "invalid" });
|
|
expect(parseClassification(modelAnswer({ lift: "huge" }), MANIFEST, routes)).toMatchObject({ kind: "invalid" });
|
|
expect(parseClassification(modelAnswer({ route: "batch" }), MANIFEST, routes)).toMatchObject({ kind: "invalid" });
|
|
expect(parseClassification(modelAnswer({ kind: "bugg" }), MANIFEST, routes)).toMatchObject({ kind: "invalid" });
|
|
});
|
|
|
|
test("a malformed answer is rejected", () => {
|
|
expect(parseClassification("not json", MANIFEST, routes)).toMatchObject({ kind: "invalid" });
|
|
expect(parseClassification("[]", MANIFEST, routes)).toMatchObject({ kind: "invalid" });
|
|
expect(parseClassification(modelAnswer({ needs_repro: "yes" }), MANIFEST, routes)).toMatchObject({ kind: "invalid" });
|
|
expect(parseClassification(modelAnswer({ reason: " " }), MANIFEST, routes)).toMatchObject({ kind: "invalid" });
|
|
expect(parseClassification(modelAnswer({ version: "" }), MANIFEST, routes)).toMatchObject({ kind: "invalid" });
|
|
});
|
|
});
|
|
|
|
describe("shouldReclassify", () => {
|
|
const now = new Date("2026-09-17T12:10:00Z");
|
|
|
|
test("an issue that already carries a domain label is left alone, whatever else it has", () => {
|
|
expect(shouldReclassify(issue({ labels: label("domain:caching", "kind:bug") }), now)).toBe(false);
|
|
expect(shouldReclassify(issue({ labels: label("needs:template", "domain:caching") }), now)).toBe(false);
|
|
});
|
|
|
|
test("a gated issue is re-run however old it is", () => {
|
|
const old = new Date(Date.parse("2026-09-17T12:00:00Z") + EDIT_WINDOW_MS * 48);
|
|
expect(shouldReclassify(issue({ labels: label("bug", "needs:template") }), old)).toBe(true);
|
|
});
|
|
|
|
test("an unlabelled issue is re-run inside the edit window and ignored after it", () => {
|
|
expect(shouldReclassify(issue({ labels: label("bug") }), now)).toBe(true);
|
|
const later = new Date(Date.parse("2026-09-17T12:00:00Z") + EDIT_WINDOW_MS);
|
|
expect(shouldReclassify(issue({ labels: label("bug") }), later)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("classifyIssue", () => {
|
|
const config = {
|
|
repo: "BerriAI/litellm",
|
|
issueNumber: 41700,
|
|
model: "gpt-5.6-luna",
|
|
action: "opened",
|
|
now: new Date("2026-09-17T12:10:00Z"),
|
|
};
|
|
|
|
function fakeApi(fetched: IssueForClassification): GitHubApi {
|
|
return {
|
|
request: async <T>(method: string, path: string): Promise<T> => {
|
|
if (method === "GET" && path === "/repos/BerriAI/litellm/issues/41700") {
|
|
return fetched as T;
|
|
}
|
|
throw new Error(`unexpected ${method} ${path}`);
|
|
},
|
|
};
|
|
}
|
|
|
|
function fakeLlm(answer: string): { readonly llm: LlmClient; readonly requests: ChatRequest[] } {
|
|
const requests: ChatRequest[] = [];
|
|
return {
|
|
requests,
|
|
llm: {
|
|
complete: async (request) => {
|
|
requests.push(request);
|
|
return answer;
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
test("a gated issue never reaches the model", async () => {
|
|
const { llm, requests } = fakeLlm(modelAnswer());
|
|
const verdict = await classifyIssue(fakeApi(issue({ body: "no template" })), llm, config, "PROMPT", schema);
|
|
expect(verdict).toEqual({ gate: "template", template: "bug", missing: [...BUG_SECTIONS] });
|
|
expect(requests).toEqual([]);
|
|
});
|
|
|
|
test("an issue that passes the gate is classified by one call with the configured model", async () => {
|
|
const { llm, requests } = fakeLlm(modelAnswer());
|
|
const verdict = await classifyIssue(fakeApi(issue()), llm, config, "PROMPT", schema);
|
|
expect(verdict).toMatchObject({ gate: "pass", domain: "llm-translation", provider: "bedrock", priority: "p1" });
|
|
expect(requests).toHaveLength(1);
|
|
expect(requests[0]?.model).toBe("gpt-5.6-luna");
|
|
expect(requests[0]?.messages[0]?.content).toBe("PROMPT");
|
|
});
|
|
|
|
test("an answer the manifest does not know fails the run instead of returning a partial set", async () => {
|
|
const { llm } = fakeLlm(modelAnswer({ domain: "made-up" }));
|
|
await expect(classifyIssue(fakeApi(issue()), llm, config, "PROMPT", schema)).rejects.toThrow("failed validation");
|
|
});
|
|
|
|
test("a pull request number is refused", async () => {
|
|
const { llm, requests } = fakeLlm(modelAnswer());
|
|
await expect(classifyIssue(fakeApi(issue({ pull_request: {} })), llm, config, "PROMPT", schema)).rejects.toThrow(
|
|
"is a pull request",
|
|
);
|
|
expect(requests).toEqual([]);
|
|
});
|
|
|
|
test("an edit to an issue that was classified while the edit was pending is ignored", async () => {
|
|
const { llm, requests } = fakeLlm(modelAnswer());
|
|
const edited = { ...config, action: "edited" };
|
|
const labelled = issue({ labels: label("domain:llm-translation", "kind:bug", "priority:p1", "lift:small") });
|
|
expect(await classifyIssue(fakeApi(labelled), llm, edited, "PROMPT", schema)).toBeNull();
|
|
expect(requests).toEqual([]);
|
|
});
|
|
|
|
test("an edit that fixes a gated issue is classified against the new body", async () => {
|
|
const { llm, requests } = fakeLlm(modelAnswer());
|
|
const edited = { ...config, action: "edited" };
|
|
const verdict = await classifyIssue(fakeApi(issue({ labels: label("bug", "needs:template") })), llm, edited, "PROMPT", schema);
|
|
expect(verdict).toMatchObject({ gate: "pass", domain: "llm-translation" });
|
|
expect(requests).toHaveLength(1);
|
|
});
|
|
|
|
test("an edit during the first run, before any label landed, is classified instead of dropped", async () => {
|
|
const { llm, requests } = fakeLlm(modelAnswer());
|
|
const edited = { ...config, action: "edited" };
|
|
expect(await classifyIssue(fakeApi(issue({ labels: label("bug") })), llm, edited, "PROMPT", schema)).toMatchObject({
|
|
gate: "pass",
|
|
});
|
|
expect(requests).toHaveLength(1);
|
|
});
|
|
|
|
test("a manual run classifies an old unlabelled issue that an edit would ignore", async () => {
|
|
const { llm, requests } = fakeLlm(modelAnswer());
|
|
const old = issue({ labels: label("bug"), created_at: "2020-01-01T00:00:00Z" });
|
|
expect(await classifyIssue(fakeApi(old), llm, { ...config, action: "edited" }, "PROMPT", schema)).toBeNull();
|
|
expect(await classifyIssue(fakeApi(old), llm, { ...config, action: "" }, "PROMPT", schema)).toMatchObject({ gate: "pass" });
|
|
expect(requests).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("readConfig", () => {
|
|
const env = {
|
|
GITHUB_TOKEN: "t",
|
|
GITHUB_REPOSITORY: "BerriAI/litellm",
|
|
ISSUE_NUMBER: "41700",
|
|
LITELLM_API_BASE: "https://llm.example.com",
|
|
LITELLM_API_KEY: "sk-test",
|
|
ISSUE_CLASSIFIER_MODEL: "gpt-5.6-luna",
|
|
};
|
|
|
|
const now = new Date("2026-09-17T12:10:00Z");
|
|
|
|
test("reads the six settings, and the event action when the workflow passes one", () => {
|
|
expect(readConfig(env, now)).toEqual({
|
|
token: "t",
|
|
repo: "BerriAI/litellm",
|
|
issueNumber: 41700,
|
|
apiBase: "https://llm.example.com",
|
|
apiKey: "sk-test",
|
|
model: "gpt-5.6-luna",
|
|
action: "",
|
|
now,
|
|
});
|
|
expect(readConfig({ ...env, GITHUB_EVENT_ACTION: "edited" }, now)).toMatchObject({ action: "edited" });
|
|
});
|
|
|
|
test("refuses a missing or malformed setting by name", () => {
|
|
expect(() => readConfig({ ...env, GITHUB_TOKEN: undefined }, now)).toThrow("GITHUB_TOKEN");
|
|
expect(() => readConfig({ ...env, GITHUB_REPOSITORY: "nope" }, now)).toThrow("GITHUB_REPOSITORY");
|
|
expect(() => readConfig({ ...env, ISSUE_NUMBER: "0" }, now)).toThrow("ISSUE_NUMBER");
|
|
expect(() => readConfig({ ...env, LITELLM_API_BASE: "" }, now)).toThrow("LITELLM_API_BASE");
|
|
expect(() => readConfig({ ...env, LITELLM_API_BASE: "llm.example.com" }, now)).toThrow("LITELLM_API_BASE");
|
|
expect(() => readConfig({ ...env, LITELLM_API_KEY: "" }, now)).toThrow("LITELLM_API_KEY");
|
|
expect(() => readConfig({ ...env, ISSUE_CLASSIFIER_MODEL: undefined }, now)).toThrow("ISSUE_CLASSIFIER_MODEL");
|
|
});
|
|
});
|