- Add muse-spark-1.2-contributor as responses-only model on OpenCode Go - Normalize object tool schemas without properties in OpenCode Go executor - Make fallback Responses call_ids unique across same-millisecond calls - Make Responses output coercion fail-soft for circular and non-stringifiable values
195 lines
6.5 KiB
JavaScript
195 lines
6.5 KiB
JavaScript
import { ROLE, OPENAI_BLOCK, RESPONSES_ITEM } from "../schema/index.js";
|
|
|
|
/**
|
|
* Normalize Responses API input to array format.
|
|
* Accepts string or array, returns array of message items.
|
|
* An empty array is treated like an empty string — providers require at least one user
|
|
* message, so we inject a placeholder rather than forwarding an empty messages[].
|
|
* @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: RESPONSES_ITEM.MESSAGE, role: ROLE.USER, content: [{ type: RESPONSES_ITEM.INPUT_TEXT, text }] }];
|
|
}
|
|
if (Array.isArray(input)) {
|
|
// Empty input[] would produce messages:[] which all providers reject (#389)
|
|
if (input.length === 0) {
|
|
return [{ type: RESPONSES_ITEM.MESSAGE, role: ROLE.USER, content: [{ type: RESPONSES_ITEM.INPUT_TEXT, text: "..." }] }];
|
|
}
|
|
return input;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Strict Responses upstreams reject overlong call_ids with InputValidationError (#393).
|
|
export const MAX_RESPONSES_CALL_ID_LEN = 64;
|
|
|
|
// Fallback ids share one Date.now() when a batch of items is sanitized in a tight
|
|
// loop — a per-process sequence keeps same-millisecond ids unique so
|
|
// function_call ↔ function_call_output correlation never collides.
|
|
let responsesCallIdSeq = 0;
|
|
|
|
export function clampResponsesCallId(id) {
|
|
if (typeof id !== "string" || !id) return `call_${Date.now()}_${(responsesCallIdSeq += 1)}`;
|
|
return id.length > MAX_RESPONSES_CALL_ID_LEN ? id.substring(0, MAX_RESPONSES_CALL_ID_LEN) : id;
|
|
}
|
|
|
|
// Single-stringify: objects → JSON once; valid JSON strings pass through untouched;
|
|
// anything else (partial fragments, empty) falls back to "{}" instead of
|
|
// double-encoding and tripping upstream InputValidationError.
|
|
export function coerceResponsesArguments(value) {
|
|
if (value === undefined || value === null || value === "") return "{}";
|
|
if (typeof value !== "string") {
|
|
try {
|
|
return JSON.stringify(value);
|
|
} catch {
|
|
return "{}";
|
|
}
|
|
}
|
|
try {
|
|
JSON.parse(value);
|
|
return value;
|
|
} catch {
|
|
return "{}";
|
|
}
|
|
}
|
|
|
|
// function_call_output.output must be a string — never null/object.
|
|
export function coerceResponsesOutput(value) {
|
|
if (typeof value === "string") return value;
|
|
if (value === undefined || value === null) return "";
|
|
if (Array.isArray(value)) {
|
|
return value.map((c) => {
|
|
try {
|
|
return c?.text ?? JSON.stringify(c);
|
|
} catch {
|
|
return String(c);
|
|
}
|
|
}).join("");
|
|
}
|
|
try {
|
|
return JSON.stringify(value);
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert OpenAI Responses API format to standard chat completions format
|
|
* Responses API uses: { input: [...], instructions: "..." }
|
|
* Chat API uses: { messages: [...] }
|
|
*/
|
|
export function convertResponsesApiFormat(body) {
|
|
if (!body.input) return body;
|
|
|
|
const result = { ...body };
|
|
result.messages = [];
|
|
|
|
// Convert instructions to system message
|
|
if (body.instructions) {
|
|
result.messages.push({ role: ROLE.SYSTEM, content: body.instructions });
|
|
}
|
|
|
|
// Group items by conversation turn
|
|
let currentAssistantMsg = null;
|
|
let pendingToolCalls = [];
|
|
let pendingToolResults = [];
|
|
|
|
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
|
|
// Fallback: if no type but has role property, treat as message
|
|
const itemType = item.type || (item.role ? RESPONSES_ITEM.MESSAGE : null);
|
|
|
|
if (itemType === RESPONSES_ITEM.MESSAGE) {
|
|
// Flush any pending assistant message with tool calls
|
|
if (currentAssistantMsg) {
|
|
result.messages.push(currentAssistantMsg);
|
|
currentAssistantMsg = null;
|
|
}
|
|
// Flush pending tool results
|
|
if (pendingToolResults.length > 0) {
|
|
for (const tr of pendingToolResults) {
|
|
result.messages.push(tr);
|
|
}
|
|
pendingToolResults = [];
|
|
}
|
|
|
|
// Convert content: input_text → text, output_text → text, input_image → image_url
|
|
const content = Array.isArray(item.content)
|
|
? item.content.map(c => {
|
|
if (c.type === RESPONSES_ITEM.INPUT_TEXT) return { type: OPENAI_BLOCK.TEXT, text: c.text };
|
|
if (c.type === RESPONSES_ITEM.OUTPUT_TEXT) return { type: OPENAI_BLOCK.TEXT, text: c.text };
|
|
if (c.type === RESPONSES_ITEM.INPUT_IMAGE) {
|
|
const url = c.image_url || c.file_id || "";
|
|
return { type: OPENAI_BLOCK.IMAGE_URL, image_url: { url, detail: c.detail || "auto" } };
|
|
}
|
|
return c;
|
|
})
|
|
: item.content;
|
|
result.messages.push({ role: item.role, content });
|
|
}
|
|
else if (itemType === RESPONSES_ITEM.FUNCTION_CALL) {
|
|
// Start or append to assistant message with tool_calls
|
|
if (!currentAssistantMsg) {
|
|
currentAssistantMsg = {
|
|
role: ROLE.ASSISTANT,
|
|
content: null,
|
|
tool_calls: []
|
|
};
|
|
}
|
|
// Skip items with empty/missing name — upstream APIs reject nameless tool calls (#444)
|
|
if (!item.name || typeof item.name !== "string" || item.name.trim() === "") continue;
|
|
currentAssistantMsg.tool_calls.push({
|
|
id: item.call_id,
|
|
type: OPENAI_BLOCK.FUNCTION,
|
|
function: {
|
|
name: item.name,
|
|
arguments: item.arguments
|
|
}
|
|
});
|
|
}
|
|
else if (itemType === RESPONSES_ITEM.FUNCTION_CALL_OUTPUT) {
|
|
// Flush assistant message first if exists
|
|
if (currentAssistantMsg) {
|
|
result.messages.push(currentAssistantMsg);
|
|
currentAssistantMsg = null;
|
|
}
|
|
// Add tool result
|
|
pendingToolResults.push({
|
|
role: ROLE.TOOL,
|
|
tool_call_id: item.call_id,
|
|
content: typeof item.output === "string" ? item.output : JSON.stringify(item.output)
|
|
});
|
|
}
|
|
else if (itemType === RESPONSES_ITEM.REASONING) {
|
|
// Skip reasoning items - they are for display only
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Flush remaining
|
|
if (currentAssistantMsg) {
|
|
result.messages.push(currentAssistantMsg);
|
|
}
|
|
if (pendingToolResults.length > 0) {
|
|
for (const tr of pendingToolResults) {
|
|
result.messages.push(tr);
|
|
}
|
|
}
|
|
|
|
// Cleanup Responses API specific fields
|
|
delete result.input;
|
|
delete result.instructions;
|
|
delete result.include;
|
|
delete result.prompt_cache_key;
|
|
delete result.store;
|
|
delete result.reasoning;
|
|
|
|
return result;
|
|
}
|