Skip to content
Merged
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
4 changes: 2 additions & 2 deletions src/api/providers/__tests__/requesty.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/api/providers/fetchers/__tests__/modelCache.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})

Expand Down
2 changes: 1 addition & 1 deletion src/api/providers/fetchers/modelCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
9 changes: 6 additions & 3 deletions src/api/providers/fetchers/requesty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a specific reason for changing the parameter order to (baseUrl?, apiKey?) instead of keeping apiKey as the first parameter? Most other provider functions have apiKey as the primary parameter. This could be confusing for consistency.

const models: Record<string, ModelInfo> = {}

try {
Expand All @@ -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) {
Expand Down
7 changes: 5 additions & 2 deletions src/api/providers/requesty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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()
}

Expand Down
9 changes: 8 additions & 1 deletion src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
]
Expand Down
2 changes: 1 addition & 1 deletion src/shared/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
136 changes: 136 additions & 0 deletions src/shared/utils/__tests__/requesty.spec.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 56 additions & 0 deletions src/shared/utils/requesty.ts
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions webview-ui/src/components/settings/ApiOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,7 @@ const ApiOptions = ({

{selectedProvider === "requesty" && (
<Requesty
uriScheme={uriScheme}
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
routerModels={routerModels}
Expand Down
Loading
Loading