From 9e857e615417ca843de2594d9639ba980c4c1892 Mon Sep 17 00:00:00 2001 From: Li Hongmin Date: Wed, 28 Jan 2026 16:27:00 +0900 Subject: [PATCH] feat: add Azure provider support Add support for Azure-hosted OpenAI-compatible models including OpenAI (GPT-4, GPT-5.2), DeepSeek, and other compatible models. Implementation: - Add Azure provider configuration with auto-discovery from environment - Environment variables: AZURE_ENDPOINT, AZURE_API_KEY, AZURE_DEPLOYMENT, AZURE_API_VERSION - URL fix middleware to handle Azure's specific URL format - Tool call ID sanitization for Azure's 40-character limit - Onboarding wizard support for interactive Azure setup - Comprehensive tests for configuration and URL handling - Documentation with setup guide and troubleshooting Technical details: - Azure uses different URL structure: {endpoint}/openai/deployments/{deployment}/chat/completions?api-version={version} - OpenAI SDK constructs URLs incorrectly for Azure, placing query params before path - URL fix middleware intercepts and corrects malformed URLs transparently - Supports max_completion_tokens for newer models via compat config - Tool call IDs automatically truncated to 40 characters for Azure compatibility Onboard integration: - Added Azure to auth choice groups (appears after OpenAI) - Interactive prompts for endpoint, deployment name, API key, and API version - Auto-discovery from environment variables if already configured - Supports both manual configuration and environment variable detection Co-Authored-By: Claude Sonnet 4.5 (1M context) --- docs/providers/azure.md | 230 ++++++++++++++++++ src/agents/azure-url-fix.test.ts | 69 ++++++ src/agents/azure-url-fix.ts | 74 ++++++ src/agents/model-auth.azure.test.ts | 51 ++++ src/agents/model-auth.ts | 13 + .../models-config.providers.azure.test.ts | 109 +++++++++ src/agents/models-config.providers.ts | 72 +++++- src/agents/pi-embedded-runner/run/attempt.ts | 6 + src/agents/transcript-policy.ts | 14 +- src/commands/auth-choice-options.ts | 12 + .../auth-choice.apply.api-providers.ts | 35 +++ src/commands/onboard-auth.config-azure.ts | 139 +++++++++++ src/commands/onboard-auth.credentials.ts | 13 + src/commands/onboard-auth.ts | 7 + src/commands/onboard-types.ts | 1 + 15 files changed, 840 insertions(+), 5 deletions(-) create mode 100644 docs/providers/azure.md create mode 100644 src/agents/azure-url-fix.test.ts create mode 100644 src/agents/azure-url-fix.ts create mode 100644 src/agents/model-auth.azure.test.ts create mode 100644 src/agents/models-config.providers.azure.test.ts create mode 100644 src/commands/onboard-auth.config-azure.ts diff --git a/docs/providers/azure.md b/docs/providers/azure.md new file mode 100644 index 000000000..b70de09eb --- /dev/null +++ b/docs/providers/azure.md @@ -0,0 +1,230 @@ +# Azure Provider + +Azure supports deploying OpenAI-compatible models through Azure infrastructure. Moltbot's Azure provider allows you to use various models deployed on Azure, including OpenAI models (GPT-4, GPT-3.5), DeepSeek, and other compatible models. + +## Prerequisites + +Before using Azure with Moltbot, you need: + +1. An active Azure subscription with Azure AI access +2. An Azure resource with model deployments +3. Your Azure API key and endpoint +4. A deployed model (OpenAI, DeepSeek, or other compatible models) + +## Configuration + +### Environment Variables + +The easiest way to configure Azure is through environment variables: + +```bash +export AZURE_ENDPOINT="https://your-resource.cognitiveservices.azure.com" +export AZURE_API_KEY="your-api-key-here" +export AZURE_DEPLOYMENT="your-deployment-name" +export AZURE_API_VERSION="2024-02-01" # Optional, defaults to 2024-08-01-preview +``` + +#### Required Variables + +- `AZURE_ENDPOINT`: Your Azure resource endpoint URL + - Format: `https://{resource-name}.cognitiveservices.azure.com` or `https://{region}.api.cognitive.microsoft.com` + - Find this in the Azure Portal under your resource's "Keys and Endpoint" section + +- `AZURE_API_KEY`: Your Azure API key + - Find this in the Azure Portal under "Keys and Endpoint" + - Either KEY 1 or KEY 2 will work + +- `AZURE_DEPLOYMENT`: The name of your model deployment + - This is the deployment name you configured in Azure AI Studio + - Must match exactly as configured in Azure + - Examples: `gpt-4`, `gpt-5.2`, `deepseek-chat` + +#### Optional Variables + +- `AZURE_API_VERSION`: Azure API version + - Default: `2024-08-01-preview` + - Use a stable API version for production workloads + - Common versions: `2024-02-01`, `2024-08-01-preview` + +### models.json Configuration + +Alternatively, you can configure Azure in your `models.json` file: + +```json +{ + "providers": { + "azure": { + "baseUrl": "https://your-resource.cognitiveservices.azure.com/openai/deployments/your-deployment/chat/completions?api-version=2024-02-01", + "apiKey": "AZURE_API_KEY", + "api": "openai-completions", + "headers": { + "api-key": "${AZURE_API_KEY}" + }, + "models": [ + { + "id": "", + "name": "Azure GPT-4", + "reasoning": false, + "input": ["text"], + "cost": { + "input": 10, + "output": 30, + "cacheRead": 2.5, + "cacheWrite": 12.5 + }, + "contextWindow": 200000, + "maxTokens": 16384, + "compat": { + "maxTokensField": "max_completion_tokens" + } + } + ] + } + } +} +``` + +## Supported Models + +Azure supports various OpenAI-compatible models: + +### OpenAI Models +- **GPT-4 Series**: `gpt-4`, `gpt-4-turbo`, `gpt-4-vision` +- **GPT-5.2**: Latest models with extended context +- **GPT-3.5**: `gpt-35-turbo` (note the hyphen instead of dot) +- **o1/o3 Models**: Reasoning models with extended thinking + +### DeepSeek Models +- **DeepSeek-V3**: Latest DeepSeek model +- **DeepSeek-Chat**: General conversation model + +### Other Compatible Models +Any OpenAI API-compatible model deployed on Azure will work with this provider. + +## Usage + +### With Environment Variables + +```bash +# Configure Azure +export AZURE_ENDPOINT="https://eastus2.api.cognitive.microsoft.com" +export AZURE_API_KEY="your-key-here" +export AZURE_DEPLOYMENT="gpt-5.2" +export AZURE_API_VERSION="2024-02-01" + +# Use with Moltbot +moltbot agent --message "Hello" --model azure/gpt-5.2 +``` + +### List Available Models + +```bash +moltbot models list | grep azure +``` + +Expected output: +``` +azure/{deployment-name} Azure {deployment-name} ... +``` + +## Deployment Name Mapping + +Azure uses deployment names instead of model IDs. When you configure `AZURE_DEPLOYMENT=gpt-5.2`, Moltbot will expose this as: + +``` +azure/gpt-5.2 +``` + +The deployment name becomes the model identifier in Moltbot. + +## Important Notes + +### URL Construction + +Azure has a specific URL format that differs from standard OpenAI: + +``` +Standard OpenAI: https://api.openai.com/v1/chat/completions +Azure: https://{endpoint}/openai/deployments/{deployment}/chat/completions?api-version={version} +``` + +Moltbot automatically handles URL construction through an internal URL fix middleware, so you don't need to worry about the format differences. + +### API Compatibility + +Azure's API is compatible with OpenAI's API format but uses: +- Header: `api-key` instead of `Authorization: Bearer` +- Parameter: `max_completion_tokens` instead of `max_tokens` (for newer models) +- Query parameter: `api-version` is required + +## Troubleshooting + +### HTTP 404 Errors + +**Problem**: Getting 404 errors when making API calls + +**Solutions**: +1. Verify your `AZURE_ENDPOINT` is correct (should not include `/openai/deployments/`) +2. Verify your `AZURE_DEPLOYMENT` name matches exactly in Azure Portal +3. Check that `AZURE_API_VERSION` is supported by your deployment +4. Ensure your Azure resource has the model deployed + +### Authentication Errors + +**Problem**: 401 Unauthorized errors + +**Solutions**: +1. Verify `AZURE_API_KEY` is correct +2. Check that the API key hasn't been regenerated in Azure Portal +3. Ensure the key matches the endpoint (don't mix keys from different resources) + +### Unsupported Parameter Errors + +**Problem**: `Unsupported parameter: 'max_tokens'` error + +**Solution**: Newer Azure models require `max_completion_tokens` instead of `max_tokens`. Moltbot handles this automatically through the `compat.maxTokensField` setting. + +### Model Not Found + +**Problem**: Model doesn't appear in `moltbot models list` + +**Solutions**: +1. Check all required environment variables are set +2. Verify `AZURE_DEPLOYMENT` is exactly as configured in Azure +3. Restart Moltbot after changing environment variables + +## Security Best Practices + +1. **Never commit API keys**: Use environment variables or Azure Key Vault +2. **Rotate keys regularly**: Regenerate your API keys periodically +3. **Use RBAC**: Configure role-based access control in Azure +4. **Monitor usage**: Enable Azure Monitor for usage tracking +5. **Set spending limits**: Configure budgets in Azure Cost Management + +## API Versions + +Azure uses API versioning. Common versions: + +- `2024-02-01`: Stable production version +- `2024-08-01-preview`: Preview with latest features +- `2023-12-01`: Older stable version + +Check [Azure OpenAI API documentation](https://learn.microsoft.com/azure/ai-services/openai/reference) for the latest versions. + +## Comparison with Standard OpenAI + +| Feature | Azure | Standard OpenAI | +|---------|-------|-----------------| +| Endpoint | Regional (e.g., eastus2) | Global (api.openai.com) | +| Authentication | api-key header | Bearer token | +| Deployment | Named deployments | Model IDs | +| Billing | Azure subscription | OpenAI account | +| Data residency | Regional | Global | +| Enterprise features | Azure integration | OpenAI org settings | + +## Additional Resources + +- [Azure AI Services Documentation](https://learn.microsoft.com/azure/ai-services/) +- [Azure OpenAI Quickstart](https://learn.microsoft.com/azure/ai-services/openai/quickstart) +- [Model Deployments](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource) +- [API Reference](https://learn.microsoft.com/azure/ai-services/openai/reference) diff --git a/src/agents/azure-url-fix.test.ts b/src/agents/azure-url-fix.test.ts new file mode 100644 index 000000000..8b231dedd --- /dev/null +++ b/src/agents/azure-url-fix.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { installAzureUrlFix, resetAzureUrlFixForTesting } from "./azure-url-fix.js"; + +describe("Azure URL Fix", () => { + let originalFetch: typeof fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + resetAzureUrlFixForTesting(); // Reset state for each test + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("should fix malformed Azure URLs with query params before path", async () => { + // Mock fetch to capture the fixed URL + let capturedUrl = ""; + globalThis.fetch = async (input: RequestInfo | URL) => { + capturedUrl = typeof input === "string" ? input : (input as any).url || input.toString(); + return new Response("{}"); + }; + + installAzureUrlFix(); + + // Simulate OpenAI SDK's malformed URL construction + await fetch( + "https://eastus2.api.cognitive.microsoft.com/openai/deployments/gpt-5.2/chat/completions?api-version=2024-02-01/chat/completions", + ); + + // Verify URL was fixed + expect(capturedUrl).toBe( + "https://eastus2.api.cognitive.microsoft.com/openai/deployments/gpt-5.2/chat/completions?api-version=2024-02-01", + ); + }); + + it("should not modify non-Azure URLs", async () => { + let capturedUrl = ""; + globalThis.fetch = async (input: RequestInfo | URL) => { + capturedUrl = typeof input === "string" ? input : (input as any).url; + return new Response("{}"); + }; + + installAzureUrlFix(); + + const testUrl = "https://api.openai.com/v1/chat/completions"; + await fetch(testUrl); + + expect(capturedUrl).toBe(testUrl); + }); + + it("should handle URLs with different Azure endpoint formats", async () => { + let capturedUrl = ""; + globalThis.fetch = async (input: RequestInfo | URL) => { + capturedUrl = typeof input === "string" ? input : (input as any).url; + return new Response("{}"); + }; + + installAzureUrlFix(); + + await fetch( + "https://test.openai.azure.com/openai/deployments/gpt-4?api-version=2024-08-01-preview/chat/completions", + ); + + expect(capturedUrl).toBe( + "https://test.openai.azure.com/openai/deployments/gpt-4/chat/completions?api-version=2024-08-01-preview", + ); + }); +}); diff --git a/src/agents/azure-url-fix.ts b/src/agents/azure-url-fix.ts new file mode 100644 index 000000000..6cb383313 --- /dev/null +++ b/src/agents/azure-url-fix.ts @@ -0,0 +1,74 @@ +/** + * Azure OpenAI URL fix utility + * + * Problem: OpenAI SDK constructs URLs as: baseURL + path + * For Azure with query params: https://endpoint/deployments/name?api-version=xxx + /chat/completions + * Result: https://endpoint/deployments/name?api-version=xxx/chat/completions (WRONG) + * + * This utility intercepts fetch calls and fixes Azure URLs before sending. + */ + +let azureUrlFixInstalled = false; + +export function installAzureUrlFix(): void { + if (azureUrlFixInstalled) return; + + const originalFetch = globalThis.fetch; + + globalThis.fetch = async function azureCompatibleFetch( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise { + // Extract URL string from input + let url = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : (input as Request).url; + + // Detect malformed Azure URLs + // Pattern: https://endpoint/.../chat/completions?api-version={version}/chat/completions + // The SDK appends /chat/completions after the query string, which is wrong + // Fix to: https://endpoint/.../chat/completions?api-version={version} + const azureUrlPattern = /(https:\/\/[^?]+)(\?[^/]+)(\/.*)/; + const match = url.match(azureUrlPattern); + + if (match) { + const [, basePath, queryParam, wrongPath] = match; + // Only fix if this looks like an Azure URL with deployments + if (basePath.includes("/deployments/") && queryParam.includes("api-version=")) { + // Check if basePath already contains the wrongPath (duplicate case) + const fixedUrl = basePath.endsWith(wrongPath) + ? `${basePath}${queryParam}` // Already has path, just remove duplicate + : `${basePath}${wrongPath}${queryParam}`; // Need to move path before query + + // Reconstruct input with fixed URL + if (typeof input === "string") { + return originalFetch(fixedUrl, init); + } else if (input instanceof URL) { + return originalFetch(new URL(fixedUrl), init); + } else if (input instanceof Request) { + return originalFetch(new Request(fixedUrl, input), init); + } + } + } + + // Non-Azure URLs pass through unchanged + return originalFetch(input, init); + } as typeof fetch; + + azureUrlFixInstalled = true; +} + +export function isAzureUrlFixInstalled(): boolean { + return azureUrlFixInstalled; +} + +/** + * Reset the URL fix installation state (for testing only) + * @internal + */ +export function resetAzureUrlFixForTesting(): void { + azureUrlFixInstalled = false; +} diff --git a/src/agents/model-auth.azure.test.ts b/src/agents/model-auth.azure.test.ts new file mode 100644 index 000000000..e835cfb05 --- /dev/null +++ b/src/agents/model-auth.azure.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest"; +import { + resolveAzureEndpoint, + resolveAzureApiVersion, + resolveAzureDeployment, +} from "./model-auth.js"; + +describe("Azure provider configuration", () => { + it("should resolve Azure endpoint from environment", () => { + const endpoint = resolveAzureEndpoint({ + AZURE_ENDPOINT: "https://eastus2.api.cognitive.microsoft.com", + }); + expect(endpoint).toBe("https://eastus2.api.cognitive.microsoft.com"); + }); + + it("should resolve Azure API version with default", () => { + const version = resolveAzureApiVersion({}); + expect(version).toBe("2024-08-01-preview"); + }); + + it("should resolve Azure API version from environment", () => { + const version = resolveAzureApiVersion({ + AZURE_API_VERSION: "2024-02-01", + }); + expect(version).toBe("2024-02-01"); + }); + + it("should resolve Azure deployment from environment", () => { + const deployment = resolveAzureDeployment({ + AZURE_DEPLOYMENT: "gpt-5.2", + }); + expect(deployment).toBe("gpt-5.2"); + }); + + it("should return undefined for missing endpoint", () => { + const endpoint = resolveAzureEndpoint({}); + expect(endpoint).toBeUndefined(); + }); + + it("should return undefined for missing deployment", () => { + const deployment = resolveAzureDeployment({}); + expect(deployment).toBeUndefined(); + }); + + it("should trim whitespace from values", () => { + const endpoint = resolveAzureEndpoint({ + AZURE_ENDPOINT: " https://test.com ", + }); + expect(endpoint).toBe("https://test.com"); + }); +}); diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 96e4e4ae6..117d15138 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -229,6 +229,18 @@ export async function resolveApiKeyForProvider(params: { export type EnvApiKeyResult = { apiKey: string; source: string }; export type ModelAuthMode = "api-key" | "oauth" | "token" | "mixed" | "aws-sdk" | "unknown"; +export function resolveAzureEndpoint(env = process.env): string | undefined { + return env.AZURE_ENDPOINT?.trim() || undefined; +} + +export function resolveAzureApiVersion(env = process.env): string { + return env.AZURE_API_VERSION?.trim() || "2024-08-01-preview"; +} + +export function resolveAzureDeployment(env = process.env): string | undefined { + return env.AZURE_DEPLOYMENT?.trim() || undefined; +} + export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null { const normalized = normalizeProviderId(provider); const applied = new Set(getShellEnvAppliedKeys()); @@ -271,6 +283,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null { const envMap: Record = { openai: "OPENAI_API_KEY", + azure: "AZURE_API_KEY", google: "GEMINI_API_KEY", groq: "GROQ_API_KEY", deepgram: "DEEPGRAM_API_KEY", diff --git a/src/agents/models-config.providers.azure.test.ts b/src/agents/models-config.providers.azure.test.ts new file mode 100644 index 000000000..156736327 --- /dev/null +++ b/src/agents/models-config.providers.azure.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from "vitest"; +import { resolveImplicitAzureProvider } from "./models-config.providers.js"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +describe("Azure provider auto-discovery", () => { + it("should return null when environment variables are missing", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "moltbot-test-")); + const provider = await resolveImplicitAzureProvider({ + agentDir: tempDir, + env: {}, + }); + expect(provider).toBeNull(); + }); + + it("should auto-discover Azure provider from environment variables", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "moltbot-test-")); + const provider = await resolveImplicitAzureProvider({ + agentDir: tempDir, + env: { + AZURE_ENDPOINT: "https://eastus2.api.cognitive.microsoft.com", + AZURE_API_KEY: "test-key", + AZURE_DEPLOYMENT: "gpt-5.2", + AZURE_API_VERSION: "2024-02-01", + }, + }); + + expect(provider).not.toBeNull(); + expect(provider?.apiKey).toBe("test-key"); + expect(provider?.api).toBe("openai-completions"); + expect(provider?.headers).toEqual({ "api-key": "test-key" }); + }); + + it("should construct correct baseUrl with deployment and API version", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "moltbot-test-")); + const provider = await resolveImplicitAzureProvider({ + agentDir: tempDir, + env: { + AZURE_ENDPOINT: "https://eastus2.api.cognitive.microsoft.com", + AZURE_API_KEY: "test-key", + AZURE_DEPLOYMENT: "gpt-5.2", + AZURE_API_VERSION: "2024-02-01", + }, + }); + + expect(provider?.baseUrl).toBe( + "https://eastus2.api.cognitive.microsoft.com/openai/deployments/gpt-5.2/chat/completions?api-version=2024-02-01", + ); + }); + + it("should create model definition with empty ID", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "moltbot-test-")); + const provider = await resolveImplicitAzureProvider({ + agentDir: tempDir, + env: { + AZURE_ENDPOINT: "https://test.com", + AZURE_API_KEY: "key", + AZURE_DEPLOYMENT: "gpt-4", + }, + }); + + expect(provider?.models).toHaveLength(1); + expect(provider?.models[0].id).toBe(""); + expect(provider?.models[0].name).toBe("Azure gpt-4"); + }); + + it("should detect reasoning models (o1/o3)", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "moltbot-test-")); + const provider = await resolveImplicitAzureProvider({ + agentDir: tempDir, + env: { + AZURE_ENDPOINT: "https://test.com", + AZURE_API_KEY: "key", + AZURE_DEPLOYMENT: "gpt-o1-preview", + }, + }); + + expect(provider?.models[0].reasoning).toBe(true); + }); + + it("should detect vision models", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "moltbot-test-")); + const provider = await resolveImplicitAzureProvider({ + agentDir: tempDir, + env: { + AZURE_ENDPOINT: "https://test.com", + AZURE_API_KEY: "key", + AZURE_DEPLOYMENT: "gpt-4-vision", + }, + }); + + expect(provider?.models[0].input).toEqual(["text", "image"]); + }); + + it("should use max_completion_tokens compatibility", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "moltbot-test-")); + const provider = await resolveImplicitAzureProvider({ + agentDir: tempDir, + env: { + AZURE_ENDPOINT: "https://test.com", + AZURE_API_KEY: "key", + AZURE_DEPLOYMENT: "gpt-5.2", + }, + }); + + expect(provider?.models[0].compat?.maxTokensField).toBe("max_completion_tokens"); + }); +}); diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index cb556aced..26c81fa31 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -5,7 +5,13 @@ import { resolveCopilotApiToken, } from "../providers/github-copilot-token.js"; import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js"; -import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js"; +import { + resolveAwsSdkEnvVarName, + resolveEnvApiKey, + resolveAzureEndpoint, + resolveAzureApiVersion, + resolveAzureDeployment, +} from "./model-auth.js"; import { discoverBedrockModels } from "./bedrock-discovery.js"; import { buildSyntheticModelDefinition, @@ -359,6 +365,63 @@ async function buildOllamaProvider(): Promise { }; } +export async function resolveImplicitAzureProvider(params: { + agentDir: string; + env?: NodeJS.ProcessEnv; +}): Promise { + const env = params.env ?? process.env; + + const endpoint = resolveAzureEndpoint(env); + const deployment = resolveAzureDeployment(env); + const apiVersion = resolveAzureApiVersion(env); + const authStore = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }); + const apiKey = + resolveEnvApiKeyVarName("azure") ?? + resolveApiKeyFromProfiles({ provider: "azure", store: authStore }); + + if (!endpoint || !deployment || !apiKey) { + return null; + } + + // Construct Azure-compatible baseUrl + // Note: OpenAI SDK will append /chat/completions to this URL, resulting in: + // {endpoint}/openai/deployments/{deployment}/chat/completions?api-version={version}/chat/completions + // Our URL fix middleware (azure-url-fix.ts) intercepts fetch calls and corrects this to: + // {endpoint}/openai/deployments/{deployment}/chat/completions?api-version={version} + const baseUrl = `${endpoint}/openai/deployments/${deployment}/chat/completions?api-version=${apiVersion}`; + + // Create model definition for the Azure deployment + const models: ModelDefinitionConfig[] = [ + { + id: deployment, + name: `Azure ${deployment}`, + reasoning: deployment.toLowerCase().includes("o1") || deployment.toLowerCase().includes("o3"), + input: deployment.toLowerCase().includes("vision") ? ["text", "image"] : ["text"], + cost: { + input: 10, + output: 30, + cacheRead: 2.5, + cacheWrite: 12.5, + }, + contextWindow: 200000, + maxTokens: 16384, + compat: { + maxTokensField: "max_completion_tokens", + }, + }, + ]; + + return { + baseUrl, + apiKey, + api: "openai-completions", + headers: { + "api-key": apiKey, + }, + models, + }; +} + export async function resolveImplicitProviders(params: { agentDir: string; }): Promise { @@ -418,6 +481,13 @@ export async function resolveImplicitProviders(params: { providers.ollama = { ...(await buildOllamaProvider()), apiKey: ollamaKey }; } + // Azure provider - auto-discover from environment variables + // Supports OpenAI-compatible APIs (OpenAI, DeepSeek, etc.) on Azure + const azureProvider = await resolveImplicitAzureProvider(params); + if (azureProvider) { + providers.azure = azureProvider; + } + return providers; } diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 46a53bd8f..7eeea1960 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -85,6 +85,7 @@ import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; import { MAX_IMAGE_BYTES } from "../../../media/constants.js"; import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./types.js"; import { detectAndLoadPromptImages } from "./images.js"; +import { installAzureUrlFix, isAzureUrlFixInstalled } from "../../azure-url-fix.js"; export function injectHistoryImagesIntoMessages( messages: AgentMessage[], @@ -133,6 +134,11 @@ export function injectHistoryImagesIntoMessages( export async function runEmbeddedAttempt( params: EmbeddedRunAttemptParams, ): Promise { + // Install Azure URL fix once per process + if (!isAzureUrlFixInstalled()) { + installAzureUrlFix(); + } + const resolvedWorkspace = resolveUserPath(params.workspaceDir); const prevCwd = process.cwd(); const runAbortController = new AbortController(); diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index 9ae14d38f..f98e66c91 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -72,6 +72,7 @@ export function resolveTranscriptPolicy(params: { const modelId = params.modelId ?? ""; const isGoogle = isGoogleModelApi(params.modelApi); const isAnthropic = isAnthropicApi(params.modelApi, provider); + const isAzure = provider === "azure"; const isOpenAi = isOpenAiProvider(provider) || (!provider && isOpenAiApi(params.modelApi)); const isMistral = isMistralModel({ provider, modelId }); const isOpenRouterGemini = @@ -85,7 +86,7 @@ export function resolveTranscriptPolicy(params: { const needsNonImageSanitize = isGoogle || isAnthropic || isMistral || isOpenRouterGemini; - const sanitizeToolCallIds = isGoogle || isMistral; + const sanitizeToolCallIds = isGoogle || isMistral || isAzure; const toolCallIdMode: ToolCallIdMode | undefined = isMistral ? "strict9" : sanitizeToolCallIds @@ -98,12 +99,17 @@ export function resolveTranscriptPolicy(params: { const normalizeAntigravityThinkingBlocks = isAntigravityClaudeModel; return { - sanitizeMode: isOpenAi ? "images-only" : needsNonImageSanitize ? "full" : "images-only", - sanitizeToolCallIds: !isOpenAi && sanitizeToolCallIds, + sanitizeMode: + isOpenAi && !isAzure + ? "images-only" + : needsNonImageSanitize || isAzure + ? "full" + : "images-only", + sanitizeToolCallIds: (isAzure || !isOpenAi) && sanitizeToolCallIds, toolCallIdMode, repairToolUseResultPairing: !isOpenAi && repairToolUseResultPairing, preserveSignatures: isAntigravityClaudeModel, - sanitizeThoughtSignatures: isOpenAi ? undefined : sanitizeThoughtSignatures, + sanitizeThoughtSignatures: isOpenAi && !isAzure ? undefined : sanitizeThoughtSignatures, normalizeAntigravityThinkingBlocks, applyGoogleTurnOrdering: !isOpenAi && isGoogle, validateGeminiTurns: !isOpenAi && isGoogle, diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 6b49ff17b..fc2ba4365 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -9,6 +9,7 @@ export type AuthChoiceOption = { export type AuthChoiceGroupId = | "openai" + | "azure" | "anthropic" | "google" | "copilot" @@ -41,6 +42,12 @@ const AUTH_CHOICE_GROUP_DEFS: { hint: "Codex OAuth + API key", choices: ["openai-codex", "openai-api-key"], }, + { + value: "azure", + label: "Azure", + hint: "OpenAI, DeepSeek, and compatible models on Azure", + choices: ["azure-api-key"], + }, { value: "anthropic", label: "Anthropic", @@ -134,6 +141,11 @@ export function buildAuthChoiceOptions(params: { }); options.push({ value: "chutes", label: "Chutes (OAuth)" }); options.push({ value: "openai-api-key", label: "OpenAI API key" }); + options.push({ + value: "azure-api-key", + label: "Azure API key", + hint: "OpenAI, DeepSeek, and compatible models on Azure", + }); options.push({ value: "openrouter-api-key", label: "OpenRouter API key" }); options.push({ value: "ai-gateway-api-key", diff --git a/src/commands/auth-choice.apply.api-providers.ts b/src/commands/auth-choice.apply.api-providers.ts index 8be02008b..5e74b372e 100644 --- a/src/commands/auth-choice.apply.api-providers.ts +++ b/src/commands/auth-choice.apply.api-providers.ts @@ -13,6 +13,7 @@ import { } from "./google-gemini-model-default.js"; import { applyAuthProfileConfig, + applyAzureConfig, applyKimiCodeConfig, applyKimiCodeProviderConfig, applyMoonshotConfig, @@ -28,12 +29,14 @@ import { applyVercelAiGatewayConfig, applyVercelAiGatewayProviderConfig, applyZaiConfig, + AZURE_DEFAULT_MODEL_REF, KIMI_CODE_MODEL_REF, MOONSHOT_DEFAULT_MODEL_REF, OPENROUTER_DEFAULT_MODEL_REF, SYNTHETIC_DEFAULT_MODEL_REF, VENICE_DEFAULT_MODEL_REF, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, + setAzureApiKey, setGeminiApiKey, setKimiCodeApiKey, setMoonshotApiKey, @@ -321,6 +324,38 @@ export async function applyAuthChoiceApiProviders( return { config: nextConfig, agentModelOverride }; } + if (authChoice === "azure-api-key") { + const azureConfig = await applyAzureConfig({ + prompter: params.prompter, + agentDir: params.agentDir, + }); + nextConfig = { ...nextConfig, ...azureConfig }; + + if (params.setDefaultModel) { + nextConfig = { + ...nextConfig, + agents: { + ...nextConfig.agents, + defaults: { + ...nextConfig.agents?.defaults, + model: { + ...nextConfig.agents?.defaults?.model, + primary: AZURE_DEFAULT_MODEL_REF, + }, + }, + }, + }; + await params.prompter.note( + `Default model set to ${AZURE_DEFAULT_MODEL_REF}`, + "Model configured", + ); + } else { + agentModelOverride = AZURE_DEFAULT_MODEL_REF; + await noteAgentModel(AZURE_DEFAULT_MODEL_REF); + } + return { config: nextConfig, agentModelOverride }; + } + if (authChoice === "gemini-api-key") { let hasCredential = false; diff --git a/src/commands/onboard-auth.config-azure.ts b/src/commands/onboard-auth.config-azure.ts new file mode 100644 index 000000000..4ea519fb9 --- /dev/null +++ b/src/commands/onboard-auth.config-azure.ts @@ -0,0 +1,139 @@ +import type { MoltbotConfig } from "../config/config.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; +import { setAzureApiKey } from "./onboard-auth.credentials.js"; + +export const AZURE_DEFAULT_MODEL_ID = "gpt-5.2"; +export const AZURE_DEFAULT_MODEL_REF = `azure/${AZURE_DEFAULT_MODEL_ID}`; + +export async function applyAzureConfig(params: { + prompter: WizardPrompter; + agentDir?: string; +}): Promise { + const { prompter, agentDir } = params; + + await prompter.note( + [ + "Azure provider supports OpenAI-compatible models on Azure infrastructure:", + "", + "• OpenAI models: GPT-4, GPT-5.2, o1/o3", + "• DeepSeek models: DeepSeek-V3, DeepSeek-Chat", + "• Other compatible models deployed on Azure", + "", + "You'll need:", + "1. Azure endpoint (e.g., https://eastus2.api.cognitive.microsoft.com)", + "2. Azure API key", + "3. Deployment name (e.g., gpt-5.2, deepseek-chat)", + "4. API version (default: 2024-08-01-preview)", + "", + "Find these in Azure Portal under your resource's 'Keys and Endpoint'.", + ].join("\n"), + "Azure Setup", + ); + + const endpoint = await prompter.text({ + message: "Azure endpoint URL", + placeholder: "https://your-resource.cognitiveservices.azure.com", + validate: (value) => { + if (!value?.trim()) return "Endpoint is required"; + try { + new URL(value); + return undefined; + } catch { + return "Invalid URL format"; + } + }, + }); + + const deployment = await prompter.text({ + message: "Deployment name", + placeholder: "gpt-5.2", + validate: (value) => (!value?.trim() ? "Deployment name is required" : undefined), + }); + + const apiKey = await prompter.text({ + message: "Azure API key", + validate: (value: string) => (!value?.trim() ? "API key is required" : undefined), + }); + + const apiVersion = await prompter.text({ + message: "API version (optional)", + placeholder: "2024-08-01-preview", + initialValue: "2024-08-01-preview", + }); + + await setAzureApiKey(apiKey, agentDir); + + const baseUrl = `${endpoint.trim()}/openai/deployments/${deployment.trim()}?api-version=${apiVersion.trim()}`; + + return { + models: { + providers: { + azure: { + baseUrl, + apiKey: "AZURE_API_KEY", + api: "openai-completions", + headers: { + "api-key": "${AZURE_API_KEY}", + }, + models: [ + { + id: deployment.trim(), + name: `Azure ${deployment.trim()}`, + reasoning: + deployment.toLowerCase().includes("o1") || deployment.toLowerCase().includes("o3"), + input: deployment.toLowerCase().includes("vision") ? ["text", "image"] : ["text"], + cost: { + input: 10, + output: 30, + cacheRead: 2.5, + cacheWrite: 12.5, + }, + contextWindow: 200000, + maxTokens: 16384, + compat: { + maxTokensField: "max_completion_tokens", + }, + }, + ], + }, + }, + }, + }; +} + +export function applyAzureProviderConfig(): MoltbotConfig { + return { + models: { + providers: { + azure: { + baseUrl: + "${AZURE_ENDPOINT}/openai/deployments/${AZURE_DEPLOYMENT}?api-version=${AZURE_API_VERSION}", + apiKey: "AZURE_API_KEY", + api: "openai-completions", + headers: { + "api-key": "${AZURE_API_KEY}", + }, + models: [ + { + id: "${AZURE_DEPLOYMENT}", + name: "Azure ${AZURE_DEPLOYMENT}", + reasoning: false, + input: ["text"], + cost: { + input: 10, + output: 30, + cacheRead: 2.5, + cacheWrite: 12.5, + }, + contextWindow: 200000, + maxTokens: 16384, + compat: { + maxTokensField: "max_completion_tokens", + }, + }, + ], + }, + }, + }, + }; +} diff --git a/src/commands/onboard-auth.credentials.ts b/src/commands/onboard-auth.credentials.ts index b2fb58542..c2ccda251 100644 --- a/src/commands/onboard-auth.credentials.ts +++ b/src/commands/onboard-auth.credentials.ts @@ -34,6 +34,19 @@ export async function setAnthropicApiKey(key: string, agentDir?: string) { }); } +export async function setAzureApiKey(key: string, agentDir?: string) { + // Write to resolved agent dir so gateway finds credentials on startup. + upsertAuthProfile({ + profileId: "azure:default", + credential: { + type: "api_key", + provider: "azure", + key, + }, + agentDir: resolveAuthAgentDir(agentDir), + }); +} + export async function setGeminiApiKey(key: string, agentDir?: string) { // Write to resolved agent dir so gateway finds credentials on startup. upsertAuthProfile({ diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index b122d89cf..d6a8bac5b 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -32,9 +32,16 @@ export { applyOpencodeZenConfig, applyOpencodeZenProviderConfig, } from "./onboard-auth.config-opencode.js"; +export { + applyAzureConfig, + applyAzureProviderConfig, + AZURE_DEFAULT_MODEL_ID, + AZURE_DEFAULT_MODEL_REF, +} from "./onboard-auth.config-azure.js"; export { OPENROUTER_DEFAULT_MODEL_REF, setAnthropicApiKey, + setAzureApiKey, setGeminiApiKey, setKimiCodeApiKey, setMinimaxApiKey, diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index aa1d9afe0..d60bb6708 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -11,6 +11,7 @@ export type AuthChoice = | "chutes" | "openai-codex" | "openai-api-key" + | "azure-api-key" | "openrouter-api-key" | "ai-gateway-api-key" | "moonshot-api-key"