Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/cli/src/extensions/factories.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/extensions/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"));
Expand Down
36 changes: 36 additions & 0 deletions packages/cli/src/extensions/openrouter-provider.ts
Original file line number Diff line number Diff line change
@@ -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.
});
};
158 changes: 158 additions & 0 deletions packages/cli/src/openrouter-models.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
167 changes: 167 additions & 0 deletions packages/cli/src/openrouter-models.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<OpenRouterModel[]> {
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");
}
Loading
Loading