Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
23 changes: 23 additions & 0 deletions src/shared/utils/requesty.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
const REQUESTY_BASE_URL = "https://router.requesty.ai/v1"

type URLType = "router" | "app" | "api"

const replaceCname = (baseUrl: string, type: URLType): string => {
if (type === "router") {
return baseUrl
} else {
return baseUrl.replace("router", type).replace("v1", "")
Copy link
Contributor

Choose a reason for hiding this comment

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

The replaceCname function uses simple string replacement (.replace) for both 'router' and 'v1'. While this works for the expected default URL, if a custom base URL contains these substrings in an unexpected location, it may yield unintended results. Consider using more precise matching (e.g. regex with boundaries) to ensure only the intended parts are replaced.

Suggested change
return baseUrl.replace("router", type).replace("v1", "")
return baseUrl.replace(/\brouter\b/, type).replace(/\/v1\b/, "")

}
}

export const toRequestyServiceUrl = (baseUrl?: string, service: URLType = "router"): string => {
let url = replaceCname(baseUrl ?? REQUESTY_BASE_URL, service)

try {
return new URL(url).toString()
} catch (error) {
// If the provided baseUrl is invalid, fall back to the default
console.warn(`Invalid base URL "${baseUrl}", falling back to default`)
return new URL(replaceCname(REQUESTY_BASE_URL, service)).toString()
}
}
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
36 changes: 29 additions & 7 deletions webview-ui/src/components/settings/providers/Requesty.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,6 +23,7 @@ type RequestyProps = {
refetchRouterModels: () => void
organizationAllowList: OrganizationAllowList
modelValidationError?: string
uriScheme?: string
}

export const Requesty = ({
Expand All @@ -31,6 +33,7 @@ export const Requesty = ({
refetchRouterModels,
organizationAllowList,
modelValidationError,
uriScheme,
}: RequestyProps) => {
const { t } = useAppTranslation()

Expand All @@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

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

Is the OAuth path oauth/authorize?callback_url= guaranteed to be the same for all custom Requesty instances? If custom instances might have different OAuth endpoints, this should be configurable or documented.


return authUrl.toString()
}

return (
<>
<VSCodeTextField
Expand All @@ -65,20 +77,30 @@ 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>
<div className="text-sm text-vscode-descriptionForeground -mt-2">
{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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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>
)
Expand Down
14 changes: 9 additions & 5 deletions webview-ui/src/components/ui/hooks/useRequestyKeyInfo.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand All @@ -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",
Expand All @@ -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,
Expand Down
Loading