switchforge 1.0.0__tar.gz → 1.1.0__tar.gz
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.
- {switchforge-1.0.0 → switchforge-1.1.0}/PKG-INFO +5 -4
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/ai/provider.py +35 -36
- switchforge-1.1.0/forge_core/ai/structured.py +62 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/utils/tokens.py +16 -2
- {switchforge-1.0.0 → switchforge-1.1.0}/pyproject.toml +4 -4
- switchforge-1.0.0/forge_core/ai/structured.py +0 -68
- {switchforge-1.0.0 → switchforge-1.1.0}/.gitignore +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/README.md +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge-core.spec +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/__init__.py +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/__main__.py +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/ai/__init__.py +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/ai/prompts.py +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/auth.py +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/cli.py +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/config.py +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/core/__init__.py +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/core/agent_manager.py +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/core/coverage.py +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/core/file_manager.py +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/models/__init__.py +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/models/config.py +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/models/dto.py +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/models/project.py +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/models/test_result.py +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/orchestrator.py +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/phases/__init__.py +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/phases/analyze_project.py +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/phases/audit_tests.py +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/phases/compile_fix.py +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/phases/coverage_report.py +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/phases/detect_stack.py +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/phases/exclusion_scan.py +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/phases/fix_broken.py +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/phases/generate_tests.py +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/phases/journey_mapping.py +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/phases/self_learn.py +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/utils/__init__.py +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/utils/logger.py +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/utils/reporter.py +0 -0
- {switchforge-1.0.0 → switchforge-1.1.0}/forge_core/utils/shell.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: switchforge
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: AI-powered backend test generation engine
|
|
5
5
|
Project-URL: Homepage, https://theswitchcompany.online/products/forge/core
|
|
6
6
|
Project-URL: Repository, https://github.com/switchcompany/forge-core
|
|
@@ -18,18 +18,19 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
18
18
|
Classifier: Topic :: Software Development :: Testing
|
|
19
19
|
Requires-Python: >=3.10
|
|
20
20
|
Requires-Dist: httpx>=0.27.0
|
|
21
|
-
Requires-Dist:
|
|
22
|
-
Requires-Dist: litellm>=1.40.0
|
|
21
|
+
Requires-Dist: openai>=1.30.0
|
|
23
22
|
Requires-Dist: pydantic>=2.7.0
|
|
24
23
|
Requires-Dist: pyyaml>=6.0
|
|
25
24
|
Requires-Dist: rich>=13.7.0
|
|
26
|
-
Requires-Dist: tiktoken>=0.7.0
|
|
27
25
|
Requires-Dist: typer>=0.12.0
|
|
26
|
+
Provides-Extra: accurate-tokens
|
|
27
|
+
Requires-Dist: tiktoken>=0.7.0; extra == 'accurate-tokens'
|
|
28
28
|
Provides-Extra: dev
|
|
29
29
|
Requires-Dist: pyinstaller>=6.0; extra == 'dev'
|
|
30
30
|
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
31
31
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
32
32
|
Requires-Dist: ruff>=0.5.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: tiktoken>=0.7.0; extra == 'dev'
|
|
33
34
|
Description-Content-Type: text/markdown
|
|
34
35
|
|
|
35
36
|
# Switchforge (Forge Core CLI)
|
|
@@ -1,27 +1,44 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""AI provider — direct OpenAI SDK, zero native dependencies."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import os
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
7
|
-
import
|
|
8
|
+
from openai import OpenAI
|
|
8
9
|
|
|
9
10
|
from forge_core.models.config import AIConfig, AIProvider
|
|
10
11
|
from forge_core.utils import logger
|
|
11
12
|
from forge_core.utils.tokens import count_tokens
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
|
|
15
|
+
def _get_client(config: AIConfig) -> OpenAI:
|
|
16
|
+
"""Create an OpenAI-compatible client for any provider."""
|
|
17
|
+
api_key = config.api_key or os.environ.get("OPENAI_API_KEY", "")
|
|
18
|
+
|
|
19
|
+
if config.provider == AIProvider.ANTHROPIC:
|
|
20
|
+
base_url = config.base_url or "https://api.anthropic.com/v1"
|
|
21
|
+
api_key = config.api_key or os.environ.get("ANTHROPIC_API_KEY", "")
|
|
22
|
+
elif config.provider == AIProvider.OLLAMA:
|
|
23
|
+
base_url = config.base_url or "http://localhost:11434/v1"
|
|
24
|
+
api_key = "ollama"
|
|
25
|
+
elif config.provider == AIProvider.AZURE:
|
|
26
|
+
base_url = config.base_url or os.environ.get("AZURE_OPENAI_ENDPOINT", "")
|
|
27
|
+
api_key = config.api_key or os.environ.get("AZURE_OPENAI_API_KEY", "")
|
|
28
|
+
elif config.base_url:
|
|
29
|
+
base_url = config.base_url
|
|
30
|
+
else:
|
|
31
|
+
base_url = None # default OpenAI
|
|
32
|
+
|
|
33
|
+
kwargs: dict[str, Any] = {"api_key": api_key}
|
|
34
|
+
if base_url:
|
|
35
|
+
kwargs["base_url"] = base_url
|
|
36
|
+
|
|
37
|
+
return OpenAI(**kwargs)
|
|
15
38
|
|
|
16
39
|
|
|
17
40
|
def _resolve_model(config: AIConfig) -> str:
|
|
18
|
-
"""Resolve the model
|
|
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}"
|
|
41
|
+
"""Resolve the model name."""
|
|
25
42
|
return config.model
|
|
26
43
|
|
|
27
44
|
|
|
@@ -32,19 +49,10 @@ def complete(
|
|
|
32
49
|
json_mode: bool = False,
|
|
33
50
|
max_tokens: int | None = None,
|
|
34
51
|
) -> 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
|
-
"""
|
|
52
|
+
"""Send a completion request to the configured AI provider."""
|
|
47
53
|
model = _resolve_model(config)
|
|
54
|
+
client = _get_client(config)
|
|
55
|
+
|
|
48
56
|
messages = [
|
|
49
57
|
{"role": "system", "content": system_prompt},
|
|
50
58
|
{"role": "user", "content": user_prompt},
|
|
@@ -57,19 +65,14 @@ def complete(
|
|
|
57
65
|
"max_tokens": max_tokens or config.max_tokens,
|
|
58
66
|
}
|
|
59
67
|
|
|
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
68
|
if json_mode:
|
|
65
69
|
kwargs["response_format"] = {"type": "json_object"}
|
|
66
70
|
|
|
67
|
-
# Log token usage
|
|
68
71
|
input_tokens = count_tokens(system_prompt + user_prompt, config.model)
|
|
69
72
|
logger.info(f"AI call → {model} ({input_tokens} input tokens)")
|
|
70
73
|
|
|
71
74
|
try:
|
|
72
|
-
response =
|
|
75
|
+
response = client.chat.completions.create(**kwargs)
|
|
73
76
|
content = response.choices[0].message.content or ""
|
|
74
77
|
|
|
75
78
|
output_tokens = count_tokens(content, config.model)
|
|
@@ -88,11 +91,9 @@ def complete_with_fallback(
|
|
|
88
91
|
fallback_models: list[str] | None = None,
|
|
89
92
|
json_mode: bool = False,
|
|
90
93
|
) -> str:
|
|
91
|
-
"""Try primary model, fall back to alternatives on failure.
|
|
92
|
-
|
|
93
|
-
Useful for complex classes where one model might fail.
|
|
94
|
-
"""
|
|
94
|
+
"""Try primary model, fall back to alternatives on failure."""
|
|
95
95
|
models = [_resolve_model(config)] + (fallback_models or [])
|
|
96
|
+
client = _get_client(config)
|
|
96
97
|
|
|
97
98
|
last_error = None
|
|
98
99
|
for model in models:
|
|
@@ -106,12 +107,10 @@ def complete_with_fallback(
|
|
|
106
107
|
"temperature": config.temperature,
|
|
107
108
|
"max_tokens": config.max_tokens,
|
|
108
109
|
}
|
|
109
|
-
if config.api_key:
|
|
110
|
-
kwargs["api_key"] = config.api_key
|
|
111
110
|
if json_mode:
|
|
112
111
|
kwargs["response_format"] = {"type": "json_object"}
|
|
113
112
|
|
|
114
|
-
response =
|
|
113
|
+
response = client.chat.completions.create(**kwargs)
|
|
115
114
|
return response.choices[0].message.content or ""
|
|
116
115
|
except Exception as e:
|
|
117
116
|
last_error = e
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Structured AI outputs using OpenAI JSON mode + pydantic parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import TypeVar
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from forge_core.models.config import AIConfig
|
|
11
|
+
from forge_core.ai.provider import complete
|
|
12
|
+
from forge_core.utils import logger
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T", bound=BaseModel)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def extract(
|
|
18
|
+
config: AIConfig,
|
|
19
|
+
system_prompt: str,
|
|
20
|
+
user_prompt: str,
|
|
21
|
+
response_model: type[T],
|
|
22
|
+
max_retries: int = 2,
|
|
23
|
+
) -> T:
|
|
24
|
+
"""Extract structured data from AI using JSON mode + pydantic.
|
|
25
|
+
|
|
26
|
+
Forces the AI to return data matching a pydantic model schema.
|
|
27
|
+
Retries on validation failure.
|
|
28
|
+
"""
|
|
29
|
+
schema = response_model.model_json_schema()
|
|
30
|
+
structured_prompt = (
|
|
31
|
+
f"{system_prompt}\n\n"
|
|
32
|
+
f"You MUST respond with valid JSON matching this schema:\n"
|
|
33
|
+
f"```json\n{json.dumps(schema, indent=2)}\n```\n"
|
|
34
|
+
f"Respond ONLY with the JSON object, no other text."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
logger.info(f"Structured extraction → {config.model} → {response_model.__name__}")
|
|
38
|
+
|
|
39
|
+
last_error = None
|
|
40
|
+
for attempt in range(max_retries + 1):
|
|
41
|
+
try:
|
|
42
|
+
raw = complete(config, structured_prompt, user_prompt, json_mode=True)
|
|
43
|
+
# Strip markdown code fences if present
|
|
44
|
+
cleaned = raw.strip()
|
|
45
|
+
if cleaned.startswith("```"):
|
|
46
|
+
cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned
|
|
47
|
+
if cleaned.endswith("```"):
|
|
48
|
+
cleaned = cleaned[:-3]
|
|
49
|
+
cleaned = cleaned.strip()
|
|
50
|
+
|
|
51
|
+
result = response_model.model_validate_json(cleaned)
|
|
52
|
+
logger.success(f"Extracted {response_model.__name__} successfully")
|
|
53
|
+
return result
|
|
54
|
+
except Exception as e:
|
|
55
|
+
last_error = e
|
|
56
|
+
if attempt < max_retries:
|
|
57
|
+
logger.warn(f"Extraction attempt {attempt + 1} failed: {e}, retrying...")
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
raise RuntimeError(
|
|
61
|
+
f"Structured extraction failed after {max_retries + 1} attempts: {last_error}"
|
|
62
|
+
)
|
|
@@ -1,12 +1,23 @@
|
|
|
1
|
-
"""Token counting and prompt size management
|
|
1
|
+
"""Token counting and prompt size management."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
try:
|
|
6
|
+
import tiktoken
|
|
7
|
+
_HAS_TIKTOKEN = True
|
|
8
|
+
except ImportError:
|
|
9
|
+
_HAS_TIKTOKEN = False
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _estimate_tokens(text: str) -> int:
|
|
13
|
+
"""Rough token estimate (~4 chars per token) when tiktoken is unavailable."""
|
|
14
|
+
return len(text) // 4 + 1
|
|
6
15
|
|
|
7
16
|
|
|
8
17
|
def count_tokens(text: str, model: str = "gpt-4o") -> int:
|
|
9
18
|
"""Count tokens in text for a given model."""
|
|
19
|
+
if not _HAS_TIKTOKEN:
|
|
20
|
+
return _estimate_tokens(text)
|
|
10
21
|
try:
|
|
11
22
|
enc = tiktoken.encoding_for_model(model)
|
|
12
23
|
except KeyError:
|
|
@@ -16,6 +27,9 @@ def count_tokens(text: str, model: str = "gpt-4o") -> int:
|
|
|
16
27
|
|
|
17
28
|
def truncate_to_tokens(text: str, max_tokens: int, model: str = "gpt-4o") -> str:
|
|
18
29
|
"""Truncate text to fit within max_tokens."""
|
|
30
|
+
if not _HAS_TIKTOKEN:
|
|
31
|
+
char_limit = max_tokens * 4
|
|
32
|
+
return text[:char_limit] if len(text) > char_limit else text
|
|
19
33
|
try:
|
|
20
34
|
enc = tiktoken.encoding_for_model(model)
|
|
21
35
|
except KeyError:
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "switchforge"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.1.0"
|
|
8
8
|
description = "AI-powered backend test generation engine"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -25,22 +25,22 @@ classifiers = [
|
|
|
25
25
|
]
|
|
26
26
|
|
|
27
27
|
dependencies = [
|
|
28
|
-
"
|
|
29
|
-
"instructor>=1.3.0",
|
|
28
|
+
"openai>=1.30.0",
|
|
30
29
|
"typer>=0.12.0",
|
|
31
30
|
"rich>=13.7.0",
|
|
32
31
|
"pyyaml>=6.0",
|
|
33
|
-
"tiktoken>=0.7.0",
|
|
34
32
|
"httpx>=0.27.0",
|
|
35
33
|
"pydantic>=2.7.0",
|
|
36
34
|
]
|
|
37
35
|
|
|
38
36
|
[project.optional-dependencies]
|
|
37
|
+
accurate-tokens = ["tiktoken>=0.7.0"]
|
|
39
38
|
dev = [
|
|
40
39
|
"pytest>=8.0",
|
|
41
40
|
"pytest-asyncio>=0.23",
|
|
42
41
|
"ruff>=0.5.0",
|
|
43
42
|
"pyinstaller>=6.0",
|
|
43
|
+
"tiktoken>=0.7.0",
|
|
44
44
|
]
|
|
45
45
|
|
|
46
46
|
[project.scripts]
|
|
@@ -1,68 +0,0 @@
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|