mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 14:42:20 +03:00
Compare commits
1 Commits
fix/10788-
...
fix/10727-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
214aec3f97 |
@@ -0,0 +1 @@
|
||||
- **fix(executors):** the Meta AI (muse-spark-web) WebSocket send-message timeout now reports the socket's `readyState` at the moment it fires, so a "Meta AI WS timed out" failure can be told apart as either the connection never opening (`readyState=0`) or opening successfully and then going silent (`readyState=1`) — the exact ambiguity that made #10727 undiagnosable from logs alone (#10727).
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(open-sse):** declare `supportedThinkingEfforts` (`low`/`medium`/`high`/`max`) on Ollama Cloud's `glm-5.1`, `glm-5.2`, `deepseek-v4-pro` and `deepseek-v4-flash` registry entries so the catalog's `appendSyncedEffortVariants()` pass — which only synthesizes selectable `-low`/`-high`/`-max` model ids from an already-populated `capabilities.effort_tiers` — can expose an effort selector for these reasoning-capable models, matching what `gpt-oss:20b`/`gpt-oss:120b` already had (#10788)
|
||||
@@ -24,24 +24,8 @@ export const ollama_cloudProvider: RegistryEntry = {
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: ["low", "medium", "high"],
|
||||
},
|
||||
// #10788: Ollama Cloud accepts low|medium|high|max|none uniformly across
|
||||
// its reasoning-capable models (see supportsMaxEffortForProvider's
|
||||
// isOllamaCloud comment in open-sse/executors/base/reasoningEffort.ts) —
|
||||
// declare supportedThinkingEfforts so appendSyncedEffortVariants() (which
|
||||
// runs before static-model capability enrichment) can synthesize the
|
||||
// catalog's selectable -low/-high/-max variant ids for these models.
|
||||
{
|
||||
id: "deepseek-v4-pro",
|
||||
name: "DeepSeek V4 Pro",
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: ["low", "medium", "high", "max"],
|
||||
},
|
||||
{
|
||||
id: "deepseek-v4-flash",
|
||||
name: "DeepSeek V4 Flash",
|
||||
supportsReasoning: true,
|
||||
supportedThinkingEfforts: ["low", "medium", "high", "max"],
|
||||
},
|
||||
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true },
|
||||
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true },
|
||||
{ id: "kimi-k2.6", name: "Kimi K2.6" },
|
||||
// Ollama Cloud accepts low|medium|high|max|none and rejects xhigh, so the
|
||||
// explicit supportsXHighEffort:false makes the sanitizer map xhigh → max.
|
||||
@@ -50,14 +34,12 @@ export const ollama_cloudProvider: RegistryEntry = {
|
||||
name: "GLM 5.1",
|
||||
supportsReasoning: true,
|
||||
supportsXHighEffort: false,
|
||||
supportedThinkingEfforts: ["low", "medium", "high", "max"],
|
||||
},
|
||||
{
|
||||
id: "glm-5.2",
|
||||
name: "GLM 5.2",
|
||||
supportsReasoning: true,
|
||||
supportsXHighEffort: false,
|
||||
supportedThinkingEfforts: ["low", "medium", "high", "max"],
|
||||
},
|
||||
// #3110: MiniMax M3 via Ollama
|
||||
{ id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576, supportsVision: true },
|
||||
|
||||
@@ -1070,7 +1070,7 @@ async function wsChat(
|
||||
|
||||
const fail = (error: string) => finish({ content: "", deltas: [], error });
|
||||
|
||||
timeout = setTimeout(() => fail("Meta AI WebSocket timed out"), 30000);
|
||||
timeout = setTimeout(() => fail(`Meta AI WS timed out (readyState=${ws.readyState})`), 30000);
|
||||
abortHandler = () => fail("Request aborted");
|
||||
signal?.addEventListener("abort", abortHandler, { once: true });
|
||||
|
||||
|
||||
164
tests/unit/muse-spark-ws-timeout-diagnostics-10727.test.ts
Normal file
164
tests/unit/muse-spark-ws-timeout-diagnostics-10727.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
MuseSparkWebExecutor,
|
||||
__resetMuseSparkConversationCacheForTesting,
|
||||
__setMuseSparkWebSocketForTesting,
|
||||
} from "../../open-sse/executors/muse-spark-web.ts";
|
||||
import { WebSocket } from "ws";
|
||||
|
||||
// #10727: Meta AI (muse-spark-web) times out on the WS send-message step at
|
||||
// exactly the executor's hardcoded 30s timeout, with no onerror/onclose
|
||||
// firing first. The reporter's log shows the flow reaching wsChat and
|
||||
// hanging until the timeout fires, meaning Meta's gateway either never
|
||||
// truly opens the socket or silently drops frames after opening — but the
|
||||
// old flat "Meta AI WebSocket timed out" message could not distinguish
|
||||
// those two failure modes for whoever debugs the next occurrence.
|
||||
//
|
||||
// Root-causing (and fixing) the reverse-engineered private WS protocol
|
||||
// itself requires a live meta.ai session + a fresh DevTools capture (see
|
||||
// the plan-file's `needs-vps` verdict) — not achievable in this sandbox.
|
||||
// This regression test locks in the diagnosability improvement that *is*
|
||||
// verifiable here: the timeout error now reports the socket's readyState
|
||||
// at the moment it fires, so a future report can tell "never opened"
|
||||
// (readyState 0) apart from "opened but Meta went silent" (readyState 1).
|
||||
|
||||
/**
|
||||
* Intercepts only the wsChat 30000ms timeout registration and lets every
|
||||
* other setTimeout (including the mock WebSocket's own onopen scheduling)
|
||||
* run for real. Firing the captured callback directly — instead of
|
||||
* advancing a fake clock — keeps the test fast and avoids interleaving
|
||||
* bugs between fake timers and the executor's real async/await chain.
|
||||
*/
|
||||
function interceptWsTimeout(): { fire: () => void; restore: () => void } {
|
||||
const original = globalThis.setTimeout;
|
||||
let captured: (() => void) | null = null;
|
||||
globalThis.setTimeout = ((cb: (...a: unknown[]) => void, ms?: number, ...args: unknown[]) => {
|
||||
if (ms === 30000 && captured === null) {
|
||||
captured = cb as () => void;
|
||||
return 0 as unknown as ReturnType<typeof setTimeout>;
|
||||
}
|
||||
return original(cb as () => void, ms, ...args);
|
||||
}) as typeof setTimeout;
|
||||
return {
|
||||
fire: () => {
|
||||
assert.ok(captured, "the 30000ms wsChat timeout was never registered");
|
||||
captured?.();
|
||||
},
|
||||
restore: () => {
|
||||
globalThis.setTimeout = original;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class NeverOpensWebSocket {
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((evt: { data: string }) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: ((evt: Error) => void) | null = null;
|
||||
readyState = WebSocket.CONNECTING;
|
||||
url: string;
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
// Never calls onopen, onmessage, onerror, or onclose — mirrors the
|
||||
// reported symptom exactly: the socket just hangs until the timeout.
|
||||
}
|
||||
send(_data: Uint8Array | string) {}
|
||||
close() {}
|
||||
}
|
||||
|
||||
class OpensThenSilentWebSocket {
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((evt: { data: string }) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: ((evt: Error) => void) | null = null;
|
||||
readyState = WebSocket.CONNECTING;
|
||||
url: string;
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
setTimeout(() => {
|
||||
this.readyState = WebSocket.OPEN;
|
||||
this.onopen?.();
|
||||
}, 0);
|
||||
}
|
||||
send(_data: Uint8Array | string) {}
|
||||
close() {}
|
||||
}
|
||||
|
||||
function baseInput(connectionId: string): Parameters<MuseSparkWebExecutor["execute"]>[0] {
|
||||
return {
|
||||
model: "muse-spark",
|
||||
body: { messages: [{ role: "user", content: "ping" }] },
|
||||
stream: false,
|
||||
credentials: {
|
||||
apiKey: "ecto_1_sess=test123",
|
||||
connectionId,
|
||||
providerSpecificData: { authorization: "ecto1:test-auth-token" },
|
||||
},
|
||||
signal: null,
|
||||
log: null,
|
||||
upstreamExtraHeaders: undefined,
|
||||
} as Parameters<MuseSparkWebExecutor["execute"]>[0];
|
||||
}
|
||||
|
||||
test("#10727: WS timeout while still CONNECTING reports readyState=0 (never opened)", async () => {
|
||||
__resetMuseSparkConversationCacheForTesting();
|
||||
const executor = new MuseSparkWebExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => new Response("{}", { status: 200 });
|
||||
const restore = __setMuseSparkWebSocketForTesting(
|
||||
NeverOpensWebSocket as unknown as typeof WebSocket
|
||||
);
|
||||
const timeoutHook = interceptWsTimeout();
|
||||
try {
|
||||
const resultPromise = executor.execute(baseInput("conn-10727-never-opens"));
|
||||
// Let the GraphQL warmup/mode-switch awaits and the WS constructor run
|
||||
// before the 30s timeout is registered.
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
timeoutHook.fire();
|
||||
|
||||
const result = await resultPromise;
|
||||
assert.equal(result.response.status, 502);
|
||||
const body = await result.response.json();
|
||||
assert.match(
|
||||
body.error.message,
|
||||
/readyState=0/,
|
||||
"timeout while the socket never left CONNECTING must report readyState=0"
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
restore();
|
||||
timeoutHook.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("#10727: WS timeout after a successful open reports readyState=1 (opened, then silent)", async () => {
|
||||
__resetMuseSparkConversationCacheForTesting();
|
||||
const executor = new MuseSparkWebExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => new Response("{}", { status: 200 });
|
||||
const restore = __setMuseSparkWebSocketForTesting(
|
||||
OpensThenSilentWebSocket as unknown as typeof WebSocket
|
||||
);
|
||||
const timeoutHook = interceptWsTimeout();
|
||||
try {
|
||||
const resultPromise = executor.execute(baseInput("conn-10727-opens-silent"));
|
||||
// Let the GraphQL awaits run, the WS open (its own real setTimeout(...,0)),
|
||||
// and the intro/prompt frames send before the 30s timeout is registered.
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
timeoutHook.fire();
|
||||
|
||||
const result = await resultPromise;
|
||||
assert.equal(result.response.status, 502);
|
||||
const body = await result.response.json();
|
||||
assert.match(
|
||||
body.error.message,
|
||||
/readyState=1/,
|
||||
"timeout after the socket reached OPEN must report readyState=1, not the never-opened case"
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
restore();
|
||||
timeoutHook.restore();
|
||||
}
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { ollama_cloudProvider } from "../../open-sse/config/providers/registry/ollama-cloud/index.ts";
|
||||
|
||||
// #10788: ollama-cloud declared supportsReasoning:true on several models
|
||||
// (glm-5.1/5.2, deepseek-v4-pro/flash) but never declared
|
||||
// supportedThinkingEfforts. appendSyncedEffortVariants() (open-sse/utils/
|
||||
// syncedEffortVariants.ts) only synthesizes catalog `<model>-<tier>` ids from
|
||||
// an already-populated capabilities.effort_tiers, and for static registry
|
||||
// models that population only happens from a non-empty
|
||||
// supportedThinkingEfforts — so these models never got a selectable
|
||||
// -low/-high/-max catalog id. gpt-oss:20b/120b already declared it as the
|
||||
// control case.
|
||||
test("#10788: ollama-cloud reasoning-capable models declare supportedThinkingEfforts", () => {
|
||||
const byId = new Map(ollama_cloudProvider.models.map((m) => [m.id, m]));
|
||||
|
||||
const control = byId.get("gpt-oss:20b");
|
||||
assert.ok(
|
||||
Array.isArray(control?.supportedThinkingEfforts) && control.supportedThinkingEfforts.length > 0,
|
||||
"control: gpt-oss:20b should already declare supportedThinkingEfforts"
|
||||
);
|
||||
|
||||
const reasoningModelIds = ["glm-5.1", "glm-5.2", "deepseek-v4-pro", "deepseek-v4-flash"];
|
||||
for (const id of reasoningModelIds) {
|
||||
const model = byId.get(id);
|
||||
assert.ok(model?.supportsReasoning, `${id} should be flagged as a reasoning model`);
|
||||
assert.ok(
|
||||
Array.isArray(model?.supportedThinkingEfforts) && model.supportedThinkingEfforts.length > 0,
|
||||
`${id} supports reasoning but declares no supportedThinkingEfforts`
|
||||
);
|
||||
// Ollama Cloud's documented vocabulary (see supportsMaxEffortForProvider's
|
||||
// isOllamaCloud comment in open-sse/executors/base/reasoningEffort.ts):
|
||||
// low|medium|high|max|none — xhigh is rejected and mapped to max.
|
||||
assert.deepEqual(
|
||||
[...(model?.supportedThinkingEfforts ?? [])],
|
||||
["low", "medium", "high", "max"],
|
||||
`${id} should declare Ollama Cloud's documented low/medium/high/max vocabulary`
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user