diff --git a/.changeset/azure-responses-v1-endpoints.md b/.changeset/azure-responses-v1-endpoints.md new file mode 100644 index 00000000000..f10774e0c00 --- /dev/null +++ b/.changeset/azure-responses-v1-endpoints.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix OpenAI Responses Azure URL normalization so Azure v1 endpoints avoid unsupported `api-version` parameters. diff --git a/src/api/providers/__tests__/openai-responses.spec.ts b/src/api/providers/__tests__/openai-responses.spec.ts index 959538da090..340c8595357 100644 --- a/src/api/providers/__tests__/openai-responses.spec.ts +++ b/src/api/providers/__tests__/openai-responses.spec.ts @@ -2,6 +2,7 @@ // npx vitest run api/providers/__tests__/openai-responses.spec.ts import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI, { AzureOpenAI } from "openai" import { OpenAiCompatibleResponsesHandler } from "../openai-responses" import { ApiHandlerOptions } from "../../../shared/api" @@ -100,4 +101,156 @@ describe("OpenAiCompatibleResponsesHandler", () => { }), ) }) + + it("normalizes fallback URL without duplicating /v1", async () => { + const handler = new OpenAiCompatibleResponsesHandler({ + openAiApiKey: "test-key", + openAiBaseUrl: "https://api.example.com/v1", + openAiModelId: "gpt-4o", + } satisfies ApiHandlerOptions) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + body: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) + controller.close() + }, + }), + }) + global.fetch = mockFetch as any + mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + } + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.example.com/v1/responses", + expect.objectContaining({ + method: "POST", + }), + ) + }) + + it("rejects Azure AI Inference endpoints for Responses API", async () => { + const handler = new OpenAiCompatibleResponsesHandler({ + openAiApiKey: "test-key", + openAiBaseUrl: "https://myresource.services.ai.azure.com/models", + openAiModelId: "gpt-5.2-codex", + } satisfies ApiHandlerOptions) + + const stream = handler.createMessage(systemPrompt, messages) + + await expect(async () => { + for await (const _chunk of stream) { + } + }).rejects.toThrow("Azure AI Inference endpoints") + + await expect(handler.completePrompt("Test prompt")).rejects.toThrow("Azure AI Inference endpoints") + }) + + it("does not pass chat-completions path override for Azure OpenAI Responses calls", async () => { + const handler = new OpenAiCompatibleResponsesHandler({ + openAiApiKey: "test-key", + openAiBaseUrl: "https://myresource.openai.azure.com/openai/v1", + openAiUseAzure: true, + openAiModelId: "my-deployment", + } satisfies ApiHandlerOptions) + + mockResponsesCreate.mockResolvedValueOnce({ + [Symbol.asyncIterator]: async function* () { + yield { type: "response.text.delta", delta: "hello" } + yield { + type: "response.done", + response: { + usage: { + prompt_tokens: 1, + completion_tokens: 1, + }, + }, + } + }, + }) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + } + + expect(mockResponsesCreate).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + signal: expect.any(AbortSignal), + }), + ) + const options = mockResponsesCreate.mock.calls[0][1] + expect(options.path).toBeUndefined() + }) + + it("uses Azure fallback auth and normalizes Azure deployment chat URL to /openai/v1/responses without api-version", async () => { + const handler = new OpenAiCompatibleResponsesHandler({ + openAiApiKey: "test-key", + openAiBaseUrl: + "https://myresource.openai.azure.com/openai/deployments/my-deployment/chat/completions?api-version=2024-05-01-preview", + openAiUseAzure: true, + azureApiVersion: "2024-08-01-preview", + openAiModelId: "my-deployment", + } satisfies ApiHandlerOptions) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + body: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) + controller.close() + }, + }), + }) + global.fetch = mockFetch as any + mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + } + + expect(mockFetch).toHaveBeenCalledTimes(1) + const [requestUrl, requestOptions] = mockFetch.mock.calls[0] + expect(requestUrl).toBe("https://myresource.openai.azure.com/openai/v1/responses") + expect(requestUrl).not.toContain("api-version=") + expect(requestOptions.headers["api-key"]).toBe("test-key") + expect(requestOptions.headers.Authorization).toBeUndefined() + }) + + it("normalizes cognitiveservices Azure endpoint to /openai/v1/responses without api-version", async () => { + const handler = new OpenAiCompatibleResponsesHandler({ + openAiApiKey: "test-key", + openAiBaseUrl: "https://myresource.cognitiveservices.azure.com", + openAiUseAzure: true, + azureApiVersion: "2024-08-01-preview", + openAiModelId: "my-deployment", + } satisfies ApiHandlerOptions) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + body: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) + controller.close() + }, + }), + }) + global.fetch = mockFetch as any + mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + } + + expect(mockFetch).toHaveBeenCalledTimes(1) + const [requestUrl, requestOptions] = mockFetch.mock.calls[0] + expect(requestUrl).toBe("https://myresource.cognitiveservices.azure.com/openai/v1/responses") + expect(requestUrl).not.toContain("api-version=") + expect(requestOptions.headers["api-key"]).toBe("test-key") + expect(requestOptions.headers.Authorization).toBeUndefined() + }) }) diff --git a/src/api/providers/openai-responses.ts b/src/api/providers/openai-responses.ts index abbdfb4f3b6..9ad15aa9331 100644 --- a/src/api/providers/openai-responses.ts +++ b/src/api/providers/openai-responses.ts @@ -7,7 +7,6 @@ import { azureOpenAiDefaultApiVersion, openAiModelInfoSaneDefaults, NATIVE_TOOL_DEFAULTS, - OPENAI_AZURE_AI_INFERENCE_PATH, } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" @@ -19,7 +18,6 @@ import { calculateApiCostOpenAI } from "../../shared/cost" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import { getApiRequestTimeout } from "./utils/timeout-config" -import { handleOpenAIError } from "./utils/openai-error-handler" import { normalizeObjectAdditionalPropertiesFalse } from "./kilocode/openai-strict-schema" // kilocode_change import { isMcpTool } from "../../utils/mcp-name" @@ -31,6 +29,8 @@ export class OpenAiCompatibleResponsesHandler extends BaseProvider implements Si private readonly providerName = "OpenAI Compatible (Responses)" private abortController?: AbortController private readonly toolCallIdentityById = new Map() + private readonly isAzureAiInferenceEndpoint: boolean + private readonly isAzureOpenAiEndpoint: boolean constructor(options: ApiHandlerOptions) { super() @@ -43,19 +43,12 @@ export class OpenAiCompatibleResponsesHandler extends BaseProvider implements Si const baseURL = this.options.openAiBaseUrl ?? "https://api.openai.com/v1" const apiKey = this.options.openAiApiKey ?? "not-provided" const timeout = getApiRequestTimeout() - 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 + this.isAzureAiInferenceEndpoint = this._isAzureAiInference(this.options.openAiBaseUrl) + this.isAzureOpenAiEndpoint = + urlHost === "azure.com" || urlHost.endsWith(".azure.com") || !!options.openAiUseAzure - if (isAzureAiInference) { - this.client = new OpenAI({ - baseURL, - apiKey, - defaultHeaders: this.options.openAiHeaders || {}, - defaultQuery: { "api-version": this.options.azureApiVersion || "2024-05-01-preview" }, - timeout, - }) - } else if (isAzureOpenAi) { + if (this.isAzureOpenAiEndpoint) { this.client = new AzureOpenAI({ baseURL, apiKey, @@ -78,6 +71,9 @@ export class OpenAiCompatibleResponsesHandler extends BaseProvider implements Si messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { + // kilocode_change start + this.assertSupportedResponsesEndpoint() + // kilocode_change end const model = this.getModel() yield* this.handleResponsesApiMessage(model, systemPrompt, messages, metadata) } @@ -92,11 +88,8 @@ export class OpenAiCompatibleResponsesHandler extends BaseProvider implements Si const formattedInput = this.formatFullConversation(systemPrompt, messages) const requestBody = this.buildRequestBody(model, formattedInput, systemPrompt, verbosity, metadata) - const requestOptions = this._isAzureAiInference(this.options.openAiBaseUrl) - ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } - : undefined - yield* this.executeRequest(requestBody, requestOptions) + yield* this.executeRequest(requestBody) } private buildRequestBody( @@ -170,12 +163,11 @@ export class OpenAiCompatibleResponsesHandler extends BaseProvider implements Si return body } - private async *executeRequest(requestBody: any, requestOptions?: OpenAI.RequestOptions): ApiStream { + private async *executeRequest(requestBody: any): ApiStream { this.abortController = new AbortController() try { const stream = (await (this.client as any).responses.create(requestBody, { - ...(requestOptions || {}), signal: this.abortController.signal, })) as AsyncIterable @@ -194,7 +186,7 @@ export class OpenAiCompatibleResponsesHandler extends BaseProvider implements Si yield outChunk } } - } catch (sdkErr: any) { + } catch (_sdkErr: any) { yield* this.makeResponsesApiRequest(requestBody) } finally { this.abortController = undefined @@ -285,9 +277,11 @@ export class OpenAiCompatibleResponsesHandler extends BaseProvider implements Si } private async *makeResponsesApiRequest(requestBody: any): ApiStream { + // kilocode_change start + this.assertSupportedResponsesEndpoint() + // kilocode_change end const apiKey = this.options.openAiApiKey ?? "not-provided" - const baseUrl = this.options.openAiBaseUrl || "https://api.openai.com" - const url = `${baseUrl}/v1/responses` + const { url, headers } = this.getResponsesFallbackTarget(apiKey) this.abortController = new AbortController() @@ -296,9 +290,8 @@ export class OpenAiCompatibleResponsesHandler extends BaseProvider implements Si method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, Accept: "text/event-stream", - ...(this.options.openAiHeaders || {}), + ...headers, }, body: JSON.stringify(requestBody), signal: this.abortController.signal, @@ -318,8 +311,14 @@ export class OpenAiCompatibleResponsesHandler extends BaseProvider implements Si errorMessage = "Access denied. Your API key doesn't have access to this resource." break case 404: - errorMessage = - "Responses API endpoint not found. The endpoint may not be available yet or requires a different configuration." + // kilocode_change start + errorMessage = this.isAzureOpenAiEndpoint + ? "Responses API endpoint not found. For Azure OpenAI, use a base URL like https://.openai.azure.com/openai/v1 and set model to your deployment name." + : "Responses API endpoint not found. The endpoint may not be available yet or requires a different configuration." + if ((this.options.openAiBaseUrl || "").includes("/deployments/")) { + errorMessage += " Do not use a /deployments/.../chat/completions URL as the base URL." + } + // kilocode_change end break case 429: errorMessage = "Rate limit exceeded. Please try again later." @@ -499,6 +498,9 @@ export class OpenAiCompatibleResponsesHandler extends BaseProvider implements Si } async completePrompt(prompt: string): Promise { + // kilocode_change start + this.assertSupportedResponsesEndpoint() + // kilocode_change end this.abortController = new AbortController() try { @@ -517,9 +519,6 @@ export class OpenAiCompatibleResponsesHandler extends BaseProvider implements Si const response = await (this.client as any).responses.create(requestBody, { signal: this.abortController.signal, - ...(this._isAzureAiInference(this.options.openAiBaseUrl) - ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } - : {}), }) if (response?.output && Array.isArray(response.output)) { @@ -546,6 +545,86 @@ export class OpenAiCompatibleResponsesHandler extends BaseProvider implements Si } } + // kilocode_change start + private assertSupportedResponsesEndpoint(): void { + if (this.isAzureAiInferenceEndpoint) { + throw new Error( + "Azure AI Inference endpoints (*.services.ai.azure.com) are not supported by OpenAI Compatible (Responses). Use an Azure OpenAI endpoint like https://.openai.azure.com/openai/v1 and set model to your deployment name.", + ) + } + } + + private getResponsesFallbackTarget(apiKey: string): { url: string; headers: Record } { + const normalizedBaseUrl = this.normalizeResponsesBaseUrl(this.options.openAiBaseUrl) + const url = new URL(`${normalizedBaseUrl.replace(/\/+$/, "")}/responses`) + const headers: Record = { + ...(this.options.openAiHeaders || {}), + } + + if (this.isAzureOpenAiEndpoint) { + headers["api-key"] = apiKey + if (this.shouldAppendAzureApiVersion(url) && !url.searchParams.has("api-version")) { + url.searchParams.set("api-version", this.options.azureApiVersion || azureOpenAiDefaultApiVersion) + } + } else { + headers.Authorization = `Bearer ${apiKey}` + } + + return { url: url.toString(), headers } + } + + private normalizeResponsesBaseUrl(baseUrl?: string): string { + const defaultBaseUrl = this.isAzureOpenAiEndpoint + ? "https://api.openai.azure.com/openai/v1" + : "https://api.openai.com/v1" + + if (!baseUrl) { + return defaultBaseUrl + } + + try { + const parsed = new URL(baseUrl) + parsed.search = "" + parsed.hash = "" + + let pathname = parsed.pathname.replace(/\/+$/, "") + pathname = pathname.replace(/\/(chat\/completions|completions|responses)$/, "") + + if (this.isAzureOpenAiEndpoint) { + if (/\/openai\/deployments\/[^/]+$/.test(pathname)) { + pathname = "/openai/v1" + } else if (pathname === "" || pathname === "/") { + pathname = "/openai/v1" + } else if (pathname === "/v1") { + pathname = "/openai/v1" + } else if (pathname === "/openai") { + pathname = "/openai/v1" + } else if (pathname.endsWith("/openai")) { + pathname = `${pathname}/v1` + } else if (!pathname.endsWith("/v1")) { + pathname = `${pathname}/v1` + } + } else { + if (pathname === "" || pathname === "/") { + pathname = "/v1" + } else if (!pathname.endsWith("/v1")) { + pathname = `${pathname}/v1` + } + } + + parsed.pathname = pathname + return parsed.toString().replace(/\/$/, "") + } catch { + return defaultBaseUrl + } + } + + private shouldAppendAzureApiVersion(url: URL): boolean { + const pathname = url.pathname.replace(/\/+$/, "") + return !pathname.includes("/openai/v1") + } + // kilocode_change end + protected _getUrlHost(baseUrl?: string): string { try { return new URL(baseUrl ?? "").host diff --git a/webview-ui/src/utils/__tests__/validate.spec.ts b/webview-ui/src/utils/__tests__/validate.spec.ts index b83dc61ad79..2a2c7fd037b 100644 --- a/webview-ui/src/utils/__tests__/validate.spec.ts +++ b/webview-ui/src/utils/__tests__/validate.spec.ts @@ -202,6 +202,31 @@ describe("Model Validation Functions", () => { expect(result).toBeUndefined() }) + // kilocode_change start + it("returns openAi validation error for incomplete openai-responses configuration", () => { + const config: ProviderSettings = { + apiProvider: "openai-responses", + openAiApiKey: "valid-key", + openAiModelId: "gpt-5.2-codex", + } + + const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization) + expect(result).toBe("settings:validation.openAi") + }) + + it("returns undefined for valid openai-responses configuration", () => { + const config: ProviderSettings = { + apiProvider: "openai-responses", + openAiBaseUrl: "https://myresource.openai.azure.com/openai/v1", + openAiApiKey: "valid-key", + openAiModelId: "my-deployment", + } + + const result = validateApiConfigurationExcludingModelErrors(config, mockRouterModels, allowAllOrganization) + expect(result).toBeUndefined() + }) + // kilocode_change end + it("returns error for missing API key", () => { const config: ProviderSettings = { apiProvider: "openrouter", diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index c7db9f77d52..5a33483a00f 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -104,6 +104,13 @@ function validateModelsAndKeysProvided(apiConfiguration: ProviderSettings): stri return i18next.t("settings:validation.openAi") } break + // kilocode_change start + case "openai-responses": + if (!apiConfiguration.openAiBaseUrl || !apiConfiguration.openAiApiKey || !apiConfiguration.openAiModelId) { + return i18next.t("settings:validation.openAi") + } + break + // kilocode_change end case "ollama": if (!apiConfiguration.ollamaModelId) { return i18next.t("settings:validation.modelId")