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:
@@ -685,9 +685,11 @@ export function createApp(deps: AppDeps): Express {
|
|||||||
const DEMO_CHAT_MODEL = "moonshotai/kimi-k2.6";
|
const DEMO_CHAT_MODEL = "moonshotai/kimi-k2.6";
|
||||||
|
|
||||||
function isChatMessageArray(v: unknown): v is ChatMessage[] {
|
function isChatMessageArray(v: unknown): v is ChatMessage[] {
|
||||||
|
// The free chat demo stays text-only: the `tool` role (added to
|
||||||
|
// ChatMessage for the gateway demo endpoint) is not accepted here.
|
||||||
return Array.isArray(v) && v.length > 0 && v.every(
|
return Array.isArray(v) && v.length > 0 && v.every(
|
||||||
(m) => m && typeof m === "object"
|
(m) => m && typeof m === "object"
|
||||||
&& (["system", "user", "assistant"] as const).includes((m as ChatMessage).role)
|
&& ["system", "user", "assistant"].includes((m as ChatMessage).role)
|
||||||
&& typeof (m as ChatMessage).content === "string",
|
&& typeof (m as ChatMessage).content === "string",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
76
deploy/mainnet/docker-compose.supplier-kimi-chat-2.yml
Normal file
76
deploy/mainnet/docker-compose.supplier-kimi-chat-2.yml
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# deploy/mainnet/docker-compose.supplier-kimi-chat-2.yml — Supplier — MAINNET
|
||||||
|
# (OpenRouter / Kimi K2.6 — MULTI-TURN CHAT capability `llm.chat.v1`, REPLICA 2)
|
||||||
|
#
|
||||||
|
# Second llm.chat.v1 replica: a chat session holds a supplier's single slot for
|
||||||
|
# the whole conversation, so concurrent chat users = number of chat suppliers.
|
||||||
|
# Deployed for the shared-demo-key endpoint (session-backed /chat/completions).
|
||||||
|
# Identical to docker-compose.supplier-kimi-chat.yml except for its own wallet,
|
||||||
|
# advert, env file and hostname.
|
||||||
|
#
|
||||||
|
# Pre-flight:
|
||||||
|
# 1. DNS A-record: mp-suppliers-kimi-chat-2.vector.apexfusion.org → host IP
|
||||||
|
# 2. New supplier wallet (gen-keypair), funded with advert bond + fee float.
|
||||||
|
# 3. Post a fresh llm.chat.v1 advert for this wallet:
|
||||||
|
# --capability-id llm.chat.v1 --model moonshotai/kimi-k2.6
|
||||||
|
# --max-processing-ms 1800000 (same price as the primary chat advert)
|
||||||
|
# 4. supplier/.env.kimi-chat-2 written on host (chmod 600) with at minimum:
|
||||||
|
# SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
|
||||||
|
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
|
||||||
|
# LIVE_CHAIN=1
|
||||||
|
|
||||||
|
name: marketplace-mainnet-supplier-kimi-chat-2
|
||||||
|
|
||||||
|
services:
|
||||||
|
supplier:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: supplier/Dockerfile
|
||||||
|
container_name: marketplace-mainnet-supplier-kimi-chat-2
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- path: ../../supplier/.env.kimi-chat-2
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
|
||||||
|
CAPABILITY_KIND: chat-session
|
||||||
|
LLM_BACKEND: openai
|
||||||
|
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||||
|
OPENAI_BASE_URL: https://openrouter.ai/api
|
||||||
|
# Kimi K2.6 generations can be slow; mirror the one-off Kimi compose.
|
||||||
|
OPENAI_TIMEOUT_MS: "300000"
|
||||||
|
# 2.5 min idle end: single-slot suppliers must free up quickly for the
|
||||||
|
# shared demo endpoint (the gateway transparently reopens on next turn).
|
||||||
|
CHAT_IDLE_TIMEOUT_MS: "150000"
|
||||||
|
PORT: "8080"
|
||||||
|
# Enable LiveOgmiosProvider so Claim/Submit actually broadcast.
|
||||||
|
LIVE_CHAIN: "1"
|
||||||
|
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||||
|
# come from the operator-provided supplier/.env.kimi-chat-2 on a mainnet host.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||||
|
interval: 20s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
networks:
|
||||||
|
- marketplace-mainnet-net
|
||||||
|
- traefik-net
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=traefik-net"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet.entrypoints=web"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet.rule=Host(`mp-suppliers-kimi-chat-2.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.services.mp-suppliers-kimi-chat-2-mainnet.loadbalancer.server.port=8080"
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
marketplace-mainnet-net:
|
||||||
|
external: true
|
||||||
|
name: marketplace-mainnet-net
|
||||||
|
traefik-net:
|
||||||
|
external: true
|
||||||
|
name: traefik-net
|
||||||
76
deploy/mainnet/docker-compose.supplier-kimi-chat-3.yml
Normal file
76
deploy/mainnet/docker-compose.supplier-kimi-chat-3.yml
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# deploy/mainnet/docker-compose.supplier-kimi-chat-3.yml — Supplier — MAINNET
|
||||||
|
# (OpenRouter / Kimi K2.6 — MULTI-TURN CHAT capability `llm.chat.v1`, REPLICA 3)
|
||||||
|
#
|
||||||
|
# Third llm.chat.v1 replica: a chat session holds a supplier's single slot for
|
||||||
|
# the whole conversation, so concurrent chat users = number of chat suppliers.
|
||||||
|
# Deployed for the shared-demo-key endpoint (session-backed /chat/completions).
|
||||||
|
# Identical to docker-compose.supplier-kimi-chat.yml except for its own wallet,
|
||||||
|
# advert, env file and hostname.
|
||||||
|
#
|
||||||
|
# Pre-flight:
|
||||||
|
# 1. DNS A-record: mp-suppliers-kimi-chat-3.vector.apexfusion.org → host IP
|
||||||
|
# 2. New supplier wallet (gen-keypair), funded with advert bond + fee float.
|
||||||
|
# 3. Post a fresh llm.chat.v1 advert for this wallet:
|
||||||
|
# --capability-id llm.chat.v1 --model moonshotai/kimi-k2.6
|
||||||
|
# --max-processing-ms 1800000 (same price as the primary chat advert)
|
||||||
|
# 4. supplier/.env.kimi-chat-3 written on host (chmod 600) with at minimum:
|
||||||
|
# SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
|
||||||
|
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
|
||||||
|
# LIVE_CHAIN=1
|
||||||
|
|
||||||
|
name: marketplace-mainnet-supplier-kimi-chat-3
|
||||||
|
|
||||||
|
services:
|
||||||
|
supplier:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: supplier/Dockerfile
|
||||||
|
container_name: marketplace-mainnet-supplier-kimi-chat-3
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- path: ../../supplier/.env.kimi-chat-3
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
|
||||||
|
CAPABILITY_KIND: chat-session
|
||||||
|
LLM_BACKEND: openai
|
||||||
|
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||||
|
OPENAI_BASE_URL: https://openrouter.ai/api
|
||||||
|
# Kimi K2.6 generations can be slow; mirror the one-off Kimi compose.
|
||||||
|
OPENAI_TIMEOUT_MS: "300000"
|
||||||
|
# 2.5 min idle end: single-slot suppliers must free up quickly for the
|
||||||
|
# shared demo endpoint (the gateway transparently reopens on next turn).
|
||||||
|
CHAT_IDLE_TIMEOUT_MS: "150000"
|
||||||
|
PORT: "8080"
|
||||||
|
# Enable LiveOgmiosProvider so Claim/Submit actually broadcast.
|
||||||
|
LIVE_CHAIN: "1"
|
||||||
|
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||||
|
# come from the operator-provided supplier/.env.kimi-chat-3 on a mainnet host.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||||
|
interval: 20s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
networks:
|
||||||
|
- marketplace-mainnet-net
|
||||||
|
- traefik-net
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=traefik-net"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet.entrypoints=web"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet.rule=Host(`mp-suppliers-kimi-chat-3.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.services.mp-suppliers-kimi-chat-3-mainnet.loadbalancer.server.port=8080"
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
marketplace-mainnet-net:
|
||||||
|
external: true
|
||||||
|
name: marketplace-mainnet-net
|
||||||
|
traefik-net:
|
||||||
|
external: true
|
||||||
|
name: traefik-net
|
||||||
@@ -49,8 +49,10 @@ services:
|
|||||||
OPENAI_BASE_URL: https://openrouter.ai/api
|
OPENAI_BASE_URL: https://openrouter.ai/api
|
||||||
# Kimi K2.6 generations can be slow; mirror the one-off Kimi compose.
|
# Kimi K2.6 generations can be slow; mirror the one-off Kimi compose.
|
||||||
OPENAI_TIMEOUT_MS: "300000"
|
OPENAI_TIMEOUT_MS: "300000"
|
||||||
# Auto-end an idle session after 5 min (supplier Submits the transcript).
|
# 2.5 min idle end (supplier Submits the transcript): single-slot
|
||||||
CHAT_IDLE_TIMEOUT_MS: "300000"
|
# suppliers must free up quickly for the shared demo endpoint (the
|
||||||
|
# gateway transparently reopens a session on the next turn).
|
||||||
|
CHAT_IDLE_TIMEOUT_MS: "150000"
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
# Enable LiveOgmiosProvider so Claim/Submit actually broadcast.
|
# Enable LiveOgmiosProvider so Claim/Submit actually broadcast.
|
||||||
LIVE_CHAIN: "1"
|
LIVE_CHAIN: "1"
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"start": "tsx src/index.ts",
|
"start": "tsx src/index.ts",
|
||||||
"dev": "tsx src/index.ts",
|
"dev": "tsx src/index.ts",
|
||||||
"rotate-master-key": "tsx src/cli/rotate-master-key.ts"
|
"rotate-master-key": "tsx src/cli/rotate-master-key.ts",
|
||||||
|
"create-demo-key": "tsx src/cli/create-demo-key.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@marketplace/buyer": "workspace:*",
|
"@marketplace/buyer": "workspace:*",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { seal } from "../crypto/seal.js";
|
|||||||
import { hashApiKey, requireKey } from "../middleware/apiKeyAuth.js";
|
import { hashApiKey, requireKey } from "../middleware/apiKeyAuth.js";
|
||||||
import { asyncHandler } from "../middleware/http.js";
|
import { asyncHandler } from "../middleware/http.js";
|
||||||
import { totalLovelace, hasCollateral } from "../onchain/preflight.js";
|
import { totalLovelace, hasCollateral } from "../onchain/preflight.js";
|
||||||
import { badRequest } from "../openai/errors.js";
|
import { badRequest, forbidden } from "../openai/errors.js";
|
||||||
|
|
||||||
const ACTIVE_ESCROW_STATES = new Set(["Open", "Claimed", "Submitted"]);
|
const ACTIVE_ESCROW_STATES = new Set(["Open", "Claimed", "Submitted"]);
|
||||||
|
|
||||||
@@ -89,7 +89,10 @@ export function makeAccountHandler(deps: GatewayDeps) {
|
|||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
key_prefix: keyRow.key_prefix,
|
key_prefix: keyRow.key_prefix,
|
||||||
deposit_address: keyRow.deposit_address,
|
// The shared demo key's wallet is operator-funded; hiding the deposit
|
||||||
|
// address keeps strangers from confusing it with their own wallet.
|
||||||
|
deposit_address: keyRow.demo ? null : keyRow.deposit_address,
|
||||||
|
...(keyRow.demo ? { demo: true } : {}),
|
||||||
balance: {
|
balance: {
|
||||||
available_lovelace: available.toString(),
|
available_lovelace: available.toString(),
|
||||||
locked_in_escrow_lovelace: lockedInEscrow.toString(),
|
locked_in_escrow_lovelace: lockedInEscrow.toString(),
|
||||||
@@ -118,6 +121,11 @@ export function makeAccountHandler(deps: GatewayDeps) {
|
|||||||
export function makeWithdrawHandler(deps: GatewayDeps) {
|
export function makeWithdrawHandler(deps: GatewayDeps) {
|
||||||
return asyncHandler(async (req: Request, res: Response) => {
|
return asyncHandler(async (req: Request, res: Response) => {
|
||||||
const keyRow = requireKey(req);
|
const keyRow = requireKey(req);
|
||||||
|
if (keyRow.demo) {
|
||||||
|
// The demo key is shared publicly; without this, anyone holding it
|
||||||
|
// could drain the operator-funded wallet to their own address.
|
||||||
|
throw forbidden("demo_key_restricted", "withdrawals are disabled for the shared demo key");
|
||||||
|
}
|
||||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
const toAddress = body.to_address;
|
const toAddress = body.to_address;
|
||||||
|
|||||||
76
gateway/src/cli/create-demo-key.ts
Normal file
76
gateway/src/cli/create-demo-key.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/cli/create-demo-key.ts — mint the shared demo API key.
|
||||||
|
*
|
||||||
|
* Same custodial machinery as POST /signup, but sets demo=1: withdraw is
|
||||||
|
* blocked, /account hides the deposit address, chat completions run through
|
||||||
|
* the session-backed demo executor, and per-IP rate limits apply. Public
|
||||||
|
* signup can never mint a demo key — this CLI is the only path.
|
||||||
|
*
|
||||||
|
* Run inside the gateway container (hits the volume-mounted DB):
|
||||||
|
* docker exec <gateway> pnpm --filter @marketplace/gateway create-demo-key
|
||||||
|
* Or locally:
|
||||||
|
* GATEWAY_MASTER_KEY=<64hex> GATEWAY_DB_DIR=./data/gateway NETWORK_ID=1 \
|
||||||
|
* pnpm --filter @marketplace/gateway create-demo-key [--label shared-demo]
|
||||||
|
*
|
||||||
|
* The raw key is printed ONCE and never stored (only its sha256).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomBytes, randomUUID } from "crypto";
|
||||||
|
import { GatewayStore } from "../db/store.js";
|
||||||
|
import { genPrivKeyHex, deriveWalletKey } from "../wallet.js";
|
||||||
|
import { seal } from "../crypto/seal.js";
|
||||||
|
import { hashApiKey } from "../middleware/apiKeyAuth.js";
|
||||||
|
|
||||||
|
const HEX64 = /^[0-9a-fA-F]{64}$/;
|
||||||
|
|
||||||
|
function arg(name: string): string | undefined {
|
||||||
|
const i = process.argv.indexOf(`--${name}`);
|
||||||
|
return i >= 0 ? process.argv[i + 1] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main(): void {
|
||||||
|
const masterKey = process.env.GATEWAY_MASTER_KEY;
|
||||||
|
if (!masterKey || !HEX64.test(masterKey)) {
|
||||||
|
throw new Error("create-demo-key: GATEWAY_MASTER_KEY must be 64 hex chars");
|
||||||
|
}
|
||||||
|
const dbDir = arg("db") ?? process.env.GATEWAY_DB_DIR ?? "./data/gateway";
|
||||||
|
const networkId: 0 | 1 = process.env.NETWORK_ID === "1" ? 1 : 0;
|
||||||
|
const label = arg("label") ?? "shared-demo";
|
||||||
|
|
||||||
|
const privHex = genPrivKeyHex();
|
||||||
|
const walletKey = deriveWalletKey(privHex, networkId);
|
||||||
|
const sealed = seal(privHex, masterKey);
|
||||||
|
|
||||||
|
const rawKey = `vk_${networkId === 1 ? "live" : "test"}_${randomBytes(24).toString("hex")}`;
|
||||||
|
|
||||||
|
const store = new GatewayStore(dbDir);
|
||||||
|
store.insertKey({
|
||||||
|
id: randomUUID(),
|
||||||
|
key_hash: hashApiKey(rawKey),
|
||||||
|
key_prefix: rawKey.slice(0, 12),
|
||||||
|
label,
|
||||||
|
wallet_pkh: walletKey.pubKeyHash,
|
||||||
|
deposit_address: walletKey.address,
|
||||||
|
enc_priv_nonce: sealed.nonce,
|
||||||
|
enc_priv_ct: sealed.ct,
|
||||||
|
enc_priv_tag: sealed.tag,
|
||||||
|
master_key_version: 1,
|
||||||
|
created_at: Date.now(),
|
||||||
|
demo: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(
|
||||||
|
[
|
||||||
|
`[gateway] demo key created (label=${label}, network=${networkId === 1 ? "mainnet" : "testnet"})`,
|
||||||
|
``,
|
||||||
|
` api_key: ${rawKey}`,
|
||||||
|
` deposit_address: ${walletKey.address}`,
|
||||||
|
``,
|
||||||
|
`Save the api_key now — it is shown only once. Fund the deposit address`,
|
||||||
|
`with the demo float (withdrawals are disabled for this key).`,
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -39,6 +39,7 @@ export interface GatewayConfig {
|
|||||||
dbDir: string;
|
dbDir: string;
|
||||||
signupRate: { max: number; windowMs: number };
|
signupRate: { max: number; windowMs: number };
|
||||||
keyRate: { max: number; windowMs: number };
|
keyRate: { max: number; windowMs: number };
|
||||||
|
demoIpRate: { max: number; windowMs: number };
|
||||||
sweeperIntervalMs: number;
|
sweeperIntervalMs: number;
|
||||||
walletHealthIntervalMs: number;
|
walletHealthIntervalMs: number;
|
||||||
sdkRegistryMax: number;
|
sdkRegistryMax: number;
|
||||||
@@ -111,6 +112,10 @@ export function loadConfig(env: Record<string, string | undefined>): GatewayConf
|
|||||||
max: posInt(env, "KEY_RATE_MAX", 60),
|
max: posInt(env, "KEY_RATE_MAX", 60),
|
||||||
windowMs: posInt(env, "KEY_RATE_WINDOW_MS", 60 * 1000),
|
windowMs: posInt(env, "KEY_RATE_WINDOW_MS", 60 * 1000),
|
||||||
},
|
},
|
||||||
|
demoIpRate: {
|
||||||
|
max: posInt(env, "DEMO_IP_RATE_MAX", 20),
|
||||||
|
windowMs: posInt(env, "DEMO_IP_RATE_WINDOW_MS", 60 * 1000),
|
||||||
|
},
|
||||||
sweeperIntervalMs: posInt(env, "SWEEPER_INTERVAL_MS", 60 * 1000),
|
sweeperIntervalMs: posInt(env, "SWEEPER_INTERVAL_MS", 60 * 1000),
|
||||||
walletHealthIntervalMs: posInt(env, "WALLET_HEALTH_INTERVAL_MS", 10 * 60 * 1000),
|
walletHealthIntervalMs: posInt(env, "WALLET_HEALTH_INTERVAL_MS", 10 * 60 * 1000),
|
||||||
sdkRegistryMax: posInt(env, "SDK_REGISTRY_MAX", 500),
|
sdkRegistryMax: posInt(env, "SDK_REGISTRY_MAX", 500),
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ CREATE TABLE IF NOT EXISTS api_keys (
|
|||||||
enc_priv_tag TEXT NOT NULL,
|
enc_priv_tag TEXT NOT NULL,
|
||||||
master_key_version INTEGER NOT NULL DEFAULT 1,
|
master_key_version INTEGER NOT NULL DEFAULT 1,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
disabled INTEGER NOT NULL DEFAULT 0
|
disabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
demo INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_keys_hash ON api_keys(key_hash);
|
CREATE INDEX IF NOT EXISTS idx_keys_hash ON api_keys(key_hash);
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export interface ApiKeyRow {
|
|||||||
master_key_version: number;
|
master_key_version: number;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
disabled: number;
|
disabled: number;
|
||||||
|
/** 1 = shared demo key: withdraw blocked, session-backed completions, per-IP limits. */
|
||||||
|
demo: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsageRow {
|
export interface UsageRow {
|
||||||
@@ -81,13 +83,20 @@ export class GatewayStore {
|
|||||||
this.db.pragma("journal_mode = WAL");
|
this.db.pragma("journal_mode = WAL");
|
||||||
this.db.exec(CREATE_TABLES_SQL);
|
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(`
|
this.sInsertKey = this.db.prepare(`
|
||||||
INSERT INTO api_keys (
|
INSERT INTO api_keys (
|
||||||
id, key_hash, key_prefix, label, wallet_pkh, deposit_address,
|
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
|
enc_priv_nonce, enc_priv_ct, enc_priv_tag, master_key_version, created_at, disabled, demo
|
||||||
) VALUES (
|
) VALUES (
|
||||||
@id, @key_hash, @key_prefix, @label, @wallet_pkh, @deposit_address,
|
@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
|
@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.sGetKeyByHash = this.db.prepare(`SELECT * FROM api_keys WHERE key_hash = @key_hash`);
|
||||||
@@ -133,8 +142,8 @@ export class GatewayStore {
|
|||||||
this.sListOpenSessions = this.db.prepare(`SELECT * FROM sessions WHERE state = 'open'`);
|
this.sListOpenSessions = this.db.prepare(`SELECT * FROM sessions WHERE state = 'open'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
insertKey(row: Omit<ApiKeyRow, "disabled">): void {
|
insertKey(row: Omit<ApiKeyRow, "disabled" | "demo"> & { demo?: number }): void {
|
||||||
this.sInsertKey.run({ ...row, label: row.label ?? null });
|
this.sInsertKey.run({ ...row, label: row.label ?? null, demo: row.demo ?? 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
getKeyByHash(keyHash: string): ApiKeyRow | undefined {
|
getKeyByHash(keyHash: string): ApiKeyRow | undefined {
|
||||||
@@ -193,4 +202,8 @@ export class GatewayStore {
|
|||||||
listOpenSessions(): SessionRow[] {
|
listOpenSessions(): SessionRow[] {
|
||||||
return this.sListOpenSessions.all() as SessionRow[];
|
return this.sListOpenSessions.all() as SessionRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listOpenSessionsByKey(keyId: string): SessionRow[] {
|
||||||
|
return this.listOpenSessions().filter((s) => s.key_id === keyId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,28 @@ export function ipRateLimit(opts: { max: number; windowMs: number }) {
|
|||||||
return makeLimiter(opts, (req) => req.ip ?? "unknown");
|
return makeLimiter(opts, (req) => req.ip ?? "unknown");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Per-API-key limiter (apply AFTER apiKeyAuth). */
|
/** Per-API-key limiter (apply AFTER apiKeyAuth). Demo keys are exempt: the
|
||||||
|
* shared demo key would let one abusive IP exhaust everyone's budget — the
|
||||||
|
* per-IP demoIpRateLimit is the demo control instead. */
|
||||||
export function keyRateLimit(opts: { max: number; windowMs: number }) {
|
export function keyRateLimit(opts: { max: number; windowMs: number }) {
|
||||||
return makeLimiter(opts, (req) => requireKey(req).id);
|
const limit = makeLimiter(opts, (req) => requireKey(req).id);
|
||||||
|
return (req: Request, res: Response, next: NextFunction): void => {
|
||||||
|
if (requireKey(req).demo) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
limit(req, res, next);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-IP limiter that applies ONLY to demo-key requests (after apiKeyAuth). */
|
||||||
|
export function demoIpRateLimit(opts: { max: number; windowMs: number }) {
|
||||||
|
const limit = makeLimiter(opts, (req) => req.ip ?? "unknown");
|
||||||
|
return (req: Request, res: Response, next: NextFunction): void => {
|
||||||
|
if (!requireKey(req).demo) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
limit(req, res, next);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
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) };
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import { preflight } from "../onchain/preflight.js";
|
|||||||
import { resolveSubmittedRef, acceptAndConfirm } from "../onchain/settle.js";
|
import { resolveSubmittedRef, acceptAndConfirm } from "../onchain/settle.js";
|
||||||
import { ensureWalletHealthy } from "../walletHealth.js";
|
import { ensureWalletHealthy } from "../walletHealth.js";
|
||||||
import { parseChatRequest, type ParsedChatRequest } from "./validate.js";
|
import { parseChatRequest, type ParsedChatRequest } from "./validate.js";
|
||||||
|
import { runDemoChat } from "./demoChat.js";
|
||||||
import {
|
import {
|
||||||
genId,
|
genId,
|
||||||
buildChatCompletion,
|
buildChatCompletion,
|
||||||
@@ -67,8 +68,17 @@ function recordUsage(
|
|||||||
export function makeChatCompletionsHandler(deps: GatewayDeps) {
|
export function makeChatCompletionsHandler(deps: GatewayDeps) {
|
||||||
return asyncHandler(async (req: Request, res: Response) => {
|
return asyncHandler(async (req: Request, res: Response) => {
|
||||||
const keyRow = requireKey(req);
|
const keyRow = requireKey(req);
|
||||||
const parsed = parseChatRequest(req.body);
|
|
||||||
const ctx = deps.registry.getContext(keyRow);
|
const ctx = deps.registry.getContext(keyRow);
|
||||||
|
if (keyRow.demo) {
|
||||||
|
// Demo keys run session-backed with tools passthrough, and never fall
|
||||||
|
// back to one-shot (its settle path assumes ≤1 in-flight escrow per
|
||||||
|
// wallet; the demo wallet holds one escrow per concurrent session).
|
||||||
|
// Not under the key mutex — runDemoChat takes it only for session opens.
|
||||||
|
const parsed = parseChatRequest(req.body, { allowTools: true });
|
||||||
|
await runDemoChat(deps, keyRow, ctx, parsed, res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = parseChatRequest(req.body);
|
||||||
await ctx.mutex.run(() => runOneShot(deps, keyRow, ctx, parsed, res));
|
await ctx.mutex.run(() => runOneShot(deps, keyRow, ctx, parsed, res));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
279
gateway/src/openai/demoChat.ts
Normal file
279
gateway/src/openai/demoChat.ts
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/openai/demoChat.ts — session-backed /chat/completions for the
|
||||||
|
* shared demo key.
|
||||||
|
*
|
||||||
|
* Demo requests keep the standard OpenAI wire shape (tools included) but run
|
||||||
|
* against real escrow-per-session marketplace conversations: transcript-prefix
|
||||||
|
* affinity maps each stateless request onto an open session and forwards only
|
||||||
|
* the unseen delta; a miss transparently opens a new session (escrow post —
|
||||||
|
* the only chain work, under the key mutex) and replays the full history as
|
||||||
|
* the first turn. Supplier idle-closes are healed by reopening; the sweeper
|
||||||
|
* settles the abandoned escrow.
|
||||||
|
*
|
||||||
|
* Demo keys NEVER fall back to the one-shot path: resolveSubmittedRef's
|
||||||
|
* lone-Submitted-row fallback assumes ≤1 in-flight escrow per wallet, and the
|
||||||
|
* demo wallet holds one escrow per concurrent session.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import type { Response } from "express";
|
||||||
|
import type { ChatMessage, ToolCall } from "@marketplace/shared/tx";
|
||||||
|
import type { GatewayDeps } from "../deps.js";
|
||||||
|
import type { ApiKeyRow, SessionRow } from "../db/store.js";
|
||||||
|
import type { KeyContext } from "../sdk/registry.js";
|
||||||
|
import { transcripts, getSessionLock, dropSessionState } from "./transcripts.js";
|
||||||
|
import { matchSessionPrefix, type AffinityCandidate } from "./affinity.js";
|
||||||
|
import {
|
||||||
|
CAPABILITY,
|
||||||
|
SessionGoneError,
|
||||||
|
openSessionCore,
|
||||||
|
streamSupplierTurn,
|
||||||
|
type TurnResult,
|
||||||
|
} from "./sessions.js";
|
||||||
|
import type { ParsedChatRequest } from "./validate.js";
|
||||||
|
import { genId, buildChatCompletion, buildChunk, sseData, SSE_DONE, type Usage } from "./shapes.js";
|
||||||
|
import { badRequest, toGatewayError, toErrorBody } from "./errors.js";
|
||||||
|
|
||||||
|
interface DemoSessionMeta {
|
||||||
|
keyId: string;
|
||||||
|
model: string;
|
||||||
|
lastUsedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open sessions owned by the demo executor (sessionId → meta). Mirrors live
|
||||||
|
* in transcripts.ts; this map scopes affinity to demo-opened sessions. */
|
||||||
|
const demoSessions = new Map<string, DemoSessionMeta>();
|
||||||
|
|
||||||
|
/** Forget a session (closed/settled elsewhere, e.g. by the sweeper). */
|
||||||
|
export function dropDemoSession(sessionId: string): void {
|
||||||
|
demoSessions.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function candidatesFor(keyId: string, model: string): AffinityCandidate[] {
|
||||||
|
const out: AffinityCandidate[] = [];
|
||||||
|
for (const [sessionId, meta] of demoSessions) {
|
||||||
|
if (meta.keyId !== keyId || meta.model !== model) continue;
|
||||||
|
const mirror = transcripts.get(sessionId);
|
||||||
|
if (!mirror) continue;
|
||||||
|
out.push({ sessionId, model: meta.model, mirror, lastUsedAt: meta.lastUsedAt });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record the session's flat fee when the demo executor closes its row (the
|
||||||
|
* supplier already Submitted; the sweeper Accepts the escrow afterwards). */
|
||||||
|
function recordSessionCost(deps: GatewayDeps, session: SessionRow): void {
|
||||||
|
deps.store.insertUsage({
|
||||||
|
id: randomUUID(),
|
||||||
|
key_id: session.key_id,
|
||||||
|
created_at: Date.now(),
|
||||||
|
kind: "chat_session",
|
||||||
|
model: session.model,
|
||||||
|
capability_id: CAPABILITY,
|
||||||
|
supplier_pkh: session.supplier_pkh,
|
||||||
|
escrow_ref: session.escrow_ref,
|
||||||
|
cost_lovelace: session.price_lovelace,
|
||||||
|
prompt_tokens: 0,
|
||||||
|
completion_tokens: 0,
|
||||||
|
status: "completed",
|
||||||
|
failure_reason: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function markSessionGone(deps: GatewayDeps, session: SessionRow): void {
|
||||||
|
deps.store.setSessionState(session.id, "closed", Date.now());
|
||||||
|
dropSessionState(session.id);
|
||||||
|
dropDemoSession(session.id);
|
||||||
|
recordSessionCost(deps, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
type LockedTurnOutcome =
|
||||||
|
| { kind: "done"; result: TurnResult }
|
||||||
|
| { kind: "retry" } // candidate no longer valid — re-run the match
|
||||||
|
| { kind: "gone" }; // supplier lost the session — open a fresh one
|
||||||
|
|
||||||
|
/** Streaming sink shared across turn attempts on one response. */
|
||||||
|
interface StreamSink {
|
||||||
|
onToken: (token: string) => void;
|
||||||
|
emitToolCalls: (toolCalls: ToolCall[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runDemoChat(
|
||||||
|
deps: GatewayDeps,
|
||||||
|
keyRow: ApiKeyRow,
|
||||||
|
ctx: KeyContext,
|
||||||
|
parsed: ParsedChatRequest,
|
||||||
|
res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const last = parsed.messages[parsed.messages.length - 1];
|
||||||
|
if (last.role !== "user" && last.role !== "tool") {
|
||||||
|
// Reject before any chain work — opening an escrow for an unanswerable
|
||||||
|
// request would burn a session fee for a guaranteed supplier 400.
|
||||||
|
throw badRequest("invalid_messages", "the last message must have role user or tool");
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = genId();
|
||||||
|
let keepalive: ReturnType<typeof setInterval> | undefined;
|
||||||
|
let roleSent = false;
|
||||||
|
if (parsed.stream) {
|
||||||
|
res.status(200);
|
||||||
|
res.setHeader("Content-Type", "text/event-stream");
|
||||||
|
res.setHeader("Cache-Control", "no-cache, no-transform");
|
||||||
|
res.setHeader("Connection", "keep-alive");
|
||||||
|
res.setHeader("X-Accel-Buffering", "no");
|
||||||
|
res.write(": connected\n\n");
|
||||||
|
keepalive = setInterval(() => {
|
||||||
|
try {
|
||||||
|
res.write(": keepalive\n\n");
|
||||||
|
} catch {
|
||||||
|
/* client gone */
|
||||||
|
}
|
||||||
|
}, 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureRole = (): void => {
|
||||||
|
if (roleSent) return;
|
||||||
|
roleSent = true;
|
||||||
|
res.write(sseData(buildChunk({ id, model: parsed.model, delta: { role: "assistant" }, finishReason: null })));
|
||||||
|
};
|
||||||
|
const sink: StreamSink = {
|
||||||
|
onToken: (token) => {
|
||||||
|
if (!parsed.stream) return;
|
||||||
|
ensureRole();
|
||||||
|
res.write(sseData(buildChunk({ id, model: parsed.model, delta: { content: token }, finishReason: null })));
|
||||||
|
},
|
||||||
|
emitToolCalls: (toolCalls) => {
|
||||||
|
if (!parsed.stream) return;
|
||||||
|
ensureRole();
|
||||||
|
res.write(
|
||||||
|
sseData(
|
||||||
|
buildChunk({
|
||||||
|
id,
|
||||||
|
model: parsed.model,
|
||||||
|
delta: { tool_calls: toolCalls.map((tc, index) => ({ index, ...tc })) },
|
||||||
|
finishReason: null,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runWithAffinity(deps, keyRow, ctx, parsed, sink);
|
||||||
|
if (keepalive) clearInterval(keepalive);
|
||||||
|
|
||||||
|
const usage: Usage = {
|
||||||
|
prompt_tokens: result.usage.prompt_tokens,
|
||||||
|
completion_tokens: result.usage.completion_tokens,
|
||||||
|
total_tokens: result.usage.prompt_tokens + result.usage.completion_tokens,
|
||||||
|
};
|
||||||
|
if (parsed.stream) {
|
||||||
|
if (result.assistantMessage.tool_calls?.length) sink.emitToolCalls(result.assistantMessage.tool_calls);
|
||||||
|
ensureRole();
|
||||||
|
res.write(sseData(buildChunk({ id, model: parsed.model, delta: {}, finishReason: result.finishReason, usage })));
|
||||||
|
res.write(SSE_DONE);
|
||||||
|
res.end();
|
||||||
|
} else {
|
||||||
|
res.status(200).json(
|
||||||
|
buildChatCompletion({
|
||||||
|
id,
|
||||||
|
model: parsed.model,
|
||||||
|
content: result.assistantMessage.content,
|
||||||
|
toolCalls: result.assistantMessage.tool_calls,
|
||||||
|
finishReason: result.finishReason,
|
||||||
|
usage,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (keepalive) clearInterval(keepalive);
|
||||||
|
if (parsed.stream && res.headersSent) {
|
||||||
|
try {
|
||||||
|
res.write(sseData(toErrorBody(toGatewayError(err))));
|
||||||
|
} catch {
|
||||||
|
/* client gone */
|
||||||
|
}
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runWithAffinity(
|
||||||
|
deps: GatewayDeps,
|
||||||
|
keyRow: ApiKeyRow,
|
||||||
|
ctx: KeyContext,
|
||||||
|
parsed: ParsedChatRequest,
|
||||||
|
sink: StreamSink,
|
||||||
|
): Promise<TurnResult> {
|
||||||
|
// Affinity: try matching sessions until one serves the turn. The match runs
|
||||||
|
// synchronously (no await between match and lock acquire, so the candidate
|
||||||
|
// set can't shift underneath us); the prefix is re-checked inside the lock
|
||||||
|
// because a concurrent request may have advanced the session first.
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
const match = matchSessionPrefix(candidatesFor(keyRow.id, parsed.model), parsed.messages, parsed.model);
|
||||||
|
if (!match) break;
|
||||||
|
|
||||||
|
const outcome: LockedTurnOutcome = await getSessionLock(match.sessionId).run(async () => {
|
||||||
|
const meta = demoSessions.get(match.sessionId);
|
||||||
|
const mirror = transcripts.get(match.sessionId);
|
||||||
|
const session = deps.store.getSession(match.sessionId);
|
||||||
|
if (!meta || !mirror || !session || session.state !== "open") {
|
||||||
|
dropDemoSession(match.sessionId);
|
||||||
|
return { kind: "retry" };
|
||||||
|
}
|
||||||
|
const recheck = matchSessionPrefix(
|
||||||
|
[{ sessionId: session.id, model: meta.model, mirror, lastUsedAt: meta.lastUsedAt }],
|
||||||
|
parsed.messages,
|
||||||
|
parsed.model,
|
||||||
|
);
|
||||||
|
if (!recheck) return { kind: "retry" };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await streamSupplierTurn(
|
||||||
|
deps,
|
||||||
|
session,
|
||||||
|
{ messages: recheck.delta, tools: parsed.tools, tool_choice: parsed.toolChoice },
|
||||||
|
sink.onToken,
|
||||||
|
);
|
||||||
|
meta.lastUsedAt = Date.now();
|
||||||
|
return { kind: "done", result };
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SessionGoneError) {
|
||||||
|
// Supplier idle-closed (or restarted). Close our row, record the
|
||||||
|
// fee (the escrow settles via the sweeper) and reopen fresh.
|
||||||
|
markSessionGone(deps, session);
|
||||||
|
return { kind: "gone" };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (outcome.kind === "done") return outcome.result;
|
||||||
|
if (outcome.kind === "gone") break;
|
||||||
|
// retry: candidate vanished/advanced — re-match against current state.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Miss: open a new session (chain work → key mutex) and replay the full
|
||||||
|
// history as its first turn.
|
||||||
|
const session = await ctx.mutex.run(() => openSessionCore(deps, keyRow, ctx, parsed.model));
|
||||||
|
demoSessions.set(session.id, { keyId: keyRow.id, model: parsed.model, lastUsedAt: Date.now() });
|
||||||
|
return getSessionLock(session.id).run(async () => {
|
||||||
|
try {
|
||||||
|
return await streamSupplierTurn(
|
||||||
|
deps,
|
||||||
|
session,
|
||||||
|
{ messages: parsed.messages, tools: parsed.tools, tool_choice: parsed.toolChoice },
|
||||||
|
sink.onToken,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SessionGoneError) {
|
||||||
|
// Freshly opened yet already gone (supplier crashed mid-claim).
|
||||||
|
// Surface as an upstream error rather than looping.
|
||||||
|
markSessionGone(deps, session);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -36,6 +36,8 @@ export const unauthorized = (message = "invalid API key"): GatewayError =>
|
|||||||
new GatewayError(401, "authentication_error", "invalid_api_key", message);
|
new GatewayError(401, "authentication_error", "invalid_api_key", message);
|
||||||
export const paymentRequired = (message: string, extra?: Record<string, unknown>): GatewayError =>
|
export const paymentRequired = (message: string, extra?: Record<string, unknown>): GatewayError =>
|
||||||
new GatewayError(402, "invalid_request_error", "insufficient_funds", message, extra);
|
new GatewayError(402, "invalid_request_error", "insufficient_funds", message, extra);
|
||||||
|
export const forbidden = (code: string, message: string): GatewayError =>
|
||||||
|
new GatewayError(403, "invalid_request_error", code, message);
|
||||||
export const notFound = (code: string, message: string): GatewayError =>
|
export const notFound = (code: string, message: string): GatewayError =>
|
||||||
new GatewayError(404, "invalid_request_error", code, message);
|
new GatewayError(404, "invalid_request_error", code, message);
|
||||||
export const rateLimited = (message: string): GatewayError =>
|
export const rateLimited = (message: string): GatewayError =>
|
||||||
|
|||||||
@@ -7,12 +7,20 @@
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import type { GatewayDeps } from "../deps.js";
|
import type { GatewayDeps } from "../deps.js";
|
||||||
import { asyncHandler } from "../middleware/http.js";
|
import { asyncHandler } from "../middleware/http.js";
|
||||||
|
import { requireKey } from "../middleware/apiKeyAuth.js";
|
||||||
import { listModels } from "../routing/selectSupplier.js";
|
import { listModels } from "../routing/selectSupplier.js";
|
||||||
import { buildModelsList } from "./shapes.js";
|
import { buildModelsList } from "./shapes.js";
|
||||||
|
import { CAPABILITY as CHAT_CAPABILITY } from "./sessions.js";
|
||||||
|
|
||||||
export function makeModelsHandler(deps: GatewayDeps) {
|
export function makeModelsHandler(deps: GatewayDeps) {
|
||||||
return asyncHandler(async (_req: Request, res: Response) => {
|
return asyncHandler(async (req: Request, res: Response) => {
|
||||||
const models = await listModels({ indexerUrl: deps.config.indexerUrl, fetchFn: deps.fetchFn });
|
const keyRow = requireKey(req);
|
||||||
|
// Demo keys only reach chat-capable suppliers (session-backed executor).
|
||||||
|
const models = await listModels({
|
||||||
|
indexerUrl: deps.config.indexerUrl,
|
||||||
|
capabilityId: keyRow.demo ? CHAT_CAPABILITY : undefined,
|
||||||
|
fetchFn: deps.fetchFn,
|
||||||
|
});
|
||||||
res.status(200).json(buildModelsList(models));
|
res.status(200).json(buildModelsList(models));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,9 @@
|
|||||||
|
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { TxConstructionError, type ChatMessage } from "@marketplace/shared/tx";
|
import { TxConstructionError, normalizeChatMessage, type ChatMessage } from "@marketplace/shared/tx";
|
||||||
import { SupplierError } from "@marketplace/buyer/sdk";
|
import { SupplierError } from "@marketplace/buyer/sdk";
|
||||||
|
import { transcripts, getSessionLock, dropSessionState } from "./transcripts.js";
|
||||||
import type { GatewayDeps } from "../deps.js";
|
import type { GatewayDeps } from "../deps.js";
|
||||||
import type { ApiKeyRow, GatewayStore, SessionRow } from "../db/store.js";
|
import type { ApiKeyRow, GatewayStore, SessionRow } from "../db/store.js";
|
||||||
import type { KeyContext } from "../sdk/registry.js";
|
import type { KeyContext } from "../sdk/registry.js";
|
||||||
@@ -36,10 +37,15 @@ import {
|
|||||||
type Usage,
|
type Usage,
|
||||||
} from "./shapes.js";
|
} from "./shapes.js";
|
||||||
|
|
||||||
const CAPABILITY = "llm.chat.v1";
|
export const CAPABILITY = "llm.chat.v1";
|
||||||
|
|
||||||
/** In-memory per-session transcript mirror (sessionId → messages). */
|
/** Thrown when the supplier no longer knows the session (idle-closed/restart). */
|
||||||
const transcripts = new Map<string, ChatMessage[]>();
|
export class SessionGoneError extends Error {
|
||||||
|
constructor(sessionId: string) {
|
||||||
|
super(`supplier no longer has session ${sessionId}`);
|
||||||
|
this.name = "SessionGoneError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function refStr(ref: { txHash: string; index: number }): string {
|
function refStr(ref: { txHash: string; index: number }): string {
|
||||||
return `${ref.txHash}#${ref.index}`;
|
return `${ref.txHash}#${ref.index}`;
|
||||||
@@ -56,17 +62,22 @@ export function makeOpenSessionHandler(deps: GatewayDeps) {
|
|||||||
throw badRequest("invalid_model", "`model` is required");
|
throw badRequest("invalid_model", "`model` is required");
|
||||||
}
|
}
|
||||||
const ctx = deps.registry.getContext(keyRow);
|
const ctx = deps.registry.getContext(keyRow);
|
||||||
await ctx.mutex.run(() => openSession(deps, keyRow, ctx, model, res));
|
const session = await ctx.mutex.run(() => openSessionCore(deps, keyRow, ctx, model));
|
||||||
|
res.status(200).json({ id: session.id, object: "chat.session", model, created: nowSec() });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openSession(
|
/**
|
||||||
|
* Select a supplier, preflight the wallet, post+claim the session escrow and
|
||||||
|
* persist the SessionRow. Chain work — the CALLER must hold the key mutex
|
||||||
|
* (concurrent PostEscrow from one wallet double-spends the same UTxO).
|
||||||
|
*/
|
||||||
|
export async function openSessionCore(
|
||||||
deps: GatewayDeps,
|
deps: GatewayDeps,
|
||||||
keyRow: ApiKeyRow,
|
keyRow: ApiKeyRow,
|
||||||
ctx: KeyContext,
|
ctx: KeyContext,
|
||||||
model: string,
|
model: string,
|
||||||
res: Response,
|
): Promise<SessionRow> {
|
||||||
): Promise<void> {
|
|
||||||
const candidates = await selectCandidates({
|
const candidates = await selectCandidates({
|
||||||
indexerUrl: deps.config.indexerUrl,
|
indexerUrl: deps.config.indexerUrl,
|
||||||
model,
|
model,
|
||||||
@@ -127,7 +138,9 @@ async function openSession(
|
|||||||
});
|
});
|
||||||
transcripts.set(sessionId, []);
|
transcripts.set(sessionId, []);
|
||||||
|
|
||||||
res.status(200).json({ id: sessionId, object: "chat.session", model, created: nowSec() });
|
const session = deps.store.getSession(sessionId);
|
||||||
|
if (!session) throw new Error(`session ${sessionId} vanished after insert`);
|
||||||
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── messages (one streamed turn) ────────────────────────────────────────────
|
// ─── messages (one streamed turn) ────────────────────────────────────────────
|
||||||
@@ -144,8 +157,12 @@ export function makeSessionMessageHandler(deps: GatewayDeps) {
|
|||||||
const content = resolveTurnContent(session.id, body);
|
const content = resolveTurnContent(session.id, body);
|
||||||
if (content === "") throw badRequest("content_required", "no user content in this turn");
|
if (content === "") throw badRequest("content_required", "no user content in this turn");
|
||||||
|
|
||||||
const ctx = deps.registry.getContext(keyRow);
|
// Turns are chain-free (one HTTP POST to the supplier + SSE relay), so the
|
||||||
await ctx.mutex.run(() => streamTurn(deps, session, content, stream, res));
|
// per-key mutex — which exists to serialize wallet UTxO spends — is NOT
|
||||||
|
// held here. What needs serialization is this session's transcript:
|
||||||
|
// concurrent turns would interleave supplier appends and corrupt both the
|
||||||
|
// mirror and the receipt hash. Hence a per-session lock.
|
||||||
|
await getSessionLock(session.id).run(() => streamTurn(deps, session, content, stream, res));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +193,88 @@ function foldSystem(sessionId: string, content: string, body: Record<string, unk
|
|||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One turn's payload to the supplier: legacy single user string, or a
|
||||||
|
* multi-message delta (e.g. tool results) with optional tools passthrough. */
|
||||||
|
export type TurnPayload =
|
||||||
|
| { content: string }
|
||||||
|
| { messages: ChatMessage[]; tools?: unknown[]; tool_choice?: unknown };
|
||||||
|
|
||||||
|
export interface TurnResult {
|
||||||
|
assistantMessage: ChatMessage;
|
||||||
|
finishReason: "stop" | "tool_calls";
|
||||||
|
usage: { prompt_tokens: number; completion_tokens: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive one off-chain turn against the session's supplier and mirror the
|
||||||
|
* result into the session transcript. Caller must hold the session lock.
|
||||||
|
* Throws SessionGoneError when the supplier no longer knows the session
|
||||||
|
* (idle-closed or restarted) so callers can transparently reopen.
|
||||||
|
*/
|
||||||
|
export async function streamSupplierTurn(
|
||||||
|
deps: GatewayDeps,
|
||||||
|
session: SessionRow,
|
||||||
|
payload: TurnPayload,
|
||||||
|
onToken?: (token: string) => void,
|
||||||
|
): Promise<TurnResult> {
|
||||||
|
const upstream = await deps.fetchFn(`${session.supplier_base_url.replace(/\/+$/, "")}/v1/chat/message`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", accept: "text/event-stream", "X-Escrow-Ref": session.escrow_ref },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (upstream.status === 404) {
|
||||||
|
await upstream.text().catch(() => "");
|
||||||
|
throw new SessionGoneError(session.id);
|
||||||
|
}
|
||||||
|
if (!upstream.ok || !upstream.body) {
|
||||||
|
const detail = await upstream.text().catch(() => "");
|
||||||
|
throw toGatewayError(new SupplierError("supplier_http_error", { status: upstream.status, message: detail.slice(0, 200) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
let assistant = "";
|
||||||
|
let errored: string | undefined;
|
||||||
|
let doneMessage: ChatMessage | null = null;
|
||||||
|
let doneFinish: string | undefined;
|
||||||
|
let usage = { prompt_tokens: 0, completion_tokens: 0 };
|
||||||
|
await readSupplierSse(upstream.body as ReadableStream<Uint8Array>, (frame) => {
|
||||||
|
if (frame.type === "token" && typeof frame.value === "string") {
|
||||||
|
assistant += frame.value;
|
||||||
|
onToken?.(frame.value);
|
||||||
|
} else if (frame.type === "done") {
|
||||||
|
// Upgraded suppliers echo the final assistant message verbatim; mirror
|
||||||
|
// that exact object so both transcripts stay hash-identical.
|
||||||
|
doneMessage = normalizeChatMessage(frame.message);
|
||||||
|
if (typeof frame.finish_reason === "string") doneFinish = frame.finish_reason;
|
||||||
|
if (frame.usage && typeof frame.usage === "object") {
|
||||||
|
const u = frame.usage as Record<string, unknown>;
|
||||||
|
usage = {
|
||||||
|
prompt_tokens: typeof u.prompt_tokens === "number" ? u.prompt_tokens : 0,
|
||||||
|
completion_tokens: typeof u.completion_tokens === "number" ? u.completion_tokens : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (frame.type === "error") {
|
||||||
|
errored = typeof frame.message === "string" ? frame.message : "supplier stream error";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (errored !== undefined) {
|
||||||
|
throw toGatewayError(new SupplierError("upstream_error", { message: errored }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistantMessage: ChatMessage = doneMessage ?? { role: "assistant", content: assistant };
|
||||||
|
const finishReason: TurnResult["finishReason"] =
|
||||||
|
doneFinish === "tool_calls" || (assistantMessage.tool_calls?.length ?? 0) > 0 ? "tool_calls" : "stop";
|
||||||
|
|
||||||
|
// Mirror the turn for endChat's response_hash verification / affinity matching.
|
||||||
|
const mirror = transcripts.get(session.id) ?? [];
|
||||||
|
if ("content" in payload) mirror.push({ role: "user", content: payload.content });
|
||||||
|
else mirror.push(...payload.messages);
|
||||||
|
mirror.push(assistantMessage);
|
||||||
|
transcripts.set(session.id, mirror);
|
||||||
|
|
||||||
|
return { assistantMessage, finishReason, usage };
|
||||||
|
}
|
||||||
|
|
||||||
async function streamTurn(
|
async function streamTurn(
|
||||||
deps: GatewayDeps,
|
deps: GatewayDeps,
|
||||||
session: SessionRow,
|
session: SessionRow,
|
||||||
@@ -184,15 +283,6 @@ async function streamTurn(
|
|||||||
res: Response,
|
res: Response,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const id = genId();
|
const id = genId();
|
||||||
const upstream = await deps.fetchFn(`${session.supplier_base_url.replace(/\/+$/, "")}/v1/chat/message`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json", accept: "text/event-stream", "X-Escrow-Ref": session.escrow_ref },
|
|
||||||
body: JSON.stringify({ content }),
|
|
||||||
});
|
|
||||||
if (!upstream.ok || !upstream.body) {
|
|
||||||
const detail = await upstream.text().catch(() => "");
|
|
||||||
throw toGatewayError(new SupplierError("supplier_http_error", { status: upstream.status, message: detail.slice(0, 200) }));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stream) {
|
if (stream) {
|
||||||
res.status(200);
|
res.status(200);
|
||||||
@@ -203,46 +293,43 @@ async function streamTurn(
|
|||||||
res.write(sseData(buildChunk({ id, model: session.model, delta: { role: "assistant" }, finishReason: null })));
|
res.write(sseData(buildChunk({ id, model: session.model, delta: { role: "assistant" }, finishReason: null })));
|
||||||
}
|
}
|
||||||
|
|
||||||
let assistant = "";
|
let result: TurnResult;
|
||||||
let errored: string | undefined;
|
try {
|
||||||
await readSupplierSse(upstream.body as ReadableStream<Uint8Array>, (frame) => {
|
result = await streamSupplierTurn(deps, session, { content }, (token) => {
|
||||||
if (frame.type === "token" && typeof frame.value === "string") {
|
if (stream) res.write(sseData(buildChunk({ id, model: session.model, delta: { content: token }, finishReason: null })));
|
||||||
assistant += frame.value;
|
});
|
||||||
if (stream) res.write(sseData(buildChunk({ id, model: session.model, delta: { content: frame.value }, finishReason: null })));
|
} catch (err) {
|
||||||
} else if (frame.type === "error") {
|
if (err instanceof SessionGoneError) {
|
||||||
errored = typeof frame.message === "string" ? frame.message : "supplier stream error";
|
err = toGatewayError(new SupplierError("supplier_http_error", { status: 404, message: err.message }));
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
if (errored !== undefined) {
|
|
||||||
if (stream) {
|
if (stream) {
|
||||||
res.write(sseData(toErrorBody(toGatewayError(new SupplierError("upstream_error", { message: errored })))));
|
res.write(sseData(toErrorBody(toGatewayError(err))));
|
||||||
res.end();
|
res.end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw toGatewayError(new SupplierError("upstream_error", { message: errored }));
|
throw toGatewayError(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirror the transcript for endChat's response_hash verification.
|
|
||||||
const mirror = transcripts.get(session.id) ?? [];
|
|
||||||
mirror.push({ role: "user", content });
|
|
||||||
mirror.push({ role: "assistant", content: assistant });
|
|
||||||
transcripts.set(session.id, mirror);
|
|
||||||
|
|
||||||
if (stream) {
|
if (stream) {
|
||||||
res.write(sseData(buildChunk({ id, model: session.model, delta: {}, finishReason: "stop" })));
|
res.write(sseData(buildChunk({ id, model: session.model, delta: {}, finishReason: result.finishReason })));
|
||||||
res.write(SSE_DONE);
|
res.write(SSE_DONE);
|
||||||
res.end();
|
res.end();
|
||||||
} else {
|
} else {
|
||||||
const usage: Usage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
|
const usage: Usage = {
|
||||||
res.status(200).json(buildChatCompletion({ id, model: session.model, content: assistant, usage }));
|
prompt_tokens: result.usage.prompt_tokens,
|
||||||
|
completion_tokens: result.usage.completion_tokens,
|
||||||
|
total_tokens: result.usage.prompt_tokens + result.usage.completion_tokens,
|
||||||
|
};
|
||||||
|
res.status(200).json(buildChatCompletion({ id, model: session.model, content: result.assistantMessage.content, usage }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SupplierFrame {
|
interface SupplierFrame {
|
||||||
type?: string;
|
type?: string;
|
||||||
value?: string;
|
value?: string;
|
||||||
message?: string;
|
message?: unknown;
|
||||||
|
finish_reason?: unknown;
|
||||||
|
usage?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read a supplier SSE stream of {type:token|done|error} frames. */
|
/** Read a supplier SSE stream of {type:token|done|error} frames. */
|
||||||
@@ -308,7 +395,7 @@ async function closeSession(
|
|||||||
});
|
});
|
||||||
|
|
||||||
deps.store.setSessionState(session.id, "closed", Date.now());
|
deps.store.setSessionState(session.id, "closed", Date.now());
|
||||||
transcripts.delete(session.id);
|
dropSessionState(session.id);
|
||||||
|
|
||||||
const prompt = result.receipt.prompt_tokens ?? 0;
|
const prompt = result.receipt.prompt_tokens ?? 0;
|
||||||
const completion = result.receipt.completion_tokens ?? 0;
|
const completion = result.receipt.completion_tokens ?? 0;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import { randomBytes } from "crypto";
|
import { randomBytes } from "crypto";
|
||||||
import type { Receipt } from "@marketplace/shared/receipt";
|
import type { Receipt } from "@marketplace/shared/receipt";
|
||||||
|
import type { ToolCall } from "@marketplace/shared/tx";
|
||||||
|
|
||||||
export function genId(): string {
|
export function genId(): string {
|
||||||
return `chatcmpl-${randomBytes(16).toString("hex")}`;
|
return `chatcmpl-${randomBytes(16).toString("hex")}`;
|
||||||
@@ -40,6 +41,8 @@ export function buildChatCompletion(args: {
|
|||||||
model: string;
|
model: string;
|
||||||
content: string;
|
content: string;
|
||||||
usage: Usage;
|
usage: Usage;
|
||||||
|
toolCalls?: ToolCall[];
|
||||||
|
finishReason?: "stop" | "tool_calls";
|
||||||
vector?: VectorReceipt;
|
vector?: VectorReceipt;
|
||||||
}): Record<string, unknown> {
|
}): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
@@ -50,8 +53,12 @@ export function buildChatCompletion(args: {
|
|||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
index: 0,
|
index: 0,
|
||||||
message: { role: "assistant", content: args.content },
|
message: {
|
||||||
finish_reason: "stop",
|
role: "assistant",
|
||||||
|
content: args.content,
|
||||||
|
...(args.toolCalls?.length ? { tool_calls: args.toolCalls } : {}),
|
||||||
|
},
|
||||||
|
finish_reason: args.finishReason ?? "stop",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
usage: args.usage,
|
usage: args.usage,
|
||||||
@@ -63,8 +70,10 @@ export function buildChatCompletion(args: {
|
|||||||
export function buildChunk(args: {
|
export function buildChunk(args: {
|
||||||
id: string;
|
id: string;
|
||||||
model: string;
|
model: string;
|
||||||
delta: { role?: string; content?: string } | Record<string, never>;
|
delta:
|
||||||
finishReason: "stop" | null;
|
| { role?: string; content?: string; tool_calls?: Array<{ index: number } & ToolCall> }
|
||||||
|
| Record<string, never>;
|
||||||
|
finishReason: "stop" | "tool_calls" | null;
|
||||||
usage?: Usage;
|
usage?: Usage;
|
||||||
vector?: VectorReceipt;
|
vector?: VectorReceipt;
|
||||||
}): Record<string, unknown> {
|
}): Record<string, unknown> {
|
||||||
|
|||||||
35
gateway/src/openai/transcripts.ts
Normal file
35
gateway/src/openai/transcripts.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/openai/transcripts.ts — in-memory per-session state shared by
|
||||||
|
* the session routes (sessions.ts), the demo affinity executor (demoChat.ts)
|
||||||
|
* and the sweeper's housekeeping.
|
||||||
|
*
|
||||||
|
* transcripts: mirror of each open session's transcript (sessionId → messages).
|
||||||
|
* Kept hash-identical to the supplier's authoritative transcript (endChat's
|
||||||
|
* response_hash check). Lost on restart — abandoned sessions are recovered
|
||||||
|
* by the supplier idle watchdog + gateway sweeper.
|
||||||
|
* sessionLocks: per-session Mutex serializing turns on ONE session (concurrent
|
||||||
|
* turns would interleave supplier transcript appends). Distinct from the
|
||||||
|
* per-key Mutex, which only serializes on-chain wallet work.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ChatMessage } from "@marketplace/shared/tx";
|
||||||
|
import { Mutex } from "../sdk/registry.js";
|
||||||
|
|
||||||
|
export const transcripts = new Map<string, ChatMessage[]>();
|
||||||
|
|
||||||
|
const sessionLocks = new Map<string, Mutex>();
|
||||||
|
|
||||||
|
export function getSessionLock(sessionId: string): Mutex {
|
||||||
|
let lock = sessionLocks.get(sessionId);
|
||||||
|
if (!lock) {
|
||||||
|
lock = new Mutex();
|
||||||
|
sessionLocks.set(sessionId, lock);
|
||||||
|
}
|
||||||
|
return lock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forget all in-memory state for a session (closed/gone). */
|
||||||
|
export function dropSessionState(sessionId: string): void {
|
||||||
|
transcripts.delete(sessionId);
|
||||||
|
sessionLocks.delete(sessionId);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
* gateway/src/openai/validate.ts — request body validation for chat endpoints.
|
* gateway/src/openai/validate.ts — request body validation for chat endpoints.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ChatMessage } from "@marketplace/shared/tx";
|
import { normalizeChatMessage, type ChatMessage } from "@marketplace/shared/tx";
|
||||||
import { badRequest } from "./errors.js";
|
import { badRequest } from "./errors.js";
|
||||||
|
|
||||||
export interface ParsedChatRequest {
|
export interface ParsedChatRequest {
|
||||||
@@ -10,21 +10,30 @@ export interface ParsedChatRequest {
|
|||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
maxTokens?: number;
|
maxTokens?: number;
|
||||||
stream: boolean;
|
stream: boolean;
|
||||||
|
tools?: unknown[];
|
||||||
|
toolChoice?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROLES = new Set(["system", "user", "assistant"]);
|
const ROLES = new Set(["system", "user", "assistant"]);
|
||||||
|
|
||||||
/** Validate an OpenAI chat.completions-style body. Rejects tools/functions;
|
/** Validate an OpenAI chat.completions-style body. Default path rejects
|
||||||
* silently ignores temperature/n/stop/top_p/etc (documented). */
|
* tools/functions; `allowTools` (demo keys) accepts tools/tool_choice, the
|
||||||
export function parseChatRequest(body: unknown): ParsedChatRequest {
|
* `tool` role, null content and assistant tool_calls, normalizing every
|
||||||
|
* message into canonical mirror shape. Silently ignores temperature/n/stop/
|
||||||
|
* top_p/etc (documented). */
|
||||||
|
export function parseChatRequest(body: unknown, opts?: { allowTools?: boolean }): ParsedChatRequest {
|
||||||
|
const allowTools = opts?.allowTools === true;
|
||||||
if (typeof body !== "object" || body === null) {
|
if (typeof body !== "object" || body === null) {
|
||||||
throw badRequest("invalid_body", "request body must be a JSON object");
|
throw badRequest("invalid_body", "request body must be a JSON object");
|
||||||
}
|
}
|
||||||
const b = body as Record<string, unknown>;
|
const b = body as Record<string, unknown>;
|
||||||
|
|
||||||
if ("tools" in b || "tool_choice" in b || "functions" in b || "function_call" in b) {
|
if (!allowTools && ("tools" in b || "tool_choice" in b || "functions" in b || "function_call" in b)) {
|
||||||
throw badRequest("unsupported_parameter", "tools and function-calling are not supported by this gateway");
|
throw badRequest("unsupported_parameter", "tools and function-calling are not supported by this gateway");
|
||||||
}
|
}
|
||||||
|
if (allowTools && ("functions" in b || "function_call" in b)) {
|
||||||
|
throw badRequest("unsupported_parameter", "legacy functions/function_call are not supported; use tools");
|
||||||
|
}
|
||||||
|
|
||||||
const model = b.model;
|
const model = b.model;
|
||||||
if (typeof model !== "string" || model === "") {
|
if (typeof model !== "string" || model === "") {
|
||||||
@@ -37,6 +46,14 @@ export function parseChatRequest(body: unknown): ParsedChatRequest {
|
|||||||
}
|
}
|
||||||
const messages: ChatMessage[] = [];
|
const messages: ChatMessage[] = [];
|
||||||
for (const m of rawMessages) {
|
for (const m of rawMessages) {
|
||||||
|
if (allowTools) {
|
||||||
|
const msg = normalizeChatMessage(m);
|
||||||
|
if (!msg) {
|
||||||
|
throw badRequest("invalid_messages", "each message must be OpenAI-shaped {role, content, tool_calls?, tool_call_id?}");
|
||||||
|
}
|
||||||
|
messages.push(msg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
typeof m !== "object" ||
|
typeof m !== "object" ||
|
||||||
m === null ||
|
m === null ||
|
||||||
@@ -50,6 +67,13 @@ export function parseChatRequest(body: unknown): ParsedChatRequest {
|
|||||||
messages.push({ role: mm.role, content: mm.content });
|
messages.push({ role: mm.role, content: mm.content });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let tools: unknown[] | undefined;
|
||||||
|
let toolChoice: unknown;
|
||||||
|
if (allowTools && Array.isArray(b.tools) && b.tools.length > 0) {
|
||||||
|
tools = b.tools;
|
||||||
|
toolChoice = b.tool_choice;
|
||||||
|
}
|
||||||
|
|
||||||
let maxTokens: number | undefined;
|
let maxTokens: number | undefined;
|
||||||
const mt = b.max_tokens ?? b.max_completion_tokens;
|
const mt = b.max_tokens ?? b.max_completion_tokens;
|
||||||
if (mt !== undefined && mt !== null) {
|
if (mt !== undefined && mt !== null) {
|
||||||
@@ -60,5 +84,5 @@ export function parseChatRequest(body: unknown): ParsedChatRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stream = b.stream === true;
|
const stream = b.stream === true;
|
||||||
return { model, messages, maxTokens, stream };
|
return { model, messages, maxTokens, stream, tools, toolChoice };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,19 +101,27 @@ export async function selectCandidates(opts: SelectSupplierOpts): Promise<Suppli
|
|||||||
return candidates;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Distinct models across Active suppliers (for GET /openai/v1/models). */
|
/** Distinct models across Active suppliers (for GET /openai/v1/models).
|
||||||
|
* Pass capabilityId to list only models under one capability (demo keys see
|
||||||
|
* only llm.chat.v1 models — the session-backed executor's routing target). */
|
||||||
export async function listModels(opts: {
|
export async function listModels(opts: {
|
||||||
indexerUrl: string;
|
indexerUrl: string;
|
||||||
|
capabilityId?: string;
|
||||||
fetchFn?: typeof globalThis.fetch;
|
fetchFn?: typeof globalThis.fetch;
|
||||||
}): Promise<string[]> {
|
}): Promise<string[]> {
|
||||||
const fetchFn = opts.fetchFn ?? globalThis.fetch;
|
const fetchFn = opts.fetchFn ?? globalThis.fetch;
|
||||||
const res = await fetchFn(`${opts.indexerUrl}/suppliers`);
|
const url = opts.capabilityId
|
||||||
|
? `${opts.indexerUrl}/suppliers?capability_id=${encodeURIComponent(opts.capabilityId)}`
|
||||||
|
: `${opts.indexerUrl}/suppliers`;
|
||||||
|
const res = await fetchFn(url);
|
||||||
if (!res.ok) throw new Error(`indexer /suppliers returned ${res.status}`);
|
if (!res.ok) throw new Error(`indexer /suppliers returned ${res.status}`);
|
||||||
const body = (await res.json()) as unknown;
|
const body = (await res.json()) as unknown;
|
||||||
if (!Array.isArray(body)) throw new Error("indexer /suppliers did not return an array");
|
if (!Array.isArray(body)) throw new Error("indexer /suppliers did not return an array");
|
||||||
const models = new Set<string>();
|
const models = new Set<string>();
|
||||||
for (const raw of body as SupplierView[]) {
|
for (const raw of body as SupplierView[]) {
|
||||||
if (raw.advert_status === "Active" && typeof raw.model === "string") models.add(raw.model);
|
if (raw.advert_status !== "Active" || typeof raw.model !== "string") continue;
|
||||||
|
if (opts.capabilityId !== undefined && raw.capability_id !== opts.capabilityId) continue;
|
||||||
|
models.add(raw.model);
|
||||||
}
|
}
|
||||||
return [...models].sort();
|
return [...models].sort();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
import express, { type Express, type NextFunction, type Request, type Response } from "express";
|
import express, { type Express, type NextFunction, type Request, type Response } from "express";
|
||||||
import type { GatewayDeps } from "./deps.js";
|
import type { GatewayDeps } from "./deps.js";
|
||||||
import { makeApiKeyAuth } from "./middleware/apiKeyAuth.js";
|
import { makeApiKeyAuth } from "./middleware/apiKeyAuth.js";
|
||||||
import { ipRateLimit, keyRateLimit } from "./middleware/rateLimit.js";
|
import { ipRateLimit, keyRateLimit, demoIpRateLimit } from "./middleware/rateLimit.js";
|
||||||
import { sendError } from "./middleware/http.js";
|
import { sendError } from "./middleware/http.js";
|
||||||
import { notFound } from "./openai/errors.js";
|
import { notFound } from "./openai/errors.js";
|
||||||
import { makeChatCompletionsHandler } from "./openai/chatCompletions.js";
|
import { makeChatCompletionsHandler } from "./openai/chatCompletions.js";
|
||||||
@@ -69,10 +69,12 @@ export function createApp(deps: GatewayDeps): Express {
|
|||||||
// Public signup, rate-limited per IP.
|
// Public signup, rate-limited per IP.
|
||||||
app.post("/signup", ipRateLimit(deps.config.signupRate), makeSignupHandler(deps));
|
app.post("/signup", ipRateLimit(deps.config.signupRate), makeSignupHandler(deps));
|
||||||
|
|
||||||
// Bearer-gated routes: auth → per-key rate limit → handler.
|
// Bearer-gated routes: auth → per-key rate limit (skips demo keys) →
|
||||||
|
// per-IP demo limit (demo keys only) → handler.
|
||||||
const auth = makeApiKeyAuth(deps.store);
|
const auth = makeApiKeyAuth(deps.store);
|
||||||
const krl = keyRateLimit(deps.config.keyRate);
|
const krl = keyRateLimit(deps.config.keyRate);
|
||||||
const gated = [auth, krl] as const;
|
const dirl = demoIpRateLimit(deps.config.demoIpRate);
|
||||||
|
const gated = [auth, krl, dirl] as const;
|
||||||
|
|
||||||
app.get("/account", auth, makeAccountHandler(deps));
|
app.get("/account", auth, makeAccountHandler(deps));
|
||||||
app.post("/account/withdraw", auth, makeWithdrawHandler(deps));
|
app.post("/account/withdraw", auth, makeWithdrawHandler(deps));
|
||||||
|
|||||||
@@ -10,26 +10,77 @@
|
|||||||
* state and never blind-reclaim a Submitted escrow. The on-chain validator is
|
* state and never blind-reclaim a Submitted escrow. The on-chain validator is
|
||||||
* the source of truth for deadlines, so premature/late attempts simply fail and
|
* the source of truth for deadlines, so premature/late attempts simply fail and
|
||||||
* are caught here; the age thresholds just avoid racing in-flight requests.
|
* are caught here; the age thresholds just avoid racing in-flight requests.
|
||||||
|
*
|
||||||
|
* Chat sessions get special handling:
|
||||||
|
* - Their deliver_by is ~30 min (vs ~10 min one-shot), so rows belonging to
|
||||||
|
* an OPEN session use CHAT_RECLAIM_AFTER_MS — otherwise every open session
|
||||||
|
* older than 11 min would trigger a doomed Reclaim attempt per sweep while
|
||||||
|
* holding the key's mutex.
|
||||||
|
* - When a session's escrow reaches a terminal state (Accepted here, spent
|
||||||
|
* elsewhere, or Reclaimed), the session row is closed, its in-memory state
|
||||||
|
* dropped, and a usage row recorded — abandoned demo sessions (nobody calls
|
||||||
|
* /close) settle and get billed through this path.
|
||||||
|
*
|
||||||
|
* Escrow↔session lineage: the indexer keeps one row per utxo_ref per state
|
||||||
|
* (terminal spends update nothing), and the validator preserves posted_at
|
||||||
|
* across states — so a session's lineage is "rows sharing the posted_at of the
|
||||||
|
* session's original Open ref row".
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
import type { GatewayDeps } from "./deps.js";
|
import type { GatewayDeps } from "./deps.js";
|
||||||
import { fetchEscrows, acceptAndConfirm, reclaimAndConfirm } from "./onchain/settle.js";
|
import type { SessionRow } from "./db/store.js";
|
||||||
|
import { fetchEscrows, acceptAndConfirm, reclaimAndConfirm, type EscrowRow } from "./onchain/settle.js";
|
||||||
import { parseRef } from "./routing/selectSupplier.js";
|
import { parseRef } from "./routing/selectSupplier.js";
|
||||||
|
import { dropSessionState } from "./openai/transcripts.js";
|
||||||
|
import { dropDemoSession } from "./openai/demoChat.js";
|
||||||
|
import { CAPABILITY as CHAT_CAPABILITY } from "./openai/sessions.js";
|
||||||
|
|
||||||
// Don't touch escrows newer than this — an in-flight request is settling them.
|
// Don't touch escrows newer than this — an in-flight request is settling them.
|
||||||
const MIN_AGE_MS = 90_000;
|
const MIN_AGE_MS = 90_000;
|
||||||
// Reclaim Open/Claimed escrows older than this (past deliver_by ~10m + buffer).
|
// Reclaim Open/Claimed escrows older than this (past deliver_by ~10m + buffer).
|
||||||
const RECLAIM_AFTER_MS = 11 * 60 * 1000;
|
const RECLAIM_AFTER_MS = 11 * 60 * 1000;
|
||||||
|
// Chat-session escrows: deliver_by ≈ posted_at + 30 min, so wait longer.
|
||||||
|
const CHAT_RECLAIM_AFTER_MS = 35 * 60 * 1000;
|
||||||
|
|
||||||
function log(msg: string): void {
|
function log(msg: string): void {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`[gateway:sweeper] ${msg}`);
|
console.log(`[gateway:sweeper] ${msg}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeSessionRow(
|
||||||
|
deps: GatewayDeps,
|
||||||
|
session: SessionRow,
|
||||||
|
outcome: { status: "completed" | "reclaimed"; costLovelace: string | null },
|
||||||
|
): void {
|
||||||
|
// Re-read: an in-flight close (route handler or demo executor) may have won.
|
||||||
|
const current = deps.store.getSession(session.id);
|
||||||
|
if (!current || current.state !== "open") return;
|
||||||
|
deps.store.setSessionState(session.id, "closed", Date.now());
|
||||||
|
dropSessionState(session.id);
|
||||||
|
dropDemoSession(session.id);
|
||||||
|
deps.store.insertUsage({
|
||||||
|
id: randomUUID(),
|
||||||
|
key_id: session.key_id,
|
||||||
|
created_at: Date.now(),
|
||||||
|
kind: "chat_session",
|
||||||
|
model: session.model,
|
||||||
|
capability_id: CHAT_CAPABILITY,
|
||||||
|
supplier_pkh: session.supplier_pkh,
|
||||||
|
escrow_ref: session.escrow_ref,
|
||||||
|
cost_lovelace: outcome.costLovelace,
|
||||||
|
prompt_tokens: 0,
|
||||||
|
completion_tokens: 0,
|
||||||
|
status: outcome.status,
|
||||||
|
failure_reason: null,
|
||||||
|
});
|
||||||
|
log(`closed abandoned session ${session.id} (${outcome.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function runSweepOnce(deps: GatewayDeps): Promise<void> {
|
export async function runSweepOnce(deps: GatewayDeps): Promise<void> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const keyRow of deps.store.listKeys()) {
|
for (const keyRow of deps.store.listKeys()) {
|
||||||
let rows;
|
let rows: EscrowRow[];
|
||||||
try {
|
try {
|
||||||
rows = await fetchEscrows(deps.config.indexerUrl, keyRow.wallet_pkh, deps.fetchFn);
|
rows = await fetchEscrows(deps.config.indexerUrl, keyRow.wallet_pkh, deps.fetchFn);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -37,20 +88,53 @@ export async function runSweepOnce(deps: GatewayDeps): Promise<void> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lineage-match open sessions: the session stores its ORIGINAL Open ref;
|
||||||
|
// rows sharing that row's posted_at are the same escrow in later states.
|
||||||
|
const sessionByPostedAt = new Map<number, SessionRow>();
|
||||||
|
for (const session of deps.store.listOpenSessionsByKey(keyRow.id)) {
|
||||||
|
const origin = rows.find((r) => r.utxo_ref === session.escrow_ref);
|
||||||
|
if (origin) sessionByPostedAt.set(origin.posted_at, session);
|
||||||
|
}
|
||||||
|
|
||||||
const ctx = deps.registry.getContext(keyRow);
|
const ctx = deps.registry.getContext(keyRow);
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const ref = parseRef(row.utxo_ref);
|
const ref = parseRef(row.utxo_ref);
|
||||||
if (!ref) continue;
|
if (!ref) continue;
|
||||||
const ageMs = now - (row.posted_at ?? 0);
|
const ageMs = now - (row.posted_at ?? 0);
|
||||||
if (ageMs < MIN_AGE_MS) continue;
|
if (ageMs < MIN_AGE_MS) continue;
|
||||||
|
const chatSession = sessionByPostedAt.get(row.posted_at);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (row.state === "Submitted") {
|
if (row.state === "Submitted") {
|
||||||
await ctx.mutex.run(() => acceptAndConfirm(deps.chain, ctx.walletKey, ref));
|
try {
|
||||||
log(`accepted stranded Submitted escrow ${row.utxo_ref}`);
|
await ctx.mutex.run(() => acceptAndConfirm(deps.chain, ctx.walletKey, ref));
|
||||||
} else if ((row.state === "Open" || row.state === "Claimed") && ageMs > RECLAIM_AFTER_MS) {
|
log(`accepted stranded Submitted escrow ${row.utxo_ref}`);
|
||||||
await ctx.mutex.run(() => reclaimAndConfirm(deps.chain, ctx.walletKey, ref));
|
if (chatSession) {
|
||||||
log(`reclaimed stranded ${row.state} escrow ${row.utxo_ref}`);
|
closeSessionRow(deps, chatSession, { status: "completed", costLovelace: chatSession.price_lovelace });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Accept fails routinely when the UTxO is already spent (settled
|
||||||
|
// by an in-flight request, an earlier sweep, or supplier Release).
|
||||||
|
// For session lineages, confirm the spend on-chain before closing
|
||||||
|
// the row — an Accept that failed for another reason (timeout)
|
||||||
|
// must leave the session open for the next sweep.
|
||||||
|
if (chatSession && (await deps.chain.queryUtxo(ref)) === null) {
|
||||||
|
closeSessionRow(deps, chatSession, { status: "completed", costLovelace: chatSession.price_lovelace });
|
||||||
|
} else if (!chatSession) {
|
||||||
|
// Preserve the historical quiet-skip for one-shot escrows.
|
||||||
|
void err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (row.state === "Open" || row.state === "Claimed") {
|
||||||
|
const reclaimAfterMs = chatSession ? CHAT_RECLAIM_AFTER_MS : RECLAIM_AFTER_MS;
|
||||||
|
if (ageMs > reclaimAfterMs) {
|
||||||
|
await ctx.mutex.run(() => reclaimAndConfirm(deps.chain, ctx.walletKey, ref));
|
||||||
|
log(`reclaimed stranded ${row.state} escrow ${row.utxo_ref}`);
|
||||||
|
if (chatSession) {
|
||||||
|
// Funds returned — the session never settled; record no cost.
|
||||||
|
closeSessionRow(deps, chatSession, { status: "reclaimed", costLovelace: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Expected when the validator rejects (wrong window/state) or the UTxO
|
// Expected when the validator rejects (wrong window/state) or the UTxO
|
||||||
@@ -58,9 +142,6 @@ export async function runSweepOnce(deps: GatewayDeps): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark closed/settled chat sessions whose escrow is no longer active.
|
|
||||||
// (Best-effort housekeeping; the escrow recovery above is the load-bearing part.)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Start the periodic sweeper. Returns a stop() function. */
|
/** Start the periodic sweeper. Returns a stop() function. */
|
||||||
|
|||||||
81
gateway/test/affinity.test.ts
Normal file
81
gateway/test/affinity.test.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* affinity.test.ts — transcript-prefix session matching rules.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { matchSessionPrefix, type AffinityCandidate } from "../src/openai/affinity.js";
|
||||||
|
import type { ChatMessage } from "@marketplace/shared/tx";
|
||||||
|
|
||||||
|
const u = (content: string): ChatMessage => ({ role: "user", content });
|
||||||
|
const a = (content: string): ChatMessage => ({ role: "assistant", content });
|
||||||
|
const TOOL_CALL = { id: "call_1", type: "function" as const, function: { name: "get_time", arguments: "{}" } };
|
||||||
|
|
||||||
|
function cand(sessionId: string, mirror: ChatMessage[], lastUsedAt = 0, model = "m"): AffinityCandidate {
|
||||||
|
return { sessionId, model, mirror, lastUsedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("matchSessionPrefix", () => {
|
||||||
|
it("matches an exact prefix and returns the delta", () => {
|
||||||
|
const got = matchSessionPrefix([cand("s1", [u("hi"), a("hello")])], [u("hi"), a("hello"), u("more")], "m");
|
||||||
|
expect(got).toEqual({ sessionId: "s1", delta: [u("more")] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a multi-message tool delta ending with a tool result", () => {
|
||||||
|
const mirror = [u("hi"), { role: "assistant" as const, content: "", tool_calls: [TOOL_CALL] }];
|
||||||
|
const incoming = [...mirror, { role: "tool" as const, content: "3pm", tool_call_id: "call_1" }];
|
||||||
|
const got = matchSessionPrefix([cand("s1", mirror)], incoming, "m");
|
||||||
|
expect(got?.sessionId).toBe("s1");
|
||||||
|
expect(got?.delta).toHaveLength(1);
|
||||||
|
expect(got?.delta[0].role).toBe("tool");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects deltas that end with a non-respondable role", () => {
|
||||||
|
const got = matchSessionPrefix([cand("s1", [u("hi")])], [u("hi"), a("stray assistant")], "m");
|
||||||
|
expect(got).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never matches when incoming is not longer than the mirror (retry of answered request)", () => {
|
||||||
|
const mirror = [u("hi"), a("hello")];
|
||||||
|
expect(matchSessionPrefix([cand("s1", mirror)], mirror, "m")).toBeNull();
|
||||||
|
expect(matchSessionPrefix([cand("s1", mirror)], [u("hi")], "m")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects diverged histories (prefix mismatch)", () => {
|
||||||
|
const got = matchSessionPrefix([cand("s1", [u("hi"), a("hello")])], [u("hi"), a("DIFFERENT"), u("more")], "m");
|
||||||
|
expect(got).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires the model to match", () => {
|
||||||
|
expect(matchSessionPrefix([cand("s1", [u("hi")], 0, "other")], [u("hi"), u("more")], "m")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the longest mirror, then most recently used", () => {
|
||||||
|
const incoming = [u("hi"), a("hello"), u("more")];
|
||||||
|
const longest = matchSessionPrefix(
|
||||||
|
[cand("short", [u("hi")], 99), cand("long", [u("hi"), a("hello")], 0)],
|
||||||
|
incoming,
|
||||||
|
"m",
|
||||||
|
);
|
||||||
|
expect(longest?.sessionId).toBe("long");
|
||||||
|
|
||||||
|
const tie = matchSessionPrefix(
|
||||||
|
[cand("old", [u("hi"), a("hello")], 1), cand("recent", [u("hi"), a("hello")], 2)],
|
||||||
|
incoming,
|
||||||
|
"m",
|
||||||
|
);
|
||||||
|
expect(tie?.sessionId).toBe("recent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches an empty mirror (fresh session) against any respondable history", () => {
|
||||||
|
const incoming = [{ role: "system" as const, content: "be nice" }, u("hi")];
|
||||||
|
const got = matchSessionPrefix([cand("fresh", [])], incoming, "m");
|
||||||
|
expect(got).toEqual({ sessionId: "fresh", delta: incoming });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats content null-normalization as equivalent (tool-call assistant echo)", () => {
|
||||||
|
const mirror = [u("hi"), { role: "assistant" as const, content: "", tool_calls: [TOOL_CALL] }];
|
||||||
|
// Client echoes the assistant message back with content:"" (normalized from null upstream).
|
||||||
|
const incoming = [u("hi"), { role: "assistant" as const, content: "", tool_calls: [{ ...TOOL_CALL }] }, { role: "tool" as const, content: "x", tool_call_id: "call_1" }];
|
||||||
|
expect(matchSessionPrefix([cand("s1", mirror)], incoming, "m")?.sessionId).toBe("s1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,23 +1,32 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { randomUUID } from "crypto";
|
import { randomBytes, randomUUID } from "crypto";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { createApp } from "../src/server.js";
|
import { createApp } from "../src/server.js";
|
||||||
import { GatewayStore } from "../src/db/store.js";
|
import { GatewayStore } from "../src/db/store.js";
|
||||||
import { SdkRegistry } from "../src/sdk/registry.js";
|
import { SdkRegistry } from "../src/sdk/registry.js";
|
||||||
|
import { genPrivKeyHex, deriveWalletKey } from "../src/wallet.js";
|
||||||
|
import { seal } from "../src/crypto/seal.js";
|
||||||
|
import { hashApiKey } from "../src/middleware/apiKeyAuth.js";
|
||||||
import type { GatewayConfig } from "../src/config.js";
|
import type { GatewayConfig } from "../src/config.js";
|
||||||
import type { GatewayDeps } from "../src/deps.js";
|
import type { GatewayDeps } from "../src/deps.js";
|
||||||
|
|
||||||
|
const MASTER = "ab".repeat(32);
|
||||||
|
|
||||||
const SUPPLIERS = [
|
const SUPPLIERS = [
|
||||||
{ utxo_ref: "aa".repeat(32) + "#0", supplier_pkh: "s1", capability_id: "llm.text.generate.v1", model: "qwen", max_output_tokens: 256, price_lovelace: "1000000", supplier_bond_lovelace: "1000000", buyer_bond_lovelace: "1000000", endpoint_url: "http://sup", advert_status: "Active", status: "free" },
|
{ utxo_ref: "aa".repeat(32) + "#0", supplier_pkh: "s1", capability_id: "llm.text.generate.v1", model: "qwen", max_output_tokens: 256, price_lovelace: "1000000", supplier_bond_lovelace: "1000000", buyer_bond_lovelace: "1000000", endpoint_url: "http://sup", advert_status: "Active", status: "free" },
|
||||||
|
{ utxo_ref: "bb".repeat(32) + "#0", supplier_pkh: "s2", capability_id: "llm.chat.v1", model: "kimi", max_output_tokens: 256, price_lovelace: "1000000", supplier_bond_lovelace: "1000000", buyer_bond_lovelace: "1000000", endpoint_url: "http://sup", advert_status: "Active", status: "free" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function makeDeps(fetchFn?: typeof globalThis.fetch): GatewayDeps {
|
function makeDeps(
|
||||||
|
fetchFn?: typeof globalThis.fetch,
|
||||||
|
overrides?: { demoIpRate?: { max: number; windowMs: number }; chain?: GatewayDeps["chain"] },
|
||||||
|
): GatewayDeps {
|
||||||
const dbDir = join(tmpdir(), `gw-test-${randomUUID()}`);
|
const dbDir = join(tmpdir(), `gw-test-${randomUUID()}`);
|
||||||
const store = new GatewayStore(dbDir);
|
const store = new GatewayStore(dbDir);
|
||||||
const config: GatewayConfig = {
|
const config: GatewayConfig = {
|
||||||
masterKeyHex: "ab".repeat(32),
|
masterKeyHex: MASTER,
|
||||||
indexerUrl: "http://ix",
|
indexerUrl: "http://ix",
|
||||||
ogmiosUrl: "http://og",
|
ogmiosUrl: "http://og",
|
||||||
networkId: 0,
|
networkId: 0,
|
||||||
@@ -26,11 +35,13 @@ function makeDeps(fetchFn?: typeof globalThis.fetch): GatewayDeps {
|
|||||||
dbDir,
|
dbDir,
|
||||||
signupRate: { max: 1000, windowMs: 60_000 },
|
signupRate: { max: 1000, windowMs: 60_000 },
|
||||||
keyRate: { max: 1000, windowMs: 60_000 },
|
keyRate: { max: 1000, windowMs: 60_000 },
|
||||||
|
demoIpRate: overrides?.demoIpRate ?? { max: 1000, windowMs: 60_000 },
|
||||||
sweeperIntervalMs: 60_000,
|
sweeperIntervalMs: 60_000,
|
||||||
walletHealthIntervalMs: 600_000,
|
walletHealthIntervalMs: 600_000,
|
||||||
sdkRegistryMax: 100,
|
sdkRegistryMax: 100,
|
||||||
|
corsOrigins: [],
|
||||||
};
|
};
|
||||||
const chain = { queryUtxosByAddress: async () => [] } as unknown as GatewayDeps["chain"];
|
const chain = overrides?.chain ?? ({ queryUtxosByAddress: async () => [] } as unknown as GatewayDeps["chain"]);
|
||||||
const registry = new SdkRegistry({
|
const registry = new SdkRegistry({
|
||||||
chain,
|
chain,
|
||||||
indexerUrl: config.indexerUrl,
|
indexerUrl: config.indexerUrl,
|
||||||
@@ -103,3 +114,238 @@ describe("gateway HTTP", () => {
|
|||||||
expect(res.body.error.code).toBe("unsupported_parameter");
|
expect(res.body.error.code).toBe("unsupported_parameter");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Shared demo key (session-backed completions) ───────────────────────────
|
||||||
|
|
||||||
|
const TOOLS = [{ type: "function", function: { name: "get_time", parameters: {} } }];
|
||||||
|
const TOOL_CALL = { id: "call_1", type: "function", function: { name: "get_time", arguments: "{}" } };
|
||||||
|
|
||||||
|
/** A funded wallet: covers price+bonds+collateral+fee and has pure-ADA ≥ 5. */
|
||||||
|
const fundedChain = {
|
||||||
|
queryUtxosByAddress: async () => [
|
||||||
|
{ ref: { txHash: "ff".repeat(32), index: 0 }, address: "a", lovelace: 20_000_000n, assets: {}, datumHex: null, scriptRef: null },
|
||||||
|
],
|
||||||
|
} as unknown as GatewayDeps["chain"];
|
||||||
|
|
||||||
|
function supplierSse(frames: unknown[]): Response {
|
||||||
|
const body = frames.map((f) => `data: ${JSON.stringify(f)}\n\n`).join("");
|
||||||
|
return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function doneFrame(message: Record<string, unknown>, finishReason: string, usage = { prompt_tokens: 3, completion_tokens: 2 }) {
|
||||||
|
return { type: "done", message, finish_reason: finishReason, usage };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** fetchFn serving the indexer supplier list and scripted /v1/chat/message responses. */
|
||||||
|
function demoFetch(messageResponses: Array<() => Response>) {
|
||||||
|
const messageCalls: Array<Record<string, unknown>> = [];
|
||||||
|
let i = 0;
|
||||||
|
const fetchFn = (async (url: unknown, init?: { body?: string }) => {
|
||||||
|
const u = String(url);
|
||||||
|
if (u.includes("/v1/chat/message")) {
|
||||||
|
messageCalls.push(init?.body ? (JSON.parse(init.body) as Record<string, unknown>) : {});
|
||||||
|
const make = messageResponses[Math.min(i, messageResponses.length - 1)];
|
||||||
|
i += 1;
|
||||||
|
return make();
|
||||||
|
}
|
||||||
|
if (u.includes("/suppliers")) return new Response(JSON.stringify(SUPPLIERS), { status: 200 });
|
||||||
|
return new Response("[]", { status: 200 });
|
||||||
|
}) as unknown as typeof globalThis.fetch;
|
||||||
|
return { fetchFn, messageCalls };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert a demo-flagged key directly (public signup can't mint one) and stub
|
||||||
|
* the SDK's chain-touching startChat on the pre-warmed per-key context. */
|
||||||
|
function setupDemoKey(deps: GatewayDeps) {
|
||||||
|
const rawKey = `vk_test_${randomBytes(24).toString("hex")}`;
|
||||||
|
const privHex = genPrivKeyHex();
|
||||||
|
const walletKey = deriveWalletKey(privHex, 0);
|
||||||
|
const sealed = seal(privHex, MASTER);
|
||||||
|
deps.store.insertKey({
|
||||||
|
id: randomUUID(),
|
||||||
|
key_hash: hashApiKey(rawKey),
|
||||||
|
key_prefix: rawKey.slice(0, 12),
|
||||||
|
label: "shared-demo",
|
||||||
|
wallet_pkh: walletKey.pubKeyHash,
|
||||||
|
deposit_address: walletKey.address,
|
||||||
|
enc_priv_nonce: sealed.nonce,
|
||||||
|
enc_priv_ct: sealed.ct,
|
||||||
|
enc_priv_tag: sealed.tag,
|
||||||
|
master_key_version: 1,
|
||||||
|
created_at: Date.now(),
|
||||||
|
demo: 1,
|
||||||
|
});
|
||||||
|
const keyRow = deps.store.getKeyByHash(hashApiKey(rawKey))!;
|
||||||
|
const ctx = deps.registry.getContext(keyRow);
|
||||||
|
const startChat = vi.fn(async () => ({
|
||||||
|
escrowRef: { txHash: randomBytes(32).toString("hex"), index: 0 },
|
||||||
|
sessionNonce: "nonce",
|
||||||
|
supplierBaseUrl: "http://sup",
|
||||||
|
}));
|
||||||
|
(ctx.sdk as { startChat: unknown }).startChat = startChat;
|
||||||
|
return { rawKey, keyRow, startChat };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("gateway demo key", () => {
|
||||||
|
it("runs a session-backed completion with tools and reuses the session via affinity", async () => {
|
||||||
|
const { fetchFn, messageCalls } = demoFetch([
|
||||||
|
() => supplierSse([doneFrame({ role: "assistant", content: "", tool_calls: [TOOL_CALL] }, "tool_calls")]),
|
||||||
|
() => supplierSse([{ type: "token", value: "It is 3pm" }, doneFrame({ role: "assistant", content: "It is 3pm" }, "stop")]),
|
||||||
|
]);
|
||||||
|
const deps = makeDeps(fetchFn, { chain: fundedChain });
|
||||||
|
const { rawKey, startChat } = setupDemoKey(deps);
|
||||||
|
const app = createApp(deps);
|
||||||
|
|
||||||
|
// Turn 1: model asks for a tool call.
|
||||||
|
const history: Array<Record<string, unknown>> = [{ role: "user", content: "what time is it?" }];
|
||||||
|
const res1 = await request(app)
|
||||||
|
.post("/openai/v1/chat/completions")
|
||||||
|
.set("authorization", `Bearer ${rawKey}`)
|
||||||
|
.send({ model: "kimi", messages: history, tools: TOOLS });
|
||||||
|
expect(res1.status).toBe(200);
|
||||||
|
expect(res1.body.choices[0].message.tool_calls).toEqual([TOOL_CALL]);
|
||||||
|
expect(res1.body.choices[0].finish_reason).toBe("tool_calls");
|
||||||
|
expect(startChat).toHaveBeenCalledTimes(1);
|
||||||
|
// First turn replays the full history and forwards tools.
|
||||||
|
expect((messageCalls[0].messages as unknown[]).length).toBe(1);
|
||||||
|
expect(messageCalls[0].tools).toEqual(TOOLS);
|
||||||
|
|
||||||
|
// Turn 2: client echoes the assistant tool_calls + tool result.
|
||||||
|
history.push({ role: "assistant", content: "", tool_calls: [TOOL_CALL] });
|
||||||
|
history.push({ role: "tool", content: "3pm", tool_call_id: "call_1" });
|
||||||
|
const res2 = await request(app)
|
||||||
|
.post("/openai/v1/chat/completions")
|
||||||
|
.set("authorization", `Bearer ${rawKey}`)
|
||||||
|
.send({ model: "kimi", messages: history, tools: TOOLS });
|
||||||
|
expect(res2.status).toBe(200);
|
||||||
|
expect(res2.body.choices[0].message.content).toBe("It is 3pm");
|
||||||
|
expect(res2.body.choices[0].finish_reason).toBe("stop");
|
||||||
|
// Affinity hit: no new session, only the unseen delta is forwarded.
|
||||||
|
expect(startChat).toHaveBeenCalledTimes(1);
|
||||||
|
const delta = messageCalls[1].messages as Array<{ role: string }>;
|
||||||
|
expect(delta).toHaveLength(1);
|
||||||
|
expect(delta[0].role).toBe("tool");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("streams tool_calls chunks with finish_reason tool_calls", async () => {
|
||||||
|
const { fetchFn } = demoFetch([
|
||||||
|
() => supplierSse([doneFrame({ role: "assistant", content: "", tool_calls: [TOOL_CALL] }, "tool_calls")]),
|
||||||
|
]);
|
||||||
|
const deps = makeDeps(fetchFn, { chain: fundedChain });
|
||||||
|
const { rawKey } = setupDemoKey(deps);
|
||||||
|
const res = await request(createApp(deps))
|
||||||
|
.post("/openai/v1/chat/completions")
|
||||||
|
.set("authorization", `Bearer ${rawKey}`)
|
||||||
|
.send({ model: "kimi", messages: [{ role: "user", content: "time?" }], tools: TOOLS, stream: true });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const chunks = res.text
|
||||||
|
.split("\n\n")
|
||||||
|
.filter((l) => l.startsWith("data: ") && !l.includes("[DONE]"))
|
||||||
|
.map((l) => JSON.parse(l.slice(6)));
|
||||||
|
const toolChunk = chunks.find((c) => c.choices?.[0]?.delta?.tool_calls);
|
||||||
|
expect(toolChunk.choices[0].delta.tool_calls[0].function.name).toBe("get_time");
|
||||||
|
expect(chunks.at(-1).choices[0].finish_reason).toBe("tool_calls");
|
||||||
|
expect(res.text).toContain("data: [DONE]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transparently reopens when the supplier lost the session (idle-close)", async () => {
|
||||||
|
const { fetchFn, messageCalls } = demoFetch([
|
||||||
|
() => supplierSse([{ type: "token", value: "Hi" }, doneFrame({ role: "assistant", content: "Hi" }, "stop")]),
|
||||||
|
() => new Response("not found", { status: 404 }),
|
||||||
|
() => supplierSse([{ type: "token", value: "Sure" }, doneFrame({ role: "assistant", content: "Sure" }, "stop")]),
|
||||||
|
]);
|
||||||
|
const deps = makeDeps(fetchFn, { chain: fundedChain });
|
||||||
|
const { rawKey, startChat } = setupDemoKey(deps);
|
||||||
|
const app = createApp(deps);
|
||||||
|
|
||||||
|
const res1 = await request(app)
|
||||||
|
.post("/openai/v1/chat/completions")
|
||||||
|
.set("authorization", `Bearer ${rawKey}`)
|
||||||
|
.send({ model: "kimi", messages: [{ role: "user", content: "hi" }] });
|
||||||
|
expect(res1.status).toBe(200);
|
||||||
|
|
||||||
|
// Extended history matches the session, but the supplier 404s the turn →
|
||||||
|
// the executor closes the session and reopens, replaying full history.
|
||||||
|
const history = [
|
||||||
|
{ role: "user", content: "hi" },
|
||||||
|
{ role: "assistant", content: "Hi" },
|
||||||
|
{ role: "user", content: "more please" },
|
||||||
|
];
|
||||||
|
const res2 = await request(app)
|
||||||
|
.post("/openai/v1/chat/completions")
|
||||||
|
.set("authorization", `Bearer ${rawKey}`)
|
||||||
|
.send({ model: "kimi", messages: history });
|
||||||
|
expect(res2.status).toBe(200);
|
||||||
|
expect(res2.body.choices[0].message.content).toBe("Sure");
|
||||||
|
expect(startChat).toHaveBeenCalledTimes(2);
|
||||||
|
expect((messageCalls[2].messages as unknown[]).length).toBe(3);
|
||||||
|
// The dead session's fee was recorded for spend audit.
|
||||||
|
const usage = deps.store.listUsage(deps.store.listAllKeys().find((k) => k.demo === 1)!.id, 10);
|
||||||
|
expect(usage.some((u) => u.kind === "chat_session" && u.cost_lovelace === "1000000")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects histories that do not end with user/tool before any chain work", async () => {
|
||||||
|
const deps = makeDeps(undefined, { chain: fundedChain });
|
||||||
|
const { rawKey, startChat } = setupDemoKey(deps);
|
||||||
|
const res = await request(createApp(deps))
|
||||||
|
.post("/openai/v1/chat/completions")
|
||||||
|
.set("authorization", `Bearer ${rawKey}`)
|
||||||
|
.send({ model: "kimi", messages: [{ role: "assistant", content: "hello" }] });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(startChat).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks withdraw and hides the deposit address for demo keys", async () => {
|
||||||
|
const deps = makeDeps();
|
||||||
|
const { rawKey } = setupDemoKey(deps);
|
||||||
|
const app = createApp(deps);
|
||||||
|
|
||||||
|
const wd = await request(app)
|
||||||
|
.post("/account/withdraw")
|
||||||
|
.set("authorization", `Bearer ${rawKey}`)
|
||||||
|
.send({ to_address: "addr_test1abc" });
|
||||||
|
expect(wd.status).toBe(403);
|
||||||
|
expect(wd.body.error.code).toBe("demo_key_restricted");
|
||||||
|
|
||||||
|
const acct = await request(app).get("/account").set("authorization", `Bearer ${rawKey}`);
|
||||||
|
expect(acct.status).toBe(200);
|
||||||
|
expect(acct.body.deposit_address).toBeNull();
|
||||||
|
expect(acct.body.demo).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists only chat-capable models for demo keys", async () => {
|
||||||
|
const fetchFn = (async (url: unknown) =>
|
||||||
|
new Response(JSON.stringify(String(url).includes("/suppliers") ? SUPPLIERS.filter((s) => !String(url).includes("capability_id") || s.capability_id === "llm.chat.v1") : []), { status: 200 })) as unknown as typeof globalThis.fetch;
|
||||||
|
const deps = makeDeps(fetchFn);
|
||||||
|
const { rawKey } = setupDemoKey(deps);
|
||||||
|
const app = createApp(deps);
|
||||||
|
|
||||||
|
const demoModels = await request(app).get("/openai/v1/models").set("authorization", `Bearer ${rawKey}`);
|
||||||
|
expect(demoModels.body.data.map((m: { id: string }) => m.id)).toEqual(["kimi"]);
|
||||||
|
|
||||||
|
const signup = await request(app).post("/signup").send({});
|
||||||
|
const normalModels = await request(app).get("/openai/v1/models").set("authorization", `Bearer ${signup.body.api_key as string}`);
|
||||||
|
expect(normalModels.body.data.map((m: { id: string }) => m.id).sort()).toEqual(["kimi", "qwen"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rate-limits demo requests per IP (and not per key)", async () => {
|
||||||
|
const deps = makeDeps(undefined, { demoIpRate: { max: 1, windowMs: 60_000 } });
|
||||||
|
const { rawKey } = setupDemoKey(deps);
|
||||||
|
const app = createApp(deps);
|
||||||
|
|
||||||
|
// Model with no chat suppliers → cheap 404 AFTER passing the limiter.
|
||||||
|
const body = { model: "nope", messages: [{ role: "user", content: "hi" }] };
|
||||||
|
const first = await request(app).post("/openai/v1/chat/completions").set("authorization", `Bearer ${rawKey}`).send(body);
|
||||||
|
expect(first.status).toBe(404);
|
||||||
|
const second = await request(app).post("/openai/v1/chat/completions").set("authorization", `Bearer ${rawKey}`).send(body);
|
||||||
|
expect(second.status).toBe(429);
|
||||||
|
|
||||||
|
// Non-demo keys are untouched by the demo IP limiter.
|
||||||
|
const signup = await request(app).post("/signup").send({});
|
||||||
|
const normal = await request(app)
|
||||||
|
.post("/openai/v1/chat/completions")
|
||||||
|
.set("authorization", `Bearer ${signup.body.api_key as string}`)
|
||||||
|
.send(body);
|
||||||
|
expect(normal.status).not.toBe(429);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
79
gateway/test/migration.test.ts
Normal file
79
gateway/test/migration.test.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* migration.test.ts — the demo-column migration on pre-existing databases.
|
||||||
|
*
|
||||||
|
* GatewayStore has no migration framework (pure CREATE TABLE IF NOT EXISTS),
|
||||||
|
* so opening a DB created before the `demo` column must ALTER it in place.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
import { mkdirSync } from "fs";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { GatewayStore } from "../src/db/store.js";
|
||||||
|
|
||||||
|
const LEGACY_API_KEYS_SQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
key_hash TEXT NOT NULL UNIQUE,
|
||||||
|
key_prefix TEXT NOT NULL,
|
||||||
|
label TEXT,
|
||||||
|
wallet_pkh TEXT NOT NULL,
|
||||||
|
deposit_address TEXT NOT NULL,
|
||||||
|
enc_priv_nonce TEXT NOT NULL,
|
||||||
|
enc_priv_ct TEXT NOT NULL,
|
||||||
|
enc_priv_tag TEXT NOT NULL,
|
||||||
|
master_key_version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
disabled INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
function keyRow(id: string) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
key_hash: `hash-${id}`,
|
||||||
|
key_prefix: "vk_test_xxxx",
|
||||||
|
label: null,
|
||||||
|
wallet_pkh: "pkh",
|
||||||
|
deposit_address: "addr_test1x",
|
||||||
|
enc_priv_nonce: "n",
|
||||||
|
enc_priv_ct: "c",
|
||||||
|
enc_priv_tag: "t",
|
||||||
|
master_key_version: 1,
|
||||||
|
created_at: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GatewayStore demo-column migration", () => {
|
||||||
|
it("adds the demo column to a legacy DB and defaults existing keys to 0", () => {
|
||||||
|
const dir = join(tmpdir(), `gw-mig-${randomUUID()}`);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
const raw = new Database(join(dir, "gateway.db"));
|
||||||
|
raw.exec(LEGACY_API_KEYS_SQL);
|
||||||
|
raw
|
||||||
|
.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)
|
||||||
|
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)`,
|
||||||
|
)
|
||||||
|
.run(keyRow("legacy"));
|
||||||
|
raw.close();
|
||||||
|
|
||||||
|
const store = new GatewayStore(dir);
|
||||||
|
const legacy = store.getKeyByHash("hash-legacy");
|
||||||
|
expect(legacy?.demo).toBe(0);
|
||||||
|
|
||||||
|
store.insertKey({ ...keyRow("demo-key"), demo: 1 });
|
||||||
|
expect(store.getKeyByHash("hash-demo-key")?.demo).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists the demo flag on fresh databases (default 0)", () => {
|
||||||
|
const dir = join(tmpdir(), `gw-fresh-${randomUUID()}`);
|
||||||
|
const store = new GatewayStore(dir);
|
||||||
|
store.insertKey(keyRow("plain"));
|
||||||
|
expect(store.getKeyByHash("hash-plain")?.demo).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -84,6 +84,10 @@ describe("routing/selectSupplier", () => {
|
|||||||
expect(await listModels({ indexerUrl: "http://ix", fetchFn: jsonResponse(rows) })).toEqual(["m", "other"]);
|
expect(await listModels({ indexerUrl: "http://ix", fetchFn: jsonResponse(rows) })).toEqual(["m", "other"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lists only one capability's models when capabilityId is given", async () => {
|
||||||
|
expect(await listModels({ indexerUrl: "http://ix", capabilityId: "llm.chat.v1", fetchFn: jsonResponse(rows) })).toEqual(["m"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("parseRef", () => {
|
it("parseRef", () => {
|
||||||
expect(parseRef("aa".repeat(32) + "#3")).toEqual({ txHash: "aa".repeat(32), index: 3 });
|
expect(parseRef("aa".repeat(32) + "#3")).toEqual({ txHash: "aa".repeat(32), index: 3 });
|
||||||
expect(parseRef("nope")).toBeNull();
|
expect(parseRef("nope")).toBeNull();
|
||||||
@@ -104,6 +108,37 @@ describe("openai/validate", () => {
|
|||||||
expect(() => parseChatRequest({ model: "m", messages: [] })).toThrow();
|
expect(() => parseChatRequest({ model: "m", messages: [] })).toThrow();
|
||||||
expect(() => parseChatRequest({ messages: [{ role: "user", content: "x" }] })).toThrow();
|
expect(() => parseChatRequest({ messages: [{ role: "user", content: "x" }] })).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const TOOL_CALL = { id: "call_1", type: "function", function: { name: "get_time", arguments: "{}" } };
|
||||||
|
|
||||||
|
it("allowTools accepts tools, tool role, null content and assistant tool_calls", () => {
|
||||||
|
const p = parseChatRequest(
|
||||||
|
{
|
||||||
|
model: "m",
|
||||||
|
messages: [
|
||||||
|
{ role: "user", content: "hi" },
|
||||||
|
{ role: "assistant", content: null, tool_calls: [TOOL_CALL] },
|
||||||
|
{ role: "tool", content: "3pm", tool_call_id: "call_1" },
|
||||||
|
],
|
||||||
|
tools: [{ type: "function", function: { name: "get_time", parameters: {} } }],
|
||||||
|
tool_choice: "auto",
|
||||||
|
},
|
||||||
|
{ allowTools: true },
|
||||||
|
);
|
||||||
|
expect(p.tools).toHaveLength(1);
|
||||||
|
expect(p.toolChoice).toBe("auto");
|
||||||
|
expect(p.messages[1]).toEqual({ role: "assistant", content: "", tool_calls: [TOOL_CALL] });
|
||||||
|
expect(p.messages[2]).toEqual({ role: "tool", content: "3pm", tool_call_id: "call_1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allowTools still rejects legacy functions and the default path still rejects tool role", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseChatRequest({ model: "m", messages: [{ role: "user", content: "x" }], functions: [] }, { allowTools: true }),
|
||||||
|
).toThrow(GatewayError);
|
||||||
|
expect(() =>
|
||||||
|
parseChatRequest({ model: "m", messages: [{ role: "tool", content: "x", tool_call_id: "c" }] }),
|
||||||
|
).toThrow(GatewayError);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("openai/shapes", () => {
|
describe("openai/shapes", () => {
|
||||||
@@ -121,6 +156,24 @@ describe("openai/shapes", () => {
|
|||||||
expect(ch.object).toBe("chat.completion.chunk");
|
expect(ch.object).toBe("chat.completion.chunk");
|
||||||
expect((ch.choices as any)[0].delta.content).toBe("tok");
|
expect((ch.choices as any)[0].delta.content).toBe("tok");
|
||||||
});
|
});
|
||||||
|
it("tool_calls chunk + finish_reason tool_calls", () => {
|
||||||
|
const tc = { index: 0, id: "call_1", type: "function" as const, function: { name: "f", arguments: "{}" } };
|
||||||
|
const ch = buildChunk({ id: "id", model: "m", delta: { tool_calls: [tc] }, finishReason: "tool_calls" });
|
||||||
|
expect((ch.choices as any)[0].delta.tool_calls).toEqual([tc]);
|
||||||
|
expect((ch.choices as any)[0].finish_reason).toBe("tool_calls");
|
||||||
|
});
|
||||||
|
it("completion with tool_calls and finish_reason", () => {
|
||||||
|
const cc = buildChatCompletion({
|
||||||
|
id: "id",
|
||||||
|
model: "m",
|
||||||
|
content: "",
|
||||||
|
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||||
|
toolCalls: [{ id: "call_1", type: "function", function: { name: "f", arguments: "{}" } }],
|
||||||
|
finishReason: "tool_calls",
|
||||||
|
});
|
||||||
|
expect((cc.choices as any)[0].message.tool_calls).toHaveLength(1);
|
||||||
|
expect((cc.choices as any)[0].finish_reason).toBe("tool_calls");
|
||||||
|
});
|
||||||
it("renderMessages folds roles", () => {
|
it("renderMessages folds roles", () => {
|
||||||
expect(renderMessages([{ role: "system", content: "be nice" }, { role: "user", content: "hi" }])).toBe("System: be nice\n\nUser: hi");
|
expect(renderMessages([{ role: "system", content: "be nice" }, { role: "user", content: "hi" }])).toBe("System: be nice\n\nUser: hi");
|
||||||
});
|
});
|
||||||
|
|||||||
88
packages/shared/src/tx/chatMessage.ts
Normal file
88
packages/shared/src/tx/chatMessage.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* tx/chatMessage.ts — canonical ChatMessage normalization + equivalence.
|
||||||
|
*
|
||||||
|
* The supplier's session transcript and the gateway's in-memory mirror must be
|
||||||
|
* hash-identical (receipt response_hash = sha256(canonicalize(transcript))),
|
||||||
|
* so both sides build messages through normalizeChatMessage: optional fields
|
||||||
|
* are truly absent when empty, content is never null, and vendor extras are
|
||||||
|
* stripped. chatMessagesEquivalent is the tolerant comparison used by the
|
||||||
|
* demo-endpoint transcript-prefix affinity matcher.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ChatMessage, ToolCall } from "./types.js";
|
||||||
|
|
||||||
|
const ROLES = new Set(["system", "user", "assistant", "tool"]);
|
||||||
|
|
||||||
|
function normalizeToolCall(raw: unknown): ToolCall | null {
|
||||||
|
if (typeof raw !== "object" || raw === null) return null;
|
||||||
|
const tc = raw as Record<string, unknown>;
|
||||||
|
const fn = tc.function as Record<string, unknown> | undefined;
|
||||||
|
if (typeof tc.id !== "string" || tc.id === "") return null;
|
||||||
|
if (tc.type !== "function") return null;
|
||||||
|
if (typeof fn !== "object" || fn === null) return null;
|
||||||
|
if (typeof fn.name !== "string" || fn.name === "") return null;
|
||||||
|
if (typeof fn.arguments !== "string") return null;
|
||||||
|
return { id: tc.id, type: "function", function: { name: fn.name, arguments: fn.arguments } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and canonicalize an untrusted message-shaped value.
|
||||||
|
* Returns null when the value is not a usable ChatMessage.
|
||||||
|
*/
|
||||||
|
export function normalizeChatMessage(raw: unknown): ChatMessage | null {
|
||||||
|
if (typeof raw !== "object" || raw === null) return null;
|
||||||
|
const m = raw as Record<string, unknown>;
|
||||||
|
if (typeof m.role !== "string" || !ROLES.has(m.role)) return null;
|
||||||
|
const role = m.role as ChatMessage["role"];
|
||||||
|
|
||||||
|
let content: string;
|
||||||
|
if (typeof m.content === "string") content = m.content;
|
||||||
|
else if (m.content === null || m.content === undefined) content = "";
|
||||||
|
else return null;
|
||||||
|
|
||||||
|
const msg: ChatMessage = { role, content };
|
||||||
|
|
||||||
|
if (m.tool_calls !== undefined && m.tool_calls !== null) {
|
||||||
|
if (role !== "assistant" || !Array.isArray(m.tool_calls)) return null;
|
||||||
|
const calls: ToolCall[] = [];
|
||||||
|
for (const raw of m.tool_calls) {
|
||||||
|
const tc = normalizeToolCall(raw);
|
||||||
|
if (!tc) return null;
|
||||||
|
calls.push(tc);
|
||||||
|
}
|
||||||
|
if (calls.length > 0) msg.tool_calls = calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m.tool_call_id !== undefined && m.tool_call_id !== null) {
|
||||||
|
if (role !== "tool" || typeof m.tool_call_id !== "string" || m.tool_call_id === "") return null;
|
||||||
|
msg.tool_call_id = m.tool_call_id;
|
||||||
|
}
|
||||||
|
if (role === "tool" && msg.tool_call_id === undefined) return null;
|
||||||
|
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolCallsEqual(a?: ToolCall[], b?: ToolCall[]): boolean {
|
||||||
|
const la = a ?? [];
|
||||||
|
const lb = b ?? [];
|
||||||
|
if (la.length !== lb.length) return false;
|
||||||
|
return la.every((tc, i) =>
|
||||||
|
tc.id === lb[i].id &&
|
||||||
|
tc.function.name === lb[i].function.name &&
|
||||||
|
tc.function.arguments === lb[i].function.arguments,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tolerant equality for affinity prefix matching. Both sides are expected to
|
||||||
|
* already be normalized; comparison ignores incidental key order and treats
|
||||||
|
* missing tool fields as empty.
|
||||||
|
*/
|
||||||
|
export function chatMessagesEquivalent(a: ChatMessage, b: ChatMessage): boolean {
|
||||||
|
return (
|
||||||
|
a.role === b.role &&
|
||||||
|
a.content === b.content &&
|
||||||
|
(a.tool_call_id ?? "") === (b.tool_call_id ?? "") &&
|
||||||
|
toolCallsEqual(a.tool_calls, b.tool_calls)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,8 +2,9 @@
|
|||||||
* tx/index.ts — public exports for M1-B tx builders.
|
* tx/index.ts — public exports for M1-B tx builders.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type { ChatMessage, WalletKey, BuildResult, PostAdvertBuildResult, PostEscrowBuildResult } from "./types.js";
|
export type { ChatMessage, ToolCall, WalletKey, BuildResult, PostAdvertBuildResult, PostEscrowBuildResult } from "./types.js";
|
||||||
export { TxConstructionError } from "./types.js";
|
export { TxConstructionError } from "./types.js";
|
||||||
|
export { normalizeChatMessage, chatMessagesEquivalent } from "./chatMessage.js";
|
||||||
export { mockSlotToWallclockMs, mockWallclockMsToSlot, NETWORK_BUFFER_MS } from "./internal/constants.js";
|
export { mockSlotToWallclockMs, mockWallclockMsToSlot, NETWORK_BUFFER_MS } from "./internal/constants.js";
|
||||||
export { detectCborBackend, type CborBackend } from "./internal/cborBackend.js";
|
export { detectCborBackend, type CborBackend } from "./internal/cborBackend.js";
|
||||||
export type { Signer } from "./signer.js";
|
export type { Signer } from "./signer.js";
|
||||||
|
|||||||
@@ -11,14 +11,29 @@
|
|||||||
|
|
||||||
import type { OutputReference } from "../chain/ChainProvider.js";
|
import type { OutputReference } from "../chain/ChainProvider.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ToolCall — OpenAI-compatible function tool call emitted by an assistant turn.
|
||||||
|
* `function.arguments` is the raw JSON string as produced by the model.
|
||||||
|
*/
|
||||||
|
export interface ToolCall {
|
||||||
|
id: string;
|
||||||
|
type: "function";
|
||||||
|
function: { name: string; arguments: string };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ChatMessage — OpenAI-compatible chat message.
|
* ChatMessage — OpenAI-compatible chat message.
|
||||||
* v1 supports user/system/assistant roles. The wire shape is intentionally
|
* `content` is always a string ("" for tool-call-only assistant messages —
|
||||||
* narrow; future capabilities (tools, multimodal) will extend or replace it.
|
* OpenAI's `content: null` is normalized on ingest). `tool_calls` /
|
||||||
|
* `tool_call_id` MUST be omitted (not set to undefined) when absent: the
|
||||||
|
* receipt hash canonicalizes the transcript objects, so field presence is
|
||||||
|
* part of the identity shared between supplier transcript and gateway mirror.
|
||||||
*/
|
*/
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
role: "system" | "user" | "assistant";
|
role: "system" | "user" | "assistant" | "tool";
|
||||||
content: string;
|
content: string;
|
||||||
|
tool_calls?: ToolCall[];
|
||||||
|
tool_call_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -92,14 +92,22 @@ export class ChatSessionStore {
|
|||||||
r.lastActivityMs = Date.now();
|
r.lastActivityMs = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Append a whole turn delta (e.g. user msg, or tool results) verbatim. */
|
||||||
|
appendMessages(escrowRef: string, messages: ChatMessage[]): void {
|
||||||
|
const r = this.records.get(escrowRef);
|
||||||
|
if (!r) return;
|
||||||
|
r.transcript.push(...messages);
|
||||||
|
r.lastActivityMs = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
appendAssistant(
|
appendAssistant(
|
||||||
escrowRef: string,
|
escrowRef: string,
|
||||||
content: string,
|
message: ChatMessage,
|
||||||
usage: { prompt_tokens: number; completion_tokens: number },
|
usage: { prompt_tokens: number; completion_tokens: number },
|
||||||
): void {
|
): void {
|
||||||
const r = this.records.get(escrowRef);
|
const r = this.records.get(escrowRef);
|
||||||
if (!r) return;
|
if (!r) return;
|
||||||
r.transcript.push({ role: "assistant", content });
|
r.transcript.push(message);
|
||||||
r.promptTokens += usage.prompt_tokens;
|
r.promptTokens += usage.prompt_tokens;
|
||||||
r.completionTokens += usage.completion_tokens;
|
r.completionTokens += usage.completion_tokens;
|
||||||
r.lastActivityMs = Date.now();
|
r.lastActivityMs = Date.now();
|
||||||
|
|||||||
@@ -26,13 +26,20 @@
|
|||||||
* - Empty-string content is treated as malformed (matches ollama.ts).
|
* - Empty-string content is treated as malformed (matches ollama.ts).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ChatMessage } from "@marketplace/shared/tx";
|
import type { ChatMessage, ToolCall } from "@marketplace/shared/tx";
|
||||||
|
|
||||||
export interface CallOpenAiParams {
|
export interface CallOpenAiParams {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
model: string;
|
model: string;
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
|
/**
|
||||||
|
* OpenAI-compatible `tools` / `tool_choice` passthrough (chat-session
|
||||||
|
* capability only). Forwarded verbatim when present; the upstream provider
|
||||||
|
* validates the schema.
|
||||||
|
*/
|
||||||
|
tools?: unknown[];
|
||||||
|
toolChoice?: unknown;
|
||||||
/**
|
/**
|
||||||
* Optional bearer token. When non-empty, an `authorization: Bearer <apiKey>`
|
* Optional bearer token. When non-empty, an `authorization: Bearer <apiKey>`
|
||||||
* header is sent. Omit (or pass "") for endpoints that don't require auth,
|
* header is sent. Omit (or pass "") for endpoints that don't require auth,
|
||||||
@@ -60,6 +67,10 @@ export interface OpenAiResult {
|
|||||||
prompt_tokens: number;
|
prompt_tokens: number;
|
||||||
completion_tokens: number;
|
completion_tokens: number;
|
||||||
wallclock_ms: number;
|
wallclock_ms: number;
|
||||||
|
/** Present when the model requested tool calls (streaming path only). */
|
||||||
|
tool_calls?: ToolCall[];
|
||||||
|
/** Upstream finish_reason when reported (e.g. "stop", "tool_calls"). */
|
||||||
|
finish_reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OpenAiErrorReason = "openai_failure" | "openai_timeout" | "openai_malformed";
|
export type OpenAiErrorReason = "openai_failure" | "openai_timeout" | "openai_malformed";
|
||||||
@@ -204,7 +215,7 @@ export async function callOpenAiStream(
|
|||||||
params: CallOpenAiParams,
|
params: CallOpenAiParams,
|
||||||
onToken: (delta: string) => void,
|
onToken: (delta: string) => void,
|
||||||
): Promise<OpenAiResult> {
|
): Promise<OpenAiResult> {
|
||||||
const { baseUrl, model, messages, timeoutMs, apiKey, maxTokens, disableReasoning } = params;
|
const { baseUrl, model, messages, timeoutMs, apiKey, maxTokens, disableReasoning, tools, toolChoice } = params;
|
||||||
const url = `${baseUrl}/v1/chat/completions`;
|
const url = `${baseUrl}/v1/chat/completions`;
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
model,
|
model,
|
||||||
@@ -214,6 +225,10 @@ export async function callOpenAiStream(
|
|||||||
};
|
};
|
||||||
if (typeof maxTokens === "number" && maxTokens > 0) payload.max_tokens = maxTokens;
|
if (typeof maxTokens === "number" && maxTokens > 0) payload.max_tokens = maxTokens;
|
||||||
if (disableReasoning) payload.reasoning = { enabled: false };
|
if (disableReasoning) payload.reasoning = { enabled: false };
|
||||||
|
if (Array.isArray(tools) && tools.length > 0) {
|
||||||
|
payload.tools = tools;
|
||||||
|
if (toolChoice !== undefined) payload.tool_choice = toolChoice;
|
||||||
|
}
|
||||||
const body = JSON.stringify(payload);
|
const body = JSON.stringify(payload);
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
@@ -268,7 +283,28 @@ export async function callOpenAiStream(
|
|||||||
let accumulated = "";
|
let accumulated = "";
|
||||||
let promptTokens = 0;
|
let promptTokens = 0;
|
||||||
let completionTokens = 0;
|
let completionTokens = 0;
|
||||||
|
let finishReason: string | undefined;
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
|
// delta.tool_calls arrives as indexed fragments (id/name once, arguments in
|
||||||
|
// pieces) — accumulate per index, emit complete calls after the stream ends.
|
||||||
|
const toolCallParts = new Map<number, { id: string; name: string; arguments: string }>();
|
||||||
|
|
||||||
|
const handleToolCallDeltas = (raw: unknown): void => {
|
||||||
|
if (!Array.isArray(raw)) return;
|
||||||
|
for (const item of raw) {
|
||||||
|
if (!item || typeof item !== "object") continue;
|
||||||
|
const frag = item as Record<string, unknown>;
|
||||||
|
const index = typeof frag.index === "number" ? frag.index : 0;
|
||||||
|
const part = toolCallParts.get(index) ?? { id: "", name: "", arguments: "" };
|
||||||
|
if (typeof frag.id === "string" && frag.id.length > 0) part.id = frag.id;
|
||||||
|
const fn = frag.function as Record<string, unknown> | undefined;
|
||||||
|
if (fn && typeof fn === "object") {
|
||||||
|
if (typeof fn.name === "string" && fn.name.length > 0) part.name = fn.name;
|
||||||
|
if (typeof fn.arguments === "string") part.arguments += fn.arguments;
|
||||||
|
}
|
||||||
|
toolCallParts.set(index, part);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleData = (payload: string): void => {
|
const handleData = (payload: string): void => {
|
||||||
const trimmed = payload.trim();
|
const trimmed = payload.trim();
|
||||||
@@ -284,14 +320,15 @@ export async function callOpenAiStream(
|
|||||||
const obj = parsed as Record<string, unknown>;
|
const obj = parsed as Record<string, unknown>;
|
||||||
const choices = obj.choices;
|
const choices = obj.choices;
|
||||||
if (Array.isArray(choices) && choices.length > 0) {
|
if (Array.isArray(choices) && choices.length > 0) {
|
||||||
const delta = (choices[0] as Record<string, unknown>)?.delta as
|
const choice = choices[0] as Record<string, unknown>;
|
||||||
| Record<string, unknown>
|
const delta = choice?.delta as Record<string, unknown> | undefined;
|
||||||
| undefined;
|
|
||||||
const content = delta?.content;
|
const content = delta?.content;
|
||||||
if (typeof content === "string" && content.length > 0) {
|
if (typeof content === "string" && content.length > 0) {
|
||||||
accumulated += content;
|
accumulated += content;
|
||||||
onToken(content);
|
onToken(content);
|
||||||
}
|
}
|
||||||
|
handleToolCallDeltas(delta?.tool_calls);
|
||||||
|
if (typeof choice?.finish_reason === "string") finishReason = choice.finish_reason;
|
||||||
}
|
}
|
||||||
const usageRaw = obj.usage;
|
const usageRaw = obj.usage;
|
||||||
if (usageRaw && typeof usageRaw === "object") {
|
if (usageRaw && typeof usageRaw === "object") {
|
||||||
@@ -336,14 +373,24 @@ export async function callOpenAiStream(
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accumulated.length === 0) {
|
const toolCalls: ToolCall[] = [...toolCallParts.entries()]
|
||||||
|
.sort(([a], [b]) => a - b)
|
||||||
|
.filter(([, p]) => p.id.length > 0 && p.name.length > 0)
|
||||||
|
.map(([, p]) => ({ id: p.id, type: "function" as const, function: { name: p.name, arguments: p.arguments } }));
|
||||||
|
|
||||||
|
// A pure tool-call turn legitimately has no content; only an empty stream
|
||||||
|
// with neither content nor tool calls is malformed.
|
||||||
|
if (accumulated.length === 0 && toolCalls.length === 0) {
|
||||||
throw new OpenAiError("openai_malformed", "OpenAI stream produced no content");
|
throw new OpenAiError("openai_malformed", "OpenAI stream produced no content");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const result: OpenAiResult = {
|
||||||
content: accumulated,
|
content: accumulated,
|
||||||
prompt_tokens: promptTokens,
|
prompt_tokens: promptTokens,
|
||||||
completion_tokens: completionTokens,
|
completion_tokens: completionTokens,
|
||||||
wallclock_ms: Date.now() - startedAt,
|
wallclock_ms: Date.now() - startedAt,
|
||||||
};
|
};
|
||||||
|
if (toolCalls.length > 0) result.tool_calls = toolCalls;
|
||||||
|
if (finishReason !== undefined) result.finish_reason = finishReason;
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,8 +37,14 @@ import { createHash } from "crypto";
|
|||||||
import type { ChainProvider, OutputReference } from "@marketplace/shared/chain";
|
import type { ChainProvider, OutputReference } from "@marketplace/shared/chain";
|
||||||
import { decodeAdvertDatum, decodeEscrowDatum, canonicalize } from "@marketplace/shared/cbor";
|
import { decodeAdvertDatum, decodeEscrowDatum, canonicalize } from "@marketplace/shared/cbor";
|
||||||
import type { AdvertDatum, EscrowDatum } from "@marketplace/shared/cbor";
|
import type { AdvertDatum, EscrowDatum } from "@marketplace/shared/cbor";
|
||||||
import type { WalletKey } from "@marketplace/shared/tx";
|
import type { ChatMessage, WalletKey } from "@marketplace/shared/tx";
|
||||||
import { buildClaimTx, chatSessionPromptHash, mockSlotToWallclockMs, detectCborBackend } from "@marketplace/shared/tx";
|
import {
|
||||||
|
buildClaimTx,
|
||||||
|
chatSessionPromptHash,
|
||||||
|
mockSlotToWallclockMs,
|
||||||
|
detectCborBackend,
|
||||||
|
normalizeChatMessage,
|
||||||
|
} from "@marketplace/shared/tx";
|
||||||
|
|
||||||
import type { SupplierState } from "./state.js";
|
import type { SupplierState } from "./state.js";
|
||||||
import type { SupplierConfig } from "./config.js";
|
import type { SupplierConfig } from "./config.js";
|
||||||
@@ -825,16 +831,46 @@ function makeChatSessionHandlers(deps: ResolvedDeps) {
|
|||||||
return jsonError(res, 404, "chat_session_not_found",
|
return jsonError(res, 404, "chat_session_not_found",
|
||||||
`no active chat session for ${escrowRefStr}`);
|
`no active chat session for ${escrowRefStr}`);
|
||||||
}
|
}
|
||||||
const body = (req.body ?? {}) as { content?: unknown };
|
// Two body shapes: legacy {content} (single user turn) and
|
||||||
const content = typeof body.content === "string" ? body.content : "";
|
// {messages, tools?, tool_choice?} (multi-message delta — e.g. tool
|
||||||
if (content.length === 0) {
|
// results — appended to the transcript as one turn).
|
||||||
return jsonError(res, 400, "content_required", "body.content must be a non-empty string");
|
const body = (req.body ?? {}) as {
|
||||||
|
content?: unknown;
|
||||||
|
messages?: unknown;
|
||||||
|
tools?: unknown;
|
||||||
|
tool_choice?: unknown;
|
||||||
|
};
|
||||||
|
let delta: ChatMessage[];
|
||||||
|
if (Array.isArray(body.messages)) {
|
||||||
|
const normalized: ChatMessage[] = [];
|
||||||
|
for (const raw of body.messages) {
|
||||||
|
const msg = normalizeChatMessage(raw);
|
||||||
|
if (!msg) {
|
||||||
|
return jsonError(res, 400, "invalid_messages",
|
||||||
|
"body.messages must be OpenAI-shaped {role, content, tool_calls?, tool_call_id?} objects");
|
||||||
|
}
|
||||||
|
normalized.push(msg);
|
||||||
|
}
|
||||||
|
const last = normalized[normalized.length - 1];
|
||||||
|
if (!last || (last.role !== "user" && last.role !== "tool")) {
|
||||||
|
return jsonError(res, 400, "invalid_messages",
|
||||||
|
"body.messages must end with a user or tool message");
|
||||||
|
}
|
||||||
|
delta = normalized;
|
||||||
|
} else {
|
||||||
|
const content = typeof body.content === "string" ? body.content : "";
|
||||||
|
if (content.length === 0) {
|
||||||
|
return jsonError(res, 400, "content_required", "body.content must be a non-empty string");
|
||||||
|
}
|
||||||
|
delta = [{ role: "user", content }];
|
||||||
}
|
}
|
||||||
|
const tools = Array.isArray(body.tools) && body.tools.length > 0 ? body.tools : undefined;
|
||||||
|
const toolChoice = tools !== undefined ? body.tool_choice : undefined;
|
||||||
|
|
||||||
// Pause the idle timer while a turn is in flight; re-arm when it ends so
|
// Pause the idle timer while a turn is in flight; re-arm when it ends so
|
||||||
// a long generation never trips the auto-end mid-stream.
|
// a long generation never trips the auto-end mid-stream.
|
||||||
if (record.idleTimer) { clearTimeout(record.idleTimer); record.idleTimer = undefined; }
|
if (record.idleTimer) { clearTimeout(record.idleTimer); record.idleTimer = undefined; }
|
||||||
deps.chatSessions.appendUser(escrowRefStr, content);
|
deps.chatSessions.appendMessages(escrowRefStr, delta);
|
||||||
|
|
||||||
res.status(200);
|
res.status(200);
|
||||||
res.setHeader("Content-Type", "text/event-stream");
|
res.setHeader("Content-Type", "text/event-stream");
|
||||||
@@ -858,14 +894,30 @@ function makeChatSessionHandlers(deps: ResolvedDeps) {
|
|||||||
apiKey: deps.config.openaiApiKey,
|
apiKey: deps.config.openaiApiKey,
|
||||||
maxTokens: deps.config.openaiMaxTokens,
|
maxTokens: deps.config.openaiMaxTokens,
|
||||||
disableReasoning: deps.config.openaiReasoningDisabled,
|
disableReasoning: deps.config.openaiReasoningDisabled,
|
||||||
|
tools,
|
||||||
|
toolChoice,
|
||||||
},
|
},
|
||||||
(delta) => sse({ type: "token", value: delta }),
|
(tok) => sse({ type: "token", value: tok }),
|
||||||
);
|
);
|
||||||
deps.chatSessions.appendAssistant(escrowRefStr, result.content, {
|
// Build the assistant message once and send it VERBATIM in the done
|
||||||
|
// frame: the gateway mirrors this object so both transcripts stay
|
||||||
|
// hash-identical for the receipt (field presence matters).
|
||||||
|
const assistantMsg: ChatMessage = { role: "assistant", content: result.content };
|
||||||
|
if (result.tool_calls && result.tool_calls.length > 0) assistantMsg.tool_calls = result.tool_calls;
|
||||||
|
deps.chatSessions.appendAssistant(escrowRefStr, assistantMsg, {
|
||||||
prompt_tokens: result.prompt_tokens,
|
prompt_tokens: result.prompt_tokens,
|
||||||
completion_tokens: result.completion_tokens,
|
completion_tokens: result.completion_tokens,
|
||||||
});
|
});
|
||||||
sse({ type: "done" });
|
const finishReason =
|
||||||
|
result.finish_reason === "tool_calls" || (result.tool_calls?.length ?? 0) > 0
|
||||||
|
? "tool_calls"
|
||||||
|
: "stop";
|
||||||
|
sse({
|
||||||
|
type: "done",
|
||||||
|
message: assistantMsg,
|
||||||
|
finish_reason: finishReason,
|
||||||
|
usage: { prompt_tokens: result.prompt_tokens, completion_tokens: result.completion_tokens },
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errMsg = err instanceof Error ? err.message : String(err);
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
sse({ type: "error", message: errMsg });
|
sse({ type: "error", message: errMsg });
|
||||||
|
|||||||
96
tests/unit/shared-chat-message.test.ts
Normal file
96
tests/unit/shared-chat-message.test.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* shared-chat-message.test.ts — normalizeChatMessage / chatMessagesEquivalent.
|
||||||
|
*
|
||||||
|
* The supplier transcript and the gateway mirror must canonicalize to the SAME
|
||||||
|
* string (receipt response_hash = sha256(canonicalize(transcript))), so the
|
||||||
|
* normalizer's field-presence behavior is load-bearing: absent optional fields
|
||||||
|
* must be truly absent, content is never null, vendor extras are stripped.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { normalizeChatMessage, chatMessagesEquivalent } from "../../packages/shared/src/tx/chatMessage.js";
|
||||||
|
import { canonicalize } from "../../packages/shared/src/cbor/canonical.js";
|
||||||
|
|
||||||
|
const TOOL_CALL = { id: "call_1", type: "function", function: { name: "get_time", arguments: "{}" } };
|
||||||
|
|
||||||
|
describe("normalizeChatMessage", () => {
|
||||||
|
it("passes through plain user/system/assistant messages", () => {
|
||||||
|
expect(normalizeChatMessage({ role: "user", content: "hi" })).toEqual({ role: "user", content: "hi" });
|
||||||
|
expect(normalizeChatMessage({ role: "system", content: "be nice" })).toEqual({ role: "system", content: "be nice" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes null/undefined content to empty string", () => {
|
||||||
|
expect(normalizeChatMessage({ role: "assistant", content: null, tool_calls: [TOOL_CALL] })).toEqual({
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
tool_calls: [TOOL_CALL],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps optional fields ABSENT when not provided (hash identity)", () => {
|
||||||
|
const msg = normalizeChatMessage({ role: "assistant", content: "x" })!;
|
||||||
|
expect("tool_calls" in msg).toBe(false);
|
||||||
|
expect("tool_call_id" in msg).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips vendor extras from tool calls", () => {
|
||||||
|
const msg = normalizeChatMessage({
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
tool_calls: [{ ...TOOL_CALL, extra_vendor_field: 42, function: { ...TOOL_CALL.function, parsed: {} } }],
|
||||||
|
})!;
|
||||||
|
expect(msg.tool_calls).toEqual([TOOL_CALL]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires tool role to carry tool_call_id", () => {
|
||||||
|
expect(normalizeChatMessage({ role: "tool", content: "result" })).toBeNull();
|
||||||
|
expect(normalizeChatMessage({ role: "tool", content: "result", tool_call_id: "call_1" })).toEqual({
|
||||||
|
role: "tool",
|
||||||
|
content: "result",
|
||||||
|
tool_call_id: "call_1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects tool_calls on non-assistant roles and malformed shapes", () => {
|
||||||
|
expect(normalizeChatMessage({ role: "user", content: "x", tool_calls: [TOOL_CALL] })).toBeNull();
|
||||||
|
expect(normalizeChatMessage({ role: "assistant", content: "", tool_calls: [{ id: "", type: "function", function: { name: "f", arguments: "" } }] })).toBeNull();
|
||||||
|
expect(normalizeChatMessage({ role: "bogus", content: "x" })).toBeNull();
|
||||||
|
expect(normalizeChatMessage("nope")).toBeNull();
|
||||||
|
expect(normalizeChatMessage({ role: "user", content: 42 })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("canonicalizes identically regardless of input key order (mirror vs transcript)", () => {
|
||||||
|
const a = normalizeChatMessage({ role: "assistant", content: "", tool_calls: [TOOL_CALL] })!;
|
||||||
|
const b = normalizeChatMessage({
|
||||||
|
tool_calls: [{ function: { arguments: "{}", name: "get_time" }, type: "function", id: "call_1" }],
|
||||||
|
content: null,
|
||||||
|
role: "assistant",
|
||||||
|
})!;
|
||||||
|
expect(canonicalize([a])).toBe(canonicalize([b]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("chatMessagesEquivalent", () => {
|
||||||
|
it("matches normalized equals and rejects differences", () => {
|
||||||
|
const a = normalizeChatMessage({ role: "assistant", content: "", tool_calls: [TOOL_CALL] })!;
|
||||||
|
const b = normalizeChatMessage({ role: "assistant", content: null, tool_calls: [TOOL_CALL] })!;
|
||||||
|
expect(chatMessagesEquivalent(a, b)).toBe(true);
|
||||||
|
|
||||||
|
const c = normalizeChatMessage({ role: "assistant", content: "hi" })!;
|
||||||
|
expect(chatMessagesEquivalent(a, c)).toBe(false);
|
||||||
|
const d = normalizeChatMessage({
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
tool_calls: [{ ...TOOL_CALL, function: { name: "get_time", arguments: '{"tz":"utc"}' } }],
|
||||||
|
})!;
|
||||||
|
expect(chatMessagesEquivalent(a, d)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compares tool results by tool_call_id", () => {
|
||||||
|
const a = normalizeChatMessage({ role: "tool", content: "3pm", tool_call_id: "call_1" })!;
|
||||||
|
const b = normalizeChatMessage({ role: "tool", content: "3pm", tool_call_id: "call_1" })!;
|
||||||
|
const c = normalizeChatMessage({ role: "tool", content: "3pm", tool_call_id: "call_2" })!;
|
||||||
|
expect(chatMessagesEquivalent(a, b)).toBe(true);
|
||||||
|
expect(chatMessagesEquivalent(a, c)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
103
tests/unit/supplier-openai-stream.test.ts
Normal file
103
tests/unit/supplier-openai-stream.test.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* supplier-openai-stream.test.ts — callOpenAiStream tools passthrough.
|
||||||
|
*
|
||||||
|
* Mocks global.fetch with SSE-shaped Response bodies. Covers:
|
||||||
|
* - tools/tool_choice included in the upstream payload only when provided
|
||||||
|
* - fragmented delta.tool_calls accumulation across frames
|
||||||
|
* - pure tool-call turns (empty content) no longer throw openai_malformed
|
||||||
|
* - empty content AND no tool calls still throws
|
||||||
|
* - finish_reason capture
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { callOpenAiStream, OpenAiError } from "../../supplier/src/openai.js";
|
||||||
|
|
||||||
|
const BASE = { baseUrl: "http://up", model: "kimi", messages: [{ role: "user" as const, content: "hi" }], timeoutMs: 5_000 };
|
||||||
|
const TOOLS = [{ type: "function", function: { name: "get_time", parameters: {} } }];
|
||||||
|
|
||||||
|
function sse(frames: unknown[]): string {
|
||||||
|
return frames.map((f) => `data: ${typeof f === "string" ? f : JSON.stringify(f)}\n\n`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStreamFetch(body: string) {
|
||||||
|
return vi.fn().mockResolvedValue(new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("callOpenAiStream — tools passthrough", () => {
|
||||||
|
const CONTENT_FRAMES = sse([
|
||||||
|
{ choices: [{ delta: { content: "Hello" }, finish_reason: null }] },
|
||||||
|
{ choices: [{ delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 3, completion_tokens: 2 } },
|
||||||
|
"[DONE]",
|
||||||
|
]);
|
||||||
|
|
||||||
|
it("includes tools + tool_choice in the payload when provided", async () => {
|
||||||
|
const fetchFn = makeStreamFetch(CONTENT_FRAMES);
|
||||||
|
vi.stubGlobal("fetch", fetchFn);
|
||||||
|
await callOpenAiStream({ ...BASE, tools: TOOLS, toolChoice: "auto" }, () => {});
|
||||||
|
const body = JSON.parse(fetchFn.mock.calls[0][1].body as string);
|
||||||
|
expect(body.tools).toEqual(TOOLS);
|
||||||
|
expect(body.tool_choice).toBe("auto");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits tools from the payload when not provided", async () => {
|
||||||
|
const fetchFn = makeStreamFetch(CONTENT_FRAMES);
|
||||||
|
vi.stubGlobal("fetch", fetchFn);
|
||||||
|
await callOpenAiStream(BASE, () => {});
|
||||||
|
const body = JSON.parse(fetchFn.mock.calls[0][1].body as string);
|
||||||
|
expect("tools" in body).toBe(false);
|
||||||
|
expect("tool_choice" in body).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates fragmented delta.tool_calls across frames", async () => {
|
||||||
|
const frames = sse([
|
||||||
|
{ choices: [{ delta: { tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: "get_time", arguments: "" } }] }, finish_reason: null }] },
|
||||||
|
{ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '{"tz":' } }] }, finish_reason: null }] },
|
||||||
|
{ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '"utc"}' } }] }, finish_reason: null }] },
|
||||||
|
{ choices: [{ delta: {}, finish_reason: "tool_calls" }], usage: { prompt_tokens: 7, completion_tokens: 5 } },
|
||||||
|
"[DONE]",
|
||||||
|
]);
|
||||||
|
vi.stubGlobal("fetch", makeStreamFetch(frames));
|
||||||
|
const result = await callOpenAiStream({ ...BASE, tools: TOOLS }, () => {});
|
||||||
|
expect(result.tool_calls).toEqual([
|
||||||
|
{ id: "call_1", type: "function", function: { name: "get_time", arguments: '{"tz":"utc"}' } },
|
||||||
|
]);
|
||||||
|
expect(result.finish_reason).toBe("tool_calls");
|
||||||
|
expect(result.content).toBe("");
|
||||||
|
expect(result.prompt_tokens).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders multiple tool calls by index", async () => {
|
||||||
|
const frames = sse([
|
||||||
|
{ choices: [{ delta: { tool_calls: [
|
||||||
|
{ index: 1, id: "call_b", type: "function", function: { name: "b", arguments: "{}" } },
|
||||||
|
{ index: 0, id: "call_a", type: "function", function: { name: "a", arguments: "{}" } },
|
||||||
|
] }, finish_reason: null }] },
|
||||||
|
{ choices: [{ delta: {}, finish_reason: "tool_calls" }] },
|
||||||
|
"[DONE]",
|
||||||
|
]);
|
||||||
|
vi.stubGlobal("fetch", makeStreamFetch(frames));
|
||||||
|
const result = await callOpenAiStream({ ...BASE, tools: TOOLS }, () => {});
|
||||||
|
expect(result.tool_calls?.map((t) => t.id)).toEqual(["call_a", "call_b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still throws openai_malformed when content AND tool calls are both empty", async () => {
|
||||||
|
const frames = sse([{ choices: [{ delta: {}, finish_reason: "stop" }] }, "[DONE]"]);
|
||||||
|
vi.stubGlobal("fetch", makeStreamFetch(frames));
|
||||||
|
await expect(callOpenAiStream(BASE, () => {})).rejects.toMatchObject({ reason: "openai_malformed" });
|
||||||
|
await expect(callOpenAiStream(BASE, () => {})).rejects.toBeInstanceOf(OpenAiError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still streams plain content unchanged (no tools)", async () => {
|
||||||
|
vi.stubGlobal("fetch", makeStreamFetch(CONTENT_FRAMES));
|
||||||
|
const tokens: string[] = [];
|
||||||
|
const result = await callOpenAiStream(BASE, (t) => tokens.push(t));
|
||||||
|
expect(tokens).toEqual(["Hello"]);
|
||||||
|
expect(result.content).toBe("Hello");
|
||||||
|
expect(result.tool_calls).toBeUndefined();
|
||||||
|
expect(result.finish_reason).toBe("stop");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user