evalforge-runtime 0.1.0__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.
@@ -0,0 +1,37 @@
1
+ """EvalForge Runtime — execution engine for generated applications."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+
6
+ def main() -> None:
7
+ """CLI entry point: evalforge-runtime start --config <path>"""
8
+ import argparse
9
+
10
+ from evalforge_runtime.config import load_config
11
+ from evalforge_runtime.server import create_app
12
+
13
+ parser = argparse.ArgumentParser(
14
+ prog="evalforge-runtime",
15
+ description="EvalForge Runtime Server",
16
+ )
17
+ subparsers = parser.add_subparsers(dest="command")
18
+
19
+ start_parser = subparsers.add_parser("start", help="Start the runtime server")
20
+ start_parser.add_argument(
21
+ "--config", default="evalforge.config.yaml", help="Path to config YAML"
22
+ )
23
+ start_parser.add_argument("--host", default="0.0.0.0", help="Bind host")
24
+ start_parser.add_argument("--port", type=int, default=8000, help="Bind port")
25
+
26
+ args = parser.parse_args()
27
+
28
+ config_path = getattr(args, "config", "evalforge.config.yaml")
29
+ host = getattr(args, "host", "0.0.0.0")
30
+ port = getattr(args, "port", 8000)
31
+
32
+ config = load_config(config_path)
33
+ app = create_app(config)
34
+
35
+ import uvicorn
36
+
37
+ uvicorn.run(app, host=host, port=port)
@@ -0,0 +1,5 @@
1
+ """Enable python -m evalforge_runtime."""
2
+
3
+ from evalforge_runtime import main
4
+
5
+ main()
@@ -0,0 +1,33 @@
1
+ """API key authentication middleware."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from fastapi import HTTPException, Request
8
+
9
+ from evalforge_runtime.config import AuthConfig
10
+
11
+
12
+ class APIKeyAuth:
13
+ """FastAPI dependency that validates API key authentication."""
14
+
15
+ def __init__(self, config: AuthConfig):
16
+ self.config = config
17
+ self.api_key_methods = [m for m in config.methods if m.type == "api_key"]
18
+
19
+ async def __call__(self, request: Request) -> str:
20
+ """Validate the request. Returns the authenticated key."""
21
+ for method in self.api_key_methods:
22
+ header_name = method.header or "X-API-Key"
23
+ key = request.headers.get(header_name)
24
+ if key and self._validate_key(key):
25
+ return key
26
+
27
+ raise HTTPException(status_code=401, detail="Unauthorized")
28
+
29
+ def _validate_key(self, key: str) -> bool:
30
+ """Check if the key is in the allowed list."""
31
+ valid_keys = os.environ.get("EVALFORGE_API_KEYS", "").split(",")
32
+ valid_keys = [k.strip() for k in valid_keys if k.strip()]
33
+ return key in valid_keys
@@ -0,0 +1,157 @@
1
+ """Pydantic models for evalforge.config.yaml and config loading."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Any, Literal
9
+
10
+ import yaml
11
+ from pydantic import BaseModel
12
+
13
+
14
+ class ProjectConfig(BaseModel):
15
+ id: str
16
+ evalforge_url: str | None = None
17
+ version: str = "0.0.0"
18
+
19
+
20
+ class SecretConfig(BaseModel):
21
+ provider: Literal[
22
+ "evalforge", "env", "azure_keyvault", "aws_secrets_manager", "sap_credential_store"
23
+ ] = "env"
24
+ # Azure Key Vault
25
+ vault_url: str | None = None
26
+ # AWS Secrets Manager
27
+ region: str | None = None
28
+ secret_name: str | None = None
29
+ # SAP Credential Store
30
+ instance: str | None = None
31
+
32
+
33
+ class AuthMethod(BaseModel):
34
+ type: Literal["oauth2", "api_key"]
35
+ issuer: str | None = None
36
+ audience: str | None = None
37
+ header: str | None = None
38
+
39
+
40
+ class AuthConfig(BaseModel):
41
+ methods: list[AuthMethod] = []
42
+
43
+
44
+ class LLMConfig(BaseModel):
45
+ model: str = "anthropic/claude-sonnet-4-6"
46
+
47
+
48
+ class ReviewConfig(BaseModel):
49
+ enabled: bool = False
50
+ timeout: str = "24h"
51
+
52
+
53
+ class LangfuseConfig(BaseModel):
54
+ enabled: bool = False
55
+ host: str | None = None
56
+
57
+
58
+ class ObservabilityConfig(BaseModel):
59
+ langfuse: LangfuseConfig = LangfuseConfig()
60
+
61
+
62
+ class TriggerConfig(BaseModel):
63
+ type: Literal["schedule", "webhook", "process"]
64
+ cron: str | None = None
65
+ after: str | None = None
66
+
67
+
68
+ class ProcessConfig(BaseModel):
69
+ process_id: str
70
+ instructions: str = ""
71
+ trigger: TriggerConfig
72
+ connector: str | None = None
73
+ connector_params: dict[str, Any] | None = None
74
+ review: ReviewConfig = ReviewConfig()
75
+ llm_model: str | None = None
76
+ output_schema: dict[str, str] | None = None
77
+ before_module: str | None = None
78
+ execution_module: str | None = None
79
+ after_module: str | None = None
80
+
81
+
82
+ class DatabaseConfig(BaseModel):
83
+ url: str = "sqlite+aiosqlite:///./data/app.db"
84
+
85
+
86
+ class StorageConfig(BaseModel):
87
+ type: Literal["local", "s3"] = "local"
88
+ path: str = "./data/files"
89
+ bucket: str | None = None
90
+ region: str | None = None
91
+
92
+
93
+ class AppConfig(BaseModel):
94
+ project: ProjectConfig
95
+ secrets: SecretConfig = SecretConfig()
96
+ auth: AuthConfig = AuthConfig()
97
+ llm: LLMConfig = LLMConfig()
98
+ database: DatabaseConfig = DatabaseConfig()
99
+ storage: StorageConfig = StorageConfig()
100
+ observability: ObservabilityConfig = ObservabilityConfig()
101
+ processes: dict[str, ProcessConfig] = {}
102
+
103
+
104
+ def _parse_duration(duration_str: str) -> int:
105
+ """Parse duration string like '24h', '30m', '7d' to seconds."""
106
+ duration_str = duration_str.strip().lower()
107
+ if duration_str.endswith("d"):
108
+ return int(duration_str[:-1]) * 86400
109
+ elif duration_str.endswith("h"):
110
+ return int(duration_str[:-1]) * 3600
111
+ elif duration_str.endswith("m"):
112
+ return int(duration_str[:-1]) * 60
113
+ elif duration_str.endswith("s"):
114
+ return int(duration_str[:-1])
115
+ else:
116
+ return int(duration_str)
117
+
118
+
119
+ _ENV_VAR_PATTERN = re.compile(r"\$\{([^}]+)}")
120
+
121
+
122
+ def _resolve_env_vars(value: Any) -> Any:
123
+ """Recursively resolve ${ENV_VAR} patterns in config values."""
124
+ if isinstance(value, str):
125
+ def replacer(match: re.Match[str]) -> str:
126
+ var_name = match.group(1)
127
+ env_value = os.environ.get(var_name)
128
+ if env_value is None:
129
+ return match.group(0) # leave unresolved
130
+ return env_value
131
+
132
+ return _ENV_VAR_PATTERN.sub(replacer, value)
133
+ elif isinstance(value, dict):
134
+ return {k: _resolve_env_vars(v) for k, v in value.items()}
135
+ elif isinstance(value, list):
136
+ return [_resolve_env_vars(item) for item in value]
137
+ return value
138
+
139
+
140
+ def load_config(path: str | Path) -> AppConfig:
141
+ """Load and validate an evalforge.config.yaml file."""
142
+ from dotenv import load_dotenv
143
+
144
+ load_dotenv()
145
+
146
+ config_path = Path(path)
147
+ if not config_path.exists():
148
+ raise FileNotFoundError(f"Config file not found: {config_path}")
149
+
150
+ with open(config_path) as f:
151
+ raw = yaml.safe_load(f)
152
+
153
+ if not isinstance(raw, dict):
154
+ raise ValueError(f"Config file must contain a YAML mapping, got {type(raw).__name__}")
155
+
156
+ resolved = _resolve_env_vars(raw)
157
+ return AppConfig.model_validate(resolved)
@@ -0,0 +1,5 @@
1
+ """Connectors provide input (triggers/sources) and output (actions) for processes."""
2
+
3
+ from evalforge_runtime.connectors.base import Connector
4
+
5
+ __all__ = ["Connector"]
@@ -0,0 +1,40 @@
1
+ """Connector base class."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ from evalforge_runtime.types import FileRef
10
+
11
+
12
+ @dataclass
13
+ class ConnectorItem:
14
+ """A single item fetched by a connector (e.g., one email)."""
15
+
16
+ ref: str # unique identifier (e.g., email message ID)
17
+ data: dict[str, Any] # the item's data fields
18
+ attachments: list[FileRef] = field(default_factory=list)
19
+
20
+
21
+ class Connector(ABC):
22
+ """Base class for all connectors."""
23
+
24
+ def __init__(self, params: dict[str, Any] | None = None, secrets: dict[str, str] | None = None):
25
+ self.params = params or {}
26
+ self.secrets = secrets or {}
27
+
28
+ @abstractmethod
29
+ async def fetch(self) -> list[ConnectorItem]:
30
+ """Fetch new items from the source. Called on each trigger."""
31
+ ...
32
+
33
+ async def validate(self) -> None:
34
+ """Validate credentials and config on startup. Raises on failure."""
35
+ pass
36
+
37
+ @abstractmethod
38
+ def name(self) -> str:
39
+ """Return the connector name (e.g., 'exchange-inbox')."""
40
+ ...
@@ -0,0 +1,255 @@
1
+ """Microsoft Exchange Online connector via Microsoft Graph API.
2
+
3
+ Supports shared mailboxes — the service principal needs
4
+ Mail.ReadWrite application permission on the target mailbox.
5
+
6
+ Required secrets:
7
+ EXCHANGE_TENANT_ID
8
+ EXCHANGE_CLIENT_ID
9
+ EXCHANGE_CLIENT_SECRET
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from typing import Any
16
+
17
+ import httpx
18
+
19
+ from evalforge_runtime.connectors.base import Connector, ConnectorItem
20
+ from evalforge_runtime.storage import LocalStorage
21
+ from evalforge_runtime.types import FileRef
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class ExchangeConnector(Connector):
27
+ """Microsoft Exchange connector via Graph API."""
28
+
29
+ def __init__(
30
+ self,
31
+ params: dict[str, Any] | None = None,
32
+ secrets: dict[str, str] | None = None,
33
+ storage: LocalStorage | None = None,
34
+ ):
35
+ super().__init__(params, secrets)
36
+ self.storage = storage
37
+ self._token: str | None = None
38
+ self._mailbox = self.params.get("mailbox", "")
39
+ self._folder = self.params.get("folder", "Inbox")
40
+ self._filter = self.params.get("filter", "unread")
41
+
42
+ def name(self) -> str:
43
+ return "exchange-inbox"
44
+
45
+ async def validate(self) -> None:
46
+ """Validate Exchange credentials by acquiring a token."""
47
+ required = ["EXCHANGE_TENANT_ID", "EXCHANGE_CLIENT_ID", "EXCHANGE_CLIENT_SECRET"]
48
+ missing = [k for k in required if k not in self.secrets]
49
+ if missing:
50
+ raise ValueError(f"Exchange connector missing secrets: {', '.join(missing)}")
51
+ if not self._mailbox:
52
+ raise ValueError("Exchange connector requires 'mailbox' parameter")
53
+ await self._acquire_token()
54
+
55
+ async def _acquire_token(self) -> str:
56
+ """Acquire OAuth2 token via client credentials flow."""
57
+ if self._token:
58
+ return self._token
59
+
60
+ tenant_id = self.secrets["EXCHANGE_TENANT_ID"]
61
+ async with httpx.AsyncClient() as client:
62
+ resp = await client.post(
63
+ f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token",
64
+ data={
65
+ "grant_type": "client_credentials",
66
+ "client_id": self.secrets["EXCHANGE_CLIENT_ID"],
67
+ "client_secret": self.secrets["EXCHANGE_CLIENT_SECRET"],
68
+ "scope": "https://graph.microsoft.com/.default",
69
+ },
70
+ )
71
+ resp.raise_for_status()
72
+ self._token = resp.json()["access_token"]
73
+ return self._token
74
+
75
+ async def fetch(self) -> list[ConnectorItem]:
76
+ """Fetch unread messages from the configured mailbox."""
77
+ token = await self._acquire_token()
78
+ headers = {"Authorization": f"Bearer {token}"}
79
+
80
+ filter_str = "$filter=isRead eq false" if self._filter == "unread" else ""
81
+ url = (
82
+ f"https://graph.microsoft.com/v1.0/users/{self._mailbox}"
83
+ f"/mailFolders/{self._folder}/messages?{filter_str}&$top=50"
84
+ )
85
+
86
+ async with httpx.AsyncClient() as client:
87
+ resp = await client.get(url, headers=headers)
88
+ resp.raise_for_status()
89
+ messages = resp.json().get("value", [])
90
+
91
+ items: list[ConnectorItem] = []
92
+ for msg in messages:
93
+ attachments: list[FileRef] = []
94
+ if msg.get("hasAttachments") and self.storage:
95
+ attachments = await self._fetch_attachments(
96
+ msg["id"], headers, msg["id"]
97
+ )
98
+
99
+ items.append(ConnectorItem(
100
+ ref=msg["id"],
101
+ data={
102
+ "email_subject": msg.get("subject", ""),
103
+ "email_body": msg.get("body", {}).get("content", ""),
104
+ "sender": msg.get("from", {}).get("emailAddress", {}).get("address", ""),
105
+ "received_at": msg.get("receivedDateTime", ""),
106
+ },
107
+ attachments=attachments,
108
+ ))
109
+
110
+ return items
111
+
112
+ async def _fetch_attachments(
113
+ self, message_id: str, headers: dict, execution_ref: str
114
+ ) -> list[FileRef]:
115
+ """Fetch and store attachments for a message."""
116
+ if not self.storage:
117
+ return []
118
+
119
+ url = (
120
+ f"https://graph.microsoft.com/v1.0/users/{self._mailbox}"
121
+ f"/messages/{message_id}/attachments"
122
+ )
123
+
124
+ async with httpx.AsyncClient() as client:
125
+ resp = await client.get(url, headers=headers)
126
+ resp.raise_for_status()
127
+ attachments_data = resp.json().get("value", [])
128
+
129
+ refs: list[FileRef] = []
130
+ for att in attachments_data:
131
+ if att.get("@odata.type") != "#microsoft.graph.fileAttachment":
132
+ continue
133
+
134
+ import base64
135
+ content = base64.b64decode(att.get("contentBytes", ""))
136
+ filename = att.get("name", "attachment")
137
+ extension = filename.rsplit(".", 1)[-1] if "." in filename else ""
138
+ mime_type = att.get("contentType", "application/octet-stream")
139
+ key = f"connector/exchange/{execution_ref}/{filename}"
140
+
141
+ await self.storage.put(key, content, mime_type)
142
+
143
+ refs.append(FileRef(
144
+ type="local",
145
+ key=key,
146
+ filename=filename,
147
+ size=len(content),
148
+ mimeType=mime_type,
149
+ extension=extension,
150
+ ))
151
+
152
+ return refs
153
+
154
+ # --- Output methods (used by after steps) ---
155
+
156
+ async def send_message(
157
+ self, to: list[str], subject: str, body: str,
158
+ attachments: list[FileRef] | None = None,
159
+ ) -> str:
160
+ """Send a new email. Returns message ID."""
161
+ token = await self._acquire_token()
162
+ message: dict[str, Any] = {
163
+ "subject": subject,
164
+ "body": {"contentType": "HTML", "content": body},
165
+ "toRecipients": [{"emailAddress": {"address": addr}} for addr in to],
166
+ }
167
+
168
+ async with httpx.AsyncClient() as client:
169
+ resp = await client.post(
170
+ f"https://graph.microsoft.com/v1.0/users/{self._mailbox}/sendMail",
171
+ headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
172
+ json={"message": message, "saveToSentItems": True},
173
+ )
174
+ resp.raise_for_status()
175
+ return "sent"
176
+
177
+ async def reply(self, message_id: str, body: str, reply_all: bool = False) -> None:
178
+ """Reply to an existing message."""
179
+ token = await self._acquire_token()
180
+ endpoint = "replyAll" if reply_all else "reply"
181
+
182
+ async with httpx.AsyncClient() as client:
183
+ resp = await client.post(
184
+ f"https://graph.microsoft.com/v1.0/users/{self._mailbox}"
185
+ f"/messages/{message_id}/{endpoint}",
186
+ headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
187
+ json={"comment": body},
188
+ )
189
+ resp.raise_for_status()
190
+
191
+ async def forward(self, message_id: str, to: str, comment: str = "") -> None:
192
+ """Forward a message."""
193
+ token = await self._acquire_token()
194
+
195
+ async with httpx.AsyncClient() as client:
196
+ resp = await client.post(
197
+ f"https://graph.microsoft.com/v1.0/users/{self._mailbox}"
198
+ f"/messages/{message_id}/forward",
199
+ headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
200
+ json={
201
+ "comment": comment,
202
+ "toRecipients": [{"emailAddress": {"address": to}}],
203
+ },
204
+ )
205
+ resp.raise_for_status()
206
+
207
+ async def move_message(self, message_id: str, folder: str) -> None:
208
+ """Move a message to a folder."""
209
+ token = await self._acquire_token()
210
+
211
+ async with httpx.AsyncClient() as client:
212
+ # Get or create folder
213
+ folder_id = await self._get_or_create_folder(folder, token)
214
+ resp = await client.post(
215
+ f"https://graph.microsoft.com/v1.0/users/{self._mailbox}"
216
+ f"/messages/{message_id}/move",
217
+ headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
218
+ json={"destinationId": folder_id},
219
+ )
220
+ resp.raise_for_status()
221
+
222
+ async def mark_read(self, message_id: str) -> None:
223
+ """Mark a message as read."""
224
+ token = await self._acquire_token()
225
+
226
+ async with httpx.AsyncClient() as client:
227
+ resp = await client.patch(
228
+ f"https://graph.microsoft.com/v1.0/users/{self._mailbox}"
229
+ f"/messages/{message_id}",
230
+ headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
231
+ json={"isRead": True},
232
+ )
233
+ resp.raise_for_status()
234
+
235
+ async def _get_or_create_folder(self, folder_name: str, token: str) -> str:
236
+ """Get folder ID by name, creating it if needed."""
237
+ async with httpx.AsyncClient() as client:
238
+ resp = await client.get(
239
+ f"https://graph.microsoft.com/v1.0/users/{self._mailbox}/mailFolders"
240
+ f"?$filter=displayName eq '{folder_name}'",
241
+ headers={"Authorization": f"Bearer {token}"},
242
+ )
243
+ resp.raise_for_status()
244
+ folders = resp.json().get("value", [])
245
+ if folders:
246
+ return folders[0]["id"]
247
+
248
+ # Create folder
249
+ resp = await client.post(
250
+ f"https://graph.microsoft.com/v1.0/users/{self._mailbox}/mailFolders",
251
+ headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
252
+ json={"displayName": folder_name},
253
+ )
254
+ resp.raise_for_status()
255
+ return resp.json()["id"]