aisbf 0.2.2__py3-none-any.whl → 0.2.4__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 (24) hide show
  1. aisbf/config.py +57 -1
  2. aisbf/handlers.py +314 -33
  3. aisbf/providers.py +164 -9
  4. {aisbf-0.2.2.data → aisbf-0.2.4.data}/data/share/aisbf/aisbf/config.py +57 -1
  5. aisbf-0.2.4.data/data/share/aisbf/aisbf/handlers.py +664 -0
  6. {aisbf-0.2.2.data → aisbf-0.2.4.data}/data/share/aisbf/aisbf/providers.py +164 -9
  7. aisbf-0.2.4.data/data/share/aisbf/main.py +421 -0
  8. {aisbf-0.2.2.dist-info → aisbf-0.2.4.dist-info}/METADATA +1 -1
  9. aisbf-0.2.4.dist-info/RECORD +24 -0
  10. aisbf-0.2.2.data/data/share/aisbf/aisbf/handlers.py +0 -383
  11. aisbf-0.2.2.data/data/share/aisbf/main.py +0 -214
  12. aisbf-0.2.2.dist-info/RECORD +0 -24
  13. {aisbf-0.2.2.data → aisbf-0.2.4.data}/data/share/aisbf/aisbf/__init__.py +0 -0
  14. {aisbf-0.2.2.data → aisbf-0.2.4.data}/data/share/aisbf/aisbf/models.py +0 -0
  15. {aisbf-0.2.2.data → aisbf-0.2.4.data}/data/share/aisbf/aisbf.sh +0 -0
  16. {aisbf-0.2.2.data → aisbf-0.2.4.data}/data/share/aisbf/autoselect.json +0 -0
  17. {aisbf-0.2.2.data → aisbf-0.2.4.data}/data/share/aisbf/autoselect.md +0 -0
  18. {aisbf-0.2.2.data → aisbf-0.2.4.data}/data/share/aisbf/providers.json +0 -0
  19. {aisbf-0.2.2.data → aisbf-0.2.4.data}/data/share/aisbf/requirements.txt +0 -0
  20. {aisbf-0.2.2.data → aisbf-0.2.4.data}/data/share/aisbf/rotations.json +0 -0
  21. {aisbf-0.2.2.dist-info → aisbf-0.2.4.dist-info}/WHEEL +0 -0
  22. {aisbf-0.2.2.dist-info → aisbf-0.2.4.dist-info}/entry_points.txt +0 -0
  23. {aisbf-0.2.2.dist-info → aisbf-0.2.4.dist-info}/licenses/LICENSE.txt +0 -0
  24. {aisbf-0.2.2.dist-info → aisbf-0.2.4.dist-info}/top_level.txt +0 -0
aisbf/providers.py CHANGED
@@ -62,14 +62,56 @@ class BaseProviderHandler:
62
62
  self.last_request_time = time.time()
63
63
 
64
64
  def record_failure(self):
65
+ import logging
66
+ logger = logging.getLogger(__name__)
67
+
65
68
  self.error_tracking['failures'] += 1
66
69
  self.error_tracking['last_failure'] = time.time()
70
+
71
+ failure_count = self.error_tracking['failures']
72
+ logger.warning(f"=== PROVIDER FAILURE RECORDED ===")
73
+ logger.warning(f"Provider: {self.provider_id}")
74
+ logger.warning(f"Failure count: {failure_count}/3")
75
+ logger.warning(f"Last failure time: {self.error_tracking['last_failure']}")
76
+
67
77
  if self.error_tracking['failures'] >= 3:
68
78
  self.error_tracking['disabled_until'] = time.time() + 300 # 5 minutes
79
+ disabled_until_time = self.error_tracking['disabled_until']
80
+ cooldown_remaining = int(disabled_until_time - time.time())
81
+ logger.error(f"!!! PROVIDER DISABLED !!!")
82
+ logger.error(f"Provider: {self.provider_id}")
83
+ logger.error(f"Reason: 3 consecutive failures reached")
84
+ logger.error(f"Disabled until: {disabled_until_time}")
85
+ logger.error(f"Cooldown period: {cooldown_remaining} seconds (5 minutes)")
86
+ logger.error(f"Provider will be automatically re-enabled after cooldown")
87
+ else:
88
+ remaining_failures = 3 - failure_count
89
+ logger.warning(f"Provider still active. {remaining_failures} more failure(s) will disable it")
90
+ logger.warning(f"=== END FAILURE RECORDING ===")
69
91
 
70
92
  def record_success(self):
93
+ import logging
94
+ logger = logging.getLogger(__name__)
95
+
96
+ was_disabled = self.error_tracking['disabled_until'] is not None
97
+ previous_failures = self.error_tracking['failures']
98
+
71
99
  self.error_tracking['failures'] = 0
72
100
  self.error_tracking['disabled_until'] = None
101
+
102
+ logger.info(f"=== PROVIDER SUCCESS RECORDED ===")
103
+ logger.info(f"Provider: {self.provider_id}")
104
+ logger.info(f"Previous failure count: {previous_failures}")
105
+ logger.info(f"Failure count reset to: 0")
106
+
107
+ if was_disabled:
108
+ logger.info(f"!!! PROVIDER RE-ENABLED !!!")
109
+ logger.info(f"Provider: {self.provider_id}")
110
+ logger.info(f"Reason: Successful request after cooldown period")
111
+ logger.info(f"Provider is now active and available for requests")
112
+ else:
113
+ logger.info(f"Provider remains active")
114
+ logger.info(f"=== END SUCCESS RECORDING ===")
73
115
 
74
116
  class GoogleProviderHandler(BaseProviderHandler):
75
117
  def __init__(self, provider_id: str, api_key: str):
@@ -249,30 +291,122 @@ class AnthropicProviderHandler(BaseProviderHandler):
249
291
  ]
250
292
 
251
293
  class OllamaProviderHandler(BaseProviderHandler):
252
- def __init__(self, provider_id: str):
253
- super().__init__(provider_id)
254
- self.client = httpx.AsyncClient(base_url=config.providers[provider_id].endpoint)
294
+ def __init__(self, provider_id: str, api_key: Optional[str] = None):
295
+ super().__init__(provider_id, api_key)
296
+ # Increase timeout for Ollama requests (especially for cloud models)
297
+ # Using separate timeouts for connect, read, write, and pool
298
+ timeout = httpx.Timeout(
299
+ connect=60.0, # 60 seconds to establish connection
300
+ read=300.0, # 5 minutes to read response
301
+ write=60.0, # 60 seconds to write request
302
+ pool=60.0 # 60 seconds for pool acquisition
303
+ )
304
+ self.client = httpx.AsyncClient(base_url=config.providers[provider_id].endpoint, timeout=timeout)
255
305
 
256
306
  async def handle_request(self, model: str, messages: List[Dict], max_tokens: Optional[int] = None,
257
307
  temperature: Optional[float] = 1.0, stream: Optional[bool] = False) -> Dict:
308
+ import logging
309
+ import json
310
+ logger = logging.getLogger(__name__)
311
+ logger.info(f"=== OllamaProviderHandler.handle_request START ===")
312
+ logger.info(f"Provider ID: {self.provider_id}")
313
+ logger.info(f"Endpoint: {self.client.base_url}")
314
+ logger.info(f"Model: {model}")
315
+ logger.info(f"Messages count: {len(messages)}")
316
+ logger.info(f"Max tokens: {max_tokens}")
317
+ logger.info(f"Temperature: {temperature}")
318
+ logger.info(f"Stream: {stream}")
319
+ logger.info(f"API key provided: {bool(self.api_key)}")
320
+
258
321
  if self.is_rate_limited():
322
+ logger.error("Provider is rate limited")
259
323
  raise Exception("Provider rate limited")
260
324
 
261
325
  try:
326
+ # Test connection first
327
+ logger.info("Testing Ollama connection...")
328
+ try:
329
+ health_response = await self.client.get("/api/tags", timeout=10.0)
330
+ logger.info(f"Ollama health check passed: {health_response.status_code}")
331
+ logger.info(f"Available models: {health_response.json().get('models', [])}")
332
+ except Exception as e:
333
+ logger.error(f"Ollama health check failed: {str(e)}")
334
+ logger.error(f"Cannot connect to Ollama at {self.client.base_url}")
335
+ logger.error(f"Please ensure Ollama is running and accessible")
336
+ raise Exception(f"Cannot connect to Ollama at {self.client.base_url}: {str(e)}")
337
+
262
338
  # Apply rate limiting
339
+ logger.info("Applying rate limiting...")
263
340
  await self.apply_rate_limit()
341
+ logger.info("Rate limiting applied")
264
342
 
265
- response = await self.client.post("/api/generate", json={
343
+ prompt = "\n\n".join([f"{msg['role']}: {msg['content']}" for msg in messages])
344
+ logger.info(f"Prompt length: {len(prompt)} characters")
345
+
346
+ request_data = {
266
347
  "model": model,
267
- "prompt": "\n\n".join([f"{msg['role']}: {msg['content']}" for msg in messages]),
348
+ "prompt": prompt,
268
349
  "options": {
269
350
  "temperature": temperature,
270
351
  "num_predict": max_tokens
271
- }
272
- })
352
+ },
353
+ "stream": False # Explicitly disable streaming for non-streaming requests
354
+ }
355
+
356
+ # Add API key to headers if provided (for Ollama cloud models)
357
+ headers = {}
358
+ if self.api_key:
359
+ headers["Authorization"] = f"Bearer {self.api_key}"
360
+ logger.info("API key added to request headers for Ollama cloud")
361
+
362
+ logger.info(f"Sending POST request to {self.client.base_url}/api/generate")
363
+ logger.info(f"Request data: {request_data}")
364
+ logger.info(f"Request headers: {headers}")
365
+ logger.info(f"Client timeout: {self.client.timeout}")
366
+
367
+ response = await self.client.post("/api/generate", json=request_data, headers=headers)
368
+ logger.info(f"Response status code: {response.status_code}")
369
+ logger.info(f"Response content type: {response.headers.get('content-type')}")
370
+ logger.info(f"Response content length: {len(response.content)} bytes")
371
+ logger.info(f"Raw response content (first 500 chars): {response.text[:500]}")
273
372
  response.raise_for_status()
373
+
374
+ # Ollama may return multiple JSON objects, parse them all
375
+ content = response.text
376
+ logger.info(f"Attempting to parse response as JSON...")
377
+
378
+ try:
379
+ # Try parsing as single JSON first
380
+ response_json = response.json()
381
+ logger.info(f"Response parsed as single JSON: {response_json}")
382
+ except json.JSONDecodeError as e:
383
+ # If that fails, try parsing multiple JSON objects
384
+ logger.warning(f"Failed to parse as single JSON: {e}")
385
+ logger.info(f"Attempting to parse as multiple JSON objects...")
386
+
387
+ # Parse multiple JSON objects (one per line)
388
+ responses = []
389
+ for line in content.strip().split('\n'):
390
+ if line.strip():
391
+ try:
392
+ obj = json.loads(line)
393
+ responses.append(obj)
394
+ except json.JSONDecodeError as line_error:
395
+ logger.error(f"Failed to parse line: {line}")
396
+ logger.error(f"Error: {line_error}")
397
+
398
+ if not responses:
399
+ raise Exception("No valid JSON objects found in response")
400
+
401
+ # Combine responses - take the last complete response
402
+ # Ollama sends multiple chunks, we want the final one
403
+ response_json = responses[-1]
404
+ logger.info(f"Parsed {len(responses)} JSON objects, using last one: {response_json}")
405
+
406
+ logger.info(f"Final response: {response_json}")
274
407
  self.record_success()
275
- return response.json()
408
+ logger.info(f"=== OllamaProviderHandler.handle_request END ===")
409
+ return response_json
276
410
  except Exception as e:
277
411
  self.record_failure()
278
412
  raise e
@@ -294,8 +428,29 @@ PROVIDER_HANDLERS = {
294
428
  }
295
429
 
296
430
  def get_provider_handler(provider_id: str, api_key: Optional[str] = None) -> BaseProviderHandler:
431
+ import logging
432
+ logger = logging.getLogger(__name__)
433
+ logger.info(f"=== get_provider_handler START ===")
434
+ logger.info(f"Provider ID: {provider_id}")
435
+ logger.info(f"API key provided: {bool(api_key)}")
436
+
297
437
  provider_config = config.get_provider(provider_id)
438
+ logger.info(f"Provider config: {provider_config}")
439
+ logger.info(f"Provider type: {provider_config.type}")
440
+ logger.info(f"Provider endpoint: {provider_config.endpoint}")
441
+
298
442
  handler_class = PROVIDER_HANDLERS.get(provider_config.type)
443
+ logger.info(f"Handler class: {handler_class.__name__ if handler_class else 'None'}")
444
+ logger.info(f"Available handler types: {list(PROVIDER_HANDLERS.keys())}")
445
+
299
446
  if not handler_class:
447
+ logger.error(f"Unsupported provider type: {provider_config.type}")
300
448
  raise ValueError(f"Unsupported provider type: {provider_config.type}")
301
- return handler_class(provider_id, api_key)
449
+
450
+ # All handlers now accept api_key as optional parameter
451
+ logger.info(f"Creating handler with provider_id and optional api_key")
452
+ handler = handler_class(provider_id, api_key)
453
+
454
+ logger.info(f"Handler created: {handler.__class__.__name__}")
455
+ logger.info(f"=== get_provider_handler END ===")
456
+ return handler
@@ -34,6 +34,7 @@ class ProviderConfig(BaseModel):
34
34
  endpoint: str
35
35
  type: str
36
36
  api_key_required: bool
37
+ rate_limit: float = 0.0
37
38
 
38
39
  class RotationConfig(BaseModel):
39
40
  providers: List[Dict]
@@ -111,32 +112,78 @@ class Config:
111
112
  print(f"Created default config file: {dst}")
112
113
 
113
114
  def _load_providers(self):
115
+ import logging
116
+ logger = logging.getLogger(__name__)
117
+ logger.info(f"=== Config._load_providers START ===")
118
+
114
119
  providers_path = Path.home() / '.aisbf' / 'providers.json'
120
+ logger.info(f"Looking for providers at: {providers_path}")
121
+
115
122
  if not providers_path.exists():
123
+ logger.info(f"User config not found, falling back to source config")
116
124
  # Fallback to source config if user config doesn't exist
117
125
  try:
118
126
  source_dir = self._get_config_source_dir()
119
127
  providers_path = source_dir / 'providers.json'
128
+ logger.info(f"Using source config at: {providers_path}")
120
129
  except FileNotFoundError:
130
+ logger.error("Could not find providers.json configuration file")
121
131
  raise FileNotFoundError("Could not find providers.json configuration file")
122
132
 
133
+ logger.info(f"Loading providers from: {providers_path}")
123
134
  with open(providers_path) as f:
124
135
  data = json.load(f)
125
136
  self.providers = {k: ProviderConfig(**v) for k, v in data['providers'].items()}
137
+ logger.info(f"Loaded {len(self.providers)} providers: {list(self.providers.keys())}")
138
+ for provider_id, provider_config in self.providers.items():
139
+ logger.info(f" - {provider_id}: type={provider_config.type}, endpoint={provider_config.endpoint}")
140
+ logger.info(f"=== Config._load_providers END ===")
126
141
 
127
142
  def _load_rotations(self):
143
+ import logging
144
+ logger = logging.getLogger(__name__)
145
+ logger.info(f"=== Config._load_rotations START ===")
146
+
128
147
  rotations_path = Path.home() / '.aisbf' / 'rotations.json'
148
+ logger.info(f"Looking for rotations at: {rotations_path}")
149
+
129
150
  if not rotations_path.exists():
151
+ logger.info(f"User config not found, falling back to source config")
130
152
  # Fallback to source config if user config doesn't exist
131
153
  try:
132
154
  source_dir = self._get_config_source_dir()
133
155
  rotations_path = source_dir / 'rotations.json'
156
+ logger.info(f"Using source config at: {rotations_path}")
134
157
  except FileNotFoundError:
158
+ logger.error("Could not find rotations.json configuration file")
135
159
  raise FileNotFoundError("Could not find rotations.json configuration file")
136
160
 
161
+ logger.info(f"Loading rotations from: {rotations_path}")
137
162
  with open(rotations_path) as f:
138
163
  data = json.load(f)
139
164
  self.rotations = {k: RotationConfig(**v) for k, v in data['rotations'].items()}
165
+ logger.info(f"Loaded {len(self.rotations)} rotations: {list(self.rotations.keys())}")
166
+
167
+ # Validate that all providers referenced in rotations exist
168
+ logger.info(f"=== VALIDATING ROTATION PROVIDERS ===")
169
+ available_providers = list(self.providers.keys())
170
+ logger.info(f"Available providers: {available_providers}")
171
+
172
+ for rotation_id, rotation_config in self.rotations.items():
173
+ logger.info(f"Validating rotation: {rotation_id}")
174
+ for provider in rotation_config.providers:
175
+ provider_id = provider['provider_id']
176
+ if provider_id not in self.providers:
177
+ logger.warning(f"!!! CONFIGURATION WARNING !!!")
178
+ logger.warning(f"Rotation '{rotation_id}' references provider '{provider_id}' which is NOT defined in providers.json")
179
+ logger.warning(f"Available providers: {available_providers}")
180
+ logger.warning(f"This provider will be SKIPPED during rotation requests")
181
+ logger.warning(f"Please add the provider to providers.json or remove it from the rotation configuration")
182
+ logger.warning(f"!!! END WARNING !!!")
183
+ else:
184
+ logger.info(f" ✓ Provider '{provider_id}' is available")
185
+
186
+ logger.info(f"=== Config._load_rotations END ===")
140
187
 
141
188
  def _load_autoselect(self):
142
189
  autoselect_path = Path.home() / '.aisbf' / 'autoselect.json'
@@ -162,7 +209,16 @@ class Config:
162
209
  }
163
210
 
164
211
  def get_provider(self, provider_id: str) -> ProviderConfig:
165
- return self.providers.get(provider_id)
212
+ import logging
213
+ logger = logging.getLogger(__name__)
214
+ logger.info(f"Config.get_provider called with provider_id: {provider_id}")
215
+ logger.info(f"Available providers: {list(self.providers.keys())}")
216
+ result = self.providers.get(provider_id)
217
+ if result:
218
+ logger.info(f"Found provider: {result}")
219
+ else:
220
+ logger.warning(f"Provider {provider_id} not found!")
221
+ return result
166
222
 
167
223
  def get_rotation(self, rotation_id: str) -> RotationConfig:
168
224
  return self.rotations.get(rotation_id)