autoforge-ai 0.1.0
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.
- package/.claude/commands/check-code.md +32 -0
- package/.claude/commands/checkpoint.md +40 -0
- package/.claude/commands/create-spec.md +613 -0
- package/.claude/commands/expand-project.md +234 -0
- package/.claude/commands/gsd-to-autoforge-spec.md +10 -0
- package/.claude/commands/review-pr.md +75 -0
- package/.claude/templates/app_spec.template.txt +331 -0
- package/.claude/templates/coding_prompt.template.md +265 -0
- package/.claude/templates/initializer_prompt.template.md +354 -0
- package/.claude/templates/testing_prompt.template.md +146 -0
- package/.env.example +64 -0
- package/LICENSE.md +676 -0
- package/README.md +423 -0
- package/agent.py +444 -0
- package/api/__init__.py +10 -0
- package/api/database.py +536 -0
- package/api/dependency_resolver.py +449 -0
- package/api/migration.py +156 -0
- package/auth.py +83 -0
- package/autoforge_paths.py +315 -0
- package/autonomous_agent_demo.py +293 -0
- package/bin/autoforge.js +3 -0
- package/client.py +607 -0
- package/env_constants.py +27 -0
- package/examples/OPTIMIZE_CONFIG.md +230 -0
- package/examples/README.md +531 -0
- package/examples/org_config.yaml +172 -0
- package/examples/project_allowed_commands.yaml +139 -0
- package/lib/cli.js +791 -0
- package/mcp_server/__init__.py +1 -0
- package/mcp_server/feature_mcp.py +988 -0
- package/package.json +53 -0
- package/parallel_orchestrator.py +1800 -0
- package/progress.py +247 -0
- package/prompts.py +427 -0
- package/pyproject.toml +17 -0
- package/rate_limit_utils.py +132 -0
- package/registry.py +614 -0
- package/requirements-prod.txt +14 -0
- package/security.py +959 -0
- package/server/__init__.py +17 -0
- package/server/main.py +261 -0
- package/server/routers/__init__.py +32 -0
- package/server/routers/agent.py +177 -0
- package/server/routers/assistant_chat.py +327 -0
- package/server/routers/devserver.py +309 -0
- package/server/routers/expand_project.py +239 -0
- package/server/routers/features.py +746 -0
- package/server/routers/filesystem.py +514 -0
- package/server/routers/projects.py +524 -0
- package/server/routers/schedules.py +356 -0
- package/server/routers/settings.py +127 -0
- package/server/routers/spec_creation.py +357 -0
- package/server/routers/terminal.py +453 -0
- package/server/schemas.py +593 -0
- package/server/services/__init__.py +36 -0
- package/server/services/assistant_chat_session.py +496 -0
- package/server/services/assistant_database.py +304 -0
- package/server/services/chat_constants.py +57 -0
- package/server/services/dev_server_manager.py +557 -0
- package/server/services/expand_chat_session.py +399 -0
- package/server/services/process_manager.py +657 -0
- package/server/services/project_config.py +475 -0
- package/server/services/scheduler_service.py +683 -0
- package/server/services/spec_chat_session.py +502 -0
- package/server/services/terminal_manager.py +756 -0
- package/server/utils/__init__.py +1 -0
- package/server/utils/process_utils.py +134 -0
- package/server/utils/project_helpers.py +32 -0
- package/server/utils/validation.py +54 -0
- package/server/websocket.py +903 -0
- package/start.py +456 -0
- package/ui/dist/assets/index-8W_wmZzz.js +168 -0
- package/ui/dist/assets/index-B47Ubhox.css +1 -0
- package/ui/dist/assets/vendor-flow-CVNK-_lx.js +7 -0
- package/ui/dist/assets/vendor-query-BUABzP5o.js +1 -0
- package/ui/dist/assets/vendor-radix-DTNNCg2d.js +45 -0
- package/ui/dist/assets/vendor-react-qkC6yhPU.js +1 -0
- package/ui/dist/assets/vendor-utils-COeKbHgx.js +2 -0
- package/ui/dist/assets/vendor-xterm-DP_gxef0.js +16 -0
- package/ui/dist/index.html +23 -0
- package/ui/dist/ollama.png +0 -0
- package/ui/dist/vite.svg +6 -0
- package/ui/package.json +57 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Schedules Router
|
|
3
|
+
================
|
|
4
|
+
|
|
5
|
+
API endpoints for managing agent schedules.
|
|
6
|
+
Provides CRUD operations for time-based schedule configuration.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from datetime import datetime, timedelta, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING, Generator, Tuple
|
|
13
|
+
|
|
14
|
+
from fastapi import APIRouter, HTTPException
|
|
15
|
+
from sqlalchemy.orm import Session
|
|
16
|
+
|
|
17
|
+
# Schedule limits to prevent resource exhaustion
|
|
18
|
+
MAX_SCHEDULES_PER_PROJECT = 50
|
|
19
|
+
|
|
20
|
+
from ..schemas import (
|
|
21
|
+
NextRunResponse,
|
|
22
|
+
ScheduleCreate,
|
|
23
|
+
ScheduleListResponse,
|
|
24
|
+
ScheduleResponse,
|
|
25
|
+
ScheduleUpdate,
|
|
26
|
+
)
|
|
27
|
+
from ..utils.project_helpers import get_project_path as _get_project_path
|
|
28
|
+
from ..utils.validation import validate_project_name
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from api.database import Schedule as ScheduleModel
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _schedule_to_response(schedule: "ScheduleModel") -> ScheduleResponse:
|
|
35
|
+
"""Convert a Schedule ORM object to a ScheduleResponse Pydantic model.
|
|
36
|
+
|
|
37
|
+
SQLAlchemy Column descriptors resolve to Python types at instance access time,
|
|
38
|
+
but mypy sees the Column[T] descriptor type. Using model_validate with
|
|
39
|
+
from_attributes handles this conversion correctly.
|
|
40
|
+
"""
|
|
41
|
+
return ScheduleResponse.model_validate(schedule, from_attributes=True)
|
|
42
|
+
|
|
43
|
+
router = APIRouter(
|
|
44
|
+
prefix="/api/projects/{project_name}/schedules",
|
|
45
|
+
tags=["schedules"]
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@contextmanager
|
|
50
|
+
def _get_db_session(project_name: str) -> Generator[Tuple[Session, Path], None, None]:
|
|
51
|
+
"""Get database session for a project as a context manager.
|
|
52
|
+
|
|
53
|
+
Usage:
|
|
54
|
+
with _get_db_session(project_name) as (db, project_path):
|
|
55
|
+
# ... use db ...
|
|
56
|
+
# db is automatically closed
|
|
57
|
+
"""
|
|
58
|
+
from api.database import create_database
|
|
59
|
+
|
|
60
|
+
project_name = validate_project_name(project_name)
|
|
61
|
+
project_path = _get_project_path(project_name)
|
|
62
|
+
|
|
63
|
+
if not project_path:
|
|
64
|
+
raise HTTPException(
|
|
65
|
+
status_code=404,
|
|
66
|
+
detail=f"Project '{project_name}' not found in registry"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if not project_path.exists():
|
|
70
|
+
raise HTTPException(
|
|
71
|
+
status_code=404,
|
|
72
|
+
detail=f"Project directory not found: {project_path}"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
_, SessionLocal = create_database(project_path)
|
|
76
|
+
db = SessionLocal()
|
|
77
|
+
try:
|
|
78
|
+
yield db, project_path
|
|
79
|
+
except Exception:
|
|
80
|
+
db.rollback()
|
|
81
|
+
raise
|
|
82
|
+
finally:
|
|
83
|
+
db.close()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@router.get("", response_model=ScheduleListResponse)
|
|
87
|
+
async def list_schedules(project_name: str):
|
|
88
|
+
"""Get all schedules for a project."""
|
|
89
|
+
from api.database import Schedule
|
|
90
|
+
|
|
91
|
+
with _get_db_session(project_name) as (db, _):
|
|
92
|
+
schedules = db.query(Schedule).filter(
|
|
93
|
+
Schedule.project_name == project_name
|
|
94
|
+
).order_by(Schedule.start_time).all()
|
|
95
|
+
|
|
96
|
+
return ScheduleListResponse(
|
|
97
|
+
schedules=[_schedule_to_response(s) for s in schedules]
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@router.post("", response_model=ScheduleResponse, status_code=201)
|
|
102
|
+
async def create_schedule(project_name: str, data: ScheduleCreate):
|
|
103
|
+
"""Create a new schedule for a project."""
|
|
104
|
+
from api.database import Schedule
|
|
105
|
+
|
|
106
|
+
from ..services.scheduler_service import get_scheduler
|
|
107
|
+
|
|
108
|
+
with _get_db_session(project_name) as (db, project_path):
|
|
109
|
+
# Check schedule limit to prevent resource exhaustion
|
|
110
|
+
existing_count = db.query(Schedule).filter(
|
|
111
|
+
Schedule.project_name == project_name
|
|
112
|
+
).count()
|
|
113
|
+
|
|
114
|
+
if existing_count >= MAX_SCHEDULES_PER_PROJECT:
|
|
115
|
+
raise HTTPException(
|
|
116
|
+
status_code=400,
|
|
117
|
+
detail=f"Maximum schedules per project ({MAX_SCHEDULES_PER_PROJECT}) exceeded"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Create schedule record
|
|
121
|
+
schedule = Schedule(
|
|
122
|
+
project_name=project_name,
|
|
123
|
+
start_time=data.start_time,
|
|
124
|
+
duration_minutes=data.duration_minutes,
|
|
125
|
+
days_of_week=data.days_of_week,
|
|
126
|
+
enabled=data.enabled,
|
|
127
|
+
yolo_mode=data.yolo_mode,
|
|
128
|
+
model=data.model,
|
|
129
|
+
)
|
|
130
|
+
db.add(schedule)
|
|
131
|
+
db.commit()
|
|
132
|
+
db.refresh(schedule)
|
|
133
|
+
|
|
134
|
+
# Register with APScheduler if enabled
|
|
135
|
+
if schedule.enabled:
|
|
136
|
+
import logging
|
|
137
|
+
logger = logging.getLogger(__name__)
|
|
138
|
+
|
|
139
|
+
scheduler = get_scheduler()
|
|
140
|
+
await scheduler.add_schedule(project_name, schedule, project_path)
|
|
141
|
+
logger.info(f"Registered schedule {schedule.id} with APScheduler")
|
|
142
|
+
|
|
143
|
+
# Check if we're currently within this schedule's window
|
|
144
|
+
# If so, start the agent immediately (cron won't trigger until next occurrence)
|
|
145
|
+
now = datetime.now(timezone.utc)
|
|
146
|
+
is_within = scheduler._is_within_window(schedule, now)
|
|
147
|
+
logger.info(f"Schedule {schedule.id}: is_within_window={is_within}, now={now}, start={schedule.start_time}")
|
|
148
|
+
|
|
149
|
+
if is_within:
|
|
150
|
+
# Check for manual stop override
|
|
151
|
+
from api.database import ScheduleOverride
|
|
152
|
+
override = db.query(ScheduleOverride).filter(
|
|
153
|
+
ScheduleOverride.schedule_id == schedule.id,
|
|
154
|
+
ScheduleOverride.override_type == "stop",
|
|
155
|
+
ScheduleOverride.expires_at > now,
|
|
156
|
+
).first()
|
|
157
|
+
|
|
158
|
+
logger.info(f"Schedule {schedule.id}: has_override={override is not None}")
|
|
159
|
+
|
|
160
|
+
if not override:
|
|
161
|
+
# Start agent immediately
|
|
162
|
+
logger.info(
|
|
163
|
+
f"Schedule {schedule.id} is within active window, starting agent immediately"
|
|
164
|
+
)
|
|
165
|
+
try:
|
|
166
|
+
await scheduler._start_agent(project_name, project_path, schedule)
|
|
167
|
+
logger.info(f"Successfully started agent for schedule {schedule.id}")
|
|
168
|
+
except Exception as e:
|
|
169
|
+
logger.error(f"Failed to start agent for schedule {schedule.id}: {e}", exc_info=True)
|
|
170
|
+
|
|
171
|
+
return _schedule_to_response(schedule)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@router.get("/next", response_model=NextRunResponse)
|
|
175
|
+
async def get_next_scheduled_run(project_name: str):
|
|
176
|
+
"""Calculate next scheduled run across all enabled schedules."""
|
|
177
|
+
from api.database import Schedule, ScheduleOverride
|
|
178
|
+
|
|
179
|
+
from ..services.scheduler_service import get_scheduler
|
|
180
|
+
|
|
181
|
+
with _get_db_session(project_name) as (db, _):
|
|
182
|
+
schedules = db.query(Schedule).filter(
|
|
183
|
+
Schedule.project_name == project_name,
|
|
184
|
+
Schedule.enabled == True, # noqa: E712
|
|
185
|
+
).all()
|
|
186
|
+
|
|
187
|
+
if not schedules:
|
|
188
|
+
return NextRunResponse(
|
|
189
|
+
has_schedules=False,
|
|
190
|
+
next_start=None,
|
|
191
|
+
next_end=None,
|
|
192
|
+
is_currently_running=False,
|
|
193
|
+
active_schedule_count=0,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
now = datetime.now(timezone.utc)
|
|
197
|
+
scheduler = get_scheduler()
|
|
198
|
+
|
|
199
|
+
# Find active schedules and calculate next run
|
|
200
|
+
active_count = 0
|
|
201
|
+
next_start = None
|
|
202
|
+
latest_end = None
|
|
203
|
+
|
|
204
|
+
for schedule in schedules:
|
|
205
|
+
if scheduler._is_within_window(schedule, now):
|
|
206
|
+
# Check for manual stop override
|
|
207
|
+
override = db.query(ScheduleOverride).filter(
|
|
208
|
+
ScheduleOverride.schedule_id == schedule.id,
|
|
209
|
+
ScheduleOverride.override_type == "stop",
|
|
210
|
+
ScheduleOverride.expires_at > now,
|
|
211
|
+
).first()
|
|
212
|
+
|
|
213
|
+
if not override:
|
|
214
|
+
# Schedule is active and not manually stopped
|
|
215
|
+
active_count += 1
|
|
216
|
+
# Calculate end time for this window
|
|
217
|
+
end_time = _calculate_window_end(schedule, now)
|
|
218
|
+
if latest_end is None or end_time > latest_end:
|
|
219
|
+
latest_end = end_time
|
|
220
|
+
# If override exists, treat schedule as not active
|
|
221
|
+
else:
|
|
222
|
+
# Calculate next start time
|
|
223
|
+
next_schedule_start = _calculate_next_start(schedule, now)
|
|
224
|
+
if next_schedule_start and (next_start is None or next_schedule_start < next_start):
|
|
225
|
+
next_start = next_schedule_start
|
|
226
|
+
|
|
227
|
+
return NextRunResponse(
|
|
228
|
+
has_schedules=True,
|
|
229
|
+
next_start=next_start if active_count == 0 else None,
|
|
230
|
+
next_end=latest_end,
|
|
231
|
+
is_currently_running=active_count > 0,
|
|
232
|
+
active_schedule_count=active_count,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@router.get("/{schedule_id}", response_model=ScheduleResponse)
|
|
237
|
+
async def get_schedule(project_name: str, schedule_id: int):
|
|
238
|
+
"""Get a single schedule by ID."""
|
|
239
|
+
from api.database import Schedule
|
|
240
|
+
|
|
241
|
+
with _get_db_session(project_name) as (db, _):
|
|
242
|
+
schedule = db.query(Schedule).filter(
|
|
243
|
+
Schedule.id == schedule_id,
|
|
244
|
+
Schedule.project_name == project_name,
|
|
245
|
+
).first()
|
|
246
|
+
|
|
247
|
+
if not schedule:
|
|
248
|
+
raise HTTPException(status_code=404, detail="Schedule not found")
|
|
249
|
+
|
|
250
|
+
return _schedule_to_response(schedule)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@router.patch("/{schedule_id}", response_model=ScheduleResponse)
|
|
254
|
+
async def update_schedule(
|
|
255
|
+
project_name: str,
|
|
256
|
+
schedule_id: int,
|
|
257
|
+
data: ScheduleUpdate
|
|
258
|
+
):
|
|
259
|
+
"""Update an existing schedule."""
|
|
260
|
+
from api.database import Schedule
|
|
261
|
+
|
|
262
|
+
from ..services.scheduler_service import get_scheduler
|
|
263
|
+
|
|
264
|
+
with _get_db_session(project_name) as (db, project_path):
|
|
265
|
+
schedule = db.query(Schedule).filter(
|
|
266
|
+
Schedule.id == schedule_id,
|
|
267
|
+
Schedule.project_name == project_name,
|
|
268
|
+
).first()
|
|
269
|
+
|
|
270
|
+
if not schedule:
|
|
271
|
+
raise HTTPException(status_code=404, detail="Schedule not found")
|
|
272
|
+
|
|
273
|
+
was_enabled = schedule.enabled
|
|
274
|
+
|
|
275
|
+
# Update only fields that were explicitly provided
|
|
276
|
+
# This allows sending {"model": null} to clear it vs omitting the field entirely
|
|
277
|
+
update_data = data.model_dump(exclude_unset=True)
|
|
278
|
+
for field, value in update_data.items():
|
|
279
|
+
setattr(schedule, field, value)
|
|
280
|
+
|
|
281
|
+
db.commit()
|
|
282
|
+
db.refresh(schedule)
|
|
283
|
+
|
|
284
|
+
# Update APScheduler jobs
|
|
285
|
+
scheduler = get_scheduler()
|
|
286
|
+
if schedule.enabled:
|
|
287
|
+
# Re-register with updated times
|
|
288
|
+
await scheduler.add_schedule(project_name, schedule, project_path)
|
|
289
|
+
elif was_enabled:
|
|
290
|
+
# Was enabled, now disabled - remove jobs
|
|
291
|
+
scheduler.remove_schedule(schedule_id)
|
|
292
|
+
|
|
293
|
+
return _schedule_to_response(schedule)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@router.delete("/{schedule_id}", status_code=204)
|
|
297
|
+
async def delete_schedule(project_name: str, schedule_id: int):
|
|
298
|
+
"""Delete a schedule."""
|
|
299
|
+
from api.database import Schedule
|
|
300
|
+
|
|
301
|
+
from ..services.scheduler_service import get_scheduler
|
|
302
|
+
|
|
303
|
+
with _get_db_session(project_name) as (db, _):
|
|
304
|
+
schedule = db.query(Schedule).filter(
|
|
305
|
+
Schedule.id == schedule_id,
|
|
306
|
+
Schedule.project_name == project_name,
|
|
307
|
+
).first()
|
|
308
|
+
|
|
309
|
+
if not schedule:
|
|
310
|
+
raise HTTPException(status_code=404, detail="Schedule not found")
|
|
311
|
+
|
|
312
|
+
# Remove APScheduler jobs
|
|
313
|
+
scheduler = get_scheduler()
|
|
314
|
+
scheduler.remove_schedule(schedule_id)
|
|
315
|
+
|
|
316
|
+
# Delete from database
|
|
317
|
+
db.delete(schedule)
|
|
318
|
+
db.commit()
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _calculate_window_end(schedule, now: datetime) -> datetime:
|
|
322
|
+
"""Calculate when the current window ends."""
|
|
323
|
+
start_hour, start_minute = map(int, schedule.start_time.split(":"))
|
|
324
|
+
|
|
325
|
+
# Create start time for today in UTC
|
|
326
|
+
window_start = now.replace(
|
|
327
|
+
hour=start_hour, minute=start_minute, second=0, microsecond=0
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
# If current time is before start time, the window started yesterday
|
|
331
|
+
if now < window_start:
|
|
332
|
+
window_start = window_start - timedelta(days=1)
|
|
333
|
+
|
|
334
|
+
return window_start + timedelta(minutes=schedule.duration_minutes)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _calculate_next_start(schedule, now: datetime) -> datetime | None:
|
|
338
|
+
"""Calculate the next start time for a schedule."""
|
|
339
|
+
start_hour, start_minute = map(int, schedule.start_time.split(":"))
|
|
340
|
+
|
|
341
|
+
# Create start time for today
|
|
342
|
+
candidate = now.replace(
|
|
343
|
+
hour=start_hour, minute=start_minute, second=0, microsecond=0
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# If already past today's start time, check tomorrow
|
|
347
|
+
if candidate <= now:
|
|
348
|
+
candidate = candidate + timedelta(days=1)
|
|
349
|
+
|
|
350
|
+
# Find the next active day
|
|
351
|
+
for _ in range(7):
|
|
352
|
+
if schedule.is_active_on_day(candidate.weekday()):
|
|
353
|
+
return candidate
|
|
354
|
+
candidate = candidate + timedelta(days=1)
|
|
355
|
+
|
|
356
|
+
return None
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Settings Router
|
|
3
|
+
===============
|
|
4
|
+
|
|
5
|
+
API endpoints for global settings management.
|
|
6
|
+
Settings are stored in the registry database and shared across all projects.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import mimetypes
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter
|
|
14
|
+
|
|
15
|
+
from ..schemas import ModelInfo, ModelsResponse, SettingsResponse, SettingsUpdate
|
|
16
|
+
from ..services.chat_constants import ROOT_DIR
|
|
17
|
+
|
|
18
|
+
# Mimetype fix for Windows - must run before StaticFiles is mounted
|
|
19
|
+
mimetypes.add_type("text/javascript", ".js", True)
|
|
20
|
+
|
|
21
|
+
# Ensure root is on sys.path for registry import
|
|
22
|
+
if str(ROOT_DIR) not in sys.path:
|
|
23
|
+
sys.path.insert(0, str(ROOT_DIR))
|
|
24
|
+
|
|
25
|
+
from registry import (
|
|
26
|
+
AVAILABLE_MODELS,
|
|
27
|
+
DEFAULT_MODEL,
|
|
28
|
+
get_all_settings,
|
|
29
|
+
set_setting,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parse_yolo_mode(value: str | None) -> bool:
|
|
36
|
+
"""Parse YOLO mode string to boolean."""
|
|
37
|
+
return (value or "false").lower() == "true"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_glm_mode() -> bool:
|
|
41
|
+
"""Check if GLM API is configured via environment variables."""
|
|
42
|
+
base_url = os.getenv("ANTHROPIC_BASE_URL", "")
|
|
43
|
+
# GLM mode is when ANTHROPIC_BASE_URL is set but NOT pointing to Ollama
|
|
44
|
+
return bool(base_url) and not _is_ollama_mode()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _is_ollama_mode() -> bool:
|
|
48
|
+
"""Check if Ollama API is configured via environment variables."""
|
|
49
|
+
base_url = os.getenv("ANTHROPIC_BASE_URL", "")
|
|
50
|
+
return "localhost:11434" in base_url or "127.0.0.1:11434" in base_url
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@router.get("/models", response_model=ModelsResponse)
|
|
54
|
+
async def get_available_models():
|
|
55
|
+
"""Get list of available models.
|
|
56
|
+
|
|
57
|
+
Frontend should call this to get the current list of models
|
|
58
|
+
instead of hardcoding them.
|
|
59
|
+
"""
|
|
60
|
+
return ModelsResponse(
|
|
61
|
+
models=[ModelInfo(id=m["id"], name=m["name"]) for m in AVAILABLE_MODELS],
|
|
62
|
+
default=DEFAULT_MODEL,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _parse_int(value: str | None, default: int) -> int:
|
|
67
|
+
"""Parse integer setting with default fallback."""
|
|
68
|
+
if value is None:
|
|
69
|
+
return default
|
|
70
|
+
try:
|
|
71
|
+
return int(value)
|
|
72
|
+
except (ValueError, TypeError):
|
|
73
|
+
return default
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _parse_bool(value: str | None, default: bool = False) -> bool:
|
|
77
|
+
"""Parse boolean setting with default fallback."""
|
|
78
|
+
if value is None:
|
|
79
|
+
return default
|
|
80
|
+
return value.lower() == "true"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@router.get("", response_model=SettingsResponse)
|
|
84
|
+
async def get_settings():
|
|
85
|
+
"""Get current global settings."""
|
|
86
|
+
all_settings = get_all_settings()
|
|
87
|
+
|
|
88
|
+
return SettingsResponse(
|
|
89
|
+
yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")),
|
|
90
|
+
model=all_settings.get("model", DEFAULT_MODEL),
|
|
91
|
+
glm_mode=_is_glm_mode(),
|
|
92
|
+
ollama_mode=_is_ollama_mode(),
|
|
93
|
+
testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
|
|
94
|
+
playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True),
|
|
95
|
+
batch_size=_parse_int(all_settings.get("batch_size"), 3),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@router.patch("", response_model=SettingsResponse)
|
|
100
|
+
async def update_settings(update: SettingsUpdate):
|
|
101
|
+
"""Update global settings."""
|
|
102
|
+
if update.yolo_mode is not None:
|
|
103
|
+
set_setting("yolo_mode", "true" if update.yolo_mode else "false")
|
|
104
|
+
|
|
105
|
+
if update.model is not None:
|
|
106
|
+
set_setting("model", update.model)
|
|
107
|
+
|
|
108
|
+
if update.testing_agent_ratio is not None:
|
|
109
|
+
set_setting("testing_agent_ratio", str(update.testing_agent_ratio))
|
|
110
|
+
|
|
111
|
+
if update.playwright_headless is not None:
|
|
112
|
+
set_setting("playwright_headless", "true" if update.playwright_headless else "false")
|
|
113
|
+
|
|
114
|
+
if update.batch_size is not None:
|
|
115
|
+
set_setting("batch_size", str(update.batch_size))
|
|
116
|
+
|
|
117
|
+
# Return updated settings
|
|
118
|
+
all_settings = get_all_settings()
|
|
119
|
+
return SettingsResponse(
|
|
120
|
+
yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")),
|
|
121
|
+
model=all_settings.get("model", DEFAULT_MODEL),
|
|
122
|
+
glm_mode=_is_glm_mode(),
|
|
123
|
+
ollama_mode=_is_ollama_mode(),
|
|
124
|
+
testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
|
|
125
|
+
playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True),
|
|
126
|
+
batch_size=_parse_int(all_settings.get("batch_size"), 3),
|
|
127
|
+
)
|