mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-15 11:43:10 +03:00
Closed #9183 in favor of this branch at the operator's request. Folds in the exact PR diff (verified byte-identical via `gh pr diff 9183`, applied cleanly with `git apply`): - Fixed output_index collisions between reasoning/message/tool-call items in Responses API streaming, which caused clients to ignore tool calls. - Fixed a race in chunk processing where tool calls and finish signals were lost when they arrived in the same chunk as reasoning/text content. - Enabled full processing of multi-choice chunks (previously truncated to a single choice). - Added reasoning_content capture/replay for DeepSeek-based models (big-pickle), keyed off the real position in the next turn's replayed messages array instead of a hardcoded index 0 — prevents history corruption that caused models to lose context and stop prematurely. - Added big-pickle to models recognized for native textual reasoning tags so <think> blocks convert to reasoning items correctly. - Improved Responses-to-Chat translation to group reasoning, content, and tool calls into a single assistant turn, as strict OpenAI-compatible upstreams require. - Added a descriptive placeholder for encrypted Responses API reasoning blocks so downgraded Chat API requests keep context. Test plan: - All 82 tests across reasoning-cache.test.ts, translator-helper-branches.test.ts, translator-request-openai-responses.test.ts, and the 3 new test files (responses-api-truncation.test.ts, responses-replay-fixes.test.ts, responses-request-translation.test.ts) pass - npm run typecheck:core — clean - npm run check:file-size — chatCore.ts (5024->5032) and translator/response/openai-responses.ts (1174->1180) rebaselined, both cohesive additions at the two reasoning-cache capture call sites and the turn-grouping fix; tests/unit/reasoning-cache.test.ts rebaselined at 1035 (crosses the 1000-line new-file test cap, entirely this fold's diff)
297 lines
9.6 KiB
TypeScript
297 lines
9.6 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { ensureTestEnvironment, BASE_URL, API_KEY } from "./liveGeminiShared.ts";
|
|
|
|
const MODEL = process.env.TEST_RESPONSES_MODEL || "gemini/gemini-2.0-flash";
|
|
const REASONING_MODEL = process.env.TEST_REASONING_MODEL || "opencode/big-pickle";
|
|
const skip = !API_KEY ? "OMNIROUTE_API_KEY not set — skipping live test" : undefined;
|
|
|
|
test.before(async () => {
|
|
await ensureTestEnvironment();
|
|
});
|
|
|
|
async function readResponsesSSEStream(response: Response): Promise<{
|
|
fullText: string;
|
|
items: Record<string, unknown>[];
|
|
finishStatus: string;
|
|
}> {
|
|
const reader = response.body!.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
let fullText = "";
|
|
const items: Record<string, unknown>[] = [];
|
|
let finishStatus = "unknown";
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const lines = buffer.split("\n");
|
|
buffer = lines.pop() || "";
|
|
|
|
for (const line of lines) {
|
|
if (!line.startsWith("data: ")) continue;
|
|
const data = line.slice(6).trim();
|
|
if (data === "[DONE]") continue;
|
|
|
|
try {
|
|
const parsed = JSON.parse(data) as Record<string, unknown>;
|
|
if (parsed.type === "response.output_text.delta") {
|
|
fullText += (parsed.delta as string) || "";
|
|
} else if (parsed.type === "response.output_item.added") {
|
|
items.push(parsed.item as Record<string, unknown>);
|
|
} else if (parsed.type === "response.done") {
|
|
const resp = parsed.response as Record<string, unknown> | undefined;
|
|
finishStatus = (resp?.status as string) || "done";
|
|
}
|
|
} catch (e) {
|
|
// console.error("Error parsing SSE chunk:", e, data);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { fullText, items, finishStatus };
|
|
}
|
|
|
|
test("live Responses API — tool call generation", { skip }, async (t) => {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 120_000);
|
|
|
|
try {
|
|
console.log(`[TEST] Responses API: Sending tool call request to ${BASE_URL}/api/v1/responses`);
|
|
const response = await fetch(`${BASE_URL}/api/v1/responses`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${API_KEY}`,
|
|
},
|
|
body: JSON.stringify({
|
|
model: MODEL,
|
|
input: [
|
|
{
|
|
type: "message",
|
|
role: "user",
|
|
content: [{ type: "text", text: "What is the weather in San Francisco?" }],
|
|
},
|
|
],
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
name: "get_weather",
|
|
description: "Get the current weather in a given location",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
location: {
|
|
type: "string",
|
|
description: "The city and state, e.g. San Francisco, CA",
|
|
},
|
|
},
|
|
required: ["location"],
|
|
},
|
|
},
|
|
],
|
|
stream: true,
|
|
}),
|
|
signal: controller.signal,
|
|
});
|
|
clearTimeout(timeout);
|
|
|
|
assert.equal(response.status, 200, `Expected HTTP 200, got ${response.status}`);
|
|
|
|
const { fullText, items, finishStatus } = await readResponsesSSEStream(response);
|
|
|
|
console.log(`[TEST] Responses API Result: items=${items.length}, finish=${finishStatus}`);
|
|
|
|
const toolCalls = items.filter((item) => item.type === "function_call");
|
|
assert.ok(toolCalls.length > 0, "Should have at least one tool call");
|
|
assert.equal(toolCalls[0].name, "get_weather", "Tool call name should match");
|
|
} catch (err) {
|
|
clearTimeout(timeout);
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
test("live Responses API — multi-turn reasoning capture/replay", { skip }, async (t) => {
|
|
// This test simulates a model that produces reasoning and checks if we can replay it
|
|
// We'll use a thinking model alias "big-pickle" if we can, or just gemini with thinking enabled.
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 120_000);
|
|
|
|
try {
|
|
// Turn 1: Ask something that triggers reasoning
|
|
console.log(`[TEST] Responses API: Turn 1 (Reasoning trigger) with ${REASONING_MODEL}`);
|
|
const response1 = await fetch(`${BASE_URL}/api/v1/responses`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${API_KEY}`,
|
|
},
|
|
body: JSON.stringify({
|
|
model: REASONING_MODEL,
|
|
input: [
|
|
{
|
|
type: "message",
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "Think step-by-step about this problem: 123 * 456 + 789" },
|
|
],
|
|
},
|
|
],
|
|
stream: true,
|
|
}),
|
|
signal: controller.signal,
|
|
});
|
|
|
|
assert.equal(response1.status, 200);
|
|
const result1 = await readResponsesSSEStream(response1);
|
|
|
|
// Find reasoning item if any
|
|
const reasoningItems = result1.items.filter((item) => item.type === "reasoning");
|
|
console.log(`[TEST] Turn 1: Found ${reasoningItems.length} reasoning items`);
|
|
|
|
// Turn 2: Follow up
|
|
// We need to send back the assistant message (which might contain reasoning)
|
|
const assistantMessage = {
|
|
type: "message",
|
|
role: "assistant",
|
|
content: [{ type: "output_text", text: result1.fullText }],
|
|
};
|
|
|
|
// If there was reasoning, it should have been captured and replayed if we include it
|
|
// Responses API usually includes the reasoning in the input history.
|
|
const input2 = [
|
|
{
|
|
type: "message",
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "Solve this complex math problem step by step: 123 * 456 + 789" },
|
|
],
|
|
},
|
|
...result1.items.map((item) => ({ ...item })), // Include reasoning and message
|
|
{
|
|
type: "message",
|
|
role: "user",
|
|
content: [{ type: "text", text: "Now add 1 to the result." }],
|
|
},
|
|
];
|
|
|
|
console.log(`[TEST] Responses API: Turn 2 (Follow up) with ${REASONING_MODEL}`);
|
|
const response2 = await fetch(`${BASE_URL}/api/v1/responses`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${API_KEY}`,
|
|
},
|
|
body: JSON.stringify({
|
|
model: REASONING_MODEL,
|
|
input: input2,
|
|
stream: true,
|
|
}),
|
|
signal: controller.signal,
|
|
});
|
|
|
|
assert.equal(response2.status, 200);
|
|
const result2 = await readResponsesSSEStream(response2);
|
|
assert.ok(result2.fullText.length > 0, "Should have response in turn 2");
|
|
} catch (err) {
|
|
clearTimeout(timeout);
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
test(
|
|
"live Responses API — multi-turn with tool result (reasoning replay check)",
|
|
{ skip },
|
|
async (t) => {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 180_000);
|
|
|
|
try {
|
|
// Turn 1: Trigger tool call
|
|
console.log(`[TEST] Responses API: Turn 1 (Tool trigger) with ${MODEL}`);
|
|
const response1 = await fetch(`${BASE_URL}/api/v1/responses`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${API_KEY}`,
|
|
},
|
|
body: JSON.stringify({
|
|
model: MODEL,
|
|
input: [
|
|
{
|
|
type: "message",
|
|
role: "user",
|
|
content: [{ type: "text", text: "What is the weather in SF?" }],
|
|
},
|
|
],
|
|
tools: [
|
|
{
|
|
type: "function",
|
|
name: "get_weather",
|
|
description: "Get weather",
|
|
parameters: { type: "object", properties: { location: { type: "string" } } },
|
|
},
|
|
],
|
|
stream: true,
|
|
}),
|
|
signal: controller.signal,
|
|
});
|
|
|
|
assert.equal(response1.status, 200);
|
|
const result1 = await readResponsesSSEStream(response1);
|
|
const toolCall = result1.items.find((item) => item.type === "function_call");
|
|
assert.ok(toolCall, "Should have generated a tool call");
|
|
|
|
// Turn 2: Provide tool result and ask for final answer
|
|
// We'll use REASONING_MODEL for the final turn to verify reasoning replay
|
|
const input2 = [
|
|
{
|
|
type: "message",
|
|
role: "user",
|
|
content: [{ type: "text", text: "What is the weather in SF?" }],
|
|
},
|
|
...result1.items.map((item) => ({ ...item })),
|
|
{
|
|
type: "function_call_output",
|
|
call_id: toolCall.call_id,
|
|
output: JSON.stringify({ temp: 22, unit: "celsius", condition: "Sunny" }),
|
|
},
|
|
{
|
|
type: "message",
|
|
role: "user",
|
|
content: [{ type: "text", text: "Great, tell me the result." }],
|
|
},
|
|
];
|
|
|
|
console.log(`[TEST] Responses API: Turn 2 (Tool result) with ${REASONING_MODEL}`);
|
|
const response2 = await fetch(`${BASE_URL}/api/v1/responses`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${API_KEY}`,
|
|
},
|
|
body: JSON.stringify({
|
|
model: REASONING_MODEL,
|
|
input: input2,
|
|
stream: true,
|
|
}),
|
|
signal: controller.signal,
|
|
});
|
|
|
|
assert.equal(response2.status, 200);
|
|
const result2 = await readResponsesSSEStream(response2);
|
|
assert.ok(result2.fullText.length > 0, "Should have final response");
|
|
|
|
// Verify reasoning in turns is not lost if the model supports it
|
|
// Note: We can't easily check the providerRequest from the outside,
|
|
// but a successful response without "forgetting" is a good sign.
|
|
} catch (err) {
|
|
clearTimeout(timeout);
|
|
throw err;
|
|
}
|
|
}
|
|
);
|