Add Azure OpenAI Custom Model Integration (#1001)

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. 

<img width="1510" height="908" alt="Screenshot 2025-08-18 at 9 14 32 PM"
src="https://github.com/user-attachments/assets/04aa99e1-1590-4bb0-86c9-a67b97bc7500"
/>

---------

Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
Co-authored-by: Will Chen <willchen90@gmail.com>
This commit is contained in:
Tanner-Maasen
2025-08-30 22:47:25 -05:00
committed by GitHub
parent 86cc50c50c
commit 2ffbbbca8f
14 changed files with 375 additions and 5 deletions

View File

@@ -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();
});

View File

@@ -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();
});

View File

@@ -0,0 +1 @@
This is a simple basic response

View File

@@ -673,7 +673,7 @@ export class PageObject {
async selectModel({ provider, model }: { provider: string; model: string }) { async selectModel({ provider, model }: { provider: string; model: string }) {
await this.page.getByRole("button", { name: "Model: Auto" }).click(); 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(); await this.page.getByText(model, { exact: true }).click();
} }
@@ -701,6 +701,23 @@ export class PageObject {
.click(); .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() { async setUpTestProvider() {
await this.page.getByText("Add custom providerConnect to").click(); await this.page.getByText("Add custom providerConnect to").click();
// Fill out provider dialog // Fill out provider dialog

View File

@@ -0,0 +1,6 @@
- paragraph: tc=basic
- paragraph: This is a simple basic response
- img
- text: less than a minute ago
- button "Retry":
- img

54
package-lock.json generated
View File

@@ -10,8 +10,9 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "^2.0.4", "@ai-sdk/anthropic": "^2.0.4",
"@ai-sdk/azure": "^2.0.17",
"@ai-sdk/google": "^2.0.6", "@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/openai-compatible": "^1.0.8",
"@ai-sdk/provider-utils": "^3.0.3", "@ai-sdk/provider-utils": "^3.0.3",
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^1.9.4",
@@ -142,6 +143,57 @@
"zod": "^3.25.76 || ^4" "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": { "node_modules/@ai-sdk/gateway": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-1.0.7.tgz", "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-1.0.7.tgz",

View File

@@ -85,8 +85,9 @@
}, },
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "^2.0.4", "@ai-sdk/anthropic": "^2.0.4",
"@ai-sdk/azure": "^2.0.17",
"@ai-sdk/google": "^2.0.6", "@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/openai-compatible": "^1.0.8",
"@ai-sdk/provider-utils": "^3.0.3", "@ai-sdk/provider-utils": "^3.0.3",
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^1.9.4",

View File

@@ -6,6 +6,7 @@ import {
AccordionItem, AccordionItem,
AccordionTrigger, AccordionTrigger,
} from "@/components/ui/accordion"; } from "@/components/ui/accordion";
import { AzureConfiguration } from "./AzureConfiguration";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { UserSettings } from "@/lib/schemas"; import { UserSettings } from "@/lib/schemas";
@@ -46,6 +47,11 @@ export function ApiKeyConfiguration({
onDeleteKey, onDeleteKey,
isDyad, isDyad,
}: ApiKeyConfigurationProps) { }: ApiKeyConfigurationProps) {
// Special handling for Azure OpenAI which requires environment variables
if (provider === "azure") {
return <AzureConfiguration envVars={envVars} />;
}
const envApiKey = envVarName ? envVars[envVarName] : undefined; const envApiKey = envVarName ? envVars[envVarName] : undefined;
const userApiKey = settings?.providerSettings?.[provider]?.apiKey?.value; const userApiKey = settings?.providerSettings?.[provider]?.apiKey?.value;

View File

@@ -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<string, string | undefined>;
}
export function AzureConfiguration({ envVars }: AzureConfigurationProps) {
const azureApiKey = envVars["AZURE_API_KEY"];
const azureResourceName = envVars["AZURE_RESOURCE_NAME"];
const isAzureConfigured = !!(azureApiKey && azureResourceName);
return (
<div className="space-y-4">
<Alert
variant={isAzureConfigured ? "default" : "destructive"}
className={
isAzureConfigured
? ""
: "border-red-200 bg-red-100 dark:border-red-800/50 dark:bg-red-800/20"
}
>
<Info
className={`h-4 w-4 ${isAzureConfigured ? "" : "text-red-800 dark:text-red-400"}`}
/>
<AlertTitle
className={isAzureConfigured ? "" : "text-red-800 dark:text-red-400"}
>
Azure OpenAI Configuration
</AlertTitle>
<AlertDescription
className={isAzureConfigured ? "" : "text-red-800 dark:text-red-400"}
>
Azure OpenAI requires both an API key and resource name to be
configured via environment variables.
</AlertDescription>
</Alert>
<Accordion
type="multiple"
className="w-full space-y-4"
defaultValue={["azure-config"]}
>
<AccordionItem
value="azure-config"
className="border rounded-lg px-4 bg-background"
>
<AccordionTrigger className="text-lg font-medium hover:no-underline cursor-pointer">
Environment Variables Configuration
</AccordionTrigger>
<AccordionContent className="pt-4">
<div className="space-y-4">
<div>
<h4 className="font-medium mb-2">
Required Environment Variables:
</h4>
<div className="space-y-3 text-sm">
<div className="flex justify-between items-center p-3 bg-muted rounded border">
<code className="font-mono text-foreground">
AZURE_API_KEY
</code>
<span
className={`px-2 py-1 rounded text-xs font-medium ${azureApiKey ? "bg-green-100 text-green-800 dark:bg-green-800/20 dark:text-green-400" : "bg-red-100 text-red-800 dark:bg-red-800/20 dark:text-red-400"}`}
>
{azureApiKey ? "Set" : "Not Set"}
</span>
</div>
<div className="flex justify-between items-center p-3 bg-muted rounded border">
<code className="font-mono text-foreground">
AZURE_RESOURCE_NAME
</code>
<span
className={`px-2 py-1 rounded text-xs font-medium ${azureResourceName ? "bg-green-100 text-green-800 dark:bg-green-800/20 dark:text-green-400" : "bg-red-100 text-red-800 dark:bg-red-800/20 dark:text-red-400"}`}
>
{azureResourceName ? "Set" : "Not Set"}
</span>
</div>
</div>
</div>
<div className="mt-4 p-4 bg-blue-50 dark:bg-blue-950/30 rounded border border-blue-200 dark:border-blue-700">
<h5 className="font-medium mb-2 text-blue-900 dark:text-blue-200">
How to configure:
</h5>
<ol className="list-decimal list-inside space-y-1 text-sm text-blue-800 dark:text-blue-300">
<li>Get your API key from the Azure portal</li>
<li>
Find your resource name (the name you gave your Azure OpenAI
resource)
</li>
<li>Set these environment variables before starting Dyad</li>
<li>Restart Dyad after setting the environment variables</li>
</ol>
</div>
{isAzureConfigured && (
<Alert>
<KeyRound className="h-4 w-4" />
<AlertTitle>Azure OpenAI Configured</AlertTitle>
<AlertDescription>
Both required environment variables are set. You can now use
Azure OpenAI models.
</AlertDescription>
</Alert>
)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}

View File

@@ -69,7 +69,14 @@ export function ProviderSettingsPage({ provider }: ProviderSettingsPageProps) {
userApiKey !== "Not Set"; userApiKey !== "Not Set";
const hasEnvKey = !!(envVarName && envVars[envVarName]); 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 --- // --- Save Handler ---
const handleSaveKey = async () => { const handleSaveKey = async () => {

View File

@@ -211,6 +211,40 @@ export const MODEL_OPTIONS: Record<string, ModelOption[]> = {
temperature: 0, 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<string, string> = { export const PROVIDER_TO_ENV_VAR: Record<string, string> = {
@@ -218,6 +252,7 @@ export const PROVIDER_TO_ENV_VAR: Record<string, string> = {
anthropic: "ANTHROPIC_API_KEY", anthropic: "ANTHROPIC_API_KEY",
google: "GEMINI_API_KEY", google: "GEMINI_API_KEY",
openrouter: "OPENROUTER_API_KEY", openrouter: "OPENROUTER_API_KEY",
azure: "AZURE_API_KEY",
}; };
export const CLOUD_PROVIDERS: Record< export const CLOUD_PROVIDERS: Record<
@@ -258,6 +293,12 @@ export const CLOUD_PROVIDERS: Record<
websiteUrl: "https://academy.dyad.sh/settings", websiteUrl: "https://academy.dyad.sh/settings",
gatewayPrefix: "dyad/", gatewayPrefix: "dyad/",
}, },
azure: {
displayName: "Azure OpenAI",
hasFreeTier: false,
websiteUrl: "https://portal.azure.com/",
gatewayPrefix: "",
},
}; };
const LOCAL_PROVIDERS: Record< const LOCAL_PROVIDERS: Record<

View File

@@ -1,6 +1,7 @@
import { createOpenAI } from "@ai-sdk/openai"; import { createOpenAI } from "@ai-sdk/openai";
import { createGoogleGenerativeAI as createGoogle } from "@ai-sdk/google"; import { createGoogleGenerativeAI as createGoogle } from "@ai-sdk/google";
import { createAnthropic } from "@ai-sdk/anthropic"; import { createAnthropic } from "@ai-sdk/anthropic";
import { azure } from "@ai-sdk/azure";
import { createOpenRouter } from "@openrouter/ai-sdk-provider"; import { createOpenRouter } from "@openrouter/ai-sdk-provider";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import type { LargeLanguageModel, UserSettings } from "../../lib/schemas"; import type { LargeLanguageModel, UserSettings } from "../../lib/schemas";
@@ -224,6 +225,54 @@ function getRegularModelClient(
backupModelClients: [], 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": { case "ollama": {
const provider = createOllamaProvider({ baseURL: getOllamaApiUrl() }); const provider = createOllamaProvider({ baseURL: getOllamaApiUrl() });
return { return {

View File

@@ -34,6 +34,7 @@ const providers = [
"openrouter", "openrouter",
"ollama", "ollama",
"lmstudio", "lmstudio",
"azure",
] as const; ] as const;
export const cloudProviders = providers.filter( export const cloudProviders = providers.filter(

View File

@@ -137,13 +137,20 @@ app.get("/lmstudio/api/v0/models", (req, res) => {
res.json(lmStudioModels); res.json(lmStudioModels);
}); });
["lmstudio", "gateway", "engine", "ollama"].forEach((provider) => { ["lmstudio", "gateway", "engine", "ollama", "azure"].forEach((provider) => {
app.post( app.post(
`/${provider}/v1/chat/completions`, `/${provider}/v1/chat/completions`,
createChatCompletionHandler(provider), 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: // Default test provider handler:
app.post("/v1/chat/completions", createChatCompletionHandler(".")); app.post("/v1/chat/completions", createChatCompletionHandler("."));