Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/fair-dingos-bake.md
Original file line number Diff line number Diff line change
@@ -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).
88 changes: 68 additions & 20 deletions src/api/providers/__tests__/openai-responses.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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"))
Expand All @@ -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",
}),
)
})
})
16 changes: 14 additions & 2 deletions src/api/providers/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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`
}
}
Loading