agentgear-ai 0.1.16__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. agentgear/__init__.py +18 -0
  2. agentgear/cli/__init__.py +1 -0
  3. agentgear/cli/main.py +125 -0
  4. agentgear/sdk/__init__.py +6 -0
  5. agentgear/sdk/client.py +276 -0
  6. agentgear/sdk/decorators.py +65 -0
  7. agentgear/sdk/integrations/openai.py +52 -0
  8. agentgear/sdk/prompt.py +23 -0
  9. agentgear/sdk/trace.py +59 -0
  10. agentgear/server/__init__.py +1 -0
  11. agentgear/server/app/__init__.py +1 -0
  12. agentgear/server/app/api/__init__.py +1 -0
  13. agentgear/server/app/api/auth.py +156 -0
  14. agentgear/server/app/api/datasets.py +185 -0
  15. agentgear/server/app/api/evaluations.py +69 -0
  16. agentgear/server/app/api/evaluators.py +157 -0
  17. agentgear/server/app/api/llm_models.py +39 -0
  18. agentgear/server/app/api/metrics.py +18 -0
  19. agentgear/server/app/api/projects.py +139 -0
  20. agentgear/server/app/api/prompts.py +227 -0
  21. agentgear/server/app/api/runs.py +75 -0
  22. agentgear/server/app/api/seed.py +106 -0
  23. agentgear/server/app/api/settings.py +135 -0
  24. agentgear/server/app/api/spans.py +56 -0
  25. agentgear/server/app/api/tokens.py +67 -0
  26. agentgear/server/app/api/users.py +116 -0
  27. agentgear/server/app/auth.py +80 -0
  28. agentgear/server/app/config.py +26 -0
  29. agentgear/server/app/db.py +41 -0
  30. agentgear/server/app/deps.py +46 -0
  31. agentgear/server/app/main.py +77 -0
  32. agentgear/server/app/migrations.py +88 -0
  33. agentgear/server/app/models.py +339 -0
  34. agentgear/server/app/schemas.py +343 -0
  35. agentgear/server/app/utils/email.py +30 -0
  36. agentgear/server/app/utils/llm.py +27 -0
  37. agentgear/server/static/assets/index-BAAzXAln.js +121 -0
  38. agentgear/server/static/assets/index-CE45MZx1.css +1 -0
  39. agentgear/server/static/index.html +13 -0
  40. agentgear_ai-0.1.16.dist-info/METADATA +387 -0
  41. agentgear_ai-0.1.16.dist-info/RECORD +44 -0
  42. agentgear_ai-0.1.16.dist-info/WHEEL +4 -0
  43. agentgear_ai-0.1.16.dist-info/entry_points.txt +2 -0
  44. agentgear_ai-0.1.16.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,56 @@
1
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
2
+ from sqlalchemy.orm import Session
3
+
4
+ from agentgear.server.app import schemas
5
+ from agentgear.server.app.config import get_settings
6
+ from agentgear.server.app.db import get_db
7
+ from agentgear.server.app.models import Project, Run, Span
8
+ from agentgear.server.app.deps import require_scopes
9
+
10
+ router = APIRouter(prefix="/api/spans", tags=["spans"])
11
+
12
+
13
+ @router.post("", response_model=schemas.SpanRead, status_code=status.HTTP_201_CREATED)
14
+ def create_span(
15
+ payload: schemas.SpanCreate,
16
+ request: Request,
17
+ db: Session = Depends(get_db),
18
+ _: None = Depends(require_scopes(["runs.write"])),
19
+ ):
20
+ settings = get_settings()
21
+ project = db.query(Project).filter(Project.id == payload.project_id).first()
22
+ if not project:
23
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
24
+ if not settings.local_mode and request.state.project_id and request.state.project_id != project.id:
25
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Project mismatch")
26
+
27
+ run = db.query(Run).filter(Run.id == payload.run_id, Run.project_id == project.id).first()
28
+ if not run:
29
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Run not found")
30
+
31
+ span = Span(
32
+ project_id=project.id,
33
+ run_id=payload.run_id,
34
+ parent_id=payload.parent_id,
35
+ name=payload.name,
36
+ start_time=payload.start_time or run.created_at,
37
+ end_time=payload.end_time,
38
+ latency_ms=payload.latency_ms,
39
+ metadata_=payload.metadata,
40
+ )
41
+ db.add(span)
42
+ db.commit()
43
+ db.refresh(span)
44
+ return span
45
+
46
+
47
+ @router.get("", response_model=list[schemas.SpanRead])
48
+ def list_spans(
49
+ run_id: str | None = Query(default=None),
50
+ db: Session = Depends(get_db),
51
+ ):
52
+ query = db.query(Span)
53
+ if run_id:
54
+ query = query.filter(Span.run_id == run_id)
55
+ spans = query.order_by(Span.start_time.desc()).all()
56
+ return spans
@@ -0,0 +1,67 @@
1
+ from fastapi import APIRouter, Depends, HTTPException, status
2
+ from sqlalchemy.orm import Session
3
+
4
+ from agentgear.server.app import schemas
5
+ from agentgear.server.app.auth import generate_token, hash_token
6
+ from agentgear.server.app.db import get_db
7
+ from agentgear.server.app.deps import require_project, require_scopes
8
+ from agentgear.server.app.models import APIKey, Project
9
+
10
+ router = APIRouter(prefix="/api/projects/{project_id}/tokens", tags=["tokens"])
11
+
12
+
13
+ @router.post("", response_model=schemas.TokenWithSecret, status_code=status.HTTP_201_CREATED)
14
+ def create_token(
15
+ project_id: str,
16
+ project: Project = Depends(require_project),
17
+ payload: schemas.TokenCreate = None,
18
+ db: Session = Depends(get_db),
19
+ _: None = Depends(require_scopes(["tokens.manage"])),
20
+ ):
21
+ scopes = payload.scopes if payload else ["runs.write", "prompts.read", "prompts.write", "tokens.manage"]
22
+ raw_token, hashed = generate_token()
23
+ record = APIKey(project_id=project.id, key_hash=hashed, scopes=scopes)
24
+ db.add(record)
25
+ db.commit()
26
+ db.refresh(record)
27
+ return schemas.TokenWithSecret(
28
+ id=record.id,
29
+ project_id=record.project_id,
30
+ scopes=record.scopes,
31
+ created_at=record.created_at,
32
+ revoked=record.revoked,
33
+ last_used_at=record.last_used_at,
34
+ token=raw_token,
35
+ )
36
+
37
+
38
+ @router.get("", response_model=list[schemas.TokenRead])
39
+ def list_tokens(
40
+ project_id: str,
41
+ project: Project = Depends(require_project),
42
+ db: Session = Depends(get_db),
43
+ _: None = Depends(require_scopes(["tokens.manage"])),
44
+ ):
45
+ tokens = db.query(APIKey).filter(APIKey.project_id == project.id).all()
46
+ return tokens
47
+
48
+
49
+ @router.delete("/{token_id}", status_code=status.HTTP_204_NO_CONTENT)
50
+ def revoke_token(
51
+ project_id: str,
52
+ token_id: str,
53
+ project: Project = Depends(require_project),
54
+ db: Session = Depends(get_db),
55
+ _: None = Depends(require_scopes(["tokens.manage"])),
56
+ ):
57
+ token = (
58
+ db.query(APIKey)
59
+ .filter(APIKey.id == token_id, APIKey.project_id == project.id, APIKey.revoked.is_(False))
60
+ .first()
61
+ )
62
+ if not token:
63
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Token not found")
64
+ token.revoked = True
65
+ db.add(token)
66
+ db.commit()
67
+ return None
@@ -0,0 +1,116 @@
1
+ from typing import List
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException, status, Request
4
+ from sqlalchemy.orm import Session
5
+
6
+ from agentgear.server.app import schemas
7
+ from agentgear.server.app.auth import hash_password
8
+ from agentgear.server.app.db import get_db
9
+ from agentgear.server.app.models import User
10
+ import secrets
11
+
12
+ router = APIRouter(prefix="/api/users", tags=["users"])
13
+
14
+
15
+ @router.get("", response_model=List[schemas.UserRead])
16
+ def list_users(db: Session = Depends(get_db)):
17
+ return db.query(User).all()
18
+
19
+
20
+ @router.post("", response_model=schemas.UserRead, status_code=status.HTTP_201_CREATED)
21
+ def create_user(
22
+ payload: schemas.UserCreate,
23
+ request: Request,
24
+ db: Session = Depends(get_db)
25
+ ):
26
+ # RBAC: Only admin can create users
27
+ if not hasattr(request.state, "role") or request.state.role != "admin":
28
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
29
+
30
+ # Check if user exists
31
+ if db.query(User).filter(User.username == payload.username).first():
32
+ raise HTTPException(status_code=400, detail="Username already exists")
33
+
34
+ salt = secrets.token_hex(8)
35
+ password_hash = hash_password(payload.password, salt)
36
+
37
+ user = User(
38
+ username=payload.username,
39
+ email=payload.email,
40
+ password_hash=password_hash,
41
+ salt=salt,
42
+ role=payload.role,
43
+ project_id=payload.project_id
44
+ )
45
+ db.add(user)
46
+ db.commit()
47
+ db.refresh(user)
48
+
49
+ # Try to send invite email
50
+ try:
51
+ if user.email:
52
+ from agentgear.server.app.models import SMTPSettings
53
+ from agentgear.server.app.utils.email import send_email
54
+
55
+ # Fetch SMTP settings for this project (or globally if null project_id?)
56
+ # User might be created for specific project.
57
+ # If project_id is None (admin), we might look for a 'default' project or just skip?
58
+ # Or look for *any* SMTP config?
59
+ # Let's try to lookup by project_id first.
60
+ smtp = None
61
+ if payload.project_id:
62
+ smtp = db.query(SMTPSettings).filter(SMTPSettings.project_id == payload.project_id).first()
63
+
64
+ # Fallback: Find *any* enabled SMTP config (e.g. from admin project) if not found?
65
+ if not smtp:
66
+ smtp = db.query(SMTPSettings).filter(SMTPSettings.enabled == True).first()
67
+
68
+ if smtp and smtp.enabled:
69
+ subject = "Welcome to AgentGear"
70
+ html = f"""
71
+ <p>Hello {user.username},</p>
72
+ <p>You have been invited to AgentGear.</p>
73
+ <p><strong>Username:</strong> {user.username}</p>
74
+ <p><strong>Password:</strong> {payload.password}</p>
75
+ <p><a href="{request.base_url}">Login here</a></p>
76
+ """
77
+ send_email(smtp, [user.email], subject, html)
78
+ except Exception as e:
79
+ # Log but don't fail the request
80
+ import logging
81
+ logging.error(f"Failed to send invite email: {e}")
82
+
83
+ return user
84
+
85
+
86
+ @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
87
+ def delete_user(user_id: str, request: Request, db: Session = Depends(get_db)):
88
+ # RBAC: Only admin
89
+ if not hasattr(request.state, "role") or request.state.role != "admin":
90
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
91
+
92
+ user = db.query(User).filter(User.id == user_id).first()
93
+ if not user:
94
+ raise HTTPException(status_code=404, detail="User not found")
95
+
96
+ db.delete(user)
97
+ db.commit()
98
+
99
+
100
+ @router.put("/{user_id}/password", status_code=status.HTTP_204_NO_CONTENT)
101
+ def change_password(user_id: str, payload: schemas.UserPasswordReset, request: Request, db: Session = Depends(get_db)):
102
+ # RBAC: Only admin (implied by requirement "allow admin to... change password")
103
+ if not hasattr(request.state, "role") or request.state.role != "admin":
104
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
105
+
106
+ user = db.query(User).filter(User.id == user_id).first()
107
+ if not user:
108
+ raise HTTPException(status_code=404, detail="User not found")
109
+
110
+ salt = secrets.token_hex(8)
111
+ password_hash = hash_password(payload.password, salt)
112
+ user.password_hash = password_hash
113
+ user.salt = salt
114
+
115
+ db.add(user)
116
+ db.commit()
@@ -0,0 +1,80 @@
1
+ import hashlib
2
+ import secrets
3
+ from datetime import datetime
4
+ from typing import Optional
5
+
6
+ from fastapi import HTTPException, Request, status
7
+ from fastapi.responses import JSONResponse
8
+ from starlette.middleware.base import BaseHTTPMiddleware
9
+
10
+ from agentgear.server.app.config import get_settings
11
+ from agentgear.server.app.db import SessionLocal
12
+ from agentgear.server.app.models import APIKey, Project
13
+
14
+ HEADER_NAME = "X-AgentGear-Key"
15
+
16
+
17
+ def hash_token(token: str) -> str:
18
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
19
+
20
+
21
+ def hash_password(password: str, salt: str) -> str:
22
+ return hashlib.sha256(f"{salt}:{password}".encode("utf-8")).hexdigest()
23
+
24
+
25
+ def generate_token() -> tuple[str, str]:
26
+ raw = secrets.token_urlsafe(32)
27
+ return raw, hash_token(raw)
28
+
29
+
30
+ class TokenAuthMiddleware(BaseHTTPMiddleware):
31
+ def __init__(self, app):
32
+ super().__init__(app)
33
+ self.settings = get_settings()
34
+
35
+ async def dispatch(self, request: Request, call_next):
36
+ path = request.url.path
37
+ # Allow unauthenticated access for non-API routes and auth endpoints
38
+ if not path.startswith("/api") or path.startswith("/api/auth") or path.startswith("/api/seed"):
39
+ response = await call_next(request)
40
+ return response
41
+ if self.settings.local_mode:
42
+ response = await call_next(request)
43
+ return response
44
+
45
+ token_value = request.headers.get(HEADER_NAME)
46
+ request.state.project_id = None
47
+ request.state.token_scopes = []
48
+
49
+ if not token_value:
50
+ if self.settings.local_mode:
51
+ response = await call_next(request)
52
+ return response
53
+ return JSONResponse(status_code=status.HTTP_401_UNAUTHORIZED, content={"detail": "Missing API key"})
54
+
55
+ token_hash = hash_token(token_value)
56
+ db = SessionLocal()
57
+ try:
58
+ api_key: Optional[APIKey] = db.query(APIKey).filter(APIKey.key_hash == token_hash).first()
59
+ if not api_key or api_key.revoked:
60
+ return JSONResponse(status_code=status.HTTP_401_UNAUTHORIZED, content={"detail": "Invalid API key"})
61
+
62
+ project: Optional[Project] = (
63
+ db.query(Project).filter(Project.id == api_key.project_id).first()
64
+ if api_key
65
+ else None
66
+ )
67
+ if not project:
68
+ return JSONResponse(status_code=status.HTTP_401_UNAUTHORIZED, content={"detail": "Project not found"})
69
+
70
+ api_key.last_used_at = datetime.utcnow()
71
+ db.add(api_key)
72
+ db.commit()
73
+ request.state.project_id = project.id
74
+ request.state.token_scopes = api_key.scopes or []
75
+ request.state.role = api_key.role or "user"
76
+ finally:
77
+ db.close()
78
+
79
+ response = await call_next(request)
80
+ return response
@@ -0,0 +1,26 @@
1
+ from functools import lru_cache
2
+ from pydantic import BaseModel
3
+ from pydantic_settings import BaseSettings, SettingsConfigDict
4
+
5
+
6
+ class Settings(BaseSettings):
7
+ model_config = SettingsConfigDict(env_prefix="AGENTGEAR_", env_file=".env", env_file_encoding="utf-8")
8
+
9
+ database_url: str = "sqlite:///~/.agentgear/agentgear.db"
10
+ api_host: str = "0.0.0.0"
11
+ api_port: int = 8000
12
+ secret_key: str = "agentgear-dev-secret"
13
+ allow_origins: list[str] = ["*"]
14
+ local_mode: bool = False
15
+ admin_username: str | None = None
16
+ admin_password: str | None = None
17
+
18
+
19
+ class VersionInfo(BaseModel):
20
+ version: str = "0.1.9"
21
+ name: str = "AgentGear"
22
+
23
+
24
+ @lru_cache
25
+ def get_settings() -> Settings:
26
+ return Settings()
@@ -0,0 +1,41 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ from sqlalchemy import create_engine
5
+ from sqlalchemy.orm import sessionmaker, declarative_base
6
+
7
+ from agentgear.server.app.config import get_settings
8
+
9
+ settings = get_settings()
10
+
11
+ def _normalize_sqlite_url(url: str) -> str:
12
+ if not url.startswith("sqlite"):
13
+ return url
14
+ prefix = "sqlite:///"
15
+ path = url[len(prefix) :] if url.startswith(prefix) else url.removeprefix("sqlite:")
16
+ path = os.path.expanduser(path)
17
+ p = Path(path)
18
+ if not p.is_absolute():
19
+ p = Path.cwd() / p
20
+ p.parent.mkdir(parents=True, exist_ok=True)
21
+ return f"sqlite:///{p}"
22
+
23
+
24
+ db_url = _normalize_sqlite_url(settings.database_url)
25
+
26
+ engine = create_engine(
27
+ db_url,
28
+ connect_args={"check_same_thread": False} if db_url.startswith("sqlite") else {},
29
+ )
30
+
31
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
32
+
33
+ Base = declarative_base()
34
+
35
+
36
+ def get_db():
37
+ db = SessionLocal()
38
+ try:
39
+ yield db
40
+ finally:
41
+ db.close()
@@ -0,0 +1,46 @@
1
+ from typing import Callable, Iterable, Optional
2
+
3
+ from fastapi import Depends, HTTPException, Request, status
4
+ from sqlalchemy.orm import Session
5
+
6
+ from agentgear.server.app.config import get_settings
7
+ from agentgear.server.app.db import get_db
8
+ from agentgear.server.app.models import Project
9
+
10
+
11
+ def require_project(
12
+ request: Request, db: Session = Depends(get_db), project_id: Optional[str] = None
13
+ ) -> Project:
14
+ settings = get_settings()
15
+ state_project_id = getattr(request.state, "project_id", None)
16
+ if state_project_id:
17
+ project = db.query(Project).filter(Project.id == state_project_id).first()
18
+ if not project:
19
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Project not found")
20
+ if project_id and project.id != project_id:
21
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Project mismatch")
22
+ return project
23
+
24
+ if settings.local_mode:
25
+ if not project_id:
26
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Project required")
27
+ project = db.query(Project).filter(Project.id == project_id).first()
28
+ if not project:
29
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
30
+ return project
31
+
32
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized")
33
+
34
+
35
+ def require_scopes(required: Iterable[str]) -> Callable:
36
+ required_set = set(required)
37
+
38
+ def dependency(request: Request):
39
+ settings = get_settings()
40
+ if settings.local_mode:
41
+ return
42
+ token_scopes = set(getattr(request.state, "token_scopes", []) or [])
43
+ if not required_set.issubset(token_scopes):
44
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient scope")
45
+
46
+ return dependency
@@ -0,0 +1,77 @@
1
+ from pathlib import Path
2
+
3
+ from fastapi import FastAPI, HTTPException
4
+ from fastapi.middleware.cors import CORSMiddleware
5
+ from fastapi.responses import FileResponse
6
+ from fastapi.staticfiles import StaticFiles
7
+
8
+ from agentgear.server.app.api import auth, metrics, projects, prompts, runs, spans, tokens, users, llm_models, seed, settings as settings_api, datasets, evaluations, evaluators
9
+ from agentgear.server.app.auth import TokenAuthMiddleware
10
+ from agentgear.server.app.config import VersionInfo, get_settings
11
+ from agentgear.server.app.db import Base, engine
12
+ from agentgear.server.app.migrations import apply_migrations
13
+
14
+
15
+ def create_app() -> FastAPI:
16
+ settings = get_settings()
17
+ Base.metadata.create_all(bind=engine)
18
+ apply_migrations(engine)
19
+
20
+ app = FastAPI(title="AgentGear", version=VersionInfo().version)
21
+ app.add_middleware(TokenAuthMiddleware)
22
+ app.add_middleware(
23
+ CORSMiddleware,
24
+ allow_origins=settings.allow_origins,
25
+ allow_credentials=True,
26
+ allow_methods=["*"],
27
+ allow_headers=["*"],
28
+ )
29
+
30
+ app.include_router(projects.router)
31
+ app.include_router(tokens.router)
32
+ app.include_router(prompts.router)
33
+ app.include_router(runs.router)
34
+ app.include_router(spans.router)
35
+ app.include_router(metrics.router)
36
+ app.include_router(auth.router)
37
+ app.include_router(users.router)
38
+ app.include_router(llm_models.router)
39
+ app.include_router(settings_api.router)
40
+ app.include_router(seed.router)
41
+ app.include_router(datasets.router)
42
+ app.include_router(evaluations.router)
43
+ app.include_router(evaluators.router)
44
+
45
+ @app.get("/api/health")
46
+ def health():
47
+ return {"status": "ok", "version": VersionInfo().version}
48
+
49
+ # Serve bundled React build (emitted to agentgear/server/static)
50
+ static_dir = Path(__file__).parent.parent / "static"
51
+ assets_dir = static_dir / "assets"
52
+ if assets_dir.exists():
53
+ app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
54
+
55
+ @app.get("/", include_in_schema=False)
56
+ def serve_index():
57
+ index_path = static_dir / "index.html"
58
+ if index_path.exists():
59
+ return FileResponse(index_path)
60
+ raise HTTPException(status_code=404, detail="Dashboard not built")
61
+
62
+ @app.get("/{full_path:path}", include_in_schema=False)
63
+ def spa_routes(full_path: str):
64
+ if full_path.startswith(("api", "docs", "openapi.json", "redoc")):
65
+ raise HTTPException(status_code=404, detail="Not found")
66
+ candidate = static_dir / full_path
67
+ if candidate.exists() and candidate.is_file():
68
+ return FileResponse(candidate)
69
+ index_path = static_dir / "index.html"
70
+ if index_path.exists():
71
+ return FileResponse(index_path)
72
+ raise HTTPException(status_code=404, detail="Dashboard not built")
73
+
74
+ return app
75
+
76
+
77
+ app = create_app()
@@ -0,0 +1,88 @@
1
+ """
2
+ Lightweight, in-process schema migrations for SQLite.
3
+
4
+ This keeps existing local SQLite databases compatible with newer models
5
+ without requiring Alembic at runtime. Only additive, backward-compatible
6
+ changes are performed here.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from typing import Dict, Set
13
+
14
+ from sqlalchemy import Engine, text
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def _add_column_if_missing(conn, table: str, column: str, ddl: str, cache: Dict[str, Set[str]]):
20
+ cols = cache.get(table)
21
+ if cols is None:
22
+ rows = conn.execute(text(f'PRAGMA table_info("{table}")')).fetchall()
23
+ cols = {row[1] for row in rows} # row[1] = column name
24
+ cache[table] = cols
25
+ if column in cols:
26
+ return
27
+ logger.info("Adding missing column %s.%s", table, column)
28
+ conn.execute(text(f'ALTER TABLE "{table}" ADD COLUMN \"{column}\" {ddl}'))
29
+ cols.add(column)
30
+
31
+
32
+ def _create_index_if_missing(conn, name: str, table: str, column: str):
33
+ conn.execute(text(f'CREATE INDEX IF NOT EXISTS "{name}" ON "{table}" ("{column}")'))
34
+
35
+
36
+ def apply_migrations(engine: Engine) -> None:
37
+ """Apply minimal forward-only migrations for SQLite databases."""
38
+ if not engine.url.drivername.startswith("sqlite"):
39
+ return
40
+
41
+ with engine.begin() as conn:
42
+ cache: Dict[str, Set[str]] = {}
43
+
44
+ # Runs: backfill trace + telemetry fields added after initial releases.
45
+ _add_column_if_missing(conn, "runs", "trace_id", "VARCHAR", cache)
46
+ _add_column_if_missing(conn, "runs", "status", "VARCHAR", cache)
47
+ _add_column_if_missing(conn, "runs", "model", "VARCHAR", cache)
48
+ _add_column_if_missing(conn, "runs", "request_payload", "JSON", cache)
49
+ _add_column_if_missing(conn, "runs", "response_payload", "JSON", cache)
50
+ _add_column_if_missing(conn, "runs", "error_stack", "TEXT", cache)
51
+ _add_column_if_missing(conn, "runs", "tags", "JSON", cache)
52
+ _create_index_if_missing(conn, "ix_runs_trace_id", "runs", "trace_id")
53
+
54
+ # Spans: backfill trace linkage + telemetry fields.
55
+ _add_column_if_missing(conn, "spans", "trace_id", "VARCHAR", cache)
56
+ _add_column_if_missing(conn, "spans", "status", "VARCHAR", cache)
57
+ _add_column_if_missing(conn, "spans", "model", "VARCHAR", cache)
58
+ _add_column_if_missing(conn, "spans", "request_payload", "JSON", cache)
59
+ _add_column_if_missing(conn, "spans", "response_payload", "JSON", cache)
60
+ _add_column_if_missing(conn, "spans", "token_input", "INTEGER", cache)
61
+ _add_column_if_missing(conn, "spans", "token_output", "INTEGER", cache)
62
+ _add_column_if_missing(conn, "spans", "cost", "FLOAT", cache)
63
+ _add_column_if_missing(conn, "spans", "error", "TEXT", cache)
64
+ _add_column_if_missing(conn, "spans", "error_stack", "TEXT", cache)
65
+ _add_column_if_missing(conn, "spans", "tags", "JSON", cache)
66
+ _add_column_if_missing(conn, "spans", "tags", "JSON", cache)
67
+ _create_index_if_missing(conn, "ix_spans_trace_id", "spans", "trace_id")
68
+
69
+ # Prompts: add scope and tags
70
+ _add_column_if_missing(conn, "prompts", "scope", "VARCHAR", cache)
71
+ _add_column_if_missing(conn, "prompts", "tags", "JSON", cache)
72
+
73
+ # APIKeys: add role
74
+ _add_column_if_missing(conn, "api_keys", "role", "VARCHAR", cache)
75
+
76
+ # Evaluators
77
+ conn.execute(text("""
78
+ CREATE TABLE IF NOT EXISTS evaluators (
79
+ id VARCHAR PRIMARY KEY,
80
+ project_id VARCHAR NOT NULL,
81
+ name VARCHAR NOT NULL,
82
+ prompt_template TEXT NOT NULL,
83
+ model VARCHAR NOT NULL,
84
+ config JSON,
85
+ created_at DATETIME DEFAULT (datetime('now', 'localtime')) NOT NULL,
86
+ FOREIGN KEY(project_id) REFERENCES projects(id)
87
+ )
88
+ """))