solana-agent 27.1.0__py3-none-any.whl → 27.3.0__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.
@@ -3,7 +3,7 @@ LLM provider adapters for the Solana Agent system.
3
3
 
4
4
  These adapters implement the LLMProvider interface for different LLM services.
5
5
  """
6
- from typing import AsyncGenerator, Literal, Optional, Type, TypeVar
6
+ from typing import AsyncGenerator, List, Literal, Optional, Type, TypeVar
7
7
 
8
8
  from openai import AsyncOpenAI
9
9
  from pydantic import BaseModel
@@ -14,16 +14,25 @@ from solana_agent.interfaces.providers.llm import LLMProvider
14
14
 
15
15
  T = TypeVar('T', bound=BaseModel)
16
16
 
17
+ DEFAULT_CHAT_MODEL = "gpt-4.1-mini"
18
+ DEFAULT_PARSE_MODEL = "gpt-4.1-nano"
19
+ DEFAULT_EMBEDDING_MODEL = "text-embedding-3-large"
20
+ DEFAULT_EMBEDDING_DIMENSIONS = 3072
21
+ DEFAULT_TRANSCRIPTION_MODEL = "gpt-4o-mini-transcribe"
22
+ DEFAULT_TTS_MODEL = "tts-1"
23
+
17
24
 
18
25
  class OpenAIAdapter(LLMProvider):
19
26
  """OpenAI implementation of LLMProvider with web search capabilities."""
20
27
 
21
28
  def __init__(self, api_key: str):
22
29
  self.client = AsyncOpenAI(api_key=api_key)
23
- self.parse_model = "gpt-4o-mini"
24
- self.text_model = "gpt-4o-mini"
25
- self.transcription_model = "gpt-4o-mini-transcribe"
26
- self.tts_model = "tts-1"
30
+ self.parse_model = DEFAULT_PARSE_MODEL
31
+ self.text_model = DEFAULT_CHAT_MODEL
32
+ self.transcription_model = DEFAULT_TRANSCRIPTION_MODEL
33
+ self.tts_model = DEFAULT_TTS_MODEL
34
+ self.embedding_model = DEFAULT_EMBEDDING_MODEL
35
+ self.embedding_dimensions = DEFAULT_EMBEDDING_DIMENSIONS
27
36
 
28
37
  async def tts(
29
38
  self,
@@ -248,3 +257,48 @@ class OpenAIAdapter(LLMProvider):
248
257
  raise ValueError(
249
258
  f"Failed to generate structured output: {e}. All fallbacks failed."
250
259
  ) from e
260
+
261
+ async def embed_text(
262
+ self,
263
+ text: str,
264
+ model: Optional[str] = None,
265
+ dimensions: Optional[int] = None
266
+ ) -> List[float]: # pragma: no cover
267
+ """Generate an embedding for the given text using OpenAI.
268
+
269
+ Args:
270
+ text: The text to embed.
271
+ model: The embedding model to use (defaults to text-embedding-3-large).
272
+ dimensions: Desired output dimensions for the embedding.
273
+
274
+ Returns:
275
+ A list of floats representing the embedding vector.
276
+ """
277
+ if not text:
278
+ raise ValueError("Text cannot be empty")
279
+
280
+ try:
281
+ # Use provided model/dimensions or fall back to defaults
282
+ embedding_model = model or self.embedding_model
283
+ embedding_dimensions = dimensions or self.embedding_dimensions
284
+
285
+ # Replace newlines with spaces as recommended by OpenAI
286
+ text = text.replace("\n", " ")
287
+
288
+ response = await self.client.embeddings.create(
289
+ input=[text],
290
+ model=embedding_model,
291
+ dimensions=embedding_dimensions
292
+ )
293
+
294
+ if response.data and response.data[0].embedding:
295
+ return response.data[0].embedding
296
+ else:
297
+ raise ValueError(
298
+ "Failed to retrieve embedding from OpenAI response")
299
+
300
+ except Exception as e:
301
+ print(f"Error generating embedding: {e}")
302
+ import traceback
303
+ print(traceback.format_exc())
304
+ raise
@@ -0,0 +1,496 @@
1
+ from typing import List, Dict, Any, Optional, Literal, Union
2
+ from pinecone import PineconeAsyncio, ServerlessSpec
3
+ from pinecone.exceptions import PineconeApiException
4
+ import asyncio
5
+
6
+ from solana_agent.interfaces.providers.vector_storage import VectorStorageProvider
7
+ # LLMProvider is no longer needed here
8
+
9
+ # Type definitions remain useful
10
+ PineconeRerankModel = Literal[
11
+ "cohere-rerank-3.5",
12
+ "bge-reranker-v2-m3",
13
+ "pinecone-rerank-v0",
14
+ ]
15
+ # Kept for potential future use, but not used internally now
16
+ InputType = Literal["query", "passage"]
17
+ TruncateType = Literal["END", "NONE"] # Kept for potential future use
18
+
19
+
20
+ class PineconeAdapter(VectorStorageProvider):
21
+ """
22
+ Adapter for interacting with Pinecone vector database using PineconeAsyncio.
23
+ Assumes embeddings are generated externally (e.g., via OpenAI).
24
+ Supports Pinecone native reranking.
25
+ Follows context management patterns for Pinecone client v3+.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ api_key: Optional[str] = None,
31
+ index_name: Optional[str] = None,
32
+ # Default for OpenAI text-embedding-3-large, MUST match external embedder
33
+ embedding_dimensions: int = 3072,
34
+ cloud_provider: str = "aws",
35
+ region: str = "us-east-1",
36
+ metric: str = "cosine",
37
+ create_index_if_not_exists: bool = True,
38
+ # Reranking Config
39
+ use_reranking: bool = False,
40
+ rerank_model: Optional[PineconeRerankModel] = None,
41
+ rerank_top_k: int = 3, # Final number of results after reranking
42
+ # Multiplier for initial fetch before rerank
43
+ initial_query_top_k_multiplier: int = 5,
44
+ # Metadata field containing text for reranking
45
+ rerank_text_field: str = "text",
46
+ ):
47
+ """
48
+ Initialize the Pinecone Adapter.
49
+
50
+ Args:
51
+ api_key: Pinecone API key.
52
+ index_name: Name of the Pinecone index.
53
+ embedding_dimensions: Dimension of the embeddings generated externally. MUST match the index dimension.
54
+ cloud_provider: Cloud provider for the index (e.g., 'aws', 'gcp').
55
+ region: Region for the index.
56
+ metric: Distance metric for the index (e.g., 'cosine', 'dotproduct', 'euclidean').
57
+ create_index_if_not_exists: Attempt to create the index if it doesn't exist.
58
+ use_reranking: Enable Pinecone native reranking.
59
+ rerank_model: The reranking model to use (required if use_reranking is True).
60
+ rerank_top_k: Final number of results to return after reranking.
61
+ initial_query_top_k_multiplier: Fetch top_k * multiplier results initially for reranking.
62
+ rerank_text_field: The key in vector metadata containing the text content for reranking.
63
+ """
64
+ self.api_key = api_key
65
+ self.index_name = index_name
66
+ # Crucial: Must match external embedder and index
67
+ self.embedding_dimensions = embedding_dimensions
68
+ self.cloud_provider = cloud_provider
69
+ self.region = region
70
+ self.metric = metric
71
+ self.create_index_if_not_exists = create_index_if_not_exists
72
+
73
+ # Reranking Config
74
+ self.use_reranking = use_reranking
75
+ self.rerank_model = rerank_model
76
+ self.rerank_top_k = rerank_top_k
77
+ # Calculate how many results to fetch initially if reranking
78
+ self.initial_query_top_k_multiplier = initial_query_top_k_multiplier
79
+
80
+ self.rerank_text_field = rerank_text_field
81
+
82
+ self.pinecone: Optional[PineconeAsyncio] = None
83
+ # Store index host for connections
84
+ self.index_host: Optional[str] = None
85
+
86
+ # --- Validation ---
87
+ if not self.api_key:
88
+ raise ValueError("Pinecone API key is required.")
89
+ if not self.index_name:
90
+ raise ValueError("Pinecone index name is required.")
91
+ if self.embedding_dimensions <= 0:
92
+ raise ValueError(
93
+ "embedding_dimensions must be a positive integer.")
94
+ if self.use_reranking and not self.rerank_model:
95
+ raise ValueError(
96
+ "rerank_model must be specified when use_reranking is True.")
97
+
98
+ print(
99
+ f"PineconeAdapter configured for index '{self.index_name}' using external embeddings with dimension {self.embedding_dimensions}.")
100
+ if self.use_reranking:
101
+ print(f"Reranking enabled using model '{self.rerank_model}'.")
102
+
103
+ self._init_lock = asyncio.Lock()
104
+ self._initialized = False
105
+
106
+ async def _initialize_async(self):
107
+ """Asynchronously initialize the Pinecone client and get index host."""
108
+ async with self._init_lock:
109
+ if self._initialized:
110
+ return
111
+
112
+ try:
113
+ print("Initializing PineconeAsyncio client...")
114
+ self.pinecone = PineconeAsyncio(api_key=self.api_key)
115
+
116
+ if self.create_index_if_not_exists:
117
+ await self._create_index_if_not_exists_async()
118
+
119
+ print(
120
+ f"Describing Pinecone index '{self.index_name}' to get host...")
121
+ index_description = await self.pinecone.describe_index(self.index_name)
122
+ self.index_host = index_description.host
123
+ if not self.index_host:
124
+ raise RuntimeError(
125
+ f"Could not obtain host for index '{self.index_name}'.")
126
+ print(f"Obtained index host: {self.index_host}")
127
+
128
+ # Validate index dimension matches configured dimension
129
+ index_dimension = index_description.dimension
130
+ if index_dimension != 0 and index_dimension != self.embedding_dimensions:
131
+ # This is a critical mismatch
132
+ raise ValueError(
133
+ f"CRITICAL MISMATCH: Pinecone index dimension ({index_dimension}) "
134
+ f"does not match configured embedding dimension ({self.embedding_dimensions}). "
135
+ f"Ensure the index was created with the correct dimension or update the adapter configuration."
136
+ )
137
+ elif index_dimension == 0:
138
+ print(
139
+ f"Warning: Pinecone index dimension reported as 0. Cannot verify match with configured dimension ({self.embedding_dimensions}).")
140
+
141
+ print("Attempting to get index stats...")
142
+ stats = await self.describe_index_stats()
143
+ print(f"Successfully retrieved index stats: {stats}")
144
+
145
+ total_vector_count = stats.get("total_vector_count", 0)
146
+ print(
147
+ f"Current index '{self.index_name}' contains {total_vector_count} vectors.")
148
+
149
+ self._initialized = True
150
+ print("Pinecone adapter initialization complete.")
151
+
152
+ except PineconeApiException as e:
153
+ print(f"Pinecone API error during async initialization: {e}")
154
+ self.pinecone = None
155
+ self.index_host = None
156
+ raise
157
+ except Exception as e:
158
+ print(
159
+ f"Failed to initialize Pinecone async adapter for index '{self.index_name}': {e}")
160
+ self.pinecone = None
161
+ self.index_host = None
162
+ raise
163
+
164
+ async def _create_index_if_not_exists_async(self) -> None:
165
+ """Create the Pinecone index asynchronously if it doesn't already exist."""
166
+ if not self.pinecone:
167
+ raise RuntimeError(
168
+ "Pinecone client not initialized before creating index.")
169
+ try:
170
+ indexes_response = await self.pinecone.list_indexes()
171
+ existing_indexes = indexes_response.get('indexes', [])
172
+ existing_names = [idx.get('name') for idx in existing_indexes]
173
+
174
+ if self.index_name not in existing_names:
175
+ print(
176
+ f"Creating Pinecone index '{self.index_name}' with dimension {self.embedding_dimensions}...")
177
+
178
+ spec_data = {
179
+ "cloud": self.cloud_provider,
180
+ "region": self.region
181
+ }
182
+
183
+ create_params = {
184
+ "name": self.index_name,
185
+ "dimension": self.embedding_dimensions, # Use configured dimension
186
+ "metric": self.metric,
187
+ # Assuming serverless, adjust if needed
188
+ "spec": ServerlessSpec(**spec_data)
189
+ }
190
+
191
+ await self.pinecone.create_index(**create_params)
192
+ print(
193
+ f"✅ Successfully initiated creation of Pinecone index '{self.index_name}'. Waiting for it to be ready...")
194
+ # Wait time might need adjustment based on index size/type and cloud provider
195
+ await asyncio.sleep(30) # Increased wait time
196
+ else:
197
+ print(f"Using existing Pinecone index '{self.index_name}'")
198
+ except Exception as e:
199
+ print(
200
+ f"Error checking or creating Pinecone index asynchronously: {e}")
201
+ raise
202
+
203
+ async def _ensure_initialized(self):
204
+ """Ensure the async client is initialized before use."""
205
+ if not self._initialized:
206
+ await self._initialize_async()
207
+ if not self._initialized or not self.pinecone or not self.index_host:
208
+ raise RuntimeError(
209
+ "Pinecone async client failed to initialize or get index host.")
210
+
211
+ # _get_embedding method is removed as embeddings are handled externally
212
+
213
+ async def upsert_text(self, *args, **kwargs): # pragma: no cover
214
+ """Deprecated: Embeddings should be generated externally."""
215
+ raise NotImplementedError(
216
+ "upsert_text is deprecated. Use the generic upsert method with pre-computed vectors.")
217
+
218
+ async def upsert(
219
+ self,
220
+ # Expects {"id": str, "values": List[float], "metadata": Optional[Dict]}
221
+ vectors: List[Dict[str, Any]],
222
+ namespace: Optional[str] = None
223
+ ) -> None: # pragma: no cover
224
+ """Upsert pre-embedded vectors into Pinecone asynchronously."""
225
+ await self._ensure_initialized()
226
+ if not vectors:
227
+ print("Upsert skipped: No vectors provided.")
228
+ return
229
+ try:
230
+ async with self.pinecone.IndexAsyncio(host=self.index_host) as index_instance:
231
+ upsert_params = {"vectors": vectors}
232
+ if namespace:
233
+ upsert_params["namespace"] = namespace
234
+ await index_instance.upsert(**upsert_params)
235
+ print(
236
+ f"Successfully upserted {len(vectors)} vectors into namespace '{namespace or 'default'}'.")
237
+ except PineconeApiException as e:
238
+ print(f"Pinecone API error during async upsert: {e}")
239
+ raise
240
+ except Exception as e:
241
+ print(f"Error during async upsert: {e}")
242
+ raise
243
+
244
+ async def query_text(self, *args, **kwargs): # pragma: no cover
245
+ """Deprecated: Use query() for simple vector search or query_and_rerank() for reranking."""
246
+ raise NotImplementedError(
247
+ "query_text is deprecated. Use query() or query_and_rerank() with a pre-computed vector.")
248
+
249
+ async def query_and_rerank(
250
+ self,
251
+ vector: List[float],
252
+ query_text_for_rerank: str, # The original query text is needed for the reranker
253
+ top_k: int = 5,
254
+ namespace: Optional[str] = None,
255
+ filter: Optional[Dict[str, Any]] = None,
256
+ include_values: bool = False,
257
+ include_metadata: bool = True,
258
+ ) -> List[Dict[str, Any]]: # pragma: no cover
259
+ """
260
+ Queries Pinecone with a vector and reranks the results using Pinecone's reranker.
261
+ Requires 'use_reranking' to be True and 'rerank_model' to be set during init.
262
+
263
+ Args:
264
+ vector: The query vector.
265
+ query_text_for_rerank: The original text query used for the reranking model.
266
+ top_k: The final number of results desired after reranking.
267
+ namespace: Optional Pinecone namespace.
268
+ filter: Optional metadata filter for the initial query.
269
+ include_values: Whether to include vector values in the results.
270
+ include_metadata: Whether to include metadata in the results.
271
+
272
+ Returns:
273
+ A list of reranked result dictionaries.
274
+ """
275
+ await self._ensure_initialized()
276
+
277
+ if not self.use_reranking:
278
+ print(
279
+ "Warning: query_and_rerank called but use_reranking is False. Performing standard query.")
280
+ return await self.query(vector, top_k, namespace, filter, include_values, include_metadata)
281
+
282
+ if not self.rerank_model:
283
+ raise ValueError(
284
+ "Cannot rerank: rerank_model was not specified during initialization.")
285
+
286
+ # Determine how many results to fetch initially for reranking
287
+ initial_k = top_k * self.initial_query_top_k_multiplier
288
+
289
+ try:
290
+ # 1. Initial Vector Search
291
+ initial_results = await self.query(
292
+ vector=vector,
293
+ top_k=initial_k,
294
+ namespace=namespace,
295
+ filter=filter,
296
+ include_values=include_values, # Include values if requested in final output
297
+ include_metadata=True # Always need metadata for reranking text field
298
+ )
299
+
300
+ if not initial_results:
301
+ return [] # No results from initial query
302
+
303
+ # 2. Prepare for Reranking
304
+ documents_to_rerank = []
305
+ original_results_map = {}
306
+ for match in initial_results:
307
+ # Ensure metadata exists and contains the rerank text field
308
+ doc_metadata = match.get("metadata")
309
+ if isinstance(doc_metadata, dict):
310
+ doc_text = doc_metadata.get(self.rerank_text_field)
311
+ if doc_text and isinstance(doc_text, str):
312
+ documents_to_rerank.append(doc_text)
313
+ # Store original match keyed by the text for easy lookup after reranking
314
+ original_results_map[doc_text] = match
315
+ else:
316
+ print(
317
+ f"Warning: Skipping result ID {match.get('id')} for reranking - missing or invalid text in field '{self.rerank_text_field}'.")
318
+ else:
319
+ print(
320
+ f"Warning: Skipping result ID {match.get('id')} for reranking - metadata is missing or not a dictionary.")
321
+
322
+ if not documents_to_rerank:
323
+ print(
324
+ f"⚠️ Reranking skipped: No documents found with text in the specified field ('{self.rerank_text_field}'). Returning top {top_k} initial results.")
325
+ # Return the originally requested top_k
326
+ return initial_results[:top_k]
327
+
328
+ # 3. Perform Reranking Call
329
+ if not self.pinecone:
330
+ raise RuntimeError(
331
+ "Pinecone client not initialized for reranking.")
332
+
333
+ try:
334
+ print(
335
+ f"Reranking {len(documents_to_rerank)} results using {self.rerank_model} for query: '{query_text_for_rerank[:50]}...'")
336
+ rerank_params = {} # Add model-specific params if needed
337
+
338
+ rerank_request = {
339
+ "query": query_text_for_rerank,
340
+ "documents": documents_to_rerank,
341
+ "model": self.rerank_model,
342
+ "top_n": top_k, # Request the final desired number
343
+ "parameters": rerank_params
344
+ }
345
+
346
+ rerank_response = await self.pinecone.rerank(**rerank_request)
347
+
348
+ # 4. Process Reranked Results
349
+ reranked_results = []
350
+ if rerank_response and rerank_response.results:
351
+ for result in rerank_response.results:
352
+ # Adjust based on actual rerank response structure (assuming v3+)
353
+ doc_text = result.document.text if result.document else ""
354
+ score = result.relevance_score
355
+ original_match = original_results_map.get(doc_text)
356
+ if original_match:
357
+ # Create a new dict to avoid modifying the original map values
358
+ updated_match = dict(original_match)
359
+ # Update score with relevance score
360
+ updated_match["score"] = score
361
+ reranked_results.append(updated_match)
362
+ else:
363
+ print(
364
+ f"Warning: Reranked document text not found in original results map: '{doc_text[:50]}...'")
365
+
366
+ if reranked_results:
367
+ print(
368
+ f"Reranking complete. Returning {len(reranked_results)} results.")
369
+ return reranked_results
370
+ else:
371
+ # Should not happen if rerank_response.results existed, but handle defensively
372
+ print(
373
+ "Warning: No matches found after processing reranking response. Falling back to initial vector search results.")
374
+ return initial_results[:top_k]
375
+
376
+ except Exception as rerank_error:
377
+ print(
378
+ f"Error during reranking with {self.rerank_model}: {rerank_error}. Returning initial results.")
379
+ # Fallback to top_k initial results
380
+ return initial_results[:top_k]
381
+
382
+ except Exception as e:
383
+ print(f"Failed to query or rerank: {e}")
384
+ return []
385
+
386
+ async def query(
387
+ self,
388
+ vector: List[float],
389
+ top_k: int = 5,
390
+ namespace: Optional[str] = None,
391
+ filter: Optional[Dict[str, Any]] = None,
392
+ include_values: bool = False,
393
+ include_metadata: bool = True,
394
+ ) -> List[Dict[str, Any]]: # pragma: no cover
395
+ """
396
+ Query Pinecone for similar vectors asynchronously (no reranking).
397
+
398
+ Args:
399
+ vector: The query vector.
400
+ top_k: The number of results to return.
401
+ namespace: Optional Pinecone namespace.
402
+ filter: Optional metadata filter.
403
+ include_values: Whether to include vector values in the results.
404
+ include_metadata: Whether to include metadata in the results.
405
+
406
+ Returns:
407
+ A list of result dictionaries.
408
+ """
409
+ await self._ensure_initialized()
410
+ try:
411
+ async with self.pinecone.IndexAsyncio(host=self.index_host) as index_instance:
412
+ query_params = {
413
+ "vector": vector,
414
+ "top_k": top_k,
415
+ "include_values": include_values,
416
+ "include_metadata": include_metadata
417
+ }
418
+ if namespace:
419
+ query_params["namespace"] = namespace
420
+ if filter:
421
+ query_params["filter"] = filter
422
+ query_response = await index_instance.query(**query_params)
423
+
424
+ # Ensure response structure is handled safely
425
+ matches = query_response.get(
426
+ "matches", []) if query_response else []
427
+ return matches
428
+
429
+ except PineconeApiException as e:
430
+ print(f"Pinecone API error during async query: {e}")
431
+ raise # Re-raise API errors
432
+ except Exception as e:
433
+ print(f"Error during async query: {e}")
434
+ return [] # Return empty list for general errors
435
+
436
+ async def delete(
437
+ self,
438
+ ids: List[str],
439
+ namespace: Optional[str] = None
440
+ ) -> None: # pragma: no cover
441
+ """Delete vectors by IDs from Pinecone asynchronously."""
442
+ await self._ensure_initialized()
443
+ if not ids:
444
+ print("Delete skipped: No IDs provided.")
445
+ return
446
+ try:
447
+ async with self.pinecone.IndexAsyncio(host=self.index_host) as index_instance:
448
+ delete_params = {"ids": ids}
449
+ if namespace:
450
+ delete_params["namespace"] = namespace
451
+ await index_instance.delete(**delete_params)
452
+ print(
453
+ f"Attempted to delete {len(ids)} vectors from namespace '{namespace or 'default'}'.")
454
+ except PineconeApiException as e:
455
+ print(f"Pinecone API error during async delete: {e}")
456
+ raise
457
+ except Exception as e:
458
+ print(f"Error during async delete: {e}")
459
+ raise
460
+
461
+ async def describe_index_stats(self) -> Dict[str, Any]: # pragma: no cover
462
+ """Get statistics about the index asynchronously."""
463
+ print(f"describe_index_stats: Entering for host {self.index_host}")
464
+ try:
465
+ print(
466
+ f"describe_index_stats: Getting IndexAsyncio context for host {self.index_host}...")
467
+ async with self.pinecone.IndexAsyncio(host=self.index_host) as index_instance:
468
+ print(
469
+ f"describe_index_stats: Context acquired. Calling describe_index_stats on index instance...")
470
+ stats_response = await index_instance.describe_index_stats()
471
+ print(
472
+ f"describe_index_stats: Call completed. Response: {stats_response}")
473
+
474
+ # Convert response to dict if necessary (handle potential None or different types)
475
+ if hasattr(stats_response, 'to_dict'):
476
+ result_dict = stats_response.to_dict()
477
+ elif isinstance(stats_response, dict):
478
+ result_dict = stats_response
479
+ else:
480
+ # Attempt basic conversion or return empty
481
+ try:
482
+ result_dict = dict(stats_response)
483
+ except (TypeError, ValueError):
484
+ print(
485
+ f"Warning: Could not convert stats_response to dict: {stats_response}")
486
+ result_dict = {}
487
+
488
+ print(f"describe_index_stats: Returning stats dict: {result_dict}")
489
+ return result_dict
490
+ except PineconeApiException as e:
491
+ print(
492
+ f"Pinecone API error describing index stats asynchronously: {e}")
493
+ raise # Re-raise API errors
494
+ except Exception as e:
495
+ print(f"Error describing index stats asynchronously: {e}")
496
+ return {} # Return empty dict for general errors