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
23 changes: 18 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
# LLM — use LiteLLM model string format (provider/model)
# OpenRouter (default — 200+ models via one key)
LLM_MODEL=openrouter/openai/gpt-4o
OPENROUTER_API_KEY=sk-or-...
# ─── LLM provider ─────────────────────────────────────────────────────────────
# Pick ONE option. Uses LiteLLM model string format (provider/model).
#
# Option A — Claude Code (zero-config): if the `claude` CLI is installed and logged
# in, it is auto-detected and your subscription is used as the LLM. For this to work,
# leave LLM_MODEL, OPENROUTER_API_KEY, ANTHROPIC_API_KEY and LLM_API_BASE UNSET below.
# Set CLAUDE_CODE_AUTODETECT=false to turn detection off.
# CLAUDE_CODE_AUTODETECT=true

# Option B — OpenRouter (200+ models via one key). Uncomment both lines to use it
# (this takes priority over Claude Code auto-detection).
# LLM_MODEL=openrouter/openai/gpt-4o
# OPENROUTER_API_KEY=sk-or-...

# Anthropic direct
# Anthropic direct (API key, not subscription)
# LLM_MODEL=anthropic/claude-3-5-sonnet-20241022
# ANTHROPIC_API_KEY=sk-ant-...

Expand All @@ -24,6 +33,10 @@ OPENROUTER_API_KEY=sk-or-...
# LLM_API_BASE=https://your-endpoint/v1
# LLM_API_KEY=your-key

# Cloud provider — selects the active provider (aws | azure | gcp). Default: aws.
# Azure and GCP are stubs; only aws is implemented today.
# CLOUD_PROVIDER=aws

# AWS (use IAM role or explicit keys)
AWS_REGION=us-east-1
AWS_PROFILE=devops-agent-readonly # optional — use named profile (local dev)
Expand Down
26 changes: 26 additions & 0 deletions docs/llm_providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,32 @@ LiteLLM routes to Anthropic API → same agent graph, same tools

## Supported providers (examples)

### Claude Code (zero-config, uses your subscription)

If you have [Claude Code](https://claude.ai/code) installed and logged in, OpenDevOps can use your **existing Claude subscription** as the LLM — no API key, no `.env` changes.

> **Important — leave these unset.** Auto-detection only activates when you have **not** explicitly configured another provider. To use Claude Code, make sure your `.env` does **not** set any of:
> - `LLM_MODEL`
> - `OPENROUTER_API_KEY`
> - `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN`
> - `LLM_API_BASE`
>
> If any of those are present, that provider wins and Claude Code is skipped. The simplest setup is an empty (or absent) `.env` — Claude Code is picked up automatically.

**How it works:**
1. On startup the app checks whether the `claude` CLI is in your `PATH`.
2. It reads `~/.claude/settings.json` for your selected model (e.g. `sonnet` → `anthropic/claude-sonnet-4-6`).
3. It reuses Claude Code's subscription login — the OAuth token in `~/.claude/.credentials.json` (`sk-ant-oat…`). LiteLLM sends it as a Bearer token with the OAuth beta header automatically.

The active backend is shown in **Settings → Environment** and in the **setup wizard**.

**Notes:**
- Rate limits are shared with your interactive Claude Code usage — both draw from the same subscription quota.
- The OAuth token expires periodically; running any `claude` command refreshes it.
- To force a different provider even when Claude Code is installed, set `LLM_MODEL` + the matching key, or disable detection entirely with `CLAUDE_CODE_AUTODETECT=false`.

---

### OpenRouter (default — 200+ models via one key)
```bash
LLM_MODEL=openrouter/openai/gpt-4o
Expand Down
50 changes: 50 additions & 0 deletions frontend/src/components/LlmBackendCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { CheckCircle, XCircle, AlertTriangle, Cpu } from 'lucide-react';
import { cn } from '../lib/utils';
import type { LlmBackendInfo } from '../types';

interface Props {
backend: LlmBackendInfo;
className?: string;
}

const SOURCE_STYLE: Record<string, { color: string; bg: string; border: string }> = {
claude_code: { color: 'text-amber-700 dark:text-amber-300', bg: 'bg-amber-50 dark:bg-amber-500/10', border: 'border-amber-200 dark:border-amber-500/20' },
anthropic: { color: 'text-orange-700 dark:text-orange-300', bg: 'bg-orange-50 dark:bg-orange-500/10', border: 'border-orange-200 dark:border-orange-500/20' },
openrouter: { color: 'text-purple-700 dark:text-purple-300', bg: 'bg-purple-50 dark:bg-purple-500/10', border: 'border-purple-200 dark:border-purple-500/20' },
openai: { color: 'text-green-700 dark:text-green-300', bg: 'bg-green-50 dark:bg-green-500/10', border: 'border-green-200 dark:border-green-500/20' },
groq: { color: 'text-blue-700 dark:text-blue-300', bg: 'bg-blue-50 dark:bg-blue-500/10', border: 'border-blue-200 dark:border-blue-500/20' },
custom: { color: 'text-indigo-700 dark:text-indigo-300', bg: 'bg-indigo-50 dark:bg-indigo-500/10', border: 'border-indigo-200 dark:border-indigo-500/20' },
claude_code_no_auth: { color: 'text-amber-700 dark:text-amber-300', bg: 'bg-amber-50 dark:bg-amber-500/10', border: 'border-amber-200 dark:border-amber-500/20' },
default: { color: 'text-gray-500 dark:text-[#71717A]', bg: 'bg-gray-50 dark:bg-[#18181B]', border: 'border-gray-200 dark:border-[#27272A]' },
};

function StatusIcon({ configured, source }: { configured: boolean; source: string }) {
if (source === 'claude_code_no_auth') return <AlertTriangle size={14} className="text-amber-500 shrink-0" />;
if (source === 'default') return <XCircle size={14} className="text-gray-400 shrink-0" />;
if (configured) return <CheckCircle size={14} className="text-emerald-500 shrink-0" />;
return <XCircle size={14} className="text-red-500 shrink-0" />;
}

export default function LlmBackendCard({ backend, className }: Props) {
const style = SOURCE_STYLE[backend.source] ?? SOURCE_STYLE.default;

return (
<div className={cn('rounded-xl border p-4', style.bg, style.border, className)}>
<div className="flex items-start gap-3">
<div className={cn('mt-0.5 rounded-lg p-1.5', style.bg)}>
<Cpu size={14} className={style.color} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<StatusIcon configured={backend.configured} source={backend.source} />
<span className={cn('text-sm font-semibold', style.color)}>{backend.display_name}</span>
</div>
<p className="text-xs text-gray-500 dark:text-[#71717A] font-mono truncate">{backend.detail}</p>
{backend.configured && (
<p className="text-xs text-gray-400 dark:text-[#52525B] mt-1 font-mono truncate">{backend.model}</p>
)}
</div>
</div>
</div>
);
}
24 changes: 22 additions & 2 deletions frontend/src/pages/InitPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { ArrowRight, ArrowLeft, Loader2, Terminal, Eye, EyeOff, CheckCircle, XCi
import { cn } from '../lib/utils';
import { useAuth } from '../context/AuthContext';
import { apiFetch } from '../lib/api';
import LlmBackendCard from '../components/LlmBackendCard';
import type { LlmBackendInfo } from '../types';

const inp = 'w-full text-base text-gray-900 dark:text-white bg-gray-50 dark:bg-[#18181B] border border-gray-200 dark:border-[#27272A] rounded-xl px-4 py-3.5 outline-none focus:border-indigo-500 dark:focus:border-indigo-400 focus:ring-2 focus:ring-indigo-500/10 transition-all placeholder:text-gray-400 dark:placeholder:text-[#52525B]';

Expand Down Expand Up @@ -56,7 +58,8 @@ export default function InitPage() {
const [showPw, setShowPw] = useState(false);

// Step 2
const [region, setRegion] = useState('us-east-1');
const [region, setRegion] = useState('us-east-1');
const [llmBackend, setLlmBackend] = useState<LlmBackendInfo | null>(null);

// Step 3
const [perms, setPerms] = useState<Record<string, PermResult>>({});
Expand All @@ -77,6 +80,7 @@ export default function InitPage() {
.then(r => r.json())
.then(d => {
setRegion(d.aws_region || 'us-east-1');
if (d.llm_backend) setLlmBackend(d.llm_backend as LlmBackendInfo);
if (d.initialized && (d.has_user || !d.auth_enabled)) {
navigate('/', { replace: true });
return;
Expand Down Expand Up @@ -252,9 +256,25 @@ export default function InitPage() {
{step === 2 && (
<>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white tracking-tight mb-3">AWS Configuration</h1>
<p className="text-lg text-gray-500 dark:text-[#A1A1AA] mb-10 leading-relaxed">
<p className="text-lg text-gray-500 dark:text-[#A1A1AA] mb-6 leading-relaxed">
Configure the AWS region and optional SNS topic for alert delivery.
</p>

{/* LLM Backend status */}
{llmBackend && (
<div className="mb-8">
<p className="text-sm font-medium text-gray-700 dark:text-[#D4D4D8] mb-2">LLM Backend</p>
<LlmBackendCard backend={llmBackend} />
{!llmBackend.configured && (
<p className="mt-2 text-xs text-gray-400 dark:text-[#52525B]">
You can set <code className="bg-gray-100 dark:bg-[#27272A] px-1 rounded">LLM_MODEL</code> and an API key in <code className="bg-gray-100 dark:bg-[#27272A] px-1 rounded">.env</code>, or install{' '}
<a href="https://claude.ai/code" target="_blank" rel="noopener noreferrer" className="text-indigo-500 dark:text-[#818CF8] hover:underline">Claude Code</a>{' '}
for zero-config setup. See <a href="https://github.com/AhmadHammad21/OpenDevOps/blob/main/docs/llm_providers.md" target="_blank" rel="noopener noreferrer" className="text-indigo-500 dark:text-[#818CF8] hover:underline">docs/llm_providers.md</a>.
</p>
)}
</div>
)}

<div className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-[#D4D4D8] mb-2">
Expand Down
12 changes: 11 additions & 1 deletion frontend/src/pages/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import { apiFetch } from '../lib/api';
import { useAuth } from '../context/AuthContext';
import { useTheme } from '../context/ThemeContext';
import { cn } from '../lib/utils';
import LlmBackendCard from '../components/LlmBackendCard';
import type { LlmBackendInfo } from '../types';

interface EnvVar { key: string; value: string; secret: boolean }
interface AgentVar { key: string; label: string; value: string; hint: string }
interface SettingsData { env: EnvVar[]; agent: AgentVar[] }
interface SettingsData { env: EnvVar[]; agent: AgentVar[]; llm_backend?: LlmBackendInfo }
interface PermResult { passed: boolean | null; error: string | null }

type Tab = 'env' | 'agent' | 'integrations' | 'aws' | 'preferences';
Expand Down Expand Up @@ -225,6 +227,13 @@ export default function SettingsPage() {

{/* Environment tab */}
{tab === 'env' && (
<>
{data?.llm_backend && (
<div className="mb-5">
<p className="text-[11px] font-semibold text-gray-400 dark:text-[#64748B] uppercase tracking-[0.07em] mb-2">LLM Backend</p>
<LlmBackendCard backend={data.llm_backend} />
</div>
)}
<div className="bg-white dark:bg-[#18181C] border border-gray-200 dark:border-[#27272F] rounded-lg overflow-hidden shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
<div className="px-4 py-[11px] border-b border-gray-200 dark:border-[#27272F] bg-gray-50 dark:bg-[#1E1E24]">
<span className="text-[11px] font-semibold text-gray-400 dark:text-[#64748B] uppercase tracking-[0.07em]">Environment Variables</span>
Expand Down Expand Up @@ -252,6 +261,7 @@ export default function SettingsPage() {
<div className="px-4 py-6 text-[13px] text-gray-400 dark:text-[#64748B] text-center">Loading…</div>
)}
</div>
</>
)}

{/* Agent config tab */}
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ export interface Usage {
cost_usd?: number;
}

export interface LlmBackendInfo {
source: string; // claude_code | anthropic | openrouter | openai | groq | custom | default
model: string;
display_name: string;
provider: string;
configured: boolean;
detail: string;
}

export interface UserMessage {
id: string;
role: 'user';
Expand Down
55 changes: 31 additions & 24 deletions src/agent/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,34 +12,27 @@
from models.agent import Confidence, Investigation, InvestigationResult, RootCauseCategory
from agent.prompts import SYSTEM_PROMPT
from loguru import logger
from providers import get_active_provider
from tools._cap import with_cap
from tools.cloudtrail import ALL_CLOUDTRAIL_TOOLS
from tools.cloudwatch import ALL_CLOUDWATCH_TOOLS
from tools.ec2 import ALL_EC2_TOOLS
from tools.ecs import ALL_ECS_TOOLS
from tools.bash_tool import ALL_BASH_TOOLS
from tools.final_answer import ALL_FINAL_ANSWER_TOOLS
from tools.history import ALL_HISTORY_TOOLS
from tools.iam import ALL_IAM_TOOLS
from tools.lambda_ import ALL_LAMBDA_TOOLS
from tools.rds import ALL_RDS_TOOLS
from tools.skills import ALL_SKILL_TOOLS

ALL_TOOLS = (
ALL_CLOUDWATCH_TOOLS
+ ALL_CLOUDTRAIL_TOOLS
+ ALL_ECS_TOOLS
+ ALL_LAMBDA_TOOLS
+ ALL_EC2_TOOLS
+ ALL_RDS_TOOLS
+ ALL_IAM_TOOLS
+ ALL_HISTORY_TOOLS
+ ALL_BASH_TOOLS
+ ALL_SKILL_TOOLS
+ ALL_FINAL_ANSWER_TOOLS
)
# Cloud-agnostic tools available regardless of provider.
SHARED_TOOLS = ALL_HISTORY_TOOLS + ALL_BASH_TOOLS + ALL_SKILL_TOOLS + ALL_FINAL_ANSWER_TOOLS

# Active provider's cloud tools + shared tools. Provider is selected by CLOUD_PROVIDER.
ALL_TOOLS = get_active_provider().tools() + SHARED_TOOLS

_agent = None
_active_model: str | None = None


def get_active_model() -> str:
"""Return the resolved LLM model string (may differ from settings.llm_model when
Claude Code auto-detection overrides the default)."""
return _active_model or settings.llm_model


def _run_async(coro: Any) -> Any:
Expand All @@ -55,19 +48,33 @@ def _run_async(coro: Any) -> Any:
raise RuntimeError("Cannot run synchronous agent call inside an active event loop")


def _build_system_prompt(api_key: str | None) -> Any:
"""Return the system prompt for create_deep_agent — a SystemMessage with the
Claude Code identity block for subscription OAuth tokens, else a plain string."""
from agent.llm import shape_system_content
content = shape_system_content(SYSTEM_PROMPT, api_key)
if isinstance(content, list):
from langchain_core.messages import SystemMessage
return SystemMessage(content=content)
return content


def init_agent(checkpointer: Any) -> None:
"""Create the agent with the given checkpointer. Called once during app startup."""
global _agent
global _agent, _active_model
from agent.llm import resolve_model_and_key
model_name, api_key = resolve_model_and_key()
_active_model = model_name
model = ChatLiteLLM(
model=settings.llm_model,
model=model_name,
api_base=settings.llm_api_base or None,
api_key=settings.llm_api_key or None,
api_key=api_key,
)
tools = [with_cap(t) for t in ALL_TOOLS] if settings.tool_response_max_chars > 0 else ALL_TOOLS
_agent = create_deep_agent(
model=model,
tools=tools,
system_prompt=SYSTEM_PROMPT,
system_prompt=_build_system_prompt(api_key),
checkpointer=checkpointer,
)

Expand Down
23 changes: 23 additions & 0 deletions src/agent/llm/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""LLM backend resolution and provider detection.

Public API:
- resolve_model_and_key(): (model, api_key) for ChatLiteLLM initialisation
- get_backend_info(): a description of the active backend for the UI
- shape_system_content(): provider-specific system-prompt shaping
"""

from agent.llm.backend import LlmBackendInfo, get_backend_info, resolve_model_and_key
from agent.llm.identity import (
CLAUDE_CODE_IDENTITY,
is_subscription_token,
shape_system_content,
)

__all__ = [
"LlmBackendInfo",
"get_backend_info",
"resolve_model_and_key",
"CLAUDE_CODE_IDENTITY",
"is_subscription_token",
"shape_system_content",
]
Loading
Loading