mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
feat: improve Azure OpenAI compatibility
- Enhanced Azure detection logic to match Cline implementation - Added URL extraction for Azure endpoints with query parameters - Fixed reasoning model handling for Azure (o1, o3, o4) - Added specific error messages for common Azure issues - Addresses issue #1334
This commit is contained in:
parent
2eb586b422
commit
2370614ca0
1 changed files with 252 additions and 153 deletions
|
|
@ -39,7 +39,16 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
const apiKey = this.options.openAiApiKey ?? "not-provided"
|
||||
const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl)
|
||||
const urlHost = this._getUrlHost(this.options.openAiBaseUrl)
|
||||
const isAzureOpenAi = urlHost === "azure.com" || urlHost.endsWith(".azure.com") || options.openAiUseAzure
|
||||
|
||||
// Improved Azure detection logic similar to Cline
|
||||
// Primary indicator is azureApiVersion, then check URL patterns
|
||||
const isAzureOpenAi = !!(
|
||||
this.options.azureApiVersion ||
|
||||
options.openAiUseAzure ||
|
||||
(urlHost &&
|
||||
(urlHost.includes("azure.com") || urlHost.includes("azure.us")) &&
|
||||
!this.options.openAiModelId?.toLowerCase().includes("deepseek"))
|
||||
)
|
||||
|
||||
const headers = {
|
||||
...DEFAULT_HEADERS,
|
||||
|
|
@ -57,8 +66,11 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
} else if (isAzureOpenAi) {
|
||||
// Azure API shape slightly differs from the core API shape:
|
||||
// https://github.com/openai/openai-node?tab=readme-ov-file#microsoft-azure-openai
|
||||
// Extract base URL without query parameters for Azure client
|
||||
const cleanBaseURL = this._extractBaseUrl(baseURL)
|
||||
|
||||
this.client = new AzureOpenAI({
|
||||
baseURL,
|
||||
baseURL: cleanBaseURL,
|
||||
apiKey,
|
||||
apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion,
|
||||
defaultHeaders: headers,
|
||||
|
|
@ -77,163 +89,191 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
messages: Anthropic.Messages.MessageParam[],
|
||||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): ApiStream {
|
||||
const { info: modelInfo, reasoning } = this.getModel()
|
||||
const modelUrl = this.options.openAiBaseUrl ?? ""
|
||||
const modelId = this.options.openAiModelId ?? ""
|
||||
const enabledR1Format = this.options.openAiR1FormatEnabled ?? false
|
||||
const enabledLegacyFormat = this.options.openAiLegacyFormat ?? false
|
||||
const isAzureAiInference = this._isAzureAiInference(modelUrl)
|
||||
const deepseekReasoner = modelId.includes("deepseek-reasoner") || enabledR1Format
|
||||
const ark = modelUrl.includes(".volces.com")
|
||||
try {
|
||||
const { info: modelInfo, reasoning } = this.getModel()
|
||||
const modelUrl = this.options.openAiBaseUrl ?? ""
|
||||
const modelId = this.options.openAiModelId ?? ""
|
||||
const enabledR1Format = this.options.openAiR1FormatEnabled ?? false
|
||||
const enabledLegacyFormat = this.options.openAiLegacyFormat ?? false
|
||||
const isAzureAiInference = this._isAzureAiInference(modelUrl)
|
||||
const deepseekReasoner = modelId.includes("deepseek-reasoner") || enabledR1Format
|
||||
const ark = modelUrl.includes(".volces.com")
|
||||
|
||||
if (modelId.includes("o1") || modelId.includes("o3") || modelId.includes("o4")) {
|
||||
yield* this.handleO3FamilyMessage(modelId, systemPrompt, messages)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.options.openAiStreamingEnabled ?? true) {
|
||||
let systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = {
|
||||
role: "system",
|
||||
content: systemPrompt,
|
||||
// Handle reasoning models (o1, o3, o4) separately
|
||||
// These models don't support system messages in the traditional way
|
||||
if (modelId.includes("o1") || modelId.includes("o3") || modelId.includes("o4")) {
|
||||
yield* this.handleO3FamilyMessage(modelId, systemPrompt, messages)
|
||||
return
|
||||
}
|
||||
|
||||
let convertedMessages
|
||||
|
||||
if (deepseekReasoner) {
|
||||
convertedMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
} else if (ark || enabledLegacyFormat) {
|
||||
convertedMessages = [systemMessage, ...convertToSimpleMessages(messages)]
|
||||
} else {
|
||||
if (modelInfo.supportsPromptCache) {
|
||||
systemMessage = {
|
||||
role: "system",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: systemPrompt,
|
||||
// @ts-ignore-next-line
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
],
|
||||
}
|
||||
if (this.options.openAiStreamingEnabled ?? true) {
|
||||
let systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = {
|
||||
role: "system",
|
||||
content: systemPrompt,
|
||||
}
|
||||
|
||||
convertedMessages = [systemMessage, ...convertToOpenAiMessages(messages)]
|
||||
let convertedMessages
|
||||
|
||||
if (modelInfo.supportsPromptCache) {
|
||||
// Note: the following logic is copied from openrouter:
|
||||
// Add cache_control to the last two user messages
|
||||
// (note: this works because we only ever add one user message at a time, but if we added multiple we'd need to mark the user message before the last assistant message)
|
||||
const lastTwoUserMessages = convertedMessages.filter((msg) => msg.role === "user").slice(-2)
|
||||
|
||||
lastTwoUserMessages.forEach((msg) => {
|
||||
if (typeof msg.content === "string") {
|
||||
msg.content = [{ type: "text", text: msg.content }]
|
||||
if (deepseekReasoner) {
|
||||
convertedMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
} else if (ark || enabledLegacyFormat) {
|
||||
convertedMessages = [systemMessage, ...convertToSimpleMessages(messages)]
|
||||
} else {
|
||||
if (modelInfo.supportsPromptCache) {
|
||||
systemMessage = {
|
||||
role: "system",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: systemPrompt,
|
||||
// @ts-ignore-next-line
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(msg.content)) {
|
||||
// NOTE: this is fine since env details will always be added at the end. but if it weren't there, and the user added a image_url type message, it would pop a text part before it and then move it after to the end.
|
||||
let lastTextPart = msg.content.filter((part) => part.type === "text").pop()
|
||||
convertedMessages = [systemMessage, ...convertToOpenAiMessages(messages)]
|
||||
|
||||
if (!lastTextPart) {
|
||||
lastTextPart = { type: "text", text: "..." }
|
||||
msg.content.push(lastTextPart)
|
||||
if (modelInfo.supportsPromptCache) {
|
||||
// Note: the following logic is copied from openrouter:
|
||||
// Add cache_control to the last two user messages
|
||||
// (note: this works because we only ever add one user message at a time, but if we added multiple we'd need to mark the user message before the last assistant message)
|
||||
const lastTwoUserMessages = convertedMessages.filter((msg) => msg.role === "user").slice(-2)
|
||||
|
||||
lastTwoUserMessages.forEach((msg) => {
|
||||
if (typeof msg.content === "string") {
|
||||
msg.content = [{ type: "text", text: msg.content }]
|
||||
}
|
||||
|
||||
// @ts-ignore-next-line
|
||||
lastTextPart["cache_control"] = { type: "ephemeral" }
|
||||
if (Array.isArray(msg.content)) {
|
||||
// NOTE: this is fine since env details will always be added at the end. but if it weren't there, and the user added a image_url type message, it would pop a text part before it and then move it after to the end.
|
||||
let lastTextPart = msg.content.filter((part) => part.type === "text").pop()
|
||||
|
||||
if (!lastTextPart) {
|
||||
lastTextPart = { type: "text", text: "..." }
|
||||
msg.content.push(lastTextPart)
|
||||
}
|
||||
|
||||
// @ts-ignore-next-line
|
||||
lastTextPart["cache_control"] = { type: "ephemeral" }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const isGrokXAI = this._isGrokXAI(this.options.openAiBaseUrl)
|
||||
|
||||
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
|
||||
model: modelId,
|
||||
temperature:
|
||||
this.options.modelTemperature ?? (deepseekReasoner ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0),
|
||||
messages: convertedMessages,
|
||||
stream: true as const,
|
||||
...(isGrokXAI ? {} : { stream_options: { include_usage: true } }),
|
||||
...(reasoning && reasoning),
|
||||
}
|
||||
|
||||
// Add max_tokens if needed
|
||||
this.addMaxTokensIfNeeded(requestOptions, modelInfo)
|
||||
|
||||
const stream = await this.client.chat.completions.create(
|
||||
requestOptions,
|
||||
isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {},
|
||||
)
|
||||
|
||||
const matcher = new XmlMatcher(
|
||||
"think",
|
||||
(chunk) =>
|
||||
({
|
||||
type: chunk.matched ? "reasoning" : "text",
|
||||
text: chunk.data,
|
||||
}) as const,
|
||||
)
|
||||
|
||||
let lastUsage
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta ?? {}
|
||||
|
||||
if (delta.content) {
|
||||
for (const chunk of matcher.update(delta.content)) {
|
||||
yield chunk
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isGrokXAI = this._isGrokXAI(this.options.openAiBaseUrl)
|
||||
|
||||
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
|
||||
model: modelId,
|
||||
temperature: this.options.modelTemperature ?? (deepseekReasoner ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0),
|
||||
messages: convertedMessages,
|
||||
stream: true as const,
|
||||
...(isGrokXAI ? {} : { stream_options: { include_usage: true } }),
|
||||
...(reasoning && reasoning),
|
||||
}
|
||||
|
||||
// Add max_tokens if needed
|
||||
this.addMaxTokensIfNeeded(requestOptions, modelInfo)
|
||||
|
||||
const stream = await this.client.chat.completions.create(
|
||||
requestOptions,
|
||||
isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {},
|
||||
)
|
||||
|
||||
const matcher = new XmlMatcher(
|
||||
"think",
|
||||
(chunk) =>
|
||||
({
|
||||
type: chunk.matched ? "reasoning" : "text",
|
||||
text: chunk.data,
|
||||
}) as const,
|
||||
)
|
||||
|
||||
let lastUsage
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta ?? {}
|
||||
|
||||
if (delta.content) {
|
||||
for (const chunk of matcher.update(delta.content)) {
|
||||
yield chunk
|
||||
if ("reasoning_content" in delta && delta.reasoning_content) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
text: (delta.reasoning_content as string | undefined) || "",
|
||||
}
|
||||
}
|
||||
if (chunk.usage) {
|
||||
lastUsage = chunk.usage
|
||||
}
|
||||
}
|
||||
|
||||
if ("reasoning_content" in delta && delta.reasoning_content) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
text: (delta.reasoning_content as string | undefined) || "",
|
||||
}
|
||||
for (const chunk of matcher.final()) {
|
||||
yield chunk
|
||||
}
|
||||
if (chunk.usage) {
|
||||
lastUsage = chunk.usage
|
||||
|
||||
if (lastUsage) {
|
||||
yield this.processUsageMetrics(lastUsage, modelInfo)
|
||||
}
|
||||
} else {
|
||||
// o1 for instance doesnt support streaming, non-1 temp, or system prompt
|
||||
const systemMessage: OpenAI.Chat.ChatCompletionUserMessageParam = {
|
||||
role: "user",
|
||||
content: systemPrompt,
|
||||
}
|
||||
|
||||
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {
|
||||
model: modelId,
|
||||
messages: deepseekReasoner
|
||||
? convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
: enabledLegacyFormat
|
||||
? [systemMessage, ...convertToSimpleMessages(messages)]
|
||||
: [systemMessage, ...convertToOpenAiMessages(messages)],
|
||||
}
|
||||
|
||||
// Add max_tokens if needed
|
||||
this.addMaxTokensIfNeeded(requestOptions, modelInfo)
|
||||
|
||||
const response = await this.client.chat.completions.create(
|
||||
requestOptions,
|
||||
this._isAzureAiInference(modelUrl) ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {},
|
||||
)
|
||||
|
||||
yield {
|
||||
type: "text",
|
||||
text: response.choices[0]?.message.content || "",
|
||||
}
|
||||
|
||||
yield this.processUsageMetrics(response.usage, modelInfo)
|
||||
}
|
||||
|
||||
for (const chunk of matcher.final()) {
|
||||
yield chunk
|
||||
} catch (error: any) {
|
||||
// Handle Azure-specific errors
|
||||
if (error?.status === 400 && error?.message?.includes("does not support 'system'")) {
|
||||
throw new Error(
|
||||
`Azure OpenAI error: This model does not support system messages. ` +
|
||||
`For reasoning models (o1, o3, o4), please ensure the model ID is correctly set. ` +
|
||||
`Current model: ${this.options.openAiModelId}`,
|
||||
)
|
||||
} else if (error?.status === 404) {
|
||||
throw new Error(
|
||||
`Azure OpenAI error: Resource not found. Please check: ` +
|
||||
`1) Your deployment name is correct, ` +
|
||||
`2) The base URL format is correct (e.g., https://YOUR-RESOURCE.openai.azure.com), ` +
|
||||
`3) The API version is supported (current: ${this.options.azureApiVersion || azureOpenAiDefaultApiVersion})`,
|
||||
)
|
||||
} else if (error?.message?.includes("api-key")) {
|
||||
throw new Error(
|
||||
`Azure OpenAI authentication error: Invalid API key. ` +
|
||||
`Please ensure you're using the correct Azure OpenAI API key from your Azure portal.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (lastUsage) {
|
||||
yield this.processUsageMetrics(lastUsage, modelInfo)
|
||||
}
|
||||
} else {
|
||||
// o1 for instance doesnt support streaming, non-1 temp, or system prompt
|
||||
const systemMessage: OpenAI.Chat.ChatCompletionUserMessageParam = {
|
||||
role: "user",
|
||||
content: systemPrompt,
|
||||
}
|
||||
|
||||
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {
|
||||
model: modelId,
|
||||
messages: deepseekReasoner
|
||||
? convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
: enabledLegacyFormat
|
||||
? [systemMessage, ...convertToSimpleMessages(messages)]
|
||||
: [systemMessage, ...convertToOpenAiMessages(messages)],
|
||||
}
|
||||
|
||||
// Add max_tokens if needed
|
||||
this.addMaxTokensIfNeeded(requestOptions, modelInfo)
|
||||
|
||||
const response = await this.client.chat.completions.create(
|
||||
requestOptions,
|
||||
this._isAzureAiInference(modelUrl) ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {},
|
||||
)
|
||||
|
||||
yield {
|
||||
type: "text",
|
||||
text: response.choices[0]?.message.content || "",
|
||||
}
|
||||
|
||||
yield this.processUsageMetrics(response.usage, modelInfo)
|
||||
// Re-throw other errors as-is
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -290,19 +330,39 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
): ApiStream {
|
||||
const modelInfo = this.getModel().info
|
||||
const methodIsAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl)
|
||||
const urlHost = this._getUrlHost(this.options.openAiBaseUrl)
|
||||
const isAzureOpenAi = !!(
|
||||
this.options.azureApiVersion ||
|
||||
this.options.openAiUseAzure ||
|
||||
(urlHost && (urlHost.includes("azure.com") || urlHost.includes("azure.us")))
|
||||
)
|
||||
|
||||
if (this.options.openAiStreamingEnabled ?? true) {
|
||||
const isGrokXAI = this._isGrokXAI(this.options.openAiBaseUrl)
|
||||
|
||||
// For Azure OpenAI, we need to handle the system prompt differently
|
||||
// Azure's o1/o3 models don't support the "developer" role
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = isAzureOpenAi
|
||||
? [
|
||||
// For Azure, combine system prompt with first user message
|
||||
{
|
||||
role: "user",
|
||||
content: systemPrompt + "\n\n" + (messages[0]?.content || ""),
|
||||
},
|
||||
...convertToOpenAiMessages(messages.slice(1)),
|
||||
]
|
||||
: [
|
||||
// For standard OpenAI, use developer role
|
||||
{
|
||||
role: "developer",
|
||||
content: `Formatting re-enabled\n${systemPrompt}`,
|
||||
},
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
|
||||
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
|
||||
model: modelId,
|
||||
messages: [
|
||||
{
|
||||
role: "developer",
|
||||
content: `Formatting re-enabled\n${systemPrompt}`,
|
||||
},
|
||||
...convertToOpenAiMessages(messages),
|
||||
],
|
||||
messages: openAiMessages,
|
||||
stream: true,
|
||||
...(isGrokXAI ? {} : { stream_options: { include_usage: true } }),
|
||||
reasoning_effort: modelInfo.reasoningEffort,
|
||||
|
|
@ -321,15 +381,28 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
|
||||
yield* this.handleStreamResponse(stream)
|
||||
} else {
|
||||
// For Azure OpenAI, we need to handle the system prompt differently
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = isAzureOpenAi
|
||||
? [
|
||||
// For Azure, combine system prompt with first user message
|
||||
{
|
||||
role: "user",
|
||||
content: systemPrompt + "\n\n" + (messages[0]?.content || ""),
|
||||
},
|
||||
...convertToOpenAiMessages(messages.slice(1)),
|
||||
]
|
||||
: [
|
||||
// For standard OpenAI, use developer role
|
||||
{
|
||||
role: "developer",
|
||||
content: `Formatting re-enabled\n${systemPrompt}`,
|
||||
},
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
|
||||
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {
|
||||
model: modelId,
|
||||
messages: [
|
||||
{
|
||||
role: "developer",
|
||||
content: `Formatting re-enabled\n${systemPrompt}`,
|
||||
},
|
||||
...convertToOpenAiMessages(messages),
|
||||
],
|
||||
messages: openAiMessages,
|
||||
reasoning_effort: modelInfo.reasoningEffort,
|
||||
temperature: undefined,
|
||||
}
|
||||
|
|
@ -390,6 +463,32 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
return urlHost.endsWith(".services.ai.azure.com")
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the base URL without query parameters
|
||||
* This is needed for Azure OpenAI endpoints that include api-version in the URL
|
||||
*/
|
||||
private _extractBaseUrl(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
// For Azure OpenAI, we need to preserve the path up to /openai/deployments/{deployment-name}
|
||||
// but remove /chat/completions and query parameters
|
||||
const pathParts = urlObj.pathname.split("/")
|
||||
const deploymentIndex = pathParts.indexOf("deployments")
|
||||
|
||||
if (deploymentIndex !== -1 && deploymentIndex + 1 < pathParts.length) {
|
||||
// Keep path up to and including the deployment name
|
||||
const basePath = pathParts.slice(0, deploymentIndex + 2).join("/")
|
||||
return `${urlObj.protocol}//${urlObj.host}${basePath}`
|
||||
}
|
||||
|
||||
// If not a deployment URL, just return origin + pathname without query
|
||||
return `${urlObj.protocol}//${urlObj.host}${urlObj.pathname}`
|
||||
} catch (error) {
|
||||
// If URL parsing fails, return as-is
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds max_completion_tokens to the request body if needed based on provider configuration
|
||||
* Note: max_tokens is deprecated in favor of max_completion_tokens as per OpenAI documentation
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue