diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 26c4dee7e1..800e40f6e9 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -85,7 +85,7 @@ export const isInternalProvider = (key: string): key is InternalProvider => * Custom providers are completely configurable within Roo Code settings. */ -export const customProviders = ["openai"] as const +export const customProviders = ["openai", "anthropic-custom"] as const export type CustomProvider = (typeof customProviders)[number] @@ -262,6 +262,15 @@ const openAiSchema = baseProviderSettingsSchema.extend({ openAiHeaders: z.record(z.string(), z.string()).optional(), }) +const anthropicCustomSchema = baseProviderSettingsSchema.extend({ + anthropicCustomBaseUrl: z.string().optional(), + anthropicCustomApiKey: z.string().optional(), + anthropicCustomModelId: z.string().optional(), + anthropicCustomModelInfo: modelInfoSchema.nullish(), + anthropicCustomStreamingEnabled: z.boolean().optional(), + anthropicCustomHeaders: z.record(z.string(), z.string()).optional(), +}) + const ollamaSchema = baseProviderSettingsSchema.extend({ ollamaModelId: z.string().optional(), ollamaBaseUrl: z.string().optional(), @@ -427,6 +436,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv bedrockSchema.merge(z.object({ apiProvider: z.literal("bedrock") })), vertexSchema.merge(z.object({ apiProvider: z.literal("vertex") })), openAiSchema.merge(z.object({ apiProvider: z.literal("openai") })), + anthropicCustomSchema.merge(z.object({ apiProvider: z.literal("anthropic-custom") })), ollamaSchema.merge(z.object({ apiProvider: z.literal("ollama") })), vsCodeLmSchema.merge(z.object({ apiProvider: z.literal("vscode-lm") })), lmStudioSchema.merge(z.object({ apiProvider: z.literal("lmstudio") })), @@ -463,6 +473,7 @@ export const providerSettingsSchema = z.object({ ...bedrockSchema.shape, ...vertexSchema.shape, ...openAiSchema.shape, + ...anthropicCustomSchema.shape, ...ollamaSchema.shape, ...vsCodeLmSchema.shape, ...lmStudioSchema.shape, @@ -512,6 +523,7 @@ export const modelIdKeys = [ "apiModelId", "openRouterModelId", "openAiModelId", + "anthropicCustomModelId", "ollamaModelId", "lmStudioModelId", "lmStudioDraftModelId", @@ -575,7 +587,7 @@ export const modelIdKeysByProvider: Record = { */ // Providers that use Anthropic-style API protocol. -export const ANTHROPIC_STYLE_PROVIDERS: ProviderName[] = ["anthropic", "bedrock", "minimax"] +export const ANTHROPIC_STYLE_PROVIDERS: ProviderName[] = ["anthropic", "anthropic-custom", "bedrock", "minimax"] export const getApiProtocol = (provider: ProviderName | undefined, modelId?: string): "anthropic" | "openai" => { if (provider && ANTHROPIC_STYLE_PROVIDERS.includes(provider)) { @@ -615,7 +627,7 @@ export const getApiProtocol = (provider: ProviderName | undefined, modelId?: str */ export const MODELS_BY_PROVIDER: Record< - Exclude, + Exclude, { id: ProviderName; label: string; models: string[] } > = { anthropic: { diff --git a/src/api/index.ts b/src/api/index.ts index 0c901f8e23..4618b7b6ba 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -133,6 +133,7 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { switch (apiProvider) { case "anthropic": + case "anthropic-custom": return new AnthropicHandler(options) case "openrouter": return new OpenRouterHandler(options) diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index 7a4ef30ad0..9a22e80b92 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -8,6 +8,7 @@ import { type AnthropicModelId, anthropicDefaultModelId, anthropicModels, + openAiModelInfoSaneDefaults, ANTHROPIC_DEFAULT_MAX_TOKENS, ApiProviderError, } from "@roo-code/types" @@ -38,12 +39,17 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa super() this.options = options + const baseURL = this.options.anthropicCustomBaseUrl || this.options.anthropicBaseUrl || undefined + const apiKey = this.options.anthropicCustomApiKey || this.options.apiKey const apiKeyFieldName = - this.options.anthropicBaseUrl && this.options.anthropicUseAuthToken ? "authToken" : "apiKey" + baseURL && this.options.anthropicUseAuthToken && !this.options.anthropicCustomApiKey + ? "authToken" + : "apiKey" this.client = new Anthropic({ - baseURL: this.options.anthropicBaseUrl || undefined, - [apiKeyFieldName]: this.options.apiKey, + baseURL, + [apiKeyFieldName]: apiKey, + defaultHeaders: this.options.anthropicCustomHeaders, timeout: this.timeoutMs, }) } @@ -352,9 +358,14 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa } getModel() { - const modelId = this.options.apiModelId - const id = modelId && modelId in anthropicModels ? (modelId as AnthropicModelId) : anthropicDefaultModelId - let info: ModelInfo = anthropicModels[id] + const customModelId = this.options.anthropicCustomModelId + const modelId = customModelId || this.options.apiModelId + const id = + customModelId || + (modelId && modelId in anthropicModels ? (modelId as AnthropicModelId) : anthropicDefaultModelId) + let info: ModelInfo = customModelId + ? this.options.anthropicCustomModelInfo || openAiModelInfoSaneDefaults + : anthropicModels[id as AnthropicModelId] // If 1M context beta is enabled for supported models, update the model info if ( diff --git a/src/shared/ProfileValidator.ts b/src/shared/ProfileValidator.ts index 7246a90177..fd1703c72c 100644 --- a/src/shared/ProfileValidator.ts +++ b/src/shared/ProfileValidator.ts @@ -53,6 +53,8 @@ export class ProfileValidator { switch (profile.apiProvider) { case "openai": return profile.openAiModelId + case "anthropic-custom": + return profile.anthropicCustomModelId case "anthropic": case "openai-native": case "bedrock": diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 70617a1ee6..db9ee93da2 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -48,6 +48,7 @@ import { import { Anthropic, + AnthropicCustom, Baseten, Bedrock, DeepSeek, @@ -468,6 +469,16 @@ const ApiOptions = ({ /> )} + {selectedProvider === "anthropic-custom" && ( + + )} + {selectedProvider === "openai-codex" && ( ( + field: K, + value: ProviderSettings[K], + isUserAction?: boolean, + ) => void + organizationAllowList: OrganizationAllowList + modelValidationError?: string + simplifySettings?: boolean +} + +const anthropicCustomDefaultModelId = "claude-sonnet-4-5" + +export const AnthropicCustom = ({ + apiConfiguration, + setApiConfigurationField, + organizationAllowList, + modelValidationError, + simplifySettings, +}: AnthropicCustomProps) => { + const { t } = useAppTranslation() + + const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicCustomBaseUrl) + + useEffect(() => { + if (!apiConfiguration.anthropicCustomModelInfo) { + setApiConfigurationField( + "anthropicCustomModelInfo", + { + ...openAiModelInfoSaneDefaults, + ...(anthropicModels[anthropicCustomDefaultModelId] || {}), + }, + false, + ) + } + }, [apiConfiguration.anthropicCustomModelInfo, setApiConfigurationField]) + + const handleInputChange = useCallback( + ( + field: K, + transform: (event: E) => ProviderSettings[K] = inputEventTransform, + ) => + (event: E | Event) => { + setApiConfigurationField(field, transform(event as E)) + }, + [setApiConfigurationField], + ) + + const getCustomModelInfo = () => apiConfiguration?.anthropicCustomModelInfo || openAiModelInfoSaneDefaults + + return ( + <> + + + +
+ {t("settings:providers.apiKeyStorageNotice")} +
+ {!apiConfiguration?.anthropicCustomApiKey && ( + + {t("settings:providers.getAnthropicApiKey")} + + )} +
+ { + setAnthropicBaseUrlSelected(checked) + + if (!checked) { + setApiConfigurationField("anthropicCustomBaseUrl", "") + } + }}> + {t("settings:providers.useCustomBaseUrl")} + + {anthropicBaseUrlSelected && ( + + + + )} +
+ { + setApiConfigurationField(field, value, isUserAction) + + if (field === "anthropicCustomModelId") { + setApiConfigurationField( + "anthropicCustomModelInfo", + { + ...openAiModelInfoSaneDefaults, + ...(anthropicModels[value as keyof typeof anthropicModels] || {}), + }, + false, + ) + } + }} + defaultModelId={anthropicCustomDefaultModelId} + models={anthropicModels} + modelIdKey="anthropicCustomModelId" + serviceName="Anthropic" + serviceUrl="https://docs.anthropic.com" + organizationAllowList={organizationAllowList} + errorMessage={modelValidationError} + simplifySettings={simplifySettings} + /> + +
+
+ {t("settings:providers.customModel.capabilities")} +
+ +
+ { + const value = parseInt((e.target as HTMLInputElement).value) + + return { + ...getCustomModelInfo(), + maxTokens: isNaN(value) ? undefined : value, + } + })} + placeholder={t("settings:placeholders.numbers.maxTokens")} + className="w-full"> + + +
+ {t("settings:providers.customModel.maxTokens.description")} +
+
+ +
+ { + const value = parseInt((e.target as HTMLInputElement).value) + + return { + ...getCustomModelInfo(), + contextWindow: isNaN(value) ? openAiModelInfoSaneDefaults.contextWindow : value, + } + })} + placeholder={t("settings:placeholders.numbers.contextWindow")} + className="w-full"> + + +
+ {t("settings:providers.customModel.contextWindow.description")} +
+
+ +
+
+ ({ + ...getCustomModelInfo(), + supportsImages: checked, + }))}> + + {t("settings:providers.customModel.imageSupport.label")} + + + + + +
+
+ +
+
+ ({ + ...getCustomModelInfo(), + supportsPromptCache: checked, + }))}> + {t("settings:providers.customModel.promptCache.label")} + + + + +
+
+ +
+ { + const parsed = parseFloat((e.target as HTMLInputElement).value) + + return { + ...getCustomModelInfo(), + inputPrice: isNaN(parsed) ? openAiModelInfoSaneDefaults.inputPrice : parsed, + } + })} + placeholder={t("settings:placeholders.numbers.inputPrice")} + className="w-full"> +
+ + + + +
+
+
+ +
+ { + const parsed = parseFloat((e.target as HTMLInputElement).value) + + return { + ...getCustomModelInfo(), + outputPrice: isNaN(parsed) ? openAiModelInfoSaneDefaults.outputPrice : parsed, + } + })} + placeholder={t("settings:placeholders.numbers.outputPrice")} + className="w-full"> +
+ + + + +
+
+
+ + {getCustomModelInfo().supportsPromptCache && ( + <> +
+ { + const parsed = parseFloat((e.target as HTMLInputElement).value) + + return { + ...getCustomModelInfo(), + cacheReadsPrice: isNaN(parsed) ? 0 : parsed, + } + })} + placeholder={t("settings:placeholders.numbers.inputPrice")} + className="w-full"> + + {t("settings:providers.customModel.pricing.cacheReads.label")} + + +
+
+ { + const parsed = parseFloat((e.target as HTMLInputElement).value) + + return { + ...getCustomModelInfo(), + cacheWritesPrice: isNaN(parsed) ? 0 : parsed, + } + })} + placeholder={t("settings:placeholders.numbers.cacheWritePrice")} + className="w-full"> + + +
+ + )} + + +
+ + ) +} diff --git a/webview-ui/src/components/settings/providers/index.ts b/webview-ui/src/components/settings/providers/index.ts index d5dd0d0ded..23b9bc7859 100644 --- a/webview-ui/src/components/settings/providers/index.ts +++ b/webview-ui/src/components/settings/providers/index.ts @@ -1,4 +1,5 @@ export { Anthropic } from "./Anthropic" +export { AnthropicCustom } from "./AnthropicCustom" export { Bedrock } from "./Bedrock" export { DeepSeek } from "./DeepSeek" export { Gemini } from "./Gemini" diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index d3ebb6c0dd..b14d13c6d8 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -370,8 +370,15 @@ function getSelectedModel({ // case "anthropic": // case "fake-ai": default: { - provider satisfies "anthropic" | "gemini-cli" | "fake-ai" - const id = apiConfiguration.apiModelId ?? defaultModelId + provider satisfies "anthropic" | "anthropic-custom" | "gemini-cli" | "fake-ai" + const id = apiConfiguration.apiModelId ?? apiConfiguration.anthropicCustomModelId ?? defaultModelId + + // For anthropic-custom, use custom model info if available + if (provider === "anthropic-custom") { + const info = apiConfiguration.anthropicCustomModelInfo || undefined + return { id, info } + } + const baseInfo = anthropicModels[id as keyof typeof anthropicModels] // Apply 1M context beta tier pricing for supported Claude 4 models diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index 3de6480802..06bd63e237 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -97,6 +97,14 @@ function validateModelsAndKeysProvided( return i18next.t("settings:validation.openAi") } break + case "anthropic-custom": + if (!apiConfiguration.anthropicCustomApiKey) { + return i18next.t("settings:validation.apiKey") + } + if (!apiConfiguration.anthropicCustomModelId) { + return i18next.t("settings:validation.modelId") + } + break case "ollama": if (!apiConfiguration.ollamaModelId) { return i18next.t("settings:validation.modelId") @@ -195,6 +203,10 @@ function getModelIdForProvider(apiConfiguration: ProviderSettings, provider: Pro return apiConfiguration.vsCodeLmModelSelector?.id } + if (provider === "anthropic-custom") { + return apiConfiguration.anthropicCustomModelId + } + if (isCustomProvider(provider) || isFauxProvider(provider)) { return apiConfiguration.apiModelId }