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
- 1Point 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"], ) - 2Swap 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_
- 3Prefix 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"}], )
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
| Surface | OpenAI | Ringside | Migration effort |
|---|---|---|---|
| Base URL | api.openai.com/v1 | api.fightclub.pro/v1 | One line |
| Auth header | Authorization: Bearer sk-... | Authorization: Bearer ko_<64 hex> | Swap the env var |
| Model ref | gpt-4o-mini | fc:openai/gpt-4o-mini | Prefix every model string |
| Chat request/response JSON | OpenAI schema | Identical | None |
| Embeddings | /v1/embeddings, OpenAI schema | Identical (model is a bare name here, e.g. text-embedding-3-small) | None |
| Files | /v1/files multipart | Identical (use purpose=attachments, not OpenAI's assistants) | One enum value |
| Batch | /v1/batches JSONL | Identical (completion_window is "24h" only) | None |
| Assistants / threads / runs | OpenAI Assistants | Wire-compatible shim | None on the wire |
| Vector stores | /v1/vector_stores | Wire shape mirrors OpenAI | None on the wire |
| SDK package | openai | Same openai package | None |
| Request id | Response body / header | X-Request-Id response header only, never a body field | Read the header |
| Error body | { error: { type, code, message, param } } | Same shape plus retry_after_seconds on 429s | Branch 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.
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: trueA 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.
| type | When | Typical code |
|---|---|---|
authentication_error | Missing or revoked key/token | invalid_api_key |
invalid_request_error | Bad body, missing field, bad model ref | invalid_model_ref, missing_messages |
permission_error | Key lacks a scope, or a client token hit a forbidden endpoint | insufficient_scope |
rate_limit_error | A rate ceiling was hit (carries retry_after_seconds) | rate_limited, customer_rate_limit_exceeded |
wallet_error | Out of funds or over budget | wallet_empty, customer_budget_exceeded |
conflict_error | State conflict | thread_locked, external_id_taken |
not_found_error | No such object, also returned cross-tenant | not_found |
internal_error / api_error | Server-side or upstream failure | upstream_unavailable |
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") # secondsIdempotency: 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_seconds900 (min 60, max 3600),rpm_limit60 (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/filesrejects client tokens outright with 403endpoint_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) andClient(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.
| Header | Direction | What it does |
|---|---|---|
FC-Customer | request | Attribute the call to a customer (cus_ id, ext:<id> or a plain external id). Drives budgets and reporting. |
FC-Customer-Strict: true | request | Disable lazy-create of an unknown customer; an unknown id then errors instead of being created. |
X-FC-Billable-Amount | request | Override the metered charge in micro-dollars for resale / markup. |
idempotency-key | request | De-dupe retries over a 24 h window. |
X-Request-Id | response | The request id (header only, never in the body). |
X-Ringside-Model-Resolved | response | The concrete fc: model a match:/slot:/dyn: ref resolved to. |
X-Ringside-Wallet-Balance / -Deducted | response | Balance after the call and the exact amount this call cost. |
X-RateLimit-Remaining | response | Requests 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=attachmentswhere OpenAI usedassistants(the OpenAI value is rejected).purpose=batchis required beforePOST /v1/batcheswill accept a JSONL input file. - ·Batch:
completion_windowis"24h"only, max 50,000 requests, priced at half the synchronous per-call rate. Partial failures do not fail the batch; failed lines land inerror_file_id. - ·Assistants/threads/runs: wire-compatible, but spend attribution moves to thread metadata (
metadata.customer_idormetadata.customer_external_id). A function tool pauses the run atrequires_action; answer withsubmit_tool_outputs. One active run per thread, else 409thread_locked. - ·Vector stores: wire shape mirrors OpenAI, queried through the
file_searchtool inside an Assistants run. Ringside addsgraphrag_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.
| Language | Install |
|---|---|
| Python | pip install ringside |
| Node | npm install @ringside/sdk |
| Go | go get github.com/fightclub/ringside-go |
| Java | pro.fightclub:ringside-sdk |
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.