docker 1 mese fa
parent
commit
6fb0ca6cfe
28 ha cambiato i file con 1279 aggiunte e 8 eliminazioni
  1. 1 0
      deployments/docker/.env.example
  2. 4 0
      deployments/docker/docker-compose.yml
  3. 1 0
      scripts/migrate_all.py
  4. 36 0
      services/model-gateway-service/alembic.ini
  5. 51 0
      services/model-gateway-service/alembic/env.py
  6. 1 0
      services/model-gateway-service/alembic/versions/.gitkeep
  7. 56 0
      services/model-gateway-service/alembic/versions/20260428_0001_init_model_gateway_models.py
  8. 86 0
      services/model-gateway-service/app/api/routes.py
  9. 116 3
      services/model-gateway-service/app/application/services.py
  10. 2 0
      services/model-gateway-service/app/bootstrap/app.py
  11. 1 0
      services/model-gateway-service/app/bootstrap/settings.py
  12. 3 0
      services/model-gateway-service/app/db/__init__.py
  13. 23 0
      services/model-gateway-service/app/db/models.py
  14. 28 0
      services/model-gateway-service/app/db/session.py
  15. 3 0
      services/model-gateway-service/app/domain/__init__.py
  16. 53 0
      services/model-gateway-service/app/domain/repositories.py
  17. 16 5
      services/model-gateway-service/app/infrastructure/provider.py
  18. 17 0
      services/model-gateway-service/app/schemas/__init__.py
  19. 103 0
      services/model-gateway-service/app/schemas/model.py
  20. 2 0
      services/model-gateway-service/pyproject.toml
  21. 3 0
      web/src/App.tsx
  22. 1 0
      web/src/api/index.ts
  23. 127 0
      web/src/api/mock.ts
  24. 38 0
      web/src/api/models.ts
  25. 3 0
      web/src/lib/constants.ts
  26. 442 0
      web/src/pages/models/ModelsPage.tsx
  27. 1 0
      web/src/types/index.ts
  28. 61 0
      web/src/types/model.ts

+ 1 - 0
deployments/docker/.env.example

@@ -8,6 +8,7 @@ AGENT_PLATFORM_WORKFLOW_DATABASE_URL=postgresql+psycopg://admin:hFOvG5UBeK5KIGhz
 AGENT_PLATFORM_SESSION_DATABASE_URL=postgresql+psycopg://admin:hFOvG5UBeK5KIGhz5cQH@postgres:5432/session_service
 AGENT_PLATFORM_RUNTIME_DATABASE_URL=postgresql+psycopg://admin:hFOvG5UBeK5KIGhz5cQH@postgres:5432/runtime_service
 AGENT_PLATFORM_TOOL_DATABASE_URL=postgresql+psycopg://admin:hFOvG5UBeK5KIGhz5cQH@postgres:5432/tool_service
+AGENT_PLATFORM_MODEL_GATEWAY_DATABASE_URL=postgresql+psycopg://admin:hFOvG5UBeK5KIGhz5cQH@postgres:5432/model_gateway
 AGENT_PLATFORM_AGENT_DATABASE_URL=postgresql+psycopg://admin:hFOvG5UBeK5KIGhz5cQH@postgres:5432/agent_service
 AGENT_PLATFORM_MEMORY_DATABASE_URL=postgresql+psycopg://admin:hFOvG5UBeK5KIGhz5cQH@postgres:5432/memory_service
 AGENT_PLATFORM_TEAM_DATABASE_URL=postgresql+psycopg://admin:hFOvG5UBeK5KIGhz5cQH@postgres:5432/team_service

+ 4 - 0
deployments/docker/docker-compose.yml

@@ -162,11 +162,14 @@ services:
     command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8005"]
     environment:
       <<: *agent-platform-common-env
+      AGENT_PLATFORM_DATABASE_URL: ${AGENT_PLATFORM_MODEL_GATEWAY_DATABASE_URL:-sqlite:////data/model_gateway_service.db}
       AGENT_PLATFORM_PROVIDER_BASE_URL: ${AGENT_PLATFORM_PROVIDER_BASE_URL:-http://host.docker.internal:11434/v1}
       AGENT_PLATFORM_PROVIDER_API_KEY: ${AGENT_PLATFORM_PROVIDER_API_KEY:-}
       AGENT_PLATFORM_DEFAULT_MODEL: ${AGENT_PLATFORM_DEFAULT_MODEL:-}
     ports:
       - "8005:8005"
+    volumes:
+      - model_gateway_service_data:/data
     healthcheck:
       test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8005/models/health').read()"]
       interval: 15s
@@ -685,3 +688,4 @@ volumes:
   session_service_data:
   runtime_service_data:
   tool_service_data:
+  model_gateway_service_data:

+ 1 - 0
scripts/migrate_all.py

@@ -12,6 +12,7 @@ DEFAULT_SERVICE_ORDER = [
     "session-service",
     "tool-service",
     "runtime-service",
+    "model-gateway-service",
     "memory-service",
     "skill-service",
     "agent-service",

+ 36 - 0
services/model-gateway-service/alembic.ini

@@ -0,0 +1,36 @@
+[alembic]
+script_location = alembic
+prepend_sys_path = .
+sqlalchemy.url = sqlite:///./model_gateway_service.db
+
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers = console
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s

+ 51 - 0
services/model-gateway-service/alembic/env.py

@@ -0,0 +1,51 @@
+from logging.config import fileConfig
+
+from alembic import context
+from app.bootstrap.settings import ModelGatewayServiceSettings
+from app.db.models import Base
+from sqlalchemy import engine_from_config, pool
+
+config = context.config
+
+if config.config_file_name is not None:
+    fileConfig(config.config_file_name)
+
+config.set_main_option("sqlalchemy.url", ModelGatewayServiceSettings().database_url)
+target_metadata = Base.metadata
+
+
+def run_migrations_offline() -> None:
+    url = config.get_main_option("sqlalchemy.url")
+    context.configure(
+        url=url,
+        target_metadata=target_metadata,
+        literal_binds=True,
+        version_table="model_gateway_alembic_version",
+    )
+
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+def run_migrations_online() -> None:
+    connectable = engine_from_config(
+        config.get_section(config.config_ini_section, {}),
+        prefix="sqlalchemy.",
+        poolclass=pool.NullPool,
+    )
+
+    with connectable.connect() as connection:
+        context.configure(
+            connection=connection,
+            target_metadata=target_metadata,
+            version_table="model_gateway_alembic_version",
+        )
+
+        with context.begin_transaction():
+            context.run_migrations()
+
+
+if context.is_offline_mode():
+    run_migrations_offline()
+else:
+    run_migrations_online()

+ 1 - 0
services/model-gateway-service/alembic/versions/.gitkeep

@@ -0,0 +1 @@
+

+ 56 - 0
services/model-gateway-service/alembic/versions/20260428_0001_init_model_gateway_models.py

@@ -0,0 +1,56 @@
+"""init model gateway models
+
+Revision ID: 20260428_0001
+Revises:
+Create Date: 2026-04-28 10:00:00
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260428_0001"
+down_revision: str | None = None
+branch_labels: Sequence[str] | None = None
+depends_on: Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "model_definition",
+        sa.Column("code", sa.String(length=64), nullable=False),
+        sa.Column("name", sa.String(length=128), nullable=False),
+        sa.Column("provider_type", sa.String(length=32), nullable=False),
+        sa.Column("provider_base_url", sa.String(length=512), nullable=False),
+        sa.Column("provider_api_key", sa.Text(), nullable=True),
+        sa.Column("model_name", sa.String(length=128), nullable=False),
+        sa.Column("status", sa.String(length=32), nullable=False),
+        sa.Column("description", sa.Text(), nullable=True),
+        sa.Column("capabilities_json", sa.JSON(), nullable=False),
+        sa.Column("context_window", sa.Integer(), nullable=True),
+        sa.Column("max_output_tokens", sa.Integer(), nullable=True),
+        sa.Column("default_temperature", sa.Float(), nullable=True),
+        sa.Column("timeout_seconds", sa.Float(), nullable=False),
+        sa.Column("metadata_json", sa.JSON(), nullable=True),
+        sa.Column("id", sa.String(length=36), nullable=False),
+        sa.Column("created_by", sa.String(length=36), nullable=True),
+        sa.Column("updated_by", sa.String(length=36), nullable=True),
+        sa.Column("created_time", sa.DateTime(), nullable=False),
+        sa.Column("updated_time", sa.DateTime(), nullable=False),
+        sa.Column("deleted_time", sa.DateTime(), nullable=True),
+        sa.Column("version", sa.Integer(), nullable=False),
+        sa.PrimaryKeyConstraint("id"),
+    )
+    op.create_index("ix_model_definition_code", "model_definition", ["code"], unique=True)
+    op.create_index("ix_model_definition_model_name", "model_definition", ["model_name"])
+    op.create_index("ix_model_definition_provider_type", "model_definition", ["provider_type"])
+    op.create_index("ix_model_definition_status", "model_definition", ["status"])
+
+
+def downgrade() -> None:
+    op.drop_index("ix_model_definition_status", table_name="model_definition")
+    op.drop_index("ix_model_definition_provider_type", table_name="model_definition")
+    op.drop_index("ix_model_definition_model_name", table_name="model_definition")
+    op.drop_index("ix_model_definition_code", table_name="model_definition")
+    op.drop_table("model_definition")

+ 86 - 0
services/model-gateway-service/app/api/routes.py

@@ -1,9 +1,21 @@
 from core_domain import ChatCompletionRequestContract, ChatCompletionResponseContract, ServiceHealth
 from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import text
+from sqlalchemy.orm import Session
 
 from app.application.services import ModelGatewayApplicationService
 from app.bootstrap.settings import ModelGatewayServiceSettings
+from app.db.session import get_db
+from app.domain.repositories import ModelDefinitionRepository
 from app.infrastructure.provider import ModelProviderClient, ModelProviderClientError
+from app.schemas.model import (
+    ModelCreateRequest,
+    ModelResponse,
+    ModelStatusUpdateRequest,
+    ModelTestRequest,
+    ModelTestResponse,
+    ModelUpdateRequest,
+)
 
 router = APIRouter()
 
@@ -13,19 +25,93 @@ def get_model_gateway_settings() -> ModelGatewayServiceSettings:
 
 
 def get_model_gateway_application_service(
+    db: Session = Depends(get_db),
     settings: ModelGatewayServiceSettings = Depends(get_model_gateway_settings)) -> ModelGatewayApplicationService:
     return ModelGatewayApplicationService(
+        model_repository=ModelDefinitionRepository(db),
         provider_client=ModelProviderClient(settings=settings),
         settings=settings)
 
 
 @router.get("/health", response_model=ServiceHealth)
 def health_check(
+    db: Session = Depends(get_db),
     settings: ModelGatewayServiceSettings = Depends(get_model_gateway_settings)) -> ServiceHealth:
+    db.execute(text("SELECT 1"))
     provider_status = "configured" if settings.provider_base_url else "missing"
     return ServiceHealth(service="model-gateway-service", status="ok", database=provider_status)
 
 
+@router.post("", response_model=ModelResponse)
+def create_model(
+    payload: ModelCreateRequest,
+    service: ModelGatewayApplicationService = Depends(get_model_gateway_application_service),
+) -> ModelResponse:
+    try:
+        entity = service.create_model(payload)
+    except ValueError as exc:
+        raise HTTPException(status_code=422, detail=str(exc)) from exc
+    return ModelResponse.from_entity(entity)
+
+
+@router.get("", response_model=list[ModelResponse])
+def list_models(
+    service: ModelGatewayApplicationService = Depends(get_model_gateway_application_service),
+) -> list[ModelResponse]:
+    return [ModelResponse.from_entity(item) for item in service.list_models()]
+
+
+@router.patch("/{model_id}", response_model=ModelResponse)
+def update_model(
+    model_id: str,
+    payload: ModelUpdateRequest,
+    service: ModelGatewayApplicationService = Depends(get_model_gateway_application_service),
+) -> ModelResponse:
+    try:
+        entity = service.update_model(model_id=model_id, payload=payload)
+    except ValueError as exc:
+        raise HTTPException(status_code=422, detail=str(exc)) from exc
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"model not found: {model_id}")
+    return ModelResponse.from_entity(entity)
+
+
+@router.patch("/{model_id}/status", response_model=ModelResponse)
+def update_model_status(
+    model_id: str,
+    payload: ModelStatusUpdateRequest,
+    service: ModelGatewayApplicationService = Depends(get_model_gateway_application_service),
+) -> ModelResponse:
+    entity = service.update_model_status(model_id=model_id, payload=payload)
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"model not found: {model_id}")
+    return ModelResponse.from_entity(entity)
+
+
+@router.delete("/{model_id}", status_code=204)
+def delete_model(
+    model_id: str,
+    service: ModelGatewayApplicationService = Depends(get_model_gateway_application_service),
+) -> None:
+    if not service.delete_model(model_id):
+        raise HTTPException(status_code=404, detail=f"model not found: {model_id}")
+
+
+@router.post("/{model_id}/test", response_model=ModelTestResponse)
+def test_model(
+    model_id: str,
+    payload: ModelTestRequest,
+    service: ModelGatewayApplicationService = Depends(get_model_gateway_application_service),
+) -> ModelTestResponse:
+    try:
+        result = service.test_model(model_id=model_id, payload=payload)
+    except ModelProviderClientError as exc:
+        raise HTTPException(status_code=502, detail=str(exc)) from exc
+    if result is None:
+        raise HTTPException(status_code=404, detail=f"model not found: {model_id}")
+    return result
+
+
 @router.post("/chat-completions", response_model=ChatCompletionResponseContract)
 def create_chat_completion(
     payload: ChatCompletionRequestContract,

+ 116 - 3
services/model-gateway-service/app/application/services.py

@@ -1,24 +1,137 @@
 from core_domain import ChatCompletionRequestContract, ChatCompletionResponseContract
 
 from app.bootstrap.settings import ModelGatewayServiceSettings
+from app.db.models import ModelDefinition
+from app.domain.repositories import ModelDefinitionRepository
 from app.infrastructure.provider import ModelProviderClient
+from app.schemas.model import (
+    ModelCreateRequest,
+    ModelStatusUpdateRequest,
+    ModelTestRequest,
+    ModelTestResponse,
+    ModelUpdateRequest,
+)
 
 
 class ModelGatewayApplicationService:
     def __init__(
         self,
         *,
+        model_repository: ModelDefinitionRepository,
         provider_client: ModelProviderClient,
         settings: ModelGatewayServiceSettings) -> None:
+        self.model_repository = model_repository
         self.provider_client = provider_client
         self.settings = settings
 
+    def create_model(self, payload: ModelCreateRequest) -> ModelDefinition:
+        if self.model_repository.get_by_code(payload.code) is not None:
+            raise ValueError(f"model code already exists: {payload.code}")
+        return self.model_repository.create(
+            ModelDefinition(
+                code=payload.code,
+                name=payload.name,
+                provider_type=payload.provider_type,
+                provider_base_url=str(payload.provider_base_url),
+                provider_api_key=payload.provider_api_key,
+                model_name=payload.model_name,
+                status=payload.status,
+                description=payload.description,
+                capabilities_json=payload.capabilities_json,
+                context_window=payload.context_window,
+                max_output_tokens=payload.max_output_tokens,
+                default_temperature=payload.default_temperature,
+                timeout_seconds=payload.timeout_seconds,
+                metadata_json=payload.metadata_json,
+            )
+        )
+
+    def list_models(self) -> list[ModelDefinition]:
+        return self.model_repository.list_all()
+
+    def update_model(self, model_id: str, payload: ModelUpdateRequest) -> ModelDefinition | None:
+        entity = self.model_repository.get_by_id(model_id)
+        if entity is None:
+            return None
+        updates = payload.model_dump(exclude_unset=True)
+        if "code" in updates and updates["code"] != entity.code:
+            existing = self.model_repository.get_by_code(str(updates["code"]))
+            if existing is not None and existing.id != entity.id:
+                raise ValueError(f"model code already exists: {updates['code']}")
+        for key, value in updates.items():
+            if key == "provider_base_url" and value is not None:
+                value = str(value)
+            setattr(entity, key, value)
+        return self.model_repository.update(entity)
+
+    def update_model_status(
+        self,
+        model_id: str,
+        payload: ModelStatusUpdateRequest,
+    ) -> ModelDefinition | None:
+        entity = self.model_repository.get_by_id(model_id)
+        if entity is None:
+            return None
+        entity.status = payload.status
+        return self.model_repository.update(entity)
+
+    def delete_model(self, model_id: str) -> bool:
+        entity = self.model_repository.get_by_id(model_id)
+        if entity is None:
+            return False
+        self.model_repository.delete(entity)
+        return True
+
     def create_chat_completion(
         self,
         payload: ChatCompletionRequestContract) -> ChatCompletionResponseContract:
+        configured_model = None
+        if payload.model:
+            configured_model = self.model_repository.get_active_for_request(payload.model)
+
+        if configured_model is not None:
+            resolved_payload = payload.model_copy(
+                update={
+                    "model": configured_model.model_name,
+                    "temperature": payload.temperature
+                    if payload.temperature is not None
+                    else configured_model.default_temperature,
+                    "max_tokens": payload.max_tokens or configured_model.max_output_tokens,
+                }
+            )
+            return self.provider_client.create_chat_completion(
+                resolved_payload,
+                provider_base_url=configured_model.provider_base_url,
+                provider_api_key=configured_model.provider_api_key,
+                timeout_seconds=configured_model.timeout_seconds,
+            )
+
         resolved_payload = payload.model_copy(
-            update={
-                "model": payload.model or self.settings.default_model,
-            }
+            update={"model": payload.model or self.settings.default_model}
         )
         return self.provider_client.create_chat_completion(resolved_payload)
+
+    def test_model(self, model_id: str, payload: ModelTestRequest) -> ModelTestResponse | None:
+        entity = self.model_repository.get_by_id(model_id)
+        if entity is None:
+            return None
+        messages = []
+        if payload.system_prompt:
+            messages.append({"role": "system", "content": payload.system_prompt})
+        messages.append({"role": "user", "content": payload.prompt})
+        response = self.provider_client.create_chat_completion(
+            ChatCompletionRequestContract(
+                model=entity.model_name,
+                messages=messages,
+                temperature=payload.temperature
+                if payload.temperature is not None
+                else entity.default_temperature,
+                max_tokens=payload.max_tokens or entity.max_output_tokens,
+            ),
+            provider_base_url=entity.provider_base_url,
+            provider_api_key=entity.provider_api_key,
+            timeout_seconds=entity.timeout_seconds,
+        )
+        from app.schemas.model import ModelResponse
+
+        return ModelTestResponse(model=ModelResponse.from_entity(entity), response=response)

+ 2 - 0
services/model-gateway-service/app/bootstrap/app.py

@@ -4,6 +4,7 @@ from fastapi import FastAPI
 
 from app.api.routes import router
 from app.bootstrap.settings import ModelGatewayServiceSettings
+from app.db.session import build_session_factory
 
 
 def create_app() -> FastAPI:
@@ -12,6 +13,7 @@ def create_app() -> FastAPI:
         title="agent-platform model-gateway-service",
         version="0.1.0")
     app.state.settings = settings
+    app.state.session_factory = build_session_factory(settings)
     add_observability(app, settings.service_name)
     add_internal_service_auth(app, settings)
     app.include_router(router, prefix="/models", tags=["models"])

+ 1 - 0
services/model-gateway-service/app/bootstrap/settings.py

@@ -4,6 +4,7 @@ from core_shared import ServiceSettings
 class ModelGatewayServiceSettings(ServiceSettings):
     service_name: str = "model-gateway-service"
     service_port: int = 8005
+    database_url: str = "sqlite:///./model_gateway_service.db"
     provider_type: str = "openai_compatible"
     provider_base_url: str = "http://127.0.0.1:11434/v1"
     provider_api_key: str | None = None

+ 3 - 0
services/model-gateway-service/app/db/__init__.py

@@ -0,0 +1,3 @@
+from .models import Base, ModelDefinition
+
+__all__ = ["Base", "ModelDefinition"]

+ 23 - 0
services/model-gateway-service/app/db/models.py

@@ -0,0 +1,23 @@
+from core_db import AuditMixin, Base, EntityMixin, VersionMixin
+from sqlalchemy import Float, Integer, String, Text
+from sqlalchemy.dialects.sqlite import JSON
+from sqlalchemy.orm import Mapped, mapped_column
+
+
+class ModelDefinition(EntityMixin, AuditMixin, VersionMixin, Base):
+    __tablename__ = "model_definition"
+
+    code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
+    name: Mapped[str] = mapped_column(String(128))
+    provider_type: Mapped[str] = mapped_column(String(32), default="openai_compatible", index=True)
+    provider_base_url: Mapped[str] = mapped_column(String(512))
+    provider_api_key: Mapped[str | None] = mapped_column(Text, nullable=True)
+    model_name: Mapped[str] = mapped_column(String(128), index=True)
+    status: Mapped[str] = mapped_column(String(32), default="active", index=True)
+    description: Mapped[str | None] = mapped_column(Text, nullable=True)
+    capabilities_json: Mapped[list[str]] = mapped_column(JSON, default=list)
+    context_window: Mapped[int | None] = mapped_column(Integer, nullable=True)
+    max_output_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
+    default_temperature: Mapped[float | None] = mapped_column(Float, nullable=True)
+    timeout_seconds: Mapped[float] = mapped_column(Float, default=60.0)
+    metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)

+ 28 - 0
services/model-gateway-service/app/db/session.py

@@ -0,0 +1,28 @@
+from collections.abc import Generator
+
+from core_db import DatabaseSettings, create_engine_from_settings, create_session_factory
+from fastapi import Request
+from sqlalchemy.orm import Session, sessionmaker
+
+from app.bootstrap.settings import ModelGatewayServiceSettings
+
+
+def build_session_factory(
+    settings: ModelGatewayServiceSettings | None = None,
+) -> sessionmaker[Session]:
+    resolved_settings = settings or ModelGatewayServiceSettings()
+    db_settings = DatabaseSettings(
+        database_url=resolved_settings.database_url,
+        echo_sql=resolved_settings.echo_sql,
+    )
+    engine = create_engine_from_settings(db_settings)
+    return create_session_factory(engine)
+
+
+def get_db(request: Request) -> Generator[Session, None, None]:
+    session_factory: sessionmaker[Session] = request.app.state.session_factory
+    session = session_factory()
+    try:
+        yield session
+    finally:
+        session.close()

+ 3 - 0
services/model-gateway-service/app/domain/__init__.py

@@ -0,0 +1,3 @@
+from .repositories import ModelDefinitionRepository
+
+__all__ = ["ModelDefinitionRepository"]

+ 53 - 0
services/model-gateway-service/app/domain/repositories.py

@@ -0,0 +1,53 @@
+from datetime import datetime
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.db.models import ModelDefinition
+
+
+class ModelDefinitionRepository:
+    def __init__(self, db: Session) -> None:
+        self.db = db
+
+    def create(self, entity: ModelDefinition) -> ModelDefinition:
+        self.db.add(entity)
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity
+
+    def list_all(self) -> list[ModelDefinition]:
+        stmt = select(ModelDefinition).order_by(
+            ModelDefinition.status.asc(),
+            ModelDefinition.name.asc(),
+        )
+        return list(self.db.scalars(stmt))
+
+    def get_by_id(self, model_id: str) -> ModelDefinition | None:
+        return self.db.get(ModelDefinition, model_id)
+
+    def get_by_code(self, code: str) -> ModelDefinition | None:
+        stmt = select(ModelDefinition).where(ModelDefinition.code == code).limit(1)
+        return self.db.scalar(stmt)
+
+    def get_active_for_request(self, model: str) -> ModelDefinition | None:
+        stmt = (
+            select(ModelDefinition)
+            .where(ModelDefinition.status == "active")
+            .where(
+                (ModelDefinition.code == model)
+                | (ModelDefinition.model_name == model)
+            )
+            .limit(1)
+        )
+        return self.db.scalar(stmt)
+
+    def update(self, entity: ModelDefinition) -> ModelDefinition:
+        entity.updated_time = datetime.utcnow()
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity
+
+    def delete(self, entity: ModelDefinition) -> None:
+        self.db.delete(entity)
+        self.db.commit()

+ 16 - 5
services/model-gateway-service/app/infrastructure/provider.py

@@ -15,7 +15,12 @@ class ModelProviderClient:
 
     def create_chat_completion(
         self,
-        payload: ChatCompletionRequestContract) -> ChatCompletionResponseContract:
+        payload: ChatCompletionRequestContract,
+        *,
+        provider_base_url: str | None = None,
+        provider_api_key: str | None = None,
+        timeout_seconds: float = 60.0,
+    ) -> ChatCompletionResponseContract:
         if payload.model is None:
             raise ModelProviderClientError("model is required for chat completion")
 
@@ -33,13 +38,19 @@ class ModelProviderClient:
             request_payload["tool_choice"] = payload.tool_choice
 
         request_headers: dict[str, str] = {"content-type": "application/json"}
-        if self.settings.provider_api_key:
-            request_headers["authorization"] = f"Bearer {self.settings.provider_api_key}"
+        api_key = (
+            provider_api_key
+            if provider_api_key is not None
+            else self.settings.provider_api_key
+        )
+        if api_key:
+            request_headers["authorization"] = f"Bearer {api_key}"
 
         try:
-            with httpx.Client(timeout=60.0) as client:
+            base_url = provider_base_url or self.settings.provider_base_url
+            with httpx.Client(timeout=timeout_seconds) as client:
                 response = client.post(
-                    f"{self.settings.provider_base_url.rstrip('/')}/chat/completions",
+                    f"{base_url.rstrip('/')}/chat/completions",
                     json=request_payload,
                     headers=request_headers)
                 response.raise_for_status()

+ 17 - 0
services/model-gateway-service/app/schemas/__init__.py

@@ -0,0 +1,17 @@
+from .model import (
+    ModelCreateRequest,
+    ModelResponse,
+    ModelStatusUpdateRequest,
+    ModelTestRequest,
+    ModelTestResponse,
+    ModelUpdateRequest,
+)
+
+__all__ = [
+    "ModelCreateRequest",
+    "ModelResponse",
+    "ModelStatusUpdateRequest",
+    "ModelTestRequest",
+    "ModelTestResponse",
+    "ModelUpdateRequest",
+]

+ 103 - 0
services/model-gateway-service/app/schemas/model.py

@@ -0,0 +1,103 @@
+from datetime import datetime
+from typing import TYPE_CHECKING, Literal
+
+from core_domain import ChatCompletionResponseContract
+from core_shared import JSONValue
+from pydantic import BaseModel, Field, HttpUrl
+
+if TYPE_CHECKING:
+    from app.db.models import ModelDefinition
+
+ModelStatus = Literal["active", "disabled"]
+
+
+class ModelCreateRequest(BaseModel):
+    code: str = Field(min_length=1, max_length=64)
+    name: str = Field(min_length=1, max_length=128)
+    provider_type: str = "openai_compatible"
+    provider_base_url: HttpUrl | str
+    provider_api_key: str | None = None
+    model_name: str = Field(min_length=1, max_length=128)
+    status: ModelStatus = "active"
+    description: str | None = None
+    capabilities_json: list[str] = Field(default_factory=lambda: ["chat"])
+    context_window: int | None = Field(default=None, ge=1)
+    max_output_tokens: int | None = Field(default=None, ge=1)
+    default_temperature: float | None = Field(default=None, ge=0, le=2)
+    timeout_seconds: float = Field(default=60.0, ge=1, le=300)
+    metadata_json: dict[str, JSONValue] = Field(default_factory=dict)
+
+
+class ModelUpdateRequest(BaseModel):
+    code: str | None = Field(default=None, min_length=1, max_length=64)
+    name: str | None = Field(default=None, min_length=1, max_length=128)
+    provider_type: str | None = None
+    provider_base_url: HttpUrl | str | None = None
+    provider_api_key: str | None = None
+    model_name: str | None = Field(default=None, min_length=1, max_length=128)
+    status: ModelStatus | None = None
+    description: str | None = None
+    capabilities_json: list[str] | None = None
+    context_window: int | None = Field(default=None, ge=1)
+    max_output_tokens: int | None = Field(default=None, ge=1)
+    default_temperature: float | None = Field(default=None, ge=0, le=2)
+    timeout_seconds: float | None = Field(default=None, ge=1, le=300)
+    metadata_json: dict[str, JSONValue] | None = None
+
+
+class ModelStatusUpdateRequest(BaseModel):
+    status: ModelStatus
+
+
+class ModelResponse(BaseModel):
+    id: str
+    code: str
+    name: str
+    provider_type: str
+    provider_base_url: str
+    has_provider_api_key: bool
+    model_name: str
+    status: ModelStatus
+    description: str | None = None
+    capabilities_json: list[str] = Field(default_factory=list)
+    context_window: int | None = None
+    max_output_tokens: int | None = None
+    default_temperature: float | None = None
+    timeout_seconds: float
+    metadata_json: dict[str, JSONValue] | None = None
+    created_time: datetime
+    updated_time: datetime
+
+    @classmethod
+    def from_entity(cls, entity: "ModelDefinition") -> "ModelResponse":
+        return cls(
+            id=entity.id,
+            code=entity.code,
+            name=entity.name,
+            provider_type=entity.provider_type,
+            provider_base_url=entity.provider_base_url,
+            has_provider_api_key=bool(entity.provider_api_key),
+            model_name=entity.model_name,
+            status=entity.status,
+            description=entity.description,
+            capabilities_json=list(entity.capabilities_json or []),
+            context_window=entity.context_window,
+            max_output_tokens=entity.max_output_tokens,
+            default_temperature=entity.default_temperature,
+            timeout_seconds=entity.timeout_seconds,
+            metadata_json=entity.metadata_json,
+            created_time=entity.created_time,
+            updated_time=entity.updated_time,
+        )
+
+
+class ModelTestRequest(BaseModel):
+    prompt: str = Field(default="Reply with a short readiness check.", min_length=1)
+    system_prompt: str | None = "You are a concise model connectivity checker."
+    temperature: float | None = Field(default=None, ge=0, le=2)
+    max_tokens: int | None = Field(default=128, ge=1)
+
+
+class ModelTestResponse(BaseModel):
+    model: ModelResponse
+    response: ChatCompletionResponseContract

+ 2 - 0
services/model-gateway-service/pyproject.toml

@@ -11,7 +11,9 @@ dependencies = [
   "fastapi>=0.111,<1.0",
   "httpx>=0.27,<1.0",
   "pydantic>=2.7,<3.0",
+  "sqlalchemy>=2.0,<3.0",
   "uvicorn[standard]>=0.30,<1.0",
+  "core-db",
   "core-domain",
   "core-shared",
 ]

+ 3 - 0
web/src/App.tsx

@@ -14,6 +14,7 @@ const WorkflowEditorPage = lazy(() => import("@/pages/workflow-editor/WorkflowEd
 const AgentListPage = lazy(() => import("@/pages/agents/AgentListPage").then((module) => ({ default: module.AgentListPage })));
 const SessionChatPage = lazy(() => import("@/pages/sessions/SessionChatPage").then((module) => ({ default: module.SessionChatPage })));
 const ToolsPage = lazy(() => import("@/pages/tools/ToolsPage").then((module) => ({ default: module.ToolsPage })));
+const ModelsPage = lazy(() => import("@/pages/models/ModelsPage").then((module) => ({ default: module.ModelsPage })));
 const KnowledgePage = lazy(() => import("@/pages/knowledge/KnowledgePage").then((module) => ({ default: module.KnowledgePage })));
 const TeamsPage = lazy(() => import("@/pages/teams/TeamsPage").then((module) => ({ default: module.TeamsPage })));
 const SettingsPage = lazy(() => import("@/pages/settings/SettingsPage").then((module) => ({ default: module.SettingsPage })));
@@ -41,6 +42,7 @@ export default function App() {
               <Route path="/agents" element={<AgentListPage />} />
               <Route path="/sessions" element={<SessionChatPage />} />
               <Route path="/tools" element={<ToolsPage />} />
+              <Route path="/models" element={<ModelsPage />} />
               <Route path="/knowledge" element={<KnowledgePage />} />
               <Route path="/knowledge/:section" element={<KnowledgePage />} />
               <Route path="/teams" element={<TeamsPage />} />
@@ -73,6 +75,7 @@ function RoutePreloader() {
         import("@/pages/agents/AgentListPage"),
         import("@/pages/sessions/SessionChatPage"),
         import("@/pages/tools/ToolsPage"),
+        import("@/pages/models/ModelsPage"),
         import("@/pages/knowledge/KnowledgePage"),
         import("@/pages/teams/TeamsPage"),
         import("@/pages/settings/SettingsPage"),

+ 1 - 0
web/src/api/index.ts

@@ -9,3 +9,4 @@ export * from "./runtime";
 export * from "./tools";
 export * from "./knowledge";
 export * from "./teams";
+export * from "./models";

+ 127 - 0
web/src/api/mock.ts

@@ -14,6 +14,8 @@ import type {
   KnowledgeDocument,
   LoginResponse,
   Message,
+  ModelDefinition,
+  ModelTestResponse,
   NodeRun,
   SearchResult,
   ServiceHealth,
@@ -199,6 +201,47 @@ const agentRuns: AgentRun[] = [
   },
 ];
 
+const models: ModelDefinition[] = [
+  {
+    id: "model_llama_local",
+    code: "local_chat",
+    name: "Local Chat",
+    provider_type: "openai_compatible",
+    provider_base_url: "http://127.0.0.1:11434/v1",
+    has_provider_api_key: false,
+    model_name: "llama3.1",
+    status: "active",
+    description: "Local OpenAI-compatible chat endpoint.",
+    capabilities_json: ["chat"],
+    context_window: 8192,
+    max_output_tokens: 1024,
+    default_temperature: 0.2,
+    timeout_seconds: 60,
+    metadata_json: {},
+    created_time: iso(-2600),
+    updated_time: iso(-120),
+  },
+  {
+    id: "model_cloud_primary",
+    code: "cloud_primary",
+    name: "Cloud Primary",
+    provider_type: "openai_compatible",
+    provider_base_url: "https://api.openai.com/v1",
+    has_provider_api_key: true,
+    model_name: "gpt-4.1-mini",
+    status: "disabled",
+    description: "Cloud fallback model configuration.",
+    capabilities_json: ["chat", "tools"],
+    context_window: 128000,
+    max_output_tokens: 4096,
+    default_temperature: 0.3,
+    timeout_seconds: 60,
+    metadata_json: {},
+    created_time: iso(-2100),
+    updated_time: iso(-600),
+  },
+];
+
 const sessions: Session[] = [
   {
     id: "ses_001",
@@ -661,6 +704,90 @@ function route(config: AxiosRequestConfig): unknown {
   if (url === "/runtime/execution-logs") return [] satisfies ExecutionLog[];
   if (url === "/runtime/trace-spans") return [] satisfies TraceSpan[];
 
+  if (url === "/models" && method === "get") return models;
+  if (url === "/models" && method === "post") {
+    const created: ModelDefinition = {
+      id: id("model"),
+      code: String(payload.code ?? "model"),
+      name: String(payload.name ?? "New Model"),
+      provider_type: String(payload.provider_type ?? "openai_compatible"),
+      provider_base_url: String(payload.provider_base_url ?? "http://127.0.0.1:11434/v1"),
+      has_provider_api_key: Boolean(payload.provider_api_key),
+      model_name: String(payload.model_name ?? "llama3.1"),
+      status: String(payload.status ?? "active") as ModelDefinition["status"],
+      description: (payload.description as string | null) ?? null,
+      capabilities_json: Array.isArray(payload.capabilities_json) ? payload.capabilities_json.map(String) : ["chat"],
+      context_window: Number(payload.context_window) || null,
+      max_output_tokens: Number(payload.max_output_tokens) || null,
+      default_temperature: Number(payload.default_temperature) || null,
+      timeout_seconds: Number(payload.timeout_seconds ?? 60),
+      metadata_json: {},
+      created_time: iso(),
+      updated_time: iso(),
+    };
+    models.unshift(created);
+    return created;
+  }
+  if (url.match(/^\/models\/[^/]+$/) && method === "patch") {
+    const modelId = url.split("/")[2];
+    const target = models.find((item) => item.id === modelId);
+    if (!target) return {};
+    Object.assign(target, {
+      ...payload,
+      has_provider_api_key: payload.provider_api_key ? true : target.has_provider_api_key,
+      updated_time: iso(),
+    });
+    delete (target as ModelDefinition & { provider_api_key?: string }).provider_api_key;
+    return target;
+  }
+  if (url.match(/^\/models\/[^/]+\/status$/) && method === "patch") {
+    const modelId = url.split("/")[2];
+    const target = models.find((item) => item.id === modelId);
+    if (!target) return {};
+    target.status = String(payload.status ?? "active") as ModelDefinition["status"];
+    target.updated_time = iso();
+    return target;
+  }
+  if (url.match(/^\/models\/[^/]+$/) && method === "delete") {
+    const modelId = url.split("/")[2];
+    const index = models.findIndex((item) => item.id === modelId);
+    if (index >= 0) models.splice(index, 1);
+    return {};
+  }
+  if (url.match(/^\/models\/[^/]+\/test$/) && method === "post") {
+    const modelId = url.split("/")[2];
+    const target = models.find((item) => item.id === modelId) ?? models[0] ?? {
+      id: "model_mock",
+      code: "mock",
+      name: "Mock Model",
+      provider_type: "openai_compatible",
+      provider_base_url: "http://127.0.0.1:11434/v1",
+      has_provider_api_key: false,
+      model_name: "mock-model",
+      status: "active",
+      description: null,
+      capabilities_json: ["chat"],
+      context_window: null,
+      max_output_tokens: null,
+      default_temperature: null,
+      timeout_seconds: 60,
+      metadata_json: {},
+      created_time: iso(),
+      updated_time: iso(),
+    } satisfies ModelDefinition;
+    return {
+      model: target,
+      response: {
+        model: target?.model_name ?? "mock-model",
+        content: `Mock response from ${target?.name ?? "model"}: ${String(payload.prompt ?? "OK")}`,
+        finish_reason: "stop",
+        tool_calls_json: [],
+        usage_json: { prompt_tokens: 12, completion_tokens: 18, total_tokens: 30 },
+        raw_response_json: { mocked: true },
+      },
+    } satisfies ModelTestResponse;
+  }
+
   if (url === "/tools" && method === "get") return tools;
   if (url === "/tools" && method === "post") {
     const created: ToolDefinition = {

+ 38 - 0
web/src/api/models.ts

@@ -0,0 +1,38 @@
+import { apiClient } from "./client";
+import type {
+  ModelCreateRequest,
+  ModelDefinition,
+  ModelStatus,
+  ModelTestRequest,
+  ModelTestResponse,
+  ModelUpdateRequest,
+} from "@/types";
+
+export async function listModels() {
+  const { data } = await apiClient.get<ModelDefinition[]>("/models");
+  return data;
+}
+
+export async function createModel(payload: ModelCreateRequest) {
+  const { data } = await apiClient.post<ModelDefinition>("/models", payload);
+  return data;
+}
+
+export async function updateModel(modelId: string, payload: ModelUpdateRequest) {
+  const { data } = await apiClient.patch<ModelDefinition>(`/models/${modelId}`, payload);
+  return data;
+}
+
+export async function updateModelStatus(modelId: string, status: ModelStatus) {
+  const { data } = await apiClient.patch<ModelDefinition>(`/models/${modelId}/status`, { status });
+  return data;
+}
+
+export async function deleteModel(modelId: string) {
+  await apiClient.delete(`/models/${modelId}`);
+}
+
+export async function testModel(modelId: string, payload: ModelTestRequest) {
+  const { data } = await apiClient.post<ModelTestResponse>(`/models/${modelId}/test`, payload);
+  return data;
+}

+ 3 - 0
web/src/lib/constants.ts

@@ -4,6 +4,7 @@ import {
   GitBranch,
   LayoutDashboard,
   MessageSquare,
+  Cpu,
   Settings,
   Users,
   Wrench,
@@ -17,6 +18,7 @@ export const ROUTE_PATHS = {
   agents: "/agents",
   sessions: "/sessions",
   tools: "/tools",
+  models: "/models",
   knowledge: "/knowledge",
   teams: "/teams",
   settings: "/settings",
@@ -28,6 +30,7 @@ export const NAV_ITEMS: Array<{ label: string; path: string; icon: LucideIcon }>
   { label: "Agents", path: ROUTE_PATHS.agents, icon: Bot },
   { label: "Sessions", path: ROUTE_PATHS.sessions, icon: MessageSquare },
   { label: "Tools", path: ROUTE_PATHS.tools, icon: Wrench },
+  { label: "Models", path: ROUTE_PATHS.models, icon: Cpu },
   { label: "Knowledge", path: ROUTE_PATHS.knowledge, icon: BookOpen },
   { label: "Teams", path: ROUTE_PATHS.teams, icon: Users },
   { label: "Settings", path: ROUTE_PATHS.settings, icon: Settings },

+ 442 - 0
web/src/pages/models/ModelsPage.tsx

@@ -0,0 +1,442 @@
+import * as React from "react";
+import {
+  Activity,
+  CheckCircle2,
+  Cpu,
+  FlaskConical,
+  Plus,
+  Power,
+  RefreshCw,
+  Save,
+  Trash2,
+} from "lucide-react";
+import {
+  createModel,
+  deleteModel,
+  listModels,
+  testModel,
+  updateModel,
+  updateModelStatus,
+} from "@/api";
+import { ApiErrorState } from "@/components/shared/ApiErrorState";
+import { EmptyState } from "@/components/shared/EmptyState";
+import { EntityListItem } from "@/components/shared/EntityListItem";
+import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
+import { MetricCard } from "@/components/shared/MetricCard";
+import { PageHeader } from "@/components/shared/PageHeader";
+import { SearchInput } from "@/components/shared/SearchInput";
+import { StatusBadge } from "@/components/shared/StatusBadge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Dialog } from "@/components/ui/dialog";
+import { Input, Textarea } from "@/components/ui/input";
+import { Select } from "@/components/ui/select";
+import { toast } from "@/components/ui/toaster";
+import { formatDateTime } from "@/lib/utils";
+import type { ModelCreateRequest, ModelDefinition, ModelStatus } from "@/types";
+
+type ModelFormState = {
+  code: string;
+  name: string;
+  provider_type: string;
+  provider_base_url: string;
+  provider_api_key: string;
+  model_name: string;
+  status: ModelStatus;
+  description: string;
+  capabilities: string;
+  context_window: string;
+  max_output_tokens: string;
+  default_temperature: string;
+  timeout_seconds: string;
+};
+
+const emptyForm: ModelFormState = {
+  code: "",
+  name: "",
+  provider_type: "openai_compatible",
+  provider_base_url: "http://127.0.0.1:11434/v1",
+  provider_api_key: "",
+  model_name: "",
+  status: "active",
+  description: "",
+  capabilities: "chat",
+  context_window: "",
+  max_output_tokens: "",
+  default_temperature: "",
+  timeout_seconds: "60",
+};
+
+export function ModelsPage() {
+  const [models, setModels] = React.useState<ModelDefinition[]>([]);
+  const [selectedId, setSelectedId] = React.useState<string>();
+  const [search, setSearch] = React.useState("");
+  const [statusFilter, setStatusFilter] = React.useState("all");
+  const [loading, setLoading] = React.useState(true);
+  const [saving, setSaving] = React.useState(false);
+  const [testing, setTesting] = React.useState(false);
+  const [error, setError] = React.useState<string>();
+  const [createOpen, setCreateOpen] = React.useState(false);
+  const [form, setForm] = React.useState<ModelFormState>(emptyForm);
+  const [testPrompt, setTestPrompt] = React.useState("Say OK in one short sentence.");
+  const [testOutput, setTestOutput] = React.useState<string>();
+
+  const selected = models.find((model) => model.id === selectedId);
+  const providers = Array.from(new Set(models.map((model) => model.provider_type))).sort();
+  const activeCount = models.filter((model) => model.status === "active").length;
+  const chatReadyCount = models.filter((model) => model.capabilities_json.includes("chat")).length;
+
+  const filtered = models.filter((model) => {
+    const haystack = `${model.name} ${model.code} ${model.model_name} ${model.provider_type}`.toLowerCase();
+    const matchesSearch = haystack.includes(search.toLowerCase());
+    const matchesStatus = statusFilter === "all" || model.status === statusFilter;
+    return matchesSearch && matchesStatus;
+  });
+
+  const load = React.useCallback(async () => {
+    setLoading(true);
+    setError(undefined);
+    try {
+      const data = await listModels();
+      setModels(data);
+      setSelectedId((current) => current ?? data[0]?.id);
+    } catch (err) {
+      setError(err instanceof Error ? err.message : "Failed to load models");
+    } finally {
+      setLoading(false);
+    }
+  }, []);
+
+  React.useEffect(() => {
+    void load();
+  }, [load]);
+
+  React.useEffect(() => {
+    if (selected) setForm(fromModel(selected));
+  }, [selected]);
+
+  async function createFromDialog(payload: ModelCreateRequest) {
+    const created = await createModel(payload);
+    setModels((current) => [created, ...current]);
+    setSelectedId(created.id);
+    setCreateOpen(false);
+    toast.success("Model created");
+  }
+
+  async function saveSelected() {
+    if (!selected) return;
+    setSaving(true);
+    try {
+      const payload = toPayload(form);
+      if (!form.provider_api_key.trim()) delete payload.provider_api_key;
+      const updated = await updateModel(selected.id, payload);
+      setModels((current) => current.map((model) => (model.id === updated.id ? updated : model)));
+      toast.success("Model saved");
+    } catch (err) {
+      toast.error(err instanceof Error ? err.message : "Failed to save model");
+    } finally {
+      setSaving(false);
+    }
+  }
+
+  async function toggleSelected() {
+    if (!selected) return;
+    const nextStatus: ModelStatus = selected.status === "active" ? "disabled" : "active";
+    const updated = await updateModelStatus(selected.id, nextStatus);
+    setModels((current) => current.map((model) => (model.id === updated.id ? updated : model)));
+    toast.success(nextStatus === "active" ? "Model enabled" : "Model disabled");
+  }
+
+  async function deleteSelected() {
+    if (!selected) return;
+    await deleteModel(selected.id);
+    setModels((current) => current.filter((model) => model.id !== selected.id));
+    setSelectedId(models.find((model) => model.id !== selected.id)?.id);
+    setTestOutput(undefined);
+    toast.success("Model deleted");
+  }
+
+  async function runTest() {
+    if (!selected) return;
+    setTesting(true);
+    setTestOutput(undefined);
+    try {
+      const result = await testModel(selected.id, { prompt: testPrompt, max_tokens: 128 });
+      setTestOutput(result.response.content || JSON.stringify(result.response.raw_response_json, null, 2));
+      toast.success("Model test completed");
+    } catch (err) {
+      setTestOutput(err instanceof Error ? err.message : "Model test failed");
+      toast.error("Model test failed");
+    } finally {
+      setTesting(false);
+    }
+  }
+
+  if (loading) return <LoadingSpinner label="Loading models" />;
+  if (error) return <ApiErrorState message={error} onRetry={() => void load()} />;
+
+  return (
+    <div className="space-y-6">
+      <PageHeader
+        title="Models"
+        description="Manage model providers, serving names, defaults, and connectivity tests."
+        actions={
+          <>
+            <Button variant="outline" onClick={() => void load()}>
+              <RefreshCw className="h-4 w-4" /> Refresh
+            </Button>
+            <Button onClick={() => setCreateOpen(true)}>
+              <Plus className="h-4 w-4" /> New Model
+            </Button>
+          </>
+        }
+      />
+
+      <div className="grid gap-4 md:grid-cols-3">
+        <MetricCard label="Models" value={models.length} icon={Cpu} />
+        <MetricCard label="Active" value={activeCount} icon={CheckCircle2} />
+        <MetricCard label="Chat Ready" value={chatReadyCount} icon={Activity} />
+      </div>
+
+      <div className="grid gap-6 xl:grid-cols-[380px_1fr]">
+        <Card>
+          <CardHeader>
+            <CardTitle>Model Catalog</CardTitle>
+            <CardDescription>{filtered.length} of {models.length} shown</CardDescription>
+          </CardHeader>
+          <CardContent className="space-y-4">
+            <SearchInput value={search} onChange={setSearch} placeholder="Search models" />
+            <Select
+              value={statusFilter}
+              onChange={(event) => setStatusFilter(event.target.value)}
+              options={[
+                { value: "all", label: "All statuses" },
+                { value: "active", label: "Active" },
+                { value: "disabled", label: "Disabled" },
+              ]}
+            />
+            {filtered.length ? (
+              <div className="space-y-2">
+                {filtered.map((model) => (
+                  <EntityListItem
+                    key={model.id}
+                    title={model.name}
+                    subtitle={`${model.code} - ${model.model_name}`}
+                    active={model.id === selectedId}
+                    onClick={() => {
+                      setSelectedId(model.id);
+                      setTestOutput(undefined);
+                    }}
+                    meta={<StatusBadge status={model.status} />}
+                  />
+                ))}
+              </div>
+            ) : (
+              <EmptyState icon={Cpu} title="No models" description="Create a model configuration to start routing chat completions." />
+            )}
+          </CardContent>
+        </Card>
+
+        {selected ? (
+          <div className="space-y-6">
+            <Card>
+              <CardHeader>
+                <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
+                  <div>
+                    <CardTitle>{selected.name}</CardTitle>
+                    <CardDescription>
+                      {selected.provider_type} / {selected.model_name}
+                    </CardDescription>
+                  </div>
+                  <div className="flex flex-wrap gap-2">
+                    <Button variant="outline" onClick={() => void toggleSelected()}>
+                      <Power className="h-4 w-4" /> {selected.status === "active" ? "Disable" : "Enable"}
+                    </Button>
+                    <Button variant="destructive" onClick={() => void deleteSelected()}>
+                      <Trash2 className="h-4 w-4" /> Delete
+                    </Button>
+                  </div>
+                </div>
+              </CardHeader>
+              <CardContent>
+                <ModelForm form={form} providers={providers} onChange={setForm} />
+                <div className="mt-5 flex justify-end">
+                  <Button onClick={() => void saveSelected()} disabled={saving}>
+                    <Save className="h-4 w-4" /> {saving ? "Saving" : "Save"}
+                  </Button>
+                </div>
+              </CardContent>
+            </Card>
+
+            <Card>
+              <CardHeader>
+                <CardTitle>Connectivity Test</CardTitle>
+                <CardDescription>Send a short prompt through this exact provider configuration.</CardDescription>
+              </CardHeader>
+              <CardContent className="space-y-4">
+                <Textarea value={testPrompt} onChange={(event) => setTestPrompt(event.target.value)} />
+                <div className="flex justify-end">
+                  <Button onClick={() => void runTest()} disabled={testing || selected.status !== "active"}>
+                    <FlaskConical className="h-4 w-4" /> {testing ? "Testing" : "Test Model"}
+                  </Button>
+                </div>
+                {testOutput ? (
+                  <pre className="max-h-72 overflow-auto rounded-md border border-border bg-muted/40 p-3 text-sm whitespace-pre-wrap">
+                    {testOutput}
+                  </pre>
+                ) : null}
+                <div className="grid gap-2 text-sm text-muted-foreground sm:grid-cols-2">
+                  <span>Updated {formatDateTime(selected.updated_time)}</span>
+                  <span>{selected.has_provider_api_key ? "API key configured" : "No API key configured"}</span>
+                </div>
+              </CardContent>
+            </Card>
+          </div>
+        ) : (
+          <EmptyState icon={Cpu} title="No model selected" description="Select or create a model to edit its configuration." />
+        )}
+      </div>
+
+      <CreateModelDialog
+        open={createOpen}
+        onOpenChange={setCreateOpen}
+        onCreate={createFromDialog}
+        providers={providers}
+      />
+    </div>
+  );
+}
+
+function ModelForm({
+  form,
+  providers,
+  onChange,
+}: {
+  form: ModelFormState;
+  providers: string[];
+  onChange: (form: ModelFormState) => void;
+}) {
+  const set = (key: keyof ModelFormState, value: string) => onChange({ ...form, [key]: value });
+  const providerOptions = Array.from(new Set(["openai_compatible", "openai", "ollama", ...providers])).map((value) => ({
+    value,
+    label: value,
+  }));
+  return (
+    <div className="grid gap-4 md:grid-cols-2">
+      <Field label="Code"><Input value={form.code} onChange={(event) => set("code", event.target.value)} /></Field>
+      <Field label="Name"><Input value={form.name} onChange={(event) => set("name", event.target.value)} /></Field>
+      <Field label="Provider"><Select value={form.provider_type} onChange={(event) => set("provider_type", event.target.value)} options={providerOptions} /></Field>
+      <Field label="Provider Base URL"><Input value={form.provider_base_url} onChange={(event) => set("provider_base_url", event.target.value)} /></Field>
+      <Field label="Model Name"><Input value={form.model_name} onChange={(event) => set("model_name", event.target.value)} /></Field>
+      <Field label="API Key"><Input type="password" value={form.provider_api_key} onChange={(event) => set("provider_api_key", event.target.value)} placeholder="Leave blank to keep unset" /></Field>
+      <Field label="Capabilities"><Input value={form.capabilities} onChange={(event) => set("capabilities", event.target.value)} placeholder="chat, tools, vision" /></Field>
+      <Field label="Status"><Select value={form.status} onChange={(event) => set("status", event.target.value)} options={[{ value: "active", label: "Active" }, { value: "disabled", label: "Disabled" }]} /></Field>
+      <Field label="Context Window"><Input value={form.context_window} onChange={(event) => set("context_window", event.target.value)} inputMode="numeric" /></Field>
+      <Field label="Max Output Tokens"><Input value={form.max_output_tokens} onChange={(event) => set("max_output_tokens", event.target.value)} inputMode="numeric" /></Field>
+      <Field label="Default Temperature"><Input value={form.default_temperature} onChange={(event) => set("default_temperature", event.target.value)} inputMode="decimal" /></Field>
+      <Field label="Timeout Seconds"><Input value={form.timeout_seconds} onChange={(event) => set("timeout_seconds", event.target.value)} inputMode="decimal" /></Field>
+      <div className="md:col-span-2">
+        <Field label="Description"><Textarea value={form.description} onChange={(event) => set("description", event.target.value)} /></Field>
+      </div>
+    </div>
+  );
+}
+
+function CreateModelDialog({
+  open,
+  onOpenChange,
+  onCreate,
+  providers,
+}: {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  onCreate: (payload: ModelCreateRequest) => Promise<void>;
+  providers: string[];
+}) {
+  const [form, setForm] = React.useState<ModelFormState>(emptyForm);
+  const [saving, setSaving] = React.useState(false);
+
+  React.useEffect(() => {
+    if (open) setForm(emptyForm);
+  }, [open]);
+
+  async function submit() {
+    setSaving(true);
+    try {
+      await onCreate(toPayload(form) as ModelCreateRequest);
+    } catch (err) {
+      toast.error(err instanceof Error ? err.message : "Failed to create model");
+    } finally {
+      setSaving(false);
+    }
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange} title="New Model" description="Add an OpenAI-compatible model endpoint." className="max-w-4xl">
+      <ModelForm form={form} providers={providers} onChange={setForm} />
+      <div className="mt-5 flex justify-end">
+        <Button onClick={() => void submit()} disabled={saving}>
+          <Plus className="h-4 w-4" /> {saving ? "Creating" : "Create Model"}
+        </Button>
+      </div>
+    </Dialog>
+  );
+}
+
+function Field({ label, children }: { label: string; children: React.ReactNode }) {
+  return (
+    <label className="space-y-1.5 text-sm font-medium">
+      <span>{label}</span>
+      {children}
+    </label>
+  );
+}
+
+function fromModel(model: ModelDefinition): ModelFormState {
+  return {
+    code: model.code,
+    name: model.name,
+    provider_type: model.provider_type,
+    provider_base_url: model.provider_base_url,
+    provider_api_key: "",
+    model_name: model.model_name,
+    status: model.status,
+    description: model.description ?? "",
+    capabilities: model.capabilities_json.join(", "),
+    context_window: String(model.context_window ?? ""),
+    max_output_tokens: String(model.max_output_tokens ?? ""),
+    default_temperature: String(model.default_temperature ?? ""),
+    timeout_seconds: String(model.timeout_seconds ?? 60),
+  };
+}
+
+function toPayload(form: ModelFormState): ModelCreateRequest {
+  return {
+    code: form.code.trim(),
+    name: form.name.trim(),
+    provider_type: form.provider_type.trim() || "openai_compatible",
+    provider_base_url: form.provider_base_url.trim(),
+    provider_api_key: form.provider_api_key.trim() || null,
+    model_name: form.model_name.trim(),
+    status: form.status,
+    description: form.description.trim() || null,
+    capabilities_json: form.capabilities.split(",").map((item) => item.trim()).filter(Boolean),
+    context_window: parseOptionalInteger(form.context_window),
+    max_output_tokens: parseOptionalInteger(form.max_output_tokens),
+    default_temperature: parseOptionalFloat(form.default_temperature),
+    timeout_seconds: parseOptionalFloat(form.timeout_seconds) ?? 60,
+    metadata_json: {},
+  };
+}
+
+function parseOptionalInteger(value: string) {
+  if (!value.trim()) return null;
+  const parsed = Number.parseInt(value, 10);
+  return Number.isFinite(parsed) ? parsed : null;
+}
+
+function parseOptionalFloat(value: string) {
+  if (!value.trim()) return null;
+  const parsed = Number.parseFloat(value);
+  return Number.isFinite(parsed) ? parsed : null;
+}

+ 1 - 0
web/src/types/index.ts

@@ -3,6 +3,7 @@ export * from "./auth";
 export * from "./api-key";
 export * from "./app";
 export * from "./workflow";
+export * from "./model";
 export * from "./agent";
 export * from "./session";
 export * from "./runtime";

+ 61 - 0
web/src/types/model.ts

@@ -0,0 +1,61 @@
+import type { JSONObject } from "./common";
+
+export type ModelStatus = "active" | "disabled";
+
+export interface ModelDefinition {
+  id: string;
+  code: string;
+  name: string;
+  provider_type: string;
+  provider_base_url: string;
+  has_provider_api_key: boolean;
+  model_name: string;
+  status: ModelStatus;
+  description?: string | null;
+  capabilities_json: string[];
+  context_window?: number | null;
+  max_output_tokens?: number | null;
+  default_temperature?: number | null;
+  timeout_seconds: number;
+  metadata_json?: JSONObject | null;
+  created_time: string;
+  updated_time: string;
+}
+
+export interface ModelCreateRequest {
+  code: string;
+  name: string;
+  provider_type: string;
+  provider_base_url: string;
+  provider_api_key?: string | null;
+  model_name: string;
+  status?: ModelStatus;
+  description?: string | null;
+  capabilities_json?: string[];
+  context_window?: number | null;
+  max_output_tokens?: number | null;
+  default_temperature?: number | null;
+  timeout_seconds?: number;
+  metadata_json?: JSONObject;
+}
+
+export type ModelUpdateRequest = Partial<ModelCreateRequest>;
+
+export interface ModelTestRequest {
+  prompt: string;
+  system_prompt?: string | null;
+  temperature?: number | null;
+  max_tokens?: number | null;
+}
+
+export interface ModelTestResponse {
+  model: ModelDefinition;
+  response: {
+    model?: string | null;
+    content: string;
+    finish_reason?: string | null;
+    tool_calls_json?: JSONObject[];
+    usage_json: JSONObject;
+    raw_response_json: JSONObject;
+  };
+}