Documentation
API documentation.
It is OpenAI-compatible. Point your existing client at our base URL and everything you already do — streaming, tool calls, JSON mode, client.fine_tuning.jobs.create() — works unchanged.
Get started
Sign in at your workspace, create a key, and copy it — it is shown once and stored only as a hash, so we cannot show it to you again. New accounts start free, with no card.
from openai import OpenAI
client = OpenAI(
base_url="https://api.lightspacehq.com/v1",
api_key=os.environ["LIGHTSPACE_API_KEY"],
)
r = client.chat.completions.create(
model="qwen2.5-vl-7b",
messages=[{"role": "user", "content": [
{"type": "text", "text": "Invoice number, date and total as JSON."},
{"type": "image_url",
"image_url": {"url": "https://example.com/invoice.jpg"}},
]}],
)
print(r.choices[0].message.content)Base URL
https://api.lightspacehq.com/v1
Auth
Authorization: Bearer lsk_live_…
Keys carry the account. There is nothing else to configure and no quota to apply for.
Every endpoint
key means an API key.session means signed in to the workspace — those are account management, not things you call from your app.
| Method | Path | Auth | Does |
|---|---|---|---|
| POST | /v1/chat/completions | key | Text, code and vision. Streaming, tools, JSON mode. |
| POST | /v1/embeddings | key | Vectors for search and retrieval. |
| GET | /v1/models | key | The catalogue, your fine-tunes included. |
| GET | /v1/models/{id} | key | One model, with context length and supported parameters. |
| POST | /v1/files | key | Upload training data. Validated line by line on the way in. |
| GET | /v1/files | key | List your files. |
| GET | /v1/files/{id}/content | key | Download one back. |
| DELETE | /v1/files/{id} | key | Delete one. Refused while a job still references it. |
| POST | /v1/train | key | Data in, model out. One call. Add continue_from to add to a model you already have. |
| GET | /v1/train/{id} | key | How that run is going, in plain language. |
| POST | /v1/fine_tuning/jobs | key | Start a training run. |
| GET | /v1/fine_tuning/jobs | key | List runs, newest first. |
| GET | /v1/fine_tuning/jobs/{id} | key | One run and its current status. |
| POST | /v1/fine_tuning/jobs/{id}/cancel | key | Stop a run that has not finished. |
| GET | /v1/fine_tuning/jobs/{id}/events | key | What happened, oldest first. |
| GET | /v1/fine_tuning/jobs/{id}/checkpoints | key | Per-step loss and accuracy. |
| GET | /v1/fine_tuning/jobs/{id}/report | key | A verdict on whether the result is usable. |
| GET | /v1/fine_tuning/jobs/{id}/weights | key | Download the adapter. checkpoint=adapter|merged. |
| GET | /v1/learning | session | Captured answers and how many are reviewed. |
| POST | /v1/learning | session | Turn capture on or off. |
| POST | /v1/learning/{id} | session | Rate an answer, or write the correct one. |
| POST | /v1/learning/dataset | session | Turn reviewed answers into a training file. |
| GET | /v1/me | session | Your plan and what is left of today. |
| GET | /v1/keys | session | List keys. The secret is never returned twice. |
| POST | /v1/keys | session | Issue a key. The secret is shown once. |
| DELETE | /v1/keys/{id} | session | Revoke a key immediately. |
| GET | /v1/usage | session | 30 days of usage, by model. |
| POST | /v1/projects | session | Create a project. Projects archive, never delete. |
| POST | /v1/repos | session | Connect a GitHub repo to run on a devbox. |
Inference
One endpoint for text, code and vision. Send an image as a URL or adata: URI and ask about it. Streaming, tool calls and JSON mode behave exactly as they do against OpenAI, because the client is theirs.
from openai import OpenAI
client = OpenAI(
base_url="https://api.lightspacehq.com/v1",
api_key=os.environ["LIGHTSPACE_API_KEY"],
)
r = client.chat.completions.create(
model="qwen2.5-vl-7b",
messages=[{"role": "user", "content": [
{"type": "text", "text": "Invoice number, date and total as JSON."},
{"type": "image_url",
"image_url": {"url": "https://example.com/invoice.jpg"}},
]}],
)
print(r.choices[0].message.content)Response headers
x-lightspace-model
x-lightspace-daily-remaining
x-lightspace-ms
Streaming
Set stream: true. Bytes pass through untouched; billing happens when the stream ends.
Your fine-tunes
Call them by the id the job returned. Nothing else changes.
Files
Training data is JSONL: one {"messages": [...]}object per line, at least ten of them, each ending in an assistant message. We check every line on upload rather than at job time — "line 4,812 is malformed" is useful immediately and useless twenty minutes into a training run.
invoices.jsonl
{"messages": [{"role": "user", "content": "What is the invoice total?"},
{"role": "assistant", "content": "The total is USD 4,182.50."}]}
{"messages": [{"role": "user", "content": "Who issued it?"},
{"role": "assistant", "content": "ACME Supplies Ltd."}]}Fine-tuning
The same shape OpenAI uses, field for field, including the status valuesvalidating_files → queued → running → succeeded. What is different is what happens after: you get a verdict on whether the result is usable, and the weights are yours to download.
# 1. your examples: one {"messages": [...]} per line
f = client.files.create(
file=open("invoices.jsonl", "rb"),
purpose="fine-tune",
)
# 2. train
job = client.fine_tuning.jobs.create(
model="qwen2.5-vl-7b",
training_file=f.id,
suffix="invoices",
)
# 3. watch it happen
for e in client.fine_tuning.jobs.list_events(job.id).data:
print(e.level, e.message)
# 4. is it any good? we grade it, you decide
report = requests.get(
f"https://api.lightspacehq.com/v1/fine_tuning/jobs/{job.id}/report",
headers={"Authorization": f"Bearer {key}"},
).json()
print(report["verdict"], "-", report["headline"])
# red - Not ready. Fix what is flagged below and train again.the job object
{
"id": "ftjob-4152b0654a974e989f3bb06f",
"object": "fine_tuning.job",
"model": "qwen2.5-vl-7b",
"status": "succeeded",
"training_file": "file-38fb22174d5e4c31921ec52a",
"fine_tuned_model": "ft:qwen2.5-vl-7b:acme",
"trained_tokens": 1536,
"hyperparameters": {"n_epochs": 1, "batch_size": "auto",
"learning_rate_multiplier": "auto"},
"method": {"type": "supervised"},
"destination": "acme/qwen2.5-vl-7b:acme",
"created_at": 1789430592,
"finished_at": 1789431048,
"error": {"code": null, "message": null, "param": null}
}GET /v1/fine_tuning/jobs/{id}/report
{
"object": "fine_tuning.job.report",
"verdict": "red",
"headline": "Not ready. Fix what is flagged below and train again.",
"summary": {
"examples": 12, "steps": 100,
"first_validation_loss": 6.992,
"final_validation_loss": 1.704,
"final_training_loss": 0.120,
"improvement_pct": 76
},
"checks": [
{"name": "Learning", "verdict": "green",
"detail": "Validation loss fell from 6.992 to 1.704 — a 76% improvement."},
{"name": "Overfitting", "verdict": "red",
"detail": "Training loss is 0.120 but validation loss is 1.704 — it has
memorised your examples rather than learned the pattern."},
{"name": "Data volume", "verdict": "red",
"detail": "12 examples is very few. Treat it as a proof that the
pipeline works, not as a model."}
],
"curve": [{"step": 10, "train_loss": 5.323}, {"step": 100, "train_loss": 0.120}],
"weights": {"available": true, "kind": "LoRA adapter", "size_human": "74.1 MB",
"files": "adapters.safetensors + adapter_config.json"}
}What the verdict means
A loss curve is not an answer. Four things are checked, and the worst one decides the verdict: whether validation loss actually fell; whether training loss is far below it, which means it memorised your examples instead of learning the pattern; whether there were enough examples for the result to mean anything; and whether it was still improving when it stopped, which means another run would do better.
green
Ready to use.
amber
Usable, with caveats. Read them before you ship it.
red
Not ready. Fix what is flagged and train again.
Adding more data later
Send the same call again with continue_fromset to the model you already have. It keeps its id, so nothing you have built on it needs to change, and the new examples are added to what it already knows.
curl -X POST https://api.lightspacehq.com/v1/train \
-H "Authorization: Bearer $KEY" \
-F file=@january-corrections.csv \
-F continue_from=ft:qwen2.5-1.5b:acmeWithout it, a second run starts from the base again. That matters more than it sounds: people send what is new, so a model retrained on this month's twenty corrections has never seen the four hundred examples from March, and is worse at all of them. Nothing in a loss curve shows this — the second run's numbers look fine, because it learned the twenty.
What stops it forgetting
Every time you train, a slice of that batch is put aside and never trained on. When you add data later, some of the older examples are mixed back in, and afterwards every slice you have ever sent is scored again against the new model. If it answers any of them worse than it used to, the update is not applied — your existing model keeps serving, unchanged, and the run tells you which batch it started losing.
Below a couple of thousand examples it simply retrains on everything instead, because at that size a full rebuild takes minutes and cannot forget anything. You do not choose between these; the size of your data does.
Self-learning
Keep what the model answered in production, correct what it got wrong, and train on the corrections. Off unless you turn it on, because retaining your prompts is your decision. Only reviewed answers make it into a dataset — training on unreviewed output teaches a model to agree with itself, which is how one gets confidently worse.
# Turn on capture once; every answer is kept for review.
requests.post(f"{base}/v1/learning", json={"enabled": True}, cookies=session)
# Your team reviews in the workspace, or from code:
requests.post(f"{base}/v1/learning/{capture_id}",
json={"rating": "good"}, cookies=session)
requests.post(f"{base}/v1/learning/{capture_id}",
json={"corrected": "The total is USD 4,182.50."},
cookies=session)
# Corrections become a training file. Only reviewed ones are used —
# a model trained on its own unreviewed output learns to agree with itself.
ds = requests.post(f"{base}/v1/learning/dataset", cookies=session).json()
client.fine_tuning.jobs.create(
model="qwen2.5-vl-7b",
training_file=ds["id"],
suffix="corrected",
)Models
GET /v1/models returns OpenAI's list shape with the fields a multi-model gateway actually needs: context length, input and output modalities, the parameters each model supports, and whether a machine is serving it right now. Prices are string decimals per token, because binary floats and money do not mix.
Language
qwen3-32b, qwen3-30b-a3b, gemma3-27b, phi-4-14b, llama3.1-8b, deepseek-r1-distill-8b
Code & agents
gpt-oss-120b, gpt-oss-20b, glm-z1-9b
Vision
qwen3-vl-30b, qwen2.5-vl-7b, glm-4.6v-flash, pixtral-12b, deepseek-ocr, internvl2-8b, molmo-7b, moondream-2b
Speech
whisper-large-v3, parakeet, qwen3-tts, kokoro
Embeddings
qwen3-embedding-8b, qwen3-embedding-0.6b, bge-m3
Images
flux.1-schnell, sdxl
Projects and repositories
Projects scope work the way OpenAI's do — and they archive rather than delete, because deleting one would take its usage history with it. Connect a GitHub repository and the API returns the commands to run it on a devbox and open it in VS Code, Cursor or IntelliJ. There is no manifest format to learn: a Dockerfile is the contract.
Errors and limits
Errors are OpenAI-shaped, so your client's existing handling works. Plans are a flat monthly price with no per-request charge and no overage; what bounds use is a daily allowance of compute that resets at 00:00 UTC. Hitting it returns429 with a realRetry-After, not a bill.
{
"error": {
"message": "You have used today's compute on the studio plan. It resets
in 42 minutes, at 00:00 UTC. Nothing extra is charged.",
"type": "rate_limit_error",
"code": "daily_limit_reached"
}
}| 400 | invalid_request_error | Something in the request is wrong. The message says what. |
| 401 | invalid_api_key | No key, an unknown key, or one that has been revoked. |
| 404 | not_found | No such file, job or model — or it belongs to someone else. |
| 409 | file_in_use | The file is referenced by a job, so it cannot be deleted. |
| 429 | rate_limit_error | Today's allowance is spent. Retry-After says when it returns. |
| 503 | api_error | No machine is attached to serve that model right now. |