mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 06:12:10 +03:00
This commit is contained in:
committed by
GitHub
parent
c9e24ae48c
commit
5875c7993f
@@ -9,6 +9,7 @@
|
||||
### 🐛 Fixed
|
||||
|
||||
- **fix(intelligence): run pricing + models.dev sync from the live startup path** — like the Arena ELO sync (v3.8.24), the external **pricing sync** (`PRICING_SYNC_ENABLED`) and the **models.dev capability sync** (Settings → AI toggle) were only initialized from `server-init.ts`, which the Next standalone runtime never executes — and models.dev had no caller at all. Their toggles were inert in production. Both are now initialized from `instrumentation-node.ts` (self-gated, opt-in preserved, non-blocking, never fatal). (thanks @diegosouzapw)
|
||||
- **fix(providers): surface the real Devin error + correct the Windsurf auth instructions** — Devin chat returned a generic 502 "Invalid SSE response for non-streaming request" that swallowed the real cause (e.g. "Devin CLI not found"): an error-only SSE chunk (no `choices`) is now propagated with its sanitized message. The Windsurf "Visit windsurf.com/show-auth-token" instruction (the bare URL shows no token without an IDE-supplied `?state=`) now directs users to the `Windsurf: Provide Auth Token` command-palette flow. ([#3324](https://github.com/diegosouzapw/OmniRoute/issues/3324) — thanks @mikmaneggahommie)
|
||||
- **fix(grok-web): clearer 403 message for anti-bot / IP-reputation blocks** — a Grok Web subscription validating from a flagged datacenter/VPS IP got a 403 that read like an invalid cookie, sending users to chase a cookie that was actually fine. A non-auth 403 (Cloudflare challenge / anti-bot body) now returns a message stating the cookie is likely OK and the block is IP-reputation-based — retry from a residential IP or configure a proxy (auth-shaped 403s keep the re-paste guidance). ([#3474](https://github.com/diegosouzapw/OmniRoute/issues/3474) — thanks @friedtofu1608)
|
||||
- **fix(db): make the mass-pending-migrations safety threshold env-overridable** — restoring a backup DB from an older version could trip "Detected N pending migrations … threshold is 50" with no way to override the hardcoded `50`. The threshold is now configurable via `OMNIROUTE_MAX_PENDING_MIGRATIONS` (resolved at startup; `0` disables the check). ([#3416](https://github.com/diegosouzapw/OmniRoute/issues/3416) — thanks @samuraiIT)
|
||||
- **test(proxy): cover the Vercel-relay `proxyFetch` path** — net-new tests for `buildVercelRelayHeaders` and the `vercel`-type relay short-circuit (`x-relay-target`/`-path`/`-auth`, TCP-skip, missing-auth fail-closed), closing one of the deferred test gaps tracked in [#2743](https://github.com/diegosouzapw/OmniRoute/issues/2743). (thanks @diegosouzapw)
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"open-sse/handlers/imageGeneration.ts": 3777,
|
||||
"open-sse/handlers/responseSanitizer.ts": 1103,
|
||||
"open-sse/handlers/search.ts": 1442,
|
||||
"open-sse/handlers/sseParser.ts": 812,
|
||||
"open-sse/handlers/videoGeneration.ts": 1026,
|
||||
"open-sse/mcp-server/schemas/tools.ts": 1437,
|
||||
"open-sse/mcp-server/server.ts": 1457,
|
||||
@@ -70,7 +71,7 @@
|
||||
"src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx": 1016,
|
||||
"src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2148,
|
||||
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1069,
|
||||
"src/app/api/oauth/[provider]/[action]/route.ts": 897,
|
||||
"src/app/api/oauth/[provider]/[action]/route.ts": 903,
|
||||
"src/app/api/providers/[id]/models/route.ts": 2426,
|
||||
"src/app/api/providers/[id]/test/route.ts": 842,
|
||||
"src/app/api/usage/analytics/route.ts": 941,
|
||||
@@ -117,5 +118,6 @@
|
||||
"_rebaseline_2026_06_13_3782_hide_persist": "Re-baseline #3782 (preservar modelos eye-hidden no auto-sync): models.ts 1132→1180 (+48). Crescimento = flag distinto isDeleted em ModelCompatOverride/ModelCompatPatch + handling em mergeModelCompatOverride + helper getModelIsDeleted, para separar 'deletado' (trash, dropado no re-sync #3199) de 'oculto' (eye toggle, preservado). Lógica coesa de visibilidade no módulo db; não-extraível. Os 3 drifts release-wide (ProxyRegistryManager/sidebarVisibility/schemas) são do #3809 do owner, não deste PR.",
|
||||
"_rebaseline_2026_06_13_3758_chat_early_eof": "Re-baseline #3758 (#3817 mergeado): chat.ts 1392→1425 (+33). Crescimento = retry bounded de STREAM_EARLY_EOF no handleSingleModelChat (contador streamEarlyEofRetries + bloco de retry guardado por shouldRetryStreamEarlyEof). Lógica coesa no handler de chat; não-extraível. Reconciliação tardia — o bump foi esquecido no PR do fix (o de antigravity/models foi feito).",
|
||||
"_rebaseline_2026_06_13_3416_migration_threshold": "Re-baseline #3416 (threshold de migrações pendentes via env): migrationRunner.ts 1100→1125 (+25). Crescimento = helper resolveMaxPendingMigrations() que lê OMNIROUTE_MAX_PENDING_MIGRATIONS em call-time (valida finito+>=0, fallback 50) + JSDoc. Lógica coesa de config no runner; não-extraível.",
|
||||
"_rebaseline_2026_06_13_3474_grok_403": "Re-baseline #3474 (mensagem clara no 403 anti-bot do Grok): validation.ts 4302→4348 (+46). Crescimento = helper isGrokAntiBotBlock() + branch 403 de 3 tiers (auth-shaped / anti-bot-IP-reputation / upstream-error). Lógica coesa de classificação no validator; não-extraível."
|
||||
"_rebaseline_2026_06_13_3474_grok_403": "Re-baseline #3474 (mensagem clara no 403 anti-bot do Grok): validation.ts 4302→4348 (+46). Crescimento = helper isGrokAntiBotBlock() + branch 403 de 3 tiers (auth-shaped / anti-bot-IP-reputation / upstream-error). Lógica coesa de classificação no validator; não-extraível.",
|
||||
"_rebaseline_2026_06_13_3324_windsurf_devin": "Re-baseline #3324 (windsurf auth text + devin error propagation): route.ts 897→903 (+6, texto da instrução windsurf→fluxo command-palette) + sseParser.ts ADICIONADO como frozen 812 (era 746, +66 = helper extractSSEErrorMessage que faz surface do erro real SSE em vez do 502 genérico). 812 fica 12 acima do cap 800 — helper coeso no parser de SSE, congelado com justificativa (precedente providerLimits/useProviderConnections)."
|
||||
}
|
||||
|
||||
@@ -161,6 +161,7 @@ import { invalidateCodexQuotaCache } from "../services/codexQuotaFetcher.ts";
|
||||
import { translateNonStreamingResponse } from "./responseTranslator.ts";
|
||||
import { extractUsageFromResponse } from "./usageExtractor.ts";
|
||||
import {
|
||||
extractSSEErrorMessage,
|
||||
parseSSEToClaudeResponse,
|
||||
parseSSEToOpenAIResponse,
|
||||
parseSSEToResponsesOutput,
|
||||
@@ -4870,7 +4871,14 @@ export async function handleChatCore({
|
||||
connectionId,
|
||||
status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`,
|
||||
}).catch(() => {});
|
||||
const invalidSseMessage = "Invalid SSE response for non-streaming request";
|
||||
// Some executors (e.g. the Devin/Windsurf CLI) always emit
|
||||
// text/event-stream, signalling failure with an error-only chunk
|
||||
// (`data: {"error":{"message":"Devin CLI not found..."}}`) that carries
|
||||
// no `choices`. Surface that real, sanitized message instead of the
|
||||
// generic 502 so the actionable error is not swallowed (#3324).
|
||||
const surfacedSseError = extractSSEErrorMessage(streamPayload);
|
||||
const invalidSseMessage =
|
||||
surfacedSseError || "Invalid SSE response for non-streaming request";
|
||||
persistAttemptLogs({
|
||||
status: HTTP_STATUS.BAD_GATEWAY,
|
||||
error: invalidSseMessage,
|
||||
@@ -5268,9 +5276,8 @@ export async function handleChatCore({
|
||||
// === Quota Share POST-hook (B/F7) — fire-and-forget, fail-open ===
|
||||
if (apiKeyInfo?.id && credentials?.connectionId) {
|
||||
try {
|
||||
const { scheduleRecordConsumption, buildConsumptionCost } = await import(
|
||||
"@/lib/quota/spendRecorder"
|
||||
);
|
||||
const { scheduleRecordConsumption, buildConsumptionCost } =
|
||||
await import("@/lib/quota/spendRecorder");
|
||||
scheduleRecordConsumption(
|
||||
{
|
||||
apiKeyId: apiKeyInfo.id,
|
||||
|
||||
@@ -1,4 +1,70 @@
|
||||
import { appendToolCallArgumentDelta } from "../utils/toolCallArguments.ts";
|
||||
import { sanitizeErrorMessage } from "../utils/error.ts";
|
||||
|
||||
/**
|
||||
* Extract a provider error message from a buffered SSE stream that carries an
|
||||
* error-only chunk (`data: {"error":...}`) and no content chunks.
|
||||
*
|
||||
* Some executors always return `text/event-stream` even on failure (e.g. the
|
||||
* Devin/Windsurf CLI executors emit `data: {"error":{"message":"Devin CLI not
|
||||
* found..."}}`). Those chunks have no `choices`/Claude/Responses content, so the
|
||||
* content parsers (parseSSEToOpenAIResponse etc.) correctly return `null`. Without
|
||||
* this helper the caller would replace the real upstream error with a generic
|
||||
* "Invalid SSE response" 502, swallowing the actionable message (#3324).
|
||||
*
|
||||
* Provider-agnostic: matches any `data:` chunk that has an `error` field but no
|
||||
* `choices` array. The returned message is always run through sanitizeErrorMessage
|
||||
* so stack traces / absolute source paths never leak (Hard Rule #12). Returns
|
||||
* `null` when no error-only chunk is present (so valid-content streams are left
|
||||
* to the normal parsers).
|
||||
*/
|
||||
export function extractSSEErrorMessage(rawSSE: unknown): string | null {
|
||||
const lines = String(rawSSE || "").split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("data:")) continue;
|
||||
const payload = trimmed.slice(5).trim();
|
||||
if (!payload || payload === "[DONE]") continue;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(payload);
|
||||
} catch {
|
||||
continue; // Ignore malformed lines and keep scanning.
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
|
||||
const record = parsed as Record<string, unknown>;
|
||||
|
||||
// A chunk with content (choices) is not an error-only chunk — defer to the
|
||||
// normal content parsers so the valid-SSE path is never short-circuited.
|
||||
if (Array.isArray(record.choices)) continue;
|
||||
|
||||
const err = record.error;
|
||||
if (err == null) continue;
|
||||
|
||||
let message = "";
|
||||
if (typeof err === "string") {
|
||||
message = err;
|
||||
} else if (typeof err === "object" && !Array.isArray(err)) {
|
||||
const errRecord = err as Record<string, unknown>;
|
||||
if (typeof errRecord.message === "string") {
|
||||
message = errRecord.message;
|
||||
} else {
|
||||
message = JSON.stringify(err);
|
||||
}
|
||||
} else {
|
||||
message = String(err);
|
||||
}
|
||||
|
||||
const sanitized = sanitizeErrorMessage(message);
|
||||
if (sanitized) return sanitized;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert OpenAI-style SSE chunks into a single non-streaming JSON response.
|
||||
* Used as a fallback when upstream returns text/event-stream for stream=false.
|
||||
|
||||
@@ -126,7 +126,9 @@ export async function GET(
|
||||
error:
|
||||
`Browser OAuth disabled for ${earlyParams.provider} — use import-token via ` +
|
||||
`/api/oauth/${earlyParams.provider}/import-token. ` +
|
||||
`Visit https://windsurf.com/show-auth-token to obtain a token.`,
|
||||
`In the Windsurf/VS Code IDE, run the "Windsurf: Provide Auth Token" command ` +
|
||||
`(or click the Jupyter "Get Windsurf Authentication Token" button), then copy+paste the shown token. ` +
|
||||
`Opening https://windsurf.com/show-auth-token directly only shows a "Redirecting" page — the IDE must initiate the ?state=... flow.`,
|
||||
},
|
||||
{ status: 410 }
|
||||
);
|
||||
@@ -328,7 +330,9 @@ export async function POST(
|
||||
error:
|
||||
`Browser OAuth disabled for ${earlyParams.provider} — use import-token via ` +
|
||||
`/api/oauth/${earlyParams.provider}/import-token. ` +
|
||||
`Visit https://windsurf.com/show-auth-token to obtain a token.`,
|
||||
`In the Windsurf/VS Code IDE, run the "Windsurf: Provide Auth Token" command ` +
|
||||
`(or click the Jupyter "Get Windsurf Authentication Token" button), then copy+paste the shown token. ` +
|
||||
`Opening https://windsurf.com/show-auth-token directly only shows a "Redirecting" page — the IDE must initiate the ?state=... flow.`,
|
||||
},
|
||||
{ status: 410 }
|
||||
);
|
||||
@@ -353,7 +357,9 @@ export async function POST(
|
||||
error:
|
||||
`Browser OAuth disabled for ${provider} — use import-token via ` +
|
||||
`/api/oauth/${provider}/import-token. ` +
|
||||
`Visit https://windsurf.com/show-auth-token to obtain a token.`,
|
||||
`In the Windsurf/VS Code IDE, run the "Windsurf: Provide Auth Token" command ` +
|
||||
`(or click the Jupyter "Get Windsurf Authentication Token" button), then copy+paste the shown token. ` +
|
||||
`Opening https://windsurf.com/show-auth-token directly only shows a "Redirecting" page — the IDE must initiate the ?state=... flow.`,
|
||||
},
|
||||
{ status: 410 }
|
||||
);
|
||||
|
||||
@@ -378,7 +378,9 @@ export const TRAE_CONFIG = {
|
||||
//
|
||||
// Active fields:
|
||||
// - inferenceUrl → used by WindsurfExecutor (open-sse/executors/windsurf.ts)
|
||||
// - showAuthTokenUrl → linked from OAuthModal "Get token" button
|
||||
// - showAuthTokenUrl → reference URL; the real token only renders when the
|
||||
// IDE "Windsurf: Provide Auth Token" command opens it
|
||||
// with an IDE-supplied ?state= param (see field below)
|
||||
// - firebaseApiKey → reserved for Phase 2
|
||||
// - ideName → sent in extension headers
|
||||
export const WINDSURF_CONFIG = {
|
||||
@@ -397,7 +399,11 @@ export const WINDSURF_CONFIG = {
|
||||
// ── Active fields (still consumed by runtime) ─────────────────────────────
|
||||
// Inference server URL (gRPC-web requests go here)
|
||||
inferenceUrl: "https://server.self-serve.windsurf.com",
|
||||
// Primary login path: user visits this page, copies token, pastes it
|
||||
// Primary login path: the user runs the "Windsurf: Provide Auth Token" command
|
||||
// inside the Windsurf/VS Code IDE (or clicks the Jupyter "Get Windsurf
|
||||
// Authentication Token" button), which opens this URL WITH an IDE-supplied
|
||||
// `?state=<xyz>` param and renders the token. Opening this bare URL directly
|
||||
// only shows a "Redirecting" page with no token (#3324).
|
||||
showAuthTokenUrl: "https://windsurf.com/show-auth-token",
|
||||
// Token refresh via Firebase Secure Token Service (reserved for Phase 2).
|
||||
// Default is the public Firebase Web client identifier embedded in the
|
||||
|
||||
@@ -8,7 +8,12 @@ import { WINDSURF_CONFIG } from "../constants/oauth";
|
||||
* Firebase OAuth + RegisterUser flow (see docs/superpowers/specs/2026-05-29-windsurf-login-fix-design.md),
|
||||
* the only supported login path is import-token:
|
||||
*
|
||||
* 1. User opens https://windsurf.com/show-auth-token in a browser
|
||||
* 1. In the Windsurf / VS Code IDE, the user runs the
|
||||
* "Windsurf: Provide Auth Token" command from the command palette
|
||||
* (or clicks the Jupyter "Get Windsurf Authentication Token" button).
|
||||
* NOTE: opening https://windsurf.com/show-auth-token directly only renders
|
||||
* a "Redirecting" page with no token — the IDE must initiate the flow
|
||||
* (it adds a `?state=<xyz>` param) for the token to be displayed.
|
||||
* 2. Copies the displayed Windsurf API key (`sk-ws-...` style)
|
||||
* 3. Pastes it into OmniRoute via /api/oauth/windsurf/import-token
|
||||
*
|
||||
@@ -22,7 +27,8 @@ export const windsurf = {
|
||||
|
||||
/**
|
||||
* Validate a pasted Windsurf API key. Accepts the `sk-ws-...` format issued by
|
||||
* windsurf.com/show-auth-token and the legacy raw-token format. Empty or
|
||||
* the IDE "Windsurf: Provide Auth Token" command (via windsurf.com/show-auth-token
|
||||
* with the IDE-supplied `?state=...`) and the legacy raw-token format. Empty or
|
||||
* whitespace-only tokens are rejected.
|
||||
*/
|
||||
validateImportToken(token: string): { valid: boolean; reason?: string } {
|
||||
|
||||
@@ -553,7 +553,11 @@ export default function OAuthModal({
|
||||
const isGoogleLoopbackRelay =
|
||||
GOOGLE_OAUTH_PROVIDERS.has(provider) && isLoopbackOrigin && hasMatchingState;
|
||||
|
||||
if (event.origin !== window.location.origin && !isLocalhostSamePort && !isGoogleLoopbackRelay) {
|
||||
if (
|
||||
event.origin !== window.location.origin &&
|
||||
!isLocalhostSamePort &&
|
||||
!isGoogleLoopbackRelay
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (event.data?.type === "oauth_callback") {
|
||||
@@ -733,8 +737,8 @@ export default function OAuthModal({
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-text-muted">
|
||||
{provider === "windsurf"
|
||||
? "Visit windsurf.com/show-auth-token, copy your Windsurf API key, and paste it below."
|
||||
: "Provide your WINDSURF_API_KEY (obtained via `devin auth login` or windsurf.com/show-auth-token)."}
|
||||
? 'In the Windsurf / VS Code IDE, run the "Windsurf: Provide Auth Token" command from the command palette (or click the Jupyter "Get Windsurf Authentication Token" button), then copy the shown token and paste it below. Opening windsurf.com/show-auth-token directly only shows a "Redirecting" page — the IDE must initiate the flow.'
|
||||
: 'Provide your WINDSURF_API_KEY (obtained via `devin auth login`, or via the Windsurf IDE "Windsurf: Provide Auth Token" command).'}
|
||||
</p>
|
||||
<Input
|
||||
value={pasteToken}
|
||||
|
||||
@@ -317,7 +317,7 @@ export const OAUTH_PROVIDERS = {
|
||||
subscriptionRisk: true,
|
||||
riskNoticeVariant: "oauth",
|
||||
authHint:
|
||||
"Sign in at windsurf.com to get your token. Visit windsurf.com/show-auth-token after logging in and paste it here, or use the device-code login flow.",
|
||||
'In the Windsurf / VS Code IDE, open the command palette and run `Windsurf: Provide Auth Token` (or click the Jupyter "Get Windsurf Authentication Token" button), then copy the shown token and paste it here. Note: opening windsurf.com/show-auth-token directly only renders a "Redirecting" page — the IDE must initiate the flow (it adds a `?state=...` param) for the token to appear.',
|
||||
website: "https://windsurf.com",
|
||||
},
|
||||
"devin-cli": {
|
||||
@@ -498,7 +498,8 @@ export const WEB_COOKIE_PROVIDERS = {
|
||||
textIcon: "LMA",
|
||||
website: "https://lmarena.ai",
|
||||
hasFree: true,
|
||||
freeNote: "Free model comparison platform — 40+ models (GPT, Claude, Gemini, Llama). No subscription required.",
|
||||
freeNote:
|
||||
"Free model comparison platform — 40+ models (GPT, Claude, Gemini, Llama). No subscription required.",
|
||||
authHint:
|
||||
"Paste your session cookie from lmarena.ai (DevTools → Application → Cookies). Optional — works with free tier for basic comparisons.",
|
||||
riskNoticeVariant: "webCookie",
|
||||
|
||||
162
tests/unit/sse-error-passthrough-3324.test.ts
Normal file
162
tests/unit/sse-error-passthrough-3324.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const { parseSSEToOpenAIResponse, extractSSEErrorMessage } =
|
||||
await import("../../open-sse/handlers/sseParser.ts");
|
||||
|
||||
const GENERIC = "Invalid SSE response for non-streaming request";
|
||||
|
||||
// ─── PART 2: error-only SSE passthrough ───────────────────────────────────────
|
||||
|
||||
test("extractSSEErrorMessage surfaces the error message from an error-only SSE chunk", () => {
|
||||
const rawSSE = [
|
||||
'data: {"error":{"message":"Devin CLI not found on PATH"}}',
|
||||
"",
|
||||
"data: [DONE]",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const surfaced = extractSSEErrorMessage(rawSSE);
|
||||
|
||||
assert.ok(surfaced, "expected a surfaced error message, got null");
|
||||
assert.ok(
|
||||
surfaced.includes("Devin CLI not found on PATH"),
|
||||
`expected surfaced message to contain the real Devin error, got: ${surfaced}`
|
||||
);
|
||||
assert.notEqual(surfaced, GENERIC);
|
||||
});
|
||||
|
||||
test("extractSSEErrorMessage surfaces the real Devin spawn error shape (type+code)", () => {
|
||||
const rawSSE = [
|
||||
'data: {"error":{"message":"Devin CLI not found: devin. Install via https://cli.devin.ai or set CLI_DEVIN_BIN env var.","type":"devin_cli_error","code":"spawn_failed"}}',
|
||||
"",
|
||||
"data: [DONE]",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const surfaced = extractSSEErrorMessage(rawSSE);
|
||||
|
||||
assert.ok(surfaced);
|
||||
assert.ok(surfaced.includes("Devin CLI not found"));
|
||||
});
|
||||
|
||||
test("extractSSEErrorMessage accepts a top-level string error field", () => {
|
||||
const rawSSE = ['data: {"error":"upstream blew up"}', "", "data: [DONE]"].join("\n");
|
||||
|
||||
const surfaced = extractSSEErrorMessage(rawSSE);
|
||||
|
||||
assert.ok(surfaced);
|
||||
assert.ok(surfaced.includes("upstream blew up"));
|
||||
});
|
||||
|
||||
test("extractSSEErrorMessage returns null when a valid choices chunk is present (no false positive)", () => {
|
||||
const rawSSE = [
|
||||
'data: {"id":"chatcmpl_ok","choices":[{"index":0,"delta":{"content":"hi"},"finish_reason":"stop"}]}',
|
||||
"data: [DONE]",
|
||||
].join("\n");
|
||||
|
||||
assert.equal(extractSSEErrorMessage(rawSSE), null);
|
||||
});
|
||||
|
||||
test("extractSSEErrorMessage returns null for a stream with no error and no choices", () => {
|
||||
const rawSSE = ['data: {"foo":"bar"}', "data: [DONE]"].join("\n");
|
||||
|
||||
assert.equal(extractSSEErrorMessage(rawSSE), null);
|
||||
});
|
||||
|
||||
test("extractSSEErrorMessage sanitizes stack-trace-like error messages (no `at /` leak)", () => {
|
||||
// Genuine V8 stack shape: real newline between the message and the frames, so
|
||||
// a JSON.stringify of a real Error.stack-style string round-trips with the
|
||||
// newline intact (sanitizeErrorMessage drops everything after the first line).
|
||||
const stacky =
|
||||
"ENOENT: spawn devin\n at ChildProcess._handle.onexit (/home/me/app/open-sse/executors/devin-cli.ts:170:5)";
|
||||
// A bare absolute source path on the surviving first line must also be redacted.
|
||||
const bareInline = "Devin failed loading /home/me/app/open-sse/executors/devin-cli.ts module";
|
||||
const rawSSE = [
|
||||
`data: ${JSON.stringify({ error: { message: stacky } })}`,
|
||||
"",
|
||||
`data: ${JSON.stringify({ error: { message: bareInline } })}`,
|
||||
"",
|
||||
"data: [DONE]",
|
||||
].join("\n");
|
||||
|
||||
const surfaced = extractSSEErrorMessage(rawSSE);
|
||||
|
||||
assert.ok(surfaced, "expected a sanitized error message");
|
||||
// The first error-only chunk wins; its stack tail is dropped at the newline.
|
||||
assert.equal(surfaced, "ENOENT: spawn devin");
|
||||
assert.ok(!surfaced.includes("at /"), `surfaced message leaked a stack frame path: ${surfaced}`);
|
||||
assert.ok(
|
||||
!/\/[^\s]+\.ts/.test(surfaced),
|
||||
`surfaced message leaked an absolute source path: ${surfaced}`
|
||||
);
|
||||
|
||||
// A bare absolute source path on a single line is redacted to <path>.
|
||||
const bareSurfaced = extractSSEErrorMessage(
|
||||
[`data: ${JSON.stringify({ error: { message: bareInline } })}`, "", "data: [DONE]"].join("\n")
|
||||
);
|
||||
assert.ok(bareSurfaced);
|
||||
assert.ok(
|
||||
bareSurfaced.includes("<path>"),
|
||||
`expected the bare absolute path to be redacted, got: ${bareSurfaced}`
|
||||
);
|
||||
assert.ok(!/\/[^\s]+\.ts/.test(bareSurfaced), `bare-path message leaked: ${bareSurfaced}`);
|
||||
});
|
||||
|
||||
// ─── Regression: the normal valid-SSE parse path still works ───────────────────
|
||||
|
||||
test("parseSSEToOpenAIResponse still parses a normal valid SSE (no regression)", () => {
|
||||
const rawSSE = [
|
||||
'data: {"id":"chatcmpl_ok","model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":"hello"},"finish_reason":"stop"}]}',
|
||||
"data: [DONE]",
|
||||
].join("\n");
|
||||
|
||||
const parsed = parseSSEToOpenAIResponse(rawSSE, "fallback-model");
|
||||
|
||||
assert.ok(parsed);
|
||||
assert.equal(parsed.choices[0].message.content, "hello");
|
||||
});
|
||||
|
||||
test("parseSSEToOpenAIResponse still returns null for an error-only SSE (boundary owns the error path)", () => {
|
||||
const rawSSE = ['data: {"error":{"message":"Devin CLI not found on PATH"}}', "data: [DONE]"].join(
|
||||
"\n"
|
||||
);
|
||||
|
||||
// The valid-SSE parser intentionally returns null here (no `choices`); the
|
||||
// error surfacing is the job of extractSSEErrorMessage at the boundary.
|
||||
assert.equal(parseSSEToOpenAIResponse(rawSSE, "fallback-model"), null);
|
||||
});
|
||||
|
||||
// ─── PART 1: windsurf instruction text references the IDE command-palette flow ─
|
||||
|
||||
test("PART 1: windsurf authHint references the `Windsurf: Provide Auth Token` command", () => {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const providers = readFileSync(
|
||||
path.join(here, "../../src/shared/constants/providers.ts"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
// The windsurf authHint must lead with the IDE command-palette flow.
|
||||
assert.match(
|
||||
providers,
|
||||
/Windsurf: Provide Auth Token/,
|
||||
"providers.ts windsurf authHint should reference the `Windsurf: Provide Auth Token` command"
|
||||
);
|
||||
});
|
||||
|
||||
test("PART 1: oauth route 410 errors reference the IDE command-palette flow", () => {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const route = readFileSync(
|
||||
path.join(here, "../../src/app/api/oauth/[provider]/[action]/route.ts"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
assert.match(
|
||||
route,
|
||||
/Windsurf: Provide Auth Token/,
|
||||
"oauth route should direct users to the `Windsurf: Provide Auth Token` command"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user