kubrick-cli 0.1.2__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.
@@ -0,0 +1,224 @@
1
+ """Anthropic provider adapter."""
2
+
3
+ import http.client
4
+ import json
5
+ import ssl
6
+ from typing import Dict, Iterator, List
7
+
8
+ from .base import ProviderAdapter, ProviderMetadata
9
+
10
+
11
+ class AnthropicProvider(ProviderAdapter):
12
+ """Provider adapter for Anthropic API."""
13
+
14
+ METADATA = ProviderMetadata(
15
+ name="anthropic",
16
+ display_name="Anthropic",
17
+ description="Anthropic API (Claude Sonnet 4.5, etc.)",
18
+ config_fields=[
19
+ {
20
+ "key": "anthropic_api_key",
21
+ "label": "Anthropic API key",
22
+ "type": "password",
23
+ "help_text": "Get your API key from: https://console.anthropic.com/settings/keys",
24
+ },
25
+ {
26
+ "key": "anthropic_model",
27
+ "label": "Model name",
28
+ "type": "text",
29
+ "default": "claude-sonnet-4-5-20250929",
30
+ },
31
+ ],
32
+ )
33
+
34
+ def __init__(
35
+ self,
36
+ anthropic_api_key: str,
37
+ anthropic_model: str = "claude-sonnet-4-5-20250929",
38
+ ):
39
+ """
40
+ Initialize Anthropic provider.
41
+
42
+ Args:
43
+ anthropic_api_key: Anthropic API key
44
+ anthropic_model: Model name (default: claude-3-5-sonnet-20241022)
45
+ """
46
+ self.api_key = anthropic_api_key
47
+ self._model_name = anthropic_model
48
+ self.base_url = "api.anthropic.com"
49
+ self.timeout = 600
50
+ self.api_version = "2023-06-01"
51
+
52
+ def generate_streaming(
53
+ self, messages: List[Dict[str, str]], stream_options: Dict = None
54
+ ) -> Iterator[str]:
55
+ """
56
+ Generate streaming response from Anthropic.
57
+
58
+ Args:
59
+ messages: List of message dicts with 'role' and 'content'
60
+ stream_options: Optional streaming parameters
61
+
62
+ Yields:
63
+ Text chunks as they arrive
64
+ """
65
+ # Anthropic requires system message to be separate
66
+ system_message = ""
67
+ conversation_messages = []
68
+
69
+ for msg in messages:
70
+ if msg["role"] == "system":
71
+ system_message = msg["content"]
72
+ else:
73
+ conversation_messages.append(msg)
74
+
75
+ # Build request payload
76
+ payload = {
77
+ "model": self._model_name,
78
+ "messages": conversation_messages,
79
+ "max_tokens": 4096,
80
+ "stream": True,
81
+ }
82
+
83
+ if system_message:
84
+ payload["system"] = system_message
85
+
86
+ # Add optional parameters
87
+ if stream_options:
88
+ if "temperature" in stream_options:
89
+ payload["temperature"] = stream_options["temperature"]
90
+ if "max_tokens" in stream_options:
91
+ payload["max_tokens"] = stream_options["max_tokens"]
92
+
93
+ headers = {
94
+ "Content-Type": "application/json",
95
+ "x-api-key": self.api_key,
96
+ "anthropic-version": self.api_version,
97
+ }
98
+
99
+ body = json.dumps(payload).encode("utf-8")
100
+
101
+ # Create HTTPS connection
102
+ context = ssl.create_default_context()
103
+ conn = http.client.HTTPSConnection(
104
+ self.base_url, 443, timeout=self.timeout, context=context
105
+ )
106
+
107
+ try:
108
+ conn.request("POST", "/v1/messages", body=body, headers=headers)
109
+ response = conn.getresponse()
110
+
111
+ if response.status != 200:
112
+ error_body = response.read().decode("utf-8")
113
+ raise Exception(f"Anthropic API error {response.status}: {error_body}")
114
+
115
+ # Read streaming response
116
+ buffer = ""
117
+ while True:
118
+ chunk = response.read(1024)
119
+ if not chunk:
120
+ break
121
+
122
+ if isinstance(chunk, bytes):
123
+ chunk = chunk.decode("utf-8")
124
+
125
+ buffer += chunk
126
+
127
+ # Process complete lines
128
+ while "\n" in buffer:
129
+ line, buffer = buffer.split("\n", 1)
130
+ line = line.strip()
131
+
132
+ if not line:
133
+ continue
134
+
135
+ # Anthropic SSE format
136
+ if line.startswith("data: "):
137
+ line = line[6:]
138
+
139
+ if line.startswith("event: "):
140
+ # Skip event lines
141
+ continue
142
+
143
+ try:
144
+ data = json.loads(line)
145
+
146
+ # Handle different event types
147
+ event_type = data.get("type")
148
+
149
+ if event_type == "content_block_delta":
150
+ # Extract text delta
151
+ delta = data.get("delta", {})
152
+ if delta.get("type") == "text_delta":
153
+ text = delta.get("text", "")
154
+ if text:
155
+ yield text
156
+
157
+ elif event_type == "message_stop":
158
+ # End of stream
159
+ return
160
+
161
+ except json.JSONDecodeError:
162
+ # Skip malformed JSON
163
+ continue
164
+
165
+ finally:
166
+ conn.close()
167
+
168
+ def generate(
169
+ self, messages: List[Dict[str, str]], stream_options: Dict = None
170
+ ) -> str:
171
+ """
172
+ Generate non-streaming response from Anthropic.
173
+
174
+ Args:
175
+ messages: List of message dicts with 'role' and 'content'
176
+ stream_options: Optional parameters
177
+
178
+ Returns:
179
+ Complete response text
180
+ """
181
+ chunks = []
182
+ for chunk in self.generate_streaming(messages, stream_options):
183
+ chunks.append(chunk)
184
+ return "".join(chunks)
185
+
186
+ def is_healthy(self) -> bool:
187
+ """
188
+ Check if Anthropic API is accessible.
189
+
190
+ Returns:
191
+ True if healthy, False otherwise
192
+ """
193
+ try:
194
+ # Simple health check - just verify we can connect
195
+ context = ssl.create_default_context()
196
+ conn = http.client.HTTPSConnection(
197
+ self.base_url, 443, timeout=10, context=context
198
+ )
199
+ headers = {
200
+ "x-api-key": self.api_key,
201
+ "anthropic-version": self.api_version,
202
+ }
203
+ # Try to make a minimal request
204
+ conn.request("GET", "/v1/models", headers=headers)
205
+ response = conn.getresponse()
206
+ conn.close()
207
+ # Anthropic might not have a /v1/models endpoint, so we check for 404 as well
208
+ return response.status in (200, 404)
209
+ except Exception:
210
+ return False
211
+
212
+ @property
213
+ def provider_name(self) -> str:
214
+ """Get provider name."""
215
+ return "anthropic"
216
+
217
+ @property
218
+ def model_name(self) -> str:
219
+ """Get model name."""
220
+ return self._model_name
221
+
222
+ def set_model(self, model_name: str):
223
+ """Set model name dynamically."""
224
+ self._model_name = model_name
@@ -0,0 +1,137 @@
1
+ """Base provider adapter interface."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Dict, Iterator, List, Optional
5
+
6
+
7
+ class ProviderMetadata:
8
+ """
9
+ Metadata for a provider that describes its configuration needs.
10
+
11
+ This allows providers to be self-describing and enables automatic
12
+ setup wizard generation and provider discovery.
13
+ """
14
+
15
+ def __init__(
16
+ self,
17
+ name: str,
18
+ display_name: str,
19
+ description: str,
20
+ config_fields: List[Dict[str, str]],
21
+ ):
22
+ """
23
+ Initialize provider metadata.
24
+
25
+ Args:
26
+ name: Internal provider name (e.g., "triton", "openai")
27
+ display_name: Human-readable name (e.g., "Triton", "OpenAI")
28
+ description: Brief description shown in setup wizard
29
+ config_fields: List of configuration field definitions
30
+ Each field should be a dict with:
31
+ - key: Config key name (e.g., "openai_api_key")
32
+ - label: Display label (e.g., "OpenAI API key")
33
+ - type: Field type ("text", "password", "url")
34
+ - default: Default value (optional)
35
+ - help_text: Help text or URL (optional)
36
+ """
37
+ self.name = name
38
+ self.display_name = display_name
39
+ self.description = description
40
+ self.config_fields = config_fields
41
+
42
+
43
+ class ProviderAdapter(ABC):
44
+ """
45
+ Abstract base class for LLM provider adapters.
46
+
47
+ All provider implementations must inherit from this class and implement
48
+ the required methods for streaming and non-streaming generation.
49
+
50
+ To create a truly plug-and-play provider:
51
+ 1. Inherit from ProviderAdapter
52
+ 2. Define METADATA as a class attribute with ProviderMetadata
53
+ 3. Implement all abstract methods
54
+ 4. Place the file in kubrick_cli/providers/ directory
55
+
56
+ The provider will be automatically discovered and available in the setup wizard.
57
+ """
58
+
59
+ # Providers should override this with their metadata
60
+ METADATA: Optional[ProviderMetadata] = None
61
+
62
+ @abstractmethod
63
+ def generate_streaming(
64
+ self, messages: List[Dict[str, str]], stream_options: Dict = None
65
+ ) -> Iterator[str]:
66
+ """
67
+ Generate streaming response from LLM.
68
+
69
+ Args:
70
+ messages: List of message dicts with 'role' and 'content'
71
+ stream_options: Optional streaming parameters
72
+
73
+ Yields:
74
+ Text chunks as they arrive
75
+ """
76
+ pass
77
+
78
+ @abstractmethod
79
+ def generate(
80
+ self, messages: List[Dict[str, str]], stream_options: Dict = None
81
+ ) -> str:
82
+ """
83
+ Generate non-streaming response from LLM.
84
+
85
+ Args:
86
+ messages: List of message dicts with 'role' and 'content'
87
+ stream_options: Optional parameters
88
+
89
+ Returns:
90
+ Complete response text
91
+ """
92
+ pass
93
+
94
+ @abstractmethod
95
+ def is_healthy(self) -> bool:
96
+ """
97
+ Check if the provider is healthy and accessible.
98
+
99
+ Returns:
100
+ True if healthy, False otherwise
101
+ """
102
+ pass
103
+
104
+ @property
105
+ @abstractmethod
106
+ def provider_name(self) -> str:
107
+ """
108
+ Get the name of this provider.
109
+
110
+ Returns:
111
+ Provider name (e.g., "triton", "openai", "anthropic")
112
+ """
113
+ pass
114
+
115
+ @property
116
+ @abstractmethod
117
+ def model_name(self) -> str:
118
+ """
119
+ Get the model name being used.
120
+
121
+ Returns:
122
+ Model name/identifier
123
+ """
124
+ pass
125
+
126
+ def set_model(self, model_name: str):
127
+ """
128
+ Set the model name dynamically (optional, for model switching).
129
+
130
+ Args:
131
+ model_name: New model name
132
+
133
+ Note:
134
+ Default implementation does nothing. Override in subclasses
135
+ that support model switching.
136
+ """
137
+ pass
@@ -0,0 +1,183 @@
1
+ """Provider factory with automatic provider discovery."""
2
+
3
+ import importlib
4
+ import inspect
5
+ import pkgutil
6
+ from pathlib import Path
7
+ from typing import Dict, List, Type
8
+
9
+ from .base import ProviderAdapter, ProviderMetadata
10
+
11
+
12
+ class ProviderFactory:
13
+ """
14
+ Factory for creating provider adapter instances with automatic discovery.
15
+
16
+ This factory automatically discovers all provider classes in the providers
17
+ directory that inherit from ProviderAdapter and have METADATA defined.
18
+ """
19
+
20
+ _provider_registry: Dict[str, Type[ProviderAdapter]] = {}
21
+ _discovered = False
22
+
23
+ @classmethod
24
+ def _discover_providers(cls):
25
+ """
26
+ Discover all provider classes in the providers directory.
27
+
28
+ This method scans the providers package for classes that:
29
+ 1. Inherit from ProviderAdapter
30
+ 2. Are not the base ProviderAdapter class itself
31
+ 3. Have a METADATA attribute defined
32
+ """
33
+ if cls._discovered:
34
+ return
35
+
36
+ # Get the directory containing provider modules
37
+ providers_dir = Path(__file__).parent
38
+
39
+ # Scan all Python files in the providers directory
40
+ for module_info in pkgutil.iter_modules([str(providers_dir)]):
41
+ module_name = module_info.name
42
+
43
+ # Skip special files
44
+ if module_name in ("base", "factory", "__init__"):
45
+ continue
46
+
47
+ try:
48
+ # Import the module
49
+ module = importlib.import_module(
50
+ f"kubrick_cli.providers.{module_name}"
51
+ )
52
+
53
+ # Find all classes in the module
54
+ for name, obj in inspect.getmembers(module, inspect.isclass):
55
+ # Check if it's a ProviderAdapter subclass (but not the base class)
56
+ if (
57
+ issubclass(obj, ProviderAdapter)
58
+ and obj is not ProviderAdapter
59
+ and hasattr(obj, "METADATA")
60
+ and obj.METADATA is not None
61
+ ):
62
+ # Register the provider by its metadata name
63
+ provider_name = obj.METADATA.name.lower()
64
+ cls._provider_registry[provider_name] = obj
65
+
66
+ except Exception as e:
67
+ # Log the error but continue discovering other providers
68
+ print(
69
+ f"Warning: Failed to load provider from {module_name}: {e}"
70
+ )
71
+ continue
72
+
73
+ cls._discovered = True
74
+
75
+ @classmethod
76
+ def create_provider(cls, config: Dict) -> ProviderAdapter:
77
+ """
78
+ Create a provider instance based on configuration.
79
+
80
+ Args:
81
+ config: Configuration dictionary containing provider settings
82
+
83
+ Returns:
84
+ ProviderAdapter instance
85
+
86
+ Raises:
87
+ ValueError: If provider is invalid or required credentials are missing
88
+ """
89
+ # Ensure providers are discovered
90
+ cls._discover_providers()
91
+
92
+ provider_name = config.get("provider", "triton").lower()
93
+
94
+ if provider_name not in cls._provider_registry:
95
+ available = ", ".join(cls._provider_registry.keys())
96
+ raise ValueError(
97
+ f"Unknown provider: {provider_name}. "
98
+ f"Available providers: {available}"
99
+ )
100
+
101
+ # Get the provider class
102
+ provider_class = cls._provider_registry[provider_name]
103
+ metadata = provider_class.METADATA
104
+
105
+ # Extract configuration values needed for this provider
106
+ provider_config = {}
107
+ for field in metadata.config_fields:
108
+ key = field["key"]
109
+ value = config.get(key)
110
+
111
+ # Check for required fields (no default value)
112
+ if value is None and "default" not in field:
113
+ raise ValueError(
114
+ f"{metadata.display_name} configuration missing required field: '{key}'. "
115
+ f"Please run setup wizard or add '{key}' to config."
116
+ )
117
+
118
+ # Use provided value or default
119
+ provider_config[key] = value if value is not None else field.get("default")
120
+
121
+ # Instantiate the provider
122
+ # We need to map config keys to constructor parameters
123
+ # This is done by inspecting the __init__ signature
124
+ init_signature = inspect.signature(provider_class.__init__)
125
+ init_params = {}
126
+
127
+ for param_name, param in init_signature.parameters.items():
128
+ if param_name == "self":
129
+ continue
130
+
131
+ # Try to find a matching config field
132
+ for field in metadata.config_fields:
133
+ # Match based on key name or parameter name
134
+ if field["key"] == param_name or field["key"].endswith(f"_{param_name}"):
135
+ init_params[param_name] = provider_config[field["key"]]
136
+ break
137
+
138
+ return provider_class(**init_params)
139
+
140
+ @classmethod
141
+ def list_available_providers(cls) -> List[ProviderMetadata]:
142
+ """
143
+ Get list of available providers with their metadata.
144
+
145
+ Returns:
146
+ List of ProviderMetadata objects
147
+ """
148
+ cls._discover_providers()
149
+
150
+ providers = []
151
+ for provider_class in cls._provider_registry.values():
152
+ if provider_class.METADATA:
153
+ providers.append(provider_class.METADATA)
154
+
155
+ # Sort with Triton first (default), then others alphabetically
156
+ def sort_key(p):
157
+ # Triton comes first (returns 0), others sorted alphabetically (returns name)
158
+ return (0, "") if p.name == "triton" else (1, p.name)
159
+
160
+ providers.sort(key=sort_key)
161
+ return providers
162
+
163
+ @classmethod
164
+ def get_provider_metadata(cls, provider_name: str) -> ProviderMetadata:
165
+ """
166
+ Get metadata for a specific provider.
167
+
168
+ Args:
169
+ provider_name: Name of the provider
170
+
171
+ Returns:
172
+ ProviderMetadata object
173
+
174
+ Raises:
175
+ ValueError: If provider not found
176
+ """
177
+ cls._discover_providers()
178
+
179
+ provider_name = provider_name.lower()
180
+ if provider_name not in cls._provider_registry:
181
+ raise ValueError(f"Provider not found: {provider_name}")
182
+
183
+ return cls._provider_registry[provider_name].METADATA