Compare commits

...
Author SHA1 Message Date
Arafatkatze 756d8c9eb8 refactor(api): switch cline provider to vercel ai gateway stream
- Update `ClineHandler` to use `createVercelAIGatewayStream` instead of `createOpenRouterStream`
- Remove `openRouterProviderSorting` parameter from handler options and API calls
- Add specific provider routing logic for Claude Sonnet 1M in `vercel-ai-gateway-stream.ts` to prefer Anthropic with Google Vertex fallback
- Update `kat-coder-pro` model ID in the OpenRouter model picker configuration
2026-01-06 07:50:06 -08:00
Arafatkatze d88703dc2c adding changes with debug logs 2026-01-05 23:17:50 -08:00
Arafatkatze 2ef9553769 chore: remove debug console.log statements from Vercel AI Gateway PR 2026-01-05 23:15:43 -08:00
Arafatkatze b28664e294 feat: add Vercel AI Gateway model support for plan and act modes
Add planModeVercelAiGatewayModelId, planModeVercelAiGatewayModelInfo,
actModeVercelAiGatewayModelId, and actModeVercelAiGatewayModelInfo
configuration options across StateManager, proto conversions, and
API configuration types to enable Vercel AI Gateway provider selection
in both plan and act modes.
2026-01-05 22:18:44 -08:00
Arafatkatze 43779a21f0 feat: add Vercel AI Gateway model support to ModelPickerModal
- Add vercelAiGatewayModels to the component's context
- Use separate model source for Vercel provider vs OpenRouter
- Handle Vercel-specific model field updates when selecting models
- Update memoization dependencies to include vercelAiGatewayModels
2026-01-05 21:26:17 -08:00
Arafatkatze cfa7fa09e3 fix: use dedicated Vercel AI Gateway model fields instead of OpenRouter fields
Replace openRouterModelId/openRouterModelInfo with vercelAiGatewayModelId/
vercelAiGatewayModelInfo in the Vercel AI Gateway model picker and mode
sync configuration to properly separate provider-specific settings.
2026-01-05 21:14:20 -08:00
Arafatkatze 3c9b270a19 feat(vercel-ai-gateway): add model refresh and improve reasoning support
- Add refreshVercelAiGatewayModelsRpc to proto and implement model
  fetching from Vercel AI Gateway API
- Support reasoning effort, thinking budget, and Gemini thinking level
  parameters for extended thinking models
- Skip reasoning content for models that don't support it (devstral, grok-4)
- Add R1 format conversion for DeepSeek reasoning models
- Create ModelPicker component with fuzzy search for model selection
- Wire up model refresh functionality in webview settings
2026-01-05 21:12:40 -08:00
20 changed files with 723 additions and 98 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
feat(vercel-ai-gateway): add model refresh and improve reasoning support
+2
View File
@@ -49,6 +49,8 @@ service ModelsService {
rpc refreshOcaModels(StringRequest) returns (OcaCompatibleModelInfo);
// Fetches available models from AIhubmix
rpc getAihubmixModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
// Refreshes and returns Vercel AI Gateway models
rpc refreshVercelAiGatewayModelsRpc(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
}
// List of VS Code LM models
+4 -3
View File
@@ -255,9 +255,10 @@ function createHandlerForProvider(
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
openRouterProviderSorting: options.openRouterProviderSorting,
openRouterModelId: mode === "plan" ? options.planModeOpenRouterModelId : options.actModeOpenRouterModelId,
openRouterModelInfo: mode === "plan" ? options.planModeOpenRouterModelInfo : options.actModeOpenRouterModelInfo,
vercelAiGatewayModelId:
mode === "plan" ? options.planModeVercelAiGatewayModelId : options.actModeVercelAiGatewayModelId,
vercelAiGatewayModelInfo:
mode === "plan" ? options.planModeVercelAiGatewayModelInfo : options.actModeVercelAiGatewayModelInfo,
geminiThinkingLevel: mode === "plan" ? options.geminiPlanModeThinkingLevel : options.geminiActModeThinkingLevel,
})
case "litellm":
+14 -12
View File
@@ -1,4 +1,4 @@
import { ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "@shared/api"
import { ModelInfo } from "@shared/api"
import { shouldSkipReasoningForModel } from "@utils/model-utils"
import axios from "axios"
import OpenAI from "openai"
@@ -13,9 +13,9 @@ import { ClineStorageMessage } from "@/shared/messages/content"
import { fetch, getAxiosSettings } from "@/shared/net"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
import { createOpenRouterStream } from "../transform/openrouter-stream"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { ToolCallProcessor } from "../transform/tool-call-processor"
import { createVercelAIGatewayStream } from "../transform/vercel-ai-gateway-stream"
import { OpenRouterErrorResponse } from "./types"
interface ClineHandlerOptions extends CommonApiHandlerOptions {
@@ -23,9 +23,8 @@ interface ClineHandlerOptions extends CommonApiHandlerOptions {
taskId?: string
reasoningEffort?: string
thinkingBudgetTokens?: number
openRouterProviderSorting?: string
openRouterModelId?: string
openRouterModelInfo?: ModelInfo
vercelAiGatewayModelId?: string
vercelAiGatewayModelInfo?: ModelInfo
clineAccountId?: string
geminiThinkingLevel?: string
}
@@ -107,14 +106,13 @@ export class ClineHandler implements ApiHandler {
let didOutputUsage: boolean = false
const stream = await createOpenRouterStream(
const stream = await createVercelAIGatewayStream(
client,
systemPrompt,
messages,
this.getModel(),
this.options.reasoningEffort,
this.options.thinkingBudgetTokens,
this.options.openRouterProviderSorting,
tools,
this.options.geminiThinkingLevel,
)
@@ -167,7 +165,11 @@ export class ClineHandler implements ApiHandler {
// Reasoning tokens are returned separately from the content
// Skip reasoning content for Grok 4 models since it only displays "thinking" without providing useful information
if ("reasoning" in delta && delta.reasoning && !shouldSkipReasoningForModel(this.options.openRouterModelId)) {
if (
"reasoning" in delta &&
delta.reasoning &&
!shouldSkipReasoningForModel(this.options.vercelAiGatewayModelId)
) {
yield {
type: "reasoning",
reasoning: typeof delta.reasoning === "string" ? delta.reasoning : JSON.stringify(delta.reasoning),
@@ -186,7 +188,7 @@ export class ClineHandler implements ApiHandler {
delta.reasoning_details &&
// @ts-ignore-next-line
delta?.reasoning_details?.length && // exists and non-0
!shouldSkipReasoningForModel(this.options.openRouterModelId)
!shouldSkipReasoningForModel(this.options.vercelAiGatewayModelId)
) {
yield {
type: "reasoning",
@@ -272,11 +274,11 @@ export class ClineHandler implements ApiHandler {
}
getModel(): { id: string; info: ModelInfo } {
const modelId = this.options.openRouterModelId
const modelInfo = this.options.openRouterModelInfo
const modelId = this.options.vercelAiGatewayModelId
const modelInfo = this.options.vercelAiGatewayModelInfo
if (modelId && modelInfo) {
return { id: modelId, info: modelInfo }
}
return { id: openRouterDefaultModelId, info: openRouterDefaultModelInfo }
throw new Error("No Vercel AI Gateway model configured. Please select a model in settings.")
}
}
+10 -2
View File
@@ -1,4 +1,5 @@
import { ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "@shared/api"
import { shouldSkipReasoningForModel } from "@utils/model-utils"
import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { ClineStorageMessage } from "@/shared/messages/content"
@@ -13,7 +14,9 @@ interface VercelAIGatewayHandlerOptions extends CommonApiHandlerOptions {
vercelAiGatewayApiKey?: string
openRouterModelId?: string
openRouterModelInfo?: ModelInfo
reasoningEffort?: string
thinkingBudgetTokens?: number
geminiThinkingLevel?: string
}
export class VercelAIGatewayHandler implements ApiHandler {
@@ -58,8 +61,10 @@ export class VercelAIGatewayHandler implements ApiHandler {
systemPrompt,
messages,
{ id: modelId, info: modelInfo },
this.options.reasoningEffort,
this.options.thinkingBudgetTokens,
tools,
this.options.geminiThinkingLevel,
)
let didOutputUsage: boolean = false
@@ -67,6 +72,7 @@ export class VercelAIGatewayHandler implements ApiHandler {
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
yield {
type: "text",
@@ -79,7 +85,8 @@ export class VercelAIGatewayHandler implements ApiHandler {
}
// Reasoning tokens are returned separately from the content
if ("reasoning" in delta && delta.reasoning) {
// Skip reasoning content for models that don't support it (e.g., devstral, grok-4)
if ("reasoning" in delta && delta.reasoning && !shouldSkipReasoningForModel(this.options.openRouterModelId)) {
yield {
type: "reasoning",
reasoning: typeof delta.reasoning === "string" ? delta.reasoning : JSON.stringify(delta.reasoning),
@@ -91,7 +98,8 @@ export class VercelAIGatewayHandler implements ApiHandler {
"reasoning_details" in delta &&
delta.reasoning_details &&
// @ts-ignore-next-line
delta.reasoning_details.length // exists and non-0
delta.reasoning_details.length && // exists and non-0
!shouldSkipReasoningForModel(this.options.openRouterModelId)
) {
yield {
type: "reasoning",
@@ -5,9 +5,11 @@ import {
openRouterClaudeSonnet41mModelId,
openRouterClaudeSonnet451mModelId,
} from "@shared/api"
import { shouldSkipReasoningForModel } from "@utils/model-utils"
import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { convertToR1Format } from "./r1-format"
import { getOpenAIToolParams } from "./tool-call-processor"
export async function createVercelAIGatewayStream(
@@ -15,77 +17,206 @@ export async function createVercelAIGatewayStream(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
model: { id: string; info: ModelInfo },
reasoningEffort?: string,
thinkingBudgetTokens?: number,
tools?: OpenAITool[],
geminiThinkingLevel?: string,
) {
// Convert Anthropic messages to OpenAI format
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
const isClaudeSonnet1m = model.id === openRouterClaudeSonnet41mModelId || model.id === openRouterClaudeSonnet451mModelId
if (isClaudeSonnet1m) {
// remove the custom :1m suffix, to create the model id openrouter API expects
// remove the custom :1m suffix, to create the model id the API expects
model.id = model.id.slice(0, -CLAUDE_SONNET_1M_SUFFIX.length)
}
// Gemini models require thought signatures for tool calls. When switching providers mid-conversation,
// historical tool calls may not include Gemini reasoning details, which can poison the next request.
// Bandaid: for Gemini only, drop tool_calls that lack reasoning_details and their paired tool messages.
if (model.id.includes("gemini")) {
const droppedToolCallIds = new Set<string>()
const sanitized: OpenAI.Chat.ChatCompletionMessageParam[] = []
for (const msg of openAiMessages) {
if (msg.role === "assistant") {
const anyMsg = msg as any
const toolCalls = anyMsg.tool_calls
if (Array.isArray(toolCalls) && toolCalls.length > 0) {
const reasoningDetails = anyMsg.reasoning_details
const hasReasoningDetails = Array.isArray(reasoningDetails) && reasoningDetails.length > 0
if (!hasReasoningDetails) {
for (const tc of toolCalls) {
if (tc?.id) droppedToolCallIds.add(tc.id)
}
// Keep any textual content, but drop the tool_calls themselves.
if (anyMsg.content) {
sanitized.push({ role: "assistant", content: anyMsg.content } as any)
}
continue
}
}
}
if (msg.role === "tool") {
const anyMsg = msg as any
if (anyMsg.tool_call_id && droppedToolCallIds.has(anyMsg.tool_call_id)) {
continue
}
}
sanitized.push(msg)
}
openAiMessages = sanitized
}
// Prompt caching for supported models
// This handles cache_control for Claude and MiniMax models
const isAnthropicModel = model.id.startsWith("anthropic/")
const isMinimaxModel = model.id.startsWith("minimax/")
if (isAnthropicModel || isMinimaxModel) {
openAiMessages[0] = {
role: "system",
content: systemPrompt,
// @ts-ignore-next-line
cache_control: { type: "ephemeral" },
content: [
{
type: "text",
text: systemPrompt,
// @ts-ignore-next-line
cache_control: { type: "ephemeral" },
},
],
}
// Add cache_control to the last two user messages for conversation context caching
// Add cache_control to the last two user messages
// (note: this works because we only ever add one user message at a time, but if we added multiple we'd need to mark the user message before the last assistant message)
const lastTwoUserMessages = openAiMessages.filter((msg) => msg.role === "user").slice(-2)
lastTwoUserMessages.forEach((msg) => {
if (typeof msg.content === "string" && msg.content.length > 0) {
if (typeof msg.content === "string") {
msg.content = [{ type: "text", text: msg.content }]
}
if (Array.isArray(msg.content)) {
// Find the last text part in the message content
const lastTextPart = msg.content.filter((part) => part.type === "text").pop()
// NOTE: this is fine since env details will always be added at the end. but if it weren't there, and the user added a image_url type message, it would pop a text part before it and then move it after to the end.
let lastTextPart = msg.content.filter((part) => part.type === "text").pop()
if (lastTextPart && lastTextPart.text && lastTextPart.text.length > 0) {
// @ts-ignore-next-line
lastTextPart["cache_control"] = { type: "ephemeral" }
if (!lastTextPart) {
lastTextPart = { type: "text", text: "..." }
msg.content.push(lastTextPart)
}
// @ts-ignore-next-line
lastTextPart["cache_control"] = { type: "ephemeral" }
}
})
}
// Configure reasoning parameters similar to OpenRouter
let temperature: number | undefined = 0
let reasoning: { max_tokens: number } | undefined
if (isAnthropicModel) {
const budget_tokens = thinkingBudgetTokens || 0
const reasoningOn = budget_tokens !== 0
if (reasoningOn) {
temperature = undefined // extended thinking does not support non-1 temperature
reasoning = { max_tokens: budget_tokens }
}
} else if (thinkingBudgetTokens && model.info?.thinkingConfig && thinkingBudgetTokens > 0) {
temperature = undefined // extended thinking does not support non-1 temperature
reasoning = { max_tokens: thinkingBudgetTokens }
// Model-specific max tokens
// Not sure how the API defaults max tokens when no value is provided, but the anthropic api requires this value and since they offer both 4096 and 8192 variants, we should ensure 8192.
let maxTokens: number | undefined
switch (model.id) {
case "anthropic/claude-haiku-4.5":
case "anthropic/claude-4.5-haiku":
case "anthropic/claude-sonnet-4.5":
case "anthropic/claude-4.5-sonnet":
case "anthropic/claude-sonnet-4":
case "anthropic/claude-opus-4.5":
case "anthropic/claude-opus-4.1":
case "anthropic/claude-opus-4":
case "anthropic/claude-3.7-sonnet":
case "anthropic/claude-3.7-sonnet:beta":
case "anthropic/claude-3.7-sonnet:thinking":
case "anthropic/claude-3-7-sonnet":
case "anthropic/claude-3-7-sonnet:beta":
case "anthropic/claude-3.5-sonnet":
case "anthropic/claude-3.5-sonnet:beta":
case "anthropic/claude-3.5-sonnet-20240620":
case "anthropic/claude-3.5-sonnet-20240620:beta":
case "anthropic/claude-3-5-haiku":
case "anthropic/claude-3-5-haiku:beta":
case "anthropic/claude-3-5-haiku-20241022":
case "anthropic/claude-3-5-haiku-20241022:beta":
maxTokens = 8_192
break
}
// Model-specific temperature and topP settings
let temperature: number | undefined = 0
let topP: number | undefined
if (
model.id.startsWith("deepseek/deepseek-r1") ||
model.id === "perplexity/sonar-reasoning" ||
model.id === "qwen/qwq-32b:free" ||
model.id === "qwen/qwq-32b"
) {
// Recommended values from DeepSeek
temperature = 0.7
topP = 0.95
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
}
if (model.id.startsWith("google/gemini-3.0") || model.id === "google/gemini-3.0") {
// Recommended value from google
temperature = 1.0
}
// Reasoning/thinking budget configuration
let reasoning: { max_tokens: number } | undefined
switch (model.id) {
case "anthropic/claude-haiku-4.5":
case "anthropic/claude-4.5-haiku":
case "anthropic/claude-sonnet-4.5":
case "anthropic/claude-4.5-sonnet":
case "anthropic/claude-sonnet-4":
case "anthropic/claude-opus-4.5":
case "anthropic/claude-opus-4.1":
case "anthropic/claude-opus-4":
case "anthropic/claude-3.7-sonnet":
case "anthropic/claude-3.7-sonnet:beta":
case "anthropic/claude-3.7-sonnet:thinking":
case "anthropic/claude-3-7-sonnet":
case "anthropic/claude-3-7-sonnet:beta":
const budget_tokens = thinkingBudgetTokens || 0
const reasoningOn = budget_tokens !== 0
if (reasoningOn) {
temperature = undefined // extended thinking does not support non-1 temperature
reasoning = { max_tokens: budget_tokens }
}
break
default:
if (
thinkingBudgetTokens &&
model.info?.thinkingConfig &&
thinkingBudgetTokens > 0 &&
!(model.id.includes("gemini") && geminiThinkingLevel)
) {
temperature = undefined // extended thinking does not support non-1 temperature
reasoning = { max_tokens: thinkingBudgetTokens }
break
}
}
// Skip reasoning for models that don't support it (e.g., devstral, grok-4)
const includeReasoning = !shouldSkipReasoningForModel(model.id)
// @ts-ignore-next-line
const stream = await client.chat.completions.create({
model: model.id,
max_tokens: model.info.maxTokens,
max_tokens: maxTokens,
temperature: temperature,
top_p: topP,
messages: openAiMessages,
stream: true,
stream_options: { include_usage: true },
include_reasoning: true,
include_reasoning: includeReasoning,
...(model.id.startsWith("openai/o") ? { reasoning_effort: reasoningEffort || "medium" } : {}),
...(reasoning ? { reasoning } : {}),
...getOpenAIToolParams(tools),
...(model.id.includes("gemini") && geminiThinkingLevel
? { thinking_config: { thinking_level: geminiThinkingLevel, include_thoughts: true } }
: {}),
// Claude Sonnet 1M provider routing - prefer Anthropic, fallback to Google Vertex
...(isClaudeSonnet1m ? { providerOptions: { providers: { order: ["anthropic", "google-vertex"] } } } : {}),
})
return stream
@@ -18,7 +18,7 @@ export async function refreshVercelAiGatewayModels(_controller: Controller): Pro
let models: Record<string, ModelInfo> = {}
try {
const response = await axios.get("https://ai-gateway.vercel.sh/v1/models", getAxiosSettings())
const response = await axios.get("https://ai-gateway.vercel.sh/v1/models?include_mappings=true", getAxiosSettings())
if (response.data?.data) {
const rawModels = response.data.data
+18
View File
@@ -603,6 +603,8 @@ export class StateManager {
planModeAihubmixModelId,
planModeAihubmixModelInfo,
planModeNousResearchModelId,
planModeVercelAiGatewayModelId,
planModeVercelAiGatewayModelInfo,
geminiPlanModeThinkingLevel,
// Act mode configurations
actModeApiProvider,
@@ -641,6 +643,8 @@ export class StateManager {
actModeAihubmixModelId,
actModeAihubmixModelInfo,
actModeNousResearchModelId,
actModeVercelAiGatewayModelId,
actModeVercelAiGatewayModelInfo,
geminiActModeThinkingLevel,
} = apiConfiguration
@@ -683,6 +687,8 @@ export class StateManager {
planModeAihubmixModelId,
planModeAihubmixModelInfo,
planModeNousResearchModelId,
planModeVercelAiGatewayModelId,
planModeVercelAiGatewayModelInfo,
geminiPlanModeThinkingLevel,
// Act mode configuration updates
@@ -722,6 +728,8 @@ export class StateManager {
actModeAihubmixModelId,
actModeAihubmixModelInfo,
actModeNousResearchModelId,
actModeVercelAiGatewayModelId,
actModeVercelAiGatewayModelInfo,
geminiActModeThinkingLevel,
// Global state updates
@@ -1280,6 +1288,11 @@ export class StateManager {
this.taskStateCache["planModeAihubmixModelInfo"] || this.globalStateCache["planModeAihubmixModelInfo"],
planModeNousResearchModelId:
this.taskStateCache["planModeNousResearchModelId"] || this.globalStateCache["planModeNousResearchModelId"],
planModeVercelAiGatewayModelId:
this.taskStateCache["planModeVercelAiGatewayModelId"] || this.globalStateCache["planModeVercelAiGatewayModelId"],
planModeVercelAiGatewayModelInfo:
this.taskStateCache["planModeVercelAiGatewayModelInfo"] ||
this.globalStateCache["planModeVercelAiGatewayModelInfo"],
geminiPlanModeThinkingLevel:
this.taskStateCache["geminiPlanModeThinkingLevel"] || this.globalStateCache["geminiPlanModeThinkingLevel"],
@@ -1350,6 +1363,11 @@ export class StateManager {
this.taskStateCache["actModeAihubmixModelInfo"] || this.globalStateCache["actModeAihubmixModelInfo"],
actModeNousResearchModelId:
this.taskStateCache["actModeNousResearchModelId"] || this.globalStateCache["actModeNousResearchModelId"],
actModeVercelAiGatewayModelId:
this.taskStateCache["actModeVercelAiGatewayModelId"] || this.globalStateCache["actModeVercelAiGatewayModelId"],
actModeVercelAiGatewayModelInfo:
this.taskStateCache["actModeVercelAiGatewayModelInfo"] ||
this.globalStateCache["actModeVercelAiGatewayModelInfo"],
geminiActModeThinkingLevel:
this.taskStateCache["geminiActModeThinkingLevel"] || this.globalStateCache["geminiActModeThinkingLevel"],
nousResearchApiKey: this.secretsCache["nousResearchApiKey"],
+14
View File
@@ -402,6 +402,11 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
context.globalState.get<GlobalStateAndSettings["planModeAihubmixModelInfo"]>("planModeAihubmixModelInfo")
const planModeNousResearchModelId =
context.globalState.get<GlobalStateAndSettings["planModeNousResearchModelId"]>("planModeNousResearchModelId")
const planModeVercelAiGatewayModelId =
context.globalState.get<GlobalStateAndSettings["planModeVercelAiGatewayModelId"]>("planModeVercelAiGatewayModelId")
const planModeVercelAiGatewayModelInfo = context.globalState.get<
GlobalStateAndSettings["planModeVercelAiGatewayModelInfo"]
>("planModeVercelAiGatewayModelInfo")
// Act mode configurations
const actModeApiProvider = context.globalState.get<GlobalStateAndSettings["actModeApiProvider"]>("actModeApiProvider")
const actModeApiModelId = context.globalState.get<GlobalStateAndSettings["actModeApiModelId"]>("actModeApiModelId")
@@ -476,6 +481,11 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
context.globalState.get<GlobalStateAndSettings["actModeAihubmixModelId"]>("actModeAihubmixModelId")
const actModeAihubmixModelInfo =
context.globalState.get<GlobalStateAndSettings["actModeAihubmixModelInfo"]>("actModeAihubmixModelInfo")
const actModeVercelAiGatewayModelId =
context.globalState.get<GlobalStateAndSettings["actModeVercelAiGatewayModelId"]>("actModeVercelAiGatewayModelId")
const actModeVercelAiGatewayModelInfo = context.globalState.get<
GlobalStateAndSettings["actModeVercelAiGatewayModelInfo"]
>("actModeVercelAiGatewayModelInfo")
let apiProvider: ApiProvider
if (planModeApiProvider) {
@@ -611,6 +621,8 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
planModeAihubmixModelId,
planModeAihubmixModelInfo,
planModeNousResearchModelId,
planModeVercelAiGatewayModelId,
planModeVercelAiGatewayModelInfo,
geminiPlanModeThinkingLevel,
// Act mode configurations
actModeApiProvider: actModeApiProvider || apiProvider,
@@ -649,6 +661,8 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
actModeAihubmixModelId,
actModeAihubmixModelInfo,
actModeNousResearchModelId,
actModeVercelAiGatewayModelId,
actModeVercelAiGatewayModelInfo,
geminiActModeThinkingLevel,
// Other global fields
+4 -1
View File
@@ -177,7 +177,8 @@ export interface ApiHandlerOptions {
planModeHicapModelId?: string
planModeHicapModelInfo?: ModelInfo
planModeNousResearchModelId?: string
// Act mode configurations
planModeVercelAiGatewayModelId?: string
planModeVercelAiGatewayModelInfo?: ModelInfo
// Act mode configurations
actModeApiModelId?: string
@@ -217,6 +218,8 @@ export interface ApiHandlerOptions {
actModeHicapModelId?: string
actModeHicapModelInfo?: ModelInfo
actModeNousResearchModelId?: string
actModeVercelAiGatewayModelId?: string
actModeVercelAiGatewayModelInfo?: ModelInfo
}
export type ApiConfiguration = ApiHandlerOptions &
@@ -534,6 +534,8 @@ export function convertApiConfigurationToProto(config: ApiConfiguration): ProtoA
planModeHicapModelId: config.planModeHicapModelId,
planModeHicapModelInfo: convertModelInfoToProtoOpenRouter(config.planModeHicapModelInfo),
planModeNousResearchModelId: config.planModeNousResearchModelId,
planModeVercelAiGatewayModelId: config.planModeVercelAiGatewayModelId,
planModeVercelAiGatewayModelInfo: convertModelInfoToProtoOpenRouter(config.planModeVercelAiGatewayModelInfo),
// Act mode configurations
actModeApiProvider: config.actModeApiProvider ? convertApiProviderToProto(config.actModeApiProvider) : undefined,
@@ -573,6 +575,8 @@ export function convertApiConfigurationToProto(config: ApiConfiguration): ProtoA
actModeHicapModelId: config.actModeHicapModelId,
actModeHicapModelInfo: convertModelInfoToProtoOpenRouter(config.actModeHicapModelInfo),
actModeNousResearchModelId: config.actModeNousResearchModelId,
actModeVercelAiGatewayModelId: config.actModeVercelAiGatewayModelId,
actModeVercelAiGatewayModelInfo: convertModelInfoToProtoOpenRouter(config.actModeVercelAiGatewayModelInfo),
}
}
@@ -705,6 +709,8 @@ export function convertProtoToApiConfiguration(protoConfig: ProtoApiConfiguratio
planModeHicapModelId: protoConfig.planModeHicapModelId,
planModeHicapModelInfo: convertProtoToModelInfo(protoConfig.planModeHicapModelInfo),
planModeNousResearchModelId: protoConfig.planModeNousResearchModelId,
planModeVercelAiGatewayModelId: protoConfig.planModeVercelAiGatewayModelId,
planModeVercelAiGatewayModelInfo: convertProtoToModelInfo(protoConfig.planModeVercelAiGatewayModelInfo),
// Act mode configurations
actModeApiProvider:
@@ -745,5 +751,7 @@ export function convertProtoToApiConfiguration(protoConfig: ProtoApiConfiguratio
actModeHicapModelId: protoConfig.actModeHicapModelId,
actModeHicapModelInfo: convertProtoToModelInfo(protoConfig.actModeHicapModelInfo),
actModeNousResearchModelId: protoConfig.actModeNousResearchModelId,
actModeVercelAiGatewayModelId: protoConfig.actModeVercelAiGatewayModelId,
actModeVercelAiGatewayModelInfo: convertProtoToModelInfo(protoConfig.actModeVercelAiGatewayModelInfo),
}
}
+4
View File
@@ -173,6 +173,8 @@ export interface Settings {
planModeAihubmixModelId: string | undefined
planModeAihubmixModelInfo: ModelInfo | undefined
planModeNousResearchModelId: string | undefined
planModeVercelAiGatewayModelId: string | undefined
planModeVercelAiGatewayModelInfo: ModelInfo | undefined
// Act mode configurations
actModeApiProvider: ApiProvider
actModeApiModelId: string | undefined
@@ -211,6 +213,8 @@ export interface Settings {
actModeAihubmixModelId: string | undefined
actModeAihubmixModelInfo: ModelInfo | undefined
actModeNousResearchModelId: string | undefined
actModeVercelAiGatewayModelId: string | undefined
actModeVercelAiGatewayModelInfo: ModelInfo | undefined
// OpenTelemetry configuration
openTelemetryEnabled: boolean
@@ -265,6 +265,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
mode,
apiConfiguration,
openRouterModels,
vercelAiGatewayModels,
platform,
localWorkflowToggles,
globalWorkflowToggles,
@@ -1046,7 +1047,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
// Separate the API config submission logic
const submitApiConfig = useCallback(async () => {
const apiValidationResult = validateApiConfiguration(mode, apiConfiguration)
const modelIdValidationResult = validateModelId(mode, apiConfiguration, openRouterModels)
const modelIdValidationResult = validateModelId(mode, apiConfiguration, openRouterModels, vercelAiGatewayModels)
if (!apiValidationResult && !modelIdValidationResult && apiConfiguration) {
try {
@@ -1067,7 +1068,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
console.error("Error refreshing state:", error)
})
}
}, [apiConfiguration, openRouterModels])
}, [apiConfiguration, openRouterModels, vercelAiGatewayModels])
const onModeToggle = useCallback(() => {
// if (textAreaDisabled) return
@@ -1149,9 +1150,17 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
// Get model display name
const modelDisplayName = useMemo(() => {
const { selectedProvider, selectedModelId } = normalizeApiConfiguration(apiConfiguration, mode)
const { vsCodeLmModelSelector, togetherModelId, lmStudioModelId, ollamaModelId, liteLlmModelId, requestyModelId } =
getModeSpecificFields(apiConfiguration, mode)
const {
vsCodeLmModelSelector,
togetherModelId,
lmStudioModelId,
ollamaModelId,
liteLlmModelId,
requestyModelId,
vercelAiGatewayModelId,
} = getModeSpecificFields(apiConfiguration, mode)
const unknownModel = "unknown"
if (!apiConfiguration) {
return unknownModel
}
@@ -1172,6 +1181,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
return `${selectedProvider}:${liteLlmModelId}`
case "requesty":
return `${selectedProvider}:${requestyModelId}`
case "vercel-ai-gateway":
return `${selectedProvider}:${vercelAiGatewayModelId || selectedModelId}`
case "anthropic":
case "openrouter":
default:
@@ -89,6 +89,7 @@ const ModelPickerModal: React.FC<ModelPickerModalProps> = ({ isOpen, onOpenChang
const {
apiConfiguration,
openRouterModels,
vercelAiGatewayModels,
navigateToSettings,
planActSeparateModelsSetting,
showSettings,
@@ -162,14 +163,19 @@ const ModelPickerModal: React.FC<ModelPickerModalProps> = ({ isOpen, onOpenChang
// Get models for current provider
const allModels = useMemo((): ModelItem[] => {
if (OPENROUTER_MODEL_PROVIDERS.includes(selectedProvider)) {
const modelIds = Object.keys(openRouterModels || {})
// Use vercelAiGatewayModels for Vercel and Cline providers, openRouterModels for OpenRouter
const modelsSource =
selectedProvider === "vercel-ai-gateway" || selectedProvider === "cline"
? vercelAiGatewayModels
: openRouterModels
const modelIds = Object.keys(modelsSource || {})
const filteredIds = filterOpenRouterModelIds(modelIds, selectedProvider)
return filteredIds.map((id) => ({
id,
name: id.split("/").pop() || id,
provider: id.split("/")[0],
info: openRouterModels[id],
info: modelsSource[id],
}))
}
@@ -185,7 +191,7 @@ const ModelPickerModal: React.FC<ModelPickerModalProps> = ({ isOpen, onOpenChang
}
return []
}, [selectedProvider, openRouterModels, apiConfiguration])
}, [selectedProvider, openRouterModels, vercelAiGatewayModels, apiConfiguration])
// Multi-word substring search - all words must match somewhere in id/name/provider
const matchesSearch = useCallback((model: ModelItem, query: string): boolean => {
@@ -266,7 +272,25 @@ const ModelPickerModal: React.FC<ModelPickerModalProps> = ({ isOpen, onOpenChang
(modelId: string, modelInfo?: ModelInfoType) => {
const modeToUse = isSplit ? activeEditMode : currentMode
if (OPENROUTER_MODEL_PROVIDERS.includes(selectedProvider)) {
if (selectedProvider === "vercel-ai-gateway" || selectedProvider === "cline") {
// Vercel AI Gateway and Cline use Vercel model fields
const modelInfoToUse = modelInfo || vercelAiGatewayModels[modelId]
handleModeFieldsChange(
{
vercelAiGatewayModelId: { plan: "planModeVercelAiGatewayModelId", act: "actModeVercelAiGatewayModelId" },
vercelAiGatewayModelInfo: {
plan: "planModeVercelAiGatewayModelInfo",
act: "actModeVercelAiGatewayModelInfo",
},
},
{
vercelAiGatewayModelId: modelId,
vercelAiGatewayModelInfo: modelInfoToUse,
},
modeToUse,
)
} else if (OPENROUTER_MODEL_PROVIDERS.includes(selectedProvider)) {
// OpenRouter uses openRouter fields
const modelInfoToUse = modelInfo || openRouterModels[modelId]
handleModeFieldsChange(
{
@@ -309,6 +333,7 @@ const ModelPickerModal: React.FC<ModelPickerModalProps> = ({ isOpen, onOpenChang
isSplit,
activeEditMode,
openRouterModels,
vercelAiGatewayModels,
onOpenChange,
],
)
@@ -374,7 +399,8 @@ const ModelPickerModal: React.FC<ModelPickerModalProps> = ({ isOpen, onOpenChang
// Determine which list the index falls into
if (selectedIndex < featuredModels.length) {
const model = featuredModels[selectedIndex]
handleSelectModel(model.id, openRouterModels[model.id])
// Featured models are for Cline provider which uses Vercel models
handleSelectModel(model.id, vercelAiGatewayModels[model.id])
} else {
const model = filteredModels[selectedIndex - featuredModels.length]
handleSelectModel(model.id, model.info)
@@ -387,7 +413,7 @@ const ModelPickerModal: React.FC<ModelPickerModalProps> = ({ isOpen, onOpenChang
break
}
},
[filteredModels, featuredModels, selectedIndex, handleSelectModel, openRouterModels, onOpenChange],
[filteredModels, featuredModels, selectedIndex, handleSelectModel, vercelAiGatewayModels, onOpenChange],
)
// Reset selectedIndex and clear refs when search/provider changes
@@ -664,9 +690,7 @@ const ModelPickerModal: React.FC<ModelPickerModalProps> = ({ isOpen, onOpenChang
</TooltipContent>
</Tooltip>
</SplitModeRow>
) : (
selectedModelId &&
modelBelongsToProvider &&
) : selectedModelId && modelBelongsToProvider ? (
(() => {
// Check if current model has a featured label (only for Cline provider)
const currentFeaturedModel = isClineProvider
@@ -695,7 +719,11 @@ const ModelPickerModal: React.FC<ModelPickerModalProps> = ({ isOpen, onOpenChang
</CurrentModelRow>
)
})()
)}
) : !selectedModelId && selectedProvider === "vercel-ai-gateway" ? (
<EmptyModelRow>
<span className="text-[11px] text-description">Select a model below</span>
</EmptyModelRow>
) : null}
{/* For Cline: Show recommended models */}
{isClineProvider &&
@@ -703,7 +731,7 @@ const ModelPickerModal: React.FC<ModelPickerModalProps> = ({ isOpen, onOpenChang
<ModelItemContainer
$isSelected={index === selectedIndex}
key={model.id}
onClick={() => handleSelectModel(model.id, openRouterModels[model.id])}
onClick={() => handleSelectModel(model.id, vercelAiGatewayModels[model.id])}
onMouseEnter={() => setSelectedIndex(index)}
ref={(el) => (itemRefs.current[index] = el)}>
<ModelInfoRow>
@@ -995,6 +1023,21 @@ const EmptyState = styled.div`
color: var(--vscode-descriptionForeground);
`
// Empty model row - shown when no model is selected for providers like Vercel
const EmptyModelRow = styled.div`
display: flex;
align-items: center;
justify-content: center;
padding: 8px 10px;
min-height: 28px;
box-sizing: border-box;
background: ${CODE_BLOCK_BG_COLOR};
position: sticky;
top: 0;
z-index: 1;
border-bottom: 1px solid var(--vscode-editorGroup-border);
`
// Current model row - highlighted, sticky at top when scrolling, clickable to close
const CurrentModelRow = styled.div`
display: flex;
@@ -86,7 +86,7 @@ export const freeModels = [
label: "FREE",
},
{
id: "kwaipilot/kat-coder-pro:free",
id: "kwaipilot/kat-coder-pro-v1",
description: "KwaiKAT's most advanced agentic coding model in the KAT-Coder series",
label: "FREE",
},
@@ -0,0 +1,335 @@
import type { ModelInfo } from "@shared/api"
import type { Mode } from "@shared/storage/types"
import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import Fuse from "fuse.js"
import type React from "react"
import { type KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"
import { useMount } from "react-use"
import styled from "styled-components"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { highlight } from "../history/HistoryView"
import { ModelInfoView } from "./common/ModelInfoView"
import ThinkingBudgetSlider from "./ThinkingBudgetSlider"
import { getModeSpecificFields } from "./utils/providerUtils"
import { useApiConfigurationHandlers } from "./utils/useApiConfigurationHandlers"
export interface VercelModelPickerProps {
isPopup?: boolean
currentMode: Mode
}
const VercelModelPicker: React.FC<VercelModelPickerProps> = ({ isPopup, currentMode }) => {
const { handleModeFieldsChange } = useApiConfigurationHandlers()
const { apiConfiguration, vercelAiGatewayModels, refreshVercelAiGatewayModels } = useExtensionState()
const modeFields = getModeSpecificFields(apiConfiguration, currentMode)
// Vercel AI Gateway uses its own model fields
const [searchTerm, setSearchTerm] = useState(modeFields.vercelAiGatewayModelId || "")
const [isDropdownVisible, setIsDropdownVisible] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const dropdownRef = useRef<HTMLDivElement>(null)
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
const dropdownListRef = useRef<HTMLDivElement>(null)
const handleModelChange = (newModelId: string) => {
setSearchTerm(newModelId)
// Vercel AI Gateway uses its own model fields
handleModeFieldsChange(
{
vercelAiGatewayModelId: { plan: "planModeVercelAiGatewayModelId", act: "actModeVercelAiGatewayModelId" },
vercelAiGatewayModelInfo: { plan: "planModeVercelAiGatewayModelInfo", act: "actModeVercelAiGatewayModelInfo" },
},
{
vercelAiGatewayModelId: newModelId,
vercelAiGatewayModelInfo: vercelAiGatewayModels[newModelId],
},
currentMode,
)
}
const { selectedModelId, selectedModelInfo } = useMemo(() => {
return {
selectedModelId: modeFields.vercelAiGatewayModelId || "",
selectedModelInfo: modeFields.vercelAiGatewayModelInfo as ModelInfo | undefined,
}
}, [modeFields.vercelAiGatewayModelId, modeFields.vercelAiGatewayModelInfo])
useMount(refreshVercelAiGatewayModels)
// Sync external changes when the modelId changes
useEffect(() => {
const currentModelId = modeFields.vercelAiGatewayModelId || ""
setSearchTerm(currentModelId)
}, [modeFields.vercelAiGatewayModelId])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsDropdownVisible(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => {
document.removeEventListener("mousedown", handleClickOutside)
}
}, [])
const modelIds = useMemo(() => {
return Object.keys(vercelAiGatewayModels).sort((a, b) => a.localeCompare(b))
}, [vercelAiGatewayModels])
const searchableItems = useMemo(() => {
return modelIds.map((id) => ({
id,
html: id,
}))
}, [modelIds])
const fuse = useMemo(() => {
return new Fuse(searchableItems, {
keys: ["html"],
threshold: 0.6,
shouldSort: true,
isCaseSensitive: false,
ignoreLocation: false,
includeMatches: true,
minMatchCharLength: 1,
})
}, [searchableItems])
const modelSearchResults = useMemo(() => {
const searchResults = searchTerm ? highlight(fuse.search(searchTerm), "model-item-highlight") : searchableItems
return searchResults
}, [searchableItems, searchTerm, fuse])
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (!isDropdownVisible) {
return
}
switch (event.key) {
case "ArrowDown":
event.preventDefault()
setSelectedIndex((prev) => (prev < modelSearchResults.length - 1 ? prev + 1 : prev))
break
case "ArrowUp":
event.preventDefault()
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev))
break
case "Enter":
event.preventDefault()
if (selectedIndex >= 0 && selectedIndex < modelSearchResults.length) {
handleModelChange(modelSearchResults[selectedIndex].id)
setIsDropdownVisible(false)
} else {
handleModelChange(searchTerm)
setIsDropdownVisible(false)
}
break
case "Escape":
setIsDropdownVisible(false)
setSelectedIndex(-1)
break
}
}
const hasInfo = useMemo(() => {
try {
return modelIds.some((id) => id.toLowerCase() === searchTerm.toLowerCase())
} catch {
return false
}
}, [modelIds, searchTerm])
useEffect(() => {
setSelectedIndex(-1)
if (dropdownListRef.current) {
dropdownListRef.current.scrollTop = 0
}
}, [searchTerm])
useEffect(() => {
if (selectedIndex >= 0 && itemRefs.current[selectedIndex]) {
itemRefs.current[selectedIndex]?.scrollIntoView({
block: "nearest",
behavior: "smooth",
})
}
}, [selectedIndex])
const showBudgetSlider = useMemo(() => {
return (
selectedModelId?.toLowerCase().includes("claude-haiku-4.5") ||
selectedModelId?.toLowerCase().includes("claude-4.5-haiku") ||
selectedModelId?.toLowerCase().includes("claude-sonnet-4.5") ||
selectedModelId?.toLowerCase().includes("claude-sonnet-4") ||
selectedModelId?.toLowerCase().includes("claude-opus-4.1") ||
selectedModelId?.toLowerCase().includes("claude-opus-4") ||
selectedModelId?.toLowerCase().includes("claude-opus-4.5") ||
selectedModelId?.toLowerCase().includes("claude-3-7-sonnet") ||
selectedModelId?.toLowerCase().includes("claude-3.7-sonnet")
)
}, [selectedModelId])
return (
<div style={{ width: "100%", paddingBottom: 2 }}>
<style>
{`
.model-item-highlight {
background-color: var(--vscode-editor-findMatchHighlightBackground);
color: inherit;
}
`}
</style>
<div style={{ display: "flex", flexDirection: "column" }}>
<label htmlFor="vercel-model-search">
<span style={{ fontWeight: 500 }}>Model</span>
</label>
<DropdownWrapper ref={dropdownRef}>
<VSCodeTextField
id="vercel-model-search"
onBlur={() => {
if (searchTerm !== selectedModelId) {
handleModelChange(searchTerm)
}
}}
onFocus={() => setIsDropdownVisible(true)}
onInput={(e) => {
setSearchTerm((e.target as HTMLInputElement)?.value.toLowerCase() || "")
setIsDropdownVisible(true)
}}
onKeyDown={handleKeyDown}
placeholder="Search and select a model..."
style={{
width: "100%",
zIndex: VERCEL_MODEL_PICKER_Z_INDEX,
position: "relative",
}}
value={searchTerm}>
{searchTerm && (
<div
aria-label="Clear search"
className="input-icon-button codicon codicon-close"
onClick={() => {
setSearchTerm("")
setIsDropdownVisible(true)
}}
slot="end"
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
}}
/>
)}
</VSCodeTextField>
{isDropdownVisible && (
<DropdownList ref={dropdownListRef}>
{modelSearchResults.length > 0 ? (
modelSearchResults.map((item, index) => (
<DropdownItem
isSelected={index === selectedIndex}
key={item.id}
onClick={() => {
handleModelChange(item.id)
setIsDropdownVisible(false)
}}
onMouseEnter={() => setSelectedIndex(index)}
ref={(el) => (itemRefs.current[index] = el)}>
<span dangerouslySetInnerHTML={{ __html: item.html }} />
</DropdownItem>
))
) : (
<DropdownItem isSelected={false}>
<span style={{ color: "var(--vscode-descriptionForeground)" }}>
{Object.keys(vercelAiGatewayModels).length === 0
? "Loading models..."
: "No models found"}
</span>
</DropdownItem>
)}
</DropdownList>
)}
</DropdownWrapper>
</div>
{hasInfo && selectedModelInfo ? (
<>
{showBudgetSlider && <ThinkingBudgetSlider currentMode={currentMode} />}
<ModelInfoView
isPopup={isPopup}
modelInfo={selectedModelInfo}
selectedModelId={selectedModelId}
showProviderRouting={false}
/>
</>
) : (
<p
style={{
fontSize: "12px",
marginTop: 0,
color: "var(--vscode-descriptionForeground)",
}}>
{Object.keys(vercelAiGatewayModels).length === 0 ? (
<>
Enter your Vercel AI Gateway API key above to load available models. You can get an API key from{" "}
<VSCodeLink
href="https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai"
style={{ display: "inline", fontSize: "inherit" }}>
Vercel AI Gateway.
</VSCodeLink>
</>
) : (
<>
Select a model from the dropdown above. The extension fetches available models from your Vercel AI
Gateway configuration.
</>
)}
</p>
)}
</div>
)
}
export default VercelModelPicker
// Dropdown styles
const DropdownWrapper = styled.div`
position: relative;
width: 100%;
`
export const VERCEL_MODEL_PICKER_Z_INDEX = 1_000
const DropdownList = styled.div`
position: absolute;
top: calc(100% - 3px);
left: 0;
width: calc(100% - 2px);
max-height: 200px;
overflow-y: auto;
background-color: var(--vscode-dropdown-background);
border: 1px solid var(--vscode-list-activeSelectionBackground);
z-index: ${VERCEL_MODEL_PICKER_Z_INDEX - 1};
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
`
const DropdownItem = styled.div<{ isSelected: boolean }>`
padding: 5px 10px;
cursor: pointer;
word-break: break-all;
white-space: normal;
background-color: ${({ isSelected }) => (isSelected ? "var(--vscode-list-activeSelectionBackground)" : "inherit")};
&:hover {
background-color: var(--vscode-list-activeSelectionBackground);
}
`
@@ -2,8 +2,8 @@ import { Mode } from "@shared/storage/types"
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { DebouncedTextField } from "../common/DebouncedTextField"
import OpenRouterModelPicker from "../OpenRouterModelPicker"
import { useApiConfigurationHandlers } from "../utils/useApiConfigurationHandlers"
import VercelModelPicker from "../VercelModelPicker"
/**
* Props for the VercelAIGatewayProvider component
@@ -53,11 +53,7 @@ export const VercelAIGatewayProvider = ({ showModelOptions, isPopup, currentMode
</p>
</div>
{showModelOptions && (
<>
<OpenRouterModelPicker currentMode={currentMode} isPopup={isPopup} />
</>
)}
{showModelOptions && <VercelModelPicker currentMode={currentMode} isPopup={isPopup} />}
</div>
)
}
@@ -258,18 +258,19 @@ export function normalizeApiConfiguration(
selectedModelInfo: requestyModelInfo || requestyDefaultModelInfo,
}
case "cline":
const clineOpenRouterModelId =
(currentMode === "plan"
? apiConfiguration?.planModeOpenRouterModelId
: apiConfiguration?.actModeOpenRouterModelId) || openRouterDefaultModelId
const clineOpenRouterModelInfo =
(currentMode === "plan"
? apiConfiguration?.planModeOpenRouterModelInfo
: apiConfiguration?.actModeOpenRouterModelInfo) || openRouterDefaultModelInfo
// Cline uses Vercel AI Gateway model fields
const clineVercelModelId =
currentMode === "plan"
? apiConfiguration?.planModeVercelAiGatewayModelId
: apiConfiguration?.actModeVercelAiGatewayModelId
const clineVercelModelInfo =
currentMode === "plan"
? apiConfiguration?.planModeVercelAiGatewayModelInfo
: apiConfiguration?.actModeVercelAiGatewayModelInfo
return {
selectedProvider: provider,
selectedModelId: clineOpenRouterModelId,
selectedModelInfo: clineOpenRouterModelInfo,
selectedModelId: clineVercelModelId || "",
selectedModelInfo: clineVercelModelInfo || openRouterDefaultModelInfo,
}
case "openai":
const openAiModelId =
@@ -416,16 +417,18 @@ export function normalizeApiConfiguration(
},
}
case "vercel-ai-gateway":
// Vercel AI Gateway uses OpenRouter model fields
// Vercel AI Gateway uses its own model fields
const vercelModelId =
currentMode === "plan" ? apiConfiguration?.planModeOpenRouterModelId : apiConfiguration?.actModeOpenRouterModelId
currentMode === "plan"
? apiConfiguration?.planModeVercelAiGatewayModelId
: apiConfiguration?.actModeVercelAiGatewayModelId
const vercelModelInfo =
currentMode === "plan"
? apiConfiguration?.planModeOpenRouterModelInfo
: apiConfiguration?.actModeOpenRouterModelInfo
? apiConfiguration?.planModeVercelAiGatewayModelInfo
: apiConfiguration?.actModeVercelAiGatewayModelInfo
return {
selectedProvider: provider,
selectedModelId: vercelModelId || openRouterDefaultModelId,
selectedModelId: vercelModelId || "",
selectedModelInfo: vercelModelInfo || openRouterDefaultModelInfo,
}
case "zai":
@@ -512,6 +515,7 @@ export function getModeSpecificFields(apiConfiguration: ApiConfiguration | undef
hicapModelId: undefined,
aihubmixModelId: undefined,
nousResearchModelId: undefined,
vercelAiGatewayModelId: undefined,
// Model info objects
openAiModelInfo: undefined,
@@ -563,6 +567,8 @@ export function getModeSpecificFields(apiConfiguration: ApiConfiguration | undef
aihubmixModelId: mode === "plan" ? apiConfiguration.planModeAihubmixModelId : apiConfiguration.actModeAihubmixModelId,
nousResearchModelId:
mode === "plan" ? apiConfiguration.planModeNousResearchModelId : apiConfiguration.actModeNousResearchModelId,
vercelAiGatewayModelId:
mode === "plan" ? apiConfiguration.planModeVercelAiGatewayModelId : apiConfiguration.actModeVercelAiGatewayModelId,
// Model info objects
openAiModelInfo: mode === "plan" ? apiConfiguration.planModeOpenAiModelInfo : apiConfiguration.actModeOpenAiModelInfo,
@@ -580,6 +586,10 @@ export function getModeSpecificFields(apiConfiguration: ApiConfiguration | undef
hicapModelInfo: mode === "plan" ? apiConfiguration.planModeHicapModelInfo : apiConfiguration.actModeHicapModelInfo,
aihubmixModelInfo:
mode === "plan" ? apiConfiguration.planModeAihubmixModelInfo : apiConfiguration.actModeAihubmixModelInfo,
vercelAiGatewayModelInfo:
mode === "plan"
? apiConfiguration.planModeVercelAiGatewayModelInfo
: apiConfiguration.actModeVercelAiGatewayModelInfo,
// AWS Bedrock fields
awsBedrockCustomSelected:
@@ -640,13 +650,20 @@ export async function syncModeConfigurations(
// Handle provider-specific fields
switch (apiProvider) {
case "openrouter":
case "cline":
updates.planModeOpenRouterModelId = sourceFields.openRouterModelId
updates.actModeOpenRouterModelId = sourceFields.openRouterModelId
updates.planModeOpenRouterModelInfo = sourceFields.openRouterModelInfo
updates.actModeOpenRouterModelInfo = sourceFields.openRouterModelInfo
break
case "cline":
// Cline uses Vercel AI Gateway model fields
updates.planModeVercelAiGatewayModelId = sourceFields.vercelAiGatewayModelId
updates.actModeVercelAiGatewayModelId = sourceFields.vercelAiGatewayModelId
updates.planModeVercelAiGatewayModelInfo = sourceFields.vercelAiGatewayModelInfo
updates.actModeVercelAiGatewayModelInfo = sourceFields.vercelAiGatewayModelInfo
break
case "requesty":
updates.planModeRequestyModelId = sourceFields.requestyModelId
updates.actModeRequestyModelId = sourceFields.requestyModelId
@@ -742,11 +759,11 @@ export async function syncModeConfigurations(
break
case "vercel-ai-gateway":
// Vercel AI Gateway uses OpenRouter model fields
updates.planModeOpenRouterModelId = sourceFields.openRouterModelId
updates.actModeOpenRouterModelId = sourceFields.openRouterModelId
updates.planModeOpenRouterModelInfo = sourceFields.openRouterModelInfo
updates.actModeOpenRouterModelInfo = sourceFields.openRouterModelInfo
// Vercel AI Gateway uses its own model fields
updates.planModeVercelAiGatewayModelId = sourceFields.vercelAiGatewayModelId
updates.actModeVercelAiGatewayModelId = sourceFields.vercelAiGatewayModelId
updates.planModeVercelAiGatewayModelInfo = sourceFields.vercelAiGatewayModelInfo
updates.actModeVercelAiGatewayModelInfo = sourceFields.vercelAiGatewayModelInfo
break
case "oca":
updates.planModeOcaModelId = sourceFields.ocaModelId
@@ -34,6 +34,7 @@ export interface ExtensionStateContextType extends ExtensionState {
showWelcome: boolean
onboardingModels: OnboardingModelGroup | undefined
openRouterModels: Record<string, ModelInfo>
vercelAiGatewayModels: Record<string, ModelInfo>
hicapModels: Record<string, ModelInfo>
liteLlmModels: Record<string, ModelInfo>
openAiModels: string[]
@@ -86,6 +87,7 @@ export interface ExtensionStateContextType extends ExtensionState {
// Refresh functions
refreshOpenRouterModels: () => void
refreshVercelAiGatewayModels: () => void
refreshHicapModels: () => void
refreshLiteLlmModels: () => void
setUserInfo: (userInfo?: UserInfo) => void
@@ -261,6 +263,7 @@ export const ExtensionStateContextProvider: React.FC<{
const [openRouterModels, setOpenRouterModels] = useState<Record<string, ModelInfo>>({
[openRouterDefaultModelId]: openRouterDefaultModelInfo,
})
const [vercelAiGatewayModels, setVercelAiGatewayModels] = useState<Record<string, ModelInfo>>({})
const [hicapModels, setHicapModels] = useState<Record<string, ModelInfo>>({})
const [liteLlmModels, setLiteLlmModels] = useState<Record<string, ModelInfo>>({})
const [totalTasksSize, setTotalTasksSize] = useState<number | null>(null)
@@ -707,15 +710,27 @@ export const ExtensionStateContextProvider: React.FC<{
.catch((err) => console.error("Failed to refresh Baseten models:", err))
}, [])
const refreshVercelAiGatewayModels = useCallback(() => {
ModelsServiceClient.refreshVercelAiGatewayModelsRpc(EmptyRequest.create({}))
.then((response: OpenRouterCompatibleModelInfo) => {
const models = fromProtobufModels(response.models)
setVercelAiGatewayModels(models)
})
.catch((error: Error) => console.error("Failed to refresh Vercel AI Gateway models:", error))
}, [])
// Auto-refresh model lists on API key availability
useEffect(() => {
if (!openRouterModels || Object.keys(openRouterModels).length <= 1) {
refreshOpenRouterModels()
}
if (!vercelAiGatewayModels || Object.keys(vercelAiGatewayModels).length === 0) {
refreshVercelAiGatewayModels()
}
if (state.apiConfiguration?.basetenApiKey) {
refreshBasetenModels()
}
}, [refreshOpenRouterModels, state?.apiConfiguration?.basetenApiKey, refreshBasetenModels])
}, [refreshOpenRouterModels, refreshVercelAiGatewayModels, state?.apiConfiguration?.basetenApiKey, refreshBasetenModels])
const contextValue: ExtensionStateContextType = {
...state,
@@ -723,6 +738,7 @@ export const ExtensionStateContextProvider: React.FC<{
showWelcome,
onboardingModels,
openRouterModels,
vercelAiGatewayModels,
hicapModels,
liteLlmModels,
openAiModels,
@@ -832,6 +848,7 @@ export const ExtensionStateContextProvider: React.FC<{
setMcpTab,
setTotalTasksSize,
refreshOpenRouterModels,
refreshVercelAiGatewayModels,
refreshHicapModels,
refreshLiteLlmModels,
onRelinquishControl,
+16 -6
View File
@@ -177,24 +177,34 @@ export function validateModelId(
currentMode: Mode,
apiConfiguration?: ApiConfiguration,
openRouterModels?: Record<string, ModelInfo>,
vercelAiGatewayModels?: Record<string, ModelInfo>,
): string | undefined {
if (apiConfiguration) {
const { apiProvider, openRouterModelId } = getModeSpecificFields(apiConfiguration, currentMode)
const { apiProvider, openRouterModelId, vercelAiGatewayModelId } = getModeSpecificFields(apiConfiguration, currentMode)
switch (apiProvider) {
case "openrouter":
case "cline":
const modelId = openRouterModelId || openRouterDefaultModelId // in case the user hasn't changed the model id, it will be undefined by default
if (!modelId) {
const orModelId = openRouterModelId || openRouterDefaultModelId // in case the user hasn't changed the model id, it will be undefined by default
if (!orModelId) {
return "You must provide a model ID."
}
if (modelId.startsWith("@preset/")) {
if (orModelId.startsWith("@preset/")) {
break
}
if (openRouterModels && !Object.keys(openRouterModels).includes(modelId)) {
if (openRouterModels && !Object.keys(openRouterModels).includes(orModelId)) {
// even if the model list endpoint failed, extensionstatecontext will always have the default model info
return "The model ID you provided is not available. Please choose a different model."
}
break
case "cline":
// Cline uses Vercel AI Gateway models
const clineModelId = vercelAiGatewayModelId
if (!clineModelId) {
return "You must select a model."
}
if (vercelAiGatewayModels && !Object.keys(vercelAiGatewayModels).includes(clineModelId)) {
return "The model ID you provided is not available. Please choose a different model."
}
break
}
}
return undefined