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:
@@ -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"
|
||||
|
||||
147
supplier/src/cli/gen-keypair.ts
Normal file
147
supplier/src/cli/gen-keypair.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
158
supplier/src/openai.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user