feat(responses): respect client streaming preference + string input support (#121)
- Remove forced stream=true from responsesHandler - Add stream-to-JSON converter for non-streaming clients (Codex) - Accept string input in Responses API (normalize to array) - Codex SSE header fallback for missing Content-Type - Refactor: extract shared normalizeResponsesInput() Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { BaseExecutor } from "./base.js";
|
import { BaseExecutor } from "./base.js";
|
||||||
import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.js";
|
import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.js";
|
||||||
import { PROVIDERS } from "../config/constants.js";
|
import { PROVIDERS } from "../config/constants.js";
|
||||||
|
import { normalizeResponsesInput } from "../translator/helpers/responsesApiHelper.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Codex Executor - handles OpenAI Codex API (Responses API format)
|
* Codex Executor - handles OpenAI Codex API (Responses API format)
|
||||||
@@ -24,6 +25,10 @@ export class CodexExecutor extends BaseExecutor {
|
|||||||
* Transform request before sending - inject default instructions if missing
|
* Transform request before sending - inject default instructions if missing
|
||||||
*/
|
*/
|
||||||
transformRequest(model, body, stream, credentials) {
|
transformRequest(model, body, stream, credentials) {
|
||||||
|
// Convert string input to array format (Codex API requires input as array)
|
||||||
|
const normalized = normalizeResponsesInput(body.input);
|
||||||
|
if (normalized) body.input = normalized;
|
||||||
|
|
||||||
// Ensure input is present and non-empty (Codex API rejects empty input)
|
// Ensure input is present and non-empty (Codex API rejects empty input)
|
||||||
if (!body.input || (Array.isArray(body.input) && body.input.length === 0)) {
|
if (!body.input || (Array.isArray(body.input) && body.input.length === 0)) {
|
||||||
body.input = [{ type: "message", role: "user", content: [{ type: "input_text", text: "..." }] }];
|
body.input = [{ type: "message", role: "user", content: [{ type: "input_text", text: "..." }] }];
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { HTTP_STATUS } from "../config/constants.js";
|
|||||||
import { handleBypassRequest } from "../utils/bypassHandler.js";
|
import { handleBypassRequest } from "../utils/bypassHandler.js";
|
||||||
import { saveRequestUsage, trackPendingRequest, appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
|
import { saveRequestUsage, trackPendingRequest, appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
|
||||||
import { getExecutor } from "../executors/index.js";
|
import { getExecutor } from "../executors/index.js";
|
||||||
|
import { convertResponsesStreamToJson } from "../transformer/streamToJsonConverter.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Translate non-streaming response to OpenAI format
|
* Translate non-streaming response to OpenAI format
|
||||||
@@ -365,8 +366,12 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
const modelTargetFormat = getModelTargetFormat(alias, model);
|
const modelTargetFormat = getModelTargetFormat(alias, model);
|
||||||
const targetFormat = modelTargetFormat || getTargetFormat(provider);
|
const targetFormat = modelTargetFormat || getTargetFormat(provider);
|
||||||
|
|
||||||
|
// Track if client actually wants streaming (before we force it for providers)
|
||||||
|
const clientRequestedStreaming = body.stream === true;
|
||||||
|
const providerRequiresStreaming = provider === 'openai' || provider === 'codex';
|
||||||
|
|
||||||
// Force streaming for OpenAI/Codex models (they don't support non-streaming mode properly)
|
// Force streaming for OpenAI/Codex models (they don't support non-streaming mode properly)
|
||||||
const stream = (provider === 'openai' || provider === 'codex') ? true : (body.stream !== false);
|
const stream = providerRequiresStreaming ? true : (body.stream !== false);
|
||||||
|
|
||||||
// Create request logger for this session: sourceFormat_targetFormat_model
|
// Create request logger for this session: sourceFormat_targetFormat_model
|
||||||
const reqLogger = await createRequestLogger(sourceFormat, targetFormat, model);
|
const reqLogger = await createRequestLogger(sourceFormat, targetFormat, model);
|
||||||
@@ -552,6 +557,77 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
return createErrorResult(statusCode, errMsg, retryAfterMs);
|
return createErrorResult(statusCode, errMsg, retryAfterMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Provider forced streaming but client wants JSON - convert SSE to JSON
|
||||||
|
if (!clientRequestedStreaming && providerRequiresStreaming) {
|
||||||
|
trackPendingRequest(model, provider, connectionId, false);
|
||||||
|
const contentType = providerResponse.headers.get("content-type") || "";
|
||||||
|
|
||||||
|
// Treat as SSE if content-type says so OR if it's empty/missing
|
||||||
|
// (Codex API doesn't always set Content-Type on streaming responses)
|
||||||
|
const isSSEResponse = contentType.includes("text/event-stream") || (contentType === "" && provider === "codex");
|
||||||
|
if (isSSEResponse) {
|
||||||
|
const isResponsesApi = sourceFormat === 'openai-responses';
|
||||||
|
|
||||||
|
if (isResponsesApi) {
|
||||||
|
// Responses API SSE → Responses API JSON (for pydantic_ai, OpenAI SDK, etc.)
|
||||||
|
try {
|
||||||
|
const jsonResponse = await convertResponsesStreamToJson(providerResponse.body);
|
||||||
|
log?.info?.("STREAM", `Converted Responses API SSE → JSON for non-streaming client`);
|
||||||
|
|
||||||
|
if (onRequestSuccess) await onRequestSuccess();
|
||||||
|
|
||||||
|
const usage = jsonResponse.usage || {};
|
||||||
|
appendRequestLog({ model, provider, connectionId, tokens: usage, status: "200 OK" }).catch(() => { });
|
||||||
|
|
||||||
|
if (usage && typeof usage === 'object') {
|
||||||
|
const msg = `[${new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" })}] 📊 [USAGE] ${provider.toUpperCase()} | in=${usage?.input_tokens || 0} | out=${usage?.output_tokens || 0}${connectionId ? ` | account=${connectionId.slice(0, 8)}...` : ""}`;
|
||||||
|
console.log(`${COLORS.green}${msg}${COLORS.reset}`);
|
||||||
|
|
||||||
|
saveRequestUsage({
|
||||||
|
provider: provider || "unknown",
|
||||||
|
model: model || "unknown",
|
||||||
|
tokens: { prompt_tokens: usage.input_tokens || 0, completion_tokens: usage.output_tokens || 0 },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
connectionId: connectionId || undefined,
|
||||||
|
apiKey: apiKey || undefined
|
||||||
|
}).catch(() => { });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
response: new Response(JSON.stringify(jsonResponse), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Access-Control-Allow-Origin": "*"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[ChatCore] Responses API SSE→JSON conversion failed:", error);
|
||||||
|
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Failed to convert streaming response to JSON");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Chat Completions SSE → Chat Completions JSON
|
||||||
|
const sseText = await providerResponse.text();
|
||||||
|
const parsed = parseSSEToOpenAIResponse(sseText, model);
|
||||||
|
if (parsed) {
|
||||||
|
if (onRequestSuccess) await onRequestSuccess();
|
||||||
|
appendRequestLog({ model, provider, connectionId, tokens: parsed.usage, status: "200 OK" }).catch(() => { });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
response: new Response(JSON.stringify(parsed), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Access-Control-Allow-Origin": "*"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Invalid SSE response for non-streaming request");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Non-streaming response
|
// Non-streaming response
|
||||||
if (!stream) {
|
if (!stream) {
|
||||||
trackPendingRequest(model, provider, connectionId, false);
|
trackPendingRequest(model, provider, connectionId, false);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { handleChatCore } from "./chatCore.js";
|
import { handleChatCore } from "./chatCore.js";
|
||||||
import { convertResponsesApiFormat } from "../translator/helpers/responsesApiHelper.js";
|
import { convertResponsesApiFormat } from "../translator/helpers/responsesApiHelper.js";
|
||||||
import { createResponsesApiTransformStream } from "../transformer/responsesTransformer.js";
|
import { createResponsesApiTransformStream } from "../transformer/responsesTransformer.js";
|
||||||
|
import { convertResponsesStreamToJson } from "../transformer/streamToJsonConverter.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle /v1/responses request
|
* Handle /v1/responses request
|
||||||
@@ -24,8 +25,12 @@ export async function handleResponsesCore({ body, modelInfo, credentials, log, o
|
|||||||
// Convert Responses API format to Chat Completions format
|
// Convert Responses API format to Chat Completions format
|
||||||
const convertedBody = convertResponsesApiFormat(body);
|
const convertedBody = convertResponsesApiFormat(body);
|
||||||
|
|
||||||
// Ensure stream is enabled
|
// Preserve client's stream preference (matches OpenClaw behavior)
|
||||||
convertedBody.stream = true;
|
// Default to false if omitted: Boolean(undefined) = false
|
||||||
|
const clientRequestedStreaming = convertedBody.stream === true;
|
||||||
|
if (convertedBody.stream === undefined) {
|
||||||
|
convertedBody.stream = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Call chat core handler
|
// Call chat core handler
|
||||||
const result = await handleChatCore({
|
const result = await handleChatCore({
|
||||||
@@ -46,26 +51,52 @@ export async function handleResponsesCore({ body, modelInfo, credentials, log, o
|
|||||||
const response = result.response;
|
const response = result.response;
|
||||||
const contentType = response.headers.get("Content-Type") || "";
|
const contentType = response.headers.get("Content-Type") || "";
|
||||||
|
|
||||||
// If not SSE or error, return as-is
|
// Case 1: Client wants non-streaming, but got SSE (provider forced it, e.g., Codex)
|
||||||
if (!contentType.includes("text/event-stream") || response.status !== 200) {
|
if (!clientRequestedStreaming && contentType.includes("text/event-stream")) {
|
||||||
return result;
|
try {
|
||||||
|
const jsonResponse = await convertResponsesStreamToJson(response.body);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
response: new Response(JSON.stringify(jsonResponse), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Access-Control-Allow-Origin": "*"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Responses API] Stream-to-JSON conversion failed:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
status: 500,
|
||||||
|
error: "Failed to convert streaming response to JSON"
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform SSE stream to Responses API format (no logging in worker)
|
// Case 2: Client wants streaming, got SSE - transform it
|
||||||
const transformStream = createResponsesApiTransformStream(null);
|
if (clientRequestedStreaming && contentType.includes("text/event-stream")) {
|
||||||
const transformedBody = response.body.pipeThrough(transformStream);
|
const transformStream = createResponsesApiTransformStream(null);
|
||||||
|
const transformedBody = response.body.pipeThrough(transformStream);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
response: new Response(transformedBody, {
|
response: new Response(transformedBody, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "text/event-stream",
|
"Content-Type": "text/event-stream",
|
||||||
"Cache-Control": "no-cache",
|
"Cache-Control": "no-cache",
|
||||||
"Connection": "keep-alive",
|
"Connection": "keep-alive",
|
||||||
"Access-Control-Allow-Origin": "*"
|
"Access-Control-Allow-Origin": "*"
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 3: Non-SSE response (error or non-streaming from provider) - return as-is
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ function buildAnthropicCompatibleUrl(baseUrl) {
|
|||||||
|
|
||||||
// Detect request format from body structure
|
// Detect request format from body structure
|
||||||
export function detectFormat(body) {
|
export function detectFormat(body) {
|
||||||
// OpenAI Responses API: has input[] array instead of messages[]
|
// OpenAI Responses API: has input (array or string) instead of messages[]
|
||||||
if (body.input && Array.isArray(body.input)) {
|
// The Responses API accepts both input as array and input as a plain string
|
||||||
|
if (body.input && (Array.isArray(body.input) || typeof body.input === "string") && !body.messages) {
|
||||||
return "openai-responses";
|
return "openai-responses";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
103
open-sse/transformer/streamToJsonConverter.js
Normal file
103
open-sse/transformer/streamToJsonConverter.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Stream-to-JSON Converter
|
||||||
|
* Converts Responses API SSE stream to single JSON response
|
||||||
|
* Used when client requests non-streaming but provider forces streaming (e.g., Codex)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Responses API SSE stream to single JSON response
|
||||||
|
* @param {ReadableStream} stream - SSE stream from provider
|
||||||
|
* @returns {Promise<Object>} Final JSON response in Responses API format
|
||||||
|
*/
|
||||||
|
export async function convertResponsesStreamToJson(stream) {
|
||||||
|
const reader = stream.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
let buffer = "";
|
||||||
|
let responseId = "";
|
||||||
|
let output = [];
|
||||||
|
let created = Math.floor(Date.now() / 1000);
|
||||||
|
let status = "in_progress";
|
||||||
|
let usage = { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
|
||||||
|
|
||||||
|
// Map of output_index -> item (for ordered output array)
|
||||||
|
const items = new Map();
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
// Split by double newline (SSE event separator)
|
||||||
|
const messages = buffer.split("\n\n");
|
||||||
|
buffer = messages.pop() || ""; // Keep incomplete message in buffer
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (!msg.trim()) continue;
|
||||||
|
|
||||||
|
// Parse SSE event
|
||||||
|
const eventMatch = msg.match(/^event:\s*(.+)$/m);
|
||||||
|
const dataMatch = msg.match(/^data:\s*(.+)$/m);
|
||||||
|
|
||||||
|
if (!eventMatch || !dataMatch) continue;
|
||||||
|
|
||||||
|
const eventType = eventMatch[1].trim();
|
||||||
|
const dataStr = dataMatch[1].trim();
|
||||||
|
|
||||||
|
if (dataStr === "[DONE]") continue;
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(dataStr);
|
||||||
|
} catch {
|
||||||
|
// Skip malformed JSON
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle different event types
|
||||||
|
if (eventType === "response.created") {
|
||||||
|
responseId = parsed.response?.id || responseId;
|
||||||
|
created = parsed.response?.created_at || created;
|
||||||
|
}
|
||||||
|
else if (eventType === "response.output_item.done") {
|
||||||
|
const idx = parsed.output_index ?? 0;
|
||||||
|
items.set(idx, parsed.item);
|
||||||
|
}
|
||||||
|
else if (eventType === "response.completed") {
|
||||||
|
status = "completed";
|
||||||
|
if (parsed.response?.usage) {
|
||||||
|
usage.input_tokens = parsed.response.usage.input_tokens || 0;
|
||||||
|
usage.output_tokens = parsed.response.usage.output_tokens || 0;
|
||||||
|
usage.total_tokens = parsed.response.usage.total_tokens || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (eventType === "response.failed") {
|
||||||
|
status = "failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build output array from accumulated items (ordered by index)
|
||||||
|
const maxIndex = items.size > 0 ? Math.max(...items.keys()) : -1;
|
||||||
|
for (let i = 0; i <= maxIndex; i++) {
|
||||||
|
output.push(items.get(i) || {
|
||||||
|
type: "message",
|
||||||
|
content: [],
|
||||||
|
role: "assistant"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: responseId || `resp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
object: "response",
|
||||||
|
created_at: created,
|
||||||
|
status: status || "completed",
|
||||||
|
output,
|
||||||
|
usage
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Normalize Responses API input to array format.
|
||||||
|
* Accepts string or array, returns array of message items.
|
||||||
|
* @param {string|Array} input - raw input from Responses API body
|
||||||
|
* @returns {Array|null} normalized array or null if invalid
|
||||||
|
*/
|
||||||
|
export function normalizeResponsesInput(input) {
|
||||||
|
if (typeof input === "string") {
|
||||||
|
const text = input.trim() === "" ? "..." : input;
|
||||||
|
return [{ type: "message", role: "user", content: [{ type: "input_text", text }] }];
|
||||||
|
}
|
||||||
|
if (Array.isArray(input)) return input;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert OpenAI Responses API format to standard chat completions format
|
* Convert OpenAI Responses API format to standard chat completions format
|
||||||
* Responses API uses: { input: [...], instructions: "..." }
|
* Responses API uses: { input: [...], instructions: "..." }
|
||||||
@@ -19,7 +34,10 @@ export function convertResponsesApiFormat(body) {
|
|||||||
let pendingToolCalls = [];
|
let pendingToolCalls = [];
|
||||||
let pendingToolResults = [];
|
let pendingToolResults = [];
|
||||||
|
|
||||||
for (const item of body.input) {
|
const inputItems = normalizeResponsesInput(body.input);
|
||||||
|
if (!inputItems) return body;
|
||||||
|
|
||||||
|
for (const item of inputItems) {
|
||||||
// Determine item type - Droid CLI sends role-based items without 'type' field
|
// Determine item type - Droid CLI sends role-based items without 'type' field
|
||||||
// Fallback: if no type but has role property, treat as message
|
// Fallback: if no type but has role property, treat as message
|
||||||
const itemType = item.type || (item.role ? "message" : null);
|
const itemType = item.type || (item.role ? "message" : null);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
import { register } from "../index.js";
|
import { register } from "../index.js";
|
||||||
import { FORMATS } from "../formats.js";
|
import { FORMATS } from "../formats.js";
|
||||||
|
import { normalizeResponsesInput } from "../helpers/responsesApiHelper.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert OpenAI Responses API request to OpenAI Chat Completions format
|
* Convert OpenAI Responses API request to OpenAI Chat Completions format
|
||||||
@@ -25,7 +26,10 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
|
|||||||
let currentAssistantMsg = null;
|
let currentAssistantMsg = null;
|
||||||
let pendingToolResults = [];
|
let pendingToolResults = [];
|
||||||
|
|
||||||
for (const item of body.input) {
|
const inputItems = normalizeResponsesInput(body.input);
|
||||||
|
if (!inputItems) return body;
|
||||||
|
|
||||||
|
for (const item of inputItems) {
|
||||||
// Determine item type - Droid CLI sends role-based items without 'type' field
|
// Determine item type - Droid CLI sends role-based items without 'type' field
|
||||||
// Fallback: if no type but has role property, treat as message
|
// Fallback: if no type but has role property, treat as message
|
||||||
const itemType = item.type || (item.role ? "message" : null);
|
const itemType = item.type || (item.role ? "message" : null);
|
||||||
@@ -234,4 +238,3 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials)
|
|||||||
// Register both directions
|
// Register both directions
|
||||||
register(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI, openaiResponsesToOpenAIRequest, null);
|
register(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI, openaiResponsesToOpenAIRequest, null);
|
||||||
register(FORMATS.OPENAI, FORMATS.OPENAI_RESPONSES, openaiToOpenAIResponsesRequest, null);
|
register(FORMATS.OPENAI, FORMATS.OPENAI_RESPONSES, openaiToOpenAIResponsesRequest, null);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user