mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-25 00:22:09 +03:00
⭐5 — Cursor PKCE login com Bearer quota, auto router e empty-turn errors. Feature completa e testada (11 arquivos de teste, 133 testes focados, todos verdes).
**Validação (worktree combinado `.claude/worktrees/fix-9909`, board sobre `origin/release/v3.8.50`):**
- 3 conflitos reais resolvidos: `config/quality/eslint-suppressions.json` (aditivo), `open-sse/config/providers/registry/cursor/index.ts` (dedup de 208 entradas de catálogo, 0 IDs duplicados verificado), `open-sse/executors/cursor.ts` (imports aditivos).
- `npm run typecheck:core`: limpo.
- `check-changelog-integrity`, `check-file-size`, `check-complexity` (2615/2774), `check-cognitive-complexity` (1175/1223), `check-dead-code` (410/416): todos OK.
- `check-public-creds`: 1 entrada obsoleta pré-existente na allowlist (`copilot-m365-web.ts:330`), já presente no tip da release — não é desta PR.
- `npm run lint`: 0 errors (5 warnings pré-existentes).
- Testes focados (`cursor-agent-cli-version`, `cursor-available-models`, `cursor-catalog-combo-compat`, `cursor-errors-classify`, `cursor-login-pkce`, `cursor-model-effort-suffix-7289`, `cursor-streaming`, `cursor-token-extractor`, `cursor-token-refresh-wiring`, `cursor-usage-fetcher`, `empty-stream-no-content-8649`): 133/133 verdes.
- Corrigido durante a validação: 1 teste novo da própria PR (`cursor-model-effort-suffix-7289.test.ts`, "splits effort off legacy grok- ids") colidia com `CURSOR_MODEL_ALIASES` já mesclado na release (mapeia `grok-4.5-high` → `cursor-grok-4.5-high` antes do fallback legado rodar); ajustado para usar um id não-aliasado (`grok-3-high`) que de fato exercita o fallback — commit `68b58ed`.
Obrigado pela contribuição, @yansigit — feature robusta com boa cobertura de testes.
255 lines
8.9 KiB
TypeScript
255 lines
8.9 KiB
TypeScript
/**
|
|
* #8649 — a stream that terminates correctly but carries no content must not
|
|
* close silently.
|
|
*
|
|
* The non-streaming path already refuses this: `isEmptyContentResponse` rewrites
|
|
* a 200-with-no-content into a 502 "Provider returned empty content", which the
|
|
* combo layer then treats as a model-level transient and fails over (#5085).
|
|
*
|
|
* The streaming path has no equivalent. Two guards exist and neither applies:
|
|
*
|
|
* - `ensureStreamReadiness` is a LIVENESS probe. Its own failure message is
|
|
* "Stream ended before producing a non-ping SSE event" — any structured
|
|
* non-ping frame satisfies it, including a bare `delta:{"role":"assistant"}`.
|
|
* - `createDisconnectAwareStream`'s #7699 branch fires on a MISSING terminal
|
|
* marker and is scoped to the Claude client format, because for other
|
|
* formats a marker-less close is not necessarily a drop.
|
|
*
|
|
* The reported stream trips neither: it is OpenAI-format, it terminates with
|
|
* `finish_reason: "stop"` and `[DONE]`, and it contains nothing. The client
|
|
* (issue #8649: an `auto/*` combo landing on an uncredentialed backend) sees a
|
|
* clean empty assistant turn and retries to its cap with no error to stop on.
|
|
*
|
|
* "Terminated normally but emitted zero content" is unambiguous in every format,
|
|
* unlike the marker case — so this guard is deliberately format-agnostic. The
|
|
* legitimate-empty terminal states are carved out to match the non-streaming
|
|
* predicate's `LEGIT_EMPTY_OPENAI_FINISH` / `LEGIT_EMPTY_CLAUDE_STOP`.
|
|
*/
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
const { createDisconnectAwareStream, createStreamController } =
|
|
await import("../../open-sse/utils/streamHandler.ts");
|
|
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
|
|
|
|
function noopAbortWritable(): { getWriter: () => { abort: () => Promise<void> } } {
|
|
return { getWriter: () => ({ abort: () => Promise.resolve() }) };
|
|
}
|
|
|
|
async function drainStream(stream: ReadableStream<Uint8Array>): Promise<string> {
|
|
const reader = stream.getReader();
|
|
const parts: Uint8Array[] = [];
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
parts.push(value);
|
|
}
|
|
return new TextDecoder().decode(
|
|
parts.reduce((acc, c) => {
|
|
const merged = new Uint8Array(acc.length + c.length);
|
|
merged.set(acc, 0);
|
|
merged.set(c, acc.length);
|
|
return merged;
|
|
}, new Uint8Array(0))
|
|
);
|
|
}
|
|
|
|
/** Run `frames` through the disconnect-aware wrapper and return what the client sees. */
|
|
async function runClientStream(frames: string[], format: string | null): Promise<string> {
|
|
const upstream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
const encoder = new TextEncoder();
|
|
for (const frame of frames) controller.enqueue(encoder.encode(frame));
|
|
controller.close();
|
|
},
|
|
});
|
|
const transform = new TransformStream<Uint8Array, Uint8Array>({
|
|
transform(chunk, controller) {
|
|
controller.enqueue(chunk);
|
|
},
|
|
});
|
|
const sc = createStreamController({
|
|
provider: "test",
|
|
model: "test-model",
|
|
clientResponseFormat: format,
|
|
});
|
|
return drainStream(
|
|
createDisconnectAwareStream(
|
|
{ readable: upstream.pipeThrough(transform), writable: noopAbortWritable() },
|
|
sc
|
|
)
|
|
);
|
|
}
|
|
|
|
const chunk = (delta: string, finish: string) =>
|
|
`data: {"id":"chatcmpl-x","object":"chat.completion.chunk","model":"m","choices":[{"index":0,"delta":${delta},"finish_reason":${finish}}]}\n\n`;
|
|
const DONE = "data: [DONE]\n\n";
|
|
|
|
test("#8649 an OpenAI stream that finishes with stop but no content surfaces an error", async () => {
|
|
const text = await runClientStream(
|
|
[chunk('{"role":"assistant"}', "null"), chunk("{}", '"stop"'), DONE],
|
|
null
|
|
);
|
|
|
|
assert.match(
|
|
text,
|
|
/"finish_reason":\s*"error"/,
|
|
"a completed-but-contentless stream must surface an error, not a clean empty turn"
|
|
);
|
|
assert.match(text, /empty|no content/i);
|
|
});
|
|
|
|
test("#8649 a stream that carries content is passed through untouched", async () => {
|
|
const text = await runClientStream(
|
|
[
|
|
chunk('{"role":"assistant"}', "null"),
|
|
chunk('{"content":"hello"}', "null"),
|
|
chunk("{}", '"stop"'),
|
|
DONE,
|
|
],
|
|
null
|
|
);
|
|
|
|
assert.match(text, /"content":"hello"/);
|
|
assert.doesNotMatch(text, /"finish_reason":\s*"error"/, "a healthy stream must not be rewritten");
|
|
});
|
|
|
|
test("#8649 a tool-call-only stream is a legitimate completion, not an empty one", async () => {
|
|
const toolDelta =
|
|
'{"tool_calls":[{"index":0,"id":"c1","function":{"name":"f","arguments":"{}"}}]}';
|
|
const text = await runClientStream(
|
|
[
|
|
chunk('{"role":"assistant"}', "null"),
|
|
chunk(toolDelta, "null"),
|
|
chunk("{}", '"tool_calls"'),
|
|
DONE,
|
|
],
|
|
null
|
|
);
|
|
|
|
assert.match(text, /tool_calls/);
|
|
assert.doesNotMatch(text, /"finish_reason":\s*"error"/, "a tool-call turn must not be flagged");
|
|
});
|
|
|
|
test("#8649 finish_reason length with no content is a legitimate truncation, not an error", async () => {
|
|
// Mirrors LEGIT_EMPTY_OPENAI_FINISH in errorClassifier.ts — a response
|
|
// truncated at the token limit is a valid terminal state even with no text.
|
|
const text = await runClientStream(
|
|
[chunk('{"role":"assistant"}', "null"), chunk("{}", '"length"'), DONE],
|
|
null
|
|
);
|
|
|
|
assert.doesNotMatch(
|
|
text,
|
|
/"finish_reason":\s*"error"/,
|
|
"a token-limit truncation must not be rewritten as an error"
|
|
);
|
|
});
|
|
|
|
test("#8649 finish_reason content_filter with no content is a legitimate terminal state", async () => {
|
|
const text = await runClientStream(
|
|
[chunk('{"role":"assistant"}', "null"), chunk("{}", '"content_filter"'), DONE],
|
|
null
|
|
);
|
|
|
|
assert.doesNotMatch(text, /"finish_reason":\s*"error"/, "a filtered turn must not be rewritten");
|
|
});
|
|
|
|
test("#8649 a reasoning-only stream counts as content (#2520)", async () => {
|
|
const text = await runClientStream(
|
|
[chunk('{"reasoning_content":"thinking out loud"}', "null"), chunk("{}", '"stop"'), DONE],
|
|
null
|
|
);
|
|
|
|
assert.doesNotMatch(
|
|
text,
|
|
/"finish_reason":\s*"error"/,
|
|
"reasoning-only output is real model output, not an empty turn"
|
|
);
|
|
});
|
|
|
|
test("#8649 a non-SSE body is never judged empty — it has no frames to judge", async () => {
|
|
// Not every body reaching this wrapper is event-stream; a plain completion is
|
|
// forwarded through the same path. It has no `data:` frames, so "no content
|
|
// seen" says nothing about it and the guard must stay out of the way.
|
|
const text = await runClientStream(["plain forwarded bytes, no completion marker"], null);
|
|
|
|
assert.equal(text, "plain forwarded bytes, no completion marker");
|
|
assert.doesNotMatch(text, /"finish_reason":\s*"error"/);
|
|
assert.doesNotMatch(text, /event: error/);
|
|
});
|
|
|
|
test("#8649 the Claude-format contentless stream is caught too", async () => {
|
|
const frames = [
|
|
'event: message_start\ndata: {"type":"message_start","message":{"id":"m","content":[]}}\n\n',
|
|
'event: message_stop\ndata: {"type":"message_stop"}\n\n',
|
|
];
|
|
const text = await runClientStream(frames, FORMATS.CLAUDE);
|
|
|
|
assert.match(text, /event: error\r?\n/, "a contentless Claude stream must surface an error");
|
|
});
|
|
|
|
const CURSOR_RATE_MSG = "Cursor rate limit / usage exceeded: not_found: AI Model Not Found";
|
|
|
|
test("#8649 error-only SSE must not be rewritten as empty content (#3685 parity)", async () => {
|
|
const text = await runClientStream(
|
|
[
|
|
`data: ${JSON.stringify({
|
|
error: { message: CURSOR_RATE_MSG, type: "rate_limit_error" },
|
|
})}\n\n`,
|
|
DONE,
|
|
],
|
|
null
|
|
);
|
|
|
|
assert.match(text, /AI Model Not Found/);
|
|
assert.doesNotMatch(
|
|
text,
|
|
/Provider returned empty content/,
|
|
"an already-emitted SSE error must not get a synthetic empty-content 502"
|
|
);
|
|
});
|
|
|
|
test("#8649 hybrid choices:[] + error must not be rewritten as empty content", async () => {
|
|
// Cursor finalize historically emitted this shape before aligning with
|
|
// buildStreamErrorChunks — readiness sees `choices`, #8649 must still stand down.
|
|
const text = await runClientStream(
|
|
[
|
|
`data: ${JSON.stringify({
|
|
id: "chatcmpl-x",
|
|
object: "chat.completion.chunk",
|
|
created: 1,
|
|
model: "m",
|
|
choices: [],
|
|
error: { message: CURSOR_RATE_MSG, type: "rate_limit_error" },
|
|
})}\n\n`,
|
|
DONE,
|
|
],
|
|
null
|
|
);
|
|
|
|
assert.match(text, /AI Model Not Found/);
|
|
assert.doesNotMatch(text, /Provider returned empty content/);
|
|
});
|
|
|
|
test("#8649 buildStreamErrorChunks-shaped error must not be rewritten as empty content", async () => {
|
|
const text = await runClientStream(
|
|
[
|
|
`data: ${JSON.stringify({
|
|
object: "chat.completion.chunk",
|
|
choices: [{ index: 0, delta: {}, finish_reason: "error" }],
|
|
error: {
|
|
message: CURSOR_RATE_MSG,
|
|
type: "rate_limit_error",
|
|
code: "rate_limit_exceeded",
|
|
},
|
|
})}\n\n`,
|
|
DONE,
|
|
],
|
|
null
|
|
);
|
|
|
|
assert.match(text, /AI Model Not Found/);
|
|
assert.doesNotMatch(text, /Provider returned empty content/);
|
|
});
|