fix: support Azure provider with reasoning models (#1310)

Co-authored-by: Ralph Khreish <Crunchyman-ralph@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Fixes #638
This commit is contained in:
Ralph Khreish
2025-12-16 16:14:24 +01:00
committed by GitHub
parent 353e3bffd6
commit 4b6570e300
18 changed files with 2093 additions and 2077 deletions

View File

@@ -0,0 +1,7 @@
---
"task-master-ai": patch
---
Fix Azure OpenAI provider to use correct deployment-based URL format
- Add Azure OpenAI documentation page <https://docs.task-master.dev/providers/azure>

View File

@@ -5,6 +5,7 @@
import { CUSTOM_PROVIDERS } from '@tm/core'; import { CUSTOM_PROVIDERS } from '@tm/core';
import chalk from 'chalk'; import chalk from 'chalk';
import inquirer from 'inquirer'; import inquirer from 'inquirer';
import { getAzureBaseURL } from '../../lib/model-management.js';
import { validateOllamaModel, validateOpenRouterModel } from './fetchers.js'; import { validateOllamaModel, validateOpenRouterModel } from './fetchers.js';
import { CUSTOM_PROVIDER_IDS } from './types.js'; import { CUSTOM_PROVIDER_IDS } from './types.js';
import type { import type {
@@ -86,18 +87,16 @@ export const customProviderConfigs: Record<
}, },
AZURE: { AZURE: {
id: '__CUSTOM_AZURE__', id: '__CUSTOM_AZURE__',
name: '* Custom Azure model', name: '* Custom Azure OpenAI model',
provider: CUSTOM_PROVIDERS.AZURE, provider: CUSTOM_PROVIDERS.AZURE,
requiresBaseURL: true,
promptMessage: (role) => promptMessage: (role) =>
`Enter the custom Azure OpenAI Model ID for the ${role} role (e.g., gpt-4o):`, `Enter the Azure deployment name for the ${role} role (e.g., gpt-4o):`,
checkEnvVars: () => { checkEnvVars: () => {
if ( if (!process.env.AZURE_OPENAI_API_KEY) {
!process.env.AZURE_OPENAI_API_KEY ||
!process.env.AZURE_OPENAI_ENDPOINT
) {
console.error( console.error(
chalk.red( chalk.red(
'Error: AZURE_OPENAI_API_KEY and/or AZURE_OPENAI_ENDPOINT environment variables are missing. Please set them before using custom Azure models.' 'Error: AZURE_OPENAI_API_KEY environment variable is missing. Please set it before using Azure models.'
) )
); );
return false; return false;
@@ -171,7 +170,8 @@ export async function handleCustomProvider(
modelId?: string | null; modelId?: string | null;
provider?: string | null; provider?: string | null;
baseURL?: string | null; baseURL?: string | null;
} | null = null } | null = null,
projectRoot?: string
): Promise<{ ): Promise<{
modelId: string | null; modelId: string | null;
provider: string | null; provider: string | null;
@@ -203,6 +203,9 @@ export async function handleCustomProvider(
if (currentModel?.provider === config.provider && currentModel?.baseURL) { if (currentModel?.provider === config.provider && currentModel?.baseURL) {
// Already using this provider - preserve existing baseURL // Already using this provider - preserve existing baseURL
defaultBaseURL = currentModel.baseURL; defaultBaseURL = currentModel.baseURL;
} else if (config.provider === CUSTOM_PROVIDERS.AZURE && projectRoot) {
// For Azure, try to use the global azureBaseURL from config
defaultBaseURL = getAzureBaseURL(projectRoot) || '';
} else { } else {
// Switching providers or no existing baseURL - use fallback default // Switching providers or no existing baseURL - use fallback default
defaultBaseURL = config.defaultBaseURL || ''; defaultBaseURL = config.defaultBaseURL || '';

View File

@@ -69,7 +69,8 @@ async function handleSetModel(
const result = await handleCustomProvider( const result = await handleCustomProvider(
selectedValue, selectedValue,
role, role,
currentModel currentModel,
projectRoot
); );
if (!result.success) { if (!result.success) {
return { success: false, modified: false }; return { success: false, modified: false };

View File

@@ -160,3 +160,11 @@ export function writeConfig(config: any, projectRoot: string): boolean {
export function getAvailableModels(): ModelData[] { export function getAvailableModels(): ModelData[] {
return configManagerJs.getAvailableModels() as ModelData[]; return configManagerJs.getAvailableModels() as ModelData[];
} }
/**
* Get Azure base URL from config
*/
export function getAzureBaseURL(projectRoot?: string | null): string {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (configManagerJs.getAzureBaseURL(projectRoot as any) as string) ?? '';
}

View File

@@ -53,6 +53,10 @@
"capabilities/task-structure" "capabilities/task-structure"
] ]
}, },
{
"group": "AI Providers",
"pages": ["providers/azure"]
},
{ {
"group": "TDD Workflow (Autopilot)", "group": "TDD Workflow (Autopilot)",
"pages": [ "pages": [
@@ -79,8 +83,8 @@
} }
}, },
"logo": { "logo": {
"light": "/logo/task-master-logo.png", "light": "/logo/light.svg",
"dark": "/logo/task-master-logo.png" "dark": "/logo/dark.svg"
}, },
"footer": { "footer": {
"socials": { "socials": {

View File

@@ -1,9 +1,4 @@
<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"> <svg width="352" height="352" viewBox="0 0 352 352" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Blue form with check from logo --> <rect width="352" height="352" rx="128" fill="black"/>
<rect x="16" y="10" width="68" height="80" rx="9" fill="#3366CC"/> <path d="M167.347 293.799L142.176 268.619C137.272 263.713 137.272 255.773 142.176 250.866L201.479 191.54C203.11 189.908 205.756 189.908 207.398 191.54L229.609 213.76C231.24 215.392 233.886 215.392 235.527 213.76L240.881 208.404C242.513 206.772 242.513 204.125 240.881 202.483L218.67 180.263C217.039 178.631 217.039 175.984 218.67 174.342L242.168 150.835C243.799 149.203 243.799 146.557 242.168 144.914L234.701 137.445C233.07 135.813 230.424 135.813 228.782 137.445L129.136 237.131C124.232 242.037 116.295 242.037 111.39 237.131L86.1783 211.908C81.2739 207.002 81.2739 199.062 86.1783 194.156L102.397 177.93C104.029 176.298 104.029 173.651 102.397 172.009L86.492 156.097C81.5876 151.191 81.5876 143.251 86.492 138.345L111.485 113.332C116.389 108.425 124.326 108.425 129.23 113.332L145.575 129.683C147.206 131.315 149.852 131.315 151.494 129.683L186.138 95.0244C187.77 93.3925 187.77 90.7457 186.138 89.1033L178.891 81.8536C177.26 80.2217 174.614 80.2217 172.973 81.8536L165.872 88.9569C164.241 90.5888 161.595 90.5888 159.953 88.9569L151.075 80.0752C149.444 78.4433 149.444 75.7966 151.075 74.1541L167.043 58.1798C171.948 53.2734 179.885 53.2734 184.789 58.1798L209.792 83.1927C214.697 88.099 214.697 96.0392 209.792 100.945L151.483 159.278C149.852 160.91 147.206 160.91 145.564 159.278L123.301 137.006C121.67 135.374 119.024 135.374 117.382 137.006L110.136 144.255C108.504 145.887 108.504 148.534 110.136 150.176L131.96 172.009C133.591 173.641 133.591 176.288 131.96 177.93L109.832 200.066C108.201 201.698 108.201 204.345 109.832 205.987L117.299 213.457C118.93 215.089 121.576 215.089 123.218 213.457L222.864 113.771C227.768 108.865 235.705 108.865 240.609 113.771L265.822 138.993C270.726 143.9 270.726 151.84 265.822 156.746L248.243 174.331C246.612 175.963 246.612 178.61 248.243 180.253L264.535 196.551C269.44 201.458 269.44 209.398 264.535 214.304L241.425 237.423C236.521 242.33 228.584 242.33 223.679 237.423L207.387 221.125C205.756 219.493 203.11 219.493 201.468 221.125L165.82 256.787C164.189 258.419 164.189 261.066 165.82 262.708L173.245 270.136C174.876 271.768 177.521 271.768 179.163 270.136L185.145 264.152C186.776 262.52 189.422 262.52 191.064 264.152L199.942 273.034C201.573 274.666 201.573 277.312 199.942 278.955L185.082 293.82C180.178 298.727 172.241 298.727 167.336 293.82L167.347 293.799Z" fill="white"/>
<polyline points="33,44 41,55 56,29" fill="none" stroke="#FFFFFF" stroke-width="6"/>
<circle cx="33" cy="64" r="4" fill="#FFFFFF"/>
<rect x="43" y="61" width="27" height="6" fill="#FFFFFF"/>
<circle cx="33" cy="77" r="4" fill="#FFFFFF"/>
<rect x="43" y="75" width="27" height="6" fill="#FFFFFF"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 513 B

After

Width:  |  Height:  |  Size: 2.4 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 929 B

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 941 B

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -8,6 +8,6 @@
"preview": "mintlify preview" "preview": "mintlify preview"
}, },
"devDependencies": { "devDependencies": {
"mintlify": "^4.2.111" "mintlify": "^4.2.243"
} }
} }

View File

@@ -0,0 +1,159 @@
---
title: "Azure OpenAI"
sidebarTitle: "Azure OpenAI"
description: "Configure Task Master to use Azure OpenAI Service"
---
Azure OpenAI Service provides enterprise-grade access to OpenAI models through Microsoft Azure.
## Prerequisites
1. **Azure subscription** with access to Azure OpenAI Service
2. **Deployed model** in your Azure OpenAI resource
3. **API key** from your Azure OpenAI resource
## Quick Setup
### 1. Set Your API Key
Add your Azure OpenAI API key to your `.env` file:
```bash
AZURE_OPENAI_API_KEY="your-azure-openai-api-key"
```
### 2. Configure the Base URL
Set `azureBaseURL` in your `.taskmaster/config.json` under the `global` section:
```json
{
"global": {
"azureBaseURL": "https://your-resource.openai.azure.com/openai"
}
}
```
Or use the CLI:
```bash
task-master models --set-azure-base-url "https://your-resource.openai.azure.com/openai"
```
<Tip>
Task Master automatically appends `/openai` to your base URL if needed.
</Tip>
### 3. Set Azure as Your Provider
```bash
task-master models --set-main azure:gpt-4o
```
## Supported Models
Task Master supports Azure OpenAI models that use the Chat Completions API:
| Model | Notes |
|-------|-------|
| `gpt-4o` | Recommended for most use cases |
| `gpt-4o-mini` | Cost-effective option |
| `gpt-4-1` | GPT-4 Turbo |
<Note>
The `modelId` should match your Azure deployment name exactly. Azure deployment names are case-sensitive.
</Note>
## Configuration Examples
### Basic Configuration
```json
{
"models": {
"main": {
"provider": "azure",
"modelId": "gpt-4o",
"maxTokens": 16384,
"temperature": 0.2
}
},
"global": {
"azureBaseURL": "https://my-resource.openai.azure.com/openai"
}
}
```
### Role-Specific Base URLs
You can set different base URLs per role if you have deployments in different regions:
```json
{
"models": {
"main": {
"provider": "azure",
"modelId": "gpt-4o",
"baseURL": "https://us-east-resource.openai.azure.com/openai",
"maxTokens": 16384
},
"fallback": {
"provider": "azure",
"modelId": "gpt-4o-mini",
"baseURL": "https://eu-west-resource.openai.azure.com/openai",
"maxTokens": 16384
}
}
}
```
## MCP Server Configuration
For Claude Code integration, include your Azure configuration in `.mcp.json`:
```json
{
"mcpServers": {
"task-master-ai": {
"command": "npx",
"args": ["-y", "task-master-ai"],
"env": {
"AZURE_OPENAI_API_KEY": "your-azure-key-here"
}
}
}
}
```
## Troubleshooting
### "Azure endpoint URL is required"
Make sure you've set either:
- `global.azureBaseURL` in config
- `models.[role].baseURL` for the specific role
### "Invalid API key"
Verify your `AZURE_OPENAI_API_KEY` is correct and has access to the deployment.
### "Resource not found" (404)
1. Ensure the `modelId` matches your Azure deployment name exactly
2. Verify your base URL format is correct (should be `https://your-resource.openai.azure.com`)
## Getting Your Azure Credentials
### Azure Portal
1. Go to [Azure Portal](https://portal.azure.com)
2. Navigate to your Azure OpenAI resource
3. Under **Keys and Endpoint**, copy:
- One of the API keys
- The endpoint URL
### Creating a Deployment
1. In your Azure OpenAI resource, go to **Model deployments**
2. Click **Manage Deployments** to open Azure AI Studio
3. Create a new deployment and note the deployment name - this is your `modelId`

3734
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -60,7 +60,7 @@
"dependencies": { "dependencies": {
"@ai-sdk/amazon-bedrock": "^3.0.23", "@ai-sdk/amazon-bedrock": "^3.0.23",
"@ai-sdk/anthropic": "^2.0.18", "@ai-sdk/anthropic": "^2.0.18",
"@ai-sdk/azure": "^2.0.34", "@ai-sdk/azure": "^2.0.89",
"@ai-sdk/google": "^2.0.16", "@ai-sdk/google": "^2.0.16",
"@ai-sdk/google-vertex": "^3.0.86", "@ai-sdk/google-vertex": "^3.0.86",
"@ai-sdk/groq": "^2.0.21", "@ai-sdk/groq": "^2.0.21",

View File

@@ -13,7 +13,11 @@ export const VALIDATED_PROVIDERS = [
'perplexity', 'perplexity',
'xai', 'xai',
'groq', 'groq',
'mistral' 'mistral',
'azure',
'openrouter',
'bedrock',
'ollama'
] as const; ] as const;
export type ValidatedProvider = (typeof VALIDATED_PROVIDERS)[number]; export type ValidatedProvider = (typeof VALIDATED_PROVIDERS)[number];
@@ -42,8 +46,5 @@ export const CUSTOM_PROVIDERS_ARRAY = Object.values(CUSTOM_PROVIDERS);
// All known providers (for reference) // All known providers (for reference)
export const ALL_PROVIDERS = [ export const ALL_PROVIDERS = [
...VALIDATED_PROVIDERS, ...new Set([...VALIDATED_PROVIDERS, ...CUSTOM_PROVIDERS_ARRAY])
...CUSTOM_PROVIDERS_ARRAY ];
] as const;
export type Provider = ValidatedProvider | CustomProvider;

View File

@@ -1232,6 +1232,100 @@
"allowed_roles": ["main", "fallback"], "allowed_roles": ["main", "fallback"],
"max_tokens": 16384, "max_tokens": 16384,
"supported": true "supported": true
},
{
"id": "gpt-5",
"name": "GPT-5",
"swe_score": 0.749,
"cost_per_1m_tokens": {
"input": 5.0,
"output": 20.0
},
"allowed_roles": ["main", "fallback"],
"max_tokens": 100000,
"temperature": 1,
"supported": true,
"api_type": "responses"
},
{
"id": "o1",
"name": "o1",
"swe_score": 0.489,
"cost_per_1m_tokens": {
"input": 15.0,
"output": 60.0
},
"allowed_roles": ["main"],
"max_tokens": 100000,
"supported": true,
"api_type": "responses"
},
{
"id": "o3",
"name": "o3",
"swe_score": 0.5,
"cost_per_1m_tokens": {
"input": 2.0,
"output": 8.0
},
"allowed_roles": ["main", "fallback"],
"max_tokens": 100000,
"supported": true,
"api_type": "responses"
},
{
"id": "o3-mini",
"name": "o3-mini",
"swe_score": 0.493,
"cost_per_1m_tokens": {
"input": 1.1,
"output": 4.4
},
"allowed_roles": ["main"],
"max_tokens": 100000,
"supported": true,
"api_type": "responses"
},
{
"id": "o4-mini",
"name": "o4-mini",
"swe_score": 0.45,
"cost_per_1m_tokens": {
"input": 1.1,
"output": 4.4
},
"allowed_roles": ["main", "fallback"],
"max_tokens": 100000,
"supported": true,
"api_type": "responses"
},
{
"id": "gpt-5.1",
"name": "GPT-5.1",
"swe_score": 0.76,
"cost_per_1m_tokens": {
"input": 1.25,
"output": 10.0
},
"allowed_roles": ["main", "fallback"],
"max_tokens": 128000,
"reasoning_efforts": ["none", "low", "medium", "high"],
"supported": true,
"api_type": "responses"
},
{
"id": "gpt-5.2",
"name": "GPT-5.2",
"swe_score": 0.8,
"cost_per_1m_tokens": {
"input": 1.75,
"output": 14.0
},
"allowed_roles": ["main", "fallback"],
"max_tokens": 128000,
"reasoning_efforts": ["none", "low", "medium", "high", "xhigh"],
"supported": true,
"api_type": "responses"
} }
], ],
"bedrock": [ "bedrock": [

View File

@@ -551,8 +551,33 @@ async function setModel(role, modelId, options = {}) {
} else if (providerHint === CUSTOM_PROVIDERS.AZURE) { } else if (providerHint === CUSTOM_PROVIDERS.AZURE) {
// Set provider without model validation since Azure models are managed by Azure // Set provider without model validation since Azure models are managed by Azure
determinedProvider = CUSTOM_PROVIDERS.AZURE; determinedProvider = CUSTOM_PROVIDERS.AZURE;
warningMessage = `Warning: Custom Azure model '${modelId}' set. Please ensure the model deployment is valid and accessible in your Azure account.`;
// Get current provider for this role to check if we should preserve baseURL
let currentProvider;
if (role === 'main') {
currentProvider = getMainProvider(projectRoot);
} else if (role === 'research') {
currentProvider = getResearchProvider(projectRoot);
} else if (role === 'fallback') {
currentProvider = getFallbackProvider(projectRoot);
}
// Only preserve baseURL if we're already using AZURE
const existingBaseURL =
currentProvider === CUSTOM_PROVIDERS.AZURE
? getBaseUrlForRole(role, projectRoot)
: null;
const resolvedBaseURL = baseURL || existingBaseURL;
if (!resolvedBaseURL) {
throw new Error(
`Base URL is required for Azure providers. Please provide a baseURL or set global.azureBaseURL in config.`
);
}
warningMessage = `Warning: Custom Azure model '${modelId}' set with base URL '${resolvedBaseURL}'. Please ensure the model deployment is valid and accessible in your Azure account.`;
report('warn', warningMessage); report('warn', warningMessage);
// Store the computed baseURL so it gets saved in config
computedBaseURL = resolvedBaseURL;
} else if (providerHint === CUSTOM_PROVIDERS.VERTEX) { } else if (providerHint === CUSTOM_PROVIDERS.VERTEX) {
// Set provider without model validation since Vertex models are managed by Google Cloud // Set provider without model validation since Vertex models are managed by Google Cloud
determinedProvider = CUSTOM_PROVIDERS.VERTEX; determinedProvider = CUSTOM_PROVIDERS.VERTEX;
@@ -700,7 +725,8 @@ async function setModel(role, modelId, options = {}) {
computedBaseURL && computedBaseURL &&
(determinedProvider === CUSTOM_PROVIDERS.OPENAI_COMPATIBLE || (determinedProvider === CUSTOM_PROVIDERS.OPENAI_COMPATIBLE ||
determinedProvider === CUSTOM_PROVIDERS.LMSTUDIO || determinedProvider === CUSTOM_PROVIDERS.LMSTUDIO ||
determinedProvider === CUSTOM_PROVIDERS.OLLAMA) determinedProvider === CUSTOM_PROVIDERS.OLLAMA ||
determinedProvider === CUSTOM_PROVIDERS.AZURE)
) { ) {
currentConfig.models[role].baseURL = computedBaseURL; currentConfig.models[role].baseURL = computedBaseURL;
} else { } else {

View File

@@ -1,6 +1,6 @@
/** /**
* azure.js * azure.js
* AI provider implementation for Azure OpenAI models using Vercel AI SDK. * AI provider implementation for Azure OpenAI Service using Vercel AI SDK.
*/ */
import { createAzure } from '@ai-sdk/azure'; import { createAzure } from '@ai-sdk/azure';
@@ -37,6 +37,35 @@ export class AzureProvider extends BaseAIProvider {
} }
} }
/**
* Normalizes the base URL to ensure it ends with /openai for proper Azure API routing.
* The Azure API expects paths like /openai/deployments/{model}/chat/completions
* @param {string} baseURL - Original base URL
* @returns {string} Normalized base URL ending with /openai
*/
normalizeBaseURL(baseURL) {
if (!baseURL) return baseURL;
try {
const url = new URL(baseURL);
let pathname = url.pathname.replace(/\/+$/, ''); // Remove trailing slashes
// If the path doesn't end with /openai, append it
if (!pathname.endsWith('/openai')) {
pathname = `${pathname}/openai`;
}
url.pathname = pathname;
return url.toString();
} catch {
// Fallback for invalid URLs
const normalized = baseURL.replace(/\/+$/, '');
return normalized.endsWith('/openai')
? normalized
: `${normalized}/openai`;
}
}
/** /**
* Creates and returns an Azure OpenAI client instance. * Creates and returns an Azure OpenAI client instance.
* @param {object} params - Parameters for client initialization * @param {object} params - Parameters for client initialization
@@ -48,11 +77,14 @@ export class AzureProvider extends BaseAIProvider {
getClient(params) { getClient(params) {
try { try {
const { apiKey, baseURL } = params; const { apiKey, baseURL } = params;
// Normalize base URL to ensure it ends with /openai
const normalizedBaseURL = this.normalizeBaseURL(baseURL);
const fetchImpl = this.createProxyFetch(); const fetchImpl = this.createProxyFetch();
return createAzure({ return createAzure({
apiKey, apiKey,
baseURL, baseURL: normalizedBaseURL,
...(fetchImpl && { fetch: fetchImpl }) ...(fetchImpl && { fetch: fetchImpl })
}); });
} catch (error) { } catch (error) {

View File

@@ -221,7 +221,7 @@ jest.unstable_mockModule('../../src/ai-providers/index.js', () => ({
generateText: jest.fn(), generateText: jest.fn(),
streamText: jest.fn(), streamText: jest.fn(),
generateObject: jest.fn(), generateObject: jest.fn(),
getRequiredApiKeyName: jest.fn(() => 'AZURE_API_KEY'), getRequiredApiKeyName: jest.fn(() => 'AZURE_OPENAI_API_KEY'),
isRequiredApiKey: jest.fn(() => true) isRequiredApiKey: jest.fn(() => true)
})), })),
VertexAIProvider: jest.fn(() => ({ VertexAIProvider: jest.fn(() => ({