diff --git a/src/api/providers/__tests__/requesty.spec.ts b/src/api/providers/__tests__/requesty.spec.ts index 72d4aa790b..4d5037ed9e 100644 --- a/src/api/providers/__tests__/requesty.spec.ts +++ b/src/api/providers/__tests__/requesty.spec.ts @@ -66,11 +66,11 @@ describe("RequestyHandler", () => { }) it("can use a base URL instead of the default", () => { - const handler = new RequestyHandler({ ...mockOptions, requestyBaseUrl: "some-base-url" }) + const handler = new RequestyHandler({ ...mockOptions, requestyBaseUrl: "https://custom.requesty.ai/v1" }) expect(handler).toBeInstanceOf(RequestyHandler) expect(OpenAI).toHaveBeenCalledWith({ - baseURL: "some-base-url", + baseURL: "https://custom.requesty.ai/v1", apiKey: mockOptions.requestyApiKey, defaultHeaders: { "HTTP-Referer": "https://github.com/RooVetGit/Roo-Cline", diff --git a/src/api/providers/fetchers/__tests__/modelCache.spec.ts b/src/api/providers/fetchers/__tests__/modelCache.spec.ts index 21f5ce8bff..2a72ef1cc5 100644 --- a/src/api/providers/fetchers/__tests__/modelCache.spec.ts +++ b/src/api/providers/fetchers/__tests__/modelCache.spec.ts @@ -103,7 +103,7 @@ describe("getModels with new GetModelsOptions", () => { const result = await getModels({ provider: "requesty", apiKey: DUMMY_REQUESTY_KEY }) - expect(mockGetRequestyModels).toHaveBeenCalledWith(DUMMY_REQUESTY_KEY) + expect(mockGetRequestyModels).toHaveBeenCalledWith(undefined, DUMMY_REQUESTY_KEY) expect(result).toEqual(mockModels) }) diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index a21e75ded9..f4c240a61c 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -59,7 +59,7 @@ export const getModels = async (options: GetModelsOptions): Promise break case "requesty": // Requesty models endpoint requires an API key for per-user custom policies - models = await getRequestyModels(options.apiKey) + models = await getRequestyModels(options.baseUrl, options.apiKey) break case "glama": models = await getGlamaModels() diff --git a/src/api/providers/fetchers/requesty.ts b/src/api/providers/fetchers/requesty.ts index e339dae1aa..308539f2e4 100644 --- a/src/api/providers/fetchers/requesty.ts +++ b/src/api/providers/fetchers/requesty.ts @@ -3,8 +3,9 @@ import axios from "axios" import type { ModelInfo } from "@roo-code/types" import { parseApiPrice } from "../../../shared/cost" +import { toRequestyServiceUrl } from "../../../shared/utils/requesty" -export async function getRequestyModels(apiKey?: string): Promise> { +export async function getRequestyModels(baseUrl?: string, apiKey?: string): Promise> { const models: Record = {} try { @@ -14,8 +15,10 @@ export async function getRequestyModels(apiKey?: string): Promise = [ { key: "openrouter", options: { provider: "openrouter" } }, - { key: "requesty", options: { provider: "requesty", apiKey: apiConfiguration.requestyApiKey } }, + { + key: "requesty", + options: { + provider: "requesty", + apiKey: apiConfiguration.requestyApiKey, + baseUrl: apiConfiguration.requestyBaseUrl, + }, + }, { key: "glama", options: { provider: "glama" } }, { key: "unbound", options: { provider: "unbound", apiKey: apiConfiguration.unboundApiKey } }, ] diff --git a/src/shared/api.ts b/src/shared/api.ts index 274779fc16..3dde992c6f 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -145,7 +145,7 @@ export const getModelMaxOutputTokens = ({ export type GetModelsOptions = | { provider: "openrouter" } | { provider: "glama" } - | { provider: "requesty"; apiKey?: string } + | { provider: "requesty"; apiKey?: string; baseUrl?: string } | { provider: "unbound"; apiKey?: string } | { provider: "litellm"; apiKey: string; baseUrl: string } | { provider: "ollama"; baseUrl?: string } diff --git a/src/shared/utils/__tests__/requesty.spec.ts b/src/shared/utils/__tests__/requesty.spec.ts new file mode 100644 index 0000000000..eddb639843 --- /dev/null +++ b/src/shared/utils/__tests__/requesty.spec.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { toRequestyServiceUrl } from "../requesty" + +describe("toRequestyServiceUrl", () => { + beforeEach(() => { + vi.clearAllMocks() + // Mock console.warn to avoid noise in test output + vi.spyOn(console, "warn").mockImplementation(() => {}) + }) + + describe("with default parameters", () => { + it("should return default router URL when no baseUrl provided", () => { + const result = toRequestyServiceUrl() + expect(result).toBe("https://router.requesty.ai/v1") + }) + + it("should return default router URL when baseUrl is undefined", () => { + const result = toRequestyServiceUrl(undefined) + expect(result).toBe("https://router.requesty.ai/v1") + }) + + it("should return default router URL when baseUrl is empty string", () => { + const result = toRequestyServiceUrl("") + expect(result).toBe("https://router.requesty.ai/v1") + }) + }) + + describe("with custom baseUrl", () => { + it("should use custom baseUrl for router service", () => { + const result = toRequestyServiceUrl("https://custom.requesty.ai/v1") + expect(result).toBe("https://custom.requesty.ai/v1") + }) + + it("should handle baseUrl with trailing slash", () => { + const result = toRequestyServiceUrl("https://custom.requesty.ai/v1/") + expect(result).toBe("https://custom.requesty.ai/v1/") + }) + + it("should handle baseUrl without path", () => { + const result = toRequestyServiceUrl("https://custom.requesty.ai") + expect(result).toBe("https://custom.requesty.ai/") + }) + + it("should handle localhost URLs", () => { + const result = toRequestyServiceUrl("http://localhost:8080/v1") + expect(result).toBe("http://localhost:8080/v1") + }) + + it("should handle URLs with ports", () => { + const result = toRequestyServiceUrl("https://custom.requesty.ai:3000/v1") + expect(result).toBe("https://custom.requesty.ai:3000/v1") + }) + }) + + describe("with different service types", () => { + it("should return router URL for router service", () => { + const result = toRequestyServiceUrl("https://router.requesty.ai/v1", "router") + expect(result).toBe("https://router.requesty.ai/v1") + }) + + it("should replace router with app and remove v1 for app service", () => { + const result = toRequestyServiceUrl("https://router.requesty.ai/v1", "app") + expect(result).toBe("https://app.requesty.ai/") + }) + + it("should replace router with api and remove v1 for api service", () => { + const result = toRequestyServiceUrl("https://router.requesty.ai/v1", "api") + expect(result).toBe("https://api.requesty.ai/") + }) + + it("should handle custom baseUrl with app service", () => { + const result = toRequestyServiceUrl("https://router.custom.ai/v1", "app") + expect(result).toBe("https://app.custom.ai/") + }) + + it("should handle URLs where router appears multiple times", () => { + const result = toRequestyServiceUrl("https://router.router-requesty.ai/v1", "app") + // This will replace the first occurrence only + expect(result).toBe("https://app.router-requesty.ai/") + }) + }) + + describe("error handling", () => { + it("should fall back to default URL for invalid baseUrl", () => { + const result = toRequestyServiceUrl("not-a-valid-url") + expect(result).toBe("https://router.requesty.ai/v1") + expect(console.warn).toHaveBeenCalledWith('Invalid base URL "not-a-valid-url", falling back to default') + }) + + it("should fall back to default URL for malformed URL", () => { + const result = toRequestyServiceUrl("ht!tp://[invalid") + expect(result).toBe("https://router.requesty.ai/v1") + expect(console.warn).toHaveBeenCalled() + }) + + it("should fall back to default app URL for invalid baseUrl with app service", () => { + const result = toRequestyServiceUrl("invalid-url", "app") + expect(result).toBe("https://app.requesty.ai/") + expect(console.warn).toHaveBeenCalled() + }) + + it("should handle null baseUrl gracefully", () => { + const result = toRequestyServiceUrl(null as any) + expect(result).toBe("https://router.requesty.ai/v1") + }) + + it("should handle non-string baseUrl gracefully", () => { + const result = toRequestyServiceUrl(123 as any) + expect(result).toBe("https://router.requesty.ai/v1") + }) + }) + + describe("edge cases", () => { + it("should handle protocol-relative URLs by falling back to default", () => { + const result = toRequestyServiceUrl("//custom.requesty.ai/v1") + // Protocol-relative URLs are not valid for URL constructor, will fall back + expect(result).toBe("https://router.requesty.ai/v1") + expect(console.warn).toHaveBeenCalled() + }) + + it("should preserve query parameters", () => { + const result = toRequestyServiceUrl("https://custom.requesty.ai/v1?key=value") + expect(result).toBe("https://custom.requesty.ai/v1?key=value") + }) + + it("should preserve URL fragments", () => { + const result = toRequestyServiceUrl("https://custom.requesty.ai/v1#section") + expect(result).toBe("https://custom.requesty.ai/v1#section") + }) + + it("should handle URLs with authentication", () => { + const result = toRequestyServiceUrl("https://user:pass@custom.requesty.ai/v1") + expect(result).toBe("https://user:pass@custom.requesty.ai/v1") + }) + }) +}) diff --git a/src/shared/utils/requesty.ts b/src/shared/utils/requesty.ts new file mode 100644 index 0000000000..b5d73e629f --- /dev/null +++ b/src/shared/utils/requesty.ts @@ -0,0 +1,56 @@ +const REQUESTY_BASE_URL = "https://router.requesty.ai/v1" + +type URLType = "router" | "app" | "api" + +/** + * Replaces the service type in the URL (router -> app/api) and removes version suffix for non-router services + * @param baseUrl The base URL to transform + * @param type The service type to use + * @returns The transformed URL + */ +const replaceCname = (baseUrl: string, type: URLType): string => { + if (type === "router") { + return baseUrl + } + + // Parse the URL to safely replace the subdomain + try { + const url = new URL(baseUrl) + // Replace 'router' in the hostname with the service type + if (url.hostname.includes("router")) { + url.hostname = url.hostname.replace("router", type) + } + // Remove '/v1' from the pathname for non-router services + if (url.pathname.endsWith("/v1")) { + url.pathname = url.pathname.slice(0, -3) + } + return url.toString() + } catch { + // Fallback to simple string replacement if URL parsing fails + return baseUrl.replace("router", type).replace("/v1", "") + } +} + +/** + * Converts a base URL to a Requesty service URL with proper validation and fallback + * @param baseUrl Optional custom base URL. Falls back to default if invalid or not provided + * @param service The service type (router, app, or api). Defaults to 'router' + * @returns A valid Requesty service URL + */ +export const toRequestyServiceUrl = (baseUrl?: string | null, service: URLType = "router"): string => { + // Handle null, undefined, empty string, or non-string values + const urlToUse = baseUrl && typeof baseUrl === "string" && baseUrl.trim() ? baseUrl.trim() : REQUESTY_BASE_URL + + try { + // Validate the URL first + const validatedUrl = new URL(urlToUse).toString() + // Apply service type transformation + return replaceCname(validatedUrl, service) + } catch (error) { + // If the provided baseUrl is invalid, fall back to the default + if (baseUrl && baseUrl !== REQUESTY_BASE_URL) { + console.warn(`Invalid base URL "${baseUrl}", falling back to default`) + } + return replaceCname(REQUESTY_BASE_URL, service) + } +} diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 36a25613d0..13a8ab4848 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -424,6 +424,7 @@ const ApiOptions = ({ {selectedProvider === "requesty" && ( void organizationAllowList: OrganizationAllowList modelValidationError?: string + uriScheme?: string } export const Requesty = ({ @@ -31,6 +33,7 @@ export const Requesty = ({ refetchRouterModels, organizationAllowList, modelValidationError, + uriScheme, }: RequestyProps) => { const { t } = useAppTranslation() @@ -54,6 +57,15 @@ export const Requesty = ({ [setApiConfigurationField], ) + const getApiKeyUrl = () => { + const callbackUrl = getCallbackUrl("requesty", uriScheme) + const baseUrl = toRequestyServiceUrl(apiConfiguration.requestyBaseUrl, "app") + + const authUrl = new URL(`oauth/authorize?callback_url=${callbackUrl}`, baseUrl) + + return authUrl.toString() + } + return ( <> {apiConfiguration?.requestyApiKey && ( - + )} @@ -73,12 +88,19 @@ export const Requesty = ({ {t("settings:providers.apiKeyStorageNotice")} {!apiConfiguration?.requestyApiKey && ( - + {t("settings:providers.getRequestyApiKey")} - + )} { - const { data: keyInfo } = useRequestyKeyInfo(apiKey) +type RequestyBalanceDisplayProps = { + apiKey: string + baseUrl?: string +} + +export const RequestyBalanceDisplay = ({ baseUrl, apiKey }: RequestyBalanceDisplayProps) => { + const { data: keyInfo } = useRequestyKeyInfo(baseUrl, apiKey) if (!keyInfo) { return null @@ -13,8 +19,11 @@ export const RequestyBalanceDisplay = ({ apiKey }: { apiKey: string }) => { const balance = parseFloat(keyInfo.org_balance) const formattedBalance = balance.toFixed(2) + const resolvedBaseUrl = toRequestyServiceUrl(baseUrl, "app") + const settingsUrl = new URL("settings", resolvedBaseUrl) + return ( - + ${formattedBalance} ) diff --git a/webview-ui/src/components/ui/hooks/useRequestyKeyInfo.ts b/webview-ui/src/components/ui/hooks/useRequestyKeyInfo.ts index e44aa5fcd1..dd61c17eee 100644 --- a/webview-ui/src/components/ui/hooks/useRequestyKeyInfo.ts +++ b/webview-ui/src/components/ui/hooks/useRequestyKeyInfo.ts @@ -1,6 +1,7 @@ import axios from "axios" import { z } from "zod" import { useQuery, UseQueryOptions } from "@tanstack/react-query" +import { toRequestyServiceUrl } from "@roo/utils/requesty" const requestyKeyInfoSchema = z.object({ name: z.string(), @@ -14,11 +15,14 @@ const requestyKeyInfoSchema = z.object({ export type RequestyKeyInfo = z.infer -async function getRequestyKeyInfo(apiKey?: string) { +async function getRequestyKeyInfo(baseUrl?: string, apiKey?: string) { if (!apiKey) return null + const url = toRequestyServiceUrl(baseUrl, "api") + const apiKeyUrl = new URL("x/apikey", url) + try { - const response = await axios.get("https://api.requesty.ai/x/apikey", { + const response = await axios.get(apiKeyUrl.toString(), { headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", @@ -39,10 +43,10 @@ async function getRequestyKeyInfo(apiKey?: string) { } type UseRequestyKeyInfoOptions = Omit, "queryKey" | "queryFn"> -export const useRequestyKeyInfo = (apiKey?: string, options?: UseRequestyKeyInfoOptions) => { +export const useRequestyKeyInfo = (baseUrl?: string, apiKey?: string, options?: UseRequestyKeyInfoOptions) => { return useQuery({ - queryKey: ["requesty-key-info", apiKey], - queryFn: () => getRequestyKeyInfo(apiKey), + queryKey: ["requesty-key-info", baseUrl, apiKey], + queryFn: () => getRequestyKeyInfo(baseUrl, apiKey), staleTime: 30 * 1000, // 30 seconds enabled: !!apiKey, ...options,