From 2ffbbbca8fcf0581dea6d23a74a5c3e9711a97c8 Mon Sep 17 00:00:00 2001 From: Tanner-Maasen <55934534+tmaasen@users.noreply.github.com> Date: Sat, 30 Aug 2025 22:47:25 -0500 Subject: [PATCH] Add Azure OpenAI Custom Model Integration (#1001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #710 This PR implements comprehensive Azure OpenAI integration for Dyad, enabling users to leverage Azure OpenAI models through proper environment variable configuration. The implementation adds Azure as a supported provider with full integration into the existing language model architecture, including support for GPT-5 models. Key features include environment-based configuration using `AZURE_API_KEY` and `AZURE_RESOURCE_NAME`, specialized UI components that provide clear setup instructions and status indicators, and seamless integration with Dyad's existing provider system. The Azure provider leverages the @ai-sdk/azure package (v1.3.25) for compatibility with the current TypeScript language model interfaces. The implementation includes robust error handling for missing configuration, comprehensive test coverage with 9 new unit tests covering critical functionality like model client creation and error scenarios, and an E2E test for the Azure-specific settings UI. Screenshot 2025-08-18 at 9 14 32 PM --------- Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> Co-authored-by: Will Chen --- e2e-tests/azure_provider_settings.spec.ts | 43 +++++++ e2e-tests/azure_send_message.spec.ts | 20 +++ e2e-tests/fixtures/azure/basic.md | 1 + e2e-tests/helpers/test_helper.ts | 19 ++- ...nd-message-through-Azure-OpenAI-1.aria.yml | 6 + package-lock.json | 54 +++++++- package.json | 3 +- .../settings/ApiKeyConfiguration.tsx | 6 + .../settings/AzureConfiguration.tsx | 119 ++++++++++++++++++ .../settings/ProviderSettingsPage.tsx | 9 +- src/ipc/shared/language_model_helpers.ts | 41 ++++++ src/ipc/utils/get_model_client.ts | 49 ++++++++ src/lib/schemas.ts | 1 + testing/fake-llm-server/index.ts | 9 +- 14 files changed, 375 insertions(+), 5 deletions(-) create mode 100644 e2e-tests/azure_provider_settings.spec.ts create mode 100644 e2e-tests/azure_send_message.spec.ts create mode 100644 e2e-tests/fixtures/azure/basic.md create mode 100644 e2e-tests/snapshots/azure_send_message.spec.ts_send-message-through-Azure-OpenAI-1.aria.yml create mode 100644 src/components/settings/AzureConfiguration.tsx diff --git a/e2e-tests/azure_provider_settings.spec.ts b/e2e-tests/azure_provider_settings.spec.ts new file mode 100644 index 0000000..1f9819e --- /dev/null +++ b/e2e-tests/azure_provider_settings.spec.ts @@ -0,0 +1,43 @@ +import { expect } from "@playwright/test"; +import { test as testWithPo } from "./helpers/test_helper"; + +testWithPo("Azure provider settings UI", async ({ po }) => { + await po.setUp(); + await po.goToSettingsTab(); + + // Look for Azure OpenAI in the provider list + await expect(po.page.getByText("Azure OpenAI")).toBeVisible(); + + // Navigate to Azure provider settings + await po.page.getByText("Azure OpenAI").click(); + + // Wait for Azure settings page to load + await po.page.waitForSelector('h1:has-text("Configure Azure OpenAI")', { + state: "visible", + 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 + await expect( + po.page.getByText("Environment Variables Configuration"), + ).toBeVisible(); + + // Check setup instructions are present + await expect(po.page.getByText("How to configure:")).toBeVisible(); + 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"), + ).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(); +}); diff --git a/e2e-tests/azure_send_message.spec.ts b/e2e-tests/azure_send_message.spec.ts new file mode 100644 index 0000000..9f23aba --- /dev/null +++ b/e2e-tests/azure_send_message.spec.ts @@ -0,0 +1,20 @@ +import { testSkipIfWindows } from "./helpers/test_helper"; + +// Set environment variables before the test runs to enable Azure testing +process.env.TEST_AZURE_BASE_URL = "http://localhost:3500/azure"; +process.env.AZURE_API_KEY = "fake-azure-key-for-testing"; +process.env.AZURE_RESOURCE_NAME = "fake-resource-for-testing"; + +testSkipIfWindows("send message through Azure OpenAI", async ({ po }) => { + // Set up Azure without test provider + await po.setUpAzure(); + + // Select Azure model + await po.selectTestAzureModel(); + + // Send a test prompt that returns a normal conversational response + await po.sendPrompt("tc=basic"); + + // Verify we get a response (this means Azure integration is working) + await po.snapshotMessages(); +}); diff --git a/e2e-tests/fixtures/azure/basic.md b/e2e-tests/fixtures/azure/basic.md new file mode 100644 index 0000000..668dfa6 --- /dev/null +++ b/e2e-tests/fixtures/azure/basic.md @@ -0,0 +1 @@ +This is a simple basic response diff --git a/e2e-tests/helpers/test_helper.ts b/e2e-tests/helpers/test_helper.ts index 0b6932c..527122e 100644 --- a/e2e-tests/helpers/test_helper.ts +++ b/e2e-tests/helpers/test_helper.ts @@ -673,7 +673,7 @@ export class PageObject { async selectModel({ provider, model }: { provider: string; model: string }) { await this.page.getByRole("button", { name: "Model: Auto" }).click(); - await this.page.getByText(provider).click(); + await this.page.getByText(provider, { exact: true }).click(); await this.page.getByText(model, { exact: true }).click(); } @@ -701,6 +701,23 @@ export class PageObject { .click(); } + async selectTestAzureModel() { + await this.page.getByRole("button", { name: "Model: Auto" }).click(); + await this.page.getByText("Azure OpenAI", { exact: true }).click(); + await this.page.getByText("GPT-5", { exact: true }).click(); + } + + async setUpAzure({ autoApprove = false }: { autoApprove?: boolean } = {}) { + await this.githubConnector.clearPushEvents(); + await this.goToSettingsTab(); + if (autoApprove) { + await this.toggleAutoApprove(); + } + // Azure should already be configured via environment variables + // so we don't need additional setup steps like setUpDyadProvider + await this.goToAppsTab(); + } + async setUpTestProvider() { await this.page.getByText("Add custom providerConnect to").click(); // Fill out provider dialog diff --git a/e2e-tests/snapshots/azure_send_message.spec.ts_send-message-through-Azure-OpenAI-1.aria.yml b/e2e-tests/snapshots/azure_send_message.spec.ts_send-message-through-Azure-OpenAI-1.aria.yml new file mode 100644 index 0000000..e89919d --- /dev/null +++ b/e2e-tests/snapshots/azure_send_message.spec.ts_send-message-through-Azure-OpenAI-1.aria.yml @@ -0,0 +1,6 @@ +- paragraph: tc=basic +- paragraph: This is a simple basic response +- img +- text: less than a minute ago +- button "Retry": + - img \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 25475ff..10efcb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,9 @@ "license": "MIT", "dependencies": { "@ai-sdk/anthropic": "^2.0.4", + "@ai-sdk/azure": "^2.0.17", "@ai-sdk/google": "^2.0.6", - "@ai-sdk/openai": "^2.0.15", + "@ai-sdk/openai": "2.0.15", "@ai-sdk/openai-compatible": "^1.0.8", "@ai-sdk/provider-utils": "^3.0.3", "@biomejs/biome": "^1.9.4", @@ -142,6 +143,57 @@ "zod": "^3.25.76 || ^4" } }, + "node_modules/@ai-sdk/azure": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@ai-sdk/azure/-/azure-2.0.17.tgz", + "integrity": "sha512-ZyUsN2lR61VdBhnFKyoRLJxJfB6NY7MavZFLec5kyuvF6+JeXNyzJhriZP0E7lVbJe9JSqU8LAnKCDhG5gfL3Q==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/openai": "2.0.17", + "@ai-sdk/provider": "2.0.0", + "@ai-sdk/provider-utils": "3.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4" + } + }, + "node_modules/@ai-sdk/azure/node_modules/@ai-sdk/openai": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-2.0.17.tgz", + "integrity": "sha512-nt0Dvn3etQJwzJtS6XEUchZkDb3NAjn8yTmLZj1fF+F2pyUbiwKg4joW9kjsrDhcwOxdoQ26OyONsVLE9AWfMw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "2.0.0", + "@ai-sdk/provider-utils": "3.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4" + } + }, + "node_modules/@ai-sdk/azure/node_modules/@ai-sdk/provider-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.4.tgz", + "integrity": "sha512-/3Z6lfUp8r+ewFd9yzHkCmPlMOJUXup2Sx3aoUyrdXLhOmAfHRl6Z4lDbIdV0uvw/QYoBcVLJnvXN7ncYeS3uQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "2.0.0", + "@standard-schema/spec": "^1.0.0", + "eventsource-parser": "^3.0.3", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4" + } + }, "node_modules/@ai-sdk/gateway": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-1.0.7.tgz", diff --git a/package.json b/package.json index de3c916..486e409 100644 --- a/package.json +++ b/package.json @@ -85,8 +85,9 @@ }, "dependencies": { "@ai-sdk/anthropic": "^2.0.4", + "@ai-sdk/azure": "^2.0.17", "@ai-sdk/google": "^2.0.6", - "@ai-sdk/openai": "^2.0.15", + "@ai-sdk/openai": "2.0.15", "@ai-sdk/openai-compatible": "^1.0.8", "@ai-sdk/provider-utils": "^3.0.3", "@biomejs/biome": "^1.9.4", diff --git a/src/components/settings/ApiKeyConfiguration.tsx b/src/components/settings/ApiKeyConfiguration.tsx index f26e515..3a50894 100644 --- a/src/components/settings/ApiKeyConfiguration.tsx +++ b/src/components/settings/ApiKeyConfiguration.tsx @@ -6,6 +6,7 @@ import { AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; +import { AzureConfiguration } from "./AzureConfiguration"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { UserSettings } from "@/lib/schemas"; @@ -46,6 +47,11 @@ export function ApiKeyConfiguration({ onDeleteKey, isDyad, }: ApiKeyConfigurationProps) { + // Special handling for Azure OpenAI which requires environment variables + if (provider === "azure") { + return ; + } + const envApiKey = envVarName ? envVars[envVarName] : undefined; const userApiKey = settings?.providerSettings?.[provider]?.apiKey?.value; diff --git a/src/components/settings/AzureConfiguration.tsx b/src/components/settings/AzureConfiguration.tsx new file mode 100644 index 0000000..864f51e --- /dev/null +++ b/src/components/settings/AzureConfiguration.tsx @@ -0,0 +1,119 @@ +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Info, KeyRound } from "lucide-react"; + +interface AzureConfigurationProps { + envVars: Record; +} + +export function AzureConfiguration({ envVars }: AzureConfigurationProps) { + const azureApiKey = envVars["AZURE_API_KEY"]; + const azureResourceName = envVars["AZURE_RESOURCE_NAME"]; + + const isAzureConfigured = !!(azureApiKey && azureResourceName); + + return ( +
+ + + + Azure OpenAI Configuration + + + Azure OpenAI requires both an API key and resource name to be + configured via environment variables. + + + + + + + Environment Variables Configuration + + +
+
+

+ Required Environment Variables: +

+
+
+ + AZURE_API_KEY + + + {azureApiKey ? "Set" : "Not Set"} + +
+
+ + AZURE_RESOURCE_NAME + + + {azureResourceName ? "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. +
+
+ + {isAzureConfigured && ( + + + Azure OpenAI Configured + + Both required environment variables are set. You can now use + Azure OpenAI models. + + + )} +
+
+
+
+
+ ); +} diff --git a/src/components/settings/ProviderSettingsPage.tsx b/src/components/settings/ProviderSettingsPage.tsx index 32df229..3240be8 100644 --- a/src/components/settings/ProviderSettingsPage.tsx +++ b/src/components/settings/ProviderSettingsPage.tsx @@ -69,7 +69,14 @@ export function ProviderSettingsPage({ provider }: ProviderSettingsPageProps) { userApiKey !== "Not Set"; const hasEnvKey = !!(envVarName && envVars[envVarName]); - const isConfigured = isValidUserKey || hasEnvKey; // Configured if either is set + // Special handling for Azure OpenAI configuration + const isAzureConfigured = + provider === "azure" + ? !!(envVars["AZURE_API_KEY"] && envVars["AZURE_RESOURCE_NAME"]) + : false; + + const isConfigured = + provider === "azure" ? isAzureConfigured : isValidUserKey || hasEnvKey; // Configured if either is set // --- Save Handler --- const handleSaveKey = async () => { diff --git a/src/ipc/shared/language_model_helpers.ts b/src/ipc/shared/language_model_helpers.ts index de2a201..15cb713 100644 --- a/src/ipc/shared/language_model_helpers.ts +++ b/src/ipc/shared/language_model_helpers.ts @@ -211,6 +211,40 @@ export const MODEL_OPTIONS: Record = { temperature: 0, }, ], + azure: [ + { + name: "gpt-5", + displayName: "GPT-5", + description: "Azure OpenAI GPT-5 model with reasoning capabilities", + maxOutputTokens: 128_000, + contextWindow: 400_000, + temperature: 0, + }, + { + name: "gpt-5-mini", + displayName: "GPT-5 Mini", + description: "Azure OpenAI GPT-5 Mini model", + maxOutputTokens: 128_000, + contextWindow: 400_000, + temperature: 0, + }, + { + name: "gpt-5-nano", + displayName: "GPT-5 Nano", + description: "Azure OpenAI GPT-5 Nano model", + maxOutputTokens: 128_000, + contextWindow: 400_000, + temperature: 0, + }, + { + name: "gpt-5-chat", + displayName: "GPT-5 Chat", + description: "Azure OpenAI GPT-5 Chat model", + maxOutputTokens: 16_384, + contextWindow: 128_000, + temperature: 0, + }, + ], }; export const PROVIDER_TO_ENV_VAR: Record = { @@ -218,6 +252,7 @@ export const PROVIDER_TO_ENV_VAR: Record = { anthropic: "ANTHROPIC_API_KEY", google: "GEMINI_API_KEY", openrouter: "OPENROUTER_API_KEY", + azure: "AZURE_API_KEY", }; export const CLOUD_PROVIDERS: Record< @@ -258,6 +293,12 @@ export const CLOUD_PROVIDERS: Record< websiteUrl: "https://academy.dyad.sh/settings", gatewayPrefix: "dyad/", }, + azure: { + displayName: "Azure OpenAI", + hasFreeTier: false, + websiteUrl: "https://portal.azure.com/", + gatewayPrefix: "", + }, }; const LOCAL_PROVIDERS: Record< diff --git a/src/ipc/utils/get_model_client.ts b/src/ipc/utils/get_model_client.ts index 4d86fc6..d7390a9 100644 --- a/src/ipc/utils/get_model_client.ts +++ b/src/ipc/utils/get_model_client.ts @@ -1,6 +1,7 @@ import { createOpenAI } from "@ai-sdk/openai"; import { createGoogleGenerativeAI as createGoogle } from "@ai-sdk/google"; import { createAnthropic } from "@ai-sdk/anthropic"; +import { azure } from "@ai-sdk/azure"; import { createOpenRouter } from "@openrouter/ai-sdk-provider"; import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; import type { LargeLanguageModel, UserSettings } from "../../lib/schemas"; @@ -224,6 +225,54 @@ function getRegularModelClient( backupModelClients: [], }; } + case "azure": { + // Check if we're in e2e testing mode + const testAzureBaseUrl = getEnvVar("TEST_AZURE_BASE_URL"); + + if (testAzureBaseUrl) { + // Use fake server for e2e testing + logger.info(`Using test Azure base URL: ${testAzureBaseUrl}`); + const provider = createOpenAICompatible({ + name: "azure-test", + baseURL: testAzureBaseUrl, + apiKey: "fake-api-key-for-testing", + }); + return { + modelClient: { + model: provider(model.name), + builtinProviderId: providerId, + }, + backupModelClients: [], + }; + } + + // 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"); + + if (!resourceName) { + throw new Error( + "Azure OpenAI resource name is required. Please 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.", + ); + } + + // Use the default Azure provider with environment variables + // The azure provider automatically picks up AZURE_RESOURCE_NAME and AZURE_API_KEY + return { + modelClient: { + model: azure(model.name), + builtinProviderId: providerId, + }, + backupModelClients: [], + }; + } case "ollama": { const provider = createOllamaProvider({ baseURL: getOllamaApiUrl() }); return { diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 37bd236..dd2d10e 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -34,6 +34,7 @@ const providers = [ "openrouter", "ollama", "lmstudio", + "azure", ] as const; export const cloudProviders = providers.filter( diff --git a/testing/fake-llm-server/index.ts b/testing/fake-llm-server/index.ts index bc36acb..c5fa243 100644 --- a/testing/fake-llm-server/index.ts +++ b/testing/fake-llm-server/index.ts @@ -137,13 +137,20 @@ app.get("/lmstudio/api/v0/models", (req, res) => { res.json(lmStudioModels); }); -["lmstudio", "gateway", "engine", "ollama"].forEach((provider) => { +["lmstudio", "gateway", "engine", "ollama", "azure"].forEach((provider) => { app.post( `/${provider}/v1/chat/completions`, createChatCompletionHandler(provider), ); }); +// Azure-specific endpoints (Azure client uses different URL patterns) +app.post("/azure/chat/completions", createChatCompletionHandler("azure")); +app.post( + "/azure/openai/deployments/:deploymentId/chat/completions", + createChatCompletionHandler("azure"), +); + // Default test provider handler: app.post("/v1/chat/completions", createChatCompletionHandler("."));