diff --git a/deploy/mainnet/docker-compose.supplier-deepseek.yml b/deploy/mainnet/docker-compose.supplier-deepseek.yml new file mode 100644 index 0000000..6147cb4 --- /dev/null +++ b/deploy/mainnet/docker-compose.supplier-deepseek.yml @@ -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 diff --git a/supplier/.env.example b/supplier/.env.example index c7bb8cb..41bcb93 100644 --- a/supplier/.env.example +++ b/supplier/.env.example @@ -42,6 +42,20 @@ PORT=8080 # generous. Increase if you see ollama_timeout reasons in logs. 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 ` 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) ── # 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 diff --git a/supplier/src/config.ts b/supplier/src/config.ts index ba00e2d..3db6b79 100644 --- a/supplier/src/config.ts +++ b/supplier/src/config.ts @@ -65,6 +65,12 @@ export interface SupplierConfig { ollamaUrl: string; /** Empty string when capabilityKind="tts" or llmBackend="ollama". */ openaiBaseUrl: string; + /** + * Bearer token forwarded as `Authorization: Bearer ` 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". */ piperUrl: string; advertRef: AdvertRef; @@ -193,6 +199,8 @@ export function loadConfig(env: Record): SupplierCon openaiTimeoutMs = Number(openaiTimeoutStr); } + const openaiApiKey = env.OPENAI_API_KEY ?? ""; + const piperTimeoutStr = env.PIPER_TIMEOUT_MS; let piperTimeoutMs = 120_000; if (piperTimeoutStr !== undefined && piperTimeoutStr !== "") { @@ -212,6 +220,7 @@ export function loadConfig(env: Record): SupplierCon ogmiosUrl, ollamaUrl, openaiBaseUrl, + openaiApiKey, piperUrl, advertRef, networkId, diff --git a/supplier/src/jobRunner.ts b/supplier/src/jobRunner.ts index 530ad77..60f7c46 100644 --- a/supplier/src/jobRunner.ts +++ b/supplier/src/jobRunner.ts @@ -91,6 +91,7 @@ export async function runChatJob(params: RunChatJobParams): Promise { model: advert.model, messages: requestBody.messages, timeoutMs: deps.config.openaiTimeoutMs, + apiKey: deps.config.openaiApiKey, }); } else { inference = await ollama.callOllama({ diff --git a/supplier/src/openai.ts b/supplier/src/openai.ts index 891e9e6..d0b894d 100644 --- a/supplier/src/openai.ts +++ b/supplier/src/openai.ts @@ -33,6 +33,12 @@ export interface CallOpenAiParams { model: string; messages: ChatMessage[]; timeoutMs: number; + /** + * Optional bearer token. When non-empty, an `authorization: Bearer ` + * header is sent. Omit (or pass "") for endpoints that don't require auth, + * e.g. the ChatMock localhost proxy. + */ + apiKey?: string; } export interface OpenAiResult { @@ -62,10 +68,15 @@ function isAbortError(err: unknown): boolean { } export async function callOpenAi(params: CallOpenAiParams): Promise { - const { baseUrl, model, messages, timeoutMs } = params; + const { baseUrl, model, messages, timeoutMs, apiKey } = params; const url = `${baseUrl}/v1/chat/completions`; const body = JSON.stringify({ model, messages, stream: false }); + const headers: Record = { "content-type": "application/json" }; + if (apiKey) { + headers.authorization = `Bearer ${apiKey}`; + } + const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); @@ -74,7 +85,7 @@ export async function callOpenAi(params: CallOpenAiParams): Promise { const body = JSON.parse(init.body as string); 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).mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers.authorization).toBeUndefined(); + }); + + it("sends authorization: Bearer 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).mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + 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).mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers.authorization).toBeUndefined(); + }); }); // ─── Happy path ─────────────────────────────────────────────────────────────