adding responses api to oai

This commit is contained in:
2026-07-21 11:17:44 -05:00
parent 0e9131dafa
commit 08a11825ea
4 changed files with 334 additions and 0 deletions
+65
View File
@@ -13,6 +13,7 @@ import {
translateAnthropicResponse,
translateOpenAIChunk,
} from './anthropic.js';
import { translateResponsesRequest, translateResponsesResponse, translateResponsesChunk } from './responses.js';
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) => {
let translated;
try {
+183
View File
@@ -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;
}