From 7d23f2c9200552c4cbd0564d9ed79da799aa3a5b Mon Sep 17 00:00:00 2001 From: HDCode Date: Thu, 19 Feb 2026 02:20:26 +0100 Subject: [PATCH] fix(providers): avoid duplicate /v1 in OpenAI Responses fallback URL --- .changeset/fair-dingos-bake.md | 5 ++ .../__tests__/openai-responses.spec.ts | 88 ++++++++++++++----- src/api/providers/openai-responses.ts | 16 +++- 3 files changed, 87 insertions(+), 22 deletions(-) create mode 100644 .changeset/fair-dingos-bake.md diff --git a/.changeset/fair-dingos-bake.md b/.changeset/fair-dingos-bake.md new file mode 100644 index 00000000000..4a3a1c930e0 --- /dev/null +++ b/.changeset/fair-dingos-bake.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix OpenAI-compatible Responses fallback requests when custom base URLs already include `/v1` (https://github.com/Kilo-Org/kilocode/issues/5979). diff --git a/src/api/providers/__tests__/openai-responses.spec.ts b/src/api/providers/__tests__/openai-responses.spec.ts index 959538da090..8b3fe955baf 100644 --- a/src/api/providers/__tests__/openai-responses.spec.ts +++ b/src/api/providers/__tests__/openai-responses.spec.ts @@ -33,6 +33,25 @@ describe("OpenAiCompatibleResponsesHandler", () => { }, ] + const createMockSseResponse = () => ({ + ok: true, + body: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('data: {"type":"response.text.delta","delta":"Hello"}\n\n')) + controller.enqueue( + new TextEncoder().encode('data: {"type":"response.text.delta","delta":" world"}\n\n'), + ) + controller.enqueue( + new TextEncoder().encode( + 'data: {"type":"response.done","response":{"usage":{"prompt_tokens":10,"completion_tokens":2}}}\n\n', + ), + ) + controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) + controller.close() + }, + }), + }) + beforeEach(() => { mockResponsesCreate.mockReset() if ((global as any).fetch) { @@ -62,26 +81,7 @@ describe("OpenAiCompatibleResponsesHandler", () => { openAiModelId: "gpt-4o", } satisfies ApiHandlerOptions) - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode('data: {"type":"response.text.delta","delta":"Hello"}\n\n'), - ) - controller.enqueue( - new TextEncoder().encode('data: {"type":"response.text.delta","delta":" world"}\n\n'), - ) - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.done","response":{"usage":{"prompt_tokens":10,"completion_tokens":2}}}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), - }) + const mockFetch = vi.fn().mockResolvedValue(createMockSseResponse()) global.fetch = mockFetch as any mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) @@ -100,4 +100,52 @@ describe("OpenAiCompatibleResponsesHandler", () => { }), ) }) + + it("does not duplicate /v1 when base URL already includes /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(createMockSseResponse()) + global.fetch = mockFetch as any + + mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) + + // Consume the stream to trigger the fallback API call + for await (const _chunk of handler.createMessage(systemPrompt, messages)) { + } + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.example.com/v1/responses", + expect.objectContaining({ + method: "POST", + }), + ) + }) + + it("normalizes duplicate /v1 suffixes and trailing slashes", async () => { + const handler = new OpenAiCompatibleResponsesHandler({ + openAiApiKey: "test-key", + openAiBaseUrl: "https://api.example.com/v1/v1///", + openAiModelId: "gpt-4o", + } satisfies ApiHandlerOptions) + + const mockFetch = vi.fn().mockResolvedValue(createMockSseResponse()) + global.fetch = mockFetch as any + + mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) + + // Consume the stream to trigger the fallback API call + for await (const _chunk of handler.createMessage(systemPrompt, messages)) { + } + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.example.com/v1/responses", + expect.objectContaining({ + method: "POST", + }), + ) + }) }) diff --git a/src/api/providers/openai-responses.ts b/src/api/providers/openai-responses.ts index abbdfb4f3b6..b8c8dd534b7 100644 --- a/src/api/providers/openai-responses.ts +++ b/src/api/providers/openai-responses.ts @@ -286,8 +286,7 @@ export class OpenAiCompatibleResponsesHandler extends BaseProvider implements Si private async *makeResponsesApiRequest(requestBody: any): ApiStream { const apiKey = this.options.openAiApiKey ?? "not-provided" - const baseUrl = this.options.openAiBaseUrl || "https://api.openai.com" - const url = `${baseUrl}/v1/responses` + const url = this.getResponsesApiUrl() this.abortController = new AbortController() @@ -558,4 +557,17 @@ export class OpenAiCompatibleResponsesHandler extends BaseProvider implements Si const urlHost = this._getUrlHost(baseUrl) return urlHost.endsWith(".services.ai.azure.com") } + + private getResponsesApiUrl(): string { + const configuredBaseUrl = this.options.openAiBaseUrl?.trim() + const baseUrl = configuredBaseUrl && configuredBaseUrl.length > 0 ? configuredBaseUrl : "https://api.openai.com" + + const normalizedBaseUrl = baseUrl + .replace(/\/+$/, "") + // Some OpenAI-compatible providers are configured with a /v1 suffix. + // Collapse repeated trailing /v1 segments so we always send one /v1/responses path. + .replace(/(?:\/v1)+$/i, "") + + return `${normalizedBaseUrl}/v1/responses` + } }