diff --git a/.env.example b/.env.example index 441f5c2..84cad97 100644 --- a/.env.example +++ b/.env.example @@ -8,9 +8,12 @@ HERMES_PRE_AI_API_PORT=8645 HERMES_POST_AI_API_PORT=8646 HERMES_PUBLISHED_BIND_IP=127.0.0.1 -HERMES_PRE_AI_PROVIDER=nous -HERMES_POST_AI_PROVIDER=nous HERMES_INTERNAL_API_SERVER_KEY=change-this-to-a-separate-long-random-key +HERMES_DEFAULT_PROVIDER=openai-codex +HERMES_DEFAULT_THINKING_EFFORT=medium +HERMES_DEFAULT_CLAUDE_MODEL=claude-sonnet-4.6 +HERMES_DEFAULT_CODEX_MODEL=gpt-5.4-codex +HERMES_DEFAULT_GEMINI_MODEL=gemini-3.5-flash HERMES_HOME_HOST=/opt/hermes-control-plane/hermes CODEX_HOME_HOST=/opt/hermes-control-plane/codex diff --git a/README.md b/README.md index 66422c2..5a2bf93 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,9 @@ HERMES_LOG_RETENTION_DAYS Audit log retention in days (default: 90) HERMES_AUDIT_MAX_BYTES Max bytes per logged request body (default: 65536) HERMES_IMAGE Registry image Portainer pulls for app services HERMES_AGENT_REF Pinned Hermes source revision baked into the image -HERMES_INTERNAL_API_SERVER_KEY Separate internal key required when enabling the post gateway +HERMES_INTERNAL_API_SERVER_KEY Separate internal key required when enabling API gateways +HERMES_DEFAULT_PROVIDER Provider used when an API request omits model/provider (default: openai-codex) +HERMES_DEFAULT_THINKING_EFFORT Effort used when an API request omits reasoning/thinking effort (default: medium) ``` ## Admin Login @@ -62,19 +64,19 @@ If the admin UI is exposed through an HTTPS reverse proxy, set `HERMES_ADMIN_COO API keys start with `hms_`. Each key is scoped to allow the pre gateway, the post gateway, or both. -The native gateways are intentionally opt-in so missing provider authentication -cannot break the control plane: +The native API gateways are opt-in so missing provider authentication cannot +break the control plane. Both public API wrappers forward to one internal +Hermes gateway. That internal gateway uses the configured primary model and +`fallback_providers` chain, so the pre API is not pinned to one upstream +provider. ```bash -# Requires Hermes Nous or xAI authentication in HERMES_HOME_HOST -docker compose --profile pre-gateway up --build -d - -# Starts the native Hermes tool/API gateway -docker compose --profile post-gateway up --build -d +# Starts the shared internal Hermes gateway plus both public API wrappers. +docker compose --profile pre-gateway --profile post-gateway up --build -d ``` -- **Pre API** (`http://:8645/v1/chat/completions`) — for requests sent before Hermes processing. -- **Post API** (`http://:8646/v1/chat/completions`) — for requests sent after Hermes processing. +- **Pre API** (`http://:8645/v1/chat/completions`) — public OpenAI-compatible API with `pre` API-key permissions and audit labels. +- **Post API** (`http://:8646/v1/chat/completions`) — public OpenAI-compatible API with `post` API-key permissions and audit labels. Send requests with a Bearer token: @@ -84,6 +86,20 @@ Authorization: Bearer hms_... Content-Type: application/json ``` +If a JSON request to `/v1/chat/completions` or `/v1/responses` omits a model, +the wrapper fills one from the default provider: + +```text +Claude: claude-sonnet-4.6 +Codex: gpt-5.4-codex +Gemini: gemini-3.5-flash +``` + +Clients can still set the normal OpenAI-compatible `model` field. For thinking +effort, clients can send `reasoning.effort`, `reasoning_effort`, or +`thinking_effort`; the wrapper forwards it as `reasoning.effort`. If omitted, +it defaults to `medium`. + A key not authorized for a gateway returns `403 Forbidden`. A revoked or rotated key returns `410 Gone`. ## API Key Management @@ -252,34 +268,38 @@ default Hermes `config.yaml` only when one does not already exist. Existing configuration and authentication files are preserved. The default Portainer deployment starts only PostgreSQL and the control plane. -This is deliberate: Codex, Claude, and Gemini auth mounts are optional, and the -native pre-Hermes proxy supports only providers reported by `hermes proxy providers`. -This Hermes build currently reports `nous` and `xai`. +This is deliberate: Codex, Claude, and Gemini auth mounts are optional. Enable +both API profiles when you want the OpenAI-compatible endpoints. ```text Default services: hermes-postgres, hermes-control-plane -Pre profile: hermes-pre-upstream, hermes-pre-api -Post profile: hermes-post-upstream, hermes-post-api +Pre profile: hermes-ai-upstream, hermes-pre-api +Post profile: hermes-ai-upstream, hermes-post-api ``` Enable optional gateway profiles only after their prerequisites are configured. -With Docker Compose, use `--profile pre-gateway` or `--profile post-gateway`. -In a Portainer version that exposes Compose profiles, enable the matching -profile during stack deployment. +With Docker Compose, use `--profile pre-gateway --profile post-gateway`. In +Portainer, set `COMPOSE_PROFILES=pre-gateway,post-gateway` in the stack +environment if the UI does not expose profile toggles. Set these in Portainer when enabling gateways: ```text +COMPOSE_PROFILES=pre-gateway,post-gateway HERMES_PRE_AI_API_PORT=8645 -HERMES_PRE_AI_PROVIDER=nous HERMES_POST_AI_API_PORT=8646 -HERMES_POST_AI_PROVIDER=nous HERMES_INTERNAL_API_SERVER_KEY= +HERMES_DEFAULT_PROVIDER=openai-codex +HERMES_DEFAULT_THINKING_EFFORT=medium +HERMES_DEFAULT_CLAUDE_MODEL=claude-sonnet-4.6 +HERMES_DEFAULT_CODEX_MODEL=gpt-5.4-codex +HERMES_DEFAULT_GEMINI_MODEL=gemini-3.5-flash ``` -The public post API accepts the control plane's `hms_` user keys. Internally it -replaces that header with `HERMES_INTERNAL_API_SERVER_KEY` before forwarding to -Hermes. Do not reuse the admin password or expose the native upstream ports. +The public pre and post APIs accept the control plane's `hms_` user keys. +Internally they replace that header with `HERMES_INTERNAL_API_SERVER_KEY` +before forwarding to Hermes. Do not reuse the admin password or expose the +native upstream port. ## Backup diff --git a/api-gateway.cjs b/api-gateway.cjs index d4fef08..6ee9a60 100644 --- a/api-gateway.cjs +++ b/api-gateway.cjs @@ -17,6 +17,13 @@ const GATEWAY_PORT = parseInt(process.env.HERMES_API_GATEWAY_PORT || "8080", 10) const UPSTREAM_URL = process.env.HERMES_UPSTREAM_URL const UPSTREAM_API_KEY = process.env.HERMES_UPSTREAM_API_KEY || "" const AUDIT_MAX_BYTES = parseInt(process.env.HERMES_AUDIT_MAX_BYTES || "10485760", 10) +const DEFAULT_PROVIDER = normalizeProviderName(process.env.HERMES_DEFAULT_PROVIDER || "openai-codex") +const DEFAULT_THINKING_EFFORT = process.env.HERMES_DEFAULT_THINKING_EFFORT || "medium" +const DEFAULT_MODELS = { + anthropic: process.env.HERMES_DEFAULT_CLAUDE_MODEL || "claude-sonnet-4.6", + "openai-codex": process.env.HERMES_DEFAULT_CODEX_MODEL || "gpt-5.4-codex", + "google-gemini-cli": process.env.HERMES_DEFAULT_GEMINI_MODEL || "gemini-3.5-flash", +} // Validate required env vars if (!DATABASE_URL) { @@ -64,6 +71,73 @@ function upstreamRequestHeaders(headers) { return result } +// ─── OpenAI-compatible request defaults ────────────────────────────────────── + +function normalizeProviderName(provider) { + const raw = String(provider || "").trim().toLowerCase() + if (["claude", "anthropic"].includes(raw)) return "anthropic" + if (["codex", "chatgpt", "openai", "openai-codex"].includes(raw)) return "openai-codex" + if (["gemini", "google", "google-gemini-cli"].includes(raw)) return "google-gemini-cli" + return raw +} + +function defaultModelForProvider(provider) { + return DEFAULT_MODELS[normalizeProviderName(provider)] || DEFAULT_MODELS[DEFAULT_PROVIDER] || DEFAULT_MODELS["openai-codex"] +} + +function providerFromRequestBody(body) { + return normalizeProviderName( + body.provider || + body.hermes_provider || + body.metadata?.provider || + DEFAULT_PROVIDER + ) +} + +function shouldNormalizeOpenAiBody(req) { + if (req.method !== "POST") return false + const path = String(req.url || "").split("?")[0] + return path === "/v1/chat/completions" || path === "/v1/responses" +} + +function normalizeOpenAiRequestBody(req, requestBodyBuffer) { + if (!shouldNormalizeOpenAiBody(req) || !requestBodyBuffer.length) { + return { buffer: requestBodyBuffer, json: null, model: null } + } + + let parsed + try { + parsed = JSON.parse(requestBodyBuffer.toString("utf8")) + } catch { + return { buffer: requestBodyBuffer, json: null, model: null } + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { buffer: requestBodyBuffer, json: parsed, model: null } + } + + const normalized = { ...parsed } + if (!normalized.model) { + normalized.model = defaultModelForProvider(providerFromRequestBody(normalized)) + } + + const explicitEffort = + normalized.reasoning_effort || + normalized.thinking_effort || + normalized.reasoning?.effort || + normalized.thinking?.effort || + DEFAULT_THINKING_EFFORT + + normalized.reasoning = { + ...(normalized.reasoning && typeof normalized.reasoning === "object" ? normalized.reasoning : {}), + effort: explicitEffort, + } + delete normalized.reasoning_effort + delete normalized.thinking_effort + + const buffer = Buffer.from(JSON.stringify(normalized), "utf8") + return { buffer, json: normalized, model: normalized.model || null } +} + // ─── Upstream request ───────────────────────────────────────────────────────── /** @@ -279,15 +353,23 @@ async function handleRequest(req, res, pool) { return } + const normalizedRequest = normalizeOpenAiRequestBody(req, requestBodyBuffer) + requestBodyBuffer = normalizedRequest.buffer + // Extract model from request JSON if possible let requestModel = null let requestJson = null - try { - const parsed = JSON.parse(requestBodyBuffer.toString("utf8")) - requestModel = parsed.model || null - requestJson = requestBodyExceeded ? null : parsed - } catch { - // non-JSON body + if (normalizedRequest.json && !requestBodyExceeded) { + requestModel = normalizedRequest.model + requestJson = normalizedRequest.json + } else { + try { + const parsed = JSON.parse(requestBodyBuffer.toString("utf8")) + requestModel = parsed.model || null + requestJson = requestBodyExceeded ? null : parsed + } catch { + // non-JSON body + } } await beginUsageEvent(pool, { diff --git a/app.js b/app.js index 8df2091..26402ca 100644 --- a/app.js +++ b/app.js @@ -2,9 +2,9 @@ // ── Constants ────────────────────────────────────────────────────────────── const PROVIDERS = [ - { id: "anthropic", label: "Claude", kind: "OAuth Pool", mark: "A1", default_model: "claude-opus-4.6", oauth: true }, - { id: "openai-codex", label: "Codex", kind: "OAuth Pool", mark: "B2", default_model: "gpt-5.3-codex", oauth: true }, - { id: "google-gemini-cli", label: "Gemini", kind: "OAuth Pool", mark: "C3", default_model: "gemini-3-pro-preview", oauth: true }, + { id: "anthropic", label: "Claude", kind: "OAuth Pool", mark: "A1", default_model: "claude-sonnet-4.6", oauth: true }, + { id: "openai-codex", label: "Codex", kind: "OAuth Pool", mark: "B2", default_model: "gpt-5.4-codex", oauth: true }, + { id: "google-gemini-cli", label: "Gemini", kind: "OAuth Pool", mark: "C3", default_model: "gemini-3.5-flash", oauth: true }, { id: "deepseek", label: "DeepSeek", kind: "API Key", mark: "D4", default_model: "deepseek-chat", oauth: false } ] @@ -207,6 +207,9 @@ function renderProviderCards(data) { for (const p of PROVIDERS) { const pool = poolByProvider[p.id] const count = pool?.count ?? 0 + const authState = pool?.authState || (p.id === "deepseek" + ? (data.deepseekConfigured ? { state: "authenticated", label: "Key set" } : { state: "unauthenticated", label: "No key" }) + : { state: "unauthenticated", label: "Unauthenticated" }) const card = document.createElement("div") card.className = "pcard" @@ -251,6 +254,7 @@ function renderProviderCards(data) {
${p.kind}

${p.label}

${p.id}${count ? ` · ${count}` : ""}
+
${escapeHtml(authState.label)}
${credsHtml}
${actionsHtml}
diff --git a/docker-compose.yml b/docker-compose.yml index f1ccb10..bcb1642 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -86,31 +86,8 @@ services: retries: 3 start_period: 10s - hermes-pre-upstream: - profiles: ["pre-gateway"] - build: *hermes-build - image: *hermes-image - user: ${HERMES_CONTAINER_USER:-0:0} - restart: unless-stopped - expose: - - "8645" - command: - - /bin/sh - - -lc - - exec "$$HERMES_EXE" proxy start --provider "$$HERMES_PRE_AI_PROVIDER" --host 0.0.0.0 --port 8645 - environment: - <<: *hermes-environment - HERMES_PRE_AI_PROVIDER: ${HERMES_PRE_AI_PROVIDER:-nous} - volumes: *hermes-volumes - healthcheck: - test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8645/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""] - interval: 30s - timeout: 10s - retries: 3 - start_period: 20s - - hermes-post-upstream: - profiles: ["post-gateway"] + hermes-ai-upstream: + profiles: ["pre-gateway", "post-gateway"] build: *hermes-build image: *hermes-image user: ${HERMES_CONTAINER_USER:-0:0} @@ -127,7 +104,6 @@ services: API_SERVER_HOST: 0.0.0.0 API_SERVER_PORT: 8642 API_SERVER_KEY: ${HERMES_INTERNAL_API_SERVER_KEY:-change-this-internal-hermes-key} - HERMES_POST_AI_PROVIDER: ${HERMES_POST_AI_PROVIDER:-nous} volumes: *hermes-volumes healthcheck: test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8642/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""] @@ -150,13 +126,19 @@ services: HERMES_API_ROUTE_KIND: pre HERMES_API_GATEWAY_HOST: 0.0.0.0 HERMES_API_GATEWAY_PORT: 8645 - HERMES_UPSTREAM_URL: http://hermes-pre-upstream:8645 + HERMES_UPSTREAM_URL: http://hermes-ai-upstream:8642 + HERMES_UPSTREAM_API_KEY: ${HERMES_INTERNAL_API_SERVER_KEY:-change-this-internal-hermes-key} + HERMES_DEFAULT_PROVIDER: ${HERMES_DEFAULT_PROVIDER:-openai-codex} + HERMES_DEFAULT_THINKING_EFFORT: ${HERMES_DEFAULT_THINKING_EFFORT:-medium} + HERMES_DEFAULT_CLAUDE_MODEL: ${HERMES_DEFAULT_CLAUDE_MODEL:-claude-sonnet-4.6} + HERMES_DEFAULT_CODEX_MODEL: ${HERMES_DEFAULT_CODEX_MODEL:-gpt-5.4-codex} + HERMES_DEFAULT_GEMINI_MODEL: ${HERMES_DEFAULT_GEMINI_MODEL:-gemini-3.5-flash} HERMES_LOG_RETENTION_DAYS: ${HERMES_LOG_RETENTION_DAYS:-90} HERMES_AUDIT_MAX_BYTES: ${HERMES_AUDIT_MAX_BYTES:-10485760} depends_on: hermes-postgres: condition: service_healthy - hermes-pre-upstream: + hermes-ai-upstream: condition: service_started healthcheck: test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8645/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""] @@ -179,14 +161,19 @@ services: HERMES_API_ROUTE_KIND: post HERMES_API_GATEWAY_HOST: 0.0.0.0 HERMES_API_GATEWAY_PORT: 8646 - HERMES_UPSTREAM_URL: http://hermes-post-upstream:8642 + HERMES_UPSTREAM_URL: http://hermes-ai-upstream:8642 HERMES_UPSTREAM_API_KEY: ${HERMES_INTERNAL_API_SERVER_KEY:-change-this-internal-hermes-key} + HERMES_DEFAULT_PROVIDER: ${HERMES_DEFAULT_PROVIDER:-openai-codex} + HERMES_DEFAULT_THINKING_EFFORT: ${HERMES_DEFAULT_THINKING_EFFORT:-medium} + HERMES_DEFAULT_CLAUDE_MODEL: ${HERMES_DEFAULT_CLAUDE_MODEL:-claude-sonnet-4.6} + HERMES_DEFAULT_CODEX_MODEL: ${HERMES_DEFAULT_CODEX_MODEL:-gpt-5.4-codex} + HERMES_DEFAULT_GEMINI_MODEL: ${HERMES_DEFAULT_GEMINI_MODEL:-gemini-3.5-flash} HERMES_LOG_RETENTION_DAYS: ${HERMES_LOG_RETENTION_DAYS:-90} HERMES_AUDIT_MAX_BYTES: ${HERMES_AUDIT_MAX_BYTES:-10485760} depends_on: hermes-postgres: condition: service_healthy - hermes-post-upstream: + hermes-ai-upstream: condition: service_started healthcheck: test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8646/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""] diff --git a/server.cjs b/server.cjs index f4813b5..1165a99 100644 --- a/server.cjs +++ b/server.cjs @@ -600,6 +600,29 @@ function matchIdentity(provider, entry, identities) { return candidates[entry.index - 1] || null } +function authStateForPool(pool) { + const entries = Array.isArray(pool.entries) ? pool.entries : [] + if (!entries.length) { + return { state: "unauthenticated", label: "Unauthenticated" } + } + const raw = entries.map((entry) => entry.raw || "").join(" ").toLowerCase() + if (/\b(exhausted|quota|usage limit|monthly limit|rate[-\s]?limit|429)\b/.test(raw)) { + return { state: "usage_limited", label: "Usage limit reached" } + } + if (/\b(expired|revoked|invalid|unauthorized|auth failed|401|403)\b/.test(raw)) { + return { state: "error", label: "Auth needs attention" } + } + return { state: "authenticated", label: "Authenticated" } +} + +function attachAuthStates(pools) { + return pools.map((pool) => ({ + ...pool, + count: pool.count ?? (Array.isArray(pool.entries) ? pool.entries.length : 0), + authState: authStateForPool(pool), + })) +} + function enrichAuthPools(pools) { const identities = [ ...readCodexCliIdentities(), @@ -640,7 +663,7 @@ function enrichAuthPools(pools) { existingProviders.add(provider) } } - return enriched + return attachAuthStates(enriched) } function authDirForProvider(provider) { diff --git a/style.css b/style.css index db58e51..56c473a 100644 --- a/style.css +++ b/style.css @@ -492,6 +492,43 @@ body::after { margin-top: 2px; } +.auth-pill { + display: inline-flex; + align-items: center; + width: fit-content; + margin-top: 7px; + padding: 3px 7px; + border: 1px solid var(--rule); + border-radius: 999px; + font-family: var(--ff-mono); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ink-2); + background: rgba(255,255,255,0.03); +} + +.auth-pill[data-state="authenticated"] { + border-color: rgba(168, 210, 122, 0.45); + color: #a8d27a; +} + +.auth-pill[data-state="usage_limited"] { + border-color: rgba(232, 138, 138, 0.55); + color: #e88a8a; +} + +.auth-pill[data-state="error"] { + border-color: rgba(232, 138, 138, 0.55); + color: #e88a8a; +} + +.auth-pill[data-state="unauthenticated"] { + border-color: rgba(246, 241, 227, 0.22); + color: var(--ink-3); +} + .pcard-creds { list-style: none; margin: 0; padding: 0; diff --git a/test/api-gateway.integration.test.cjs b/test/api-gateway.integration.test.cjs index fbb3537..a279745 100644 --- a/test/api-gateway.integration.test.cjs +++ b/test/api-gateway.integration.test.cjs @@ -393,6 +393,93 @@ test("api-gateway integration", { timeout: 60000 }, async (t) => { assert.ok(body.usage, "should have usage") }) + await t.test("OpenAI-compatible requests get default model and medium reasoning effort", async () => { + let receivedBody = null + const captureUpstream = await startFakeUpstream((req, res) => { + let raw = "" + req.setEncoding("utf8") + req.on("data", (c) => { raw += c }) + req.on("end", () => { + receivedBody = JSON.parse(raw) + res.writeHead(200, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ model: receivedBody.model, usage: {} })) + }) + }) + const defaultsGw = await startGateway({ + databaseUrl, + routeKind: "pre", + upstreamUrl: captureUpstream.url, + extraEnv: { + HERMES_DEFAULT_PROVIDER: "openai-codex", + HERMES_DEFAULT_CODEX_MODEL: "gpt-5.4-codex", + }, + }) + + try { + const { status } = await gatewayRequest({ + host: defaultsGw.host, + port: defaultsGw.port, + path: "/v1/chat/completions", + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${preKey}`, + }, + body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }), + }) + assert.equal(status, 200) + assert.equal(receivedBody.model, "gpt-5.4-codex") + assert.deepEqual(receivedBody.reasoning, { effort: "medium" }) + } finally { + await defaultsGw.close() + await captureUpstream.close() + } + }) + + await t.test("request model and thinking_effort override gateway defaults", async () => { + let receivedBody = null + const captureUpstream = await startFakeUpstream((req, res) => { + let raw = "" + req.setEncoding("utf8") + req.on("data", (c) => { raw += c }) + req.on("end", () => { + receivedBody = JSON.parse(raw) + res.writeHead(200, { "Content-Type": "application/json" }) + res.end(JSON.stringify({ model: receivedBody.model, usage: {} })) + }) + }) + const defaultsGw = await startGateway({ + databaseUrl, + routeKind: "pre", + upstreamUrl: captureUpstream.url, + }) + + try { + const { status } = await gatewayRequest({ + host: defaultsGw.host, + port: defaultsGw.port, + path: "/v1/responses", + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${preKey}`, + }, + body: JSON.stringify({ + model: "claude-sonnet-4.6", + thinking_effort: "high", + input: "hi", + }), + }) + assert.equal(status, 200) + assert.equal(receivedBody.model, "claude-sonnet-4.6") + assert.deepEqual(receivedBody.reasoning, { effort: "high" }) + assert.equal(receivedBody.thinking_effort, undefined) + } finally { + await defaultsGw.close() + await captureUpstream.close() + } + }) + await t.test("configured upstream API key replaces the client API key", async () => { let receivedAuthorization = null const authenticatedUpstream = await startFakeUpstream((req, res) => { diff --git a/test/compose-contract.test.cjs b/test/compose-contract.test.cjs index 9b860cf..8e4c1af 100644 --- a/test/compose-contract.test.cjs +++ b/test/compose-contract.test.cjs @@ -29,27 +29,23 @@ test("compose config is valid and has correct service structure", (t) => { const services = config.services || {} const serviceNames = Object.keys(services) - // Must have all 6 services + // Must have all public services plus one shared native Hermes gateway upstream. assert(serviceNames.includes("hermes-postgres"), "missing hermes-postgres") assert(serviceNames.includes("hermes-control-plane"), "missing hermes-control-plane") - assert(serviceNames.includes("hermes-pre-upstream"), "missing hermes-pre-upstream") - assert(serviceNames.includes("hermes-post-upstream"), "missing hermes-post-upstream") + assert(serviceNames.includes("hermes-ai-upstream"), "missing hermes-ai-upstream") assert(serviceNames.includes("hermes-pre-api"), "missing hermes-pre-api") assert(serviceNames.includes("hermes-post-api"), "missing hermes-post-api") // Upstream services must NOT have host ports - const preUpstream = services["hermes-pre-upstream"] - const postUpstream = services["hermes-post-upstream"] + const aiUpstream = services["hermes-ai-upstream"] const preApi = services["hermes-pre-api"] const postApi = services["hermes-post-api"] - assert.deepEqual(preUpstream.profiles, ["pre-gateway"], "hermes-pre-upstream should be opt-in") + assert.deepEqual(aiUpstream.profiles, ["pre-gateway", "post-gateway"], "hermes-ai-upstream should start with either API profile") assert.deepEqual(preApi.profiles, ["pre-gateway"], "hermes-pre-api should be opt-in") - assert.deepEqual(postUpstream.profiles, ["post-gateway"], "hermes-post-upstream should be opt-in") assert.deepEqual(postApi.profiles, ["post-gateway"], "hermes-post-api should be opt-in") - assert(!preUpstream.ports || preUpstream.ports.length === 0, "hermes-pre-upstream should not publish host ports") - assert(!postUpstream.ports || postUpstream.ports.length === 0, "hermes-post-upstream should not publish host ports") + assert(!aiUpstream.ports || aiUpstream.ports.length === 0, "hermes-ai-upstream should not publish host ports") // Public gateway services must publish correct ports assert(preApi.ports && preApi.ports.length > 0, "hermes-pre-api should publish ports") @@ -69,10 +65,10 @@ test("compose config is valid and has correct service structure", (t) => { assert.equal(preApiPort?.host_ip, "127.0.0.1", "pre API should bind to loopback by default") assert.equal(postApiPort?.host_ip, "127.0.0.1", "post API should bind to loopback by default") - // Post upstream must have API_SERVER_ENABLED=true - const postUpstreamEnv = postUpstream.environment || {} - assert.equal(String(postUpstreamEnv.API_SERVER_ENABLED), "true", "hermes-post-upstream API_SERVER_ENABLED must be true") - assert(postUpstreamEnv.API_SERVER_KEY, "hermes-post-upstream must have an internal API server key") + // Shared native Hermes upstream must have API_SERVER_ENABLED=true. + const aiUpstreamEnv = aiUpstream.environment || {} + assert.equal(String(aiUpstreamEnv.API_SERVER_ENABLED), "true", "hermes-ai-upstream API_SERVER_ENABLED must be true") + assert(aiUpstreamEnv.API_SERVER_KEY, "hermes-ai-upstream must have an internal API server key") // Control plane and gateway services must receive DATABASE_URL const controlPlaneEnv = services["hermes-control-plane"].environment || {} @@ -89,7 +85,7 @@ test("compose config is valid and has correct service structure", (t) => { "/home/hermes/.claude", "/home/hermes/.gemini", ] - for (const serviceName of ["hermes-control-plane", "hermes-pre-upstream", "hermes-post-upstream"]) { + for (const serviceName of ["hermes-control-plane", "hermes-ai-upstream"]) { const volumes = services[serviceName].volumes || [] for (const target of expectedStateTargets) { const mount = volumes.find((volume) => volume.target === target) @@ -99,12 +95,17 @@ test("compose config is valid and has correct service structure", (t) => { const preApiEnv = preApi.environment || {} assert("DATABASE_URL" in preApiEnv, "hermes-pre-api must have DATABASE_URL") + assert.equal( + preApiEnv.HERMES_UPSTREAM_API_KEY, + aiUpstreamEnv.API_SERVER_KEY, + "pre API must authenticate to the native Hermes API with the same internal key" + ) const postApiEnv = postApi.environment || {} assert("DATABASE_URL" in postApiEnv, "hermes-post-api must have DATABASE_URL") assert.equal( postApiEnv.HERMES_UPSTREAM_API_KEY, - postUpstreamEnv.API_SERVER_KEY, + aiUpstreamEnv.API_SERVER_KEY, "post API must authenticate to the native Hermes API with the same internal key" ) diff --git a/test/status-identities.test.cjs b/test/status-identities.test.cjs index ebc8597..555c504 100644 --- a/test/status-identities.test.cjs +++ b/test/status-identities.test.cjs @@ -164,6 +164,8 @@ fi try { const status = await waitForServer("http://127.0.0.1:19743/api/status", proc) const byProvider = Object.fromEntries(status.pools.map((pool) => [pool.provider, pool])) + assert.strictEqual(byProvider["openai-codex"].authState.state, "authenticated") + assert.strictEqual(byProvider["openai-codex"].authState.label, "Authenticated") assert.deepStrictEqual(byProvider["openai-codex"].entries.map((entry) => entry.email), [ "zach@example.com", "emma@example.com", @@ -237,6 +239,10 @@ fi assert.strictEqual(byProvider.anthropic.entries[0].identity, "claude@example.com") assert.strictEqual(byProvider["google-gemini-cli"].entries[0].identity, "gemini@example.com") + assert.strictEqual(byProvider["openai-codex"].authState.state, "authenticated") + assert.strictEqual(byProvider.anthropic.authState.state, "authenticated") + assert.strictEqual(byProvider["google-gemini-cli"].authState.state, "authenticated") + const remove = await postJson("http://127.0.0.1:19744/api/auth/remove", { provider: "openai-codex", index: 2, @@ -253,7 +259,54 @@ fi } } -main().then(directMountedAuthMain).catch((err) => { +async function authStateMain() { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hermes-auth-state-test-")) + const hermesHome = path.join(tmp, ".hermes") + const codexHome = path.join(tmp, ".codex") + fs.mkdirSync(hermesHome, { recursive: true }) + fs.mkdirSync(codexHome, { recursive: true }) + fs.writeFileSync(path.join(hermesHome, "config.yaml"), "fallback_providers: []\n") + + const fakeHermes = path.join(tmp, "hermes") + fs.writeFileSync(fakeHermes, `#!/bin/sh +if [ "$1" = "auth" ] && [ "$2" = "list" ]; then + cat <<'EOF' +openai-codex (1 credential): + #1 default exhausted: monthly usage limit reached ← +anthropic (0 credentials): +EOF +elif [ "$1" = "fallback" ] && [ "$2" = "list" ]; then + echo "No fallback configured" +elif [ "$1" = "version" ]; then + echo "test-hermes" +else + exit 0 +fi +`) + fs.chmodSync(fakeHermes, 0o755) + + const proc = startStatusServer({ after: (fn) => process.once("exit", fn) }, { + hermesHome, + codexHome, + claudeHome: path.join(tmp, ".claude"), + geminiHome: path.join(tmp, ".gemini"), + fakeHermes, + port: 19745, + }) + try { + const status = await waitForServer("http://127.0.0.1:19745/api/status", proc) + const byProvider = Object.fromEntries(status.pools.map((pool) => [pool.provider, pool])) + assert.strictEqual(byProvider["openai-codex"].authState.state, "usage_limited") + assert.match(byProvider["openai-codex"].authState.label, /Usage limit/i) + assert.strictEqual(byProvider.anthropic.authState.state, "unauthenticated") + assert.strictEqual(byProvider.anthropic.authState.label, "Unauthenticated") + } finally { + proc.kill() + fs.rmSync(tmp, { recursive: true, force: true }) + } +} + +main().then(directMountedAuthMain).then(authStateMain).catch((err) => { console.error(err) process.exit(1) })