Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions codeframe/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@ class EnvironmentConfig:
# LLM provider config (workspace-level default; overridden by env vars and CLI flags)
llm: Optional[LLMConfig] = None

# Settings page (issue #554) — UI-managed agent settings
max_cost_usd: Optional[float] = None
agent_type_models: dict[str, str] = dataclass_field(default_factory=dict)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def validate(self) -> list[str]:
"""Validate configuration values.

Expand Down
42 changes: 42 additions & 0 deletions codeframe/ui/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -901,3 +901,45 @@ class ErrorResponse(BaseModel):
)

detail: str = Field(..., description="Error message describing what went wrong")


# ============================================================================
# Settings (issue #554)
# ============================================================================


AGENT_TYPES = ("claude_code", "codex", "opencode", "react")


class AgentTypeModelConfig(BaseModel):
"""Default model for a single agent type."""

agent_type: str = Field(
..., description="One of: claude_code, codex, opencode, react"
)
default_model: str = Field(
default="",
description="Model identifier (e.g. 'claude-opus-4', 'gpt-4o'); empty string means unset",
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated


class AgentSettings(BaseModel):
"""Agent settings shared by GET response and PUT request."""

agent_models: List[AgentTypeModelConfig] = Field(
..., description="Default model per agent type"
)
max_turns: int = Field(
default=20, gt=0, description="Maximum turns per task (must be > 0)"
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
max_cost_usd: Optional[float] = Field(
default=None, ge=0, description="Maximum cost per task in USD"
)


class AgentSettingsResponse(AgentSettings):
"""Response shape for GET /api/v2/settings."""


class UpdateAgentSettingsRequest(AgentSettings):
"""Request shape for PUT /api/v2/settings."""
106 changes: 106 additions & 0 deletions codeframe/ui/routers/settings_v2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""V2 Settings router — agent settings managed via the web UI.

Reads/writes a flat AgentSettings shape persisted in
.codeframe/config.yaml via load_environment_config / save_environment_config.

Routes:
GET /api/v2/settings - Load agent settings (returns defaults if missing)
PUT /api/v2/settings - Save agent settings (merges into existing config)
"""

import logging

from fastapi import APIRouter, Depends, HTTPException, Request

from codeframe.core.config import (
EnvironmentConfig,
load_environment_config,
save_environment_config,
)
from codeframe.core.workspace import Workspace
from codeframe.lib.rate_limiter import rate_limit_standard
from codeframe.ui.dependencies import get_v2_workspace
from codeframe.ui.models import (
AGENT_TYPES,
AgentSettingsResponse,
AgentTypeModelConfig,
UpdateAgentSettingsRequest,
)
from codeframe.ui.response_models import ErrorCodes, api_error

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/api/v2/settings", tags=["settings"])


def _config_to_response(config: EnvironmentConfig) -> AgentSettingsResponse:
"""Map an EnvironmentConfig to the flat AgentSettings response shape."""
saved_models = config.agent_type_models or {}
agent_models = [
AgentTypeModelConfig(
agent_type=agent_type,
default_model=saved_models.get(agent_type, ""),
)
for agent_type in AGENT_TYPES
]
return AgentSettingsResponse(
agent_models=agent_models,
max_turns=config.agent_budget.max_iterations,
max_cost_usd=config.max_cost_usd,
)


@router.get("", response_model=AgentSettingsResponse)
@rate_limit_standard()
async def get_settings(
request: Request,
workspace: Workspace = Depends(get_v2_workspace),
) -> AgentSettingsResponse:
"""Load agent settings for the workspace.

Returns defaults if no .codeframe/config.yaml exists.
"""
try:
config = load_environment_config(workspace.repo_path) or EnvironmentConfig()
return _config_to_response(config)
except Exception as e:
logger.error(f"Failed to load settings: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=api_error(
"Failed to load settings", ErrorCodes.EXECUTION_FAILED, str(e)
),
)


@router.put("", response_model=AgentSettingsResponse)
@rate_limit_standard()
async def update_settings(
request: Request,
body: UpdateAgentSettingsRequest,
workspace: Workspace = Depends(get_v2_workspace),
) -> AgentSettingsResponse:
"""Save agent settings.

Merges into existing EnvironmentConfig so unrelated fields
(package_manager, test_framework, etc.) are preserved.
"""
try:
config = load_environment_config(workspace.repo_path) or EnvironmentConfig()

config.agent_budget.max_iterations = body.max_turns
config.max_cost_usd = body.max_cost_usd
config.agent_type_models = {
entry.agent_type: entry.default_model for entry in body.agent_models
}

save_environment_config(workspace.repo_path, config)
return _config_to_response(config)
except Exception as e:
logger.error(f"Failed to save settings: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=api_error(
"Failed to save settings", ErrorCodes.EXECUTION_FAILED, str(e)
),
)
2 changes: 2 additions & 0 deletions codeframe/ui/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
review_v2,
schedule_v2,
session_chat_ws,
settings_v2,
terminal_ws,
streaming_v2,
tasks_v2,
Expand Down Expand Up @@ -496,6 +497,7 @@ async def test_broadcast(message: dict, project_id: int = None):
app.include_router(proof_v2.router) # /api/v2/proof
app.include_router(review_v2.router) # /api/v2/review
app.include_router(schedule_v2.router) # /api/v2/schedule
app.include_router(settings_v2.router) # /api/v2/settings
app.include_router(streaming_v2.router) # /api/v2/tasks/{id}/stream (SSE)
app.include_router(tasks_v2.router) # /api/v2/tasks
app.include_router(templates_v2.router) # /api/v2/templates
Expand Down
201 changes: 201 additions & 0 deletions tests/ui/test_settings_v2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
"""Integration tests for settings_v2 router.

Tests:
- GET /api/v2/settings returns defaults for empty workspace
- PUT /api/v2/settings persists new settings
- GET after PUT returns the saved settings (round-trip)
- PUT merges into existing EnvironmentConfig without losing other fields
"""

import shutil
import tempfile
from pathlib import Path

import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient

pytestmark = pytest.mark.v2


@pytest.fixture
def test_workspace():
"""Create a temporary workspace for testing."""
temp_dir = Path(tempfile.mkdtemp())
workspace_path = temp_dir / "test_workspace"
workspace_path.mkdir(parents=True, exist_ok=True)

from codeframe.core.workspace import create_or_load_workspace

workspace = create_or_load_workspace(workspace_path)

yield workspace

shutil.rmtree(temp_dir, ignore_errors=True)


@pytest.fixture
def test_client(test_workspace):
"""Create a FastAPI TestClient with settings_v2 router and workspace override."""
from codeframe.ui.routers import settings_v2
from codeframe.ui.dependencies import get_v2_workspace

app = FastAPI()
app.include_router(settings_v2.router)

def get_test_workspace():
return test_workspace

app.dependency_overrides[get_v2_workspace] = get_test_workspace

client = TestClient(app)
client.workspace = test_workspace
yield client


class TestSettingsV2Get:
"""Tests for GET /api/v2/settings."""

def test_returns_defaults_when_no_config(self, test_client):
"""GET returns default settings for a workspace with no config file."""
response = test_client.get("/api/v2/settings")
assert response.status_code == 200
data = response.json()

# Maps to EnvironmentConfig.agent_budget.max_iterations (default 100)
assert data["max_turns"] == 100
assert data["max_cost_usd"] is None

agent_types = {entry["agent_type"] for entry in data["agent_models"]}
assert agent_types == {"claude_code", "codex", "opencode", "react"}

for entry in data["agent_models"]:
assert entry["default_model"] == ""

def test_returns_existing_config(self, test_client, test_workspace):
"""GET returns saved settings from .codeframe/config.yaml."""
from codeframe.core.config import (
EnvironmentConfig,
save_environment_config,
)

config = EnvironmentConfig()
config.max_cost_usd = 5.50
config.agent_type_models = {"claude_code": "claude-opus-4"}
config.agent_budget.max_iterations = 42
save_environment_config(test_workspace.repo_path, config)

response = test_client.get("/api/v2/settings")
assert response.status_code == 200
data = response.json()

assert data["max_turns"] == 42
assert data["max_cost_usd"] == 5.50
cc_entry = next(
e for e in data["agent_models"] if e["agent_type"] == "claude_code"
)
assert cc_entry["default_model"] == "claude-opus-4"


class TestSettingsV2Put:
"""Tests for PUT /api/v2/settings."""

def test_put_persists_settings(self, test_client, test_workspace):
"""PUT saves settings to .codeframe/config.yaml."""
body = {
"agent_models": [
{"agent_type": "claude_code", "default_model": "claude-sonnet-4"},
{"agent_type": "codex", "default_model": "gpt-4o"},
{"agent_type": "opencode", "default_model": ""},
{"agent_type": "react", "default_model": "claude-opus-4"},
],
"max_turns": 30,
"max_cost_usd": 10.0,
}

response = test_client.put("/api/v2/settings", json=body)
assert response.status_code == 200
data = response.json()
assert data["max_turns"] == 30
assert data["max_cost_usd"] == 10.0

# Verify persisted to disk
from codeframe.core.config import load_environment_config

loaded = load_environment_config(test_workspace.repo_path)
assert loaded is not None
assert loaded.agent_budget.max_iterations == 30
assert loaded.max_cost_usd == 10.0
assert loaded.agent_type_models["claude_code"] == "claude-sonnet-4"
assert loaded.agent_type_models["codex"] == "gpt-4o"
assert loaded.agent_type_models["react"] == "claude-opus-4"

def test_put_round_trip(self, test_client):
"""GET after PUT returns the saved settings."""
body = {
"agent_models": [
{"agent_type": "claude_code", "default_model": "claude-opus-4"},
{"agent_type": "codex", "default_model": ""},
{"agent_type": "opencode", "default_model": ""},
{"agent_type": "react", "default_model": ""},
],
"max_turns": 25,
"max_cost_usd": 7.5,
}
put_resp = test_client.put("/api/v2/settings", json=body)
assert put_resp.status_code == 200

get_resp = test_client.get("/api/v2/settings")
assert get_resp.status_code == 200
data = get_resp.json()
assert data["max_turns"] == 25
assert data["max_cost_usd"] == 7.5
cc = next(e for e in data["agent_models"] if e["agent_type"] == "claude_code")
assert cc["default_model"] == "claude-opus-4"

def test_put_preserves_unrelated_config(self, test_client, test_workspace):
"""PUT does not destroy other EnvironmentConfig fields like package_manager."""
from codeframe.core.config import (
EnvironmentConfig,
save_environment_config,
load_environment_config,
)

# Pre-existing config with non-default fields
existing = EnvironmentConfig()
existing.package_manager = "poetry"
existing.test_framework = "jest"
save_environment_config(test_workspace.repo_path, existing)

body = {
"agent_models": [
{"agent_type": "claude_code", "default_model": "claude-opus-4"},
{"agent_type": "codex", "default_model": ""},
{"agent_type": "opencode", "default_model": ""},
{"agent_type": "react", "default_model": ""},
],
"max_turns": 50,
"max_cost_usd": None,
}
response = test_client.put("/api/v2/settings", json=body)
assert response.status_code == 200

loaded = load_environment_config(test_workspace.repo_path)
assert loaded.package_manager == "poetry"
assert loaded.test_framework == "jest"
assert loaded.agent_budget.max_iterations == 50

def test_put_validates_max_turns_positive(self, test_client):
"""PUT rejects max_turns <= 0."""
body = {
"agent_models": [
{"agent_type": "claude_code", "default_model": ""},
{"agent_type": "codex", "default_model": ""},
{"agent_type": "opencode", "default_model": ""},
{"agent_type": "react", "default_model": ""},
],
"max_turns": 0,
"max_cost_usd": None,
}
response = test_client.put("/api/v2/settings", json=body)
assert response.status_code == 422
Loading
Loading