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:
apeltekci
2026-02-15 11:47:55 +07:00
committed by decolua
co-authored by Claude Sonnet 4.5 Cursor
parent 202fee714b
commit ac7cedd27e
7 changed files with 263 additions and 26 deletions
+77 -1
View File
@@ -12,6 +12,7 @@ import { HTTP_STATUS } from "../config/constants.js";
import { handleBypassRequest } from "../utils/bypassHandler.js";
import { saveRequestUsage, trackPendingRequest, appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
import { getExecutor } from "../executors/index.js";
import { convertResponsesStreamToJson } from "../transformer/streamToJsonConverter.js";
/**
* 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 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)
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
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);
}
// 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
if (!stream) {
trackPendingRequest(model, provider, connectionId, false);
+51 -20
View File
@@ -6,6 +6,7 @@
import { handleChatCore } from "./chatCore.js";
import { convertResponsesApiFormat } from "../translator/helpers/responsesApiHelper.js";
import { createResponsesApiTransformStream } from "../transformer/responsesTransformer.js";
import { convertResponsesStreamToJson } from "../transformer/streamToJsonConverter.js";
/**
* 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
const convertedBody = convertResponsesApiFormat(body);
// Ensure stream is enabled
convertedBody.stream = true;
// Preserve client's stream preference (matches OpenClaw behavior)
// Default to false if omitted: Boolean(undefined) = false
const clientRequestedStreaming = convertedBody.stream === true;
if (convertedBody.stream === undefined) {
convertedBody.stream = false;
}
// Call chat core handler
const result = await handleChatCore({
@@ -46,26 +51,52 @@ export async function handleResponsesCore({ body, modelInfo, credentials, log, o
const response = result.response;
const contentType = response.headers.get("Content-Type") || "";
// If not SSE or error, return as-is
if (!contentType.includes("text/event-stream") || response.status !== 200) {
return result;
// Case 1: Client wants non-streaming, but got SSE (provider forced it, e.g., Codex)
if (!clientRequestedStreaming && contentType.includes("text/event-stream")) {
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)
const transformStream = createResponsesApiTransformStream(null);
const transformedBody = response.body.pipeThrough(transformStream);
// Case 2: Client wants streaming, got SSE - transform it
if (clientRequestedStreaming && contentType.includes("text/event-stream")) {
const transformStream = createResponsesApiTransformStream(null);
const transformedBody = response.body.pipeThrough(transformStream);
return {
success: true,
response: new Response(transformedBody, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*"
}
})
};
return {
success: true,
response: new Response(transformedBody, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*"
}
})
};
}
// Case 3: Non-SSE response (error or non-streaming from provider) - return as-is
return result;
}