diff --git a/server/translator.mjs b/server/translator.mjs index f86d1d2..16077c6 100644 --- a/server/translator.mjs +++ b/server/translator.mjs @@ -123,12 +123,17 @@ export function chatCompletionsToResponsesBody(body) { if (typeof max_output_tokens === "number") responsesPayload.max_output_tokens = max_output_tokens; else if (typeof max_tokens === "number") responsesPayload.max_output_tokens = max_tokens; - // Translate reasoning_effort (OpenAI Chat Completions) to reasoning: { effort } (Responses API) + // Translate reasoning_effort (OpenAI Chat Completions) to reasoning: { effort, summary } (Responses API) const incomingEffort = body.reasoning_effort || (typeof body.reasoning === "object" ? body.reasoning?.effort : undefined); - if (incomingEffort && incomingEffort !== "auto" && incomingEffort !== "none") { - responsesPayload.reasoning = { - effort: incomingEffort + if (incomingEffort !== "none") { + const incomingSummary = typeof body.reasoning === "object" && body.reasoning?.summary ? body.reasoning.summary : "detailed"; + const reasoningObj = { + summary: incomingSummary }; + if (incomingEffort && incomingEffort !== "auto") { + reasoningObj.effort = incomingEffort; + } + responsesPayload.reasoning = reasoningObj; } if (Array.isArray(tools)) { @@ -171,11 +176,23 @@ export function responsesToChatCompletionsResponse(responsesData, requestedModel const model = responsesData.model || requestedModel; let textContent = ""; + let reasoningContent = ""; const toolCalls = []; const output = Array.isArray(responsesData.output) ? responsesData.output : []; for (const item of output) { - if (item.type === "message") { + if (item.type === "reasoning") { + if (Array.isArray(item.summary)) { + const parts = item.summary.map(s => s.text || s.content || (typeof s === "string" ? s : "")).filter(Boolean); + if (parts.length > 0) { + reasoningContent = parts.join("\n\n"); + } + } else if (typeof item.summary === "string") { + reasoningContent = item.summary; + } else if (typeof item.content === "string") { + reasoningContent = item.content; + } + } else if (item.type === "message") { if (Array.isArray(item.content)) { for (const part of item.content) { if ((part.type === "output_text" || part.type === "text") && typeof part.text === "string") { @@ -205,7 +222,8 @@ export function responsesToChatCompletionsResponse(responsesData, requestedModel const message = { role: "assistant", - content: textContent || (toolCalls.length ? null : "") + content: textContent || (toolCalls.length ? null : ""), + ...(reasoningContent ? { reasoning_content: reasoningContent } : {}) }; if (toolCalls.length > 0) { message.tool_calls = toolCalls; @@ -280,6 +298,31 @@ export function translateResponsesEventToOpenAiChunk(eventString, state = { id: choices: [] }; + // Reasoning Summary / Reasoning Text Delta + if ( + eventType === "response.reasoning_summary_text.delta" || + eventType === "response.reasoning_text.delta" || + data.type === "response.reasoning_summary_text.delta" || + data.type === "response.reasoning_text.delta" + ) { + let reasoningText = data.delta ?? data.text ?? data.reasoning_delta ?? ""; + if (reasoningText) { + state.hasEmittedReasoning = true; + if (typeof data.summary_index === "number") { + if (state.lastSummaryIndex !== undefined && state.lastSummaryIndex !== data.summary_index) { + reasoningText = "\n\n" + reasoningText; + } + state.lastSummaryIndex = data.summary_index; + } + baseChunk.choices.push({ + index: 0, + delta: { reasoning_content: reasoningText }, + finish_reason: null + }); + return `data: ${JSON.stringify(baseChunk)}\n\n`; + } + } + // Text Delta if ( eventType === "response.output_text.delta" || diff --git a/test/live-models.test.mjs b/test/live-models.test.mjs index fff5d60..eb8979c 100644 --- a/test/live-models.test.mjs +++ b/test/live-models.test.mjs @@ -83,10 +83,65 @@ server.listen(TEST_PORT, "127.0.0.1", async () => { assert.equal(effortRes.status, 200, `Muse Spark with reasoning_effort should be 200 (was ${effortRes.status})`); const effortJson = await effortRes.json(); const effortReply = effortJson.choices?.[0]?.message?.content?.trim(); + const effortReasoning = effortJson.choices?.[0]?.message?.reasoning_content; const effortDuration = ((Date.now() - effortStart) / 1000).toFixed(2); console.log(`✔ Muse Spark with reasoning_effort='high' responded in ${effortDuration}s:`); + console.log(` Reasoning: "${effortReasoning ? effortReasoning.slice(0, 80) + '...' : '(none)'}"`); console.log(` Output: "${effortReply}"`); assert.ok(effortReply && /yes/i.test(effortReply), "Muse Spark should answer yes for prime 97"); + assert.ok(effortReasoning && effortReasoning.length > 0, "Non-streaming message should include reasoning_content"); + + // 4. Verify Live Streaming Reasoning Tokens with Muse Spark 1.3 Contributor + console.log("\n[4/4] Verifying Live Streaming Reasoning Chunks (Muse Spark 1.3)..."); + const streamStart = Date.now(); + const streamRes = await fetch(`${baseUrl}/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "muse-spark-1.3-contributor", + messages: [{ role: "user", content: "Which is bigger: 9.11 or 9.9?" }], + reasoning_effort: "high", + stream: true + }) + }); + + assert.equal(streamRes.status, 200); + const reader = streamRes.body.getReader(); + const decoder = new TextDecoder(); + let streamBuf = ""; + let reasoningTokens = ""; + let contentTokens = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + streamBuf += decoder.decode(value, { stream: true }); + const parts = streamBuf.split("\n\n"); + streamBuf = parts.pop() || ""; + for (const part of parts) { + for (const line of part.split("\n")) { + if (line.startsWith("data: ") && !line.includes("[DONE]")) { + try { + const parsed = JSON.parse(line.slice(6)); + const delta = parsed.choices?.[0]?.delta; + if (delta?.reasoning_content) { + reasoningTokens += delta.reasoning_content; + } + if (delta?.content) { + contentTokens += delta.content; + } + } catch {} + } + } + } + } + + const streamDuration = ((Date.now() - streamStart) / 1000).toFixed(2); + console.log(`✔ Stream completed in ${streamDuration}s:`); + console.log(` Streamed Reasoning: "${reasoningTokens.slice(0, 100)}..." (${reasoningTokens.length} chars)`); + console.log(` Streamed Content: "${contentTokens.slice(0, 100)}..." (${contentTokens.length} chars)`); + assert.ok(reasoningTokens.length > 0, "Should receive streaming reasoning_content tokens"); + assert.ok(contentTokens.length > 0, "Should receive streaming content tokens"); console.log("\n=================================================="); console.log("✔ LIVE VERIFICATION PASSED FOR ALL MODELS & REASONING!"); diff --git a/test/translator.test.mjs b/test/translator.test.mjs index a9f4a6c..e9b8a38 100644 --- a/test/translator.test.mjs +++ b/test/translator.test.mjs @@ -43,7 +43,7 @@ const effortHighReq = { reasoning_effort: "high" }; const effortHighPayload = chatCompletionsToResponsesBody(effortHighReq); -assert.deepEqual(effortHighPayload.reasoning, { effort: "high" }); +assert.deepEqual(effortHighPayload.reasoning, { effort: "high", summary: "detailed" }); const effortXHighReq = { model: "muse-spark-1.3-contributor", @@ -51,7 +51,7 @@ const effortXHighReq = { reasoning_effort: "xhigh" }; const effortXHighPayload = chatCompletionsToResponsesBody(effortXHighReq); -assert.deepEqual(effortXHighPayload.reasoning, { effort: "xhigh" }); +assert.deepEqual(effortXHighPayload.reasoning, { effort: "xhigh", summary: "detailed" }); const effortNoneReq = { model: "muse-spark-1.3-contributor", @@ -67,13 +67,17 @@ const effortNestedReq = { reasoning: { effort: "low" } }; const effortNestedPayload = chatCompletionsToResponsesBody(effortNestedReq); -assert.deepEqual(effortNestedPayload.reasoning, { effort: "low" }); +assert.deepEqual(effortNestedPayload.reasoning, { effort: "low", summary: "detailed" }); // 3. Responses API non-streaming JSON -> Chat Completions JSON const rawResponsesResp = { id: "resp_1234567890", model: "muse-spark-1.3-contributor", output: [ + { + type: "reasoning", + summary: [{ type: "summary_text", text: "Reasoning step 1" }, { type: "summary_text", text: "Reasoning step 2" }] + }, { type: "message", role: "assistant", @@ -90,6 +94,7 @@ const rawResponsesResp = { const chatResp = responsesToChatCompletionsResponse(rawResponsesResp, "muse-spark-1.3-contributor"); assert.equal(chatResp.id, "chatcmpl_1234567890"); assert.equal(chatResp.choices[0].message.content, "Hello from Responses API!"); +assert.equal(chatResp.choices[0].message.reasoning_content, "Reasoning step 1\n\nReasoning step 2"); assert.equal(chatResp.choices[0].finish_reason, "stop"); assert.equal(chatResp.usage.prompt_tokens, 15); assert.equal(chatResp.usage.completion_tokens, 8); @@ -97,6 +102,15 @@ assert.equal(chatResp.usage.total_tokens, 23); // 4. SSE translation const state = { id: "", model: "muse-spark-1.3-contributor" }; + +// 4a. Reasoning summary delta SSE +const reasoningChunkRaw = `event: response.reasoning_summary_text.delta\ndata: {"type":"response.reasoning_summary_text.delta","delta":"Thinking through the problem..."}\n\n`; +const reasoningChunkOpenAi = translateResponsesEventToOpenAiChunk(reasoningChunkRaw, state); +assert.ok(reasoningChunkOpenAi.startsWith("data: ")); +const parsedReasoningChunk = JSON.parse(reasoningChunkOpenAi.replace("data: ", "").trim()); +assert.equal(parsedReasoningChunk.choices[0].delta.reasoning_content, "Thinking through the problem..."); + +// 4b. Text delta SSE const textChunkRaw = `event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"Hello "}\n\n`; const textChunkOpenAi = translateResponsesEventToOpenAiChunk(textChunkRaw, state); assert.ok(textChunkOpenAi.startsWith("data: "));