switchforge 1.0.0__py3-none-any.whl

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.
forge_core/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Forge Core — AI-powered backend test generation engine."""
2
+
3
+ __version__ = "2.0.0"
forge_core/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for `python -m forge_core`."""
2
+
3
+ from forge_core.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
File without changes
@@ -0,0 +1,87 @@
1
+ """Load and template .md prompt files for AI consumption."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from forge_core.utils import logger
8
+
9
+
10
+ def load_prompt(prompts_dir: Path, prompt_name: str) -> str:
11
+ """Load a prompt .md file from the prompts directory.
12
+
13
+ Args:
14
+ prompts_dir: Path to the prompts directory.
15
+ prompt_name: Name of the prompt file (e.g., 'detect-tech-stack').
16
+
17
+ Returns:
18
+ The prompt content as a string.
19
+ """
20
+ file_path = prompts_dir / f"{prompt_name}.prompt.md"
21
+ if not file_path.exists():
22
+ logger.warn(f"Prompt not found: {file_path}")
23
+ return ""
24
+
25
+ content = file_path.read_text(encoding="utf-8")
26
+ # Strip YAML frontmatter if present
27
+ if content.startswith("---"):
28
+ parts = content.split("---", 2)
29
+ if len(parts) >= 3:
30
+ content = parts[2].strip()
31
+
32
+ return content
33
+
34
+
35
+ def template_prompt(prompt: str, variables: dict[str, str]) -> str:
36
+ """Replace template variables in a prompt.
37
+
38
+ Variables use {{variable_name}} syntax.
39
+ """
40
+ result = prompt
41
+ for key, value in variables.items():
42
+ result = result.replace(f"{{{{{key}}}}}", value)
43
+ return result
44
+
45
+
46
+ def build_file_context(files: dict[str, str], max_files: int = 50) -> str:
47
+ """Build a file context string from a dict of {path: content}.
48
+
49
+ Formats each file with clear separators for the AI to parse.
50
+ """
51
+ parts: list[str] = []
52
+ for i, (path, content) in enumerate(files.items()):
53
+ if i >= max_files:
54
+ parts.append(f"\n... and {len(files) - max_files} more files (truncated)")
55
+ break
56
+ parts.append(f"--- {path} ---\n{content}")
57
+
58
+ return "\n\n".join(parts)
59
+
60
+
61
+ def load_learnings(central_path: str | None, local_path: Path | None) -> str:
62
+ """Load LEARNINGS.md from central hub and/or local project."""
63
+ learnings_parts: list[str] = []
64
+
65
+ if central_path:
66
+ central_file = Path(central_path) / "LEARNINGS.md"
67
+ if central_file.exists():
68
+ learnings_parts.append(
69
+ f"=== Central Learnings ===\n{central_file.read_text(encoding='utf-8')}"
70
+ )
71
+
72
+ if local_path:
73
+ local_file = local_path / "LEARNINGS.md"
74
+ if local_file.exists():
75
+ learnings_parts.append(
76
+ f"=== Project Learnings ===\n{local_file.read_text(encoding='utf-8')}"
77
+ )
78
+
79
+ return "\n\n".join(learnings_parts)
80
+
81
+
82
+ def load_knowledge_pack(central_path: str, pack_name: str) -> str:
83
+ """Load a specific knowledge pack (e.g., kotlin-ktor.md)."""
84
+ pack_path = Path(central_path) / "knowledge-packs" / f"{pack_name}.md"
85
+ if pack_path.exists():
86
+ return pack_path.read_text(encoding="utf-8")
87
+ return ""
@@ -0,0 +1,121 @@
1
+ """LiteLLM-based AI provider — unified interface for 100+ models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import litellm
8
+
9
+ from forge_core.models.config import AIConfig, AIProvider
10
+ from forge_core.utils import logger
11
+ from forge_core.utils.tokens import count_tokens
12
+
13
+ # Suppress LiteLLM's verbose logging
14
+ litellm.suppress_debug_info = True
15
+
16
+
17
+ def _resolve_model(config: AIConfig) -> str:
18
+ """Resolve the model string for LiteLLM based on provider."""
19
+ if config.provider == AIProvider.OLLAMA:
20
+ return f"ollama/{config.model}"
21
+ if config.provider == AIProvider.AZURE:
22
+ return f"azure/{config.model}"
23
+ if config.provider == AIProvider.BEDROCK:
24
+ return f"bedrock/{config.model}"
25
+ return config.model
26
+
27
+
28
+ def complete(
29
+ config: AIConfig,
30
+ system_prompt: str,
31
+ user_prompt: str,
32
+ json_mode: bool = False,
33
+ max_tokens: int | None = None,
34
+ ) -> str:
35
+ """Send a completion request to the configured AI provider.
36
+
37
+ Args:
38
+ config: AI provider configuration.
39
+ system_prompt: System-level instructions.
40
+ user_prompt: User message with code/context.
41
+ json_mode: If True, request JSON response format.
42
+ max_tokens: Override max tokens for this call.
43
+
44
+ Returns:
45
+ The AI's response text.
46
+ """
47
+ model = _resolve_model(config)
48
+ messages = [
49
+ {"role": "system", "content": system_prompt},
50
+ {"role": "user", "content": user_prompt},
51
+ ]
52
+
53
+ kwargs: dict[str, Any] = {
54
+ "model": model,
55
+ "messages": messages,
56
+ "temperature": config.temperature,
57
+ "max_tokens": max_tokens or config.max_tokens,
58
+ }
59
+
60
+ if config.api_key:
61
+ kwargs["api_key"] = config.api_key
62
+ if config.base_url:
63
+ kwargs["api_base"] = config.base_url
64
+ if json_mode:
65
+ kwargs["response_format"] = {"type": "json_object"}
66
+
67
+ # Log token usage
68
+ input_tokens = count_tokens(system_prompt + user_prompt, config.model)
69
+ logger.info(f"AI call → {model} ({input_tokens} input tokens)")
70
+
71
+ try:
72
+ response = litellm.completion(**kwargs)
73
+ content = response.choices[0].message.content or ""
74
+
75
+ output_tokens = count_tokens(content, config.model)
76
+ logger.info(f"AI response ← {output_tokens} output tokens")
77
+
78
+ return content
79
+ except Exception as e:
80
+ logger.error(f"AI call failed: {e}")
81
+ raise
82
+
83
+
84
+ def complete_with_fallback(
85
+ config: AIConfig,
86
+ system_prompt: str,
87
+ user_prompt: str,
88
+ fallback_models: list[str] | None = None,
89
+ json_mode: bool = False,
90
+ ) -> str:
91
+ """Try primary model, fall back to alternatives on failure.
92
+
93
+ Useful for complex classes where one model might fail.
94
+ """
95
+ models = [_resolve_model(config)] + (fallback_models or [])
96
+
97
+ last_error = None
98
+ for model in models:
99
+ try:
100
+ kwargs: dict[str, Any] = {
101
+ "model": model,
102
+ "messages": [
103
+ {"role": "system", "content": system_prompt},
104
+ {"role": "user", "content": user_prompt},
105
+ ],
106
+ "temperature": config.temperature,
107
+ "max_tokens": config.max_tokens,
108
+ }
109
+ if config.api_key:
110
+ kwargs["api_key"] = config.api_key
111
+ if json_mode:
112
+ kwargs["response_format"] = {"type": "json_object"}
113
+
114
+ response = litellm.completion(**kwargs)
115
+ return response.choices[0].message.content or ""
116
+ except Exception as e:
117
+ last_error = e
118
+ logger.warn(f"Model {model} failed, trying next: {e}")
119
+ continue
120
+
121
+ raise RuntimeError(f"All models failed. Last error: {last_error}")
@@ -0,0 +1,68 @@
1
+ """Structured AI outputs using instructor + pydantic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, TypeVar
6
+
7
+ import instructor
8
+ import litellm
9
+ from pydantic import BaseModel
10
+
11
+ from forge_core.models.config import AIConfig
12
+ from forge_core.ai.provider import _resolve_model
13
+ from forge_core.utils import logger
14
+
15
+ T = TypeVar("T", bound=BaseModel)
16
+
17
+
18
+ def extract(
19
+ config: AIConfig,
20
+ system_prompt: str,
21
+ user_prompt: str,
22
+ response_model: type[T],
23
+ max_retries: int = 2,
24
+ ) -> T:
25
+ """Extract structured data from AI using instructor.
26
+
27
+ Forces the AI to return data matching a pydantic model schema.
28
+ Retries on validation failure.
29
+
30
+ Args:
31
+ config: AI provider configuration.
32
+ system_prompt: System-level instructions.
33
+ user_prompt: User message with code/context.
34
+ response_model: Pydantic model class to extract.
35
+ max_retries: Number of retries on validation failure.
36
+
37
+ Returns:
38
+ An instance of response_model populated by the AI.
39
+ """
40
+ model = _resolve_model(config)
41
+
42
+ client = instructor.from_litellm(litellm.completion)
43
+
44
+ kwargs: dict[str, Any] = {
45
+ "model": model,
46
+ "messages": [
47
+ {"role": "system", "content": system_prompt},
48
+ {"role": "user", "content": user_prompt},
49
+ ],
50
+ "temperature": config.temperature,
51
+ "max_retries": max_retries,
52
+ "response_model": response_model,
53
+ }
54
+
55
+ if config.api_key:
56
+ kwargs["api_key"] = config.api_key
57
+ if config.base_url:
58
+ kwargs["api_base"] = config.base_url
59
+
60
+ logger.info(f"Structured extraction → {model} → {response_model.__name__}")
61
+
62
+ try:
63
+ result = client.chat.completions.create(**kwargs)
64
+ logger.success(f"Extracted {response_model.__name__} successfully")
65
+ return result
66
+ except Exception as e:
67
+ logger.error(f"Structured extraction failed: {e}")
68
+ raise
forge_core/auth.py ADDED
@@ -0,0 +1,72 @@
1
+ """SaaS API authentication and license verification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+
8
+ from forge_core.models.config import ForgeConfig, Plan, PlanLimits
9
+ from forge_core.utils import logger
10
+ from forge_core.utils.reporter import check_license
11
+
12
+
13
+ def verify_license(config: ForgeConfig) -> ForgeConfig:
14
+ """Verify license with SaaS API and update plan limits.
15
+
16
+ If no auth token is set, runs in free/offline mode.
17
+ """
18
+ if not config.auth_token:
19
+ logger.info("Running in offline mode (no auth token)")
20
+ config.limits = PlanLimits.for_plan(Plan.FREE)
21
+ return config
22
+
23
+ # Check with SaaS API
24
+ logger.info("Verifying license with SaaS API...")
25
+ try:
26
+ license_info = asyncio.run(check_license(config))
27
+ except Exception:
28
+ license_info = {}
29
+
30
+ if not license_info:
31
+ logger.warn("Could not verify license — falling back to free tier limits")
32
+ config.limits = PlanLimits.for_plan(Plan.FREE)
33
+ return config
34
+
35
+ # Update config from license response
36
+ plan_str = license_info.get("plan", "free")
37
+ try:
38
+ plan = Plan(plan_str)
39
+ except ValueError:
40
+ plan = Plan.FREE
41
+
42
+ config.limits = PlanLimits.for_plan(plan)
43
+ config.tenant.org_id = license_info.get("org_id", config.tenant.org_id)
44
+ config.tenant.org_name = license_info.get("org_name", config.tenant.org_name)
45
+
46
+ # If Pro/Enterprise, use SaaS proxy for AI calls
47
+ if plan in (Plan.PRO, Plan.ENTERPRISE):
48
+ config.ai.use_saas_proxy = True
49
+ if not config.ai.api_key:
50
+ config.ai.api_key = config.auth_token # SaaS proxy uses auth token
51
+ config.ai.base_url = f"{config.saas_api_url}/api/v1/ai"
52
+
53
+ logger.success(f"License verified: {plan.value} plan ({config.tenant.org_name})")
54
+ return config
55
+
56
+
57
+ def save_auth_token(token: str) -> None:
58
+ """Save auth token to ~/.forge-core/auth."""
59
+ auth_dir = Path.home() / ".forge-core"
60
+ auth_dir.mkdir(exist_ok=True)
61
+ auth_file = auth_dir / "auth"
62
+ auth_file.write_text(token, encoding="utf-8")
63
+ auth_file.chmod(0o600)
64
+ logger.success("Auth token saved")
65
+
66
+
67
+ def load_auth_token() -> str:
68
+ """Load auth token from ~/.forge-core/auth."""
69
+ auth_file = Path.home() / ".forge-core" / "auth"
70
+ if auth_file.exists():
71
+ return auth_file.read_text(encoding="utf-8").strip()
72
+ return ""
forge_core/cli.py ADDED
@@ -0,0 +1,209 @@
1
+ """Forge Core CLI — powered by typer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import typer
10
+ from rich.console import Console
11
+
12
+ from forge_core import __version__
13
+
14
+ app = typer.Typer(
15
+ name="forge-core",
16
+ help="AI-powered backend test generation engine",
17
+ no_args_is_help=True,
18
+ )
19
+ console = Console()
20
+
21
+
22
+ @app.command()
23
+ def run(
24
+ project_path: Path = typer.Argument(
25
+ ".",
26
+ help="Path to the backend project to test",
27
+ exists=True,
28
+ dir_okay=True,
29
+ file_okay=False,
30
+ ),
31
+ target_coverage: float = typer.Option(
32
+ 90.0, "--target", "-t", help="Target line coverage percentage"
33
+ ),
34
+ max_iterations: int = typer.Option(
35
+ 10, "--max-iterations", "-i", help="Maximum generation iterations"
36
+ ),
37
+ model: str = typer.Option(
38
+ "", "--model", "-m", help="AI model to use (e.g., gpt-4o, claude-sonnet-4-20250514)"
39
+ ),
40
+ provider: str = typer.Option(
41
+ "", "--provider", "-p", help="AI provider (openai, anthropic, ollama, azure)"
42
+ ),
43
+ api_key: str = typer.Option(
44
+ "", "--api-key", "-k", help="AI provider API key (or set OPENAI_API_KEY env var)"
45
+ ),
46
+ mode: str = typer.Option(
47
+ "full", "--mode", help="Run mode: full, targeted, analyze_only, analyze_review"
48
+ ),
49
+ files: Optional[list[str]] = typer.Option(
50
+ None, "--file", "-f", help="Specific files to test (targeted mode)"
51
+ ),
52
+ ):
53
+ """Run Forge Core on a backend project to generate unit tests."""
54
+ from forge_core.auth import load_auth_token, verify_license
55
+ from forge_core.config import load_config
56
+ from forge_core.models.config import RunMode
57
+ from forge_core.orchestrator import Orchestrator
58
+ from forge_core.utils import logger
59
+ from forge_core.utils.reporter import upload_report
60
+
61
+ # Resolve project path
62
+ project_path = project_path.resolve()
63
+
64
+ # Load config
65
+ auth_token = load_auth_token()
66
+ config = load_config(
67
+ project_path=project_path,
68
+ api_key=api_key,
69
+ provider=provider,
70
+ model=model,
71
+ target_coverage=target_coverage,
72
+ auth_token=auth_token,
73
+ )
74
+ config.max_iterations = max_iterations
75
+
76
+ # Set mode
77
+ try:
78
+ config.mode = RunMode(mode)
79
+ except ValueError:
80
+ logger.error(f"Invalid mode: {mode}. Use: full, targeted, analyze_only, analyze_review")
81
+ raise typer.Exit(1)
82
+
83
+ if files:
84
+ config.mode = RunMode.TARGETED
85
+ config.target_files = list(files)
86
+
87
+ # Verify license
88
+ config = verify_license(config)
89
+
90
+ # Validate API key
91
+ if not config.ai.api_key:
92
+ console.print(
93
+ "[red]No API key found.[/red]\n\n"
94
+ "Set one of:\n"
95
+ " export OPENAI_API_KEY=sk-...\n"
96
+ " export ANTHROPIC_API_KEY=sk-ant-...\n"
97
+ " forge-core run --api-key sk-...\n"
98
+ " forge-core login (for Pro/Enterprise)\n"
99
+ )
100
+ raise typer.Exit(1)
101
+
102
+ # Run the pipeline
103
+ orchestrator = Orchestrator(config)
104
+ report = orchestrator.run()
105
+
106
+ # Upload report to SaaS (if authenticated)
107
+ if config.auth_token:
108
+ asyncio.run(upload_report(config, report.model_dump()))
109
+
110
+ # Exit with appropriate code
111
+ if report.production_files_changed > 0:
112
+ logger.error("SAFETY VIOLATION: Production files were changed!")
113
+ raise typer.Exit(2)
114
+
115
+ raise typer.Exit(0)
116
+
117
+
118
+ @app.command()
119
+ def login(
120
+ token: str = typer.Option(
121
+ "", "--token", "-t", help="Auth token from theswitchcompany.online"
122
+ ),
123
+ ):
124
+ """Authenticate with TheSwitchCompany SaaS for Pro/Enterprise features."""
125
+ from forge_core.auth import save_auth_token, verify_license
126
+ from forge_core.config import load_config
127
+ from forge_core.utils import logger
128
+
129
+ if not token:
130
+ console.print(
131
+ "Get your auth token from:\n"
132
+ " [blue]https://theswitchcompany.online/dashboard/settings[/blue]\n\n"
133
+ "Then run:\n"
134
+ " forge-core login --token YOUR_TOKEN"
135
+ )
136
+ raise typer.Exit(0)
137
+
138
+ save_auth_token(token)
139
+
140
+ # Verify the token
141
+ config = load_config(Path("."), auth_token=token)
142
+ config = verify_license(config)
143
+ logger.success(f"Logged in as {config.tenant.org_name or 'user'}")
144
+
145
+
146
+ @app.command()
147
+ def init(
148
+ project_path: Path = typer.Argument(".", help="Path to the project"),
149
+ ):
150
+ """Initialize Forge Core in a project (creates .github/agent-config.yml)."""
151
+ from forge_core.utils import logger
152
+
153
+ project_path = project_path.resolve()
154
+ config_dir = project_path / ".github"
155
+ config_dir.mkdir(exist_ok=True)
156
+
157
+ config_file = config_dir / "agent-config.yml"
158
+ if config_file.exists():
159
+ logger.info("agent-config.yml already exists")
160
+ raise typer.Exit(0)
161
+
162
+ config_file.write_text(
163
+ "# Forge Core configuration\n"
164
+ "# See: https://theswitchcompany.online/docs/forge-core\n\n"
165
+ "# Tenant (populated by SaaS or set manually)\n"
166
+ 'org_id: ""\n'
167
+ 'user_id: ""\n'
168
+ 'project_id: ""\n\n'
169
+ "# Runtime\n"
170
+ 'runtime: "auto"\n'
171
+ "max_parallel_agents: 4\n"
172
+ 'cache_dir: ".forge-cache"\n',
173
+ encoding="utf-8",
174
+ )
175
+
176
+ logger.success(f"Initialized Forge Core in {project_path}")
177
+ console.print("\nNext: [blue]forge-core run[/blue] to generate tests")
178
+
179
+
180
+ @app.command()
181
+ def version():
182
+ """Show Forge Core version."""
183
+ console.print(f"Forge Core v{__version__}")
184
+
185
+
186
+ @app.command()
187
+ def status():
188
+ """Show current configuration and license status."""
189
+ from forge_core.auth import load_auth_token, verify_license
190
+ from forge_core.config import load_config
191
+
192
+ auth_token = load_auth_token()
193
+ config = load_config(Path("."), auth_token=auth_token)
194
+
195
+ if auth_token:
196
+ config = verify_license(config)
197
+
198
+ console.print(f"\n[bold]Forge Core v{__version__}[/bold]\n")
199
+ console.print(f" Plan: {config.limits.plan.value}")
200
+ console.print(f" Org: {config.tenant.org_name or '(not set)'}")
201
+ console.print(f" Model: {config.ai.model}")
202
+ console.print(f" Provider: {config.ai.provider.value}")
203
+ console.print(f" API Key: {'***' + config.ai.api_key[-4:] if config.ai.api_key else '(not set)'}")
204
+ console.print(f" Auth: {'✓ logged in' if auth_token else '✗ offline'}")
205
+ console.print()
206
+
207
+
208
+ if __name__ == "__main__":
209
+ app()
forge_core/config.py ADDED
@@ -0,0 +1,118 @@
1
+ """Configuration loader — merges agent-config.yml, env vars, and CLI args."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+
10
+ from forge_core.models.config import AIConfig, AIProvider, ForgeConfig, TenantInfo
11
+ from forge_core.utils import logger
12
+
13
+
14
+ def load_config(
15
+ project_path: Path,
16
+ api_key: str = "",
17
+ provider: str = "",
18
+ model: str = "",
19
+ target_coverage: float = 0,
20
+ auth_token: str = "",
21
+ ) -> ForgeConfig:
22
+ """Load configuration from agent-config.yml, env vars, and CLI overrides.
23
+
24
+ Priority: CLI args > env vars > agent-config.yml > defaults.
25
+ """
26
+ config = ForgeConfig(project_path=project_path)
27
+
28
+ # 1. Load agent-config.yml from the project
29
+ yml_path = project_path / ".github" / "agent-config.yml"
30
+ if yml_path.exists():
31
+ _load_yml(config, yml_path)
32
+
33
+ # 2. Environment variables override
34
+ _load_env(config)
35
+
36
+ # 3. CLI args override
37
+ if api_key:
38
+ config.ai.api_key = api_key
39
+ if provider:
40
+ config.ai.provider = AIProvider(provider)
41
+ if model:
42
+ config.ai.model = model
43
+ if target_coverage > 0:
44
+ config.target_coverage = target_coverage
45
+ if auth_token:
46
+ config.auth_token = auth_token
47
+
48
+ # 4. Resolve prompts directory
49
+ config.prompts_dir = str(project_path / ".github" / "prompts")
50
+
51
+ return config
52
+
53
+
54
+ def _load_yml(config: ForgeConfig, yml_path: Path) -> None:
55
+ """Load settings from agent-config.yml."""
56
+ try:
57
+ data = yaml.safe_load(yml_path.read_text(encoding="utf-8")) or {}
58
+ except Exception as e:
59
+ logger.warn(f"Failed to parse {yml_path}: {e}")
60
+ return
61
+
62
+ if "central_agent_path" in data:
63
+ config.central_agent_path = data["central_agent_path"]
64
+ if "knowledge_packs_dir" in data:
65
+ config.knowledge_packs_dir = data["knowledge_packs_dir"]
66
+ if "cache_dir" in data:
67
+ config.cache_dir = data["cache_dir"]
68
+
69
+ # Tenant info
70
+ config.tenant = TenantInfo(
71
+ org_id=data.get("org_id", ""),
72
+ org_name=data.get("org_name", ""),
73
+ user_id=data.get("user_id", ""),
74
+ project_id=data.get("project_id", ""),
75
+ )
76
+
77
+ # Runtime config
78
+ if "runtime" in data and data["runtime"] != "auto":
79
+ try:
80
+ config.ai.provider = AIProvider(data["runtime"])
81
+ except ValueError:
82
+ pass
83
+ if "max_parallel_agents" in data:
84
+ config.limits.max_parallel_agents = int(data["max_parallel_agents"])
85
+
86
+
87
+ def _load_env(config: ForgeConfig) -> None:
88
+ """Load settings from environment variables."""
89
+ env_map = {
90
+ "FORGE_API_KEY": "api_key",
91
+ "OPENAI_API_KEY": "api_key",
92
+ "ANTHROPIC_API_KEY": "api_key",
93
+ "FORGE_MODEL": "model",
94
+ "FORGE_PROVIDER": "provider",
95
+ "FORGE_AUTH_TOKEN": "auth_token",
96
+ }
97
+
98
+ for env_var, field in env_map.items():
99
+ value = os.environ.get(env_var, "")
100
+ if not value:
101
+ continue
102
+
103
+ if field == "api_key" and not config.ai.api_key:
104
+ config.ai.api_key = value
105
+ # Auto-detect provider from key prefix
106
+ if env_var == "ANTHROPIC_API_KEY" or value.startswith("sk-ant-"):
107
+ config.ai.provider = AIProvider.ANTHROPIC
108
+ if config.ai.model == "gpt-4o":
109
+ config.ai.model = "claude-sonnet-4-20250514"
110
+ elif field == "model":
111
+ config.ai.model = value
112
+ elif field == "provider":
113
+ try:
114
+ config.ai.provider = AIProvider(value)
115
+ except ValueError:
116
+ pass
117
+ elif field == "auth_token":
118
+ config.auth_token = value
File without changes