Skip to content
Open
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
95 changes: 61 additions & 34 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 `<baseUrl>/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`.

7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
205 changes: 93 additions & 112 deletions requesty.js
Original file line number Diff line number Diff line change
@@ -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
* <baseUrl>/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) {
Expand All @@ -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)}`,
);
}
}
Loading