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:
67
gateway/src/openai/affinity.ts
Normal file
67
gateway/src/openai/affinity.ts
Normal 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) };
|
||||
}
|
||||
Reference in New Issue
Block a user