opik-optimizer 0.7.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.
@@ -0,0 +1,65 @@
1
+ import importlib.metadata
2
+ import logging
3
+ from .logging_config import setup_logging
4
+
5
+ __version__ = importlib.metadata.version("opik_optimizer")
6
+
7
+ # Using WARNING as a sensible default to avoid flooding users with INFO/DEBUG
8
+ setup_logging(level=logging.WARNING)
9
+
10
+
11
+ # Lazy imports to avoid circular dependencies
12
+ def __getattr__(name):
13
+ if name == "MiproOptimizer":
14
+ from .mipro_optimizer import MiproOptimizer
15
+
16
+ return MiproOptimizer
17
+ elif name == "BaseOptimizer":
18
+ from .base_optimizer import BaseOptimizer
19
+
20
+ return BaseOptimizer
21
+ elif name == "MetaPromptOptimizer":
22
+ from .meta_prompt_optimizer import MetaPromptOptimizer
23
+
24
+ return MetaPromptOptimizer
25
+ elif name == "FewShotBayesianOptimizer":
26
+ from .few_shot_bayesian_optimizer import FewShotBayesianOptimizer
27
+
28
+ return FewShotBayesianOptimizer
29
+ elif name in ["MetricConfig", "OptimizationConfig", "TaskConfig"]:
30
+ from .optimization_config.configs import (
31
+ MetricConfig,
32
+ OptimizationConfig,
33
+ TaskConfig,
34
+ )
35
+
36
+ return locals()[name]
37
+ elif name in ["from_dataset_field", "from_llm_response_text"]:
38
+ from .optimization_config.mappers import (
39
+ from_dataset_field,
40
+ from_llm_response_text,
41
+ )
42
+
43
+ return locals()[name]
44
+ raise AttributeError(f"module 'opik_optimizer' has no attribute '{name}'")
45
+
46
+
47
+ from opik.evaluation.models.litellm import warning_filters
48
+
49
+ warning_filters.add_warning_filters()
50
+
51
+ from .optimization_result import OptimizationResult
52
+
53
+ __all__ = [
54
+ "BaseOptimizer",
55
+ "FewShotBayesianOptimizer",
56
+ "MetaPromptOptimizer",
57
+ "MiproOptimizer",
58
+ "MetricConfig",
59
+ "OptimizationConfig",
60
+ "TaskConfig",
61
+ "from_dataset_field",
62
+ "from_llm_response_text",
63
+ "OptimizationResult",
64
+ "setup_logging",
65
+ ]
@@ -0,0 +1,43 @@
1
+ import threading
2
+ import time
3
+ import queue
4
+ from functools import wraps
5
+
6
+ class RateLimiter:
7
+ """
8
+ Rate limiter that enforces a maximum number of calls across all threads.
9
+ """
10
+ def __init__(self, max_calls_per_second):
11
+ self.max_calls_per_second = max_calls_per_second
12
+ self.interval = 1.0 / max_calls_per_second # Time between allowed calls
13
+ self.last_call_time = 0
14
+ self.lock = threading.Lock()
15
+
16
+ def acquire(self):
17
+ """
18
+ Wait until a call is allowed according to the global rate limit.
19
+ Returns immediately if the call is allowed, otherwise blocks until it's time.
20
+ """
21
+ with self.lock:
22
+ current_time = time.time()
23
+ time_since_last = current_time - self.last_call_time
24
+
25
+ # If we haven't waited long enough since the last call
26
+ if time_since_last < self.interval:
27
+ # Calculate how much longer we need to wait
28
+ sleep_time = self.interval - time_since_last
29
+ time.sleep(sleep_time)
30
+
31
+ # Update the last call time (after potential sleep)
32
+ self.last_call_time = time.time()
33
+
34
+ def rate_limited(limiter):
35
+ """Decorator to rate limit a function using the provided limiter"""
36
+ def decorator(func):
37
+ @wraps(func)
38
+ def wrapper(*args, **kwargs):
39
+ limiter.acquire()
40
+ return func(*args, **kwargs)
41
+ return wrapper
42
+ return decorator
43
+
@@ -0,0 +1,240 @@
1
+ from typing import Optional, Union, List, Dict, Any
2
+ import opik
3
+ import logging
4
+ import time
5
+
6
+ import litellm
7
+ from opik.evaluation import metrics
8
+ from opik.opik_context import get_current_span_data
9
+ from opik.rest_api.core import ApiError
10
+
11
+ from pydantic import BaseModel
12
+ from ._throttle import RateLimiter, rate_limited
13
+ from .cache_config import initialize_cache
14
+ from opik.evaluation.models.litellm import opik_monitor as opik_litellm_monitor
15
+ from .optimization_config.configs import TaskConfig, MetricConfig
16
+
17
+ limiter = RateLimiter(max_calls_per_second=15)
18
+
19
+ # Don't use unsupported params:
20
+ litellm.drop_params = True
21
+
22
+ # Set up logging:
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class OptimizationRound(BaseModel):
27
+ round_number: int
28
+ current_prompt: str
29
+ current_score: float
30
+ generated_prompts: List[Dict[str, Any]]
31
+ best_prompt: str
32
+ best_score: float
33
+ improvement: float
34
+
35
+
36
+ class BaseOptimizer:
37
+ def __init__(self, model: str, project_name: Optional[str] = None, **model_kwargs):
38
+ """
39
+ Base class for optimizers.
40
+
41
+ Args:
42
+ model: LiteLLM model name
43
+ project_name: Opik project name
44
+ model_kwargs: additional args for model (eg, temperature)
45
+ """
46
+ self.model = model
47
+ self.reasoning_model = model
48
+ self.model_kwargs = model_kwargs
49
+ self.project_name = project_name
50
+ self._history = []
51
+ self.experiment_config = None
52
+ self.llm_call_counter = 0
53
+
54
+ # Initialize shared cache
55
+ initialize_cache()
56
+
57
+ def optimize_prompt(
58
+ self,
59
+ dataset: Union[str, opik.Dataset],
60
+ metric_config: MetricConfig,
61
+ task_config: TaskConfig,
62
+ prompt: str,
63
+ input_key: str,
64
+ output_key: str,
65
+ experiment_config: Optional[Dict] = None,
66
+ **kwargs,
67
+ ):
68
+ """
69
+ Optimize a prompt.
70
+
71
+ Args:
72
+ dataset: Opik dataset name, or Opik dataset
73
+ metric_config: instance of a MetricConfig
74
+ task_config: instance of a TaskConfig
75
+ prompt: the prompt to optimize
76
+ input_key: input field of dataset
77
+ output_key: output field of dataset
78
+ experiment_config: Optional configuration for the experiment
79
+ **kwargs: Additional arguments for optimization
80
+ """
81
+ self.dataset = dataset
82
+ self.metric = metric
83
+ self.prompt = prompt
84
+ self.input_key = input_key
85
+ self.output_key = output_key
86
+ self.experiment_config = experiment_config
87
+
88
+ def evaluate_prompt(
89
+ self,
90
+ dataset: Union[str, opik.Dataset],
91
+ metric_config: MetricConfig,
92
+ prompt: str,
93
+ input_key: str,
94
+ output_key: str,
95
+ n_samples: int = 10,
96
+ task_config: Optional[TaskConfig] = None,
97
+ dataset_item_ids: Optional[List[str]] = None,
98
+ experiment_config: Optional[Dict] = None,
99
+ **kwargs,
100
+ ) -> float:
101
+ """
102
+ Evaluate a prompt.
103
+
104
+ Args:
105
+ dataset: Opik dataset name, or Opik dataset
106
+ metric_config: instance of a MetricConfig
107
+ task_config: instance of a TaskConfig
108
+ prompt: the prompt to evaluate
109
+ input_key: input field of dataset
110
+ output_key: output field of dataset
111
+ n_samples: number of items to test in the dataset
112
+ dataset_item_ids: Optional list of dataset item IDs to evaluate
113
+ experiment_config: Optional configuration for the experiment
114
+ **kwargs: Additional arguments for evaluation
115
+
116
+ Returns:
117
+ float: The evaluation score
118
+ """
119
+ self.dataset = dataset
120
+ self.metric_config = metric_config
121
+ self.task_config = task_config
122
+ self.prompt = prompt
123
+ self.input_key = input_key
124
+ self.output_key = output_key
125
+ self.experiment_config = experiment_config
126
+ return 0.0 # Base implementation returns 0
127
+
128
+ def get_history(self) -> List[Dict[str, Any]]:
129
+ """
130
+ Get the optimization history.
131
+
132
+ Returns:
133
+ List[Dict[str, Any]]: List of optimization rounds with their details
134
+ """
135
+ return self._history
136
+
137
+ def _add_to_history(self, round_data: Dict[str, Any]):
138
+ """
139
+ Add a round to the optimization history.
140
+
141
+ Args:
142
+ round_data: Dictionary containing round details
143
+ """
144
+ self._history.append(round_data)
145
+
146
+ @rate_limited(limiter)
147
+ def _call_model(
148
+ self,
149
+ prompt: str,
150
+ system_prompt: Optional[str] = None,
151
+ is_reasoning: bool = False,
152
+ ) -> str:
153
+ """Call the model to get suggestions based on the meta-prompt."""
154
+ model = self.reasoning_model if is_reasoning else self.model
155
+ messages = []
156
+
157
+ if system_prompt:
158
+ messages.append({"role": "system", "content": system_prompt})
159
+ logger.debug(f"Using custom system prompt: {system_prompt[:100]}...")
160
+ else:
161
+ messages.append(
162
+ {"role": "system", "content": "You are a helpful assistant."}
163
+ )
164
+
165
+ messages.append({"role": "user", "content": prompt})
166
+ logger.debug(f"Calling model {model} with prompt: {prompt[:100]}...")
167
+
168
+ api_params = self.model_kwargs.copy()
169
+ api_params.update(
170
+ {
171
+ "model": model,
172
+ "messages": messages,
173
+ # Ensure required params like 'temperature', 'max_tokens' are present
174
+ # Defaults added here for safety, though usually set in __init__ kwargs
175
+ "temperature": api_params.get("temperature", 0.3),
176
+ "max_tokens": api_params.get("max_tokens", 1000),
177
+ }
178
+ )
179
+
180
+ # Attempt to add Opik monitoring if available
181
+ try:
182
+ # Assuming opik_litellm_monitor is imported and configured elsewhere
183
+ api_params = opik_litellm_monitor.try_add_opik_monitoring_to_params(
184
+ api_params
185
+ )
186
+ logger.debug("Opik monitoring hooks added to LiteLLM params.")
187
+ except Exception as e:
188
+ logger.warning(f"Could not add Opik monitoring to LiteLLM params: {e}")
189
+
190
+ logger.debug(
191
+ f"Final API params (excluding messages): { {k:v for k,v in api_params.items() if k != 'messages'} }"
192
+ )
193
+
194
+ # Increment Counter
195
+ self.llm_call_counter += 1
196
+ logger.debug(f"LLM Call Count: {self.llm_call_counter}")
197
+
198
+ try:
199
+ response = litellm.completion(**api_params)
200
+ model_output = response.choices[0].message.content.strip()
201
+ logger.debug(f"Model response from {model_to_use}: {model_output[:100]}...")
202
+ return model_output
203
+ except litellm.exceptions.RateLimitError as e:
204
+ logger.error(f"LiteLLM Rate Limit Error for model {model_to_use}: {e}")
205
+ # Consider adding retry logic here with tenacity
206
+ raise
207
+ except litellm.exceptions.APIConnectionError as e:
208
+ logger.error(f"LiteLLM API Connection Error for model {model_to_use}: {e}")
209
+ # Consider adding retry logic here
210
+ raise
211
+ except litellm.exceptions.ContextWindowExceededError as e:
212
+ logger.error(
213
+ f"LiteLLM Context Window Exceeded Error for model {model_to_use}. Prompt length: {len(prompt)}. Details: {e}"
214
+ )
215
+ raise
216
+ except litellm.exceptions.APIError as e: # Catch broader API errors
217
+ logger.error(f"LiteLLM API Error for model {model_to_use}: {e}")
218
+ raise
219
+ except Exception as e:
220
+ # Catch any other unexpected errors
221
+ logger.error(
222
+ f"Unexpected error during model call to {model_to_use}: {type(e).__name__} - {e}"
223
+ )
224
+ raise
225
+
226
+ def update_optimization(self, optimization, status: str) -> None:
227
+ """
228
+ Update the optimization status
229
+ """
230
+ # FIXME: remove when a solution is added to opik's optimization.update method
231
+ count = 0
232
+ while count < 3:
233
+ try:
234
+ optimization.update(status="completed")
235
+ break
236
+ except ApiError:
237
+ count += 1
238
+ time.sleep(5)
239
+ if count == 3:
240
+ logger.warning("Unable to update optimization status; continuing...")
@@ -0,0 +1,24 @@
1
+ import os
2
+ from pathlib import Path
3
+ import litellm
4
+ from litellm.caching import Cache
5
+
6
+ # Configure cache directory
7
+ CACHE_DIR = os.path.expanduser("~/.litellm_cache")
8
+ Path(CACHE_DIR).mkdir(parents=True, exist_ok=True)
9
+
10
+ # Configure cache settings
11
+ CACHE_CONFIG = {
12
+ "type": "disk",
13
+ "disk_cache_dir": CACHE_DIR,
14
+ }
15
+
16
+ def initialize_cache():
17
+ """Initialize the LiteLLM cache with custom configuration."""
18
+ litellm.cache = Cache(**CACHE_CONFIG)
19
+ return litellm.cache
20
+
21
+ def clear_cache():
22
+ """Clear the LiteLLM cache."""
23
+ if litellm.cache:
24
+ litellm.cache.clear()
@@ -0,0 +1,7 @@
1
+ from .datasets import get_or_create_dataset
2
+ from .cache import get_litellm_cache
3
+
4
+ __all__ = [
5
+ "get_or_create_dataset",
6
+ "get_litellm_cache",
7
+ ]
@@ -0,0 +1,112 @@
1
+ from urllib.parse import urlparse, parse_qs
2
+ import sqlite3
3
+ import shutil
4
+ import os
5
+ import litellm
6
+ from litellm.caching import Cache
7
+ import requests
8
+
9
+ NAMED_CACHES = {
10
+ "test": "https://drive.google.com/file/d/1RifNtpN-pl0DW49daRaAMJwW7MCsOh6y/view?usp=sharing",
11
+ "test2": "https://drive.google.com/uc?id=1RifNtpN-pl0DW49daRaAMJwW7MCsOh6y&export=download",
12
+ }
13
+ CACHE_DIR = os.path.expanduser("~/.litellm_cache")
14
+
15
+
16
+ def get_litellm_cache(name: str):
17
+ """
18
+ Get a LiteLLM cache from a remote location, and add it to the
19
+ local cache
20
+ """
21
+ # Try to close an existing one, if there is one:
22
+ try:
23
+ litellm.cache.cache.disk_cache.close()
24
+ except Exception:
25
+ pass
26
+
27
+ if not os.path.exists(CACHE_DIR):
28
+ os.makedirs(CACHE_DIR)
29
+
30
+ if name.lower() in NAMED_CACHES:
31
+ return get_litellm_cache(NAMED_CACHES[name.lower()])
32
+ elif name.startswith("https://drive.google.com/file/d/"):
33
+ file_id = name.split("/d/")[1].split("/view")[0]
34
+ download_url = f"https://drive.google.com/uc?id={file_id}&export=download"
35
+ file_path = _get_google_drive_file(download_url)
36
+ elif name.startswith("https://drive.google.com/uc"):
37
+ file_path = _get_google_drive_file(name)
38
+ else:
39
+ raise Exception("Unknown cache type: %r" % name)
40
+
41
+ dest_path = os.path.join(CACHE_DIR, "cache.db")
42
+
43
+ if os.path.exists(dest_path):
44
+ # Copy contents from source to dest:
45
+ _copy_cache(file_path, dest_path)
46
+ else:
47
+ # Just copy the file:
48
+ shutil.copy(file_path, dest_path)
49
+
50
+ # Update the cache to use the new database:
51
+ litellm.cache = Cache(type="disk", disk_cache_dir=CACHE_DIR)
52
+
53
+
54
+ def _copy_cache(source_path, dest_path):
55
+ """
56
+ Copy cached items from a source to a destination cache.
57
+ """
58
+ source_conn = sqlite3.connect(source_path)
59
+ source_conn.row_factory = sqlite3.Row
60
+ source_cursor = source_conn.cursor()
61
+
62
+ dest_conn = sqlite3.connect(dest_path)
63
+ dest_cursor = dest_conn.cursor()
64
+
65
+ source_cursor.execute(f"PRAGMA table_info(Cache)")
66
+ columns_info = source_cursor.fetchall()
67
+ column_names = [info[1] for info in columns_info[1:]] # Skip rowid
68
+ placeholders = ", ".join(["?"] * len(column_names))
69
+ columns_str = ", ".join(column_names)
70
+
71
+ inserted_count = 0
72
+ source_cursor.execute("SELECT * FROM Cache")
73
+ records = source_cursor.fetchall()
74
+ for record in records:
75
+ record = dict(record)
76
+ del record["rowid"]
77
+ key_value = record["key"]
78
+
79
+ dest_cursor.execute("SELECT 1 FROM Cache WHERE key = ?", (key_value,))
80
+ existing_record = dest_cursor.fetchone()
81
+
82
+ if not existing_record:
83
+ dest_cursor.execute(
84
+ f"INSERT INTO Cache ({columns_str}) VALUES ({placeholders})",
85
+ list(record.values()),
86
+ )
87
+ inserted_count += 1
88
+
89
+ print(f"Inserted {inserted_count} record(s) in litellm cache")
90
+ dest_conn.commit()
91
+
92
+
93
+ def _get_google_drive_file(file_url):
94
+ """
95
+ Given a common google drive URL with id=ID
96
+ get it, or use cache.
97
+ """
98
+ parsed_url = urlparse(file_url)
99
+ query_params = parse_qs(parsed_url.query)
100
+ id_value = query_params.get("id")[0]
101
+
102
+ cache_file_path = os.path.join(CACHE_DIR, id_value)
103
+
104
+ if not os.path.exists(cache_file_path):
105
+ response = requests.get(file_url)
106
+ response.raise_for_status()
107
+
108
+ with open(cache_file_path, "wb") as tmp_file:
109
+ for chunk in response.iter_content(chunk_size=8192):
110
+ tmp_file.write(chunk)
111
+
112
+ return cache_file_path