diff --git a/packages/types/src/constants.ts b/packages/types/src/constants.ts new file mode 100644 index 000000000..92fca1150 --- /dev/null +++ b/packages/types/src/constants.ts @@ -0,0 +1,39 @@ +/** + * API key environment variable names organized by provider + */ +export const API_KEYS = { + ANTHROPIC: 'ANTHROPIC_API_KEY', + OPENAI: 'OPENAI_API_KEY', + OPEN_ROUTER: 'OPENROUTER_API_KEY', + GLAMA: 'GLAMA_API_KEY', + GEMINI: 'GEMINI_API_KEY', + MISTRAL: 'MISTRAL_API_KEY', + DEEP_SEEK: 'DEEPSEEK_API_KEY', + UNBOUND: 'UNBOUND_API_KEY', + REQUESTY: 'REQUESTY_API_KEY', + XAI: 'XAI_API_KEY', + GROQ: 'GROQ_API_KEY', + CHUTES: 'CHUTES_API_KEY', + LITELLM: 'LITELLM_API_KEY', + CEREBRAS: 'CEREBRAS_API_KEY', + DEEP_INFRA: 'DEEPINFRA_API_KEY', + DOUBAO: 'DOUBAO_API_KEY', + FEATHERLESS: 'FEATHERLESS_API_KEY', + FIREWORKS: 'FIREWORKS_API_KEY', + HUGGING_FACE: 'HUGGINGFACE_API_KEY', + IO_INTELLIGENCE: 'IOINTELLIGENCE_API_KEY', + MOONSHOOT: 'MOONSHOT_API_KEY', + SAMBA_NOVA: 'SAMBANOVA_API_KEY', + VERCEL: 'VERCEL_API_KEY', + ZAI: 'ZAI_API_KEY', +} as const + +/** + * Array of all API key environment variable names + */ +export const API_KEY_ENV_VAR_NAMES = Object.values(API_KEYS) + +/** + * Type for API key environment variable names + */ +export type ApiKeyEnvVar = typeof API_KEYS[keyof typeof API_KEYS] \ No newline at end of file diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 7a7d5059e..af3fb7d87 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,6 +1,7 @@ export * from "./api.js" export * from "./cloud.js" export * from "./codebase-index.js" +export * from "./constants.js" export * from "./cookie-consent.js" export * from "./events.js" export * from "./experiment.js" diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 843415854..b14f42228 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -190,6 +190,7 @@ const apiModelIdProviderModelSchema = baseProviderSettingsSchema.extend({ const anthropicSchema = apiModelIdProviderModelSchema.extend({ apiKey: z.string().optional(), + anthropicConfigUseEnvVars: z.boolean().optional(), anthropicBaseUrl: z.string().optional(), anthropicUseAuthToken: z.boolean().optional(), anthropicBeta1MContext: z.boolean().optional(), // Enable 'context-1m-2025-08-07' beta for 1M context window. @@ -203,10 +204,12 @@ const claudeCodeSchema = apiModelIdProviderModelSchema.extend({ const glamaSchema = baseProviderSettingsSchema.extend({ glamaModelId: z.string().optional(), glamaApiKey: z.string().optional(), + glamaConfigUseEnvVars: z.boolean().optional(), }) const openRouterSchema = baseProviderSettingsSchema.extend({ openRouterApiKey: z.string().optional(), + openRouterConfigUseEnvVars: z.boolean().optional(), openRouterModelId: z.string().optional(), openRouterBaseUrl: z.string().optional(), openRouterSpecificProvider: z.string().optional(), @@ -243,6 +246,7 @@ const vertexSchema = apiModelIdProviderModelSchema.extend({ const openAiSchema = baseProviderSettingsSchema.extend({ openAiBaseUrl: z.string().optional(), openAiApiKey: z.string().optional(), + openAiConfigUseEnvVars: z.boolean().optional(), openAiLegacyFormat: z.boolean().optional(), openAiR1FormatEnabled: z.boolean().optional(), openAiModelId: z.string().optional(), @@ -281,6 +285,7 @@ const lmStudioSchema = baseProviderSettingsSchema.extend({ const geminiSchema = apiModelIdProviderModelSchema.extend({ geminiApiKey: z.string().optional(), + geminiConfigUseEnvVars: z.boolean().optional(), googleGeminiBaseUrl: z.string().optional(), enableUrlContext: z.boolean().optional(), enableGrounding: z.boolean().optional(), @@ -293,6 +298,7 @@ const geminiCliSchema = apiModelIdProviderModelSchema.extend({ const openAiNativeSchema = apiModelIdProviderModelSchema.extend({ openAiNativeApiKey: z.string().optional(), + openAiNativeConfigUseEnvVars: z.boolean().optional(), openAiNativeBaseUrl: z.string().optional(), // OpenAI Responses API service tier for openai-native provider only. // UI should only expose this when the selected model supports flex/priority. @@ -301,23 +307,27 @@ const openAiNativeSchema = apiModelIdProviderModelSchema.extend({ const mistralSchema = apiModelIdProviderModelSchema.extend({ mistralApiKey: z.string().optional(), + mistralConfigUseEnvVars: z.boolean().optional(), mistralCodestralUrl: z.string().optional(), }) const deepSeekSchema = apiModelIdProviderModelSchema.extend({ deepSeekBaseUrl: z.string().optional(), deepSeekApiKey: z.string().optional(), + deepSeekConfigUseEnvVars: z.boolean().optional(), }) const deepInfraSchema = apiModelIdProviderModelSchema.extend({ deepInfraBaseUrl: z.string().optional(), deepInfraApiKey: z.string().optional(), + deepInfraConfigUseEnvVars: z.boolean().optional(), deepInfraModelId: z.string().optional(), }) const doubaoSchema = apiModelIdProviderModelSchema.extend({ doubaoBaseUrl: z.string().optional(), doubaoApiKey: z.string().optional(), + doubaoConfigUseEnvVars: z.boolean().optional(), }) const moonshotSchema = apiModelIdProviderModelSchema.extend({ @@ -325,16 +335,19 @@ const moonshotSchema = apiModelIdProviderModelSchema.extend({ .union([z.literal("https://api.moonshot.ai/v1"), z.literal("https://api.moonshot.cn/v1")]) .optional(), moonshotApiKey: z.string().optional(), + moonshotConfigUseEnvVars: z.boolean().optional(), }) const unboundSchema = baseProviderSettingsSchema.extend({ unboundApiKey: z.string().optional(), + unboundConfigUseEnvVars: z.boolean().optional(), unboundModelId: z.string().optional(), }) const requestySchema = baseProviderSettingsSchema.extend({ requestyBaseUrl: z.string().optional(), requestyApiKey: z.string().optional(), + requestyConfigUseEnvVars: z.boolean().optional(), requestyModelId: z.string().optional(), }) @@ -346,35 +359,42 @@ const fakeAiSchema = baseProviderSettingsSchema.extend({ const xaiSchema = apiModelIdProviderModelSchema.extend({ xaiApiKey: z.string().optional(), + xaiConfigUseEnvVars: z.boolean().optional(), }) const groqSchema = apiModelIdProviderModelSchema.extend({ groqApiKey: z.string().optional(), + groqConfigUseEnvVars: z.boolean().optional(), }) const huggingFaceSchema = baseProviderSettingsSchema.extend({ huggingFaceApiKey: z.string().optional(), + huggingFaceConfigUseEnvVars: z.boolean().optional(), huggingFaceModelId: z.string().optional(), huggingFaceInferenceProvider: z.string().optional(), }) const chutesSchema = apiModelIdProviderModelSchema.extend({ chutesApiKey: z.string().optional(), + chutesConfigUseEnvVars: z.boolean().optional(), }) const litellmSchema = baseProviderSettingsSchema.extend({ litellmBaseUrl: z.string().optional(), litellmApiKey: z.string().optional(), + litellmConfigUseEnvVars: z.boolean().optional(), litellmModelId: z.string().optional(), litellmUsePromptCache: z.boolean().optional(), }) const cerebrasSchema = apiModelIdProviderModelSchema.extend({ cerebrasApiKey: z.string().optional(), + cerebrasConfigUseEnvVars: z.boolean().optional(), }) const sambaNovaSchema = apiModelIdProviderModelSchema.extend({ sambaNovaApiKey: z.string().optional(), + sambaNovaConfigUseEnvVars: z.boolean().optional(), }) export const zaiApiLineSchema = z.enum(["international_coding", "international", "china_coding", "china"]) @@ -383,20 +403,24 @@ export type ZaiApiLine = z.infer const zaiSchema = apiModelIdProviderModelSchema.extend({ zaiApiKey: z.string().optional(), + zaiConfigUseEnvVars: z.boolean().optional(), zaiApiLine: zaiApiLineSchema.optional(), }) const fireworksSchema = apiModelIdProviderModelSchema.extend({ fireworksApiKey: z.string().optional(), + fireworksConfigUseEnvVars: z.boolean().optional(), }) const featherlessSchema = apiModelIdProviderModelSchema.extend({ featherlessApiKey: z.string().optional(), + featherlessConfigUseEnvVars: z.boolean().optional(), }) const ioIntelligenceSchema = apiModelIdProviderModelSchema.extend({ ioIntelligenceModelId: z.string().optional(), ioIntelligenceApiKey: z.string().optional(), + ioIntelligenceConfigUseEnvVars: z.boolean().optional(), }) const qwenCodeSchema = apiModelIdProviderModelSchema.extend({ @@ -409,6 +433,7 @@ const rooSchema = apiModelIdProviderModelSchema.extend({ const vercelAiGatewaySchema = baseProviderSettingsSchema.extend({ vercelAiGatewayApiKey: z.string().optional(), + vercelConfigUseEnvVars: z.boolean().optional(), vercelAiGatewayModelId: z.string().optional(), }) diff --git a/src/api/__tests__/index.spec.ts b/src/api/__tests__/index.spec.ts new file mode 100644 index 000000000..ee374f965 --- /dev/null +++ b/src/api/__tests__/index.spec.ts @@ -0,0 +1,614 @@ +// npx vitest run src/api/__tests__/index.spec.ts + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" +import { getEnvVar, buildApiHandler } from "../index" +import type { ProviderSettings } from "@roo-code/types" + +// Mock all the handler classes +vi.mock("../providers", () => ({ + AnthropicHandler: vi.fn().mockImplementation((options) => ({ provider: "anthropic", options })), + GlamaHandler: vi.fn().mockImplementation((options) => ({ provider: "glama", options })), + OpenRouterHandler: vi.fn().mockImplementation((options) => ({ provider: "openrouter", options })), + OpenAiHandler: vi.fn().mockImplementation((options) => ({ provider: "openai", options })), + GeminiHandler: vi.fn().mockImplementation((options) => ({ provider: "gemini", options })), + OpenAiNativeHandler: vi.fn().mockImplementation((options) => ({ provider: "openai-native", options })), + MistralHandler: vi.fn().mockImplementation((options) => ({ provider: "mistral", options })), + DeepSeekHandler: vi.fn().mockImplementation((options) => ({ provider: "deepseek", options })), + UnboundHandler: vi.fn().mockImplementation((options) => ({ provider: "unbound", options })), + RequestyHandler: vi.fn().mockImplementation((options) => ({ provider: "requesty", options })), + XAIHandler: vi.fn().mockImplementation((options) => ({ provider: "xai", options })), + GroqHandler: vi.fn().mockImplementation((options) => ({ provider: "groq", options })), + ChutesHandler: vi.fn().mockImplementation((options) => ({ provider: "chutes", options })), + LiteLLMHandler: vi.fn().mockImplementation((options) => ({ provider: "litellm", options })), + ClaudeCodeHandler: vi.fn().mockImplementation((options) => ({ provider: "claude-code", options })), + AwsBedrockHandler: vi.fn().mockImplementation((options) => ({ provider: "bedrock", options })), + VertexHandler: vi.fn().mockImplementation((options) => ({ provider: "vertex", options })), + AnthropicVertexHandler: vi.fn().mockImplementation((options) => ({ provider: "anthropic-vertex", options })), + OllamaHandler: vi.fn().mockImplementation((options) => ({ provider: "ollama", options })), + LmStudioHandler: vi.fn().mockImplementation((options) => ({ provider: "lmstudio", options })), + MoonshotHandler: vi.fn().mockImplementation((options) => ({ provider: "moonshot", options })), + VsCodeLmHandler: vi.fn().mockImplementation((options) => ({ provider: "vscode-lm", options })), + HumanRelayHandler: vi.fn().mockImplementation(() => ({ provider: "human-relay" })), + FakeAIHandler: vi.fn().mockImplementation((options) => ({ provider: "fake-ai", options })), + HuggingFaceHandler: vi.fn().mockImplementation((options) => ({ provider: "huggingface", options })), + CerebrasHandler: vi.fn().mockImplementation((options) => ({ provider: "cerebras", options })), + SambaNovaHandler: vi.fn().mockImplementation((options) => ({ provider: "sambanova", options })), + ZAiHandler: vi.fn().mockImplementation((options) => ({ provider: "zai", options })), + FireworksHandler: vi.fn().mockImplementation((options) => ({ provider: "fireworks", options })), + FeatherlessHandler: vi.fn().mockImplementation((options) => ({ provider: "featherless", options })), + IOIntelligenceHandler: vi.fn().mockImplementation((options) => ({ provider: "io-intelligence", options })), + VercelAiGatewayHandler: vi.fn().mockImplementation((options) => ({ provider: "vercel-ai-gateway", options })), + DoubaoHandler: vi.fn().mockImplementation((options) => ({ provider: "doubao", options })), + DeepInfraHandler: vi.fn().mockImplementation((options) => ({ provider: "deepinfra", options })), +})) + +describe("API Environment Variable Integration", () => { + const originalEnv = process.env + + beforeEach(() => { + vi.resetModules() + process.env = { ...originalEnv } + }) + + afterEach(() => { + process.env = originalEnv + }) + + describe("getEnvVar function", () => { + it("should return environment variable when it exists", () => { + process.env.TEST_KEY = "test-value" + const result = getEnvVar("TEST_KEY") + expect(result).toBe("test-value") + }) + + it("should return default value when environment variable does not exist", () => { + delete process.env.TEST_KEY + const result = getEnvVar("TEST_KEY", "default-value") + expect(result).toBe("default-value") + }) + + it("should return undefined when key is undefined and no default provided", () => { + const result = getEnvVar(undefined) + expect(result).toBeUndefined() + }) + + it("should return default value when key is undefined", () => { + const result = getEnvVar(undefined, "default-value") + expect(result).toBe("default-value") + }) + + it("should prioritize environment variable over default", () => { + process.env.TEST_KEY = "env-value" + const result = getEnvVar("TEST_KEY", "default-value") + expect(result).toBe("env-value") + }) + + it("should handle empty string environment variable", () => { + process.env.TEST_KEY = "" + const result = getEnvVar("TEST_KEY", "default-value") + expect(result).toBe("") + }) + }) + + describe("buildApiHandler environment variable integration", () => { + describe("anthropic provider", () => { + it("should use environment variable when anthropicConfigUseEnvVars is true", () => { + process.env.ANTHROPIC_API_KEY = "env-anthropic-key" + + const config: ProviderSettings = { + apiProvider: "anthropic", + apiKey: "config-key", + anthropicConfigUseEnvVars: true, + } + + const handler = buildApiHandler(config) as any + expect(handler.options.apiKey).toBe("env-anthropic-key") + }) + + it("should use config value when anthropicConfigUseEnvVars is false", () => { + process.env.ANTHROPIC_API_KEY = "env-anthropic-key" + + const config: ProviderSettings = { + apiProvider: "anthropic", + apiKey: "config-key", + anthropicConfigUseEnvVars: false, + } + + const handler = buildApiHandler(config) as any + expect(handler.options.apiKey).toBe("config-key") + }) + + it("should fallback to config value when env var not set", () => { + delete process.env.ANTHROPIC_API_KEY + + const config: ProviderSettings = { + apiProvider: "anthropic", + apiKey: "config-key", + anthropicConfigUseEnvVars: true, + } + + const handler = buildApiHandler(config) as any + expect(handler.options.apiKey).toBe("config-key") + }) + }) + + describe("glama provider", () => { + it("should use environment variable when glamaConfigUseEnvVars is true", () => { + process.env.GLAMA_API_KEY = "env-glama-key" + + const config: ProviderSettings = { + apiProvider: "glama", + glamaApiKey: "config-key", + glamaConfigUseEnvVars: true, + } + + const handler = buildApiHandler(config) as any + expect(handler.options.glamaApiKey).toBe("env-glama-key") + }) + + it("should use config value when glamaConfigUseEnvVars is false", () => { + process.env.GLAMA_API_KEY = "env-glama-key" + + const config: ProviderSettings = { + apiProvider: "glama", + glamaApiKey: "config-key", + glamaConfigUseEnvVars: false, + } + + const handler = buildApiHandler(config) as any + expect(handler.options.glamaApiKey).toBe("config-key") + }) + }) + + describe("openrouter provider", () => { + it("should use environment variable when openRouterConfigUseEnvVars is true", () => { + process.env.OPENROUTER_API_KEY = "env-openrouter-key" + + const config: ProviderSettings = { + apiProvider: "openrouter", + openRouterApiKey: "config-key", + openRouterConfigUseEnvVars: true, + } + + const handler = buildApiHandler(config) as any + expect(handler.options.openRouterApiKey).toBe("env-openrouter-key") + }) + }) + + describe("openai provider", () => { + it("should use environment variable when openAiConfigUseEnvVars is true", () => { + process.env.OPENAI_API_KEY = "env-openai-key" + + const config: ProviderSettings = { + apiProvider: "openai", + openAiApiKey: "config-key", + openAiConfigUseEnvVars: true, + } + + const handler = buildApiHandler(config) as any + expect(handler.options.openAiApiKey).toBe("env-openai-key") + }) + }) + + describe("gemini provider", () => { + it("should use environment variable when geminiConfigUseEnvVars is true", () => { + process.env.GEMINI_API_KEY = "env-gemini-key" + + const config: ProviderSettings = { + apiProvider: "gemini", + geminiApiKey: "config-key", + geminiConfigUseEnvVars: true, + } + + const handler = buildApiHandler(config) as any + expect(handler.options.geminiApiKey).toBe("env-gemini-key") + }) + }) + + describe("openai-native provider", () => { + it("should use environment variable when openAiNativeConfigUseEnvVars is true", () => { + process.env.OPENAI_API_KEY = "env-openai-native-key" + + const config: ProviderSettings = { + apiProvider: "openai-native", + openAiNativeApiKey: "config-key", + openAiNativeConfigUseEnvVars: true, + } + + const handler = buildApiHandler(config) as any + expect(handler.options.openAiNativeApiKey).toBe("env-openai-native-key") + }) + }) + + describe("mistral provider", () => { + it("should use environment variable when mistralConfigUseEnvVars is true", () => { + process.env.MISTRAL_API_KEY = "env-mistral-key" + + const config: ProviderSettings = { + apiProvider: "mistral", + mistralApiKey: "config-key", + mistralConfigUseEnvVars: true, + } + + const handler = buildApiHandler(config) as any + expect(handler.options.mistralApiKey).toBe("env-mistral-key") + }) + }) + + describe("deepseek provider", () => { + it("should use environment variable when deepSeekConfigUseEnvVars is true", () => { + process.env.DEEPSEEK_API_KEY = "env-deepseek-key" + + const config: ProviderSettings = { + apiProvider: "deepseek", + deepSeekApiKey: "config-key", + deepSeekConfigUseEnvVars: true, + } + + const handler = buildApiHandler(config) as any + expect(handler.options.deepSeekApiKey).toBe("env-deepseek-key") + }) + }) + + describe("unbound provider", () => { + it("should use environment variable when unboundConfigUseEnvVars is true", () => { + process.env.UNBOUND_API_KEY = "env-unbound-key" + + const config: ProviderSettings = { + apiProvider: "unbound", + unboundApiKey: "config-key", + unboundConfigUseEnvVars: true, + } + + const handler = buildApiHandler(config) as any + expect(handler.options.unboundApiKey).toBe("env-unbound-key") + }) + }) + + describe("requesty provider", () => { + it("should use environment variable when requestyConfigUseEnvVars is true", () => { + process.env.REQUESTY_API_KEY = "env-requesty-key" + + const config: ProviderSettings = { + apiProvider: "requesty", + requestyApiKey: "config-key", + requestyConfigUseEnvVars: true, + } + + const handler = buildApiHandler(config) as any + expect(handler.options.requestyApiKey).toBe("env-requesty-key") + }) + }) + + describe("xai provider", () => { + it("should use environment variable when xaiConfigUseEnvVars is true", () => { + process.env.XAI_API_KEY = "env-xai-key" + + const config: ProviderSettings = { + apiProvider: "xai", + xaiApiKey: "config-key", + xaiConfigUseEnvVars: true, + } + + const handler = buildApiHandler(config) as any + expect(handler.options.xaiApiKey).toBe("env-xai-key") + }) + }) + + describe("groq provider", () => { + it("should use environment variable when groqConfigUseEnvVars is true", () => { + process.env.GROQ_API_KEY = "env-groq-key" + + const config: ProviderSettings = { + apiProvider: "groq", + groqApiKey: "config-key", + groqConfigUseEnvVars: true, + } + + const handler = buildApiHandler(config) as any + expect(handler.options.groqApiKey).toBe("env-groq-key") + }) + }) + + describe("chutes provider", () => { + it("should use environment variable when chutesConfigUseEnvVars is true", () => { + process.env.CHUTES_API_KEY = "env-chutes-key" + + const config: ProviderSettings = { + apiProvider: "chutes", + chutesApiKey: "config-key", + chutesConfigUseEnvVars: true, + } + + const handler = buildApiHandler(config) as any + expect(handler.options.chutesApiKey).toBe("env-chutes-key") + }) + }) + + describe("litellm provider", () => { + it("should use environment variable when litellmConfigUseEnvVars is true", () => { + process.env.LITELLM_API_KEY = "env-litellm-key" + + const config: ProviderSettings = { + apiProvider: "litellm", + litellmApiKey: "config-key", + litellmConfigUseEnvVars: true, + } + + const handler = buildApiHandler(config) as any + expect(handler.options.litellmApiKey).toBe("env-litellm-key") + }) + }) + + describe("cerebras provider", () => { + it("should use environment variable when cerebrasConfigUseEnvVars is true", () => { + process.env.CEREBRAS_API_KEY = "env-cerebras-key" + + const config: ProviderSettings = { + apiProvider: "cerebras", + cerebrasApiKey: "config-key", + cerebrasConfigUseEnvVars: true, + } + + const handler = buildApiHandler(config) as any + expect(handler.options.cerebrasApiKey).toBe("env-cerebras-key") + }) + }) + + describe("sambanova provider", () => { + it("should use environment variable when sambaNovaConfigUseEnvVars is true", () => { + process.env.SAMBANOVA_API_KEY = "env-sambanova-key" + + const config: ProviderSettings = { + apiProvider: "sambanova", + sambaNovaApiKey: "config-key", + sambaNovaConfigUseEnvVars: true, + } + + const handler = buildApiHandler(config) as any + expect(handler.options.sambaNovaApiKey).toBe("env-sambanova-key") + }) + }) + + describe("zai provider", () => { + it("should use environment variable when zaiConfigUseEnvVars is true", () => { + process.env.ZAI_API_KEY = "env-zai-key" + + const config: ProviderSettings = { + apiProvider: "zai", + zaiApiKey: "config-key", + zaiConfigUseEnvVars: true, + } + + const handler = buildApiHandler(config) as any + expect(handler.options.zaiApiKey).toBe("env-zai-key") + }) + }) + + describe("fireworks provider", () => { + it("should use environment variable when fireworksConfigUseEnvVars is true", () => { + process.env.FIREWORKS_API_KEY = "env-fireworks-key" + + const config: ProviderSettings = { + apiProvider: "fireworks", + fireworksApiKey: "config-key", + fireworksConfigUseEnvVars: true, + } + + const handler = buildApiHandler(config) as any + expect(handler.options.fireworksApiKey).toBe("env-fireworks-key") + }) + }) + + describe("featherless provider", () => { + it("should use environment variable when featherlessConfigUseEnvVars is true", () => { + process.env.FEATHERLESS_API_KEY = "env-featherless-key" + + const config: ProviderSettings = { + apiProvider: "featherless", + featherlessApiKey: "config-key", + featherlessConfigUseEnvVars: true, + } + + const handler = buildApiHandler(config) as any + expect(handler.options.featherlessApiKey).toBe("env-featherless-key") + }) + }) + + describe("io-intelligence provider", () => { + it("should use environment variable when ioIntelligenceConfigUseEnvVars is true", () => { + process.env.IOINTELLIGENCE_API_KEY = "env-iointelligence-key" + + const config: ProviderSettings = { + apiProvider: "io-intelligence", + ioIntelligenceApiKey: "config-key", + ioIntelligenceConfigUseEnvVars: true, + } + + const handler = buildApiHandler(config) as any + expect(handler.options.ioIntelligenceApiKey).toBe("env-iointelligence-key") + }) + }) + + describe("vercel-ai-gateway provider", () => { + it("should use environment variable when vercelConfigUseEnvVars is true", () => { + process.env.VERCEL_API_KEY = "env-vercel-key" + + const config: ProviderSettings = { + apiProvider: "vercel-ai-gateway", + vercelAiGatewayApiKey: "config-key", + vercelConfigUseEnvVars: true, + } + + const handler = buildApiHandler(config) as any + expect(handler.options.vercelAiGatewayApiKey).toBe("env-vercel-key") + }) + }) + + describe("doubao provider", () => { + it("should use environment variable when doubaoConfigUseEnvVars is true", () => { + process.env.DOUBAO_API_KEY = "env-doubao-key" + + const config: ProviderSettings = { + apiProvider: "doubao", + doubaoApiKey: "config-key", + doubaoConfigUseEnvVars: true, + } + + const handler = buildApiHandler(config) as any + expect(handler.options.doubaoApiKey).toBe("env-doubao-key") + }) + }) + + describe("moonshot provider", () => { + it("should use environment variable when moonshotConfigUseEnvVars is true", () => { + process.env.MOONSHOT_API_KEY = "env-moonshot-key" + + const config: ProviderSettings = { + apiProvider: "moonshot", + moonshotApiKey: "config-key", + moonshotConfigUseEnvVars: true, + } + + const handler = buildApiHandler(config) as any + expect(handler.options.moonshotApiKey).toBe("env-moonshot-key") + }) + }) + + describe("huggingface provider", () => { + it("should use environment variable when huggingFaceConfigUseEnvVars is true", () => { + process.env.HUGGINGFACE_API_KEY = "env-huggingface-key" + + const config: ProviderSettings = { + apiProvider: "huggingface", + huggingFaceApiKey: "config-key", + huggingFaceConfigUseEnvVars: true, + } + + const handler = buildApiHandler(config) as any + expect(handler.options.huggingFaceApiKey).toBe("env-huggingface-key") + }) + }) + + describe("deepinfra provider", () => { + it("should use environment variable when deepInfraConfigUseEnvVars is true", () => { + process.env.DEEPINFRA_API_KEY = "env-deepinfra-key" + + const config: ProviderSettings = { + apiProvider: "deepinfra", + deepInfraApiKey: "config-key", + deepInfraConfigUseEnvVars: true, + } + + const handler = buildApiHandler(config) as any + expect(handler.options.deepInfraApiKey).toBe("env-deepinfra-key") + }) + }) + + describe("providers without environment variable support", () => { + it("should not modify options for claude-code provider", () => { + const config: ProviderSettings = { + apiProvider: "claude-code", + apiKey: "config-key", + } + + const handler = buildApiHandler(config) as any + expect(handler.options.apiKey).toBe("config-key") + }) + + it("should not modify options for bedrock provider", () => { + const config: ProviderSettings = { + apiProvider: "bedrock", + } + + const handler = buildApiHandler(config) as any + expect(handler.provider).toBe("bedrock") + }) + }) + + describe("edge cases", () => { + it("should handle missing apiKeyUseEnvVar flag gracefully", () => { + process.env.ANTHROPIC_API_KEY = "env-anthropic-key" + + const config: ProviderSettings = { + apiProvider: "anthropic", + apiKey: "config-key", + // anthropicConfigUseEnvVars is intentionally omitted + } + + const handler = buildApiHandler(config) as any + // Should use config value since flag is falsy + expect(handler.options.apiKey).toBe("config-key") + }) + + it("should handle all supported environment variables", () => { + const envVars = [ + "ANTHROPIC_API_KEY", + "GLAMA_API_KEY", + "OPENROUTER_API_KEY", + "OPENAI_API_KEY", + "GEMINI_API_KEY", + "MISTRAL_API_KEY", + "DEEPSEEK_API_KEY", + "UNBOUND_API_KEY", + "REQUESTY_API_KEY", + "XAI_API_KEY", + "GROQ_API_KEY", + "CHUTES_API_KEY", + "LITELLM_API_KEY", + "CEREBRAS_API_KEY", + "SAMBANOVA_API_KEY", + "ZAI_API_KEY", + "FIREWORKS_API_KEY", + "FEATHERLESS_API_KEY", + "IOINTELLIGENCE_API_KEY", + "VERCEL_API_KEY", + "DOUBAO_API_KEY", + "MOONSHOT_API_KEY", + "HUGGINGFACE_API_KEY", + "DEEPINFRA_API_KEY", + ] + + envVars.forEach((envVar, index) => { + process.env[envVar] = `test-value-${index}` + }) + + // Test that getEnvVar can handle all these environment variables + envVars.forEach((envVar, index) => { + const result = getEnvVar(envVar) + expect(result).toBe(`test-value-${index}`) + }) + }) + + it("should not mutate original config object", () => { + process.env.ANTHROPIC_API_KEY = "env-anthropic-key" + + const originalConfig: ProviderSettings = { + apiProvider: "anthropic", + apiKey: "config-key", + anthropicConfigUseEnvVars: true, + } + + const handler = buildApiHandler(originalConfig) as any + + // Verify the handler got the environment variable + expect(handler.options.apiKey).toBe("env-anthropic-key") + // The original config should remain unchanged due to object destructuring + expect(originalConfig.apiKey).toBe("config-key") + }) + + it("should handle undefined config values gracefully", () => { + process.env.ANTHROPIC_API_KEY = "env-anthropic-key" + + const config: ProviderSettings = { + apiProvider: "anthropic", + apiKey: undefined as any, + anthropicConfigUseEnvVars: true, + } + + const handler = buildApiHandler(config) as any + expect(handler.options.apiKey).toBe("env-anthropic-key") + }) + }) + }) +}) diff --git a/src/api/index.ts b/src/api/index.ts index ac0096767..29c499b9e 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,6 +1,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import type { ProviderSettings, ModelInfo } from "@roo-code/types" +import { API_KEYS } from "@roo-code/types" import { ApiStream } from "./transform/stream" @@ -87,17 +88,40 @@ export interface ApiHandler { countTokens(content: Array): Promise } +/** + * Read an environment variable value, returning a default value if not set. If neither key nor default is set, + * returns undefined + * @param key The environment variable key + * @param defaultValue The default value to return if the variable is not set + * @returns The value of the environment variable or the default value + */ +export function getEnvVar(key: string | undefined, defaultValue?: string | undefined): string | undefined { + if (key === undefined) { + return defaultValue + } + return process.env[key as string] ?? defaultValue +} + export function buildApiHandler(configuration: ProviderSettings): ApiHandler { const { apiProvider, ...options } = configuration switch (apiProvider) { case "anthropic": + if (options.anthropicConfigUseEnvVars) { + options.apiKey = getEnvVar(API_KEYS.ANTHROPIC, options.apiKey) + } return new AnthropicHandler(options) case "claude-code": return new ClaudeCodeHandler(options) case "glama": + if (options.glamaConfigUseEnvVars) { + options.glamaApiKey = getEnvVar(API_KEYS.GLAMA, options.glamaApiKey) + } return new GlamaHandler(options) case "openrouter": + if (options.openRouterConfigUseEnvVars) { + options.openRouterApiKey = getEnvVar(API_KEYS.OPEN_ROUTER, options.openRouterApiKey) + } return new OpenRouterHandler(options) case "bedrock": return new AwsBedrockHandler(options) @@ -106,64 +130,130 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { ? new AnthropicVertexHandler(options) : new VertexHandler(options) case "openai": + if (options.openAiConfigUseEnvVars) { + options.openAiApiKey = getEnvVar(API_KEYS.OPENAI, options.openAiApiKey) + } return new OpenAiHandler(options) case "ollama": return new NativeOllamaHandler(options) case "lmstudio": return new LmStudioHandler(options) case "gemini": + if (options.geminiConfigUseEnvVars) { + options.geminiApiKey = getEnvVar(API_KEYS.GEMINI, options.geminiApiKey) + } return new GeminiHandler(options) case "openai-native": + if (options.openAiNativeConfigUseEnvVars) { + options.openAiNativeApiKey = getEnvVar(API_KEYS.OPENAI, options.openAiNativeApiKey) + } return new OpenAiNativeHandler(options) case "deepseek": + if (options.deepSeekConfigUseEnvVars) { + options.deepSeekApiKey = getEnvVar(API_KEYS.DEEP_SEEK, options.deepSeekApiKey) + } return new DeepSeekHandler(options) case "doubao": + if (options.doubaoConfigUseEnvVars) { + options.doubaoApiKey = getEnvVar(API_KEYS.DOUBAO, options.doubaoApiKey) + } return new DoubaoHandler(options) case "qwen-code": return new QwenCodeHandler(options) case "moonshot": + if (options.moonshotConfigUseEnvVars) { + options.moonshotApiKey = getEnvVar(API_KEYS.MOONSHOOT, options.moonshotApiKey) + } return new MoonshotHandler(options) case "vscode-lm": return new VsCodeLmHandler(options) case "mistral": + if (options.mistralConfigUseEnvVars) { + options.mistralApiKey = getEnvVar(API_KEYS.MISTRAL, options.mistralApiKey) + } return new MistralHandler(options) case "unbound": + if (options.unboundConfigUseEnvVars) { + options.unboundApiKey = getEnvVar(API_KEYS.UNBOUND, options.unboundApiKey) + } return new UnboundHandler(options) case "requesty": + if (options.requestyConfigUseEnvVars) { + options.requestyApiKey = getEnvVar(API_KEYS.REQUESTY, options.requestyApiKey) + } return new RequestyHandler(options) case "human-relay": return new HumanRelayHandler() case "fake-ai": return new FakeAIHandler(options) case "xai": + if (options.xaiConfigUseEnvVars) { + options.xaiApiKey = getEnvVar(API_KEYS.XAI, options.xaiApiKey) + } return new XAIHandler(options) case "groq": + if (options.groqConfigUseEnvVars) { + options.groqApiKey = getEnvVar(API_KEYS.GROQ, options.groqApiKey) + } return new GroqHandler(options) case "deepinfra": + if (options.deepInfraConfigUseEnvVars) { + options.deepInfraApiKey = getEnvVar(API_KEYS.DEEP_INFRA, options.deepInfraApiKey) + } return new DeepInfraHandler(options) case "huggingface": + if (options.huggingFaceConfigUseEnvVars) { + options.huggingFaceApiKey = getEnvVar(API_KEYS.HUGGING_FACE, options.huggingFaceApiKey) + } return new HuggingFaceHandler(options) case "chutes": + if (options.chutesConfigUseEnvVars) { + options.chutesApiKey = getEnvVar(API_KEYS.CHUTES, options.chutesApiKey) + } return new ChutesHandler(options) case "litellm": + if (options.litellmConfigUseEnvVars) { + options.litellmApiKey = getEnvVar(API_KEYS.LITELLM, options.litellmApiKey) + } return new LiteLLMHandler(options) case "cerebras": + if (options.cerebrasConfigUseEnvVars) { + options.cerebrasApiKey = getEnvVar(API_KEYS.CEREBRAS, options.cerebrasApiKey) + } return new CerebrasHandler(options) case "sambanova": + if (options.sambaNovaConfigUseEnvVars) { + options.sambaNovaApiKey = getEnvVar(API_KEYS.SAMBA_NOVA, options.sambaNovaApiKey) + } return new SambaNovaHandler(options) case "zai": + if (options.zaiConfigUseEnvVars) { + options.zaiApiKey = getEnvVar(API_KEYS.ZAI, options.zaiApiKey) + } return new ZAiHandler(options) case "fireworks": + if (options.fireworksConfigUseEnvVars) { + options.fireworksApiKey = getEnvVar(API_KEYS.FIREWORKS, options.fireworksApiKey) + } return new FireworksHandler(options) case "io-intelligence": + if (options.ioIntelligenceConfigUseEnvVars) { + options.ioIntelligenceApiKey = getEnvVar(API_KEYS.IO_INTELLIGENCE, options.ioIntelligenceApiKey) + } return new IOIntelligenceHandler(options) case "roo": // Never throw exceptions from provider constructors // The provider-proxy server will handle authentication and return appropriate error codes return new RooHandler(options) case "featherless": + if (options.featherlessConfigUseEnvVars) { + options.featherlessApiKey = getEnvVar(API_KEYS.FEATHERLESS, options.featherlessApiKey) + } return new FeatherlessHandler(options) case "vercel-ai-gateway": + if (options.vercelConfigUseEnvVars) { + options.vercelAiGatewayApiKey = getEnvVar(API_KEYS.VERCEL, options.vercelAiGatewayApiKey) + } return new VercelAiGatewayHandler(options) default: apiProvider satisfies "gemini-cli" | undefined diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 2c20d0939..1712f42e0 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -37,6 +37,7 @@ import { requestyDefaultModelId, openRouterDefaultModelId, glamaDefaultModelId, + API_KEY_ENV_VAR_NAMES, DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT, DEFAULT_WRITE_DELAY_MS, ORGANIZATION_ALLOW_ALL, @@ -1058,6 +1059,7 @@ export class ClineProvider window.IMAGES_BASE_URI = "${imagesUri}" window.AUDIO_BASE_URI = "${audioUri}" window.MATERIAL_ICONS_BASE_URI = "${materialIconsUri}" + window.ENV_VAR_EXISTS = ${JSON.stringify(this.checkEnvVarApiKeys())} Roo Code @@ -1070,6 +1072,22 @@ export class ClineProvider ` } + /** + * Creates a map of supported API keys with boolean indicating presence. + * Returns only boolean existence for API keys, not the values. + */ + private checkEnvVarApiKeys(): Record { + const result: Record = {} + API_KEY_ENV_VAR_NAMES.forEach((envVar) => { + const exists = !!process.env[envVar] + result[envVar] = exists + if (exists) { + console.log(`[ClineProvider] Found environment variable: ${envVar}`) + } + }) + return result + } + /** * Defines and returns the HTML that should be rendered within the webview panel. * @@ -1131,6 +1149,7 @@ export class ClineProvider window.IMAGES_BASE_URI = "${imagesUri}" window.AUDIO_BASE_URI = "${audioUri}" window.MATERIAL_ICONS_BASE_URI = "${materialIconsUri}" + window.ENV_VAR_EXISTS = ${JSON.stringify(this.checkEnvVarApiKeys())} Roo Code @@ -1837,7 +1856,7 @@ export class ClineProvider const currentMode = mode ?? defaultModeSlug const hasSystemPromptOverride = await this.hasFileBasedSystemPromptOverride(currentMode) - return { + const stateToReturn = { version: this.context.extension?.packageJSON?.version ?? "", apiConfiguration, customInstructions, @@ -1965,6 +1984,8 @@ export class ClineProvider openRouterUseMiddleOutTransform, featureRoomoteControlEnabled, } + + return stateToReturn } /** diff --git a/webview-ui/src/components/settings/ApiKey.tsx b/webview-ui/src/components/settings/ApiKey.tsx new file mode 100644 index 000000000..49d18c153 --- /dev/null +++ b/webview-ui/src/components/settings/ApiKey.tsx @@ -0,0 +1,72 @@ +import { useState, ReactNode } from "react" +import { Checkbox } from "vscrui" +import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" +import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" +import { t } from "i18next" + +type ApiKeyProps = { + apiKey: string + apiKeyEnvVar: string + setApiKey: (value: string) => void + setConfigUseEnvVars: (value: boolean) => void + apiKeyLabel: string + configUseEnvVars: boolean + getApiKeyUrl?: string + getApiKeyLabel?: string + disabled?: boolean + balanceDisplay?: ReactNode +} + +export const ApiKey = ({ + apiKey, + apiKeyEnvVar, + setApiKey, + setConfigUseEnvVars, + apiKeyLabel, + configUseEnvVars, + getApiKeyUrl, + getApiKeyLabel, + disabled = false, + balanceDisplay, +}: ApiKeyProps) => { + const envVarExists = (window as any).ENV_VAR_EXISTS || {} + const apiKeyEnvVarExists = !!envVarExists[apiKeyEnvVar] + const [useEnvVar, setUseEnvVar] = useState(configUseEnvVars && apiKeyEnvVarExists) + + const handleUseEnvVarChange = (checked: boolean) => { + setUseEnvVar(checked) + setConfigUseEnvVars(checked) + } + + return ( + <> + setApiKey((e.target as HTMLInputElement).value)} + placeholder={t("settings:placeholders.apiKey")} + className="w-full" + disabled={useEnvVar || disabled}> +
+ + {apiKey && balanceDisplay} +
+
+
+ {t("settings:providers.apiKeyStorageNotice")} +
+ + {t("settings:providers.apiKeyUseEnvVar", { name: apiKeyEnvVar })} + + {!(apiKey || useEnvVar) && getApiKeyUrl && getApiKeyLabel && ( + + {getApiKeyLabel} + + )} + + ) +} diff --git a/webview-ui/src/components/settings/__tests__/ApiKey.spec.tsx b/webview-ui/src/components/settings/__tests__/ApiKey.spec.tsx new file mode 100644 index 000000000..a847d0b0e --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/ApiKey.spec.tsx @@ -0,0 +1,249 @@ +import { render, screen, fireEvent } from "@testing-library/react" +import { describe, it, expect, vi, beforeEach } from "vitest" +import { API_KEYS } from "@roo-code/types" + +import { ApiKey } from "../ApiKey" + +// Mock VSCode components +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeTextField: ({ children, value, onInput, disabled, placeholder }: any) => ( +
+ + onInput && onInput({ target: { value: e.target.value } })} + disabled={disabled} + placeholder={placeholder} + data-testid="api-key-input" + /> +
+ ), +})) + +// Mock vscrui components +vi.mock("vscrui", () => ({ + Checkbox: ({ children, checked, onChange, disabled }: any) => ( + + ), +})) + +// Mock VSCode button link +vi.mock("@src/components/common/VSCodeButtonLink", () => ({ + VSCodeButtonLink: ({ children, href }: any) => ( + + {children} + + ), +})) + +// Mock i18next +vi.mock("i18next", () => ({ + t: (key: string, options?: any) => { + const translations: Record = { + "settings:placeholders.apiKey": "Enter API key", + "settings:providers.apiKeyStorageNotice": "API keys are stored locally", + "settings:providers.apiKeyUseEnvVar": `Use environment variable ${options?.name || "ENV_VAR"}`, + } + return translations[key] || key + }, +})) + +// Mock window.PROCESS_ENV +const mockProcessEnv = { + [API_KEYS.ANTHROPIC]: "existing-env-value", + [API_KEYS.GEMINI]: undefined, +} + +// Mock global window object if not available +if (typeof window === "undefined") { + global.window = {} as any +} + +Object.defineProperty(window, "PROCESS_ENV", { + value: mockProcessEnv, + writable: true, +}) + +// Mock window.ENV_VAR_EXISTS +Object.defineProperty(window, "ENV_VAR_EXISTS", { + value: { + TEST_API_KEY: true, + [API_KEYS.ANTHROPIC]: true, + [API_KEYS.OPENAI]: false, + }, + writable: true, +}) + +describe("ApiKey Component", () => { + const defaultProps = { + apiKey: "", + apiKeyEnvVar: "TEST_API_KEY", + setApiKey: vi.fn(), + setConfigUseEnvVars: vi.fn(), + apiKeyLabel: "Test API Key", + configUseEnvVars: false, + getApiKeyUrl: "https://example.com/get-key", + getApiKeyLabel: "Get API Key", + disabled: false, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders with basic props", () => { + render() + + expect(screen.getByText("Test API Key")).toBeInTheDocument() + expect(screen.getByPlaceholderText("Enter API key")).toBeInTheDocument() + expect(screen.getByText("API keys are stored locally")).toBeInTheDocument() + }) + + it("shows environment variable checkbox", () => { + render() + + expect(screen.getByText("Use environment variable TEST_API_KEY")).toBeInTheDocument() + expect(screen.getByTestId("env-var-checkbox")).toBeInTheDocument() + }) + + it("calls setApiKey when input value changes", () => { + render() + + const input = screen.getByTestId("api-key-input") + fireEvent.change(input, { target: { value: "new-api-key" } }) + + expect(defaultProps.setApiKey).toHaveBeenCalledWith("new-api-key") + }) + + it("calls setConfigUseEnvVars when checkbox is toggled", () => { + const props = { + ...defaultProps, + apiKeyEnvVar: API_KEYS.ANTHROPIC, // This exists in mockProcessEnv + } + + render() + + const checkbox = screen.getByTestId("env-var-checkbox-input") + fireEvent.click(checkbox) + + expect(props.setConfigUseEnvVars).toHaveBeenCalledWith(true) + }) + + it("disables input when useEnvVar is true and env var exists", () => { + const props = { + ...defaultProps, + apiKeyEnvVar: API_KEYS.ANTHROPIC, // This exists in mockProcessEnv + configUseEnvVars: true, + } + + render() + + const input = screen.getByTestId("api-key-input") + expect(input).toBeDisabled() + }) + + it("disables input when disabled prop is true", () => { + const props = { + ...defaultProps, + disabled: true, + } + + render() + + const input = screen.getByTestId("api-key-input") + expect(input).toBeDisabled() + }) + + it("shows get API key button when no key is provided and URLs are given", () => { + render() + + expect(screen.getByText("Get API Key")).toBeInTheDocument() + expect(screen.getByTestId("get-api-key-link")).toHaveAttribute("href", "https://example.com/get-key") + }) + + it("does not show get API key button when API key is provided", () => { + const props = { + ...defaultProps, + apiKey: "existing-key", + } + + render() + + expect(screen.queryByText("Get API Key")).not.toBeInTheDocument() + }) + + it("does not show get API key button when using env var with existing env value", () => { + const props = { + ...defaultProps, + apiKeyEnvVar: API_KEYS.ANTHROPIC, // This exists in mockProcessEnv + configUseEnvVars: true, + } + + render() + + expect(screen.queryByText("Get API Key")).not.toBeInTheDocument() + }) + + it("disables checkbox when environment variable does not exist", () => { + const props = { + ...defaultProps, + apiKeyEnvVar: "NONEXISTENT_API_KEY", + } + + render() + + const checkbox = screen.getByTestId("env-var-checkbox-input") + expect(checkbox).toBeDisabled() + }) + + it("enables checkbox when environment variable exists", () => { + const props = { + ...defaultProps, + apiKeyEnvVar: API_KEYS.ANTHROPIC, // This exists in mockProcessEnv + } + + render() + + const checkbox = screen.getByTestId("env-var-checkbox-input") + expect(checkbox).not.toBeDisabled() + }) + + it("handles checkbox state correctly when env var exists and is initially checked", () => { + const props = { + ...defaultProps, + apiKeyEnvVar: API_KEYS.ANTHROPIC, // This exists in mockProcessEnv + configUseEnvVars: true, + } + + render() + + const checkbox = screen.getByTestId("env-var-checkbox-input") + expect(checkbox).toBeChecked() + expect(screen.getByTestId("api-key-input")).toBeDisabled() + }) + + it("unchecks checkbox when env var does not exist but is initially checked", () => { + const props = { + ...defaultProps, + apiKeyEnvVar: "NONEXISTENT_API_KEY", + configUseEnvVars: true, + } + + render() + + // The component should internally set useEnvVar to false when env var doesn't exist + const checkbox = screen.getByTestId("env-var-checkbox-input") + expect(checkbox).not.toBeChecked() + expect(screen.getByTestId("api-key-input")).not.toBeDisabled() + }) +}) diff --git a/webview-ui/src/components/settings/providers/Anthropic.tsx b/webview-ui/src/components/settings/providers/Anthropic.tsx index feef788d4..0d58472dc 100644 --- a/webview-ui/src/components/settings/providers/Anthropic.tsx +++ b/webview-ui/src/components/settings/providers/Anthropic.tsx @@ -3,12 +3,13 @@ import { Checkbox } from "vscrui" import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" import type { ProviderSettings } from "@roo-code/types" +import { API_KEYS } from "@roo-code/types" import { useAppTranslation } from "@src/i18n/TranslationContext" -import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" import { useSelectedModel } from "@src/components/ui/hooks/useSelectedModel" import { inputEventTransform, noTransform } from "../transforms" +import { ApiKey } from "../ApiKey" type AnthropicProps = { apiConfiguration: ProviderSettings @@ -38,22 +39,16 @@ export const Anthropic = ({ apiConfiguration, setApiConfigurationField }: Anthro return ( <> - - - -
- {t("settings:providers.apiKeyStorageNotice")} -
- {!apiConfiguration?.apiKey && ( - - {t("settings:providers.getAnthropicApiKey")} - - )} + setApiConfigurationField("apiKey", value)} + setConfigUseEnvVars={(value: boolean) => setApiConfigurationField("anthropicConfigUseEnvVars", value)} + apiKeyLabel={t("settings:providers.anthropicApiKey")} + getApiKeyUrl="https://console.anthropic.com/settings/keys" + getApiKeyLabel={t("settings:providers.getAnthropicApiKey")} + />
{ const { t } = useAppTranslation() - const handleInputChange = useCallback( - ( - field: K, - transform: (event: E) => ProviderSettings[K] = inputEventTransform, - ) => - (event: E | Event) => { - setApiConfigurationField(field, transform(event as E)) - }, - [setApiConfigurationField], - ) return ( <> - - - -
- {t("settings:providers.apiKeyStorageNotice")} -
- {!apiConfiguration?.cerebrasApiKey && ( - - {t("settings:providers.getCerebrasApiKey")} - - )} + setApiConfigurationField("cerebrasApiKey", value)} + setConfigUseEnvVars={(value: boolean) => setApiConfigurationField("cerebrasConfigUseEnvVars", value)} + apiKeyLabel={t("settings:providers.cerebrasApiKey")} + getApiKeyUrl="https://cloud.cerebras.ai?utm_source=roocode" + getApiKeyLabel={t("settings:providers.getCerebrasApiKey")} + /> ) } diff --git a/webview-ui/src/components/settings/providers/Chutes.tsx b/webview-ui/src/components/settings/providers/Chutes.tsx index c51479421..3766c3901 100644 --- a/webview-ui/src/components/settings/providers/Chutes.tsx +++ b/webview-ui/src/components/settings/providers/Chutes.tsx @@ -1,12 +1,9 @@ -import { useCallback } from "react" -import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" - import type { ProviderSettings } from "@roo-code/types" +import { API_KEYS } from "@roo-code/types" import { useAppTranslation } from "@src/i18n/TranslationContext" -import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" -import { inputEventTransform } from "../transforms" +import { ApiKey } from "../ApiKey" type ChutesProps = { apiConfiguration: ProviderSettings @@ -16,35 +13,18 @@ type ChutesProps = { export const Chutes = ({ apiConfiguration, setApiConfigurationField }: ChutesProps) => { const { t } = useAppTranslation() - const handleInputChange = useCallback( - ( - field: K, - transform: (event: E) => ProviderSettings[K] = inputEventTransform, - ) => - (event: E | Event) => { - setApiConfigurationField(field, transform(event as E)) - }, - [setApiConfigurationField], - ) - return ( <> - - - -
- {t("settings:providers.apiKeyStorageNotice")} -
- {!apiConfiguration?.chutesApiKey && ( - - {t("settings:providers.getChutesApiKey")} - - )} + setApiConfigurationField("chutesApiKey", value)} + setConfigUseEnvVars={(value: boolean) => setApiConfigurationField("chutesConfigUseEnvVars", value)} + apiKeyLabel={t("settings:providers.chutesApiKey")} + getApiKeyUrl="https://chutes.ai/app/api" + getApiKeyLabel={t("settings:providers.getChutesApiKey")} + /> ) } diff --git a/webview-ui/src/components/settings/providers/DeepInfra.tsx b/webview-ui/src/components/settings/providers/DeepInfra.tsx index eee3d3996..fc27faa17 100644 --- a/webview-ui/src/components/settings/providers/DeepInfra.tsx +++ b/webview-ui/src/components/settings/providers/DeepInfra.tsx @@ -1,7 +1,6 @@ -import { useCallback, useEffect, useState } from "react" -import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" +import { useEffect, useState } from "react" -import { OrganizationAllowList, type ProviderSettings, deepInfraDefaultModelId } from "@roo-code/types" +import { OrganizationAllowList, type ProviderSettings, deepInfraDefaultModelId, API_KEYS } from "@roo-code/types" import type { RouterModels } from "@roo/api" @@ -9,8 +8,8 @@ import { vscode } from "@src/utils/vscode" import { useAppTranslation } from "@src/i18n/TranslationContext" import { Button } from "@src/components/ui" -import { inputEventTransform } from "../transforms" import { ModelPicker } from "../ModelPicker" +import { ApiKey } from "../ApiKey" type DeepInfraProps = { apiConfiguration: ProviderSettings @@ -33,16 +32,6 @@ export const DeepInfra = ({ const [didRefetch, setDidRefetch] = useState() - const handleInputChange = useCallback( - ( - field: K, - transform: (event: E) => ProviderSettings[K] = inputEventTransform, - ) => - (event: E | Event) => { - setApiConfigurationField(field, transform(event as E)) - }, - [setApiConfigurationField], - ) useEffect(() => { // When base URL or API key changes, trigger a silent refresh of models @@ -51,14 +40,14 @@ export const DeepInfra = ({ return ( <> - - - + setApiConfigurationField("deepInfraApiKey", value)} + setConfigUseEnvVars={(value: boolean) => setApiConfigurationField("deepInfraConfigUseEnvVars", value)} + apiKeyLabel={t("settings:providers.apiKey")} + />
-
- - - -
- {t("settings:providers.apiKeyStorageNotice")} -
- {!apiConfiguration?.moonshotApiKey && ( - - {t("settings:providers.getMoonshotApiKey")} - - )} -
+ setApiConfigurationField("moonshotApiKey", value)} + setConfigUseEnvVars={(value: boolean) => setApiConfigurationField("moonshotConfigUseEnvVars", value)} + apiKeyLabel={t("settings:providers.moonshotApiKey")} + getApiKeyUrl={ + apiConfiguration.moonshotBaseUrl === "https://api.moonshot.cn/v1" + ? "https://platform.moonshot.cn/console/api-keys" + : "https://platform.moonshot.ai/console/api-keys" + } + getApiKeyLabel={t("settings:providers.getMoonshotApiKey")} + /> ) } diff --git a/webview-ui/src/components/settings/providers/OpenAI.tsx b/webview-ui/src/components/settings/providers/OpenAI.tsx index 59b907c45..6c679261a 100644 --- a/webview-ui/src/components/settings/providers/OpenAI.tsx +++ b/webview-ui/src/components/settings/providers/OpenAI.tsx @@ -3,12 +3,13 @@ import { Checkbox } from "vscrui" import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" import type { ModelInfo, ProviderSettings } from "@roo-code/types" +import { API_KEYS } from "@roo-code/types" import { useAppTranslation } from "@src/i18n/TranslationContext" -import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, StandardTooltip } from "@src/components/ui" import { inputEventTransform } from "../transforms" +import { ApiKey } from "../ApiKey" type OpenAIProps = { apiConfiguration: ProviderSettings @@ -58,22 +59,16 @@ export const OpenAI = ({ apiConfiguration, setApiConfigurationField, selectedMod /> )} - - - -
- {t("settings:providers.apiKeyStorageNotice")} -
- {!apiConfiguration?.openAiNativeApiKey && ( - - {t("settings:providers.getOpenAiApiKey")} - - )} + setApiConfigurationField("openAiNativeApiKey", value)} + setConfigUseEnvVars={(value: boolean) => setApiConfigurationField("openAiNativeConfigUseEnvVars", value)} + apiKeyLabel={t("settings:providers.openAiApiKey")} + getApiKeyUrl="https://platform.openai.com/api-keys" + getApiKeyLabel={t("settings:providers.getOpenAiApiKey")} + /> {(() => { const allowedTiers = (selectedModelInfo?.tiers?.map((t) => t.name).filter(Boolean) || []).filter( diff --git a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx index 736b0253c..6f23013fa 100644 --- a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx +++ b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx @@ -10,6 +10,7 @@ import { type OrganizationAllowList, azureOpenAiDefaultApiVersion, openAiModelInfoSaneDefaults, + API_KEYS, } from "@roo-code/types" import { ExtensionMessage } from "@roo/ExtensionMessage" @@ -22,6 +23,7 @@ import { inputEventTransform, noTransform } from "../transforms" import { ModelPicker } from "../ModelPicker" import { R1FormatSetting } from "../R1FormatSetting" import { ThinkingBudget } from "../ThinkingBudget" +import { ApiKey } from "../ApiKey" type OpenAICompatibleProps = { apiConfiguration: ProviderSettings @@ -129,14 +131,14 @@ export const OpenAICompatible = ({ className="w-full"> - - - + setApiConfigurationField("openAiApiKey", value)} + setConfigUseEnvVars={(value: boolean) => setApiConfigurationField("openAiConfigUseEnvVars", value)} + apiKeyLabel={t("settings:providers.openAiApiKey")} + /> - -
- - {apiConfiguration?.openRouterApiKey && ( - - )} -
-
-
- {t("settings:providers.apiKeyStorageNotice")} -
- {!apiConfiguration?.openRouterApiKey && ( - - {t("settings:providers.getOpenRouterApiKey")} - - )} + setApiConfigurationField("openRouterApiKey", value)} + setConfigUseEnvVars={(value: boolean) => setApiConfigurationField("openRouterConfigUseEnvVars", value)} + apiKeyLabel={t("settings:providers.openRouterApiKey")} + getApiKeyUrl={getOpenRouterAuthUrl(uriScheme)} + getApiKeyLabel={t("settings:providers.getOpenRouterApiKey")} + balanceDisplay={apiConfiguration?.openRouterApiKey && ( + + )} + /> {!fromWelcomeView && ( <>
diff --git a/webview-ui/src/components/settings/providers/Requesty.tsx b/webview-ui/src/components/settings/providers/Requesty.tsx index 82e4ef8e4..89c462168 100644 --- a/webview-ui/src/components/settings/providers/Requesty.tsx +++ b/webview-ui/src/components/settings/providers/Requesty.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from "react" import { VSCodeCheckbox, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" -import { type ProviderSettings, type OrganizationAllowList, requestyDefaultModelId } from "@roo-code/types" +import { type ProviderSettings, type OrganizationAllowList, requestyDefaultModelId, API_KEYS } from "@roo-code/types" import type { RouterModels } from "@roo/api" @@ -9,11 +9,12 @@ import { vscode } from "@src/utils/vscode" import { useAppTranslation } from "@src/i18n/TranslationContext" import { Button } from "@src/components/ui" -import { inputEventTransform } from "../transforms" import { ModelPicker } from "../ModelPicker" import { RequestyBalanceDisplay } from "./RequestyBalanceDisplay" +import { ApiKey } from "../ApiKey" import { getCallbackUrl } from "@/oauth/urls" import { toRequestyServiceUrl } from "@roo/utils/requesty" +import { inputEventTransform } from "../transforms" type RequestyProps = { apiConfiguration: ProviderSettings @@ -65,41 +66,23 @@ export const Requesty = ({ return ( <> - -
- - {apiConfiguration?.requestyApiKey && ( - setApiConfigurationField("requestyApiKey", value)} + setConfigUseEnvVars={(value: boolean) => setApiConfigurationField("requestyConfigUseEnvVars", value)} + apiKeyLabel={t("settings:providers.requestyApiKey")} + getApiKeyUrl={getApiKeyUrl()} + getApiKeyLabel={t("settings:providers.getRequestyApiKey")} + balanceDisplay={ + apiConfiguration?.requestyApiKey && ( + - )} -
-
-
- {t("settings:providers.apiKeyStorageNotice")} -
- {!apiConfiguration?.requestyApiKey && ( - - {t("settings:providers.getRequestyApiKey")} - - )} - + ) + } + /> { diff --git a/webview-ui/src/components/settings/providers/SambaNova.tsx b/webview-ui/src/components/settings/providers/SambaNova.tsx index 9202f8d06..cbb471e7e 100644 --- a/webview-ui/src/components/settings/providers/SambaNova.tsx +++ b/webview-ui/src/components/settings/providers/SambaNova.tsx @@ -1,12 +1,9 @@ -import { useCallback } from "react" -import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" - import type { ProviderSettings } from "@roo-code/types" +import { API_KEYS } from "@roo-code/types" import { useAppTranslation } from "@src/i18n/TranslationContext" -import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" -import { inputEventTransform } from "../transforms" +import { ApiKey } from "../ApiKey" type SambaNovaProps = { apiConfiguration: ProviderSettings @@ -16,37 +13,19 @@ type SambaNovaProps = { export const SambaNova = ({ apiConfiguration, setApiConfigurationField }: SambaNovaProps) => { const { t } = useAppTranslation() - const handleInputChange = useCallback( - ( - field: K, - transform: (event: E) => ProviderSettings[K] = inputEventTransform, - ) => - (event: E | Event) => { - setApiConfigurationField(field, transform(event as E)) - }, - [setApiConfigurationField], - ) return ( <> - - - -
- {t("settings:providers.apiKeyStorageNotice")} -
- {!apiConfiguration?.sambaNovaApiKey && ( - - {t("settings:providers.getSambaNovaApiKey")} - - )} + setApiConfigurationField("sambaNovaApiKey", value)} + setConfigUseEnvVars={(value: boolean) => setApiConfigurationField("sambaNovaConfigUseEnvVars", value)} + apiKeyLabel={t("settings:providers.sambaNovaApiKey")} + getApiKeyUrl="https://cloud.sambanova.ai/?utm_source=roocode&utm_medium=external&utm_campaign=cloud_signup" + getApiKeyLabel={t("settings:providers.getSambaNovaApiKey")} + /> ) } diff --git a/webview-ui/src/components/settings/providers/Unbound.tsx b/webview-ui/src/components/settings/providers/Unbound.tsx index 15826d0c0..e9b812034 100644 --- a/webview-ui/src/components/settings/providers/Unbound.tsx +++ b/webview-ui/src/components/settings/providers/Unbound.tsx @@ -1,18 +1,16 @@ -import { useCallback, useState, useRef } from "react" -import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" +import { useCallback, useRef, useState } from "react" import { useQueryClient } from "@tanstack/react-query" -import { type ProviderSettings, type OrganizationAllowList, unboundDefaultModelId } from "@roo-code/types" +import { type ProviderSettings, type OrganizationAllowList, unboundDefaultModelId, API_KEYS } from "@roo-code/types" import type { RouterModels } from "@roo/api" import { useAppTranslation } from "@src/i18n/TranslationContext" -import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" import { vscode } from "@src/utils/vscode" import { Button } from "@src/components/ui" -import { inputEventTransform } from "../transforms" import { ModelPicker } from "../ModelPicker" +import { ApiKey } from "../ApiKey" type UnboundProps = { apiConfiguration: ProviderSettings @@ -42,17 +40,6 @@ export const Unbound = ({ const didRefetchTimerRef = useRef() const invalidKeyTimerRef = useRef() - const handleInputChange = useCallback( - ( - field: K, - transform: (event: E) => ProviderSettings[K] = inputEventTransform, - ) => - (event: E | Event) => { - setApiConfigurationField(field, transform(event as E)) - }, - [setApiConfigurationField], - ) - const saveConfiguration = useCallback(async () => { vscode.postMessage({ type: "upsertApiConfiguration", @@ -141,22 +128,16 @@ export const Unbound = ({ return ( <> - - - -
- {t("settings:providers.apiKeyStorageNotice")} -
- {!apiConfiguration?.unboundApiKey && ( - - {t("settings:providers.getUnboundApiKey")} - - )} + setApiConfigurationField("unboundApiKey", value)} + setConfigUseEnvVars={(value: boolean) => setApiConfigurationField("unboundConfigUseEnvVars", value)} + apiKeyLabel={t("settings:providers.unboundApiKey")} + getApiKeyUrl="https://gateway.getunbound.ai" + getApiKeyLabel={t("settings:providers.getUnboundApiKey")} + />
-
- - - -
- {t("settings:providers.apiKeyStorageNotice")} -
- {!apiConfiguration?.zaiApiKey && ( - - {t("settings:providers.getZaiApiKey")} - - )} -
+ setApiConfigurationField("zaiApiKey", value)} + setConfigUseEnvVars={(value: boolean) => setApiConfigurationField("zaiConfigUseEnvVars", value)} + apiKeyLabel={t("settings:providers.zaiApiKey")} + getApiKeyUrl={ + zaiApiLineConfigs[apiConfiguration.zaiApiLine ?? "international_coding"].isChina + ? "https://open.bigmodel.cn/console/overview" + : "https://z.ai/manage-apikey/apikey-list" + } + getApiKeyLabel={t("settings:providers.getZaiApiKey")} + /> ) } diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 2aa6b7ad7..59f04e7e9 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -479,7 +479,8 @@ "placeholder": "Per defecte: claude", "maxTokensLabel": "Tokens màxims de sortida", "maxTokensDescription": "Nombre màxim de tokens de sortida per a les respostes de Claude Code. El valor per defecte és 8000." - } + }, + "apiKeyUseEnvVar": "Utilitzar clau API de la variable d'entorn {{name}}" }, "browser": { "enable": { diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index a96e21518..1fe54de6a 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -479,7 +479,8 @@ "placeholder": "Standard: claude", "maxTokensLabel": "Maximale Ausgabe-Tokens", "maxTokensDescription": "Maximale Anzahl an Ausgabe-Tokens für Claude Code-Antworten. Standard ist 8000." - } + }, + "apiKeyUseEnvVar": "API-Schlüssel aus Umgebungsvariable {{name}} verwenden" }, "browser": { "enable": { diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index aa3199e8e..ad60e7b22 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -251,6 +251,7 @@ "vercelAiGatewayApiKey": "Vercel AI Gateway API Key", "getVercelAiGatewayApiKey": "Get Vercel AI Gateway API Key", "apiKeyStorageNotice": "API keys are stored securely in VSCode's Secret Storage", + "apiKeyUseEnvVar": "Use API key from env var {{name}}", "glamaApiKey": "Glama API Key", "getGlamaApiKey": "Get Glama API Key", "useCustomBaseUrl": "Use custom base URL", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 44c1b9496..c5a811a46 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -479,7 +479,8 @@ "placeholder": "Por defecto: claude", "maxTokensLabel": "Tokens máximos de salida", "maxTokensDescription": "Número máximo de tokens de salida para las respuestas de Claude Code. El valor predeterminado es 8000." - } + }, + "apiKeyUseEnvVar": "Usar clave API de la variable de entorno {{name}}" }, "browser": { "enable": { diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index cd2b3bef8..eca0f9b80 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -479,7 +479,8 @@ "placeholder": "Défaut : claude", "maxTokensLabel": "Jetons de sortie max", "maxTokensDescription": "Nombre maximum de jetons de sortie pour les réponses de Claude Code. La valeur par défaut est 8000." - } + }, + "apiKeyUseEnvVar": "Utiliser la clé API de la variable d'environnement {{name}}" }, "browser": { "enable": { diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index d9d8184fb..1d547c133 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -479,7 +479,8 @@ "placeholder": "डिफ़ॉल्ट: claude", "maxTokensLabel": "अधिकतम आउटपुट टोकन", "maxTokensDescription": "Claude Code प्रतिक्रियाओं के लिए आउटपुट टोकन की अधिकतम संख्या। डिफ़ॉल्ट 8000 है।" - } + }, + "apiKeyUseEnvVar": "पर्यावरण चर {{name}} से API कुंजी का उपयोग करें" }, "browser": { "enable": { diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 187f42958..49735785d 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -483,7 +483,8 @@ "placeholder": "Default: claude", "maxTokensLabel": "Token Output Maks", "maxTokensDescription": "Jumlah maksimum token output untuk respons Claude Code. Default adalah 8000." - } + }, + "apiKeyUseEnvVar": "Gunakan kunci API dari variabel lingkungan {{name}}" }, "browser": { "enable": { diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 335877b0a..f412d0b41 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -479,7 +479,8 @@ "placeholder": "Predefinito: claude", "maxTokensLabel": "Token di output massimi", "maxTokensDescription": "Numero massimo di token di output per le risposte di Claude Code. Il valore predefinito è 8000." - } + }, + "apiKeyUseEnvVar": "Usa chiave API dalla variabile d'ambiente {{name}}" }, "browser": { "enable": { diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index bce95eeab..e1e427ba1 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -479,7 +479,8 @@ "placeholder": "デフォルト:claude", "maxTokensLabel": "最大出力トークン", "maxTokensDescription": "Claude Codeレスポンスの最大出力トークン数。デフォルトは8000です。" - } + }, + "apiKeyUseEnvVar": "環境変数{{name}}からAPIキーを使用" }, "browser": { "enable": { diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index f7aec2f4c..33c00dd0f 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -479,7 +479,8 @@ "placeholder": "기본값: claude", "maxTokensLabel": "최대 출력 토큰", "maxTokensDescription": "Claude Code 응답의 최대 출력 토큰 수. 기본값은 8000입니다." - } + }, + "apiKeyUseEnvVar": "환경 변수 {{name}}에서 API 키 사용" }, "browser": { "enable": { diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index d5b246e22..4bb112fbc 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -479,7 +479,8 @@ "placeholder": "Standaard: claude", "maxTokensLabel": "Max Output Tokens", "maxTokensDescription": "Maximaal aantal output-tokens voor Claude Code-reacties. Standaard is 8000." - } + }, + "apiKeyUseEnvVar": "Gebruik API-sleutel van omgevingsvariabele {{name}}" }, "browser": { "enable": { diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 385a38fe2..e95ca88c1 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -479,7 +479,8 @@ "placeholder": "Domyślnie: claude", "maxTokensLabel": "Maksymalna liczba tokenów wyjściowych", "maxTokensDescription": "Maksymalna liczba tokenów wyjściowych dla odpowiedzi Claude Code. Domyślnie 8000." - } + }, + "apiKeyUseEnvVar": "Użyj klucza API ze zmiennej środowiskowej {{name}}" }, "browser": { "enable": { diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index be2ff89ff..514ebe65e 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -479,7 +479,8 @@ "placeholder": "Padrão: claude", "maxTokensLabel": "Tokens de saída máximos", "maxTokensDescription": "Número máximo de tokens de saída para respostas do Claude Code. O padrão é 8000." - } + }, + "apiKeyUseEnvVar": "Usar chave API da variável de ambiente {{name}}" }, "browser": { "enable": { diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index b429f01f4..98557910b 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -479,7 +479,8 @@ "placeholder": "По умолчанию: claude", "maxTokensLabel": "Макс. выходных токенов", "maxTokensDescription": "Максимальное количество выходных токенов для ответов Claude Code. По умолчанию 8000." - } + }, + "apiKeyUseEnvVar": "Использовать API-ключ из переменной окружения {{name}}" }, "browser": { "enable": { diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 429599d7e..4d51f9ac0 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -479,7 +479,8 @@ "placeholder": "Varsayılan: claude", "maxTokensLabel": "Maksimum Çıktı Token sayısı", "maxTokensDescription": "Claude Code yanıtları için maksimum çıktı token sayısı. Varsayılan 8000'dir." - } + }, + "apiKeyUseEnvVar": "{{name}} çevre değişkeninden API anahtarını kullan" }, "browser": { "enable": { diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 35fd639ba..a45247ac8 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -479,7 +479,8 @@ "placeholder": "Mặc định: claude", "maxTokensLabel": "Số token đầu ra tối đa", "maxTokensDescription": "Số lượng token đầu ra tối đa cho các phản hồi của Claude Code. Mặc định là 8000." - } + }, + "apiKeyUseEnvVar": "Sử dụng khóa API từ biến môi trường {{name}}" }, "browser": { "enable": { diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index abb3e4463..6524d08b6 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -479,7 +479,8 @@ "placeholder": "默认:claude", "maxTokensLabel": "最大输出 Token", "maxTokensDescription": "Claude Code 响应的最大输出 Token 数量。默认为 8000。" - } + }, + "apiKeyUseEnvVar": "使用环境变量 {{name}} 中的 API 密钥" }, "browser": { "enable": { diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 91f7c5677..ac3f130cb 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -479,7 +479,8 @@ "placeholder": "預設:claude", "maxTokensLabel": "最大輸出 Token", "maxTokensDescription": "Claude Code 回應的最大輸出 Token 數量。預設為 8000。" - } + }, + "apiKeyUseEnvVar": "使用環境變數 {{name}} 中的 API 密鑰" }, "browser": { "enable": { diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index d15f82e4c..cebadbe98 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -39,22 +39,22 @@ export function validateApiConfiguration( function validateModelsAndKeysProvided(apiConfiguration: ProviderSettings): string | undefined { switch (apiConfiguration.apiProvider) { case "openrouter": - if (!apiConfiguration.openRouterApiKey) { + if (!(apiConfiguration.openRouterApiKey || apiConfiguration.openRouterConfigUseEnvVars)) { return i18next.t("settings:validation.apiKey") } break case "glama": - if (!apiConfiguration.glamaApiKey) { + if (!(apiConfiguration.glamaApiKey || apiConfiguration.glamaConfigUseEnvVars)) { return i18next.t("settings:validation.apiKey") } break case "unbound": - if (!apiConfiguration.unboundApiKey) { + if (!(apiConfiguration.unboundApiKey || apiConfiguration.unboundConfigUseEnvVars)) { return i18next.t("settings:validation.apiKey") } break case "requesty": - if (!apiConfiguration.requestyApiKey) { + if (!(apiConfiguration.requestyApiKey || apiConfiguration.requestyConfigUseEnvVars)) { return i18next.t("settings:validation.apiKey") } break @@ -64,12 +64,12 @@ function validateModelsAndKeysProvided(apiConfiguration: ProviderSettings): stri } break case "litellm": - if (!apiConfiguration.litellmApiKey) { + if (!(apiConfiguration.litellmApiKey || apiConfiguration.litellmConfigUseEnvVars)) { return i18next.t("settings:validation.apiKey") } break case "anthropic": - if (!apiConfiguration.apiKey) { + if (!(apiConfiguration.apiKey || apiConfiguration.anthropicConfigUseEnvVars)) { return i18next.t("settings:validation.apiKey") } break @@ -84,22 +84,24 @@ function validateModelsAndKeysProvided(apiConfiguration: ProviderSettings): stri } break case "gemini": - if (!apiConfiguration.geminiApiKey) { + if (!(apiConfiguration.geminiApiKey || apiConfiguration.geminiConfigUseEnvVars)) { return i18next.t("settings:validation.apiKey") } break case "openai-native": - if (!apiConfiguration.openAiNativeApiKey) { + if (!(apiConfiguration.openAiNativeApiKey || apiConfiguration.openAiNativeConfigUseEnvVars)) { return i18next.t("settings:validation.apiKey") } break case "mistral": - if (!apiConfiguration.mistralApiKey) { + if (!(apiConfiguration.mistralApiKey || apiConfiguration.mistralConfigUseEnvVars)) { return i18next.t("settings:validation.apiKey") } break case "openai": - if (!apiConfiguration.openAiBaseUrl || !apiConfiguration.openAiApiKey || !apiConfiguration.openAiModelId) { + if (!apiConfiguration.openAiBaseUrl + || !(apiConfiguration.openAiApiKey || apiConfiguration.openAiConfigUseEnvVars) + || !apiConfiguration.openAiModelId) { return i18next.t("settings:validation.openAi") } break