fix(runtime): reload model config at service boundaries

This commit is contained in:
Federico Jaramillo Martinez
2026-07-17 23:48:32 +02:00
parent 3a208e648e
commit 45f068ef05
4 changed files with 141 additions and 27 deletions
@@ -1,5 +1,9 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createAssistantMessageEventStream, InMemoryCredentialStore, type AssistantMessage } from "@earendil-works/pi-ai";
import type { StreamFn } from "@earendil-works/pi-agent-core";
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
import { describe, expect, it, vi } from "vitest";
import { PiSessionService } from "./piSessionService.js";
import { CapturingSessionEventHub, createTestModelRuntime, fakeRuntime, runtimeCreator, seedCredential, sessionGateway, sessionRecord, sessionRef, TEST_MODEL_ID, TEST_MODEL_PROVIDER, testModel, testModelRuntime, type RuntimeCreator } from "./piSessionService.testSupport.js";
@@ -347,12 +351,56 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
await service.dispose();
});
it("reloads models.json before listing and selecting models", async () => {
const agentDir = await mkdtemp(join(tmpdir(), "pi-web-model-runtime-"));
try {
const modelsPath = join(agentDir, "models.json");
await writeLocalModelsConfig(modelsPath, "initial-model");
const modelRuntime = await ModelRuntime.create({
credentials: new InMemoryCredentialStore(),
modelsPath,
allowModelNetwork: false,
});
const setSessionModel = vi.fn(() => Promise.resolve());
const fake = fakeRuntime("models-session", { modelRuntime, setModel: setSessionModel });
const service = new PiSessionService(new CapturingSessionEventHub(), {
agentDir,
modelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("models-session")]),
heartbeatIntervalMs: 60_000,
});
try {
await writeLocalModelsConfig(modelsPath, "listed-model");
const listed = await service.availableModels(sessionRef("models-session"));
expect(listed).toEqual(expect.arrayContaining([
expect.objectContaining({ provider: "test-local", id: "listed-model" }),
]));
expect(listed).not.toEqual(expect.arrayContaining([
expect.objectContaining({ provider: "test-local", id: "initial-model" }),
]));
await writeLocalModelsConfig(modelsPath, "selected-model");
await expect(service.setModel(sessionRef("models-session"), "test-local", "selected-model")).resolves.toBeDefined();
expect(setSessionModel).toHaveBeenCalledWith(expect.objectContaining({
provider: "test-local",
id: "selected-model",
}));
} finally {
await service.dispose();
}
} finally {
await rm(agentDir, { recursive: true, force: true });
}
});
it("refreshes auth state and dedupes warnings when logout removes the current model's credentials", async () => {
const hub = new CapturingSessionEventHub();
// The shared model runtime reads a live credential store; auth changes are
// simulated by mutating the store and refreshing the runtime (the same
// sequence AuthService performs before emitting an AuthChange), then
// notifying the service via applyAuthChange.
// The shared model runtime reads a live credential store. Mutating the store
// and refreshing here simulates the committed snapshot that
// ModelRuntime.login()/logout() establishes before AuthService emits.
// applyAuthChange then only needs to notify active sessions.
const credentials = new InMemoryCredentialStore();
await seedCredential(credentials, "anthropic", { type: "api_key", key: "sk-test" });
const modelRuntime = await createTestModelRuntime(credentials);
@@ -409,3 +457,25 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
await service.dispose();
});
});
async function writeLocalModelsConfig(path: string, modelId: string): Promise<void> {
await writeFile(path, JSON.stringify({
providers: {
"test-local": {
name: "Test Local",
baseUrl: "http://127.0.0.1:1234/v1",
apiKey: "offline-test-key",
api: "openai-completions",
models: [{
id: modelId,
name: modelId,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1_000,
maxTokens: 100,
}],
},
},
}));
}