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
6 changes: 5 additions & 1 deletion .config.schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ properties:
properties:
provider:
type: string
enum: ["openai", "ollama"]
# `openai` and `openai-responses` are the same endpoint through the two
# API shapes. Pick the one the endpoint publishes: a gateway that has no
# chat/completions operation returns its own 404 regardless of the model,
# and Azure's Codex deployments are Responses-only in any case.
enum: ["openai", "openai-responses", "ollama"]
model:
type: string
description: "e.g. gpt-4o-mini, gpt-5.6-luna. LLM_MODEL overrides this."
Expand Down
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ embeddings/
embeddings_bak/
csv_files/
records/
.dockerignore
sockets/
.venv/
.env
.env.*
Expand Down
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,43 @@ jobs:
- name: Run tests
run: poetry run pytest

schema:
# initdb/init.sql is hand-written and Chainlit's data layer sends every
# non-None key of the dict it is handed, so a key added upstream becomes
# an INSERT into a column that does not exist here. That went unnoticed
# for 22 months and surfaced as UndefinedColumn on every user request.
# The guard has to run against a database actually built from init.sql,
# which is what this job is.
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: ci-only
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v4

- name: Set up Python and Poetry
uses: ./.github/actions/install_python_poetry

- name: Create the databases from initdb/init.sql
run: |
psql postgresql://postgres:ci-only@localhost:5432/postgres \
-v ON_ERROR_STOP=1 -f initdb/init.sql

- name: Check the schema against the installed Chainlit
run: |
PYTHONPATH=./src poetry run python bin/check_schema.py \
--dsn postgresql://postgres:ci-only@localhost:5432/chatbio_chainlit


poetry-check:
# No event filter. It used to run only on pull_request and
# workflow_dispatch, so every push to main skipped it -- and GitHub counts
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,4 @@ data/
# belongs in the repository.
*.pem
*.key
sockets/
47 changes: 46 additions & 1 deletion bin/chat-chainlit.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
from util import captcha_cookie
from util.chainlit_helpers import (
PrefixedS3StorageClient,
TextStreamCallbackHandler,
is_feature_enabled,
message_rate_limited,
save_openai_metrics,
Expand Down Expand Up @@ -89,6 +90,50 @@
f"{len(_mounted)} of {len(SECRET_NAMES)} secrets supplied as Docker secrets"
)


def _sanitize_llm_settings() -> None:
"""Stop non-JSON values reaching the persisted generation settings.

`_build_llm_settings` is the one place in chainlit's GenerationHelper that
does not run its output through `ensure_values_serializable` -- `_start_trace`
does, for run.inputs. So anything unserializable goes straight into
SQLAlchemyDataLayer.create_step's json.dumps and the INSERT dies, which
deletes the llm step from thread history entirely.

Ours is `response_format`: with_structured_output(method="json_schema") binds
the pydantic CLASS and serializes it only at request time. That method is
deliberate -- the gpt-5.6 family refuses function tools on
/v1/chat/completions -- so the class stays in the kwargs and is made safe
here instead.

Patched at the tracer rather than in a data-layer subclass because the same
dict is also handed to context.emitter.update_step, so the socket sees it.
"""
from chainlit.langchain.callbacks import GenerationHelper

original = GenerationHelper._build_llm_settings

def patched(
self: Any,
serialized: dict[str, Any],
invocation_params: dict[str, Any] | None = None,
) -> tuple[Any, ...]:
result: tuple[Any, ...] = original(self, serialized, invocation_params)
if len(result) != 4:
return result
provider, model, tools, settings = result
return (
provider,
model,
tools,
self.ensure_values_serializable(settings) if settings else settings,
)

GenerationHelper._build_llm_settings = patched # type: ignore[method-assign]


_sanitize_llm_settings()

config: Config | None = Config.from_yaml()

profiles: list[ProfileName] = config.profiles if config else [ProfileName.React_to_Me]
Expand Down Expand Up @@ -552,7 +597,7 @@ async def answer_with_model(content: str, message_id: str) -> None:

thread_id: str = current_thread_id()

chainlit_cb = cl.AsyncLangchainCallbackHandler(
chainlit_cb = TextStreamCallbackHandler(
stream_final_answer=True,
force_stream_final_answer=True, # we're not using prefix tokens
)
Expand Down
17 changes: 14 additions & 3 deletions bin/chat-fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,9 +271,20 @@ async def captcha_page() -> Response:

@app.post(f"{CHAINLIT_URI}/verify_captcha")
async def verify_captcha(request: Request) -> Response:
# Bounded: anyone can reach this route, and the defaults put no cap on
# file parts, which were spooled to the disk the whole host shares
# (review, area 1b). The form has one field.
# Bounded: anyone can reach this route. max_files=0 refuses file parts
# outright -- they were spooled to the disk the whole host shares (review,
# area 1b) -- and max_part_size holds the one field, a Turnstile token a
# couple of hundred bytes long, to 8 KiB rather than Starlette's 1 MiB
# default.
#
# max_part_size needs Starlette >= 0.44, which added it to Request.form().
# The published image shipped 0.41.3, and starlette arrives transitively via
# chainlit with nothing pinning the floor, so this raised
# `TypeError: Request.form() got an unexpected keyword argument
# 'max_part_size'` -- a 500 on the entry path, for every visitor, and only
# on the image. A transitive version is not a floor; the floor lives in
# pyproject now. Starlette 1.3.1 is where these limits are actually enforced
# in FormParser, which is the floor declared there.
form_data = await request.form(max_files=0, max_fields=4, max_part_size=8192)
cf_turnstile_response = form_data.get("cf-turnstile-response")
if not isinstance(cf_turnstile_response, str):
Expand Down
Loading
Loading