diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 9ee134278..cae191ec9 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,9 +2,15 @@ ## [Unreleased] +### Added + +- Added `personalizeOpenRouterCatalog()` (`@earendil-works/pi-ai/providers/openrouter`), which narrows an OpenRouter catalog to the chat models a key may use, with that key's context size, max output, and prices from `GET /models/user` ([#10353](https://github.com/earendil-works/pi/issues/10353)) +- Added `RefreshModelsContext.baseUrl`, the registered provider's base URL including configuration overrides applied by wrappers, so dynamic catalogs can follow a base URL set in `models.json` ([#10353](https://github.com/earendil-works/pi/issues/10353)) + ### Fixed - Fixed Mistral streams being aborted mid-generation (e.g. during long thinking) after the request timeout; the timeout now applies only to waiting for response headers ([#10609](https://github.com/earendil-works/pi/issues/10609)) +- Fixed OpenRouter router models such as `openrouter/auto` showing a cost of -$1,000,000 per 1M tokens; OpenRouter's negative placeholder prices are now 0 ## [1.1.0] - 2026-10-07 diff --git a/packages/ai/scripts/openrouter-catalog.ts b/packages/ai/scripts/openrouter-catalog.ts index e188dddb4..0517e98f6 100644 --- a/packages/ai/scripts/openrouter-catalog.ts +++ b/packages/ai/scripts/openrouter-catalog.ts @@ -1,94 +1,26 @@ -import type { ClassifierModel, ImageModel, Model, ModelCost, ModelCostTier } from "../src/types.ts"; +import { type OpenRouterModel, openRouterCost } from "../src/providers/openrouter-api.ts"; +import type { ClassifierModel, ImageModel, Model } from "../src/types.ts"; import { getOpenRouterThinkingLevelMap, type OpenRouterReasoningMetadata } from "./openrouter-reasoning-options.ts"; -export interface OpenRouterModelListItem { - id: string; +export interface OpenRouterModelListItem extends OpenRouterModel { name: string; supported_parameters?: string[]; architecture?: { modality?: string; input_modalities?: string[]; output_modalities?: string[] }; - pricing?: { - prompt?: string; - completion?: string; - input_cache_read?: string; - input_cache_write?: string; - overrides?: OpenRouterPricingOverride[]; - }; - top_provider?: { - context_length?: number; - max_completion_tokens?: number; - }; - context_length?: number; reasoning?: OpenRouterReasoningMetadata; } -/** - * A conditional price. `min_prompt_tokens` selects prompt-length pricing; `utc_*` fields select - * time-of-day or weekday pricing. Missing rates keep the base price. - */ -export interface OpenRouterPricingOverride { - min_prompt_tokens?: number; - utc_start?: number; - utc_end?: number; - utc_days?: string[]; - prompt?: string; - completion?: string; - input_cache_read?: string; - input_cache_write?: string; -} - export interface OpenRouterCatalog { chat: Model<"anthropic-messages" | "openai-completions">[]; images: ImageModel<"openrouter-images">[]; classifiers: ClassifierModel<"typesafe-system-one">[]; } -function roundCost(value: number): number { - return Number(value.toFixed(6)); -} - function modalities(values: string[] | undefined): ("text" | "image")[] { return Array.from( new Set((values ?? []).filter((value): value is "text" | "image" => value === "text" || value === "image")), ); } -// Convert pricing from $/token to $/million tokens -function perMillion(value: string | undefined, fallback: number): number { - return value ? roundCost(parseFloat(value) * 1_000_000) : fallback; -} - -function cost(model: OpenRouterModelListItem): ModelCost { - const pricing = model.pricing; - const base = { - input: perMillion(pricing?.prompt, 0), - output: perMillion(pricing?.completion, 0), - cacheRead: perMillion(pricing?.input_cache_read, 0), - cacheWrite: perMillion(pricing?.input_cache_write, 0), - }; - // Prompt-length overrides become request-wide tiers. Time-of-day overrides are skipped - // because ModelCost cannot express them. - const tiers = (pricing?.overrides ?? []).flatMap((override): ModelCostTier[] => { - if ( - override.min_prompt_tokens === undefined || - override.utc_start !== undefined || - override.utc_end !== undefined || - override.utc_days !== undefined - ) { - return []; - } - return [ - { - inputTokensAbove: override.min_prompt_tokens, - input: perMillion(override.prompt, base.input), - output: perMillion(override.completion, base.output), - cacheRead: perMillion(override.input_cache_read, base.cacheRead), - cacheWrite: perMillion(override.input_cache_write, base.cacheWrite), - }, - ]; - }); - return tiers.length > 0 ? { ...base, tiers } : base; -} - /** * Build the OpenRouter catalog from the default listing and the * `output_modalities=image` and `output_modalities=decisions` listings. The @@ -124,7 +56,7 @@ export function buildOpenRouterCatalog( reasoning: model.supported_parameters?.includes("reasoning") || false, ...(thinkingLevelMap && { thinkingLevelMap }), input, - cost: cost(model), + cost: openRouterCost(model.pricing), contextWindow: model.top_provider?.context_length || model.context_length || 4096, maxTokens: model.top_provider?.max_completion_tokens || 4096, }); @@ -145,7 +77,7 @@ export function buildOpenRouterCatalog( baseUrl: "https://openrouter.ai/api/v1", input: input.length > 0 ? input : ["text"], output, - cost: cost(model), + cost: openRouterCost(model.pricing), }); } @@ -164,7 +96,7 @@ export function buildOpenRouterCatalog( provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", input: input.length > 0 ? input : ["text"], - cost: cost(model), + cost: openRouterCost(model.pricing), contextWindow: model.top_provider?.context_length || model.context_length || 4096, }); } diff --git a/packages/ai/src/models.ts b/packages/ai/src/models.ts index fbcc52231..7c9be11bb 100644 --- a/packages/ai/src/models.ts +++ b/packages/ai/src/models.ts @@ -93,6 +93,11 @@ export interface RefreshModelsContext { force?: boolean; /** Always present, including when the public refresh caller omits its optional signal. */ signal: AbortSignal; + /** + * Base URL of the registered provider, including configuration overrides that wrappers apply + * outside the provider receiving this context. + */ + baseUrl?: string; } export interface ModelsRefreshOptions { @@ -546,6 +551,7 @@ class ModelsImpl implements MutableModels { allowNetwork, force: allowNetwork ? force : undefined, signal, + baseUrl: provider.baseUrl, }); } diff --git a/packages/ai/src/providers/openrouter-api.ts b/packages/ai/src/providers/openrouter-api.ts new file mode 100644 index 000000000..33f16ed8c --- /dev/null +++ b/packages/ai/src/providers/openrouter-api.ts @@ -0,0 +1,77 @@ +import type { ModelCost, ModelCostTier } from "../types.ts"; + +/** OpenRouter's per-token prices, as decimal strings. */ +export interface OpenRouterPricing { + prompt?: string; + completion?: string; + input_cache_read?: string; + input_cache_write?: string; + overrides?: OpenRouterPricingOverride[]; +} + +/** + * A conditional price. `min_prompt_tokens` selects prompt-length pricing; `utc_*` fields select + * time-of-day or weekday pricing. Missing rates keep the base price. + */ +export interface OpenRouterPricingOverride { + min_prompt_tokens?: number; + utc_start?: number; + utc_end?: number; + utc_days?: string[]; + prompt?: string; + completion?: string; + input_cache_read?: string; + input_cache_write?: string; +} + +/** The fields pi reads from a model in OpenRouter's `GET /models` and `GET /models/user`. */ +export interface OpenRouterModel { + id: string; + context_length?: number | null; + top_provider?: { context_length?: number | null; max_completion_tokens?: number | null }; + pricing?: OpenRouterPricing; +} + +/** + * Convert one $/token price to $/1M tokens. 0 means free. Missing, invalid and negative prices use + * the fallback: OpenRouter reports -1 for router models such as `openrouter/auto`, whose price + * depends on the model they pick. + */ +function perMillion(value: string | undefined, fallback: number): number { + const perToken = Number.parseFloat(value ?? ""); + return perToken >= 0 ? Number((perToken * 1_000_000).toFixed(6)) : fallback; +} + +/** + * Convert OpenRouter's $/token prices to pi's $/1M tokens. Missing, invalid and negative base + * prices are 0. Prompt-length overrides become request-wide tiers; a rate a tier does not list + * keeps the base price. Time-of-day overrides are skipped because ModelCost cannot express them. + */ +export function openRouterCost(pricing: OpenRouterPricing | undefined): ModelCost { + const base = { + input: perMillion(pricing?.prompt, 0), + output: perMillion(pricing?.completion, 0), + cacheRead: perMillion(pricing?.input_cache_read, 0), + cacheWrite: perMillion(pricing?.input_cache_write, 0), + }; + const tiers = (pricing?.overrides ?? []).flatMap((override): ModelCostTier[] => { + if ( + override.min_prompt_tokens === undefined || + override.utc_start !== undefined || + override.utc_end !== undefined || + override.utc_days !== undefined + ) { + return []; + } + return [ + { + inputTokensAbove: override.min_prompt_tokens, + input: perMillion(override.prompt, base.input), + output: perMillion(override.completion, base.output), + cacheRead: perMillion(override.input_cache_read, base.cacheRead), + cacheWrite: perMillion(override.input_cache_write, base.cacheWrite), + }, + ]; + }); + return tiers.length > 0 ? { ...base, tiers } : base; +} diff --git a/packages/ai/src/providers/openrouter.ts b/packages/ai/src/providers/openrouter.ts index 86efb8d9d..4cc040f05 100644 --- a/packages/ai/src/providers/openrouter.ts +++ b/packages/ai/src/providers/openrouter.ts @@ -4,14 +4,66 @@ import { openrouterImagesApi } from "../api/openrouter-images.lazy.ts"; import { typesafeSystemOneApi } from "../api/typesafe-system-one.lazy.ts"; import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; import { loadOpenRouterOAuth } from "../auth/oauth/load.ts"; -import { createProvider, type Provider } from "../models.ts"; +import { createProvider, type Provider, type RefreshModelsContext } from "../models.ts"; +import type { AnyModel } from "../types.ts"; +import { isModelType } from "../utils/model-operations.ts"; import { OPENROUTER_CLASSIFIER_MODELS, OPENROUTER_IMAGE_MODELS, OPENROUTER_MODELS } from "./openrouter.models.ts"; +import { type OpenRouterModel, openRouterCost } from "./openrouter-api.ts"; + +const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; +const OPENROUTER_USER_MODELS_TIMEOUT_MS = 15_000; + +/** + * Restrict an OpenRouter catalog to the chat models the key may use, with the key's limits and + * prices from `GET /models/user`. Image and classifier models are returned unchanged. A missing + * limit keeps the catalog value. The request uses the provider's configured base URL, so regional + * endpoints such as `https://us.openrouter.ai/api/v1` apply their region's filtering. When the + * endpoint rejects the key (401/403) or does not exist (404), for example with a proxy configured + * in models.json, the catalog is returned unchanged. + */ +export async function personalizeOpenRouterCatalog( + catalog: readonly AnyModel[], + context: RefreshModelsContext, +): Promise { + const key = context.credential?.type === "oauth" ? context.credential.access : context.credential?.key; + if (!key) return catalog; + const response = await fetch(`${(context.baseUrl ?? OPENROUTER_BASE_URL).replace(/\/+$/, "")}/models/user`, { + headers: { accept: "application/json", authorization: `Bearer ${key}` }, + signal: AbortSignal.any([context.signal, AbortSignal.timeout(OPENROUTER_USER_MODELS_TIMEOUT_MS)]), + }); + if (response.status === 401 || response.status === 403 || response.status === 404) return catalog; + if (!response.ok) throw new Error(`OpenRouter model list request failed: ${response.status}`); + const body: unknown = await response.json(); + if (typeof body !== "object" || body === null || !("data" in body) || !Array.isArray(body.data)) { + throw new Error("Invalid OpenRouter model list response"); + } + const allowed = new Map(); + for (const entry of body.data as unknown[]) { + if (typeof entry === "object" && entry !== null && "id" in entry && typeof entry.id === "string") { + allowed.set(entry.id, entry as OpenRouterModel); + } + } + return catalog.flatMap((model): AnyModel[] => { + if (!isModelType(model, "chat")) return [model]; + const entry = allowed.get(model.id); + if (!entry) return []; + return [ + { + ...model, + // `||`: OpenRouter reports a missing limit as absent, null or 0, like the generator's fallback. + contextWindow: entry.top_provider?.context_length || entry.context_length || model.contextWindow, + maxTokens: entry.top_provider?.max_completion_tokens || model.maxTokens, + cost: entry.pricing ? openRouterCost(entry.pricing) : model.cost, + }, + ]; + }); +} export function openrouterProvider(): Provider<"anthropic-messages" | "openai-completions"> { return createProvider<"anthropic-messages" | "openai-completions">({ id: "openrouter", name: "OpenRouter", - baseUrl: "https://openrouter.ai/api/v1", + baseUrl: OPENROUTER_BASE_URL, auth: { apiKey: envApiKeyAuth("OpenRouter API key", ["OPENROUTER_API_KEY"]), oauth: lazyOAuth({ diff --git a/packages/ai/test/models-runtime.test.ts b/packages/ai/test/models-runtime.test.ts index 0ad13fca2..0789f940c 100644 --- a/packages/ai/test/models-runtime.test.ts +++ b/packages/ai/test/models-runtime.test.ts @@ -439,6 +439,26 @@ describe("Models runtime", () => { expect(unconfiguredRefreshes).toBe(0); }); + it("passes the registered provider's base URL, including wrapper overrides, to refreshModels", async () => { + // A models.json baseUrl (e.g. a regional OpenRouter host) is applied by a wrapper around the + // built-in provider. The built-in refresh must still request that URL. + const baseUrls: (string | undefined)[] = []; + const inner: Provider = { + ...testProvider({ + id: "dynamic", + refreshModels: async (context) => { + baseUrls.push(context.baseUrl); + }, + }), + baseUrl: "https://default.example.test/v1", + }; + const models = createModels(); + models.setProvider({ ...inner, baseUrl: "https://regional.example.test/v1" }); + + await models.refresh(); + expect(baseUrls).toEqual(["https://regional.example.test/v1", "https://regional.example.test/v1"]); + }); + it("refreshes expired OAuth before refreshing models", async () => { const credentials = new InMemoryCredentialStore(); let modelRefreshCredential: unknown; diff --git a/packages/ai/test/openrouter-user-models.test.ts b/packages/ai/test/openrouter-user-models.test.ts new file mode 100644 index 000000000..59537b5be --- /dev/null +++ b/packages/ai/test/openrouter-user-models.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { RefreshModelsContext } from "../src/models.ts"; +import { personalizeOpenRouterCatalog } from "../src/providers/openrouter.ts"; +import type { OpenRouterModel } from "../src/providers/openrouter-api.ts"; +import type { AnyModel, ClassifierModel, ImageModel, Model } from "../src/types.ts"; + +const zeroCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + +function chatModel(id: string): Model<"openai-completions"> { + return { + id, + name: id, + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 50_000, + }; +} + +const imageModel: ImageModel<"openrouter-images"> = { + type: "image", + id: "image", + name: "image", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text"], + output: ["image"], + cost: zeroCost, +}; + +const classifierModel: ClassifierModel<"typesafe-system-one"> = { + type: "classifier", + id: "classifier", + name: "classifier", + api: "typesafe-system-one", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text"], + cost: zeroCost, + contextWindow: 64_000, +}; + +function refreshContext(): RefreshModelsContext { + return { + credential: { type: "api_key", key: "sk-or-test" }, + publish: async () => true, + allowNetwork: true, + signal: new AbortController().signal, + }; +} + +function stubUserModels(response: Response): void { + vi.spyOn(globalThis, "fetch").mockResolvedValue(response); +} + +afterEach(() => vi.restoreAllMocks()); + +describe("personalizeOpenRouterCatalog", () => { + it("keeps only the key's chat models, with the key's limits and prices", async () => { + const data: OpenRouterModel[] = [ + { + id: "listed", + context_length: 1_000_000, + top_provider: { context_length: 262_144, max_completion_tokens: 32_768 }, + pricing: { prompt: "0.000003", completion: "0.000015" }, + }, + // A missing limit keeps the catalog value, so generator fixes for missing OpenRouter data survive. + { id: "no-limits", context_length: 0, top_provider: { context_length: null, max_completion_tokens: null } }, + // Router models report -1, meaning the price depends on the model they pick. + { id: "router", pricing: { prompt: "-1", completion: "-1" } }, + ]; + stubUserModels(Response.json({ data })); + const catalog: AnyModel[] = [ + chatModel("listed"), + chatModel("no-limits"), + chatModel("router"), + chatModel("blocked"), + imageModel, + classifierModel, + ]; + + const result = await personalizeOpenRouterCatalog(catalog, refreshContext()); + + expect(result.map((model) => model.id)).toEqual(["listed", "no-limits", "router", "image", "classifier"]); + expect(result[0]).toMatchObject({ + contextWindow: 262_144, + maxTokens: 32_768, + cost: { input: 3, output: 15, cacheRead: 0, cacheWrite: 0 }, + }); + expect(result[1]).toMatchObject({ contextWindow: 200_000, maxTokens: 50_000, cost: chatModel("x").cost }); + expect(result[2]).toMatchObject({ cost: zeroCost }); + // /models/user lists text models only, so image and classifier models are not filtered. + expect(result[3]).toBe(imageModel); + expect(result[4]).toBe(classifierModel); + }); + + it.each([401, 403, 404])( + "returns the catalog unchanged when the endpoint rejects the key or is missing (%i)", + async (status) => { + // For example a proxy configured in models.json that rejects the key or has no /models/user: + // the user keeps the full list instead of a failing refresh. + stubUserModels(new Response("", { status })); + const catalog = [chatModel("a")]; + + expect(await personalizeOpenRouterCatalog(catalog, refreshContext())).toBe(catalog); + }, + ); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1baee1786..34c9da06a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,7 @@ - Added `reachesTools` to tool definitions, so tools that reach other tools (such as `codemode` and `tool_search`) declare which tools a call may reach; the MCP extension uses it to wait for the servers a call needs and to decide whether a replacement `codemode` or `tool_search` tool can reach MCP tools - Added `aliases` to tool namespaces; codemode's `describeNamespace()` and the `namespace` option of `searchTools()` match them, and MCP namespaces use the server name +- Added per-key OpenRouter model lists: with an OpenRouter key, Pi lists only the chat models that key may use, with the key's context size, max output, and prices, including guardrails and regional endpoints set as the base URL in `models.json`. The list is saved and used offline ([#10353](https://github.com/earendil-works/pi/issues/10353)) ### Changed diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index ca0ed1117..0da96ddf6 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -113,6 +113,26 @@ Radius is currently in early alpha and evolving quickly. See [radius.earendil.co Radius authentication uses its gateway catalog and caches refreshed model metadata for later offline startup. A custom Radius gateway configured in `models.json` uses its own catalog rather than inheriting the public `radius.pi.dev` catalog. +### OpenRouter + +Authenticate with `/login openrouter` or `OPENROUTER_API_KEY`. + +With a key, Pi lists only the OpenRouter chat models that key may use, with the key's context size, max output, and prices. These come from OpenRouter's `/models/user` endpoint and reflect guardrails and provider restrictions set in OpenRouter. Image and classifier models are not filtered. + +Pi refreshes the list in the background at startup in interactive mode, when `/model` opens, after `/login`, and on `pi update --models`. The last list is saved and used offline and in `pi -p` and RPC mode. After switching keys outside `/login`, the previous key's list stays until the next refresh. + +Regional endpoints filter models to their region. To use one, set the base URL in [`models.json`](models.md#configure-a-compatible-endpoint): + +```json +{ + "providers": { + "openrouter": { "baseUrl": "https://us.openrouter.ai/api/v1" } + } +} +``` + +If the base URL rejects the key at `/models/user` or does not have that endpoint, for example a proxy configured as the OpenRouter base URL, Pi lists every OpenRouter model. + ### Azure OpenAI The provider ID is `azure` (formerly `azure-openai-responses`). Use it as the key in `auth.json`, `models.json`, and `settings.json`, and in model references such as `--model azure/gpt-5.4`. diff --git a/packages/coding-agent/src/core/model-runtime.ts b/packages/coding-agent/src/core/model-runtime.ts index 81acdbb74..7c1dc820c 100644 --- a/packages/coding-agent/src/core/model-runtime.ts +++ b/packages/coding-agent/src/core/model-runtime.ts @@ -57,6 +57,7 @@ import { type StreamOptions, } from "@earendil-works/pi-ai"; import * as builtinProviderCatalog from "@earendil-works/pi-ai/providers/all"; +import { personalizeOpenRouterCatalog } from "@earendil-works/pi-ai/providers/openrouter"; import { assertChatModel, assertClassifierInputSupported, @@ -80,7 +81,7 @@ import { resolveConfiguredModelHeaders, validateExtensionProvider, } from "./provider-composer.ts"; -import { withRemoteCatalog } from "./remote-catalog-provider.ts"; +import { withPersonalizedCatalog, withRemoteCatalog } from "./remote-catalog-provider.ts"; import { RuntimeCredentials } from "./runtime-credentials.ts"; import { createVirtualModel, @@ -224,13 +225,19 @@ export class ModelRuntime implements Models { ? new FileModelsStore(options.modelsStorePath ?? join(dirname(modelsPath), "models-store.json")) : new InMemoryCodingAgentModelsStore()); const builtinModelDataGeneratedAt = builtinProviderCatalog.getBuiltinModelDataGeneratedAt(); - const providers = builtinProviderCatalog - .builtinProviders() - .map((provider) => - provider.id === "radius" - ? provider - : withRemoteCatalog(provider, options.catalogBaseUrl, builtinModelDataGeneratedAt), - ); + const providers = builtinProviderCatalog.builtinProviders().map((provider) => { + if (provider.id === "radius") return provider; + // OpenRouter narrows the catalog to the models, limits and prices of the user's key. + if (provider.id === "openrouter") { + return withPersonalizedCatalog( + provider, + personalizeOpenRouterCatalog, + options.catalogBaseUrl, + builtinModelDataGeneratedAt, + ); + } + return withRemoteCatalog(provider, options.catalogBaseUrl, builtinModelDataGeneratedAt); + }); const runtime = new ModelRuntime( credentials, config, diff --git a/packages/coding-agent/src/core/remote-catalog-provider.ts b/packages/coding-agent/src/core/remote-catalog-provider.ts index 8717b9643..8f945f7d8 100644 --- a/packages/coding-agent/src/core/remote-catalog-provider.ts +++ b/packages/coding-agent/src/core/remote-catalog-provider.ts @@ -5,6 +5,7 @@ import { type ModelsStoreEntry, type ModelType, type Provider, + type RefreshModelsContext, } from "@earendil-works/pi-ai"; import { VERSION } from "../config.ts"; import { fetchWithRetry } from "../utils/management-http.ts"; @@ -49,14 +50,56 @@ function parseCatalog(providerId: string, value: unknown): AnyModel[] { .map((model) => ({ ...model, provider: providerId }) as AnyModel); } -function remoteModels(entry: ModelsStoreEntry | undefined, localGeneratedAt: number | undefined): readonly AnyModel[] { - if (!entry) return []; - if (localGeneratedAt !== undefined && (entry.lastModified === undefined || entry.lastModified <= localGeneratedAt)) { - return []; - } - return entry.models; +/** Read a successful pi.dev catalog response. A missing or invalid Last-Modified header is 0. */ +async function readCatalog( + providerId: string, + response: Response, +): Promise<{ models: AnyModel[]; lastModified: number; etag: string | undefined }> { + const lastModified = Date.parse(response.headers.get("last-modified") ?? ""); + return { + models: parseCatalog(providerId, await response.json()), + lastModified: Number.isNaN(lastModified) ? 0 : lastModified, + etag: response.headers.get("etag") ?? undefined, + }; } +/** Whether a pi.dev catalog is newer than the model data shipped with pi. */ +function isNewer(lastModified: number | undefined, localGeneratedAt: number | undefined): boolean { + return localGeneratedAt === undefined || (lastModified !== undefined && lastModified > localGeneratedAt); +} + +function remoteModels(entry: ModelsStoreEntry | undefined, localGeneratedAt: number | undefined): readonly AnyModel[] { + return entry && isNewer(entry.lastModified, localGeneratedAt) ? entry.models : []; +} + +function requestCatalog( + providerId: string, + catalogBaseUrl: string, + signal: AbortSignal, + validator?: string, +): Promise { + const url = new URL(`/api/models/providers/${encodeURIComponent(providerId)}`, catalogBaseUrl); + url.searchParams.set("types", REMOTE_CATALOG_MODEL_TYPES.join(",")); + return fetchWithRetry( + url, + { + headers: { + accept: "application/json", + "User-Agent": getPiUserAgent(VERSION), + ...(validator ? { "if-none-match": validator } : {}), + }, + signal, + }, + { attemptTimeoutMs: REMOTE_CATALOG_ATTEMPT_TIMEOUT_MS }, + ); +} + +/** Replaces the catalog with a credential-specific model list. */ +export type PersonalizeCatalog = ( + catalog: readonly AnyModel[], + context: RefreshModelsContext, +) => Promise; + /** Add a persisted pi.dev catalog overlay to a static built-in provider. */ export function withRemoteCatalog( provider: Provider, @@ -86,6 +129,9 @@ export function withRemoteCatalog( return; } if (!context.allowNetwork || context.signal.aborted) return; + // Skip the request if pi.dev was checked less than REMOTE_CATALOG_REFRESH_INTERVAL_MS ago. + // An entry without lastModified (saved by an older pi, or after a failed first request) is + // always refetched: remoteModels() ignores it, so waiting would leave the overlay empty. if ( !context.force && stored?.checkedAt !== undefined && @@ -98,20 +144,7 @@ export function withRemoteCatalog( // Only revalidate when a cached body backs the validator, so a 304 can never // leave the overlay empty. const validator = stored && stored.models.length > 0 ? stored.etag : undefined; - const url = new URL(`/api/models/providers/${encodeURIComponent(provider.id)}`, catalogBaseUrl); - url.searchParams.set("types", REMOTE_CATALOG_MODEL_TYPES.join(",")); - const response = await fetchWithRetry( - url, - { - headers: { - accept: "application/json", - "User-Agent": getPiUserAgent(VERSION), - ...(validator ? { "if-none-match": validator } : {}), - }, - signal: context.signal, - }, - { attemptTimeoutMs: REMOTE_CATALOG_ATTEMPT_TIMEOUT_MS }, - ); + const response = await requestCatalog(provider.id, catalogBaseUrl, context.signal, validator); if (context.signal.aborted) return; const checkedAt = Date.now(); // Unchanged: dynamicModels already holds the stored overlay, so only the @@ -137,15 +170,9 @@ export function withRemoteCatalog( await context.publish({ persist: { ...(stored ?? { models: [] }), checkedAt } }); throw new Error(`Model catalog request failed for ${provider.id}: ${response.status}`); } - const refreshed = parseCatalog(provider.id, await response.json()); - const lastModified = Date.parse(response.headers.get("last-modified") ?? ""); + const catalog = await readCatalog(provider.id, response); if (context.signal.aborted) return; - const entry: ModelsStoreEntry = { - models: refreshed, - checkedAt, - lastModified: Number.isNaN(lastModified) ? 0 : lastModified, - etag: response.headers.get("etag") ?? undefined, - }; + const entry: ModelsStoreEntry = { ...catalog, checkedAt }; const published = remoteModels(entry, localGeneratedAt); await context.publish({ persist: entry, @@ -156,3 +183,58 @@ export function withRemoteCatalog( }, }; } + +/** + * Like `withRemoteCatalog`, but on each network refresh the catalog (built-in models plus a newer + * pi.dev catalog) is passed through `personalize` and the result is saved. The saved entry holds + * the personalized list, not the pi.dev catalog, and replaces the catalog, including offline. + * The refresh interval and etag revalidation are skipped. An empty list (nothing saved yet, or an + * empty catalog saved by an older pi after a failed or missing pi.dev catalog) falls back to the + * built-in models. + */ +export function withPersonalizedCatalog( + provider: Provider, + personalize: PersonalizeCatalog, + catalogBaseUrl: string = DEFAULT_CATALOG_BASE_URL, + localGeneratedAt?: number, +): Provider { + let personalModels: readonly AnyModel[] = []; + const builtinModels = (): readonly AnyModel[] => provider.getAllModels?.() ?? provider.getModels(); + const getAllModels = (): readonly AnyModel[] => (personalModels.length > 0 ? personalModels : builtinModels()); + + return { + ...provider, + getModels: () => getAllModels().filter((model) => isModelType(model, "chat")), + getAllModels, + refreshModels: async (context) => { + const restored = (context.stored?.models ?? []).filter((model) => model.provider === provider.id); + if ( + !(await context.publish({ + update: () => { + personalModels = restored; + }, + })) + ) { + return; + } + if (!context.allowNetwork || context.signal.aborted) return; + + const response = await requestCatalog(provider.id, catalogBaseUrl, context.signal); + if (context.signal.aborted) return; + let remote: readonly AnyModel[] = []; + if (response.status !== 404 && response.status !== 501) { + if (!response.ok) throw new Error(`Model catalog request failed for ${provider.id}: ${response.status}`); + const catalog = await readCatalog(provider.id, response); + if (isNewer(catalog.lastModified, localGeneratedAt)) remote = catalog.models; + } + const personalized = await personalize(mergeModels(builtinModels(), remote), context); + if (context.signal.aborted) return; + await context.publish({ + persist: { models: personalized, checkedAt: Date.now() }, + update: () => { + personalModels = personalized; + }, + }); + }, + }; +} diff --git a/packages/coding-agent/test/remote-catalog-provider.test.ts b/packages/coding-agent/test/remote-catalog-provider.test.ts index b79bc79d3..03b7ebfa5 100644 --- a/packages/coding-agent/test/remote-catalog-provider.test.ts +++ b/packages/coding-agent/test/remote-catalog-provider.test.ts @@ -1,6 +1,7 @@ import { createModels, createProvider, + type ImageModel, InMemoryModelsStore, type Model, type ModelsPublication, @@ -9,7 +10,12 @@ import { } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; import { VERSION } from "../src/config.ts"; -import { REMOTE_CATALOG_MODEL_TYPES, withRemoteCatalog } from "../src/core/remote-catalog-provider.ts"; +import { + type PersonalizeCatalog, + REMOTE_CATALOG_MODEL_TYPES, + withPersonalizedCatalog, + withRemoteCatalog, +} from "../src/core/remote-catalog-provider.ts"; const neverAbortedSignal = new AbortController().signal; @@ -28,24 +34,24 @@ function model(id: string): Model<"openai-completions"> { }; } -function testProvider(localGeneratedAt?: number) { - return withRemoteCatalog( - createProvider({ - id: "test-provider", - auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } }, - models: [model("static")], - api: { - stream: () => { - throw new Error("not used"); - }, - streamSimple: () => { - throw new Error("not used"); - }, +function builtinProvider(): Provider { + return createProvider({ + id: "test-provider", + auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } }, + models: [model("static")], + api: { + stream: () => { + throw new Error("not used"); }, - }), - "https://pi.dev", - localGeneratedAt, - ); + streamSimple: () => { + throw new Error("not used"); + }, + }, + }); +} + +function testProvider(localGeneratedAt?: number) { + return withRemoteCatalog(builtinProvider(), "https://pi.dev", localGeneratedAt); } async function refreshProvider( @@ -289,3 +295,53 @@ describe("remote catalog provider", () => { expect(await store.read(provider.id)).toMatchObject({ models: [], checkedAt: expect.any(Number) }); }); }); + +describe("personalized catalog provider", () => { + const imageModel: ImageModel<"openrouter-images"> = { + type: "image", + id: "image", + name: "image", + api: "openrouter-images", + provider: "test-provider", + baseUrl: "https://example.test/v1", + input: ["text"], + output: ["image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }; + + it("keeps the saved list and saves nothing when personalization fails", async () => { + // Saving the catalog without personalization would replace the key's list with every model. + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("not found", { status: 404 })); + const store = new InMemoryModelsStore(); + const saved = { models: [model("allowed")], checkedAt: 1 }; + await store.write("test-provider", saved); + const personalize: PersonalizeCatalog = async () => { + throw new Error("user models request failed"); + }; + const provider = withPersonalizedCatalog(builtinProvider(), personalize); + + await expect(refreshProvider(provider, store)).rejects.toThrow("user models request failed"); + expect(provider.getModels().map((entry) => entry.id)).toEqual(["allowed"]); + expect(await store.read(provider.id)).toEqual(saved); + }); + + it("restores a saved list as is, and falls back to the built-in models only when it is empty", async () => { + const personalize: PersonalizeCatalog = async () => { + throw new Error("not used offline"); + }; + const store = new InMemoryModelsStore(); + const provider = withPersonalizedCatalog(builtinProvider(), personalize); + + // pi versions before per-key lists stored the plain pi.dev catalog here, and saved an empty list + // after a missing pi.dev catalog or a failed first request. Only such an entry can be empty. + await store.write(provider.id, { models: [], checkedAt: 1 }); + await refreshProvider(provider, store, { allowNetwork: false }); + expect(provider.getModels().map((entry) => entry.id)).toEqual(["static"]); + + // A key that may use no chat models keeps its image models and shows no chat models. + await store.write(provider.id, { models: [imageModel], checkedAt: 1 }); + await refreshProvider(provider, store, { allowNetwork: false }); + expect(provider.getModels()).toEqual([]); + expect(provider.getAllModels?.().map((entry) => entry.id)).toEqual(["image"]); + }); +});