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.
- agentgear/__init__.py +18 -0
- agentgear/cli/__init__.py +1 -0
- agentgear/cli/main.py +125 -0
- agentgear/sdk/__init__.py +6 -0
- agentgear/sdk/client.py +276 -0
- agentgear/sdk/decorators.py +65 -0
- agentgear/sdk/integrations/openai.py +52 -0
- agentgear/sdk/prompt.py +23 -0
- agentgear/sdk/trace.py +59 -0
- agentgear/server/__init__.py +1 -0
- agentgear/server/app/__init__.py +1 -0
- agentgear/server/app/api/__init__.py +1 -0
- agentgear/server/app/api/auth.py +156 -0
- agentgear/server/app/api/datasets.py +185 -0
- agentgear/server/app/api/evaluations.py +69 -0
- agentgear/server/app/api/evaluators.py +157 -0
- agentgear/server/app/api/llm_models.py +39 -0
- agentgear/server/app/api/metrics.py +18 -0
- agentgear/server/app/api/projects.py +139 -0
- agentgear/server/app/api/prompts.py +227 -0
- agentgear/server/app/api/runs.py +75 -0
- agentgear/server/app/api/seed.py +106 -0
- agentgear/server/app/api/settings.py +135 -0
- agentgear/server/app/api/spans.py +56 -0
- agentgear/server/app/api/tokens.py +67 -0
- agentgear/server/app/api/users.py +116 -0
- agentgear/server/app/auth.py +80 -0
- agentgear/server/app/config.py +26 -0
- agentgear/server/app/db.py +41 -0
- agentgear/server/app/deps.py +46 -0
- agentgear/server/app/main.py +77 -0
- agentgear/server/app/migrations.py +88 -0
- agentgear/server/app/models.py +339 -0
- agentgear/server/app/schemas.py +343 -0
- agentgear/server/app/utils/email.py +30 -0
- agentgear/server/app/utils/llm.py +27 -0
- agentgear/server/static/assets/index-BAAzXAln.js +121 -0
- agentgear/server/static/assets/index-CE45MZx1.css +1 -0
- agentgear/server/static/index.html +13 -0
- agentgear_ai-0.1.16.dist-info/METADATA +387 -0
- agentgear_ai-0.1.16.dist-info/RECORD +44 -0
- agentgear_ai-0.1.16.dist-info/WHEEL +4 -0
- agentgear_ai-0.1.16.dist-info/entry_points.txt +2 -0
- 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
|
+
"""))
|