adding responses api to oai
This commit is contained in:
@@ -54,6 +54,8 @@ curl http://localhost:4005/oai/v1/chat/completions \
|
|||||||
-d '{"model":"kimi-k3","messages":[{"role":"user","content":"Hello"}]}'
|
-d '{"model":"kimi-k3","messages":[{"role":"user","content":"Hello"}]}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Codex and other OpenAI Responses API clients can use `POST /oai/v1/responses`. Responses requests are translated to the upstream Chat Completions API and responses, including streaming events, are translated back to the Responses format.
|
||||||
|
|
||||||
Streaming requests are supported with `"stream":true` and are passed through as Server-Sent Events. The model list is fetched from OpenCode Go, so it can include models whose upstream transport is not OpenAI Chat Completions.
|
Streaming requests are supported with `"stream":true` and are passed through as Server-Sent Events. The model list is fetched from OpenCode Go, so it can include models whose upstream transport is not OpenAI Chat Completions.
|
||||||
|
|
||||||
The Anthropic-compatible adapter exposes `POST /ant/v1/messages` and `GET /ant/v1/models`. Messages, tools, JSON responses, and streaming events are translated to and from OpenAI Chat Completions. Anthropic clients should use their normal Messages API request format and can use the OpenCode model IDs returned by the models endpoint.
|
The Anthropic-compatible adapter exposes `POST /ant/v1/messages` and `GET /ant/v1/models`. Messages, tools, JSON responses, and streaming events are translated to and from OpenAI Chat Completions. Anthropic clients should use their normal Messages API request format and can use the OpenCode model IDs returned by the models endpoint.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
translateAnthropicResponse,
|
translateAnthropicResponse,
|
||||||
translateOpenAIChunk,
|
translateOpenAIChunk,
|
||||||
} from './anthropic.js';
|
} from './anthropic.js';
|
||||||
|
import { translateResponsesRequest, translateResponsesResponse, translateResponsesChunk } from './responses.js';
|
||||||
|
|
||||||
export const DEFAULT_UPSTREAM_BASE_URL = 'https://opencode.ai/zen/go/v1';
|
export const DEFAULT_UPSTREAM_BASE_URL = 'https://opencode.ai/zen/go/v1';
|
||||||
|
|
||||||
@@ -179,6 +180,70 @@ export function createProxyApp({ keys, fetchImpl = globalThis.fetch, upstreamBas
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/oai/v1/responses', async (request, response) => {
|
||||||
|
let translated;
|
||||||
|
try {
|
||||||
|
translated = translateResponsesRequest(request.body);
|
||||||
|
validateContext(translated, maxContextTokens);
|
||||||
|
} catch (error) {
|
||||||
|
return response.status(error.status || 400).json({ error: { message: error.message, type: 'invalid_request_error' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ip = clientIp(request);
|
||||||
|
const limit = rateLimiter.check(ip);
|
||||||
|
if (!limit.allowed) return rateLimitError(response, false, limit);
|
||||||
|
const upstream = await fetchWithKeyRetries(`${upstreamBaseUrl}/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', accept: translated.stream ? 'text/event-stream' : 'application/json' },
|
||||||
|
body: JSON.stringify(translated),
|
||||||
|
});
|
||||||
|
if (!upstream.ok) return forwardError(upstream, response);
|
||||||
|
|
||||||
|
const responseId = `resp_${Date.now()}`;
|
||||||
|
if (!translated.stream) {
|
||||||
|
const body = await upstream.text();
|
||||||
|
const payload = JSON.parse(body);
|
||||||
|
rateLimiter.recordCost(ip, numericCost(payload));
|
||||||
|
return response.type('application/json').send(JSON.stringify(translateResponsesResponse(payload, translated.model, responseId)));
|
||||||
|
}
|
||||||
|
if (!upstream.body) return response.status(502).json({ error: { type: 'upstream_error', message: 'Upstream returned no streaming body' } });
|
||||||
|
|
||||||
|
response.status(upstream.status).type('text/event-stream');
|
||||||
|
const reader = upstream.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
let recordedCost = false;
|
||||||
|
const state = { id: responseId, model: translated.model };
|
||||||
|
const writeEvents = (text) => {
|
||||||
|
for (const eventText of text.split(/\n\n/)) {
|
||||||
|
if (!eventText.trim()) continue;
|
||||||
|
const data = eventText.split('\n').find((line) => line.startsWith('data:'))?.slice(5).trim();
|
||||||
|
if (!data || data === '[DONE]') continue;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(data);
|
||||||
|
const cost = numericCost(payload);
|
||||||
|
if (!recordedCost && cost !== null) { rateLimiter.recordCost(ip, cost); recordedCost = true; }
|
||||||
|
response.write(translateResponsesChunk(payload, state));
|
||||||
|
} catch { /* ignore malformed upstream events */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
|
||||||
|
const parts = buffer.split('\n\n');
|
||||||
|
buffer = parts.pop() || '';
|
||||||
|
writeEvents(parts.join('\n\n'));
|
||||||
|
if (done) break;
|
||||||
|
}
|
||||||
|
writeEvents(buffer);
|
||||||
|
response.end();
|
||||||
|
} catch (error) {
|
||||||
|
if (!response.headersSent) response.status(error.status || 502).json({ error: { message: error.status === 400 ? error.message : `Unable to reach OpenCode Go: ${error.message}`, type: error.status === 400 ? 'invalid_request_error' : 'upstream_error' } });
|
||||||
|
else response.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post(['/ant/v1/messages', '/ant/v1/v1/messages'], async (request, response) => {
|
app.post(['/ant/v1/messages', '/ant/v1/v1/messages'], async (request, response) => {
|
||||||
let translated;
|
let translated;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
function responseError(message) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.status = 400;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentToChat(content) {
|
||||||
|
if (content === null) return null;
|
||||||
|
if (typeof content === 'string') return content;
|
||||||
|
if (!Array.isArray(content)) throw responseError('message content must be a string or array');
|
||||||
|
return content.map((part) => {
|
||||||
|
if (part?.type === 'input_text' || part?.type === 'output_text' || part?.type === 'text') return { type: 'text', text: part.text || '' };
|
||||||
|
if (part?.type === 'input_image' && part.image_url) return { type: 'image_url', image_url: { url: part.image_url } };
|
||||||
|
if (part?.type === 'image_url' && part.image_url?.url) return part;
|
||||||
|
throw responseError(`Unsupported input content type: ${part?.type || 'unknown'}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMessage(messages, role, content) {
|
||||||
|
const message = { role, content: contentToChat(content) };
|
||||||
|
messages.push(message);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translateResponsesRequest(body = {}) {
|
||||||
|
if (!body.model) throw responseError('model is required');
|
||||||
|
if (body.input === undefined) throw responseError('input is required');
|
||||||
|
|
||||||
|
const messages = [];
|
||||||
|
if (body.instructions !== undefined) addMessage(messages, 'system', body.instructions);
|
||||||
|
|
||||||
|
const input = Array.isArray(body.input) ? body.input : [{ type: 'message', role: 'user', content: body.input }];
|
||||||
|
for (const item of input) {
|
||||||
|
if (item?.type === 'message' || item?.role) {
|
||||||
|
const role = item.role === 'developer' ? 'system' : item.role;
|
||||||
|
if (!['system', 'user', 'assistant'].includes(role)) throw responseError(`Unsupported input role: ${item.role || 'missing'}`);
|
||||||
|
addMessage(messages, role, item.content ?? '');
|
||||||
|
} else if (item?.type === 'function_call_output') {
|
||||||
|
messages.push({ role: 'tool', tool_call_id: item.call_id, content: String(item.output ?? '') });
|
||||||
|
} else if (item?.type === 'function_call') {
|
||||||
|
const assistant = messages.at(-1)?.role === 'assistant' ? messages.at(-1) : addMessage(messages, 'assistant', null);
|
||||||
|
assistant.tool_calls = assistant.tool_calls || [];
|
||||||
|
assistant.tool_calls.push({ id: item.call_id, type: 'function', function: { name: item.name, arguments: item.arguments || '{}' } });
|
||||||
|
} else if (item?.type === 'reasoning') {
|
||||||
|
// Reasoning items are opaque to the Chat Completions transport. Keep the
|
||||||
|
// conversation valid while allowing a subsequent Responses turn.
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
throw responseError(`Unsupported input item type: ${item?.type || 'unknown'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = { model: body.model, messages };
|
||||||
|
if (body.max_output_tokens !== undefined) result.max_tokens = body.max_output_tokens;
|
||||||
|
for (const field of ['temperature', 'top_p']) if (body[field] !== undefined) result[field] = body[field];
|
||||||
|
if (body.stream !== undefined) result.stream = body.stream;
|
||||||
|
if (body.tools !== undefined) {
|
||||||
|
result.tools = body.tools.map((tool) => {
|
||||||
|
const definition = tool?.function || tool;
|
||||||
|
// Chat Completions can only represent callable function tools. Codex may
|
||||||
|
// also send Responses-native built-ins (for example web/search tools),
|
||||||
|
// which this local Chat Completions upstream cannot execute.
|
||||||
|
if (!definition?.name) return null;
|
||||||
|
return { type: 'function', function: { name: definition.name, description: definition.description, parameters: definition.parameters || { type: 'object' }, strict: definition.strict } };
|
||||||
|
}).filter(Boolean);
|
||||||
|
}
|
||||||
|
if (body.tool_choice !== undefined) {
|
||||||
|
if (body.tool_choice?.type === 'function' && body.tool_choice.name) {
|
||||||
|
result.tool_choice = { type: 'function', function: { name: body.tool_choice.name } };
|
||||||
|
} else {
|
||||||
|
result.tool_choice = body.tool_choice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usageOf(usage = {}) {
|
||||||
|
const input = usage.prompt_tokens ?? usage.input_tokens ?? 0;
|
||||||
|
const output = usage.completion_tokens ?? usage.output_tokens ?? 0;
|
||||||
|
return { input_tokens: input, output_tokens: output, total_tokens: input + output };
|
||||||
|
}
|
||||||
|
|
||||||
|
function outputFromMessage(message, responseId) {
|
||||||
|
const output = [];
|
||||||
|
if (message.reasoning_content) output.push({
|
||||||
|
type: 'reasoning', id: `${responseId}_reasoning`, status: 'completed', summary: [{ type: 'summary_text', text: message.reasoning_content }],
|
||||||
|
});
|
||||||
|
if (message.content) output.push({
|
||||||
|
type: 'message', id: `${responseId}_msg`, status: 'completed', role: 'assistant',
|
||||||
|
content: [{ type: 'output_text', text: message.content, annotations: [] }],
|
||||||
|
});
|
||||||
|
for (const call of message.tool_calls || []) output.push({
|
||||||
|
type: 'function_call', id: call.id || `${responseId}_call`, call_id: call.id, name: call.function?.name, arguments: call.function?.arguments || '{}', status: 'completed',
|
||||||
|
});
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translateResponsesResponse(payload, requestedModel, responseId = `resp_${Date.now()}`) {
|
||||||
|
const choice = payload.choices?.[0] || {};
|
||||||
|
const output = outputFromMessage(choice.message || {}, responseId);
|
||||||
|
return {
|
||||||
|
id: payload.id?.startsWith('resp_') ? payload.id : responseId,
|
||||||
|
object: 'response', created_at: Math.floor(Date.now() / 1000), status: 'completed',
|
||||||
|
model: payload.model || requestedModel, output, output_text: output.filter((item) => item.type === 'message').flatMap((item) => item.content).map((part) => part.text).join(''),
|
||||||
|
usage: usageOf(payload.usage), error: null, incomplete_details: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sse(type, data) { return `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`; }
|
||||||
|
|
||||||
|
export function translateResponsesChunk(chunk, state) {
|
||||||
|
const choice = chunk.choices?.[0];
|
||||||
|
if (!choice) return '';
|
||||||
|
state.nextOutputIndex ??= 0;
|
||||||
|
const delta = choice.delta || {};
|
||||||
|
let output = '';
|
||||||
|
if (!state.started) {
|
||||||
|
state.started = true;
|
||||||
|
output += sse('response.created', { type: 'response.created', response: { id: state.id, object: 'response', status: 'in_progress', model: state.model, output: [], usage: null } });
|
||||||
|
output += sse('response.in_progress', { type: 'response.in_progress', response: { id: state.id, object: 'response', status: 'in_progress', model: state.model } });
|
||||||
|
}
|
||||||
|
const reasoning = delta.reasoning_content || delta.reasoning;
|
||||||
|
if (reasoning) {
|
||||||
|
state.reasoningText = (state.reasoningText || '') + reasoning;
|
||||||
|
if (!state.reasoningStarted) {
|
||||||
|
state.reasoningStarted = true;
|
||||||
|
state.reasoningIndex = state.nextOutputIndex++;
|
||||||
|
output += sse('response.output_item.added', { type: 'response.output_item.added', output_index: state.reasoningIndex, item: { type: 'reasoning', id: `${state.id}_reasoning`, status: 'in_progress', summary: [] } });
|
||||||
|
output += sse('response.reasoning_summary_part.added', { type: 'response.reasoning_summary_part.added', item_id: `${state.id}_reasoning`, output_index: state.reasoningIndex, summary_index: 0, part: { type: 'summary_text', text: '' } });
|
||||||
|
}
|
||||||
|
output += sse('response.reasoning_summary_text.delta', { type: 'response.reasoning_summary_text.delta', item_id: `${state.id}_reasoning`, output_index: state.reasoningIndex, summary_index: 0, delta: reasoning });
|
||||||
|
}
|
||||||
|
if (delta.content) {
|
||||||
|
state.text = (state.text || '') + delta.content;
|
||||||
|
if (!state.messageStarted) {
|
||||||
|
state.messageStarted = true;
|
||||||
|
state.messageIndex = state.nextOutputIndex++;
|
||||||
|
output += sse('response.output_item.added', { type: 'response.output_item.added', output_index: state.messageIndex, item: { type: 'message', id: `${state.id}_msg`, status: 'in_progress', role: 'assistant', content: [] } });
|
||||||
|
output += sse('response.content_part.added', { type: 'response.content_part.added', item_id: `${state.id}_msg`, output_index: state.messageIndex, content_index: 0, part: { type: 'output_text', text: '', annotations: [] } });
|
||||||
|
}
|
||||||
|
output += sse('response.output_text.delta', { type: 'response.output_text.delta', item_id: `${state.id}_msg`, output_index: state.messageIndex, content_index: 0, delta: delta.content });
|
||||||
|
}
|
||||||
|
if (delta.tool_calls?.length) {
|
||||||
|
state.tools = state.tools || [];
|
||||||
|
for (const call of delta.tool_calls) {
|
||||||
|
const index = call.index ?? 0;
|
||||||
|
let tool = state.tools[index];
|
||||||
|
if (!tool) {
|
||||||
|
tool = { outputIndex: state.nextOutputIndex++, id: call.id || `${state.id}_call_${index}`, name: call.function?.name || '', arguments: '' };
|
||||||
|
state.tools[index] = tool;
|
||||||
|
output += sse('response.output_item.added', { type: 'response.output_item.added', output_index: tool.outputIndex, item: { type: 'function_call', id: tool.id, call_id: tool.id, name: tool.name, arguments: '', status: 'in_progress' } });
|
||||||
|
}
|
||||||
|
if (call.function?.arguments) {
|
||||||
|
tool.arguments += call.function.arguments;
|
||||||
|
output += sse('response.function_call_arguments.delta', { type: 'response.function_call_arguments.delta', item_id: tool.id, output_index: tool.outputIndex, delta: call.function.arguments });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (choice.finish_reason) {
|
||||||
|
if (state.reasoningStarted) {
|
||||||
|
output += sse('response.reasoning_summary_text.done', { type: 'response.reasoning_summary_text.done', item_id: `${state.id}_reasoning`, output_index: state.reasoningIndex, summary_index: 0, text: state.reasoningText || '' });
|
||||||
|
output += sse('response.reasoning_summary_part.done', { type: 'response.reasoning_summary_part.done', item_id: `${state.id}_reasoning`, output_index: state.reasoningIndex, summary_index: 0, part: { type: 'summary_text', text: state.reasoningText || '' } });
|
||||||
|
output += sse('response.output_item.done', { type: 'response.output_item.done', output_index: state.reasoningIndex, item: { type: 'reasoning', id: `${state.id}_reasoning`, status: 'completed', summary: [{ type: 'summary_text', text: state.reasoningText || '' }] } });
|
||||||
|
}
|
||||||
|
if (state.messageStarted) {
|
||||||
|
output += sse('response.output_text.done', { type: 'response.output_text.done', item_id: `${state.id}_msg`, output_index: state.messageIndex, content_index: 0, text: state.text || '' });
|
||||||
|
output += sse('response.content_part.done', { type: 'response.content_part.done', item_id: `${state.id}_msg`, output_index: state.messageIndex, content_index: 0, part: { type: 'output_text', text: state.text || '', annotations: [] } });
|
||||||
|
output += sse('response.output_item.done', { type: 'response.output_item.done', output_index: state.messageIndex, item: { type: 'message', id: `${state.id}_msg`, status: 'completed', role: 'assistant', content: [{ type: 'output_text', text: state.text || '', annotations: [] }] } });
|
||||||
|
}
|
||||||
|
const completedOutput = [];
|
||||||
|
if (state.reasoningStarted) completedOutput.push({ type: 'reasoning', id: `${state.id}_reasoning`, status: 'completed', summary: [{ type: 'summary_text', text: state.reasoningText || '' }] });
|
||||||
|
if (state.messageStarted) completedOutput.push({ type: 'message', id: `${state.id}_msg`, status: 'completed', role: 'assistant', content: [{ type: 'output_text', text: state.text || '', annotations: [] }] });
|
||||||
|
for (const tool of state.tools || []) {
|
||||||
|
if (!tool) continue;
|
||||||
|
output += sse('response.function_call_arguments.done', { type: 'response.function_call_arguments.done', item_id: tool.id, output_index: tool.outputIndex, arguments: tool.arguments });
|
||||||
|
const item = { type: 'function_call', id: tool.id, call_id: tool.id, name: tool.name, arguments: tool.arguments, status: 'completed' };
|
||||||
|
output += sse('response.output_item.done', { type: 'response.output_item.done', output_index: tool.outputIndex, item });
|
||||||
|
completedOutput.push(item);
|
||||||
|
}
|
||||||
|
output += sse('response.completed', { type: 'response.completed', response: { id: state.id, object: 'response', status: 'completed', model: state.model, output: completedOutput, usage: usageOf(chunk.usage) } });
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
@@ -87,6 +87,90 @@ describe('proxy', () => {
|
|||||||
expect(fetchImpl.mock.calls[0][1].headers.accept).toBe('text/event-stream');
|
expect(fetchImpl.mock.calls[0][1].headers.accept).toBe('text/event-stream');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('translates Responses requests and returns a Responses object', async () => {
|
||||||
|
const fetchImpl = vi.fn().mockResolvedValue(response(JSON.stringify({
|
||||||
|
id: 'chatcmpl-response', model: 'm',
|
||||||
|
choices: [{ message: { role: 'assistant', content: 'Hello from Responses' }, finish_reason: 'stop' }],
|
||||||
|
usage: { prompt_tokens: 8, completion_tokens: 3 },
|
||||||
|
})));
|
||||||
|
const app = createProxyApp({ keys: ['key'], fetchImpl });
|
||||||
|
const result = await request(app).post('/oai/v1/responses').send({
|
||||||
|
model: 'm', instructions: 'Be brief', max_output_tokens: 20,
|
||||||
|
input: [{ role: 'user', content: [{ type: 'input_text', text: 'Hello' }] }],
|
||||||
|
}).expect(200);
|
||||||
|
|
||||||
|
expect(result.body).toMatchObject({ object: 'response', status: 'completed', model: 'm' });
|
||||||
|
expect(result.body.output[0].content[0]).toMatchObject({ type: 'output_text', text: 'Hello from Responses' });
|
||||||
|
expect(result.body.usage).toEqual({ input_tokens: 8, output_tokens: 3, total_tokens: 11 });
|
||||||
|
expect(JSON.parse(fetchImpl.mock.calls[0][1].body)).toEqual({
|
||||||
|
model: 'm', max_tokens: 20,
|
||||||
|
messages: [{ role: 'system', content: 'Be brief' }, { role: 'user', content: [{ type: 'text', text: 'Hello' }] }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('translates Responses streaming events', async () => {
|
||||||
|
const fetchImpl = vi.fn().mockResolvedValue(response([
|
||||||
|
'data: {"id":"c1","model":"m","choices":[{"delta":{"role":"assistant","content":"Hi"}}]}',
|
||||||
|
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}',
|
||||||
|
'data: [DONE]', '',
|
||||||
|
].join('\n\n'), { headers: { 'content-type': 'text/event-stream' } }));
|
||||||
|
const app = createProxyApp({ keys: ['key'], fetchImpl });
|
||||||
|
const result = await request(app).post('/oai/v1/responses').send({ model: 'm', input: 'Hi', stream: true }).expect(200);
|
||||||
|
expect(result.text).toContain('event: response.created');
|
||||||
|
expect(result.text).toContain('event: response.output_text.delta');
|
||||||
|
expect(result.text).toContain('"delta":"Hi"');
|
||||||
|
expect(result.text).toContain('event: response.completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('streams tool calls as completed Responses function_call items', async () => {
|
||||||
|
const fetchImpl = vi.fn().mockResolvedValue(response([
|
||||||
|
'data: {"id":"c-tool","model":"m","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call-9","function":{"name":"read_file","arguments":"{\\"path\\":"}}]}}]}',
|
||||||
|
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\"main.py\\"}"}}]}}]}',
|
||||||
|
'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}',
|
||||||
|
'data: [DONE]', '',
|
||||||
|
].join('\n\n'), { headers: { 'content-type': 'text/event-stream' } }));
|
||||||
|
const app = createProxyApp({ keys: ['key'], fetchImpl });
|
||||||
|
const result = await request(app).post('/oai/v1/responses').send({ model: 'm', input: 'explore', stream: true }).expect(200);
|
||||||
|
expect(result.text).toContain('event: response.output_item.added');
|
||||||
|
expect(result.text).toContain('"name":"read_file"');
|
||||||
|
expect(result.text).toContain('event: response.function_call_arguments.delta');
|
||||||
|
expect(result.text).toContain('event: response.function_call_arguments.done');
|
||||||
|
expect(result.text).toContain('"arguments":"{\\"path\\":\\"main.py\\"}"');
|
||||||
|
expect(result.text).toContain('event: response.output_item.done');
|
||||||
|
const completed = result.text.split('\n\n').find((line) => line.startsWith('event: response.completed'));
|
||||||
|
expect(completed).toContain('"type":"function_call"');
|
||||||
|
expect(completed).toContain('"call_id":"call-9"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('streams reasoning content as Responses reasoning events', async () => {
|
||||||
|
const fetchImpl = vi.fn().mockResolvedValue(response([
|
||||||
|
'data: {"id":"c-reason","model":"m","choices":[{"delta":{"reasoning_content":"think first "}}]}',
|
||||||
|
'data: {"choices":[{"delta":{"reasoning_content":"then answer"}}]}',
|
||||||
|
'data: {"choices":[{"delta":{"content":"done"},"finish_reason":"stop"}]}',
|
||||||
|
'data: [DONE]', '',
|
||||||
|
].join('\n\n'), { headers: { 'content-type': 'text/event-stream' } }));
|
||||||
|
const app = createProxyApp({ keys: ['key'], fetchImpl });
|
||||||
|
const result = await request(app).post('/oai/v1/responses').send({ model: 'm', input: 'Hi', stream: true }).expect(200);
|
||||||
|
expect(result.text).toContain('event: response.reasoning_summary_text.delta');
|
||||||
|
expect(result.text).toContain('"delta":"think first "');
|
||||||
|
expect(result.text).toContain('"delta":"then answer"');
|
||||||
|
expect(result.text).toContain('event: response.reasoning_summary_text.done');
|
||||||
|
expect(result.text).toContain('"output_index":1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts nested Chat Completions function tools from Codex', async () => {
|
||||||
|
const fetchImpl = vi.fn().mockResolvedValue(response(JSON.stringify({
|
||||||
|
id: 'chatcmpl-tool', choices: [{ message: { role: 'assistant', content: 'done' }, finish_reason: 'stop' }], usage: {},
|
||||||
|
})));
|
||||||
|
const app = createProxyApp({ keys: ['key'], fetchImpl });
|
||||||
|
await request(app).post('/oai/v1/responses').send({
|
||||||
|
model: 'm', input: 'lookup', tools: [{ type: 'function', function: { name: 'lookup', parameters: { type: 'object' } } }],
|
||||||
|
}).expect(200);
|
||||||
|
expect(JSON.parse(fetchImpl.mock.calls[0][1].body).tools).toEqual([{
|
||||||
|
type: 'function', function: { name: 'lookup', parameters: { type: 'object' } },
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
it('propagates upstream errors', async () => {
|
it('propagates upstream errors', async () => {
|
||||||
const fetchImpl = vi.fn().mockResolvedValue(response('{"error":"bad key"}', { status: 401 }));
|
const fetchImpl = vi.fn().mockResolvedValue(response('{"error":"bad key"}', { status: 401 }));
|
||||||
const app = createProxyApp({ keys: ['key'], fetchImpl });
|
const app = createProxyApp({ keys: ['key'], fetchImpl });
|
||||||
|
|||||||
Reference in New Issue
Block a user