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>
210 lines
7.0 KiB
TypeScript
210 lines
7.0 KiB
TypeScript
/**
|
|
* gateway/src/db/store.ts — synchronous SQLite store (mirrors buyer/src/db/archive.ts).
|
|
*
|
|
* better-sqlite3 + WAL + prepared statements. Low write volume (≤1 row per
|
|
* request) so synchronous is fine and simpler than async.
|
|
*/
|
|
|
|
import { mkdirSync } from "fs";
|
|
import { join, resolve } from "path";
|
|
import Database from "better-sqlite3";
|
|
import type { Database as BetterDatabase } from "better-sqlite3";
|
|
import { CREATE_TABLES_SQL } from "./schema.js";
|
|
|
|
export interface ApiKeyRow {
|
|
id: string;
|
|
key_hash: string;
|
|
key_prefix: string;
|
|
label: string | null;
|
|
wallet_pkh: string;
|
|
deposit_address: string;
|
|
enc_priv_nonce: string;
|
|
enc_priv_ct: string;
|
|
enc_priv_tag: string;
|
|
master_key_version: number;
|
|
created_at: number;
|
|
disabled: number;
|
|
/** 1 = shared demo key: withdraw blocked, session-backed completions, per-IP limits. */
|
|
demo: number;
|
|
}
|
|
|
|
export interface UsageRow {
|
|
id: string;
|
|
key_id: string;
|
|
created_at: number;
|
|
kind: string;
|
|
model: string | null;
|
|
capability_id: string | null;
|
|
supplier_pkh: string | null;
|
|
escrow_ref: string | null;
|
|
cost_lovelace: string | null;
|
|
prompt_tokens: number;
|
|
completion_tokens: number;
|
|
status: string;
|
|
failure_reason: string | null;
|
|
}
|
|
|
|
export interface SessionRow {
|
|
id: string;
|
|
key_id: string;
|
|
escrow_ref: string;
|
|
session_nonce: string;
|
|
supplier_base_url: string;
|
|
supplier_pkh: string;
|
|
model: string;
|
|
price_lovelace: string;
|
|
state: string;
|
|
opened_at: number;
|
|
closed_at: number | null;
|
|
}
|
|
|
|
export class GatewayStore {
|
|
private readonly db: BetterDatabase;
|
|
|
|
private readonly sInsertKey;
|
|
private readonly sGetKeyByHash;
|
|
private readonly sGetKeyById;
|
|
private readonly sListKeys;
|
|
private readonly sListAllKeys;
|
|
private readonly sUpdateKeyEncryption;
|
|
private readonly sInsertUsage;
|
|
private readonly sSumCost;
|
|
private readonly sCountByKey;
|
|
private readonly sListUsage;
|
|
private readonly sInsertSession;
|
|
private readonly sGetSession;
|
|
private readonly sSetSessionState;
|
|
private readonly sListOpenSessions;
|
|
|
|
constructor(dbDir: string) {
|
|
const dir = resolve(dbDir);
|
|
mkdirSync(dir, { recursive: true });
|
|
this.db = new Database(join(dir, "gateway.db"));
|
|
this.db.pragma("journal_mode = WAL");
|
|
this.db.exec(CREATE_TABLES_SQL);
|
|
|
|
// Additive migration for DBs created before the demo flag existed
|
|
// (CREATE TABLE IF NOT EXISTS won't alter an existing table).
|
|
const keyCols = this.db.pragma("table_info(api_keys)") as Array<{ name: string }>;
|
|
if (!keyCols.some((c) => c.name === "demo")) {
|
|
this.db.exec("ALTER TABLE api_keys ADD COLUMN demo INTEGER NOT NULL DEFAULT 0");
|
|
}
|
|
|
|
this.sInsertKey = this.db.prepare(`
|
|
INSERT INTO api_keys (
|
|
id, key_hash, key_prefix, label, wallet_pkh, deposit_address,
|
|
enc_priv_nonce, enc_priv_ct, enc_priv_tag, master_key_version, created_at, disabled, demo
|
|
) VALUES (
|
|
@id, @key_hash, @key_prefix, @label, @wallet_pkh, @deposit_address,
|
|
@enc_priv_nonce, @enc_priv_ct, @enc_priv_tag, @master_key_version, @created_at, 0, @demo
|
|
)
|
|
`);
|
|
this.sGetKeyByHash = this.db.prepare(`SELECT * FROM api_keys WHERE key_hash = @key_hash`);
|
|
this.sGetKeyById = this.db.prepare(`SELECT * FROM api_keys WHERE id = @id`);
|
|
this.sListKeys = this.db.prepare(`SELECT * FROM api_keys WHERE disabled = 0`);
|
|
this.sListAllKeys = this.db.prepare(`SELECT * FROM api_keys`);
|
|
this.sUpdateKeyEncryption = this.db.prepare(`
|
|
UPDATE api_keys SET enc_priv_nonce = @enc_priv_nonce, enc_priv_ct = @enc_priv_ct,
|
|
enc_priv_tag = @enc_priv_tag, master_key_version = @master_key_version WHERE id = @id
|
|
`);
|
|
|
|
this.sInsertUsage = this.db.prepare(`
|
|
INSERT INTO usage (
|
|
id, key_id, created_at, kind, model, capability_id, supplier_pkh, escrow_ref,
|
|
cost_lovelace, prompt_tokens, completion_tokens, status, failure_reason
|
|
) VALUES (
|
|
@id, @key_id, @created_at, @kind, @model, @capability_id, @supplier_pkh, @escrow_ref,
|
|
@cost_lovelace, @prompt_tokens, @completion_tokens, @status, @failure_reason
|
|
)
|
|
`);
|
|
this.sSumCost = this.db.prepare(`
|
|
SELECT COALESCE(SUM(CAST(cost_lovelace AS INTEGER)), 0) AS total
|
|
FROM usage WHERE key_id = @key_id AND status = 'completed'
|
|
`);
|
|
this.sCountByKey = this.db.prepare(`SELECT COUNT(*) AS n FROM usage WHERE key_id = @key_id`);
|
|
this.sListUsage = this.db.prepare(`
|
|
SELECT * FROM usage WHERE key_id = @key_id ORDER BY created_at DESC LIMIT @limit
|
|
`);
|
|
|
|
this.sInsertSession = this.db.prepare(`
|
|
INSERT INTO sessions (
|
|
id, key_id, escrow_ref, session_nonce, supplier_base_url, supplier_pkh,
|
|
model, price_lovelace, state, opened_at, closed_at
|
|
) VALUES (
|
|
@id, @key_id, @escrow_ref, @session_nonce, @supplier_base_url, @supplier_pkh,
|
|
@model, @price_lovelace, @state, @opened_at, NULL
|
|
)
|
|
`);
|
|
this.sGetSession = this.db.prepare(`SELECT * FROM sessions WHERE id = @id`);
|
|
this.sSetSessionState = this.db.prepare(`
|
|
UPDATE sessions SET state = @state, closed_at = @closed_at WHERE id = @id
|
|
`);
|
|
this.sListOpenSessions = this.db.prepare(`SELECT * FROM sessions WHERE state = 'open'`);
|
|
}
|
|
|
|
insertKey(row: Omit<ApiKeyRow, "disabled" | "demo"> & { demo?: number }): void {
|
|
this.sInsertKey.run({ ...row, label: row.label ?? null, demo: row.demo ?? 0 });
|
|
}
|
|
|
|
getKeyByHash(keyHash: string): ApiKeyRow | undefined {
|
|
return this.sGetKeyByHash.get({ key_hash: keyHash }) as ApiKeyRow | undefined;
|
|
}
|
|
|
|
getKeyById(id: string): ApiKeyRow | undefined {
|
|
return this.sGetKeyById.get({ id }) as ApiKeyRow | undefined;
|
|
}
|
|
|
|
listKeys(): ApiKeyRow[] {
|
|
return this.sListKeys.all() as ApiKeyRow[];
|
|
}
|
|
|
|
listAllKeys(): ApiKeyRow[] {
|
|
return this.sListAllKeys.all() as ApiKeyRow[];
|
|
}
|
|
|
|
updateKeyEncryption(
|
|
id: string,
|
|
enc: { enc_priv_nonce: string; enc_priv_ct: string; enc_priv_tag: string; master_key_version: number },
|
|
): void {
|
|
this.sUpdateKeyEncryption.run({ id, ...enc });
|
|
}
|
|
|
|
insertUsage(row: UsageRow): void {
|
|
this.sInsertUsage.run(row);
|
|
}
|
|
|
|
sumCostLovelace(keyId: string): bigint {
|
|
const r = this.sSumCost.get({ key_id: keyId }) as { total: number } | undefined;
|
|
return BigInt(r?.total ?? 0);
|
|
}
|
|
|
|
countUsage(keyId: string): number {
|
|
const r = this.sCountByKey.get({ key_id: keyId }) as { n: number } | undefined;
|
|
return r?.n ?? 0;
|
|
}
|
|
|
|
listUsage(keyId: string, limit: number): UsageRow[] {
|
|
return this.sListUsage.all({ key_id: keyId, limit }) as UsageRow[];
|
|
}
|
|
|
|
insertSession(row: Omit<SessionRow, "closed_at">): void {
|
|
this.sInsertSession.run(row);
|
|
}
|
|
|
|
getSession(id: string): SessionRow | undefined {
|
|
return this.sGetSession.get({ id }) as SessionRow | undefined;
|
|
}
|
|
|
|
setSessionState(id: string, state: string, closedAt: number | null): void {
|
|
this.sSetSessionState.run({ id, state, closed_at: closedAt });
|
|
}
|
|
|
|
listOpenSessions(): SessionRow[] {
|
|
return this.sListOpenSessions.all() as SessionRow[];
|
|
}
|
|
|
|
listOpenSessionsByKey(keyId: string): SessionRow[] {
|
|
return this.listOpenSessions().filter((s) => s.key_id === keyId);
|
|
}
|
|
}
|