mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
* fix(sse): per-model 403 on passthrough providers locks the model, not the connection (#3027)
A per-model subscription 403 from a passthrough / per-model-quota provider
(e.g. ollama-cloud "this model requires a subscription, upgrade for access" on
deepseek-v4-pro) cooled down the ENTIRE connection instead of locking out only
the paid model, knocking out the free models on the same key and escalating an
exponential connection-wide backoff on repeats.
markAccountUnavailable's per-model lockout gate only covered 404/429/>=500, and
the only 403 -> model-lockout special case was hard-coded to grok-web. Generalize
it: a model-scoped 403 on an isPerModelQuotaProvider becomes a model lockout
(connection stays active). Terminal whole-key 403s (permanent/banned, account
deactivated, credits exhausted, project-route) keep their connection-level path.
Test-first: reproduction (paid model locked, connection active, free model still
eligible), regression guard (deactivated key still terminal -> banned, not
downgraded), and backoff guard (repeated 403s do not escalate connection backoff).
* feat(sse): persistent session + rolling-window memory for deepseek-web (#2942)
The DeepSeek web API takes only a single `prompt` string (no messages array) and
the executor created a fresh chat session per request, deleting it afterward — so
agentic multi-turn clients got per-turn amnesia.
Add two opt-in, per-connection settings (providerSpecificData), both defaulting to
the legacy behavior so plain-chat users are unaffected:
- historyWindow (number, default 0): when > 0, messagesToPrompt stitches the last N
non-system turns into a role-tagged transcript ("User:"/"Assistant:") so context
carries across turns within the single prompt string.
- persistSession (bool, default false): reuse one upstream chat session per userToken
(cached in the existing sessionCache) instead of creating/deleting one per request.
A reused session that fails is treated as stale (deleted in the DeepSeek UI): the
cache entry is dropped, a fresh session is created, and the completion is retried
once. Error paths invalidate the cached session so the next turn self-heals.
Test-first: pure prompt-builder window semantics (legacy/window/cap/empty) and
execute()-level session behavior via mocked fetch (fresh-per-request default, reuse,
stale-session fresh retry, history threaded into the prompt). Full existing
deepseek-web suite (35 tests) still green.
Note: chat.deepseek.com is an unofficial reverse-engineered surface; the live
round-trip can't be exercised in CI (no userToken/session). Treated as best-effort
per the issue; the prompt/session logic is unit-tested in isolation.
* feat(sse): tool-call translation for deepseek-web (#2820)
deepseek-web previously hard-failed any request carrying tools[] with a 400 (#2848),
so agentic clients could not use it for function calling at all. Add a bidirectional
translation layer (new open-sse/translator/webTools.ts, reusable by the other
web-cookie executors):
- Request: serializeToolsToPrompt() turns the OpenAI tools[] into a <tool>{...}</tool>
prompt contract, injected as a leading system message.
- Response: parseToolCallsFromText() extracts the model's <tool> blocks into OpenAI
tool_calls (arguments as a JSON string), strips them from content, and the executor
emits finish_reason "tool_calls" — for both non-stream and stream clients (the reply
is buffered since a tool block must be parsed whole).
A plain reply (no <tool> block) still streams normally with finish_reason "stop".
The superseded #2848 400-contract test is updated to assert the new translation.
Test-first: pure serializer/parser units + execute() round-trip via mocked fetch
(no-400, prompt serialization, non-stream tool_calls, stream tool_calls, plain reply).
Note: chat.deepseek.com is an unofficial reverse-engineered surface and the exact
<tool> emission depends on the model following the injected contract; the live
round-trip can't be exercised in CI. Best-effort per the issue; translation logic is
unit-tested in isolation.
* fix(sse): drop duplicate per-model 403 block — already in release via #3096
The release branch already scopes per-model 403 to model lockout (PR #3096,
commit 7042d562c) with the canonical !terminalStatus guard + exactCooldownMs
upstream hint. This PR's separate isTerminalOrRoute403 block shadowed it and
omitted the retry hint. Keep only the deepseek-web (#2942) + webTools (#2820)
changes here; the #3027 regression test is retained as coverage for #3096.
190 lines
6.7 KiB
TypeScript
190 lines
6.7 KiB
TypeScript
// @ts-nocheck
|
|
// #2942 — persistent session for deepseek-web. Default (no config) keeps the legacy
|
|
// fresh-session-per-request behavior. With providerSpecificData.persistSession=true the
|
|
// executor reuses one upstream chat session across requests (keyed by userToken), does
|
|
// not delete it, and on a reused-session failure falls back to a fresh session + retries
|
|
// once. historyWindow is threaded into the prompt builder.
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
const dsMod = await import("../../open-sse/executors/deepseek-web.ts");
|
|
const { DeepSeekWebExecutor } = dsMod;
|
|
|
|
const POW_CHALLENGE = {
|
|
algorithm: "DeepSeekHashV1",
|
|
challenge: "311b26ae1e0fe7375e242958ce46db5552a6c67fea3f96880dcd846c63a74286",
|
|
salt: "1122334455667788",
|
|
signature: "sig123",
|
|
difficulty: 1,
|
|
expire_at: 1778891543095,
|
|
expire_after: 300000,
|
|
target_path: "/api/v0/chat/completion",
|
|
};
|
|
|
|
const OK_SSE = [
|
|
"event: ready\n",
|
|
'data: {"request_message_id":1,"response_message_id":2}\n',
|
|
"\n",
|
|
'data: {"v":{"response":{"message_id":2,"fragments":[{"id":1,"type":"RESPONSE","content":"Hi"}]}}}\n',
|
|
"\n",
|
|
'data: {"p":"response/status","o":"SET","v":"FINISHED"}\n',
|
|
"\n",
|
|
"event: close\n",
|
|
'data: {"click_behavior":"none"}\n',
|
|
].join("");
|
|
|
|
/**
|
|
* Mock the DeepSeek web API. `completionOutcomes` is consumed one per completion call:
|
|
* "ok" -> 200 SSE stream
|
|
* "fail" -> 500 error
|
|
* When exhausted, defaults to "ok".
|
|
*/
|
|
function installMock(completionOutcomes = []) {
|
|
const original = globalThis.fetch;
|
|
const calls = { create: 0, delete: 0, completion: 0, completionBodies: [] };
|
|
let outcomeIdx = 0;
|
|
dsMod.tokenCache?.clear();
|
|
dsMod.sessionCache?.clear();
|
|
|
|
globalThis.fetch = async (url, opts = {}) => {
|
|
const u = String(url);
|
|
if (u.includes("/users/current")) {
|
|
return new Response(
|
|
JSON.stringify({ code: 0, data: { biz_data: { token: "access-token-xyz" } } }),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
);
|
|
}
|
|
if (u.includes("/chat_session/create")) {
|
|
calls.create += 1;
|
|
return new Response(
|
|
JSON.stringify({
|
|
code: 0,
|
|
data: { biz_data: { chat_session: { id: `session-${calls.create}` } } },
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
);
|
|
}
|
|
if (u.includes("/chat_session/delete")) {
|
|
calls.delete += 1;
|
|
return new Response(JSON.stringify({ code: 0 }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
if (u.includes("/create_pow_challenge")) {
|
|
return new Response(JSON.stringify({ code: 0, data: { biz_data: { challenge: POW_CHALLENGE } } }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
if (u.includes("/chat/completion")) {
|
|
calls.completion += 1;
|
|
try {
|
|
calls.completionBodies.push(JSON.parse(opts.body));
|
|
} catch {
|
|
calls.completionBodies.push(null);
|
|
}
|
|
const outcome = completionOutcomes[outcomeIdx++] ?? "ok";
|
|
if (outcome === "fail") {
|
|
return new Response(JSON.stringify({ error: "server" }), {
|
|
status: 500,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
return new Response(new TextEncoder().encode(OK_SSE), {
|
|
status: 200,
|
|
headers: { "Content-Type": "text/event-stream" },
|
|
});
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
};
|
|
|
|
return {
|
|
calls,
|
|
restore: () => {
|
|
globalThis.fetch = original;
|
|
dsMod.tokenCache?.clear();
|
|
dsMod.sessionCache?.clear();
|
|
},
|
|
};
|
|
}
|
|
|
|
async function run(executor, { persistSession, historyWindow, messages, token } = {}) {
|
|
const providerSpecificData = {};
|
|
if (persistSession !== undefined) providerSpecificData.persistSession = persistSession;
|
|
if (historyWindow !== undefined) providerSpecificData.historyWindow = historyWindow;
|
|
const result = await executor.execute({
|
|
model: "default",
|
|
body: { messages: messages ?? [{ role: "user", content: "hi" }] },
|
|
stream: false,
|
|
credentials: { apiKey: token ?? "user-token-1", providerSpecificData },
|
|
signal: AbortSignal.timeout(10000),
|
|
});
|
|
// drain so the stream-cleanup (delete) fires deterministically
|
|
await result.response.text();
|
|
return result;
|
|
}
|
|
|
|
test("default (no config): fresh session per request + deletes it", async () => {
|
|
const mock = installMock();
|
|
try {
|
|
const executor = new DeepSeekWebExecutor();
|
|
await run(executor, { token: "tkn-default" });
|
|
await run(executor, { token: "tkn-default" });
|
|
assert.equal(mock.calls.create, 2, "creates a fresh session each request");
|
|
assert.ok(mock.calls.delete >= 1, "deletes the session (no persistence)");
|
|
} finally {
|
|
mock.restore();
|
|
}
|
|
});
|
|
|
|
test("persistSession=true: reuses cached session across requests, does not delete", async () => {
|
|
const mock = installMock();
|
|
try {
|
|
const executor = new DeepSeekWebExecutor();
|
|
await run(executor, { persistSession: true, token: "tkn-persist" });
|
|
await run(executor, { persistSession: true, token: "tkn-persist" });
|
|
assert.equal(mock.calls.create, 1, "session created once and reused");
|
|
assert.equal(mock.calls.delete, 0, "persistent session is not deleted");
|
|
} finally {
|
|
mock.restore();
|
|
}
|
|
});
|
|
|
|
test("persistSession=true: reused-session failure falls back to a fresh session and retries once", async () => {
|
|
// call 1 -> create+cache (ok). call 2 -> reuse cached -> completion 500 -> create fresh -> retry ok.
|
|
const mock = installMock(["ok", "fail", "ok"]);
|
|
try {
|
|
const executor = new DeepSeekWebExecutor();
|
|
const r1 = await run(executor, { persistSession: true, token: "tkn-heal" });
|
|
assert.ok(r1.response.ok, "first call ok");
|
|
const r2 = await run(executor, { persistSession: true, token: "tkn-heal" });
|
|
assert.ok(r2.response.ok, "second call recovers via fresh-session retry");
|
|
assert.equal(mock.calls.create, 2, "one initial + one fresh-session retry");
|
|
assert.equal(mock.calls.completion, 3, "1 (call1) + 1 failed reuse + 1 retry");
|
|
} finally {
|
|
mock.restore();
|
|
}
|
|
});
|
|
|
|
test("historyWindow is threaded into the completion prompt", async () => {
|
|
const mock = installMock();
|
|
try {
|
|
const executor = new DeepSeekWebExecutor();
|
|
await run(executor, {
|
|
historyWindow: 10,
|
|
token: "tkn-hist",
|
|
messages: [
|
|
{ role: "user", content: "earlier-turn-marker" },
|
|
{ role: "assistant", content: "ack" },
|
|
{ role: "user", content: "latest-turn" },
|
|
],
|
|
});
|
|
const body = mock.calls.completionBodies[0];
|
|
assert.ok(body.prompt.includes("earlier-turn-marker"), "earlier turn carried into prompt");
|
|
assert.ok(body.prompt.includes("latest-turn"), "latest turn present");
|
|
} finally {
|
|
mock.restore();
|
|
}
|
|
});
|