Improve LLM client immutability and CI model defaults. (#9)
Release / generate-dungeon (push) Failing after 2m30s
Release / upload-to-gitea-release (push) Has been skipped

Replace mutable Ollama model export with a const fallback and initializeModel return value, resolving the model from the environment after optional API discovery. Use a for-of loop over attempt indices instead of let in the retry path.

Continue PDF generation when map image generation or upscaling fails, and avoid mutating request headers in place.

Document Open WebUI-style URLs in the README, pin OLLAMA_MODEL in the Gitea release workflow, and adjust integration and unit tests for the new initialization behavior.

Reviewed-on: #9
Co-authored-by: keligrubb <keligrubb324@gmail.com>
Co-committed-by: keligrubb <keligrubb324@gmail.com>
This commit was merged in pull request #9.
This commit is contained in:
2026-04-15 02:45:25 +00:00
committed by Keli Grubb
parent 4428cd4cb8
commit 7ea9f93dc8
7 changed files with 93 additions and 77 deletions
+22 -22
View File
@@ -6,46 +6,46 @@ import path from "path";
const hasOllama = !!process.env.OLLAMA_API_URL;
describe.skipIf(!hasOllama)("Dungeon generation (Ollama)", () => {
let dungeonData;
describe.skipIf(!hasOllama)("Dungeon generation (Ollama)", { timeout: 120000 }, () => {
const fixture = {};
beforeAll(async () => {
dungeonData = await generateDungeon();
}, 120000);
fixture.dungeon = await generateDungeon();
});
it("generates dungeon data", () => {
expect(dungeonData).toBeDefined();
expect(fixture.dungeon).toBeDefined();
});
it("title is 2-4 words, no colons", () => {
expect(dungeonData.title).toBeTruthy();
const words = dungeonData.title.split(/\s+/);
expect(fixture.dungeon.title).toBeTruthy();
const words = fixture.dungeon.title.split(/\s+/);
expect(words.length).toBeGreaterThanOrEqual(2);
expect(words.length).toBeLessThanOrEqual(4);
expect(dungeonData.title).not.toContain(":");
expect(fixture.dungeon.title).not.toContain(":");
});
it("flavor text is ≤60 words", () => {
expect(dungeonData.flavor).toBeTruthy();
const words = dungeonData.flavor.split(/\s+/);
expect(fixture.dungeon.flavor).toBeTruthy();
const words = fixture.dungeon.flavor.split(/\s+/);
expect(words.length).toBeLessThanOrEqual(60);
});
it("hooks have no title prefixes", () => {
expect(dungeonData.hooksRumors).toBeDefined();
dungeonData.hooksRumors.forEach((hook) => {
expect(fixture.dungeon.hooksRumors).toBeDefined();
fixture.dungeon.hooksRumors.forEach((hook) => {
expect(hook).not.toMatch(/^[^:]+:\s/);
});
});
it("has exactly 6 random events", () => {
expect(dungeonData.randomEvents).toBeDefined();
expect(dungeonData.randomEvents.length).toBe(6);
expect(fixture.dungeon.randomEvents).toBeDefined();
expect(fixture.dungeon.randomEvents.length).toBe(6);
});
it("encounter details do not start with encounter name", () => {
expect(dungeonData.encounters).toBeDefined();
dungeonData.encounters.forEach((encounter) => {
expect(fixture.dungeon.encounters).toBeDefined();
fixture.dungeon.encounters.forEach((encounter) => {
if (encounter.details) {
const detailsLower = encounter.details.toLowerCase();
const nameLower = encounter.name.toLowerCase();
@@ -55,8 +55,8 @@ describe.skipIf(!hasOllama)("Dungeon generation (Ollama)", () => {
});
it("treasure descriptions do not start with 'description'", () => {
expect(dungeonData.treasure).toBeDefined();
dungeonData.treasure.forEach((item) => {
expect(fixture.dungeon.treasure).toBeDefined();
fixture.dungeon.treasure.forEach((item) => {
if (typeof item === "object" && item.description) {
expect(item.description.toLowerCase().startsWith("description")).toBe(false);
}
@@ -64,8 +64,8 @@ describe.skipIf(!hasOllama)("Dungeon generation (Ollama)", () => {
});
it("NPC traits do not start with 'description'", () => {
expect(dungeonData.npcs).toBeDefined();
dungeonData.npcs.forEach((npc) => {
expect(fixture.dungeon.npcs).toBeDefined();
fixture.dungeon.npcs.forEach((npc) => {
if (npc.trait) {
expect(npc.trait.toLowerCase().startsWith("description")).toBe(false);
}
@@ -75,11 +75,11 @@ describe.skipIf(!hasOllama)("Dungeon generation (Ollama)", () => {
it("PDF fits on one page", async () => {
const testPdfPath = path.join(process.cwd(), "test-output.pdf");
try {
await generatePDF(dungeonData, testPdfPath);
await generatePDF(fixture.dungeon, testPdfPath);
const pdfBuffer = await fs.readFile(testPdfPath);
const pdfText = pdfBuffer.toString("binary");
const pageCount = (pdfText.match(/\/Type\s*\/Page[^s]/g) || []).length;
const expectedPages = dungeonData.map ? 2 : 1;
const expectedPages = fixture.dungeon.map ? 2 : 1;
expect(pageCount).toBeLessThanOrEqual(expectedPages);
} finally {
try {
+9 -7
View File
@@ -203,16 +203,16 @@ describe("initializeModel (mocked fetch)", () => {
expect(globalThis.fetch).not.toHaveBeenCalled();
});
it("leaves OLLAMA_MODEL unchanged when fetch returns not ok", async () => {
it("does not set env when fetch returns not ok", async () => {
process.env.OLLAMA_MODEL = "";
const before = OLLAMA_MODEL;
vi.mocked(globalThis.fetch).mockResolvedValueOnce({
ok: false,
status: 404,
json: () => Promise.resolve({}),
});
await initializeModel();
expect(OLLAMA_MODEL).toBe(before);
const resolved = await initializeModel();
expect(resolved).toBe(OLLAMA_MODEL);
expect(process.env.OLLAMA_MODEL).toBe("");
});
it("fetches /api/tags when OLLAMA_MODEL not set", async () => {
@@ -222,11 +222,13 @@ describe("initializeModel (mocked fetch)", () => {
ok: true,
json: () => Promise.resolve({ models: [{ name: "test-model" }] }),
});
await initializeModel();
const resolved = await initializeModel();
expect(globalThis.fetch).toHaveBeenCalled();
const [url, opts] = vi.mocked(globalThis.fetch).mock.calls[0];
expect(String(url)).toMatch(/\/api\/tags$/);
expect(opts?.method || "GET").toBe("GET");
expect(resolved).toBe("test-model");
expect(process.env.OLLAMA_MODEL).toBe("test-model");
});
it("fetches /api/v1/models when URL has open-webui path and sets model from data.data id", async () => {
@@ -239,7 +241,7 @@ describe("initializeModel (mocked fetch)", () => {
await initializeModel();
const [url] = vi.mocked(globalThis.fetch).mock.calls[0];
expect(String(url)).toMatch(/\/api\/v1\/models$/);
expect(OLLAMA_MODEL).toBe("webui-model");
expect(process.env.OLLAMA_MODEL).toBe("webui-model");
});
it("sets model from data.data[0].name when id missing", async () => {
@@ -250,7 +252,7 @@ describe("initializeModel (mocked fetch)", () => {
json: () => Promise.resolve({ data: [{ name: "webui-model-name" }] }),
});
await initializeModel();
expect(OLLAMA_MODEL).toBe("webui-model-name");
expect(process.env.OLLAMA_MODEL).toBe("webui-model-name");
});
it("catches fetch failure and warns", async () => {