diff --git a/src/agent/core/domain/llm/registry.ts b/src/agent/core/domain/llm/registry.ts index bc37b4083..cd4b8e447 100644 --- a/src/agent/core/domain/llm/registry.ts +++ b/src/agent/core/domain/llm/registry.ts @@ -735,6 +735,7 @@ export function resolveRegistryProvider( 'openai-compatible', 'openrouter', 'perplexity', + 'requesty', 'togetherai', 'vercel', 'xai', diff --git a/src/agent/infra/llm/providers/index.ts b/src/agent/infra/llm/providers/index.ts index d6cdef877..682644cd6 100644 --- a/src/agent/infra/llm/providers/index.ts +++ b/src/agent/infra/llm/providers/index.ts @@ -26,6 +26,7 @@ import {openaiCompatibleProvider} from './openai-compatible.js' import {openaiProvider} from './openai.js' import {openrouterProvider} from './openrouter.js' import {perplexityProvider} from './perplexity.js' +import {requestyProvider} from './requesty.js' import {togetheraiProvider} from './togetherai.js' import {vercelProvider} from './vercel.js' import {xaiProvider} from './xai.js' @@ -52,6 +53,7 @@ const PROVIDER_MODULES: Readonly> = { 'openai-compatible': openaiCompatibleProvider, openrouter: openrouterProvider, perplexity: perplexityProvider, + requesty: requestyProvider, togetherai: togetheraiProvider, vercel: vercelProvider, xai: xaiProvider, diff --git a/src/agent/infra/llm/providers/requesty.ts b/src/agent/infra/llm/providers/requesty.ts new file mode 100644 index 000000000..45dd64eea --- /dev/null +++ b/src/agent/infra/llm/providers/requesty.ts @@ -0,0 +1,47 @@ +/** + * Requesty Provider Module + * + * Access 300+ models via the Requesty router using its OpenAI-compatible API. + * Requesty exposes an OpenAI-shaped endpoint at https://router.requesty.ai/v1, + * so it is wired through @ai-sdk/openai-compatible. + */ + +import {createOpenAICompatible} from '@ai-sdk/openai-compatible' + +import type {GeneratorFactoryConfig, ProviderModule} from './types.js' + +import {AiSdkContentGenerator} from '../generators/ai-sdk-content-generator.js' + +const REQUESTY_BASE_URL = 'https://router.requesty.ai/v1' + +export const requestyProvider: ProviderModule = { + apiKeyUrl: 'https://app.requesty.ai/api-keys', + authType: 'api-key', + baseUrl: REQUESTY_BASE_URL, + category: 'popular', + createGenerator(config: GeneratorFactoryConfig) { + const provider = createOpenAICompatible({ + apiKey: config.apiKey || '', + baseURL: config.baseUrl || REQUESTY_BASE_URL, + headers: { + 'HTTP-Referer': config.httpReferer ?? 'https://byterover.dev', + 'X-Title': config.siteName ?? 'byterover-cli', + ...config.headers, + }, + name: 'requesty', + }) + + return new AiSdkContentGenerator({ + model: provider.chatModel(config.model), + requestTimeoutMs: config.requestTimeoutMs, + }) + }, + defaultModel: 'openai/gpt-4o-mini', + description: 'Access 300+ models via the Requesty router', + envVars: ['REQUESTY_API_KEY'], + id: 'requesty', + name: 'Requesty', + priority: 1.5, + + providerType: 'openai', +} diff --git a/src/server/core/domain/entities/provider-registry.ts b/src/server/core/domain/entities/provider-registry.ts index aff9fe1d6..bbd92f956 100644 --- a/src/server/core/domain/entities/provider-registry.ts +++ b/src/server/core/domain/entities/provider-registry.ts @@ -321,6 +321,22 @@ export const PROVIDER_REGISTRY: Readonly> = { name: 'Perplexity', priority: 13, }, + requesty: { + apiKeyUrl: 'https://app.requesty.ai/api-keys', + baseUrl: 'https://router.requesty.ai/v1', + category: 'popular', + defaultModel: 'openai/gpt-4o-mini', + description: 'Access 300+ models via the Requesty router', + envVars: ['REQUESTY_API_KEY'], + headers: { + 'HTTP-Referer': 'https://byterover.dev', + 'X-Title': 'byterover-cli', + }, + id: 'requesty', + modelsEndpoint: '/models', + name: 'Requesty', + priority: 1.5, + }, togetherai: { apiKeyUrl: 'https://api.together.ai/settings/api-keys', baseUrl: 'https://api.together.xyz/v1', diff --git a/src/server/infra/http/provider-model-fetcher-registry.ts b/src/server/infra/http/provider-model-fetcher-registry.ts index 027e0aee9..c13516b47 100644 --- a/src/server/infra/http/provider-model-fetcher-registry.ts +++ b/src/server/infra/http/provider-model-fetcher-registry.ts @@ -16,6 +16,7 @@ import { OpenAICompatibleModelFetcher, OpenAIModelFetcher, OpenRouterModelFetcher, + RequestyModelFetcher, } from './provider-model-fetchers.js' /** @@ -134,6 +135,12 @@ export async function getModelFetcher(providerId: string): Promise { + const client = getRequestyApiClient() + const models = await client.fetchModels(apiKey, options?.forceRefresh ?? false) + return models.map((m: RequestyNormalizedModel) => ({ + contextLength: m.contextLength, + description: m.description, + id: m.id, + isFree: m.isFree, + name: m.name, + pricing: m.pricing, + provider: m.provider, + })) + } + + async validateApiKey(apiKey: string): Promise<{error?: string; isValid: boolean}> { + const client = getRequestyApiClient() + return client.validateApiKey(apiKey) + } +} + // ============================================================================ // Shared helpers // ============================================================================ diff --git a/src/server/infra/http/requesty-api-client.ts b/src/server/infra/http/requesty-api-client.ts new file mode 100644 index 000000000..e6af2cdbf --- /dev/null +++ b/src/server/infra/http/requesty-api-client.ts @@ -0,0 +1,263 @@ +/** + * Requesty API Client + * + * Handles API calls to Requesty for: + * - Fetching available models + * - Validating API keys + * + * Uses the Requesty REST API (OpenAI-compatible): https://router.requesty.ai/v1 + */ + +import axios, {isAxiosError} from 'axios' + +import {ProxyConfig} from './proxy-config.js' + +/** + * Requesty model from the /models endpoint. + * Requesty is OpenAI-compatible and exposes models in the OpenAI shape. + */ +export interface RequestyModel { + /** Supported modalities */ + architecture?: { + instruct_type?: string + modality: string // e.g., 'text->text', 'text+image->text' + tokenizer: string + } + /** Context length (max tokens) */ + context_length: number + /** Description */ + description?: string + /** Model ID (e.g., 'openai/gpt-4o-mini') */ + id: string + /** Display name */ + name: string + /** Per-request limits */ + per_request_limits?: { + completion_tokens?: string + prompt_tokens?: string + } + /** Pricing per token (as string) */ + pricing: { + completion: string // USD per output token (as string) + prompt: string // USD per input token (as string) + } + /** Top provider info */ + top_provider?: { + context_length?: number + is_moderated?: boolean + max_completion_tokens?: number + } +} + +/** + * Response from Requesty /models endpoint. + */ +interface ModelsResponse { + data: RequestyModel[] +} + +/** + * Normalized model for use in the application. + */ +export interface NormalizedModel { + /** Context window size */ + contextLength: number + /** Optional description */ + description?: string + /** Model ID (e.g., 'openai/gpt-4o-mini') */ + id: string + /** Whether this model is free */ + isFree: boolean + /** Display name */ + name: string + /** Pricing per million tokens */ + pricing: { + inputPerM: number + outputPerM: number + } + /** Provider name extracted from ID (e.g., 'openai') */ + provider: string +} + +/** + * Cache entry for models. + */ +interface ModelCache { + models: NormalizedModel[] + timestamp: number +} + +/** + * Requesty API client configuration. + */ +export interface RequestyApiClientConfig { + /** Base URL for Requesty API */ + baseUrl?: string + /** Cache TTL in milliseconds (default: 1 hour) */ + cacheTtlMs?: number + /** HTTP Referer header */ + httpReferer?: string + /** X-Title header */ + xTitle?: string +} + +const DEFAULT_BASE_URL = 'https://router.requesty.ai/v1' +const DEFAULT_CACHE_TTL = 60 * 60 * 1000 // 1 hour + +/** + * Requesty API Client. + * + * Provides methods to interact with the Requesty API for fetching models + * and validating API keys. + * + * @example + * ```typescript + * const client = new RequestyApiClient() + * + * // Validate API key + * const isValid = await client.validateApiKey('...') + * + * // Fetch models + * const models = await client.fetchModels('...') + * ``` + */ +export class RequestyApiClient { + private readonly baseUrl: string + private readonly cacheTtlMs: number + private readonly httpReferer?: string + private modelCache: ModelCache | undefined + private readonly xTitle?: string + + public constructor(config: RequestyApiClientConfig = {}) { + this.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL + this.cacheTtlMs = config.cacheTtlMs ?? DEFAULT_CACHE_TTL + this.httpReferer = config.httpReferer ?? 'https://byterover.dev' + this.xTitle = config.xTitle ?? 'byterover-cli' + } + + /** + * Clears the model cache. + */ + public clearCache(): void { + this.modelCache = undefined + } + + /** + * Fetches available models from Requesty. + * Results are cached for the configured TTL. + * + * @param apiKey - The API key to use + * @param forceRefresh - If true, bypasses cache + * @returns Array of normalized models + */ + public async fetchModels(apiKey: string, forceRefresh = false): Promise { + // Check cache + if (!forceRefresh && this.modelCache && Date.now() - this.modelCache.timestamp < this.cacheTtlMs) { + return this.modelCache.models + } + + const models = await this.fetchModelsInternal(apiKey) + + // Update cache + this.modelCache = { + models, + timestamp: Date.now(), + } + + return models + } + + /** + * Validates an API key by attempting to fetch models. + * + * @param apiKey - The API key to validate + * @returns Object with isValid flag and optional error message + */ + public async validateApiKey(apiKey: string): Promise<{error?: string; isValid: boolean}> { + try { + await this.fetchModelsInternal(apiKey) + return {isValid: true} + } catch (error) { + if (isAxiosError(error)) { + if (error.response?.status === 401) { + return {error: 'Invalid API key', isValid: false} + } + + if (error.response?.status === 403) { + return {error: 'API key does not have required permissions', isValid: false} + } + + return {error: `API error: ${error.response?.statusText ?? error.message}`, isValid: false} + } + + return {error: error instanceof Error ? error.message : 'Unknown error', isValid: false} + } + } + + /** + * Internal method to fetch models from Requesty API. + */ + private async fetchModelsInternal(apiKey: string): Promise { + const response = await axios.get(`${this.baseUrl}/models`, { + headers: { + Authorization: `Bearer ${apiKey}`, + 'HTTP-Referer': this.httpReferer, + 'X-Title': this.xTitle, + }, + httpAgent: ProxyConfig.getProxyAgent(), + httpsAgent: ProxyConfig.getProxyAgent(), + proxy: false, + timeout: 30_000, + }) + + return response.data.data.map((model) => this.normalizeModel(model)) + } + + /** + * Normalizes a Requesty model to our standard format. + */ + private normalizeModel(model: RequestyModel): NormalizedModel { + // Extract provider from model ID (e.g., 'openai' from 'openai/gpt-4o-mini') + const [provider, ...nameParts] = model.id.split('/') + const shortName = nameParts.join('/') || model.id + + // Parse pricing (convert from string to number) + // Requesty returns price per token, multiply by 1M to get price per million tokens + const inputPricePerToken = Number.parseFloat(model.pricing.prompt) || 0 + const outputPricePerToken = Number.parseFloat(model.pricing.completion) || 0 + const inputPerM = inputPricePerToken * 1_000_000 + const outputPerM = outputPricePerToken * 1_000_000 + + // Check if free (both prices are 0) + const isFree = inputPricePerToken === 0 && outputPricePerToken === 0 + + return { + contextLength: model.context_length, + description: model.description, + id: model.id, + isFree, + name: model.name || shortName, + pricing: { + inputPerM, + outputPerM, + }, + provider: `Requesty (${provider})`, + } + } +} + +/** + * Singleton instance of the Requesty API client. + */ +let _requestyApiClient: RequestyApiClient | undefined + +/** + * Gets or creates the singleton Requesty API client. + */ +export function getRequestyApiClient(): RequestyApiClient { + if (!_requestyApiClient) { + _requestyApiClient = new RequestyApiClient() + } + + return _requestyApiClient +} diff --git a/src/server/infra/provider/provider-config-resolver.ts b/src/server/infra/provider/provider-config-resolver.ts index 6790b00b9..7792324ba 100644 --- a/src/server/infra/provider/provider-config-resolver.ts +++ b/src/server/infra/provider/provider-config-resolver.ts @@ -154,6 +154,21 @@ export async function resolveProviderConfig( } } + case 'requesty': { + const providerDef = getProviderById(activeProvider) + const headers = providerDef?.headers + return { + activeModel, + activeProvider, + maxInputTokens, + provider: activeProvider, + providerApiKey: apiKey || undefined, + providerBaseUrl: config.getBaseUrl(activeProvider) || providerDef?.baseUrl || undefined, + providerHeaders: headers && Object.keys(headers).length > 0 ? {...headers} : undefined, + providerKeyMissing: providerRequiresApiKey(activeProvider) && !apiKey, + } + } + default: { const providerDef = getProviderById(activeProvider) const providerConfig = config.providers[activeProvider]