Skip to content
lightspace

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.

your first request
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.

MethodPathAuthDoes
POST/v1/chat/completionskeyText, code and vision. Streaming, tools, JSON mode.
POST/v1/embeddingskeyVectors for search and retrieval.
GET/v1/modelskeyThe catalogue, your fine-tunes included.
GET/v1/models/{id}keyOne model, with context length and supported parameters.
POST/v1/fileskeyUpload training data. Validated line by line on the way in.
GET/v1/fileskeyList your files.
GET/v1/files/{id}/contentkeyDownload one back.
DELETE/v1/files/{id}keyDelete one. Refused while a job still references it.
POST/v1/trainkeyData in, model out. One call. Add continue_from to add to a model you already have.
GET/v1/train/{id}keyHow that run is going, in plain language.
POST/v1/fine_tuning/jobskeyStart a training run.
GET/v1/fine_tuning/jobskeyList runs, newest first.
GET/v1/fine_tuning/jobs/{id}keyOne run and its current status.
POST/v1/fine_tuning/jobs/{id}/cancelkeyStop a run that has not finished.
GET/v1/fine_tuning/jobs/{id}/eventskeyWhat happened, oldest first.
GET/v1/fine_tuning/jobs/{id}/checkpointskeyPer-step loss and accuracy.
GET/v1/fine_tuning/jobs/{id}/reportkeyA verdict on whether the result is usable.
GET/v1/fine_tuning/jobs/{id}/weightskeyDownload the adapter. checkpoint=adapter|merged.
GET/v1/learningsessionCaptured answers and how many are reviewed.
POST/v1/learningsessionTurn capture on or off.
POST/v1/learning/{id}sessionRate an answer, or write the correct one.
POST/v1/learning/datasetsessionTurn reviewed answers into a training file.
GET/v1/mesessionYour plan and what is left of today.
GET/v1/keyssessionList keys. The secret is never returned twice.
POST/v1/keyssessionIssue a key. The secret is shown once.
DELETE/v1/keys/{id}sessionRevoke a key immediately.
GET/v1/usagesession30 days of usage, by model.
POST/v1/projectssessionCreate a project. Projects archive, never delete.
POST/v1/repossessionConnect 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.

inference
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.

fine-tuning
# 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:acme

Without 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.

self-learning
# 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"
  }
}
400invalid_request_errorSomething in the request is wrong. The message says what.
401invalid_api_keyNo key, an unknown key, or one that has been revoked.
404not_foundNo such file, job or model — or it belongs to someone else.
409file_in_useThe file is referenced by a job, so it cannot be deleted.
429rate_limit_errorToday's allowance is spent. Retry-After says when it returns.
503api_errorNo machine is attached to serve that model right now.