Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/lovely-jeans-worry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"roo-cline": minor
---

API provider: Choose specific provider when using OpenRouter
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ node_modules
coverage/

.DS_Store
.history/

# Builds
bin/
Expand Down
7 changes: 7 additions & 0 deletions src/api/providers/openrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { getModelParams, SingleCompletionHandler } from ".."
import { BaseProvider } from "./base-provider"
import { defaultHeaders } from "./openai"

const OPENROUTER_DEFAULT_PROVIDER_NAME = "[default]"

// Add custom interface for OpenRouter params.
type OpenRouterChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParams & {
transforms?: string[]
Expand Down Expand Up @@ -109,6 +111,11 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
messages: openAiMessages,
stream: true,
include_reasoning: true,
// Only include provider if openRouterSpecificProvider is not "[default]".
...(this.options.openRouterSpecificProvider &&
this.options.openRouterSpecificProvider !== OPENROUTER_DEFAULT_PROVIDER_NAME && {
provider: { order: [this.options.openRouterSpecificProvider] },
}),
// This way, the transforms field will only be included in the parameters when openRouterUseMiddleOutTransform is true.
...((this.options.openRouterUseMiddleOutTransform ?? true) && { transforms: ["middle-out"] }),
}
Expand Down
2 changes: 2 additions & 0 deletions src/shared/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface ApiHandlerOptions {
openRouterModelId?: string
openRouterModelInfo?: ModelInfo
openRouterBaseUrl?: string
openRouterSpecificProvider?: string
awsAccessKey?: string
awsSecretKey?: string
awsSessionToken?: string
Expand Down Expand Up @@ -93,6 +94,7 @@ export const API_CONFIG_KEYS: GlobalStateKey[] = [
"openRouterModelId",
"openRouterModelInfo",
"openRouterBaseUrl",
"openRouterSpecificProvider",
"awsRegion",
"awsUseCrossRegionInference",
// "awsUsePromptCache", // NOT exist on GlobalStateKey
Expand Down
1 change: 1 addition & 0 deletions src/shared/globalState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const GLOBAL_STATE_KEYS = [
"openRouterModelId",
"openRouterModelInfo",
"openRouterBaseUrl",
"openRouterSpecificProvider",
"openRouterUseMiddleOutTransform",
"allowedCommands",
"soundEnabled",
Expand Down
86 changes: 86 additions & 0 deletions webview-ui/src/components/settings/ApiOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { TemperatureControl } from "./TemperatureControl"
import { validateApiConfiguration, validateModelId } from "@/utils/validate"
import { ApiErrorMessage } from "./ApiErrorMessage"
import { ThinkingBudget } from "./ThinkingBudget"
import { getOpenRouterProvidersForModel, OPENROUTER_DEFAULT_PROVIDER_NAME } from "../../utils/openrouter-helper"

const modelsByProvider: Record<string, Record<string, ModelInfo>> = {
anthropic: anthropicModels,
Expand Down Expand Up @@ -91,6 +92,7 @@ const ApiOptions = ({
errorMessage,
setErrorMessage,
}: ApiOptionsProps) => {
const [openRouterProviders, setOpenRouterProviders] = useState<Record<string, ModelInfo>>({})
const [ollamaModels, setOllamaModels] = useState<string[]>([])
const [lmStudioModels, setLmStudioModels] = useState<string[]>([])
const [vsCodeLmModels, setVsCodeLmModels] = useState<vscodemodels.LanguageModelChatSelector[]>([])
Expand Down Expand Up @@ -186,6 +188,53 @@ const ApiOptions = ({
setErrorMessage(apiValidationResult)
}, [apiConfiguration, glamaModels, openRouterModels, setErrorMessage, unboundModels, requestyModels])

useEffect(() => {
if (
selectedProvider === "openrouter" &&
apiConfiguration?.openRouterModelId &&
apiConfiguration.openRouterModelId in openRouterModels
) {
getOpenRouterProvidersForModel(apiConfiguration.openRouterModelId)
.then((providers) => {
setOpenRouterProviders(providers)
})
.catch((error) => {
console.error("Error fetching OpenRouter providers:", error)
})
}
}, [selectedProvider, apiConfiguration?.openRouterModelId, openRouterModels])

useEffect(() => {
if (selectedProvider === "openrouter" && apiConfiguration?.openRouterSpecificProvider) {
let targetModelInfo

if (apiConfiguration.openRouterSpecificProvider in openRouterProviders) {
const currentProviderInfo = openRouterProviders[apiConfiguration.openRouterSpecificProvider]
targetModelInfo = {
...apiConfiguration.openRouterModelInfo,
...currentProviderInfo,
}
} else {
targetModelInfo = openRouterModels[selectedModelId]
}

const currentModelInfoString = JSON.stringify(apiConfiguration.openRouterModelInfo)
const targetModelInfoString = JSON.stringify(targetModelInfo)

if (targetModelInfoString && currentModelInfoString !== targetModelInfoString) {
setApiConfigurationField("openRouterModelInfo", targetModelInfo)
}
}
}, [
selectedProvider,
apiConfiguration.openRouterSpecificProvider,
openRouterProviders,
openRouterModels,
selectedModelId,
apiConfiguration.openRouterModelInfo,
setApiConfigurationField,
])

const onMessage = useCallback((event: MessageEvent) => {
const message: ExtensionMessage = event.data

Expand Down Expand Up @@ -1213,6 +1262,43 @@ const ApiOptions = ({
/>
)}

{selectedProvider === "openrouter" && (
<>
<div className="dropdown-container" style={{ marginTop: 3 }}>
<label htmlFor="openrouter-specific-provider" className="font-medium">
Provider (more info at{" "}
<a href={`https://openrouter.ai/${selectedModelId}/providers`}>OpenRouter</a>)
</label>
<Dropdown
id="openrouter-specific-provider"
value={apiConfiguration?.openRouterSpecificProvider || ""}
onChange={handleInputChange("openRouterSpecificProvider", dropdownEventTransform)}
style={{ width: "100%" }}
options={[
{ value: OPENROUTER_DEFAULT_PROVIDER_NAME, label: OPENROUTER_DEFAULT_PROVIDER_NAME },
// Add provider options from the openRouterProviders state with more info
...Object.keys(openRouterProviders).map((provider) => {
const providerInfo = openRouterProviders[provider]
const contextWindow = providerInfo?.contextWindow
? `${Math.floor(providerInfo.contextWindow / 1000)}K`
: "?"
const inputPrice = providerInfo?.inputPrice
? `$${providerInfo.inputPrice.toFixed(2)}`
: "?"
const outputPrice = providerInfo?.outputPrice
? `$${providerInfo.outputPrice.toFixed(2)}`
: "?"
return {
value: provider,
label: `${provider} (Context: ${contextWindow}, Price: ${inputPrice} / ${outputPrice})`,
}
}),
]}
/>
</div>
</>
)}

{selectedProvider === "glama" && (
<ModelPicker
apiConfiguration={apiConfiguration}
Expand Down
56 changes: 56 additions & 0 deletions webview-ui/src/utils/openrouter-helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import axios from "axios"
import { ModelInfo } from "../../../src/shared/api"
import { parseApiPrice } from "../../../src/utils/cost"

export const OPENROUTER_DEFAULT_PROVIDER_NAME = "[default]"
export async function getOpenRouterProvidersForModel(modelId: string) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we can DRY things up a here a bit with the getOpenRouterModels function, which does a similar decoration of the model info. It probably also makes sense for getOpenRouterModels to live in this helper file. What do you think? I'm happy to take a stab at that later today as well.

Copy link
Author

Choose a reason for hiding this comment

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

At first I wanted to include the getOpenRouterProvidersForModel function into the \src\api\providers\openrouter.ts file. But since there are no others import from "below webview-ui", I supposed it would be a bad idea.

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

try {
const response = await axios.get(`https://openrouter.ai/api/v1/models/${modelId}/endpoints`)
const rawEndpoints = response.data.data

for (const rawEndpoint of rawEndpoints.endpoints) {
const modelInfo: ModelInfo = {
maxTokens: rawEndpoint.max_completion_tokens,
contextWindow: rawEndpoint.context_length,
supportsImages: rawEndpoints.architecture?.modality?.includes("image"),
supportsPromptCache: false,
inputPrice: parseApiPrice(rawEndpoint.pricing?.prompt),
outputPrice: parseApiPrice(rawEndpoint.pricing?.completion),
description: rawEndpoints.description,
thinking: modelId === "anthropic/claude-3.7-sonnet:thinking",
}

// Set additional properties based on model type
switch (true) {
case modelId.startsWith("anthropic/claude-3.7-sonnet"):
modelInfo.supportsComputerUse = true
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 3.75
modelInfo.cacheReadsPrice = 0.3
modelInfo.maxTokens = rawEndpoint.id === "anthropic/claude-3.7-sonnet:thinking" ? 64_000 : 16_384
break
case modelId.startsWith("anthropic/claude-3.5-sonnet-20240620"):
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 3.75
modelInfo.cacheReadsPrice = 0.3
modelInfo.maxTokens = 8192
break
// Add other cases as needed
default:
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 0.3
modelInfo.cacheReadsPrice = 0.03
break
}

const providerName = rawEndpoint.name.split("|")[0].trim()
models[providerName] = modelInfo
}
} catch (error) {
console.error(`Error fetching OpenRouter providers:`, error)
}

return models
}