Skip to content

Commit d175272

Browse files
daniel-lxsellipsis-dev[bot]requesty-JohnCosta27Thibault00
authored
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]>
1 parent 262033d commit d175272

File tree

13 files changed

+267
-26
lines changed

13 files changed

+267
-26
lines changed

src/api/providers/__tests__/requesty.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,11 @@ describe("RequestyHandler", () => {
6666
})
6767

6868
it("can use a base URL instead of the default", () => {
69-
const handler = new RequestyHandler({ ...mockOptions, requestyBaseUrl: "some-base-url" })
69+
const handler = new RequestyHandler({ ...mockOptions, requestyBaseUrl: "https://custom.requesty.ai/v1" })
7070
expect(handler).toBeInstanceOf(RequestyHandler)
7171

7272
expect(OpenAI).toHaveBeenCalledWith({
73-
baseURL: "some-base-url",
73+
baseURL: "https://custom.requesty.ai/v1",
7474
apiKey: mockOptions.requestyApiKey,
7575
defaultHeaders: {
7676
"HTTP-Referer": "https://github.com/RooVetGit/Roo-Cline",

src/api/providers/fetchers/__tests__/modelCache.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ describe("getModels with new GetModelsOptions", () => {
103103

104104
const result = await getModels({ provider: "requesty", apiKey: DUMMY_REQUESTY_KEY })
105105

106-
expect(mockGetRequestyModels).toHaveBeenCalledWith(DUMMY_REQUESTY_KEY)
106+
expect(mockGetRequestyModels).toHaveBeenCalledWith(undefined, DUMMY_REQUESTY_KEY)
107107
expect(result).toEqual(mockModels)
108108
})
109109

src/api/providers/fetchers/modelCache.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export const getModels = async (options: GetModelsOptions): Promise<ModelRecord>
5959
break
6060
case "requesty":
6161
// Requesty models endpoint requires an API key for per-user custom policies
62-
models = await getRequestyModels(options.apiKey)
62+
models = await getRequestyModels(options.baseUrl, options.apiKey)
6363
break
6464
case "glama":
6565
models = await getGlamaModels()

src/api/providers/fetchers/requesty.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import axios from "axios"
33
import type { ModelInfo } from "@roo-code/types"
44

55
import { parseApiPrice } from "../../../shared/cost"
6+
import { toRequestyServiceUrl } from "../../../shared/utils/requesty"
67

7-
export async function getRequestyModels(apiKey?: string): Promise<Record<string, ModelInfo>> {
8+
export async function getRequestyModels(baseUrl?: string, apiKey?: string): Promise<Record<string, ModelInfo>> {
89
const models: Record<string, ModelInfo> = {}
910

1011
try {
@@ -14,8 +15,10 @@ export async function getRequestyModels(apiKey?: string): Promise<Record<string,
1415
headers["Authorization"] = `Bearer ${apiKey}`
1516
}
1617

17-
const url = "https://router.requesty.ai/v1/models"
18-
const response = await axios.get(url, { headers })
18+
const resolvedBaseUrl = toRequestyServiceUrl(baseUrl)
19+
const modelsUrl = new URL("models", resolvedBaseUrl)
20+
21+
const response = await axios.get(modelsUrl.toString(), { headers })
1922
const rawModels = response.data.data
2023

2124
for (const rawModel of rawModels) {

src/api/providers/requesty.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { DEFAULT_HEADERS } from "./constants"
1515
import { getModels } from "./fetchers/modelCache"
1616
import { BaseProvider } from "./base-provider"
1717
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
18+
import { toRequestyServiceUrl } from "../../shared/utils/requesty"
1819

1920
// Requesty usage includes an extra field for Anthropic use cases.
2021
// Safely cast the prompt token details section to the appropriate structure.
@@ -40,21 +41,23 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan
4041
protected options: ApiHandlerOptions
4142
protected models: ModelRecord = {}
4243
private client: OpenAI
44+
private baseURL: string
4345

4446
constructor(options: ApiHandlerOptions) {
4547
super()
4648

4749
this.options = options
50+
this.baseURL = toRequestyServiceUrl(options.requestyBaseUrl)
4851

4952
this.client = new OpenAI({
50-
baseURL: options.requestyBaseUrl || "https://router.requesty.ai/v1",
53+
baseURL: this.baseURL,
5154
apiKey: this.options.requestyApiKey ?? "not-provided",
5255
defaultHeaders: DEFAULT_HEADERS,
5356
})
5457
}
5558

5659
public async fetchModel() {
57-
this.models = await getModels({ provider: "requesty" })
60+
this.models = await getModels({ provider: "requesty", baseUrl: this.baseURL })
5861
return this.getModel()
5962
}
6063

src/core/webview/webviewMessageHandler.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -565,7 +565,14 @@ export const webviewMessageHandler = async (
565565

566566
const modelFetchPromises: Array<{ key: RouterName; options: GetModelsOptions }> = [
567567
{ key: "openrouter", options: { provider: "openrouter" } },
568-
{ key: "requesty", options: { provider: "requesty", apiKey: apiConfiguration.requestyApiKey } },
568+
{
569+
key: "requesty",
570+
options: {
571+
provider: "requesty",
572+
apiKey: apiConfiguration.requestyApiKey,
573+
baseUrl: apiConfiguration.requestyBaseUrl,
574+
},
575+
},
569576
{ key: "glama", options: { provider: "glama" } },
570577
{ key: "unbound", options: { provider: "unbound", apiKey: apiConfiguration.unboundApiKey } },
571578
]

src/shared/api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ export const getModelMaxOutputTokens = ({
145145
export type GetModelsOptions =
146146
| { provider: "openrouter" }
147147
| { provider: "glama" }
148-
| { provider: "requesty"; apiKey?: string }
148+
| { provider: "requesty"; apiKey?: string; baseUrl?: string }
149149
| { provider: "unbound"; apiKey?: string }
150150
| { provider: "litellm"; apiKey: string; baseUrl: string }
151151
| { provider: "ollama"; baseUrl?: string }
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest"
2+
import { toRequestyServiceUrl } from "../requesty"
3+
4+
describe("toRequestyServiceUrl", () => {
5+
beforeEach(() => {
6+
vi.clearAllMocks()
7+
// Mock console.warn to avoid noise in test output
8+
vi.spyOn(console, "warn").mockImplementation(() => {})
9+
})
10+
11+
describe("with default parameters", () => {
12+
it("should return default router URL when no baseUrl provided", () => {
13+
const result = toRequestyServiceUrl()
14+
expect(result).toBe("https://router.requesty.ai/v1")
15+
})
16+
17+
it("should return default router URL when baseUrl is undefined", () => {
18+
const result = toRequestyServiceUrl(undefined)
19+
expect(result).toBe("https://router.requesty.ai/v1")
20+
})
21+
22+
it("should return default router URL when baseUrl is empty string", () => {
23+
const result = toRequestyServiceUrl("")
24+
expect(result).toBe("https://router.requesty.ai/v1")
25+
})
26+
})
27+
28+
describe("with custom baseUrl", () => {
29+
it("should use custom baseUrl for router service", () => {
30+
const result = toRequestyServiceUrl("https://custom.requesty.ai/v1")
31+
expect(result).toBe("https://custom.requesty.ai/v1")
32+
})
33+
34+
it("should handle baseUrl with trailing slash", () => {
35+
const result = toRequestyServiceUrl("https://custom.requesty.ai/v1/")
36+
expect(result).toBe("https://custom.requesty.ai/v1/")
37+
})
38+
39+
it("should handle baseUrl without path", () => {
40+
const result = toRequestyServiceUrl("https://custom.requesty.ai")
41+
expect(result).toBe("https://custom.requesty.ai/")
42+
})
43+
44+
it("should handle localhost URLs", () => {
45+
const result = toRequestyServiceUrl("http://localhost:8080/v1")
46+
expect(result).toBe("http://localhost:8080/v1")
47+
})
48+
49+
it("should handle URLs with ports", () => {
50+
const result = toRequestyServiceUrl("https://custom.requesty.ai:3000/v1")
51+
expect(result).toBe("https://custom.requesty.ai:3000/v1")
52+
})
53+
})
54+
55+
describe("with different service types", () => {
56+
it("should return router URL for router service", () => {
57+
const result = toRequestyServiceUrl("https://router.requesty.ai/v1", "router")
58+
expect(result).toBe("https://router.requesty.ai/v1")
59+
})
60+
61+
it("should replace router with app and remove v1 for app service", () => {
62+
const result = toRequestyServiceUrl("https://router.requesty.ai/v1", "app")
63+
expect(result).toBe("https://app.requesty.ai/")
64+
})
65+
66+
it("should replace router with api and remove v1 for api service", () => {
67+
const result = toRequestyServiceUrl("https://router.requesty.ai/v1", "api")
68+
expect(result).toBe("https://api.requesty.ai/")
69+
})
70+
71+
it("should handle custom baseUrl with app service", () => {
72+
const result = toRequestyServiceUrl("https://router.custom.ai/v1", "app")
73+
expect(result).toBe("https://app.custom.ai/")
74+
})
75+
76+
it("should handle URLs where router appears multiple times", () => {
77+
const result = toRequestyServiceUrl("https://router.router-requesty.ai/v1", "app")
78+
// This will replace the first occurrence only
79+
expect(result).toBe("https://app.router-requesty.ai/")
80+
})
81+
})
82+
83+
describe("error handling", () => {
84+
it("should fall back to default URL for invalid baseUrl", () => {
85+
const result = toRequestyServiceUrl("not-a-valid-url")
86+
expect(result).toBe("https://router.requesty.ai/v1")
87+
expect(console.warn).toHaveBeenCalledWith('Invalid base URL "not-a-valid-url", falling back to default')
88+
})
89+
90+
it("should fall back to default URL for malformed URL", () => {
91+
const result = toRequestyServiceUrl("ht!tp://[invalid")
92+
expect(result).toBe("https://router.requesty.ai/v1")
93+
expect(console.warn).toHaveBeenCalled()
94+
})
95+
96+
it("should fall back to default app URL for invalid baseUrl with app service", () => {
97+
const result = toRequestyServiceUrl("invalid-url", "app")
98+
expect(result).toBe("https://app.requesty.ai/")
99+
expect(console.warn).toHaveBeenCalled()
100+
})
101+
102+
it("should handle null baseUrl gracefully", () => {
103+
const result = toRequestyServiceUrl(null as any)
104+
expect(result).toBe("https://router.requesty.ai/v1")
105+
})
106+
107+
it("should handle non-string baseUrl gracefully", () => {
108+
const result = toRequestyServiceUrl(123 as any)
109+
expect(result).toBe("https://router.requesty.ai/v1")
110+
})
111+
})
112+
113+
describe("edge cases", () => {
114+
it("should handle protocol-relative URLs by falling back to default", () => {
115+
const result = toRequestyServiceUrl("//custom.requesty.ai/v1")
116+
// Protocol-relative URLs are not valid for URL constructor, will fall back
117+
expect(result).toBe("https://router.requesty.ai/v1")
118+
expect(console.warn).toHaveBeenCalled()
119+
})
120+
121+
it("should preserve query parameters", () => {
122+
const result = toRequestyServiceUrl("https://custom.requesty.ai/v1?key=value")
123+
expect(result).toBe("https://custom.requesty.ai/v1?key=value")
124+
})
125+
126+
it("should preserve URL fragments", () => {
127+
const result = toRequestyServiceUrl("https://custom.requesty.ai/v1#section")
128+
expect(result).toBe("https://custom.requesty.ai/v1#section")
129+
})
130+
131+
it("should handle URLs with authentication", () => {
132+
const result = toRequestyServiceUrl("https://user:[email protected]/v1")
133+
expect(result).toBe("https://user:[email protected]/v1")
134+
})
135+
})
136+
})

src/shared/utils/requesty.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
const REQUESTY_BASE_URL = "https://router.requesty.ai/v1"
2+
3+
type URLType = "router" | "app" | "api"
4+
5+
/**
6+
* Replaces the service type in the URL (router -> app/api) and removes version suffix for non-router services
7+
* @param baseUrl The base URL to transform
8+
* @param type The service type to use
9+
* @returns The transformed URL
10+
*/
11+
const replaceCname = (baseUrl: string, type: URLType): string => {
12+
if (type === "router") {
13+
return baseUrl
14+
}
15+
16+
// Parse the URL to safely replace the subdomain
17+
try {
18+
const url = new URL(baseUrl)
19+
// Replace 'router' in the hostname with the service type
20+
if (url.hostname.includes("router")) {
21+
url.hostname = url.hostname.replace("router", type)
22+
}
23+
// Remove '/v1' from the pathname for non-router services
24+
if (url.pathname.endsWith("/v1")) {
25+
url.pathname = url.pathname.slice(0, -3)
26+
}
27+
return url.toString()
28+
} catch {
29+
// Fallback to simple string replacement if URL parsing fails
30+
return baseUrl.replace("router", type).replace("/v1", "")
31+
}
32+
}
33+
34+
/**
35+
* Converts a base URL to a Requesty service URL with proper validation and fallback
36+
* @param baseUrl Optional custom base URL. Falls back to default if invalid or not provided
37+
* @param service The service type (router, app, or api). Defaults to 'router'
38+
* @returns A valid Requesty service URL
39+
*/
40+
export const toRequestyServiceUrl = (baseUrl?: string | null, service: URLType = "router"): string => {
41+
// Handle null, undefined, empty string, or non-string values
42+
const urlToUse = baseUrl && typeof baseUrl === "string" && baseUrl.trim() ? baseUrl.trim() : REQUESTY_BASE_URL
43+
44+
try {
45+
// Validate the URL first
46+
const validatedUrl = new URL(urlToUse).toString()
47+
// Apply service type transformation
48+
return replaceCname(validatedUrl, service)
49+
} catch (error) {
50+
// If the provided baseUrl is invalid, fall back to the default
51+
if (baseUrl && baseUrl !== REQUESTY_BASE_URL) {
52+
console.warn(`Invalid base URL "${baseUrl}", falling back to default`)
53+
}
54+
return replaceCname(REQUESTY_BASE_URL, service)
55+
}
56+
}

webview-ui/src/components/settings/ApiOptions.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,7 @@ const ApiOptions = ({
424424

425425
{selectedProvider === "requesty" && (
426426
<Requesty
427+
uriScheme={uriScheme}
427428
apiConfiguration={apiConfiguration}
428429
setApiConfigurationField={setApiConfigurationField}
429430
routerModels={routerModels}

0 commit comments

Comments
 (0)