From e31b21c3e4e04f7de4ccdad868e7d7b55a4a784b Mon Sep 17 00:00:00 2001 From: xuhaodev Date: Thu, 29 Jan 2026 09:23:59 +0800 Subject: [PATCH] feat: add Azure OpenAI provider support --- .env.example | 12 + docker-compose.azure.yml | 100 +++++++ src/agents/azure-openai-provider.ts | 360 ++++++++++++++++++++++++++ src/agents/model-auth.ts | 1 + src/agents/models-config.providers.ts | 29 +++ src/config/types.models.ts | 4 + 6 files changed, 506 insertions(+) create mode 100644 docker-compose.azure.yml create mode 100644 src/agents/azure-openai-provider.ts diff --git a/.env.example b/.env.example index 29652fe46..f841997bd 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,15 @@ TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx TWILIO_AUTH_TOKEN=your_auth_token_here # Must be a WhatsApp-enabled Twilio number, prefixed with whatsapp: TWILIO_WHATSAPP_FROM=whatsapp:+17343367101 + +# Azure OpenAI Configuration +# Your Azure OpenAI API key +AZURE_OPENAI_API_KEY=your_azure_openai_api_key_here +# Your Azure OpenAI resource name (e.g., "my-openai-resource") +AZURE_OPENAI_RESOURCE_NAME=your_resource_name_here +# Your Azure OpenAI deployment name (e.g., "gpt-4o", "gpt-35-turbo") +AZURE_OPENAI_DEPLOYMENT_NAME=your_deployment_name_here +# API version (optional, defaults to 2024-08-01-preview) +AZURE_OPENAI_API_VERSION=2024-08-01-preview +# Or provide the full endpoint URL directly (alternative to resource name) +AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com diff --git a/docker-compose.azure.yml b/docker-compose.azure.yml new file mode 100644 index 000000000..d0b0bfcfd --- /dev/null +++ b/docker-compose.azure.yml @@ -0,0 +1,100 @@ +# Docker Compose for Azure OpenAI deployment +# Usage: +# 1. Copy .env.example to .env and fill in your Azure OpenAI credentials +# 2. Build the image: docker compose -f docker-compose.azure.yml build +# 3. Run the gateway: docker compose -f docker-compose.azure.yml up -d moltbot-azure-gateway +# 4. Or run the CLI: docker compose -f docker-compose.azure.yml run --rm moltbot-azure-cli + +services: + moltbot-azure-gateway: + build: + context: . + dockerfile: Dockerfile + image: moltbot-azure:local + container_name: moltbot-azure-gateway + environment: + HOME: /home/node + TERM: xterm-256color + # Gateway authentication token (generate a secure token for production) + CLAWDBOT_GATEWAY_TOKEN: ${CLAWDBOT_GATEWAY_TOKEN:-your-secure-gateway-token} + # Azure OpenAI Configuration (required) + AZURE_OPENAI_API_KEY: ${AZURE_OPENAI_API_KEY:?Azure OpenAI API key is required} + AZURE_OPENAI_RESOURCE_NAME: ${AZURE_OPENAI_RESOURCE_NAME:?Azure OpenAI resource name is required} + AZURE_OPENAI_DEPLOYMENT_NAME: ${AZURE_OPENAI_DEPLOYMENT_NAME:?Azure OpenAI deployment name is required} + AZURE_OPENAI_API_VERSION: ${AZURE_OPENAI_API_VERSION:-2024-08-01-preview} + # Optional: provide full endpoint URL instead of resource name + AZURE_OPENAI_ENDPOINT: ${AZURE_OPENAI_ENDPOINT} + volumes: + # Persist configuration and session data + - ${CLAWDBOT_CONFIG_DIR:-./data/config}:/home/node/.moltbot + - ${CLAWDBOT_WORKSPACE_DIR:-./data/workspace}:/home/node/clawd + ports: + # Gateway HTTP/WebSocket port + - "${CLAWDBOT_GATEWAY_PORT:-18789}:18789" + # Bridge port for channel connections + - "${CLAWDBOT_BRIDGE_PORT:-18790}:18790" + init: true + restart: unless-stopped + command: + [ + "node", + "dist/index.js", + "gateway", + "--bind", + "${CLAWDBOT_GATEWAY_BIND:-lan}", + "--port", + "${CLAWDBOT_GATEWAY_PORT:-18789}", + "--allow-unconfigured" + ] + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:18789/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + moltbot-azure-cli: + build: + context: . + dockerfile: Dockerfile + image: moltbot-azure:local + container_name: moltbot-azure-cli + environment: + HOME: /home/node + TERM: xterm-256color + BROWSER: echo + # Azure OpenAI Configuration (required) + AZURE_OPENAI_API_KEY: ${AZURE_OPENAI_API_KEY:?Azure OpenAI API key is required} + AZURE_OPENAI_RESOURCE_NAME: ${AZURE_OPENAI_RESOURCE_NAME:?Azure OpenAI resource name is required} + AZURE_OPENAI_DEPLOYMENT_NAME: ${AZURE_OPENAI_DEPLOYMENT_NAME:?Azure OpenAI deployment name is required} + AZURE_OPENAI_API_VERSION: ${AZURE_OPENAI_API_VERSION:-2024-08-01-preview} + AZURE_OPENAI_ENDPOINT: ${AZURE_OPENAI_ENDPOINT} + volumes: + - ${CLAWDBOT_CONFIG_DIR:-./data/config}:/home/node/.moltbot + - ${CLAWDBOT_WORKSPACE_DIR:-./data/workspace}:/home/node/clawd + stdin_open: true + tty: true + init: true + entrypoint: ["node", "dist/index.js"] + + # Minimal test service to verify Azure OpenAI connection + moltbot-azure-test: + build: + context: . + dockerfile: Dockerfile + image: moltbot-azure:local + environment: + HOME: /home/node + TERM: xterm-256color + AZURE_OPENAI_API_KEY: ${AZURE_OPENAI_API_KEY:?Azure OpenAI API key is required} + AZURE_OPENAI_RESOURCE_NAME: ${AZURE_OPENAI_RESOURCE_NAME:?Azure OpenAI resource name is required} + AZURE_OPENAI_DEPLOYMENT_NAME: ${AZURE_OPENAI_DEPLOYMENT_NAME:?Azure OpenAI deployment name is required} + AZURE_OPENAI_API_VERSION: ${AZURE_OPENAI_API_VERSION:-2024-08-01-preview} + command: ["node", "dist/index.js", "models", "list"] + profiles: + - test + +# Named volumes for persistent storage (optional, use bind mounts above for easier access) +volumes: + moltbot-config: + moltbot-workspace: diff --git a/src/agents/azure-openai-provider.ts b/src/agents/azure-openai-provider.ts new file mode 100644 index 000000000..cf84542d7 --- /dev/null +++ b/src/agents/azure-openai-provider.ts @@ -0,0 +1,360 @@ +/** + * Azure OpenAI Provider Configuration + * + * This module provides support for Azure OpenAI Service, which uses a different + * API endpoint format and authentication mechanism compared to OpenAI's standard API. + * + * Azure OpenAI endpoint format: + * https://{resourceName}.openai.azure.com/openai/deployments/{deploymentName}/chat/completions?api-version={apiVersion} + */ + +import type { ModelDefinitionConfig } from "../config/types.models.js"; +import type { ProviderConfig } from "./models-config.providers.js"; + +// Store for Azure OpenAI configurations to enable fetch interception +const azureOpenAIConfigs = new Map(); + +/** + * Register an Azure OpenAI resource for fetch interception. + * This enables the global fetch wrapper to add api-version query parameters. + */ +export function registerAzureOpenAIResource(resourceName: string, apiVersion: string): void { + azureOpenAIConfigs.set(resourceName.toLowerCase(), { apiVersion }); +} + +/** + * Check if a URL is an Azure OpenAI endpoint and get its configuration. + */ +export function getAzureOpenAIConfig( + url: string, +): { resourceName: string; apiVersion: string } | null { + try { + const parsed = new URL(url); + const match = /^([^.]+)\.openai\.azure\.com$/.exec(parsed.hostname); + if (!match) return null; + const resourceName = match[1].toLowerCase(); + const config = azureOpenAIConfigs.get(resourceName); + if (!config) return null; + return { resourceName, apiVersion: config.apiVersion }; + } catch { + return null; + } +} + +// Track if the fetch wrapper has been installed +let fetchWrapperInstalled = false; +let originalFetch: typeof fetch | null = null; + +/** + * Install a global fetch wrapper that adds api-version query parameter + * to Azure OpenAI requests. + */ +export function installAzureOpenAIFetchWrapper(): void { + if (fetchWrapperInstalled) return; + + originalFetch = globalThis.fetch; + fetchWrapperInstalled = true; + + globalThis.fetch = async function azureOpenAIFetchWrapper( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const azureConfig = getAzureOpenAIConfig(url); + + if (azureConfig) { + // Add api-version query parameter to Azure OpenAI requests + const parsedUrl = new URL(url); + if (!parsedUrl.searchParams.has("api-version")) { + parsedUrl.searchParams.set("api-version", azureConfig.apiVersion); + const newUrl = parsedUrl.toString(); + + // Recreate the request with the new URL + if (typeof input === "string") { + return originalFetch!(newUrl, init); + } else if (input instanceof URL) { + return originalFetch!(new URL(newUrl), init); + } else { + // Request object - create new request with modified URL + const newRequest = new Request(newUrl, { + method: input.method, + headers: input.headers, + body: init?.body ?? input.body, + mode: input.mode, + credentials: input.credentials, + cache: input.cache, + redirect: input.redirect, + referrer: input.referrer, + integrity: input.integrity, + signal: init?.signal ?? input.signal, + }); + return originalFetch!(newRequest); + } + } + } + + return originalFetch!(input, init); + }; +} + +/** + * Uninstall the Azure OpenAI fetch wrapper (for testing). + */ +export function uninstallAzureOpenAIFetchWrapper(): void { + if (!fetchWrapperInstalled || !originalFetch) return; + globalThis.fetch = originalFetch; + fetchWrapperInstalled = false; + originalFetch = null; +} + +// Azure OpenAI default configuration +const AZURE_OPENAI_DEFAULT_API_VERSION = "2024-08-01-preview"; +const AZURE_OPENAI_DEFAULT_CONTEXT_WINDOW = 128000; +const AZURE_OPENAI_DEFAULT_MAX_TOKENS = 4096; +const AZURE_OPENAI_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +export interface AzureOpenAIConfig { + /** Azure OpenAI resource name (the name of your Azure OpenAI resource) */ + resourceName: string; + /** Deployment name (the name of your model deployment) */ + deploymentName: string; + /** API version (defaults to 2024-08-01-preview) */ + apiVersion?: string; + /** Azure OpenAI API key */ + apiKey?: string; + /** Custom model display name */ + modelName?: string; + /** Whether this is a reasoning model (like o1) */ + reasoning?: boolean; + /** Supported input types */ + input?: Array<"text" | "image">; + /** Context window size */ + contextWindow?: number; + /** Max output tokens */ + maxTokens?: number; +} + +/** + * Builds the Azure OpenAI base URL from resource name + */ +export function buildAzureOpenAIBaseUrl(resourceName: string): string { + return `https://${resourceName}.openai.azure.com`; +} + +/** + * Builds the full Azure OpenAI API endpoint URL + */ +export function buildAzureOpenAIEndpoint( + resourceName: string, + deploymentName: string, + apiVersion: string = AZURE_OPENAI_DEFAULT_API_VERSION, +): string { + return `${buildAzureOpenAIBaseUrl(resourceName)}/openai/deployments/${deploymentName}/chat/completions?api-version=${apiVersion}`; +} + +/** + * Creates a model definition for Azure OpenAI deployment + */ +export function buildAzureOpenAIModelDefinition(config: AzureOpenAIConfig): ModelDefinitionConfig { + const modelId = config.deploymentName; + const modelName = config.modelName ?? `Azure ${config.deploymentName}`; + + return { + id: modelId, + name: modelName, + reasoning: config.reasoning ?? false, + input: config.input ?? ["text"], + cost: AZURE_OPENAI_DEFAULT_COST, + contextWindow: config.contextWindow ?? AZURE_OPENAI_DEFAULT_CONTEXT_WINDOW, + maxTokens: config.maxTokens ?? AZURE_OPENAI_DEFAULT_MAX_TOKENS, + }; +} + +/** + * Builds a provider configuration for Azure OpenAI + * + * Azure OpenAI uses a different URL format: + * https://{resource}.openai.azure.com/openai/deployments/{deployment}/chat/completions?api-version={version} + * + * We set the baseUrl to include the deployment path so the OpenAI SDK appends /chat/completions correctly. + * The api-version query parameter is added automatically via a global fetch wrapper. + */ +export function buildAzureOpenAIProvider(config: AzureOpenAIConfig): ProviderConfig { + const apiVersion = config.apiVersion ?? AZURE_OPENAI_DEFAULT_API_VERSION; + // Azure OpenAI requires the deployment in the URL path + // Format: https://{resource}.openai.azure.com/openai/deployments/{deployment} + // The SDK will append /chat/completions to this + const baseUrl = `https://${config.resourceName}.openai.azure.com/openai/deployments/${config.deploymentName}`; + + // Register the resource for fetch interception to add api-version query param + registerAzureOpenAIResource(config.resourceName, apiVersion); + // Install the global fetch wrapper + installAzureOpenAIFetchWrapper(); + + return { + baseUrl, + api: "openai-completions", + apiKey: config.apiKey, + // Azure OpenAI uses api-key header instead of Bearer token + authHeader: false, + headers: { + "api-key": config.apiKey ?? "", + }, + // Azure requires api-version as query parameter - store in provider config + azureResourceName: config.resourceName, + azureApiVersion: apiVersion, + models: [buildAzureOpenAIModelDefinition(config)], + }; +} + +/** + * Builds a provider configuration for Azure OpenAI with multiple deployments + * + * Note: Each deployment gets its own baseUrl with the deployment name in the path. + * For multiple deployments, we use the first deployment in the baseUrl. + * The api-version query parameter is added automatically via a global fetch wrapper. + */ +export function buildAzureOpenAIProviderMultiDeployment(params: { + resourceName: string; + apiKey?: string; + apiVersion?: string; + deployments: Array<{ + name: string; + displayName?: string; + reasoning?: boolean; + input?: Array<"text" | "image">; + contextWindow?: number; + maxTokens?: number; + }>; +}): ProviderConfig { + const apiVersion = params.apiVersion ?? AZURE_OPENAI_DEFAULT_API_VERSION; + const firstDeployment = params.deployments[0]?.name ?? "default"; + const baseUrl = `https://${params.resourceName}.openai.azure.com/openai/deployments/${firstDeployment}`; + + // Register the resource for fetch interception to add api-version query param + registerAzureOpenAIResource(params.resourceName, apiVersion); + // Install the global fetch wrapper + installAzureOpenAIFetchWrapper(); + + const models: ModelDefinitionConfig[] = params.deployments.map((deployment) => ({ + id: deployment.name, + name: deployment.displayName ?? `Azure ${deployment.name}`, + reasoning: deployment.reasoning ?? false, + input: deployment.input ?? ["text"], + cost: AZURE_OPENAI_DEFAULT_COST, + contextWindow: deployment.contextWindow ?? AZURE_OPENAI_DEFAULT_CONTEXT_WINDOW, + maxTokens: deployment.maxTokens ?? AZURE_OPENAI_DEFAULT_MAX_TOKENS, + })); + + return { + baseUrl, + api: "openai-completions", + apiKey: params.apiKey, + authHeader: false, + headers: { + "api-key": params.apiKey ?? "", + }, + azureResourceName: params.resourceName, + azureApiVersion: apiVersion, + models, + }; +} + +/** + * Environment variable names for Azure OpenAI configuration + */ +export const AZURE_OPENAI_ENV = { + API_KEY: "AZURE_OPENAI_API_KEY", + RESOURCE_NAME: "AZURE_OPENAI_RESOURCE_NAME", + DEPLOYMENT_NAME: "AZURE_OPENAI_DEPLOYMENT_NAME", + API_VERSION: "AZURE_OPENAI_API_VERSION", + ENDPOINT: "AZURE_OPENAI_ENDPOINT", +} as const; + +/** + * Resolves Azure OpenAI configuration from environment variables + */ +export function resolveAzureOpenAIConfigFromEnv( + env: NodeJS.ProcessEnv = process.env, +): AzureOpenAIConfig | null { + const apiKey = env[AZURE_OPENAI_ENV.API_KEY]?.trim(); + const resourceName = env[AZURE_OPENAI_ENV.RESOURCE_NAME]?.trim(); + const deploymentName = env[AZURE_OPENAI_ENV.DEPLOYMENT_NAME]?.trim(); + const apiVersion = env[AZURE_OPENAI_ENV.API_VERSION]?.trim(); + + // If endpoint is provided directly, parse resource name from it + const endpoint = env[AZURE_OPENAI_ENV.ENDPOINT]?.trim(); + let resolvedResourceName = resourceName; + if (!resolvedResourceName && endpoint) { + const match = /https:\/\/([^.]+)\.openai\.azure\.com/.exec(endpoint); + if (match) { + resolvedResourceName = match[1]; + } + } + + if (!apiKey || !resolvedResourceName || !deploymentName) { + return null; + } + + return { + resourceName: resolvedResourceName, + deploymentName, + apiVersion: apiVersion || AZURE_OPENAI_DEFAULT_API_VERSION, + apiKey, + }; +} + +/** + * Default export for common models available on Azure OpenAI + * These are the most commonly deployed models + */ +export const AZURE_OPENAI_COMMON_MODELS = { + "gpt-4o": { + reasoning: false, + input: ["text", "image"] as Array<"text" | "image">, + contextWindow: 128000, + maxTokens: 16384, + }, + "gpt-4o-mini": { + reasoning: false, + input: ["text", "image"] as Array<"text" | "image">, + contextWindow: 128000, + maxTokens: 16384, + }, + "gpt-4-turbo": { + reasoning: false, + input: ["text", "image"] as Array<"text" | "image">, + contextWindow: 128000, + maxTokens: 4096, + }, + "gpt-4": { + reasoning: false, + input: ["text"] as Array<"text" | "image">, + contextWindow: 8192, + maxTokens: 4096, + }, + "gpt-35-turbo": { + reasoning: false, + input: ["text"] as Array<"text" | "image">, + contextWindow: 16384, + maxTokens: 4096, + }, + "o1-preview": { + reasoning: true, + input: ["text"] as Array<"text" | "image">, + contextWindow: 128000, + maxTokens: 32768, + }, + "o1-mini": { + reasoning: true, + input: ["text"] as Array<"text" | "image">, + contextWindow: 128000, + maxTokens: 65536, + }, +} as const; diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 96e4e4ae6..b11e9f539 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -285,6 +285,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null { venice: "VENICE_API_KEY", mistral: "MISTRAL_API_KEY", opencode: "OPENCODE_API_KEY", + "azure-openai": "AZURE_OPENAI_API_KEY", }; const envVar = envMap[normalized]; if (!envVar) return null; diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index a176dac8a..9e7827726 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -13,6 +13,11 @@ import { SYNTHETIC_MODEL_CATALOG, } from "./synthetic-models.js"; import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js"; +import { + buildAzureOpenAIProvider, + resolveAzureOpenAIConfigFromEnv, + AZURE_OPENAI_ENV, +} from "./azure-openai-provider.js"; type ModelsConfig = NonNullable; export type ProviderConfig = NonNullable[string]; @@ -418,6 +423,30 @@ export async function resolveImplicitProviders(params: { providers.ollama = { ...(await buildOllamaProvider()), apiKey: ollamaKey }; } + // Azure OpenAI provider - auto-discover from environment variables + const azureOpenAIConfig = resolveAzureOpenAIConfigFromEnv(); + if (azureOpenAIConfig) { + providers["azure-openai"] = buildAzureOpenAIProvider(azureOpenAIConfig); + } else { + // Check for API key in auth profiles + const azureOpenAIKey = + resolveEnvApiKeyVarName("azure-openai") ?? + resolveApiKeyFromProfiles({ provider: "azure-openai", store: authStore }); + if (azureOpenAIKey) { + // If we have an API key but not full config, check for resource/deployment in env + const resourceName = process.env[AZURE_OPENAI_ENV.RESOURCE_NAME]?.trim(); + const deploymentName = process.env[AZURE_OPENAI_ENV.DEPLOYMENT_NAME]?.trim(); + if (resourceName && deploymentName) { + providers["azure-openai"] = buildAzureOpenAIProvider({ + resourceName, + deploymentName, + apiKey: azureOpenAIKey, + apiVersion: process.env[AZURE_OPENAI_ENV.API_VERSION]?.trim(), + }); + } + } + } + return providers; } diff --git a/src/config/types.models.ts b/src/config/types.models.ts index 11b6c64cb..c7201812a 100644 --- a/src/config/types.models.ts +++ b/src/config/types.models.ts @@ -41,6 +41,10 @@ export type ModelProviderConfig = { headers?: Record; authHeader?: boolean; models: ModelDefinitionConfig[]; + /** Azure OpenAI specific: resource name */ + azureResourceName?: string; + /** Azure OpenAI specific: API version */ + azureApiVersion?: string; }; export type BedrockDiscoveryConfig = {