diff --git a/.env.example b/.env.example index 68b45078..098b9046 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index bcc4dd52..a4d0f2de 100644 --- a/README.md +++ b/README.md @@ -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 `. 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: diff --git a/RELEASES.md b/RELEASES.md index a4a4a112..025eced5 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -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/` models with context window, output limit, vision, reasoning, and cost metadata. + ## v0.3.49 - 2026-09-06 ### Research runtime refresh diff --git a/src/model/api-key-providers.ts b/src/model/api-key-providers.ts index 3917c78a..0a44ebe2 100644 --- a/src/model/api-key-providers.ts +++ b/src/model/api-key-providers.ts @@ -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" }, diff --git a/src/model/catalog.ts b/src/model/catalog.ts index 131024dc..3c55c0b6 100644 --- a/src/model/catalog.ts +++ b/src/model/catalog.ts @@ -47,6 +47,7 @@ const PROVIDER_LABELS: Record = { openai: "OpenAI", "openai-codex": "OpenAI Codex", openrouter: "OpenRouter", + requesty: "Requesty", google: "Google", "google-gemini-cli": "Google Gemini CLI", zai: "Z.AI / GLM", diff --git a/src/model/commands.ts b/src/model/commands.ts index fcce1342..9799599d 100644 --- a/src/model/commands.ts +++ b/src/model/commands.ts @@ -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); @@ -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; } @@ -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 { @@ -471,6 +493,66 @@ async function promptLiteLlmProviderSetup(): Promise { + 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 { const registry = await createModelRegistry(authPath); const modelsError = registry.getError(); @@ -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; @@ -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) { diff --git a/src/model/models-json.ts b/src/model/models-json.ts index 72c7e239..3e3c2739 100644 --- a/src/model/models-json.ts +++ b/src/model/models-json.ts @@ -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; - models?: Array<{ id: string }>; + models?: ModelsJsonModelConfig[]; }; export function upsertProviderConfig( diff --git a/src/model/requesty.ts b/src/model/requesty.ts new file mode 100644 index 00000000..5bcd4072 --- /dev/null +++ b/src/model/requesty.ts @@ -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; + 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(); + 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 { + 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 { + 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, + }; +} diff --git a/src/workbench/credential-catalog.ts b/src/workbench/credential-catalog.ts index b36eb324..aa8c5444 100644 --- a/src/workbench/credential-catalog.ts +++ b/src/workbench/credential-catalog.ts @@ -14,6 +14,7 @@ const MODEL_CREDENTIAL_SECTIONS: Record { + const authPath = createAuthPath({}); + + const resolved = await resolveModelProviderForCommand(authPath, "requesty"); + + assert.equal(resolved?.kind, "api-key"); + assert.equal(resolved?.id, "requesty"); +}); + test("resolveModelProviderForCommand prefers OAuth when a provider supports both auth modes", async () => { const authPath = createAuthPath({}); @@ -641,6 +651,7 @@ test("isLocalModelProvider flags custom providers whose models.json baseUrl poin providers: { "my-proxy": { baseUrl: "http://127.0.0.1:8000/v1" }, openrouter: { baseUrl: "https://openrouter.ai/api/v1" }, + requesty: { baseUrl: "https://router.requesty.ai/v1" }, }, }) + "\n", "utf8", @@ -648,6 +659,7 @@ test("isLocalModelProvider flags custom providers whose models.json baseUrl poin assert.equal(isLocalModelProvider(authPath, "my-proxy"), true); assert.equal(isLocalModelProvider(authPath, "openrouter"), false); + assert.equal(isLocalModelProvider(authPath, "requesty"), false); }); test("buildLocalModelWorkflowNotice names the configured model and the workflow", () => { diff --git a/tests/requesty.test.ts b/tests/requesty.test.ts new file mode 100644 index 00000000..543e6c15 --- /dev/null +++ b/tests/requesty.test.ts @@ -0,0 +1,187 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import { buildModelStatusSnapshotFromRecords } from "../src/model/catalog.js"; +import { upsertProviderConfig } from "../src/model/models-json.js"; +import { createModelRegistry } from "../src/model/registry.js"; +import { + REQUESTY_DEFAULT_BASE_URL, + REQUESTY_EU_BASE_URL, + fetchRequestyCatalog, + isRequestyChatModel, + mergeRequestyCatalogs, + resolveRequestyDefaultBaseUrl, + toRequestyModelConfig, + type RequestyCatalogModel, +} from "../src/model/requesty.js"; +import { WORKBENCH_CREDENTIAL_PROVIDERS } from "../src/workbench/credential-catalog.js"; + +function createAuthPath(contents: Record): string { + const root = mkdtempSync(join(tmpdir(), "feynman-requesty-auth-")); + const authPath = join(root, "auth.json"); + writeFileSync(authPath, JSON.stringify(contents, null, 2) + "\n", "utf8"); + return authPath; +} + +const MANAGED_SONNET: RequestyCatalogModel = { + id: "claude-sonnet-4-5", + api: "chat", + context_window: 200000, + max_output_tokens: 64000, + input_price: 0.000003, + output_price: 0.000015, + cached_price: 0.0000003, + caching_price: 0.00000375, + supports_reasoning: true, + supports_vision: true, +}; + +const FULL_GPT_4O_MINI: RequestyCatalogModel = { + id: "openai/gpt-4o-mini", + api: "chat", + context_window: 128000, + max_output_tokens: 16384, + input_price: 0.00000015, + output_price: 0.0000006, + supports_reasoning: false, + supports_vision: true, +}; + +test("toRequestyModelConfig maps Requesty catalog fields to Pi models.json fields", () => { + const config = toRequestyModelConfig(MANAGED_SONNET); + + assert.deepEqual(config, { + id: "claude-sonnet-4-5", + reasoning: true, + input: ["text", "image"], + contextWindow: 200000, + maxTokens: 64000, + cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + }); +}); + +test("toRequestyModelConfig omits unknown limits and zeroes missing prices", () => { + const config = toRequestyModelConfig({ id: "vendor/model", api: "chat" }); + + assert.deepEqual(config, { + id: "vendor/model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }); +}); + +test("mergeRequestyCatalogs lists managed policies first and drops non-chat and duplicate entries", () => { + const merged = mergeRequestyCatalogs( + [MANAGED_SONNET, { id: "openai/text-embedding-3-small", api: "embedding" }], + [FULL_GPT_4O_MINI, MANAGED_SONNET, { id: "" }], + ); + + assert.deepEqual(merged.map((model) => model.id), ["claude-sonnet-4-5", "openai/gpt-4o-mini"]); + assert.equal(isRequestyChatModel({ id: "x", api: "image" }), false); + assert.equal(isRequestyChatModel({ id: "x" }), true); +}); + +test("resolveRequestyDefaultBaseUrl honors REQUESTY_BASE_URL for regional routers", () => { + assert.equal(resolveRequestyDefaultBaseUrl({}), REQUESTY_DEFAULT_BASE_URL); + assert.equal(resolveRequestyDefaultBaseUrl({ REQUESTY_BASE_URL: `${REQUESTY_EU_BASE_URL}/` }), REQUESTY_EU_BASE_URL); + assert.equal(resolveRequestyDefaultBaseUrl({ REQUESTY_BASE_URL: " " }), REQUESTY_DEFAULT_BASE_URL); +}); + +test("fetchRequestyCatalog prefers /models/managed and merges /models only when requested", async () => { + const originalFetch = globalThis.fetch; + const requested: string[] = []; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + requested.push(url); + assert.equal((init?.headers as Record | undefined)?.Authorization, "Bearer test-key"); + if (url.endsWith("/models/managed")) { + return new Response(JSON.stringify({ object: "list", data: [MANAGED_SONNET] }), { status: 200 }); + } + if (url.endsWith("/models")) { + return new Response(JSON.stringify({ object: "list", data: [FULL_GPT_4O_MINI] }), { status: 200 }); + } + return new Response("not found", { status: 404 }); + }) as typeof fetch; + + try { + const managedOnly = await fetchRequestyCatalog(REQUESTY_DEFAULT_BASE_URL, "test-key", { includeFullCatalog: false }); + assert.deepEqual(managedOnly?.sources, ["managed"]); + assert.deepEqual(managedOnly?.models.map((model) => model.id), ["claude-sonnet-4-5"]); + assert.deepEqual(requested, [`${REQUESTY_DEFAULT_BASE_URL}/models/managed`]); + + requested.length = 0; + const merged = await fetchRequestyCatalog(REQUESTY_DEFAULT_BASE_URL, "test-key", { includeFullCatalog: true }); + assert.deepEqual(merged?.sources, ["managed", "full"]); + assert.deepEqual(merged?.models.map((model) => model.id), ["claude-sonnet-4-5", "openai/gpt-4o-mini"]); + assert.deepEqual(requested, [`${REQUESTY_DEFAULT_BASE_URL}/models/managed`, `${REQUESTY_DEFAULT_BASE_URL}/models`]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("fetchRequestyCatalog falls back to /models when managed policies are unavailable", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request) => { + const url = String(input); + if (url.endsWith("/models/managed")) { + return new Response("unavailable", { status: 503 }); + } + return new Response(JSON.stringify({ object: "list", data: [FULL_GPT_4O_MINI] }), { status: 200 }); + }) as typeof fetch; + + try { + const catalog = await fetchRequestyCatalog(REQUESTY_DEFAULT_BASE_URL, undefined, { includeFullCatalog: false }); + assert.deepEqual(catalog?.sources, ["full"]); + assert.deepEqual(catalog?.models.map((model) => model.id), ["openai/gpt-4o-mini"]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("Requesty models.json provider resolves through the Pi registry with an env-backed key", async () => { + const authPath = createAuthPath({}); + const modelsJsonPath = join(dirname(authPath), "models.json"); + const result = upsertProviderConfig(modelsJsonPath, "requesty", { + baseUrl: REQUESTY_DEFAULT_BASE_URL, + apiKey: "$REQUESTY_API_KEY", + api: "openai-completions", + authHeader: true, + models: [toRequestyModelConfig(MANAGED_SONNET), toRequestyModelConfig(FULL_GPT_4O_MINI)], + }); + assert.equal(result.ok, true); + + const previousKey = process.env.REQUESTY_API_KEY; + process.env.REQUESTY_API_KEY = "sk-requesty-test"; + try { + const registry = await createModelRegistry(authPath); + assert.equal(registry.getError(), undefined); + const model = registry.getAll().find((entry) => entry.provider === "requesty" && entry.id === "openai/gpt-4o-mini"); + assert.equal(model?.api, "openai-completions"); + assert.equal(model?.baseUrl, REQUESTY_DEFAULT_BASE_URL); + assert.equal(model?.contextWindow, 128000); + assert.equal(model?.maxTokens, 16384); + assert.ok(registry.getAvailable().some((entry) => entry.provider === "requesty" && entry.id === "claude-sonnet-4-5")); + assert.equal(await registry.getApiKeyForProvider("requesty"), "sk-requesty-test"); + } finally { + if (previousKey === undefined) delete process.env.REQUESTY_API_KEY; + else process.env.REQUESTY_API_KEY = previousKey; + } +}); + +test("Requesty is labeled in model status and listed as a gateway credential", () => { + const snapshot = buildModelStatusSnapshotFromRecords( + [{ provider: "requesty", id: "openai/gpt-4o-mini" }], + [{ provider: "requesty", id: "openai/gpt-4o-mini" }], + "requesty/openai/gpt-4o-mini", + ); + assert.equal(snapshot.currentValid, true); + assert.equal(snapshot.providers[0]?.label, "Requesty"); + + const credential = WORKBENCH_CREDENTIAL_PROVIDERS.find((provider) => provider.id === "requesty"); + assert.equal(credential?.envVar, "REQUESTY_API_KEY"); + assert.deepEqual(credential?.tags, ["model", "gateway"]); +}); diff --git a/website/src/content/docs/getting-started/configuration.md b/website/src/content/docs/getting-started/configuration.md index c7c7a7b3..30b35d14 100644 --- a/website/src/content/docs/getting-started/configuration.md +++ b/website/src/content/docs/getting-started/configuration.md @@ -66,6 +66,7 @@ To add another provider, authenticate it first: ```bash feynman model login anthropic feynman model login openrouter +feynman model login requesty feynman model login google feynman model login amazon-bedrock ``` @@ -77,7 +78,7 @@ feynman model list feynman model set / ``` -The `model set` command accepts both `provider/model` and `provider:model` formats. Feynman rejects premium Pro-class model IDs here and in `--model`. Exact DeepSeek V4 Pro IDs remain available because the model name does not identify a premium service tier. `feynman model login openrouter` opens the OAuth authorization page. If a remote or headless session cannot receive the loopback callback, copy the browser's final redirect URL or authorization code back into Feynman's prompt to finish sign-in. As an alternative, set `OPENROUTER_API_KEY` before launching Feynman to use API-key authentication without the OAuth flow. `feynman model login google` opens the API-key flow directly, while `feynman model login amazon-bedrock` verifies the AWS credential chain that Pi uses for Bedrock access. +The `model set` command accepts both `provider/model` and `provider:model` formats. Feynman rejects premium Pro-class model IDs here and in `--model`. Exact DeepSeek V4 Pro IDs remain available because the model name does not identify a premium service tier. `feynman model login openrouter` opens the OAuth authorization page. If a remote or headless session cannot receive the loopback callback, copy the browser's final redirect URL or authorization code back into Feynman's prompt to finish sign-in. As an alternative, set `OPENROUTER_API_KEY` before launching Feynman to use API-key authentication without the OAuth flow. `feynman model login requesty` registers the Requesty gateway from `REQUESTY_API_KEY` (or a pasted key) and seeds its model list from Requesty's managed policies; enter `https://router.eu.requesty.ai/v1` as the base URL, or set `REQUESTY_BASE_URL`, for EU routing. `feynman model login google` opens the API-key flow directly, while `feynman model login amazon-bedrock` verifies the AWS credential chain that Pi uses for Bedrock access. ## Web search configuration diff --git a/website/src/content/docs/getting-started/setup.md b/website/src/content/docs/getting-started/setup.md index dd6ddee7..13c10c8c 100644 --- a/website/src/content/docs/getting-started/setup.md +++ b/website/src/content/docs/getting-started/setup.md @@ -49,6 +49,25 @@ Amazon Bedrock (AWS credential chain) Feynman verifies the same AWS credential chain Pi uses at runtime, including `AWS_PROFILE`, `~/.aws` credentials/config, SSO, ECS/IRSA, and EC2 instance roles. Once that check passes, Bedrock models become available in `feynman model list` without needing a traditional API key. +### Requesty + +Requesty is a hosted OpenAI-compatible gateway that routes one API key across 700+ models. Choose the API-key flow, then select: + +```text +Requesty (OpenAI-compatible gateway) +``` + +The default settings are: + +```text +Base URL: https://router.requesty.ai/v1 (or https://router.eu.requesty.ai/v1 for EU routing) +API mode: openai-completions +API key: read from REQUESTY_API_KEY unless you paste one +Model catalog: managed policies, optionally plus the full vendor/model catalog +``` + +Get a key at [app.requesty.ai/api-keys](https://app.requesty.ai/api-keys). Feynman reads Requesty's `/models/managed` endpoint (curated, Requesty-maintained routing policies such as `claude-sonnet-4-5` or `gpt-5.4-mini`) and can also merge the full `/models` catalog (`vendor/model` ids such as `openai/gpt-4o-mini`). Models appear in `feynman model list` as `requesty/`. Set `REQUESTY_BASE_URL` to change the router URL offered by default, for example to the EU router. + ### Local models: LM Studio, LiteLLM, Ollama, vLLM If you want to use LM Studio, start the LM Studio local server, load a model, choose the API-key flow, and then select: diff --git a/website/src/content/docs/reference/cli-commands.md b/website/src/content/docs/reference/cli-commands.md index e5cfb5fa..10cd70b1 100644 --- a/website/src/content/docs/reference/cli-commands.md +++ b/website/src/content/docs/reference/cli-commands.md @@ -70,7 +70,7 @@ PaperRank writes a ranked brief, normalized paper/score JSONL, a score audit, ci | `feynman model logout [id]` | Clear stored auth for a model provider | | `feynman model set ` | Set the default approved research model for all sessions | -These commands manage your model provider configuration. The `model set` command updates `~/.feynman/agent/settings.json` with the new default. It accepts either `provider/model-name` or `provider:model-name`; run `feynman model list` first and choose an approved model ID from that output. For `feynman model login openrouter` over SSH or another headless session, paste the browser's final redirect URL or authorization code into Feynman when the loopback callback is unavailable, or set `OPENROUTER_API_KEY` before launch to use API-key authentication without OAuth. Running `feynman model login google` or `feynman model login amazon-bedrock` routes directly into the relevant API-key setup flow instead of requiring the interactive picker. +These commands manage your model provider configuration. The `model set` command updates `~/.feynman/agent/settings.json` with the new default. It accepts either `provider/model-name` or `provider:model-name`; run `feynman model list` first and choose an approved model ID from that output. For `feynman model login openrouter` over SSH or another headless session, paste the browser's final redirect URL or authorization code into Feynman when the loopback callback is unavailable, or set `OPENROUTER_API_KEY` before launch to use API-key authentication without OAuth. `feynman model login requesty` registers the Requesty OpenAI-compatible gateway (set `REQUESTY_API_KEY`, optionally `REQUESTY_BASE_URL` for the EU router) and seeds its models from Requesty's managed policies. Running `feynman model login google` or `feynman model login amazon-bedrock` routes directly into the relevant API-key setup flow instead of requiring the interactive picker. ## AlphaXiv commands