Skip to content

Commit c7f4096

Browse files
committed
feat: add unverified organizations mode for openai
1 parent 95e4235 commit c7f4096

File tree

5 files changed

+415
-50
lines changed

5 files changed

+415
-50
lines changed

packages/types/src/provider-settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,7 @@ const openAiNativeSchema = apiModelIdProviderModelSchema.extend({
296296
// OpenAI Responses API service tier for openai-native provider only.
297297
// UI should only expose this when the selected model supports flex/priority.
298298
openAiNativeServiceTier: serviceTierSchema.optional(),
299+
openAiNativeUnverifiedOrg: z.boolean().optional(),
299300
})
300301

301302
const mistralSchema = apiModelIdProviderModelSchema.extend({

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

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,143 @@ describe("OpenAiNativeHandler", () => {
125125
}
126126
}).rejects.toThrow("OpenAI service error")
127127
})
128+
129+
it("should handle non-streaming responses via SDK when stream=false", async () => {
130+
// Reconfigure handler to force non-stream (buildRequestBody sets stream = !openAiNativeUnverifiedOrg)
131+
handler = new OpenAiNativeHandler({
132+
...mockOptions,
133+
openAiNativeUnverifiedOrg: true, // => stream: false
134+
})
135+
136+
// Mock SDK non-streaming JSON response
137+
mockResponsesCreate.mockResolvedValueOnce({
138+
id: "resp_nonstream_1",
139+
output: [
140+
{
141+
type: "message",
142+
content: [{ type: "output_text", text: "Non-streamed reply" }],
143+
},
144+
],
145+
usage: {
146+
input_tokens: 12,
147+
output_tokens: 7,
148+
cache_read_input_tokens: 0,
149+
cache_creation_input_tokens: 0,
150+
},
151+
})
152+
153+
const stream = handler.createMessage(systemPrompt, messages)
154+
const chunks: any[] = []
155+
for await (const chunk of stream) {
156+
chunks.push(chunk)
157+
}
158+
159+
// Verify yielded content and usage from non-streaming path
160+
expect(chunks.length).toBeGreaterThan(0)
161+
expect(chunks[0]).toEqual({ type: "text", text: "Non-streamed reply" })
162+
const usage = chunks.find((c) => c.type === "usage")
163+
expect(usage).toBeTruthy()
164+
expect(usage.inputTokens).toBe(12)
165+
expect(usage.outputTokens).toBe(7)
166+
167+
// Ensure SDK was called with stream=false and structured input
168+
expect(mockResponsesCreate).toHaveBeenCalledTimes(1)
169+
const body = mockResponsesCreate.mock.calls[0][0]
170+
expect(body.stream).toBe(false)
171+
expect(body.instructions).toBe(systemPrompt)
172+
expect(body.input).toEqual([{ role: "user", content: [{ type: "input_text", text: "Hello!" }] }])
173+
})
174+
175+
it("should retry non-streaming when previous_response_id is invalid (400) and then succeed", async () => {
176+
// Reconfigure handler to force non-stream (stream=false)
177+
handler = new OpenAiNativeHandler({
178+
...mockOptions,
179+
openAiNativeUnverifiedOrg: true,
180+
})
181+
182+
// First SDK call fails with 400 indicating previous_response_id not found
183+
const err: any = new Error("Previous response not found")
184+
err.status = 400
185+
err.response = { status: 400 }
186+
mockResponsesCreate.mockRejectedValueOnce(err).mockResolvedValueOnce({
187+
id: "resp_after_retry",
188+
output: [
189+
{
190+
type: "message",
191+
content: [{ type: "output_text", text: "Reply after retry" }],
192+
},
193+
],
194+
usage: {
195+
input_tokens: 9,
196+
output_tokens: 3,
197+
cache_read_input_tokens: 0,
198+
cache_creation_input_tokens: 0,
199+
},
200+
})
201+
202+
const stream = handler.createMessage(systemPrompt, messages, {
203+
taskId: "t-1",
204+
previousResponseId: "resp_invalid",
205+
})
206+
207+
const chunks: any[] = []
208+
for await (const chunk of stream) {
209+
chunks.push(chunk)
210+
}
211+
212+
// Two SDK calls (retry path)
213+
expect(mockResponsesCreate).toHaveBeenCalledTimes(2)
214+
215+
// First call: includes previous_response_id and only latest user message
216+
const firstBody = mockResponsesCreate.mock.calls[0][0]
217+
expect(firstBody.stream).toBe(false)
218+
expect(firstBody.previous_response_id).toBe("resp_invalid")
219+
expect(firstBody.input).toEqual([{ role: "user", content: [{ type: "input_text", text: "Hello!" }] }])
220+
221+
// Second call (retry): no previous_response_id, includes full conversation (still single latest message in this test)
222+
const secondBody = mockResponsesCreate.mock.calls[1][0]
223+
expect(secondBody.stream).toBe(false)
224+
expect(secondBody.previous_response_id).toBeUndefined()
225+
expect(secondBody.instructions).toBe(systemPrompt)
226+
// With only one message in this suite, the "full conversation" equals the single user message
227+
expect(secondBody.input).toEqual([{ role: "user", content: [{ type: "input_text", text: "Hello!" }] }])
228+
229+
// Verify yielded chunks from retry
230+
expect(chunks[0]).toEqual({ type: "text", text: "Reply after retry" })
231+
const usage = chunks.find((c) => c.type === "usage")
232+
expect(usage.inputTokens).toBe(9)
233+
expect(usage.outputTokens).toBe(3)
234+
})
235+
236+
it("should NOT fallback to SSE when unverified org is true and non-stream SDK error occurs", async () => {
237+
// Force non-stream path via unverified org toggle
238+
handler = new OpenAiNativeHandler({
239+
...mockOptions,
240+
openAiNativeUnverifiedOrg: true, // => stream: false
241+
})
242+
243+
// Make SDK throw a non-previous_response error (e.g., 500)
244+
const err: any = new Error("Some server error")
245+
err.status = 500
246+
err.response = { status: 500 }
247+
mockResponsesCreate.mockRejectedValueOnce(err)
248+
249+
// Prepare a fetch mock to detect any unintended SSE fallback usage
250+
const mockFetch = vitest.fn()
251+
;(global as any).fetch = mockFetch as any
252+
253+
const stream = handler.createMessage(systemPrompt, messages)
254+
255+
// Expect iteration to reject and no SSE fallback to be attempted
256+
await expect(async () => {
257+
for await (const _ of stream) {
258+
// consume
259+
}
260+
}).rejects.toThrow("Some server error")
261+
262+
// Ensure SSE fallback was NOT invoked
263+
expect(mockFetch).not.toHaveBeenCalled()
264+
})
128265
})
129266

130267
describe("completePrompt", () => {
@@ -1734,3 +1871,87 @@ describe("GPT-5 streaming event coverage (additional)", () => {
17341871
})
17351872
})
17361873
})
1874+
1875+
describe("Unverified org gating behavior", () => {
1876+
beforeEach(() => {
1877+
// Ensure call counts don't accumulate from previous test suites
1878+
mockResponsesCreate.mockClear()
1879+
// Ensure no SSE fallback interference
1880+
if ((global as any).fetch) {
1881+
delete (global as any).fetch
1882+
}
1883+
})
1884+
1885+
afterEach(() => {
1886+
// Clean up any accidental fetch mocks
1887+
if ((global as any).fetch) {
1888+
delete (global as any).fetch
1889+
}
1890+
})
1891+
1892+
it("omits reasoning.summary in createMessage request when unverified org is true (GPT-5)", async () => {
1893+
// Arrange
1894+
const handler = new OpenAiNativeHandler({
1895+
apiModelId: "gpt-5-2025-08-07",
1896+
openAiNativeApiKey: "test-api-key",
1897+
openAiNativeUnverifiedOrg: true, // => stream=false, and summary must be omitted
1898+
})
1899+
1900+
// SDK returns a minimal valid non-stream response
1901+
mockResponsesCreate.mockResolvedValueOnce({
1902+
id: "resp_nonstream_2",
1903+
output: [],
1904+
usage: { input_tokens: 1, output_tokens: 1 },
1905+
})
1906+
1907+
// Act
1908+
const systemPrompt = "You are a helpful assistant."
1909+
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello!" }]
1910+
const stream = handler.createMessage(systemPrompt, messages)
1911+
for await (const _ of stream) {
1912+
// drain
1913+
}
1914+
1915+
// Assert
1916+
expect(mockResponsesCreate).toHaveBeenCalledTimes(1)
1917+
const body = mockResponsesCreate.mock.calls[0][0]
1918+
expect(body.model).toBe("gpt-5-2025-08-07")
1919+
expect(body.stream).toBe(false)
1920+
// GPT-5 includes reasoning effort; summary must be omitted for unverified orgs
1921+
expect(body.reasoning?.effort).toBeDefined()
1922+
expect(body.reasoning?.summary).toBeUndefined()
1923+
})
1924+
1925+
it("omits reasoning.summary in completePrompt request when unverified org is true (GPT-5)", async () => {
1926+
// Arrange
1927+
const handler = new OpenAiNativeHandler({
1928+
apiModelId: "gpt-5-2025-08-07",
1929+
openAiNativeApiKey: "test-api-key",
1930+
openAiNativeUnverifiedOrg: true, // => summary must be omitted in completePrompt too
1931+
})
1932+
1933+
// SDK returns a non-stream completion
1934+
mockResponsesCreate.mockResolvedValueOnce({
1935+
output: [
1936+
{
1937+
type: "message",
1938+
content: [{ type: "output_text", text: "Completion" }],
1939+
},
1940+
],
1941+
})
1942+
1943+
// Act
1944+
const result = await handler.completePrompt("Prompt text")
1945+
1946+
// Assert
1947+
expect(result).toBe("Completion")
1948+
expect(mockResponsesCreate).toHaveBeenCalledTimes(1)
1949+
const body = mockResponsesCreate.mock.calls[0][0]
1950+
expect(body.model).toBe("gpt-5-2025-08-07")
1951+
expect(body.stream).toBe(false)
1952+
expect(body.store).toBe(false)
1953+
// Reasoning present, but summary must be omitted
1954+
expect(body.reasoning?.effort).toBeDefined()
1955+
expect(body.reasoning?.summary).toBeUndefined()
1956+
})
1957+
})

0 commit comments

Comments
 (0)