diff --git a/bun.lock b/bun.lock index f3ca07eb..026ef77f 100644 --- a/bun.lock +++ b/bun.lock @@ -20,10 +20,10 @@ "name": "roamgate-server", "version": "0.8.2", "dependencies": { - "@earendil-works/chord": "1.0.2", - "@earendil-works/pi-ai": "1.0.2", - "@earendil-works/pi-coding-agent": "1.0.2", - "@earendil-works/pi-durable": "1.0.2", + "@earendil-works/chord": "1.1.0", + "@earendil-works/pi-ai": "1.1.0", + "@earendil-works/pi-coding-agent": "1.1.0", + "@earendil-works/pi-durable": "1.1.0", "proper-lockfile": "4.1.2", "typebox": "1.3.34", "undici": "8.11.2", @@ -220,23 +220,23 @@ "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.2", "", {}, "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw=="], - "@earendil-works/chord": ["@earendil-works/chord@1.0.2", "", { "dependencies": { "esbuild": "0.28.2" } }, "sha512-eUXGZjigyEQsFiT/oBJUOaCrzYZRmqf8AgLrlbyuS4u02hjSVhLYRi3xqk1o4UERCgRfjRegqwj/oucawIR7YQ=="], + "@earendil-works/chord": ["@earendil-works/chord@1.1.0", "", { "dependencies": { "esbuild": "0.28.2" } }, "sha512-gsHzKfyQ3t0ZIQ3cLBjJ5Vqy1TYnFyruHIV3g3BwmulCDYe5BW4yT9vaJk9QzN6YANILIO75uzuq+Gdxtzz1og=="], - "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@1.0.2", "", { "dependencies": { "@earendil-works/pi-ai": "^1.0.2", "typebox": "1.3.27" } }, "sha512-VRfewY1R5mbedJzlm01PxdCmqU8QrSXjXXhV50bp6rvVuT8YYQv1inTXRY4YzssmGAYpkC2rXipJKP5BpBFXew=="], + "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@1.1.0", "", { "dependencies": { "@earendil-works/pi-ai": "^1.1.0", "typebox": "1.3.27" } }, "sha512-aX1KZomNCPmwYnXa3OivF3VYLJ+WPUkIJlEIZTgwdOZdY/+oToWTQ334WYpQeOz9POpiYFNMJLPwIhXQ4e48kg=="], - "@earendil-works/pi-ai": ["@earendil-works/pi-ai@1.0.2", "", { "dependencies": { "@anthropic-ai/sdk": "0.129.0", "@aws-sdk/client-bedrock-runtime": "3.1127.0", "@earendil-works/pi-telemetry": "^1.0.2", "@google/genai": "2.21.0", "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", "openai": "7.19.0", "partial-json": "0.1.7", "typebox": "1.3.27" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-JP59xGlSAQ/HhQ6EHN5qmneBlWfDyu6FPPryAvAaSo8A38ubAsEpS9YisCM5Jbbns0srrDffLZonE0OXorHI5A=="], + "@earendil-works/pi-ai": ["@earendil-works/pi-ai@1.1.0", "", { "dependencies": { "@anthropic-ai/sdk": "0.129.0", "@aws-sdk/client-bedrock-runtime": "3.1127.0", "@earendil-works/pi-telemetry": "^1.1.0", "@google/genai": "2.21.0", "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", "openai": "7.19.0", "partial-json": "0.1.7", "typebox": "1.3.27" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-1T7LAkc/5Bvc0v6w4vAGVdCrli0o/E0pEmYKTnixu95vSFArBjvbhS/G4ZwI0RUePgf0Imcu0VyqlM4EcXxqfw=="], - "@earendil-works/pi-codemode": ["@earendil-works/pi-codemode@1.0.2", "", { "dependencies": { "quickjs-wasi": "3.6.2" } }, "sha512-tvfDSVz984ra/4y/hIFmYGpP5ojC+C6/ey+DNbjTzfJvbXviW4JYTXwVcm33KAceI0xcOuE+P1yRGLtQgis0/w=="], + "@earendil-works/pi-codemode": ["@earendil-works/pi-codemode@1.1.0", "", { "dependencies": { "quickjs-wasi": "3.6.2" } }, "sha512-8Asc2AzhoNaXS1snmRFa96OhcWIlFM5k1Kuiz6p2DvIYgi5QHQccRAmDVY9oO92TzWrizpvQpconZGQ7JhlI7Q=="], - "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@1.0.2", "", { "dependencies": { "@earendil-works/chord": "^1.0.2", "@earendil-works/pi-agent-core": "^1.0.2", "@earendil-works/pi-ai": "^1.0.2", "@earendil-works/pi-codemode": "^1.0.2", "@earendil-works/pi-mcp": "^1.0.2", "@earendil-works/pi-tui": "^1.0.2", "@silvia-odwyer/photon-node": "0.3.4", "brace-expansion": "5.0.12", "chalk": "6.0.0", "cross-spawn": "7.0.6", "diff": "8.0.4", "grok-mermaid": "0.2.3", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.8", "jiti": "2.7.0", "minimatch": "10.2.6", "proper-lockfile": "4.1.2", "quickjs-wasi": "3.6.2", "semver": "7.8.5", "typebox": "1.3.27", "undici": "8.10.2", "yaml": "2.9.0" }, "bin": { "pi": "dist/bundle/cli.js" } }, "sha512-3ZdIghMSELMGV3sKi5iASOb1Jwb696fLjmNu0aezaqDxTLLWWoRpqBYkGxJ1CgAMCbtfqXWEF0lcRrlVXmiEGQ=="], + "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@1.1.0", "", { "dependencies": { "@earendil-works/chord": "^1.1.0", "@earendil-works/pi-agent-core": "^1.1.0", "@earendil-works/pi-ai": "^1.1.0", "@earendil-works/pi-codemode": "^1.1.0", "@earendil-works/pi-mcp": "^1.1.0", "@earendil-works/pi-tui": "^1.1.0", "@silvia-odwyer/photon-node": "0.3.4", "brace-expansion": "5.0.12", "chalk": "6.0.0", "cross-spawn": "7.0.6", "diff": "8.0.4", "grok-mermaid": "0.2.3", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.8", "jiti": "2.7.0", "minimatch": "10.2.6", "proper-lockfile": "4.1.2", "quickjs-wasi": "3.6.2", "semver": "7.8.5", "typebox": "1.3.27", "undici": "8.10.2", "yaml": "2.9.0" }, "bin": { "pi": "dist/bundle/cli.js" } }, "sha512-SeEi/4hdcHNgA9UWlefZl7ZZpm3dzi2OoxNjDHsBJ9o298LNOtbL4DGKgitlEj6uCTccvtw6f2hlCkTPVJ2RXg=="], - "@earendil-works/pi-durable": ["@earendil-works/pi-durable@1.0.2", "", { "dependencies": { "@earendil-works/chord": "^1.0.2", "@earendil-works/pi-ai": "^1.0.2", "diff": "8.0.4", "typebox": "1.3.27" } }, "sha512-pDh8eMSSFIVOTh0H53KhtjaS/XFrulEVREN8wnY8vtbYY0srioZ2impajvkXwoZcG6pRgbPz/WVatZLyk5TIbg=="], + "@earendil-works/pi-durable": ["@earendil-works/pi-durable@1.1.0", "", { "dependencies": { "@earendil-works/chord": "^1.1.0", "@earendil-works/pi-ai": "^1.1.0", "diff": "8.0.4", "typebox": "1.3.27" } }, "sha512-NtuB3RY24vQ+0/rWYpeHupyFWBfXuKq17to3VMYUYqfD/uuGnz6F6UTPBEm51VSxxA6LZwRcDwLX2LtRehnIgQ=="], - "@earendil-works/pi-mcp": ["@earendil-works/pi-mcp@1.0.2", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-ED3+q41xLQkxzdadZIQy68SLYcqCJbve8iGbGkZoWjPJ9OZCPHTKcof24fjMO1LfnbIl1uMfhRBueS6IfdiCbQ=="], + "@earendil-works/pi-mcp": ["@earendil-works/pi-mcp@1.1.0", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-xGKwvu3SvVTeoIx8Z7UGoJprB4VK20wKORMhufcWGx61fIauhu3J+UqfB5eeoF1oE/geYBA598K6nh1SdABQkg=="], - "@earendil-works/pi-telemetry": ["@earendil-works/pi-telemetry@1.0.2", "", {}, "sha512-Ev5h9TE8nEXHCfRKhWj0qpUFhQgVqNteS95vsM2KNGRGm533vOO2ruDPaEwImBKPMBKXy4omz3D6V2cm41gLOQ=="], + "@earendil-works/pi-telemetry": ["@earendil-works/pi-telemetry@1.1.0", "", {}, "sha512-8gAK05/2pPozZZz6hCLOi8x2vsqzvsmO9gG92kbvuvTPm0qMHaFlaXX98ndXsrOi5pmzha7vNLguuG4+0Rgxjw=="], - "@earendil-works/pi-tui": ["@earendil-works/pi-tui@1.0.2", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.11" } }, "sha512-ElfzjnckohEjzHK5Q5iOpqLpQ1YbM/c/XmjBdPVR5k4uoorQPnny5BtT+rvC0cAjVVCUOHWAWppust7FAaEsOQ=="], + "@earendil-works/pi-tui": ["@earendil-works/pi-tui@1.1.0", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.11" } }, "sha512-v7wkS0y2ErZvZkSfemqd9RrBZJ5x6p8Ujsv7NdJj01IXJyFM0kNL6rMdcKcvTe7EVTd6ugvDB9VPZSoaBF9QxQ=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 093ad420..c2a271de 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -533,6 +533,14 @@ syntax; entering a key in the custom model editor accepts literal keys only. Pi's extensions, skills, project instructions, and built-in write/command tools are not loaded into Ranger. +Pi SDK 1.1 names its Azure provider `azure`. Ranger also keeps +`azure-openai-responses` as **Azure OpenAI (legacy ID)**, so existing selections, +scheduled tasks, and interrupted runs continue using their original credentials +and model configuration. No file migration is required. When both provider IDs +are configured, their credentials and custom model overrides stay independent; +Ranger does not copy or rename either store entry. The Responses model API value +remains `azure-openai-responses` for both provider IDs. + For remote bridge deployments, follow the provider's displayed device-code or manual authorization flow. A browser's localhost callback points to the browser machine; it does not automatically reach the bridge host. Cancelling a login diff --git a/server/package.json b/server/package.json index 89d14c96..71a1215f 100644 --- a/server/package.json +++ b/server/package.json @@ -29,10 +29,10 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@earendil-works/chord": "1.0.2", - "@earendil-works/pi-ai": "1.0.2", - "@earendil-works/pi-coding-agent": "1.0.2", - "@earendil-works/pi-durable": "1.0.2", + "@earendil-works/chord": "1.1.0", + "@earendil-works/pi-ai": "1.1.0", + "@earendil-works/pi-coding-agent": "1.1.0", + "@earendil-works/pi-durable": "1.1.0", "proper-lockfile": "4.1.2", "typebox": "1.3.34", "undici": "8.11.2", diff --git a/server/src/assistant/pi-azure-compat.test.ts b/server/src/assistant/pi-azure-compat.test.ts new file mode 100644 index 00000000..de4f02e9 --- /dev/null +++ b/server/src/assistant/pi-azure-compat.test.ts @@ -0,0 +1,533 @@ +import type { EntryRecord } from "@earendil-works/pi-durable"; +import { expect, test } from "bun:test"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { + LEGACY_AZURE_PROVIDER, + registerLegacyAzureProvider, +} from "./pi-azure-compat"; +import { type AssistantDriver, createPiDriver } from "./pi-driver"; + +const legacy = LEGACY_AZURE_PROVIDER; +const model = "gpt-4.1"; + +function sse(events: Record[]) { + return events + .map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`) + .join(""); +} + +function reply(tool = false) { + const item = tool + ? { + id: "fc_original", + type: "function_call", + call_id: "call_original", + name: "workspace_status", + arguments: "{}", + } + : { + id: "msg_original", + type: "message", + role: "assistant", + content: [ + { type: "output_text", text: "Azure answer", annotations: [] }, + ], + }; + return new Response( + sse([ + { type: "response.output_item.added", output_index: 0, item }, + { type: "response.output_item.done", output_index: 0, item }, + { + type: "response.completed", + response: { + id: "response_original", + status: "completed", + output: [item], + usage: { input_tokens: 10, output_tokens: 1, total_tokens: 11 }, + }, + }, + ]), + { headers: { "content-type": "text/event-stream" } }, + ); +} + +function records(directory: string, entries: unknown[]) { + const id = (entries[0] as { id: string }).id; + const database = new DatabaseSync( + join(directory, "durable", id, "execution.sqlite"), + { + readOnly: true, + }, + ); + try { + return database + .prepare("SELECT record FROM entries ORDER BY id") + .all() + .map((row) => JSON.parse(String(row.record)) as EntryRecord); + } finally { + database.close(); + } +} + +async function fixture(response: (index: number) => Response = () => reply()) { + const directory = mkdtempSync(join(tmpdir(), "roamgate-azure-")); + const authPath = join(directory, "auth.json"); + const sharedAuthPath = join(directory, "pi-auth.json"); + const modelsPath = join(directory, "models.json"); + const requests: { + url: string; + key: string | null; + label: string | null; + input: unknown[]; + }[] = []; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const body = (await request.json()) as { input: unknown[] }; + requests.push({ + url: request.url, + key: request.headers.get("api-key"), + label: request.headers.get("x-roamgate-test"), + input: body.input, + }); + return response(requests.length); + }, + }); + const auth = { + [legacy]: { type: "api_key", key: "legacy-key" }, + azure: { type: "api_key", key: "current-key" }, + }; + writeFileSync(authPath, JSON.stringify(auth)); + writeFileSync( + sharedAuthPath, + JSON.stringify({ + [legacy]: { type: "api_key", key: "shared-legacy-key" }, + azure: { type: "api_key", key: "shared-current-key" }, + }), + ); + writeFileSync( + modelsPath, + JSON.stringify({ + providers: { + [legacy]: { + baseUrl: new URL("legacy/", server.url).href, + modelOverrides: { + [model]: { + name: "Original Azure model", + headers: { "x-roamgate-test": "legacy" }, + }, + }, + }, + azure: { + baseUrl: new URL("current/", server.url).href, + modelOverrides: { + [model]: { + name: "Current Azure model", + headers: { "x-roamgate-test": "current" }, + }, + }, + }, + }, + }), + ); + const pi = await import("@earendil-works/pi-coding-agent"); + const drivers: AssistantDriver[] = []; + const createDriver = (dataDirectory = directory) => { + const driver = createPiDriver( + dataDirectory, + async () => + ({ + ...pi, + getAgentDir: () => directory, + ModelRuntime: { + create: ( + options: import("@earendil-works/pi-coding-agent").CreateModelRuntimeOptions, + ) => + pi.ModelRuntime.create({ + ...options, + authPath: options.authPath ?? sharedAuthPath, + }), + }, + }) as unknown as typeof pi, + directory, + ); + drivers.push(driver); + return driver; + }; + const input = ( + changes: Partial[0]> = {}, + ): Parameters[0] => ({ + config: { + provider: legacy, + model, + credential_source: "assistant", + allowed_workspaces: [], + }, + text: "Check workspace status", + entries: [], + requestId: "legacy-request", + signal: new AbortController().signal, + read: async () => ({ text: "Verified status" }), + delta: () => {}, + message: () => {}, + tool: () => {}, + error: () => { + throw new Error("Azure request failed"); + }, + ...changes, + }); + return { + directory, + authPath, + sharedAuthPath, + modelsPath, + requests, + createDriver, + input, + pi, + async cleanup() { + for (const driver of drivers) await driver.dispose(); + server.stop(true); + rmSync(directory, { recursive: true, force: true }); + }, + }; +} + +test("legacy Azure registration suppresses only eager refresh and restores its own descriptor", async () => { + const f = await fixture(); + try { + const runtime = await f.pi.ModelRuntime.create({ + authPath: f.authPath, + modelsPath: f.modelsPath, + refreshOnCreate: false, + }); + const refresh = runtime.refresh; + let calls = 0; + Object.defineProperty(runtime, "refresh", { + configurable: true, + enumerable: false, + writable: true, + value: async (options: Parameters[0]) => { + calls++; + return refresh.call(runtime, options); + }, + }); + const before = Object.getOwnPropertyDescriptor(runtime, "refresh"); + await registerLegacyAzureProvider(runtime); + expect(calls).toBe(0); + expect(Object.getOwnPropertyDescriptor(runtime, "refresh")).toEqual(before); + expect(runtime.getError()).toBeUndefined(); + expect(runtime.getModel(legacy, model)?.provider).toBe(legacy); + await runtime.refresh({ allowNetwork: false, providers: [legacy] }); + expect(calls).toBe(1); + expect( + runtime.getAvailableSnapshot().some((entry) => entry.provider === legacy), + ).toBe(true); + expect(f.requests).toHaveLength(0); + } finally { + await f.cleanup(); + } +}); + +test("legacy Azure registration restores inherited refresh when registration fails", async () => { + const f = await fixture(); + try { + const runtime = await f.pi.ModelRuntime.create({ + authPath: f.authPath, + modelsPath: null, + refreshOnCreate: false, + }); + const refresh = runtime.refresh; + runtime.registerNativeProvider = () => { + throw new Error("registration failed"); + }; + await expect(registerLegacyAzureProvider(runtime)).rejects.toThrow( + "registration failed", + ); + expect(Object.hasOwn(runtime, "refresh")).toBe(false); + expect(runtime.refresh).toBe(refresh); + } finally { + await f.cleanup(); + } +}); + +test("legacy Azure login never borrows or replaces the current Azure credential", async () => { + const f = await fixture(); + try { + const current = { azure: { type: "api_key", key: "current-key" } }; + writeFileSync(f.authPath, JSON.stringify(current)); + const shared = readFileSync(f.sharedAuthPath, "utf8"); + const models = readFileSync(f.modelsPath, "utf8"); + const driver = f.createDriver(); + const before = await driver.catalog("assistant"); + expect( + before.providers.find((provider) => provider.id === legacy)?.configured, + ).toBe(false); + expect( + before.providers.find((provider) => provider.id === "azure")?.configured, + ).toBe(true); + expect(before.models.some((entry) => entry.provider === legacy)).toBe( + false, + ); + await driver.login(legacy, "api_key", { + credential_source: "assistant", + signal: new AbortController().signal, + prompt: async () => "replacement-legacy-key", + notify: () => {}, + }); + expect(JSON.parse(readFileSync(f.authPath, "utf8"))).toEqual({ + ...current, + [legacy]: { type: "api_key", key: "replacement-legacy-key" }, + }); + expect(readFileSync(f.sharedAuthPath, "utf8")).toBe(shared); + expect(readFileSync(f.modelsPath, "utf8")).toBe(models); + expect( + (await driver.catalog("assistant")).models.some( + (entry) => entry.provider === legacy, + ), + ).toBe(true); + expect(f.requests).toHaveLength(0); + } finally { + await f.cleanup(); + } +}); + +test("legacy Azure catalog preserves both IDs and custom providers without commands or file migration", async () => { + const f = await fixture(); + const sentinel = join(f.directory, "executed"); + try { + const models = JSON.parse(readFileSync(f.modelsPath, "utf8")); + models.providers.local = { + baseUrl: "http://127.0.0.1:1/v1", + api: "openai-completions", + apiKey: `!touch '${sentinel}'`, + models: [{ id: "custom", reasoning: false }], + }; + models.providers[legacy].apiKey = `!touch '${sentinel}'`; + writeFileSync(f.modelsPath, JSON.stringify(models)); + const auth = JSON.parse(readFileSync(f.authPath, "utf8")); + auth.anthropic = { type: "api_key", key: `!touch '${sentinel}'` }; + writeFileSync(f.authPath, JSON.stringify(auth)); + const originals = [f.authPath, f.sharedAuthPath, f.modelsPath].map((path) => + readFileSync(path, "utf8"), + ); + const driver = f.createDriver(); + for (const source of ["assistant", "pi"] as const) { + const catalog = await driver.catalog(source); + expect( + catalog.providers.find((provider) => provider.id === legacy), + ).toMatchObject({ configured: true, credential_method: "api_key" }); + expect( + catalog.models.find( + (entry) => entry.provider === legacy && entry.id === model, + ), + ).toMatchObject({ label: "Original Azure model" }); + expect( + catalog.models.find( + (entry) => entry.provider === "azure" && entry.id === model, + ), + ).toMatchObject({ label: "Current Azure model" }); + expect( + catalog.models.find( + (entry) => entry.provider === "local" && entry.id === "custom", + )?.custom, + ).toEqual({ + base_url: "http://127.0.0.1:1/v1", + api: "openai-completions", + reasoning: false, + }); + expect( + catalog.providers.find((provider) => provider.id === legacy)?.custom, + ).toBeUndefined(); + expect(JSON.stringify(catalog)).not.toContain("legacy-key"); + expect(JSON.stringify(catalog)).not.toContain("!touch"); + expect(await driver.catalog(source)).toEqual(catalog); + } + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(existsSync(sentinel)).toBe(false); + expect( + [f.authPath, f.sharedAuthPath, f.modelsPath].map((path) => + readFileSync(path, "utf8"), + ), + ).toEqual(originals); + expect(f.requests).toHaveLength(0); + } finally { + await f.cleanup(); + } +}); + +test.each(["assistant", "pi"] as const)( + "legacy Azure %s credentials resume task storage and preserve tool item IDs", + async (source) => { + const f = await fixture((index) => reply(index === 1)); + const taskDirectory = join(f.directory, "task-run"); + let reads = 0; + try { + const originals = [f.authPath, f.sharedAuthPath, f.modelsPath].map( + (path) => readFileSync(path, "utf8"), + ); + const input = f.input({ + config: { + provider: legacy, + model, + credential_source: source, + allowed_workspaces: [], + }, + read: async () => { + reads++; + return { text: "Verified status" }; + }, + }); + const first = f.createDriver(taskDirectory); + const entries = await first.run(input); + await first.dispose(); + const messages = records(taskDirectory, entries).flatMap( + (entry) => entry.model ?? [], + ); + expect( + messages + .filter((message) => message.role === "assistant") + .every((message) => message.provider === legacy), + ).toBe(true); + expect(reads).toBe(1); + expect(f.requests).toHaveLength(2); + expect(f.requests[1]?.input).toContainEqual( + expect.objectContaining({ + type: "function_call", + id: "fc_original", + call_id: "call_original", + }), + ); + expect(f.requests[1]?.input).toContainEqual( + expect.objectContaining({ + type: "function_call_output", + call_id: "call_original", + }), + ); + const second = f.createDriver(taskDirectory); + const restored: string[] = []; + await second.run({ + ...input, + entries, + recover: true, + replace: (text) => restored.push(text), + }); + expect(restored.at(-1)).toBe("Azure answer"); + expect(f.requests).toHaveLength(2); + await second.run({ ...input, entries, requestId: "next-question" }); + expect(f.requests).toHaveLength(3); + expect(reads).toBe(1); + expect(f.requests[2]?.input).toContainEqual( + expect.objectContaining({ + type: "function_call", + id: "fc_original", + call_id: "call_original", + }), + ); + for (const request of f.requests) { + expect(request.url).toContain("/legacy/"); + expect(request.key).toBe( + source === "assistant" ? "legacy-key" : "shared-legacy-key", + ); + expect(request.label).toBe("legacy"); + } + await f + .createDriver(join(f.directory, "new-azure-run")) + .run({ ...input, config: { ...input.config, provider: "azure" } }); + expect(f.requests.at(-1)).toMatchObject({ + key: source === "assistant" ? "current-key" : "shared-current-key", + label: "current", + }); + expect(f.requests.at(-1)?.url).toContain("/current/"); + expect(existsSync(join(taskDirectory, "auth.json"))).toBe(false); + expect( + [f.authPath, f.sharedAuthPath, f.modelsPath].map((path) => + readFileSync(path, "utf8"), + ), + ).toEqual(originals); + } finally { + await f.cleanup(); + } + }, +); + +test("legacy Azure interrupted durable run resumes with its unchanged provider reference", async () => { + const partial = Promise.withResolvers(); + const f = await fixture((index) => + index === 1 + ? new Response( + new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + sse([ + { + type: "response.output_item.added", + output_index: 0, + item: { + id: "msg_partial", + type: "message", + role: "assistant", + content: [], + }, + }, + { + type: "response.output_text.delta", + output_index: 0, + delta: "Partial before pause", + }, + ]), + ), + ); + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ) + : reply(), + ); + let entries: unknown[] = []; + try { + const first = f.createDriver(); + const pending = first.run( + f.input({ + checkpoint: (value) => { + entries = value; + }, + replace: (text) => { + if (text.includes("Partial before pause")) partial.resolve(); + }, + }), + ); + await partial.promise; + await first.dispose(); + expect(await pending).toEqual(entries); + const restored: string[] = []; + await f.createDriver().run( + f.input({ + entries, + recover: true, + replace: (text) => restored.push(text), + }), + ); + expect(restored.at(-1)).toBe("Azure answer"); + expect(f.requests).toHaveLength(2); + expect(JSON.stringify(f.requests[1]?.input)).not.toContain( + "Partial before pause", + ); + expect( + records(f.directory, entries).filter((entry) => entry.kind === "pi.user"), + ).toHaveLength(1); + } finally { + await f.cleanup(); + } +}); diff --git a/server/src/assistant/pi-azure-compat.ts b/server/src/assistant/pi-azure-compat.ts new file mode 100644 index 00000000..dfb2b6da --- /dev/null +++ b/server/src/assistant/pi-azure-compat.ts @@ -0,0 +1,102 @@ +import type { + AssistantMessageEvent, + Model, + Provider, + TranscriptContext, +} from "@earendil-works/pi-ai"; +import type { ModelRuntime } from "@earendil-works/pi-coding-agent"; + +export const LEGACY_AZURE_PROVIDER = "azure-openai-responses"; + +function azureContext(context: TranscriptContext): TranscriptContext { + return { + ...context, + messages: context.messages.map((message) => + message.role === "assistant" && + message.provider === LEGACY_AZURE_PROVIDER && + message.api === "azure-openai-responses" + ? { ...message, provider: "azure" } + : message, + ), + }; +} + +async function* legacyEvents(source: AsyncIterable) { + for await (const event of source) { + if (event.type === "done") + yield { + ...event, + message: { ...event.message, provider: LEGACY_AZURE_PROVIDER }, + }; + else if (event.type === "error") + yield { + ...event, + error: { ...event.error, provider: LEGACY_AZURE_PROVIDER }, + }; + else + yield { + ...event, + partial: { ...event.partial, provider: LEGACY_AZURE_PROVIDER }, + }; + } +} + +export async function registerLegacyAzureProvider(runtime: ModelRuntime) { + const [{ azureProvider }, { lazyStream }] = await Promise.all([ + import("@earendil-works/pi-ai/providers/azure"), + import("@earendil-works/pi-ai/api/lazy"), + ]); + const azure = azureProvider(); + const getModels = () => + azure + .getModels() + .filter( + (model): model is Model<"azure-openai-responses"> => + model.api === "azure-openai-responses", + ) + .map((model) => ({ ...model, provider: LEGACY_AZURE_PROVIDER })); + const legacy: Provider<"azure-openai-responses"> = { + id: LEGACY_AZURE_PROVIDER, + name: "Azure OpenAI (legacy ID)", + auth: azure.auth, + getModels, + getAllModels: getModels, + // The current Azure transport recognizes its new provider ID when replaying + // Responses tool item IDs and reasoning. Adapt only at the transport boundary; + // credentials, model overrides, durable references, and results keep the old ID. + stream: (model, context, options) => + lazyStream(model, async () => + legacyEvents( + azure.stream( + { ...model, provider: "azure" }, + azureContext(context), + options, + ), + ), + ), + streamSimple: (model, context, options) => + lazyStream(model, async () => + legacyEvents( + azure.streamSimple( + { ...model, provider: "azure" }, + azureContext(context), + options, + ), + ), + ), + }; + // Pi 1.1's public registration API always starts an availability refresh, which + // can execute !command keys. This unpublished runtime must stay credential-blind + // until a requested login or model request. Suppress only that synchronous call. + const refresh = Object.getOwnPropertyDescriptor(runtime, "refresh"); + Object.defineProperty(runtime, "refresh", { + configurable: true, + value: async () => ({ aborted: false, errors: new Map() }), + }); + try { + runtime.registerNativeProvider(legacy); + } finally { + if (refresh) Object.defineProperty(runtime, "refresh", refresh); + else Reflect.deleteProperty(runtime, "refresh"); + } +} diff --git a/server/src/assistant/pi-driver.test.ts b/server/src/assistant/pi-driver.test.ts index b1bae20d..98298f69 100644 --- a/server/src/assistant/pi-driver.test.ts +++ b/server/src/assistant/pi-driver.test.ts @@ -31,6 +31,28 @@ import { workspaceTools, } from "./tools"; +// Pi 1.1 context ordering activates hidden Anthropic cache scaffolding, not a Ranger tool. +function expectAnthropicTools( + tools: { + name: string; + description?: string; + input_schema?: unknown; + defer_loading?: boolean; + }[], + names: string[], +) { + expect(tools.at(-1)).toEqual({ + name: "__pi_deferred_placeholder__", + description: "Reserved placeholder. Never available. Never call this.", + input_schema: { type: "object", properties: {}, required: [] }, + defer_loading: true, + }); + expect(tools.map((tool) => tool.name)).toEqual([ + ...names, + "__pi_deferred_placeholder__", + ]); +} + function durableEntries(directory: string, entries: unknown[]) { const pointer = entries[0] as { id: string }; const sqlite = join(directory, "durable", pointer.id, "execution.sqlite"); @@ -504,6 +526,55 @@ function hangingReply(index: number) { ); } +test("Anthropic cache placeholder cannot dispatch Ranger capabilities", async () => { + const f = await durableFixture((index) => + modelReply( + index, + index === 1 + ? [ + ...toolUse("__pi_deferred_placeholder__", "placeholder", {}, 0), + ...finish("tool_use"), + ] + : [...text("Done"), ...finish("end_turn")], + ), + ); + let calls = 0; + const denied = async () => { + calls++; + throw new Error("Placeholder must not dispatch"); + }; + try { + const entries = await f.createDriver().run( + f.input({ + read: denied, + propose: denied, + task: denied, + notify: denied, + }), + ); + expect(calls).toBe(0); + expectAnthropicTools( + f.requests[0]!.tools, + [ + ...workspaceTools, + ...actionTools, + ...taskTools, + ...notificationTools, + ].map((tool) => tool.name), + ); + const results = durableEntries(f.directory, entries) + .flatMap((entry) => entry.model ?? []) + .filter((message) => message.role === "toolResult"); + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ + toolName: "__pi_deferred_placeholder__", + isError: true, + }); + } finally { + await f.cleanup(); + } +}); + test("task tools use shared credentials with isolated durable storage and never replay a recorded proposal", async () => { const proposed = { title: "Daily status", @@ -554,7 +625,7 @@ test("task tools use shared credentials with isolated durable storage and never expect(creates).toBe(1); expect(lists).toBe(1); expect(f.requests).toHaveLength(3); - expect(f.requests[0]!.tools.map((tool) => tool.name)).toEqual([ + expectAnthropicTools(f.requests[0]!.tools, [ ...workspaceTools.map((tool) => tool.name), ...taskTools.map((tool) => tool.name), ]); @@ -667,7 +738,8 @@ test("management tools register only with their handlers, record pending receipt calls.map((call) => call.kind).sort(), ); expect(f.requests).toHaveLength(2); - expect(f.requests[0]!.tools.map((tool) => tool.name)).toEqual( + expectAnthropicTools( + f.requests[0]!.tools, [...workspaceTools, ...actionTools, ...taskTools].map( (tool) => tool.name, ), @@ -702,7 +774,8 @@ test("management tools register only with their handlers, record pending receipt for (const result of results) if (result.role === "toolResult") expect(result.isError).toBe(false); await f.createDriver(join(f.directory, "read-only")).run(f.input()); - expect(f.requests.at(-1)!.tools.map((tool) => tool.name)).toEqual( + expectAnthropicTools( + f.requests.at(-1)!.tools, workspaceTools.map((tool) => tool.name), ); } finally { @@ -986,7 +1059,7 @@ test("confirmed task notification tools preserve custom content, run after reads expect(sends).toBe(1); expect(reads).toBe(1); expect(f.requests).toHaveLength(2); - expect(f.requests[0]?.tools.map((tool) => tool.name)).toEqual([ + expectAnthropicTools(f.requests[0]!.tools, [ ...workspaceTools.map((tool) => tool.name), ...notificationTools.map((tool) => tool.name), ]); diff --git a/server/src/assistant/pi-driver.ts b/server/src/assistant/pi-driver.ts index 86780c40..c5cec9ec 100644 --- a/server/src/assistant/pi-driver.ts +++ b/server/src/assistant/pi-driver.ts @@ -34,6 +34,10 @@ import { workspaceTools, } from "./tools"; import { surfaceAssistantUserError } from "./errors"; +import { + LEGACY_AZURE_PROVIDER, + registerLegacyAzureProvider, +} from "./pi-azure-compat"; type AuthPrompt = { type: "text" | "secret" | "select" | "manual_code"; @@ -276,6 +280,7 @@ export function createPiDriver( allowModelNetwork: false, refreshOnCreate: false, }); + await registerLegacyAzureProvider(models); initialized.add(source); return models; })().catch((error) => { @@ -433,6 +438,7 @@ export function createPiDriver( const builtinIds = new Set( builtinProviders().map((provider) => provider.id), ); + builtinIds.add(LEGACY_AZURE_PROVIDER); // Enumeration must not resolve API keys: Pi supports executable !command keys. const stored = new Map( (await models.listCredentials()) @@ -519,7 +525,10 @@ export function createPiDriver( const { builtinProviders } = await import( "@earendil-works/pi-ai/providers/all" ); - if (builtinProviders().some((provider) => provider.id === input.provider)) + if ( + input.provider === LEGACY_AZURE_PROVIDER || + builtinProviders().some((provider) => provider.id === input.provider) + ) throw new Error("Use a distinct custom provider identifier"); const { path, saved } = modelConfiguration(input.credential_source); const previous = Object.hasOwn(saved.providers, input.provider)