diff --git a/README.md b/README.md index 9c54974..a05a7d2 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,85 @@ -# pi-requesty (Official Requesty extension for Pi) +# pi-requesty (Requesty extension for Pi) -The official Requesty extension for the Pi Coding Agent +A [Pi Coding Agent](https://github.com/earendil-works/pi-mono) extension that registers [Requesty](https://requesty.ai) as an OpenAI-compatible model provider. -## (Recommended) Install from Github Repo +The model catalog is discovered from the Requesty `/models` endpoint and cached through Pi's standard provider model store, so it refreshes automatically on startup and when the model picker opens. No manual `models.json` edits are required. + +## Install + +### From GitHub ```bash -pi install git:github.com/requestyai/pi-requesty@c28e2f8 +pi install git:github.com/requestyai/pi-requesty ``` -NOTE: Version c28e2f8 points to out latest v0.2.7 version, and keeps you safe from supply chain attack. +To run once without installing: -## Install locally +```bash +pi -e ./pi-requesty +``` -Check out the code from the official code repository `https://github.com/requestyai/pi-requesty`, and then: +### Locally ```bash pi install ./pi-requesty ``` -To run once without installing: +## Configuration -```bash -pi -e ./pi-requesty +Set your Requesty API key via either of these two methods: + +**Option 1 — `/login` (recommended):** inside Pi, run + +```text +/login requesty ``` -## Configuration +and paste your API key. -The extension only reads the `requesty` provider from `~/.pi/agent/models.json`. - -Example: - -```json -{ - "providers": { - "requesty": { - "name": "Requesty", - "baseUrl": "https://router.requesty.ai/v1", - "apiKey": "rqsty-sk-...", - "api": "openai-completions", - "models": [] - } - } -} +**Option 2 — environment variable:** + +```bash +export REQUESTY_API_KEY="rqsty-sk-..." ``` -On startup, the extension fetches `/models` using `apiKey` as the bearer token and registers discovered models with pi. +The endpoint and model list are handled automatically — there is no need to add a `requesty` provider block to `~/.pi/agent/models.json`. Models are discovered and cached at runtime. -## Command +## How model loading works -Inside pi: +The extension registers the provider with an empty baseline catalog and a +`refreshModels` hook. Pi invokes that hook: -```text -/requesty-models-sync +- on startup (first offline, restoring the cached catalog, then online), +- whenever the model picker or a model refresh runs. + +Discovered models are written to Pi's provider model store and reused on the +next launch, so the catalog is available even when offline or before the first +network refresh completes. This is the same mechanism Pi uses for its built-in +dynamic providers. + +## Notes + +- API: `openai-completions` (Requesty is OpenAI-compatible). +- Endpoint: `https://router.requesty.ai/v1` (`/models` for discovery, `/chat/completions` for streaming). + +## Development + +### Tests + +Unit tests use Node's built-in test runner (no external dependencies): + +```bash +npm test ``` -The command fetches Requesty models using `~/.pi/agent/models.json` and writes the discovered model IDs back to the same file. -Run `/reload` after syncing. +Coverage includes price/context mapping, `/models` discovery (HTTP + parsing), the +`refreshModels` caching/auth-gating behavior, and `registerProvider` config. + +### Changelog + +- **v0.3.0** (breaking): + - Replaced hand-rolled `models.json` reading/writing with Pi's standard `refreshModels` + provider model store caching. + - Removed the `/requesty-models-sync` command; models now refresh automatically (startup + model picker). + - Authentication is now configured via `/login requesty` or `REQUESTY_API_KEY` environment variable; a `requesty` block in `models.json` is no longer used. +- **v0.2.x**: earlier versions read/wrote `providers.requesty.models` directly in `~/.pi/agent/models.json` and exposed `/requesty-models-sync`. + diff --git a/package.json b/package.json index acc1cec..f223c14 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,12 @@ { "name": "pi-requesty", - "version": "0.2.7", - "description": "The official Requesty extension for the Pi Coding Agent", + "version": "0.3.0", + "description": "Requesty provider extension for the Pi Coding Agent", "type": "module", "main": "requesty.js", + "scripts": { + "test": "node --test test/*.test.js" + }, "keywords": ["pi-package", "pi", "pi-coding-agent", "pi-extensions", "requesty", "openai-compatible", "models"], "license": "MIT", "peerDependencies": { diff --git a/requesty.js b/requesty.js index 5ab8906..f32b036 100644 --- a/requesty.js +++ b/requesty.js @@ -1,63 +1,62 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; +/** + * pi-requesty: Requesty provider extension for the Pi Coding Agent. + * + * Registers the Requesty router (https://router.requesty.ai) as an + * OpenAI-compatible provider. The model catalog is discovered from + * /models and cached through pi's standard provider model store + * (context.store), so pi refreshes it automatically on startup and when the + * model picker opens. No manual models.json writes are performed. + * + * Authentication is resolved by pi in the standard way: + * - /login requesty (stored credential), or + * - REQUESTY_API_KEY environment variable + * + * Do not configure the provider in ~/.pi/agent/models.json; the extension + * defines the endpoint and (optionally) the account's allowed models are + * discovered at runtime. + */ -const MODELS_JSON_PATH = path.join(os.homedir(), ".pi", "agent", "models.json"); const PROVIDER = "requesty"; const DEFAULT_BASE_URL = "https://router.requesty.ai/v1"; const DEFAULT_NAME = "Requesty"; const DEFAULT_CONTEXT_WINDOW = 128000; const DEFAULT_MAX_TOKENS = 4096; -function normalizeBaseUrl(baseUrl) { +export function normalizeBaseUrl(baseUrl) { return baseUrl.replace(/\/+$/, ""); } -function readModelsJson() { - if (!fs.existsSync(MODELS_JSON_PATH)) { - throw new Error(`${MODELS_JSON_PATH} does not exist`); - } - - const data = JSON.parse(fs.readFileSync(MODELS_JSON_PATH, "utf8")); - if (!data.providers || typeof data.providers !== "object") { - throw new Error(`${MODELS_JSON_PATH} does not define providers`); - } - - return data; +/** Requesty prices are per-token; pi expects per-million-token rates. */ +export function pricePerMillionTokens(value) { + return (value ?? 0) * 1_000_000; } -function getRequestyConfig() { - const data = readModelsJson(); - const provider = data.providers[PROVIDER]; - - if (!provider || typeof provider !== "object") { - throw new Error(`${MODELS_JSON_PATH} does not define providers.${PROVIDER}`); - } - - if (typeof provider.apiKey !== "string" || provider.apiKey.length === 0) { - throw new Error(`providers.${PROVIDER}.apiKey must be set in ${MODELS_JSON_PATH}`); - } - - const name = typeof provider.name === "string" && provider.name.length > 0 ? provider.name : DEFAULT_NAME; - - const baseUrl = normalizeBaseUrl( - typeof provider.baseUrl === "string" && provider.baseUrl.length > 0 ? provider.baseUrl : DEFAULT_BASE_URL, - ); - +/** Map a Requesty model descriptor to pi's ProviderModelConfig shape. */ +export function toModel(model) { return { - data, - provider: { - ...provider, - name: name, - baseUrl: baseUrl, - apiKey: provider.apiKey, + id: model.id, + name: typeof model.name === "string" && model.name.length > 0 ? model.name : model.id, + reasoning: model.supports_reasoning === true, + input: model.supports_vision === true ? ["text", "image"] : ["text"], + cost: { + input: pricePerMillionTokens(model.input_price), + output: pricePerMillionTokens(model.output_price), + cacheRead: pricePerMillionTokens(model.cached_price), + cacheWrite: pricePerMillionTokens(model.caching_price), }, + contextWindow: model.context_window || DEFAULT_CONTEXT_WINDOW, + maxTokens: model.max_output_tokens || DEFAULT_MAX_TOKENS, }; } -async function discoverModels(provider) { - const response = await fetch(`${provider.baseUrl}/models`, { - headers: { Authorization: `Bearer ${provider.apiKey}` }, +/** + * Discover models from the Requesty /models endpoint. + * The endpoint is OpenAI-compatible ({ data: [...] }). + */ +export async function discoverModels(baseUrl, apiKey, signal) { + const response = await fetch(`${baseUrl}/models`, { + headers: { Authorization: `Bearer ${apiKey}` }, + signal, }); if (!response.ok) { @@ -71,81 +70,63 @@ async function discoverModels(provider) { return payload.data .filter((model) => model && typeof model.id === "string" && model.id.length > 0) - .map((model) => ({ - id: model.id, - name: typeof model.name === "string" && model.name.length > 0 ? model.name : model.id, - reasoning: model.supports_reasoning === true, - input: model.supports_vision === true ? ["text", "image"] : ["text"], - cost: { - input: pricePerMillionTokens(model.input_price), - output: pricePerMillionTokens(model.output_price), - cacheRead: pricePerMillionTokens(model.cached_price), - cacheWrite: pricePerMillionTokens(model.caching_price), - }, - contextWindow: model.context_window || DEFAULT_CONTEXT_WINDOW, - maxTokens: model.max_output_tokens || DEFAULT_MAX_TOKENS, - })); + .map(toModel); } -function pricePerMillionTokens(value) { - return (value ?? 0) * 1_000_000; -} +export default function (pi) { + const baseUrl = normalizeBaseUrl(DEFAULT_BASE_URL); + + pi.registerProvider(PROVIDER, { + name: DEFAULT_NAME, + baseUrl, + apiKey: "$REQUESTY_API_KEY", + api: "openai-completions", + // Baseline catalog is empty; models are populated dynamically by + // refreshModels and persisted in pi's provider model store. + models: [], + + /** + * Standard pi model refresh with caching. pi calls this automatically: + * - on startup, first offline (cache restore) then online (refresh) + * - whenever the model picker / model refresh runs + * + * The returned list replaces this provider's extension-provided models. + * On failure we rethrow so pi retains the previous list and surfaces the + * error; the cache from the last successful refresh is always restored + * first so models remain available offline. + * + * The API key is required for discovery: an unauthenticated /models call + * returns Requesty's full public catalog (~hundreds of models), while the + * authenticated call returns only the models this account has enabled. + * We never want the unscoped list, so we no-op (return cached) without a key. + */ + async refreshModels(context) { + const stored = await context.store.read(); + const cached = stored?.models ?? []; + + if (!context.allowNetwork || context.signal?.aborted) { + return cached; + } -function writeModelsJson(data) { - fs.mkdirSync(path.dirname(MODELS_JSON_PATH), { recursive: true }); - const tmpPath = `${MODELS_JSON_PATH}.tmp`; - fs.writeFileSync(tmpPath, `${JSON.stringify(data, null, 2)}\n`, "utf8"); - fs.renameSync(tmpPath, MODELS_JSON_PATH); -} + const apiKey = + context.credential?.type === "api_key" && typeof context.credential.key === "string" + ? context.credential.key + : undefined; -function updateModelsJson(data, models) { - data.providers[PROVIDER] = { - ...data.providers[PROVIDER], - models: models.map((model) => ({ - id: model.id, - name: model.name, - reasoning: model.reasoning, - input: model.input, - cost: model.cost, - contextWindow: model.contextWindow, - maxTokens: model.maxTokens, - })), - }; - writeModelsJson(data); -} + // No key resolved: pi normally skips refresh in this case, but guard + // defensively so we never fall back to the unauthenticated (unscoped) + // catalog. Return the cached list (possibly empty on first run). + if (!apiKey) { + return cached; + } -export default async function (pi) { - pi.registerCommand("requesty-models-sync", { - description: "Dynamically discover Requesty models and update the local models.json.", - async handler(_args, ctx) { - ctx.ui.setStatus("requesty-models-sync", "Discovering Requesty models..."); - - try { - const { data, provider } = getRequestyConfig(); - const models = await discoverModels(provider); - updateModelsJson(data, models); - ctx.ui.notify(`Discovered ${models.length} Requesty model(s). Run /reload to use models.json changes.`, "success"); - } catch (error) { - ctx.ui.notify(`Discovery failed: ${error instanceof Error ? error.message : String(error)}`, "error"); - } finally { - ctx.ui.setStatus("requesty-models-sync", undefined); + const discovered = await discoverModels(baseUrl, apiKey, context.signal); + if (context.signal?.aborted) { + return cached; } + + await context.store.write({ models: discovered, checkedAt: Date.now() }); + return discovered; }, }); - - try { - const { provider } = getRequestyConfig(); - const models = await discoverModels(provider); - - if (models.length > 0) { - pi.registerProvider(PROVIDER, { - ...provider, - models, - }); - } - } catch (error) { - console.warn( - `[pi-requesty-model-discovery] startup discovery failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } } diff --git a/test/requesty.test.js b/test/requesty.test.js new file mode 100644 index 0000000..2d3d046 --- /dev/null +++ b/test/requesty.test.js @@ -0,0 +1,266 @@ +import { strict as assert } from "node:assert"; +import { afterEach, beforeEach, describe, it, mock } from "node:test"; + +import requestyModule, { discoverModels, normalizeBaseUrl, pricePerMillionTokens, toModel } from "../requesty.js"; + +describe("normalizeBaseUrl", () => { + it("strips trailing slashes", () => { + assert.equal(normalizeBaseUrl("https://router.requesty.ai/v1///"), "https://router.requesty.ai/v1"); + }); + + it("leaves a URL without trailing slash unchanged", () => { + assert.equal(normalizeBaseUrl("https://router.requesty.ai/v1"), "https://router.requesty.ai/v1"); + }); +}); + +describe("pricePerMillionTokens", () => { + it("converts a per-token price to a per-million-token rate", () => { + assert.equal(pricePerMillionTokens(0.000002), 2); + }); + + it("returns 0 when the price is missing", () => { + assert.equal(pricePerMillionTokens(undefined), 0); + assert.equal(pricePerMillionTokens(null), 0); + }); + + it("returns 0 when the price is 0", () => { + assert.equal(pricePerMillionTokens(0), 0); + }); +}); + +describe("toModel", () => { + it("maps a full model descriptor to the pi ProviderModelConfig shape", () => { + const model = toModel({ + id: "vendor/model-1", + name: "Model One", + supports_reasoning: true, + supports_vision: true, + input_price: 0.000003, + output_price: 0.000015, + cached_price: 0.000001, + caching_price: 0.000002, + context_window: 200000, + max_output_tokens: 16384, + }); + + assert.deepEqual(model, { + id: "vendor/model-1", + name: "Model One", + reasoning: true, + input: ["text", "image"], + cost: { input: 3, output: 15, cacheRead: 1, cacheWrite: 2 }, + contextWindow: 200000, + maxTokens: 16384, + }); + }); + + it("falls back to the id for the display name", () => { + assert.equal(toModel({ id: "vendor/model-2" }).name, "vendor/model-2"); + assert.equal(toModel({ id: "vendor/model-3", name: "" }).name, "vendor/model-3"); + }); + + it("defaults reasoning/vision to false and text-only input", () => { + const model = toModel({ id: "vendor/model-4", supports_vision: false }); + assert.equal(model.reasoning, false); + assert.deepEqual(model.input, ["text"]); + }); + + it("applies default context window and max tokens when absent", () => { + const model = toModel({ id: "vendor/model-5" }); + assert.equal(model.contextWindow, 128000); + assert.equal(model.maxTokens, 4096); + }); + + it("zeroes missing cost fields", () => { + const model = toModel({ id: "vendor/model-6" }); + assert.deepEqual(model.cost, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }); + }); +}); + +describe("discoverModels", () => { + let originalFetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + mock.restoreAll(); + }); + + it("sends the API key as a bearer token", async () => { + let seenUrl; + let seenHeaders; + globalThis.fetch = async (url, opts) => { + seenUrl = url; + seenHeaders = opts.headers; + return { ok: true, async json() { return { data: [{ id: "vendor/model-1" }] }; } }; + }; + + const models = await discoverModels("https://router.requesty.ai/v1", "secret-key"); + + assert.equal(seenUrl, "https://router.requesty.ai/v1/models"); + assert.equal(seenHeaders.Authorization, "Bearer secret-key"); + assert.equal(models.length, 1); + }); + + it("parses and maps the data array", async () => { + globalThis.fetch = async () => ({ + ok: true, + async json() { + return { + data: [ + { id: "a/b", input_price: 0.000001 }, + { id: "c/d", name: "C D", supports_reasoning: true }, + ], + }; + }, + }); + + const models = await discoverModels("base", "key"); + assert.equal(models.length, 2); + assert.equal(models[0].name, "a/b"); // falls back to id + assert.equal(models[1].name, "C D"); // uses explicit name + assert.equal(models[1].reasoning, true); + }); + + it("filters out entries without an id", async () => { + globalThis.fetch = async () => ({ + ok: true, + async json() { + return { data: [{ id: "ok/model" }, { name: "no-id" }, null] }; + }, + }); + + const models = await discoverModels("base", "key"); + assert.equal(models.length, 1); + assert.equal(models[0].id, "ok/model"); + }); + + it("throws on a non-2xx response", async () => { + globalThis.fetch = async () => ({ ok: false, status: 401, statusText: "Unauthorized" }); + + await assert.rejects(discoverModels("base", "key"), /HTTP 401 Unauthorized/); + }); + + it("throws when the payload has no data array", async () => { + globalThis.fetch = async () => ({ ok: true, async json() { return { object: "list" }; } }); + + await assert.rejects(discoverModels("base", "key"), /Expected OpenAI-compatible response/); + }); +}); + +describe("extension registration and refreshModels", () => { + it("registers the requesty provider with the expected config", async () => { + const calls = []; + const pi = { registerProvider: (name, config) => calls.push({ name, config }) }; + + await requestyModule(pi); + + assert.equal(calls.length, 1); + const { name, config } = calls[0]; + assert.equal(name, "requesty"); + assert.equal(config.name, "Requesty"); + assert.equal(config.baseUrl, "https://router.requesty.ai/v1"); + assert.equal(config.apiKey, "$REQUESTY_API_KEY"); + assert.equal(config.api, "openai-completions"); + assert.deepEqual(config.models, []); + assert.equal(typeof config.refreshModels, "function"); + }); + + async function captureConfig() { + let config; + await requestyModule({ registerProvider: (_name, c) => { config = c; } }); + return config; + } + + function makeStore() { + let entry; + return { + store: { + read: async () => entry, + write: async (e) => { entry = e; }, + delete: async () => { entry = undefined; }, + }, + peek: () => entry, + }; + } + + function okFetch(data) { + globalThis.fetch = async () => ({ ok: true, async json() { return { data }; } }); + } + + it("does not fetch without a key and returns the cached list", async () => { + const config = await captureConfig(); + const { store } = makeStore(); + let fetched = false; + globalThis.fetch = async () => { fetched = true; return { ok: true, async json() { return { data: [{ id: "nope/model" }] }; } }; }; + + const result = await config.refreshModels({ store, allowNetwork: true, credential: undefined }); + + assert.deepEqual(result, []); + assert.equal(fetched, false, "must not fetch (unscoped) without a key"); + }); + + it("restores the cached list when network is disabled", async () => { + const config = await captureConfig(); + const { store } = makeStore(); + await store.write({ models: [{ id: "vendor/cached", name: "Cached" }], checkedAt: 1 }); + globalThis.fetch = async () => { throw new Error("should not fetch offline"); }; + + const result = await config.refreshModels({ store, allowNetwork: false, credential: undefined }); + + assert.equal(result.length, 1); + assert.equal(result[0].id, "vendor/cached"); + }); + + it("fetches, caches, and returns discovered models when authenticated", async () => { + const config = await captureConfig(); + const { store, peek } = makeStore(); + okFetch([{ id: "vendor/live", name: "Live", input_price: 0.000005 }]); + + const result = await config.refreshModels({ + store, + allowNetwork: true, + credential: { type: "api_key", key: "secret" }, + }); + + assert.equal(result.length, 1); + assert.equal(result[0].id, "vendor/live"); + assert.equal(result[0].cost.input, 5); + // Persisted with a checkedAt timestamp. + const entry = peek(); + assert.equal(entry.models.length, 1); + assert.equal(typeof entry.checkedAt, "number"); + }); + + it("propagates fetch failures while retaining the previous list on retry", async () => { + const config = await captureConfig(); + const { store } = makeStore(); + globalThis.fetch = async () => ({ ok: false, status: 503, statusText: "Service Unavailable" }); + + await assert.rejects( + config.refreshModels({ store, allowNetwork: true, credential: { type: "api_key", key: "secret" } }), + /HTTP 503/, + ); + }); + + it("does not write to the store when aborted", async () => { + const config = await captureConfig(); + const { store, peek } = makeStore(); + const controller = new AbortController(); + controller.abort(); + okFetch([{ id: "vendor/aborted" }]); + + const result = await config.refreshModels({ + store, + allowNetwork: true, + credential: { type: "api_key", key: "secret" }, + signal: controller.signal, + }); + + assert.deepEqual(result, []); + assert.equal(peek(), undefined, "store must not be written when aborted"); + }); +}); \ No newline at end of file