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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ LLMBENCHLAB_WORKER_MAX_ATTEMPTS=3
LLMBENCHLAB_WORKER_SHUTDOWN_GRACE_SECONDS=30
LLMBENCHLAB_WORKER_PROGRESS_FLUSH_SECONDS=5
LLMBENCHLAB_WORKER_PROGRESS_STALE_SECONDS=60
LLMBENCHLAB_DEV_WORKER_PROCESSES=1
LLMBENCHLAB_WORKER_EXPECTED_PROCESSES=1
LLMBENCHLAB_WORKER_RECOVERY_ALERT_SECONDS=60
LLMBENCHLAB_COMPOSE_WORKER_PROCESSES=2
LLMBENCHLAB_REDIS_PUBLISH_TIMEOUT_SECONDS=1
LLMBENCHLAB_REDIS_OPERATION_TIMEOUT_SECONDS=2

Expand Down
9 changes: 8 additions & 1 deletion CHANGELOG.md

Large diffs are not rendered by default.

19 changes: 15 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
SHELL := /bin/bash
.DEFAULT_GOAL := help

.PHONY: help setup dev backend worker frontend test lint format smoke migrate phase2-acceptance phase2-capacity phase2-slo docker-up docker-down
.PHONY: help setup dev dev-multi backend worker frontend test lint format smoke migrate phase2-acceptance phase2-capacity phase2-slo docker-up docker-down

help:
@echo "LLMBenchLab developer commands:"
@echo " make setup Install dependencies and initialize the local database"
@echo " make dev Start API, independent Worker, and frontend together"
@echo " make dev Start local API, Worker(s), and frontend (DEV_WORKERS=N needs PostgreSQL)"
@echo " make dev-multi Start PostgreSQL/Redis/API/frontend with two Workers (WORKERS=N)"
@echo " make backend Start only the FastAPI development server"
@echo " make worker Start only the independent task Worker"
@echo " make frontend Start only the Vite development server"
Expand All @@ -25,7 +26,13 @@ setup:
@./scripts/setup.sh

dev:
@./scripts/dev.sh
@if [[ "$(origin DEV_WORKERS)" != "undefined" ]]; then \
LLMBENCHLAB_DEV_WORKER_PROCESSES="$(DEV_WORKERS)" ./scripts/dev.sh; \
else \
./scripts/dev.sh; \
fi

dev-multi: docker-up

backend:
@./scripts/bootstrap_credential_keyring.sh
Expand Down Expand Up @@ -82,7 +89,11 @@ phase2-slo:

docker-up:
@./scripts/bootstrap_credential_keyring.sh
@docker compose up --build --wait --wait-timeout 180 --remove-orphans
@if [[ "$(origin WORKERS)" != "undefined" ]]; then \
LLMBENCHLAB_COMPOSE_WORKER_PROCESSES="$(WORKERS)" ./scripts/compose_up.sh; \
else \
./scripts/compose_up.sh; \
fi

docker-down:
@docker compose down --remove-orphans
85 changes: 52 additions & 33 deletions README.md

Large diffs are not rendered by default.

98 changes: 98 additions & 0 deletions backend/alembic/versions/20260830_0008_provider_api_protocols.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Expand registered Model provider types to explicit API protocols.

Revision ID: 20260830_0008
Revises: 20260830_0007
Create Date: 2026-08-30 12:00:00 UTC
"""

from collections.abc import Sequence

import sqlalchemy as sa

from alembic import op

revision: str = "20260830_0008"
down_revision: str | None = "20260830_0007"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None

_OLD_PROVIDER_VALUES = "provider_type IN ('mock', 'openai_compatible')"
_NEW_PROVIDER_VALUES = (
"provider_type IN ('mock', 'openai_compatible', 'openai_responses', 'anthropic_messages')"
)
_OLD_REMOTE_CONFIGURATION = (
"provider_type != 'openai_compatible' OR "
"(base_url IS NOT NULL AND remote_model_name IS NOT NULL AND "
"((credential_source = 'environment' AND api_key_env IS NOT NULL) OR "
"(credential_source = 'stored' AND api_key_env IS NULL)))"
)
_NEW_REMOTE_CONFIGURATION = (
"provider_type NOT IN "
"('openai_compatible', 'openai_responses', 'anthropic_messages') OR "
"(base_url IS NOT NULL AND remote_model_name IS NOT NULL AND "
"((credential_source = 'environment' AND api_key_env IS NOT NULL) OR "
"(credential_source = 'stored' AND api_key_env IS NULL)))"
)


def _replace_constraints(
*,
provider_values: str,
remote_configuration: str,
existing_length: int,
target_length: int,
) -> None:
with op.batch_alter_table("models") as batch_op:
batch_op.drop_constraint(op.f("ck_models_provider_type_values"), type_="check")
batch_op.drop_constraint(
op.f("ck_models_openai_configuration_required"),
type_="check",
)
batch_op.alter_column(
"provider_type",
existing_type=sa.String(length=existing_length),
type_=sa.String(length=target_length),
existing_nullable=False,
)
batch_op.create_check_constraint(
op.f("ck_models_provider_type_values"),
provider_values,
)
batch_op.create_check_constraint(
op.f("ck_models_openai_configuration_required"),
remote_configuration,
)


def upgrade() -> None:
_replace_constraints(
provider_values=_NEW_PROVIDER_VALUES,
remote_configuration=_NEW_REMOTE_CONFIGURATION,
existing_length=17,
target_length=18,
)


def downgrade() -> None:
connection = op.get_bind()
if connection.dialect.name == "sqlite":
connection.exec_driver_sql("BEGIN IMMEDIATE")
elif connection.dialect.name == "postgresql":
connection.exec_driver_sql("LOCK TABLE models IN ACCESS EXCLUSIVE MODE")
new_type_count = connection.scalar(
sa.text(
"SELECT COUNT(*) FROM models "
"WHERE provider_type IN ('openai_responses', 'anthropic_messages')"
)
)
if int(new_type_count or 0) != 0:
raise RuntimeError(
"Cannot downgrade while openai_responses or anthropic_messages Models exist; "
"delete or convert those Model configurations first"
)
_replace_constraints(
provider_values=_OLD_PROVIDER_VALUES,
remote_configuration=_OLD_REMOTE_CONFIGURATION,
existing_length=18,
target_length=17,
)
88 changes: 50 additions & 38 deletions backend/app/adapters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,49 @@
)
from .mock import MockModelAdapter
from .openai_compatible import OpenAICompatibleAdapter, sanitize_error_message
from .provider_protocols import AnthropicMessagesAdapter, OpenAIResponsesAdapter


def _remote_adapter_options(kwargs: dict[str, Any]) -> dict[str, Any]:
"""Normalize the shared constructor surface for every remote adapter."""

options = dict(kwargs)
if "remote_model_name" not in options and "model_name" in options:
options["remote_model_name"] = options.pop("model_name")
aliases = {
"connect_timeout": "connect_timeout_seconds",
"read_timeout": "read_timeout_seconds",
"write_timeout": "write_timeout_seconds",
"pool_timeout": "pool_timeout_seconds",
"retry_count": "max_retries",
"backoff_base_seconds": "retry_backoff_base_seconds",
"backoff_cap_seconds": "retry_backoff_cap_seconds",
}
for old_name, new_name in aliases.items():
if old_name in options and new_name not in options:
options[new_name] = options.pop(old_name)
allowed = {
"base_url",
"remote_model_name",
"api_key_env",
"api_key",
"connect_timeout_seconds",
"read_timeout_seconds",
"write_timeout_seconds",
"pool_timeout_seconds",
"max_retries",
"retry_backoff_base_seconds",
"retry_backoff_cap_seconds",
"client",
"sleep",
"attempt_controller",
}
required = {"base_url", "remote_model_name"}
return {
key: value
for key, value in options.items()
if key in allowed and (value is not None or key in required)
}


def build_adapter(provider_type: str, **kwargs: Any) -> ModelAdapter:
Expand All @@ -34,44 +77,11 @@ def build_adapter(provider_type: str, **kwargs: Any) -> ModelAdapter:
}
return MockModelAdapter(**mock_options)
if normalized in {"openai", "openai_compatible"}:
options = dict(kwargs)
if "remote_model_name" not in options and "model_name" in options:
options["remote_model_name"] = options.pop("model_name")
aliases = {
"connect_timeout": "connect_timeout_seconds",
"read_timeout": "read_timeout_seconds",
"write_timeout": "write_timeout_seconds",
"pool_timeout": "pool_timeout_seconds",
"retry_count": "max_retries",
"backoff_base_seconds": "retry_backoff_base_seconds",
"backoff_cap_seconds": "retry_backoff_cap_seconds",
}
for old_name, new_name in aliases.items():
if old_name in options and new_name not in options:
options[new_name] = options.pop(old_name)
allowed = {
"base_url",
"remote_model_name",
"api_key_env",
"api_key",
"connect_timeout_seconds",
"read_timeout_seconds",
"write_timeout_seconds",
"pool_timeout_seconds",
"max_retries",
"retry_backoff_base_seconds",
"retry_backoff_cap_seconds",
"client",
"sleep",
"attempt_controller",
}
required = {"base_url", "remote_model_name"}
adapter_options = {
key: value
for key, value in options.items()
if key in allowed and (value is not None or key in required)
}
return OpenAICompatibleAdapter(**adapter_options)
return OpenAICompatibleAdapter(**_remote_adapter_options(kwargs))
if normalized == "openai_responses":
return OpenAIResponsesAdapter(**_remote_adapter_options(kwargs))
if normalized == "anthropic_messages":
return AnthropicMessagesAdapter(**_remote_adapter_options(kwargs))
raise ValueError(f"Unsupported provider_type: {provider_type!r}")


Expand All @@ -80,13 +90,15 @@ def build_adapter(provider_type: str, **kwargs: Any) -> ModelAdapter:

__all__ = [
"AdapterError",
"AnthropicMessagesAdapter",
"GenerationConfig",
"Message",
"MockModelAdapter",
"ModelAdapter",
"ModelGenerationResult",
"OpenAICompatibleAdapter",
"OpenAICompatibleModelAdapter",
"OpenAIResponsesAdapter",
"ProviderAttemptContext",
"ProviderAttemptController",
"ProviderAttemptDisposition",
Expand Down
Loading
Loading