VIK-1266 · compat API quickstart

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.

Values stay in your browser (localStorage) and are never sent anywhere except the requests you trigger in section 6.

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.

Model id. The only accepted model id is 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.

Deploy does not mean open. Even after deployment the endpoints stay gated behind the 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.
Same paths everywhere. Whichever host you use, routes are under /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 new if 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.

shell · from backend/viktor
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.

modal serve output
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.

backend/viktor/.env
VIKTOR_AGENT_BENCH_FEATURE_FLAGS={"compat_api": true, "compat_api_chat_completions": true, "compat_api_responses": true, "compat_api_messages": true}
Restart required. After editing .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_id must 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_V1 from 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=dev and ALLOW_INSECURE_API_KEY_PEPPER=true to use the built-in dev pepper. Both the mint step and the server must have these set.

Where to run the mint

Run the mint inside a Modal container. The dev Neon database is generally not reachable directly from a laptop, so the reliable path is to run the mint snippet inside a container that shares the app spec. Add the snippet as a module, then use modal shell.

Save this as backend/viktor/viktor/mint_compat_key.py.

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.

shell · from backend/viktor
uv run --project viktor modal shell -m viktor.main::fastapi_app_modal -c 'python -m viktor.mint_compat_key'
The raw key is shown once. Only its hash is persisted, so copy the printed value immediately and paste it into the API key field at the top. The key format is 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.

When the in-browser tester works. The Viktor app only allows browser requests from a fixed set of origins: 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.
Ready
No request sent yet.
equivalent curl (always works from a terminal)

      

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.

shell · env-var setup
export VIKTOR_BASE_URL="{{BASE_URL}}"
export VIKTOR_API_KEY="{{API_KEY}}"

Liveness check: list models

GET /api/compat/v1/models
curl "$VIKTOR_BASE_URL/api/compat/v1/models" \
  -H "Authorization: Bearer $VIKTOR_API_KEY"

Chat Completions, non-streaming

POST /api/compat/v1/chat/completions
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

POST /api/compat/v1/chat/completions (stream)
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.

POST /api/compat/v1/messages
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.

shell · env-var setup
export OPENAI_BASE_URL="{{OPENAI_BASE}}"
export OPENAI_API_KEY="{{API_KEY}}"

Non-streaming (env vars)

python
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)

python
from openai import OpenAI

client = OpenAI(
    base_url="{{OPENAI_BASE}}",
    api_key="{{API_KEY}}",
)

Streaming

python
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.

shell · env-var setup
export OPENAI_BASE_URL="{{OPENAI_BASE}}"
export OPENAI_API_KEY="{{API_KEY}}"

Non-streaming (env vars)

typescript
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)

typescript
import OpenAI from "openai";

const client = new OpenAI({
  baseURL: "{{OPENAI_BASE}}",
  apiKey: "{{API_KEY}}",
});

Streaming

typescript
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.

Honest summary. Claude Code is env-only. Codex, OpenCode, and Pi are env-first for the key but need a minimal config file for the custom base URL. None of Codex, OpenCode, or Pi exposes a reliable env-only way to register a brand-new custom OpenAI-compatible base URL as of 2026.

10a. Pi

Which Pi. This configures the Pi coding agent CLI from the pi-mono / earendil-works project (the 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

shell · env-var setup
export VIKTOR_API_KEY="{{API_KEY}}"

Required config file: register the provider

~/.pi/agent/models.json
{
  "providers": {
    "viktor": {
      "baseUrl": "{{OPENAI_BASE}}",
      "api": "openai-completions",
      "apiKey": "$VIKTOR_API_KEY",
      "authHeader": true,
      "models": [
        { "id": "viktor", "name": "Viktor" }
      ]
    }
  }
}
shell
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

shell · env-var setup
export VIKTOR_API_KEY="{{API_KEY}}"

Required config file: register the provider

opencode.json
{
  "$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" }
      }
    }
  }
}
The default model is written as 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.

Codex needs the Responses protocol. Current Codex only supports 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

shell · env-var setup
export VIKTOR_API_KEY="{{API_KEY}}"

Required config file: register the provider

~/.codex/config.toml
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
shell
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.

shell · env-var setup
export ANTHROPIC_BASE_URL="{{ANTHROPIC_BASE}}"
export ANTHROPIC_API_KEY="{{API_KEY}}"
export ANTHROPIC_MODEL="viktor"
export ANTHROPIC_SMALL_FAST_MODEL="viktor"
claude
Auth header and models. Setting 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.
The claude.ai sign-in trap. If Claude Code is signed in to a claude.ai subscription (OAuth), it ignores 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.
Verified working. The Anthropic Messages surface is confirmed working with 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.

ProtocolMethodPathNotes
OpenAI Chat CompletionsPOST/api/compat/v1/chat/completionsStreaming and non-streaming
OpenAI ResponsesPOST/api/compat/v1/responsesUsed by Codex
Anthropic MessagesPOST/api/compat/v1/messagesRequires max_tokens
Anthropic token countPOST/api/compat/v1/messages/count_tokensOptional helper
Models listGET/api/compat/v1/modelsLiveness check
Base URLs. For OpenAI SDK clients set the base to {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

SymptomCause 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.