ai_providers
connections to models
key at-rest
| id |
BigInteger |
PK |
— |
| name |
Text |
NOT NULL |
display name · "OpenAI" · "Ollama (on-prem)" |
| kind |
Text |
NOT NULLDEFAULTCHECK
|
where it runs · cloud · local |
| adapter |
Text |
NOT NULLCHECK
|
API dialect · determines the SDK client and discovery ·
openai · anthropic · google · ollama · openai_compatible ·
→ API dialect
|
| base_url |
Text |
NULLCHECK
|
endpoint · optional for cloud, required for local (CHECK) |
| api_key_enc |
Text |
NULL |
write-only · AES-256-GCM · NULL for local ones · → keys |
| is_system |
Boolean |
NOT NULLDEFAULT
|
built-in runtime, seeded by the migration · non-deletable, base_url/adapter managed · → Built-in embedder |
| status |
Text |
NOT NULLDEFAULTCHECK
|
the result of the last check · active · error · unchecked |
| last_check_at |
DateTime(tz) |
NULL |
NULL = never checked |
| created_at |
DateTime(tz) |
DEFAULT |
server_default=now() |
| updated_at |
DateTime(tz) |
DEFAULT |
now() + trigger |
ai_models
model catalog
FK → ai_providers
UNIQUE(provider_id, model_id)
| id |
BigInteger |
PK |
— |
| provider_id |
BigInteger |
FK→ai_providersIDX
|
CASCADE |
| model_id |
Text |
NOT NULLUNIQUE*
|
the provider's identifier · "gpt-4o" |
| display_name |
Text |
NOT NULL |
display name · default = model_id |
| model_type |
Text |
NOT NULLCHECK
|
what it outputs · chat · embedding · → the type governs assignment |
| origin |
Text |
NOT NULLCHECK
|
how it entered the catalog · discovered · manual · builtin (seeded with the built-in runtime) |
| is_enabled |
Boolean |
NOT NULLDEFAULT
|
enabled on the platform · DEFAULT false |
| price_input |
Numeric |
NULL |
price per input token · NULL = not set (local) ·
→ Input/output price
|
| price_output |
Numeric |
NULL |
price per output token · NULL for embedding and local |
| meta |
JSONB |
NULL |
model intrinsics · for embedding: embedding_dim (sets halfvec(N) in KS), max_input_tokens, instruction_prefix · → Intrinsics |
| created_at |
DateTime(tz) |
DEFAULT |
now() |
| updated_at |
DateTime(tz) |
DEFAULT |
now() + trigger |
model_assignments
system functions
FK → ai_models
UNIQUE(function)
| id |
BigInteger |
PK |
— |
| function |
Text |
NOT NULLUNIQUECHECK
|
one row per function · system ones: harvester_embedding ·
query_rag ·
→ Function vocabulary
|
| model_id |
BigInteger |
FK→ai_modelsNULL
|
the assigned model · NULL = awaiting a module · RESTRICT |
| created_at |
DateTime(tz) |
DEFAULT |
now() |
| updated_at |
DateTime(tz) |
DEFAULT |
now() + trigger |
chat_models
user chat
FK → ai_models
exactly one default
| id |
BigInteger |
PK |
— |
| model_id |
BigInteger |
FK→ai_modelsUNIQUEIDX
|
an allowed chat model · RESTRICT — in the list = in use |
| is_default |
Boolean |
NOT NULLDEFAULT
|
partial UNIQUE WHERE is_default — exactly one |
| created_at |
DateTime(tz) |
DEFAULT |
now() |
| updated_at |
DateTime(tz) |
DEFAULT |
now() + trigger |
agent_models
agent models
FK → ai_models
exactly one default
| id |
BigInteger |
PK |
— · agents.model_id references this |
| model_id |
BigInteger |
FK→ai_modelsUNIQUEIDX
|
an allowed agent model · chat-type, with tool support · RESTRICT — in the list = in use |
| is_default |
Boolean |
NOT NULLDEFAULT
|
partial UNIQUE WHERE is_default — exactly one · preset in the agent editor |
| created_at |
DateTime(tz) |
DEFAULT |
now() |
| updated_at |
DateTime(tz) |
DEFAULT |
now() + trigger |
model_usage
usage · per-day aggregate
FK → ai_models
UNIQUE(model_id, function, bucket_date)
| id |
BigInteger |
PK |
— |
| model_id |
BigInteger |
FK→ai_modelsIDX
|
SET NULL · usage outlives model deletion |
| function |
Text |
NOT NULLCHECK
|
which function consumed it · shared vocabulary ·
→ Function vocabulary
|
| bucket_date |
Date |
NOT NULLUNIQUE*
|
the aggregation day |
| request_count |
BigInteger |
NOT NULLDEFAULT
|
DEFAULT 0 |
| input_tokens |
BigInteger |
NOT NULLDEFAULT
|
input tokens · DEFAULT 0 |
| output_tokens |
BigInteger |
NOT NULLDEFAULT
|
output tokens · 0 for embedding |
| cost |
Numeric |
NULL |
input·price_input + output·price_output · NULL if the prices aren't set |
| created_at |
DateTime(tz) |
DEFAULT |
now() |
| updated_at |
DateTime(tz) |
DEFAULT |
now() + trigger · the last increment of the bucket |
The ORM models are in the ai_foundation/models.py
package (code convention — one package per module), not in admin:
the assigned models are read by the
AI
consumers Harvester (embed), Knowledge Store (re-embedding), Query
Engine (RAG) and Agent Engine — four consumers, not just Admin.
Admin provides CRUD and screens; the dependencies go toward core —
with no cycles.
api_key_enc is a write-only secret, AES-256-GCM
ciphertext by Auth's crypto core; it is not returned outward (API,
export), the UI gets only the mask ••••xxxx. Local
providers usually have no key.
Two orthogonal axes. kind (cloud / local) is where
the model runs: it determines whether a key is needed and whether
base_url is required. adapter is which
protocol to speak to it on: it selects the SDK client and the
discovery endpoint (openai →
GET /v1/models, ollama →
GET /api/tags). openai_compatible covers
vLLM · llama.cpp · proxies. The protocol can't be inferred from
name (the display name) — hence a separate field.
The platform ships its own local embeddings runtime — a separate
docker-compose service (OpenAI-compatible, model-agnostic),
brought up on
make up alongside the backend; the
container is lightweight and doesn't pull ML dependencies into the
API image.
Weights are not downloaded at install:
the runtime pulls them
lazily — on model
assignment (and immediately
warms it up
without waiting for the first request), into a cache volume. Until
selected, the embedder takes neither disk nor memory, and so it
doesn't limit the deploy. The migration seeds the
is_system provider
Platform
(non-deletable,
base_url pointing to the internal
service, no key) and the catalog of built-in models — selectable
(
is_enabled=true) but not loaded. The set is defined
by the seed, not the screens (the first iteration —
bge-m3 and
Qwen3-Embedding-0.6B, both
1024-dimensional); the wireframes reference them only as examples.
There is
no default assignment: the
model_assignments.harvester_embedding row does not
exist at the start, and Harvester won't start ingesting without an
assignment — Admin must assign an embedding model (built-in
or their own) before the first ingest. The reason is
hard: the choice fixes the dimension
N of the
chunks.embedding
column, and a later change is a mass
re-embedding
(with a different
N — a schema migration). The
default is deliberately empty: neither a model nor its weight is
imposed by the installation. How this service handles load streams
and scales (v1 → v2) is in
the embeddings runtime.
Parameters set by the model itself — the vector dimension
(
embedding_dim), the window cap
(
max_input_tokens), the required prefix
(
instruction_prefix) — live in the model's
meta, not in the consumer's config (no hardcoding):
Harvester's chunker reads the cap from there, KS takes the
embedding_dim of the assigned embedding model and
provisions
halfvec(N). The built-in models are seeded
with their own intrinsics —
bge-m3:
{ embedding_dim: 1024, max_input_tokens: 8192 },
Qwen3-Embedding-0.6B:
{ 1024, 32768 }. A cloud model's
embedding_dim isn't returned by discovery, so the
Admin declares it when adding the model; a model without a
declared dimension — or one that doesn't match the provisioned
halfvec(N) — is refused at
assignment
(409), before any re-embedding starts.
The type governs assignment
model_type (chat / embedding) restricts which
functions a model can be assigned to: embedding — only to
embedding functions, chat — to chat, RAG, agents. Changing the
type and disabling a model are blocked while it is assigned to any
function or is in the chat or agent list (FK
RESTRICT on all three tables): otherwise an
assignment would be left with an incompatible or empty type, and a
list without a model.
A system function is one
model_assignments row (one
model). Chat and agents are two lists of the same shape:
chat_models (the employee selects the conversation
model) and
agent_models (the owner selects the agent
model), each with exactly one
is_default and only
chat-type models. The default is protected symmetrically to the
system functions: the
is_default mark can't be
cleared without assigning another, and the default model is held
in the list by
RESTRICT — the list is never left
without a default model. The agent's link to the selection is
agents.model_id
→
agent_models (
ON DELETE SET NULL):
remove a model from the list and the agent's reference is nulled,
and the agent stops.
Price and usage — input/output separately
For cloud chat the output token is several times more expensive
than the input, so the price is two fields
(price_input · price_output), and usage
accumulates input_tokens and
output_tokens separately. cost =
input·price_input + output·price_output. Embedding has no output —
price_output and output_tokens are empty
/ zeros. The currency is single for now (dollars), without a
separate field — multi-currency is deferred.
A single function vocabulary
The set of AI functions is defined once (an enum in
core): harvester_embedding ·
query_rag · agent_engine ·
chat. model_assignments.function is
CHECKed against the subset of the two system ones
(harvester_embedding · query_rag) — chat
and agents are assigned not here but through the
chat_models / agent_models lists;
model_usage.function accumulates usage across the
full set, including chat and
agent_engine. One source of truth — divergence is
ruled out.
model_usage accumulates requests / tokens / cost by
(model · function · day), not an event-log per request. The budget
threshold is an instance setting (
ai_monthly_budget +
ai_budget_alert_enabled) and lives not here but in
platform_settings,
next to
maintenance_mode: the admin enters the amount
by hand and turns it on with a toggle (a CHECK prevents enabling
the alert without an amount) on the
AI usage
screen, in the shared home of limits. On reaching the threshold, a
Budget notification is raised — the channel is held by the
notifications module. The week / month / year usage panorama on the
same screen is a derived
SUM over the period, without
a separate counter table. How the accounting works end to end is in
the
usage business logic.