Lumo API
Unofficial development tracker for the Proton Lumo API. Documents what works, what doesn't, and tested Python examples updated as the API evolves.
reasoning_effort performance matrix (tested with temp 0.1-0.3):| Value | Avg Time | Avg Tokens | Status |
|---|---|---|---|
"none" | ~9s | ~528 | ✅ Fastest |
"medium" | ~17s | ~1,053 | 🐌 Slowest |
"high" | ~10.5s | ~940 | ⚡ Moderate |
"max" | ~9.6s | ~894 | ⚡ Moderate |
none, medium, high, max) accepted and working.provider, finish_reason, usage.prompt_tokens_details.cached_tokens, and remaining_limits (lite/max/images)lumo-lite (fast) and lumo-max (reasoning); auto routes optimallyweather, stock, web_search, cryptocurrency, proton_infogenerate_image, describe_image, edit_image, web_search, web_extractremaining_limits.lite, remaining_limits.max, remaining_limits.images"provider": "lumo", "finish_reason": "stop", "usage": { "completion_tokens": 55, "prompt_tokens": 2136, "total_tokens": 2191, "prompt_tokens_details": { "cached_tokens": 1800 }, "remaining_limits": { "lite": 19, "max": 20, "images": 20 }, "applied_limit_category": "lite"
Feature Status
Tested directly against the live API. The Lumo API launched recently and the backend does not yet expose all features described in the frontend documentation config.
lumo-lite, lumo-max) with vision capabilities."none" (fast, no thinking) and "high" (slow, ~2-3x latency with thinking). Invalid values like "low", "medium" return HTTP 422.provider, finish_reason, and usage.prompt_tokens_details.cached_tokens.OpenCode Integration
Drop this config into your project's opencode.json to use the Lumo API directly from OpenCode. Lumo is auto detected.
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"lumo": {
"npm": "@ai-sdk/openai-compatible",
"name": "Lumo",
"options": {
"baseURL": "https://api.carlostkd.ch/v1"
},
"models": {
"auto": {
"name": "Lumo Auto"
}
}
}
}
}
const http = require('http'); const https = require('https'); const crypto = require('crypto'); const { execSync } = require('child_process'); const fs = require('fs'); const PORT = process.env.PROXY_PORT || 3001; const LUMO_API_URL = 'https://lumo.proton.me/api/ai/v1'; const LUMO_PUBLIC_KEY = `-----BEGIN PGP PUBLIC KEY BLOCK----- xjMEaA9k7RYJKwYBBAHaRw8BAQdABaPA24xROahXs66iuekwPmdOpJbPE1a8A69r siWP8rfNL1Byb3RvbiBMdW1vIChQcm9kIEtleSAwMDAyKSA8c3VwcG9ydEBwcm90 b24ubWU+wpkEExYKAEEWIQTwMqEWnd/47aco5ZqadMPvYVFKKgUCaA9k7QIbAwUJ B4TOAAULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRCadMPvYVFKKqiVAQD7 JNeudEXTaNMoQMkYjcutNwNAalwbLr5qe6N5rPogDQD/bA5KBWmDlvxVz7If6SBS 7Xzcvk8VMHYkBLKfh+bfUQzOOARoD2TtEgorBgEEAZdVAQUBAQdAnBIJoFt6Pxnp RAJMHwhdCXaE+lwQFbKgwb6LCUFWvHYDAQgHwn4EGBYKACYWIQTwMqEWnd/47aco 5ZqadMPvYVFKKkuRAQChUthLyAccUD6UrJkroc6exHIMSR5Vlk4d4L8OeFUWWAEA 3ugyE/b/pSQ4WO+fiTkHN2ZeKlyjdZMbxO6yWPA5uQk= =h/mc -----END PGP PUBLIC KEY BLOCK-----`; const LUMO_FINGERPRINT = 'F032A1169DDFF8EDA728E59A9A74C3EF61514A2A'; function generateUUID() { return crypto.randomUUID(); } function generateRandomBytes(length) { return crypto.randomBytes(length); } function u2lInit() { const key = generateRandomBytes(32); const requestId = generateUUID(); return { key, requestId }; } function u2lEncrypt(plaintext, ctx) { const iv = generateRandomBytes(12); const aad = `lumo.request.${ctx.requestId}.turn`; const cipher = crypto.createCipheriv('aes-256-gcm', ctx.key, iv); cipher.setAAD(Buffer.from(aad)); let encrypted = cipher.update(plaintext, 'utf8', 'base64'); encrypted += cipher.final('base64'); const tag = cipher.getAuthTag(); const result = Buffer.concat([iv, Buffer.from(encrypted, 'base64'), tag]); return result.toString('base64'); } function u2lDecrypt(ciphertextBase64, ctx) { const raw = Buffer.from(ciphertextBase64, 'base64'); const iv = raw.slice(0, 12); const tag = raw.slice(-16); const encrypted = raw.slice(12, -16); const aad = `lumo.response.${ctx.requestId}.chunk`; const decipher = crypto.createDecipheriv('aes-256-gcm', ctx.key, iv); decipher.setAAD(Buffer.from(aad)); decipher.setAuthTag(tag); let decrypted = decipher.update(encrypted); decrypted = Buffer.concat([decrypted, decipher.final()]); return decrypted.toString('utf8'); } function gpgEncryptKey(rawKey, fingerprint = LUMO_FINGERPRINT) { const gnupgHome = process.env.GNUPGHOME || '/tmp/gpg-home-' + process.pid; try { execSync(`mkdir -p ${gnupgHome} && chmod 700 ${gnupgHome}`); const keyFile = `/tmp/lumo_pub_${process.pid}.asc`; const encryptedFile = `/tmp/encrypted_key_${process.pid}.bin`; const plainKeyFile = `/tmp/plain_key_${process.pid}.bin`; fs.writeFileSync(keyFile, LUMO_PUBLIC_KEY); fs.writeFileSync(plainKeyFile, rawKey); try { execSync( `gpg --batch --yes --homedir ${gnupgHome} --import ${keyFile} 2>/dev/null` ); execSync( `gpg --batch --yes --homedir ${gnupgHome} --trust-model always --encrypt --recipient ${fingerprint} --output ${encryptedFile} ${plainKeyFile} 2>/dev/null` ); const encryptedData = fs.readFileSync(encryptedFile); return encryptedData.toString('base64'); } finally { try { fs.unlinkSync(keyFile); } catch {} try { fs.unlinkSync(encryptedFile); } catch {} try { fs.unlinkSync(plainKeyFile); } catch {} } } catch (err) { console.error('GPG encryption failed:', err.message); throw new Error('Failed to encrypt key with GPG'); } } function encryptMessage(message, ctx) { if (typeof message.content === 'string') { return { ...message, content: u2lEncrypt(message.content, ctx), encrypted: true }; } return message; } function encryptPayload(payload) { const ctx = u2lInit(); const encryptedMessages = payload.messages.map(msg => encryptMessage(msg, ctx)); const requestKey = gpgEncryptKey(ctx.key); return { ...payload, encrypted: true, lumo: { client_type: 'proxy', request_key: requestKey, request_id: ctx.requestId }, messages: encryptedMessages }; } // Server setup continues in the full file... // Run: node u2l-proxy.js // Then use baseURL: "http://localhost:3001/v1/chat/completions?encrypted=true"
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"lumo-encrypted": {
"npm": "@ai-sdk/openai-compatible",
"name": "Lumo (U2L Encrypted)",
"options": {
"baseURL": "http://localhost:3001/v1/chat/completions?encrypted=true",
"apiKey": "your_api_key"
},
"models": {
"auto": {
"name": "Lumo Max (Encrypted)"
}
}
}
},
"server": {
"port": 4096,
"hostname": "0.0.0.0"
}
}
- Install dependencies:
npm install @ai-sdk/openai-compatible - Start the proxy:
node u2l-proxy.js(runs on port 3001) - Ensure GPG is installed and accessible
- Configure OpenCode with the encrypted provider config above
- All messages will be encrypted client-side before reaching Lumo API
Available API Models
Three models are available on the chat completions endpoint. The auto model is recommended for general use as it routes to the best backend model for the task.
Supported Parameters
The endpoint is OpenAI chat completions compatible. Only parameters confirmed as working are listed here.
| Parameter | Type | Required | Description |
|---|---|---|---|
| model | string | required | Model ID. Use auto, lumo-lite, or lumo-max. |
| messages | array | required | Conversation history as {role, content} objects. Roles: system, user, assistant. |
| Create/List Keys | array | required | Its possible to do this I do not intend to disclose this method publicly |
| temperature | number | optional | Sampling temperature 0.0–2.0. Lower = more deterministic. Default: 1.0. |
| max_tokens | integer | optional | Maximum tokens in the completion. Defaults to model output limit. |
| stream | boolean | optional | Can be turned ON/OFF and is now required. |
| reasoning_effort | string | optional | Controls model thinking. Valid values: "none" (fast, no thinking) and "high" (slow, ~2-3x latency with visible thinking block). Invalid values like "low", "medium" return HTTP 422. Default: "high". |
Full Working Script
Production ready script with all confirmed working features. Switch models by commenting and uncommenting the MODEL lines. Extend MESSAGES for multi-turn conversations. Includes reasoning_effort, streaming, and all available tools.
import requests import json API_KEY = "your_api_key_here" # MODEL = "auto" # MODEL = "lumo-max" MODEL = "lumo-lite" TEMPERATURE = 0.7 MAX_TOKENS = 1024 # "none" = fast (no thinking), "high" = slow (with thinking) REASONING_EFFORT = "high" # Tools must be in OpenAI function format. Simple string names may not work. TOOLS = [ {"type": "function", "function": {"name": "weather", "description": "Get weather data", "parameters": {"type": "object", "properties": {}, "required": []}}}, {"type": "function", "function": {"name": "stock", "description": "Get stock price", "parameters": {"type": "object", "properties": {}, "required": []}}}, {"type": "function", "function": {"name": "web_search", "description": "Search the web", "parameters": {"type": "object", "properties": {}, "required": []}}}, {"type": "function", "function": {"name": "cryptocurrency", "description": "Get crypto prices", "parameters": {"type": "object", "properties": {}, "required": []}}}, {"type": "function", "function": {"name": "proton_info", "description": "Get Proton product info", "parameters": {"type": "object", "properties": {}, "required": []}}}, {"type": "function", "function": {"name": "generate_image", "description": "Generate an image", "parameters": {"type": "object", "properties": {}, "required": []}}}, {"type": "function", "function": {"name": "describe_image", "description": "Describe an image", "parameters": {"type": "object", "properties": {}, "required": []}}}, {"type": "function", "function": {"name": "edit_image", "description": "Edit an image", "parameters": {"type": "object", "properties": {}, "required": []}}}, {"type": "function", "function": {"name": "web_extract", "description": "Extract content from web page", "parameters": {"type": "object", "properties": {}, "required": []}}} ] SYSTEM_PROMPT = "You are a helpful assistant that gives concise and accurate answers." MESSAGES = [ {"role": "user", "content": "why is the sky blue?"}, ] url = "https://lumo-api.proton.me/api/ai/v1/chat/completions" headers = { "Authorization": "Bearer " + API_KEY, "Content-Type": "application/json", } payload = { "model": MODEL, "messages": [{"role": "system", "content": SYSTEM_PROMPT}] + MESSAGES, "temperature": TEMPERATURE, "max_tokens": MAX_TOKENS, "reasoning_effort": REASONING_EFFORT, "stream": True, "tools": TOOLS, } r = requests.post(url, headers=headers, json=payload, timeout=120) r.raise_for_status() chunks = [] for line in r.iter_lines(): if line and line.startswith(b"data:"): data = line[5:].decode('utf-8') if data == "[DONE]": break try: chunk = json.loads(data) choices = chunk.get("choices", []) if choices: delta = choices[0].get("delta", {}) if isinstance(delta, dict): content = delta.get("content") if content: chunks.append(content) except (json.JSONDecodeError, KeyError, TypeError): continue full_content = "".join(chunks) if "</think>" in full_content: full_content = full_content.split("</think>", 1)[1].strip() print(full_content)
import requests import json API_KEY = "your_api_key_here" url = "https://lumo-api.proton.me/api/ai/v1/models" headers = { "Authorization": "Bearer " + API_KEY, "Content-Type": "application/json", } r = requests.get(url, headers=headers, timeout=30) print("Status:", r.status_code) print("Response:", json.dumps(r.json(), indent=2))
Status: 200
Response: {
"object": "list",
"data": [
{
"object": "model",
"id": "lumo-lite",
"created": 1779890182,
"owned_by": "proton",
"capabilities": {
"completion_chat": true,
"function_calling": true,
"vision": true
},
"name": "Lumo Lite",
"description": "Fast, efficient model for everyday tasks.",
"max_context_length": 131072,
"aliases": [],
"deprecation": null,
"deprecation_replacement_model": null,
"default_model_temperature": 1,
"archived": false
},
{
"object": "model",
"id": "lumo-max",
"created": 1779890182,
"owned_by": "proton",
"capabilities": {
"completion_chat": true,
"function_calling": true,
"vision": true
},
"name": "Lumo Max",
"description": "More capable model for complex reasoning and longer context.",
"max_context_length": 131072,
"aliases": [],
"deprecation": null,
"deprecation_replacement_model": null,
"default_model_temperature": 1,
"archived": false
}
]
}
Redacted: I do not intend to disclose this method publicly prior to its official launch
Real-Time Tools via /api/ai/v1/chat/completions
The native endpoint uses a proprietary Prompt wrapper format and supports real-time tools fully. This is separate from the OpenAI-compatible endpoint tools passed to /chat/completions are still ignored there.
["web_search"] alone returns a "web search is OFF" response. Passing all five tools together activates whichever tool the model decides is appropriate for the query.Available Tools
| Tool | Standalone | Full Array | Notes |
|---|---|---|---|
weather | ✓ | ✓ | Returns temperature, humidity, wind, pressure |
stock | ✓ | ✓ | Real-time stock price by ticker symbol |
web_search | ✗ | ✓ | Returns full search results with sources |
web_extract | ✗ | ✓ | Extract content from web pages |
cryptocurrency | ✗ | ✓ | Real-time crypto prices across exchanges |
proton_info | ? | ✓ | Likely Proton product documentation |
generate_image | ✓ | ✓ | Generate images from text prompts |
describe_image | ✓ | ✓ | Analyze and describe uploaded images |
edit_image | ✓ | ✓ | Edit existing images with text instructions |
import requests, json ALL_TOOLS = ["proton_info", "web_search", "web_extract", "weather", "stock", "cryptocurrency", "generate_image", "describe_image", "edit_image"] payload = { "Prompt": { "type": "generation_request", "turns": [ {"role": "user", "content": "What is the weather in Geneva today?", "images": [], "encrypted": True} ], "options": {"tools": ALL_TOOLS}, "targets": ["message"] } } r = requests.post( "https://lumo.proton.me/api/ai/v1/chat/completions", headers={ "Authorization": "Bearer " + API_KEY, "Content-Type": "application/json", }, json=payload, timeout=60, stream=True ) message = "" for raw in r.iter_lines(): if not raw: continue line = raw.decode("utf-8").strip() if not line.startswith("data:"): continue try: chunk = json.loads(line[5:]) if chunk.get("type") == "token_data" and chunk.get("target") == "message": message += chunk.get("content", "") except: continue print(message.strip())
Stream Event Types
| type | target | Description |
|---|---|---|
queued | — | Request received by server |
ingesting | — | Processing started. Contains job_id and model_name |
token_data | reasoning | Internal thinking discard |
token_data | message | Response to display to user |
token_data | title | Auto-generated conversation title |
tool_call | — | Tool invoked. content is JSON with name and arguments |
tool_result | — | Tool response. content is JSON with real-time data |
Limitations & Not Yet Working
The API is in early access. The frontend documentation config is ahead of what is actually deployed on the backend.
encrypted field is accepted for both values but ignored, some progress here looks good."stream": false now works correctly and returns a clean application/json response instead of SSE no chunk reassembly needed. Setting "stream": true returns text/event-stream as expected. Previously this parameter was ignored and SSE was always forced.-1.0 to 20.0 all return 200 with no validation error. Responses are identical regardless of the value passed. No observable effect on output determinism or randomness.Lumo API Tracker — community tested · not affiliated with Proton · Live API