@@ -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