Skip to content

Latest commit

 

History

History
528 lines (445 loc) · 42.7 KB

API.md

File metadata and controls

528 lines (445 loc) · 42.7 KB

Tiiny device API reference

Everything the Tiiny Pocket answers on the network, in one place. Unofficial. First collected 2026-10-25 against TiinyOS 0.1.35, rechecked 2026-10-27 after the update to device services 0.1.36 (gateway spec unchanged, 37 endpoints; vault 54 → 59). Sources, marked per endpoint:

mark meaning
tested called and checked against a real device
spec in an OpenAPI spec the device publishes (exact paths and schemas)
docs in Tiiny's developer docs (dev.tiiny.ai), with example requests
app only seen in the TiinyOS desktop app's code; behaviour not checked

After a TiinyOS update, run python tools/api_snapshot.py --check: it refetches the device's specs, lists every added/removed/changed endpoint, regenerates endpoints/gateway.md and endpoints/kb.md, and probes the read-only routes below.

Services and ports

port service reachable over notes
80 gateway: routes to everything below Wi-Fi/LAN and USB the only port you normally need
80 /api/v1/sys, /api/v1/account, /api/v1/apps, /api/v1/webapp, /api/v1/chat/history system & account service (FastAPI) " spec: /openapi.json, /docs → endpoints/gateway.md
80 /api/v1/models, /api/v1/npu, /v1/*, /api/tags, /api/chat model service (also on 8800 on the device) " no published spec; documented below
80 /kb/* knowledge base / vault ("SimpleMem") " spec: /kb/openapi.json, /kb/docs → endpoints/kb.md
80 /v1/config, /v1/<connector>/…, /v1/mcp/… on-device assistant: connectors and MCP " docs
80 /api/services app services (Tiiny App Farm) " docs/app
dynamic each loaded model's own container Wi-Fi/LAN only port from GET /api/v1/models/running; no auth
8800 model service directly on the device only the docs use :8800; not reachable from the network
5555 upgrade/OTA service on the device only /api/upgrade/*
39218 discovery metadata GET /device.json Wi-Fi/LAN no auth; names, addresses, USB link details
39217/udp discovery broadcast (GADGET_DISCOVER_V1) LAN _gadget._tcp mDNS/DNS-SD too
9109 community NPU dashboard + /metrics (optional, not part of TiinyOS) Wi-Fi/LAN only if installed on the device

Interactive docs: only two services publish them: /docs and /redoc (system & account) and /kb/docs, /kb/redoc (vault). Models/inference, the assistant's connectors and /api/services have no docs or spec (/v1/docs 404); model containers vary (Foundation-1 has /openapi.json, the llama.cpp chat container doesn't). The pages need the API key for "Try it out" calls.

Auth: Authorization: Bearer <API key> on port 80 (the key TiinyOS shows; the device accepts more than one). The Anthropic-style route also accepts x-api-key (tested); no key → 401. Discovery (39218) and model containers need no key. After the Tiiny is powered off and on, every API call answers 502 Bad Gateway, over USB and Wi-Fi alike, until it's unlocked. Two ways unlock it within seconds: open the TiinyOS desktop app, or run tiiny login (the Tiiny CLI; on a PC that's already signed in it just says "Already logged in as …", and that's enough). After that the API keeps working even if the app is closed, and closing the app while the Tiiny runs doesn't lock it (tested, 0.1.38, 2026-10-30, three power cycles). TiinyOS "Auto Start" avoids it. Reported to Tiiny.

Host names (TiinyOS maps them to the device): openai.api.tiiny, anthropic.api.tiiny, ollama.api.tiiny, api.tiiny. All of them are the same port 80 — the routes below work on the plain IP too (tested).

System and power

method path what mark
POST /api/v1/sys/shutdown/ power off (empty body). Replies {"status":"ok","message":"System shutdown will be initiated in seconds via host API"}; off within ~10 s. 0.1.37 replies …initiated in seconds via D-Bus (earlier: via host API). Broken on 0.1.36 (same ok reply, device stays on); fixed in 0.1.37 (tested over USB and Wi-Fi, 2026-10-29), still working on 0.1.38 (2026-10-30) tested
POST /api/v1/sys/reboot/ restart spec
GET / POST /api/v1/sys/ac_power/ power on automatically when plugged in: GET → {"status","ac_power": bool}; POST {"switch": 0|1} tested (GET), app (POST)
GET /api/v1/sys/status live cpu, memory, gpu, npus, disk tested
GET /api/v1/sys/performance request timings per endpoint: summary, endpoints (limit, sort_by, include_stats_endpoint) tested
GET /api/v1/sys/device_info cpu, gpu, dnpu, ram, storage, network, versions, device name, settings tested
POST /api/v1/sys/setting change a device setting spec
POST /api/v1/sys/base_urls runtime base URLs spec
GET /api/v1/sys/storage, /storage/llm, /storage/agent storage use spec
DELETE /api/v1/sys/storage/agent/{agent_name} delete an agent's data spec
GET/POST /api/v1/sys/wifi/wifi_list, wifi_connect, wifi_disconnect, connection_status, connection_switch, wifi_change, wifi_auto_connect Wi-Fi setup docs/app — 404 on port 80 over Wi-Fi (2026-10-25); probably USB/first-setup only
GET /api/v1/npu/status NPU memory budget and utilisation (util only while a model is loaded) tested
GET :39218/device.json discovery: device name, IPv4/IPv6, USB network (usb.network, device_ip, host_ip), metadata URLs tested
POST :5555/api/upgrade/check, download_and_ota_prepare?target_version=, upgrade_to?target_version=prepared, pause_prepare, resume_prepare, reboot; GET status, status/v2 firmware updates docs/app (on-device only)

Models

All through port 80 (the model service). {id} is the URL-encoded model id, e.g. Qwen%2FQwen-Image-2.1.

method path what mark
GET /api/v1/models/?including_downloading=false installed models: id, status (running/stopped…), type, input, supported (e.g. Image Editing, Tool Use), npu_usage (% of NPU), load_time, desc tested
GET /api/v1/models/running running instances, each container's port tested
GET /api/v1/models/{id}/unload_candidate does it fit? unload_required, model_id[] to unload, npu_required/available tested
POST /api/v1/models/{id}/launch {"model_id": id} load (async; poll running) tested
POST /api/v1/models/{id}/launch/stream load with progress stream app
POST /api/v1/models/{id}/stop unload tested
POST /api/v1/models/unload_all unload everything tested
POST /api/v1/models/{id}/download, /pause_download, /interrupt; /api/v1/models/interrupt_all downloads docs/app
DELETE /api/v1/models/{id} delete a downloaded model docs
POST /api/v1/models/import, /import/inspect; GET /import/toolkits import a custom model (checks toolkit support) docs/app
GET /api/v1/models/types, /storage, /online_models, /api/v1/model-stats model types, disk use, store catalogue, stats probed
GET (ws) /api/v1/models/running/ws, /api/v1/models/npu/status/ws live updates app
GET /api/v1/statistics/week_usage?start_date=…, /app_week_usage usage stats app (needs start_date)

Inference: OpenAI-compatible (/v1)

The device handles one request at a time; parallel requests to different models slow each other down badly.

method path what mark
GET /v1/models loaded models; each has aliases (default = the loaded chat model), supports_vision, supports_chat, thinking, context_window tested
POST /v1/chat/completions OpenAI chat. Streaming (reasoning_content deltas when thinking), tools/tool calls, images as image_url data URIs. Thinking off: chat_template_kwargs: {"enable_thinking": false} (gpt-oss rejects it; use reasoning_effort). Model default = whatever is loaded. temperature, top_p and seed are passed through to the model (0.1.38: temp 0 gives the same answer every time; temp 1 varies, gateway and direct port alike; no seed = random, a fixed seed gives the same answer every time even at temp 1). "Tell me a joke" returns the same joke most of the time even at temp 1: a predictable prompt, not a dropped setting tested
GET /v1/capabilities capabilities of the running model (503 when none) probed
POST /v1/embeddings embeddings (load an embedding model, e.g. Qwen3-Embedding-0.6B) tested
POST /v1/rerank {model, query, documents[]} (not in the OpenAI spec) docs
POST /v1/images/generations create: model, prompt, size "WxH", negative_prompt, seed, steps, guidance_scale, response_format; edit: add image (one, base64/data URI) or images (array, up to 10) with an edit-capable model. n > 1 fails: one image per request. /images/edits and /variations don't exist tested
POST /v1/image/generate/stream image generation with progress app
POST /v1/audio/speech model (the full id), input, voice, language, instruct, ref_audio (base64 WAV) + ref_text, response_format wav|pcm|mp3. What each model needs differs (see "Text to speech: which voice to send" below): voice must be one of the model's own speakers; OpenAI's default alloy gives a 500 on most models tested
POST /v1/audio/transcriptions multipart model, file, language; json/text only. Resample 48 kHz stereo to 16 kHz mono first tested
POST /v1/ocr?model=<id> {"input": data URI, "response_format": "json"} → data[0].text + lines. PP-OCRv6 Small/Medium work (100% on the test image, 0.1.36). GLM-OCR doesn't work through the API on 0.1.36: here it answers 404 Endpoint not found, and /v1/chat/completions answers 400 does not support chat. It works on its own port (see Model containers) tested
GET/POST /v1/music/info, /v1/music/sessions, /v1/music/generate[/wav|/preview|/resume], /v1/music/sessions/{id}/download?format=mp3 music (Foundation-1 loops via /generate/wav with {model, prompt, config}; SongGeneration via /generate/preview + /resume) tested
GET/POST /v1/3d/capabilities, /v1/3d/tasks, /v1/3d/tasks/{id}, /v1/3d/tasks/{id}/cancel Hunyuan3D stages: multipart stage (shape, simplify, texture, export), params JSON, files image/mesh/material tested
GET /v1/video/info?model=<id> a loaded video model's options (0.1.38): canvases (the only sizes it takes; MiniMax-H3-Turbo: 1344x768, 768x1344, 832x480, 480x832), step_options (4-8), default_steps (7), fps (24), max_keyframes (2), audio (32 kHz stereo) tested
POST/GET/DELETE /v1/videos, /v1/videos/{id}, /v1/videos/{id}/content OpenAI-style video jobs (0.1.38). POST {model, prompt, size "WxH", seconds "2", steps, seed, image} (image: a start frame as a data URI) answers {id, status: queued}. Poll GET /v1/videos/{id} (status queued → in_progress → completed/failed, progress 0-100); GET …/content is the MP4 (H.264 + AAC). Max 7 s: 8 s and longer fail at once with "no exported bucket fits a request needing [audio=…, target_video=…]" (the submit itself accepts any length; a size not in canvases fails a few seconds in). Times (MiniMax-H3-Turbo): 2 s at 832x480, 4 steps: ~7 min; 7 s at 832x480, 4 steps: ~12 min; 5 s at 1344x768, 4 steps: ~32 min. Renders are cut off after 1 hour (see Known quirks) tested

Inference: Anthropic- and Ollama-compatible

method path what mark
POST /v1/messages Anthropic Messages API (on anthropic.api.tiiny or the IP). With a thinking model it thinks by default (a thinking block before the text, which can use up max_tokens); send "thinking": {"type": "disabled"} for a plain answer tested
GET /v1/models model list in Anthropic format probed
POST /api/chat Ollama chat ("stream": false, "think": false work) tested
GET /api/tags Ollama model list (all installed models) tested

Model containers (direct, Wi-Fi/LAN only, no auth)

Port from GET /api/v1/models/running; changes on every load. Blocked over USB.

model call
Qwen3-TTS (Base) POST :port/v1/synthesize {"text", "config": {"ref_audio", "ref_text", "language", "instruct"}} → audio_data (24 kHz WAV)
Foundation-1 POST :port/v1/generate/wav {"prompt", "config": {seconds_total, steps, cfg_scale, seed}} → WAV; has /openapi.json
SongGeneration-v2 POST :port/v1/generate {"config": {caption, lyrics, generation_type: mixed|bgm|vocal|separate, seconds_total, seed, steps, temperature, top_k, top_p}} → audio_data (48 kHz); only config at the top level
Hunyuan3D-2.1 POST :port/v1/tasks (multipart, as /v1/3d/tasks); GET :port/v1/tasks/{id}
GLM-OCR POST :port/v1/chat/completions with the image and the text Text Recognition: (100% on the test image, 0.1.36; the only working route for GLM-OCR)

What runs each kind of model

Read from each model's own port with plain GETs (/, /openapi.json, /health, /v1/models, /props) and the Server header; tested 2026-11-01, TiinyOS 0.1.38, one model per kind. Every model runs in its own container, started on load; the gateway on port 80 routes to it.

models what answers on the model's port
chat (Qwen3.5-9B) llama.cpp server (Server: llama.cpp, /props, owned_by: llamacpp); a GGUF file (model.q8_0.gguf for Qwen3.5-9B), 1 slot, 128K context
embeddings, rerankers, ASR (Qwen3-ASR, Whisper; both list a qwen3_forced_aligner too), PP-OCRv6 owned_by: "mirage" in /v1/models
video (MiniMax-H3-Turbo) "Mirage video server (minimax_h3)"; /v1/models lists canvases, step_options, buckets, dynamic_geometry: false, progressive_resolution: true
image (Z-Image-Turbo, FLUX.2-klein-4B) an unnamed server; /v1/models gives type: image_generation, context_length: 512
TTS (Qwen3-TTS, VibeVoice, Supertonic) one shared TTS server: /v1/synthesize, /v1/synthesize/stream, /v1/audio/speech, /v1/capabilities; Qwen3-TTS reports itself as …-Awq (AWQ-quantized)
music (Foundation-1, ACE-Step) Python FastAPI (Server: uvicorn): "Text-to-Music Server" 1.0.0 (ACE-Step: "using a configurable TTM model")
3D (Hunyuan3D-2.1, TRELLIS.2) FastAPI "Image-to-3D Server" 2.0.0: stage tasks with /v1/tasks/{id}/pause, /resume, /cancel, /artifacts, /v1/version
GLM-OCR FastAPI "OpenAI Compatible Server" 1.0.0 (owned_by: system)

Hardware (GET /api/v1/sys/device_info): CPU ARMv9.2, GPU G720, dnpu "160 BF16 TOPs", peak_int8 "190 TOPS (30 TOPS on SoC, 160 TOPS on dNPU)", ram "80 GB (32 GB on SoC, 48 GB on dNPU)". So there are two AI processors and two memory pools: the main chip's NPU with 32 GB, and a separate NPU (the "dNPU") with its own 48 GB.

Inferred, not confirmed by Tiiny:

  • "Mirage" looks like Tiiny's own engine for everything that isn't llama.cpp (it names itself only in the video server and in owned_by).
  • The main chip matches the CIX P1 (12-core Armv9.2, Mali-G720, 30 TOPS NPU), as third-party write-ups suggest. The dNPU vendor isn't public; write-ups disagree.
  • "No exported bucket fits" plus dynamic_geometry: false and a fixed list of canvases suggest the video model is compiled ahead of time for a fixed set of sizes and lengths, as NPU toolchains generally require. If so, other sizes or lengths over 7 s would need Tiiny to export new versions of the model; nothing on the device can add them. Image models' "verified sizes" may be the same idea.
  • Tiiny credits PowerInfer (its open-source CPU/GPU inference engine, github.com/Tiiny-AI/PowerInfer) as core tech; nothing on the API names it.

Knowledge base / vault (/kb)

Full list (54 endpoints, from the device's own spec): endpoints/kb.md. The vault service calls itself "SimpleMem Knowledge Base Service" 2.0.0.

Before you start: files are only indexed while an embedding model is loaded (e.g. Qwen/Qwen3-Embedding-0.6B, ~1% NPU), and a normal search (and the nightly summaries) want a chat model loaded too; without one TiinyOS shows "model not loaded" alerts and searches stall. fast_mode: true skips the chat model. The docs send Host: kb.tiiny.local; it isn't needed on the IP. Same Authorization: Bearer <API key> as everything else.

Files: upload → index → search → rename → download → delete (tested 2026-10-25)

step call body real response (trimmed)
upload POST /kb/upload multipart file (optional finalize_now, source_id, timestamp) {"success": true, "source_id": "file-8cb17251b7f4", "filename": "…", "chunks": 1, "vectorized": false, "finalized": false} (0.1 s)
list GET /kb/sources?limit=500 — {"count", "total", "limit", "offset", "items": [{"source_id", "filename", "source_type", "chunk_count", "finalized", "vectorize_status", "dream_status", …}]}
index POST /kb/finalize {"source_id": "file-…"} {"success": true, "generated_entries": 1} (0.9 s)
search POST /kb/retrieve {"question": "…", "fast_mode": false, "include_memory_context": false} {"success", "query", "count", "results": [{"entry_id", "lossless_restatement": "Source file: …\n\n<text>", "keywords", …}]} (26 s with no chat model loaded)
rename PATCH /kb/sources/{source_id} {"filename": "new name.txt"} {"success": true, "item": {…}}
download GET /kb/files/{source_id} — the original file, byte for byte
delete POST /kb/delete-memory {"source_id": "file-…"} {"success": true, "deleted_entries": 1, "removed_chunks": 1, "deleted_source": true}; gone from search too

include_memory_context defaults to true: the search then also pulls in your personal memory (profile, facts). Set it to false to search only files. GET /kb/retrieve?question=… works too, and POST /kb/query takes the same body.

KEY=your-api-key; TIINY=http://192.168.1.50
curl -H "Authorization: Bearer $KEY" -F "file=@notes.txt" $TIINY/kb/upload
curl -H "Authorization: Bearer $KEY" "$TIINY/kb/sources?limit=500"
curl -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{"source_id":"file-…"}' $TIINY/kb/finalize
curl -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{"question":"…","include_memory_context":false}' $TIINY/kb/retrieve

Conversations, daily summaries and memory (spec; read-only parts checked)

TiinyOS chats are stored in the vault as conversations and summarised ("dreamed") into memory entries by a background worker, nightly 23:00–05:00 by default (/kb/memory/schedule). All of it can be read and managed:

method path what
POST /kb/upload_conversation add conversation text: text, time (/stats for counts)
POST /kb/add add a single memory: speaker, content, timestamp, source_id, auto_finalize
POST /kb/finalize_conversation summarise pending conversation text now
GET /kb/sources/conversations, /kb/sources/files conversation sources / file sources only
GET / DELETE /kb/conversation_days, /kb/conversation_days/{day} stored conversations per day
GET / PUT /kb/memory/schedule the nightly window (nightly_start_hour* …); GET also shows the summary worker's state (tested)
GET / PUT /kb/memory/model which model writes the summaries (model_id); GET → {"model_id": "", "automatic_summary_enabled": true, "running_models": []} (tested)
POST /kb/memory/process run memory processing now
GET / POST / PATCH / DELETE /kb/memory/facts, /kb/memory/facts/{fact_id} extracted facts (fact_type, subject, predicate, object, …), filterable (status, fact_type, subject, q)
GET / POST /kb/memory/proposals, /{id}/approve, /{id}/reject facts waiting for approval
POST /kb/context build the memory context the assistant uses: query*, include_user_context, include_facts, include_vector_memories, max_tokens
GET / PATCH /kb/user-context, /kb/user-context/profile/{field}, /kb/user-context/versions?limit=1 your profile (occupation, preferences, more_about_you)

How the memory pipeline behaves (read from the spec and the live data shapes, 2026-10-25; not stress-tested):

  • Writers: anything with the API key — files (/kb/upload), single memories with a free-form speaker (/kb/add), conversation text (/kb/upload_conversation, which TiinyOS chats feed as "conversation days"). There's no per-agent permission model in the API.
  • "Dreaming": every source has dream_status, dream_attempt_count, dream_deferred_until; conversation days have has_summary, summary_memory_worthy, summary_consumed, summary_refinement_status (queued/success/skipped). A conversation_summary worker runs in the nightly window (default 23:00–05:00, /kb/memory/schedule) with the model from /kb/memory/model (empty model_id = automatic summaries off) and extracts facts (subject/predicate/object, confidence, evidence, last_seen_at, created_by, e.g. curation_auto_approve). Riskier ones become proposals (risk_level) waiting for approve/reject.
  • Eviction: none found — no TTL, size cap or retention setting. Facts are replaced by newer facts (supersedes_fact_id, status other than active) rather than aged out; removal is explicit (/kb/delete-memory, DELETE /kb/conversation_days/{day}, DELETE /kb/memory/facts/{id}). The day delete's response reports deleted_facts and deleted_proposals, so it is meant to remove what was summarised from that day too (not yet tested).
  • Memory scope (added in the 2026-10 update, vault 59 endpoints): GET/PUT /kb/memory/scope, body {"scope": "all" | "tiinyos"}. The spec describes it as the summary scope: tiinyos "summarizes TiinyOS client chats only", all "summarizes every LLM output". The GET also reports supported_scopes and origin_tagging (true here). Tested 2026-10-27 with all: on an idle device the day's /kb/conversation_days/{day} entry didn't change for 9 minutes; one chat sent straight to the gateway's /v1/chat/completions (not TiinyOS) changed its content_updated_at 14 s later. So any client's chats through the gateway (other apps, scripts) are captured into that day's conversation for the daily summary. The captured text isn't readable through the API (journal_text stays empty while queued; /kb/files/{source_id} 404s for the day file) and TiinyOS doesn't show it. Without a summary model nothing becomes memory, but the days stay queued. Imports are batched (whole minutes). Switching to tiinyos did not stop the capture: a gateway chat sent afterwards updated the day the same way (2026-10-27). See PRIVACY.md for what you can delete.

On-device assistant: connectors and MCP

From Tiiny's docs (docs), not tested here.

method path what
GET /v1/{gmail|outlook|calendar|outlook/calendar}/status, /auth-url OAuth email/calendar connectors
DELETE /v1/{connector}/connection disconnect
POST /v1/telegram/check-with-token, /v1/discord/check-with-token, /v2/x/check-with-token verify bot/API tokens
PATCH /v1/config/{telegram|discord|email|x|whatsapp}, /v1/config/channels/{…} save connector settings
POST /v1/email/imap/verify check IMAP settings
POST / GET /v1/whatsapp/qr, /v1/whatsapp/bridge/status, /bridge/logout WhatsApp bridge
GET / POST / DELETE /v1/mcp/servers, /v1/mcp/servers/import-json, /v1/mcp/servers/{id}, /{id}/check custom MCP servers
GET / POST /v1/mcp/tools, /v1/mcp/tools/call MCP tools (app)
GET /api/v1/chat/history/, /sessions; DELETE /{session_id} TiinyOS chat history (spec)

Account and pairing

/api/v1/account/* (auth, mail codes, passwords, recovery keys, device name, feedback), /api/v1/connect (pair a client), /api/v1/auth/init, /api/v1/auth/login: see endpoints/gateway.md (spec) and the TiinyOS docs.

Apps

GET /api/v1/apps, /api/v1/apps/icons; GET /api/v1/webapp/, /running, POST /start, /stop (spec); /api/services, /api/services/{id}, /download/{id}/status, /updates/check, /resource-usage/ws (docs/app): the app farm.

Known quirks (checked on 0.1.35 and 0.1.36)

  • One request at a time; images are one per request (n > 1 fails).
  • A model that just reported running may answer its first request with xh2a backend fail (seen with Qwen-Image right after a load): wait ~10 s and retry.
  • Since 0.1.36, /v1/models reports thinking as an object (supported, toggleable, enabled_by_default, levels, default_level) instead of a boolean.
  • After a restart every call answers 502 until tiiny login runs or the TiinyOS app is opened (see Auth above; 0.1.38).
  • Over USB only port 80 is open (still true on 0.1.39; wired Ethernet through a USB Ethernet adapter has no such limit): no model ports, so no GLM-OCR and no SongGeneration direct route (separate tracks, exact-length songs). The USB link's address can land in Docker's 172.17.x.x (move Docker's bip if a container can't reach it).
  • Hunyuan3D background stage fails (BriefNet missing); SongGeneration style reference fails.
  • Plain /v1/music/generate fails for SongGeneration ("Session not found"); /resume ignores seconds_total.
  • 0.1.36 to 0.1.38: GLM-OCR isn't reachable through the API at all (/v1/ocr 404, chat 400); use its own port over Wi-Fi/LAN, or PP-OCRv6 through /v1/ocr.
  • 0.1.36: power off (/api/v1/sys/shutdown/) answers ok but the device stays on. Fixed in 0.1.37.
  • With the NPU nearly full, unload_candidate can say a model fits (e.g. 55% needed, 56% free) while the load never starts. Unload other models first (/api/v1/models/unload_all), then launch.
  • Streaming chat: the last chunk before [DONE] can carry usage with an empty choices list; don't index choices[0] blindly.
  • Thinking counts toward max_tokens. With a thinking model (e.g. Qwen3.6) and a small limit like 256, the reply can come back with empty content, finish_reason: "length" and the budget spent in reasoning_content: it looks like the model refused. Turn thinking off for the request ("chat_template_kwargs": {"enable_thinking": false}; Anthropic style: "thinking": {"type": "disabled"}) or raise max_tokens to 1-2K.
  • Video renders over 1 hour fail but keep running (0.1.38, 2026-11-01, MiniMax-H3-Turbo, 1344x768, 7 s, 8 steps, which would need about 2 hours): after exactly 60 minutes GET /v1/videos/{id} reports status: failed, error.code: GENERATION_TIMEOUT ("video generation exceeded the gateway time limit"), yet the NPU stays at 100%: the render goes on in the background. DELETE /v1/videos/{id} answers deleted: true but doesn't stop it. Stopping the video model (POST /api/v1/models/{id}/stop) does, slowly (no answer within 60 s), and afterwards every model was unloaded and /api/v1/npu/status showed no NPU readings for a few minutes. The next launch of the video model (hours later) answered 409 cleanup_failed / physical_stop_failed ("runtime cleanup failed and must be retried"); launching again worked (loaded in 5 s). Keep renders well under an hour.

Test runs: USB vs Wi-Fi (2026-10-30, TiinyOS 0.1.38, device services 0.1.30)

Same device, same tools/api_test.py run twice: once over the USB cable, once over Wi-Fi. Everything behaves the same on both links except the models' own ports, which the USB link still blocks.

check USB cable Wi-Fi
chat (Qwen3.6-35B-A3B): plain, streaming, tool calls, vision, Anthropic /v1/messages, Ollama /api/chat PASS PASS
embeddings (Qwen3-Embedding-0.6B) PASS PASS
OCR: PP-OCRv6 Small / Medium via /v1/ocr (100% on the test image) PASS PASS
OCR: GLM-OCR via /v1/ocr FAIL (404) FAIL (404)
OCR: GLM-OCR via /v1/chat/completions FAIL (400 "does not support chat") FAIL (400)
OCR: GLM-OCR on its own port not reachable PASS (100%)
image (Z-Image-Turbo, 512x512) PASS PASS
voice clone (Qwen3-TTS Base) PASS PASS
transcription (Qwen3-ASR, 98% match) PASS PASS
music loop (Foundation-1) PASS PASS
3D shape (Hunyuan3D-2.1, ~7 min) PASS PASS
power off (/api/v1/sys/shutdown/) PASS (tested by hand, see below) PASS (tested by hand, see below)

Re-run 2026-11-02 after the TiinyOS desktop app updated to 1.0.3: the device itself was unchanged (TiinyOS 0.1.38, services 0.1.30), and so was everything here: the same results over Wi-Fi, both API specs unchanged (gateway 37 endpoints, kb 59), and video still capped at 7 s with the same four sizes.

Re-run 2026-11-04 (Tiiny CLI updated to v0.0.5 the same day, device unchanged): USB and Wi-Fi identical again, same results as above.

Wired Ethernet, 2026-11-09 (TiinyOS 0.1.39): a USB-C 2.5G Ethernet adapter plugged into the Tiiny's other USB-C port (not the "to PC" port), cabled to a PC that shares its internet connection, gives the Tiiny an ordinary LAN address. Over it the API behaves like Wi-Fi, not like the USB cable: every check passed (20 of 22, the two failures being the GLM-OCR API routes, as on every link), and the models' own ports are reachable (GLM-OCR direct: 100%), which the USB cable blocks. The checks added since 11-04 (preset voice, the three Jev models) pass on it too. Good option when Wi-Fi is unreliable: one cable for internet (downloads, updates) and full API access.

Power off on 0.1.38 was tested by hand later the same day: the device turned off, and on power-up it came back on 0.1.38 (no rollback). 0.1.37 (2026-10-29) gave the same results, with power off working on both links (off in ~9 s). 0.1.36 (2026-10-28) was the same except power off, which answered ok but left the device on. Compare your own runs: python tools/api_test.py --via usb, then --via wifi, then python tools/api_compare.py snapshots/api-test-*/report.json.

Chat speed (measured 2026-11-01, TiinyOS 0.1.38)

One model loaded at a time, one request at a time, over Wi-Fi, thinking off, prompt caching off (cache_prompt: false), two runs each (they agreed within 1-2%). "Writing" = 400-token answers; "reading" = 2K and 6K-token prompts. Numbers from llama.cpp's own timings in the reply (predicted_per_second, prompt_per_second).

model kind writing (tok/s) reading a prompt (tok/s) a 6K-token prompt takes
Qwen3.6-35B-A3B MoE, ~3B active, 17.4 GB 27.4 ~790 ~8 s
gpt-oss-20b MoE, ~3.6B active, 10.3 GB 24.1 ~1,300 ~5 s
Qwen3.5-9B dense, 6.2 GB 18.4 1,200-1,420 ~4.5 s
Qwen3-4B-Instruct-2507 dense, 2.3 GB 18.1 1,650-1,950 ~4 s
Qwen3.8-27B (11-01 build) dense, 15.4 GB 6.2 ~500 ~13 s
Qwen3.8-27B (11-06 build, 0.1.39) dense, 15.9 GB 12.6 350-500 ~13 s

Qwen3.8-27B got twice as fast: the Model Store updated it (15.4 → 15.9 GB) and it now writes ~12.6 tok/s at short context. Reading every weight for each token can't explain that (it would need ~200 GB/s), so inferred: the new build predicts several tokens per step (speculative/multi-token decoding). The bandwidth note below is from the 11-01 build.

What it means:

  • The MoE models are the fast ones. Qwen3.6-35B-A3B writes 4x faster than the dense 27B and is the best speed/quality pick for chat. Long prompts cost a few seconds before the first word.
  • Small dense models stop at ~18 tok/s. The 4B is no faster than the 9B, so something other than model size sets that limit (about 55 ms per token). Inferred: a fixed per-token cost, for example handing work between the CPU and the NPU.
  • Effective bandwidth: the dense 27B reads all 15.4 GB of weights for every token, so 6.24 tok/s means the device moves about 96 GB/s while running it. That's the device as a whole: the Tiiny has a CPU with 32 GB and a separate NPU with 48 GB (device_info), and Tiiny says its runtime splits work between them, so this is the combined figure, not one memory's speed. Treat it as the real-world number to plan with; the hardware's peaks are higher.
  • Rough rule for a big dense model: writing speed ≈ 96 GB/s ÷ its size in GB (a 30 GB dense model would write ~3 tok/s), but small dense models don't go above ~18 tok/s. MoE models come in at about half of what the same rule gives for their active part (35B-A3B: ~1.5 GB active → rule says ~64, measured 27). A rough estimate, not a law.

Long prompts (2026-11-06, TiinyOS 0.1.39)

How speed changes with prompt length: one request per size, a fresh prompt each time, prompt caching off, a 128-token answer. Run it yourself: python tools/speed_test.py chat --model <id> (sizes with --ctx 1k,8k,32k).

context Qwen3.6-35B-A3B: reading / writing first word after Qwen3.8-27B: reading / writing first word after
1K 723 / 27.0 tok/s 1.6 s 500 / 12.6 tok/s 2.3 s
4K 728 / 26.0 6.3 s 351 / 12.6 13 s
16K 710 / 24.8 26 s 440 / 14.2 42 s
32K 667 / 22.7 57 s 416 / 11.5 91 s
64K 600 / 19.4 2 min 8 s 353 / 8.9 3 min 38 s
128K 498 / 15.5 5 min 12 s 264 / 8.3 (one run of two; the other failed: 504) 9 min 48 s
  • Long prompts mostly cost waiting time. A full 128K prompt is read for ~5 minutes before the first word (35B-A3B); writing slows down less (27 → 15.5 tok/s). In a chat that keeps growing, the earlier part is cached, so each turn only pays for the new text (the sweep turns caching off on purpose).
  • A chat request has about 10 minutes, then the API gives up with 504 "Upstream model server timed out." Qwen3.8-27B at 128K sits right on that edge: one run finished in 599.0 s (first word after 9 min 48 s), an earlier one failed with the 504. Keep long prompts on the MoE model (5 min 19 s at 128K), or stream so you see the answer as it comes; whether streaming avoids the cut-off isn't tested yet.

Tiiny CLI vs the direct API (CLI v0.0.5, 2026-11-04, TiinyOS 0.1.38)

The same tests run through the tiiny CLI and straight against the API, over USB, same models, same inputs, scored the same way. Run it yourself: python tools/cli_compare.py (needs the CLI installed and signed in).

feature CLI API
chat (tiiny run -m … -p …) PASS PASS
chat with an image (tiiny run -m <chat model> -f image.png) FAIL: the image isn't sent PASS
embeddings (tiiny run embed) PASS PASS
OCR, PP-OCRv6 Small/Medium (tiiny run ocr) PASS (100%) PASS (100%)
OCR, GLM-OCR FAIL (HTTP 40x), same as the API FAIL (404/400)
image (tiiny run image) PASS PASS
transcription (tiiny run asr) PASS (100%) PASS (98%)
writing speed, Qwen3.6-35B-A3B (tiiny bench) 30.6 tok/s 30.7 tok/s

What to know about the CLI (v0.0.5):

  • -f with a chat model doesn't attach the image. Asked to read the test image, the model answers that no image was provided, and it makes up the invoice number every time. Send images through the API (image_url) instead.
  • tiiny load doesn't return when run from a script (no terminal attached), even with -y: the model is up in seconds, but the command keeps waiting. In scripts, load through the API (POST /api/v1/models/{id}/launch) or wrap the CLI in a timeout. Not checked in an interactive terminal. tiiny download does the same (seen 2026-11-06, CLI v0.0.5): the download completes (the API shows status: downloaded, 100%) but the command never returns. In scripts, start it, watch GET /api/v1/models/?including_downloading=true for downloaded, then stop the CLI.
  • Output formats: file results print as File: <path> Content: <the API's JSON> (OCR); transcription prints File "<path>" …: <text>. --json doesn't change the OCR output.
  • --device-address takes a bare IP only (no port); the CLI always uses port 80. Without it, the CLI uses the address it discovered, which can be an IPv6 address on Wi-Fi.
  • Speeds match the API: the CLI adds little more than its own start-up time (~0.3 s per call).

Downloading models over the USB cable

Models can't be downloaded with the Tiiny's Wi-Fi off (tested 2026-11-04, CLI v0.0.5, TiinyOS 0.1.38, over USB from a PC with internet): tiiny download <model> refuses at once with "Tiiny needs a network connection. Use 'tiiny wifi connect' to scan available networks", and nothing is queued. The Tiiny downloads models from the internet itself, and the USB link doesn't give it a way out. Keep Wi-Fi on (any network with internet) for downloads; the USB cable is fine for everything else. Check it yourself with python tools/usb_download_test.py (turns Wi-Fi off, tries a small download, turns Wi-Fi back on).

Text to speech: which voice to send (2026-11-05, TiinyOS 0.1.38)

Updated 2026-11-06 for TiinyOS 0.1.39. Every model's full voice list: VOICES.md.

POST /v1/audio/speech answers 500 "The upstream TTS model failed." when the request doesn't suit the model, most often because voice isn't one of the model's own speakers. OpenAI clients (Hermes, Open WebUI, SDK examples) send voice: "alloy" by default, which most of these models reject. Use the full model id as model (tts-1 or a short name gives 404 "not loaded").

Simplest fix: leave voice out. On 0.1.39 a request with no voice works on 7 of the 9 voice models (each uses its default voice); only Qwen3-TTS Base (clone only) and Qwen3-TTS VoiceDesign (needs instruct) refuse it.

model to pick a voice, send voice: "alloy" no voice
MOSS-TTS-Nano-100M voice: "default" (its only speaker) 500 works
Qwen3-TTS CustomVoice voice: aiden, dylan, eric, ono_anna, ryan, serena, sohee, uncle_fu, vivian works (falls back) works
Qwen3-TTS VoiceDesign instruct: a description, e.g. "A calm, warm female voice." 500 500
Qwen3-TTS Base clone only: ref_audio + ref_text 500 500
VibeVoice-1.5B clone: ref_audio + ref_text (no presets) 500 works
VibeVoice-Realtime-0.5B voice: one of 61, by language: en-Emma_woman, en-Carter_man, … (de-…, fr-…) 500 works
Supertonic-3 voice: F1-F5, M1-M5 500 works
Kokoro-82M voice: one of 42, e.g. af_heart, am_michael, bf_emma (note: af_alloy exists, plain alloy doesn't) 500 works
OmniVoice voice design: short instruct such as "female" works; a sentence like "A calm, warm female voice." gave 500 500 works
  • Supertonic-3 was broken through the API on 0.1.38 (the API passed its full id, which its server rejected); fixed in 0.1.39.
  • VibeVoice-Realtime can fail the first request after it loads, and fails short text with a speaker of another language: retry once, and match the speaker to the text's language.
  • response_format: "mp3" works wherever wav works. Not checked yet: VoxCPM2 (not downloaded on the test device).
  • List the voices on your own Tiiny and check each model: python tools/tts_voices.py (--md docs/VOICES.md regenerates the voice page; needs a Wi-Fi/LAN address, since the voice lists come from each model's own port). The harness checks one preset voice: api_test.py audio-tts-preset.

Classify text: Jev / System One (2026-11-06, TiinyOS 0.1.39)

TiinyOS 0.1.39 adds Text Classification models that speak TypeSafe's Jev / "System One" API: POST /v1/systemone (tested, through port 80, over USB and Wi-Fi). Instead of writing an answer, the model reads a text (state) and answers typed questions with probabilities, in one read per question and no text generation. Recipe: RECIPES.md. Format (from TypeSafe's API docs, and what the Tiiny returns):

  • Request: model (full id), state (string, object or array), questions: an object of named questions, up to 64:
    • {"type": "noul", "instructions": "...", "criteria": {"true": "...", "false": "..."}} (yes/no; criteria optional)
    • {"type": "choice", "instructions": "...", "criteria": {"name": "description", ...}} (pick one of 1-255)
    • {"type": "score", "instructions": "...", "criteria": ["level 0", "level 1", ...]} (ordered, 1-10 levels)
  • Response: answers keyed by your question names: noul (probability of yes), choice + confidence + probabilities, or score (expected level) + legend + probabilities; plus usage.input_tokens.
model params NPU context limit
vllm-sr/d3-lite 0.8B 7% 16K
PelaAI/KnowLine-4B-Gen5.5 4B 17% 256K
vllm-sr/d3-edge 0.6B 21% 16K
vllm-sr/d3-nano 2B 25% 16K
vllm-sr/d3-mini 4B 26% 16K
vllm-sr/d3-flash 9B 34% 16K
PelaAI/KnowLine-35B-A3B-Gen1.5 35B 48% 256K

Tested: d3-edge, d3-lite and KnowLine-4B (check: python tools/api_test.py classify). d3-lite itself runs on llama.cpp.

Speed (python tools/speed_test.py jev; a fresh text for every request, yes/no questions, whole-request time):

text size d3-lite: 1 / 8 / 64 questions KnowLine-4B: 1 / 8 / 64 questions
1K tokens 0.4 s / 2.4 s / 18 s 1.4 s / 11 s / 34 s
4K 1.9 s / 14 s / 114 s 2.4 s / 5.4 s / 29 s
16K (over its limit) 9.3 s / 13 s / 43 s
64K - 55 s / 61 s / 113 s
128K - 2 min 37 s / 2 min 48 s / fails: 504

d3-edge (0.6B, 16K limit): 1K text 0.2 s / 1.0 s / 7.4 s, 4K text 1.6 s / 12.6 s / 99 s for 1 / 8 / 64 questions: about twice d3-lite's speed on short texts, slightly faster on 4K.

  • d3 models read the whole text again for every question (~2,500-4,200 tok/s): time grows with text size × number of questions. Fast for short texts and a few questions.
  • KnowLine reuses the text across questions once it's longer than ~3,000 tokens: the first read is the cost (~1,000-2,000 tok/s), extra questions are nearly free (64 questions on a 64K text: 113 s vs 55 s for one). Below ~3,000 tokens it deliberately reads the text twice for accuracy (its store description says so; usage shows double the tokens), and each question costs a full read.
  • usage.input_tokens counts the text once per question (64 questions × 4K = ~300K), even when the model reuses it.
  • A Jev request has 4 minutes: KnowLine-4B with a 128K text and 64 questions answered 504 "The decision model timed out." after exactly 240 s. Split big batches (8 questions on a 128K text took 2 min 48 s).