fix(executor): handle CommandCode in-stream errors for combo and account fallback

CommandCode returns errors as a type:"error" event inside an HTTP 200
NDJSON stream instead of a non-200 status, so the existing combo/account
fallback logic (keyed off response.status) never triggered and the error
text was streamed to the client as if it were content.

Peek the first NDJSON events before committing to a stream; on a
type:"error" event, abort and return a proper 4xx/5xx Response instead.
Normal streams are replayed losslessly (buffered prefix + rest of the
stream) through the existing translator, so the happy path is unchanged.
Add CommandCodeExecutor.parseError() so parseUpstreamError() can extract
a clean message/status from the synthesized error body.
This commit is contained in:
Bertho Joris
2026-08-28 16:06:20 +07:00
committed by decolua
parent 9dbdca0e5e
commit 67d9182e1a
2 changed files with 427 additions and 7 deletions

View File

@@ -42,12 +42,235 @@ export class CommandCodeExecutor extends BaseExecutor {
async execute(opts) {
const result = await super.execute(opts);
if (!result?.response?.ok || !result.response.body) return result;
result.response = wrapNdjsonAsOpenAISse(result.response, opts.model);
result.response = await inspectAndWrapCommandCodeResponse(result.response, opts.model);
return result;
}
parseError(response, bodyText) {
let parsed = null;
try {
parsed = JSON.parse(bodyText || "{}");
} catch {
parsed = null;
}
const errObj = parsed?.error || parsed;
const msg = errObj?.message || parsed?.message || bodyText || response.statusText;
const status = Number(errObj?.code || errObj?.statusCode || response.status) || response.status;
return {
status,
message: msg || `CommandCode upstream error: ${response.status}`,
};
}
}
function wrapNdjsonAsOpenAISse(originalResponse, model) {
export function parseCommandCodeError(event) {
if (!event || typeof event !== "object") {
return {
statusCode: 503,
message: "CommandCode upstream error",
type: "server_error",
};
}
const errVal = event.error ?? event.message ?? "unknown";
let message = "";
let statusCode = null;
let type = "server_error";
if (typeof errVal === "object" && errVal !== null) {
message = errVal.message || errVal.error || JSON.stringify(errVal);
if (errVal.statusCode && Number.isInteger(Number(errVal.statusCode))) {
statusCode = Number(errVal.statusCode);
} else if (errVal.status && Number.isInteger(Number(errVal.status))) {
statusCode = Number(errVal.status);
}
if (errVal.type) type = errVal.type;
} else if (typeof errVal === "string") {
message = errVal;
} else {
message = JSON.stringify(errVal);
}
if (event.statusCode && Number.isInteger(Number(event.statusCode))) {
statusCode = Number(event.statusCode);
}
if (!statusCode || statusCode < 400 || statusCode > 599) {
const lower = message.toLowerCase();
if (lower.includes("rate limit") || lower.includes("too many requests")) {
statusCode = 429;
type = "rate_limit_error";
} else if (lower.includes("unauthorized") || lower.includes("invalid api key") || lower.includes("authentication")) {
statusCode = 401;
type = "authentication_error";
} else if (lower.includes("payment required") || lower.includes("billing")) {
statusCode = 402;
type = "billing_error";
} else if (lower.includes("quota") || lower.includes("forbidden") || lower.includes("permission")) {
statusCode = 403;
type = "permission_error";
} else if (lower.includes("not found")) {
statusCode = 404;
type = "invalid_request_error";
} else if (lower.includes("unavailable") || lower.includes("overloaded") || lower.includes("server error")) {
statusCode = 503;
type = "server_error";
} else {
statusCode = 503;
}
}
return { statusCode, message, type };
}
export async function inspectAndWrapCommandCodeResponse(originalResponse, model) {
const reader = originalResponse.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const bufferedLines = [];
let detectedError = null;
try {
while (true) {
const { value, done } = await reader.read();
if (done) {
const trimmed = buffer.trim();
if (trimmed) {
try {
const jsonStr = trimmed.startsWith("data:") ? trimmed.slice(5).trim() : trimmed;
const parsed = JSON.parse(jsonStr);
if (parsed?.type === "error") {
detectedError = parsed;
} else {
bufferedLines.push(trimmed);
}
} catch {
bufferedLines.push(trimmed);
}
}
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
let stopLoop = false;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const jsonStr = trimmed.startsWith("data:") ? trimmed.slice(5).trim() : trimmed;
if (!jsonStr || jsonStr === "[DONE]") {
bufferedLines.push(trimmed);
stopLoop = true;
break;
}
let event;
try {
event = JSON.parse(jsonStr);
} catch {
bufferedLines.push(trimmed);
continue;
}
if (event?.type === "error") {
detectedError = event;
stopLoop = true;
break;
}
bufferedLines.push(trimmed);
if (
event?.type === "text-delta" ||
event?.type === "reasoning-delta" ||
event?.type === "tool-input-start" ||
event?.type === "tool-call" ||
event?.type === "finish" ||
event?.type === "finish-step"
) {
stopLoop = true;
break;
}
}
if (stopLoop) break;
}
} catch {
try { reader.releaseLock(); } catch { /* ignore */ }
return originalResponse;
}
if (detectedError) {
try { await reader.cancel(); } catch { /* ignore */ }
const { statusCode, message, type } = parseCommandCodeError(detectedError);
return new Response(
JSON.stringify({
error: {
message: `[CommandCode error: ${message}]`,
type,
code: statusCode,
},
}),
{
status: statusCode,
statusText: statusCode === 503 ? "Service Unavailable" : (statusCode === 429 ? "Too Many Requests" : "Bad Gateway"),
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
}
);
}
const combinedStream = createReplayedStream(bufferedLines, buffer, reader);
return wrapNdjsonAsOpenAISse(combinedStream, model, originalResponse);
}
function createReplayedStream(bufferedLines, remainingBuffer, reader) {
const encoder = new TextEncoder();
let replayed = false;
return new ReadableStream({
async pull(controller) {
if (!replayed) {
replayed = true;
let prefix = bufferedLines.join("\n");
if (prefix && remainingBuffer) {
prefix += "\n" + remainingBuffer;
} else if (remainingBuffer) {
prefix = remainingBuffer;
} else if (prefix) {
prefix += "\n";
}
if (prefix) {
controller.enqueue(encoder.encode(prefix));
}
}
try {
const { value, done } = await reader.read();
if (done) {
controller.close();
} else {
controller.enqueue(value);
}
} catch (err) {
controller.error(err);
}
},
async cancel(reason) {
try {
await reader.cancel(reason);
} catch {
/* ignore */
}
},
});
}
function wrapNdjsonAsOpenAISse(streamBody, model, originalResponse = null) {
const decoder = new TextDecoder();
const encoder = new TextEncoder();
let buffer = "";
@@ -70,7 +293,6 @@ function wrapNdjsonAsOpenAISse(originalResponse, model) {
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
// Translate AI SDK v5 NDJSON line to one or more OpenAI chunks
emitChunks(commandCodeToOpenAIResponse(trimmed, state), controller);
}
},
@@ -83,11 +305,17 @@ function wrapNdjsonAsOpenAISse(originalResponse, model) {
},
});
const newBody = originalResponse.body.pipeThrough(transform);
const newBody = streamBody.pipeThrough(transform);
return new Response(newBody, {
status: originalResponse.status,
statusText: originalResponse.statusText,
headers: originalResponse.headers,
status: originalResponse?.status || 200,
statusText: originalResponse?.statusText || "OK",
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
...(originalResponse?.headers ? Object.fromEntries(originalResponse.headers.entries()) : {}),
"content-type": "text/event-stream",
},
});
}

View File

@@ -0,0 +1,192 @@
import { describe, it, expect, vi } from "vitest";
import {
parseCommandCodeError,
inspectAndWrapCommandCodeResponse,
CommandCodeExecutor,
} from "../../open-sse/executors/commandcode.js";
import { handleComboChat } from "../../open-sse/services/combo.js";
function createNdjsonStream(lines) {
const encoder = new TextEncoder();
return new ReadableStream({
start(controller) {
for (const line of lines) {
controller.enqueue(encoder.encode(typeof line === "string" ? line : JSON.stringify(line) + "\n"));
}
controller.close();
},
});
}
describe("parseCommandCodeError", () => {
it("parses user exact error payload with statusCode 503 and isRetryable", () => {
const event = {
type: "error",
error: {
type: "server_error",
message: "Service temporarily unavailable. Please try again shortly.",
statusCode: 503,
isRetryable: true,
},
};
const parsed = parseCommandCodeError(event);
expect(parsed.statusCode).toBe(503);
expect(parsed.message).toBe("Service temporarily unavailable. Please try again shortly.");
expect(parsed.type).toBe("server_error");
});
it("handles string error message", () => {
const event = {
type: "error",
message: "Rate limit exceeded. Please wait 30s.",
};
const parsed = parseCommandCodeError(event);
expect(parsed.statusCode).toBe(429);
expect(parsed.message).toBe("Rate limit exceeded. Please wait 30s.");
});
it("handles plain error string in error property", () => {
const event = {
type: "error",
error: "Unauthorized access",
};
const parsed = parseCommandCodeError(event);
expect(parsed.statusCode).toBe(401);
expect(parsed.message).toBe("Unauthorized access");
});
});
describe("inspectAndWrapCommandCodeResponse", () => {
it("converts initial upstream 200 with error event to 503 Response", async () => {
const ndjsonBody = createNdjsonStream([
JSON.stringify({
type: "error",
error: {
type: "server_error",
message: "Service temporarily unavailable. Please try again shortly.",
statusCode: 503,
isRetryable: true,
},
}) + "\n",
]);
const fakeResponse = new Response(ndjsonBody, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
const result = await inspectAndWrapCommandCodeResponse(fakeResponse, "poolside/laguna-s-2.1-free");
expect(result.ok).toBe(false);
expect(result.status).toBe(503);
const body = await result.json();
expect(body.error.message).toContain("Service temporarily unavailable");
expect(body.error.code).toBe(503);
});
it("converts initial upstream 200 with start/start-step followed by error to 503 Response", async () => {
const ndjsonBody = createNdjsonStream([
JSON.stringify({ type: "start" }) + "\n",
JSON.stringify({ type: "start-step" }) + "\n",
JSON.stringify({
type: "error",
error: {
type: "server_error",
message: "Service temporarily unavailable. Please try again shortly.",
statusCode: 503,
isRetryable: true,
},
}) + "\n",
]);
const fakeResponse = new Response(ndjsonBody, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
const result = await inspectAndWrapCommandCodeResponse(fakeResponse, "poolside/laguna-s-2.1-free");
expect(result.ok).toBe(false);
expect(result.status).toBe(503);
const body = await result.json();
expect(body.error.message).toContain("Service temporarily unavailable");
});
it("streams successful responses when content is emitted", async () => {
const ndjsonBody = createNdjsonStream([
JSON.stringify({ type: "start" }) + "\n",
JSON.stringify({ type: "text-delta", text: "Hello from Laguna" }) + "\n",
JSON.stringify({ type: "finish" }) + "\n",
]);
const fakeResponse = new Response(ndjsonBody, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
const result = await inspectAndWrapCommandCodeResponse(fakeResponse, "poolside/laguna-s-2.1-free");
expect(result.ok).toBe(true);
expect(result.status).toBe(200);
const text = await result.text();
expect(text).toContain("Hello from Laguna");
expect(text).toContain("data: [DONE]");
});
});
describe("CommandCode in Combo Fallback", () => {
it("automatically falls back to next model when commandcode returns 503 error", async () => {
const log = {
info: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
};
const handleSingleModel = vi.fn(async (body, modelStr) => {
if (modelStr === "commandcode/poolside/laguna-s-2.1-free") {
// Simulated failed CommandCode response
return new Response(
JSON.stringify({
error: {
message: "Service temporarily unavailable. Please try again shortly.",
type: "server_error",
code: 503,
},
}),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
if (modelStr === "openai/gpt-4o-mini") {
// Fallback model succeeds
return new Response(
JSON.stringify({
id: "chatcmpl-test",
choices: [{ message: { role: "assistant", content: "Fallback success!" } }],
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
return new Response("Not found", { status: 404 });
});
const comboResponse = await handleComboChat({
body: { messages: [{ role: "user", content: "Hello" }] },
models: ["commandcode/poolside/laguna-s-2.1-free", "openai/gpt-4o-mini"],
handleSingleModel,
log,
comboName: "test-combo",
comboStrategy: "fallback",
});
expect(comboResponse.ok).toBe(true);
expect(comboResponse.status).toBe(200);
const data = await comboResponse.json();
expect(data.choices[0].message.content).toBe("Fallback success!");
expect(handleSingleModel).toHaveBeenCalledTimes(2);
expect(handleSingleModel).toHaveBeenNthCalledWith(1, expect.anything(), "commandcode/poolside/laguna-s-2.1-free");
expect(handleSingleModel).toHaveBeenNthCalledWith(2, expect.anything(), "openai/gpt-4o-mini");
});
});