gac 3.8.1__py3-none-any.whl → 3.10.10__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.
- gac/__init__.py +4 -6
- gac/__version__.py +1 -1
- gac/ai_utils.py +18 -49
- gac/cli.py +14 -10
- gac/commit_executor.py +59 -0
- gac/config.py +28 -3
- gac/config_cli.py +19 -7
- gac/constants/__init__.py +34 -0
- gac/constants/commit.py +63 -0
- gac/constants/defaults.py +40 -0
- gac/constants/file_patterns.py +110 -0
- gac/constants/languages.py +119 -0
- gac/diff_cli.py +0 -22
- gac/errors.py +8 -2
- gac/git.py +6 -6
- gac/git_state_validator.py +193 -0
- gac/grouped_commit_workflow.py +458 -0
- gac/init_cli.py +2 -1
- gac/interactive_mode.py +179 -0
- gac/language_cli.py +0 -1
- gac/main.py +222 -959
- gac/model_cli.py +2 -1
- gac/model_identifier.py +70 -0
- gac/oauth/claude_code.py +2 -2
- gac/oauth/qwen_oauth.py +4 -0
- gac/oauth/token_store.py +2 -2
- gac/oauth_retry.py +161 -0
- gac/postprocess.py +155 -0
- gac/prompt.py +20 -490
- gac/prompt_builder.py +88 -0
- gac/providers/README.md +437 -0
- gac/providers/__init__.py +70 -81
- gac/providers/anthropic.py +12 -56
- gac/providers/azure_openai.py +48 -92
- gac/providers/base.py +329 -0
- gac/providers/cerebras.py +10 -43
- gac/providers/chutes.py +16 -72
- gac/providers/claude_code.py +64 -97
- gac/providers/custom_anthropic.py +51 -85
- gac/providers/custom_openai.py +29 -87
- gac/providers/deepseek.py +10 -43
- gac/providers/error_handler.py +139 -0
- gac/providers/fireworks.py +10 -43
- gac/providers/gemini.py +66 -73
- gac/providers/groq.py +10 -62
- gac/providers/kimi_coding.py +19 -59
- gac/providers/lmstudio.py +62 -52
- gac/providers/minimax.py +10 -43
- gac/providers/mistral.py +10 -43
- gac/providers/moonshot.py +10 -43
- gac/providers/ollama.py +54 -41
- gac/providers/openai.py +30 -46
- gac/providers/openrouter.py +15 -62
- gac/providers/protocol.py +71 -0
- gac/providers/qwen.py +55 -67
- gac/providers/registry.py +58 -0
- gac/providers/replicate.py +137 -91
- gac/providers/streamlake.py +26 -56
- gac/providers/synthetic.py +35 -47
- gac/providers/together.py +10 -43
- gac/providers/zai.py +21 -59
- gac/py.typed +0 -0
- gac/security.py +1 -1
- gac/templates/__init__.py +1 -0
- gac/templates/question_generation.txt +60 -0
- gac/templates/system_prompt.txt +224 -0
- gac/templates/user_prompt.txt +28 -0
- gac/utils.py +6 -5
- gac/workflow_context.py +162 -0
- {gac-3.8.1.dist-info → gac-3.10.10.dist-info}/METADATA +1 -1
- gac-3.10.10.dist-info/RECORD +79 -0
- gac/constants.py +0 -328
- gac-3.8.1.dist-info/RECORD +0 -56
- {gac-3.8.1.dist-info → gac-3.10.10.dist-info}/WHEEL +0 -0
- {gac-3.8.1.dist-info → gac-3.10.10.dist-info}/entry_points.txt +0 -0
- {gac-3.8.1.dist-info → gac-3.10.10.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Centralized error handling decorator for AI providers.
|
|
2
|
+
|
|
3
|
+
This module provides the single authoritative location for converting exceptions
|
|
4
|
+
to AIError types. All provider API functions should be decorated with
|
|
5
|
+
@handle_provider_errors to ensure consistent error handling.
|
|
6
|
+
|
|
7
|
+
Error Classification:
|
|
8
|
+
- httpx.ConnectError -> AIError.connection_error
|
|
9
|
+
- httpx.TimeoutException -> AIError.timeout_error
|
|
10
|
+
- httpx.HTTPStatusError:
|
|
11
|
+
- 401 -> AIError.authentication_error
|
|
12
|
+
- 429 -> AIError.rate_limit_error
|
|
13
|
+
- 404 -> AIError.model_error
|
|
14
|
+
- 5xx -> AIError.connection_error (server issues)
|
|
15
|
+
- other -> AIError.model_error
|
|
16
|
+
- Other exceptions: String-based classification as fallback
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from functools import wraps
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
|
|
26
|
+
from gac.errors import AIError
|
|
27
|
+
|
|
28
|
+
MAX_ERROR_RESPONSE_LENGTH = 200
|
|
29
|
+
|
|
30
|
+
SENSITIVE_PATTERNS = [
|
|
31
|
+
re.compile(r"sk-[A-Za-z0-9_-]{20,}"), # OpenAI keys
|
|
32
|
+
re.compile(r"sk-ant-[A-Za-z0-9_-]{20,}"), # Anthropic keys
|
|
33
|
+
re.compile(r"(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}"), # GitHub tokens
|
|
34
|
+
re.compile(r"AIza[0-9A-Za-z_-]{20,}"), # Google API keys
|
|
35
|
+
re.compile(r"(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{20,}"), # Stripe keys
|
|
36
|
+
re.compile(r"xox[baprs]-[A-Za-z0-9-]{20,}"), # Slack tokens
|
|
37
|
+
re.compile(r"eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"), # JWT tokens
|
|
38
|
+
re.compile(r"Bearer\s+[A-Za-z0-9_-]{20,}"), # Bearer tokens
|
|
39
|
+
re.compile(r"[A-Za-z0-9]{32,}"), # Generic long alphanumeric tokens
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def sanitize_error_response(text: str) -> str:
|
|
44
|
+
"""Sanitize API error response text for safe logging/display.
|
|
45
|
+
|
|
46
|
+
This function:
|
|
47
|
+
1. Redacts potential API keys and tokens
|
|
48
|
+
2. Truncates to MAX_ERROR_RESPONSE_LENGTH characters
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
text: Raw error response text from an API
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Sanitized text safe for logging/display
|
|
55
|
+
"""
|
|
56
|
+
if not text:
|
|
57
|
+
return ""
|
|
58
|
+
|
|
59
|
+
sanitized = text
|
|
60
|
+
for pattern in SENSITIVE_PATTERNS:
|
|
61
|
+
sanitized = pattern.sub("[REDACTED]", sanitized)
|
|
62
|
+
|
|
63
|
+
if len(sanitized) > MAX_ERROR_RESPONSE_LENGTH:
|
|
64
|
+
sanitized = sanitized[:MAX_ERROR_RESPONSE_LENGTH] + "..."
|
|
65
|
+
|
|
66
|
+
return sanitized
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def handle_provider_errors(provider_name: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
70
|
+
"""Decorator to standardize error handling across all AI providers.
|
|
71
|
+
|
|
72
|
+
This is the single authoritative location for error handling. Provider
|
|
73
|
+
implementations should not catch httpx exceptions - they will be caught
|
|
74
|
+
and converted to appropriate AIError types by this decorator.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
provider_name: Name of the AI provider for error messages
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Decorator function that wraps provider functions with standardized error handling
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
84
|
+
@wraps(func)
|
|
85
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
86
|
+
try:
|
|
87
|
+
return func(*args, **kwargs)
|
|
88
|
+
except AIError:
|
|
89
|
+
# Re-raise AIError exceptions as-is without wrapping
|
|
90
|
+
raise
|
|
91
|
+
except httpx.ConnectError as e:
|
|
92
|
+
raise AIError.connection_error(f"{provider_name}: {e}") from e
|
|
93
|
+
except httpx.TimeoutException as e:
|
|
94
|
+
raise AIError.timeout_error(f"{provider_name}: {e}") from e
|
|
95
|
+
except httpx.HTTPStatusError as e:
|
|
96
|
+
sanitized_response = sanitize_error_response(e.response.text)
|
|
97
|
+
if e.response.status_code == 401:
|
|
98
|
+
raise AIError.authentication_error(
|
|
99
|
+
f"{provider_name}: Invalid API key or authentication failed"
|
|
100
|
+
) from e
|
|
101
|
+
elif e.response.status_code == 429:
|
|
102
|
+
raise AIError.rate_limit_error(
|
|
103
|
+
f"{provider_name}: Rate limit exceeded. Please try again later."
|
|
104
|
+
) from e
|
|
105
|
+
elif e.response.status_code == 404:
|
|
106
|
+
raise AIError.model_error(f"{provider_name}: Model not found or endpoint not available") from e
|
|
107
|
+
elif e.response.status_code >= 500:
|
|
108
|
+
raise AIError.connection_error(
|
|
109
|
+
f"{provider_name}: Server error (HTTP {e.response.status_code})"
|
|
110
|
+
) from e
|
|
111
|
+
else:
|
|
112
|
+
raise AIError.model_error(
|
|
113
|
+
f"{provider_name}: HTTP {e.response.status_code}: {sanitized_response}"
|
|
114
|
+
) from e
|
|
115
|
+
except Exception as e:
|
|
116
|
+
# Handle any other unexpected exceptions with string-based classification
|
|
117
|
+
error_str = str(e).lower()
|
|
118
|
+
if "authentication" in error_str or "unauthorized" in error_str:
|
|
119
|
+
raise AIError.authentication_error(f"Error calling {provider_name} API: {e}") from e
|
|
120
|
+
elif "rate limit" in error_str or "quota" in error_str:
|
|
121
|
+
raise AIError.rate_limit_error(f"Error calling {provider_name} API: {e}") from e
|
|
122
|
+
elif "timeout" in error_str:
|
|
123
|
+
raise AIError.timeout_error(f"Error calling {provider_name} API: {e}") from e
|
|
124
|
+
elif "connection" in error_str:
|
|
125
|
+
raise AIError.connection_error(f"Error calling {provider_name} API: {e}") from e
|
|
126
|
+
else:
|
|
127
|
+
raise AIError.model_error(f"Error calling {provider_name} API: {e}") from e
|
|
128
|
+
|
|
129
|
+
return wrapper
|
|
130
|
+
|
|
131
|
+
return decorator
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
__all__ = [
|
|
135
|
+
"MAX_ERROR_RESPONSE_LENGTH",
|
|
136
|
+
"SENSITIVE_PATTERNS",
|
|
137
|
+
"handle_provider_errors",
|
|
138
|
+
"sanitize_error_response",
|
|
139
|
+
]
|
gac/providers/fireworks.py
CHANGED
|
@@ -1,48 +1,15 @@
|
|
|
1
1
|
"""Fireworks AI API provider for gac."""
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import os
|
|
3
|
+
from gac.providers.base import OpenAICompatibleProvider, ProviderConfig
|
|
5
4
|
|
|
6
|
-
import httpx
|
|
7
5
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
class FireworksProvider(OpenAICompatibleProvider):
|
|
7
|
+
config = ProviderConfig(
|
|
8
|
+
name="Fireworks",
|
|
9
|
+
api_key_env="FIREWORKS_API_KEY",
|
|
10
|
+
base_url="https://api.fireworks.ai/inference/v1",
|
|
11
|
+
)
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def call_fireworks_api(model: str, messages: list[dict], temperature: float, max_tokens: int) -> str:
|
|
16
|
-
"""Call Fireworks AI API directly."""
|
|
17
|
-
api_key = os.getenv("FIREWORKS_API_KEY")
|
|
18
|
-
if not api_key:
|
|
19
|
-
raise AIError.authentication_error("FIREWORKS_API_KEY not found in environment variables")
|
|
20
|
-
|
|
21
|
-
url = "https://api.fireworks.ai/inference/v1/chat/completions"
|
|
22
|
-
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
|
23
|
-
|
|
24
|
-
data = {"model": model, "messages": messages, "temperature": temperature, "max_tokens": max_tokens}
|
|
25
|
-
|
|
26
|
-
logger.debug(f"Calling Fireworks AI API with model={model}")
|
|
27
|
-
|
|
28
|
-
try:
|
|
29
|
-
response = httpx.post(
|
|
30
|
-
url, headers=headers, json=data, timeout=ProviderDefaults.HTTP_TIMEOUT, verify=get_ssl_verify()
|
|
31
|
-
)
|
|
32
|
-
response.raise_for_status()
|
|
33
|
-
response_data = response.json()
|
|
34
|
-
content = response_data["choices"][0]["message"]["content"]
|
|
35
|
-
if content is None:
|
|
36
|
-
raise AIError.model_error("Fireworks AI API returned null content")
|
|
37
|
-
if content == "":
|
|
38
|
-
raise AIError.model_error("Fireworks AI API returned empty content")
|
|
39
|
-
logger.debug("Fireworks AI API response received successfully")
|
|
40
|
-
return content
|
|
41
|
-
except httpx.HTTPStatusError as e:
|
|
42
|
-
if e.response.status_code == 429:
|
|
43
|
-
raise AIError.rate_limit_error(f"Fireworks AI API rate limit exceeded: {e.response.text}") from e
|
|
44
|
-
raise AIError.model_error(f"Fireworks AI API error: {e.response.status_code} - {e.response.text}") from e
|
|
45
|
-
except httpx.TimeoutException as e:
|
|
46
|
-
raise AIError.timeout_error(f"Fireworks AI API request timed out: {str(e)}") from e
|
|
47
|
-
except Exception as e:
|
|
48
|
-
raise AIError.model_error(f"Error calling Fireworks AI API: {str(e)}") from e
|
|
13
|
+
def _get_api_url(self, model: str | None = None) -> str:
|
|
14
|
+
"""Get Fireworks API URL with /chat/completions endpoint."""
|
|
15
|
+
return f"{self.config.base_url}/chat/completions"
|
gac/providers/gemini.py
CHANGED
|
@@ -1,70 +1,74 @@
|
|
|
1
1
|
"""Gemini AI provider implementation."""
|
|
2
2
|
|
|
3
|
-
import logging
|
|
4
|
-
import os
|
|
5
3
|
from typing import Any
|
|
6
4
|
|
|
7
|
-
import httpx
|
|
8
|
-
|
|
9
|
-
from gac.constants import ProviderDefaults
|
|
10
5
|
from gac.errors import AIError
|
|
11
|
-
from gac.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
6
|
+
from gac.providers.base import GenericHTTPProvider, ProviderConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GeminiProvider(GenericHTTPProvider):
|
|
10
|
+
"""Google Gemini provider with custom format and role conversion."""
|
|
11
|
+
|
|
12
|
+
config = ProviderConfig(
|
|
13
|
+
name="Gemini",
|
|
14
|
+
api_key_env="GEMINI_API_KEY",
|
|
15
|
+
base_url="https://generativelanguage.googleapis.com/v1beta",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
def _get_api_url(self, model: str | None = None) -> str:
|
|
19
|
+
"""Build Gemini URL with model in path."""
|
|
20
|
+
if model is None:
|
|
21
|
+
return super()._get_api_url(model)
|
|
22
|
+
return f"{self.config.base_url}/models/{model}:generateContent"
|
|
23
|
+
|
|
24
|
+
def _build_headers(self) -> dict[str, str]:
|
|
25
|
+
"""Build headers with Google API key."""
|
|
26
|
+
headers = super()._build_headers()
|
|
27
|
+
# Remove any Authorization header
|
|
28
|
+
if "Authorization" in headers:
|
|
29
|
+
del headers["Authorization"]
|
|
30
|
+
headers["x-goog-api-key"] = self.api_key
|
|
31
|
+
return headers
|
|
32
|
+
|
|
33
|
+
def _build_request_body(
|
|
34
|
+
self, messages: list[dict[str, Any]], temperature: float, max_tokens: int, model: str, **kwargs: Any
|
|
35
|
+
) -> dict[str, Any]:
|
|
36
|
+
"""Build Gemini-format request with role conversion and system instruction extraction."""
|
|
37
|
+
contents: list[dict[str, Any]] = []
|
|
38
|
+
system_instruction_parts: list[dict[str, str]] = []
|
|
39
|
+
|
|
40
|
+
for msg in messages:
|
|
41
|
+
role = msg.get("role")
|
|
42
|
+
content_value = msg.get("content")
|
|
43
|
+
content = "" if content_value is None else str(content_value)
|
|
44
|
+
|
|
45
|
+
if role == "system":
|
|
46
|
+
if content.strip():
|
|
47
|
+
system_instruction_parts.append({"text": content})
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
if role == "assistant":
|
|
51
|
+
gemini_role = "model"
|
|
52
|
+
elif role == "user":
|
|
53
|
+
gemini_role = "user"
|
|
54
|
+
else:
|
|
55
|
+
raise AIError.model_error(f"Unsupported message role for Gemini API: {role}")
|
|
56
|
+
|
|
57
|
+
contents.append({"role": gemini_role, "parts": [{"text": content}]})
|
|
58
|
+
|
|
59
|
+
body: dict[str, Any] = {
|
|
60
|
+
"contents": contents,
|
|
61
|
+
"generationConfig": {"temperature": temperature, "maxOutputTokens": max_tokens},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if system_instruction_parts:
|
|
65
|
+
body["systemInstruction"] = {"role": "system", "parts": system_instruction_parts}
|
|
66
|
+
|
|
67
|
+
return body
|
|
68
|
+
|
|
69
|
+
def _parse_response(self, response: dict[str, Any]) -> str:
|
|
70
|
+
"""Parse Gemini response format: candidates[0].content.parts[0].text."""
|
|
71
|
+
candidates = response.get("candidates")
|
|
68
72
|
if not candidates:
|
|
69
73
|
raise AIError.model_error("Gemini API response missing candidates")
|
|
70
74
|
|
|
@@ -83,15 +87,4 @@ def call_gemini_api(model: str, messages: list[dict[str, Any]], temperature: flo
|
|
|
83
87
|
if content_text is None:
|
|
84
88
|
raise AIError.model_error("Gemini API response missing text content")
|
|
85
89
|
|
|
86
|
-
logger.debug("Gemini API response received successfully")
|
|
87
90
|
return content_text
|
|
88
|
-
except AIError:
|
|
89
|
-
raise
|
|
90
|
-
except httpx.HTTPStatusError as e:
|
|
91
|
-
if e.response.status_code == 429:
|
|
92
|
-
raise AIError.rate_limit_error(f"Gemini API rate limit exceeded: {e.response.text}") from e
|
|
93
|
-
raise AIError.model_error(f"Gemini API error: {e.response.status_code} - {e.response.text}") from e
|
|
94
|
-
except httpx.TimeoutException as e:
|
|
95
|
-
raise AIError.timeout_error(f"Gemini API request timed out: {str(e)}") from e
|
|
96
|
-
except Exception as e:
|
|
97
|
-
raise AIError.model_error(f"Error calling Gemini API: {str(e)}") from e
|
gac/providers/groq.py
CHANGED
|
@@ -1,67 +1,15 @@
|
|
|
1
1
|
"""Groq API provider for gac."""
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import os
|
|
3
|
+
from gac.providers.base import OpenAICompatibleProvider, ProviderConfig
|
|
5
4
|
|
|
6
|
-
import httpx
|
|
7
5
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
class GroqProvider(OpenAICompatibleProvider):
|
|
7
|
+
config = ProviderConfig(
|
|
8
|
+
name="Groq",
|
|
9
|
+
api_key_env="GROQ_API_KEY",
|
|
10
|
+
base_url="https://api.groq.com/openai/v1",
|
|
11
|
+
)
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def call_groq_api(model: str, messages: list[dict], temperature: float, max_tokens: int) -> str:
|
|
16
|
-
"""Call Groq API directly."""
|
|
17
|
-
api_key = os.getenv("GROQ_API_KEY")
|
|
18
|
-
if not api_key:
|
|
19
|
-
raise AIError.authentication_error("GROQ_API_KEY not found in environment variables")
|
|
20
|
-
|
|
21
|
-
url = "https://api.groq.com/openai/v1/chat/completions"
|
|
22
|
-
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
|
23
|
-
|
|
24
|
-
data = {"model": model, "messages": messages, "temperature": temperature, "max_tokens": max_tokens}
|
|
25
|
-
|
|
26
|
-
try:
|
|
27
|
-
response = httpx.post(
|
|
28
|
-
url, headers=headers, json=data, timeout=ProviderDefaults.HTTP_TIMEOUT, verify=get_ssl_verify()
|
|
29
|
-
)
|
|
30
|
-
response.raise_for_status()
|
|
31
|
-
response_data = response.json()
|
|
32
|
-
|
|
33
|
-
# Debug logging to understand response structure
|
|
34
|
-
logger.debug(f"Groq API response: {response_data}")
|
|
35
|
-
|
|
36
|
-
# Handle different response formats
|
|
37
|
-
if "choices" in response_data and len(response_data["choices"]) > 0:
|
|
38
|
-
choice = response_data["choices"][0]
|
|
39
|
-
if "message" in choice and "content" in choice["message"]:
|
|
40
|
-
content = choice["message"]["content"]
|
|
41
|
-
logger.debug(f"Found content in message.content: {repr(content)}")
|
|
42
|
-
if content is None:
|
|
43
|
-
raise AIError.model_error("Groq API returned null content")
|
|
44
|
-
if content == "":
|
|
45
|
-
raise AIError.model_error("Groq API returned empty content")
|
|
46
|
-
return content
|
|
47
|
-
elif "text" in choice:
|
|
48
|
-
content = choice["text"]
|
|
49
|
-
logger.debug(f"Found content in choice.text: {repr(content)}")
|
|
50
|
-
if content is None:
|
|
51
|
-
logger.warning("Groq API returned None content in choice.text")
|
|
52
|
-
return ""
|
|
53
|
-
return content
|
|
54
|
-
else:
|
|
55
|
-
logger.warning(f"Unexpected choice structure: {choice}")
|
|
56
|
-
|
|
57
|
-
# If we can't find content in the expected places, raise an error
|
|
58
|
-
logger.error(f"Unexpected response format from Groq API: {response_data}")
|
|
59
|
-
raise AIError.model_error(f"Unexpected response format from Groq API: {response_data}")
|
|
60
|
-
except httpx.HTTPStatusError as e:
|
|
61
|
-
if e.response.status_code == 429:
|
|
62
|
-
raise AIError.rate_limit_error(f"Groq API rate limit exceeded: {e.response.text}") from e
|
|
63
|
-
raise AIError.model_error(f"Groq API error: {e.response.status_code} - {e.response.text}") from e
|
|
64
|
-
except httpx.TimeoutException as e:
|
|
65
|
-
raise AIError.timeout_error(f"Groq API request timed out: {str(e)}") from e
|
|
66
|
-
except Exception as e:
|
|
67
|
-
raise AIError.model_error(f"Error calling Groq API: {str(e)}") from e
|
|
13
|
+
def _get_api_url(self, model: str | None = None) -> str:
|
|
14
|
+
"""Get Groq API URL with /chat/completions endpoint."""
|
|
15
|
+
return f"{self.config.base_url}/chat/completions"
|
gac/providers/kimi_coding.py
CHANGED
|
@@ -1,67 +1,27 @@
|
|
|
1
1
|
"""Kimi Coding AI provider implementation."""
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import logging
|
|
5
|
-
import os
|
|
3
|
+
from typing import Any
|
|
6
4
|
|
|
7
|
-
import
|
|
5
|
+
from gac.providers.base import OpenAICompatibleProvider, ProviderConfig
|
|
8
6
|
|
|
9
|
-
from gac.constants import ProviderDefaults
|
|
10
|
-
from gac.errors import AIError
|
|
11
|
-
from gac.utils import get_ssl_verify
|
|
12
7
|
|
|
13
|
-
|
|
8
|
+
class KimiCodingProvider(OpenAICompatibleProvider):
|
|
9
|
+
"""Kimi Coding API provider using OpenAI-compatible format."""
|
|
14
10
|
|
|
11
|
+
config = ProviderConfig(
|
|
12
|
+
name="Kimi Coding",
|
|
13
|
+
api_key_env="KIMI_CODING_API_KEY",
|
|
14
|
+
base_url="https://api.kimi.com/coding/v1",
|
|
15
|
+
)
|
|
15
16
|
|
|
16
|
-
def
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if not api_key:
|
|
20
|
-
raise AIError.authentication_error("KIMI_CODING_API_KEY not found in environment variables")
|
|
17
|
+
def _get_api_url(self, model: str | None = None) -> str:
|
|
18
|
+
"""Get Kimi Coding API URL with /chat/completions endpoint."""
|
|
19
|
+
return f"{self.config.base_url}/chat/completions"
|
|
21
20
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
try:
|
|
30
|
-
response = httpx.post(
|
|
31
|
-
url, headers=headers, json=data, timeout=ProviderDefaults.HTTP_TIMEOUT, verify=get_ssl_verify()
|
|
32
|
-
)
|
|
33
|
-
response.raise_for_status()
|
|
34
|
-
response_data = response.json()
|
|
35
|
-
|
|
36
|
-
try:
|
|
37
|
-
content = response_data["choices"][0]["message"]["content"]
|
|
38
|
-
except (KeyError, IndexError, TypeError) as e:
|
|
39
|
-
logger.error(f"Unexpected response format from Kimi Coding API. Response: {json.dumps(response_data)}")
|
|
40
|
-
raise AIError.model_error(
|
|
41
|
-
f"Kimi Coding API returned unexpected format. Expected OpenAI-compatible response with "
|
|
42
|
-
f"'choices[0].message.content', but got: {type(e).__name__}. Check logs for full response structure."
|
|
43
|
-
) from e
|
|
44
|
-
|
|
45
|
-
if content is None:
|
|
46
|
-
raise AIError.model_error("Kimi Coding API returned null content")
|
|
47
|
-
if content == "":
|
|
48
|
-
raise AIError.model_error("Kimi Coding API returned empty content")
|
|
49
|
-
return content
|
|
50
|
-
except httpx.ConnectError as e:
|
|
51
|
-
raise AIError.connection_error(f"Kimi Coding API connection failed: {str(e)}") from e
|
|
52
|
-
except httpx.HTTPStatusError as e:
|
|
53
|
-
status_code = e.response.status_code
|
|
54
|
-
error_text = e.response.text
|
|
55
|
-
|
|
56
|
-
if status_code == 401:
|
|
57
|
-
raise AIError.authentication_error(f"Kimi Coding API authentication failed: {error_text}") from e
|
|
58
|
-
elif status_code == 429:
|
|
59
|
-
raise AIError.rate_limit_error(f"Kimi Coding API rate limit exceeded: {error_text}") from e
|
|
60
|
-
else:
|
|
61
|
-
raise AIError.model_error(f"Kimi Coding API error: {status_code} - {error_text}") from e
|
|
62
|
-
except httpx.TimeoutException as e:
|
|
63
|
-
raise AIError.timeout_error(f"Kimi Coding API request timed out: {str(e)}") from e
|
|
64
|
-
except AIError:
|
|
65
|
-
raise
|
|
66
|
-
except Exception as e:
|
|
67
|
-
raise AIError.model_error(f"Error calling Kimi Coding API: {str(e)}") from e
|
|
21
|
+
def _build_request_body(
|
|
22
|
+
self, messages: list[dict[str, Any]], temperature: float, max_tokens: int, model: str, **kwargs: Any
|
|
23
|
+
) -> dict[str, Any]:
|
|
24
|
+
"""Build request body with max_completion_tokens instead of max_tokens."""
|
|
25
|
+
data = super()._build_request_body(messages, temperature, max_tokens, model, **kwargs)
|
|
26
|
+
data["max_completion_tokens"] = data.pop("max_tokens")
|
|
27
|
+
return data
|
gac/providers/lmstudio.py
CHANGED
|
@@ -1,70 +1,80 @@
|
|
|
1
|
-
"""LM Studio
|
|
1
|
+
"""LM Studio API provider for gac."""
|
|
2
2
|
|
|
3
|
-
import logging
|
|
4
3
|
import os
|
|
5
4
|
from typing import Any
|
|
6
5
|
|
|
7
|
-
import
|
|
6
|
+
from gac.providers.base import OpenAICompatibleProvider, ProviderConfig
|
|
8
7
|
|
|
9
|
-
from gac.constants import ProviderDefaults
|
|
10
|
-
from gac.errors import AIError
|
|
11
|
-
from gac.utils import get_ssl_verify
|
|
12
8
|
|
|
13
|
-
|
|
9
|
+
class LMStudioProvider(OpenAICompatibleProvider):
|
|
10
|
+
"""LM Studio provider for local OpenAI-compatible models."""
|
|
14
11
|
|
|
12
|
+
config = ProviderConfig(
|
|
13
|
+
name="LM Studio",
|
|
14
|
+
api_key_env="LMSTUDIO_API_KEY",
|
|
15
|
+
base_url="http://localhost:1234/v1",
|
|
16
|
+
)
|
|
15
17
|
|
|
16
|
-
def
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
def __init__(self, config: ProviderConfig):
|
|
19
|
+
"""Initialize with configurable URL from environment."""
|
|
20
|
+
super().__init__(config)
|
|
21
|
+
# Allow URL override via environment variable
|
|
22
|
+
api_url = os.getenv("LMSTUDIO_API_URL", "http://localhost:1234")
|
|
23
|
+
api_url = api_url.rstrip("/")
|
|
24
|
+
self.config.base_url = f"{api_url}/v1"
|
|
20
25
|
|
|
21
|
-
|
|
26
|
+
def _get_api_key(self) -> str:
|
|
27
|
+
"""Get optional API key for LM Studio."""
|
|
28
|
+
api_key = os.getenv(self.config.api_key_env)
|
|
29
|
+
if not api_key:
|
|
30
|
+
return "" # Optional API key
|
|
31
|
+
return api_key
|
|
22
32
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
33
|
+
def _build_headers(self) -> dict[str, str]:
|
|
34
|
+
"""Build headers with optional API key."""
|
|
35
|
+
headers = super()._build_headers()
|
|
36
|
+
# Remove Bearer token from parent if it was added
|
|
37
|
+
if "Authorization" in headers:
|
|
38
|
+
del headers["Authorization"]
|
|
39
|
+
# Add optional Authorization
|
|
40
|
+
api_key = os.getenv("LMSTUDIO_API_KEY")
|
|
41
|
+
if api_key:
|
|
42
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
43
|
+
return headers
|
|
27
44
|
|
|
28
|
-
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"temperature": temperature,
|
|
32
|
-
"max_tokens": max_tokens,
|
|
33
|
-
"stream": False,
|
|
34
|
-
}
|
|
45
|
+
def _get_api_url(self, model: str | None = None) -> str:
|
|
46
|
+
"""Get LM Studio API URL with /chat/completions endpoint."""
|
|
47
|
+
return f"{self.config.base_url}/chat/completions"
|
|
35
48
|
|
|
36
|
-
|
|
49
|
+
def _build_request_body(
|
|
50
|
+
self, messages: list[dict[str, Any]], temperature: float, max_tokens: int, model: str, **kwargs: Any
|
|
51
|
+
) -> dict[str, Any]:
|
|
52
|
+
"""Build OpenAI-compatible request body with stream disabled."""
|
|
53
|
+
body = super()._build_request_body(messages, temperature, max_tokens, model, **kwargs)
|
|
54
|
+
body["stream"] = False
|
|
55
|
+
return body
|
|
37
56
|
|
|
38
|
-
|
|
39
|
-
response
|
|
40
|
-
|
|
41
|
-
)
|
|
42
|
-
response.raise_for_status()
|
|
43
|
-
response_data = response.json()
|
|
44
|
-
choices = response_data.get("choices") or []
|
|
45
|
-
if not choices:
|
|
46
|
-
raise AIError.model_error("LM Studio API response missing choices")
|
|
57
|
+
def _parse_response(self, response: dict[str, Any]) -> str:
|
|
58
|
+
"""Parse OpenAI-compatible response with text field fallback."""
|
|
59
|
+
from gac.errors import AIError
|
|
47
60
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
61
|
+
choices = response.get("choices")
|
|
62
|
+
if not choices or not isinstance(choices, list):
|
|
63
|
+
raise AIError.model_error("Invalid response: missing choices")
|
|
64
|
+
|
|
65
|
+
# First try message.content (standard OpenAI format)
|
|
66
|
+
choice = choices[0]
|
|
67
|
+
content = choice.get("message", {}).get("content")
|
|
68
|
+
if content is not None:
|
|
69
|
+
if content == "":
|
|
70
|
+
raise AIError.model_error("Invalid response: empty content")
|
|
52
71
|
return content
|
|
53
72
|
|
|
54
|
-
#
|
|
55
|
-
content =
|
|
56
|
-
if content:
|
|
57
|
-
|
|
73
|
+
# Fallback to text field (some OpenAI-compatible servers use this)
|
|
74
|
+
content = choice.get("text")
|
|
75
|
+
if content is not None:
|
|
76
|
+
if content == "":
|
|
77
|
+
raise AIError.model_error("Invalid response: empty content")
|
|
58
78
|
return content
|
|
59
79
|
|
|
60
|
-
raise AIError.model_error("
|
|
61
|
-
except httpx.ConnectError as e:
|
|
62
|
-
raise AIError.connection_error(f"LM Studio connection failed: {str(e)}") from e
|
|
63
|
-
except httpx.HTTPStatusError as e:
|
|
64
|
-
if e.response.status_code == 429:
|
|
65
|
-
raise AIError.rate_limit_error(f"LM Studio API rate limit exceeded: {e.response.text}") from e
|
|
66
|
-
raise AIError.model_error(f"LM Studio API error: {e.response.status_code} - {e.response.text}") from e
|
|
67
|
-
except httpx.TimeoutException as e:
|
|
68
|
-
raise AIError.timeout_error(f"LM Studio API request timed out: {str(e)}") from e
|
|
69
|
-
except Exception as e:
|
|
70
|
-
raise AIError.model_error(f"Error calling LM Studio API: {str(e)}") from e
|
|
80
|
+
raise AIError.model_error("Invalid response: missing content")
|