Skip to content

Commit 43f4366

Browse files
authored
Merge pull request #236 from AutoForgeAI/opus-47-effort
Add Claude Opus 4.7 model and reasoning effort setting
2 parents 01d1825 + 36ad8eb commit 43f4366

11 files changed

Lines changed: 149 additions & 26 deletions

File tree

client.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def convert_model_for_vertex(model: str) -> str:
3838
3939
Vertex AI uses @ to separate model name from version (e.g., claude-sonnet-4-5@20250929)
4040
while the Anthropic API uses - (e.g., claude-sonnet-4-5-20250929).
41-
Models without a date suffix (e.g., claude-opus-4-6) pass through unchanged.
41+
Models without a date suffix (e.g., claude-opus-4-7) pass through unchanged.
4242
4343
Args:
4444
model: Model name in Anthropic format (with hyphens)
@@ -342,8 +342,10 @@ def create_client(
342342
# Uses get_effective_sdk_env() which reads provider settings from the database,
343343
# ensuring UI-configured alternative providers (GLM, Ollama, Kimi, Custom) propagate
344344
# correctly to the Claude CLI subprocess
345-
from registry import get_effective_sdk_env
345+
from registry import get_effective_sdk_env, get_effort_setting
346346
sdk_env = get_effective_sdk_env()
347+
effort = get_effort_setting()
348+
print(f" - Reasoning effort: {effort}")
347349

348350
# Detect alternative API mode (Ollama, GLM, or Vertex AI)
349351
base_url = sdk_env.get("ANTHROPIC_BASE_URL", "")
@@ -452,6 +454,9 @@ async def pre_compact_hook(
452454
return ClaudeSDKClient(
453455
options=ClaudeAgentOptions(
454456
model=model,
457+
# SDK 0.1.61's effort Literal omits "xhigh" but the CLI's
458+
# --effort flag accepts it; the SDK forwards the string unchanged.
459+
effort=effort, # type: ignore[arg-type]
455460
cli_path=system_cli, # Use system CLI to avoid bundled Bun crash (exit code 3)
456461
system_prompt="You are an expert full-stack developer building a production-quality web application.",
457462
setting_sources=["project"], # Enable skills, commands, and CLAUDE.md from project dir

registry.py

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from contextlib import contextmanager
1515
from datetime import datetime
1616
from pathlib import Path
17-
from typing import Any
17+
from typing import Any, Literal, cast
1818

1919
from sqlalchemy import Boolean, Column, DateTime, Integer, String, create_engine, text
2020
from sqlalchemy.orm import DeclarativeBase, sessionmaker
@@ -46,14 +46,17 @@ def _migrate_registry_dir() -> None:
4646
# Available models with display names
4747
# To add a new model: add an entry here with {"id": "model-id", "name": "Display Name"}
4848
AVAILABLE_MODELS = [
49-
{"id": "claude-opus-4-6", "name": "Claude Opus"},
50-
{"id": "claude-sonnet-4-5-20250929", "name": "Claude Sonnet"},
49+
{"id": "claude-opus-4-7", "name": "Claude Opus"},
50+
{"id": "claude-sonnet-4-6", "name": "Claude Sonnet"},
5151
]
5252

5353
# Map legacy model IDs to their current replacements.
5454
# Used by get_all_settings() to auto-migrate stale values on first read after upgrade.
5555
LEGACY_MODEL_MAP = {
56-
"claude-opus-4-5-20251101": "claude-opus-4-6",
56+
"claude-opus-4-5-20251101": "claude-opus-4-7",
57+
"claude-opus-4-6": "claude-opus-4-7",
58+
"claude-sonnet-4-5": "claude-sonnet-4-6",
59+
"claude-sonnet-4-5-20250929": "claude-sonnet-4-6",
5760
}
5861

5962
# List of valid model IDs (derived from AVAILABLE_MODELS)
@@ -65,7 +68,15 @@ def _migrate_registry_dir() -> None:
6568
_env_default_model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL")
6669
if _env_default_model is not None:
6770
_env_default_model = _env_default_model.strip()
68-
DEFAULT_MODEL = _env_default_model or "claude-opus-4-6"
71+
# Auto-remap stale env-provided values (e.g. user's .env still pins 4.6)
72+
if _env_default_model and _env_default_model in LEGACY_MODEL_MAP:
73+
logging.getLogger(__name__).warning(
74+
"ANTHROPIC_DEFAULT_OPUS_MODEL=%s is legacy; remapping to %s. "
75+
"Update your .env to silence this warning.",
76+
_env_default_model, LEGACY_MODEL_MAP[_env_default_model],
77+
)
78+
_env_default_model = LEGACY_MODEL_MAP[_env_default_model]
79+
DEFAULT_MODEL = _env_default_model or "claude-opus-4-7"
6980

7081
# Ensure env-provided DEFAULT_MODEL is in VALID_MODELS for validation consistency
7182
# (idempotent: only adds if missing, doesn't alter AVAILABLE_MODELS semantics)
@@ -671,6 +682,28 @@ def get_setting(key: str, default: str | None = None) -> str | None:
671682
return default
672683

673684

685+
# Valid Claude Code reasoning/effort levels. Must match the CLI's --effort
686+
# choices (low, medium, high, xhigh, max) — note: the SDK's Literal type at
687+
# 0.1.61 omits "xhigh", but the string is forwarded to the CLI as-is and
688+
# accepted there.
689+
EffortLevel = Literal["low", "medium", "high", "xhigh", "max"]
690+
VALID_EFFORT_LEVELS: tuple[EffortLevel, ...] = ("low", "medium", "high", "xhigh", "max")
691+
DEFAULT_EFFORT: EffortLevel = "xhigh"
692+
693+
694+
def get_effort_setting() -> EffortLevel:
695+
"""
696+
Read the global reasoning-effort setting, falling back to ``xhigh``.
697+
698+
Unknown/invalid stored values are treated as missing so a DB corruption or
699+
schema drift can't force the CLI into an unsupported mode.
700+
"""
701+
value = get_setting("effort")
702+
if value in VALID_EFFORT_LEVELS:
703+
return cast(EffortLevel, value)
704+
return DEFAULT_EFFORT
705+
706+
674707
def set_setting(key: str, value: str) -> None:
675708
"""
676709
Set a setting value (creates or updates).
@@ -699,7 +732,7 @@ def get_all_settings() -> dict[str, str]:
699732
"""
700733
Get all settings as a dictionary.
701734
702-
Automatically migrates legacy model IDs (e.g. claude-opus-4-5-20251101 -> claude-opus-4-6)
735+
Automatically migrates legacy model IDs (e.g. claude-opus-4-6 -> claude-opus-4-7)
703736
on first read after upgrade. This is a one-time silent migration.
704737
705738
Returns:
@@ -747,10 +780,10 @@ def get_all_settings() -> dict[str, str]:
747780
"base_url": None,
748781
"requires_auth": False,
749782
"models": [
750-
{"id": "claude-opus-4-6", "name": "Claude Opus"},
751-
{"id": "claude-sonnet-4-5-20250929", "name": "Claude Sonnet"},
783+
{"id": "claude-opus-4-7", "name": "Claude Opus"},
784+
{"id": "claude-sonnet-4-6", "name": "Claude Sonnet"},
752785
],
753-
"default_model": "claude-opus-4-6",
786+
"default_model": "claude-opus-4-7",
754787
},
755788
"kimi": {
756789
"name": "Kimi K2.5 (Moonshot)",
@@ -778,11 +811,11 @@ def get_all_settings() -> dict[str, str]:
778811
"requires_auth": True,
779812
"auth_env_var": "ANTHROPIC_API_KEY",
780813
"models": [
781-
{"id": "claude-opus-4-6", "name": "Claude Opus"},
782-
{"id": "claude-sonnet-4-5", "name": "Claude Sonnet"},
814+
{"id": "claude-opus-4-7", "name": "Claude Opus"},
815+
{"id": "claude-sonnet-4-6", "name": "Claude Sonnet"},
783816
{"id": "claude-haiku-4-5", "name": "Claude Haiku"},
784817
],
785-
"default_model": "claude-opus-4-6",
818+
"default_model": "claude-opus-4-7",
786819
},
787820
"ollama": {
788821
"name": "Ollama (Local)",

server/routers/settings.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
AVAILABLE_MODELS,
2727
DEFAULT_MODEL,
2828
get_all_settings,
29+
get_effort_setting,
2930
get_setting,
3031
set_setting,
3132
)
@@ -95,6 +96,8 @@ def _parse_bool(value: str | None, default: bool = False) -> bool:
9596
return value.lower() == "true"
9697

9798

99+
100+
98101
@router.get("", response_model=SettingsResponse)
99102
async def get_settings():
100103
"""Get current global settings."""
@@ -114,6 +117,7 @@ async def get_settings():
114117
playwright_headless=True, # Always headless - embedded browser view replaces desktop windows
115118
batch_size=_parse_int(all_settings.get("batch_size"), 3),
116119
testing_batch_size=_parse_int(all_settings.get("testing_batch_size"), 3),
120+
effort=get_effort_setting(),
117121
api_provider=api_provider,
118122
api_base_url=all_settings.get("api_base_url"),
119123
api_has_auth_token=bool(all_settings.get("api_auth_token")),
@@ -142,6 +146,9 @@ async def update_settings(update: SettingsUpdate):
142146
if update.testing_batch_size is not None:
143147
set_setting("testing_batch_size", str(update.testing_batch_size))
144148

149+
if update.effort is not None:
150+
set_setting("effort", update.effort)
151+
145152
# API provider settings
146153
if update.api_provider is not None:
147154
old_provider = get_setting("api_provider", "claude")
@@ -182,6 +189,7 @@ async def update_settings(update: SettingsUpdate):
182189
playwright_headless=True, # Always headless - embedded browser view replaces desktop windows
183190
batch_size=_parse_int(all_settings.get("batch_size"), 3),
184191
testing_batch_size=_parse_int(all_settings.get("testing_batch_size"), 3),
192+
effort=get_effort_setting(),
185193
api_provider=api_provider,
186194
api_base_url=all_settings.get("api_base_url"),
187195
api_has_auth_token=bool(all_settings.get("api_auth_token")),

server/schemas.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
if str(_root) not in sys.path:
1919
sys.path.insert(0, str(_root))
2020

21-
from registry import DEFAULT_MODEL, VALID_MODELS
21+
from registry import DEFAULT_MODEL, LEGACY_MODEL_MAP, VALID_MODELS
2222

2323
# ============================================================================
2424
# Project Schemas
@@ -484,6 +484,7 @@ class SettingsResponse(BaseModel):
484484
playwright_headless: bool = True
485485
batch_size: int = 3 # Features per coding agent batch (1-15)
486486
testing_batch_size: int = 3 # Features per testing agent batch (1-15)
487+
effort: Literal["low", "medium", "high", "xhigh", "max"] = "xhigh"
487488
api_provider: str = "claude"
488489
api_base_url: str | None = None
489490
api_has_auth_token: bool = False # Never expose actual token
@@ -504,6 +505,7 @@ class SettingsUpdate(BaseModel):
504505
playwright_headless: bool | None = None
505506
batch_size: int | None = None # Features per agent batch (1-15)
506507
testing_batch_size: int | None = None # Features per testing agent batch (1-15)
508+
effort: Literal["low", "medium", "high", "xhigh", "max"] | None = None
507509
api_provider: str | None = None
508510
api_base_url: str | None = Field(None, max_length=500)
509511
api_auth_token: str | None = Field(None, max_length=500) # Write-only, never returned
@@ -520,12 +522,16 @@ def validate_api_base_url(cls, v: str | None) -> str | None:
520522

521523
@field_validator('model')
522524
@classmethod
523-
def validate_model(cls, v: str | None, info) -> str | None: # type: ignore[override]
525+
def validate_model(cls, v: str | None, info) -> str | None:
524526
if v is not None:
525527
# Skip VALID_MODELS check when using an alternative API provider
526528
api_provider = info.data.get("api_provider")
527529
if api_provider and api_provider != "claude":
528530
return v
531+
# Transparently accept legacy IDs so in-flight clients don't 422
532+
# during an upgrade window; LEGACY_MODEL_MAP already covers migration.
533+
if v in LEGACY_MODEL_MAP:
534+
v = LEGACY_MODEL_MAP[v]
529535
if v not in VALID_MODELS:
530536
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
531537
return v

server/services/assistant_chat_session.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,8 +270,9 @@ async def start(self) -> AsyncGenerator[dict, None]:
270270
system_cli = shutil.which("claude")
271271

272272
# Build environment overrides for API configuration
273-
from registry import DEFAULT_MODEL, get_effective_sdk_env
273+
from registry import DEFAULT_MODEL, get_effective_sdk_env, get_effort_setting
274274
sdk_env = get_effective_sdk_env()
275+
effort = get_effort_setting()
275276

276277
# Determine model from SDK env (provider-aware) or fallback to env/default
277278
model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", DEFAULT_MODEL)
@@ -281,6 +282,7 @@ async def start(self) -> AsyncGenerator[dict, None]:
281282
self.client = ClaudeSDKClient(
282283
options=ClaudeAgentOptions(
283284
model=model,
285+
effort=effort, # type: ignore[arg-type] # SDK 0.1.61 Literal omits "xhigh"
284286
cli_path=system_cli,
285287
# System prompt loaded from CLAUDE.md via setting_sources
286288
# This avoids Windows command line length limit (~8191 chars)

server/services/expand_chat_session.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,9 @@ async def start(self) -> AsyncGenerator[dict, None]:
161161
system_prompt = skill_content.replace("$ARGUMENTS", project_path)
162162

163163
# Build environment overrides for API configuration
164-
from registry import DEFAULT_MODEL, get_effective_sdk_env
164+
from registry import DEFAULT_MODEL, get_effective_sdk_env, get_effort_setting
165165
sdk_env = get_effective_sdk_env()
166+
effort = get_effort_setting()
166167

167168
# Determine model from SDK env (provider-aware) or fallback to env/default
168169
model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", DEFAULT_MODEL)
@@ -184,6 +185,7 @@ async def start(self) -> AsyncGenerator[dict, None]:
184185
self.client = ClaudeSDKClient(
185186
options=ClaudeAgentOptions(
186187
model=model,
188+
effort=effort, # type: ignore[arg-type] # SDK 0.1.61 Literal omits "xhigh"
187189
cli_path=system_cli,
188190
system_prompt=system_prompt,
189191
allowed_tools=[

server/services/spec_chat_session.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,9 @@ async def start(self) -> AsyncGenerator[dict, None]:
147147
system_cli = shutil.which("claude")
148148

149149
# Build environment overrides for API configuration
150-
from registry import DEFAULT_MODEL, get_effective_sdk_env
150+
from registry import DEFAULT_MODEL, get_effective_sdk_env, get_effort_setting
151151
sdk_env = get_effective_sdk_env()
152+
effort = get_effort_setting()
152153

153154
# Determine model from SDK env (provider-aware) or fallback to env/default
154155
model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", DEFAULT_MODEL)
@@ -157,6 +158,7 @@ async def start(self) -> AsyncGenerator[dict, None]:
157158
self.client = ClaudeSDKClient(
158159
options=ClaudeAgentOptions(
159160
model=model,
161+
effort=effort, # type: ignore[arg-type] # SDK 0.1.61 Literal omits "xhigh"
160162
cli_path=system_cli,
161163
# System prompt loaded from CLAUDE.md via setting_sources
162164
# Include "user" for global skills and subagents from ~/.claude/

ui/src/components/SettingsModal.tsx

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useState } from 'react'
22
import { Loader2, AlertCircle, AlertTriangle, Check, Moon, Sun, Eye, EyeOff, ShieldCheck } from 'lucide-react'
33
import { useSettings, useUpdateSettings, useAvailableModels, useAvailableProviders } from '../hooks/useProjects'
44
import { useTheme, THEMES } from '../hooks/useTheme'
5-
import type { ProviderInfo } from '../lib/types'
5+
import type { EffortLevel, ProviderInfo } from '../lib/types'
66
import {
77
Dialog,
88
DialogContent,
@@ -70,6 +70,12 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
7070
}
7171
}
7272

73+
const handleEffortChange = (level: EffortLevel) => {
74+
if (!updateSettings.isPending) {
75+
updateSettings.mutate({ effort: level })
76+
}
77+
}
78+
7379
const handleProviderChange = (providerId: string) => {
7480
if (!updateSettings.isPending) {
7581
updateSettings.mutate({ api_provider: providerId })
@@ -386,6 +392,30 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
386392
)}
387393
</div>
388394

395+
{/* Reasoning Effort */}
396+
<div className="space-y-2">
397+
<Label className="font-medium">Reasoning Effort</Label>
398+
<p className="text-sm text-muted-foreground">
399+
How deeply Claude thinks before responding. xhigh is recommended for autonomous coding.
400+
</p>
401+
<div className="flex rounded-lg border overflow-hidden">
402+
{(['low', 'medium', 'high', 'xhigh', 'max'] as EffortLevel[]).map((level) => (
403+
<button
404+
key={level}
405+
onClick={() => handleEffortChange(level)}
406+
disabled={isSaving}
407+
className={`flex-1 py-2 px-3 text-sm font-medium transition-colors ${
408+
(settings.effort ?? 'xhigh') === level
409+
? 'bg-primary text-primary-foreground'
410+
: 'bg-background text-foreground hover:bg-muted'
411+
} ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
412+
>
413+
{level}
414+
</button>
415+
))}
416+
</div>
417+
</div>
418+
389419
<hr className="border-border" />
390420

391421
{/* YOLO Mode Toggle */}

ui/src/components/views/SettingsView.tsx

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { useState } from 'react'
1010
import { Loader2, AlertCircle, AlertTriangle, Check, Moon, Sun, Eye, EyeOff, ShieldCheck, Settings } from 'lucide-react'
1111
import { useSettings, useUpdateSettings, useAvailableModels, useAvailableProviders } from '@/hooks/useProjects'
1212
import { useTheme, THEMES } from '@/hooks/useTheme'
13-
import type { ProviderInfo } from '@/lib/types'
13+
import type { EffortLevel, ProviderInfo } from '@/lib/types'
1414
import { Switch } from '@/components/ui/switch'
1515
import { Slider } from '@/components/ui/slider'
1616
import { Label } from '@/components/ui/label'
@@ -68,6 +68,12 @@ export function SettingsView() {
6868
}
6969
}
7070

71+
const handleEffortChange = (level: EffortLevel) => {
72+
if (!updateSettings.isPending) {
73+
updateSettings.mutate({ effort: level })
74+
}
75+
}
76+
7177
const handleProviderChange = (providerId: string) => {
7278
if (!updateSettings.isPending) {
7379
updateSettings.mutate({ api_provider: providerId })
@@ -421,6 +427,30 @@ export function SettingsView() {
421427
/>
422428
</div>
423429

430+
{/* Reasoning Effort */}
431+
<div className="space-y-2">
432+
<Label className="font-medium">Reasoning Effort</Label>
433+
<p className="text-sm text-muted-foreground">
434+
How deeply Claude thinks before responding. xhigh is recommended for autonomous coding.
435+
</p>
436+
<div className="flex rounded-lg border overflow-hidden">
437+
{(['low', 'medium', 'high', 'xhigh', 'max'] as EffortLevel[]).map((level) => (
438+
<button
439+
key={level}
440+
onClick={() => handleEffortChange(level)}
441+
disabled={isSaving}
442+
className={`flex-1 py-2 px-3 text-sm font-medium transition-colors ${
443+
(settings.effort ?? 'xhigh') === level
444+
? 'bg-primary text-primary-foreground'
445+
: 'bg-background text-foreground hover:bg-muted'
446+
} ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
447+
>
448+
{level}
449+
</button>
450+
))}
451+
</div>
452+
</div>
453+
424454
{/* Regression Agents */}
425455
<div className="space-y-2">
426456
<Label className="font-medium">Regression Agents</Label>

0 commit comments

Comments
 (0)