From 29d8421ce928b30b79a2aa24d952b1834c3535ca Mon Sep 17 00:00:00 2001 From: Md Rakibul Islam Rocky Date: Tue, 30 Sep 2025 23:53:52 +0300 Subject: [PATCH] Enhance Azure configuration handling and UI updates (#1289) # Changes - Update Azure configuration components to manage API key and resource name settings. - Improve visibility of conThis pull request introduces a redesigned Azure provider settings UI and refactors the logic for configuring Azure OpenAI credentials, both in the frontend and supporting hooks. The main changes focus on making the Azure configuration experience clearer and more robust by supporting both environment variable and saved settings, improving status indicators, and updating the logic for determining provider readiness. **Azure Provider UI and Logic Improvements** * Added a new `AzureConfiguration` component that provides a dedicated form for entering and saving Azure resource name and API key, with clear status indicators and error handling. The UI now explains precedence between saved settings and environment variables, and guides users through configuration. (`src/components/settings/AzureConfiguration.tsx`) * Updated the main provider settings page and API key configuration logic to pass Azure-specific settings and update functions to the new component, ensuring seamless integration and correct state management. (`src/components/settings/ApiKeyConfiguration.tsx`, `src/components/settings/ProviderSettingsPage.tsx`) [[1]](diffhunk://#diff-2104fb487cda3768cc5777889100e882f51e7fb3e13abe3cc89cf8ed1444300aR35) [[2]](diffhunk://#diff-2104fb487cda3768cc5777889100e882f51e7fb3e13abe3cc89cf8ed1444300aR51-R61) [[3]](diffhunk://#diff-9140e707ebb56ffed3272b4661ea1e6d8388ee604a8535c58e8a1564d280057cR297) * Refactored the logic for determining whether Azure is configured to check both saved settings and environment variables, ensuring accurate status display and enabling fallback to environment variables if no settings are saved. (`src/components/settings/ProviderSettingsPage.tsx`, `src/hooks/useLanguageModelProviders.ts`) [[1]](diffhunk://#diff-9140e707ebb56ffed3272b4661ea1e6d8388ee604a8535c58e8a1564d280057cL72-R99) [[2]](diffhunk://#diff-9ac9e279a0cda34a0bc519348d5474b2e355b0828a678495be3af1e8984b5be5R35-R48) * Updated the Azure provider E2E test to verify the new UI elements, status indicators, and guidance, ensuring the test matches the new configuration flow and messaging. (`e2e-tests/azure_provider_settings.spec.ts`) **Supporting Type and Import Updates** * Added and updated type imports for `AzureProviderSetting` and `VertexProviderSetting` where needed to support the new logic and UI. (`src/components/settings/ProviderSettingsPage.tsx`, `src/hooks/useLanguageModelProviders.ts`, `src/ipc/utils/get_model_client.ts`) [[1]](diffhunk://#diff-9140e707ebb56ffed3272b4661ea1e6d8388ee604a8535c58e8a1564d280057cL14-R14) [[2]](diffhunk://#diff-9ac9e279a0cda34a0bc519348d5474b2e355b0828a678495be3af1e8984b5be5L5-R5) [[3]](diffhunk://#diff-3cd526c6c10413c1387bfef450e48b880ba6f54865e96046044586ff4192bcceR15) * Changed Azure model client import to use `createAzure` for consistency and future extensibility. (`src/ipc/utils/get_model_client.ts`) [Copilot is generating a summary...]figuration status and error handling in the UI. - Refactor environment variable checks to prioritize saved settings. - Add support for Azure provider settings in the schema. - Modify tests to reflect changes in Azure configuration requirements. # Changes in short - **Azure settings panel** - Replaced with a full form that: - Persists API key and resource name - Surfaces save state - Keeps the environment-variable helper - *(src/components/settings/AzureConfiguration.tsx:23-214)* - **Settings stack workflow** - Threaded the new Azure workflow: - Config shim now passes `updateSettings` - Provider status checks prefer saved Azure values before env vars - *(src/components/settings/ApiKeyConfiguration.tsx:40-55, src/components/settings/ProviderSettingsPage.tsx:60-105)* - **Provider detection** - Azure treated like other saved credentials by: - Looking for both stored fields, or - The pair of env vars - *(src/hooks/useLanguageModelProviders.ts:5-57)* - **Back-end model creation** - Reads saved Azure credentials (falling back to env vars) - Builds the client via `createAzure` - *(src/ipc/utils/get_model_client.ts:316-369)* - **Provider schema support** - Extended so Azure can store its resource name alongside the secret - *(src/lib/schemas.ts:82-109)* - **E2E tests** - Updated Azure Playwright spec to cover the new UI - *(e2e-tests/azure_provider_settings.spec.ts:4-50)* Issues resolved: #1275 --- .gitignore | 3 +- e2e-tests/azure_provider_settings.spec.ts | 52 ++- .../settings/ApiKeyConfiguration.tsx | 10 +- .../settings/AzureConfiguration.tsx | 320 +++++++++++++----- .../settings/ProviderSettingsPage.tsx | 35 +- src/hooks/useLanguageModelProviders.ts | 20 +- src/ipc/utils/get_model_client.ts | 34 +- src/lib/schemas.ts | 7 + 8 files changed, 361 insertions(+), 120 deletions(-) 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"]);