diff --git a/apps/web-evals/src/app/api/runs/[id]/logs/failed/route.ts b/apps/web-evals/src/app/api/runs/[id]/logs/failed/route.ts index f8c6cec06b..8b2760df98 100644 --- a/apps/web-evals/src/app/api/runs/[id]/logs/failed/route.ts +++ b/apps/web-evals/src/app/api/runs/[id]/logs/failed/route.ts @@ -61,7 +61,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ archive.on("error", reject) }) - // Add each failed task's log file to the archive + // Add each failed task's log file and history files to the archive const logDir = path.join(LOG_BASE_PATH, String(runId)) let filesAdded = 0 @@ -69,18 +69,36 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ // Sanitize language and exercise to prevent path traversal const safeLanguage = sanitizePathComponent(task.language) const safeExercise = sanitizePathComponent(task.exercise) + const expectedBase = path.resolve(LOG_BASE_PATH) + + // Add the log file const logFileName = `${safeLanguage}-${safeExercise}.log` const logFilePath = path.join(logDir, logFileName) // Verify the resolved path is within the expected directory (defense in depth) - const resolvedPath = path.resolve(logFilePath) - const expectedBase = path.resolve(LOG_BASE_PATH) - if (!resolvedPath.startsWith(expectedBase)) { - continue // Skip files with suspicious paths + const resolvedLogPath = path.resolve(logFilePath) + if (resolvedLogPath.startsWith(expectedBase) && fs.existsSync(logFilePath)) { + archive.file(logFilePath, { name: logFileName }) + filesAdded++ } - if (fs.existsSync(logFilePath)) { - archive.file(logFilePath, { name: logFileName }) + // Add the API conversation history file + // Format: {language}-{exercise}.{iteration}_api_conversation_history.json + const apiHistoryFileName = `${safeLanguage}-${safeExercise}.${task.iteration}_api_conversation_history.json` + const apiHistoryFilePath = path.join(logDir, apiHistoryFileName) + const resolvedApiHistoryPath = path.resolve(apiHistoryFilePath) + if (resolvedApiHistoryPath.startsWith(expectedBase) && fs.existsSync(apiHistoryFilePath)) { + archive.file(apiHistoryFilePath, { name: apiHistoryFileName }) + filesAdded++ + } + + // Add the UI messages file + // Format: {language}-{exercise}.{iteration}_ui_messages.json + const uiMessagesFileName = `${safeLanguage}-${safeExercise}.${task.iteration}_ui_messages.json` + const uiMessagesFilePath = path.join(logDir, uiMessagesFileName) + const resolvedUiMessagesPath = path.resolve(uiMessagesFilePath) + if (resolvedUiMessagesPath.startsWith(expectedBase) && fs.existsSync(uiMessagesFilePath)) { + archive.file(uiMessagesFilePath, { name: uiMessagesFileName }) filesAdded++ } } diff --git a/apps/web-evals/src/app/runs/[id]/run.tsx b/apps/web-evals/src/app/runs/[id]/run.tsx index 41581a21c4..e2079343d3 100644 --- a/apps/web-evals/src/app/runs/[id]/run.tsx +++ b/apps/web-evals/src/app/runs/[id]/run.tsx @@ -1,8 +1,8 @@ "use client" -import { useMemo, useState, useCallback, useEffect } from "react" +import { useMemo, useState, useCallback, useEffect, Fragment } from "react" import { toast } from "sonner" -import { LoaderCircle, FileText, Copy, Check, StopCircle } from "lucide-react" +import { LoaderCircle, FileText, Copy, Check, StopCircle, List, Layers } from "lucide-react" import type { Run, TaskMetrics as _TaskMetrics, Task } from "@roo-code/evals" import type { ToolName } from "@roo-code/types" @@ -41,6 +41,9 @@ import { RunStatus } from "./run-status" type TaskMetrics = Pick<_TaskMetrics, "tokensIn" | "tokensOut" | "tokensContext" | "duration" | "cost"> +// Extended Task type with taskMetrics from useRunStatus +type TaskWithMetrics = Task & { taskMetrics: _TaskMetrics | null } + type ToolUsageEntry = { attempts: number; failures: number } type ToolUsage = Record @@ -250,6 +253,19 @@ export function Run({ run }: { run: Run }) { const [copied, setCopied] = useState(false) const [showKillDialog, setShowKillDialog] = useState(false) const [isKilling, setIsKilling] = useState(false) + const [groupByStatus, setGroupByStatus] = useState(() => { + // Initialize from localStorage if available (client-side only) + if (typeof window !== "undefined") { + const stored = localStorage.getItem("evals-group-by-status") + return stored === "true" + } + return false + }) + + // Persist groupByStatus to localStorage + useEffect(() => { + localStorage.setItem("evals-group-by-status", String(groupByStatus)) + }, [groupByStatus]) // Determine if run is still active (has heartbeat or runners) const isRunActive = !run.taskMetricsId && (!!heartbeat || (runners && runners.length > 0)) @@ -300,41 +316,6 @@ export function Run({ run }: { run: Run }) { return () => document.removeEventListener("keydown", handleKeyDown) }, [selectedTask]) - const onViewTaskLog = useCallback( - async (task: Task) => { - // Only allow viewing logs for tasks that have started - if (!task.startedAt && !tokenUsage.get(task.id)) { - toast.error("Task has not started yet") - return - } - - setSelectedTask(task) - setIsLoadingLog(true) - setTaskLog(null) - - try { - const response = await fetch(`/api/runs/${run.id}/logs/${task.id}`) - - if (!response.ok) { - const error = await response.json() - toast.error(error.error || "Failed to load log") - setSelectedTask(null) - return - } - - const data = await response.json() - setTaskLog(data.logContent) - } catch (error) { - console.error("Error loading task log:", error) - toast.error("Failed to load log") - setSelectedTask(null) - } finally { - setIsLoadingLog(false) - } - }, - [run.id, tokenUsage], - ) - const taskMetrics: Record = useMemo(() => { // Reference usageUpdatedAt to trigger recomputation when Map contents change void usageUpdatedAt @@ -376,6 +357,44 @@ export function Run({ run }: { run: Run }) { return metrics }, [tasks, tokenUsage, usageUpdatedAt]) + const onViewTaskLog = useCallback( + async (task: Task) => { + // Only allow viewing logs for tasks that have started. + // Note: we treat presence of derived metrics as evidence of a started task, + // since this page may be rendered without streaming `tokenUsage` populated. + const hasStarted = !!task.startedAt || !!tokenUsage.get(task.id) || !!taskMetrics[task.id] + if (!hasStarted) { + toast.error("Task has not started yet") + return + } + + setSelectedTask(task) + setIsLoadingLog(true) + setTaskLog(null) + + try { + const response = await fetch(`/api/runs/${run.id}/logs/${task.id}`) + + if (!response.ok) { + const error = await response.json() + toast.error(error.error || "Failed to load log") + setSelectedTask(null) + return + } + + const data = await response.json() + setTaskLog(data.logContent) + } catch (error) { + console.error("Error loading task log:", error) + toast.error("Failed to load log") + setSelectedTask(null) + } finally { + setIsLoadingLog(false) + } + }, + [run.id, tokenUsage, taskMetrics], + ) + // Collect all unique tool names from all tasks and sort by total attempts const toolColumns = useMemo(() => { // Reference usageUpdatedAt to trigger recomputation when Map contents change @@ -463,10 +482,13 @@ export function Run({ run }: { run: Run }) { } } + const remaining = tasks.length - completed + return { passed, failed, completed, + remaining, passRate: completed > 0 ? ((passed / completed) * 100).toFixed(1) : null, totalTokensIn, totalTokensOut, @@ -501,258 +523,399 @@ export function Run({ run }: { run: Run }) { return Date.now() - startTime }, [tasks, run.createdAt, run.taskMetricsId, usageUpdatedAt]) - return ( - <> -
- {stats && ( -
- {/* Provider, Model title and status */} -
- {run.settings?.apiProvider && ( - {run.settings.apiProvider} - )} -
{run.model}
- - {run.description && ( - - {run.description} - )} - {isRunActive && ( - - - - - Stop all containers for this run - - )} -
- {/* Main Stats Row */} -
- {/* Passed/Failed */} -
-
- {stats.passed} - / - {stats.failed} -
-
Passed / Failed
-
+ // Task status categories + type TaskStatusCategory = "failed" | "in_progress" | "passed" | "not_started" + + const getTaskStatusCategory = useCallback( + (task: TaskWithMetrics): TaskStatusCategory => { + if (task.passed === false) return "failed" + if (task.passed === true) return "passed" + // Check streaming data, DB metrics, or startedAt timestamp + const hasStarted = !!task.startedAt || !!tokenUsage.get(task.id) || !!taskMetrics[task.id] + if (hasStarted) return "in_progress" + return "not_started" + }, + [tokenUsage, taskMetrics], + ) - {/* Pass Rate */} -
-
= 80 - ? "text-yellow-500" - : "text-red-500" - }`}> - {stats.passRate ? `${stats.passRate}%` : "-"} -
-
Pass Rate
-
+ // Group tasks by status while preserving original index + const groupedTasks = useMemo(() => { + if (!tasks || !groupByStatus) return null - {/* Tokens */} -
-
- {formatTokens(stats.totalTokensIn)} - / - {formatTokens(stats.totalTokensOut)} -
-
Tokens In / Out
-
+ const groups: Record> = { + failed: [], + in_progress: [], + passed: [], + not_started: [], + } - {/* Cost */} -
-
{formatCurrency(stats.totalCost)}
-
Cost
-
+ tasks.forEach((task, index) => { + const status = getTaskStatusCategory(task) + groups[status].push({ task, originalIndex: index }) + }) - {/* Duration */} -
-
- {stats.totalDuration > 0 ? formatDuration(stats.totalDuration) : "-"} -
-
Duration
-
+ return groups + }, [tasks, groupByStatus, getTaskStatusCategory]) + + const statusLabels = useMemo( + (): Record => ({ + failed: { label: "Failed", className: "text-red-500", count: groupedTasks?.failed.length ?? 0 }, + in_progress: { + label: "In Progress", + className: "text-yellow-500", + count: groupedTasks?.in_progress.length ?? 0, + }, + passed: { label: "Passed", className: "text-green-500", count: groupedTasks?.passed.length ?? 0 }, + not_started: { + label: "Not Started", + className: "text-muted-foreground", + count: groupedTasks?.not_started.length ?? 0, + }, + }), + [groupedTasks], + ) - {/* Elapsed Time */} -
-
- {elapsedTime !== null ? formatDuration(elapsedTime) : "-"} -
-
Elapsed
-
-
+ const statusOrder: TaskStatusCategory[] = ["failed", "in_progress", "passed", "not_started"] - {/* Tool Usage Row */} - {Object.keys(stats.toolUsage).length > 0 && ( -
- {Object.entries(stats.toolUsage) - .sort(([, a], [, b]) => b.attempts - a.attempts) - .map(([toolName, usage]) => { - const abbr = getToolAbbreviation(toolName) - const successRate = - usage.attempts > 0 - ? ((usage.attempts - usage.failures) / usage.attempts) * 100 - : 100 - const rateColor = - successRate === 100 - ? "text-green-500" - : successRate >= 80 - ? "text-yellow-500" - : "text-red-500" - return ( - - -
- - {abbr} - - {usage.attempts} - - {formatToolUsageSuccessRate(usage)} - -
-
- {toolName} -
- ) - })} -
- )} + // Helper to render a task row + const renderTaskRow = (task: TaskWithMetrics, originalIndex: number) => { + const hasStarted = !!task.startedAt || !!tokenUsage.get(task.id) || !!taskMetrics[task.id] + return ( + hasStarted && onViewTaskLog(task)}> + + {originalIndex + 1} + + +
+ +
+ + {task.language}/{task.exercise} + {task.iteration > 1 && ( + (#{task.iteration}) + )} + + {hasStarted && ( + + + + + Click to view log + + )} +
+
+ {taskMetrics[task.id] ? ( + <> + +
+
{formatTokens(taskMetrics[task.id]!.tokensIn)}
/ +
{formatTokens(taskMetrics[task.id]!.tokensOut)}
+
+
+ + {formatTokens(taskMetrics[task.id]!.tokensContext)} + + {toolColumns.map((toolName) => { + const dbUsage = task.taskMetrics?.toolUsage?.[toolName] + const streamingUsage = toolUsage.get(task.id)?.[toolName] + const usage = task.finishedAt ? (dbUsage ?? streamingUsage) : streamingUsage + + const successRate = + usage && usage.attempts > 0 + ? ((usage.attempts - usage.failures) / usage.attempts) * 100 + : 100 + const rateColor = + successRate === 100 + ? "text-muted-foreground" + : successRate >= 80 + ? "text-yellow-500" + : "text-red-500" + return ( + + {usage ? ( +
+ {usage.attempts} + {formatToolUsageSuccessRate(usage)} +
+ ) : ( + - + )} +
+ ) + })} + + {taskMetrics[task.id]!.duration ? formatDuration(taskMetrics[task.id]!.duration) : "-"} + + + {formatCurrency(taskMetrics[task.id]!.cost)} + + + ) : ( + )} +
+ ) + } + + return ( + <> +
{!tasks ? ( ) : ( - - - - Exercise - Tokens In / Out - Context - {toolColumns.map((toolName) => ( - - - {getToolAbbreviation(toolName)} - {toolName} - - - ))} - Duration - Cost - - - - {tasks.map((task) => { - const hasStarted = !!task.startedAt || !!tokenUsage.get(task.id) - return ( - hasStarted && onViewTaskLog(task)}> - -
- -
- - {task.language}/{task.exercise} - {task.iteration > 1 && ( - - (#{task.iteration}) - - )} - - {hasStarted && ( - - - - - Click to view log - - )} -
-
-
- {taskMetrics[task.id] ? ( + <> + {/* View Toggle */} +
+ + + + + + {groupByStatus ? "Show tasks in run order" : "Group tasks by status"} + + +
+
+ + {stats && ( + + + {/* Provider, Model title and status */} +
+ {run.settings?.apiProvider && ( + + {run.settings.apiProvider} + + )} +
{run.model}
+ + {run.description && ( + + - {run.description} + + )} + {isRunActive && ( + + + + + + Stop all containers for this run + + + )} +
+ {/* Main Stats Row */} +
+ {/* Pass Rate / Fail Rate / Remaining % */} +
+
+ + {stats.completed > 0 + ? `${((stats.passed / stats.completed) * 100).toFixed(1)}%` + : "-"} + + / + + {stats.completed > 0 + ? `${((stats.failed / stats.completed) * 100).toFixed(1)}%` + : "-"} + + / + + {tasks.length > 0 + ? `${((stats.remaining / tasks.length) * 100).toFixed(1)}%` + : "-"} + +
+
+ {stats.passed} + {" / "} + {stats.failed} + {" / "} + {stats.remaining} + {" of "} + {tasks.length} +
+
+ + {/* Tokens */} +
+
+ {formatTokens(stats.totalTokensIn)} + / + {formatTokens(stats.totalTokensOut)} +
+
Tokens In / Out
+
+ + {/* Cost */} +
+
+ {formatCurrency(stats.totalCost)} +
+
Cost
+
+ + {/* Duration */} +
+
+ {stats.totalDuration > 0 + ? formatDuration(stats.totalDuration) + : "-"} +
+
Duration
+
+ + {/* Elapsed Time */} +
+
+ {elapsedTime !== null ? formatDuration(elapsedTime) : "-"} +
+
Elapsed
+
+ + {/* Estimated Time Remaining - only show if run is active and we have data */} + {!run.taskMetricsId && + elapsedTime !== null && + stats.completed > 0 && + stats.remaining > 0 && ( +
+
+ ~ + {formatDuration( + (elapsedTime / stats.completed) * stats.remaining, + )} +
+
+ Est. Remaining +
+
+ )} +
+ + {/* Tool Usage Row */} + {Object.keys(stats.toolUsage).length > 0 && ( +
+ {Object.entries(stats.toolUsage) + .sort(([, a], [, b]) => b.attempts - a.attempts) + .map(([toolName, usage]) => { + const abbr = getToolAbbreviation(toolName) + const successRate = + usage.attempts > 0 + ? ((usage.attempts - usage.failures) / + usage.attempts) * + 100 + : 100 + const rateColor = + successRate === 100 + ? "text-green-500" + : successRate >= 80 + ? "text-yellow-500" + : "text-red-500" + return ( + + +
+ + {abbr} + + + {usage.attempts} + + + {formatToolUsageSuccessRate(usage)} + +
+
+ + {toolName} + +
+ ) + })} +
+ )} +
- ) - })} - -
+ )} + + # + Exercise + Tokens In / Out + Context + {toolColumns.map((toolName) => ( + + + {getToolAbbreviation(toolName)} + {toolName} + + + ))} + Duration + Cost + + + + {groupByStatus && groupedTasks + ? // Grouped view + statusOrder.map((status) => { + const group = groupedTasks[status] + if (group.length === 0) return null + const { label, className } = statusLabels[status] + return ( + + + + + {label} ({group.length}) + + + + {group.map(({ task, originalIndex }) => + renderTaskRow(task, originalIndex), + )} + + ) + }) + : // Default order view + tasks.map((task, index) => renderTaskRow(task, index))} + + + )}
diff --git a/apps/web-evals/src/lib/__tests__/formatters.spec.ts b/apps/web-evals/src/lib/__tests__/formatters.spec.ts new file mode 100644 index 0000000000..88c8f94af9 --- /dev/null +++ b/apps/web-evals/src/lib/__tests__/formatters.spec.ts @@ -0,0 +1,30 @@ +import { formatDuration, formatTokens } from "../formatters" + +describe("formatDuration()", () => { + it("formats as H:MM:SS", () => { + expect(formatDuration(0)).toBe("0:00:00") + expect(formatDuration(1_000)).toBe("0:00:01") + expect(formatDuration(61_000)).toBe("0:01:01") + expect(formatDuration(3_661_000)).toBe("1:01:01") + }) +}) + +describe("formatTokens()", () => { + it("formats small numbers without suffix", () => { + expect(formatTokens(0)).toBe("0") + expect(formatTokens(999)).toBe("999") + }) + + it("formats thousands without decimals and clamps to 1.0M at boundary", () => { + expect(formatTokens(1_000)).toBe("1k") + expect(formatTokens(72_500)).toBe("73k") + expect(formatTokens(999_499)).toBe("999k") + expect(formatTokens(999_500)).toBe("1.0M") + }) + + it("formats millions with one decimal and clamps to 1.0B at boundary", () => { + expect(formatTokens(1_000_000)).toBe("1.0M") + expect(formatTokens(3_240_000)).toBe("3.2M") + expect(formatTokens(999_950_000)).toBe("1.0B") + }) +}) diff --git a/apps/web-evals/src/lib/formatters.ts b/apps/web-evals/src/lib/formatters.ts index e082e4f02a..155f27dd86 100644 --- a/apps/web-evals/src/lib/formatters.ts +++ b/apps/web-evals/src/lib/formatters.ts @@ -11,21 +11,10 @@ export const formatDuration = (durationMs: number) => { const minutes = Math.floor((seconds % 3600) / 60) const remainingSeconds = seconds % 60 - const parts = [] - - if (hours > 0) { - parts.push(`${hours}h`) - } - - if (minutes > 0) { - parts.push(`${minutes}m`) - } - - if (remainingSeconds > 0 || parts.length === 0) { - parts.push(`${remainingSeconds}s`) - } - - return parts.join(" ") + // Format as H:MM:SS + const mm = minutes.toString().padStart(2, "0") + const ss = remainingSeconds.toString().padStart(2, "0") + return `${hours}:${mm}:${ss}` } export const formatTokens = (tokens: number) => { @@ -34,11 +23,23 @@ export const formatTokens = (tokens: number) => { } if (tokens < 1000000) { - return `${(tokens / 1000).toFixed(1)}k` + // No decimal for thousands (e.g., 72k not 72.5k) + const rounded = Math.round(tokens / 1000) + // If rounding crosses the boundary to 1000k, show as 1.0M instead + if (rounded >= 1000) { + return "1.0M" + } + return `${rounded}k` } if (tokens < 1000000000) { - return `${(tokens / 1000000).toFixed(1)}M` + // Keep decimal for millions (e.g., 3.2M) + const rounded = Math.round(tokens / 100000) / 10 // Round to 1 decimal + // If rounding crosses the boundary to 1000M, show as 1.0B instead + if (rounded >= 1000) { + return "1.0B" + } + return `${rounded.toFixed(1)}M` } return `${(tokens / 1000000000).toFixed(1)}B` diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 57648e4901..8fc30a13f4 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -245,6 +245,7 @@ const bedrockSchema = apiModelIdProviderModelSchema.extend({ awsBedrockEndpointEnabled: z.boolean().optional(), awsBedrockEndpoint: z.string().optional(), awsBedrock1MContext: z.boolean().optional(), // Enable 'context-1m-2025-08-07' beta for 1M context window. + awsBedrockServiceTier: z.enum(["STANDARD", "FLEX", "PRIORITY"]).optional(), // AWS Bedrock service tier selection }) const vertexSchema = apiModelIdProviderModelSchema.extend({ diff --git a/packages/types/src/providers/bedrock.ts b/packages/types/src/providers/bedrock.ts index 9eaa656ef1..fb7abd2217 100644 --- a/packages/types/src/providers/bedrock.ts +++ b/packages/types/src/providers/bedrock.ts @@ -68,6 +68,21 @@ export const bedrockModels = { maxCachePoints: 1, cachableFields: ["system"], }, + "amazon.nova-2-lite-v1:0": { + maxTokens: 65_535, + contextWindow: 1_000_000, + supportsImages: true, + supportsPromptCache: true, + supportsNativeTools: true, + inputPrice: 0.33, + outputPrice: 2.75, + cacheWritesPrice: 0, + cacheReadsPrice: 0.0825, // 75% less than input price + minTokensPerCachePoint: 1, + maxCachePoints: 1, + cachableFields: ["system"], + description: "Amazon Nova 2 Lite - Comparable to Claude Haiku 4.5", + }, "amazon.nova-micro-v1:0": { maxTokens: 5000, contextWindow: 128_000, @@ -562,3 +577,31 @@ export const BEDROCK_GLOBAL_INFERENCE_MODEL_IDS = [ "anthropic.claude-haiku-4-5-20251001-v1:0", "anthropic.claude-opus-4-5-20251101-v1:0", ] as const + +// Amazon Bedrock Service Tier types +export type BedrockServiceTier = "STANDARD" | "FLEX" | "PRIORITY" + +// Models that support service tiers based on AWS documentation +// https://docs.aws.amazon.com/bedrock/latest/userguide/service-tiers-inference.html +export const BEDROCK_SERVICE_TIER_MODEL_IDS = [ + // Amazon Nova models + "amazon.nova-lite-v1:0", + "amazon.nova-2-lite-v1:0", + "amazon.nova-pro-v1:0", + "amazon.nova-pro-latency-optimized-v1:0", + // DeepSeek models + "deepseek.r1-v1:0", + // Qwen models + "qwen.qwen3-next-80b-a3b", + "qwen.qwen3-coder-480b-a35b-v1:0", + // OpenAI GPT-OSS models + "openai.gpt-oss-20b-1:0", + "openai.gpt-oss-120b-1:0", +] as const + +// Service tier pricing multipliers +export const BEDROCK_SERVICE_TIER_PRICING = { + STANDARD: 1.0, // Base price + FLEX: 0.5, // 50% discount from standard + PRIORITY: 1.75, // 75% premium over standard +} as const diff --git a/src/api/providers/__tests__/bedrock.spec.ts b/src/api/providers/__tests__/bedrock.spec.ts index cccec3818f..dd6febcc89 100644 --- a/src/api/providers/__tests__/bedrock.spec.ts +++ b/src/api/providers/__tests__/bedrock.spec.ts @@ -25,7 +25,7 @@ vi.mock("@aws-sdk/client-bedrock-runtime", () => { import { AwsBedrockHandler } from "../bedrock" import { ConverseStreamCommand, BedrockRuntimeClient } from "@aws-sdk/client-bedrock-runtime" -import { BEDROCK_1M_CONTEXT_MODEL_IDS } from "@roo-code/types" +import { BEDROCK_1M_CONTEXT_MODEL_IDS, BEDROCK_SERVICE_TIER_MODEL_IDS, bedrockModels } from "@roo-code/types" import type { Anthropic } from "@anthropic-ai/sdk" @@ -755,4 +755,245 @@ describe("AwsBedrockHandler", () => { expect(commandArg.modelId).toBe(`us.${BEDROCK_1M_CONTEXT_MODEL_IDS[0]}`) }) }) + + describe("service tier feature", () => { + const supportedModelId = BEDROCK_SERVICE_TIER_MODEL_IDS[0] // amazon.nova-lite-v1:0 + + beforeEach(() => { + mockConverseStreamCommand.mockReset() + }) + + describe("pricing multipliers in getModel()", () => { + it("should apply FLEX tier pricing with 50% discount", () => { + const handler = new AwsBedrockHandler({ + apiModelId: supportedModelId, + awsAccessKey: "test", + awsSecretKey: "test", + awsRegion: "us-east-1", + awsBedrockServiceTier: "FLEX", + }) + + const model = handler.getModel() + const baseModel = bedrockModels[supportedModelId as keyof typeof bedrockModels] as { + inputPrice: number + outputPrice: number + } + + // FLEX tier should apply 0.5 multiplier (50% discount) + expect(model.info.inputPrice).toBe(baseModel.inputPrice * 0.5) + expect(model.info.outputPrice).toBe(baseModel.outputPrice * 0.5) + }) + + it("should apply PRIORITY tier pricing with 75% premium", () => { + const handler = new AwsBedrockHandler({ + apiModelId: supportedModelId, + awsAccessKey: "test", + awsSecretKey: "test", + awsRegion: "us-east-1", + awsBedrockServiceTier: "PRIORITY", + }) + + const model = handler.getModel() + const baseModel = bedrockModels[supportedModelId as keyof typeof bedrockModels] as { + inputPrice: number + outputPrice: number + } + + // PRIORITY tier should apply 1.75 multiplier (75% premium) + expect(model.info.inputPrice).toBe(baseModel.inputPrice * 1.75) + expect(model.info.outputPrice).toBe(baseModel.outputPrice * 1.75) + }) + + it("should not modify pricing for STANDARD tier", () => { + const handler = new AwsBedrockHandler({ + apiModelId: supportedModelId, + awsAccessKey: "test", + awsSecretKey: "test", + awsRegion: "us-east-1", + awsBedrockServiceTier: "STANDARD", + }) + + const model = handler.getModel() + const baseModel = bedrockModels[supportedModelId as keyof typeof bedrockModels] as { + inputPrice: number + outputPrice: number + } + + // STANDARD tier should not modify pricing (1.0 multiplier) + expect(model.info.inputPrice).toBe(baseModel.inputPrice) + expect(model.info.outputPrice).toBe(baseModel.outputPrice) + }) + + it("should not apply service tier pricing for unsupported models", () => { + const unsupportedModelId = "anthropic.claude-3-5-sonnet-20241022-v2:0" + const handler = new AwsBedrockHandler({ + apiModelId: unsupportedModelId, + awsAccessKey: "test", + awsSecretKey: "test", + awsRegion: "us-east-1", + awsBedrockServiceTier: "FLEX", // Try to apply FLEX tier + }) + + const model = handler.getModel() + const baseModel = bedrockModels[unsupportedModelId as keyof typeof bedrockModels] as { + inputPrice: number + outputPrice: number + } + + // Pricing should remain unchanged for unsupported models + expect(model.info.inputPrice).toBe(baseModel.inputPrice) + expect(model.info.outputPrice).toBe(baseModel.outputPrice) + }) + }) + + describe("service_tier parameter in API requests", () => { + it("should include service_tier as top-level parameter for supported models", async () => { + const handler = new AwsBedrockHandler({ + apiModelId: supportedModelId, + awsAccessKey: "test", + awsSecretKey: "test", + awsRegion: "us-east-1", + awsBedrockServiceTier: "PRIORITY", + }) + + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: "Test message", + }, + ] + + const generator = handler.createMessage("", messages) + await generator.next() // Start the generator + + // Verify the command was created with service_tier at top level + // Per AWS documentation, service_tier must be a top-level parameter, not inside additionalModelRequestFields + // https://docs.aws.amazon.com/bedrock/latest/userguide/service-tiers-inference.html + expect(mockConverseStreamCommand).toHaveBeenCalled() + const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any + + // service_tier should be at the top level of the payload + expect(commandArg.service_tier).toBe("PRIORITY") + // service_tier should NOT be in additionalModelRequestFields + if (commandArg.additionalModelRequestFields) { + expect(commandArg.additionalModelRequestFields.service_tier).toBeUndefined() + } + }) + + it("should include service_tier FLEX as top-level parameter", async () => { + const handler = new AwsBedrockHandler({ + apiModelId: supportedModelId, + awsAccessKey: "test", + awsSecretKey: "test", + awsRegion: "us-east-1", + awsBedrockServiceTier: "FLEX", + }) + + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: "Test message", + }, + ] + + const generator = handler.createMessage("", messages) + await generator.next() // Start the generator + + expect(mockConverseStreamCommand).toHaveBeenCalled() + const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any + + // service_tier should be at the top level of the payload + expect(commandArg.service_tier).toBe("FLEX") + // service_tier should NOT be in additionalModelRequestFields + if (commandArg.additionalModelRequestFields) { + expect(commandArg.additionalModelRequestFields.service_tier).toBeUndefined() + } + }) + + it("should NOT include service_tier for unsupported models", async () => { + const unsupportedModelId = "anthropic.claude-3-5-sonnet-20241022-v2:0" + const handler = new AwsBedrockHandler({ + apiModelId: unsupportedModelId, + awsAccessKey: "test", + awsSecretKey: "test", + awsRegion: "us-east-1", + awsBedrockServiceTier: "PRIORITY", // Try to apply PRIORITY tier + }) + + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: "Test message", + }, + ] + + const generator = handler.createMessage("", messages) + await generator.next() // Start the generator + + expect(mockConverseStreamCommand).toHaveBeenCalled() + const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any + + // Service tier should NOT be included for unsupported models (at top level or in additionalModelRequestFields) + expect(commandArg.service_tier).toBeUndefined() + if (commandArg.additionalModelRequestFields) { + expect(commandArg.additionalModelRequestFields.service_tier).toBeUndefined() + } + }) + + it("should NOT include service_tier when not specified", async () => { + const handler = new AwsBedrockHandler({ + apiModelId: supportedModelId, + awsAccessKey: "test", + awsSecretKey: "test", + awsRegion: "us-east-1", + // No awsBedrockServiceTier specified + }) + + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: "Test message", + }, + ] + + const generator = handler.createMessage("", messages) + await generator.next() // Start the generator + + expect(mockConverseStreamCommand).toHaveBeenCalled() + const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any + + // Service tier should NOT be included when not specified (at top level or in additionalModelRequestFields) + expect(commandArg.service_tier).toBeUndefined() + if (commandArg.additionalModelRequestFields) { + expect(commandArg.additionalModelRequestFields.service_tier).toBeUndefined() + } + }) + }) + + describe("service tier with cross-region inference", () => { + it("should apply service tier pricing with cross-region inference prefix", () => { + const handler = new AwsBedrockHandler({ + apiModelId: supportedModelId, + awsAccessKey: "test", + awsSecretKey: "test", + awsRegion: "us-east-1", + awsUseCrossRegionInference: true, + awsBedrockServiceTier: "FLEX", + }) + + const model = handler.getModel() + const baseModel = bedrockModels[supportedModelId as keyof typeof bedrockModels] as { + inputPrice: number + outputPrice: number + } + + // Model ID should have cross-region prefix + expect(model.id).toBe(`us.${supportedModelId}`) + + // FLEX tier pricing should still be applied + expect(model.info.inputPrice).toBe(baseModel.inputPrice * 0.5) + expect(model.info.outputPrice).toBe(baseModel.outputPrice * 0.5) + }) + }) + }) }) diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index b96f3aa435..e90afad85f 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -99,6 +99,28 @@ vitest.mock("../fetchers/modelCache", () => ({ cacheReadsPrice: 0.3, description: "Claude 3.7 Sonnet with thinking", }, + "openai/gpt-4o": { + maxTokens: 16384, + contextWindow: 128000, + supportsImages: true, + supportsPromptCache: false, + supportsNativeTools: true, + inputPrice: 2.5, + outputPrice: 10, + description: "GPT-4o", + }, + "openai/o1": { + maxTokens: 100000, + contextWindow: 200000, + supportsImages: true, + supportsPromptCache: false, + supportsNativeTools: true, + inputPrice: 15, + outputPrice: 60, + description: "OpenAI o1", + excludedTools: ["existing_excluded"], + includedTools: ["existing_included"], + }, }) }), })) @@ -176,6 +198,51 @@ describe("OpenRouterHandler", () => { expect(result.reasoningBudget).toBeUndefined() expect(result.temperature).toBe(0) }) + + it("adds excludedTools and includedTools for OpenAI models", async () => { + const handler = new OpenRouterHandler({ + openRouterApiKey: "test-key", + openRouterModelId: "openai/gpt-4o", + }) + + const result = await handler.fetchModel() + expect(result.id).toBe("openai/gpt-4o") + expect(result.info.excludedTools).toContain("apply_diff") + expect(result.info.excludedTools).toContain("write_to_file") + expect(result.info.includedTools).toContain("apply_patch") + }) + + it("merges excludedTools and includedTools with existing values for OpenAI models", async () => { + const handler = new OpenRouterHandler({ + openRouterApiKey: "test-key", + openRouterModelId: "openai/o1", + }) + + const result = await handler.fetchModel() + expect(result.id).toBe("openai/o1") + // Should have the new exclusions + expect(result.info.excludedTools).toContain("apply_diff") + expect(result.info.excludedTools).toContain("write_to_file") + // Should preserve existing exclusions + expect(result.info.excludedTools).toContain("existing_excluded") + // Should have the new inclusions + expect(result.info.includedTools).toContain("apply_patch") + // Should preserve existing inclusions + expect(result.info.includedTools).toContain("existing_included") + }) + + it("does not add excludedTools or includedTools for non-OpenAI models", async () => { + const handler = new OpenRouterHandler({ + openRouterApiKey: "test-key", + openRouterModelId: "anthropic/claude-sonnet-4", + }) + + const result = await handler.fetchModel() + expect(result.id).toBe("anthropic/claude-sonnet-4") + // Should NOT have the tool exclusions/inclusions + expect(result.info.excludedTools).toBeUndefined() + expect(result.info.includedTools).toBeUndefined() + }) }) describe("createMessage", () => { diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index be156f876e..ea4d0025a0 100644 --- a/src/api/providers/bedrock.ts +++ b/src/api/providers/bedrock.ts @@ -18,6 +18,7 @@ import { type ModelInfo, type ProviderSettings, type BedrockModelId, + type BedrockServiceTier, bedrockDefaultModelId, bedrockModels, bedrockDefaultPromptRouterModelId, @@ -27,6 +28,8 @@ import { AWS_INFERENCE_PROFILE_MAPPING, BEDROCK_1M_CONTEXT_MODEL_IDS, BEDROCK_GLOBAL_INFERENCE_MODEL_IDS, + BEDROCK_SERVICE_TIER_MODEL_IDS, + BEDROCK_SERVICE_TIER_PRICING, } from "@roo-code/types" import { ApiStream } from "../transform/stream" @@ -74,6 +77,13 @@ interface BedrockPayload { toolConfig?: ToolConfiguration } +// Extended payload type that includes service_tier as a top-level parameter +// AWS Bedrock service tiers (STANDARD, FLEX, PRIORITY) are specified at the top level +// https://docs.aws.amazon.com/bedrock/latest/userguide/service-tiers-inference.html +type BedrockPayloadWithServiceTier = BedrockPayload & { + service_tier?: BedrockServiceTier +} + // Define specific types for content block events to avoid 'as any' usage // These handle the multiple possible structures returned by AWS SDK interface ContentBlockStartEvent { @@ -433,6 +443,17 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH additionalModelRequestFields.anthropic_beta = anthropicBetas } + // Determine if service tier should be applied (checked later when building payload) + const useServiceTier = + this.options.awsBedrockServiceTier && BEDROCK_SERVICE_TIER_MODEL_IDS.includes(baseModelId as any) + if (useServiceTier) { + logger.info("Service tier specified for Bedrock request", { + ctx: "bedrock", + modelId: modelConfig.id, + serviceTier: this.options.awsBedrockServiceTier, + }) + } + // Build tool configuration if native tools are enabled let toolConfig: ToolConfiguration | undefined if (useNativeTools && metadata?.tools) { @@ -442,7 +463,10 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH } } - const payload: BedrockPayload = { + // Build payload with optional service_tier at top level + // Service tier is a top-level parameter per AWS documentation, NOT inside additionalModelRequestFields + // https://docs.aws.amazon.com/bedrock/latest/userguide/service-tiers-inference.html + const payload: BedrockPayloadWithServiceTier = { modelId: modelConfig.id, messages: formatted.messages, system: formatted.system, @@ -451,6 +475,8 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH // Add anthropic_version at top level when using thinking features ...(thinkingEnabled && { anthropic_version: "bedrock-2023-05-31" }), ...(toolConfig && { toolConfig }), + // Add service_tier as a top-level parameter (not inside additionalModelRequestFields) + ...(useServiceTier && { service_tier: this.options.awsBedrockServiceTier }), } // Create AbortController with 10 minute timeout @@ -1089,6 +1115,30 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH defaultTemperature: BEDROCK_DEFAULT_TEMPERATURE, }) + // Apply service tier pricing if specified and model supports it + const baseModelIdForTier = this.parseBaseModelId(modelConfig.id) + if (this.options.awsBedrockServiceTier && BEDROCK_SERVICE_TIER_MODEL_IDS.includes(baseModelIdForTier as any)) { + const pricingMultiplier = BEDROCK_SERVICE_TIER_PRICING[this.options.awsBedrockServiceTier] + if (pricingMultiplier && pricingMultiplier !== 1.0) { + // Apply pricing multiplier to all price fields + modelConfig.info = { + ...modelConfig.info, + inputPrice: modelConfig.info.inputPrice + ? modelConfig.info.inputPrice * pricingMultiplier + : undefined, + outputPrice: modelConfig.info.outputPrice + ? modelConfig.info.outputPrice * pricingMultiplier + : undefined, + cacheWritesPrice: modelConfig.info.cacheWritesPrice + ? modelConfig.info.cacheWritesPrice * pricingMultiplier + : undefined, + cacheReadsPrice: modelConfig.info.cacheReadsPrice + ? modelConfig.info.cacheReadsPrice * pricingMultiplier + : undefined, + } + } + } + // Don't override maxTokens/contextWindow here; handled in getModelById (and includes user overrides) return { ...modelConfig, ...params } as { id: BedrockModelId | string diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 37550722e5..20ba015043 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -1,5 +1,6 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" +import { z } from "zod" import { openRouterDefaultModelId, @@ -30,11 +31,9 @@ import { getModelEndpoints } from "./fetchers/modelEndpointCache" import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" -import { Package } from "../../shared/package" import type { ApiHandlerCreateMessageMetadata, SingleCompletionHandler } from "../index" import { handleOpenAIError } from "./utils/openai-error-handler" import { generateImageWithProvider, ImageGenerationResult } from "./utils/image-generation" -import { sanitizeReasoningDetailId } from "./utils/sanitize-reasoning-id" // Add custom interface for OpenRouter params. type OpenRouterChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParams & { @@ -44,13 +43,77 @@ type OpenRouterChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParams & { reasoning?: OpenRouterReasoningParams } -// OpenRouter error structure that may include metadata.raw with actual upstream error +// Zod schema for OpenRouter error response structure (for caught exceptions) +const OpenRouterErrorResponseSchema = z.object({ + error: z + .object({ + message: z.string().optional(), + code: z.number().optional(), + metadata: z + .object({ + raw: z.string().optional(), + }) + .optional(), + }) + .optional(), +}) + +// OpenRouter error structure that may include error.metadata.raw with actual upstream error +// This is for caught exceptions which have the error wrapped in an "error" property interface OpenRouterErrorResponse { + error?: { + message?: string + code?: number + metadata?: { raw?: string } + } +} + +// Direct error object structure (for streaming errors passed directly) +interface OpenRouterError { message?: string code?: number metadata?: { raw?: string } } +/** + * Helper function to parse and extract error message from metadata.raw + * metadata.raw is often a JSON encoded string that may contain .message or .error fields + * Example structures: + * - {"message": "Error text"} + * - {"error": "Error text"} + * - {"error": {"message": "Error text"}} + * - {"type":"error","error":{"type":"invalid_request_error","message":"tools: Tool names must be unique."}} + */ +function extractErrorFromMetadataRaw(raw: string | undefined): string | undefined { + if (!raw) { + return undefined + } + + try { + const parsed = JSON.parse(raw) + // Check for common error message fields + if (typeof parsed === "object" && parsed !== null) { + // Check for direct message field + if (typeof parsed.message === "string") { + return parsed.message + } + // Check for nested error.message field (e.g., Anthropic error format) + if (typeof parsed.error === "object" && parsed.error !== null && typeof parsed.error.message === "string") { + return parsed.error.message + } + // Check for error as a string + if (typeof parsed.error === "string") { + return parsed.error + } + } + // If we can't extract a specific field, return the raw string + return raw + } catch { + // If it's not valid JSON, return as-is + return raw + } +} + // See `OpenAI.Chat.Completions.ChatCompletionChunk["usage"]` // `CompletionsAPI.CompletionUsage` // See also: https://openrouter.ai/docs/use-cases/usage-accounting @@ -121,19 +184,16 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH /** * Handle OpenRouter streaming error response and report to telemetry. * OpenRouter may include metadata.raw with the actual upstream provider error. + * @param error The error object (not wrapped - receives the error directly) */ - private handleStreamingError(error: OpenRouterErrorResponse, modelId: string, operation: string): never { - const rawErrorMessage = error?.metadata?.raw || error?.message + private handleStreamingError(error: OpenRouterError, modelId: string, operation: string): never { + const rawString = error?.metadata?.raw + const parsedError = extractErrorFromMetadataRaw(rawString) + const rawErrorMessage = parsedError || error?.message || "Unknown error" const apiError = Object.assign( - new ApiProviderError( - rawErrorMessage ?? "Unknown error", - this.providerName, - modelId, - operation, - error?.code, - ), - { status: error?.code, error: { message: error?.message, metadata: error?.metadata } }, + new ApiProviderError(rawErrorMessage, this.providerName, modelId, operation, error?.code), + { status: error?.code, error }, ) TelemetryService.instance.captureException(apiError) @@ -258,10 +318,38 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH try { stream = await this.client.chat.completions.create(completionParams, requestOptions) } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage") - TelemetryService.instance.captureException(apiError) - throw handleOpenAIError(error, this.providerName) + // Try to parse as OpenRouter error structure using Zod + const parseResult = OpenRouterErrorResponseSchema.safeParse(error) + + if (parseResult.success && parseResult.data.error) { + const openRouterError = parseResult.data + const rawString = openRouterError.error?.metadata?.raw + const parsedError = extractErrorFromMetadataRaw(rawString) + const rawErrorMessage = parsedError || openRouterError.error?.message || "Unknown error" + + const apiError = Object.assign( + new ApiProviderError( + rawErrorMessage, + this.providerName, + modelId, + "createMessage", + openRouterError.error?.code, + ), + { + status: openRouterError.error?.code, + error: openRouterError.error, + }, + ) + + TelemetryService.instance.captureException(apiError) + throw handleOpenAIError(error, this.providerName) + } else { + // Fallback for non-OpenRouter errors + const errorMessage = error instanceof Error ? error.message : String(error) + const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage") + TelemetryService.instance.captureException(apiError) + throw handleOpenAIError(error, this.providerName) + } } let lastUsage: CompletionUsage | undefined = undefined @@ -283,7 +371,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH for await (const chunk of stream) { // OpenRouter returns an error object instead of the OpenAI SDK throwing an error. if ("error" in chunk) { - this.handleStreamingError(chunk.error as OpenRouterErrorResponse, modelId, "createMessage") + this.handleStreamingError(chunk.error as OpenRouterError, modelId, "createMessage") } const delta = chunk.choices[0]?.delta @@ -323,18 +411,18 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH if (detail.data !== undefined) { existing.data = (existing.data || "") + detail.data } - // Update other fields if provided - sanitize ID to remove invalid characters - if (detail.id !== undefined) existing.id = sanitizeReasoningDetailId(detail.id) + // Update other fields if provided + if (detail.id !== undefined) existing.id = detail.id if (detail.format !== undefined) existing.format = detail.format if (detail.signature !== undefined) existing.signature = detail.signature } else { - // Start new reasoning detail accumulation - sanitize ID to remove invalid characters + // Start new reasoning detail accumulation reasoningDetailsAccumulator.set(key, { type: detail.type, text: detail.text, summary: detail.summary, data: detail.data, - id: sanitizeReasoningDetailId(detail.id), + id: detail.id, format: detail.format, signature: detail.signature, index, @@ -434,6 +522,16 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH info = this.endpoints[this.options.openRouterSpecificProvider] } + // For OpenAI models via OpenRouter, exclude write_to_file and apply_diff, and include apply_patch + // This matches the behavior of the native OpenAI provider + if (id.startsWith("openai/")) { + info = { + ...info, + excludedTools: [...new Set([...(info.excludedTools || []), "apply_diff", "write_to_file"])], + includedTools: [...new Set([...(info.includedTools || []), "apply_patch"])], + } + } + const isDeepSeekR1 = id.startsWith("deepseek/deepseek-r1") || id === "perplexity/sonar-reasoning" const params = getModelParams({ @@ -481,14 +579,42 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH signal: metadata?.signal, }) } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt") - TelemetryService.instance.captureException(apiError) - throw handleOpenAIError(error, this.providerName) + // Try to parse as OpenRouter error structure using Zod + const parseResult = OpenRouterErrorResponseSchema.safeParse(error) + + if (parseResult.success && parseResult.data.error) { + const openRouterError = parseResult.data + const rawString = openRouterError.error?.metadata?.raw + const parsedError = extractErrorFromMetadataRaw(rawString) + const rawErrorMessage = parsedError || openRouterError.error?.message || "Unknown error" + + const apiError = Object.assign( + new ApiProviderError( + rawErrorMessage, + this.providerName, + modelId, + "completePrompt", + openRouterError.error?.code, + ), + { + status: openRouterError.error?.code, + error: openRouterError.error, + }, + ) + + TelemetryService.instance.captureException(apiError) + throw handleOpenAIError(error, this.providerName) + } else { + // Fallback for non-OpenRouter errors + const errorMessage = error instanceof Error ? error.message : String(error) + const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt") + TelemetryService.instance.captureException(apiError) + throw handleOpenAIError(error, this.providerName) + } } if ("error" in response) { - this.handleStreamingError(response.error as OpenRouterErrorResponse, modelId, "completePrompt") + this.handleStreamingError(response.error as OpenRouterError, modelId, "completePrompt") } const completion = response as OpenAI.Chat.ChatCompletion diff --git a/webview-ui/src/components/settings/providers/Bedrock.tsx b/webview-ui/src/components/settings/providers/Bedrock.tsx index fac75170e9..75c4ea9a17 100644 --- a/webview-ui/src/components/settings/providers/Bedrock.tsx +++ b/webview-ui/src/components/settings/providers/Bedrock.tsx @@ -5,9 +5,11 @@ import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" import { type ProviderSettings, type ModelInfo, + type BedrockServiceTier, BEDROCK_REGIONS, BEDROCK_1M_CONTEXT_MODEL_IDS, BEDROCK_GLOBAL_INFERENCE_MODEL_IDS, + BEDROCK_SERVICE_TIER_MODEL_IDS, } from "@roo-code/types" import { useAppTranslation } from "@src/i18n/TranslationContext" @@ -35,6 +37,10 @@ export const Bedrock = ({ apiConfiguration, setApiConfigurationField, selectedMo !!apiConfiguration?.apiModelId && BEDROCK_GLOBAL_INFERENCE_MODEL_IDS.includes(apiConfiguration.apiModelId as any) + // Check if the selected model supports service tiers + const supportsServiceTiers = + !!apiConfiguration?.apiModelId && BEDROCK_SERVICE_TIER_MODEL_IDS.includes(apiConfiguration.apiModelId as any) + // Update the endpoint enabled state when the configuration changes useEffect(() => { setAwsEndpointSelected(!!apiConfiguration?.awsBedrockEndpointEnabled) @@ -150,6 +156,49 @@ export const Bedrock = ({ apiConfiguration, setApiConfigurationField, selectedMo
+ {supportsServiceTiers && ( +
+ + +
+ {t("settings:providers.awsServiceTierNote")} +
+
+ )} {supportsGlobalInference && (