# API reference (https://docs.interodin.com/en/api-reference)
The API speaks three formats. They all reach the same models and share one key, one balance and one set of rate limits.
| Endpoint | Format |
| --- | --- |
| `POST /v1/chat/completions` | OpenAI Chat Completions |
| `POST /v1/responses` | OpenAI Responses (stateless) |
| `POST /v1/messages` | Anthropic Messages |
| `POST /v1/messages/count_tokens` | Anthropic token counting (free) |
| `GET /v1/models` | Model list (OpenAI format; Anthropic format when you send `anthropic-version`) |
- **Base URL for OpenAI SDKs:** `https://api.interodin.com/v1`
- **Base URL for Anthropic SDKs:** `https://api.interodin.com`. They add `/v1` themselves.
- **Authentication:** `Authorization: Bearer io-sk-…` or `x-api-key: io-sk-…`. If you send both, `x-api-key` is used.
- **From servers only:** the API doesn't accept browser (CORS) requests from other sites, so keep your key on your server.
## Try it [#try-it]
Every endpoint page has a playground. Open it with **Try in Playground**:
1. Paste your key into the **Authorization** field.
2. Edit the body.
3. Send the request.
The request goes through the docs server to the API (browsers can't call the API directly), and nothing is stored on the way. Playground calls are billed like any other request, and the playground waits up to two minutes for a reply. Closing the dialog doesn't cancel a request that has already been sent, so it is still billed.
## Machine-readable spec [#machine-readable-spec]
The full OpenAPI 3.1 document is published as [openapi.json](https://docs.interodin.com/openapi.json) and [openapi.yaml](https://docs.interodin.com/openapi.yaml). Import it into Postman, Insomnia or a client generator.
# Authentication (https://docs.interodin.com/en/authentication)
Every request needs an API key. Keys are `io-sk-` followed by 43 letters and digits, and they belong to an organization. Requests made with a key draw from that organization's balance.
## Sending the key [#sending-the-key]
The API accepts the key in either header. Use whichever your SDK sends.
| Header | Used by |
| --- | --- |
| `Authorization: Bearer io-sk-…` | OpenAI SDKs, most tools, curl |
| `x-api-key: io-sk-…` | Anthropic SDKs, Claude Code |
Both headers work on every endpoint, including `/v1/messages`. If a request carries both, `x-api-key` is used.
```bash tab="Bearer"
curl https://api.interodin.com/v1/models \
-H "Authorization: Bearer $INTERODIN_API_KEY"
```
```bash tab="x-api-key"
curl https://api.interodin.com/v1/models \
-H "x-api-key: $INTERODIN_API_KEY"
```
## Creating and revoking keys [#creating-and-revoking-keys]
Create keys in the [console](https://interodin.com/console). A key is shown once, when you create it. We store only a hash, so we can't show it again. If you lose a key, revoke it and create a new one.
Revoking takes effect on every server within a minute, usually within seconds. After that, requests with the key fail with `401 invalid_api_key`.
## Limiting a key [#limiting-a-key]
When you create a key you can limit it:
- **Monthly spend limit.** Once the key has spent this much in a calendar month (UTC), its requests fail with `403 key_spend_limit_reached` until the next month. Other keys keep working.
- **Allowed models.** Requests for any other model fail with `403 model_not_allowed`. `GET /v1/models` lists only the allowed models.
Give each app, environment or teammate its own key. Usage and logs in the console are broken down per key, and you can revoke one without touching the others.
## Keeping keys safe [#keeping-keys-safe]
- Keep keys on the server. Never ship a key in a browser bundle, mobile app or public repository. The API doesn't accept browser (CORS) requests from other sites anyway.
- Read the key from an environment variable or a secret manager.
- If a key leaks, revoke it in the console at once.
## Related errors [#related-errors]
| Status | Code | Meaning |
| --- | --- | --- |
| 401 | `invalid_api_key` | The key is missing, malformed or revoked. |
| 402 | `insufficient_credits` | The organization's balance is used up. Top up in the console. |
| 403 | `model_not_allowed` | The key is not allowed to use this model. |
| 403 | `key_spend_limit_reached` | The key hit its monthly spend limit. |
| 403 | `organization_suspended` | The organization is suspended. Contact support. |
Every response carries an `x-request-id` header. Quote it when you contact support.
See [Errors and rate limits](https://docs.interodin.com/en/errors-and-rate-limits) for the full list.
# Changelog (https://docs.interodin.com/en/changelog)
## Launch [#launch]
**Models**
- Odin v1 (`odin-v1`) is available. It is text-only and supports tool calling and reasoning control. See [Odin v1](https://docs.interodin.com/en/models/odin-v1) for context size and prices.
**API**
- `POST /v1/chat/completions`: OpenAI Chat Completions, with streaming, tools, JSON mode and structured outputs.
- `POST /v1/responses`: OpenAI Responses. It is stateless: `store` is always `false` and `previous_response_id` is not supported.
- `POST /v1/messages`: Anthropic Messages, with streaming, tools and extended thinking.
- `POST /v1/messages/count_tokens`: free token counting.
- `GET /v1/models`: the list of available models.
- Prompt caching is automatic. You can send routing hints with `prompt_cache_key` and the `x-interodin-session-id` header.
- Rate-limit headers are on generation responses once a request reaches the rate-limit step, and every error carries an `x-interodin-error-code` header.
**Docs**
- Guides for all three formats, plus integrations for Claude Code, Codex CLI, Cursor, Cline, Roo Code, Continue, n8n, the OpenAI and Anthropic SDKs, the Vercel AI SDK, LangChain and LlamaIndex.
- An OpenAPI 3.1 spec at [/openapi.json](https://docs.interodin.com/openapi.json), and plain-text docs for AI agents at [/llms.txt](https://docs.interodin.com/llms.txt).
# Errors and rate limits (https://docs.interodin.com/en/errors-and-rate-limits)
## Error format [#error-format]
Errors use the format of the endpoint you called, so your SDK raises its usual exception types.
OpenAI endpoints (`/v1/chat/completions`, `/v1/responses`, and `/v1/models` without an `anthropic-version` header):
```json
{
"error": {
"message": "Your organization has no remaining credit. Top up to continue.",
"type": "insufficient_quota",
"code": "insufficient_credits",
"param": null
}
}
```
Anthropic endpoints (`/v1/messages`, `/v1/messages/count_tokens`, and `/v1/models` with an `anthropic-version` header):
```json
{
"type": "error",
"error": {
"type": "billing_error",
"message": "Your organization has no remaining credit. Top up to continue."
}
}
```
Every error response also carries the header `x-interodin-error-code` with the code from the table below. Branch on the HTTP status and on `error.code` (OpenAI format) or on the `x-interodin-error-code` header (any format). In the Anthropic format several codes share one `error.type` (for example, all three 403 codes are `permission_error`), so the header is the reliable way to tell them apart. Don't match on `message`, because the wording can change.
## Error codes [#error-codes]
| Status | Code | Meaning | What to do |
| --- | --- | --- | --- |
| 400 | `invalid_request` | The body is not valid JSON, misses a required field, or a field has an invalid value, for example `max_tokens` of 0. | Fix the request. |
| 400 | `unsupported_parameter` | A parameter is not supported, for example `previous_response_id`. | Remove it. For Responses, send the full conversation in `input`. |
| 400 | `image_input_not_supported` | The model is text-only and you attached an image to the newest message. Images in tool results or earlier turns are replaced by a text note instead of failing. | Remove the image, or use a model with vision. |
| 400 | `unsupported_content` | The request contains file or document input (`file`, `input_file` or `document` parts, or any part with a `file`, `file_id`, `file_data` or `file_url` key, even without a `type`) in the newest message. No model accepts it. Files in tool results or earlier turns are replaced by a text note. | Send the text content instead. |
| 400 | `content_refused` | The model declined to process this request. Nothing is charged for the refused request. | Rephrase the request. |
| 400 | `context_length_exceeded` | Input plus `max_tokens` exceeds the context window. | Shorten the input or lower `max_tokens`. |
| 401 | `invalid_api_key` | The key is missing, malformed or revoked. | Check the header and the key. |
| 402 | `insufficient_credits` | The organization's balance is used up. | Top up in the console, or turn on auto-recharge. |
| 403 | `model_not_allowed` | The key is not allowed to use this model. | Use an allowed model, or edit the key. |
| 403 | `key_spend_limit_reached` | The key reached its monthly spend limit. | Raise the limit or wait for next month. |
| 403 | `organization_suspended` | The organization is suspended. | Contact support. |
| 404 | `model_not_found` | The model id does not exist. | Use an id from `GET /v1/models`, such as `odin-v1`. |
| 404 | `not_found` | The path does not exist, for example `GET /v1/responses/{id}` (responses are not stored). | Check the URL. |
| 413 | `request_too_large` | The request body is larger than 10 MB. | Send less data per request. |
| 429 | `rate_limit_exceeded` | Too many requests or tokens per minute. | Wait for `Retry-After` seconds, then retry. |
| 429 | `concurrency_limit` | Your organization has too many requests in flight at once. | Wait for `Retry-After` (1 second), or send fewer requests in parallel. |
| 500 | `internal_error` | Something failed on our side. | Retry with backoff. If it persists, contact support and include the `x-request-id`. |
| 502 | `upstream_unavailable` | The model is temporarily unavailable, or it stopped sending data for 300 seconds. | Retry with backoff. |
| 504 | `timeout` | The model took too long to respond. | Retry. For long outputs, stream the response. |
Every response, errors included, carries an `x-request-id` header. Include the id when you contact support. Because we never store request content, the id is how we find your request.
If your client disconnects before the reply is complete, the request appears in your request logs with status `499` (`client_closed_request`). You never receive this status yourself, because the connection is already closed. If you abort a request that is not streaming, you are billed for the input in full plus an estimate of the output produced so far.
## Errors during a stream [#errors-during-a-stream]
Once a stream has started, the status code is already `200`. If generation fails after that point, the stream ends with an error event in the same format as the endpoint:
- **Chat Completions:** a final `data: {"error": {...}}` line.
- **Responses:** an `event: error` with a `{"type": "error", "code": "...", "message": "...", "param": null}` payload.
- **Messages:** an `event: error` with a `{"type": "error", ...}` payload.
A failure during a stream is reported as `upstream_unavailable`. Input tokens are billed in full. Output is billed for what was delivered before the failure, estimated from the delivered text when the model never reported usage.
## Rate limits [#rate-limits]
Limits apply per API key and per organization, per minute, to both requests and tokens (input plus output). Responses from the generation endpoints (`/v1/chat/completions`, `/v1/responses`, `/v1/messages`, `/v1/messages/count_tokens`) report the current window. They report whichever of your key's or your organization's windows is closer to its limit. `GET /v1/models` doesn't send these headers, and neither do errors that happen before the rate-limit check, such as `401` or a `400` for an invalid request.
| Header | Meaning |
| --- | --- |
| `x-ratelimit-limit-requests` | Requests allowed per minute. |
| `x-ratelimit-remaining-requests` | Requests left in the current window. |
| `x-ratelimit-reset-requests` | Time until the current one-minute request window ends, in whole seconds with an `s` suffix, for example `12s`. |
| `x-ratelimit-limit-tokens` | Tokens allowed per minute. |
| `x-ratelimit-remaining-tokens` | Tokens left in the current window. |
| `x-ratelimit-reset-tokens` | Time until the current one-minute token window ends, for example `12s`. |
When you exceed a limit you get `429 rate_limit_exceeded` with a `Retry-After` header, in seconds. Your organization can also have only a limited number of requests in flight at once; past that you get `429 concurrency_limit` with `Retry-After: 1`. Token counting with `/v1/messages/count_tokens` is free and works at a zero balance, but it still counts toward the rate limits and the in-flight cap. If you need higher limits, contact support.
## Retrying [#retrying]
Retry only these responses:
- `429`
- `500`
- `502`
- `504`
- network errors
Use exponential backoff with jitter, and honor `Retry-After` when it is present. Never retry other `4xx` errors unchanged, because they will fail again.
The OpenAI and Anthropic SDKs already retry these statuses. You can raise the retry count and the timeout:
```ts tab="OpenAI SDK"
import OpenAI from 'openai'
const client = new OpenAI({
baseURL: 'https://api.interodin.com/v1',
apiKey: process.env.INTERODIN_API_KEY,
maxRetries: 4,
timeout: 120_000,
})
const models = await client.models.list()
console.log(models.data.map((m) => m.id))
```
```ts tab="Anthropic SDK"
import Anthropic from '@anthropic-ai/sdk'
const client = new Anthropic({
baseURL: 'https://api.interodin.com',
apiKey: process.env.INTERODIN_API_KEY,
maxRetries: 4,
timeout: 120_000,
})
const count = await client.messages.countTokens({
model: 'odin-v1',
messages: [{ role: 'user', content: 'Hello' }],
})
console.log(count.input_tokens)
```
# Chat Completions (https://docs.interodin.com/en/guides/chat-completions)
`POST https://api.interodin.com/v1/chat/completions` takes the OpenAI Chat Completions format unchanged. Any OpenAI SDK or compatible tool works once you set the base URL to `https://api.interodin.com/v1` and the key to your `io-sk-` key.
## Basic request [#basic-request]
```bash tab="curl"
curl https://api.interodin.com/v1/chat/completions \
-H "Authorization: Bearer $INTERODIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "odin-v1",
"messages": [
{"role": "system", "content": "You are a concise assistant."},
{"role": "user", "content": "Name three Norse gods."}
]
}'
```
```python tab="Python"
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.interodin.com/v1", api_key=os.environ["INTERODIN_API_KEY"])
completion = client.chat.completions.create(
model="odin-v1",
messages=[
{"role": "system", "content": "You are a concise assistant."},
{"role": "user", "content": "Name three Norse gods."},
],
)
print(completion.choices[0].message.content)
print(completion.usage)
```
```ts tab="TypeScript"
import OpenAI from 'openai'
const client = new OpenAI({ baseURL: 'https://api.interodin.com/v1', apiKey: process.env.INTERODIN_API_KEY })
const completion = await client.chat.completions.create({
model: 'odin-v1',
messages: [
{ role: 'system', content: 'You are a concise assistant.' },
{ role: 'user', content: 'Name three Norse gods.' },
],
})
console.log(completion.choices[0]?.message.content)
console.log(completion.usage)
```
Every response carries an `x-request-id` header. Quote it when you contact support.
## Streaming [#streaming]
Set `stream: true` to receive the reply as server-sent events. Each `data:` line holds a `chat.completion.chunk`, with text in `choices[0].delta.content`. The **last chunk always carries `usage`**, even if you don't set `stream_options.include_usage`. Its `choices` array is empty. The stream ends with `data: [DONE]`.
```bash tab="curl"
curl -N https://api.interodin.com/v1/chat/completions \
-H "Authorization: Bearer $INTERODIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "odin-v1", "stream": true, "messages": [{"role": "user", "content": "Write a haiku about Odin."}]}'
```
```python tab="Python"
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.interodin.com/v1", api_key=os.environ["INTERODIN_API_KEY"])
stream = client.chat.completions.create(
model="odin-v1",
messages=[{"role": "user", "content": "Write a haiku about Odin."}],
stream=True,
stream_options={"include_usage": True},
)
for chunk in stream:
if chunk.choices:
print(chunk.choices[0].delta.content or "", end="", flush=True)
if chunk.usage:
print("\n", chunk.usage)
```
```ts tab="TypeScript"
import OpenAI from 'openai'
const client = new OpenAI({ baseURL: 'https://api.interodin.com/v1', apiKey: process.env.INTERODIN_API_KEY })
const stream = await client.chat.completions.create({
model: 'odin-v1',
messages: [{ role: 'user', content: 'Write a haiku about Odin.' }],
stream: true,
stream_options: { include_usage: true },
})
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta.content ?? '')
if (chunk.usage) console.log('\n', chunk.usage)
}
```
If your client disconnects, generation stops. You are billed for the input in full plus the output produced up to that point (for a request that is not streaming, an estimate of it).
If the model goes silent for 300 seconds in the middle of a stream, the stream ends with an error chunk whose code is `upstream_unavailable`. Treat it like any other failed request and retry.
## Tools (function calling) [#tools-function-calling]
Describe your functions in `tools`. When the model wants to call one, the reply has `finish_reason: "tool_calls"` and a `tool_calls` array. Run the function yourself, append the result as a `tool` message, and call the API again.
```ts tab="TypeScript"
import OpenAI from 'openai'
const client = new OpenAI({ baseURL: 'https://api.interodin.com/v1', apiKey: process.env.INTERODIN_API_KEY })
const tools: OpenAI.Chat.Completions.ChatCompletionTool[] = [
{
type: 'function',
function: {
name: 'get_weather',
description: 'Get the current weather for a city',
parameters: {
type: 'object',
properties: { city: { type: 'string' } },
required: ['city'],
additionalProperties: false,
},
},
},
]
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
{ role: 'user', content: 'What is the weather in Cluj?' },
]
const first = await client.chat.completions.create({ model: 'odin-v1', messages, tools })
const reply = first.choices[0]!.message
messages.push(reply)
for (const call of reply.tool_calls ?? []) {
if (call.type !== 'function') continue
const args = JSON.parse(call.function.arguments) as { city: string }
const result = { city: args.city, celsius: 14 } // call your real weather API here
messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) })
}
const second = await client.chat.completions.create({ model: 'odin-v1', messages, tools })
console.log(second.choices[0]?.message.content)
```
```python tab="Python"
import json
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.interodin.com/v1", api_key=os.environ["INTERODIN_API_KEY"])
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a city",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
"additionalProperties": False,
},
},
}
]
messages = [{"role": "user", "content": "What is the weather in Cluj?"}]
first = client.chat.completions.create(model="odin-v1", messages=messages, tools=tools)
reply = first.choices[0].message
messages.append(reply)
for call in reply.tool_calls or []:
args = json.loads(call.function.arguments)
result = {"city": args["city"], "celsius": 14} # call your real weather API here
messages.append({"role": "tool", "tool_call_id": call.id, "content": json.dumps(result)})
second = client.chat.completions.create(model="odin-v1", messages=messages, tools=tools)
print(second.choices[0].message.content)
```
`tool_choice` accepts `"auto"` (the default), `"none"`, `"required"`, or `{"type": "function", "function": {"name": "..."}}` to force one function. Set `parallel_tool_calls: false` to get at most one call per turn.
Only function tools are kept. Any other tool type, and `web_search_options`, is removed from the request without an error, so the model never sees it. Web search is not available on Chat Completions: use the [Messages](https://docs.interodin.com/en/guides/messages#web-search) or [Responses](https://docs.interodin.com/en/guides/responses#web-search) API. A `tool_choice` that names a removed tool is removed too.
## JSON mode and structured outputs [#json-mode-and-structured-outputs]
- `response_format: {"type": "json_object"}` makes the reply valid JSON. Say in the prompt what shape you want.
- `response_format: {"type": "json_schema", ...}` constrains the reply to your JSON Schema. Set `strict: true`, and set `additionalProperties: false` on every object.
```ts tab="TypeScript"
import OpenAI from 'openai'
const client = new OpenAI({ baseURL: 'https://api.interodin.com/v1', apiKey: process.env.INTERODIN_API_KEY })
const completion = await client.chat.completions.create({
model: 'odin-v1',
messages: [{ role: 'user', content: 'Extract the person: Ana is 31 and lives in Iasi.' }],
response_format: {
type: 'json_schema',
json_schema: {
name: 'person',
strict: true,
schema: {
type: 'object',
properties: { name: { type: 'string' }, age: { type: 'integer' }, city: { type: 'string' } },
required: ['name', 'age', 'city'],
additionalProperties: false,
},
},
},
})
const person = JSON.parse(completion.choices[0]?.message.content ?? '{}') as { name: string; age: number; city: string }
console.log(person)
```
```python tab="Python"
import json
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.interodin.com/v1", api_key=os.environ["INTERODIN_API_KEY"])
completion = client.chat.completions.create(
model="odin-v1",
messages=[{"role": "user", "content": "Extract the person: Ana is 31 and lives in Iasi."}],
response_format={
"type": "json_schema",
"json_schema": {
"name": "person",
"strict": True,
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
"city": {"type": "string"},
},
"required": ["name", "age", "city"],
"additionalProperties": False,
},
},
},
)
print(json.loads(completion.choices[0].message.content))
```
## Reasoning control [#reasoning-control]
Both models reason before they answer. Reasoning tokens are billed as output tokens and reported in `usage.completion_tokens_details.reasoning_tokens`.
- **On or off, no levels.** Reasoning is either on or off. `reasoning_effort: "none"` turns it off on Odin Flash, which makes answers much faster and cheaper but weaker on arithmetic and logic. On Odin v1 `none` does not turn it off: use the [Messages API](https://docs.interodin.com/en/guides/messages#extended-thinking) with `thinking: {"type": "disabled"}` for that. The values `minimal`, `low`, `medium`, `high`, `xhigh` and `max` are accepted but change nothing.
- **The reasoning trace** comes back in `choices[0].message.reasoning`, and in `delta.reasoning` when streaming. Set `include_reasoning: false` to leave it out of the reply. It is still billed.
```python tab="Python"
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.interodin.com/v1", api_key=os.environ["INTERODIN_API_KEY"])
completion = client.chat.completions.create(
model="odin-flash",
messages=[{"role": "user", "content": "Is 1,000,003 prime?"}],
# reasoning_effort="none" would turn reasoning off on this model.
)
message = completion.choices[0].message
print(getattr(message, "reasoning", None))
print(message.content)
print(completion.usage.completion_tokens_details)
```
```ts tab="TypeScript"
import OpenAI from 'openai'
const client = new OpenAI({ baseURL: 'https://api.interodin.com/v1', apiKey: process.env.INTERODIN_API_KEY })
const completion = await client.chat.completions.create({
model: 'odin-flash',
messages: [{ role: 'user', content: 'Is 1,000,003 prime?' }],
// reasoning_effort: 'none' would turn reasoning off on this model.
})
const message = completion.choices[0]?.message as (OpenAI.Chat.ChatCompletionMessage & { reasoning?: string }) | undefined
console.log(message?.reasoning)
console.log(message?.content)
```
## Prompt caching [#prompt-caching]
Caching is automatic. When the start of a request matches a recent request (same system prompt, same tools, same earlier messages), those input tokens are read from the cache and billed at the lower cached-input rate. The usage reports them in `usage.prompt_tokens_details.cached_tokens`.
To keep one conversation on the same cache, send a stable `x-interodin-session-id` header, or a `prompt_cache_key` in the body. Both are scoped to your organization, so they never collide with other customers. If you send both, give them the same value.
```ts tab="TypeScript"
import OpenAI from 'openai'
const client = new OpenAI({ baseURL: 'https://api.interodin.com/v1', apiKey: process.env.INTERODIN_API_KEY })
const completion = await client.chat.completions.create(
{
model: 'odin-v1',
messages: [{ role: 'user', content: 'Summarize our plan so far.' }],
prompt_cache_key: 'conv_7d2a9f',
},
{ headers: { 'x-interodin-session-id': 'conv_7d2a9f' } },
)
console.log(completion.usage?.prompt_tokens_details?.cached_tokens)
```
```python tab="Python"
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.interodin.com/v1", api_key=os.environ["INTERODIN_API_KEY"])
completion = client.chat.completions.create(
model="odin-v1",
messages=[{"role": "user", "content": "Summarize our plan so far."}],
prompt_cache_key="conv_7d2a9f",
extra_headers={"x-interodin-session-id": "conv_7d2a9f"},
)
print(completion.usage.prompt_tokens_details.cached_tokens)
```
See [Prompt caching](https://docs.interodin.com/en/guides/prompt-caching) for how to structure prompts so they hit the cache.
## Vision [#vision]
Image input depends on the model. **Odin v1 is text-only.** An `image_url` content part you put in the newest message fails with `400 image_input_not_supported` before any tokens are billed. Each [model page](https://docs.interodin.com/en/models) says whether the model accepts images.
File input (`file` content parts) is not supported by any model and fails with `400 unsupported_content`. Send the file's text instead.
An image or file that came from a tool result or from an earlier turn of the conversation (for example a file a coding agent read) is replaced by a short text note, so one image in the history cannot break every later message.
## Parameters [#parameters]
| Parameter | Notes |
| --- | --- |
| `model` | Required. For example `odin-v1`. |
| `messages` | Required. Roles `system`, `developer`, `user`, `assistant`, `tool`. |
| `stream`, `stream_options` | The last streamed chunk always includes `usage`. |
| `max_tokens` / `max_completion_tokens` | Clamped to the model's maximum output. A value of zero or less fails with `400 invalid_request`. |
| `temperature`, `top_p`, `stop`, `seed`, `presence_penalty`, `frequency_penalty` | Standard sampling controls. |
| `tools`, `tool_choice`, `parallel_tool_calls` | Function calling. Only function tools are kept. |
| `response_format` | `text`, `json_object` or `json_schema`. |
| `reasoning_effort`, `include_reasoning` | `reasoning_effort: "none"` turns reasoning off on Odin Flash. Other values change nothing. `include_reasoning: false` hides the trace. See [Reasoning control](#reasoning-control). |
| `prompt_cache_key` | Cache routing hint. |
| `user`, `safety_identifier` | Your own end-user id, for tracing and abuse reports. |
| `store` | Ignored: nothing is stored, so it is always `false`. |
| `metadata` | Ignored and not forwarded. |
`prompt_cache_key`, `user` and `safety_identifier` take any string up to 1024 characters. A value of any other type fails with `400 invalid_request`. They are scoped to your organization: the API forwards a hash that is specific to your organization, never the value you sent. Use an opaque id, such as a database id, rather than an email address.
The full schema is in the [API reference](https://docs.interodin.com/en/api-reference).
# Messages (https://docs.interodin.com/en/guides/messages)
`POST https://api.interodin.com/v1/messages` takes the Anthropic Messages format. Claude Code and the Anthropic SDKs use it unchanged.
**Base URL:** Anthropic SDKs append `/v1/messages` themselves, so give them the origin, `https://api.interodin.com`, without `/v1`.
**Authentication:** the SDKs send your key as `x-api-key`. `Authorization: Bearer` works too.
**Headers and query strings:** the `anthropic-version` and `anthropic-beta` headers and the `?beta=true` query string that the SDKs send are accepted. Other query parameters are accepted and ignored. Model ids with a `[1m]` suffix, such as `odin-v1[1m]`, are accepted. The suffix is ignored.
## Basic request [#basic-request]
```bash tab="curl"
curl https://api.interodin.com/v1/messages \
-H "x-api-key: $INTERODIN_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "Content-Type: application/json" \
-d '{
"model": "odin-v1",
"max_tokens": 1024,
"system": "You are a concise assistant.",
"messages": [{"role": "user", "content": "Name three Norse gods."}]
}'
```
```python tab="Python"
# pip install anthropic
import os
import anthropic
client = anthropic.Anthropic(base_url="https://api.interodin.com", api_key=os.environ["INTERODIN_API_KEY"])
message = client.messages.create(
model="odin-v1",
max_tokens=1024,
system="You are a concise assistant.",
messages=[{"role": "user", "content": "Name three Norse gods."}],
)
for block in message.content:
if block.type == "text":
print(block.text)
print(message.usage)
```
```ts tab="TypeScript"
// npm install @anthropic-ai/sdk
import Anthropic from '@anthropic-ai/sdk'
const client = new Anthropic({ baseURL: 'https://api.interodin.com', apiKey: process.env.INTERODIN_API_KEY })
const message = await client.messages.create({
model: 'odin-v1',
max_tokens: 1024,
system: 'You are a concise assistant.',
messages: [{ role: 'user', content: 'Name three Norse gods.' }],
})
for (const block of message.content) if (block.type === 'text') console.log(block.text)
console.log(message.usage)
```
`max_tokens` is required, as in the Anthropic API. Values above the model's maximum output are clamped. A value of zero or less fails with `400 invalid_request`.
Every response carries an `x-request-id` header. Quote it when you contact support.
To tag a request with your own end-user id, set `metadata.user_id` to a string of up to 1024 characters. A value of any other type fails with `400 invalid_request`. The API forwards a hash that is specific to your organization, never the value you sent. Use an opaque id, such as a database id, rather than an email address. Other `metadata` keys are dropped, and a `metadata` that is not an object is ignored.
## Streaming [#streaming]
With `stream: true` you get the standard event sequence: `message_start`, then for each content block `content_block_start`, `content_block_delta` (repeated) and `content_block_stop`, then `message_delta` and `message_stop`. `ping` events may appear at any time.
Input token usage arrives in `message_start.message.usage`. The final output token count arrives in `message_delta.usage`.
If the model goes silent for 300 seconds in the middle of a stream, the stream ends with an `error` event of type `api_error`. This is the `upstream_unavailable` failure. The status line was already sent, so there is no `x-interodin-error-code` header and the event has no code: treat any `error` event as a failed request and retry.
```python tab="Python"
import os
import anthropic
client = anthropic.Anthropic(base_url="https://api.interodin.com", api_key=os.environ["INTERODIN_API_KEY"])
with client.messages.stream(
model="odin-v1",
max_tokens=1024,
messages=[{"role": "user", "content": "Write a haiku about Odin."}],
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)
print("\n", stream.get_final_message().usage)
```
```ts tab="TypeScript"
import Anthropic from '@anthropic-ai/sdk'
const client = new Anthropic({ baseURL: 'https://api.interodin.com', apiKey: process.env.INTERODIN_API_KEY })
const stream = client.messages.stream({
model: 'odin-v1',
max_tokens: 1024,
messages: [{ role: 'user', content: 'Write a haiku about Odin.' }],
})
stream.on('text', (text) => process.stdout.write(text))
const final = await stream.finalMessage()
console.log('\n', final.usage)
```
## Tools [#tools]
Define tools with `name`, `description` and `input_schema`. When the model calls one, the reply has `stop_reason: "tool_use"` and a `tool_use` content block. Send the result back as a `tool_result` block in the next user message.
```ts tab="TypeScript"
import Anthropic from '@anthropic-ai/sdk'
const client = new Anthropic({ baseURL: 'https://api.interodin.com', apiKey: process.env.INTERODIN_API_KEY })
const tools: Anthropic.Tool[] = [
{
name: 'get_weather',
description: 'Get the current weather for a city',
input_schema: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] },
},
]
const messages: Anthropic.MessageParam[] = [{ role: 'user', content: 'What is the weather in Cluj?' }]
const first = await client.messages.create({ model: 'odin-v1', max_tokens: 1024, tools, messages })
messages.push({ role: 'assistant', content: first.content })
const results: Anthropic.ToolResultBlockParam[] = []
for (const block of first.content) {
if (block.type !== 'tool_use') continue
const { city } = block.input as { city: string }
results.push({ type: 'tool_result', tool_use_id: block.id, content: JSON.stringify({ city, celsius: 14 }) })
}
messages.push({ role: 'user', content: results })
const second = await client.messages.create({ model: 'odin-v1', max_tokens: 1024, tools, messages })
for (const block of second.content) if (block.type === 'text') console.log(block.text)
```
```python tab="Python"
import json
import os
import anthropic
client = anthropic.Anthropic(base_url="https://api.interodin.com", api_key=os.environ["INTERODIN_API_KEY"])
tools = [
{
"name": "get_weather",
"description": "Get the current weather for a city",
"input_schema": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
}
]
messages = [{"role": "user", "content": "What is the weather in Cluj?"}]
first = client.messages.create(model="odin-v1", max_tokens=1024, tools=tools, messages=messages)
messages.append({"role": "assistant", "content": first.content})
results = []
for block in first.content:
if block.type == "tool_use":
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps({"city": block.input["city"], "celsius": 14}),
})
messages.append({"role": "user", "content": results})
second = client.messages.create(model="odin-v1", max_tokens=1024, tools=tools, messages=messages)
print("".join(block.text for block in second.content if block.type == "text"))
```
`tool_choice` accepts `{"type": "auto"}`, `{"type": "any"}`, `{"type": "none"}` or `{"type": "tool", "name": "..."}`.
Only custom tools (`name`, `description`, `input_schema`) and the [web search](#web-search) tool are kept. Other tools with a `type`, such as code execution, bash, text editor and computer use, and `mcp_servers`, are removed from the request without an error, so the model never sees them. A `tool_choice` that names a removed tool is removed too.
## Web search [#web-search]
Add the web search tool and the model can search the web and cite its sources:
```json
{
"model": "odin-v1",
"max_tokens": 2000,
"tools": [{ "type": "web_search_20250305", "name": "web_search", "max_uses": 3 }],
"messages": [{ "role": "user", "content": "What is the latest stable Node.js release?" }]
}
```
The response has `server_tool_use` and `web_search_tool_result` blocks, then the answer. `usage.server_tool_use.web_search_requests` tells you how many searches ran.
- **Price.** Each search is billed on top of the tokens it uses. The per-search price is on the pricing page on the InterOdin website.
- **Limit.** A request runs at most 5 searches. `max_uses` is capped at 5, and set to 5 when you leave it out.
- **Balance.** Your balance must cover the tokens and the searches the request could run. A request that could cost more than your balance fails with `402 insufficient_credits`.
- **Where.** Web search works on this API and on [Responses](https://docs.interodin.com/en/guides/responses#web-search), not on Chat Completions.
## JSON output [#json-output]
To get JSON in a guaranteed shape, define a single tool whose `input_schema` is the shape you want, and force it with `tool_choice`. The tool's `input` is your JSON object.
```ts tab="TypeScript"
import Anthropic from '@anthropic-ai/sdk'
const client = new Anthropic({ baseURL: 'https://api.interodin.com', apiKey: process.env.INTERODIN_API_KEY })
const message = await client.messages.create({
model: 'odin-v1',
max_tokens: 1024,
tools: [
{
name: 'record_person',
description: 'Record a person extracted from the text',
input_schema: {
type: 'object',
properties: { name: { type: 'string' }, age: { type: 'integer' }, city: { type: 'string' } },
required: ['name', 'age', 'city'],
},
},
],
tool_choice: { type: 'tool', name: 'record_person' },
messages: [{ role: 'user', content: 'Extract the person: Ana is 31 and lives in Iasi.' }],
})
const call = message.content.find((block) => block.type === 'tool_use')
if (call?.type === 'tool_use') console.log(call.input)
```
## Extended thinking [#extended-thinking]
Both models think before they answer, and thinking tokens are billed as output tokens. Thinking is either on or off:
- **Off.** Send `thinking: {"type": "disabled"}`. This works on both models. Answers are much faster and cheaper, but weaker on arithmetic and logic.
- **On.** Leave `thinking` out, or send `{"type": "enabled", "budget_tokens": N}`. Thinking comes back as `thinking` content blocks. Add `"display": "omitted"` to keep the text of the blocks out of the reply. It is still billed.
- **No levels.** `budget_tokens` does not cap the thinking, and `output_config.effort` (`low` to `max`) is accepted but has no effect. `max_tokens` must still be larger than any `budget_tokens` you send.
```ts tab="TypeScript"
import Anthropic from '@anthropic-ai/sdk'
const client = new Anthropic({ baseURL: 'https://api.interodin.com', apiKey: process.env.INTERODIN_API_KEY })
const message = await client.messages.create({
model: 'odin-v1',
max_tokens: 8192,
thinking: { type: 'enabled', budget_tokens: 4096 },
messages: [{ role: 'user', content: 'Is 1,000,003 prime?' }],
})
for (const block of message.content) {
if (block.type === 'thinking') console.log('[thinking]', block.thinking)
if (block.type === 'text') console.log(block.text)
}
```
```python tab="Python"
import os
import anthropic
client = anthropic.Anthropic(base_url="https://api.interodin.com", api_key=os.environ["INTERODIN_API_KEY"])
message = client.messages.create(
model="odin-v1",
max_tokens=8192,
thinking={"type": "enabled", "budget_tokens": 4096},
messages=[{"role": "user", "content": "Is 1,000,003 prime?"}],
)
for block in message.content:
if block.type == "thinking":
print("[thinking]", block.thinking)
elif block.type == "text":
print(block.text)
```
When you continue a conversation that used tools with thinking, send the assistant's content blocks back unchanged, including any `thinking` blocks.
## Counting tokens [#counting-tokens]
`POST /v1/messages/count_tokens` takes the same body as `/v1/messages` (without `max_tokens`) and returns `{"input_tokens": N}`. It is **free**: counting is never billed, and it works even when your balance is zero. It does count toward your rate limits.
```ts tab="TypeScript"
import Anthropic from '@anthropic-ai/sdk'
const client = new Anthropic({ baseURL: 'https://api.interodin.com', apiKey: process.env.INTERODIN_API_KEY })
const count = await client.messages.countTokens({
model: 'odin-v1',
system: 'You are a concise assistant.',
messages: [{ role: 'user', content: 'How many tokens is this?' }],
})
console.log(count.input_tokens)
```
```python tab="Python"
import os
import anthropic
client = anthropic.Anthropic(base_url="https://api.interodin.com", api_key=os.environ["INTERODIN_API_KEY"])
count = client.messages.count_tokens(
model="odin-v1",
system="You are a concise assistant.",
messages=[{"role": "user", "content": "How many tokens is this?"}],
)
print(count.input_tokens)
```
## Prompt caching [#prompt-caching]
Caching is automatic: you don't need `cache_control` breakpoints. If you send them, for example from code written for Anthropic, they are accepted and ignored. Cached input is reported in `usage.cache_read_input_tokens` and billed at the cached-input rate.
To keep a conversation on the same cache, send the `x-interodin-session-id` header with a stable id. It is scoped to your organization, so it never collides with other customers.
```python tab="Python"
import os
import anthropic
client = anthropic.Anthropic(
base_url="https://api.interodin.com",
api_key=os.environ["INTERODIN_API_KEY"],
default_headers={"x-interodin-session-id": "conv_7d2a9f"},
)
message = client.messages.create(
model="odin-v1",
max_tokens=512,
messages=[{"role": "user", "content": "Summarize our plan so far."}],
)
print(message.usage.cache_read_input_tokens)
```
```ts tab="TypeScript"
import Anthropic from '@anthropic-ai/sdk'
const client = new Anthropic({
baseURL: 'https://api.interodin.com',
apiKey: process.env.INTERODIN_API_KEY,
defaultHeaders: { 'x-interodin-session-id': 'conv_7d2a9f' },
})
const message = await client.messages.create({
model: 'odin-v1',
max_tokens: 512,
messages: [{ role: 'user', content: 'Summarize our plan so far.' }],
})
console.log(message.usage.cache_read_input_tokens)
```
See [Prompt caching](https://docs.interodin.com/en/guides/prompt-caching) for details.
## Vision [#vision]
Image input depends on the model. **Odin v1 is text-only.** An `image` content block you put in the newest message fails with `400 image_input_not_supported`. Each [model page](https://docs.interodin.com/en/models) says whether the model accepts images.
`document` blocks (PDFs and other files) are not supported by any model and fail with `400 unsupported_content` when they are in the newest message.
An image or file that came from a tool result or from an earlier turn of the conversation (for example a file a coding agent read) is replaced by a short text note, so one image in the history cannot break every later message. The model sees the note instead and can say it cannot view the image.
# Prompt caching (https://docs.interodin.com/en/guides/prompt-caching)
Long conversations, big system prompts and agent tools repeat the same tokens on every request. Prompt caching lets those repeated input tokens be read from a cache. Cached input is billed at the lower **cached input** rate shown on each [model page](https://docs.interodin.com/en/models).
## It is automatic [#it-is-automatic]
You don't need to turn anything on, and you don't need `cache_control` breakpoints. They are accepted for compatibility and ignored. Every request is checked against recent requests: the longest matching prefix of the prompt is read from the cache.
Hits are best-effort. An identical prompt can still miss, for example after a period of inactivity, and there is no fixed retention time.
## What must stay the same [#what-must-stay-the-same]
The cache matches the beginning of the prompt exactly, so these must be identical, in the same order:
- the model
- the system or developer instructions
- the tool definitions
- every earlier message
Appending a new message at the end keeps the earlier prefix cacheable. Editing or reordering earlier messages, changing tools or instructions, or putting a timestamp at the top of the system prompt breaks the match from that point on.
**Rule of thumb:** put what never changes first (instructions, tools, long documents), and what changes last.
## Routing hints [#routing-hints]
Requests are spread across many servers. A routing hint sends all requests of one conversation to the same cache:
| Hint | Formats | How to send |
| --- | --- | --- |
| `x-interodin-session-id` | All three formats | A request header. Recommended. |
| `prompt_cache_key` | Chat Completions, Responses | A body field. |
Both hints are scoped to your organization. The API never forwards the value you send: it forwards a hash that is specific to your organization. Caching therefore works across all the keys of your organization, and your values can never collide with another customer's.
Use one stable, opaque value per conversation or workload, for example your conversation id. `prompt_cache_key` takes any string up to 1024 characters, and a value that is not a string fails with `400 invalid_request`. Shorter is better: 128 characters or fewer is plenty. Don't reuse one value across unrelated traffic, and use an id rather than prompts, personal data or secrets. If you send both hints, give them the same value.
```bash tab="curl"
curl https://api.interodin.com/v1/chat/completions \
-H "Authorization: Bearer $INTERODIN_API_KEY" \
-H "Content-Type: application/json" \
-H "x-interodin-session-id: conv_7d2a9f" \
-d '{
"model": "odin-v1",
"prompt_cache_key": "conv_7d2a9f",
"messages": [
{"role": "system", "content": "You are a concise coding assistant."},
{"role": "user", "content": "Review this function."}
]
}'
```
The same goes for the end-user fields `user` and `safety_identifier` (Chat Completions and Responses) and `metadata.user_id` (Messages). You can send your own end-user id in them. They take a string of up to 1024 characters, and the API forwards only a hash that is specific to your organization.
When you send many requests in parallel with the same long prefix, let the first one start streaming before you send the rest. That gives the cache entry time to form.
## Reading cached tokens [#reading-cached-tokens]
| Endpoint | Field |
| --- | --- |
| `/v1/chat/completions` | `usage.prompt_tokens_details.cached_tokens` |
| `/v1/responses` | `usage.input_tokens_details.cached_tokens` |
| `/v1/messages` | `usage.cache_read_input_tokens` |
When streaming, read these from the final usage event:
- **Chat Completions:** the last chunk.
- **Responses:** `response.completed`.
- **Messages:** `message_start`.
A value of `0` means that request had no cache hit. Your usage in the console may show cached tokens too.
## Coding agents [#coding-agents]
Tools like Claude Code and Codex send several API requests per visible turn: tool calls, retries and context compaction. Each request is billed separately, and each benefits from the cache. [Claude Code](https://docs.interodin.com/en/integrations/claude-code) needs no extra setup, because caching is automatic.
# Responses (https://docs.interodin.com/en/guides/responses)
`POST https://api.interodin.com/v1/responses` takes the OpenAI Responses format. The Codex CLI and newer OpenAI SDK features use it.
## Stateless by design [#stateless-by-design]
InterOdin never stores API content, so Responses works statelessly:
- **`store` is always `false`.** Whatever you send, nothing is kept after the reply.
- **`previous_response_id` is not supported.** Requests that set it fail with `400 unsupported_parameter`. Instead, send the whole conversation in `input`, as shown in [Multi-turn conversations](#multi-turn-conversations).
- **There is no `GET` or `DELETE /v1/responses/{id}`.** They return `404 not_found`.
- **`conversation` and `background: true` are not supported** either, and fail with `400 unsupported_parameter`.
- **Stored prompt templates (`prompt`) and `item_reference` input items** fail with `400 invalid_request`.
- **`metadata` is ignored.** It is removed from the request and not forwarded.
Everything else works as usual: text and message input, streaming, function tools, structured outputs and reasoning control.
## Basic request [#basic-request]
```bash tab="curl"
curl https://api.interodin.com/v1/responses \
-H "Authorization: Bearer $INTERODIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "odin-v1",
"instructions": "You are a concise assistant.",
"input": "Name three Norse gods."
}'
```
```python tab="Python"
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.interodin.com/v1", api_key=os.environ["INTERODIN_API_KEY"])
response = client.responses.create(
model="odin-v1",
instructions="You are a concise assistant.",
input="Name three Norse gods.",
store=False,
)
print(response.output_text)
print(response.usage)
```
```ts tab="TypeScript"
import OpenAI from 'openai'
const client = new OpenAI({ baseURL: 'https://api.interodin.com/v1', apiKey: process.env.INTERODIN_API_KEY })
const response = await client.responses.create({
model: 'odin-v1',
instructions: 'You are a concise assistant.',
input: 'Name three Norse gods.',
store: false,
})
console.log(response.output_text)
console.log(response.usage)
```
`output_text` is an SDK convenience that joins the text of all `message` items in `output`. Every response carries an `x-request-id` header. Quote it when you contact support.
## Multi-turn conversations [#multi-turn-conversations]
Keep the conversation on your side. After each reply, append the model's `message` and `function_call` output items to your `input`, then append the next user message. Leave out `reasoning` items: nothing is stored server-side, so they can't be referenced later.
```ts tab="TypeScript"
import OpenAI from 'openai'
const client = new OpenAI({ baseURL: 'https://api.interodin.com/v1', apiKey: process.env.INTERODIN_API_KEY })
const input: OpenAI.Responses.ResponseInput = [{ role: 'user', content: 'Pick a number from 1 to 10.' }]
const first = await client.responses.create({ model: 'odin-v1', input, store: false })
for (const item of first.output) {
if (item.type === 'message' || item.type === 'function_call') input.push(item)
}
input.push({ role: 'user', content: 'Now double it.' })
const second = await client.responses.create({ model: 'odin-v1', input, store: false })
console.log(second.output_text)
```
```python tab="Python"
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.interodin.com/v1", api_key=os.environ["INTERODIN_API_KEY"])
history = [{"role": "user", "content": "Pick a number from 1 to 10."}]
first = client.responses.create(model="odin-v1", input=history, store=False)
history += [item.model_dump(exclude_none=True) for item in first.output if item.type in ("message", "function_call")]
history.append({"role": "user", "content": "Now double it."})
second = client.responses.create(model="odin-v1", input=history, store=False)
print(second.output_text)
```
Keep the earlier part of `input` identical between turns. That way [prompt caching](https://docs.interodin.com/en/guides/prompt-caching) keeps the repeated history cheap.
## Streaming [#streaming]
With `stream: true` you receive typed server-sent events. The ones you'll use most:
| Event | Meaning |
| --- | --- |
| `response.created` | The response started. |
| `response.output_item.added` | A new output item (message, function call, reasoning) began. |
| `response.output_text.delta` | A piece of text, in `delta`. |
| `response.function_call_arguments.delta` | A piece of a tool call's JSON arguments. |
| `response.output_item.done` | An output item is complete. |
| `response.completed` | Done. `response.usage` holds the token counts. |
| `response.incomplete` | Done, but cut short, for example by `max_output_tokens`. `response.usage` holds the token counts. |
| `error` | Generation failed mid-stream. |
If the model goes silent for 300 seconds in the middle of a stream, the stream ends with an `error` event whose code is `upstream_unavailable`. Treat it like any other failed request and retry.
```python tab="Python"
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.interodin.com/v1", api_key=os.environ["INTERODIN_API_KEY"])
stream = client.responses.create(model="odin-v1", input="Count to five.", stream=True, store=False)
for event in stream:
if event.type == "response.output_text.delta":
print(event.delta, end="", flush=True)
elif event.type in ("response.completed", "response.incomplete"):
print("\n", event.response.usage)
```
```ts tab="TypeScript"
import OpenAI from 'openai'
const client = new OpenAI({ baseURL: 'https://api.interodin.com/v1', apiKey: process.env.INTERODIN_API_KEY })
const stream = await client.responses.create({ model: 'odin-v1', input: 'Count to five.', stream: true, store: false })
for await (const event of stream) {
if (event.type === 'response.output_text.delta') process.stdout.write(event.delta)
if (event.type === 'response.completed' || event.type === 'response.incomplete') console.log('\n', event.response.usage)
}
```
## Tools (function calling) [#tools-function-calling]
Function tools are flat in Responses: `name`, `description` and `parameters` sit at the top level of the tool. When the model calls one, `output` contains a `function_call` item. Send it back in `input` together with a `function_call_output` item that carries your result.
```ts tab="TypeScript"
import OpenAI from 'openai'
const client = new OpenAI({ baseURL: 'https://api.interodin.com/v1', apiKey: process.env.INTERODIN_API_KEY })
const tools: OpenAI.Responses.Tool[] = [
{
type: 'function',
name: 'get_weather',
description: 'Get the current weather for a city',
parameters: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'], additionalProperties: false },
strict: true,
},
]
const input: OpenAI.Responses.ResponseInput = [{ role: 'user', content: 'What is the weather in Cluj?' }]
const first = await client.responses.create({ model: 'odin-v1', input, tools, store: false })
for (const item of first.output) {
if (item.type !== 'function_call') continue
const args = JSON.parse(item.arguments) as { city: string }
input.push(item)
input.push({ type: 'function_call_output', call_id: item.call_id, output: JSON.stringify({ city: args.city, celsius: 14 }) })
}
const second = await client.responses.create({ model: 'odin-v1', input, tools, store: false })
console.log(second.output_text)
```
```python tab="Python"
import json
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.interodin.com/v1", api_key=os.environ["INTERODIN_API_KEY"])
tools = [
{
"type": "function",
"name": "get_weather",
"description": "Get the current weather for a city",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
"additionalProperties": False,
},
"strict": True,
}
]
history = [{"role": "user", "content": "What is the weather in Cluj?"}]
first = client.responses.create(model="odin-v1", input=history, tools=tools, store=False)
for item in first.output:
if item.type == "function_call":
args = json.loads(item.arguments)
history.append(item.model_dump(exclude_none=True))
history.append({
"type": "function_call_output",
"call_id": item.call_id,
"output": json.dumps({"city": args["city"], "celsius": 14}),
})
second = client.responses.create(model="odin-v1", input=history, tools=tools, store=False)
print(second.output_text)
```
Only `function` tools and the [web search](#web-search) tool are kept. Other hosted tools such as file search, code interpreter and MCP are removed from the request without an error, so the model never sees them. A `tool_choice` that names a removed tool is removed too.
## Web search [#web-search]
Add `{ "type": "web_search" }` to `tools` and the model can search the web and cite its sources. Each search appears as a `web_search_call` item in `output`.
- **Price.** Each search is billed on top of the tokens it uses. The per-search price is on the pricing page on the InterOdin website.
- **Limit.** A request is billed for the searches it runs; your balance must cover the tokens and up to 5 searches before it starts, or it fails with `402 insufficient_credits`.
- **Where.** Web search works on this API and on [Messages](https://docs.interodin.com/en/guides/messages#web-search), not on Chat Completions.
## Structured outputs [#structured-outputs]
Use `text.format` with a JSON Schema:
```ts tab="TypeScript"
import OpenAI from 'openai'
const client = new OpenAI({ baseURL: 'https://api.interodin.com/v1', apiKey: process.env.INTERODIN_API_KEY })
const response = await client.responses.create({
model: 'odin-v1',
input: 'Extract the person: Ana is 31 and lives in Iasi.',
text: {
format: {
type: 'json_schema',
name: 'person',
strict: true,
schema: {
type: 'object',
properties: { name: { type: 'string' }, age: { type: 'integer' } },
required: ['name', 'age'],
additionalProperties: false,
},
},
},
store: false,
})
console.log(JSON.parse(response.output_text))
```
```python tab="Python"
import json
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.interodin.com/v1", api_key=os.environ["INTERODIN_API_KEY"])
response = client.responses.create(
model="odin-v1",
input="Extract the person: Ana is 31 and lives in Iasi.",
text={
"format": {
"type": "json_schema",
"name": "person",
"strict": True,
"schema": {
"type": "object",
"properties": {"name": {"type": "string"}, "age": {"type": "integer"}},
"required": ["name", "age"],
"additionalProperties": False,
},
}
},
store=False,
)
print(json.loads(response.output_text))
```
`{"type": "json_object"}` is also accepted, for free-form JSON.
## Reasoning control [#reasoning-control]
Both models reason before they answer. The reasoning arrives as a `reasoning` item in `output`, and its token count is in `usage.output_tokens_details.reasoning_tokens`. Reasoning tokens are billed as output.
Reasoning is either on or off, with no levels. `reasoning: { effort: "none" }` turns it off on Odin Flash, which makes answers much faster and cheaper but weaker on arithmetic and logic. On Odin v1 `none` does not turn it off: use the [Messages API](https://docs.interodin.com/en/guides/messages#extended-thinking) with `thinking: {"type": "disabled"}` for that. The values `minimal`, `low`, `medium`, `high`, `xhigh` and `max` are accepted but change nothing.
```ts tab="TypeScript"
import OpenAI from 'openai'
const client = new OpenAI({ baseURL: 'https://api.interodin.com/v1', apiKey: process.env.INTERODIN_API_KEY })
const response = await client.responses.create({
model: 'odin-flash',
input: 'Is 1,000,003 prime?',
reasoning: { effort: 'none' },
store: false,
})
console.log(response.output_text)
console.log(response.usage?.output_tokens_details.reasoning_tokens)
```
## Prompt caching [#prompt-caching]
Caching is automatic. Send a stable `x-interodin-session-id` header, or a `prompt_cache_key`, for each conversation, and read `usage.input_tokens_details.cached_tokens`. Both are scoped to your organization, so they never collide with other customers. See [Prompt caching](https://docs.interodin.com/en/guides/prompt-caching).
## Vision [#vision]
Image input depends on the model. **Odin v1 is text-only.** An `input_image` part in the newest item fails with `400 image_input_not_supported`. Each [model page](https://docs.interodin.com/en/models) says whether the model accepts images.
File input (`input_file` parts) is not supported by any model and fails with `400 unsupported_content` when it is in the newest item.
An image or file that came from a tool result or from an earlier turn of the conversation (for example a file a coding agent read) is replaced by a short text note, so one image in the history cannot break every later message.
## Parameters [#parameters]
| Parameter | Notes |
| --- | --- |
| `model` | Required. For example `odin-v1`. |
| `input`, `instructions` | A string or a list of input items. |
| `max_output_tokens` | Clamped to the model's maximum output. A value of zero or less fails with `400 invalid_request`. |
| `prompt_cache_key` | Cache routing hint. |
| `user`, `safety_identifier` | Your own end-user id, for tracing and abuse reports. |
| `store` | Always `false`. |
| `metadata` | Ignored and not forwarded. |
`prompt_cache_key`, `user` and `safety_identifier` take any string up to 1024 characters. A value of any other type fails with `400 invalid_request`. They are scoped to your organization: the API forwards a hash that is specific to your organization, never the value you sent. Use an opaque id, such as a database id, rather than an email address.
# Introduction (https://docs.interodin.com/en)
InterOdin serves **Odin v1** (`odin-v1`) in three API formats: OpenAI Chat Completions, OpenAI Responses and Anthropic Messages. Keep your SDK, framework or coding tool, and change only the base URL and the key.
| Setting | Value |
| --- | --- |
| Base URL for OpenAI SDKs and tools | `https://api.interodin.com/v1` |
| Base URL for Anthropic SDKs and Claude Code | `https://api.interodin.com` |
| Model | `odin-v1` |
| API key | `io-sk-…`, created in the [console](https://interodin.com/console) |
## Start here [#start-here]
- [Quickstart](https://docs.interodin.com/en/quickstart): create a key and send your first request.
- [Authentication](https://docs.interodin.com/en/authentication): the two ways to send your key, and how to scope it.
- [Errors and rate limits](https://docs.interodin.com/en/errors-and-rate-limits): every error code and what to do about it.
## Pick your format [#pick-your-format]
- [Chat Completions](https://docs.interodin.com/en/guides/chat-completions) is the most widely supported format. Use it when in doubt.
- [Responses](https://docs.interodin.com/en/guides/responses) is OpenAI's newer format, used by the Codex CLI. It is stateless here.
- [Messages](https://docs.interodin.com/en/guides/messages) is Anthropic's format, used by Claude Code and the Anthropic SDKs.
All three reach the same model, with the same key, balance and rate limits.
## Plug into your tools [#plug-into-your-tools]
[Claude Code](https://docs.interodin.com/en/integrations/claude-code), [Codex CLI](https://docs.interodin.com/en/integrations/codex), [Cursor](https://docs.interodin.com/en/integrations/cursor), [Cline and Roo Code](https://docs.interodin.com/en/integrations/cline-roo), [Continue](https://docs.interodin.com/en/integrations/continue), [n8n](https://docs.interodin.com/en/integrations/n8n), the [OpenAI SDK](https://docs.interodin.com/en/integrations/openai-sdk), the [Anthropic SDK](https://docs.interodin.com/en/integrations/anthropic-sdk), the [Vercel AI SDK](https://docs.interodin.com/en/integrations/vercel-ai-sdk), [LangChain](https://docs.interodin.com/en/integrations/langchain) and [LlamaIndex](https://docs.interodin.com/en/integrations/llamaindex).
## Privacy [#privacy]
The API never stores prompts or replies. For billing, abuse prevention and your usage pages we keep only request metadata: model, token counts, cost, latency, status, the request id, the key that made the request, and the client IP address and user agent. See the [models](https://docs.interodin.com/en/models) page for context sizes and prices.
## For AI agents [#for-ai-agents]
This documentation is available as plain text: [/llms.txt](https://docs.interodin.com/llms.txt) is an index of every page, and [/llms-full.txt](https://docs.interodin.com/llms-full.txt) has the full content. The API itself is described by [/openapi.json](https://docs.interodin.com/openapi.json).
# Anthropic SDK (https://docs.interodin.com/en/integrations/anthropic-sdk)
The official Anthropic SDKs work unchanged against `/v1/messages`.
**Base URL:** use the origin, `https://api.interodin.com`, **without** `/v1`. The SDK adds `/v1/messages` itself.
## Install [#install]
```bash tab="Python"
pip install anthropic
```
```bash tab="TypeScript"
npm install @anthropic-ai/sdk
```
## Configure [#configure]
```python tab="Python"
import os
import anthropic
client = anthropic.Anthropic(
base_url="https://api.interodin.com",
api_key=os.environ["INTERODIN_API_KEY"],
)
message = client.messages.create(
model="odin-v1",
max_tokens=1024,
messages=[{"role": "user", "content": "Say hello in Romanian."}],
)
print("".join(block.text for block in message.content if block.type == "text"))
```
```ts tab="TypeScript"
import Anthropic from '@anthropic-ai/sdk'
const client = new Anthropic({
baseURL: 'https://api.interodin.com',
apiKey: process.env.INTERODIN_API_KEY,
})
const message = await client.messages.create({
model: 'odin-v1',
max_tokens: 1024,
messages: [{ role: 'user', content: 'Say hello in Romanian.' }],
})
for (const block of message.content) if (block.type === 'text') console.log(block.text)
```
Or configure it through the environment variables the SDKs read:
```bash
export ANTHROPIC_BASE_URL="https://api.interodin.com"
export ANTHROPIC_API_KEY="io-sk-your-key"
```
`api_key` / `apiKey` sends the key as `x-api-key`. If you prefer `Authorization: Bearer`, pass it as `auth_token` (Python) or `authToken` (TypeScript), or set `ANTHROPIC_AUTH_TOKEN`. Both are accepted.
## Streaming and token counting [#streaming-and-token-counting]
```python tab="Python"
import os
import anthropic
client = anthropic.Anthropic(base_url="https://api.interodin.com", api_key=os.environ["INTERODIN_API_KEY"])
count = client.messages.count_tokens(
model="odin-v1",
messages=[{"role": "user", "content": "Write a haiku about Odin."}],
)
print("input tokens:", count.input_tokens)
with client.messages.stream(
model="odin-v1",
max_tokens=512,
messages=[{"role": "user", "content": "Write a haiku about Odin."}],
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)
```
```ts tab="TypeScript"
import Anthropic from '@anthropic-ai/sdk'
const client = new Anthropic({ baseURL: 'https://api.interodin.com', apiKey: process.env.INTERODIN_API_KEY })
const count = await client.messages.countTokens({
model: 'odin-v1',
messages: [{ role: 'user', content: 'Write a haiku about Odin.' }],
})
console.log('input tokens:', count.input_tokens)
const stream = client.messages.stream({
model: 'odin-v1',
max_tokens: 512,
messages: [{ role: 'user', content: 'Write a haiku about Odin.' }],
})
stream.on('text', (text) => process.stdout.write(text))
await stream.finalMessage()
```
Token counting is not billed (it still counts toward rate limits).
## Good to know [#good-to-know]
- **Model id.** Use `odin-v1`. Model names the SDK suggests for other providers return `404 model_not_found`.
- **Prompt caching** is automatic. `cache_control` blocks are accepted and ignored. See [Prompt caching](https://docs.interodin.com/en/guides/prompt-caching).
- **Images.** Odin v1 is text-only, so an `image` block in the newest message returns `400 image_input_not_supported`. Images in tool results or earlier turns are replaced by a text note.
- **Documents.** A `document` block in the newest message returns `400 unsupported_content`.
- **Tools.** Custom tools and the web search tool (`web_search_20250305`, up to 5 searches per request, billed per search) work. Other typed tools (code execution, bash, text editor, computer use) and `mcp_servers` are removed from the request without an error. See [Web search](https://docs.interodin.com/en/guides/messages#web-search).
- **Models.** `client.models.list()` returns the Anthropic-format list, because the SDK sends `anthropic-version`.
- **Errors** arrive in Anthropic's format, so the SDK raises its usual exceptions. A `402 billing_error` means your balance is used up. To tell codes that share an error type apart, read the `x-interodin-error-code` response header.
More: [Messages guide](https://docs.interodin.com/en/guides/messages).
# Claude Code (https://docs.interodin.com/en/integrations/claude-code)
Claude Code talks the Anthropic Messages format, so it works with InterOdin unchanged. Point it at the API origin, give it your key, and set **every** model slot to `odin-v1`. The block below also switches off the Anthropic-only extras that cause most gateway errors.
## Quick start [#quick-start]
The setup script adds a separate **`claudeodin`** command: the same Claude Code, running on Odin models with your InterOdin key. Your normal `claude` is not touched and keeps using your Claude account, so you can use both side by side. This is the recommended way.
`claudeodin` keeps its settings, sessions and history in its own folder, `~/.claude-odin`. The script writes the settings below there, puts the `claudeodin` launcher next to `claude` (or in `~/.local/bin`, and tells you if that folder needs adding to your PATH), and asks for your key with the input hidden.
1. Create an API key in the [console](https://interodin.com/console) (it starts with `io-sk-`).
2. Download the [setup script](https://docs.interodin.com/claude-code-setup.mjs), then run it with Node 18 or newer:
```bash
curl -fsSL https://docs.interodin.com/claude-code-setup.mjs -o claude-code-setup.mjs
node claude-code-setup.mjs
```
3. Run `claudeodin`. No login screen appears, because the key is already set. On the first start Claude Code asks for a theme once, as it does for any new settings folder.
Prefer to make your normal `claude` use InterOdin instead, with no separate command? Run `node claude-code-setup.mjs --replace-claude`. It writes the same settings into `~/.claude/settings.json`, keeping your other settings and backing up the old file first.
The script then checks the key for free (it lists the models the key may use; `--no-check` skips this) and warns about the usual conflicts: an `ANTHROPIC_API_KEY` in your shell or a signed-in Claude.ai account. To undo everything later, run `node claude-code-setup.mjs delete` (see [Remove InterOdin and go back to normal](#remove-interodin-and-go-back-to-normal)). The script is short and has no dependencies, so you can read it before running it. Prefer to do it by hand? The next section shows the exact settings it writes, and [Add `claudeodin` by hand](#add-claudeodin-by-hand) shows the launcher.
Claude Code asks before every shell command and file edit. If that gets tiring, start it with `claudeodin --dangerously-skip-permissions` (the same as `claudeodin --permission-mode bypassPermissions`; with `--replace-claude` or the settings below, use `claude` in place of `claudeodin`), or add an alias such as `alias claudeodinx='claudeodin --dangerously-skip-permissions'`.
Only do this in a throwaway project, a container or a VM. With prompts off, the model can run any command and change or delete any file you can, with nothing asking first. Odin v1 answers without refusing, so there is no brake on the model side either. Don't use it on a machine that holds credentials or work you can't lose.
## Configure `settings.json` [#configure-settingsjson]
Add these to `~/.claude/settings.json`, which applies to all your projects. Or put the `env` block in `.claude/settings.local.json` in one project (the `modelPicker` block only works in `~/.claude/settings.json`).
```json
{
"env": {
"ANTHROPIC_BASE_URL": "https://api.interodin.com",
"ANTHROPIC_AUTH_TOKEN": "io-sk-your-key",
"ANTHROPIC_MODEL": "odin-v1[1m]",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "odin-v1[1m]",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "odin-v1[1m]",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "odin-flash",
"CLAUDE_CODE_SUBAGENT_MODEL": "odin-v1[1m]",
"CLAUDE_CODE_MAX_CONTEXT_TOKENS": "250000",
"CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS": "1",
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
"ENABLE_CLAUDEAI_MCP_SERVERS": "false"
},
"modelPicker": {
"replaceBuiltInOptions": true,
"options": [
{ "model": "odin-v1[1m]", "label": "Odin v1", "description": "Most capable, 1M context" },
{ "model": "odin-flash", "label": "Odin Flash", "description": "Fast and cheaper" }
]
}
}
```
Then start `claude` as usual.
| Variable | Why |
| --- | --- |
| `ANTHROPIC_BASE_URL` | The API origin, **without** `/v1`. Claude Code appends `/v1/messages`. |
| `ANTHROPIC_AUTH_TOKEN` | Your `io-sk-` key, sent as `Authorization: Bearer`. |
| `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL` | Claude Code picks different model slots for the main loop, subagents and quick background tasks such as titles and summaries. Mapping every slot to an InterOdin model means no call ever asks for a model that doesn't exist, which would fail with `model_not_found`. The Haiku slot (background tasks) uses the faster `odin-flash`. |
| `ANTHROPIC_MODEL` | The model for the main conversation. Without it Claude Code starts on its own default (a Claude model name), which InterOdin doesn't serve. |
| `[1m]` after `odin-v1` | Tells Claude Code that Odin v1 has a 1M-token context window, so auto-compaction triggers at the right time. Claude Code removes the suffix before it calls the API. |
| `CLAUDE_CODE_MAX_CONTEXT_TOKENS` | The window for `odin-flash`: 250,000 of its 262,144 tokens, leaving room for the reply. It only applies to a model without `[1m]`, so Odin v1 keeps its 1M. Claude Code may print a startup warning about a window above 200K; that is expected. |
| `modelPicker` | The `/model` menu lists **Odin v1** and **Odin Flash** instead of Claude models that InterOdin doesn't serve. Needs Claude Code 2.1.242 or later; older versions ignore it, and you can still switch with `/model odin-flash`. |
| `CLAUDE_CODE_SUBAGENT_MODEL` | The model for subagents (Task tool). Same reason. |
| `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` | Stops Claude Code sending pre-release request fields and `anthropic-beta` values that only Anthropic's own API understands. Without it, some upstream builds answer with `400 Extra inputs are not permitted`. |
| `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` | Stops Claude Code's background calls to Anthropic (telemetry, version checks). It also turns off Claude Code's auto-update, so update it yourself from time to time. Safe to leave out. |
| `ENABLE_CLAUDEAI_MCP_SERVERS` | If you are also signed in to claude.ai, Claude Code tries to load your claude.ai connectors (MCP servers) on startup, and they fail when every request goes to InterOdin. Setting this to `false` skips them. |
Don't commit a settings file that contains your key. For a shared project, keep the key in `.claude/settings.local.json`, which is git-ignored, or in your shell environment.
## Or use environment variables [#or-use-environment-variables]
The same variables work from your shell profile:
```bash
export ANTHROPIC_BASE_URL="https://api.interodin.com"
export ANTHROPIC_AUTH_TOKEN="io-sk-your-key"
export ANTHROPIC_MODEL="odin-v1[1m]"
export ANTHROPIC_DEFAULT_OPUS_MODEL="odin-v1[1m]"
export ANTHROPIC_DEFAULT_SONNET_MODEL="odin-v1[1m]"
export ANTHROPIC_DEFAULT_HAIKU_MODEL="odin-flash"
export CLAUDE_CODE_SUBAGENT_MODEL="odin-v1[1m]"
export CLAUDE_CODE_MAX_CONTEXT_TOKENS="250000"
export CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS="1"
export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="1"
export ENABLE_CLAUDEAI_MCP_SERVERS="false"
unset ANTHROPIC_API_KEY
```
## Add `claudeodin` by hand [#add-claudeodin-by-hand]
The script's launcher only sets one variable, `CLAUDE_CONFIG_DIR`, so Claude Code reads its settings from `~/.claude-odin` instead of `~/.claude`. To build it yourself, put the `settings.json` above in `~/.claude-odin/settings.json`, then create the launcher in a folder on your PATH:
macOS / Linux (`~/.local/bin/claudeodin`, then `chmod +x ~/.local/bin/claudeodin`):
```bash
#!/bin/sh
CLAUDE_CONFIG_DIR="$HOME/.claude-odin" exec claude "$@"
```
Windows (`claudeodin.cmd`, in the same folder as `claude.cmd`):
```bat nocheck
@echo off
setlocal
set "CLAUDE_CONFIG_DIR=%UserProfile%\.claude-odin"
set "ClaudeExe="
for %%E in (exe cmd) do if not defined ClaudeExe for %%I in (claude.%%E) do set "ClaudeExe=%%~$PATH:I"
"%ClaudeExe%" %*
```
It looks `claude` up in your PATH folders only. A plain `claude %*` would let Windows run a `claude.cmd` planted in the current folder (a cloned repository, for example) instead of the real one.
Or skip the file and add a shell alias: `alias claudeodin='CLAUDE_CONFIG_DIR="$HOME/.claude-odin" claude'` in `~/.bashrc` or `~/.zshrc`.
## Remove InterOdin and go back to normal [#remove-interodin-and-go-back-to-normal]
One command undoes the setup, whichever way you set it up:
```bash
node claude-code-setup.mjs delete
```
(`remove`, `uninstall` and `--remove` do the same.) It removes the `claudeodin` launcher and deletes exactly what the setup added to `~/.claude-odin/settings.json` and `~/.claude/settings.json`: the InterOdin variables and the Odin v1 / Odin Flash list in `/model`. Your other settings stay as they are, and each file is backed up first. A model list you set up yourself is left alone. The `~/.claude-odin` folder itself stays, with the `claudeodin` sessions and history in it; delete it if you do not need them.
If you used `claudeodin`, your normal `claude` was never changed and there is nothing else to do. If you used `--replace-claude`, restart Claude Code and run `/login` to use your Claude account again. `/model` then shows Claude's own models.
Set it up by hand instead? Delete the same entries yourself:
- The `claudeodin` launcher or alias, and the `~/.claude-odin` folder.
- In `~/.claude/settings.json` (or `.claude/settings.local.json`): the `env` entries from the table above and the `modelPicker` block.
- In your shell profile: the `export` lines from [Or use environment variables](#or-use-environment-variables), then open a new terminal. In the current one, `unset ANTHROPIC_BASE_URL ANTHROPIC_AUTH_TOKEN ANTHROPIC_MODEL ANTHROPIC_DEFAULT_OPUS_MODEL ANTHROPIC_DEFAULT_SONNET_MODEL ANTHROPIC_DEFAULT_HAIKU_MODEL CLAUDE_CODE_SUBAGENT_MODEL CLAUDE_CODE_MAX_CONTEXT_TOKENS` clears them (PowerShell: `Remove-Item Env:ANTHROPIC_BASE_URL` and so on).
Your InterOdin key keeps working for other tools. Revoke it in the console if you no longer need it.
## Check that it works [#check-that-it-works]
Inside Claude Code, run `/status`. It should show the base URL above. Then send a short prompt. The request appears within seconds in the console's request logs, against the key you used.
## Good to know [#good-to-know]
- **Prices in `/model`.** The picker shows prices such as "$5/$25 per Mtok" from Claude Code's own table of Claude models. That label is display only: Claude Code has no setting to change it, and you are billed InterOdin's prices for `odin-v1` and `odin-flash` (see the console's usage and request logs).
- **Billing.** One visible turn can be several API requests: tool calls, retries, compaction. Each request is billed for its own tokens. Watch usage per key in the console, and consider giving Claude Code its own key with a monthly spend limit.
- **Caching** is automatic and best-effort, so long sessions reuse their prompt prefix. No extra setup is needed. See [Prompt caching](https://docs.interodin.com/en/guides/prompt-caching).
- **Thinking.** Both models think by default. Thinking is on or off only: there are no effort levels or budgets, so those settings have no effect. See [Extended thinking](https://docs.interodin.com/en/guides/messages#extended-thinking).
- **Images.** Odin v1 is text-only. Pasting a screenshot fails with `400 image_input_not_supported`. If Claude Code reads an image file itself (for example one it just downloaded), the image is replaced by a short note and the session carries on. Run `/clear` or `/rewind` if a session you started elsewhere keeps failing on an image in its history.
- **Documents.** Attaching a PDF or another document fails with `400 unsupported_content`. Paste the text instead.
- **Web search.** Claude Code's `WebSearch` tool works. Each search is billed on top of the tokens, at the per-search price on the pricing page on the InterOdin website. A request runs at most 5 searches.
- **Client tools** such as the shell, file edits and your own custom tools are ordinary function tools and work normally. Other server-side tools are removed from the request without an error.
- **`[1m]` suffix.** InterOdin accepts a model id such as `odin-v1[1m]` and ignores the suffix. Don't rely on it to set Claude Code's context window. Use `CLAUDE_CODE_MAX_CONTEXT_TOKENS` instead.
## Troubleshooting [#troubleshooting]
| Symptom | Fix |
| --- | --- |
| `401` / "invalid API key" | Check `ANTHROPIC_AUTH_TOKEN`, and make sure the key isn't revoked. |
| `402` / billing error | Top up in the console. |
| `404 model_not_found` | A model slot isn't mapped. Set all three `ANTHROPIC_DEFAULT_*_MODEL` variables to `odin-v1`. |
| `There's an issue with the selected model` | `ANTHROPIC_MODEL` or a `--model` value names a model InterOdin doesn't have. Set it to `odin-v1`, or run `/model` and pick it. |
| Startup warning `Model "odin-v1" isn't recognized · context assumed to be 200k tokens` | Harmless: Claude Code doesn't know `odin-v1`. Set `CLAUDE_CODE_MAX_CONTEXT_TOKENS` as above so it compacts at the right time. |
| `400` with `Extra inputs are not permitted` or `context_management` | Set `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` to `1` (already in the block above). |
| Claude Code shows a login screen | The key must be somewhere Claude Code reads before first-run setup: `~/.claude/settings.json` or your shell. A project's `.claude/settings.local.json` is only read after the trust prompt. |
| `ANTHROPIC_API_KEY` is set in your shell and Claude Code uses it instead | Run `unset ANTHROPIC_API_KEY` (PowerShell: `Remove-Item Env:ANTHROPIC_API_KEY`) before starting `claude`. InterOdin uses `ANTHROPIC_AUTH_TOKEN`, and a leftover Anthropic key conflicts with it. |
| Warning about two credential sources (a token and a saved login) | Run `/logout` once. The key in `ANTHROPIC_AUTH_TOKEN` is then the only credential. |
| `odin-v1` missing from the `/model` list | Add `"ANTHROPIC_CUSTOM_MODEL_OPTION": "odin-v1"` to the `env` block. Gateway model discovery doesn't help here: Claude Code only lists discovered ids that contain `claude` or `anthropic`. |
| Requests still go elsewhere | A project-level or managed `settings.json` overrides yours. Run `/status` to see which values are active. |
# Cline and Roo Code (https://docs.interodin.com/en/integrations/cline-roo)
Cline and Roo Code both have an **OpenAI Compatible** provider. The setup is the same in both.
## Setup [#setup]
1. Open the extension's settings (the gear icon in its panel).
2. Set **API Provider** to **OpenAI Compatible**.
3. Fill in the fields:
| Field | Value |
| --- | --- |
| Base URL | `https://api.interodin.com/v1` |
| API Key | your `io-sk-` key |
| Model ID | `odin-v1` |
4. Under **Model Configuration**, set:
| Setting | Value |
| --- | --- |
| Context Window Size | the context size on the [Odin v1 page](https://docs.interodin.com/en/models/odin-v1), `1000000` |
| Supports Images | off |
| Supports prompt caching | on, if the option exists. Caching is automatic either way. |
5. Save, and start a task.
The extension uses the context window size to decide when to condense the conversation, so set it to the real value.
## Using the Anthropic provider instead [#using-the-anthropic-provider-instead]
Both extensions also have an **Anthropic** provider with a custom base URL option. To use the Messages format:
- Set **API Provider** to **Anthropic**.
- Enable **Use custom base URL** and enter `https://api.interodin.com`, without `/v1`.
- Paste your key.
- If the model list doesn't offer `odin-v1`, use the OpenAI Compatible setup above.
## Good to know [#good-to-know]
- **Cost.** Agentic tools send many requests per task. Give the extension its own key with a monthly spend limit, and watch it in the console.
- **Images.** Odin v1 is text-only, so leave image support off.
# Codex CLI (https://docs.interodin.com/en/integrations/codex)
The Codex CLI uses the Responses API, which InterOdin serves at `https://api.interodin.com/v1/responses`.
## 1. Export your key [#1-export-your-key]
Keep the key out of `config.toml`; `env_key` names the environment variable Codex reads it from. Export it in your shell:
```bash
export INTERODIN_API_KEY="io-sk-your-key"
```
## 2. Add a provider to `~/.codex/config.toml` [#2-add-a-provider-to-codexconfigtoml]
```toml
model = "odin-v1"
model_provider = "interodin"
model_context_window = 1000000
[model_providers.interodin]
name = "InterOdin"
base_url = "https://api.interodin.com/v1"
wire_api = "responses"
env_key = "INTERODIN_API_KEY"
```
`env_key` is the **name** of the environment variable, not the key itself.
`model_context_window` tells Codex how large Odin v1's context is, so it knows when to compact the conversation. Use the context size on the [Odin v1 page](https://docs.interodin.com/en/models/odin-v1).
## 3. Run it [#3-run-it]
```bash
codex
```
For scripts and CI, use `codex exec "your task"`.
## Keep OpenAI as your default [#keep-openai-as-your-default]
To switch providers per run instead, put the settings in a profile in the same `~/.codex/config.toml`:
```toml
[model_providers.interodin]
name = "InterOdin"
base_url = "https://api.interodin.com/v1"
wire_api = "responses"
env_key = "INTERODIN_API_KEY"
[profiles.odin]
model = "odin-v1"
model_provider = "interodin"
model_context_window = 1000000
```
```bash
codex --profile odin
```
## Good to know [#good-to-know]
- **Stateless.** InterOdin never stores responses, so Codex sends the full conversation with every request. This is automatic and needs no setting. Requests that depend on `previous_response_id` fail with `400 unsupported_parameter`. [Prompt caching](https://docs.interodin.com/en/guides/prompt-caching) keeps the repeated history cheap.
- **Hosted tools.** Web search works and is billed per search on top of the tokens (see [Web search](https://docs.interodin.com/en/guides/responses#web-search)). Other hosted tools are removed from the request without an error, so they never run. Function and custom tools pass through. Codex's local tools (shell, file edits, patches) are expected to work, but this hasn't been confirmed against a real Codex run yet.
- **Reasoning effort.** Codex's reasoning effort setting only switches reasoning on or off. `none` turns it off on Odin Flash. On Odin v1 it has no effect, and `low`, `medium` and `high` behave the same on both models. See [Reasoning control](https://docs.interodin.com/en/guides/responses#reasoning-control).
- **Images and documents.** Odin v1 is text-only. An image in the newest message fails with `400 image_input_not_supported`, and a file input fails with `400 unsupported_content`. Images and files that came from tool output or earlier turns are replaced by a text note.
- **Where settings live.** Define `model_provider`, `model_providers`, `profile` and `profiles` in your user config, `~/.codex/config.toml`. A project-local `.codex/config.toml` ignores them. CLI flags such as `--profile` override the user config. If Codex ignores the provider, check that it is defined in `~/.codex/config.toml` and that a flag or profile you passed isn't selecting another provider.
# Continue (https://docs.interodin.com/en/integrations/continue)
Continue reads models from `config.yaml`. Add Odin v1 with the `openai` provider and a custom `apiBase`.
## 1. Store your key [#1-store-your-key]
Put the key in `~/.continue/.env` so it stays out of the config file:
```bash
INTERODIN_API_KEY=io-sk-your-key
```
## 2. Add the model [#2-add-the-model]
Edit `~/.continue/config.yaml`:
```yaml
name: My config
version: 1.0.0
schema: v1
models:
- name: Odin v1
provider: openai
model: odin-v1
apiBase: https://api.interodin.com/v1
apiKey: ${{ secrets.INTERODIN_API_KEY }}
roles:
- chat
- edit
- apply
capabilities:
- tool_use
defaultCompletionOptions:
contextLength: 1000000
```
Reload Continue and pick **Odin v1** in the model menu.
## Good to know [#good-to-know]
- **Context length.** Set `contextLength` to the context size on the [Odin v1 page](https://docs.interodin.com/en/models/odin-v1).
- **Autocomplete.** Odin v1 reasons before it answers, which makes it slower than the small, fast models usually used for tab autocomplete. Keep it for chat, edit and agent use.
- **Images.** Odin v1 is text-only, so don't add the `image_input` capability.
# Cursor (https://docs.interodin.com/en/integrations/cursor)
Cursor can send chat and agent requests to any OpenAI-compatible API.
## Setup [#setup]
1. Open **Cursor Settings → Models**.
2. Under **API Keys**, paste your `io-sk-` key into the **OpenAI API Key** field and turn it on.
3. Turn on **Override OpenAI Base URL** and enter `https://api.interodin.com/v1`.
4. Under **Models**, add a custom model named `odin-v1` and enable it.
5. In a new chat or agent session, pick `odin-v1` from the model menu, not **Auto**.
Cursor's labels can change between releases. If yours differ, look for the OpenAI API key field and the OpenAI base URL override, which are the two settings that matter.
## Good to know [#good-to-know]
- **Plan.** Using your own key needs a paid Cursor plan. On team accounts, an admin must allow personal API keys.
- **Not every feature uses it.** Your key covers chat and agent requests. Features such as Tab completion keep using Cursor's built-in models and your Cursor subscription.
- **Data path.** Cursor sends custom-key requests through its own servers before they reach us. InterOdin doesn't store request content, but check Cursor's data-handling terms for its side.
- **Switching back.** Turn off the base URL override before you use a real OpenAI key with OpenAI models.
- **Images.** Odin v1 is text-only, so prompts with images return `400 image_input_not_supported`.
# LangChain (https://docs.interodin.com/en/integrations/langchain)
LangChain's OpenAI chat model works with InterOdin once it is pointed at `https://api.interodin.com/v1`. See [Not supported](https://docs.interodin.com/en/integrations/openai-sdk#not-supported) for what the OpenAI-compatible API doesn't accept. Built-in (hosted) tools are removed from the request; only your own tools run.
## Python [#python]
```bash
pip install langchain-openai
```
```python
import os
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(
model="odin-v1",
base_url="https://api.interodin.com/v1",
api_key=os.environ["INTERODIN_API_KEY"],
)
print(llm.invoke("Name three Norse gods.").content)
for chunk in llm.stream("Write a haiku about Odin."):
print(chunk.content, end="", flush=True)
```
### Tools and structured output [#tools-and-structured-output]
```python
import os
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from pydantic import BaseModel
llm = ChatOpenAI(model="odin-v1", base_url="https://api.interodin.com/v1", api_key=os.environ["INTERODIN_API_KEY"])
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
return f"14°C in {city}"
reply = llm.bind_tools([get_weather]).invoke("What is the weather in Cluj?")
print(reply.tool_calls)
class Person(BaseModel):
name: str
age: int
person = llm.with_structured_output(Person).invoke("Extract the person: Ana is 31.")
print(person)
```
### With the Anthropic integration [#with-the-anthropic-integration]
If your code uses `langchain-anthropic`, set its base URL to the origin, without `/v1`:
```python
import os
from langchain_anthropic import ChatAnthropic
llm = ChatAnthropic(
model="odin-v1",
base_url="https://api.interodin.com",
api_key=os.environ["INTERODIN_API_KEY"],
max_tokens=1024,
)
print(llm.invoke("Name three Norse gods.").content)
```
## JavaScript / TypeScript [#javascript--typescript]
```npm
npm install @langchain/openai @langchain/core
```
```ts
import { ChatOpenAI } from '@langchain/openai'
const llm = new ChatOpenAI({
model: 'odin-v1',
apiKey: process.env.INTERODIN_API_KEY,
configuration: { baseURL: 'https://api.interodin.com/v1' },
})
const reply = await llm.invoke('Name three Norse gods.')
console.log(reply.content)
for await (const chunk of await llm.stream('Write a haiku about Odin.')) {
process.stdout.write(String(chunk.content))
}
```
Token usage is reported in `response_metadata` on each reply. In the console it shows up per API key.
# LlamaIndex (https://docs.interodin.com/en/integrations/llamaindex)
LlamaIndex connects through its OpenAI-like LLM class, which accepts any model name and base URL. See [Not supported](https://docs.interodin.com/en/integrations/openai-sdk#not-supported) for what the OpenAI-compatible API doesn't accept.
## Install [#install]
```bash
pip install llama-index-core llama-index-llms-openai-like
```
## Configure [#configure]
```python
import os
from llama_index.core import Settings
from llama_index.core.llms import ChatMessage
from llama_index.llms.openai_like import OpenAILike
llm = OpenAILike(
model="odin-v1",
api_base="https://api.interodin.com/v1",
api_key=os.environ["INTERODIN_API_KEY"],
is_chat_model=True,
is_function_calling_model=True,
context_window=1000000,
)
# Make it the default LLM for indexes, query engines and agents.
Settings.llm = llm
print(llm.complete("Name three Norse gods."))
reply = llm.chat([
ChatMessage(role="system", content="You are a concise assistant."),
ChatMessage(role="user", content="Write a haiku about Odin."),
])
print(reply.message.content)
for chunk in llm.stream_complete("Count to five."):
print(chunk.delta, end="", flush=True)
```
- **`is_chat_model=True`** makes LlamaIndex use Chat Completions.
- **`is_function_calling_model=True`** lets agents use tool calling.
- **`context_window`** must match the [model page](https://docs.interodin.com/en/models/odin-v1). LlamaIndex uses it to decide how much context it can pack into a prompt.
## Embeddings [#embeddings]
InterOdin serves chat models only and has no embeddings route. Set `Settings.embed_model` explicitly to the provider you already use. If you leave it unset and a global `OPENAI_BASE_URL` points at InterOdin, LlamaIndex sends embedding requests here and gets a `404`. Retrieval works as before, and Odin v1 writes the answers.
# n8n (https://docs.interodin.com/en/integrations/n8n)
n8n's OpenAI credential has a configurable base URL, so the OpenAI chat model node works with InterOdin.
## 1. Create the credential [#1-create-the-credential]
1. In n8n, open **Credentials → Add credential** and pick **OpenAI**.
2. Set **API Key** to your `io-sk-` key.
3. Set **Base URL** to `https://api.interodin.com/v1`.
4. Save. The connection test calls `GET /v1/models` and should succeed.
## 2. Use it in an AI Agent [#2-use-it-in-an-ai-agent]
1. Add an **AI Agent** node, or a **Basic LLM Chain**.
2. Attach an **OpenAI Chat Model** sub-node and select the credential from step 1.
3. Under **Model**, choose **By ID** and enter `odin-v1`.
Tools you attach to the agent use tool calling, which Odin v1 supports.
## Or call the API directly [#or-call-the-api-directly]
For full control, use an **HTTP Request** node:
| Field | Value |
| --- | --- |
| Method | `POST` |
| URL | `https://api.interodin.com/v1/chat/completions` |
| Authentication | Generic credential → Header Auth. Name `Authorization`, value `Bearer io-sk-your-key`. |
| Body content type | JSON |
Body:
```json
{
"model": "odin-v1",
"messages": [{ "role": "user", "content": "Summarize this ticket in one sentence." }]
}
```
In an expression, read the reply from `{{ $json.choices[0].message.content }}`.
## Good to know [#good-to-know]
- **Images.** Odin v1 is text-only, so don't send images from vision nodes.
- **Errors.** A `402` error means your balance is used up. Turn on auto-recharge in the console for unattended workflows.
# OpenAI SDK (https://docs.interodin.com/en/integrations/openai-sdk)
The official OpenAI SDKs work with only a base URL change (see [Not supported](#not-supported) for the limits). Set the base URL to `https://api.interodin.com/v1` and use your `io-sk-` key. Both Chat Completions and Responses are available.
## Install [#install]
```bash tab="Python"
pip install openai
```
```bash tab="JavaScript / TypeScript"
npm install openai
```
## Configure [#configure]
Pass the base URL and key to the client:
```python tab="Python"
import os
from openai import OpenAI
client = OpenAI(
base_url="https://api.interodin.com/v1",
api_key=os.environ["INTERODIN_API_KEY"],
)
completion = client.chat.completions.create(
model="odin-v1",
messages=[{"role": "user", "content": "Say hello in Romanian."}],
)
print(completion.choices[0].message.content)
```
```ts tab="TypeScript"
import OpenAI from 'openai'
const client = new OpenAI({
baseURL: 'https://api.interodin.com/v1',
apiKey: process.env.INTERODIN_API_KEY,
})
const completion = await client.chat.completions.create({
model: 'odin-v1',
messages: [{ role: 'user', content: 'Say hello in Romanian.' }],
})
console.log(completion.choices[0]?.message.content)
```
Or configure it through the environment variables both SDKs read, without touching code:
```bash
export OPENAI_BASE_URL="https://api.interodin.com/v1"
export OPENAI_API_KEY="io-sk-your-key"
```
## Responses API [#responses-api]
```python tab="Python"
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.interodin.com/v1", api_key=os.environ["INTERODIN_API_KEY"])
response = client.responses.create(model="odin-v1", input="Say hello in Romanian.", store=False)
print(response.output_text)
```
```ts tab="TypeScript"
import OpenAI from 'openai'
const client = new OpenAI({ baseURL: 'https://api.interodin.com/v1', apiKey: process.env.INTERODIN_API_KEY })
const response = await client.responses.create({ model: 'odin-v1', input: 'Say hello in Romanian.', store: false })
console.log(response.output_text)
```
Responses is stateless here. See [Responses](https://docs.interodin.com/en/guides/responses) for how to keep a multi-turn conversation.
## Extra fields and headers [#extra-fields-and-headers]
Some fields aren't in the SDK's types. Use `extra_body` in Python, or a type assertion in TypeScript. Send the cache routing header per request:
```python tab="Python"
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.interodin.com/v1", api_key=os.environ["INTERODIN_API_KEY"])
completion = client.chat.completions.create(
model="odin-v1",
messages=[{"role": "user", "content": "Is 1,000,003 prime?"}],
reasoning_effort="low",
extra_body={"include_reasoning": False},
extra_headers={"x-interodin-session-id": "conv_7d2a9f"},
)
print(completion.choices[0].message.content)
```
```ts tab="TypeScript"
import OpenAI from 'openai'
const client = new OpenAI({ baseURL: 'https://api.interodin.com/v1', apiKey: process.env.INTERODIN_API_KEY })
const completion = await client.chat.completions.create(
{
model: 'odin-v1',
messages: [{ role: 'user', content: 'Is 1,000,003 prime?' }],
reasoning_effort: 'low',
include_reasoning: false,
} as OpenAI.Chat.ChatCompletionCreateParamsNonStreaming,
{ headers: { 'x-interodin-session-id': 'conv_7d2a9f' } },
)
console.log(completion.choices[0]?.message.content)
```
## Not supported [#not-supported]
These OpenAI features don't work against InterOdin:
- **Image and file inputs** in the newest message fail with `400`: `image_input_not_supported` for images (Odin v1 is text-only) and `unsupported_content` for files. Ones in tool output or earlier turns are replaced by a text note.
- **Hosted tools** (file search, code interpreter, MCP) are silently removed from the request. Only your own function and custom tools run. Web search runs on the Responses API only, billed per search (see [Web search](https://docs.interodin.com/en/guides/responses#web-search)), and is removed on Chat Completions.
- **`previous_response_id`, `conversation` and `background: true`** fail with `400 unsupported_parameter`. Send the full conversation in `input` instead.
- **Fetching or deleting a stored response** (`GET` / `DELETE /v1/responses/{id}`) returns `404`, because nothing is stored.
## Errors [#errors]
API errors raise the SDK's usual exceptions: `AuthenticationError` for 401, `RateLimitError` for 429, and other subclasses of `APIStatusError` (Python) or `APIError` (TypeScript) for everything else. Read the `code` field (or the `x-interodin-error-code` response header) to tell, for example, `insufficient_credits` (402) apart from other errors. The SDKs retry 429 and 5xx responses automatically. See [Errors and rate limits](https://docs.interodin.com/en/errors-and-rate-limits).
More: [Chat Completions guide](https://docs.interodin.com/en/guides/chat-completions).
# Vercel AI SDK (https://docs.interodin.com/en/integrations/vercel-ai-sdk)
The [AI SDK](https://ai-sdk.dev) connects through its OpenAI-compatible provider, which uses Chat Completions. See [Not supported](https://docs.interodin.com/en/integrations/openai-sdk#not-supported) for what the OpenAI-compatible API doesn't accept. File attachments in AI SDK messages are rejected with `400 unsupported_content`.
Don't use `createOpenAI` for InterOdin. In AI SDK 5 and later it defaults to the Responses API. Use `createOpenAICompatible`, as shown below.
## Install [#install]
```npm
npm install ai @ai-sdk/openai-compatible zod
```
## Configure the provider [#configure-the-provider]
```ts
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
import { generateText } from 'ai'
export const interodin = createOpenAICompatible({
name: 'interodin',
baseURL: 'https://api.interodin.com/v1',
apiKey: process.env.INTERODIN_API_KEY,
includeUsage: true,
})
const { text, usage } = await generateText({
model: interodin('odin-v1'),
prompt: 'Name three Norse gods.',
})
console.log(text, usage)
```
`includeUsage: true` makes streamed calls report token usage.
## Streaming [#streaming]
```ts
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
import { streamText } from 'ai'
const interodin = createOpenAICompatible({
name: 'interodin',
baseURL: 'https://api.interodin.com/v1',
apiKey: process.env.INTERODIN_API_KEY,
includeUsage: true,
})
const result = streamText({
model: interodin('odin-v1'),
system: 'You are a concise assistant.',
prompt: 'Write a haiku about Odin.',
})
for await (const delta of result.textStream) process.stdout.write(delta)
console.log('\n', await result.usage)
```
In a Next.js route handler, return `result.toUIMessageStreamResponse()` to stream into the AI SDK's `useChat` hook.
## Tools [#tools]
```ts
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
import { generateText, stepCountIs, tool } from 'ai'
import { z } from 'zod'
const interodin = createOpenAICompatible({
name: 'interodin',
baseURL: 'https://api.interodin.com/v1',
apiKey: process.env.INTERODIN_API_KEY,
})
const { text } = await generateText({
model: interodin('odin-v1'),
prompt: 'What is the weather in Cluj?',
tools: {
getWeather: tool({
description: 'Get the current weather for a city',
inputSchema: z.object({ city: z.string() }),
execute: async ({ city }) => ({ city, celsius: 14 }),
}),
},
stopWhen: stepCountIs(3),
})
console.log(text)
```
## Using the Anthropic provider instead [#using-the-anthropic-provider-instead]
If your app already uses `@ai-sdk/anthropic`, point it at the Messages endpoint. This provider expects the base URL **with** `/v1`:
```ts
import { createAnthropic } from '@ai-sdk/anthropic'
import { generateText } from 'ai'
const anthropic = createAnthropic({
baseURL: 'https://api.interodin.com/v1',
apiKey: process.env.INTERODIN_API_KEY,
})
const { text } = await generateText({ model: anthropic('odin-v1'), prompt: 'Name three Norse gods.' })
console.log(text)
```
# Models (https://docs.interodin.com/en/models)
| Model | ID | Context (tokens) | Input | EUR in / out | USD in / out |
| --- | --- | --- | --- | --- | --- |
| [Odin v1](https://docs.interodin.com/en/models/odin-v1) | `odin-v1` | 1,000,000 | Text | €4.05 / €6.75 | $4.50 / $7.50 |
| [Odin Flash](https://docs.interodin.com/en/models/odin-flash) | `odin-flash` | 262,144 | Text, images | €2.25 / €6.75 | $2.50 / $7.50 |
Prices are per 1 million tokens. `GET https://api.interodin.com/v1/models` returns the models your key may call.
# Odin Flash (https://docs.interodin.com/en/models/odin-flash)
| Property | Value |
| --- | --- |
| Model id | `odin-flash` |
| Context window | 262,144 tokens |
| Max output | 262,134 tokens |
| Input | Text and images |
| Tool calling | Yes |
| Reasoning control | Yes |
| Endpoints | `/v1/chat/completions`, `/v1/responses`, `/v1/messages` |
## Prices [#prices]
Per 1 million tokens. Organizations in Europe pay in EUR, everyone else in USD.
| Token type | EUR | USD |
| --- | --- | --- |
| Input | €2.25 | $2.50 |
| Cached input | €0.225 | $0.25 |
| Output (including reasoning) | €6.75 | $7.50 |
Cached input applies to tokens read from the [prompt cache](https://docs.interodin.com/en/guides/prompt-caching). Token counting with `/v1/messages/count_tokens` is free.
## Use it [#use-it]
```bash
curl https://api.interodin.com/v1/chat/completions \
-H "Authorization: Bearer $INTERODIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "odin-flash", "messages": [{"role": "user", "content": "Hello"}]}'
```
For [Claude Code](https://docs.interodin.com/en/integrations/claude-code), set the context window to match:
```json
{
"env": {
"ANTHROPIC_DEFAULT_OPUS_MODEL": "odin-flash",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "odin-flash",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "odin-flash",
"CLAUDE_CODE_MAX_CONTEXT_TOKENS": "262144"
}
}
```
# Odin v1 (https://docs.interodin.com/en/models/odin-v1)
| Property | Value |
| --- | --- |
| Model id | `odin-v1` |
| Context window | 1,000,000 tokens |
| Max output | 999,990 tokens |
| Input | Text only |
| Tool calling | Yes |
| Reasoning control | Yes |
| Endpoints | `/v1/chat/completions`, `/v1/responses`, `/v1/messages` |
## Prices [#prices]
Per 1 million tokens. Organizations in Europe pay in EUR, everyone else in USD.
| Token type | EUR | USD |
| --- | --- | --- |
| Input | €4.05 | $4.50 |
| Cached input | €0.405 | $0.45 |
| Output (including reasoning) | €6.75 | $7.50 |
Cached input applies to tokens read from the [prompt cache](https://docs.interodin.com/en/guides/prompt-caching). Token counting with `/v1/messages/count_tokens` is free.
## Use it [#use-it]
```bash
curl https://api.interodin.com/v1/chat/completions \
-H "Authorization: Bearer $INTERODIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "odin-v1", "messages": [{"role": "user", "content": "Hello"}]}'
```
For [Claude Code](https://docs.interodin.com/en/integrations/claude-code), set the context window to match:
```json
{
"env": {
"ANTHROPIC_DEFAULT_OPUS_MODEL": "odin-v1",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "odin-v1",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "odin-v1",
"CLAUDE_CODE_MAX_CONTEXT_TOKENS": "1000000"
}
}
```
# Quickstart (https://docs.interodin.com/en/quickstart)
## 1. Create an account and add credit [#1-create-an-account-and-add-credit]
Sign up at the [console](https://interodin.com/console). Billing is prepaid: top up your balance in the console, and requests draw from it. When the balance reaches zero, requests fail with `402 insufficient_credits` until you top up again.
## 2. Create an API key [#2-create-an-api-key]
In the console, open the API keys area and create a key. Copy the key right away. It starts with `io-sk-` and is shown only once.
Store it in an environment variable rather than in your code:
```bash tab="macOS / Linux"
export INTERODIN_API_KEY="io-sk-your-key"
```
```powershell tab="Windows PowerShell" nocheck
$env:INTERODIN_API_KEY = "io-sk-your-key"
```
## 3. Send your first request [#3-send-your-first-request]
This calls Chat Completions, the most widely supported format. The model is `odin-v1`.
The curl sample is written for bash. On Windows, use Git Bash or WSL, or run `curl.exe` from PowerShell and read the key with `$env:INTERODIN_API_KEY` instead of `$INTERODIN_API_KEY`.
```bash tab="curl (bash)"
curl https://api.interodin.com/v1/chat/completions \
-H "Authorization: Bearer $INTERODIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "odin-v1",
"messages": [{"role": "user", "content": "Say hello in Romanian."}]
}'
```
```python tab="Python"
# pip install openai
import os
from openai import OpenAI
client = OpenAI(
base_url="https://api.interodin.com/v1",
api_key=os.environ["INTERODIN_API_KEY"],
)
completion = client.chat.completions.create(
model="odin-v1",
messages=[{"role": "user", "content": "Say hello in Romanian."}],
)
print(completion.choices[0].message.content)
```
```ts tab="JavaScript / TypeScript"
// npm install openai
// Save as quickstart.mjs (or use an ESM project): the sample uses top-level await.
import OpenAI from 'openai'
const client = new OpenAI({
baseURL: 'https://api.interodin.com/v1',
apiKey: process.env.INTERODIN_API_KEY,
})
const completion = await client.chat.completions.create({
model: 'odin-v1',
messages: [{ role: 'user', content: 'Say hello in Romanian.' }],
})
console.log(completion.choices[0]?.message.content)
```
```go tab="Go"
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
func main() {
body, _ := json.Marshal(map[string]any{
"model": "odin-v1",
"messages": []map[string]string{
{"role": "user", "content": "Say hello in Romanian."},
},
})
req, _ := http.NewRequest("POST", "https://api.interodin.com/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("INTERODIN_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(res.Body)
panic(fmt.Sprintf("HTTP %d: %s", res.StatusCode, msg))
}
var out struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
panic(err)
}
fmt.Println(out.Choices[0].Message.Content)
}
```
```php tab="PHP"
true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('INTERODIN_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'model' => 'odin-v1',
'messages' => [['role' => 'user', 'content' => 'Say hello in Romanian.']],
]),
]);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);
echo $response['choices'][0]['message']['content'], PHP_EOL;
```
```java tab="Java"
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class Main {
public static void main(String[] args) throws Exception {
String body = """
{"model": "odin-v1",
"messages": [{"role": "user", "content": "Say hello in Romanian."}]}
""";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.interodin.com/v1/chat/completions"))
.header("Authorization", "Bearer " + System.getenv("INTERODIN_API_KEY"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
// Raw JSON: the answer is in choices[0].message.content.
System.out.println(response.body());
}
}
```
The reply is a standard Chat Completions object. The answer is in `choices[0].message.content` (the curl and Java samples print the whole JSON object), and `usage` tells you how many tokens you were billed for.
## 4. Next steps [#4-next-steps]
- Stream the reply token by token: [Chat Completions → Streaming](https://docs.interodin.com/en/guides/chat-completions#streaming).
- Already using the Anthropic SDK or Claude Code? Use the [Messages format](https://docs.interodin.com/en/guides/messages) with base URL `https://api.interodin.com`.
- Point a coding tool at InterOdin: [Claude Code](https://docs.interodin.com/en/integrations/claude-code), [Codex CLI](https://docs.interodin.com/en/integrations/codex), [Cursor](https://docs.interodin.com/en/integrations/cursor).
- Check the [models](https://docs.interodin.com/en/models) page for context size and prices.
# Create chat completion (https://docs.interodin.com/en/api-reference/chat-completions/create-chat-completion)
`POST /v1/chat/completions`
Generates the next assistant message. With `stream: true` the reply is sent as server-sent events: each `data:` line is a `ChatCompletionChunk`, the last chunk carries `usage`, and the stream ends with `data: [DONE]`.
Authentication: `Authorization: Bearer io-sk-…` or `x-api-key: io-sk-…`.
Headers:
| Header | Description |
| --- | --- |
| `x-interodin-session-id` | Optional prompt-cache routing hint, honoured on every generation endpoint (including `count_tokens`). Send the same opaque value (for example a conversation id, at most 128 characters recommended) on every request of one conversation. Never put prompts, personal data or secrets in it. |
Request body (`CreateChatCompletionRequest`):
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `model` | string | yes | Model id, for example `odin-v1`. |
| `messages` | ChatMessage[] | yes | |
| `stream` | boolean | no | Stream the reply as server-sent events. |
| `stream_options` | object | no | |
| `max_tokens` | integer | no | Clamped to the model maximum. 0 or less returns 400. |
| `max_completion_tokens` | integer | no | Alias of `max_tokens`. 0 or less returns 400. |
| `temperature` | number | no | |
| `top_p` | number | no | |
| `stop` | string \| string[] | no | |
| `seed` | integer | no | |
| `presence_penalty` | number | no | |
| `frequency_penalty` | number | no | |
| `tools` | ChatTool[] | no | Function tools and `custom` tools. Any other tool type is removed from the request without an error, as are `web_search_options` and a `tool_choice` that names a removed tool. |
| `store` | boolean | no | Ignored: nothing is stored. Always sent as `false`. |
| `metadata` | object | no | Ignored and not forwarded. |
| `tool_choice` | string \| object \| object | no | |
| `parallel_tool_calls` | boolean | no | |
| `response_format` | ChatResponseFormat | no | |
| `reasoning_effort` | string | no | Reasoning is on or off, with no levels. `none` turns it off on Odin Flash; it does not on Odin v1. The other values are accepted and change nothing. |
| `include_reasoning` | boolean | no | Set `false` to omit `reasoning` from the reply. Reasoning tokens are still billed as output. |
| `prompt_cache_key` | string | no | Prompt-cache routing hint. Same value for every request of one conversation. Forwarded only as a hash specific to your organization; not stored. A string of at most 1024 characters is accepted, `null` removes the field and any other value returns `400 invalid_request`. |
| `user` | string | no | Your end-user id. Forwarded only as a hash specific to your organization; not stored. A string of at most 1024 characters is accepted, `null` removes the field and any other value returns `400 invalid_request`. |
| `safety_identifier` | string | no | A stable identifier for the end user, for abuse detection. Forwarded only as a hash specific to your organization; not stored. A string of at most 1024 characters is accepted, `null` removes the field and any other value returns `400 invalid_request`. |
Success (200): A chat completion, or an event stream when `stream` is true. `application/json`: ChatCompletion; `text/event-stream`: string
Errors:
- 400: `invalid_request`, `content_refused`, `unsupported_parameter`, `image_input_not_supported`, `unsupported_content`, `context_length_exceeded`
- 401: `invalid_api_key`
- 402: `insufficient_credits`
- 403: `model_not_allowed`, `key_spend_limit_reached`, `organization_suspended`
- 404: `model_not_found`, `not_found`
- 413: `request_too_large`
- 429: `rate_limit_exceeded`, `concurrency_limit`
- 500: `internal_error`
- 502: `upstream_unavailable`
- 504: `timeout`
# Create response (https://docs.interodin.com/en/api-reference/responses/create-response)
`POST /v1/responses`
Generates a model response. Responses are never stored: `store` is always `false`, `previous_response_id` is rejected, and there is no endpoint to retrieve or delete a response. Send the whole conversation in `input` each time.
Authentication: `Authorization: Bearer io-sk-…` or `x-api-key: io-sk-…`.
Headers:
| Header | Description |
| --- | --- |
| `x-interodin-session-id` | Optional prompt-cache routing hint, honoured on every generation endpoint (including `count_tokens`). Send the same opaque value (for example a conversation id, at most 128 characters recommended) on every request of one conversation. Never put prompts, personal data or secrets in it. |
Request body (`CreateResponseRequest`):
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `model` | string | yes | |
| `input` | string \| ResponseInputItem[] | yes | |
| `instructions` | string | no | System-level instructions. |
| `stream` | boolean | no | |
| `store` | boolean | no | Responses are never stored. Any value you send is treated as `false`. |
| `previous_response_id` | string | no | Not supported. Requests that set it fail with `400 unsupported_parameter`. Send the full conversation in `input`. |
| `conversation` | any | no | Not supported. Requests that set it fail with `400 unsupported_parameter`. |
| `background` | boolean | no | Not supported. `true` fails with `400 unsupported_parameter`. |
| `prompt` | object | no | Stored prompt templates are not supported. Requests that set it fail with `400 invalid_request`. |
| `tools` | ResponseTool[] | no | Function tools and `custom` tools. Hosted tools (web search, file search, code interpreter, MCP and others) are removed from the request without an error, as is a `tool_choice` that names a removed tool. |
| `tool_choice` | string \| object | no | |
| `parallel_tool_calls` | boolean | no | |
| `text` | object | no | |
| `reasoning` | object | no | Reasoning is on or off, with no levels. `effort: none` turns it off on Odin Flash; it does not on Odin v1. The other values change nothing. |
| `max_output_tokens` | integer | no | Clamped to the model maximum. 0 or less returns 400. |
| `temperature` | number | no | |
| `top_p` | number | no | |
| `metadata` | object | no | Accepted and ignored: not forwarded. |
| `prompt_cache_key` | string | no | Prompt-cache routing hint. Same value for every request of one conversation. Forwarded only as a hash specific to your organization; not stored. A string of at most 1024 characters is accepted, `null` removes the field and any other value returns `400 invalid_request`. |
| `user` | string | no | Your end-user id. Forwarded only as a hash specific to your organization; not stored. A string of at most 1024 characters is accepted, `null` removes the field and any other value returns `400 invalid_request`. |
| `safety_identifier` | string | no | A stable identifier for the end user, for abuse detection. Forwarded only as a hash specific to your organization; not stored. A string of at most 1024 characters is accepted, `null` removes the field and any other value returns `400 invalid_request`. |
Success (200): A response object, or an event stream when `stream` is true. `application/json`: Response; `text/event-stream`: string
Errors:
- 400: `invalid_request`, `content_refused`, `unsupported_parameter`, `image_input_not_supported`, `unsupported_content`, `context_length_exceeded`
- 401: `invalid_api_key`
- 402: `insufficient_credits`
- 403: `model_not_allowed`, `key_spend_limit_reached`, `organization_suspended`
- 404: `model_not_found`, `not_found`
- 413: `request_too_large`
- 429: `rate_limit_exceeded`, `concurrency_limit`
- 500: `internal_error`
- 502: `upstream_unavailable`
- 504: `timeout`
# Create message (https://docs.interodin.com/en/api-reference/messages/create-message)
`POST /v1/messages`
Anthropic Messages format. Accepts `x-api-key` or `Authorization: Bearer` (`x-api-key` wins if both are sent), query strings such as `?beta=true`, and the `anthropic-version` / `anthropic-beta` headers.
Authentication: `Authorization: Bearer io-sk-…` or `x-api-key: io-sk-…`.
Headers:
| Header | Description |
| --- | --- |
| `x-interodin-session-id` | Optional prompt-cache routing hint, honoured on every generation endpoint (including `count_tokens`). Send the same opaque value (for example a conversation id, at most 128 characters recommended) on every request of one conversation. Never put prompts, personal data or secrets in it. |
| `anthropic-version` | Accepted for Anthropic SDK compatibility (a date such as `2023-06-01`). Only well-formed values are honoured. |
| `anthropic-beta` | Accepted for Anthropic SDK compatibility (comma-separated feature names). Only well-formed values are honoured. |
Request body (`CreateMessageRequest`):
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `model` | string | yes | |
| `messages` | object[] | yes | |
| `system` | string \| object[] | no | |
| `tools` | MessageTool[] | no | Client tools: tools with no `type` or with `type: "custom"` (`name`, `description`, `input_schema`). The web search tool (`type: "web_search_20250305"`, at most 5 searches per request, billed per search) is also kept. Tools with any other `type` (code execution, bash, text editor, computer use) and `mcp_servers` are removed from the request without an error. |
| `tool_choice` | object | no | |
| `thinking` | object | no | Thinking is on or off. `disabled` turns it off on both models. `budget_tokens` does not cap it. |
| `output_config` | object | no | Accepted and ignored: thinking has no levels. |
| `max_tokens` | integer | yes | Clamped to the model maximum. 0 or less returns 400. |
| `stream` | boolean | no | |
| `temperature` | number | no | |
| `top_p` | number | no | |
| `top_k` | integer | no | |
| `stop_sequences` | string[] | no | |
| `metadata` | object | no | Only `user_id` is forwarded, and only as a hash specific to your organization. Other keys are dropped, and a `metadata` that is not an object is ignored. |
Success (200): A message, or an event stream when `stream` is true. `application/json`: Message; `text/event-stream`: string
Errors:
- 400: `invalid_request`, `content_refused`, `unsupported_parameter`, `image_input_not_supported`, `unsupported_content`, `context_length_exceeded`
- 401: `invalid_api_key`
- 402: `insufficient_credits`
- 403: `model_not_allowed`, `key_spend_limit_reached`, `organization_suspended`
- 404: `model_not_found`, `not_found`
- 413: `request_too_large`
- 429: `rate_limit_exceeded`, `concurrency_limit`
- 500: `internal_error`
- 502: `upstream_unavailable`
- 504: `timeout`
# Count message tokens (https://docs.interodin.com/en/api-reference/messages/count-message-tokens)
`POST /v1/messages/count_tokens`
Counts the input tokens of a Messages request without running the model. Free: never billed, and it works even when the balance is zero. It counts toward rate limits.
Authentication: `Authorization: Bearer io-sk-…` or `x-api-key: io-sk-…`.
Headers:
| Header | Description |
| --- | --- |
| `x-interodin-session-id` | Optional prompt-cache routing hint, honoured on every generation endpoint (including `count_tokens`). Send the same opaque value (for example a conversation id, at most 128 characters recommended) on every request of one conversation. Never put prompts, personal data or secrets in it. |
| `anthropic-version` | Accepted for Anthropic SDK compatibility (a date such as `2023-06-01`). Only well-formed values are honoured. |
| `anthropic-beta` | Accepted for Anthropic SDK compatibility (comma-separated feature names). Only well-formed values are honoured. |
Request body (`CountTokensRequest`):
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `model` | string | yes | |
| `messages` | object[] | yes | |
| `system` | string \| object[] | no | |
| `tools` | MessageTool[] | no | Client tools: tools with no `type` or with `type: "custom"` (`name`, `description`, `input_schema`). The web search tool (`type: "web_search_20250305"`, at most 5 searches per request, billed per search) is also kept. Tools with any other `type` (code execution, bash, text editor, computer use) and `mcp_servers` are removed from the request without an error. |
| `tool_choice` | object | no | |
| `thinking` | object | no | Thinking is on or off. `disabled` turns it off on both models. `budget_tokens` does not cap it. |
| `output_config` | object | no | Accepted and ignored: thinking has no levels. |
Success (200): The input token count. `application/json`: CountTokensResponse
Errors:
- 400: `invalid_request`, `content_refused`, `unsupported_parameter`, `image_input_not_supported`, `unsupported_content`, `context_length_exceeded`
- 401: `invalid_api_key`
- 402: `insufficient_credits`
- 403: `model_not_allowed`, `key_spend_limit_reached`, `organization_suspended`
- 404: `model_not_found`, `not_found`
- 413: `request_too_large`
- 429: `rate_limit_exceeded`, `concurrency_limit`
- 500: `internal_error`
- 502: `upstream_unavailable`
- 504: `timeout`
# List models (https://docs.interodin.com/en/api-reference/models/list-models)
`GET /v1/models`
Lists the models your key may call. A key restricted to some models lists only those. OpenAI format by default; Anthropic format when the request carries an `anthropic-version` header. It sends `x-request-id` but no rate-limit headers. In practice only `invalid_api_key` (401), `organization_suspended` (403), `not_found` (404) and `internal_error` (500) occur here; the other error statuses are listed for completeness.
Authentication: `Authorization: Bearer io-sk-…` or `x-api-key: io-sk-…`.
Headers:
| Header | Description |
| --- | --- |
| `anthropic-version` | When present, the list and any error use the Anthropic format (what Anthropic SDKs expect). |
Success (200): The models your key may call. `application/json`: ModelList | AnthropicModelList
Errors:
- 400: `invalid_request`, `content_refused`, `unsupported_parameter`, `image_input_not_supported`, `unsupported_content`, `context_length_exceeded`
- 401: `invalid_api_key`
- 402: `insufficient_credits`
- 403: `model_not_allowed`, `key_spend_limit_reached`, `organization_suspended`
- 404: `model_not_found`, `not_found`
- 413: `request_too_large`
- 429: `rate_limit_exceeded`, `concurrency_limit`
- 500: `internal_error`
- 502: `upstream_unavailable`
- 504: `timeout`