Skip to content

openai-api endpoint from the conductor

Issue: dashecorp/rig-agent-runtime#674 — part 3 of rig-conductor#2008 (volume-first wake).

Problem

Rig-E's brain is a vLLM pod on RunPod. Its URL embeds the pod id (https://<podId>-8000.proxy.runpod.net/v1), and every migration or volume-first wake onto a new pod changes the id — which, with a literal llm.baseUrl in the HelmRelease, costs a gitops PR each time (rig-gitops#729/#730). The conductor already knows the truth: GET /api/pods/{name}{ podId, proxyUrl, status, isRunning, lookup, … }.

Decision

llm.baseUrlFrom = { conductorPod, conductorUrl? } makes the provider ask the conductor. llm.baseUrl alone keeps the literal path byte-for-byte as before (#668/#669).

Rule Behaviour
Source baseUrlFrom wins over baseUrl (one warning). conductorUrl overrides CONDUCTOR_BASE_URL; neither set → config error at boot. Shape is validated at character load.
Ready isRunning === true and proxyUrl is a bare https origin on an allowed host → baseUrl = origin + "/v1", endpoint …/chat/completions. isRunning: false (any status) → not ready.
proxyUrl trust The conductor is unauthenticated in-cluster and its answer decides where the bearer key is POSTed (SSRF-by-conductor-response). A proxyUrl with embedded credentials, a path, a query, a fragment, a non-https scheme, or a host outside baseUrlFrom.allowedHostSuffixes (default [".proxy.runpod.net"], matched as host === s \|\| host.endsWith("." + s); empty list = default) is refused: not ready, logged as proxyUrl rejected: <reason>, never used. The endpoint is always built from url.origin. The own-GPU move is a config change to allowedHostSuffixes.
Conductor unreadable Transport error or lookup: UNREADABLE → keep the last-known endpoint if there is one, else retry the GET 3× with 5 s between (in-turn), then not ready. 404 (unknown pod name) → config error, not retried.
Not ready AgentProviderError('openai-api', …, { fallbackEligible: false, retryable: true, providerNotReady: { pod, status, lookup, podId } }). processWithProviders (agent.js) copies providerNotReady and httpStatus onto its final re-throw, so the fields reach the chat.js catch. chat.js reads it in its wake-on-chat path and answers 200 with a waking reply — see Wake-on-chat.
Re-resolve A completion that fails with a network error or HTTP 404/502/503/504 invalidates the cache, re-resolves ONCE per chat turn and retries the completion once, then throws. 400/401/429/500 never re-resolve. Completion errors now carry httpStatus.
Cache Single-flight (concurrent turns share one GET); invalidated only by the re-resolve path; no background polling.
Boot Resolves once so openai-api endpoint: https://…/chat/completions (pod <id> status RUNNING) is logged when ready; when not ready it logs openai-api endpoint: not ready (pod qwen status EXITED lookup FOUND) — will resolve on first turn and the agent still boots. Config errors still throw at boot.
Logging Every (re)resolution logs pod id + status. The API key is never logged; the in-cluster conductor endpoint is unauthenticated, so no bearer is sent to it.

Shape

Pure policy + adapter + wiring, matching the repo's split:

  • src/agent/providers/endpoint-resolver.jsdecideEndpointSource(llm, env), endpointFromPodStatus(json), shouldRefreshEndpoint(error) (pure); createConductorPodClient({ conductorUrl }) (adapter, 5 s AbortSignal.timeout); createEndpointResolver({ source, podClient })current() / invalidate().
  • src/agent/providers/openai-api.js — builds the resolver at creation, awaits current() per turn, applies the one-shot re-resolve, exposes endpointResolution (boot promise) for tests.
  • src/agent/provider-error.jshttpStatus and providerNotReady options.
  • src/agent.jsprocessWithProviders propagates providerNotReady / httpStatus on the final re-throw.
  • src/character.jsllm.baseUrlFrom defaults to null; shape-checked via the pure function.

Character example

"llm": {
  "provider": "openai-api",
  "model": "Qwen/Qwen2.5-32B-Instruct-AWQ",
  "baseUrlFrom": {
    "conductorPod": "qwen",
    "conductorUrl": "http://rig-conductor-api.rig-conductor.svc.cluster.local:8080"
  }
}

Rig-E has no CONDUCTOR_BASE_URL env today, so conductorUrl is set explicitly in the HelmRelease (rig-gitops side of #674). No chart change is needed: character is opaque.

Tests

endpoint-resolver.test.js (policy incl. proxyUrl rejection + allow-list, adapter, cache/single-flight/retry with injected sleep), agent.test.js (providerNotReady + httpStatus survive the final re-throw), openai-api.test.js (URL-routed fake fetch: resolve, re-resolve on 404 → new pod id, no re-resolve on 400, not-ready hook, literal path unchanged), and openai-api.integration.test.js (an in-process http.createServer mock conductor: ready boot, EXITED boot, pod id change between two turns).