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

@@ -0,0 +1,235 @@
/**
* supplier-openai.test.ts — unit tests for supplier/src/openai.ts.
*
* Mirrors supplier-ollama.test.ts. All tests mock global.fetch — no real
* ChatMock / OpenAI process required.
*
* Covers:
* - Request shape: POST to ${baseUrl}/v1/chat/completions, body has model+messages+stream:false
* - Happy path: parses /v1/chat/completions response, measures wallclock_ms locally
* - Error: 5xx → OpenAiError reason "openai_failure"
* - Error: timeout → OpenAiError reason "openai_timeout"
* - Error: missing choices[0].message.content → OpenAiError reason "openai_malformed"
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { callOpenAi, OpenAiError } from "../../supplier/src/openai.js";
const BASE_URL = "http://localhost:8000";
const MODEL = "gpt-5.4";
const MESSAGES = [{ role: "user" as const, content: "Hello" }];
const TIMEOUT_MS = 5_000;
// ─── Helpers ──────────────────────────────────────────────────────────────────
function makeFetchOk(body: unknown) {
return vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => body,
});
}
function makeFetchError(status: number) {
return vi.fn().mockResolvedValue({
ok: false,
status,
json: async () => ({ error: "server error" }),
text: async () => "server error",
});
}
function makeOpenAiResponse(content: string, promptTokens = 10, completionTokens = 40) {
return {
id: "chatcmpl-test",
object: "chat.completion",
model: MODEL,
choices: [
{
index: 0,
message: { role: "assistant", content },
finish_reason: "stop",
},
],
usage: {
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
total_tokens: promptTokens + completionTokens,
},
};
}
// ─── Request shape ─────────────────────────────────────────────────────────
describe("callOpenAi() — request shape", () => {
beforeEach(() => {
vi.stubGlobal("fetch", makeFetchOk(makeOpenAiResponse("Hi there")));
});
afterEach(() => {
vi.restoreAllMocks();
});
it("sends POST to ${baseUrl}/v1/chat/completions", async () => {
await callOpenAi({ baseUrl: BASE_URL, model: MODEL, messages: MESSAGES, timeoutMs: TIMEOUT_MS });
const [url] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];
expect(url).toBe(`${BASE_URL}/v1/chat/completions`);
});
it("sends POST method", async () => {
await callOpenAi({ baseUrl: BASE_URL, model: MODEL, messages: MESSAGES, timeoutMs: TIMEOUT_MS });
const [, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];
expect(init.method?.toUpperCase()).toBe("POST");
});
it("request body contains model", async () => {
await callOpenAi({ baseUrl: BASE_URL, model: MODEL, messages: MESSAGES, timeoutMs: TIMEOUT_MS });
const [, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.model).toBe(MODEL);
});
it("request body contains messages", async () => {
await callOpenAi({ baseUrl: BASE_URL, model: MODEL, messages: MESSAGES, timeoutMs: TIMEOUT_MS });
const [, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.messages).toEqual(MESSAGES);
});
it("request body has stream: false", async () => {
await callOpenAi({ baseUrl: BASE_URL, model: MODEL, messages: MESSAGES, timeoutMs: TIMEOUT_MS });
const [, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];
const body = JSON.parse(init.body as string);
expect(body.stream).toBe(false);
});
});
// ─── Happy path ─────────────────────────────────────────────────────────────
describe("callOpenAi() — happy path", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("returns content from choices[0].message.content", async () => {
vi.stubGlobal("fetch", makeFetchOk(makeOpenAiResponse("Paris is the capital of France.")));
const result = await callOpenAi({ baseUrl: BASE_URL, model: MODEL, messages: MESSAGES, timeoutMs: TIMEOUT_MS });
expect(result.content).toBe("Paris is the capital of France.");
});
it("returns prompt_tokens from usage.prompt_tokens", async () => {
vi.stubGlobal("fetch", makeFetchOk(makeOpenAiResponse("answer", 15, 40)));
const result = await callOpenAi({ baseUrl: BASE_URL, model: MODEL, messages: MESSAGES, timeoutMs: TIMEOUT_MS });
expect(result.prompt_tokens).toBe(15);
});
it("returns completion_tokens from usage.completion_tokens", async () => {
vi.stubGlobal("fetch", makeFetchOk(makeOpenAiResponse("answer", 15, 40)));
const result = await callOpenAi({ baseUrl: BASE_URL, model: MODEL, messages: MESSAGES, timeoutMs: TIMEOUT_MS });
expect(result.completion_tokens).toBe(40);
});
it("wallclock_ms is measured locally (non-negative finite integer)", async () => {
vi.stubGlobal("fetch", makeFetchOk(makeOpenAiResponse("answer")));
const result = await callOpenAi({ baseUrl: BASE_URL, model: MODEL, messages: MESSAGES, timeoutMs: TIMEOUT_MS });
expect(Number.isFinite(result.wallclock_ms)).toBe(true);
expect(result.wallclock_ms).toBeGreaterThanOrEqual(0);
});
it("falls back to 0 token counts when usage block is absent", async () => {
const body = makeOpenAiResponse("answer");
delete (body as { usage?: unknown }).usage;
vi.stubGlobal("fetch", makeFetchOk(body));
const result = await callOpenAi({ baseUrl: BASE_URL, model: MODEL, messages: MESSAGES, timeoutMs: TIMEOUT_MS });
expect(result.prompt_tokens).toBe(0);
expect(result.completion_tokens).toBe(0);
});
});
// ─── Error: 5xx response ────────────────────────────────────────────────────
describe("callOpenAi() — openai_failure (non-2xx)", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("throws OpenAiError with reason openai_failure on 500", async () => {
vi.stubGlobal("fetch", makeFetchError(500));
await expect(
callOpenAi({ baseUrl: BASE_URL, model: MODEL, messages: MESSAGES, timeoutMs: TIMEOUT_MS })
).rejects.toMatchObject({ reason: "openai_failure" });
});
it("throws OpenAiError with reason openai_failure on 401 (Codex auth revoked)", async () => {
vi.stubGlobal("fetch", makeFetchError(401));
await expect(
callOpenAi({ baseUrl: BASE_URL, model: MODEL, messages: MESSAGES, timeoutMs: TIMEOUT_MS })
).rejects.toMatchObject({ reason: "openai_failure" });
});
it("throws OpenAiError (not generic Error) on non-2xx", async () => {
vi.stubGlobal("fetch", makeFetchError(500));
await expect(
callOpenAi({ baseUrl: BASE_URL, model: MODEL, messages: MESSAGES, timeoutMs: TIMEOUT_MS })
).rejects.toBeInstanceOf(OpenAiError);
});
});
// ─── Error: timeout ─────────────────────────────────────────────────────────
describe("callOpenAi() — openai_timeout", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("throws OpenAiError with reason openai_timeout when AbortSignal fires", async () => {
const abortErr = new DOMException("The operation was aborted.", "AbortError");
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(abortErr));
await expect(
callOpenAi({ baseUrl: BASE_URL, model: MODEL, messages: MESSAGES, timeoutMs: 10 })
).rejects.toMatchObject({ reason: "openai_timeout" });
});
});
// ─── Error: malformed response ───────────────────────────────────────────────
describe("callOpenAi() — openai_malformed", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("throws OpenAiError with reason openai_malformed when choices array is missing", async () => {
vi.stubGlobal("fetch", makeFetchOk({ id: "x", object: "chat.completion", usage: { prompt_tokens: 0, completion_tokens: 0 } }));
await expect(
callOpenAi({ baseUrl: BASE_URL, model: MODEL, messages: MESSAGES, timeoutMs: TIMEOUT_MS })
).rejects.toMatchObject({ reason: "openai_malformed" });
});
it("throws OpenAiError with reason openai_malformed when choices array is empty", async () => {
vi.stubGlobal("fetch", makeFetchOk({ id: "x", object: "chat.completion", choices: [], usage: { prompt_tokens: 0, completion_tokens: 0 } }));
await expect(
callOpenAi({ baseUrl: BASE_URL, model: MODEL, messages: MESSAGES, timeoutMs: TIMEOUT_MS })
).rejects.toMatchObject({ reason: "openai_malformed" });
});
it("throws OpenAiError with reason openai_malformed when choices[0].message is missing", async () => {
vi.stubGlobal("fetch", makeFetchOk({ id: "x", object: "chat.completion", choices: [{ index: 0, finish_reason: "stop" }] }));
await expect(
callOpenAi({ baseUrl: BASE_URL, model: MODEL, messages: MESSAGES, timeoutMs: TIMEOUT_MS })
).rejects.toMatchObject({ reason: "openai_malformed" });
});
it("throws OpenAiError with reason openai_malformed when content is null", async () => {
vi.stubGlobal("fetch", makeFetchOk({ id: "x", object: "chat.completion", choices: [{ index: 0, message: { role: "assistant", content: null }, finish_reason: "stop" }] }));
await expect(
callOpenAi({ baseUrl: BASE_URL, model: MODEL, messages: MESSAGES, timeoutMs: TIMEOUT_MS })
).rejects.toMatchObject({ reason: "openai_malformed" });
});
it("throws OpenAiError with reason openai_malformed when content is empty string", async () => {
vi.stubGlobal("fetch", makeFetchOk(makeOpenAiResponse("")));
await expect(
callOpenAi({ baseUrl: BASE_URL, model: MODEL, messages: MESSAGES, timeoutMs: TIMEOUT_MS })
).rejects.toMatchObject({ reason: "openai_malformed" });
});
});