agent-loop-guard-runtime 0.6.0a2__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 (76) hide show
  1. agent_loop_guard_runtime-0.6.0a2.dist-info/METADATA +407 -0
  2. agent_loop_guard_runtime-0.6.0a2.dist-info/RECORD +76 -0
  3. agent_loop_guard_runtime-0.6.0a2.dist-info/WHEEL +5 -0
  4. agent_loop_guard_runtime-0.6.0a2.dist-info/entry_points.txt +2 -0
  5. agent_loop_guard_runtime-0.6.0a2.dist-info/licenses/LICENSE +202 -0
  6. agent_loop_guard_runtime-0.6.0a2.dist-info/top_level.txt +1 -0
  7. app/__init__.py +6 -0
  8. app/api/__init__.py +1 -0
  9. app/api/admin_routes.py +179 -0
  10. app/api/anthropic_routes.py +21 -0
  11. app/api/common.py +457 -0
  12. app/api/mcp_routes.py +218 -0
  13. app/api/openai_routes.py +26 -0
  14. app/api/replay_routes.py +202 -0
  15. app/api/ui_routes.py +295 -0
  16. app/benchmark/__init__.py +2 -0
  17. app/benchmark/adapters.py +97 -0
  18. app/benchmark/data/starter-v1.json +38 -0
  19. app/benchmark/dataset.py +59 -0
  20. app/benchmark/models.py +46 -0
  21. app/benchmark/runner.py +63 -0
  22. app/benchmark/scorers.py +34 -0
  23. app/benchmark/statistics.py +48 -0
  24. app/benchmark/storage.py +78 -0
  25. app/cli.py +624 -0
  26. app/core/config.py +196 -0
  27. app/core/demo.py +109 -0
  28. app/core/loop_detector.py +120 -0
  29. app/core/policy_engine.py +167 -0
  30. app/core/redaction.py +84 -0
  31. app/core/security.py +54 -0
  32. app/core/token_meter.py +67 -0
  33. app/db/models.py +296 -0
  34. app/db/repository.py +1488 -0
  35. app/db/session.py +57 -0
  36. app/main.py +59 -0
  37. app/mcp/__init__.py +1 -0
  38. app/mcp/gateway.py +230 -0
  39. app/mcp/policy.py +281 -0
  40. app/mcp/presets/development.yml +25 -0
  41. app/mcp/presets/filesystem.yml +18 -0
  42. app/mcp/stdio.py +142 -0
  43. app/platform/__init__.py +1 -0
  44. app/platform/alembic/__init__.py +2 -0
  45. app/platform/alembic/env.py +38 -0
  46. app/platform/alembic/script.py.mako +24 -0
  47. app/platform/alembic/versions/0001_initial_schema.py +18 -0
  48. app/platform/alembic/versions/__init__.py +2 -0
  49. app/platform/events.py +44 -0
  50. app/platform/maintenance.py +138 -0
  51. app/platform/migrations.py +24 -0
  52. app/platform/setup.py +92 -0
  53. app/providers/__init__.py +5 -0
  54. app/providers/base.py +23 -0
  55. app/providers/mock.py +169 -0
  56. app/providers/upstream.py +101 -0
  57. app/replay/__init__.py +1 -0
  58. app/replay/costs.py +37 -0
  59. app/replay/formats.py +87 -0
  60. app/replay/sdk.py +102 -0
  61. app/sandbox/__init__.py +2 -0
  62. app/sandbox/policy.py +22 -0
  63. app/sandbox/workspace.py +281 -0
  64. app/static/styles.css +327 -0
  65. app/templates/agents.html +48 -0
  66. app/templates/base.html +27 -0
  67. app/templates/dashboard.html +55 -0
  68. app/templates/demo.html +36 -0
  69. app/templates/mcp.html +69 -0
  70. app/templates/policies.html +30 -0
  71. app/templates/replay.html +63 -0
  72. app/templates/replay_compare.html +74 -0
  73. app/templates/replay_detail.html +130 -0
  74. app/templates/session_detail.html +71 -0
  75. app/templates/sessions.html +24 -0
  76. app/templates/settings.html +20 -0
app/mcp/stdio.py ADDED
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ import threading
7
+ from pathlib import Path
8
+ from typing import TextIO
9
+
10
+ from app.core.config import AppConfig
11
+ from app.db.repository import Repository
12
+ from app.db.session import build_engine, build_session_factory, init_db
13
+ from app.mcp.gateway import MCPGateway, jsonrpc_error, mock_response
14
+ from app.mcp.policy import MCPPolicyEngine
15
+
16
+
17
+ def _write_message(stream: TextIO, message: dict, lock: threading.Lock | None = None) -> None:
18
+ payload = json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n"
19
+ if lock:
20
+ with lock:
21
+ stream.write(payload)
22
+ stream.flush()
23
+ else:
24
+ stream.write(payload)
25
+ stream.flush()
26
+
27
+
28
+ def run_stdio_proxy(
29
+ command: list[str],
30
+ *,
31
+ policy_path: str | None = None,
32
+ server_id: str = "stdio",
33
+ config: AppConfig | None = None,
34
+ ) -> int:
35
+ if not command:
36
+ raise ValueError("An upstream command is required after --.")
37
+ config = config or AppConfig.from_env()
38
+ engine = build_engine(config)
39
+ init_db(engine, config)
40
+ session_factory = build_session_factory(engine)
41
+ process = subprocess.Popen(
42
+ command,
43
+ stdin=subprocess.PIPE,
44
+ stdout=subprocess.PIPE,
45
+ stderr=subprocess.PIPE,
46
+ text=True,
47
+ encoding="utf-8",
48
+ bufsize=1,
49
+ )
50
+ assert process.stdin and process.stdout and process.stderr
51
+ output_lock = threading.Lock()
52
+ gateway_lock = threading.Lock()
53
+ pending: dict[str, str] = {}
54
+
55
+ db = session_factory()
56
+ repo = Repository(db)
57
+ repo.ensure_mcp_server(server_id, server_id, "stdio", " ".join(command))
58
+ policy = MCPPolicyEngine(policy_path or config.mcp_policy_path)
59
+ session = repo.start_mcp_session(server_id, client_name="stdio-client", mode=str(policy.data.get("mode", "enforce")))
60
+ gateway = MCPGateway(
61
+ repo,
62
+ policy,
63
+ server_id,
64
+ session_id=session.id,
65
+ approval_timeout_seconds=config.mcp_approval_timeout_seconds,
66
+ )
67
+
68
+ def upstream_reader() -> None:
69
+ for raw in process.stdout:
70
+ try:
71
+ response = json.loads(raw)
72
+ except json.JSONDecodeError:
73
+ print("[mcp upstream] ignored non-JSON stdout", file=sys.stderr)
74
+ continue
75
+ request_id = str(response.get("id")) if response.get("id") is not None else None
76
+ with gateway_lock:
77
+ if request_id and pending.pop(request_id, None) == "tools/list":
78
+ response = gateway.filter_tools(response)
79
+ _write_message(sys.stdout, response, output_lock)
80
+
81
+ def stderr_reader() -> None:
82
+ for raw in process.stderr:
83
+ sys.stderr.write(raw)
84
+ sys.stderr.flush()
85
+
86
+ threads = [
87
+ threading.Thread(target=upstream_reader, daemon=True),
88
+ threading.Thread(target=stderr_reader, daemon=True),
89
+ ]
90
+ for thread in threads:
91
+ thread.start()
92
+
93
+ try:
94
+ for raw in sys.stdin:
95
+ try:
96
+ message = json.loads(raw)
97
+ except json.JSONDecodeError:
98
+ _write_message(sys.stdout, jsonrpc_error(None, -32700, "Parse error"), output_lock)
99
+ continue
100
+ with gateway_lock:
101
+ interception = gateway.intercept(message)
102
+ if not interception.forward:
103
+ if interception.response:
104
+ _write_message(sys.stdout, interception.response, output_lock)
105
+ continue
106
+ request_id = interception.message.get("id")
107
+ if request_id is not None:
108
+ pending[str(request_id)] = str(interception.message.get("method") or "")
109
+ _write_message(process.stdin, interception.message)
110
+ finally:
111
+ if not process.stdin.closed:
112
+ process.stdin.close()
113
+ try:
114
+ process.wait(timeout=5)
115
+ except subprocess.TimeoutExpired:
116
+ process.terminate()
117
+ process.wait(timeout=5)
118
+ for thread in threads:
119
+ thread.join(timeout=2)
120
+ repo.end_mcp_session(session.id)
121
+ db.close()
122
+ return int(process.returncode or 0)
123
+
124
+
125
+ def run_mock_stdio_server() -> int:
126
+ for raw in sys.stdin:
127
+ try:
128
+ message = json.loads(raw)
129
+ except json.JSONDecodeError:
130
+ _write_message(sys.stdout, jsonrpc_error(None, -32700, "Parse error"))
131
+ continue
132
+ response = mock_response(message)
133
+ if response is not None:
134
+ _write_message(sys.stdout, response)
135
+ return 0
136
+
137
+
138
+ def validate_policy_file(path: str | Path) -> list[str]:
139
+ from app.mcp.policy import load_policy, validate_policy
140
+
141
+ data, _ = load_policy(path)
142
+ return validate_policy(data)
@@ -0,0 +1 @@
1
+ """Shared platform services used by every Agent Loop Guard module."""
@@ -0,0 +1,2 @@
1
+ """Packaged Alembic migration environment."""
2
+
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from alembic import context
4
+ from sqlalchemy import engine_from_config, pool
5
+
6
+ from app.db.models import Base
7
+
8
+ config = context.config
9
+ target_metadata = Base.metadata
10
+
11
+
12
+ def run_migrations_offline() -> None:
13
+ context.configure(
14
+ url=config.get_main_option("sqlalchemy.url"),
15
+ target_metadata=target_metadata,
16
+ literal_binds=True,
17
+ compare_type=True,
18
+ )
19
+ with context.begin_transaction():
20
+ context.run_migrations()
21
+
22
+
23
+ def run_migrations_online() -> None:
24
+ connectable = engine_from_config(
25
+ config.get_section(config.config_ini_section) or {},
26
+ prefix="sqlalchemy.",
27
+ poolclass=pool.NullPool,
28
+ )
29
+ with connectable.connect() as connection:
30
+ context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)
31
+ with context.begin_transaction():
32
+ context.run_migrations()
33
+
34
+
35
+ if context.is_offline_mode():
36
+ run_migrations_offline()
37
+ else:
38
+ run_migrations_online()
@@ -0,0 +1,24 @@
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+ """
7
+ from typing import Sequence, Union
8
+
9
+ from alembic import op
10
+ import sqlalchemy as sa
11
+ ${imports if imports else ""}
12
+
13
+ revision: str = ${repr(up_revision)}
14
+ down_revision: Union[str, None] = ${repr(down_revision)}
15
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
16
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
17
+
18
+
19
+ def upgrade() -> None:
20
+ ${upgrades if upgrades else "pass"}
21
+
22
+
23
+ def downgrade() -> None:
24
+ ${downgrades if downgrades else "pass"}
@@ -0,0 +1,18 @@
1
+ """Baseline the local Agent Loop Guard schema."""
2
+
3
+ from alembic import op
4
+
5
+ from app.db.models import Base
6
+
7
+ revision = "0001_initial"
8
+ down_revision = None
9
+ branch_labels = None
10
+ depends_on = None
11
+
12
+
13
+ def upgrade() -> None:
14
+ Base.metadata.create_all(bind=op.get_bind())
15
+
16
+
17
+ def downgrade() -> None:
18
+ Base.metadata.drop_all(bind=op.get_bind())
@@ -0,0 +1,2 @@
1
+ """Database schema revisions."""
2
+
app/platform/events.py ADDED
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ import uuid
5
+ from dataclasses import asdict, dataclass, field
6
+ from typing import Any
7
+
8
+ from app.core.redaction import redact_value
9
+
10
+
11
+ @dataclass(slots=True)
12
+ class EventEnvelope:
13
+ """Stable, redacted event contract shared by all local modules."""
14
+
15
+ source: str
16
+ type: str
17
+ project_id: str = "default"
18
+ trace_id: str | None = None
19
+ span_id: str | None = None
20
+ severity: str = "info"
21
+ timestamp_ns: int = field(default_factory=time.time_ns)
22
+ attributes: dict[str, Any] = field(default_factory=dict)
23
+ event_id: str = field(default_factory=lambda: f"evt_{uuid.uuid4().hex[:24]}")
24
+ schema_version: str = "event.v1"
25
+
26
+ def to_dict(self) -> dict[str, Any]:
27
+ payload = asdict(self)
28
+ payload["attributes"] = redact_value(self.attributes)
29
+ return payload
30
+
31
+ @classmethod
32
+ def from_dict(cls, payload: dict[str, Any]) -> EventEnvelope:
33
+ return cls(
34
+ event_id=str(payload.get("event_id") or f"evt_{uuid.uuid4().hex[:24]}"),
35
+ source=str(payload.get("source") or "unknown"),
36
+ type=str(payload.get("type") or "event"),
37
+ project_id=str(payload.get("project_id") or "default"),
38
+ trace_id=payload.get("trace_id"),
39
+ span_id=payload.get("span_id"),
40
+ severity=str(payload.get("severity") or "info"),
41
+ timestamp_ns=int(payload.get("timestamp_ns") or time.time_ns()),
42
+ attributes=dict(payload.get("attributes") or {}),
43
+ schema_version=str(payload.get("schema_version") or "event.v1"),
44
+ )
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import shutil
5
+ import socket
6
+ import sys
7
+ import zipfile
8
+ from datetime import UTC, datetime, timedelta
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import httpx
13
+ from sqlalchemy import delete, select, update
14
+ from sqlalchemy.orm import Session
15
+
16
+ from app.core.config import AppConfig
17
+ from app.db.models import (
18
+ Event,
19
+ GuardSession,
20
+ RequestRecord,
21
+ ToolCall,
22
+ TraceArtifact,
23
+ TraceEvent,
24
+ TraceRun,
25
+ TraceSpan,
26
+ )
27
+
28
+
29
+ def sqlite_path(config: AppConfig) -> Path | None:
30
+ if not config.storage_url.startswith("sqlite:///"):
31
+ return None
32
+ raw = config.storage_url.removeprefix("sqlite:///")
33
+ if raw == ":memory:":
34
+ return None
35
+ path = Path(raw)
36
+ return path if path.is_absolute() else Path.cwd() / path
37
+
38
+
39
+ def build_doctor_report(config: AppConfig) -> dict[str, Any]:
40
+ checks: list[dict[str, Any]] = []
41
+
42
+ def add(name: str, ok: bool, detail: str) -> None:
43
+ checks.append({"name": name, "ok": ok, "detail": detail})
44
+
45
+ add("python", sys.version_info >= (3, 11), sys.version.split()[0])
46
+ storage = sqlite_path(config)
47
+ if storage is None:
48
+ add("storage", True, config.storage_url)
49
+ else:
50
+ try:
51
+ storage.parent.mkdir(parents=True, exist_ok=True)
52
+ add("storage", True, str(storage))
53
+ except OSError as exc:
54
+ add("storage", False, str(exc))
55
+
56
+ try:
57
+ with socket.socket() as probe:
58
+ probe.settimeout(0.3)
59
+ available = probe.connect_ex((config.host, config.port)) != 0
60
+ if available:
61
+ add("port", True, f"{config.host}:{config.port} available")
62
+ else:
63
+ status = fetch_status(f"http://{config.host}:{config.port}")
64
+ add(
65
+ "port",
66
+ status["running"],
67
+ f"{config.host}:{config.port} "
68
+ + ("Agent Loop Guard is running" if status["running"] else "used by another process"),
69
+ )
70
+ except OSError as exc:
71
+ add("port", False, str(exc))
72
+
73
+ add("docker", shutil.which("docker") is not None, shutil.which("docker") or "not installed")
74
+ add("wsl", shutil.which("wsl") is not None, shutil.which("wsl") or "not installed")
75
+ return {"ok": all(item["ok"] for item in checks if item["name"] != "docker"), "checks": checks}
76
+
77
+
78
+ def fetch_status(base_url: str) -> dict[str, Any]:
79
+ try:
80
+ response = httpx.get(base_url.rstrip("/") + "/api/health", timeout=2)
81
+ response.raise_for_status()
82
+ return {"running": True, "url": base_url, **response.json()}
83
+ except (httpx.HTTPError, ValueError) as exc:
84
+ return {"running": False, "url": base_url, "error": str(exc)}
85
+
86
+
87
+ def create_backup(config: AppConfig, destination: Path, config_path: Path | None = None) -> Path:
88
+ destination.parent.mkdir(parents=True, exist_ok=True)
89
+ manifest = {
90
+ "schema_version": "backup.v1",
91
+ "created_at": datetime.now(UTC).isoformat(),
92
+ "storage_url": config.storage_url,
93
+ }
94
+ with zipfile.ZipFile(destination, "w", compression=zipfile.ZIP_DEFLATED) as archive:
95
+ archive.writestr("manifest.json", json.dumps(manifest, indent=2))
96
+ storage = sqlite_path(config)
97
+ if storage and storage.exists():
98
+ archive.write(storage, "data/agent_loop_guard.db")
99
+ if config_path and config_path.exists():
100
+ archive.write(config_path, "agent-loop-guard.yml")
101
+ return destination
102
+
103
+
104
+ def restore_backup(config: AppConfig, source: Path, *, force: bool = False) -> Path:
105
+ storage = sqlite_path(config)
106
+ if storage is None:
107
+ raise ValueError("Restore currently supports file-backed SQLite storage only.")
108
+ if storage.exists() and not force:
109
+ raise FileExistsError(f"{storage} exists; pass --force to replace it.")
110
+ with zipfile.ZipFile(source) as archive:
111
+ if "data/agent_loop_guard.db" not in archive.namelist():
112
+ raise ValueError("Backup does not contain a SQLite database.")
113
+ storage.parent.mkdir(parents=True, exist_ok=True)
114
+ with archive.open("data/agent_loop_guard.db") as src, storage.open("wb") as dst:
115
+ shutil.copyfileobj(src, dst)
116
+ return storage
117
+
118
+
119
+ def cleanup_old_data(db: Session, retention_days: int) -> dict[str, int]:
120
+ cutoff = datetime.now(UTC) - timedelta(days=max(1, retention_days))
121
+ old_trace_ids = select(TraceRun.id).where(TraceRun.updated_at < cutoff)
122
+ db.execute(delete(TraceArtifact).where(TraceArtifact.trace_id.in_(old_trace_ids)))
123
+ db.execute(delete(TraceEvent).where(TraceEvent.trace_id.in_(old_trace_ids)))
124
+ db.execute(delete(TraceSpan).where(TraceSpan.trace_id.in_(old_trace_ids)))
125
+ trace_result = db.execute(delete(TraceRun).where(TraceRun.updated_at < cutoff))
126
+
127
+ old_session_ids = select(GuardSession.id).where(GuardSession.updated_at < cutoff)
128
+ old_request_ids = select(RequestRecord.id).where(RequestRecord.session_id.in_(old_session_ids))
129
+ db.execute(update(TraceRun).where(TraceRun.source_session_id.in_(old_session_ids)).values(source_session_id=None))
130
+ db.execute(delete(ToolCall).where(ToolCall.request_id.in_(old_request_ids)))
131
+ db.execute(delete(Event).where(Event.session_id.in_(old_session_ids)))
132
+ db.execute(delete(RequestRecord).where(RequestRecord.session_id.in_(old_session_ids)))
133
+ session_result = db.execute(delete(GuardSession).where(GuardSession.updated_at < cutoff))
134
+ db.commit()
135
+ return {
136
+ "traces": int(trace_result.rowcount or 0),
137
+ "sessions": int(session_result.rowcount or 0),
138
+ }
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from alembic import command
6
+ from alembic.config import Config
7
+ from sqlalchemy import inspect
8
+
9
+
10
+ def alembic_config(storage_url: str) -> Config:
11
+ script_location = Path(__file__).with_name("alembic")
12
+ config = Config()
13
+ config.set_main_option("script_location", str(script_location))
14
+ config.set_main_option("sqlalchemy.url", storage_url.replace("%", "%%"))
15
+ return config
16
+
17
+
18
+ def migrate_database(engine, storage_url: str) -> None:
19
+ config = alembic_config(storage_url)
20
+ tables = set(inspect(engine).get_table_names())
21
+ if tables and "alembic_version" not in tables:
22
+ # v0.1 databases were created from the same SQLAlchemy metadata.
23
+ command.stamp(config, "0001_initial")
24
+ command.upgrade(config, "head")
app/platform/setup.py ADDED
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import secrets
5
+ from pathlib import Path
6
+
7
+ import yaml
8
+
9
+ from app.core.config import SAMPLE_CONFIG
10
+
11
+
12
+ def render_profiles(base_url: str, gateway_key: str) -> dict[str, str]:
13
+ openai_url = base_url.rstrip("/") + "/v1"
14
+ return {
15
+ "codex.toml": (
16
+ 'model = "demo-model"\n'
17
+ 'model_provider = "agent_loop_guard"\n\n'
18
+ "[model_providers.agent_loop_guard]\n"
19
+ 'name = "Agent Loop Guard"\n'
20
+ f'base_url = "{openai_url}"\n'
21
+ 'env_key = "ALG_GATEWAY_KEY"\n'
22
+ ),
23
+ "claude.env": (
24
+ f"ANTHROPIC_BASE_URL={base_url.rstrip('/')}\n"
25
+ f"ANTHROPIC_AUTH_TOKEN={gateway_key}\n"
26
+ ),
27
+ "cline.txt": (
28
+ "API Provider: OpenAI Compatible\n"
29
+ f"Base URL: {openai_url}\n"
30
+ f"API Key: {gateway_key}\n"
31
+ "Model ID: demo-model\n"
32
+ ),
33
+ "opencode.json": json.dumps(
34
+ {
35
+ "provider": {
36
+ "alg": {
37
+ "npm": "@ai-sdk/openai-compatible",
38
+ "name": "Agent Loop Guard",
39
+ "options": {"baseURL": openai_url, "apiKey": "{env:ALG_GATEWAY_KEY}"},
40
+ "models": {"demo-model": {"name": "Guarded model"}},
41
+ }
42
+ },
43
+ "model": "alg/demo-model",
44
+ },
45
+ indent=2,
46
+ )
47
+ + "\n",
48
+ }
49
+
50
+
51
+ def setup_workspace(
52
+ root: Path,
53
+ *,
54
+ host: str = "127.0.0.1",
55
+ port: int = 8787,
56
+ gateway_key: str | None = None,
57
+ force: bool = False,
58
+ ) -> dict[str, object]:
59
+ root.mkdir(parents=True, exist_ok=True)
60
+ config_path = root / "agent-loop-guard.yml"
61
+ profiles_dir = root / ".agent-loop-guard" / "profiles"
62
+ profiles_dir.mkdir(parents=True, exist_ok=True)
63
+
64
+ if config_path.exists() and not force:
65
+ config_written = False
66
+ existing = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
67
+ effective_gateway_key = str(existing.get("gateway_key") or gateway_key or "alg_demo_key")
68
+ else:
69
+ effective_gateway_key = gateway_key or f"alg_{secrets.token_urlsafe(24)}"
70
+ config_text = SAMPLE_CONFIG.replace(
71
+ "gateway_key: alg_demo_key", f"gateway_key: {effective_gateway_key}"
72
+ )
73
+ config_text = config_text.replace("host: 127.0.0.1", f"host: {host}", 1)
74
+ config_text = config_text.replace("port: 8787", f"port: {port}", 1)
75
+ config_path.write_text(config_text, encoding="utf-8")
76
+ config_written = True
77
+
78
+ base_url = f"http://{host}:{port}"
79
+ written_profiles = []
80
+ for name, content in render_profiles(base_url, effective_gateway_key).items():
81
+ path = profiles_dir / name
82
+ if force or not path.exists():
83
+ path.write_text(content, encoding="utf-8")
84
+ written_profiles.append(str(path))
85
+
86
+ return {
87
+ "config": str(config_path),
88
+ "config_written": config_written,
89
+ "profiles": written_profiles,
90
+ "base_url": base_url,
91
+ "gateway_key": effective_gateway_key if config_written else None,
92
+ }
@@ -0,0 +1,5 @@
1
+ from app.providers.base import ProviderResult, ProviderStream
2
+ from app.providers.mock import MockProvider
3
+ from app.providers.upstream import UpstreamProvider
4
+
5
+ __all__ = ["MockProvider", "ProviderResult", "ProviderStream", "UpstreamProvider"]
app/providers/base.py ADDED
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import AsyncIterator
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class ProviderResult:
10
+ status_code: int
11
+ headers: dict[str, str]
12
+ content: bytes
13
+ json_body: Any | None = None
14
+ media_type: str | None = None
15
+
16
+
17
+ @dataclass(slots=True)
18
+ class ProviderStream:
19
+ status_code: int
20
+ headers: dict[str, str]
21
+ chunks: AsyncIterator[bytes]
22
+ media_type: str = "text/event-stream"
23
+