feat(gateway,supplier,shared): shared demo key with session-backed OpenAI endpoint + tool-calling

MVP demo: one operator-funded API key anyone can plug into an IDE/harness,
running real escrow-per-session marketplace conversations (llm.chat.v1)
behind the standard OpenAI /chat/completions wire shape.

- shared: ChatMessage gains tool role + tool_calls; normalizeChatMessage/
  chatMessagesEquivalent keep supplier transcript and gateway mirror
  hash-identical for receipts (field presence is part of the identity)
- supplier: /v1/chat/message accepts {messages,tools,tool_choice} alongside
  legacy {content}; callOpenAiStream forwards tools, accumulates fragmented
  delta.tool_calls, allows content-less tool turns; done frame carries the
  assistant message verbatim + finish_reason + usage
- gateway: demo flag on api_keys (additive migration), create-demo-key CLI,
  withdraw 403 + hidden deposit address for demo keys, per-IP demo rate
  limit (demo keys skip the shared per-key limit), models filtered to chat
  capability
- gateway executor (demoChat.ts): transcript-prefix affinity maps stateless
  calls onto open sessions, forwards only unseen deltas, transparently
  reopens on supplier idle-close; never falls back to one-shot (its settle
  path assumes <=1 in-flight escrow per wallet)
- mutex scope: key mutex held only for chain work (open/close); off-chain
  turns serialize on per-session locks so replicas serve users concurrently
- sweeper: chat-aware reclaim guard (35min vs 11min) and abandoned-session
  cleanup; terminal state confirmed via on-chain queryUtxo (indexer keeps
  lineage rows at their old state forever)
- deploy: kimi-chat replicas 2+3 compose files; chat idle timeout 2.5min

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
david
2026-07-07 10:30:17 +02:00
parent b92bff56a7
commit 4ebe7c9683
35 changed files with 1876 additions and 114 deletions

View File

@@ -0,0 +1,67 @@
/**
* gateway/src/openai/affinity.ts — transcript-prefix session matching.
*
* Stateless OpenAI clients resend the FULL message history each call; sessions
* are stateful. An incoming request belongs to an open session when that
* session's mirrored transcript is a strict prefix of the incoming messages —
* the suffix (delta) is the next turn. Agentic tool loops extend the
* transcript monotonically, so affinity hits nearly always; history divergence
* (IDE compaction, regenerate, retry) just misses and costs one new session.
*
* Pure module — no I/O — so the matching rules are unit-testable.
*/
import { chatMessagesEquivalent, type ChatMessage } from "@marketplace/shared/tx";
export interface AffinityCandidate {
sessionId: string;
model: string;
mirror: ChatMessage[];
lastUsedAt: number;
}
export interface AffinityMatch {
sessionId: string;
/** Messages the session has not seen yet — forwarded as the next turn. */
delta: ChatMessage[];
}
function isPrefix(mirror: ChatMessage[], incoming: ChatMessage[]): boolean {
if (mirror.length >= incoming.length) return false;
return mirror.every((m, i) => chatMessagesEquivalent(m, incoming[i]));
}
function deltaRespondable(delta: ChatMessage[]): boolean {
const last = delta[delta.length - 1];
return last !== undefined && (last.role === "user" || last.role === "tool");
}
/**
* Pick the open session whose mirror best matches the incoming history.
* Longest mirror wins (most conversation reuse); ties go to the most recently
* used session. Returns null when nothing matches — caller opens a new
* session and replays the full history. Note `incoming.length <= mirror.length`
* never matches (a client retry of an already-answered request is a miss by
* design: the session transcript must stay append-only).
*/
export function matchSessionPrefix(
candidates: AffinityCandidate[],
incoming: ChatMessage[],
model: string,
): AffinityMatch | null {
let best: AffinityCandidate | null = null;
for (const cand of candidates) {
if (cand.model !== model) continue;
if (!isPrefix(cand.mirror, incoming)) continue;
if (!deltaRespondable(incoming.slice(cand.mirror.length))) continue;
if (
best === null ||
cand.mirror.length > best.mirror.length ||
(cand.mirror.length === best.mirror.length && cand.lastUsedAt > best.lastUsedAt)
) {
best = cand;
}
}
if (!best) return null;
return { sessionId: best.sessionId, delta: incoming.slice(best.mirror.length) };
}