Skip to content

Commit 5a3dacf

Browse files
ralyodioclaude
andcommitted
fix(ai): route OpenAI pro/codex models to Responses API
gpt-5.5-pro (and gpt-5-pro, o1/o3-pro, codex) are Responses-API-only and 404 on /chat/completions with 'not a chat model'. Detect them and route to /v1/responses in both the ai-sidebar extension and the model-providers adapter; other OpenAI-compatible providers stay on /chat/completions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d5c3eca commit 5a3dacf

3 files changed

Lines changed: 184 additions & 0 deletions

File tree

apps/desktop/extensions/ai-sidebar/providers.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,17 @@ export async function listModels(cfg) {
5757
.filter(Boolean);
5858
}
5959

60+
/**
61+
* OpenAI's "pro" reasoning models (gpt-5-pro, gpt-5.5-pro, o1-pro, o3-pro) and
62+
* the codex models are served only by the Responses API (/v1/responses). Posting
63+
* them to /chat/completions 404s with "This is not a chat model...". Route those
64+
* to /responses. Only OpenAI itself has this endpoint — the other
65+
* OpenAI-compatible providers stay on /chat/completions.
66+
*/
67+
export function usesResponsesApi(provider, model) {
68+
return provider === 'openai' && /-pro(\b|-)|codex/i.test(model || '');
69+
}
70+
6071
async function* sse(res) {
6172
const reader = res.body.getReader();
6273
const dec = new TextDecoder();
@@ -109,6 +120,32 @@ export async function chatStream(cfg, messages, onDelta) {
109120

110121
const headers = { 'content-type': 'application/json' };
111122
if (cfg.apiKey) headers['authorization'] = 'Bearer ' + cfg.apiKey;
123+
124+
if (usesResponsesApi(cfg.provider, cfg.model)) {
125+
// Responses API: `input` takes the same role/content messages; pro models
126+
// reject `temperature`, so send only model + input. Deltas arrive as
127+
// `response.output_text.delta` events instead of chat `choices[].delta`.
128+
const res = await fetch(baseUrl + '/responses', {
129+
method: 'POST',
130+
headers,
131+
body: JSON.stringify({ model: cfg.model, input: messages, stream: true }),
132+
});
133+
if (!res.ok) throw new Error(cfg.provider + ' ' + res.status + ': ' + (await res.text()));
134+
for await (const data of sse(res)) {
135+
if (data === '[DONE]') break;
136+
try {
137+
const evt = JSON.parse(data);
138+
if (evt.type === 'response.output_text.delta' && typeof evt.delta === 'string') {
139+
full += evt.delta;
140+
onDelta(evt.delta);
141+
} else if (evt.type === 'response.completed' || evt.type === 'response.failed') {
142+
break;
143+
}
144+
} catch { /* ignore keep-alives */ }
145+
}
146+
return full;
147+
}
148+
112149
const res = await fetch(baseUrl + '/chat/completions', {
113150
method: 'POST',
114151
headers,

packages/model-providers/src/adapter-openai.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,30 @@ export interface OpenAIAdapterConfig {
1919
fetchImpl?: typeof fetch;
2020
}
2121

22+
/**
23+
* OpenAI's "pro" reasoning models (gpt-5-pro, gpt-5.5-pro, o1-pro, o3-pro) and
24+
* the codex models are served only by the Responses API (/v1/responses). Posting
25+
* them to /chat/completions 404s with "This is not a chat model...". Route those
26+
* to /responses. Only OpenAI itself exposes that endpoint — the other
27+
* OpenAI-compatible providers (DeepSeek, Perplexity, …) stay on /chat/completions.
28+
*/
29+
export function usesResponsesApi(providerId: ProviderId, model: string): boolean {
30+
return providerId === 'openai' && /-pro(\b|-)|codex/i.test(model);
31+
}
32+
33+
/** Concatenates the text of every `output_text` part in a Responses `output`. */
34+
function extractResponsesText(
35+
output?: { type?: string; content?: { type?: string; text?: string }[] }[],
36+
): string {
37+
let text = '';
38+
for (const item of output ?? []) {
39+
for (const part of item.content ?? []) {
40+
if (part.type === 'output_text' && part.text) text += part.text;
41+
}
42+
}
43+
return text;
44+
}
45+
2246
export class OpenAICompatibleProvider implements ModelProvider {
2347
constructor(
2448
readonly id: ProviderId,
@@ -44,6 +68,7 @@ export class OpenAICompatibleProvider implements ModelProvider {
4468
}
4569

4670
async complete(req: CompletionRequest): Promise<CompletionResponse> {
71+
if (usesResponsesApi(this.id, req.model)) return this.completeViaResponses(req);
4772
const res = await this.fetch(`${this.config.baseUrl}/chat/completions`, {
4873
method: 'POST',
4974
headers: this.headers(),
@@ -72,6 +97,10 @@ export class OpenAICompatibleProvider implements ModelProvider {
7297
}
7398

7499
async *stream(req: CompletionRequest): AsyncIterable<CompletionChunk> {
100+
if (usesResponsesApi(this.id, req.model)) {
101+
yield* this.streamViaResponses(req);
102+
return;
103+
}
75104
const res = await this.fetch(`${this.config.baseUrl}/chat/completions`, {
76105
method: 'POST',
77106
headers: this.headers(),
@@ -101,6 +130,73 @@ export class OpenAICompatibleProvider implements ModelProvider {
101130
}
102131
yield { delta: '', done: true };
103132
}
133+
134+
/**
135+
* Responses API (/v1/responses) path for OpenAI's pro/codex models. The
136+
* role/content messages map straight onto `input`; pro models reject
137+
* `temperature`, so it is omitted.
138+
*/
139+
private async completeViaResponses(req: CompletionRequest): Promise<CompletionResponse> {
140+
const res = await this.fetch(`${this.config.baseUrl}/responses`, {
141+
method: 'POST',
142+
headers: this.headers(),
143+
body: JSON.stringify({
144+
model: req.model,
145+
input: req.messages,
146+
max_output_tokens: req.maxTokens,
147+
stream: false,
148+
}),
149+
});
150+
if (!res.ok) throw new Error(`${this.id} completion failed: ${res.status} ${await res.text()}`);
151+
const body = (await res.json()) as {
152+
output_text?: string;
153+
output?: { type?: string; content?: { type?: string; text?: string }[] }[];
154+
usage?: { input_tokens: number; output_tokens: number };
155+
};
156+
const text = body.output_text ?? extractResponsesText(body.output);
157+
const out: CompletionResponse = { text, model: req.model };
158+
if (body.usage) {
159+
out.usage = {
160+
promptTokens: body.usage.input_tokens,
161+
completionTokens: body.usage.output_tokens,
162+
};
163+
}
164+
return out;
165+
}
166+
167+
private async *streamViaResponses(req: CompletionRequest): AsyncIterable<CompletionChunk> {
168+
const res = await this.fetch(`${this.config.baseUrl}/responses`, {
169+
method: 'POST',
170+
headers: this.headers(),
171+
body: JSON.stringify({
172+
model: req.model,
173+
input: req.messages,
174+
max_output_tokens: req.maxTokens,
175+
stream: true,
176+
}),
177+
});
178+
if (!res.ok || !res.body) {
179+
throw new Error(`${this.id} stream failed: ${res.status}`);
180+
}
181+
for await (const data of sseLines(res.body)) {
182+
if (data === '[DONE]') {
183+
yield { delta: '', done: true };
184+
return;
185+
}
186+
try {
187+
const evt = JSON.parse(data) as { type?: string; delta?: string };
188+
if (evt.type === 'response.output_text.delta' && typeof evt.delta === 'string') {
189+
yield { delta: evt.delta, done: false };
190+
} else if (evt.type === 'response.completed' || evt.type === 'response.failed') {
191+
yield { delta: '', done: true };
192+
return;
193+
}
194+
} catch {
195+
// ignore keep-alive / non-JSON lines
196+
}
197+
}
198+
yield { delta: '', done: true };
199+
}
104200
}
105201

106202
/** Yields the payload of each `data:` line from an SSE response stream. */

packages/model-providers/src/adapter.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,57 @@ describe('OpenAI-compatible adapter', () => {
6363
expect(out.join('')).toBe('Hello');
6464
});
6565

66+
it('routes OpenAI pro models through /responses', async () => {
67+
const fetchImpl = vi.fn(async () =>
68+
jsonResponse({
69+
output: [{ type: 'message', content: [{ type: 'output_text', text: 'deep answer' }] }],
70+
usage: { input_tokens: 7, output_tokens: 4 },
71+
}),
72+
) as unknown as typeof fetch;
73+
74+
const provider = createProvider('openai', { apiKey: 'k', fetchImpl });
75+
const res = await provider.complete({ model: 'gpt-5.5-pro', messages: [{ role: 'user', content: 'hi' }] });
76+
77+
expect(res.text).toBe('deep answer');
78+
expect(res.usage).toEqual({ promptTokens: 7, completionTokens: 4 });
79+
const [url, init] = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
80+
expect(url).toBe('https://api.openai.com/v1/responses');
81+
const sent = JSON.parse((init as RequestInit).body as string);
82+
expect(sent.input).toEqual([{ role: 'user', content: 'hi' }]);
83+
expect(sent).not.toHaveProperty('messages');
84+
});
85+
86+
it('streams OpenAI pro models from Responses events', async () => {
87+
const fetchImpl = vi.fn(async () =>
88+
sseResponse([
89+
'data: {"type":"response.output_text.delta","delta":"Hel"}\n',
90+
'data: {"type":"response.output_text.delta","delta":"lo"}\n',
91+
'data: {"type":"response.completed"}\n',
92+
]),
93+
) as unknown as typeof fetch;
94+
95+
const provider = createProvider('openai', { apiKey: 'k', fetchImpl });
96+
const out: string[] = [];
97+
for await (const c of provider.stream({ model: 'gpt-5.5-pro', messages: [{ role: 'user', content: 'hi' }] })) {
98+
if (c.delta) out.push(c.delta);
99+
}
100+
expect(out.join('')).toBe('Hello');
101+
expect((fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0]).toBe(
102+
'https://api.openai.com/v1/responses',
103+
);
104+
});
105+
106+
it('keeps non-pro OpenAI models on /chat/completions', async () => {
107+
const fetchImpl = vi.fn(async () =>
108+
jsonResponse({ choices: [{ message: { content: 'ok' } }] }),
109+
) as unknown as typeof fetch;
110+
const provider = createProvider('openai', { apiKey: 'k', fetchImpl });
111+
await provider.complete({ model: 'gpt-5.5', messages: [{ role: 'user', content: 'hi' }] });
112+
expect((fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0]).toBe(
113+
'https://api.openai.com/v1/chat/completions',
114+
);
115+
});
116+
66117
it('throws on a non-ok response', async () => {
67118
const fetchImpl = vi.fn(async () => jsonResponse({ error: 'nope' }, false, 401)) as unknown as typeof fetch;
68119
const provider = createProvider('openai', { apiKey: 'bad', fetchImpl });

0 commit comments

Comments
 (0)