Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@ lighton/
workspace.py # Workspace, active-record, lives at root
apikey.py # ApiKey / ApiKeyScope, active-record, lives at root
tag.py # Tag, active-record (list/create/delete only; no single GET)
company_model.py # CompanyModel, active-record (company custom LLM endpoints)
content_type.py # ContentType/Facet/Attribute, content-type taxonomy + file facets
file.py # File, active-record + wait_all(); upload = ingestion
batch.py # ingest_many() batch upload behavior: BatchIngestJob (threads/poll)
job.py # ParseJob/ExtractJob, client-bound async handles you poll()
enums.py # curated StrEnum vocabularies (FileStatus, Role) shared by resources
enums.py # curated StrEnum vocabularies (FileStatus, Role, ModelType) shared by resources
types/ # PURE DATA schemas only (no behavior)
client/configuration.py # LightOnConfiguration
batch.py # BatchIngest / BatchProgress / FailedIngest (batch results)
Expand Down Expand Up @@ -112,7 +113,9 @@ Chosen pattern (user preference) over a resource-manager. Shared plumbing lives
- `list()` follows pagination fully, no silent truncation. It takes `**params` query
filters (e.g. `File.list(client, workspace_id=…)`); no typed per-resource override
because `list` is invariant in its element type, a `list[File]`-returning override
isn't LSP-assignable to the base's `list[Self]`, and ty rejects it.
isn't LSP-assignable to the base's `list[Self]`, and ty rejects it. That constraint is
why it also handles the **bare-array** collection shape (`CompanyModel`) inline, with an
`isinstance` branch, instead of letting that resource override it.
- `_absorb` overwrites **only fields present in the response**, so one-time/local-only
fields survive a later `refresh()` (see ApiKey.key, File.path below).
- Curated schema is **independent of the generated api types** (`extra="ignore"` drops noisy response fields). Hand-written models give stable, clean DX; generated ones are ugly and get regenerated.
Expand Down Expand Up @@ -157,6 +160,32 @@ response, so a later `refresh()` (whose response omits `key`) doesn't wipe it.
`NotImplementedError` rather than 404 at runtime. `create()` posts name/description/
auto_assign. Tags scope `ask`/`search` via `tags=` (OR-matched `tag_id`).

`CompanyModel` (`/api/v3/company/models`) registers a company's own LLM endpoints. Full
CRUD, with three divergences:
- **The endpoint returns a bare array**, not a `results`/`next` envelope. Handled by a
branch in `_ActiveRecord.list` rather than an override here: `list` is invariant in its
element type, so a `list[Self]`-returning override isn't LSP-assignable to the base's and
ty rejects it (the same constraint recorded under the base class).
- **`api_key` is create-only and write-only**: sent by `create()`, never returned by any
response. It survives a later `refresh()` for the same reason `ApiKey.key` does, `_absorb`
only overwrites fields present in the response. `SecretStr`, so it won't leak in a repr.
- **`save()` sends only `name`/`is_default`/`temperature`.** `litellm_model`, `endpoint` and
`api_key` are fixed at creation (the credential is write-only server-side, so it can't be
rewritten without a read-back that doesn't exist). The API **ignores** them silently rather
than rejecting them, so sending them would look like it worked. Registering a new model is
the way to change them.

Writes need the CompanyAdmin role (`PermissionDeniedError` otherwise). Two unrelated
failures share a bare 400: temperature above the provider's ceiling, and a deployment not
running the LiteLLM gateway. They carry no distinguishing code, only a `detail` string, so
the SDK doesn't branch on them; both surface as `LightOnAPIError`.

`ModelType` (enums.py) is the documented model-type vocabulary, but `model_type` is typed
`str` on the model, not the enum: the API accepts it as a free string and doesn't validate
it, so an unrecognized server value must not raise on response parsing (same reasoning as
`JobStatus`). Deferred: `/company/model-catalog` (the curated per-provider list of routing
strings that feeds `create()`) and `/api/v3/instance/models` (managed + custom, merged).

## Batch ingestion (`batch.py`)

`Workspace.ingest_many(files, *, mode=SYNC, ignore_errors=False, wait=False, timeout,
Expand Down
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ This SDK wraps the LightOn API. Create an account and get an API key on [console
- [Tags](#tags)
- [Content types](#content-types)
- [API keys](#api-keys)
- [Company models](#company-models)
- [Client configuration](#client-configuration)
- [Agent Frameworks](#agent-frameworks)

Expand Down Expand Up @@ -575,6 +576,55 @@ with LightOn() as client:
key.delete()
```

## Company models

Register your company's own LLM endpoints, your routing string, optionally your address
and your key, and they become selectable wherever a model is (for example `ask(model=...)`).

Reading is open to any member of the company; creating, updating and deleting need the
**CompanyAdmin** role and raise `PermissionDeniedError` without it.

```python
from pydantic import SecretStr

from lighton import CompanyModel, LightOn, ModelType

with LightOn() as client:
model = CompanyModel(
name="Gemma 4 (local)",
litellm_model="openai/google/gemma-4-e4b", # prefix 'openai/' for an OpenAI-compatible server
endpoint="http://localhost:1234/v1", # omit to use the provider's own
api_key=SecretStr("sk-..."), # omit if the endpoint needs none
model_type=ModelType.large_language_model, # the default
temperature=0.2, # omit to let each feature choose
).create(client)

print(model.id, model.technical_name, model.max_temperature)

for m in CompanyModel.list(client):
print(m.id, m.name, m.enabled, m.is_default)

# Make it the company default, then remove it
model.is_default = True
model.save()
model.delete()
```

`api_key` is a `SecretStr` so it never shows up in a log line or a `repr()`; it is sent on
`create()` and no response ever returns it.

`litellm_model`, `endpoint` and `api_key` are **fixed at creation**. The credential is
write-only on the server, so it cannot be rewritten without first being read back, and it
cannot be read back. `save()` therefore sends only `name`, `is_default` and `temperature`;
to change anything else, register a new model. Note the API ignores the immutable fields
silently rather than rejecting them, so editing them by hand against the raw endpoint looks
like it worked.

Two distinct failures both come back as a 400 `LightOnAPIError` and are told apart only by
their message: a `temperature` above the ceiling the model's provider accepts (Anthropic
allows up to 1.0, OpenAI and Gemini up to 2.0; a model on its own `endpoint` is unbound),
and a deployment that cannot serve company custom models at all.

## Client configuration

`LightOn()` with no arguments reads `LIGHTON_API_KEY` from the environment and talks to
Expand Down
4 changes: 4 additions & 0 deletions lighton/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
from lighton._client import LightOn
from lighton.apikey import ApiKey, ApiKeyScope
from lighton.batch import BatchIngest, BatchIngestJob, BatchProgress, FailedIngest
from lighton.company_model import CompanyModel
from lighton.content_type import Attribute, ContentType, Facet
from lighton.enums import (
ExecMode,
FileStatus,
JobStatus,
ModelType,
RelevanceScoring,
Role,
SearchMode,
Expand All @@ -28,6 +30,7 @@
"BatchIngest",
"BatchIngestJob",
"BatchProgress",
"CompanyModel",
"ContentType",
"ExecMode",
"ExtractJob",
Expand All @@ -38,6 +41,7 @@
"JobStatus",
"LightOn",
"LightOnConfiguration",
"ModelType",
"ParseJob",
"RelevanceScoring",
"Role",
Expand Down
10 changes: 10 additions & 0 deletions lighton/_active_record.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ class _ActiveRecord(BaseModel):
def list(cls, client: LightOn, **params: object) -> _list[Self]:
"""List every resource, following pagination to the end.

Handles both collection shapes the API returns: a paginated
`{"results": [...], "next": ...}` envelope, followed to the last page, and a
bare array (some endpoints, e.g. company models, return the whole collection
at once). Handled here rather than by a per-resource override because `list`
is invariant in its element type, so a `list[Self]`-returning override isn't
LSP-assignable to the base's and ty rejects it.

Args:
client: The client used to make the request and bind to each result.
**params: Optional query filters (e.g. workspace_id) sent on the first page.
Expand All @@ -52,6 +59,9 @@ def list(cls, client: LightOn, **params: object) -> _list[Self]:
path: str | None = cls._base
while path: # follow pagination, no silent truncation
page = client._request("GET", path, params=query)
if isinstance(page, _list): # unpaginated: nothing to follow
items.extend(cls._bind(client, row) for row in page)
break
items.extend(cls._bind(client, row) for row in page["results"])
path = page.get("next")
query = None # `next` already carries the query string
Expand Down
153 changes: 153 additions & 0 deletions lighton/company_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"""Company custom-model registration (active-record, see `_ActiveRecord`).

A CompanyModel is an LLM endpoint your company registers on its own account: your
routing string, optionally your address and your key, served through LightOn. Once
registered it is selectable wherever a model is (for example `ask(model=...)`).

Reads (list/get/refresh) are open to any member of the company; writes (create/save/
delete) need the CompanyAdmin role and raise `PermissionDeniedError` without it.

Three server behaviors shape this module:
- `list` returns a bare JSON array, not a paginated envelope. `_ActiveRecord.list`
recognizes both shapes, so nothing is overridden here.
- `litellm_model`, `endpoint` and `api_key` are fixed at creation. PATCH ignores
them silently rather than rejecting them, so `save()` never sends them.
- `api_key` is write-only and never comes back in a response.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, ClassVar

from pydantic import Field, SecretStr

from lighton._active_record import _ActiveRecord
from lighton.enums import ModelType
from lighton.utils import _compact

if TYPE_CHECKING:
from lighton._client import LightOn

_BASE = "/api/v3/company/models"


class CompanyModel(_ActiveRecord):
_base: ClassVar[str] = _BASE
_resource: ClassVar[str] = "company model"

id: str | None = Field(
None, description="Server-assigned UUID; None until created/retrieved."
)
name: str = Field(description="Display name, unique within the company.")
litellm_model: str = Field(
description=(
"Routing string, e.g. 'openai/gpt-4-turbo' or 'anthropic/claude-sonnet-4'. "
"For an OpenAI-compatible endpoint (LM Studio, Ollama), prefix with "
"'openai/'. Fixed at creation."
)
)
model_type: str = Field(
ModelType.large_language_model,
description=(
"Model type, e.g. 'Large Language Model' or 'Embedding Model'. See the "
"ModelType vocabulary; the server does not restrict it to those values."
),
)
endpoint: str | None = Field(
None,
description=(
"Base URL of your own deployment; None to use the provider's. Fixed at creation."
),
)
api_key: SecretStr | None = Field(
None,
description=(
"Credential for the endpoint, sent on create() and never returned by the "
"API. Fixed at creation. Use .get_secret_value() to read it back locally."
),
)
temperature: float | None = Field(
None,
description=(
"Sampling temperature (0 to 2) sent on every request to this model. None "
"lets each calling feature use its own value."
),
)
# Read-only, populated from responses.
technical_name: str | None = Field(
None, description="Identifier the gateway routes on (read-only)."
)
enabled: bool | None = Field(
None, description="Whether the model is active (read-only)."
)
is_default: bool | None = Field(
None, description="Whether this is the company's default custom model."
)
required_temperature: float | None = Field(
None,
description=(
"The only temperature this model accepts, when it accepts exactly one; "
"None for every other model (read-only)."
),
)
max_temperature: float | None = Field(
None,
description=(
"Highest temperature this model's provider accepts, None when it "
"publishes no bound (read-only)."
),
)

# --- instance lifecycle ------------------------------------------------
def create(self, client: LightOn) -> CompanyModel:
"""Register this model and bind the client for later lifecycle calls.

Args:
client: The client to register the model with and bind to `self`.

Returns:
`self`, updated with the id and the server-derived read-only fields.

Raises:
PermissionDeniedError: If the key is not a company admin's.
LightOnAPIError: 400 if the temperature is above the provider's ceiling,
or if the deployment cannot serve company custom models at all.
"""
payload = _compact(
name=self.name,
litellm_model=self.litellm_model,
model_type=self.model_type,
endpoint=self.endpoint,
api_key=self.api_key.get_secret_value() if self.api_key else None,
temperature=self.temperature,
)
data = client._request("POST", _BASE, json=payload)
self._client = client
return self._absorb(data)

def save(self) -> CompanyModel:
"""Persist local edits to name/is_default/temperature (PATCH).

Only those three are sent. `litellm_model`, `endpoint` and `api_key` are fixed
at creation: the credential is write-only in the gateway, so it cannot be
rewritten without first being read back, and it cannot be read back. Changing
any of them means registering a new model. The API ignores them silently rather
than rejecting them, so sending them would look like it worked.

Returns:
`self`, refreshed with the server's response.

Raises:
PermissionDeniedError: If the key is not a company admin's.
LightOnAPIError: 400 if the temperature is above the provider's ceiling.
"""
data = self._api(
"PATCH",
f"{_BASE}/{self.id}",
json=_compact(
name=self.name,
is_default=self.is_default,
temperature=self.temperature,
),
)
return self._absorb(data)
15 changes: 15 additions & 0 deletions lighton/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,18 @@ class Role(StrEnum):
viewer = "viewer"
editor = "editor"
owner = "owner"


class ModelType(StrEnum):
"""What a model does, for `CompanyModel.model_type`.

These four are the documented vocabulary, but the API accepts `model_type` as
a free string and doesn't validate it, so (like ``JobStatus``) this is for
call-site use, NOT to validate the field: an unrecognized server value
compares unequal rather than erroring on response parsing.
"""

large_language_model = "Large Language Model"
embedding_model = "Embedding Model"
vision_language_model = "Vision Language Model"
multi_vector_model = "Multi-Vector Model"
Loading