supplier+deploy/mainnet: optional OPENAI_API_KEY for DeepSeek (and other hosted OpenAI-compatible APIs)

Adds an optional OPENAI_API_KEY env var that supplier/src/openai.ts forwards as
`Authorization: Bearer <key>`. Empty/unset preserves the ChatMock behaviour
(no auth header). Unlocks pointing the openai backend at DeepSeek directly
(https://api.deepseek.com) — no local proxy needed.

New compose: deploy/mainnet/docker-compose.supplier-deepseek.yml (third mainnet
supplier, model deepseek-v4-pro, mp-suppliers-deepseek.vector.apexfusion.org).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
david
2026-05-27 11:23:57 +02:00
parent d59d3f56b8
commit 1466442870
6 changed files with 151 additions and 2 deletions

View File

@@ -0,0 +1,81 @@
# deploy/mainnet/docker-compose.supplier-deepseek.yml — Supplier — MAINNET (DeepSeek backend)
#
# Third mainnet supplier. LLM_BACKEND=openai pointed directly at DeepSeek's
# hosted API (OpenAI-compatible at https://api.deepseek.com). No local proxy
# container — DeepSeek is reachable over public HTTPS.
#
# Coexists with:
# - marketplace-mainnet-supplier (ollama / qwen3.6:35b, mp-suppliers.vector.apexfusion.org)
# - marketplace-mainnet-supplier-gpt (openai / ChatMock, mp-suppliers-gpt.vector.apexfusion.org)
#
# Pre-flight:
# 1. DNS A-record: mp-suppliers-deepseek.vector.apexfusion.org → host IP
# 2. supplier/.env.deepseek 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
# 3. Networks already exist (created by the canonical supplier compose):
# docker network create marketplace-mainnet-net # one-shot
# # traefik-net is created by the Traefik project
name: marketplace-mainnet-supplier-deepseek
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-deepseek
restart: unless-stopped
env_file:
# Self-contained env for the DeepSeek variant — separate from
# ../../supplier/.env (ollama-backed) and ../../supplier/.env.gpt
# (ChatMock-backed). Holds this supplier's distinct SUPPLIER_PRIV_KEY_HEX,
# ADVERT_REF, ADVERT_*, OPENAI_API_KEY, and the shared mainnet plumbing
# duplicated in full (NETWORK_ID, OGMIOS_URL, VECTOR_ZERO_TIME_MS,
# ref-script UTxOs, LIVE_CHAIN). No env_file ordering / override magic.
- path: ../../supplier/.env.deepseek
required: false
environment:
LLM_BACKEND: openai
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
OPENAI_BASE_URL: https://api.deepseek.com
# DeepSeek reasoning generations can be slow; mirror the GPT compose.
OPENAI_TIMEOUT_MS: "300000"
PORT: "8080"
# Enable LiveOgmiosProvider so submitTx/awaitTx actually broadcast.
# Without this the supplier silently falls back to ReadOnlyOgmiosProvider
# and Claim/Submit never reach chain.
LIVE_CHAIN: "1"
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
# come from the operator-provided supplier/.env.deepseek on a mainnet host.
# Setting them via compose would be a footgun: a stray re-up on
# testnet hardware could silently re-tag the supplier as mainnet.
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-deepseek-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-deepseek-mainnet.rule=Host(`mp-suppliers-deepseek.vector.apexfusion.org`)"
- "traefik.http.services.mp-suppliers-deepseek-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

View File

@@ -42,6 +42,20 @@ PORT=8080
# generous. Increase if you see ollama_timeout reasons in logs. # generous. Increase if you see ollama_timeout reasons in logs.
OLLAMA_TIMEOUT_MS=120000 OLLAMA_TIMEOUT_MS=120000
# ─── Optional — OpenAI-compatible backend (LLM_BACKEND=openai) ────────────
# Switch the chat job runner to POST /v1/chat/completions at OPENAI_BASE_URL
# instead of Ollama. Used by the mainnet ChatMock-backed supplier and any
# hosted OpenAI-compatible API (OpenAI, DeepSeek, etc.).
# LLM_BACKEND=openai
# OPENAI_BASE_URL=http://marketplace-mainnet-chatmock:8000 # or https://api.deepseek.com
# OPENAI_TIMEOUT_MS=180000
# OPENAI_API_KEY — optional bearer token. When set, the supplier forwards it
# as `Authorization: Bearer <key>` on every /v1/chat/completions request.
# Leave empty for endpoints that don't require auth (ChatMock, other local
# proxies). REQUIRED for hosted APIs (DeepSeek, OpenAI). SECRET.
# OPENAI_API_KEY=
# ─── Optional (not in supplier/src/config.ts; consumed by index.ts boot) ── # ─── Optional (not in supplier/src/config.ts; consumed by index.ts boot) ──
# Pre-derived public-key + pkh + bech32 address. M1-D will derive these from # Pre-derived public-key + pkh + bech32 address. M1-D will derive these from
# SUPPLIER_PRIV_KEY_HEX automatically; until then index.ts reads them from # SUPPLIER_PRIV_KEY_HEX automatically; until then index.ts reads them from

View File

@@ -65,6 +65,12 @@ export interface SupplierConfig {
ollamaUrl: string; ollamaUrl: string;
/** Empty string when capabilityKind="tts" or llmBackend="ollama". */ /** Empty string when capabilityKind="tts" or llmBackend="ollama". */
openaiBaseUrl: string; openaiBaseUrl: string;
/**
* Bearer token forwarded as `Authorization: Bearer <key>` when llmBackend="openai".
* Empty string means no header is sent (correct for ChatMock and other localhost
* proxies). Required for hosted OpenAI-compatible APIs (DeepSeek, OpenAI, etc.).
*/
openaiApiKey: string;
/** Empty string when capabilityKind="chat". */ /** Empty string when capabilityKind="chat". */
piperUrl: string; piperUrl: string;
advertRef: AdvertRef; advertRef: AdvertRef;
@@ -193,6 +199,8 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
openaiTimeoutMs = Number(openaiTimeoutStr); openaiTimeoutMs = Number(openaiTimeoutStr);
} }
const openaiApiKey = env.OPENAI_API_KEY ?? "";
const piperTimeoutStr = env.PIPER_TIMEOUT_MS; const piperTimeoutStr = env.PIPER_TIMEOUT_MS;
let piperTimeoutMs = 120_000; let piperTimeoutMs = 120_000;
if (piperTimeoutStr !== undefined && piperTimeoutStr !== "") { if (piperTimeoutStr !== undefined && piperTimeoutStr !== "") {
@@ -212,6 +220,7 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
ogmiosUrl, ogmiosUrl,
ollamaUrl, ollamaUrl,
openaiBaseUrl, openaiBaseUrl,
openaiApiKey,
piperUrl, piperUrl,
advertRef, advertRef,
networkId, networkId,

View File

@@ -91,6 +91,7 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
model: advert.model, model: advert.model,
messages: requestBody.messages, messages: requestBody.messages,
timeoutMs: deps.config.openaiTimeoutMs, timeoutMs: deps.config.openaiTimeoutMs,
apiKey: deps.config.openaiApiKey,
}); });
} else { } else {
inference = await ollama.callOllama({ inference = await ollama.callOllama({

View File

@@ -33,6 +33,12 @@ export interface CallOpenAiParams {
model: string; model: string;
messages: ChatMessage[]; messages: ChatMessage[];
timeoutMs: number; timeoutMs: number;
/**
* Optional bearer token. When non-empty, an `authorization: Bearer <apiKey>`
* header is sent. Omit (or pass "") for endpoints that don't require auth,
* e.g. the ChatMock localhost proxy.
*/
apiKey?: string;
} }
export interface OpenAiResult { export interface OpenAiResult {
@@ -62,10 +68,15 @@ function isAbortError(err: unknown): boolean {
} }
export async function callOpenAi(params: CallOpenAiParams): Promise<OpenAiResult> { export async function callOpenAi(params: CallOpenAiParams): Promise<OpenAiResult> {
const { baseUrl, model, messages, timeoutMs } = params; const { baseUrl, model, messages, timeoutMs, apiKey } = params;
const url = `${baseUrl}/v1/chat/completions`; const url = `${baseUrl}/v1/chat/completions`;
const body = JSON.stringify({ model, messages, stream: false }); const body = JSON.stringify({ model, messages, stream: false });
const headers: Record<string, string> = { "content-type": "application/json" };
if (apiKey) {
headers.authorization = `Bearer ${apiKey}`;
}
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs); const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -74,7 +85,7 @@ export async function callOpenAi(params: CallOpenAiParams): Promise<OpenAiResult
try { try {
response = await fetch(url, { response = await fetch(url, {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers,
body, body,
signal: controller.signal, signal: controller.signal,
}); });

View File

@@ -102,6 +102,39 @@ describe("callOpenAi() — request shape", () => {
const body = JSON.parse(init.body as string); const body = JSON.parse(init.body as string);
expect(body.stream).toBe(false); expect(body.stream).toBe(false);
}); });
it("omits authorization header when apiKey is not provided (ChatMock path)", 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 headers = init.headers as Record<string, string>;
expect(headers.authorization).toBeUndefined();
});
it("sends authorization: Bearer <apiKey> when apiKey is provided (DeepSeek/OpenAI path)", async () => {
await callOpenAi({
baseUrl: BASE_URL,
model: MODEL,
messages: MESSAGES,
timeoutMs: TIMEOUT_MS,
apiKey: "sk-test-deepseek-key",
});
const [, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];
const headers = init.headers as Record<string, string>;
expect(headers.authorization).toBe("Bearer sk-test-deepseek-key");
});
it("omits authorization header when apiKey is empty string", async () => {
await callOpenAi({
baseUrl: BASE_URL,
model: MODEL,
messages: MESSAGES,
timeoutMs: TIMEOUT_MS,
apiKey: "",
});
const [, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];
const headers = init.headers as Record<string, string>;
expect(headers.authorization).toBeUndefined();
});
}); });
// ─── Happy path ───────────────────────────────────────────────────────────── // ─── Happy path ─────────────────────────────────────────────────────────────