supplier+deploy/mainnet: LLM_BACKEND=openai via ChatMock proxy

Adds a Codex/ChatGPT backend path for the supplier alongside the
existing Ollama one. The mainnet supplier is repointed at a localhost
ChatMock proxy (RayBytes/ChatMock, pinned :v1.35) that authenticates
against a ChatGPT Pro subscription via OAuth.

- supplier/src/openai.ts: HTTP client for OpenAI-compatible
  /v1/chat/completions. Same return shape as ollama.callOllama so
  jobRunner can branch cleanly.
- supplier/src/jobRunner.ts: branch on config.llmBackend.
- supplier/src/config.ts: LLM_BACKEND ("ollama" default | "openai"),
  OPENAI_BASE_URL, OPENAI_TIMEOUT_MS (default 180_000 — GPT first-token
  latency).
- supplier/src/cli/gen-keypair.ts: standalone Ed25519 keypair generator
  + bech32 address derivation. Used to bootstrap a fresh supplier
  wallet without round-tripping through tx:post-advert --dry-run.
- deploy/mainnet/docker-compose.chatmock.yml: long-running chatmock
  serve + one-shot --profile login flow.
- deploy/mainnet/docker-compose.supplier.yml: rewritten for openai
  backend; Traefik routes at mp-suppliers-gpt.vector.apexfusion.org so
  the unsuffixed slot stays free for a future supplier with a
  different backend.
- tests/unit/supplier-openai.test.ts: unit coverage for callOpenAi.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
david
2026-05-19 13:47:44 +02:00
parent 96d9d1d354
commit 965fabd3f0
8 changed files with 731 additions and 27 deletions

View File

@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"tx:gen-keypair": "tsx src/cli/gen-keypair.ts",
"tx:post-advert": "tsx src/cli/post-advert.ts",
"tx:retire-advert": "tsx src/cli/retire-advert.ts",
"tx:publish-reference-scripts": "tsx src/cli/publish-reference-scripts.ts"

View File

@@ -0,0 +1,147 @@
/**
* gen-keypair.ts — CLI entry point for `pnpm --filter @marketplace/supplier tx:gen-keypair`.
*
* Generates a fresh Ed25519 keypair and derives the corresponding Cardano
* enterprise address. Useful for bootstrapping a new supplier wallet without
* the friction of `openssl rand -hex 32` + `tx:post-advert --dry-run` to
* read back the derived address.
*
* Usage:
* pnpm --filter @marketplace/supplier tx:gen-keypair --network 1
*
* Args:
* --network <0|1> testnet (0, default) or mainnet (1)
* --priv-key <hex> use the provided 64-hex private key instead of generating
*
* Output (stdout, JSON, suitable for piping into jq):
* {
* "privateKeyHex": "<64 hex>",
* "publicKeyHex": "<64 hex>",
* "pubKeyHash": "<56 hex>", // blake2b-224 of pubKey
* "address": "addr1..." | "addr_test1..."
* }
*
* Wallet derivation mirrors post-advert.ts:359-378 so the address produced
* here matches what the supplier itself will derive at boot.
*/
import { createHash, randomBytes } from "crypto";
import * as ed from "@noble/ed25519";
import { blake2b } from "@noble/hashes/blake2b";
import { bech32 } from "bech32";
// Wire ed25519 sha512 hook (idempotent — matches post-advert.ts).
ed.etc.sha512Sync = (...messages: Uint8Array[]): Uint8Array => {
const h = createHash("sha512");
for (const m of messages) h.update(m);
return new Uint8Array(h.digest());
};
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
export type NetworkId = 0 | 1;
export interface DerivedKeypair {
privateKeyHex: string;
publicKeyHex: string;
pubKeyHash: string;
address: string;
}
function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return bytes;
}
function bytesToHex(b: Uint8Array): string {
let out = "";
for (let i = 0; i < b.length; i++) out += b[i].toString(16).padStart(2, "0");
return out;
}
export function deriveKeypair(privHex: string, networkId: NetworkId): DerivedKeypair {
if (!HEX64_RE.test(privHex)) {
throw new Error("priv-key must be 64 hex chars (32 bytes)");
}
const priv = hexToBytes(privHex);
const pub = ed.getPublicKey(priv);
const pubHex = bytesToHex(pub);
const pkh = blake2b(pub, { dkLen: 28 });
const pkhHex = bytesToHex(pkh);
const header = networkId === 0 ? 0x60 : 0x61;
const payload = new Uint8Array(29);
payload[0] = header;
payload.set(pkh, 1);
const words = bech32.toWords(payload);
const hrp = networkId === 0 ? "addr_test" : "addr";
const address = bech32.encode(hrp, words, 1023);
return {
privateKeyHex: privHex.toLowerCase(),
publicKeyHex: pubHex,
pubKeyHash: pkhHex,
address,
};
}
export interface CliArgs {
networkId: NetworkId;
privKeyHex: string | null;
}
export function parseCliArgs(argv: string[]): CliArgs {
let networkId: NetworkId = 0;
let privKeyHex: string | null = null;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--network") {
const v = argv[++i];
if (v !== "0" && v !== "1") throw new Error('--network must be "0" or "1"');
networkId = v === "1" ? 1 : 0;
} else if (arg === "--priv-key") {
const v = argv[++i];
if (!v || !HEX64_RE.test(v)) throw new Error("--priv-key must be 64 hex chars (32 bytes)");
privKeyHex = v.toLowerCase();
} else if (arg === "--help" || arg === "-h") {
throw new Error(
"usage: tx:gen-keypair [--network 0|1] [--priv-key <64-hex>]",
);
} else {
throw new Error(`unknown flag: ${arg}`);
}
}
return { networkId, privKeyHex };
}
export function main(argv: string[]): number {
let args: CliArgs;
try {
args = parseCliArgs(argv);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`error: ${msg}\n`);
return 1;
}
const privHex = args.privKeyHex ?? bytesToHex(new Uint8Array(randomBytes(32)));
let derived: DerivedKeypair;
try {
derived = deriveKeypair(privHex, args.networkId);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`error: ${msg}\n`);
return 1;
}
process.stdout.write(JSON.stringify(derived, null, 2) + "\n");
return 0;
}
// Run when invoked directly (tsx supplier/src/cli/gen-keypair.ts ...)
const invoked = process.argv[1] ?? "";
if (invoked.endsWith("gen-keypair.ts") || invoked.endsWith("gen-keypair.js")) {
const code = main(process.argv.slice(2));
process.exit(code);
}

View File

@@ -10,9 +10,13 @@
* ADVERT_REF — "<64hex>#<int>" OutputReference to the supplier's advert UTxO
* NETWORK_ID — "0" (testnet) or "1" (mainnet)
*
* Required for CAPABILITY_KIND="chat" (default):
* Required for CAPABILITY_KIND="chat" (default) + LLM_BACKEND="ollama" (default):
* OLLAMA_URL — http:// URL for local Ollama
*
* Required for CAPABILITY_KIND="chat" + LLM_BACKEND="openai":
* OPENAI_BASE_URL — http(s):// URL for an OpenAI-compatible /v1/chat/completions
* host (e.g. ChatMock localhost proxy backed by Codex OAuth).
*
* Required for CAPABILITY_KIND="tts":
* PIPER_URL — http(s):// URL for the openedai-speech-min PiperTTS host
*
@@ -22,8 +26,13 @@
* The route handler still validates the on-chain
* advert's capability_id; this env just decides
* which dispatch lives in the process.
* LLM_BACKEND — "ollama" (default) or "openai". Selects which HTTP
* client the chat job runner uses. Only meaningful
* when CAPABILITY_KIND="chat".
* PORT — listen port (default 8080)
* OLLAMA_TIMEOUT_MS — ollama request timeout in ms (default 120_000)
* OPENAI_TIMEOUT_MS — openai request timeout in ms (default 180_000 — GPT
* first-token latency is longer than local Ollama)
* PIPER_TIMEOUT_MS — piper request timeout in ms (default 120_000)
* LIVE_CHAIN — "1" opts in to live-chain submit/await (default off).
* Any other value (including "true", "yes", "TRUE")
@@ -46,19 +55,26 @@ export interface AdvertRef {
/** Capability the supplier is configured to serve. */
export type CapabilityKind = "chat" | "tts";
/** LLM backend selected for the chat capability. */
export type LlmBackend = "ollama" | "openai";
export interface SupplierConfig {
supplierPrivKeyHex: string;
ogmiosUrl: string;
/** Empty string when capabilityKind="tts". */
/** Empty string when capabilityKind="tts" or llmBackend="openai". */
ollamaUrl: string;
/** Empty string when capabilityKind="tts" or llmBackend="ollama". */
openaiBaseUrl: string;
/** Empty string when capabilityKind="chat". */
piperUrl: string;
advertRef: AdvertRef;
networkId: 0 | 1;
port: number;
ollamaTimeoutMs: number;
openaiTimeoutMs: number;
piperTimeoutMs: number;
capabilityKind: CapabilityKind;
llmBackend: LlmBackend;
/**
* When true, the supplier boots a LiveOgmiosProvider (real submitTx/awaitTx).
* Default false → ReadOnlyOgmiosProvider (safe; no chain writes).
@@ -110,18 +126,35 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
}
const capabilityKind: CapabilityKind = capKindStr;
// LLM_BACKEND selects which HTTP client the chat job runner uses. Only
// meaningful for capabilityKind="chat"; ignored when capabilityKind="tts".
// Default "ollama" so existing chat suppliers keep booting unchanged.
const llmBackendStr = env.LLM_BACKEND ?? "ollama";
if (llmBackendStr !== "ollama" && llmBackendStr !== "openai") {
throw new Error('loadConfig: LLM_BACKEND must be "ollama" or "openai"');
}
const llmBackend: LlmBackend = llmBackendStr;
// Per-capability upstream URL — the OTHER capability's URL becomes
// optional/unused. The route handler also validates the on-chain advert's
// capability_id, so a misconfiguration here can't trick a TTS supplier into
// serving chat traffic; this gate just keeps the process boot honest.
let ollamaUrl = "";
let openaiBaseUrl = "";
let piperUrl = "";
if (capabilityKind === "chat") {
ollamaUrl = requireField(env, "OLLAMA_URL");
if (llmBackend === "openai") {
openaiBaseUrl = requireField(env, "OPENAI_BASE_URL");
ollamaUrl = env.OLLAMA_URL ?? "";
} else {
ollamaUrl = requireField(env, "OLLAMA_URL");
openaiBaseUrl = env.OPENAI_BASE_URL ?? "";
}
piperUrl = env.PIPER_URL ?? "";
} else {
piperUrl = requireField(env, "PIPER_URL");
ollamaUrl = env.OLLAMA_URL ?? "";
openaiBaseUrl = env.OPENAI_BASE_URL ?? "";
}
const advertRefStr = requireField(env, "ADVERT_REF");
@@ -151,6 +184,15 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
ollamaTimeoutMs = Number(timeoutStr);
}
const openaiTimeoutStr = env.OPENAI_TIMEOUT_MS;
let openaiTimeoutMs = 180_000;
if (openaiTimeoutStr !== undefined && openaiTimeoutStr !== "") {
if (!POS_INT_RE.test(openaiTimeoutStr)) {
throw new Error("loadConfig: OPENAI_TIMEOUT_MS must be a positive integer");
}
openaiTimeoutMs = Number(openaiTimeoutStr);
}
const piperTimeoutStr = env.PIPER_TIMEOUT_MS;
let piperTimeoutMs = 120_000;
if (piperTimeoutStr !== undefined && piperTimeoutStr !== "") {
@@ -169,13 +211,16 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
supplierPrivKeyHex,
ogmiosUrl,
ollamaUrl,
openaiBaseUrl,
piperUrl,
advertRef,
networkId,
port,
ollamaTimeoutMs,
openaiTimeoutMs,
piperTimeoutMs,
capabilityKind,
llmBackend,
liveChain,
};
}

View File

@@ -36,6 +36,7 @@ import { createHash } from "crypto";
import type { SupplierState } from "./state.js";
import type { SupplierConfig } from "./config.js";
import * as ollama from "./ollama.js";
import * as openai from "./openai.js";
import * as piper from "./piper.js";
import type {
JobStore,
@@ -78,24 +79,42 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
deps.jobs.setRunning(jobId);
try {
// ── 2. Call Ollama ─────────────────────────────────────────────────
let inference: ollama.OllamaResult;
// ── 2. Call upstream LLM ───────────────────────────────────────────
// Both backends return the same { content, prompt_tokens,
// completion_tokens, wallclock_ms } shape so receipt construction
// downstream is identical.
let inference: { content: string; prompt_tokens: number; completion_tokens: number; wallclock_ms: number };
try {
inference = await ollama.callOllama({
ollamaUrl: deps.config.ollamaUrl,
model: advert.model,
messages: requestBody.messages,
timeoutMs: deps.config.ollamaTimeoutMs,
});
if (deps.config.llmBackend === "openai") {
inference = await openai.callOpenAi({
baseUrl: deps.config.openaiBaseUrl,
model: advert.model,
messages: requestBody.messages,
timeoutMs: deps.config.openaiTimeoutMs,
});
} else {
inference = await ollama.callOllama({
ollamaUrl: deps.config.ollamaUrl,
model: advert.model,
messages: requestBody.messages,
timeoutMs: deps.config.ollamaTimeoutMs,
});
}
} catch (err) {
const reason = (err as ollama.OllamaError)?.reason ?? "ollama_failure";
const rawReason =
(err as ollama.OllamaError | openai.OpenAiError)?.reason ??
(deps.config.llmBackend === "openai" ? "openai_failure" : "ollama_failure");
const message = err instanceof Error ? err.message : String(err);
// Even ollama_timeout maps to httpStatus 502 here per Caroline's pin
// (the runner failure-code table only enumerates 502/ollama_failure).
// The narrower ollama_timeout code is preserved for HTTP routing later.
// *_timeout maps to httpStatus 502 here per Caroline's pin (the runner
// failure-code table only enumerates 502/*_failure). The narrower
// timeout reason is collapsed to the failure reason for jobs.fail.
const collapsedReason =
rawReason === "ollama_timeout" ? "ollama_failure"
: rawReason === "openai_timeout" ? "openai_failure"
: rawReason;
deps.jobs.fail(jobId, {
httpStatus: 502,
reason: reason === "ollama_timeout" ? "ollama_failure" : reason,
reason: collapsedReason,
message,
});
return;

158
supplier/src/openai.ts Normal file
View File

@@ -0,0 +1,158 @@
/**
* supplier/src/openai.ts — HTTP client for an OpenAI-compatible /v1/chat/completions
* endpoint. Used by the mainnet supplier when LLM_BACKEND=openai is routed at a
* localhost Codex-OAuth proxy (e.g. ChatMock).
*
* callOpenAi({ baseUrl, model, messages, timeoutMs })
* POST to ${baseUrl}/v1/chat/completions with body { model, messages, stream: false }
* Returns { content, prompt_tokens, completion_tokens, wallclock_ms }
*
* Error reasons:
* "openai_failure" — non-2xx HTTP response, network error, or unexpected fetch failure
* "openai_timeout" — request exceeded timeoutMs (AbortError surfaced from AbortController)
* "openai_malformed" — response body missing choices[0].message.content (or empty)
*
* /v1/chat/completions response shape (OpenAI / ChatMock):
* { choices: [{ index, message: { role: "assistant", content: string }, finish_reason }],
* usage: { prompt_tokens, completion_tokens, total_tokens } }
*
* Implementation notes:
* - Mirrors supplier/src/ollama.ts so jobRunner can branch between backends
* without changing downstream receipt-building code (same return shape).
* - wallclock_ms is measured LOCALLY via Date.now() bracketing fetch — the
* OpenAI response has no total_duration field, so we time the round-trip
* here. Inclusive of network + upstream model + JSON parse.
* - Uses global fetch + AbortController so tests can vi.stubGlobal("fetch").
* - Empty-string content is treated as malformed (matches ollama.ts).
*/
import type { ChatMessage } from "@marketplace/shared/tx";
export interface CallOpenAiParams {
baseUrl: string;
model: string;
messages: ChatMessage[];
timeoutMs: number;
}
export interface OpenAiResult {
content: string;
prompt_tokens: number;
completion_tokens: number;
wallclock_ms: number;
}
export type OpenAiErrorReason = "openai_failure" | "openai_timeout" | "openai_malformed";
export class OpenAiError extends Error {
public readonly reason: OpenAiErrorReason;
constructor(reason: OpenAiErrorReason, message?: string) {
super(message ?? reason);
this.name = "OpenAiError";
this.reason = reason;
}
}
function isAbortError(err: unknown): boolean {
if (err instanceof Error) {
if (err.name === "AbortError") return true;
if ((err as { code?: string }).code === "ABORT_ERR") return true;
}
return false;
}
export async function callOpenAi(params: CallOpenAiParams): Promise<OpenAiResult> {
const { baseUrl, model, messages, timeoutMs } = params;
const url = `${baseUrl}/v1/chat/completions`;
const body = JSON.stringify({ model, messages, stream: false });
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const startedAt = Date.now();
let response: Response;
try {
response = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body,
signal: controller.signal,
});
} catch (err) {
if (isAbortError(err)) {
throw new OpenAiError("openai_timeout", `OpenAI request exceeded ${timeoutMs}ms`);
}
throw new OpenAiError(
"openai_failure",
`OpenAI fetch failed: ${(err as Error)?.message ?? String(err)}`,
);
} finally {
clearTimeout(timer);
}
if (!response.ok) {
let detail = "";
try {
detail = await response.text();
} catch {
// ignore — body unavailable
}
throw new OpenAiError(
"openai_failure",
`OpenAI returned HTTP ${response.status}${detail ? `: ${detail.slice(0, 200)}` : ""}`,
);
}
let parsed: unknown;
try {
parsed = await response.json();
} catch (err) {
throw new OpenAiError(
"openai_malformed",
`OpenAI response was not valid JSON: ${(err as Error)?.message ?? String(err)}`,
);
}
if (!parsed || typeof parsed !== "object") {
throw new OpenAiError("openai_malformed", "OpenAI response was not an object");
}
const obj = parsed as Record<string, unknown>;
const choicesRaw = obj.choices;
if (!Array.isArray(choicesRaw) || choicesRaw.length === 0) {
throw new OpenAiError("openai_malformed", "OpenAI response missing 'choices' array");
}
const firstChoice = choicesRaw[0];
if (!firstChoice || typeof firstChoice !== "object") {
throw new OpenAiError("openai_malformed", "OpenAI response choices[0] not an object");
}
const messageRaw = (firstChoice as Record<string, unknown>).message;
if (!messageRaw || typeof messageRaw !== "object") {
throw new OpenAiError("openai_malformed", "OpenAI response missing 'choices[0].message'");
}
const content = (messageRaw as Record<string, unknown>).content;
if (typeof content !== "string" || content.length === 0) {
throw new OpenAiError(
"openai_malformed",
"OpenAI response missing/empty choices[0].message.content",
);
}
const usageRaw = obj.usage;
let promptTokens = 0;
let completionTokens = 0;
if (usageRaw && typeof usageRaw === "object") {
const usage = usageRaw as Record<string, unknown>;
if (typeof usage.prompt_tokens === "number") promptTokens = usage.prompt_tokens;
if (typeof usage.completion_tokens === "number") completionTokens = usage.completion_tokens;
}
const wallclockMs = Date.now() - startedAt;
return {
content,
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
wallclock_ms: wallclockMs,
};
}