Prefer Swagger UI? Click hereThe same API in the classic OpenAPI explorer.
// how-to · 7 min read

Migrate from OpenAI

Ringside speaks the OpenAI wire protocol. If your code already talks to the OpenAI SDK, moving it to Ringside is three edits: point base_url at https://api.fightclub.pro/v1, swap the API key and prefix every model name with fc:. Your request bodies, response shapes and the openai package itself stay exactly as they are. This page walks the three edits, gives you a precise before/after of what changes and what does not, then covers the gotchas that bite people. Those gotchas are the required model prefix, the Client auth scheme for browser tokens and the request and response headers that are Ringside-only.

Ringside is OpenAI wire-compatible on the endpoints that matter: /v1/chat/completions, /v1/embeddings, /v1/moderations, /v1/files, /v1/batches and the Assistants shim (/v1/assistants, /v1/threads, /v1/threads/{id}/runs, /v1/vector_stores). The request JSON and the response JSON match the OpenAI schemas your SDK already serializes and parses, so the official openai package works unmodified. You keep the SDK you already ship and just repoint it.

Ringside sits in front of 19 providers and routes each call by a prefixed model reference. OpenAI gives you gpt-4o. Ringside wants fc:openai/gpt-4o, or fc:anthropic/claude-..., or a match:/slot:/dyn: reference that resolves to a concrete model at request time. That single prefix is the price of admission and the reason one base URL can reach every provider. Everything else on the wire is OpenAI.

The three edits

  1. 1
    Point base_url at Ringside

    Set the SDK base URL to https://api.fightclub.pro/v1. The /v1 segment is the only versioning Ringside has. There is no version header (no Stripe-Version equivalent).

    client = OpenAI(
        base_url="https://api.fightclub.pro/v1",  # was: default OpenAI URL
        api_key=os.environ["FC_API_KEY"],
    )
  2. 2
    Swap the key

    Drop your OpenAI sk- key and use a Ringside developer key. Format is literally ko_ followed by 64 hex chars, sent as Authorization: Bearer ko_<...>. Create keys in the dashboard at https://ringside.fightclub.pro/app/api-keys. There is no test-vs-live split: one key, scoped by what you grant it.

    export FC_API_KEY=ko_0123456789abcdef...   # 64 hex chars after ko_
  3. 3
    Prefix every model name with fc:

    gpt-4o-mini becomes fc:openai/gpt-4o-mini. A bare name returns 400 invalid_model_ref. This is the one edit that touches your request bodies.

    resp = client.chat.completions.create(
        model="fc:openai/gpt-4o-mini",  # was: "gpt-4o-mini"
        messages=[{"role": "user", "content": "Hello"}],
    )
Optional fourth edit

Add the FC-Customer header to attribute the call to one of your end users (by cus_ id, by ext:<your_id> or by a plain external id string). It drives per-customer budgets, rate limits and usage reporting. Skip it and spend lands on your developer wallet.

What changes, what does not

Three lines of code change. The wire stays OpenAI.
SurfaceOpenAIRingsideMigration effort
Base URLapi.openai.com/v1api.fightclub.pro/v1One line
Auth headerAuthorization: Bearer sk-...Authorization: Bearer ko_<64 hex>Swap the env var
Model refgpt-4o-minifc:openai/gpt-4o-miniPrefix every model string
Chat request/response JSONOpenAI schemaIdenticalNone
Embeddings/v1/embeddings, OpenAI schemaIdentical (model is a bare name here, e.g. text-embedding-3-small)None
Files/v1/files multipartIdentical (use purpose=attachments, not OpenAI's assistants)One enum value
Batch/v1/batches JSONLIdentical (completion_window is "24h" only)None
Assistants / threads / runsOpenAI AssistantsWire-compatible shimNone on the wire
Vector stores/v1/vector_storesWire shape mirrors OpenAINone on the wire
SDK packageopenaiSame openai packageNone
Request idResponse body / headerX-Request-Id response header only, never a body fieldRead the header
Error body{ error: { type, code, message, param } }Same shape plus retry_after_seconds on 429sBranch on code (see below)

A full before/after

Before (OpenAI)

from openai import OpenAI

client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])

resp = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[{"role": "user", "content": "Hello"}],
)
print(resp.choices[0].message.content)

After (Ringside)

from openai import OpenAI

client = OpenAI(
    base_url="https://api.fightclub.pro/v1",
    api_key=os.environ["FC_API_KEY"],
)

resp = client.chat.completions.create(
    model="fc:openai/gpt-4o-mini",
    messages=[{"role": "user", "content": "Hello"}],
    extra_headers={"FC-Customer": "cus_42"},  # optional attribution
)
print(resp.choices[0].message.content)
import OpenAI from 'openai';

const client = new OpenAI({
  baseURL: 'https://api.fightclub.pro/v1',
  apiKey: process.env.FC_API_KEY,
});

const resp = await client.chat.completions.create({
  model: 'fc:openai/gpt-4o-mini',
  messages: [{ role: 'user', content: 'Hello' }],
}, { headers: { 'FC-Customer': 'cus_42' } });
curl https://api.fightclub.pro/v1/chat/completions \
  -H "Authorization: Bearer $FC_API_KEY" \
  -H "FC-Customer: cus_42" \
  -H "Content-Type: application/json" \
  -d '{"model":"fc:openai/gpt-4o-mini","messages":[{"role":"user","content":"Hello"}]}'

Model references in depth

The prefix is not cosmetic. It picks a routing strategy. You have four forms, and only the pinned fc: form takes a region suffix.

fc:<provider>/<model>
Pinned. fc:openai/gpt-4o-mini routes to exactly that model on that provider. This is the literal translation of an OpenAI model name and the one you want for a like-for-like migration.
match:<provider>/<family>
Resolving. match:anthropic/sonnet>=4 picks the newest model in the family that satisfies the constraint. The concrete model it landed on comes back in the X-Ringside-Model-Resolved response header.
slot:<alias>
A developer-defined alias you point at a model in the dashboard, so you can re-target production without a deploy.
dyn:<name>
A dynamic profile that selects a model per request; the choice and the reason come back in X-Ringside-Dynamic-Profile and X-Ringside-Dynamic-Reason.

Append @<region> to an fc: ref for data residency: @eu / @us for OpenAI in-region, @eu-bedrock / @us-bedrock for Claude via AWS Bedrock and @eu-vertex / @us-vertex for Claude via Google Vertex. The suffix attaches to fc: refs only. An unknown suffix is a 400.

The one migration error you will hit first

Forget the prefix on any model string and the call returns 400 invalid_model_ref with type: invalid_request_error. Grep your codebase for every place you set model= before you ship. That is where the work is.

Embeddings are the one exception. On /v1/embeddings the model field is a bare name (text-embedding-3-small, text-embedding-3-large), not an fc: ref. So if you are porting embedding code, leave those model strings alone.

Cost-aware fallback you get for free

Once you are on Ringside, model accepts an array, not just a string. Ringside tries the first model and escalates to the next on a 5xx, an upstream outage or a schema-validation failure, then tells you which one won. You get this reliability win with no new SDK surface.

curl https://api.fightclub.pro/v1/chat/completions \
  -H "Authorization: Bearer $FC_API_KEY" \
  -d '{
    "model": ["fc:openai/gpt-4o-mini", "fc:openai/gpt-4o"],
    "messages": [{"role":"user","content":"Hello"}]
  }'
# Response headers report the outcome:
#   X-Ringside-Model-Used: fc:openai/gpt-4o
#   X-Ringside-Models-Tried: fc:openai/gpt-4o-mini,fc:openai/gpt-4o
#   X-Ringside-Fallback-Triggered: true
Warning

A cascade is mutually exclusive with two things. With stream:true it returns 400 fallback_with_stream_unsupported. With an idempotency-key it returns 400 fallback_with_idempotency_unsupported. Separately, stream:true combined with response_format json_schema returns 400 streaming_with_schema_fallback_unsupported, cascade or not. Pick a single, non-streaming model when you need idempotency or strict-schema output.

Errors: branch on code, not message

The error body is OpenAI-shaped, so existing handlers that read error.code keep working. The shape is { "error": { "type", "code", "message", "param"?, "retry_after_seconds"? } }. type, code and message are always present. param shows up on field-validation errors. retry_after_seconds shows up on rate-limit errors. Always branch on code. The human-readable message can change.

typeWhenTypical code
authentication_errorMissing or revoked key/tokeninvalid_api_key
invalid_request_errorBad body, missing field, bad model refinvalid_model_ref, missing_messages
permission_errorKey lacks a scope, or a client token hit a forbidden endpointinsufficient_scope
rate_limit_errorA rate ceiling was hit (carries retry_after_seconds)rate_limited, customer_rate_limit_exceeded
wallet_errorOut of funds or over budgetwallet_empty, customer_budget_exceeded
conflict_errorState conflictthread_locked, external_id_taken
not_found_errorNo such object, also returned cross-tenantnot_found
internal_error / api_errorServer-side or upstream failureupstream_unavailable
Cross-tenant is 404, not 403

Touch an id that is not yours and you get 404 not_found_error, never 403. That keeps object ids non-enumerable. If you are migrating code that distinguishes "forbidden" from "missing", collapse it: on Ringside a 404 covers both.

Two response-header differences will trip up OpenAI-shaped retry logic. The request id lives in the X-Request-Id response header, never the body, so quote that header in support tickets. And rate-limit headers are X-RateLimit-Remaining plus, on a 429, Retry-After (integer seconds). There is no X-RateLimit-Limit and no X-RateLimit-Reset, so do not key your backoff off headers Ringside does not send.

from openai import APIStatusError

try:
    resp = client.chat.completions.create(
        model="fc:openai/gpt-4o-mini",
        messages=[{"role": "user", "content": "Hi"}],
    )
except APIStatusError as e:
    body = e.response.json()
    code = body["error"]["code"]          # branch on this, not the message
    req_id = e.response.headers.get("X-Request-Id")
    if code == "wallet_empty":
        ...   # top up the developer wallet
    elif code == "rate_limited":
        retry = e.response.headers.get("Retry-After")  # seconds

Idempotency: same idea, exact header name

Retries are de-duplicated with an idempotency-key header. The name is exactly idempotency-key, lowercase. The replay window is 24 hours and the key is scoped to the triple of (developer, customer, key). It works the same on every endpoint that mutates state. One rule trips people up. An idempotency key cannot ride along with a model:[] cascade, and that combination returns 400 fallback_with_idempotency_unsupported.

Gotcha: browser tokens use the Client scheme

If your frontend calls OpenAI directly today, you are almost certainly shipping a secret key to the browser or proxying every call through your backend. Ringside gives you a third option, and it is the one place the auth wire is deliberately not OpenAI.

Mint a short-lived client token server-side, then hand it to the browser. Your backend (holding a developer key with the api:client_tokens scope) calls POST /v1/customers/{id}/client_tokens. The browser then sends the returned JWT as Authorization: Client <jwt>. That is the Client scheme, not Bearer. That scheme is the single bit that tells Ringside this is a scoped browser token and not a developer key.

# server-side: mint a 10-minute, origin-locked, chat-only token
curl https://api.fightclub.pro/v1/customers/cus_42/client_tokens \
  -H "Authorization: Bearer $FC_API_KEY" -H "Content-Type: application/json" \
  -d '{
    "ttl_seconds": 600,
    "scope": ["chat"],
    "rpm_limit": 20,
    "origin_allowlist": ["https://app.example.com"]
  }'
# => { "token":"...", "jti":"ctok_...", "expires_at":"...", "customer_id":"cus_42" }

# browser: note the "Client" scheme, not "Bearer"
curl https://api.fightclub.pro/v1/chat/completions \
  -H "Authorization: Client <token>" -H "Origin: https://app.example.com" \
  -d '{"model":"fc:openai/gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}'
  • ·Token defaults: ttl_seconds 900 (min 60, max 3600), rpm_limit 60 (max 600, clamped to the customer rpm), scope ["chat"].
  • ·Minting is rate-limited to 100/min per customer.
  • ·A client token is rejected at endpoints outside its scope. /v1/files rejects client tokens outright with 403 endpoint_not_allowed_for_client_token.
  • ·Send the wrong word in the header and the parser returns 401 with "Authorization header must start with 'Bearer ' or 'Client '". The only two accepted schemes are Bearer (dev key) and Client (browser token).

Ringside-only headers worth wiring in

None of these are required to migrate. They are the payoff for being on Ringside: attribution, billing control and routing visibility, all over request and response headers your OpenAI code never had.

HeaderDirectionWhat it does
FC-CustomerrequestAttribute the call to a customer (cus_ id, ext:<id> or a plain external id). Drives budgets and reporting.
FC-Customer-Strict: truerequestDisable lazy-create of an unknown customer; an unknown id then errors instead of being created.
X-FC-Billable-AmountrequestOverride the metered charge in micro-dollars for resale / markup.
idempotency-keyrequestDe-dupe retries over a 24 h window.
X-Request-IdresponseThe request id (header only, never in the body).
X-Ringside-Model-ResolvedresponseThe concrete fc: model a match:/slot:/dyn: ref resolved to.
X-Ringside-Wallet-Balance / -DeductedresponseBalance after the call and the exact amount this call cost.
X-RateLimit-RemainingresponseRequests left in the current window.

Rate limits are enforced in four layers and the lowest one wins: edge per-IP, per-client-token, per-developer and per-customer. When you hit one you get a 429 carrying Retry-After and error.retry_after_seconds. Budgets are separate from funds: a customer over its monthly_budget_usd returns 402 customer_budget_exceeded even when your developer wallet still has money.

Edge cases when you port more than chat

  • ·Files: upload is the only multipart endpoint, max 512 MB per file. Use purpose=attachments where OpenAI used assistants (the OpenAI value is rejected). purpose=batch is required before POST /v1/batches will accept a JSONL input file.
  • ·Batch: completion_window is "24h" only, max 50,000 requests, priced at half the synchronous per-call rate. Partial failures do not fail the batch; failed lines land in error_file_id.
  • ·Assistants/threads/runs: wire-compatible, but spend attribution moves to thread metadata (metadata.customer_id or metadata.customer_external_id). A function tool pauses the run at requires_action; answer with submit_tool_outputs. One active run per thread, else 409 thread_locked.
  • ·Vector stores: wire shape mirrors OpenAI, queried through the file_search tool inside an Assistants run. Ringside adds graphrag_enabled, BYOK encryption and per-customer tenant isolation on top.
  • ·Chat body limits: messages serialize to at most 100,000 bytes; image inputs 20 MB (max 2 per message); audio 25 MB; PDF/doc 20 MB (max 2 per message).

When to reach for the Ringside SDK

Keep the openai package for chat, embeddings, files, batch and the Assistants shim. It already does the job. The official Ringside SDK adds typed helpers for the primitives OpenAI has no concept of: the match: builder, customer CRUD, webhooks.verify() and client-token minting. Reach for it when you start using those, not before.

LanguageInstall
Pythonpip install ringside
Nodenpm install @ringside/sdk
Gogo get github.com/fightclub/ringside-go
Javapro.fightclub:ringside-sdk
Migration checklist

Set base_url. Swap the key to ko_<...>. Prefix every chat/assistant model string with fc: (leave embedding model names bare). Read X-Request-Id from the response header. Branch your error handling on error.code. Add FC-Customer when you want attribution. That is the whole migration.