Просмотр исходного кода

feat: add custom base URL support for Requesty provider (#7337)

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
Co-authored-by: John Costa <[email protected]>
Co-authored-by: Thibault Jaigu <[email protected]>
Daniel 4 месяцев назад
Родитель
Сommit
d175272c76

+ 2 - 2
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",

+ 1 - 1
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)
 	})
 

+ 1 - 1
src/api/providers/fetchers/modelCache.ts

@@ -59,7 +59,7 @@ export const getModels = async (options: GetModelsOptions): Promise<ModelRecord>
 				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()

+ 6 - 3
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<Record<string, ModelInfo>> {
+export async function getRequestyModels(baseUrl?: string, apiKey?: string): Promise<Record<string, ModelInfo>> {
 	const models: Record<string, ModelInfo> = {}
 
 	try {
@@ -14,8 +15,10 @@ export async function getRequestyModels(apiKey?: string): Promise<Record<string,
 			headers["Authorization"] = `Bearer ${apiKey}`
 		}
 
-		const url = "https://router.requesty.ai/v1/models"
-		const response = await axios.get(url, { headers })
+		const resolvedBaseUrl = toRequestyServiceUrl(baseUrl)
+		const modelsUrl = new URL("models", resolvedBaseUrl)
+
+		const response = await axios.get(modelsUrl.toString(), { headers })
 		const rawModels = response.data.data
 
 		for (const rawModel of rawModels) {

+ 5 - 2
src/api/providers/requesty.ts

@@ -15,6 +15,7 @@ import { DEFAULT_HEADERS } from "./constants"
 import { getModels } from "./fetchers/modelCache"
 import { BaseProvider } from "./base-provider"
 import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
+import { toRequestyServiceUrl } from "../../shared/utils/requesty"
 
 // Requesty usage includes an extra field for Anthropic use cases.
 // Safely cast the prompt token details section to the appropriate structure.
@@ -40,21 +41,23 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan
 	protected options: ApiHandlerOptions
 	protected models: ModelRecord = {}
 	private client: OpenAI
+	private baseURL: string
 
 	constructor(options: ApiHandlerOptions) {
 		super()
 
 		this.options = options
+		this.baseURL = toRequestyServiceUrl(options.requestyBaseUrl)
 
 		this.client = new OpenAI({
-			baseURL: options.requestyBaseUrl || "https://router.requesty.ai/v1",
+			baseURL: this.baseURL,
 			apiKey: this.options.requestyApiKey ?? "not-provided",
 			defaultHeaders: DEFAULT_HEADERS,
 		})
 	}
 
 	public async fetchModel() {
-		this.models = await getModels({ provider: "requesty" })
+		this.models = await getModels({ provider: "requesty", baseUrl: this.baseURL })
 		return this.getModel()
 	}
 

+ 8 - 1
src/core/webview/webviewMessageHandler.ts

@@ -565,7 +565,14 @@ export const webviewMessageHandler = async (
 
 			const modelFetchPromises: Array<{ key: RouterName; options: GetModelsOptions }> = [
 				{ 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 } },
 			]

+ 1 - 1
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 }

+ 136 - 0
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:[email protected]/v1")
+			expect(result).toBe("https://user:[email protected]/v1")
+		})
+	})
+})

+ 56 - 0
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)
+	}
+}

+ 1 - 0
webview-ui/src/components/settings/ApiOptions.tsx

@@ -424,6 +424,7 @@ const ApiOptions = ({
 
 			{selectedProvider === "requesty" && (
 				<Requesty
+					uriScheme={uriScheme}
 					apiConfiguration={apiConfiguration}
 					setApiConfigurationField={setApiConfigurationField}
 					routerModels={routerModels}

+ 29 - 7
webview-ui/src/components/settings/providers/Requesty.tsx

@@ -8,12 +8,13 @@ import type { RouterModels } from "@roo/api"
 
 import { vscode } from "@src/utils/vscode"
 import { useAppTranslation } from "@src/i18n/TranslationContext"
-import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink"
 import { Button } from "@src/components/ui"
 
 import { inputEventTransform } from "../transforms"
 import { ModelPicker } from "../ModelPicker"
 import { RequestyBalanceDisplay } from "./RequestyBalanceDisplay"
+import { getCallbackUrl } from "@/oauth/urls"
+import { toRequestyServiceUrl } from "@roo/utils/requesty"
 
 type RequestyProps = {
 	apiConfiguration: ProviderSettings
@@ -22,6 +23,7 @@ type RequestyProps = {
 	refetchRouterModels: () => 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 (
 		<>
 			<VSCodeTextField
@@ -65,7 +77,10 @@ export const Requesty = ({
 				<div className="flex justify-between items-center mb-1">
 					<label className="block font-medium">{t("settings:providers.requestyApiKey")}</label>
 					{apiConfiguration?.requestyApiKey && (
-						<RequestyBalanceDisplay apiKey={apiConfiguration.requestyApiKey} />
+						<RequestyBalanceDisplay
+							baseUrl={apiConfiguration.requestyBaseUrl}
+							apiKey={apiConfiguration.requestyApiKey}
+						/>
 					)}
 				</div>
 			</VSCodeTextField>
@@ -73,12 +88,19 @@ export const Requesty = ({
 				{t("settings:providers.apiKeyStorageNotice")}
 			</div>
 			{!apiConfiguration?.requestyApiKey && (
-				<VSCodeButtonLink
-					href="https://app.requesty.ai/api-keys"
-					style={{ width: "100%" }}
-					appearance="primary">
+				<a
+					href={getApiKeyUrl()}
+					target="_blank"
+					rel="noopener noreferrer"
+					className="inline-flex items-center justify-center whitespace-nowrap text-sm font-medium focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-9 rounded-md px-3 w-full"
+					style={{
+						width: "100%",
+						textDecoration: "none",
+						color: "var(--vscode-button-foreground)",
+						backgroundColor: "var(--vscode-button-background)",
+					}}>
 					{t("settings:providers.getRequestyApiKey")}
-				</VSCodeButtonLink>
+				</a>
 			)}
 
 			<VSCodeCheckbox

+ 12 - 3
webview-ui/src/components/settings/providers/RequestyBalanceDisplay.tsx

@@ -1,9 +1,15 @@
 import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
 
 import { useRequestyKeyInfo } from "@/components/ui/hooks/useRequestyKeyInfo"
+import { toRequestyServiceUrl } from "@roo/utils/requesty"
 
-export const RequestyBalanceDisplay = ({ apiKey }: { apiKey: string }) => {
-	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 (
-		<VSCodeLink href="https://app.requesty.ai/settings" className="text-vscode-foreground hover:underline">
+		<VSCodeLink href={settingsUrl.toString()} className="text-vscode-foreground hover:underline">
 			${formattedBalance}
 		</VSCodeLink>
 	)

+ 9 - 5
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<typeof requestyKeyInfoSchema>
 
-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<UseQueryOptions<RequestyKeyInfo | null>, "queryKey" | "queryFn">
-export const useRequestyKeyInfo = (apiKey?: string, options?: UseRequestyKeyInfoOptions) => {
+export const useRequestyKeyInfo = (baseUrl?: string, apiKey?: string, options?: UseRequestyKeyInfoOptions) => {
 	return useQuery<RequestyKeyInfo | null>({
-		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,