OpenAI Compatible API (unified)
The LLM OpenAI Compatible API plugin is a unified backend plugin that exposes a single Otoroshi route as a full-featured, multi-endpoint AI API. Instead of configuring separate plugins for chat completions, audio, images, embeddings, moderation, and responses, this single plugin handles all of them with a consistent configuration.
cp:otoroshi_plugins.com.cloud.apim.otoroshi.extensions.aigateway.plugins.OpenAiCompatApi
Why use this plugin?
When building an AI gateway, you typically need to expose several API endpoints: chat completions, embeddings, image generation, audio transcription, moderation, etc. With the individual plugins, you would need to configure each one separately on the same route, mapping each to a specific path.
The OpenAI Compatible API plugin simplifies this by providing a single plugin that routes requests based on the URL path to the appropriate handler. It also goes beyond the standard OpenAI API by supporting additional endpoints:
- Anthropic Messages API (
/messages) for clients like Claude Code - OpenAI Responses API (
/responses) for the newer responses format - Open Responses (
/open-responses) for the Open Responses specification - Prompt contexts (
/contexts) for listing available prompt contexts
This makes it the ideal choice when you want to expose a single, coherent API surface that supports multiple AI client ecosystems.
Supported endpoints
| Method | Path | Description | Config field |
|---|---|---|---|
GET | /models | List available models from all configured providers | language_model_refs |
GET | /providers | List every provider type the gateway can talk to, with their capabilities | — |
GET | /model-capabilities | List the model types (modalities) the gateway supports | — |
GET | /contexts | List available prompt contexts | context_refs |
POST | /chat/completions | OpenAI Chat Completions API | language_model_refs |
POST | /responses | OpenAI Responses API (or Open Responses if configured) | language_model_refs |
POST | /open-responses | Open Responses API (always available) | language_model_refs |
POST | /oai-responses | OpenAI Responses API (always available) | language_model_refs |
POST | /messages | Anthropic Messages API | language_model_refs |
POST | /embeddings | OpenAI Embeddings API | embedding_model_refs |
POST | /images/generations | OpenAI Image Generation API | image_model_refs |
POST | /images/edits | OpenAI Image Edit API | image_model_refs |
POST | /audio/speech | OpenAI Text-to-Speech API | audio_model_refs |
POST | /audio/transcriptions | OpenAI Speech-to-Text API | audio_model_refs |
POST | /audio/translations | OpenAI Audio Translation API | audio_model_refs |
POST | /moderations | OpenAI Moderation API | moderation_model_refs |
POST | /ocr | OCR (text extraction from images and pdf) | ocr_model_refs |
POST | /mcp | MCP Streamable HTTP endpoint serving an MCP virtual server | mcp_server_ref |
Plugin configuration
{
"plugin": "cp:otoroshi_plugins.com.cloud.apim.otoroshi.extensions.aigateway.plugins.OpenAiCompatApi",
"enabled": true,
"config": {
"language_model_refs": ["provider_openai_1", "provider_mistral_1"],
"audio_model_refs": ["audio_model_1"],
"image_model_refs": ["image_model_1"],
"ocr_model_refs": ["ocr_model_1"],
"embedding_model_refs": ["embedding_model_1"],
"moderation_model_refs": ["moderation_model_1"],
"context_refs": ["context_1", "context_2"],
"max_size_upload": 104857600,
"decode_images": false,
"use_open_response_for_responses": false
}
}
| Parameter | Type | Default | Description |
|---|---|---|---|
language_model_refs | array of strings | [] | References to LLM provider entities used for chat completions, responses, and messages endpoints |
audio_model_refs | array of strings | [] | References to audio model entities used for speech and transcription endpoints |
image_model_refs | array of strings | [] | References to image model entities used for image generation and editing |
embedding_model_refs | array of strings | [] | References to embedding model entities used for the embeddings endpoint |
moderation_model_refs | array of strings | [] | References to moderation model entities used for the moderations endpoint |
ocr_model_refs | array of strings | [] | References to OCR model entities used for the /ocr endpoint |
context_refs | array of strings | [] | References to prompt context entities returned by the /contexts endpoint |
max_size_upload | number | 104857600 (100MB) | Maximum file upload size in bytes for audio, image, and OCR endpoints |
decode_images | boolean | false | When enabled, decodes base64-encoded image results into binary responses |
use_open_response_for_responses | boolean | false | When enabled, the /responses endpoint uses the Open Responses proxy instead of the default OpenAI Responses proxy |
mcp_server_ref | string | null | Reference to the MCP virtual server served on /mcp. Without it (or when the server is disabled), /mcp answers 404 |
Route configuration example
{
"id": "route_unified_ai_api",
"name": "Unified AI API",
"frontend": {
"domains": ["api.domain.tld/v1"],
"strip_path": true,
"exact": false
},
"backend": {
"targets": [
{
"id": "target_1",
"hostname": "request.otoroshi.io",
"port": 443,
"tls": true
}
]
},
"plugins": [
{
"enabled": true,
"plugin": "cp:otoroshi.next.plugins.OverrideHost",
"config": {}
},
{
"enabled": true,
"plugin": "cp:otoroshi_plugins.com.cloud.apim.otoroshi.extensions.aigateway.plugins.OpenAiCompatApi",
"config": {
"language_model_refs": ["provider_openai_1", "provider_mistral_1"],
"embedding_model_refs": ["embedding_model_1"],
"image_model_refs": ["image_model_1"],
"audio_model_refs": ["audio_model_1"],
"context_refs": ["context_project_a"],
"use_open_response_for_responses": false
}
}
]
}
Endpoint details
GET /models
Lists all models available from the configured language model providers. Compatible with the OpenAI Models API.
curl https://api.domain.tld/v1/models \
-H "Authorization: Bearer $OTOROSHI_BEARER"
When multiple providers are configured, model IDs are prefixed with the provider slug name (e.g., my_openai/gpt-4o, or my_openrouter###openai/gpt-4o when the model id already contains a /).
Query parameters
| Parameter | Values | Description |
|---|---|---|
enriched | true, 1 or no value | Adds a _metadata object to each model: types, cost, API, capabilities, modalities, limits and prices. See enriched models |
kind (or kinds) | text, audio, image, ocr, embedding, moderation, video | Only lists the models of at least one of these types. See filter models by type |
endpoint (or endpoints) | chat_completions, completions, responses, embeddings, images_generations, images_edits, audio_speech, audio_transcriptions, audio_translations, moderations, ocr, realtime, videos | Only lists the models served on at least one of these OpenAI API endpoints. See filter models by endpoint |
has_cost | true, 1 or no value / false, 0 | Only lists the models cost tracking can (or cannot) put a price on. See filter models by cost |
raw | true | Asks the provider for the model ids of its API rather than its model names, for the providers making the difference (OVH AI Endpoints, when not unified) |
kind and endpoint accept several values, by repeating the parameter or with a comma separated list, and values are case insensitive. Filters combine: a model is listed when it matches all of them. They never add the _metadata object by themselves, add enriched=true for that.
curl "https://api.domain.tld/v1/models?kind=text&endpoint=chat_completions&has_cost=true&enriched=true" \
-H "Authorization: Bearer $OTOROSHI_BEARER"
Enriched models
Add ?enriched=true to get everything you need to build a model picker, route by capability or show prices in your own UI: each model comes with a _metadata object describing what it can do, how much it can take and what it costs.
curl "https://api.domain.tld/v1/models?enriched=true" \
-H "Authorization: Bearer $OTOROSHI_BEARER"
Response:
{
"object": "list",
"data": [
{
"id": "gpt-4o",
"object": "model",
"created": 1789575515,
"owned_by": "OpenAI",
// ...
"_metadata": {
"kinds": ["text"],
"has_cost": true,
"openai_compatible": true,
"endpoints": ["chat_completions"],
"mode": "chat",
"capabilities": {
"reasoning": false,
"tool_call": true,
"structured_output": true,
"temperature": true,
"attachment": true,
"prompt_caching": true
},
"modalities": {
"input": ["text", "image", "pdf"],
"output": ["text"]
},
"limits": {
"context": 128000,
"output": 16384
},
"pricing": {
"prompt": "0.0000025",
"completion": "0.00001",
"input_cache_read": "0.00000125"
},
"knowledge": "2023-09",
"sources": {
"catalog": { "provider": "openai", "model": "gpt-4o", "match": "exact" },
"pricing": { "source": "price-table", "model": "gpt-4o" }
}
}
}
]
}
The metadata combines the models.dev catalog, bundled with the extension, with the price table used by cost tracking:
| Field | Description |
|---|---|
kinds | The model types the model belongs to: text, audio, image, ocr, embedding, moderation, video |
has_cost | Whether cost tracking puts a cost on the calls to this model, so they count against your dollar budgets |
openai_compatible | Whether the model is served through an OpenAI compatible API: the gateway talks to its provider in the OpenAI format (OpenAI, Azure OpenAI, Groq, xAI, Gemini, OVHcloud, OpenRouter and every OpenAI compatible provider), the provider serves this very model that way, and on at least one OpenAI endpoint. Models of Anthropic, Mistral, Cohere or Ollama are false: the gateway translates for them. Left out for routers, whose models are served by other providers |
endpoints | The OpenAI API endpoints the provider serves the model on, when it is openai_compatible (see below) |
mode | What the price table bills the model as (chat, responses, embedding, ...), when it knows it |
capabilities.reasoning, capabilities.reasoning_options | Whether the model thinks before answering, and the reasoning controls it accepts (effort levels, token budget) |
capabilities.tool_call, capabilities.structured_output | Tool calling and JSON schema outputs support |
capabilities.temperature, capabilities.attachment | Temperature control and file attachments support |
capabilities.prompt_caching, capabilities.web_search | Prompt caching and built-in web search support |
modalities.input, modalities.output | Accepted and produced content: text, image, pdf, audio, video |
limits.context, limits.input, limits.output | Context window, maximum input and maximum output, in tokens |
pricing | The prices cost tracking bills the model with, in dollars, as strings, in the OpenRouter format: per token for prompt, completion, input_cache_read, input_cache_write, internal_reasoning, audio, audio_output, per image for image, image_output. Models billed in another unit get input_second and output_second (per second of audio), input_character (per character of text to speak) or video_second (per second of generated video) instead of token prices |
knowledge | Knowledge cutoff |
status, deprecation_date | beta or deprecated status, and the announced deprecation date |
sources | Where the metadata comes from (see below) |
Fields the sources know nothing about are left out: a model nobody knows only comes with its kinds, guessed from its name, and has_cost.
The prices are exactly the ones your budgets are charged with, in dollars: sources.pricing.source is price-table when they come from the price table (including your own custom-prices), and models.dev when the catalog fills the gap. Prices published in another currency, like the euros of Scaleway and OVHcloud, are converted, and sources.pricing gives their currency and the exchange_rate applied.
Providers rarely spell a model id the same way, so each listed model is matched against what its own provider serves first, then against the rest of the catalog. sources.catalog.match tells how the model was recognized:
| Match | Meaning | Pricing |
|---|---|---|
exact | The very id the provider serves (models/gemini-2.5-flash is gemini-2.5-flash) | ✅ |
normalized | The same model once cosmetic differences are removed: case, dated snapshots, . versus -, routing variants like :free | ✅ |
approximate | The same model family for this provider, a short version suffix like -2411 or -001 aside | ❌ |
global, global-approximate | The same model served by another provider, which is perfect for capabilities and limits, but not for prices | ❌ |
Filter models by type
Add ?kind= to only list the models of one of the model types Otoroshi LLM supports: text, audio, image, ocr, embedding, moderation or video. It is the quickest way to find the model to put in an embedding model, an audio model or an image model entity, among everything a provider serves.
curl "https://api.domain.tld/v1/models?kind=embedding" -H "Authorization: Bearer $OTOROSHI_BEARER"
Ask for several types by repeating the parameter or with a comma separated list (?kind=image&kind=video, ?kind=image,video): a model is listed as soon as it belongs to one of them. The filter combines with ?enriched=true.
A model can belong to several types, an omni model both chats and speaks for instance. The types are worked out from what the model is for, then from what it produces. A model only served on image or realtime endpoints is not a text model, even when it answers with some text:
| Type | Models |
|---|---|
embedding | Embedding models (text-embedding-3-small, mistral-embed, e5-large-v2...) |
moderation | Moderation and safety models (omni-moderation-latest, llama-guard-4-12b, gpt-oss-safeguard-20b...) |
ocr | Document text extraction models (mistral-ocr-latest, deepseek-ocr, paddleocr-vl...), and every model of an OCR provider like AlphaEdge |
audio | Models producing audio (tts-1, gpt-4o-mini-tts, omni models) and speech recognition models (whisper-1, gpt-4o-transcribe, qwen3-asr-flash) |
image | Models producing images (gpt-image-1, gemini-2.5-flash-image, flux...) |
video | Models producing videos (sora-2, veo-3...) |
text | Models producing text, apart from the ones above: a chat model that understands audio or images stays a text model |
Rerankers belong to none of these types, so they only show up when no filter is set.
Filter models by endpoint
Add ?endpoint= to only list the models their provider serves on one of these OpenAI API endpoints: chat_completions, completions, responses, embeddings, images_generations, images_edits, audio_speech, audio_transcriptions, audio_translations, moderations, ocr, realtime or videos. It keeps out of a chat the models that would fail there, like gpt-5-pro, only served on responses, or the realtime models.
curl "https://api.domain.tld/v1/models?endpoint=chat_completions" \
-H "Authorization: Bearer $OTOROSHI_BEARER"
The endpoints come from the price table when it lists them (gpt-5.5 is served on chat_completions and responses, gpt-image-1 on images_generations and images_edits), from what the price table bills the model as otherwise, and from the model types as a last resort. They are only given for the openai_compatible models, so the filter only keeps those. Repeat the parameter or use a comma separated list to accept several endpoints.
Filter models by cost
Add ?has_cost=true to only list the models cost tracking can put a price on, and ?has_cost=false for the others. Perfect to offer only the models your dollar budgets can keep in check, or to spot the ones still missing a custom price.
curl "https://api.domain.tld/v1/models?kind=text&has_cost=true" \
-H "Authorization: Bearer $OTOROSHI_BEARER"
A model has a cost exactly when a call to it would be billed: the provider is priced by cost tracking, and either its price is known (from your custom prices, the price table or the models.dev catalog, honouring the costs-tracking-provider and costs-tracking-model metadata of the provider) or the provider reports the cost of each call itself, like OpenRouter.
?enriched=true, ?kind=, ?endpoint= and ?has_cost= combine, and also work on the models list plugins.
GET /contexts
Returns the list of prompt contexts configured for this plugin. Each context includes its id and name.
curl https://api.domain.tld/v1/contexts \
-H "Authorization: Bearer $OTOROSHI_BEARER"
Response:
[
{ "id": "context_project_a", "name": "Project A context" },
{ "id": "context_support", "name": "Support context" }
]
GET /providers
Returns the catalog of every provider the gateway can connect to, each with the capabilities (modalities) it supports. This is a great way to build a provider picker in your own UI, or to discover programmatically which providers can handle a given task — text, audio, image, OCR, embeddings, moderation or video.
curl https://api.domain.tld/v1/providers \
-H "Authorization: Bearer $OTOROSHI_BEARER"
Response:
{
"object": "list",
"data": [
{ "id": "openai", "label": "OpenAI", "capabilities": ["text", "audio", "image", "embedding", "moderation"] },
{ "id": "anthropic", "label": "Anthropic", "capabilities": ["text"] },
{ "id": "mistral", "label": "Mistral", "capabilities": ["text", "audio", "ocr", "embedding", "moderation"] },
{ "id": "ovh-ai-endpoints", "label": "OVH AI Endpoints", "capabilities": ["text", "audio", "image", "embedding", "moderation"] }
]
}
Filtering by capability
Pass one or more capabilities query parameters to keep only the providers that expose all of them. The parameter is repeatable and also accepts a comma-separated list, so the two calls below are equivalent:
curl "https://api.domain.tld/v1/providers?capabilities=image&capabilities=text" \
-H "Authorization: Bearer $OTOROSHI_BEARER"
curl "https://api.domain.tld/v1/providers?capabilities=image,text" \
-H "Authorization: Bearer $OTOROSHI_BEARER"
This returns only the providers that can do both image and text.
The same catalog is also available as a standalone plugin you can put on its own route:
cp:otoroshi_plugins.com.cloud.apim.otoroshi.extensions.aigateway.plugins.LlmProvidersCatalog
GET /model-capabilities
Returns the model types (modalities) the gateway supports — text, audio, image, ocr, embedding, moderation and video — each with the list of providers that expose it. It is the mirror image of /providers: where /providers answers "what can this provider do?", /model-capabilities answers "which providers can do this?".
curl https://api.domain.tld/v1/model-capabilities \
-H "Authorization: Bearer $OTOROSHI_BEARER"
Response:
{
"object": "list",
"data": [
{ "id": "text", "label": "Text", "providers": ["anthropic", "openai", "mistral", "..."] },
{ "id": "audio", "label": "Audio", "providers": ["openai", "groq", "elevenlabs", "mistral", "ovh-ai-endpoints", "..."] },
{ "id": "image", "label": "Image", "providers": ["openai", "gemini", "luma", "ovh-ai-endpoints", "..."] },
{ "id": "ocr", "label": "OCR", "providers": ["alphaedge", "mistral"] },
{ "id": "embedding", "label": "Embedding", "providers": ["openai", "mistral", "cohere", "ovh-ai-endpoints", "..."] },
{ "id": "moderation", "label": "Moderation", "providers": ["openai", "mistral", "ovh-ai-endpoints"] },
{ "id": "video", "label": "Video", "providers": ["luma", "openrouter"] }
]
}
The same list is also available as a standalone plugin you can put on its own route:
cp:otoroshi_plugins.com.cloud.apim.otoroshi.extensions.aigateway.plugins.LlmModelCapabilities
Both catalogs are exposed on the Otoroshi admin API as well (authenticated with an admin API key), so you can query them without exposing a route:
GET /api/extensions/cloud-apim/extensions/ai-extension/providersGET /api/extensions/cloud-apim/extensions/ai-extension/model-capabilities
curl "https://otoroshi-api.domain.tld/api/extensions/cloud-apim/extensions/ai-extension/providers?capabilities=image,text" \
-u "$ADMIN_APIKEY_ID:$ADMIN_APIKEY_SECRET"
The capabilities filter behaves exactly like on /providers above, and the response shape is identical.
POST /chat/completions
Standard OpenAI Chat Completions API endpoint. Supports streaming, tool calling, and model routing.
curl https://api.domain.tld/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OTOROSHI_BEARER" \
-d '{
"model": "gpt-4o",
"messages": [
{ "role": "user", "content": "Hello!" }
]
}'
Multipart content
Messages can mix text with images, audio and files. Documents are attached with a file content part, either inline as a data-uri or by url, and are forwarded to the target provider in its own format:
{
"model": "gpt-4o",
"messages": [
{
"role": "user",
"content": [
{ "type": "text", "text": "Summarize this document" },
{ "type": "file", "file": {
"filename": "report.pdf",
"file_data": "data:application/pdf;base64,JVBERi0xLjQK..."
}}
]
}
]
}
PDF and plain text documents are supported. The same document is sent as a file part to OpenAI compatible providers and as a document block to Anthropic, so a single request works across providers.
Model routing
When multiple providers are configured, you can target a specific provider using the model field:
- Slash syntax:
providerName/modelName(e.g.,my_openai/gpt-4o) - Hash syntax:
providerId###modelName(e.g.,provider_xxx###gpt-4o)
If no provider prefix is specified, the first configured provider is used.
Streaming
Streaming is activated when any of the following is true:
- The request body contains
"stream": true - The query parameter
?stream=trueis present - The header
x-stream: trueis present
POST /responses
OpenAI Responses API endpoint. This is the newer OpenAI API format that uses input and instructions instead of messages.
curl https://api.domain.tld/v1/responses \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OTOROSHI_BEARER" \
-d '{
"model": "gpt-4o",
"instructions": "You are a helpful assistant.",
"input": "What is the capital of France?"
}'
Response:
{
"id": "resp_xxxxx",
"object": "response",
"created_at": 1711569952,
"model": "gpt-4o",
"status": "completed",
"output": [
{
"type": "message",
"id": "msg_xxxxx",
"status": "completed",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "The capital of France is Paris.",
"annotations": []
}
]
}
],
"usage": {
"input_tokens": 25,
"output_tokens": 8,
"total_tokens": 33
}
}
Input format
The input field accepts multiple formats:
-
String: a simple text message treated as a user message
{ "input": "Hello!" } -
Array of messages: standard role-based messages
{
"input": [
{ "type": "message", "role": "user", "content": "Hello!" }
]
} -
Multipart content: messages with mixed content types
{
"input": [
{
"type": "message",
"role": "user",
"content": [
{ "type": "input_text", "text": "What's in this image?" },
{ "type": "input_image", "image_url": "https://example.com/image.png" },
{ "type": "input_file", "filename": "report.pdf", "file_data": "data:application/pdf;base64,JVBERi0xLjQK..." }
]
}
]
} -
Function call outputs: tool result messages
{
"input": [
{ "type": "function_call_output", "call_id": "call_123", "output": "Paris, 15 degrees" }
]
}
Streaming
When streaming is enabled, the response uses Server-Sent Events with the Responses API event protocol:
event: response.created
data: {"type":"response.created","response":{...}}
event: response.output_text.delta
data: {"type":"response.output_text.delta","item_id":"msg_xxx","output_index":0,"content_index":0,"delta":"The capital"}
event: response.output_text.delta
data: {"type":"response.output_text.delta","item_id":"msg_xxx","output_index":0,"content_index":0,"delta":" of France is Paris."}
event: response.completed
data: {"type":"response.completed","response":{...}}
The full lifecycle events are emitted: response.created, response.in_progress, response.output_item.added, response.content_part.added, response.output_text.delta, response.output_text.done, response.content_part.done, response.output_item.done, response.completed.
Using Open Responses instead
When use_open_response_for_responses is set to true, the /responses endpoint uses the Open Responses proxy implementation instead of the default one. The Open Responses proxy provides a richer implementation with native support for function calling, reasoning events, and more streaming events.
Regardless of this flag, the dedicated endpoints are always available:
/open-responsesalways uses the Open Responses proxy/oai-responsesalways uses the default OpenAI Responses proxy
POST /messages
Anthropic Messages API endpoint. This allows any Anthropic API client (including Claude Code) to use any LLM provider managed by Otoroshi.
curl https://api.domain.tld/v1/messages \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OTOROSHI_BEARER" \
-H "anthropic-version: 2023-06-01" \
-d '{
"model": "gpt-4o",
"max_tokens": 1024,
"messages": [
{ "role": "user", "content": "Hello!" }
]
}'
Response (Anthropic format):
{
"id": "msg_xxxxx",
"type": "message",
"role": "assistant",
"model": "gpt-4o",
"content": [
{
"type": "text",
"text": "Hello! How can I assist you today?"
}
],
"stop_reason": "end_turn",
"usage": {
"input_tokens": 12,
"output_tokens": 10
}
}
The plugin handles all format translation automatically:
- Anthropic-format tools (
input_schema) are converted to OpenAI-format tools (parameters) tool_use/tool_resultmessages are translated bidirectionally- Top-level
systemfield is converted to a system message thinkingparameters are mapped toreasoning_effortmax_tokensis mapped tomax_completion_tokens- Streaming uses the full Anthropic SSE protocol
Using Claude Code
You can use Claude Code with this plugin by setting:
export ANTHROPIC_AUTH_TOKEN=your-otoroshi-api-key
export ANTHROPIC_API_KEY=""
export ANTHROPIC_BASE_URL=https://api.domain.tld
claude --model gpt-4o
POST /embeddings
OpenAI Embeddings API endpoint.
curl https://api.domain.tld/v1/embeddings \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OTOROSHI_BEARER" \
-d '{
"model": "text-embedding-3-small",
"input": "The quick brown fox jumps over the lazy dog"
}'
POST /images/generations
OpenAI Image Generation API endpoint.
curl https://api.domain.tld/v1/images/generations \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OTOROSHI_BEARER" \
-d '{
"model": "dall-e-3",
"prompt": "A white cat sitting on a windowsill",
"n": 1,
"size": "1024x1024"
}'
When decode_images is enabled, base64-encoded image results are decoded and returned as binary image data.
POST /images/edits
OpenAI Image Edit API endpoint. Accepts multipart/form-data uploads.
POST /audio/speech
OpenAI Text-to-Speech API endpoint.
curl https://api.domain.tld/v1/audio/speech \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OTOROSHI_BEARER" \
-d '{
"model": "tts-1",
"input": "Hello, how are you?",
"voice": "alloy"
}' --output speech.mp3
POST /audio/transcriptions
OpenAI Speech-to-Text API endpoint. Accepts multipart/form-data audio file uploads up to the configured max_size_upload limit.
POST /audio/translations
OpenAI Audio Translation API endpoint. Translates audio into English text.
POST /moderations
OpenAI Moderation API endpoint.
curl https://api.domain.tld/v1/moderations \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OTOROSHI_BEARER" \
-d '{
"input": "Some text to check for moderation"
}'
POST /ocr
OCR endpoint that extracts text from images and PDF documents. Backed by the OCR model entities referenced in ocr_model_refs. Accepts either a JSON body (Mistral-style document) or a multipart/form-data file upload. See the OCR Models documentation for the full request and response formats.
curl https://api.domain.tld/v1/ocr \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OTOROSHI_BEARER" \
-d '{
"model": "alpha-digit-max",
"document": {
"type": "document_url",
"document_url": "https://example.com/scan.pdf"
}
}'
Response:
{
"model": "alpha-digit-max",
"text": "The full extracted text...",
"pages": [
{ "index": 0, "markdown": "The full extracted text..." }
],
"usage_info": { "pages_processed": 1 }
}
POST /mcp
Serves the MCP virtual server named by mcp_server_ref over the Streamable
HTTP transport, exactly like the MCP exposition plugins — same protocol revisions,
same tools, resources and prompts. The tools of your models and the tools of your MCP clients then live on
one route, behind one API key, and the calls of both show up in the same analytics.
curl https://api.domain.tld/v1/mcp \
-H "Authorization: Bearer $OTOROSHI_BEARER" \
-H "Content-Type: application/json" \
-d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }'
Set mcp_server_ref to expose it: with no reference, or when the referenced virtual server is disabled or
gone, /mcp answers 404 like any unknown path of the API.
MCP clients expect an OAuth authorization server (RFC 9728) and will not send an API key on their own. Either
configure the client with an Authorization header, or expose the same virtual server on a route of its own
with enforce_oauth — see the MCP exposition plugins.
Securing the API
Since this plugin exposes a powerful multi-endpoint AI API, you will want to secure it properly. Otoroshi provides several authentication mechanisms that can be combined on the same route. The key idea is that all security plugins run before the backend call, so you can stack multiple authentication methods and let the first valid one through.
Below are three common approaches, from simplest to most enterprise-ready. They can be used individually or combined on the same route.
API Keys
The simplest approach. Otoroshi API keys support multiple extraction methods (header, query param, Basic auth, Bearer token, JWT) and come with built-in quotas and rate limiting.
Add the ApikeyCalls plugin to your route:
{
"enabled": true,
"plugin": "cp:otoroshi.next.plugins.ApikeyCalls",
"config": {
"extractors": {
"basic": { "enabled": true },
"custom_headers": { "enabled": true },
"client_id": { "enabled": true },
"jwt": { "enabled": true, "secret_signed": true, "keypair_signed": true },
"oto_bearer": { "enabled": true }
},
"validate": true,
"mandatory": true,
"wipe_backend_request": true,
"update_quotas": true
}
}
Clients can then authenticate with a Bearer token:
curl https://api.domain.tld/v1/chat/completions \
-H "Authorization: Bearer your-otoroshi-apikey" \
-H "Content-Type: application/json" \
-d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "Hello!"}]}'
Or with Basic auth, client ID/secret headers, or JWT tokens signed with the API key secret.
You can further restrict access using mandatory tags to ensure only API keys with specific tags can access this route:
{
"enabled": true,
"plugin": "cp:otoroshi.next.plugins.NgApikeyMandatoryTags",
"config": {
"tags": ["endpoint_ai-api"]
}
}
Biscuit tokens
Biscuit tokens provide fine-grained, decentralized authorization with attenuation capabilities. This is ideal for distributing scoped tokens to different teams or applications.
Add the BiscuitUserExtractor plugin:
{
"enabled": true,
"plugin": "cp:otoroshi_plugins.com.cloud.apim.otoroshi.extensions.biscuit.plugins.BiscuitUserExtractor",
"config": {
"keypair_ref": "biscuit-keypair_your-keypair-id",
"enforce": true,
"extractor_type": "header",
"extractor_name": "Authorization",
"validations": {
"policies": [
"allow if endpoint(\"ai-api\")"
]
}
}
}
Clients send their biscuit token in the Authorization header:
curl https://api.domain.tld/v1/chat/completions \
-H "Authorization: biscuit_token_here" \
-H "Content-Type: application/json" \
-d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "Hello!"}]}'
The biscuit policy allow if endpoint("ai-api") ensures the token was explicitly granted access to this endpoint. You can create attenuated tokens with additional restrictions (time limits, IP ranges, specific models, etc.).
OIDC / JWT (Keycloak, Auth0, etc.)
For enterprise environments with an existing identity provider, you can validate JWT tokens issued by any OIDC-compliant provider (Keycloak, Auth0, Okta, Azure AD, etc.).
Add the OIDCJwtVerifier plugin:
{
"enabled": true,
"plugin": "cp:otoroshi.next.plugins.OIDCJwtVerifier",
"config": {
"ref": "auth_mod_your-oidc-verifier-id",
"mandatory": true,
"user": true
}
}
The ref points to an Otoroshi JWT verifier entity configured with your OIDC provider's JWKS URL and issuer. Clients send their JWT token as a Bearer token:
# First, get a token from your OIDC provider
TOKEN=$(curl -s -X POST https://keycloak.your-domain.com/realms/your-realm/protocol/openid-connect/token \
-d "grant_type=client_credentials" \
-d "client_id=ai-api-client" \
-d "client_secret=your-client-secret" | jq -r '.access_token')
# Then call the AI API
curl https://api.domain.tld/v1/chat/completions \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "Hello!"}]}'
Combining multiple authentication methods
The real power of Otoroshi is the ability to combine these methods on the same route. By setting mandatory: false on each auth plugin and adding the NgExpectedConsumer plugin, you can accept any of the configured authentication methods:
{
"plugins": [
{
"enabled": true,
"plugin": "cp:otoroshi_plugins.com.cloud.apim.otoroshi.extensions.aigateway.plugins.OpenAiCompatApi",
"config": {
"language_model_refs": ["provider_openai_1"],
"embedding_model_refs": ["embedding_model_1"]
}
},
{
"enabled": true,
"plugin": "cp:otoroshi.next.plugins.ApikeyCalls",
"config": {
"extractors": {
"basic": { "enabled": true },
"custom_headers": { "enabled": true },
"jwt": { "enabled": true, "secret_signed": true, "keypair_signed": true },
"oto_bearer": { "enabled": true }
},
"validate": true,
"mandatory": false,
"wipe_backend_request": true,
"update_quotas": true
}
},
{
"enabled": true,
"plugin": "cp:otoroshi_plugins.com.cloud.apim.otoroshi.extensions.biscuit.plugins.BiscuitUserExtractor",
"config": {
"keypair_ref": "biscuit-keypair_your-keypair-id",
"enforce": false,
"extractor_type": "header",
"extractor_name": "Authorization",
"validations": {
"policies": ["allow if endpoint(\"ai-api\")"]
}
}
},
{
"enabled": true,
"plugin": "cp:otoroshi.next.plugins.OIDCJwtVerifier",
"config": {
"ref": "auth_mod_your-oidc-verifier-id",
"mandatory": false,
"user": true
}
},
{
"enabled": true,
"plugin": "cp:otoroshi.next.plugins.NgExpectedConsumer",
"config": {}
}
]
}
With this setup:
- A developer can use a simple API key for quick prototyping
- An automated pipeline can use a scoped biscuit token with fine-grained permissions
- A web application can use a JWT token from your corporate Keycloak/Auth0
- The
NgExpectedConsumerplugin ensures that at least one authentication method succeeded
All three methods work on the same route, on the same API surface, without any change to the AI plugin configuration.
Comparison with individual plugins
| Feature | Individual plugins | OpenAI Compatible API |
|---|---|---|
| Setup complexity | One plugin per endpoint | Single plugin for everything |
| Path-based routing | Manual with includes filters | Automatic based on URL path |
| Anthropic Messages support | Separate AnthropicCompatProxy plugin | Built-in via /messages |
| Responses API support | Separate OpenAiResponsesProxy plugin | Built-in via /responses |
| Open Responses support | Separate OpenResponseCompatProxy plugin | Built-in via /open-responses |
| Prompt contexts | Not available | Built-in via /contexts |
| Model-specific config | Same refs for all endpoints | Separate refs per capability (language, audio, image, embedding, moderation) |
| Flexibility | Fine-grained control per endpoint | Unified but less granular |
The individual plugins are still useful when you need fine-grained control per endpoint (e.g., different provider refs for different paths, or different plugin chains per endpoint). The unified plugin is ideal when you want a quick, coherent API surface with minimal configuration.