fix(gateway,supplier): demo sessions stick to their supplier; free pinned suppliers
The demo endpoint lost its session on nearly every second message: affinity
required clients to re-echo the assistant reply byte-identically, so clients
that strip <think> blocks or reshape whitespace (Open WebUI, litellm) missed
every turn, opened a fresh escrow session on the next free single-slot
supplier, and left the old one pinned. Open WebUI's background title/tag-gen
requests each pinned another supplier — one message could exhaust all three
kimi replicas ("no available chat supplier").
- affinity: lenient assistant-content matching (think-strip + whitespace
collapse); user/system/tool messages and tool_calls stay byte-strict;
strict prefixes outrank lenient ones. The mirror is never rewritten with
client text, so receipt hashes stay valid.
- demoChat: trim trailing assistant/system messages (regenerate semantics)
instead of 400 "the last message must have role user or tool".
- supplier: roll the transcript back when the LLM call fails so retries
don't duplicate the delta (ChatSessionStore.truncateTranscript).
- demoChat: LRU-evict a demo session when no supplier is available (supplier
/v1/chat/end frees the slot, sweeper Accepts the escrow) and retry the
open with selectCandidates ignoreStatusFor to bypass the ~20s-stale
indexer status poll. Max 2 evictions per request.
- sweeper: idle janitor closes demo sessions unused for DEMO_SESSION_IDLE_MS
(default 3 min, below the supplier's 5 min CHAT_IDLE_TIMEOUT_MS) so
abandoned one-off sessions release their suppliers quickly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
122
tests/unit/supplier-chat-session-rollback.test.ts
Normal file
122
tests/unit/supplier-chat-session-rollback.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* supplier-chat-session-rollback.test.ts — failed-turn transcript rollback.
|
||||
*
|
||||
* The chat-session message handler appends the incoming delta BEFORE calling
|
||||
* the LLM backend; the gateway mirror only appends after a successful turn.
|
||||
* On backend failure the supplier must truncate the delta back out, so a
|
||||
* retried turn doesn't duplicate messages and both transcripts stay
|
||||
* hash-identical for the receipt.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import request from "supertest";
|
||||
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js";
|
||||
import type { AdvertDatum, EscrowDatum } from "../../packages/shared/src/cbor/types.js";
|
||||
import type { SupplierConfig } from "../../supplier/src/config.js";
|
||||
import { SupplierState } from "../../supplier/src/state.js";
|
||||
import { JobStore } from "../../supplier/src/jobs.js";
|
||||
import { ChatSessionStore } from "../../supplier/src/chatSession.js";
|
||||
import { createApp } from "../../supplier/src/server.js";
|
||||
import { buildSampleConfig } from "../fixtures/supplier-side/sample-config.js";
|
||||
import { buildSupplierWalletKey } from "../fixtures/supplier-side/wallet-keys.js";
|
||||
|
||||
const ESCROW_REF = `${"e".repeat(64)}#0`;
|
||||
|
||||
function chatSessionConfig(): SupplierConfig {
|
||||
return {
|
||||
...buildSampleConfig(),
|
||||
capabilityKind: "chat-session",
|
||||
llmBackend: "openai",
|
||||
ollamaUrl: "",
|
||||
openaiBaseUrl: "http://up",
|
||||
openaiApiKey: "",
|
||||
openaiTimeoutMs: 5_000,
|
||||
openaiMaxTokens: 0,
|
||||
openaiReasoningDisabled: false,
|
||||
chatIdleTimeoutMs: 60_000,
|
||||
} as SupplierConfig;
|
||||
}
|
||||
|
||||
function makeAppWithSession() {
|
||||
const chatSessions = new ChatSessionStore();
|
||||
const record = chatSessions.create({
|
||||
escrowRef: ESCROW_REF,
|
||||
claimedRef: { txHash: "c".repeat(64), index: 0 },
|
||||
advert: { model: "kimi" } as AdvertDatum,
|
||||
escrowDatum: {} as EscrowDatum,
|
||||
});
|
||||
const app = createApp({
|
||||
chain: new MockChainProvider(),
|
||||
state: new SupplierState(),
|
||||
config: chatSessionConfig(),
|
||||
supplierKey: buildSupplierWalletKey(),
|
||||
jobs: new JobStore(),
|
||||
chatSessions,
|
||||
});
|
||||
return { app, chatSessions, record };
|
||||
}
|
||||
|
||||
function sseBody(frames: unknown[]): string {
|
||||
return frames.map((f) => `data: ${typeof f === "string" ? f : JSON.stringify(f)}\n\n`).join("");
|
||||
}
|
||||
|
||||
const OK_STREAM = sseBody([
|
||||
{ choices: [{ delta: { content: "Hello" }, finish_reason: null }] },
|
||||
{ choices: [{ delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 3, completion_tokens: 2 } },
|
||||
"[DONE]",
|
||||
]);
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("ChatSessionStore.truncateTranscript", () => {
|
||||
it("rolls an appended delta back and no-ops on stale targets", () => {
|
||||
const store = new ChatSessionStore();
|
||||
store.create({
|
||||
escrowRef: ESCROW_REF,
|
||||
claimedRef: { txHash: "c".repeat(64), index: 0 },
|
||||
advert: { model: "kimi" } as AdvertDatum,
|
||||
escrowDatum: {} as EscrowDatum,
|
||||
});
|
||||
store.appendMessages(ESCROW_REF, [{ role: "user", content: "hi" }]);
|
||||
store.truncateTranscript(ESCROW_REF, 0);
|
||||
expect(store.get(ESCROW_REF)!.transcript).toEqual([]);
|
||||
|
||||
store.appendMessages(ESCROW_REF, [{ role: "user", content: "hi" }]);
|
||||
store.truncateTranscript(ESCROW_REF, 5); // target beyond current length
|
||||
store.truncateTranscript(ESCROW_REF, -1); // nonsense target
|
||||
store.truncateTranscript("missing", 0); // unknown session
|
||||
expect(store.get(ESCROW_REF)!.transcript).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /v1/chat/message — failed-turn rollback", () => {
|
||||
it("truncates the delta on backend failure, then a retry appends exactly once", async () => {
|
||||
const { app, record } = makeAppWithSession();
|
||||
const delta = [{ role: "user", content: "hi" }];
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("boom")));
|
||||
const failed = await request(app)
|
||||
.post("/v1/chat/message")
|
||||
.set("X-Escrow-Ref", ESCROW_REF)
|
||||
.send({ messages: delta });
|
||||
expect(failed.text).toContain('"type":"error"');
|
||||
// The failed delta was rolled back — nothing to duplicate on retry.
|
||||
expect(record.transcript).toEqual([]);
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(new Response(OK_STREAM, { status: 200, headers: { "content-type": "text/event-stream" } })),
|
||||
);
|
||||
const ok = await request(app)
|
||||
.post("/v1/chat/message")
|
||||
.set("X-Escrow-Ref", ESCROW_REF)
|
||||
.send({ messages: delta });
|
||||
expect(ok.text).toContain('"type":"done"');
|
||||
expect(record.transcript).toEqual([
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "assistant", content: "Hello" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user