Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
49 changes: 49 additions & 0 deletions codeframe/ui/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -948,3 +948,52 @@ class AgentSettingsResponse(AgentSettings):

class UpdateAgentSettingsRequest(AgentSettings):
"""Request shape for PUT /api/v2/settings."""


# ============================================================================
# API Key Management (issue #555)
# ============================================================================


KeyProvider = Literal["LLM_ANTHROPIC", "LLM_OPENAI", "GIT_GITHUB"]
KEY_PROVIDERS: tuple[KeyProvider, ...] = ("LLM_ANTHROPIC", "LLM_OPENAI", "GIT_GITHUB")

KeySource = Literal["environment", "stored", "none"]


class StoreKeyRequest(BaseModel):
"""Request shape for PUT /api/v2/settings/keys/{provider}."""

value: str = Field(..., min_length=1, description="The credential value to store")


class KeyStatusResponse(BaseModel):
"""Status of a single API key. Never includes the plaintext value."""

provider: KeyProvider = Field(..., description="One of: LLM_ANTHROPIC, LLM_OPENAI, GIT_GITHUB")
stored: bool = Field(..., description="True if a key is available (env or storage)")
source: KeySource = Field(
..., description="Where the key comes from: environment, stored, or none"
)
last_four: Optional[str] = Field(
default=None,
description="Last 4 characters of the key for display (None if no key available)",
)


class VerifyKeyRequest(BaseModel):
"""Request shape for POST /api/v2/settings/verify-key."""

provider: KeyProvider = Field(..., description="Which provider's key to verify")
value: Optional[str] = Field(
default=None,
description="The key value to verify; if None, the stored/env key is used",
)


class VerifyKeyResponse(BaseModel):
"""Result of a live verification attempt against a provider."""

provider: KeyProvider
valid: bool = Field(..., description="True if the provider accepted the key")
message: str = Field(..., description="Human-readable result message")
288 changes: 280 additions & 8 deletions codeframe/ui/routers/settings_v2.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,54 @@
"""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.
"""V2 Settings router — agent settings + API key management.

Routes:
GET /api/v2/settings - Load agent settings (returns defaults if missing)
PUT /api/v2/settings - Save agent settings (merges into existing config)
GET /api/v2/settings - Load agent settings (defaults if missing)
PUT /api/v2/settings - Save agent settings (merge into config)
GET /api/v2/settings/keys - List API key status for all providers
PUT /api/v2/settings/keys/{p} - Store an API key for provider p
DELETE /api/v2/settings/keys/{p} - Delete an API key for provider p
POST /api/v2/settings/verify-key - Live-verify a key against its provider

Key management is machine-wide (CredentialManager / keyring) and does not
require a workspace. Env vars take precedence at read time.
"""

import logging

from fastapi import APIRouter, Depends, HTTPException, Request
from typing import cast

from anthropic import Anthropic as _AnthropicClient
from anthropic import AuthenticationError as _AnthropicAuthError
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.concurrency import run_in_threadpool
from openai import AuthenticationError as _OpenAIAuthError
from openai import OpenAI as _OpenAIClient

from codeframe.core.config import (
AgentBudgetConfig,
EnvironmentConfig,
load_environment_config,
save_environment_config,
)
from codeframe.core.credentials import (
CredentialManager,
CredentialProvider,
CredentialSource,
validate_credential_format,
)
from codeframe.core.workspace import Workspace
from codeframe.lib.rate_limiter import rate_limit_standard
from codeframe.lib.rate_limiter import rate_limit_ai, rate_limit_standard
from codeframe.ui.dependencies import get_v2_workspace
from codeframe.ui.models import (
AGENT_TYPES,
KEY_PROVIDERS,
AgentSettingsResponse,
AgentTypeModelConfig,
KeyProvider,
KeyStatusResponse,
StoreKeyRequest,
UpdateAgentSettingsRequest,
VerifyKeyRequest,
VerifyKeyResponse,
)
from codeframe.ui.response_models import ErrorCodes, api_error

Expand All @@ -34,6 +57,14 @@
router = APIRouter(prefix="/api/v2/settings", tags=["settings"])


def get_credential_manager() -> CredentialManager:
"""Dependency: machine-wide CredentialManager.

Overridden in tests to point at an isolated temp directory.
"""
return CredentialManager()


def _config_to_response(config: EnvironmentConfig) -> AgentSettingsResponse:
"""Map an EnvironmentConfig to the flat AgentSettings response shape."""
saved_models = config.agent_type_models or {}
Expand Down Expand Up @@ -114,3 +145,244 @@ async def update_settings(
"Failed to save settings", ErrorCodes.EXECUTION_FAILED, str(e)
),
)


# ============================================================================
# API Key Management (issue #555)
#
# These endpoints are machine-wide: they do not require a workspace. Keys are
# stored via CredentialManager (platform keyring or encrypted file fallback).
# Plaintext key values are NEVER returned in any response.
# ============================================================================

# Map the public provider name to the internal CredentialProvider enum.
_PROVIDER_MAP: dict[str, CredentialProvider] = {
"LLM_ANTHROPIC": CredentialProvider.LLM_ANTHROPIC,
"LLM_OPENAI": CredentialProvider.LLM_OPENAI,
"GIT_GITHUB": CredentialProvider.GIT_GITHUB,
}


def _resolve_provider(provider: str) -> CredentialProvider:
"""Resolve a provider name from path/body, raising 400 for unknown values."""
cp = _PROVIDER_MAP.get(provider)
if cp is None:
raise HTTPException(
status_code=400,
detail=api_error(
f"Unknown provider: {provider}. Allowed: {', '.join(KEY_PROVIDERS)}",
ErrorCodes.VALIDATION_ERROR,
),
)
return cp


def _last_four(value: str | None) -> str | None:
"""Return the last 4 chars of a key, or None if no value.

For values shorter than 4 chars (which format validation should
already reject), returns the full value.
"""
if not value:
return None
return value[-4:] if len(value) >= 4 else value


def _build_status(
provider_key: KeyProvider,
manager: CredentialManager,
) -> KeyStatusResponse:
cp = _PROVIDER_MAP[provider_key]
source = manager.get_credential_source(cp)
if source == CredentialSource.NOT_FOUND:
return KeyStatusResponse(
provider=provider_key, stored=False, source="none", last_four=None
)

value = manager.get_credential(cp)
source_str = "environment" if source == CredentialSource.ENVIRONMENT else "stored"
return KeyStatusResponse(
provider=provider_key,
stored=value is not None,
source=source_str,
last_four=_last_four(value),
)


@router.get("/keys", response_model=list[KeyStatusResponse])
@rate_limit_standard()
async def list_key_status(
request: Request,
manager: CredentialManager = Depends(get_credential_manager),
) -> list[KeyStatusResponse]:
"""Return status of each known API key without exposing plaintext."""
return [_build_status(p, manager) for p in KEY_PROVIDERS]


@router.put("/keys/{provider}", response_model=KeyStatusResponse)
@rate_limit_standard()
async def store_key(
provider: str,
body: StoreKeyRequest,
request: Request,
manager: CredentialManager = Depends(get_credential_manager),
) -> KeyStatusResponse:
"""Store an API key for the given provider after validating its format."""
cp = _resolve_provider(provider)
if not validate_credential_format(cp, body.value):
raise HTTPException(
status_code=400,
detail=api_error(
f"Invalid {provider} key format",
ErrorCodes.VALIDATION_ERROR,
),
)
try:
manager.set_credential(cp, body.value)
except Exception as e:
logger.error(f"Failed to store credential: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=api_error(
"Failed to store credential", ErrorCodes.EXECUTION_FAILED, str(e)
),
)
# _resolve_provider validated `provider` against KEY_PROVIDERS, so the
# cast is safe — clearer than a type: ignore.
return _build_status(cast(KeyProvider, provider), manager)


@router.delete("/keys/{provider}", status_code=204)
@rate_limit_standard()
async def delete_key(
provider: str,
request: Request,
manager: CredentialManager = Depends(get_credential_manager),
) -> Response:
"""Delete a stored credential. Idempotent — non-existent keys are a no-op."""
cp = _resolve_provider(provider)
try:
manager.delete_credential(cp)
except Exception as e:
# Underlying store can raise on a hard keyring error; treat absence as success.
msg = str(e).lower()
if "no such" in msg or "not found" in msg:
return Response(status_code=204)
logger.error(f"Failed to delete credential: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=api_error(
"Failed to delete credential", ErrorCodes.EXECUTION_FAILED, str(e)
),
)
return Response(status_code=204)


# ----------------------------------------------------------------------------
# Live verification helpers
#
# These are module-level so tests can monkeypatch them without touching the
# real network. The Anthropic / OpenAI clients are sync, so we wrap their
# calls in run_in_threadpool to avoid blocking the event loop.
# ----------------------------------------------------------------------------


async def _check_github_token(token: str) -> tuple[bool, str]:
"""Validate a GitHub token via GET https://api.github.com/user.

Exception messages are NOT echoed back to the client because aiohttp
errors can include the request URL or headers (which carry the token).
The detailed error is logged server-side instead.
"""
import aiohttp

headers = {
"Authorization": f"token {token}",
"Accept": "application/vnd.github+json",
"User-Agent": "codeframe-key-verify",
}
try:
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(
"https://api.github.com/user", headers=headers
) as resp:
if resp.status == 200:
data = await resp.json()
login = data.get("login", "")
return True, f"Authenticated as {login}" if login else "Valid token"
if resp.status == 401:
return False, "401 Unauthorized: invalid GitHub token"
return False, f"GitHub API returned status {resp.status}"
except Exception as e:
logger.warning(f"GitHub token verification raised: {e}", exc_info=True)
return False, "GitHub verification failed: network or server error"


def _verify_anthropic_sync(key: str) -> tuple[bool, str]:
"""Verify an Anthropic key by issuing a minimal messages.create() call.

Uses messages.create rather than models.list because messages is the
stable, always-present API surface across all supported SDK versions
(>=0.18). max_tokens=1 keeps the cost trivial. Non-auth exceptions
are logged but not echoed to the client to avoid leaking provider
internals.
"""
try:
client = _AnthropicClient(api_key=key)
client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=1,
messages=[{"role": "user", "content": "ping"}],
)
return True, "Anthropic key accepted"
except _AnthropicAuthError:
return False, "Anthropic key rejected: authentication failed"
except Exception as e:
logger.warning(f"Anthropic verification raised: {e}", exc_info=True)
return False, "Anthropic verification failed: network or server error"


def _verify_openai_sync(key: str) -> tuple[bool, str]:
"""Verify an OpenAI key via models.list (small, cheap, auth-required)."""
try:
client = _OpenAIClient(api_key=key)
client.models.list()
return True, "OpenAI key accepted"
except _OpenAIAuthError:
return False, "OpenAI key rejected: authentication failed"
except Exception as e:
logger.warning(f"OpenAI verification raised: {e}", exc_info=True)
return False, "OpenAI verification failed: network or server error"


@router.post("/verify-key", response_model=VerifyKeyResponse)
@rate_limit_ai()
async def verify_key(
body: VerifyKeyRequest,
request: Request,
manager: CredentialManager = Depends(get_credential_manager),
) -> VerifyKeyResponse:
"""Live-verify a key against its provider.

If body.value is None, the stored or env-var key is used. Failed
verifications return 200 with valid=false; only unexpected errors
(programmer bugs) raise 5xx.
"""
cp = _resolve_provider(body.provider)
key = body.value if body.value else manager.get_credential(cp)
if not key:
return VerifyKeyResponse(
provider=body.provider, valid=False, message="No key provided or stored"
)

if cp == CredentialProvider.LLM_ANTHROPIC:
valid, message = await run_in_threadpool(_verify_anthropic_sync, key)
elif cp == CredentialProvider.LLM_OPENAI:
valid, message = await run_in_threadpool(_verify_openai_sync, key)
elif cp == CredentialProvider.GIT_GITHUB:
valid, message = await _check_github_token(key)
else: # pragma: no cover -- guarded by _resolve_provider
valid, message = False, "Verification not supported for this provider"

return VerifyKeyResponse(provider=body.provider, valid=valid, message=message)
2 changes: 1 addition & 1 deletion docs/PRODUCT_ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ These are items that were considered and excluded because they do not serve the
| 3.5C | Glitch capture UI | ✅ Complete | #568, #569 |
| 4A | PR status + PROOF9 merge gate | ❌ Not started | — |
| 4B | Post-merge glitch capture loop | ❌ Not started | — |
| 5.1 | Settings page (skeleton + agent config) | 🟡 In progress (#554 done; #555–556 next) | #554–556 |
| 5.1 | Settings page (skeleton + agent config) | 🟡 In progress (#554, #555 done; #556 next) | #554–556 |
| 5.2 | Cost analytics | ❌ Not started | #557–558 |
| 5.3 | Async notifications | ❌ Not started | #559–560 |
| 5.4 | PRD stress-test web UI | ❌ Not started | #561–562 |
Expand Down
Loading
Loading