fix(providers): surface real Devin error + fix Windsurf auth instructions (#3324) (#3829)

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-14 02:05:22 -03:00
committed by GitHub
parent c9e24ae48c
commit 5875c7993f
10 changed files with 279 additions and 18 deletions

View File

@@ -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)

View File

@@ -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)."
}

View File

@@ -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,

View File

@@ -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.

View File

@@ -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 }
);

View File

@@ -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

View File

@@ -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 } {

View File

@@ -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}

View File

@@ -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",

View 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"
);
});