Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions packages/types/src/__tests__/azure-openai.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {
azureOpenAiDefaultApiVersion,
getAzureOpenAiBaseUrl,
getAzureOpenAiChatCompletionsUrl,
} from "../providers/openai.js"

describe("Azure OpenAI URLs", () => {
it.each([
["https://resource.openai.azure.com", "https://resource.openai.azure.com/openai"],
["https://resource.openai.azure.com/", "https://resource.openai.azure.com/openai"],
["https://resource.openai.azure.com/openai", "https://resource.openai.azure.com/openai"],
["https://resource.openai.azure.com/OpenAI/", "https://resource.openai.azure.com/openai"],
["https://models.example.com/azure/", "https://models.example.com/azure/openai"],
])("normalizes %s to the /openai root", (baseUrl, expected) => {
expect(getAzureOpenAiBaseUrl(baseUrl)).toBe(expected)
})

it.each([
{
apiVersion: "2025-01-01-preview",
expected:
"https://resource.openai.azure.com/openai/deployments/brotherhood/chat/completions?api-version=2025-01-01-preview",
},
{
apiVersion: undefined,
expected: `https://resource.openai.azure.com/openai/deployments/brotherhood/chat/completions?api-version=${azureOpenAiDefaultApiVersion}`,
},
{
apiVersion: "",
expected: `https://resource.openai.azure.com/openai/deployments/brotherhood/chat/completions?api-version=${azureOpenAiDefaultApiVersion}`,
},
])("builds the deployment chat completions URL for API version $apiVersion", ({ apiVersion, expected }) => {
expect(getAzureOpenAiChatCompletionsUrl("https://resource.openai.azure.com/", "brotherhood", apiVersion)).toBe(
expected,
)
})
})
17 changes: 17 additions & 0 deletions packages/types/src/providers/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -978,3 +978,20 @@
return false
}
}

/**
* Normalizes an Azure OpenAI resource URL to the `/openai` root the AzureOpenAI
* client expects. A trailing `/openai` the user already typed is not repeated.
*/
export function getAzureOpenAiBaseUrl(baseUrl: string): string {
return `${baseUrl.replace(/\/openai\/?$/i, "").replace(/\/$/, "")}/openai`

Check warning on line 987 in packages/types/src/providers/openai.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

packages/types/src/providers/openai.ts:987: Survived Regex mutant (replacement: /\/openai\/?/i). See the job summary for the complete list and resolution guidance.
}

/**
* Returns the chat completions URL the AzureOpenAI client requests: it inserts
* `/deployments/<deployment>` using the configured model ID as the deployment name.
*/
export function getAzureOpenAiChatCompletionsUrl(baseUrl: string, deployment: string, apiVersion?: string): string {
const version = apiVersion || azureOpenAiDefaultApiVersion
return `${getAzureOpenAiBaseUrl(baseUrl)}/deployments/${deployment}/chat/completions?api-version=${version}`
}
70 changes: 69 additions & 1 deletion src/api/providers/__tests__/openai.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ vitest.mock("../utils/timeout-config", () => ({
getApiRequestTimeout: vitest.fn().mockReturnValue(300_000),
}))

// Echo the key and interpolation values so tests can assert what reaches the message.
vitest.mock("../../../i18n", () => ({
t: (key: string, options?: Record<string, unknown>) => (options ? `${key} ${JSON.stringify(options)}` : key),
}))

const MOCK_TIMEOUT_MS = 300_000

const mockCreate = vitest.fn()
Expand Down Expand Up @@ -81,7 +86,9 @@ vitest.mock("openai", () => {
},
}
}),
AzureOpenAI: mockAzureConstructor,
AzureOpenAI: mockAzureConstructor.mockImplementation(function () {
return { chat: { completions: { create: mockCreate } } }
}),
}
})

Expand Down Expand Up @@ -941,6 +948,67 @@ describe("OpenAiHandler", () => {
})
})

describe("Azure OpenAI deployment errors", () => {
const azureOptions = {
openAiApiKey: "test-api-key",
openAiBaseUrl: "https://resource.openai.azure.com/",
}

function makeStatusError(message: string, status: number) {
return Object.assign(new Error(message), { status })
}

async function request(
entryPoint: "streaming" | "non-streaming" | "o3" | "o3-non-streaming" | "completePrompt",
options: Partial<ApiHandlerOptions>,
) {
const requestHandler = new OpenAiHandler(
makeApiHandlerOptions({
openAiModelId: entryPoint.startsWith("o3") ? "o3-mini" : "gpt-4o",
openAiStreamingEnabled: entryPoint !== "non-streaming" && entryPoint !== "o3-non-streaming",
...options,
}),
)

if (entryPoint === "completePrompt") {
return requestHandler.completePrompt("Test prompt")
}

return collectStream(requestHandler.createMessage("system prompt", [{ role: "user", content: "Hello" }]))
}

it.each(["streaming", "non-streaming", "o3", "o3-non-streaming", "completePrompt"] as const)(
"names the missing deployment on an Azure 404 (%s)",
async (entryPoint) => {
mockCreate.mockRejectedValueOnce(makeStatusError("404 Resource not found", 404))

const error = await request(entryPoint, azureOptions).catch((e: unknown) => e)
const deployment = entryPoint.startsWith("o3") ? "o3-mini" : "gpt-4o"

expect(error).toBeInstanceOf(Error)
expect(error).toMatchObject({ status: 404 })
expect((error as Error).message).toContain(
`errors.api.azureDeploymentNotFound ${JSON.stringify({ deployment, message: "404 Resource not found" })}`,
)
},
)

it.each([
{ name: "a non-404 Azure error", options: azureOptions, status: 401 },
{
name: "a 404 from a non-Azure endpoint",
options: { ...azureOptions, openAiBaseUrl: "https://api.openai.com/v1" },
status: 404,
},
])("keeps the generic error message for $name", async ({ options, status }) => {
mockCreate.mockRejectedValueOnce(makeStatusError("Request failed", status))

const error = await request("streaming", options).catch((e: unknown) => e)

expect(error).toMatchObject({ status, message: "OpenAI completion error: Request failed" })
})
})

describe("completePrompt", () => {
it("should complete prompt successfully", async () => {
const result = await handler.completePrompt("Test prompt")
Expand Down
42 changes: 34 additions & 8 deletions src/api/providers/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import {
type ModelInfo,
azureOpenAiDefaultApiVersion,
getAzureOpenAiBaseUrl,
isAzureAiInferenceBaseUrl,
isAzureOpenAiBaseUrl,
openAiModelInfoSaneDefaults,
Expand All @@ -15,6 +16,7 @@
} from "@roo-code/types"

import type { ApiHandlerOptions } from "../../shared/api"
import { t } from "../../i18n"

import { TagMatcher } from "../../utils/tag-matcher"

Expand All @@ -27,7 +29,7 @@
import { DEFAULT_HEADERS, NOT_PROVIDED } from "./constants"
import { BaseProvider } from "./base-provider"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index"
import { handleOpenAIError } from "./utils/error-handler"
import { handleOpenAIError, handleProviderError } from "./utils/error-handler"
import { extractReasoningFromDelta } from "./utils/extract-reasoning"

// TODO: Rename this to OpenAICompatibleHandler. Also, I think the
Expand All @@ -38,6 +40,7 @@
protected client: OpenAI
private readonly providerName = "OpenAI"
private readonly extraBody: Record<string, unknown>
private readonly isAzureOpenAi: boolean

constructor(options: ApiHandlerOptions) {
super()
Expand All @@ -48,6 +51,7 @@
const apiKey = this.options.openAiApiKey ?? NOT_PROVIDED
const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl)
const isAzureOpenAi = isAzureOpenAiBaseUrl(this.options.openAiBaseUrl, options.openAiUseAzure)
this.isAzureOpenAi = isAzureOpenAi

const headers = {
...DEFAULT_HEADERS,
Expand All @@ -66,9 +70,8 @@
} 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
const azureBaseURL = `${baseURL.replace(/\/openai\/?$/i, "").replace(/\/$/, "")}/openai`
this.client = new AzureOpenAI({
baseURL: azureBaseURL,
baseURL: getAzureOpenAiBaseUrl(baseURL),
apiKey,
apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion,
defaultHeaders: headers,
Expand Down Expand Up @@ -193,7 +196,7 @@
...(isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}),
})
} catch (error) {
throw handleOpenAIError(error, this.providerName)
throw this.handleRequestError(error, modelId)
}

const matcher = new TagMatcher(
Expand Down Expand Up @@ -261,7 +264,7 @@
...(this._isAzureAiInference(modelUrl) ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}),
})
} catch (error) {
throw handleOpenAIError(error, this.providerName)
throw this.handleRequestError(error, modelId)
}

const message = response.choices?.[0]?.message
Expand Down Expand Up @@ -353,7 +356,7 @@
isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {},
)
} catch (error) {
throw handleOpenAIError(error, this.providerName)
throw this.handleRequestError(error, model.id)
}

return response.choices?.[0]?.message.content || ""
Expand Down Expand Up @@ -420,7 +423,7 @@
...(methodIsAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}),
})
} catch (error) {
throw handleOpenAIError(error, this.providerName)
throw this.handleRequestError(error, modelId)
}

yield* this.handleStreamResponse(stream)
Expand Down Expand Up @@ -455,7 +458,7 @@
...(methodIsAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}),
})
} catch (error) {
throw handleOpenAIError(error, this.providerName)
throw this.handleRequestError(error, modelId)
}

const message = response.choices?.[0]?.message
Expand Down Expand Up @@ -561,6 +564,25 @@
return urlHost === "api.x.ai" || urlHost.endsWith(".x.ai")
}

/**
* Azure OpenAI routes each request to `/openai/deployments/<model>`, so a 404 there
* almost always means the configured deployment name does not exist. Name the
* deployment in the error instead of surfacing a bare "Resource not found".
*/
private handleRequestError(error: unknown, deploymentName: string): Error {
if (this.isAzureOpenAi && isNotFoundError(error)) {
return handleProviderError(error, this.providerName, {
messageTransformer: (message) =>
`${this.providerName} completion error: ${t("common:errors.api.azureDeploymentNotFound", {
deployment: deploymentName,
message,
})}`,
})
}

return handleOpenAIError(error, this.providerName)
}

protected _isAzureAiInference(baseUrl?: string): boolean {
return isAzureAiInferenceBaseUrl(baseUrl)
}
Expand Down Expand Up @@ -589,6 +611,10 @@
}
}

function isNotFoundError(error: unknown): boolean {
return typeof error === "object" && error !== null && "status" in error && error.status === 404

Check warning on line 615 in src/api/providers/openai.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/api/providers/openai.ts:615: 6 mutation test gaps; example: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
}

export async function getOpenAiModels(baseUrl?: string, apiKey?: string, openAiHeaders?: Record<string, string>) {
try {
if (!baseUrl) {
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/ca/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/i18n/locales/de/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,8 @@
},
"api": {
"invalidKeyInvalidChars": "API key contains invalid characters.",
"apiRequestFailed": "API request failed ({{status}})"
"apiRequestFailed": "API request failed ({{status}})",
"azureDeploymentNotFound": "Azure OpenAI could not find the deployment \"{{deployment}}\". Set \"Azure deployment name\" to the exact deployment name from Azure AI Foundry, not the model name (for example gpt-4o). Details: {{message}}"
},
"manual_url_empty": "Please enter a valid callback URL",
"manual_url_no_query": "Invalid callback URL: missing query parameters",
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/es/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/i18n/locales/fr/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/i18n/locales/hi/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/i18n/locales/id/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/i18n/locales/it/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/i18n/locales/ja/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/i18n/locales/ko/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/i18n/locales/nl/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading