adding responses api to oai
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user