Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,6 @@ benchmark/results/
data/inputs/*
!data/inputs/ics_209.pdf
!data/inputs/file_template_manual.pdf

# Locally generated LLM credential-encryption key
data/secrets/
52 changes: 52 additions & 0 deletions alembic/versions/007_add_llm_user_configuration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""add local LLM provider configuration

Revision ID: 007
Revises: 006
"""
from typing import Sequence

import sqlalchemy as sa
from alembic import op


revision: str = "007"
down_revision: str | None = "006"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.create_table(
"llm_provider_credentials",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("provider", sa.String(), nullable=False),
sa.Column("api_key_encrypted", sa.String(), nullable=True),
sa.Column("key_hint", sa.String(), nullable=True),
sa.Column("base_url", sa.String(), nullable=True),
sa.Column("structured_output", sa.Boolean(), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("provider", name="uq_llm_provider_credential_provider"),
)
op.create_table(
"llm_manual_models",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("provider", sa.String(), nullable=False),
sa.Column("model", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("provider", "model", name="uq_llm_manual_model_provider_model"),
)
op.create_table(
"llm_runtime_config",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("allow_external", sa.Boolean(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)


def downgrade() -> None:
op.drop_table("llm_runtime_config")
op.drop_table("llm_manual_models")
op.drop_table("llm_provider_credentials")
3 changes: 2 additions & 1 deletion app/api/router.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
from fastapi import APIRouter

from app.api.routes import forms, input, jobs, profiles, llm, system, templates, weather, zipcode
from app.api.routes import forms, input, jobs, profiles, llm, llm_config, system, templates, weather, zipcode
from app.core.config import API_PREFIX

api_router = APIRouter()
api_router.include_router(templates.router, prefix=API_PREFIX)
api_router.include_router(forms.router, prefix=API_PREFIX)
api_router.include_router(system.router, prefix=API_PREFIX)
api_router.include_router(llm.router, prefix=API_PREFIX)
api_router.include_router(llm_config.router, prefix=API_PREFIX)
api_router.include_router(jobs.router, prefix=API_PREFIX)
api_router.include_router(weather.router, prefix=API_PREFIX)
api_router.include_router(zipcode.router, prefix=API_PREFIX)
Expand Down
3 changes: 1 addition & 2 deletions app/api/routes/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def submit_async_form_fill(form: AsyncFormFill, db: Session = Depends(get_db)):

transcript = InputService().resolve_transcript(db, form.input_id)
# Checked here so a bad pick fails the request, not every queued job
choice = llm.get_registry().resolve(form.provider, form.model)
choice = llm.effective_registry(db).resolve(form.provider, form.model)

jobs: list[AsyncJobSubmitResponse] = []
for tid in form.template_ids:
Expand Down Expand Up @@ -100,4 +100,3 @@ def submit_async_form_fill(form: AsyncFormFill, db: Session = Depends(get_db)):
))

return AsyncFormFillResponse(jobs=jobs)

76 changes: 68 additions & 8 deletions app/api/routes/llm.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,63 @@
"""LLM endpoints: GET /api/v1/system/llm/models, POST /api/v1/system/llm/test.
"""LLM endpoints: GET /api/v1/system/llm/models, POST /api/v1/system/llm/test, POST /api/v1/system/llm/pull.

Contract: contracts/path/system.yaml + contracts/schemas/system.yaml
"""

from fastapi import APIRouter
from fastapi import Depends
from fastapi.responses import StreamingResponse
from sqlmodel import Session

from app.api.schemas.system import LLMModelChoice, LLMModels, LLMProviderModels, LLMTestRequest, LLMTestResult
from app.api.deps import get_db
from app.api.schemas.enums import LLMProvider
from app.api.schemas.system import (
DownloadableModel,
LLMModelChoice,
LLMModels,
LLMProviderModels,
LLMPullRequest,
LLMTestRequest,
LLMTestResult,
ModelInfo,
)
from app.services import llm
from app.services.llm.errors import LLMNotAvailableError
from app.services.llm.ollama import OllamaLLM

router = APIRouter(prefix="/system/llm", tags=["system"])


# Fields without a value are left out: hosted providers only give a model name,
# Ollama adds size, quantization and whether the model is loaded.
# Ollama adds size, quantization, whether the model is loaded, and downloadable models.
@router.get(
"/models",
response_model=LLMModels,
response_model_exclude_none=True,
summary="List the LLM providers and models that can fill forms",
)
def list_llm_models(refresh: bool = False):
registry = llm.get_registry()
def list_llm_models(refresh: bool = False, db: Session = Depends(get_db)):
registry = llm.effective_registry(db)
providers: list[LLMProviderModels] = []
for c in registry.catalog(refresh):
downloadable = (
[DownloadableModel.model_validate(d) for d in c.downloadable]
if c.downloadable
else None
)
providers.append(
LLMProviderModels(
provider=c.provider,
available=c.available,
default_model=c.default_model,
detail=c.detail,
error_code=c.error_code,
models=[ModelInfo.model_validate(m) for m in c.models],
downloadable=downloadable,
)
)
return LLMModels(
default=LLMModelChoice.model_validate(registry.default),
providers=[LLMProviderModels.model_validate(c) for c in registry.catalog(refresh)],
providers=providers,
)


Expand All @@ -33,7 +67,33 @@ def list_llm_models(refresh: bool = False):
response_model_exclude_none=True,
summary="Check that a model actually answers",
)
def test_llm_model(body: LLMTestRequest | None = None):
def test_llm_model(body: LLMTestRequest | None = None, db: Session = Depends(get_db)):
body = body or LLMTestRequest()
check = llm.ModelValidator(llm.get_registry()).probe(body.provider, body.model)
check = llm.ModelValidator(llm.effective_registry(db)).probe(body.provider, body.model)
return LLMTestResult.model_validate(check)


@router.post(
"/pull",
response_class=StreamingResponse,
responses={
200: {"content": {"application/x-ndjson": {}}},
422: {"description": "Invalid pull request or model not installable"},
503: {"description": "Ollama service unavailable"},
},
summary="Pull an AI model from Ollama",
)
def pull_llm_model(req: LLMPullRequest, db: Session = Depends(get_db)):
registry = llm.effective_registry(db)
provider_val = req.provider or "ollama"
registry.validate_pull(provider_val, req.model)

client = registry.client(LLMProvider.ollama)
if not isinstance(client, OllamaLLM):
raise LLMNotAvailableError("Model pulling is only supported for Ollama.")

stream = client.pull_model(
req.model,
on_success=lambda: registry.provider_catalog(LLMProvider.ollama, refresh=True),
)
return StreamingResponse(stream, media_type="application/x-ndjson")
Loading
Loading