-
Notifications
You must be signed in to change notification settings - Fork 2
OpenAI Compatible Server
TinyTitanServer serves one installed model to local OpenAI-compatible clients.
It binds to 127.0.0.1 without authentication or TLS.
Caution
Keep the server local. Do not proxy, tunnel, or expose it. Before starting, verify that no other TinyTitan or local-model process is running.
memory_pressure -Q
pgrep -fl 'TinyTitanServer|TinyTitanCLI|TinyTitanPackageTests|swiftpm-testing-helper|mlx_lm|mlx-lm'Continue only when the process check prints nothing.
swift build -c release --product TinyTitanServer
.build/release/TinyTitanServer \
--model models/ornith-1.5_35B_A3B_8Bit \
--port 8083Keep that terminal open. Stop the server with Control-C.
Check it from another terminal:
curl --silent http://127.0.0.1:8083/health
curl --silent http://127.0.0.1:8083/v1/modelsSend a request:
curl --silent http://127.0.0.1:8083/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{
"model": "ornith-1.5-35b-a3b_8-Bit",
"messages": [{"role": "user", "content": "Reply with exactly READY."}],
"temperature": 0,
"max_completion_tokens": 16
}'The server derives ornith-1.5-35b-a3b_8-Bit from the verified manifest unless
--model-id overrides it. KV cache precision defaults to 8-bit.
The model id always ends in the routed-expert width (_4-Bit, _8-Bit).
The width is read from the manifest's routed-expert slot rather than parsed from
the name, so a 4-bit and an 8-bit install of the same weights are distinguishable
in /v1/models instead of both answering to one id. This is a breaking change:
the bare ornith-1.5-35b-a3b is no longer accepted. Ask the server rather than
assuming -- curl -s http://127.0.0.1:8083/v1/models lists exactly what it
serves.
An optional prepared Ornith MTP sidecar can be supplied with --mtp-model.
It requires greedy requests, native RoPE, and prompt cache off. MTP is
experimental and disabled by default because current M3 measurements show no
speed benefit; preparation is documented in Getting Started.
To enable extended context, choose YaRN and optionally override its 1M default with 512K. Use 4-bit KV when memory is tight:
.build/release/TinyTitanServer \
--model models/ornith-1.5_35B_A3B_8Bit \
--port 8083 \
--rope-scaling yarn \
--kv-bits 4See Runtime Controls for the 512K form and KV memory table.
| Endpoint | Purpose |
|---|---|
GET /health |
Process health; never loads a lazy model |
GET /v1/models |
Lists the served model(s): the one model, or every install under --models-dir (plus <id>@cpu where a second engine is a real choice). In single-model mode the -fast alias is listed beside the model; under --models-dir only the real installs are listed, and -fast is accepted but hidden |
POST /v1/chat/completions |
Chat Completions, JSON or SSE streaming |
POST /v1/responses |
Responses API, JSON or SSE streaming |
POST /v1/responses/compact |
Compaction: a conversation in, a compacted input window out, which the next response takes as its base input
|
POST /v1/messages |
Anthropic Messages, JSON or SSE streaming; selected by the anthropic-version header |
POST /v1/messages/count_tokens |
Anthropic token counting; never generates |
POST /v1/models/unload |
Releases a dynamically managed model |
The server supports system, developer, user, assistant, and tool messages;
sampling controls; stop strings; seeds; usage reporting; function tools; and
enforced structured output: asking for JSON — response_format on Chat
Completions, text.format on Responses, output_config.format on Messages —
compiles the schema into a byte-level grammar that masks the sampler, so the
model can only emit a document the schema allows. It does not provide
embeddings, multimodal input, legacy Completions, batching, or log probabilities.
Serving a catalog (--models-dir) does switch the resident model when a request
names another of the listed installs — one at a time, waiting for in-flight
generations first — and a single-model server refuses any name but its own.
Thinking is a per-request control on all three surfaces — reasoning_effort on
Chat Completions, the nested reasoning.effort on the Responses API, and
thinking on the Messages API — so a session may think on one turn and answer
directly on the next; the thoughts come back as reasoning_content (OpenAI) and as
a thinking block with an empty signature (Messages). Thinking is off for a request
that names a JSON format, because the grammar constrains every token.
The schema subset is small and explicit, and everything outside it is refused by
name at request time rather than accepted and quietly ignored:
Structured output
lists the supported keywords, the mechanism, and what the guarantee does not
cover (a response truncated by max_tokens is a truncated document).
One script starts everything: tools/server_launcher.sh.
Run it with no arguments and it asks, in plain language and with a default on every question:
- What to launch — the API server on its own, or the server plus Codex, Claude Code, Qwen Code, OpenCode or the Zed editor. The server speaks OpenAI and Anthropic at once, so this choice selects the client setup to write, not what the server runs.
-
Full agent loop or fast chat —
faststrips coding-CLI boilerplate before prefill and serves the model's<id>-fastalias. -
Which model, from the server's own catalog: every installed model and
quantization, with its size, the engine that serves it and the thinking
levels it renders. Only installs that are really under
models/are offered — the launcher re-checks each entry's directory, and when it has to fall back to its built-in list (no built server binary, nopython3) that list is filtered the same way and the families it leaves out are named in one line. A model or a width that is not installed is never a choice, and asking for one is refused with the widths that are there. For almost every install the engine is a property rather than a choice: every 35B-A3B / 125B-A6B family is GPU-only, because the CPU engine does not implement those. The dense Qwen 3.5 2B / 4B / 9B installs (4- and 8-bit) are the exception -- both engines implement their family (qwen3_5_dense), so they showGPU+CPUand the engine is asked for, with the GPU as the default. - Answer style — standard or concise.
-
Reasoning level — only the levels the chosen model's template
implements,
offfirst and default. The dense Qwen 3.5 models define the binaryenable_thinkingswitch, so their levels are exactlyoff(a direct answer) andon(reason before answering); Qwen3.8-Flash-Next rendersoff, low, medium, xhigh. A level the model does not define is refused rather than mapped to a neighbour, and a client can still switch it per request —reasoning_effort(orchat_template_kwargs) on Chat Completions,reasoning.efforton the Responses API,thinkingon the Messages API, wheredisabledis a real off and an enabled block'sbudget_tokenspicks the rung (<4096low,<16384medium, elsexhigh). The level travels into generation, so a client may think on one turn and answer directly on the next. -
RAM limit for the expert cache — 1, 2, 4, 8, 16 or 32 GB, or the
model's own measured profile (the default). The question is built around
30% of the Mac's physical memory — the cache is wired, so it cannot be
paged out and macOS, the KV cache, the prompt cache and everything else the
person is running have to fit beside it. On a 24 GB Mac that is 7 GB, on a
16 GB Mac 4 GB, on an 8 GB Mac 2 GB. A
--ramabove it is allowed and warned about, in red, before the model starts: system instability while the model is loaded, heavy swapping that stalls other apps, and much slower generation, because a paging cache loses more than the extra slots gain. The warning is the operator's call to make, not a refusal, and the summary line keeps the fact visible. The default is the install's own measured profile, which the runtime separately holds to half of physical memory — measured on a 24 GB Mac with Qwen 3.8 4-bit, that machine's 12 GB profile left 11% of memory free and glitched audio. That is why the launcher's own rule is the tighter one, and why it is 30% rather than 40%: real usage ran past half of physical memory even with the cache at 40%, so the recommendation that overshot had to move. A CPU model has no routed-expert cache, so it does not ask: the step is skipped, and--ram,--kv,--contextand--yarnare reported as not applying rather than passed along or dropped in silence.
The same choices can be passed positionally or as flags:
tools/server_launcher.sh [<client>] [fast|full] [<model> [4|8]] \
[default|concise] [<thinking>] [<ram>]
tools/server_launcher.sh --client codex --model ornith 4 --ram 8
tools/server_launcher.sh --client zed --model qwen38 4 --context 524288 --yarn
tools/server_launcher.sh --model qwen35-2b --bits 4 --thinking on --engine cpu--client server (or server/ornith 4 positionally) starts the API alone.
Flags: --client, --model, --bits, --engine, --mode, --answers,
--thinking, --ram, --context, --kv, --yarn, --port, --memory,
--dry-run, --help. --engine cpu|gpu picks the engine for the one
install shape that both engines serve (the dense Qwen 3.5 models); for every
other install it asserts the single engine that implements the family and
refuses the other with that family as the reason.
On a running server the same choice is a model-id suffix. The catalog id is
the GPU spelling, and <id>@cpu / <id>@gpu name an engine explicitly, so a
client selects one per request:
curl -s http://127.0.0.1:8080/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model": "qwen3.5-2b_4-Bit@cpu", "messages": [{"role": "user", "content": "hi"}]}'/v1/models lists the bare id and the @cpu alternative for a dense install
(and only an alternative that is a real choice -- a single-engine install gets
no second entry). Residency is still one model at a time, so switching a dense
install between engines reloads it. Starting a dense install directly works too,
without the catalog:
swift run -c release TinyTitanServer --model models/qwen3.5_2B_4Bit --cpu --reasoning onWhat it does per client:
| Client | Configured by |
|---|---|
| Codex |
~/.codex-tinytitan/config.toml (a dedicated home; wire_api = "responses") |
| Claude Code |
ANTHROPIC_BASE_URL / ANTHROPIC_API_KEY / ANTHROPIC_*_MODEL in the environment |
| Qwen Code |
~/.qwen-tinytitan/settings.json, with its stream timeouts disabled |
| OpenCode | an tinytitan provider merged into ~/.config/opencode/opencode.jsonc
|
| Zed | an openai_compatible provider merged into ~/.config/zed/settings.json
|
Both JSONC merges keep every other key in the file, tolerate comments and
trailing commas, and write a .tinytitan-backup beside the original before they
rewrite it. A file they cannot parse is left untouched.
Server profile: every installed model is reachable by name (--models-dir,
one resident at a time); GPU models are pinned to native 262,144-token
context, a 256 MiB multi-prefix prompt cache, 8-bit KV and MTP off. The
expert-cache budget, prefetch, prefill chunk and sampling defaults come from
the install's own ModelProfile row, clamped to this machine's RAM, so the
launcher overrides a measured optimum only when --ram is passed. A model
served on the CPU backend takes none of the pinned flags: that backend has
no prompt cache and no quantized KV. That is the backend, not the install -- a
dense Qwen 3.5 model served on the GPU gets the GPU profile, and the same
install served as <id>@cpu gets the CPU one.
Warning
The launcher stops an TinyTitanServer already listening on its port so it can
bind, and it never touches a process on that port that is not TinyTitanServer.
Start the server by hand when another session must stay up. The launcher asks
for the port and uses 8080 if you press Enter; --port or TINYTITAN_PORT
answers it without the question. On any port other than the default the
launcher prints the command that re-points the DeepSeek Harness route.
Use this local provider information:
| Setting | Value |
|---|---|
| Base URL | http://127.0.0.1:8083/v1 |
| API key | Any non-empty value when the client requires one |
| Ornith base model |
ornith-1.5-35b-a3b_8-Bit (or _4-Bit) |
| Ornith chat-only alias | ornith-1.5-35b-a3b_8-Bit-fast |
| Qwen 3.6 base model, when served manually |
qwen3.6-35b-a3b_8-Bit (or _4-Bit) |
| Qwen 3.6 chat-only alias | qwen3.6-35b-a3b_8-Bit-fast |
| Qwen3.8-Flash-Next, when served manually | qwen3.8-flash-next_4-Bit |
Codex configuration:
model = "ornith-1.5-35b-a3b_8-Bit"
model_provider = "tinytitan"
[model_providers.tinytitan]
name = "TinyTitan"
base_url = "http://127.0.0.1:8083/v1"
wire_api = "responses"Set any API key required by the client, for example
OPENAI_API_KEY=local. The CLI launcher writes isolated Codex and Qwen Code
configurations automatically; it does not replace their normal config homes.
DeepSeek Harness is not a launcher entry: it reaches the server through its own
llm-pi-ai provider route. Do not hand-write that route: this checkout
generates it from the installs it has —
tools/dsh_route.sh # print the block
tools/dsh_route.sh --write # replace the llm-pi-ai section in ~/.dsh/settings.yaml
tools/dsh_route.sh --models qwen38 --reasoning off --writeIt reads the server's own catalog (or TINYTITAN_CATALOG_JSON), so the picker
follows models/ — served ids, each template's thinking levels, and the three
switches that are easy to get wrong by hand. Only installs really present are
listed, one row per width.
plugins/dsh-tinytitan/ does the same at DSH boot, watches models/ while the
harness runs so an install or a deletion reaches the picker without a restart,
reads the folder directly when no server has been built yet, and additionally
mounts a compaction backend that forces thinking off for the auxiliary calls.
What it writes, for reference, looks like this
(port 8083 to match this page; 8080 by the launcher's default):
llm-pi-ai:
providers:
tinytitan:
displayName: TinyTitan
api: openai-completions
baseURL: http://127.0.0.1:8083/v1
headers:
authorization: Bearer tinytitan-local # pi-ai refuses a keyless route; the server has no auth
reasoning: medium # level for calls that name none (compaction, session titles)
streamIdleTimeoutMs: 3600000 # a cold local prefill outlives pi-ai's five-minute default
models:
- id: qwen3.8-flash-next_4-Bit # exactly what GET /v1/models lists
name: Qwen 3.8 Flash Next 125B-A6B (4-bit) # what the picker shows; the width is part of it
contextWindow: 262144 # the launcher's "context=" value
maxTokens: 32768
reasoningEfforts:
off:
low: low
medium: medium
xhigh: xhigh # what the Qwen3.8 template renders (default xhigh)
compat:
thinkingFormat: chat-template # the only place TinyTitan reads enable_thinking; pi-ai's `qwen` format sends it top-level
chatTemplateKwargs:
enable_thinking: { $var: thinking.enabled }
reasoning_effort: { $var: thinking.effort }
maxTokensField: max_tokens
supportsUsageInStreaming: truesupportsDeveloperRole: false is no longer needed — the server serves
developer as system — but a route may keep it to send the identical wire.
Verified 2026-09-14 against qwen3.8-flash-next_4-Bit started with
tools/server_launcher.sh --client server --model qwen38 --bits 4: thinking off
and on per request against a server loaded either way, thoughts returned as
reasoning, and completion_tokens_details.reasoning_tokens in the usage. KAT is
the binary-thinking family, so its route declares off: plus one level mapped
to on.
Use the selected model's base ID for coding agents and tool loops. Append
-fast for direct questions.
The fast alias uses the same weights but removes coding-CLI system prompts, tool definitions, tool history, and known reminder blocks before prefill. This can reduce first-response wall time substantially, but it also removes the information required for tool calls. It is chat-only for that request.
Multi-prefix reuse is enabled by default. Send the complete conversation each turn. A compatible continuation reports reused tokens in:
usage.prompt_tokens_details.cached_tokens
The default cache keeps up to four prefixes in RAM with a 256 MiB snapshot budget. To persist compatible prefixes across restarts:
.build/release/TinyTitanServer \
--model models/ornith-1.5_35B_A3B_8Bit \
--port 8083 \
--prompt-cache-disk "$HOME/Library/Caches/TinyTitan/prompt-cache" \
--prompt-cache-disk-mib 8192The disk cache can contain conversation text and source code. Keep it private, local, and unsynchronized. Prompt reuse improves prefill and time to first token; it does not increase decode tokens per second.
By default the server loads at startup and remains resident. For a shared Mac:
-
--lazy-loadloads on the first inference request. -
--idle-unload-seconds Nunloads after an idle period and implies lazy load. -
POST /v1/models/unloadunloads on demand when dynamic residency is enabled.
Pair idle unloading with --prompt-cache-disk; unloading discards the
in-memory prompt cache.
TinyTitan returns function-call requests but never executes them. The client must
apply its normal permission policy, execute approved tools, append each result
with the matching tool_call_id, and resend the complete history. Only
function tools and automatic or disabled tool choice are supported.
- Essential tuning: Runtime Controls
- Common connection problems: FAQ
- Reproducible measurements: Benchmarking Guide
Start
Use TinyTitan
DeepSeek Harness
Reference
Engineering
Project