diff --git a/packages/cli/src/extensions/factories.test.ts b/packages/cli/src/extensions/factories.test.ts index d1229477..7ea1bf27 100644 --- a/packages/cli/src/extensions/factories.test.ts +++ b/packages/cli/src/extensions/factories.test.ts @@ -36,6 +36,7 @@ import { sessionProcessesExtension } from "./session-processes.js"; import { backgroundBashExtension } from "./background-bash.js"; import { queueFlushExtension } from "./queue-flush.js"; import { ollamaCloudProviderExtension } from "./ollama-cloud-provider.js"; +import { openrouterProviderExtension } from "./openrouter-provider.js"; import { hostAnnounceExtension } from "./host-announce.js"; import { runPackageCommand } from "../package-commands.js"; @@ -50,6 +51,7 @@ const TEST_AGENT_DIR = mkdtempSync(join(tmpdir(), "pizzapi-factories-agentdir-") const CORE_EXTENSIONS_HEAD: ExtensionFactory[] = [ hostAnnounceExtension, ollamaCloudProviderExtension, + openrouterProviderExtension, providerRequestLogExtension, fallbackModelsExtension, triggersExtension, // Must be before remoteExtension (shutdown ordering) diff --git a/packages/cli/src/extensions/factories.ts b/packages/cli/src/extensions/factories.ts index 1b30e1d8..0153c16a 100644 --- a/packages/cli/src/extensions/factories.ts +++ b/packages/cli/src/extensions/factories.ts @@ -30,6 +30,7 @@ import { pizzapiHeaderExtension } from "./pizzapi-header.js"; import { toolSearchExtension } from "./tool-search.js"; import { ollamaWebToolsExtension } from "./ollama-web-tools.js"; import { ollamaCloudProviderExtension } from "./ollama-cloud-provider.js"; +import { openrouterProviderExtension } from "./openrouter-provider.js"; import { sessionAnalysisExtension } from "./session-analysis.js"; import { fallbackModelsExtension } from "./fallback-models.js"; import { providerRequestLogExtension } from "./provider-request-log.js"; @@ -87,6 +88,10 @@ export function buildPizzaPiExtensionFactories(options: BuildExtensionFactoriesO // see the provider immediately (see ollama-cloud-provider.ts). factories.push(named(ollamaCloudProviderExtension, "ollama-cloud-provider")); + // Live OpenRouter catalog (openrouter.ai/api/v1/models) replacing pi-ai's + // static snapshot — same reason: register before model resolution. + factories.push(named(openrouterProviderExtension, "openrouter-provider")); + // Diagnostic (off unless PIZZAPI_LOG_PROVIDER_REQUEST is set): log the // resolved provider/api and request shape for each outbound turn. factories.push(named(providerRequestLogExtension, "provider-request-log")); diff --git a/packages/cli/src/extensions/openrouter-provider.ts b/packages/cli/src/extensions/openrouter-provider.ts new file mode 100644 index 00000000..75e37162 --- /dev/null +++ b/packages/cli/src/extensions/openrouter-provider.ts @@ -0,0 +1,36 @@ +/** + * Replaces pi-ai's static OpenRouter catalog with OpenRouter's live + * /api/v1/models list (see ../openrouter-models.ts). + * + * Registration is synchronous so the provider is in place before model + * resolution; the network fetch is stale-while-revalidate — the cached list + * (24h TTL) is used immediately and a refreshed catalog re-registers the + * provider so the runtime's model snapshot picks it up mid-session. + */ +import { join } from "node:path"; +import type { ExtensionFactory } from "@earendil-works/pi-coding-agent"; +import { readStoredCredential } from "@earendil-works/pi-coding-agent"; +import { defaultAgentDir, expandHome, loadConfig } from "../config.js"; +import { fetchOpenRouterModels, registerOpenRouterProvider } from "../openrouter-models.js"; + +/** Only spend a request on users who can actually call OpenRouter. */ +function hasOpenRouterCreds(): boolean { + if (process.env.OPENROUTER_API_KEY) return true; + try { + const config = loadConfig(process.cwd()); + const agentDir = config.agentDir ? expandHome(config.agentDir) : defaultAgentDir(); + return readStoredCredential("openrouter", join(agentDir, "auth.json")) !== undefined; + } catch { + return false; + } +} + +export const openrouterProviderExtension: ExtensionFactory = (pi) => { + registerOpenRouterProvider(pi); + if (!hasOpenRouterCreds()) return; + void fetchOpenRouterModels() + .then(() => registerOpenRouterProvider(pi)) + .catch(() => { + // Offline or API down — the cached/static catalog stays in place. + }); +}; diff --git a/packages/cli/src/openrouter-models.test.ts b/packages/cli/src/openrouter-models.test.ts new file mode 100644 index 00000000..edd6bef1 --- /dev/null +++ b/packages/cli/src/openrouter-models.test.ts @@ -0,0 +1,158 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + fetchOpenRouterModels, + getCachedOpenRouterModels, + openrouterDynamicProvider, + registerOpenRouterProvider, + toOpenRouterModel, +} from "./openrouter-models.js"; + +const API_ENTRY = { + id: "anthropic/claude-sonnet-4.5", + name: "Anthropic: Claude Sonnet 4.5", + context_length: 1000000, + architecture: { input_modalities: ["text", "image", "file"] }, + pricing: { prompt: "0.000003", completion: "0.000015", input_cache_read: "0.0000003", input_cache_write: "0.00000375" }, + top_provider: { context_length: 1000000, max_completion_tokens: 64000 }, + supported_parameters: ["tools", "reasoning", "temperature"], +}; + +const homes: string[] = []; +function tempHome(): string { + const home = mkdtempSync(join(tmpdir(), "pizzapi-openrouter-")); + homes.push(home); + return home; +} + +const realFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = realFetch; + for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true }); +}); + +function stubFetch(body: unknown, ok = true): void { + globalThis.fetch = (async () => + ({ ok, status: ok ? 200 : 500, statusText: ok ? "OK" : "Server Error", json: async () => body }) as any) as any; +} + +describe("toOpenRouterModel", () => { + test("maps the live API entry into a pi model", () => { + const model = toOpenRouterModel(API_ENTRY)!; + expect(model.id).toBe("anthropic/claude-sonnet-4.5"); + expect(model.provider).toBe("openrouter"); + expect(model.api).toBe("openai-completions"); + expect(model.baseUrl).toBe("https://openrouter.ai/api/v1"); + expect(model.reasoning).toBe(true); + expect(model.input).toEqual(["text", "image"]); + expect(model.contextWindow).toBe(1000000); + expect(model.maxTokens).toBe(64000); + // per-token strings become per-million-token costs + expect(model.cost).toEqual({ input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }); + expect((model.compat as any).thinkingFormat).toBe("openrouter"); + }); + + test("drops models without tool calling", () => { + expect(toOpenRouterModel({ ...API_ENTRY, supported_parameters: ["temperature"] })).toBeNull(); + expect(toOpenRouterModel({ ...API_ENTRY, id: undefined })).toBeNull(); + }); + + test("falls back when optional fields are missing", () => { + const model = toOpenRouterModel({ id: "vendor/model", supported_parameters: ["tools"] })!; + expect(model.name).toBe("vendor/model"); + expect(model.reasoning).toBe(false); + expect(model.input).toEqual(["text"]); + expect(model.contextWindow).toBe(128000); + expect(model.maxTokens).toBe(32768); + expect(model.cost).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }); + }); +}); + +describe("fetchOpenRouterModels", () => { + test("fetches, filters, and caches", async () => { + const home = tempHome(); + stubFetch({ data: [API_ENTRY, { ...API_ENTRY, id: "no/tools", supported_parameters: [] }] }); + + const models = await fetchOpenRouterModels({ home }); + expect(models.map((m) => m.id)).toEqual(["anthropic/claude-sonnet-4.5"]); + + const cached = JSON.parse(readFileSync(join(home, ".pizzapi", "openrouter-models-cache.json"), "utf-8")); + expect(cached.models).toHaveLength(1); + expect(typeof cached.fetchedAt).toBe("number"); + expect(getCachedOpenRouterModels(home)).toHaveLength(1); + }); + + test("serves a fresh cache without hitting the network", async () => { + const home = tempHome(); + stubFetch({ data: [API_ENTRY] }); + await fetchOpenRouterModels({ home }); + + globalThis.fetch = (async () => { + throw new Error("should not fetch"); + }) as any; + expect(await fetchOpenRouterModels({ home })).toHaveLength(1); + }); + + test("refetches when the cache is stale", async () => { + const home = tempHome(); + mkdirSync(join(home, ".pizzapi"), { recursive: true }); + writeFileSync( + join(home, ".pizzapi", "openrouter-models-cache.json"), + JSON.stringify({ models: [toOpenRouterModel(API_ENTRY)], fetchedAt: Date.now() - 25 * 60 * 60 * 1000 }), + ); + stubFetch({ data: [API_ENTRY, { ...API_ENTRY, id: "vendor/new" }] }); + + expect(await fetchOpenRouterModels({ home })).toHaveLength(2); + }); + + test("throws on API failure and on an empty catalog, leaving the cache untouched", async () => { + const home = tempHome(); + stubFetch({ data: [API_ENTRY] }); + await fetchOpenRouterModels({ home }); + + stubFetch(null, false); + await expect(fetchOpenRouterModels({ home, force: true })).rejects.toThrow(/OpenRouter models API error/); + + stubFetch({ data: [] }); + await expect(fetchOpenRouterModels({ home, force: true })).rejects.toThrow(/no usable models/); + expect(getCachedOpenRouterModels(home)).toHaveLength(1); + }); + + test("ignores a corrupt cache", () => { + const home = tempHome(); + mkdirSync(join(home, ".pizzapi"), { recursive: true }); + writeFileSync(join(home, ".pizzapi", "openrouter-models-cache.json"), "{not json"); + expect(getCachedOpenRouterModels(home)).toBeNull(); + }); +}); + +describe("openrouterDynamicProvider", () => { + test("uses the live catalog when cached and the static one otherwise", async () => { + const home = tempHome(); + const staticModels = openrouterDynamicProvider(home).getModels(); + expect(staticModels.length).toBeGreaterThan(0); + + stubFetch({ data: [API_ENTRY] }); + await fetchOpenRouterModels({ home }); + + const live = openrouterDynamicProvider(home).getModels(); + expect(live.map((m) => m.id)).toEqual(["anthropic/claude-sonnet-4.5"]); + // pi-ai's auth/stream behaviour is preserved + expect(openrouterDynamicProvider(home).auth.apiKey).toBeDefined(); + }); + + test("registers through either runtime or extension API shape", () => { + const home = tempHome(); + const native: unknown[] = []; + registerOpenRouterProvider({ registerNativeProvider: (p) => native.push(p) }, home); + expect((native[0] as any).id).toBe("openrouter"); + + const viaExtension: unknown[] = []; + registerOpenRouterProvider({ registerProvider: (p) => viaExtension.push(p) }, home); + expect((viaExtension[0] as any).id).toBe("openrouter"); + + expect(() => registerOpenRouterProvider({}, home)).toThrow(/no provider registration method/); + }); +}); diff --git a/packages/cli/src/openrouter-models.ts b/packages/cli/src/openrouter-models.ts new file mode 100644 index 00000000..63e593d9 --- /dev/null +++ b/packages/cli/src/openrouter-models.ts @@ -0,0 +1,167 @@ +/** + * OpenRouter live model discovery. + * + * pi-ai ships a static OpenRouter catalog (a build-time snapshot) and pi + * overlays it with pi.dev's catalog — both go stale as OpenRouter adds models. + * This fetches https://openrouter.ai/api/v1/models directly and registers it + * as a native provider that replaces the static catalog, keeping pi-ai's + * OpenRouter auth (env key + OAuth) and streaming untouched. + * + * Results are cached in ~/.pizzapi/openrouter-models-cache.json for 24h so + * startup and model listing stay fast and offline-safe. + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { Model } from "@earendil-works/pi-ai"; +import { openrouterProvider } from "@earendil-works/pi-ai/providers/openrouter"; + +const MODELS_URL = "https://openrouter.ai/api/v1/models"; +const BASE_URL = "https://openrouter.ai/api/v1"; +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; + +/** Matches the compat flags pi-ai generates for every static OpenRouter model. */ +const OPENROUTER_COMPAT = { supportsDeveloperRole: false, thinkingFormat: "openrouter" } as const; + +type OpenRouterModel = Model<"openai-completions">; + +interface CacheEntry { + models: OpenRouterModel[]; + fetchedAt: number; +} + +interface ApiModel { + id?: unknown; + name?: unknown; + context_length?: unknown; + architecture?: { input_modalities?: unknown }; + pricing?: Record; + top_provider?: { context_length?: unknown; max_completion_tokens?: unknown }; + supported_parameters?: unknown; +} + +function cachePath(home = process.env.HOME || homedir()): string { + return join(home, ".pizzapi", "openrouter-models-cache.json"); +} + +function readCache(home?: string): CacheEntry | null { + try { + const raw = JSON.parse(readFileSync(cachePath(home), "utf-8")); + if (Array.isArray(raw?.models) && typeof raw.fetchedAt === "number") { + const models = raw.models.filter( + (m: any) => typeof m?.id === "string" && m?.provider === "openrouter" && typeof m?.contextWindow === "number", + ); + if (models.length > 0) return { models, fetchedAt: raw.fetchedAt }; + } + } catch { + // missing or corrupt cache — fall back to the static catalog + } + return null; +} + +function writeCache(entry: CacheEntry, home?: string): void { + const path = cachePath(home); + mkdirSync(join(path, ".."), { recursive: true, mode: 0o700 }); + writeFileSync(path, JSON.stringify(entry), { mode: 0o600 }); +} + +/** OpenRouter prices per token as a decimal string; pi costs are per million tokens. */ +function perMillion(price: unknown): number { + const value = typeof price === "string" ? Number.parseFloat(price) : typeof price === "number" ? price : NaN; + return Number.isFinite(value) ? value * 1_000_000 : 0; +} + +export function toOpenRouterModel(entry: ApiModel): OpenRouterModel | null { + if (typeof entry?.id !== "string" || !entry.id) return null; + const params = Array.isArray(entry.supported_parameters) ? entry.supported_parameters : []; + // Agent sessions need tool calling; pi-ai's static catalog is filtered the same way. + if (!params.includes("tools")) return null; + + const modalities = Array.isArray(entry.architecture?.input_modalities) ? entry.architecture.input_modalities : []; + const contextWindow = + (typeof entry.top_provider?.context_length === "number" && entry.top_provider.context_length) || + (typeof entry.context_length === "number" && entry.context_length) || + 128000; + const maxTokens = + typeof entry.top_provider?.max_completion_tokens === "number" + ? entry.top_provider.max_completion_tokens + : Math.min(32768, contextWindow); + + return { + id: entry.id, + name: typeof entry.name === "string" && entry.name ? entry.name : entry.id, + api: "openai-completions", + provider: "openrouter", + baseUrl: BASE_URL, + reasoning: params.includes("reasoning") || params.includes("include_reasoning"), + input: modalities.includes("image") ? ["text", "image"] : ["text"], + cost: { + input: perMillion(entry.pricing?.prompt), + output: perMillion(entry.pricing?.completion), + cacheRead: perMillion(entry.pricing?.input_cache_read), + cacheWrite: perMillion(entry.pricing?.input_cache_write), + }, + contextWindow, + maxTokens, + compat: { ...OPENROUTER_COMPAT }, + } as OpenRouterModel; +} + +export function getCachedOpenRouterModels(home?: string): OpenRouterModel[] | null { + return readCache(home)?.models ?? null; +} + +/** + * Fetch the live catalog, honouring the 24h cache unless `force` is set. + * Throws on network/parse failure — callers keep the static catalog. + */ +export async function fetchOpenRouterModels( + { signal, force, home }: { signal?: AbortSignal; force?: boolean; home?: string } = {}, +): Promise { + if (!force) { + const cached = readCache(home); + if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) return cached.models; + } + + const res = await fetch(MODELS_URL, { signal, headers: { accept: "application/json" } }); + if (!res.ok) throw new Error(`OpenRouter models API error: ${res.status} ${res.statusText}`); + const body = (await res.json()) as { data?: unknown }; + if (!Array.isArray(body?.data)) throw new Error("Unexpected response from OpenRouter /v1/models"); + + const models = body.data.map(toOpenRouterModel).filter((m): m is OpenRouterModel => m !== null); + if (models.length === 0) throw new Error("OpenRouter /v1/models returned no usable models"); + writeCache({ models, fetchedAt: Date.now() }, home); + return models; +} + +/** + * pi-ai's OpenRouter provider with the live catalog swapped in when cached. + * Auth, streaming, and everything else stay exactly as pi-ai defines them. + */ +export function openrouterDynamicProvider(home?: string) { + const base = openrouterProvider(); + return { + ...base, + getModels: () => getCachedOpenRouterModels(home) ?? base.getModels(), + }; +} + +/** + * Replace the built-in static OpenRouter catalog on a ModelRuntime. Registering + * natively (rather than as a config overlay) keeps pi-ai's auth/stream intact + * while dropping pi.dev's snapshot overlay in favour of OpenRouter's own API. + */ +export function registerOpenRouterProvider( + target: { + // ModelRuntime exposes registerNativeProvider; the extension API takes a + // native provider through its registerProvider(provider) overload. + registerNativeProvider?: (...args: any[]) => void; + registerProvider?: (...args: any[]) => void; + }, + home?: string, +): void { + const provider = openrouterDynamicProvider(home); + if (typeof target.registerNativeProvider === "function") target.registerNativeProvider(provider); + else if (typeof target.registerProvider === "function") target.registerProvider(provider); + else throw new Error("registerOpenRouterProvider: target exposes no provider registration method"); +} diff --git a/packages/cli/src/runner/daemon.ts b/packages/cli/src/runner/daemon.ts index ff2ee20f..0d91415d 100644 --- a/packages/cli/src/runner/daemon.ts +++ b/packages/cli/src/runner/daemon.ts @@ -17,6 +17,7 @@ import { resolvePizzaPiVar } from "../config/io.js"; import { mergeModelLists, readSessionModelsCache, type SessionModelEntry } from "../session-models-cache.js"; import { getSupportedThinkingLevels } from "@earendil-works/pi-ai"; import { getCachedOllamaCloudModels, registerOllamaCloudProvider, toOllamaCloudRuntimeModel } from "../ollama-cloud-models.js"; +import { registerOpenRouterProvider } from "../openrouter-models.js"; import { TunnelService } from "./services/tunnel-service.js"; import { ProcessService } from "./services/process-service.js"; import { MemoryService } from "./services/memory-service.js"; @@ -628,6 +629,9 @@ export async function listConfiguredModels(cwd = process.cwd()): Promise { expect(icon?.getAttribute("aria-label")).toBe("Ollama Cloud"); }); }); + +describe("ProviderIcon — OpenRouter", () => { + test("renders the OpenRouter mark, not the generic fallback", () => { + const { container: openrouter } = render(); + const { container: unknown } = render(); + + const icon = openrouter.getElementsByTagName("svg").item(0); + expect(icon?.getAttribute("viewBox")).toBe("0 0 24 24"); + expect(icon?.getAttribute("role")).toBe("img"); + expect(icon?.getElementsByTagName("title").item(0)?.textContent).toBe("OpenRouter"); + expect(icon?.outerHTML).not.toBe(unknown.getElementsByTagName("svg").item(0)?.outerHTML); + }); +}); diff --git a/packages/ui/src/components/ProviderIcon.tsx b/packages/ui/src/components/ProviderIcon.tsx index f1de4b77..dd44e30d 100644 --- a/packages/ui/src/components/ProviderIcon.tsx +++ b/packages/ui/src/components/ProviderIcon.tsx @@ -1,3 +1,5 @@ +import type { SVGProps } from "react"; + import { cn } from "@/lib/utils"; import { RiClaudeFill, RiRobot2Fill } from "react-icons/ri"; @@ -9,10 +11,24 @@ export interface ProviderIconProps { title?: string; } +/** + * OpenRouter mark — react-icons' Simple Icons pack doesn't ship one, so the + * path is inlined from simple-icons (CC0 1.0), matching their 24x24 viewBox. + */ +function SiOpenrouter({ title, ...props }: SVGProps & { title?: string }) { + return ( + + {title ? {title} : null} + + + ); +} + function pickIcon(provider: string) { const p = provider.toLowerCase(); // Common providers we support in PizzaPi + if (p.includes("openrouter")) return SiOpenrouter; if (p.includes("anthropic") || p.includes("claude")) return RiClaudeFill; if (p.includes("openai")) return SiOpenai; if (p.includes("ollama")) return SiOllama;