Skip to content

Commit 6e9abec

Browse files
committed
fix: improve Azure OpenAI connectivity and reasoning model support
- Use azureApiVersion as primary indicator for Azure detection (like Cline) - Add URL extraction method to handle Azure URLs with query parameters - Fix reasoning model support for Azure o1/o3/o4 models by handling system messages - Add better error messages for Azure-specific issues Fixes #1334
1 parent bcad858 commit 6e9abec

File tree

1 file changed

+161
-36
lines changed

1 file changed

+161
-36
lines changed

src/api/providers/openai.ts

Lines changed: 161 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -35,40 +35,64 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
3535
super()
3636
this.options = options
3737

38-
const baseURL = this.options.openAiBaseUrl ?? "https://api.openai.com/v1"
3938
const apiKey = this.options.openAiApiKey ?? "not-provided"
4039
const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl)
4140
const urlHost = this._getUrlHost(this.options.openAiBaseUrl)
42-
const isAzureOpenAi = urlHost === "azure.com" || urlHost.endsWith(".azure.com") || options.openAiUseAzure
41+
// Use azureApiVersion as primary indicator (like Cline), then fall back to URL patterns
42+
const isAzureOpenAi =
43+
!!this.options.azureApiVersion ||
44+
urlHost === "azure.com" ||
45+
urlHost.endsWith(".azure.com") ||
46+
options.openAiUseAzure
47+
48+
// Extract base URL for Azure endpoints that might include full paths
49+
let baseURL = this.options.openAiBaseUrl ?? "https://api.openai.com/v1"
50+
if (isAzureOpenAi && this.options.openAiBaseUrl) {
51+
baseURL = this._extractAzureBaseUrl(this.options.openAiBaseUrl)
52+
}
4353

4454
const headers = {
4555
...DEFAULT_HEADERS,
4656
...(this.options.openAiHeaders || {}),
4757
}
4858

49-
if (isAzureAiInference) {
50-
// Azure AI Inference Service (e.g., for DeepSeek) uses a different path structure
51-
this.client = new OpenAI({
52-
baseURL,
53-
apiKey,
54-
defaultHeaders: headers,
55-
defaultQuery: { "api-version": this.options.azureApiVersion || "2024-05-01-preview" },
56-
})
57-
} else if (isAzureOpenAi) {
58-
// Azure API shape slightly differs from the core API shape:
59-
// https://github.com/openai/openai-node?tab=readme-ov-file#microsoft-azure-openai
60-
this.client = new AzureOpenAI({
61-
baseURL,
62-
apiKey,
63-
apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion,
64-
defaultHeaders: headers,
65-
})
66-
} else {
67-
this.client = new OpenAI({
68-
baseURL,
69-
apiKey,
70-
defaultHeaders: headers,
71-
})
59+
try {
60+
if (isAzureAiInference) {
61+
// Azure AI Inference Service (e.g., for DeepSeek) uses a different path structure
62+
this.client = new OpenAI({
63+
baseURL,
64+
apiKey,
65+
defaultHeaders: headers,
66+
defaultQuery: { "api-version": this.options.azureApiVersion || "2024-05-01-preview" },
67+
})
68+
} else if (isAzureOpenAi) {
69+
// Azure API shape slightly differs from the core API shape:
70+
// https://github.com/openai/openai-node?tab=readme-ov-file#microsoft-azure-openai
71+
this.client = new AzureOpenAI({
72+
baseURL,
73+
apiKey,
74+
apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion,
75+
defaultHeaders: headers,
76+
})
77+
} else {
78+
this.client = new OpenAI({
79+
baseURL,
80+
apiKey,
81+
defaultHeaders: headers,
82+
})
83+
}
84+
} catch (error) {
85+
const errorMessage = error instanceof Error ? error.message : String(error)
86+
if (isAzureOpenAi) {
87+
throw new Error(
88+
`Failed to initialize Azure OpenAI client: ${errorMessage}\n` +
89+
`Please ensure:\n` +
90+
`1. Your base URL is correct (e.g., https://myresource.openai.azure.com)\n` +
91+
`2. Your API key is valid\n` +
92+
`3. If using a full endpoint URL, try using just the base URL instead`,
93+
)
94+
}
95+
throw new Error(`Failed to initialize OpenAI client: ${errorMessage}`)
7296
}
7397
}
7498

@@ -86,9 +110,33 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
86110
const deepseekReasoner = modelId.includes("deepseek-reasoner") || enabledR1Format
87111
const ark = modelUrl.includes(".volces.com")
88112

113+
// Check if this is an Azure OpenAI endpoint
114+
const urlHost = this._getUrlHost(this.options.openAiBaseUrl)
115+
const isAzureOpenAi =
116+
!!this.options.azureApiVersion ||
117+
urlHost === "azure.com" ||
118+
urlHost.endsWith(".azure.com") ||
119+
!!this.options.openAiUseAzure
120+
89121
if (modelId.includes("o1") || modelId.includes("o3") || modelId.includes("o4")) {
90-
yield* this.handleO3FamilyMessage(modelId, systemPrompt, messages)
91-
return
122+
try {
123+
yield* this.handleO3FamilyMessage(modelId, systemPrompt, messages, isAzureOpenAi)
124+
return
125+
} catch (error) {
126+
if (isAzureOpenAi && error instanceof Error) {
127+
// Check for common Azure-specific errors
128+
if (
129+
error.message.includes("does not support 'system'") ||
130+
error.message.includes("does not support 'developer'")
131+
) {
132+
throw new Error(
133+
`Azure OpenAI reasoning model error: ${error.message}\n` +
134+
`This has been fixed in the latest version. Please ensure you're using the updated code.`,
135+
)
136+
}
137+
}
138+
throw error
139+
}
92140
}
93141

94142
if (this.options.openAiStreamingEnabled ?? true) {
@@ -287,22 +335,51 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
287335
modelId: string,
288336
systemPrompt: string,
289337
messages: Anthropic.Messages.MessageParam[],
338+
isAzureOpenAi: boolean,
290339
): ApiStream {
291340
const modelInfo = this.getModel().info
292341
const methodIsAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl)
293342

294343
if (this.options.openAiStreamingEnabled ?? true) {
295344
const isGrokXAI = this._isGrokXAI(this.options.openAiBaseUrl)
296345

297-
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
298-
model: modelId,
299-
messages: [
346+
// Azure doesn't support "developer" role, so we need to combine system prompt with first user message
347+
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[]
348+
if (isAzureOpenAi) {
349+
const convertedMessages = convertToOpenAiMessages(messages)
350+
if (convertedMessages.length > 0 && convertedMessages[0].role === "user") {
351+
// Combine system prompt with first user message
352+
openAiMessages = [
353+
{
354+
role: "user",
355+
content: `${systemPrompt}\n\n${convertedMessages[0].content}`,
356+
},
357+
...convertedMessages.slice(1),
358+
]
359+
} else {
360+
// If first message isn't a user message, add system prompt as first user message
361+
openAiMessages = [
362+
{
363+
role: "user",
364+
content: systemPrompt,
365+
},
366+
...convertedMessages,
367+
]
368+
}
369+
} else {
370+
// Non-Azure endpoints support "developer" role
371+
openAiMessages = [
300372
{
301373
role: "developer",
302374
content: `Formatting re-enabled\n${systemPrompt}`,
303375
},
304376
...convertToOpenAiMessages(messages),
305-
],
377+
]
378+
}
379+
380+
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
381+
model: modelId,
382+
messages: openAiMessages,
306383
stream: true,
307384
...(isGrokXAI ? {} : { stream_options: { include_usage: true } }),
308385
reasoning_effort: modelInfo.reasoningEffort,
@@ -321,15 +398,43 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
321398

322399
yield* this.handleStreamResponse(stream)
323400
} else {
324-
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {
325-
model: modelId,
326-
messages: [
401+
// Azure doesn't support "developer" role, so we need to combine system prompt with first user message
402+
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[]
403+
if (isAzureOpenAi) {
404+
const convertedMessages = convertToOpenAiMessages(messages)
405+
if (convertedMessages.length > 0 && convertedMessages[0].role === "user") {
406+
// Combine system prompt with first user message
407+
openAiMessages = [
408+
{
409+
role: "user",
410+
content: `${systemPrompt}\n\n${convertedMessages[0].content}`,
411+
},
412+
...convertedMessages.slice(1),
413+
]
414+
} else {
415+
// If first message isn't a user message, add system prompt as first user message
416+
openAiMessages = [
417+
{
418+
role: "user",
419+
content: systemPrompt,
420+
},
421+
...convertedMessages,
422+
]
423+
}
424+
} else {
425+
// Non-Azure endpoints support "developer" role
426+
openAiMessages = [
327427
{
328428
role: "developer",
329429
content: `Formatting re-enabled\n${systemPrompt}`,
330430
},
331431
...convertToOpenAiMessages(messages),
332-
],
432+
]
433+
}
434+
435+
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {
436+
model: modelId,
437+
messages: openAiMessages,
333438
reasoning_effort: modelInfo.reasoningEffort,
334439
temperature: undefined,
335440
}
@@ -374,12 +479,32 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
374479

375480
private _getUrlHost(baseUrl?: string): string {
376481
try {
377-
return new URL(baseUrl ?? "").host
482+
// Extract base URL without query parameters for proper host detection
483+
const url = new URL(baseUrl ?? "")
484+
return url.host
378485
} catch (error) {
379486
return ""
380487
}
381488
}
382489

490+
/**
491+
* Extracts the base URL from a full Azure endpoint URL
492+
* e.g., "https://myresource.openai.azure.com/openai/deployments/gpt-4/chat/completions?api-version=2024-08-01-preview"
493+
* becomes "https://myresource.openai.azure.com"
494+
*/
495+
private _extractAzureBaseUrl(fullUrl: string): string {
496+
try {
497+
const url = new URL(fullUrl)
498+
// For Azure OpenAI, we want just the origin (protocol + host)
499+
if (url.host.includes("azure.com")) {
500+
return url.origin
501+
}
502+
return fullUrl
503+
} catch {
504+
return fullUrl
505+
}
506+
}
507+
383508
private _isGrokXAI(baseUrl?: string): boolean {
384509
const urlHost = this._getUrlHost(baseUrl)
385510
return urlHost.includes("x.ai")

0 commit comments

Comments
 (0)