Skip to content

Commit ecf2e1a

Browse files
committed
fix: Add GPT-5 Azure responses API support
- Detect GPT-5 models when using Azure OpenAI provider - Use responses API endpoint with "input" parameter instead of chat completions "messages" - Support GPT-5 specific parameters: reasoning effort (including minimal), verbosity, and reasoning summary - Handle responses API streaming format for text, reasoning, and usage events - Add comprehensive tests for GPT-5 Azure integration Fixes #6862
1 parent 3ee6072 commit ecf2e1a

File tree

2 files changed

+504
-2
lines changed

2 files changed

+504
-2
lines changed

src/api/providers/__tests__/openai.spec.ts

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const mockCreate = vitest.fn()
1212

1313
vitest.mock("openai", () => {
1414
const mockConstructor = vitest.fn()
15+
const mockAzureConstructor = vitest.fn()
1516
return {
1617
__esModule: true,
1718
default: mockConstructor.mockImplementation(() => ({
@@ -66,6 +67,58 @@ vitest.mock("openai", () => {
6667
},
6768
},
6869
})),
70+
AzureOpenAI: mockAzureConstructor.mockImplementation(() => ({
71+
chat: {
72+
completions: {
73+
create: mockCreate.mockImplementation(async (options) => {
74+
if (!options.stream) {
75+
return {
76+
id: "test-completion",
77+
choices: [
78+
{
79+
message: { role: "assistant", content: "Test response", refusal: null },
80+
finish_reason: "stop",
81+
index: 0,
82+
},
83+
],
84+
usage: {
85+
prompt_tokens: 10,
86+
completion_tokens: 5,
87+
total_tokens: 15,
88+
},
89+
}
90+
}
91+
92+
return {
93+
[Symbol.asyncIterator]: async function* () {
94+
yield {
95+
choices: [
96+
{
97+
delta: { content: "Test response" },
98+
index: 0,
99+
},
100+
],
101+
usage: null,
102+
}
103+
yield {
104+
choices: [
105+
{
106+
delta: {},
107+
index: 0,
108+
},
109+
],
110+
usage: {
111+
prompt_tokens: 10,
112+
completion_tokens: 5,
113+
total_tokens: 15,
114+
},
115+
}
116+
},
117+
}
118+
}),
119+
},
120+
},
121+
})),
69122
}
70123
})
71124

@@ -783,6 +836,166 @@ describe("OpenAiHandler", () => {
783836
)
784837
})
785838
})
839+
840+
describe("GPT-5 Azure Support", () => {
841+
it("should use responses API for GPT-5 models on Azure", async () => {
842+
// Mock fetch for responses API
843+
const mockFetch = vitest.fn().mockResolvedValue({
844+
ok: true,
845+
body: {
846+
getReader: () => ({
847+
read: vitest
848+
.fn()
849+
.mockResolvedValueOnce({
850+
done: false,
851+
value: new TextEncoder().encode(
852+
'data: {"type":"response.text.delta","delta":"Hello"}\n\n',
853+
),
854+
})
855+
.mockResolvedValueOnce({
856+
done: false,
857+
value: new TextEncoder().encode(
858+
'data: {"type":"response.done","response":{"usage":{"input_tokens":10,"output_tokens":5}}}\n\n',
859+
),
860+
})
861+
.mockResolvedValueOnce({ done: true }),
862+
releaseLock: vitest.fn(),
863+
}),
864+
},
865+
})
866+
global.fetch = mockFetch
867+
868+
const gpt5Handler = new OpenAiHandler({
869+
...mockOptions,
870+
openAiModelId: "gpt-5",
871+
openAiUseAzure: true,
872+
openAiBaseUrl: "https://test-resource.openai.azure.com/openai/responses",
873+
azureApiVersion: "2025-04-01-preview",
874+
reasoningEffort: "high",
875+
modelTemperature: 1,
876+
})
877+
878+
const systemPrompt = "You are a helpful assistant."
879+
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello!" }]
880+
881+
const stream = gpt5Handler.createMessage(systemPrompt, messages)
882+
const chunks: any[] = []
883+
884+
for await (const chunk of stream) {
885+
chunks.push(chunk)
886+
}
887+
888+
// Verify fetch was called with correct URL and body
889+
expect(mockFetch).toHaveBeenCalledWith(
890+
"https://test-resource.openai.azure.com/openai/responses",
891+
expect.objectContaining({
892+
method: "POST",
893+
headers: expect.objectContaining({
894+
"Content-Type": "application/json",
895+
"api-key": "test-api-key",
896+
Accept: "text/event-stream",
897+
}),
898+
body: expect.stringContaining(
899+
'"input":"Developer: You are a helpful assistant.\\n\\nUser: Hello!"',
900+
),
901+
}),
902+
)
903+
904+
// Verify the request body contains GPT-5 specific parameters
905+
const requestBody = JSON.parse((mockFetch.mock.calls[0][1] as any).body)
906+
expect(requestBody.model).toBe("gpt-5")
907+
expect(requestBody.input).toContain("Developer: You are a helpful assistant")
908+
expect(requestBody.input).toContain("User: Hello!")
909+
expect(requestBody.reasoning?.effort).toBe("high")
910+
expect(requestBody.temperature).toBe(1)
911+
expect(requestBody.stream).toBe(true)
912+
913+
// Verify response chunks
914+
expect(chunks).toHaveLength(2)
915+
expect(chunks[0]).toEqual({ type: "text", text: "Hello" })
916+
expect(chunks[1]).toMatchObject({
917+
type: "usage",
918+
inputTokens: 10,
919+
outputTokens: 5,
920+
})
921+
})
922+
923+
afterEach(() => {
924+
// Clear the global fetch mock after each test
925+
delete (global as any).fetch
926+
})
927+
928+
it("should handle GPT-5 models with minimal reasoning effort", async () => {
929+
// Mock fetch for responses API
930+
const mockFetch = vitest.fn().mockResolvedValue({
931+
ok: true,
932+
body: {
933+
getReader: () => ({
934+
read: vitest
935+
.fn()
936+
.mockResolvedValueOnce({
937+
done: false,
938+
value: new TextEncoder().encode(
939+
'data: {"type":"response.text.delta","delta":"Test"}\n\n',
940+
),
941+
})
942+
.mockResolvedValueOnce({ done: true }),
943+
releaseLock: vitest.fn(),
944+
}),
945+
},
946+
})
947+
global.fetch = mockFetch
948+
949+
const gpt5Handler = new OpenAiHandler({
950+
...mockOptions,
951+
openAiModelId: "gpt-5-mini",
952+
openAiUseAzure: true,
953+
openAiBaseUrl: "https://test-resource.openai.azure.com/openai/responses",
954+
reasoningEffort: "minimal",
955+
})
956+
957+
const systemPrompt = "Test"
958+
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test" }]
959+
960+
const stream = gpt5Handler.createMessage(systemPrompt, messages)
961+
const chunks: any[] = []
962+
963+
for await (const chunk of stream) {
964+
chunks.push(chunk)
965+
}
966+
967+
// Verify minimal reasoning effort is set
968+
const requestBody = JSON.parse((mockFetch.mock.calls[0][1] as any).body)
969+
expect(requestBody.reasoning?.effort).toBe("minimal")
970+
})
971+
972+
it("should not use responses API for GPT-5 models when not on Azure", async () => {
973+
// Clear any previous fetch mock
974+
delete (global as any).fetch
975+
976+
const gpt5Handler = new OpenAiHandler({
977+
...mockOptions,
978+
openAiModelId: "gpt-5",
979+
openAiUseAzure: false, // Not using Azure
980+
openAiBaseUrl: "https://api.openai.com/v1",
981+
})
982+
983+
// This should use the regular chat completions API
984+
const systemPrompt = "You are a helpful assistant."
985+
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello!" }]
986+
987+
const stream = gpt5Handler.createMessage(systemPrompt, messages)
988+
const chunks: any[] = []
989+
990+
for await (const chunk of stream) {
991+
chunks.push(chunk)
992+
}
993+
994+
// Should call the OpenAI client's chat.completions.create, not fetch
995+
expect(mockCreate).toHaveBeenCalled()
996+
expect(global.fetch).toBeUndefined()
997+
})
998+
})
786999
})
7871000

7881001
describe("getOpenAiModels", () => {

0 commit comments

Comments
 (0)