Viktor OpenAI-compatible API
Drive Viktor agent runs through an OpenAI-compatible and Anthropic-compatible HTTP surface. Fill in the two fields below and every command, config, and code sample on this page updates to your values.
1. Overview
Viktor exposes a compatibility API that speaks three protocols: OpenAI Chat Completions, OpenAI Responses, and Anthropic Messages. Point any client that talks one of those protocols at the endpoint, use the model id viktor, and each request drives a real Viktor agent run.
All routes live under the prefix /api/compat, and each protocol sub-router adds its own /v1/... path. The paths are identical no matter which host you talk to, so the only thing that changes between local and deployed use is the host. See section 2 for that distinction and the endpoint table for the full list.
viktor. Whatever you send in the request model field is echoed back in the response, but the run itself always uses Viktor.2. Where the endpoint lives: local vs deployed
The compat routes are mounted on Viktor's main FastAPI app, the web function fastapi_app_modal in web.py. That is the same app that serves production Viktor, so the surface behaves differently depending on how you reach it.
Local development
There is no standing local endpoint. Each developer spins up their own ephemeral endpoint with modal serve.
- It prints a per-developer URL like
https://zetalabs-ai-dev--viktor-{user}-dev.modal.run. - It hot-reloads on file changes and tears down when you press Ctrl-C.
- This is the mode the rest of this quickstart walks through.
Deployed
Once the stack is merged and deployed, the same routes ride on Viktor's regular stable API hosts.
- Staging:
https://staging-api.viktor.com/api/compat/v1/... - Production:
https://api.viktor.com/api/compat/v1/... - PHI production (
phi-api.viktor.com): hard-refused with HTTP 403 by design.
The deploy path is the standard Viktor one: merging to main auto-deploys dev and then staging, and production is a manual promote. PHI is refused on purpose because compat is a Standard-tier, non-PHI feature.
compat_api master flag plus the per-endpoint flags, and they fail closed. They only answer for enabled actors until those flags are turned on./api/compat/v1/.... For OpenAI SDK clients the base URL is {HOST}/api/compat/v1. For Anthropic and Claude Code the base is {HOST}/api/compat.3. Prerequisites
- The repo checked out locally, with your work happening from
backend/viktor. - uv installed, since all Python runs go through
uv run --project viktor .... - Modal authenticated on your machine (
modal token newif you have never set it up). - For the production key pepper, the 1Password CLI (
op) with vault access. For purely local dev you can instead use the built-in dev-only pepper described in section 5.
4. Start the API locally
From backend/viktor, start the served app. The web function is fastapi_app_modal.
uv run --project viktor modal serve -m viktor.main
Read the base URL from the output. Modal prints a line like the following, and the host is your base. Paste that host into the Base URL field at the top of this page.
Created web function fastapi_app_modal => https://zetalabs-ai-dev--viktor-<user>-dev.modal.run
Enable the feature flags
The compat surface is gated by feature flags and fails closed. To force-enable it locally without PostHog, add a single line to backend/viktor/.env. That file is loaded into the Modal container as a dotenv secret.
VIKTOR_AGENT_BENCH_FEATURE_FLAGS={"compat_api": true, "compat_api_chat_completions": true, "compat_api_responses": true, "compat_api_messages": true}
.env you must restart modal serve for the new flags to load. PHI environments always refuse the compat surface with a 403 and cannot be flag-enabled.5. Create an API key
You need a personal API key with the scope chat:completions. Two facts matter before you mint one.
- Identity requirement. The key's
actor_user_idmust be a real user that has a linked Slack identity. Auth can pass and the run will still be denied at identity resolution if that link is missing. - Pepper match. Key hashing uses a pepper. The minting process and the running server must share the same pepper, or the key will never validate.
Pepper options
- Production pepper. Pull
API_KEY_PEPPER_V1from the vault into your env file:op inject -i backend/viktor/.env.1password >> backend/viktor/.env. - Dev-only pepper. For purely local dev, set
APP_ENV=devandALLOW_INSECURE_API_KEY_PEPPER=trueto use the built-in dev pepper. Both the mint step and the server must have these set.
Where to run the mint
modal shell.Save this as backend/viktor/viktor/mint_compat_key.py.
import asyncio
from db.db import PublicApiKeyDBModel
from viktor.apis.public_api.security import (
generate_public_api_key,
get_current_scope_registry_version,
)
from viktor.db_ops.public_api_db_ops import create_public_api_key_db
# A real user id that has a linked Slack identity. Without the Slack link
# the run is denied at identity resolution even though auth succeeds.
ACTOR_USER_ID = "user_replace_me"
async def main() -> None:
raw_key, public_id, key_preview, secret_hash, pepper_version = (
generate_public_api_key()
)
api_key = PublicApiKeyDBModel(
name="compat-quickstart",
key_type="personal",
team_id=None,
actor_user_id=ACTOR_USER_ID,
created_by_user_id=ACTOR_USER_ID,
public_id=public_id,
key_preview=key_preview,
secret_hash=secret_hash,
pepper_version=pepper_version,
scopes=["chat:completions"],
scope_registry_version=get_current_scope_registry_version(),
)
await create_public_api_key_db(api_key)
# The raw key is returned only once. Only its hash is stored.
print("RAW KEY (copy now, shown once):", raw_key)
if __name__ == "__main__":
asyncio.run(main())
Run it in a container that shares the app spec, from backend/viktor.
uv run --project viktor modal shell -m viktor.main::fastapi_app_modal -c 'python -m viktor.mint_compat_key'
zt_live_sk_<public_id>_<secret>. Keep it out of source control.6. Try it out (live)
This panel sends a real request from your browser using the Base URL and API key from the top of the page. Pick an endpoint, edit the payload if you want, and press Send. The equivalent curl command is always shown below so you have a guaranteed path from a terminal.
viktor.com and its subdomains, the zetalabs Vercel preview hosts, viktor.space subdomains, and localhost or 127.0.0.1. This published page is served from an r2.dev origin, which is not on that list, so a request sent straight from the published page is blocked by the browser CORS check. To use the tester live, open this HTML from an allowed origin, for example serve the file at http://localhost and point it at your own modal serve host. In every case the copyable curl below works from a terminal.No request sent yet.
7. curl
Export your base and key once, then reuse them. Both Authorization: Bearer <key> and x-api-key: <key> are accepted on every compat endpoint. If you send both, x-api-key wins.
export VIKTOR_BASE_URL="{{BASE_URL}}"
export VIKTOR_API_KEY="{{API_KEY}}"
Liveness check: list models
curl "$VIKTOR_BASE_URL/api/compat/v1/models" \ -H "Authorization: Bearer $VIKTOR_API_KEY"
Chat Completions, non-streaming
curl "$VIKTOR_BASE_URL/api/compat/v1/chat/completions" \
-H "Authorization: Bearer $VIKTOR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "viktor",
"messages": [{"role": "user", "content": "Say hello in one sentence."}]
}'
Chat Completions, streaming
curl -N "$VIKTOR_BASE_URL/api/compat/v1/chat/completions" \
-H "Authorization: Bearer $VIKTOR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "viktor",
"stream": true,
"messages": [{"role": "user", "content": "Stream a short greeting."}]
}'
Anthropic Messages
The Anthropic surface requires max_tokens and conventionally uses the x-api-key header.
curl "$VIKTOR_BASE_URL/api/compat/v1/messages" \
-H "x-api-key: $VIKTOR_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "Content-Type: application/json" \
-d '{
"model": "viktor",
"max_tokens": 256,
"messages": [{"role": "user", "content": "Say hello in one sentence."}]
}'
8. Python OpenAI SDK
The OpenAI Python SDK reads OPENAI_BASE_URL and OPENAI_API_KEY from the environment automatically, so the env-var setup is the shortest path.
export OPENAI_BASE_URL="{{OPENAI_BASE}}"
export OPENAI_API_KEY="{{API_KEY}}"
Non-streaming (env vars)
from openai import OpenAI
# Reads OPENAI_BASE_URL and OPENAI_API_KEY from the environment.
client = OpenAI()
resp = client.chat.completions.create(
model="viktor",
messages=[{"role": "user", "content": "Say hello in one sentence."}],
)
print(resp.choices[0].message.content)
Explicit constructor arguments (alternative)
from openai import OpenAI
client = OpenAI(
base_url="{{OPENAI_BASE}}",
api_key="{{API_KEY}}",
)
Streaming
stream = client.chat.completions.create(
model="viktor",
messages=[{"role": "user", "content": "Stream a short greeting."}],
stream=True,
)
for chunk in stream:
delta = chunk.choices[0].delta.content
if delta:
print(delta, end="", flush=True)
print()
9. TypeScript OpenAI SDK
The Node SDK also reads OPENAI_BASE_URL and OPENAI_API_KEY automatically.
export OPENAI_BASE_URL="{{OPENAI_BASE}}"
export OPENAI_API_KEY="{{API_KEY}}"
Non-streaming (env vars)
import OpenAI from "openai";
// Reads OPENAI_BASE_URL and OPENAI_API_KEY from the environment.
const client = new OpenAI();
const resp = await client.chat.completions.create({
model: "viktor",
messages: [{ role: "user", content: "Say hello in one sentence." }],
});
console.log(resp.choices[0].message.content);
Explicit constructor arguments (alternative)
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "{{OPENAI_BASE}}",
apiKey: "{{API_KEY}}",
});
Streaming
const stream = await client.chat.completions.create({
model: "viktor",
messages: [{ role: "user", content: "Stream a short greeting." }],
stream: true,
});
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content;
if (delta) process.stdout.write(delta);
}
process.stdout.write("\n");
10. Agentic harness configuration
Each harness below is pointed at your host with model id viktor. For OpenAI-protocol harnesses the base is {HOST}/api/compat/v1. For Claude Code the base is the Anthropic surface described in its section. All snippets reflect the fields at the top of the page.
Every subsection leads with the environment-variable method and falls back to a config file only where the tool genuinely needs one. In practice Claude Code is fully env-configurable, while Codex, OpenCode, and Pi can take the API key from an env var but still require a small config-file entry to select a custom base URL. Where a config file is required, it is shown as a clearly labeled secondary step and always pulls the key from an env var rather than hardcoding it.
10a. Pi
pi command), confirmed against its current provider docs. If you meant a different tool named Pi, treat this as the generic OpenAI-compatible recipe and adjust field names to that tool.Config file required for the base URL. Pi is multi-provider and deliberately does not honor a global base-URL env var. The maintainer closed that request in favor of declaring custom providers in ~/.pi/agent/models.json, so a brand-new custom base URL must live in that file. The API key is the env-var knob: Pi's apiKey field interpolates $VIKTOR_API_KEY, so export the key rather than hardcoding it.
Primary: export the key
export VIKTOR_API_KEY="{{API_KEY}}"
Required config file: register the provider
{
"providers": {
"viktor": {
"baseUrl": "{{OPENAI_BASE}}",
"api": "openai-completions",
"apiKey": "$VIKTOR_API_KEY",
"authHeader": true,
"models": [
{ "id": "viktor", "name": "Viktor" }
]
}
}
}
pi --provider viktor --model viktor # verify the provider loaded: pi --list-models viktor
10b. OpenCode
Config file required for the base URL. A new custom OpenAI-compatible provider has to be declared as a provider block in opencode.json (project level, or global at ~/.config/opencode/opencode.json). A bare OPENAI_BASE_URL env var does not register a custom provider in OpenCode. The API key is the env-var knob: the apiKey option interpolates {env:VIKTOR_API_KEY}, so export it and reference it. The @ai-sdk/openai-compatible package targets /v1/chat/completions.
Primary: export the key
export VIKTOR_API_KEY="{{API_KEY}}"
Required config file: register the provider
{
"$schema": "https://opencode.ai/config.json",
"model": "viktor/viktor",
"provider": {
"viktor": {
"npm": "@ai-sdk/openai-compatible",
"name": "Viktor",
"options": {
"baseURL": "{{OPENAI_BASE}}",
"apiKey": "{env:VIKTOR_API_KEY}"
},
"models": {
"viktor": { "name": "Viktor" }
}
}
}
}
provider_id/model_id, so viktor/viktor here. Restart OpenCode after editing the config. If you prefer the Responses protocol over Chat Completions, swap the package to @ai-sdk/openai and keep the same base URL.10c. Codex (OpenAI Codex CLI)
Config file required for the base URL. Codex registers a custom endpoint only through a [model_providers.*] block in the user-level ~/.codex/config.toml (provider and base-url keys are ignored in project-local config for security). The old OPENAI_BASE_URL env var is deprecated, applies only to the reserved built-in openai provider, and prints a warning, so it is not a path for a custom Viktor provider. The API key is the env-var knob: the key comes from the environment variable named by env_key, so export it.
wire_api = "responses", so it must hit the compat Responses endpoint, not Chat Completions. Viktor exposes /api/compat/v1/responses, so the same {HOST}/api/compat/v1 base works. Set requires_openai_auth = false so Codex does not assume an sk- key prefix.Primary: export the key
export VIKTOR_API_KEY="{{API_KEY}}"
Required config file: register the provider
model = "viktor"
model_provider = "viktor"
[model_providers.viktor]
name = "Viktor"
base_url = "{{OPENAI_BASE}}"
env_key = "VIKTOR_API_KEY"
wire_api = "responses"
requires_openai_auth = false
codex
10d. Claude (Claude Code)
Fully env-configurable. Claude Code needs no config file. It talks the Anthropic Messages protocol and appends /v1/messages to whatever base URL you give it, so set the base to the compat prefix {HOST}/api/compat (not /api/compat/v1). The client then calls {HOST}/api/compat/v1/messages, which is the Viktor Anthropic endpoint. This base-path expectation is confirmed against the current Claude Code gateway docs.
export ANTHROPIC_BASE_URL="{{ANTHROPIC_BASE}}"
export ANTHROPIC_API_KEY="{{API_KEY}}"
export ANTHROPIC_MODEL="viktor"
export ANTHROPIC_SMALL_FAST_MODEL="viktor"
claude
ANTHROPIC_API_KEY sends the key as x-api-key, which the compat endpoint accepts. If you prefer a bearer token, use ANTHROPIC_AUTH_TOKEN instead, which sends Authorization: Bearer. ANTHROPIC_MODEL=viktor forces the accepted model id for the main model, and ANTHROPIC_SMALL_FAST_MODEL=viktor points the lightweight background model at Viktor too, since only viktor is accepted. You can also persist these under an env block in ~/.claude/settings.json.ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL and talks to the real Anthropic API instead. A request for model viktor then fails with a message like "the selected model (viktor) may not exist or you may not have access to it." The CLI hints at the fix with a line like "to use ANTHROPIC_API_KEY: claude /logout to sign out of claude.ai." Run claude /logout to sign out, then export the env vars above and start a fresh session so Claude Code falls back to ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL.model: viktor: a normal request returns a well-formed message with stop_reason end_turn. So the "model may not exist" symptom is a client auth-mode problem (still signed in to claude.ai), not an endpoint problem.Endpoint reference
All paths are relative to your host, whether that is a local modal serve URL or a deployed Viktor API host. Every endpoint requires the scope chat:completions and accepts either Authorization: Bearer or x-api-key.
| Protocol | Method | Path | Notes |
|---|---|---|---|
| OpenAI Chat Completions | POST | /api/compat/v1/chat/completions | Streaming and non-streaming |
| OpenAI Responses | POST | /api/compat/v1/responses | Used by Codex |
| Anthropic Messages | POST | /api/compat/v1/messages | Requires max_tokens |
| Anthropic token count | POST | /api/compat/v1/messages/count_tokens | Optional helper |
| Models list | GET | /api/compat/v1/models | Liveness check |
{HOST}/api/compat/v1. For Claude Code set ANTHROPIC_BASE_URL to {HOST}/api/compat so the client appends /v1/messages for you.11. Troubleshooting and gotchas
| Symptom | Cause and fix |
|---|---|
403 with chat:completions scope required |
The key lacks the required scope. Mint a key whose scopes include chat:completions. |
403 with compat_api_not_enabled |
The feature flags are off and the surface fails closed. Add the VIKTOR_AGENT_BENCH_FEATURE_FLAGS line to .env and restart modal serve. In deployed environments the actor must be enabled for the compat_api flag set. |
| Auth passes but the run is denied | Identity resolution failed. A personal key's actor_user_id must map to a real user with a linked Slack identity. |
429 under load |
You hit the concurrency cap of 8 concurrent runs per key (COMPAT_MAX_CONCURRENT_RUNS_PER_KEY). Reduce parallelism or use more keys. |
| Replay returns an empty body | The opt-in Idempotency-Key request header deduplicates requests, but a replay currently returns a well-formed yet empty body (content null, finish_reason stop) because the token stream is not persisted. Treat a replay as an acknowledgement, not a re-delivery of content. |
usage reports zero tokens |
Known cross-process gap. Token counts currently come back as zero and should not be relied on for accounting yet. |
| PHI environment refuses everything | PHI environments hard-refuse the compat surface with a 403 and cannot be flag-enabled. Use a non-PHI environment such as api.viktor.com or staging-api.viktor.com. |
Claude Code says model viktor may not exist |
Claude Code is still signed in to a claude.ai subscription, so it ignores ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL and hits the real Anthropic API. Run claude /logout, then set the env vars and start a fresh session. The endpoint itself is verified working with model: viktor (returns stop_reason end_turn), so this is a client auth-mode issue. |
| Browser tester shows a network or CORS error | The published r2.dev page origin is not allow-listed by the app. Open this HTML from an allowed origin such as localhost, or use the copyable curl command from a terminal. |
| Long request cut off | A single run has a duration cap of roughly 600 seconds. Break very long tasks into smaller requests. |
| Flag change had no effect | modal serve loads .env at boot. Restart it after any .env edit. |