Model API
Use Marina as an OpenAI-compatible LLM endpoint. When your tools send requests, agents inside the world respond — with full access to their memory, coordination tools, and world context.
Direct upstream defaults
Section titled “Direct upstream defaults”Marina can also proxy directly to a configured provider when no model agent serves the route.
Built-in defaults are each vendor’s strong tier: OpenAI gpt-6.1-sol, Anthropic claude-opus-5-5,
Gemini gemini-3.1-pro-preview, OpenRouter google/gemini-3.1-pro-preview. OpenRouter
carries models from vendors other than Anthropic and OpenAI; their models go through
ANTHROPIC_API_KEY / OPENAI_API_KEY, and built-in defaults that name one use OpenRouter
only when that vendor’s key is absent. Existing provider priority and operator-selected
models still apply. Override a provider with MARINA_DEFAULT_OPENAI_MODEL or
MARINA_DEFAULT_OPENROUTER_MODEL; a configured database default_model selects the
provider/model before fallback. Keys authorize upstream calls; configure MODEL_API_KEYS
separately for clients calling Marina.
Some defaults reason on every request and cannot turn it off (Gemini Pro; the Claude 5.5
and Fable releases through OpenRouter). Their reasoning counts against the output cap. When
Marina chose the model (marina, marina/default) and the caller set max_tokens below
8192 with no reasoning setting of its own, Marina asks for the model’s lowest reasoning
effort; the cap itself is never raised. A reply that spends the whole cap on reasoning and
returns no content and no tool call is an error, never an empty answer: HTTP 400 with
code: "reasoning_exhausted_output" and param naming the cap (502 when the caller set no
cap), with the upstream usage. A stream ends with that error as its last data: frame
before [DONE]; /v1/responses reports it as max_output_tokens (response.failed when
streaming). Raise the cap or request a lower reasoning_effort.
Luna on a default route uses reasoning_effort: "none" to keep short requests inexpensive.
Explicit reasoning settings take precedence, and a direct request naming the model keeps its
provider-default effort when omitted. OpenAI Luna requires max_completion_tokens; Marina
translates a legacy max_tokens field when no modern limit is supplied. Conflicting limits
remain subject to provider validation. Other model request contracts are preserved.
Use a higher reasoning effort explicitly when the task warrants it, and compare outcomes and total tokens on your own workload. See the official Luna model reference for current capabilities and pricing.
Together AI and Nebius Token Factory
Section titled “Together AI and Nebius Token Factory”Run marina init and choose together or nebius, or add a key through
Admin → Keys → Add. Environment configuration works too:
TOGETHER_API_KEY=your-together-keyNEBIUS_API_KEY=your-nebius-keyOnly one key is required. The model picker discovers the account’s chat models and shows input/output prices in USD per million tokens. Select one as the default, or use its complete ID wherever Marina accepts a model:
/model together/MiniMaxAI/MiniMax-M3/model nebius/MiniMaxAI/MiniMax-M3The same IDs work for agents, coding, research, forecasts, and Marina’s model API:
curl http://localhost:3300/v1/chat/completions \ -H "Authorization: Bearer $MARINA_MODEL_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"nebius/MiniMaxAI/MiniMax-M3","messages":[{"role":"user","content":"Hello"}],"stream":true}'Marina retains the full provider model ID, including organizations and flavor suffixes. A named provider’s rejection is returned to the caller; selecting a model does not authorize switching services. Tool calls, streaming and structured-output requests use the existing chat-completions path; individual models determine which features they support. Models absent from discovery can be entered using Custom. Image and embedding models are excluded from the chat picker.

The screenshot uses illustrative catalog values; the picker reads current prices from your configured provider.
| Setting | Together AI | Nebius Token Factory |
|---|---|---|
| Provider ID | together |
nebius |
| Key | TOGETHER_API_KEY |
NEBIUS_API_KEY |
| Default API root | https://api.together.ai/v1 |
https://api.tokenfactory.nebius.com/v1 |
| Optional API-root override | TOGETHER_BASE_URL |
NEBIUS_BASE_URL |
| Default model override | MARINA_DEFAULT_TOGETHER_MODEL |
MARINA_DEFAULT_NEBIUS_MODEL |
Both built-in defaults use MiniMaxAI/MiniMax-M3. Account availability can change;
choose a currently available model from the picker or set the override to its
provider-native ID. API-root overrides include /v1 and apply to discovery and
inference together. Keep credentials in the key setting, never in the URL.
For a dedicated endpoint, select its deployed model ID explicitly.
Prices come from Together’s model catalog
and Nebius’s verbose catalog,
normalized to USD per million tokens. Discovery and usage accounting share a
credential- and endpoint-scoped cache, refreshed hourly or by the picker’s refresh
button. Token usage, cached input and advertised rates feed /cost, model-request
traces and the daily spend ledger. A provider-reported charge takes precedence in
proxy accounting. Catalog prices are estimates, not an invoice: dedicated capacity,
taxes and other non-token charges are outside this estimate. A missing price is
unknown, not evidence of free inference; a catalog outage still allows an explicit
model request and does not borrow another vendor’s price.
Run readiness providers to verify actual inference after setup. Admin’s key test
checks model-list access without a paid completion. These services also work with
Marina’s existing OpenAI-compatible embedding adapter: set the appropriate
MARINA_MEMORY_EMBEDDING_URL, model and immutable revision; the standard provider
key is recognized only on its official host. See memory for embedding
configuration. For image generation, configure the existing
TOGETHER_IMAGE_BASE_URL or NEBIUS_IMAGE_BASE_URL adapter explicitly.
Quick Start
Section titled “Quick Start”1. Start the Server
Section titled “1. Start the Server”bun run startOn a local install (the default local trust profile) with no MODEL_API_KEYS set, Marina
creates a key once, keeps it next to the database (marina.db.local-api-key, mode 600) and prints
a ready-to-paste line at startup:
Use Marina from any OpenAI client: OPENAI_BASE_URL=http://localhost:3300/v1 OPENAI_API_KEY=mk_local_…Requests still need that bearer token; the loopback MCP transport is unaffected. Shared and public
deployments never get a generated key — configure MODEL_API_KEYS there.
2. Connect a Provider Agent
Section titled “2. Connect a Provider Agent”The provider agent bridges requests to an external LLM. This is an alternative to direct upstream proxying with configured provider keys.
# Using a local Ollama instancePROVIDER_URL=http://localhost:11434/v1 PROVIDER_MODEL=qwen3:4b bun run src/sdk/examples/provider.ts
# Using OpenAIPROVIDER_URL=https://api.openai.com/v1 PROVIDER_KEY=sk-your-key PROVIDER_MODEL=gpt-6-luna bun run src/sdk/examples/provider.ts3. Send a Request
Section titled “3. Send a Request”Set MARINA_MODEL_KEY privately to the local key printed at startup or an actual configured
MODEL_API_KEYS secret. Example credentials such as sk-any do not authenticate.
curl http://localhost:3300/v1/chat/completions \ -H "Authorization: Bearer $MARINA_MODEL_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"marina","messages":[{"role":"user","content":"hello"}]}'You’ll get a standard OpenAI-format response.
Use with Your Tools
Section titled “Use with Your Tools”OPENAI_API_BASE=http://localhost:3300/v1 OPENAI_API_KEY="$MARINA_MODEL_KEY" aider --model openai/marinaCursor / Continue.dev
Section titled “Cursor / Continue.dev”Add a custom model provider in your IDE settings:
- Base URL:
http://localhost:3300/v1 - API Key: the local key Marina printed at startup (or a key from
MODEL_API_KEYS) - Model:
marina
LiteLLM (Python)
Section titled “LiteLLM (Python)”import osimport litellm
response = litellm.completion( model="openai/marina", api_base="http://localhost:3300/v1", api_key=os.environ["MARINA_MODEL_KEY"], messages=[{"role": "user", "content": "hello"}],)Ollama-compatible clients
Section titled “Ollama-compatible clients”Marina also serves Ollama-compatible endpoints:
curl http://localhost:3300/api/chat \ -H "Authorization: Bearer $MARINA_MODEL_KEY" \ -d '{"model":"marina","messages":[{"role":"user","content":"hello"}]}'Streaming
Section titled “Streaming”Request streaming with "stream": true:
curl http://localhost:3300/v1/chat/completions \ -H "Authorization: Bearer $MARINA_MODEL_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"marina","messages":[{"role":"user","content":"hello"}],"stream":true}'Responses arrive as Server-Sent Events in the standard OpenAI format.
Responses API streaming
Section titled “Responses API streaming”POST /v1/responses with "stream": true is incremental on both endpoint modes:
- passthru — the upstream is asked for chat-completions SSE (with
stream_options.include_usage) and re-encoded as it arrives: eachdelta.contentis oneresponse.output_text.delta;delta.tool_callsfragments becomefunction_calloutput items (response.output_item.added→response.function_call_arguments.delta…response.function_call_arguments.done→response.output_item.done); the trailingusagechunk lands on the record. The response cache is bypassed for streams. - agents — every
model_response_chunkthe routed agent sends is one delta.
Event sequence, with a running sequence_number:
response.created → response.in_progress→ response.output_item.added (message) → response.content_part.added→ response.output_text.delta … (one per upstream/agent chunk)→ response.output_item.added (function_call) → response.function_call_arguments.delta …→ response.output_text.done → response.content_part.done → response.output_item.done→ response.function_call_arguments.done → response.output_item.done (per call, output order)→ response.completedThe response payload of response.completed is byte-identical to the non-streaming body and to
GET /v1/responses/:id. An upstream transport failure mid-stream ends with response.failed
(error.code: "upstream_error") and stores nothing; an agent timeout ends with response.failed
(error.code: "timeout"). A stream request answered whole (a cache hit, a provider that ignored
stream) still yields the standard sequence with a single delta.
Responses API tools
Section titled “Responses API tools”POST /v1/responses in passthru mode forwards tools both ways (src/net/model/responses-tools.ts). The
Responses surface speaks flat function tools and typed input items; every upstream speaks
chat-completions, and the Anthropic proxy translates chat tools onward, so a Responses client with
tools works end-to-end against OpenAI-compatible and Anthropic upstreams.
| Responses request | Chat-completions request sent upstream |
|---|---|
tools[{ type: "function", name, description, parameters, strict? }] (flat) |
tools[{ type: "function", function: { name, description, parameters, strict } }] |
tool_choice: "auto" | "none" | "required" |
same string |
tool_choice: { type: "function", name } |
{ type: "function", function: { name } } |
parallel_tool_calls |
parallel_tool_calls (Anthropic: disable_parallel_tool_use when false) |
input string / { role, content } / { type: "message", … } (input_text / output_text / text parts) |
{ role, content: text } |
input[{ type: "function_call", call_id, name, arguments }] |
assistant.tool_calls[{ id: call_id, type: "function", function: { name, arguments } }] (consecutive calls share one assistant message) |
input[{ type: "function_call_output", call_id, output }] |
{ role: "tool", tool_call_id: call_id, content: output } |
| Chat-completions reply | Responses output |
|---|---|
message.tool_calls[{ id, function: { name, arguments } }] |
{ type: "function_call", id: "fc_…", call_id: id, name, arguments, status: "completed" } — call_id is the upstream id verbatim |
delta.tool_calls fragments (stream) |
response.output_item.added (function_call) → response.function_call_arguments.delta … response.function_call_arguments.done → response.output_item.done |
Tool-loop continuation works in both client styles. A client that manages its own state resends the
whole input list (message, function_call, function_call_output). A client that threads with
previous_response_id sends only the function_call_output items: the stored prior response
carries the calls, and they are re-attached as assistant.tool_calls ahead of the role: "tool"
results so the upstream sees every result paired with its call.
Refused, never dropped. Hosted Responses tool types have no chat-completions equivalent —
web_search, web_search_preview, file_search, computer_use_preview, code_interpreter,
image_generation, mcp, … — and are refused before any upstream call with
400 { error: { code: "unsupported_parameter", param: "tools[i].type" } }. A tool_choice type
outside auto / none / required / function is param: "tool_choice.type"; an input item type
outside message / function_call / function_call_output is param: "input[i].type". A function
tool without a name (or a function_call_output without a call_id) is an ordinary 400 with the
offending param. Agents mode still refuses tools altogether (param: "tools") — in-world agents
answer in text.
Multi-Turn Conversations
Section titled “Multi-Turn Conversations”Use the X-Conversation-Id header to maintain context across requests:
# First messagecurl http://localhost:3300/v1/chat/completions \ -H "Authorization: Bearer $MARINA_MODEL_KEY" \ -H "Content-Type: application/json" \ -H "X-Conversation-Id: my-session-1" \ -d '{"model":"marina","messages":[{"role":"user","content":"What is Marina?"}]}'
# Follow-up — the agent remembers the previous exchangecurl http://localhost:3300/v1/chat/completions \ -H "Authorization: Bearer $MARINA_MODEL_KEY" \ -H "Content-Type: application/json" \ -H "X-Conversation-Id: my-session-1" \ -d '{"model":"marina","messages":[{"role":"user","content":"Tell me more about the memory system"}]}'Conversation history is retained for the duration of the server session.
Model Routing
Section titled “Model Routing”The model field controls which agents handle the request. Different model IDs route to different channels:
| You Send | Routes To Channel | Use Case |
|---|---|---|
marina |
model |
Default — general purpose |
marina:scholar |
model-scholar |
Specialist scholar agents |
marina:code |
model-code |
Coding specialist agents |
marina:<name> |
model-<name> |
Any custom specialist |
To set this up, have your provider agent join the right channel:
# This agent handles "marina:scholar" requestsAGENT_NAME=Scholar MODEL_CHANNEL=model-scholar bun run src/sdk/examples/provider.tsMultiple agents in the same channel means requests are load-balanced across them. The balancer
addresses each request to one member (target in the model_request), but every member perceives
it, and the first correlated reply (model_response with the request id, or [<id>] <text>)
from any current member of the channel fulfils it. A streamed reply belongs to the first member
that sends a correlated frame. Channel membership is the authorization boundary: keep agents that
should not answer off the model-<name> channel. When a member other than the target answers, the
completed lifecycle event records it as respondedBy.
Choose the within-channel strategy with X-Load-Balance. The header is honored on the routes that
select a single agent: POST /v1/chat/completions (in the default agents endpoint mode),
POST /v1/responses, POST /api/chat, and POST /api/generate. When the header is absent, these
routes use the operator-configured strategy from Admin → Model Endpoint (default
round-robin). The open and panel endpoint modes fan out to all channel members by design —
no within-channel selection happens, so the header has no effect there.
round-robin(default) rotates across eligible online agents.least-busyselects the eligible agent with the fewest in-flight requests.adaptiveexplicitly opts into Marina’s observable evidence policy. It can select only among the online agents already eligible for the requestedmodel; it never changes the requested model. A unique Pareto candidate or least-observed exploration candidate may be applied. If no advised candidate is eligible, Marina falls back toleast-busyand records the reason in the trace.
curl http://localhost:3300/v1/chat/completions \ -H "Authorization: Bearer $MARINA_MODEL_KEY" \ -H "Content-Type: application/json" \ -H "X-Load-Balance: adaptive" \ -d '{"model":"marina","messages":[{"role":"user","content":"hello"}]}'Adaptive routing is never enabled implicitly. Inspect its recorded strategy, evidence mode, and
fallback reason in Admin → Traces or with trace show <id>. Every agent-routed response —
streaming or not — returns the traced request identity in its x-request-id header; that value is
the trace id to pass to trace show.
Memory injection, receipts and response cache
Section titled “Memory injection, receipts and response cache”In passthru endpoint mode (Admin → Model Endpoint, or setEndpointConfig({ mode: "passthru" }))
Marina is a memory gateway in front of any upstream model: point an OpenAI SDK, the Anthropic
SDK, an Ollama client, or an editor (Cursor, Claude Code, Codex) at Marina’s base URL and every
identified caller’s requests are enriched with that caller’s own Marina memory before they reach
the upstream provider.
Surfaces
Section titled “Surfaces”Injection behaves identically on all four proxy surfaces; only the slot differs, because each protocol carries system context in its own place:
| Surface | Native slot the memory lands in |
|---|---|
POST /v1/chat/completions |
its own system message right after the caller’s leading system/developer messages (index 0 when there are none) |
POST /v1/messages (Anthropic) |
appended as the LAST text block of system (a string system becomes a two-block array) |
POST /api/chat (Ollama) |
its own system-role message after the caller’s |
POST /api/generate (Ollama) |
appended to the system string |
POST /v1/responses |
its own system message after the caller’s instructions on the upstream chat body |
The caller’s own system text always comes first and byte-identical; the memory block is the volatile tail. That ordering is what lets provider prefix caches (and the Anthropic breakpoints below) keep the stable prompt cached while the relevance-gated memory block changes.
The Ollama routes and /v1/responses proxy upstream in passthru mode (previously they only routed
to world agents). Ollama passthru always asks the upstream for a completed answer; a client that
requested Ollama’s default streaming receives it as a buffered ndjson stream. /v1/responses
passthru translates Responses tools/tool_choice and function_call/function_call_output items
to chat tools upstream and renders upstream tool_calls as function_call output items (see
Responses API tools), streams incrementally when asked (see
Responses API streaming), and keeps previous_response_id threading
through the conversation channel. Passthru /v1/responses also runs Marina’s instrument stack
(x-marina-stack, the marina/…: prefixes, and the default for an identified caller) exactly as
chat completions does; an instrumented turn is answered complete and, when stream: true was
asked, delivered as Responses SSE in one delta. The Ollama routes refuse the stack with 400.
Identity — who gets memory
Section titled “Identity — who gets memory”The caller is mapped to a Marina entity fail-closed (src/net/model/passthru-context.ts):
MODEL_API_KEYS=secret:Alice— a bound key, confined to entityAlice(lazily created).MODEL_API_KEYS=secret:*— an operator key that may name an existing entity withX-Marina-Agent: <name>. A header can never create an entity.- Anything else collapses onto the shared anonymous
passthruentity, which never receives injection, never has its transcripts captured, and never caches.
Injection is on for identified callers under the local trust profile. Elsewhere it is
opt-in: the entity property passthruContext: true, or — for bound keys only —
X-Marina-Context: on. A bound key can opt a single request out with X-Marina-Context: off;
name-mapped targets ignore the header entirely (their stored consent stands). The old behaviour
where a client-supplied [marina:shared-world-context] marker suppressed injection is gone.
What is injected, in what order
Section titled “What is injected, in what order”The addendum comes from buildUnifiedContext (scope all) for the caller’s latest user message,
plus the world sections the entity may read (member / MARINA_PASSTHRU_SHARED_POOLS pools, its
channels, the chronicle), framed as untrusted:
[marina:shared-world-context]Untrusted, read-only Marina context; verify before acting:Marina memory for Alice.Own memory [skills] (…): … ┐ stable — renders first so a providerOwn memory [trusted] (#12 imp=8 verified): … │ prefix cache (Anthropic/OpenAI) can hitOwn memory [evidence] (record r_1 v1): … ┘Own memory [proposal] (…): … ┐Own memory [unverified — own notes, …] (…): … │ volatile — renders lastShared pool … / Channel … / Chronicle: … ┘Tier order and separators are byte-stable across calls: the same memory state yields the same bytes on every surface.
Budget. MARINA_PASSTHRU_INJECT_BYTES (default 2048, clamped 256–65536) bounds the whole
addendum including the framing lines. Override per bound key with the entity property
passthruInjectBytes. Items that do not fit are cut with a visible marker or dropped, and the
receipt flags truncated: true.
Capture. Each identified, injected exchange is recorded once in the caller’s own memory as a
[passthru] User/Assistant observation (one pair per request). Identical exchanges are captured
once per entity per 24h window, so retries and replays do not multiply notes.
Memory receipts
Section titled “Memory receipts”Every injected response carries x-marina-memory-receipt — compact JSON
(marina.memory.receipt.v1, ≤ 2 KB) listing the tiers, ids (record versions / source hashes for
durable evidence), bytes per tier, the budget, the bytes used, whether it was truncated, and any
degraded tiers. When the full receipt would exceed 2 KB the header carries
{ schema, requestId, truncatedHeader: true }; the full receipt is always on the trace:
curl -si http://localhost:3300/v1/chat/completions -H "Authorization: Bearer secret" \ -d '{"model":"marina","messages":[{"role":"user","content":"what port does Amber use?"}]}' \ | grep -i -e x-request-id -e x-marina-memory-receiptThe receipt’s requestId equals the response’s x-request-id; trace show <id> renders a
Memory section (entity, budget, used bytes, one line per tier with ids), and the native
GET /api/traces format exposes it as the span attribute memoryReceipt. Receipts are on in every
trust profile — YOLO applies to permissions, never to records.
Response cache
Section titled “Response cache”An exact-match completion cache stored on the durable memory service’s pinned result cache
(cache_put / cache_get, see memory-service.md). Opt in per bound key with
the entity property passthruResponseCache: true, or under the local profile with
MARINA_PASSTHRU_RESPONSE_CACHE=on. MARINA_PASSTHRU_RESPONSE_CACHE_TTL_MS sets the entry
lifetime (default 1h).
- Key: SHA-256 of the canonicalized effective request — model identity, messages / instructions after injection, tools and sampling parameters. Stream flags and client tags are excluded. A different memory context is therefore a different key.
- Pins: the receipt’s durable
[evidence]records (by version) and captured sources (by content hash). The service refuses an empty pin set, so a completion is cached only when at least one pinned record or source shaped the prompt — legacy notes and proposals alone never cache. Revising or forgetting a pinned record, or any change to the space’s evidence generation, invalidates the entry; forgetting deletes stored values. - Never cached: streaming responses, tool-call responses, non-2xx responses, and anything from the shared anonymous identity. Identities are isolated by construction (one resident space per world account).
- Hit: the cached completion is returned with
x-marina-cache: hit, a freshx-request-id, and the original receipt of the request that produced it. - Expect the first repeat after a new exchange to miss: the captured transcript adds an
[unverified]line to the injected context, changing the effective request. From then on, identical requests hit. - Requires the bound entity to have a durable world account (it has logged into the world at least once). Without one, injection still works from legacy tiers, and the cache is a silent miss.
- Semantic (similarity-based) caching is explicitly out of scope: the key is byte-exact so a hit can never change an answer.
Authentication
Section titled “Authentication”Model endpoints require a caller key unless an operator explicitly enables the development
MARINA_OPEN_API bypass. The local profile generates and persists a private local key when
MODEL_API_KEYS is unset. Shared/public deployments should configure client keys explicitly:
MODEL_API_KEYS=sk-key-1,sk-key-2 bun run startSet MARINA_MODEL_KEY privately to one of those configured secrets, then include it:
curl http://localhost:3300/v1/chat/completions \ -H "Authorization: Bearer $MARINA_MODEL_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"marina","messages":[{"role":"user","content":"hello"}]}'Available Endpoints
Section titled “Available Endpoints”OpenAI-compatible
Section titled “OpenAI-compatible”| Method | Path | Description |
|---|---|---|
GET |
/v1/models |
List available models |
POST |
/v1/chat/completions |
Chat completion (streaming and non-streaming, tool calling) |
POST |
/v1/responses |
Responses API with server-side conversation state (stream: true, function tools) |
GET / DELETE |
/v1/responses/:id |
Read / delete a stored response |
POST |
/v1/messages |
Anthropic Messages (Claude Code, Anthropic SDKs) |
GET |
/v1/health |
Liveness |
POST |
/v1/forecast |
Forecast any question: {question, kind?, resolveBy?, unit?} → probability or number with analysts, grounding, sources (guide) |
POST |
/v1/patterns/run |
Run one free-text task under a named orchestration pattern: {pattern, task, members?, budgetUsd?} → {message, success, pattern, trace} (below) |
POST |
/v1/decisions, /v1/systemone |
Decision questions (noul / choice / score / box, with optional image parts in state) for any harness; TypeSafe-compatible (decisions) |
POST |
/v1/embeddings, /v1/completions |
Not served — explicit 404 { error: { code: "not_found" } } |
Running one task under a pattern
Section titled “Running one task under a pattern”POST /v1/patterns/run runs ONE free-text task under a named orchestration pattern and
returns the chosen answer with a trace of every stage:
curl -s http://localhost:3300/v1/patterns/run \ -H "Authorization: Bearer $MODEL_API_KEY" -H "Content-Type: application/json" \ -d '{"pattern":"debate","task":"Should we shard the events table?","members":["openrouter/openai/gpt-6.1-sol","openrouter/anthropic/claude-opus-5.5"],"budgetUsd":0.5}'# → {"message":"…","success":true,"pattern":"debate",# "trace":{"steps":[{"stage":"opening:advocate-1","member":"…","status":"ok","text":"…","costUsd":0.01},…],"costUsd":0.05}}Runnable patterns (src/orchestration/text-patterns.ts): ensemble (parallel drafts, a checker
picks one), debate (the first two members argue and rebut, the checker judges), delphi
(independent drafts, an anonymous summary, one revision each, a pick), tournament (pairwise
picks in a bracket), verification (the first member drafts, the checker reviews, one revision),
deliberation (proposals, revision after seeing anonymous peers, a pick), chorus (proposals,
each critiques one peer, revision, a pick) and pipeline (each member refines the previous
handoff; the last output wins). The legacy name nsed runs deliberation. Each is its
formation’s entry in the formation catalogue (src/orchestration/formations/). The arena and
forecast formations of the same name use the same entry with numeric and typed combiners. A checker
always picks ONE whole answer; answers are never merged.
Crew-only patterns (foundry, swarm, mapreduce, blackboard, symbiosis, research,
auction, ledger, sharding, custom) need a persistent crew and are refused with 400 unsupported_parameter (param: "pattern"); run them as a crew and call it as model
marina:<crew>. An unknown pattern is 400 listing the supported ones.
members takes 2–5 reachable upstream ids (repeats allowed; never a marina… id). It defaults
to the installation’s first available model, repeated MARINA_ENSEMBLE_SIZE times (default 3).
Under a passthru pin every member must be the pin. The checker follows the verify checker
resolution for the first member (MARINA_VERIFY_CHECKER_MODEL, else the member itself).
task is at most 16,000 characters. The route uses the same auth, per-IP limit, run-key
handling and daily spend cap as the rest of /v1. A spent cap is 429 spend_cap_reached,
and 503 pattern_unavailable means no model is configured. Before each stage, the run checks
the caller’s budgetUsd, the daily cap, the caller’s connection and a 10-minute deadline. If
any of them stops the run, it returns 200 with the best answer so far, success: false and
trace.fallback naming the reason.
Ollama-compatible
Section titled “Ollama-compatible”| Method | Path | Description |
|---|---|---|
GET |
/api/tags |
List models (name, model, digest, size, details) |
GET |
/api/version |
{ "version": "<marina version>" } |
GET |
/api/ps |
Running models — the configured default route |
POST |
/api/show |
Model details (modelfile / parameters / template are empty strings — Marina models are routes, not weights) |
POST |
/api/chat |
Chat completion |
POST |
/api/generate |
Text generation |
POST |
/api/embed, /api/embeddings |
Not served — explicit 404 |
The digest in /api/tags is a stable SHA-256 of the model id, so Ollama clients that key their
cache on it see the same model across restarts.
/api/chat and /api/generate return text only, and /api/show advertises exactly that
(capabilities: ["completion"]). Fields they cannot honor are refused with 400 unsupported_parameter (param names the field) instead of being dropped: tools, format,
think (other than false), message or prompt images, and suffix / template / raw /
context on /api/generate. options accepts temperature, top_p, num_predict, stop,
seed, presence_penalty and frequency_penalty (mapped onto the upstream call), plus the
local-runtime hints num_ctx, num_gpu, num_thread, num_batch, num_keep, main_gpu,
use_mmap, use_mlock, numa, low_vram, f16_kv and vocab_only (accepted; nothing
to act on without local weights). Any other options key is unsupported_parameter.
Request bodies are capped per route (MARINA_MAX_REQUEST_BODY_BYTES, default 8 MiB; over the cap
is 413). A proxied upstream call has a deadline (MARINA_UPSTREAM_TIMEOUT_MS, default 300 s;
exceeded → 504 upstream_error), and a non-streaming call is aborted when its client
disconnects. A client address that presents 20 rejected API keys within a minute gets 429 rate_limit_exceeded before its credential is checked, until the budget refills; the cheap reads
(/v1/models, /api/tags, health) are limited to 120 per minute per address.
Compatibility contract
Section titled “Compatibility contract”Error envelope
Section titled “Error envelope”Every error is the OpenAI nested shape with a string code SDKs can branch on
(src/net/model/openai-errors.ts):
code |
When |
|---|---|
invalid_api_key |
401/403 — missing, wrong or upstream-rejected credential |
model_not_found |
404 for an unknown model id (/v1/chat/completions, /api/show) |
not_found |
404 for anything else (unserved paths, unknown response id) |
context_length_exceeded |
400 whose reason names the context window / prompt length |
rate_limit_exceeded |
429 (Marina’s per-IP limiter or the upstream) |
unsupported_parameter |
400 for a parameter the route cannot honor — param names it |
invalid_request_error |
any other 400 |
upstream_error |
502/503/504 from the provider chain |
server_error |
500 |
unsupported_parameter replaces silent dropping. Agents / open / panel modes refuse
tools, functions, n > 1 and a non-text response_format (in-world agents answer in text over
a channel). Anthropic-backed passthru refuses n > 1, response_format: { type: "json_object" }
(use json_schema — it is translated to Anthropic output_config), non-function tool types and
audio/file content parts.
Tool calling on Anthropic-backed passthru
Section titled “Tool calling on Anthropic-backed passthru”When the passthru upstream is Anthropic (anthropic/<model>), the OpenAI body is translated
faithfully (src/net/adapters/anthropic-tools.ts):
| OpenAI request | Anthropic Messages |
|---|---|
tools[{type:"function", function:{name, description, parameters}}], legacy functions[] |
tools[{name, description, input_schema}] |
tool_choice "auto" / "none" / "required" / {function:{name}} |
{type:"auto"} / {type:"none"} / {type:"any"} / {type:"tool", name} |
parallel_tool_calls: false |
tool_choice.disable_parallel_tool_use: true |
stop (string or array) |
stop_sequences |
max_tokens / max_completion_tokens |
max_tokens (default 4096) |
user |
metadata.user_id |
| `reasoning_effort: “minimal | low |
response_format: {type:"json_schema", json_schema:{schema}} |
output_config.format: {type:"json_schema", schema} |
| system / developer messages | system[] text blocks, in order (memory injection is the first block) |
user text / image_url parts |
text / image blocks (data URLs → base64 source) |
assistant tool_calls |
tool_use blocks |
role: "tool" results |
tool_result blocks; consecutive results grouped into ONE user message |
| Anthropic response | OpenAI |
|---|---|
every text block (thinking skipped) |
message.content |
tool_use blocks |
message.tool_calls, finish_reason: "tool_calls" |
end_turn / stop_sequence / max_tokens / refusal |
stop / stop / length / content_filter |
streaming content_block_start/input_json_delta |
delta.tool_calls[{index, id, function:{name, arguments}}] fragments |
usage.input_tokens + cache_read_input_tokens + cache_creation_input_tokens |
usage.prompt_tokens; cached share in prompt_tokens_details.cached_tokens; raw cache_read_input_tokens / cache_creation_input_tokens kept |
readiness providers sends a tool-call probe to Anthropic and OpenAI providers in addition to
the text probe: one tiny tool, and the reply must contain a structured tool_calls entry naming it
with the nonce in its arguments. A provider that answers in text fails with tool call dropped.
Prompt caching
Section titled “Prompt caching”-
/v1/messagesclients (Claude Code, Anthropic SDKs): when the upstream is Anthropic the client’s native body is forwarded verbatim —cache_controlon system, tool and message blocks,thinking,tool_choice,metadata. Memory injection lands as the LAST system block. -
OpenAI-completions clients (pi-ai with
cacheControlFormat: "anthropic", or any client that putscache_controlon system content parts or on a tool): the markers are carried onto the translated Anthropicsystemblocks and tools. -
Auto-cache —
MARINA_ANTHROPIC_AUTO_CACHE(defaulttrueunder thelocaltrust profile,falseotherwise) placescache_control: { type: "ephemeral" }breakpoints in this order, within Anthropic’s limit of four (the client’s own markers count against it):- the last stable system block — the caller’s own prompt (the block before the memory block when memory was injected, else the last block);
- the last system block (the memory block) — only when the client set no marker anywhere;
- the last tool — only when the client set no marker anywhere;
- a rolling breakpoint on the last cacheable block of the latest message — only when the
client set no marker anywhere and the request reaches the model’s minimum cacheable length
(512–4096 tokens by model). In a multi-turn tool loop each turn then reads the whole earlier
conversation from cache and writes only the new tail. Not added to native
/v1/messagesbodies, whose messages are forwarded as sent.
A client’s markers are always preserved; with any present, the proxy adds only breakpoint 1 and only if that block has none. A native
/v1/messagesbody whose last system block is already marked is forwarded untouched. -
Claude via OpenRouter (
openrouter/anthropic/<model>): OpenRouter forwardscache_controlon content parts to Anthropic, so these requests keep the client’s markers and, under the same auto-cache rule, get the stable-system, memory and rolling breakpoints as text parts (a string content becomes one text part). Every other OpenAI-compatible upstream — OpenAI, Gemini or any other model on OpenRouter, local runtimes — hascache_controlstripped; those providers cache automatically. -
OpenAI upstreams: Marina forwards its traced
x-request-idas a request header and leavesprompt_cache_keyin the body untouched. -
Cache counters (
cache_read_input_tokens,cache_creation_input_tokens,prompt_tokens_details.cached_tokens) land on themodel_request_lifecyclecompleted event ascacheReadTokens/cacheWriteTokens, sotrace show <id>shows cache hits per request.
Cost and cache headers
Section titled “Cost and cache headers”Every proxied reply carries:
| Header | Value | Streams |
|---|---|---|
x-marina-upstream-model |
the served provider/model (e.g. anthropic/claude-sonnet-5) |
yes |
x-marina-cache-read-tokens |
cache-read input tokens | no |
x-marina-cache-write-tokens |
cache-write (creation) input tokens | no |
x-marina-cost-usd |
list-price cost of the call in USD | no |
Streams cannot carry token headers (they are sent before the usage is known); their tokens ride the
final SSE usage chunk and the lifecycle completed event. Cost is the upstream’s own usage.cost
when it reports one (OpenRouter), else computed from pi-ai’s built-in model catalog for the served
model; it is omitted — never 0 — for models the catalog does not list (local runtimes). The same
costUsd is on the lifecycle completed event, so agents running on marina/default are priced by
the model that actually served them.
Usage bodies extend prompt_tokens_details with cache_creation_tokens (cache writes) and, with the
same value, cache_write_tokens — the field pi-ai’s OpenAI-completions client reads. Responses API
bodies mirror it as usage.input_tokens_details.cache_creation_tokens.
usage is reported only when known: from the upstream in passthru mode, or summed from the
answering agent’s traced agent_turn_end spans in agents mode. It is omitted (never zero-filled)
when no turn reported tokens.
Troubleshooting
Section titled “Troubleshooting”“No agent available” — No agent is connected to the target channel. Start a provider agent.
Timeout after 30 seconds — The agent is slow to respond. Check that your external LLM provider is reachable.
401 Unauthorized — Configure MODEL_API_KEYS and send a matching bearer token. For local
development only, restart with MARINA_OPEN_API=true; merely leaving the key list unset does not
open the API.
Room Agent Routing
Section titled “Room Agent Routing”Room agents (spawned by world rooms) use model marina/default which routes through the local model API. The flow:
- Room agent calls
http://localhost:3300/v1/chat/completionswith model “default” (with its tools) - Because the request carries the internal token, the model API always proxies it straight to
the configured upstream (ANTHROPIC_API_KEY, OPENAI_API_KEY, … or the pinned passthru model) —
in every endpoint mode. Marina’s own agents are consumers of the upstream, never participants
of the
agents/open/panelroutes, so they are never handed to another agent and never hit the agents-mode400 unsupported_parameter: tools. - Response returned to room agent, with
x-marina-upstream-modeland (non-streaming)x-marina-cost-usdheaders; the lifecycle events carryrouteKind: "passthru",routeReason: "internal".
Room agents authenticate via an auto-generated internal token — no MODEL_API_KEYS or MARINA_OPEN_API configuration needed.
This means one upstream API key (e.g., ANTHROPIC_API_KEY) powers all room agents in the world.
