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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ OPENAI_API_KEY=
ANTHROPIC_API_KEY=
GEMINI_API_KEY=
OPENROUTER_API_KEY=
REQUESTY_API_KEY=
# Optional Requesty router override, e.g. https://router.eu.requesty.ai/v1 for EU routing.
REQUESTY_BASE_URL=
ZAI_API_KEY=
KIMI_API_KEY=
MINIMAX_API_KEY=
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ Local models are supported through the setup flow. For LM Studio, run `feynman s

To authenticate another hosted provider, run `feynman model login <provider>`. GitHub Copilot sign-in retries model discovery once when GitHub rate-limits the request. OpenRouter login opens an OAuth page and listens for a local callback; over SSH or in another headless environment, paste the browser's final redirect URL or authorization code into Feynman's prompt, or set `OPENROUTER_API_KEY` before launch to use API-key authentication without OAuth.

Requesty is supported as a hosted OpenAI-compatible gateway. Run `feynman model login requesty` (or choose `Requesty` in `feynman setup`), keep the default `https://router.requesty.ai/v1` or enter `https://router.eu.requesty.ai/v1` for EU routing, and set `REQUESTY_API_KEY` (from [app.requesty.ai/api-keys](https://app.requesty.ai/api-keys)) before launch. Feynman registers Requesty's managed routing policies (for example `requesty/claude-sonnet-4-5`) and can also register the full `vendor/model` catalog (for example `requesty/openai/gpt-4o-mini`). `REQUESTY_BASE_URL` changes the default router URL offered during setup.

### Skills Only

If you want just the research skills without the full terminal app:
Expand Down
4 changes: 4 additions & 0 deletions RELEASES.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ GitHub release notes are generated from the matching `## vX.Y.Z` section in this

## Unreleased

### Model providers

- Added Requesty as an opt-in OpenAI-compatible gateway provider. `feynman model login requesty` (or `Requesty` in `feynman setup`) reads `REQUESTY_API_KEY`, accepts the EU router `https://router.eu.requesty.ai/v1` (or `REQUESTY_BASE_URL`), and registers Requesty's managed routing policies, optionally merged with the full `vendor/model` catalog, as `requesty/<id>` models with context window, output limit, vision, reasoning, and cost metadata.

## v0.3.49 - 2026-09-06

### Research runtime refresh
Expand Down
1 change: 1 addition & 0 deletions src/model/api-key-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const MODEL_API_KEY_PROVIDERS: ApiKeyProviderInfo[] = [
{ id: "__custom__", label: "Custom provider (local/self-hosted/proxy)" },
{ id: "amazon-bedrock", label: "Amazon Bedrock (AWS credential chain)" },
{ id: "openrouter", label: "OpenRouter", envVar: "OPENROUTER_API_KEY" },
{ id: "requesty", label: "Requesty (OpenAI-compatible gateway)", envVar: "REQUESTY_API_KEY" },
{ id: "zai", label: "Z.AI / GLM", envVar: "ZAI_API_KEY" },
{ id: "kimi-coding", label: "Kimi / Moonshot", envVar: "KIMI_API_KEY" },
{ id: "minimax", label: "MiniMax", envVar: "MINIMAX_API_KEY" },
Expand Down
1 change: 1 addition & 0 deletions src/model/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const PROVIDER_LABELS: Record<string, string> = {
openai: "OpenAI",
"openai-codex": "OpenAI Codex",
openrouter: "OpenRouter",
requesty: "Requesty",
google: "Google",
"google-gemini-cli": "Google Gemini CLI",
zai: "Z.AI / GLM",
Expand Down
113 changes: 111 additions & 2 deletions src/model/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,16 @@ import {
} from "./catalog.js";
import { MODEL_API_KEY_PROVIDERS, type ApiKeyProviderInfo } from "./api-key-providers.js";
import { createModelRegistry, createModelRuntime, getModelsJsonPath } from "./registry.js";
import { upsertProviderBaseUrl, upsertProviderConfig } from "./models-json.js";
import { type ModelsJsonModelConfig, upsertProviderBaseUrl, upsertProviderConfig } from "./models-json.js";
import {
REQUESTY_API_KEY_ENV_VAR,
REQUESTY_API_KEYS_URL,
REQUESTY_EU_BASE_URL,
REQUESTY_PROVIDER_ID,
fetchRequestyCatalog,
resolveRequestyDefaultBaseUrl,
toRequestyModelConfig,
} from "./requesty.js";

const exec = promisify(execCallback);

Expand Down Expand Up @@ -117,6 +126,9 @@ function apiKeyProviderHint(provider: ApiKeyProviderInfo): string {
if (provider.id === "litellm") {
return "http://localhost:4000/v1";
}
if (provider.id === REQUESTY_PROVIDER_ID) {
return resolveRequestyDefaultBaseUrl();
}
return provider.envVar ?? provider.id;
}

Expand Down Expand Up @@ -148,6 +160,16 @@ type CustomProviderSetup = {
* but expect Bearer auth instead of x-api-key).
*/
authHeader: boolean;
/**
* Full model configs (metadata such as context window and cost) when the
* provider catalog supplies them. Defaults to bare `{ id }` entries from modelIds.
*/
models?: ModelsJsonModelConfig[];
/**
* If false, verification skips checking that `/models` lists the configured
* ids (for gateways whose selectable ids are not all served by `/models`).
*/
verifyModelIds?: boolean;
};

function normalizeProviderId(value: string): string {
Expand Down Expand Up @@ -471,6 +493,66 @@ async function promptLiteLlmProviderSetup(): Promise<CustomProviderSetup | undef
};
}

async function promptRequestyProviderSetup(): Promise<CustomProviderSetup | undefined> {
printSection("Requesty");
printInfo("Requesty is a hosted OpenAI-compatible gateway with one API key across 700+ models.");
printInfo(`Get a key at ${REQUESTY_API_KEYS_URL}. Docs: https://docs.requesty.ai`);
printInfo(`Tip: to avoid writing secrets to disk, set ${REQUESTY_API_KEY_ENV_VAR} in your shell or .env.`);

const baseUrlRaw = await promptText(
`Base URL (EU routing: ${REQUESTY_EU_BASE_URL})`,
resolveRequestyDefaultBaseUrl(),
);
const { baseUrl } = normalizeCustomProviderBaseUrl("openai-completions", baseUrlRaw);
if (!baseUrl) {
printWarning("Base URL is required.");
return undefined;
}

const pastedKey = (await promptText(`Paste API key (leave empty to use ${REQUESTY_API_KEY_ENV_VAR} env var)`, "")).trim();
// Pi resolves `$VAR` from the environment at request time; a pasted key is stored as a literal in models.json.
const apiKeyConfig = pastedKey || `$${REQUESTY_API_KEY_ENV_VAR}`;
const resolvedKey = pastedKey || process.env[REQUESTY_API_KEY_ENV_VAR]?.trim() || undefined;
if (!resolvedKey) {
printInfo(`Set ${REQUESTY_API_KEY_ENV_VAR} in your shell or .env before using Feynman.`);
}

const catalogChoices = [
"Managed policies (curated, Requesty-maintained routing for ~150 models)",
"Managed policies plus the full vendor/model catalog (700+ models)",
"Cancel",
];
const catalogSelection = await promptChoice("Model catalog to register:", catalogChoices, 0);
if (catalogSelection >= 2) {
return undefined;
}
const includeFullCatalog = catalogSelection === 1;

const catalog = await fetchRequestyCatalog(baseUrl, resolvedKey, { includeFullCatalog });
if (!catalog || catalog.models.length === 0) {
printWarning(`Could not fetch the Requesty model catalog from ${baseUrl}/models/managed or ${baseUrl}/models.`);
return undefined;
}
if (!catalog.sources.includes("managed")) {
printWarning("Managed policies were unavailable; registered the full vendor/model catalog instead.");
}

const models = catalog.models.map(toRequestyModelConfig);
const sample = models.slice(0, 10).map((model) => model.id).join(", ");
printInfo(`Detected ${models.length} Requesty models: ${sample}${models.length > 10 ? ", ..." : ""}`);

return {
providerId: REQUESTY_PROVIDER_ID,
modelIds: models.map((model) => model.id),
models,
baseUrl,
api: "openai-completions",
apiKeyConfig,
authHeader: true,
verifyModelIds: false,
};
}

async function verifyCustomProvider(setup: CustomProviderSetup, authPath: string): Promise<void> {
const registry = await createModelRegistry(authPath);
const modelsError = registry.getError();
Expand Down Expand Up @@ -525,7 +607,9 @@ async function verifyCustomProvider(setup: CustomProviderSetup, authPath: string
const modelIds = Array.isArray((json as any)?.data)
? (json as any).data.map((entry: any) => (typeof entry?.id === "string" ? entry.id : undefined)).filter(Boolean)
: [];
const missing = setup.modelIds.filter((id) => modelIds.length > 0 && !modelIds.includes(id));
const missing = setup.verifyModelIds === false
? []
: setup.modelIds.filter((id) => modelIds.length > 0 && !modelIds.includes(id));
if (modelIds.length > 0 && missing.length > 0) {
printWarning(`Verification: /models does not list configured model id(s): ${missing.join(", ")}`);
return;
Expand Down Expand Up @@ -710,6 +794,31 @@ async function configureApiKeyProvider(authPath: string, providerId?: string): P
return true;
}

if (provider.id === REQUESTY_PROVIDER_ID) {
const setup = await promptRequestyProviderSetup();
if (!setup) {
printInfo("Requesty setup cancelled.");
return false;
}

const modelsJsonPath = getModelsJsonPath(authPath);
const result = upsertProviderConfig(modelsJsonPath, setup.providerId, {
baseUrl: setup.baseUrl,
apiKey: setup.apiKeyConfig,
api: setup.api,
authHeader: setup.authHeader,
models: setup.models ?? setup.modelIds.map((id) => ({ id })),
});
if (!result.ok) {
printWarning(result.error);
return false;
}

printSuccess("Saved Requesty provider.");
await verifyCustomProvider(setup, authPath);
return true;
}

if (provider.id === "__custom__") {
const setup = await promptCustomProviderSetup();
if (!setup) {
Expand Down
12 changes: 11 additions & 1 deletion src/model/models-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,23 @@ export function upsertProviderBaseUrl(
return upsertProviderConfig(modelsJsonPath, providerId, { baseUrl });
}

export type ModelsJsonModelConfig = {
id: string;
name?: string;
reasoning?: boolean;
input?: Array<"text" | "image">;
contextWindow?: number;
maxTokens?: number;
cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
};

export type ProviderConfigPatch = {
baseUrl?: string;
apiKey?: string;
api?: string;
authHeader?: boolean;
headers?: Record<string, string>;
models?: Array<{ id: string }>;
models?: ModelsJsonModelConfig[];
};

export function upsertProviderConfig(
Expand Down
143 changes: 143 additions & 0 deletions src/model/requesty.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import type { ModelsJsonModelConfig } from "./models-json.js";

// Requesty is an OpenAI-compatible LLM gateway. Pi has no built-in provider for
// it, so Feynman registers it as a custom `openai-completions` provider in
// models.json and seeds the model list from Requesty's public catalog endpoints.

export const REQUESTY_PROVIDER_ID = "requesty";
export const REQUESTY_API_KEY_ENV_VAR = "REQUESTY_API_KEY";
export const REQUESTY_BASE_URL_ENV_VAR = "REQUESTY_BASE_URL";
export const REQUESTY_DEFAULT_BASE_URL = "https://router.requesty.ai/v1";
export const REQUESTY_EU_BASE_URL = "https://router.eu.requesty.ai/v1";
export const REQUESTY_API_KEYS_URL = "https://app.requesty.ai/api-keys";

const CATALOG_TIMEOUT_MS = 8000;
const USD_PER_TOKEN_TO_PER_MILLION = 1_000_000;

export type RequestyCatalogModel = {
id: string;
api?: string;
context_window?: number;
max_output_tokens?: number;
input_price?: number;
output_price?: number;
cached_price?: number;
caching_price?: number;
supports_reasoning?: boolean;
supports_vision?: boolean;
description?: string;
};

export type RequestyCatalogSource = "managed" | "full";

export type RequestyCatalog = {
models: RequestyCatalogModel[];
sources: RequestyCatalogSource[];
};

export function resolveRequestyDefaultBaseUrl(env: NodeJS.ProcessEnv = process.env): string {
const override = env[REQUESTY_BASE_URL_ENV_VAR]?.trim();
return override ? override.replace(/\/+$/, "") : REQUESTY_DEFAULT_BASE_URL;
}

export function isRequestyChatModel(model: unknown): model is RequestyCatalogModel {
if (!model || typeof model !== "object") return false;
const candidate = model as Record<string, unknown>;
if (typeof candidate.id !== "string" || !candidate.id) return false;
return candidate.api === undefined || candidate.api === "chat";
}

function perMillion(usdPerToken: number | undefined): number {
if (typeof usdPerToken !== "number" || !Number.isFinite(usdPerToken) || usdPerToken < 0) return 0;
return Number((usdPerToken * USD_PER_TOKEN_TO_PER_MILLION).toPrecision(6));
}

function positiveInteger(value: number | undefined): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined;
return Math.floor(value);
}

export function toRequestyModelConfig(model: RequestyCatalogModel): ModelsJsonModelConfig {
const config: ModelsJsonModelConfig = {
id: model.id,
reasoning: model.supports_reasoning === true,
input: model.supports_vision === true ? ["text", "image"] : ["text"],
cost: {
input: perMillion(model.input_price),
output: perMillion(model.output_price),
cacheRead: perMillion(model.cached_price),
cacheWrite: perMillion(model.caching_price),
},
};
const contextWindow = positiveInteger(model.context_window);
if (contextWindow !== undefined) config.contextWindow = contextWindow;
const maxTokens = positiveInteger(model.max_output_tokens);
if (maxTokens !== undefined) config.maxTokens = maxTokens;
return config;
}

/**
* Managed policies come first because they are the curated list users should
* see before the full vendor/model catalog. Duplicate ids keep the first entry.
*/
export function mergeRequestyCatalogs(managed: RequestyCatalogModel[], full: RequestyCatalogModel[]): RequestyCatalogModel[] {
const seen = new Set<string>();
const merged: RequestyCatalogModel[] = [];
for (const model of [...managed, ...full]) {
if (!isRequestyChatModel(model) || seen.has(model.id)) continue;
seen.add(model.id);
merged.push(model);
}
return merged;
}

async function fetchRequestyCatalogEndpoint(url: string, apiKey: string | undefined): Promise<RequestyCatalogModel[] | undefined> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), CATALOG_TIMEOUT_MS);
try {
const response = await fetch(url, {
method: "GET",
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined,
signal: controller.signal,
});
if (!response.ok) {
return undefined;
}
const json = (await response.json()) as { data?: unknown };
if (!Array.isArray(json?.data)) return undefined;
return json.data.filter(isRequestyChatModel);
} catch {
return undefined;
} finally {
clearTimeout(timer);
}
}

/**
* Fetches the Requesty chat catalog. `/models/managed` (curated routing
* policies) is the primary source; `/models` (full vendor/model catalog) is
* merged in when requested, and used as the fallback when the managed list is
* unavailable. Returns undefined when neither endpoint responded.
*/
export async function fetchRequestyCatalog(
baseUrl: string,
apiKey: string | undefined,
options: { includeFullCatalog: boolean },
): Promise<RequestyCatalog | undefined> {
const managed = await fetchRequestyCatalogEndpoint(`${baseUrl}/models/managed`, apiKey);
const full = options.includeFullCatalog || !managed
? await fetchRequestyCatalogEndpoint(`${baseUrl}/models`, apiKey)
: undefined;

const sources: RequestyCatalogSource[] = [];
if (managed) sources.push("managed");
if (full) sources.push("full");
if (sources.length === 0) {
return undefined;
}

return {
models: mergeRequestyCatalogs(managed ?? [], full ?? []),
sources,
};
}
1 change: 1 addition & 0 deletions src/workbench/credential-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const MODEL_CREDENTIAL_SECTIONS: Record<string, { section: string; source: strin
opencode: { section: "Model providers", source: "Feynman model login", tags: ["model", "gateway"] },
"opencode-go": { section: "Model providers", source: "Feynman model login", tags: ["model", "gateway"] },
openrouter: { section: "Model providers", source: "Feynman model login", tags: ["model", "gateway"] },
requesty: { section: "Model providers", source: "Feynman model login", tags: ["model", "gateway"] },
};

const ADDITIONAL_CREDENTIAL_PROVIDERS: WorkbenchCredentialProvider[] = [
Expand Down
1 change: 1 addition & 0 deletions tests/catalog-snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ test("DeepSeek V4 Pro IDs remain available while genuine premium Pro models stay
"baseten/deepseek-ai/DeepSeek-V4-Pro",
"fireworks/accounts/fireworks/models/deepseek-v4-pro",
"openrouter/deepseek/deepseek-v4-pro",
"requesty/deepseek-v4-pro",
]) {
assert.equal(isProClassModelSpec(spec), false, spec);
}
Expand Down
Loading