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.
Files changed (76) hide show
  1. gac/__init__.py +4 -6
  2. gac/__version__.py +1 -1
  3. gac/ai_utils.py +18 -49
  4. gac/cli.py +14 -10
  5. gac/commit_executor.py +59 -0
  6. gac/config.py +28 -3
  7. gac/config_cli.py +19 -7
  8. gac/constants/__init__.py +34 -0
  9. gac/constants/commit.py +63 -0
  10. gac/constants/defaults.py +40 -0
  11. gac/constants/file_patterns.py +110 -0
  12. gac/constants/languages.py +119 -0
  13. gac/diff_cli.py +0 -22
  14. gac/errors.py +8 -2
  15. gac/git.py +6 -6
  16. gac/git_state_validator.py +193 -0
  17. gac/grouped_commit_workflow.py +458 -0
  18. gac/init_cli.py +2 -1
  19. gac/interactive_mode.py +179 -0
  20. gac/language_cli.py +0 -1
  21. gac/main.py +222 -959
  22. gac/model_cli.py +2 -1
  23. gac/model_identifier.py +70 -0
  24. gac/oauth/claude_code.py +2 -2
  25. gac/oauth/qwen_oauth.py +4 -0
  26. gac/oauth/token_store.py +2 -2
  27. gac/oauth_retry.py +161 -0
  28. gac/postprocess.py +155 -0
  29. gac/prompt.py +20 -490
  30. gac/prompt_builder.py +88 -0
  31. gac/providers/README.md +437 -0
  32. gac/providers/__init__.py +70 -81
  33. gac/providers/anthropic.py +12 -56
  34. gac/providers/azure_openai.py +48 -92
  35. gac/providers/base.py +329 -0
  36. gac/providers/cerebras.py +10 -43
  37. gac/providers/chutes.py +16 -72
  38. gac/providers/claude_code.py +64 -97
  39. gac/providers/custom_anthropic.py +51 -85
  40. gac/providers/custom_openai.py +29 -87
  41. gac/providers/deepseek.py +10 -43
  42. gac/providers/error_handler.py +139 -0
  43. gac/providers/fireworks.py +10 -43
  44. gac/providers/gemini.py +66 -73
  45. gac/providers/groq.py +10 -62
  46. gac/providers/kimi_coding.py +19 -59
  47. gac/providers/lmstudio.py +62 -52
  48. gac/providers/minimax.py +10 -43
  49. gac/providers/mistral.py +10 -43
  50. gac/providers/moonshot.py +10 -43
  51. gac/providers/ollama.py +54 -41
  52. gac/providers/openai.py +30 -46
  53. gac/providers/openrouter.py +15 -62
  54. gac/providers/protocol.py +71 -0
  55. gac/providers/qwen.py +55 -67
  56. gac/providers/registry.py +58 -0
  57. gac/providers/replicate.py +137 -91
  58. gac/providers/streamlake.py +26 -56
  59. gac/providers/synthetic.py +35 -47
  60. gac/providers/together.py +10 -43
  61. gac/providers/zai.py +21 -59
  62. gac/py.typed +0 -0
  63. gac/security.py +1 -1
  64. gac/templates/__init__.py +1 -0
  65. gac/templates/question_generation.txt +60 -0
  66. gac/templates/system_prompt.txt +224 -0
  67. gac/templates/user_prompt.txt +28 -0
  68. gac/utils.py +6 -5
  69. gac/workflow_context.py +162 -0
  70. {gac-3.8.1.dist-info → gac-3.10.10.dist-info}/METADATA +1 -1
  71. gac-3.10.10.dist-info/RECORD +79 -0
  72. gac/constants.py +0 -328
  73. gac-3.8.1.dist-info/RECORD +0 -56
  74. {gac-3.8.1.dist-info → gac-3.10.10.dist-info}/WHEEL +0 -0
  75. {gac-3.8.1.dist-info → gac-3.10.10.dist-info}/entry_points.txt +0 -0
  76. {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
+ ]
@@ -1,48 +1,15 @@
1
1
  """Fireworks AI API provider for gac."""
2
2
 
3
- import logging
4
- import os
3
+ from gac.providers.base import OpenAICompatibleProvider, ProviderConfig
5
4
 
6
- import httpx
7
5
 
8
- from gac.constants import ProviderDefaults
9
- from gac.errors import AIError
10
- from gac.utils import get_ssl_verify
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
- logger = logging.getLogger(__name__)
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.utils import get_ssl_verify
12
-
13
- logger = logging.getLogger(__name__)
14
-
15
-
16
- def call_gemini_api(model: str, messages: list[dict[str, Any]], temperature: float, max_tokens: int) -> str:
17
- """Call Gemini API directly."""
18
- api_key = os.getenv("GEMINI_API_KEY")
19
- if not api_key:
20
- raise AIError.authentication_error("GEMINI_API_KEY not found in environment variables")
21
-
22
- url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
23
-
24
- # Build Gemini request payload, converting roles to supported values.
25
- contents: list[dict[str, Any]] = []
26
- system_instruction_parts: list[dict[str, str]] = []
27
-
28
- for msg in messages:
29
- role = msg.get("role")
30
- content_value = msg.get("content")
31
- content = "" if content_value is None else str(content_value)
32
-
33
- if role == "system":
34
- if content.strip():
35
- system_instruction_parts.append({"text": content})
36
- continue
37
-
38
- if role == "assistant":
39
- gemini_role = "model"
40
- elif role == "user":
41
- gemini_role = "user"
42
- else:
43
- raise AIError.model_error(f"Unsupported message role for Gemini API: {role}")
44
-
45
- contents.append({"role": gemini_role, "parts": [{"text": content}]})
46
-
47
- payload: dict[str, Any] = {
48
- "contents": contents,
49
- "generationConfig": {"temperature": temperature, "maxOutputTokens": max_tokens},
50
- }
51
-
52
- if system_instruction_parts:
53
- payload["systemInstruction"] = {"role": "system", "parts": system_instruction_parts}
54
-
55
- headers = {"x-goog-api-key": api_key, "Content-Type": "application/json"}
56
-
57
- logger.debug(f"Calling Gemini API with model={model}")
58
-
59
- try:
60
- response = httpx.post(
61
- url, headers=headers, json=payload, timeout=ProviderDefaults.HTTP_TIMEOUT, verify=get_ssl_verify()
62
- )
63
- response.raise_for_status()
64
- response_data = response.json()
65
-
66
- # Check for candidates and proper response structure
67
- candidates = response_data.get("candidates")
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 logging
4
- import os
3
+ from gac.providers.base import OpenAICompatibleProvider, ProviderConfig
5
4
 
6
- import httpx
7
5
 
8
- from gac.constants import ProviderDefaults
9
- from gac.errors import AIError
10
- from gac.utils import get_ssl_verify
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
- logger = logging.getLogger(__name__)
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"
@@ -1,67 +1,27 @@
1
1
  """Kimi Coding AI provider implementation."""
2
2
 
3
- import json
4
- import logging
5
- import os
3
+ from typing import Any
6
4
 
7
- import httpx
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
- logger = logging.getLogger(__name__)
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 call_kimi_coding_api(model: str, messages: list[dict], temperature: float, max_tokens: int) -> str:
17
- """Call Kimi Coding API using OpenAI-compatible endpoint."""
18
- api_key = os.getenv("KIMI_CODING_API_KEY")
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
- base_url = "https://api.kimi.com/coding/v1"
23
- url = f"{base_url}/chat/completions"
24
- headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
25
-
26
- # Use standard OpenAI format - no message conversion needed
27
- data = {"model": model, "messages": messages, "temperature": temperature, "max_completion_tokens": max_tokens}
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 AI provider implementation."""
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 httpx
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
- logger = logging.getLogger(__name__)
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 call_lmstudio_api(model: str, messages: list[dict[str, Any]], temperature: float, max_tokens: int) -> str:
17
- """Call LM Studio's OpenAI-compatible API."""
18
- api_url = os.getenv("LMSTUDIO_API_URL", "http://localhost:1234")
19
- api_url = api_url.rstrip("/")
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
- url = f"{api_url}/v1/chat/completions"
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
- headers = {"Content-Type": "application/json"}
24
- api_key = os.getenv("LMSTUDIO_API_KEY")
25
- if api_key:
26
- headers["Authorization"] = f"Bearer {api_key}"
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
- payload: dict[str, Any] = {
29
- "model": model,
30
- "messages": messages,
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
- logger.debug(f"Calling LM Studio API with model={model}")
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
- try:
39
- response = httpx.post(
40
- url, headers=headers, json=payload, timeout=ProviderDefaults.HTTP_TIMEOUT, verify=get_ssl_verify()
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
- message = choices[0].get("message") or {}
49
- content = message.get("content")
50
- if content:
51
- logger.debug("LM Studio API response received successfully")
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
- # Some OpenAI-compatible servers return text field directly
55
- content = choices[0].get("text")
56
- if content:
57
- logger.debug("LM Studio API response received successfully")
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("LM Studio API response missing content")
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")