diff --git a/.gitignore b/.gitignore index 1e653f7..fc68eb8 100644 --- a/.gitignore +++ b/.gitignore @@ -100,4 +100,5 @@ out/ sqlite.db userData/ -.env.local \ No newline at end of file +.env.local +.idea/ \ No newline at end of file diff --git a/e2e-tests/azure_provider_settings.spec.ts b/e2e-tests/azure_provider_settings.spec.ts index 1f9819e..3259b4f 100644 --- a/e2e-tests/azure_provider_settings.spec.ts +++ b/e2e-tests/azure_provider_settings.spec.ts @@ -17,27 +17,45 @@ testWithPo("Azure provider settings UI", async ({ po }) => { timeout: 5000, }); - // Check that Azure-specific UI is displayed - await expect(po.page.getByText("Azure OpenAI Configuration")).toBeVisible(); - await expect(po.page.getByText("AZURE_API_KEY")).toBeVisible(); - await expect(po.page.getByText("AZURE_RESOURCE_NAME")).toBeVisible(); - - // Check environment variable status indicators exist + // Confirm the new configuration form is rendered await expect( - po.page.getByText("Environment Variables Configuration"), + po.page.getByText("Azure OpenAI Configuration Required"), + ).toBeVisible(); + await expect(po.page.getByLabel("Resource Name")).toBeVisible(); + await expect(po.page.getByLabel("API Key")).toBeVisible(); + await expect( + po.page.getByRole("button", { name: "Save Settings" }), ).toBeVisible(); - // Check setup instructions are present - await expect(po.page.getByText("How to configure:")).toBeVisible(); + // Environment variable helper section should still be available await expect( - po.page.getByText("Get your API key from the Azure portal"), - ).toBeVisible(); - await expect(po.page.getByText("Find your resource name")).toBeVisible(); - await expect( - po.page.getByText("Set these environment variables before starting Dyad"), + po.page.getByText("Environment Variables (optional)"), ).toBeVisible(); - // Check that status indicators show "Not Set" (since no env vars are configured in test) - const statusElements = po.page.locator(".bg-red-100, .bg-red-800\\/20"); - await expect(statusElements.first()).toBeVisible(); + // FIX: disambiguate text matches to avoid strict mode violation + await expect( + po.page.getByText("AZURE_API_KEY", { exact: true }), + ).toBeVisible(); + await expect( + po.page.getByText("AZURE_RESOURCE_NAME", { exact: true }), + ).toBeVisible(); + + // Since no env vars are configured in the test run, both should read "Not Set" + await expect( + po.page + .getByTestId("azure-api-key-status") + .getByText("Not Set", { exact: true }), + ).toBeVisible(); + await expect( + po.page + .getByTestId("azure-resource-name-status") + .getByText("Not Set", { exact: true }), + ).toBeVisible(); + + // The guidance text should explain precedence between saved settings and environment variables + await expect( + po.page.getByText( + "Values saved in Settings take precedence over environment variables.", + ), + ).toBeVisible(); }); diff --git a/src/components/settings/ApiKeyConfiguration.tsx b/src/components/settings/ApiKeyConfiguration.tsx index 86e8585..800fd18 100644 --- a/src/components/settings/ApiKeyConfiguration.tsx +++ b/src/components/settings/ApiKeyConfiguration.tsx @@ -32,6 +32,7 @@ interface ApiKeyConfigurationProps { onSaveKey: () => Promise; onDeleteKey: () => Promise; isDyad: boolean; + updateSettings: (settings: Partial) => Promise; } export function ApiKeyConfiguration({ @@ -47,10 +48,17 @@ export function ApiKeyConfiguration({ onSaveKey, onDeleteKey, isDyad, + updateSettings, }: ApiKeyConfigurationProps) { // Special handling for Azure OpenAI which requires environment variables if (provider === "azure") { - return ; + return ( + + ); } // Special handling for Google Vertex AI which uses service account credentials if (provider === "vertex") { diff --git a/src/components/settings/AzureConfiguration.tsx b/src/components/settings/AzureConfiguration.tsx index 864f51e..22e5427 100644 --- a/src/components/settings/AzureConfiguration.tsx +++ b/src/components/settings/AzureConfiguration.tsx @@ -1,3 +1,6 @@ +import { useEffect, useMemo, useState } from "react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Accordion, @@ -5,111 +8,264 @@ import { AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; -import { Info, KeyRound } from "lucide-react"; +import { CheckCircle2, Info, KeyRound } from "lucide-react"; +import type { AzureProviderSetting, UserSettings } from "@/lib/schemas"; interface AzureConfigurationProps { + settings: UserSettings | null | undefined; envVars: Record; + updateSettings: (settings: Partial) => Promise; } -export function AzureConfiguration({ envVars }: AzureConfigurationProps) { - const azureApiKey = envVars["AZURE_API_KEY"]; - const azureResourceName = envVars["AZURE_RESOURCE_NAME"]; +const AZURE_API_KEY_VAR = "AZURE_API_KEY"; +const AZURE_RESOURCE_NAME_VAR = "AZURE_RESOURCE_NAME"; - const isAzureConfigured = !!(azureApiKey && azureResourceName); +export function AzureConfiguration({ + settings, + envVars, + updateSettings, +}: AzureConfigurationProps) { + const existing = + (settings?.providerSettings?.azure as AzureProviderSetting | undefined) ?? + {}; + const existingApiKey = existing.apiKey?.value ?? ""; + const existingResourceName = existing.resourceName ?? ""; + + const [apiKey, setApiKey] = useState(existingApiKey); + const [resourceName, setResourceName] = useState(existingResourceName); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + setApiKey(existingApiKey); + setResourceName(existingResourceName); + }, [existingApiKey, existingResourceName]); + + const envApiKey = envVars[AZURE_API_KEY_VAR]; + const envResourceName = envVars[AZURE_RESOURCE_NAME_VAR]; + + const hasSavedSettings = Boolean(existingApiKey && existingResourceName); + const hasEnvConfiguration = Boolean(envApiKey && envResourceName); + const isConfigured = hasSavedSettings || hasEnvConfiguration; + const usingEnvironmentOnly = hasEnvConfiguration && !hasSavedSettings; + + const hasUnsavedChanges = useMemo(() => { + return apiKey !== existingApiKey || resourceName !== existingResourceName; + }, [apiKey, existingApiKey, resourceName, existingResourceName]); + + const handleSave = async () => { + setSaving(true); + setSaved(false); + setError(null); + try { + const trimmedApiKey = apiKey.trim(); + const trimmedResourceName = resourceName.trim(); + + const azureSettings: AzureProviderSetting = { + ...existing, + }; + + if (trimmedResourceName) { + azureSettings.resourceName = trimmedResourceName; + } else { + delete azureSettings.resourceName; + } + + if (trimmedApiKey) { + azureSettings.apiKey = { value: trimmedApiKey }; + } else { + delete azureSettings.apiKey; + } + + const providerSettings = { + ...settings?.providerSettings, + azure: azureSettings, + }; + + await updateSettings({ + providerSettings, + }); + + setSaved(true); + } catch (e: any) { + setError(e?.message || "Failed to save Azure settings"); + } finally { + setSaving(false); + } + }; + + const status = useMemo(() => { + if (hasSavedSettings) { + return { + variant: "default" as const, + title: "Azure OpenAI Configured", + description: + "Dyad will use the credentials saved in Settings for Azure OpenAI models.", + icon: KeyRound, + titleClassName: "", + descriptionClassName: "", + alertClassName: "", + }; + } + if (usingEnvironmentOnly) { + return { + variant: "default" as const, + title: "Using Environment Variables", + description: + "AZURE_API_KEY and AZURE_RESOURCE_NAME are set. Values saved below will override them.", + icon: Info, + titleClassName: "", + descriptionClassName: "", + alertClassName: "", + }; + } + return { + variant: "destructive" as const, + title: "Azure OpenAI Configuration Required", + description: + "Provide your Azure resource name and API key below, or configure the AZURE_API_KEY and AZURE_RESOURCE_NAME environment variables.", + icon: Info, + titleClassName: "text-red-800 dark:text-red-400", + descriptionClassName: "text-red-800 dark:text-red-400", + alertClassName: + "border-red-200 bg-red-100 dark:border-red-800/50 dark:bg-red-800/20", + }; + }, [hasSavedSettings, usingEnvironmentOnly]); + + const StatusIcon = status.icon; return (
- - - - Azure OpenAI Configuration + + + + {status.title} - - Azure OpenAI requires both an API key and resource name to be - configured via environment variables. + + {status.description} +
+
+ + { + setResourceName(e.target.value); + setSaved(false); + setError(null); + }} + placeholder="your-azure-openai-resource" + autoComplete="off" + /> +
+
+ + { + setApiKey(e.target.value); + setSaved(false); + setError(null); + }} + placeholder="Enter your Azure OpenAI API key" + autoComplete="off" + type="password" + /> +
+
+ +
+ + {saved && !error && ( + + Saved + + )} +
+ + {!isConfigured && !error && ( + + + Configuration Needed + + Azure OpenAI requests require both a resource name and API key. + Enter them above or supply the environment variables instead. + + + )} + + {error && ( + + Save Error + {error} + + )} + - Environment Variables Configuration + Environment Variables (optional) - -
-
-

- Required Environment Variables: -

-
-
- - AZURE_API_KEY - - - {azureApiKey ? "Set" : "Not Set"} - -
-
- - AZURE_RESOURCE_NAME - - - {azureResourceName ? "Set" : "Not Set"} - -
-
+ +
+
+ + {AZURE_API_KEY_VAR} + + + {envApiKey ? "Set" : "Not Set"} +
- -
-
- How to configure: -
-
    -
  1. Get your API key from the Azure portal
  2. -
  3. - Find your resource name (the name you gave your Azure OpenAI - resource) -
  4. -
  5. Set these environment variables before starting Dyad
  6. -
  7. Restart Dyad after setting the environment variables
  8. -
+
+ + {AZURE_RESOURCE_NAME_VAR} + + + {envResourceName ? "Set" : "Not Set"} +
- - {isAzureConfigured && ( - - - Azure OpenAI Configured - - Both required environment variables are set. You can now use - Azure OpenAI models. - - - )} +
+
+

+ You can continue to configure Azure via environment variables. + If both variables are present and no settings are saved, Dyad + will use them automatically. +

+

+ Values saved in Settings take precedence over environment + variables. Restart Dyad after changing environment variables. +

diff --git a/src/components/settings/ProviderSettingsPage.tsx b/src/components/settings/ProviderSettingsPage.tsx index 262bcb5..11479c3 100644 --- a/src/components/settings/ProviderSettingsPage.tsx +++ b/src/components/settings/ProviderSettingsPage.tsx @@ -11,7 +11,11 @@ import {} from "@/components/ui/accordion"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { showError } from "@/lib/toast"; -import { UserSettings } from "@/lib/schemas"; +import { + UserSettings, + AzureProviderSetting, + VertexProviderSetting, +} from "@/lib/schemas"; import { ProviderSettingsHeader } from "./ProviderSettingsHeader"; import { ApiKeyConfiguration } from "./ApiKeyConfiguration"; @@ -69,20 +73,34 @@ export function ProviderSettingsPage({ provider }: ProviderSettingsPageProps) { userApiKey !== "Not Set"; const hasEnvKey = !!(envVarName && envVars[envVarName]); - // Special handling for Azure OpenAI configuration - const isAzureConfigured = - provider === "azure" - ? !!(envVars["AZURE_API_KEY"] && envVars["AZURE_RESOURCE_NAME"]) - : false; + const azureSettings = settings?.providerSettings?.azure as + | AzureProviderSetting + | undefined; + const azureApiKeyFromSettings = (azureSettings?.apiKey?.value ?? "").trim(); + const azureResourceNameFromSettings = ( + azureSettings?.resourceName ?? "" + ).trim(); + const azureHasSavedSettings = Boolean( + azureApiKeyFromSettings && azureResourceNameFromSettings, + ); + const azureHasEnvConfiguration = Boolean( + envVars["AZURE_API_KEY"] && envVars["AZURE_RESOURCE_NAME"], + ); - // Special handling for Vertex configuration status - const vertexSettings = settings?.providerSettings?.vertex as any; + const vertexSettings = settings?.providerSettings?.vertex as + | VertexProviderSetting + | undefined; const isVertexConfigured = Boolean( vertexSettings?.projectId && vertexSettings?.location && vertexSettings?.serviceAccountKey?.value, ); + const isAzureConfigured = + provider === "azure" + ? azureHasSavedSettings || azureHasEnvConfiguration + : false; + const isConfigured = provider === "azure" ? isAzureConfigured @@ -280,6 +298,7 @@ export function ProviderSettingsPage({ provider }: ProviderSettingsPageProps) { onSaveKey={handleSaveKey} onDeleteKey={handleDeleteKey} isDyad={isDyad} + updateSettings={updateSettings} /> )} diff --git a/src/hooks/useLanguageModelProviders.ts b/src/hooks/useLanguageModelProviders.ts index d37fd54..5cf6397 100644 --- a/src/hooks/useLanguageModelProviders.ts +++ b/src/hooks/useLanguageModelProviders.ts @@ -2,7 +2,11 @@ import { useQuery } from "@tanstack/react-query"; import { IpcClient } from "@/ipc/ipc_client"; import type { LanguageModelProvider } from "@/ipc/ipc_types"; import { useSettings } from "./useSettings"; -import { cloudProviders, VertexProviderSetting } from "@/lib/schemas"; +import { + cloudProviders, + VertexProviderSetting, + AzureProviderSetting, +} from "@/lib/schemas"; export function useLanguageModelProviders() { const ipcClient = IpcClient.getInstance(); @@ -32,6 +36,20 @@ export function useLanguageModelProviders() { } return false; } + if (provider === "azure") { + const azureSettings = providerSettings as AzureProviderSetting; + const hasSavedSettings = Boolean( + (azureSettings?.apiKey?.value ?? "").trim() && + (azureSettings?.resourceName ?? "").trim(), + ); + if (hasSavedSettings) { + return true; + } + if (envVars["AZURE_API_KEY"] && envVars["AZURE_RESOURCE_NAME"]) { + return true; + } + return false; + } if (providerSettings?.apiKey?.value) { return true; } diff --git a/src/ipc/utils/get_model_client.ts b/src/ipc/utils/get_model_client.ts index 9fcbcd8..5a41acd 100644 --- a/src/ipc/utils/get_model_client.ts +++ b/src/ipc/utils/get_model_client.ts @@ -3,7 +3,7 @@ import { createGoogleGenerativeAI as createGoogle } from "@ai-sdk/google"; import { createAnthropic } from "@ai-sdk/anthropic"; import { createXai } from "@ai-sdk/xai"; import { createVertex as createGoogleVertex } from "@ai-sdk/google-vertex"; -import { azure } from "@ai-sdk/azure"; +import { createAzure } from "@ai-sdk/azure"; import { LanguageModelV2 } from "@ai-sdk/provider"; import { createOpenRouter } from "@openrouter/ai-sdk-provider"; import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; @@ -12,6 +12,7 @@ import type { LargeLanguageModel, UserSettings, VertexProviderSetting, + AzureProviderSetting, } from "../../lib/schemas"; import { getEnvVar } from "./read_env"; import log from "electron-log"; @@ -335,28 +336,41 @@ function getRegularModelClient( }; } - // Azure OpenAI requires both API key and resource name as env vars - // We use environment variables for Azure configuration - const resourceName = getEnvVar("AZURE_RESOURCE_NAME"); - const azureApiKey = getEnvVar("AZURE_API_KEY"); + const azureSettings = settings.providerSettings?.azure as + | AzureProviderSetting + | undefined; + const azureApiKeyFromSettings = ( + azureSettings?.apiKey?.value ?? "" + ).trim(); + const azureResourceNameFromSettings = ( + azureSettings?.resourceName ?? "" + ).trim(); + const envResourceName = (getEnvVar("AZURE_RESOURCE_NAME") ?? "").trim(); + const envAzureApiKey = (getEnvVar("AZURE_API_KEY") ?? "").trim(); + + const resourceName = azureResourceNameFromSettings || envResourceName; + const azureApiKey = azureApiKeyFromSettings || envAzureApiKey; if (!resourceName) { throw new Error( - "Azure OpenAI resource name is required. Please set the AZURE_RESOURCE_NAME environment variable.", + "Azure OpenAI resource name is required. Provide it in Settings or set the AZURE_RESOURCE_NAME environment variable.", ); } if (!azureApiKey) { throw new Error( - "Azure OpenAI API key is required. Please set the AZURE_API_KEY environment variable.", + "Azure OpenAI API key is required. Provide it in Settings or set the AZURE_API_KEY environment variable.", ); } - // Use the default Azure provider with environment variables - // The azure provider automatically picks up AZURE_RESOURCE_NAME and AZURE_API_KEY + const provider = createAzure({ + resourceName, + apiKey: azureApiKey, + }); + return { modelClient: { - model: azure(model.name), + model: provider(model.name), builtinProviderId: providerId, }, backupModelClients: [], diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index f13d9d9..a1897a6 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -98,6 +98,11 @@ export const RegularProviderSettingSchema = z.object({ apiKey: SecretSchema.optional(), }); +export const AzureProviderSettingSchema = z.object({ + apiKey: SecretSchema.optional(), + resourceName: z.string().optional(), +}); + export const VertexProviderSettingSchema = z.object({ // We make this undefined so that it makes existing callsites easier. apiKey: z.undefined(), @@ -109,6 +114,7 @@ export const VertexProviderSettingSchema = z.object({ export const ProviderSettingSchema = z.union([ // Must use more specific type first! // Zod uses the first type that matches. + AzureProviderSettingSchema, VertexProviderSettingSchema, RegularProviderSettingSchema, ]); @@ -120,6 +126,7 @@ export type ProviderSetting = z.infer; export type RegularProviderSetting = z.infer< typeof RegularProviderSettingSchema >; +export type AzureProviderSetting = z.infer; export type VertexProviderSetting = z.infer; export const RuntimeModeSchema = z.enum(["web-sandbox", "local-node", "unset"]);