{"openapi":"3.1.0","info":{"title":"ACS Infra API wrapper","version":"0.1.0"},"paths":{"/health":{"get":{"summary":"Health","description":"Aggregated health/readiness for the wrapper + every live backend.\n\nPublic (no auth) so external monitors and Railway can hit it. The body\ndeliberately omits secrets and request bodies — same privacy stance as\nevery other public endpoint.\n\nResponse shape (stable contract):\n```\n{\n  \"status\": \"ok\" | \"degraded\" | \"down\",\n  \"wrapper\": {\n    \"uptime_seconds\": int,\n    \"database\": {\"ok\": bool, \"latency_ms\": int}\n  },\n  \"backends\": [\n    {\n      \"model_id\": str,\n      \"status\": \"live\" | \"staging\",\n      \"gpu_shape\": str,\n      \"breaker_state\": \"closed\" | \"open\" | \"half_open\",\n      \"breaker_consecutive_failures\": int,\n      \"breaker_last_failure_kind\": str | null,\n      \"last_completion_at\": ISO-8601 | null,\n      \"last_completion_seconds_ago\": int | null,\n      \"warm_estimate\": \"warm\" | \"cold\"\n    },\n    ...\n  ],\n  \"degraded_backends\": [str]   // model_ids whose breakers are open\n}\n```\n\nHTTP status: 503 when the wrapper itself can't serve (DB down), else 200\n(including ``degraded`` — one bad model doesn't mean the wrapper should\nbe restarted). Backward compatible: the legacy ``{\"status\": \"ok\"}`` shape\nis preserved as a subset.","operationId":"health_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/v1/models":{"get":{"summary":"Models","description":"Return the wrapper's model registry in OpenAI-shape, with capability flags.\n\nReplaces the previous upstream proxy: each upstream Modal app only knows\nabout its own served model, so aggregating from the wrapper registry is\nthe source of truth across all live models.\n\nThe ``capabilities`` block per model is the beta-grade contract: it tells\ncallers what this model supports without round-tripping. Fields:\n  - ``max_model_len``: total prompt+completion tokens vLLM will accept.\n    ``None`` if unknown (registry didn't declare it; pre-flight check is\n    skipped for this model).\n  - ``max_logprobs``: cap on a POSITIVE (top-k) ``logprobs`` /\n    ``prompt_logprobs`` count.\n  - ``prompt_logprobs_full_vocab``: whether ``prompt_logprobs=-1`` (the whole\n    distribution per prompt position) is supported.\n  - ``full_vocab_max_prompt_tokens``: max prompt length for a full-vocab\n    ``prompt_logprobs=-1`` request.\n  - ``max_output_work_tokens``: cap on\n    ``prompt_count × n × max_tokens``.\n  - ``max_inflight_per_key`` / ``max_queued_per_key``: per-key active and\n    waiting request limits.\n  - ``rate_limit_per_minute``: per-key request-rate ceiling.\n  - ``logprobs``, ``prompt_logprobs``: bool feature flags (always True\n    today; reserved for future backends without these capabilities).\n  - ``chat_template``: always ``null`` — these are base models, prompts\n    pass through verbatim without templating. Surfaced explicitly so\n    clients can detect the no-chat-template contract.","operationId":"models_v1_models_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/v1/models/{model_id}/status":{"get":{"summary":"Model Status","description":"Warm/cold state + current cold-boot stage for one model (ACS-272).\n\nThe non-streaming completion path can't carry progress text (its keepalive\nis JSON whitespace on a committed 200), so clients waiting out a cold boot\npoll this instead — e.g. from a second terminal:\n\n    curl -H \"Authorization: Bearer $KEY\" .../v1/models/llama-405b/status\n\n``boot`` is the latest authored stage the serving container published to\nthe boot-status Dict (see boot_stage.py), or null when there's no fresh\nentry — for a cold model that means Modal hasn't started our container\nyet (GPU allocation), for a warm one simply that the last boot aged out.\n``warm`` reuses the workbench warm-pill signal (Modal runner count with a\nrecent-completion fallback). Both are advisory; the model's actual\nbehaviour on a request is always the ground truth.\n\nFor activation-enabled models the response also carries\n``activation_boot`` — the ACTIVATION engine's stage (ACS-276). Activation\n*capture* requests are non-streaming by design (the whole residual-stream\ntensor comes back as one JSON body), so this field is the only progress\nsurface while a capture waits out a cold activation boot; the engines\nscale independently, so ``boot``/``warm`` say nothing about it. The key is\nabsent for models without an activation engine.","operationId":"model_status_v1_models__model_id__status_get","parameters":[{"name":"model_id","in":"path","required":true,"schema":{"type":"string","title":"Model Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/chat/completions":{"post":{"summary":"Chat Completions Unsupported","description":"Loud 400 by design.\n\nThis is a base-model API; we don't want researchers paste-from-OpenAI-tutorial\nto silently 404 — they need to be redirected to /v1/completions explicitly.","operationId":"chat_completions_unsupported_v1_chat_completions_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/v1/completions":{"post":{"summary":"Completions","operationId":"completions_v1_completions_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CompletionsRequest"}}}}}},"/v1/harvest":{"post":{"summary":"Harvest Submit","description":"Start one bulk activation-harvest job; returns 202 + a job id to poll.\n\nGuard rails for untrusted input:\n  - Content-Length cap (``harvest_max_body_bytes``) rejected 413 before the\n    body is even read;\n  - prompt COUNT cap (``harvest_max_prompts``) and a chars/4\n    estimated-token cap (``harvest_max_est_tokens``) — cheap bounds on GPU\n    time + activation storage, no tokenizer pass over thousands of prompts;\n  - per-key monthly job quota (``api_keys.monthly_harvest_budget``,\n    0 = unlimited) counted against ``harvest_jobs`` rows;\n  - per-key running-job cap (``harvest_max_running_per_key``) so one key\n    can't fan out GPU containers.\n\nThe quota gate is RACE-FREE: one transaction takes ``SELECT ... FOR\nUPDATE`` on the caller's api_keys row (serializing this key's submits),\nages out stale jobs, runs both counts, and inserts the job as ``pending``\n— so a concurrent submit blocks on the lock and then sees the pending row.\nThe transaction commits BEFORE the Modal spawn (no DB connection held\nacross the await); the row then flips to ``running`` (or ``failed`` on a\nspawn error — such rows never engaged a GPU and don't burn monthly quota).","operationId":"harvest_submit_v1_harvest_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}},"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HarvestRequest"}}}}}},"/v1/harvest/{job_id}":{"get":{"summary":"Harvest Status","description":"Poll one harvest job; on completion returns the harvest result dict.\n\nLazy reconciliation: a ``running`` row triggers one non-blocking Modal poll\n(``FunctionCall.get(timeout=0)`` via modal_ops) and persists the outcome —\nno background worker to operate. The read transaction commits BEFORE the\nModal await (no idle-in-transaction connection), and the reconcile UPDATE\nis guarded ``WHERE status = 'running'`` so a racing poll can never\noverwrite a committed terminal state. Any unexpected poll error serves the\nstored row as-is rather than 500ing. Terminal rows are served straight\nfrom Postgres. Only the owning key or another key of the same user may\nread a job; anyone else gets the same 404 as a nonexistent id.","operationId":"harvest_status_v1_harvest__job_id__get","parameters":[{"name":"job_id","in":"path","required":true,"schema":{"type":"string","title":"Job Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}}},"components":{"schemas":{"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"SteeringVector":{"additionalProperties":false,"description":"One steering directive, forwarded verbatim to vLLM-Lens.\n\nMatches the vLLM-Lens ``SteeringVector`` wire schema (v1.1.0, verified from\nsource + a live engine 2026-07-06): the direction ``activations`` is itself a\nbase64(zstd(bf16-as-int16)) **codec dict** — the client builds it (see the\nrunbook's encode recipe). The wrapper does NOT decode the tensor; it only\nvalidates the envelope + bounds size, then ``json.dumps`` the list under\n``vllm_xargs.apply_steering_vectors``. Steering mechanics (S1–S6) are\nvalidated upstream (ACS-156).","properties":{"activations":{"additionalProperties":true,"description":"Steering direction as a vLLM-Lens tensor codec dict: {data (base64), dtype, original_dtype, shape, compression}. shape (n_layers, d_model) broadcasts to all positions; (n_layers, n_positions, d_model) is position-specific.","title":"Activations","type":"object"},"layer_indices":{"description":"Decoder layer indices; len(layer_indices) must equal activations.shape[0].","items":{"type":"integer"},"minItems":1,"title":"Layer Indices","type":"array"},"scale":{"default":1.0,"description":"Scalar multiplier applied before addition.","title":"Scale","type":"number"},"norm_match":{"default":false,"description":"Rescale the steered hidden state to preserve the original per-token L2 norm.","title":"Norm Match","type":"boolean"},"position_indices":{"anyOf":[{"items":{"type":"integer"},"minItems":1,"type":"array"},{"type":"null"}],"default":null,"description":"Absolute token positions to steer; null = broadcast / all positions.","title":"Position Indices"}},"required":["activations","layer_indices"],"title":"SteeringVector","type":"object"},"CompletionsRequest":{"additionalProperties":false,"description":"Validated body for ``POST /v1/completions``.\n\n``extra=\"forbid\"`` means unknown fields produce a 422 from FastAPI (we map\nthat to a 400 in the handler). The intent is that callers paste-from-Modal\nor copy-from-OpenAI-tutorial don't get silent drops on sampler params we\ndon't yet support — they learn about it at the wrapper boundary.\n\nField semantics that aren't obvious from the type:\n  - ``prompt`` is **raw text**; the wrapper never applies a chat template.\n    Lists of strings are accepted (vLLM treats them as a batch).\n  - ``max_tokens=None`` is accepted only for a single prompt with ``n=1``.\n    The route injects a bounded default no larger than 32k or the remaining\n    model context. The wrapper's budget clamp may still reduce this further;\n    see X-Acs-Max-Tokens-Clamped response header.\n  - ``logprobs`` is an integer count of top alternatives per position\n    (vLLM's OpenAI-compatible semantics). 0/None disables, otherwise the\n    value is the top-k size.\n  - ``stop`` accepts up to 4 strings; vLLM rejects longer lists.","properties":{"model":{"anyOf":[{"type":"string"},{"type":"null"}],"default":null,"title":"Model"},"prompt":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"},{"items":{"type":"integer"},"type":"array"},{"items":{"items":{"type":"integer"},"type":"array"},"type":"array"}],"description":"Raw prompt(s): text, list of texts, or token ids. No chat templating.","title":"Prompt"},"max_tokens":{"anyOf":[{"maximum":1000000,"minimum":1,"type":"integer"},{"type":"null"}],"default":null,"title":"Max Tokens"},"n":{"default":1,"maximum":16,"minimum":1,"title":"N","type":"integer"},"temperature":{"default":1.0,"maximum":100.0,"minimum":0.0,"title":"Temperature","type":"number"},"top_p":{"default":1.0,"exclusiveMinimum":0.0,"maximum":1.0,"title":"Top P","type":"number"},"top_k":{"default":-1,"description":"-1 disables; otherwise >= 1.","title":"Top K","type":"integer"},"min_p":{"default":0.0,"maximum":1.0,"minimum":0.0,"title":"Min P","type":"number"},"presence_penalty":{"default":0.0,"maximum":2.0,"minimum":-2.0,"title":"Presence Penalty","type":"number"},"frequency_penalty":{"default":0.0,"maximum":2.0,"minimum":-2.0,"title":"Frequency Penalty","type":"number"},"repetition_penalty":{"default":1.0,"exclusiveMinimum":0.0,"maximum":2.0,"title":"Repetition Penalty","type":"number"},"seed":{"anyOf":[{"minimum":0,"type":"integer"},{"type":"null"}],"default":null,"title":"Seed"},"stop":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"maxItems":4,"type":"array"},{"type":"null"}],"default":null,"title":"Stop"},"logprobs":{"anyOf":[{"maximum":100,"minimum":0,"type":"integer"},{"type":"null"}],"default":null,"title":"Logprobs"},"echo":{"default":false,"title":"Echo","type":"boolean"},"prompt_logprobs":{"anyOf":[{"maximum":100,"minimum":-1,"type":"integer"},{"type":"null"}],"default":null,"title":"Prompt Logprobs"},"stream":{"default":false,"title":"Stream","type":"boolean"},"stream_options":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"default":null,"title":"Stream Options"},"user":{"anyOf":[{"type":"string"},{"type":"null"}],"default":null,"title":"User"},"output_residual_stream":{"anyOf":[{"type":"boolean"},{"items":{"type":"integer"},"type":"array"}],"default":false,"description":"Capture the residual stream. Pass ``true`` for ALL decoder layers, or a list of layer indices (e.g. ``[15, 20]``) to capture only those — the subset is applied on the engine, so only those layers cross the wire (ACS-266, requires vLLM-Lens >= 1.2.0). Returned under a top-level ``activations`` key. Prompt length is capped per model.","title":"Output Residual Stream"},"apply_steering_vectors":{"anyOf":[{"items":{"$ref":"#/components/schemas/SteeringVector"},"type":"array"},{"type":"null"}],"default":null,"description":"Steering vectors added to the residual stream during generation.","title":"Apply Steering Vectors"}},"required":["prompt"],"title":"CompletionsRequest","type":"object"},"HarvestRequest":{"additionalProperties":false,"description":"Body of ``POST /v1/harvest`` — one self-serve bulk-harvest job (ACS-245).\n\nShape validation only (types, ranges, non-empty prompts). The\nsettings-dependent caps — prompt COUNT (``harvest_max_prompts``) and the\nchars/4 estimated-token bound (``harvest_max_est_tokens``) — live in the\nroute, which has ``Settings``; same split as the completions caps.\n``extra=\"forbid\"`` for the same reason as ``CompletionsRequest``: a typo'd\nfield must 400, not silently fall back to a default on a paid GPU job.","properties":{"model":{"title":"Model","type":"string"},"prompts":{"items":{"type":"string"},"minItems":1,"title":"Prompts","type":"array"},"layers":{"anyOf":[{"items":{"type":"integer"},"maxItems":256,"type":"array"},{"const":"all","type":"string"},{"type":"null"}],"default":null,"title":"Layers"},"shard_size":{"default":32,"maximum":4096,"minimum":1,"title":"Shard Size","type":"integer"},"batch_size":{"anyOf":[{"maximum":64,"minimum":1,"type":"integer"},{"type":"null"}],"default":null,"title":"Batch Size"}},"required":["model","prompts"],"title":"HarvestRequest","type":"object"}}}}