aiqa-client 0.4.3__py3-none-any.whl → 0.5.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.
aiqa/__init__.py CHANGED
@@ -6,7 +6,7 @@ The client initializes automatically when WithTracing is first used.
6
6
 
7
7
  Set environment variables:
8
8
  AIQA_SERVER_URL: URL of the AIQA server
9
- AIQA_API_KEY: API key for authentication
9
+ AIQA_API_KEY: API key for authentication (required)
10
10
  AIQA_COMPONENT_TAG: Optional component identifier
11
11
  AIQA_STARTUP_DELAY_SECONDS: Optional delay before first flush (default: 10s)
12
12
 
aiqa/client.py CHANGED
@@ -2,12 +2,16 @@
2
2
  import os
3
3
  import logging
4
4
  from functools import lru_cache
5
- from typing import Optional, TYPE_CHECKING, Any
5
+ from typing import Optional, TYPE_CHECKING, Any, Dict
6
6
  from opentelemetry import trace
7
7
  from opentelemetry.sdk.trace import TracerProvider
8
8
  from opentelemetry.sdk.trace.export import BatchSpanProcessor
9
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
10
+ import requests
9
11
 
10
- logger = logging.getLogger("AIQA")
12
+ from .constants import AIQA_TRACER_NAME, LOG_TAG
13
+
14
+ logger = logging.getLogger(LOG_TAG)
11
15
 
12
16
  # Compatibility import for TraceIdRatioBased sampler
13
17
  # In older OpenTelemetry versions it was TraceIdRatioBasedSampler
@@ -20,7 +24,7 @@ except ImportError:
20
24
  from opentelemetry.sdk.trace.sampling import TraceIdRatioBasedSampler as TraceIdRatioBased
21
25
  except ImportError:
22
26
  logger.warning(
23
- "Could not import TraceIdRatioBased or TraceIdRatioBasedSampler from "
27
+ f"Could not import TraceIdRatioBased or TraceIdRatioBasedSampler from "
24
28
  "opentelemetry.sdk.trace.sampling. AIQA tracing may not work correctly. "
25
29
  "Please ensure opentelemetry-sdk>=1.24.0 is installed. "
26
30
  "Try: pip install --upgrade opentelemetry-sdk"
@@ -28,7 +32,7 @@ except ImportError:
28
32
  # Set to None so we can check later
29
33
  TraceIdRatioBased = None
30
34
 
31
- from .constants import AIQA_TRACER_NAME
35
+ from .http_utils import get_server_url, get_api_key, build_headers, format_http_error
32
36
 
33
37
  class AIQAClient:
34
38
  """
@@ -93,19 +97,19 @@ class AIQAClient:
93
97
  This will also set enabled=False to prevent further tracing attempts.
94
98
  """
95
99
  try:
96
- logger.info("AIQA tracing shutting down")
100
+ logger.info(f"AIQA tracing shutting down")
97
101
  # Disable tracing to prevent attempts to use shut-down system
98
102
  self.enabled = False
99
103
  if self._provider:
100
104
  self._provider.shutdown()
101
- if self._exporter:
102
- self._exporter.shutdown()
105
+ # OTLP exporter doesn't have a separate shutdown method - it's handled by the provider
103
106
  except Exception as e:
104
107
  logger.error(f"Error shutting down tracing: {e}")
105
108
  # Still disable even if shutdown had errors
106
109
  self.enabled = False
107
110
 
108
111
 
112
+
109
113
  # Global singleton instance
110
114
  client: AIQAClient = AIQAClient()
111
115
 
@@ -150,7 +154,7 @@ def get_aiqa_client() -> AIQAClient:
150
154
  # Optional: Initialize explicitly (usually not needed)
151
155
  client = get_aiqa_client()
152
156
  if client.enabled:
153
- print("Tracing is enabled")
157
+ print(f"Tracing is enabled")
154
158
 
155
159
  @WithTracing
156
160
  def my_function():
@@ -161,7 +165,7 @@ def get_aiqa_client() -> AIQAClient:
161
165
  _init_tracing()
162
166
  except Exception as e:
163
167
  logger.error(f"Failed to initialize AIQA tracing: {e}")
164
- logger.warning("AIQA tracing is disabled. Your application will continue to run without tracing.")
168
+ logger.warning(f"AIQA tracing is disabled. Your application will continue to run without tracing.")
165
169
  return client
166
170
 
167
171
  def _init_tracing() -> None:
@@ -171,14 +175,13 @@ def _init_tracing() -> None:
171
175
  return
172
176
 
173
177
  try:
174
- server_url = os.getenv("AIQA_SERVER_URL")
175
- api_key = os.getenv("AIQA_API_KEY")
178
+ server_url = get_server_url()
179
+ api_key = get_api_key()
176
180
 
177
- if not server_url or not api_key:
181
+ if not api_key:
178
182
  client.enabled = False
179
- missing_vars = [var for var, val in [("AIQA_SERVER_URL", server_url), ("AIQA_API_KEY", api_key)] if not val]
180
183
  logger.warning(
181
- f"AIQA tracing is disabled: missing required environment variables: {', '.join(missing_vars)}"
184
+ f"AIQA tracing is disabled: missing required environment variables: AIQA_API_KEY"
182
185
  )
183
186
  client._initialized = True
184
187
  return
@@ -225,23 +228,43 @@ def _init_tracing() -> None:
225
228
 
226
229
  def _attach_aiqa_processor(provider: TracerProvider) -> None:
227
230
  """Attach AIQA span processor to the provider. Idempotent - safe to call multiple times."""
228
- from .aiqa_exporter import AIQASpanExporter
229
-
230
231
  try:
231
232
  # Check if already attached
232
233
  for p in provider._active_span_processor._span_processors:
233
- if isinstance(getattr(p, "exporter", None), AIQASpanExporter):
234
- logger.debug("AIQA span processor already attached, skipping")
234
+ if isinstance(getattr(p, "exporter", None), OTLPSpanExporter):
235
+ logger.debug(f"AIQA span processor already attached, skipping")
235
236
  return
236
237
 
237
- exporter = AIQASpanExporter(
238
- server_url=os.getenv("AIQA_SERVER_URL"),
239
- api_key=os.getenv("AIQA_API_KEY"),
238
+ server_url = get_server_url()
239
+ api_key = get_api_key()
240
+
241
+ # Build headers for authentication
242
+ # OTLP exporter sets its own Content-Type, so we only need Authorization
243
+ auth_headers = {}
244
+ if api_key:
245
+ auth_headers["Authorization"] = f"ApiKey {api_key}"
246
+ elif os.getenv("AIQA_API_KEY"):
247
+ auth_headers["Authorization"] = f"ApiKey {os.getenv('AIQA_API_KEY')}"
248
+
249
+ # OTLP HTTP exporter requires the full endpoint URL including /v1/traces
250
+ # Ensure server_url doesn't have trailing slash or /v1/traces, then append /v1/traces
251
+ base_url = server_url.rstrip('/')
252
+ if base_url.endswith('/v1/traces'):
253
+ endpoint = base_url
254
+ else:
255
+ endpoint = f"{base_url}/v1/traces"
256
+
257
+ # Create OTLP exporter with authentication headers only
258
+ # The exporter will set Content-Type and other headers automatically
259
+ exporter = OTLPSpanExporter(
260
+ endpoint=endpoint,
261
+ headers=auth_headers if auth_headers else None,
240
262
  )
263
+
241
264
  provider.add_span_processor(BatchSpanProcessor(exporter))
242
265
  global client
243
266
  client.exporter = exporter
244
- logger.debug("AIQA span processor attached successfully")
267
+ logger.debug(f"AIQA span processor attached successfully")
245
268
  except Exception as e:
246
269
  logger.error(f"Error attaching AIQA span processor: {e}")
247
270
  # Re-raise to let _init_tracing handle it - it will log and continue
@@ -263,4 +286,66 @@ def get_aiqa_tracer() -> trace.Tracer:
263
286
  except Exception as e:
264
287
  # Log issue but still return a tracer
265
288
  logger.info(f"Issue getting AIQA tracer with version: {e}, using fallback")
266
- return trace.get_tracer(AIQA_TRACER_NAME)
289
+ return trace.get_tracer(AIQA_TRACER_NAME)
290
+
291
+
292
+ def get_organisation(
293
+ organisation_id: str,
294
+ server_url: Optional[str] = None,
295
+ api_key: Optional[str] = None
296
+ ) -> Dict[str, Any]:
297
+ """
298
+ Get organisation information based on API key via an API call.
299
+
300
+ Args:
301
+ organisation_id: ID of the organisation to retrieve
302
+ server_url: Optional server URL (defaults to AIQA_SERVER_URL env var)
303
+ api_key: Optional API key (defaults to AIQA_API_KEY env var)
304
+
305
+ Returns:
306
+ Organisation object as a dictionary
307
+ """
308
+ url = get_server_url(server_url)
309
+ key = get_api_key(api_key)
310
+ headers = build_headers(key)
311
+
312
+ response = requests.get(
313
+ f"{url}/organisation/{organisation_id}",
314
+ headers=headers,
315
+ )
316
+
317
+ if not response.ok:
318
+ raise Exception(format_http_error(response, "get organisation"))
319
+
320
+ return response.json()
321
+
322
+
323
+ def get_api_key_info(
324
+ api_key_id: str,
325
+ server_url: Optional[str] = None,
326
+ api_key: Optional[str] = None
327
+ ) -> Dict[str, Any]:
328
+ """
329
+ Get API key information via an API call.
330
+
331
+ Args:
332
+ api_key_id: ID of the API key to retrieve
333
+ server_url: Optional server URL (defaults to AIQA_SERVER_URL env var)
334
+ api_key: Optional API key (defaults to AIQA_API_KEY env var)
335
+
336
+ Returns:
337
+ ApiKey object as a dictionary
338
+ """
339
+ url = get_server_url(server_url)
340
+ key = get_api_key(api_key)
341
+ headers = build_headers(key)
342
+
343
+ response = requests.get(
344
+ f"{url}/api-key/{api_key_id}",
345
+ headers=headers,
346
+ )
347
+
348
+ if not response.ok:
349
+ raise Exception(format_http_error(response, "get api key info"))
350
+
351
+ return response.json()
aiqa/constants.py CHANGED
@@ -3,4 +3,6 @@ Constants used across the AIQA client package.
3
3
  """
4
4
 
5
5
  AIQA_TRACER_NAME = "aiqa-tracer"
6
- VERSION = "0.4.3" # automatically updated by set-version-json.sh
6
+ VERSION = "0.5.2" # automatically updated by set-version-json.sh
7
+
8
+ LOG_TAG = "AIQA" # Used in all logging output to identify AIQA messages
aiqa/experiment_runner.py CHANGED
@@ -4,6 +4,8 @@ ExperimentRunner - runs experiments on datasets and scores results
4
4
 
5
5
  import os
6
6
  import time
7
+ from .constants import LOG_TAG
8
+ from .http_utils import build_headers, get_server_url, get_api_key, format_http_error
7
9
  from typing import Any, Dict, List, Optional, Callable, Awaitable, Union
8
10
  import requests
9
11
 
@@ -35,18 +37,15 @@ class ExperimentRunner:
35
37
  """
36
38
  self.dataset_id = dataset_id
37
39
  self.experiment_id = experiment_id
38
- self.server_url = (server_url or os.getenv("AIQA_SERVER_URL", "")).rstrip("/")
39
- self.api_key = api_key or os.getenv("AIQA_API_KEY", "")
40
+ self.server_url = get_server_url(server_url)
41
+ self.api_key = get_api_key(api_key)
40
42
  self.organisation = organisation_id
41
43
  self.experiment: Optional[Dict[str, Any]] = None
42
44
  self.scores: List[Dict[str, Any]] = []
43
45
 
44
46
  def _get_headers(self) -> Dict[str, str]:
45
47
  """Build HTTP headers for API requests."""
46
- headers = {"Content-Type": "application/json"}
47
- if self.api_key:
48
- headers["Authorization"] = f"ApiKey {self.api_key}"
49
- return headers
48
+ return build_headers(self.api_key)
50
49
 
51
50
  def get_dataset(self) -> Dict[str, Any]:
52
51
  """
@@ -61,10 +60,7 @@ class ExperimentRunner:
61
60
  )
62
61
 
63
62
  if not response.ok:
64
- error_text = response.text if hasattr(response, "text") else "Unknown error"
65
- raise Exception(
66
- f"Failed to fetch dataset: {response.status_code} {response.reason} - {error_text}"
67
- )
63
+ raise Exception(format_http_error(response, "fetch dataset"))
68
64
 
69
65
  return response.json()
70
66
 
@@ -92,10 +88,7 @@ class ExperimentRunner:
92
88
  )
93
89
 
94
90
  if not response.ok:
95
- error_text = response.text if hasattr(response, "text") else "Unknown error"
96
- raise Exception(
97
- f"Failed to fetch example inputs: {response.status_code} {response.reason} - {error_text}"
98
- )
91
+ raise Exception(format_http_error(response, "fetch example inputs"))
99
92
 
100
93
  data = response.json()
101
94
  return data.get("hits", [])
@@ -130,7 +123,7 @@ class ExperimentRunner:
130
123
  "summary_results": {},
131
124
  }
132
125
 
133
- print("Creating experiment")
126
+ print(f"Creating experiment")
134
127
  response = requests.post(
135
128
  f"{self.server_url}/experiment",
136
129
  json=experiment_setup,
@@ -138,10 +131,7 @@ class ExperimentRunner:
138
131
  )
139
132
 
140
133
  if not response.ok:
141
- error_text = response.text if hasattr(response, "text") else "Unknown error"
142
- raise Exception(
143
- f"Failed to create experiment: {response.status_code} {response.reason} - {error_text}"
144
- )
134
+ raise Exception(format_http_error(response, "create experiment"))
145
135
 
146
136
  experiment = response.json()
147
137
  self.experiment_id = experiment["id"]
@@ -186,10 +176,7 @@ class ExperimentRunner:
186
176
  )
187
177
 
188
178
  if not response.ok:
189
- error_text = response.text if hasattr(response, "text") else "Unknown error"
190
- raise Exception(
191
- f"Failed to score and store: {response.status_code} {response.reason} - {error_text}"
192
- )
179
+ raise Exception(format_http_error(response, "score and store"))
193
180
 
194
181
  json_result = response.json()
195
182
  print(f"scoreAndStore response: {json_result}")
@@ -270,8 +257,7 @@ class ExperimentRunner:
270
257
  input_data = example["spans"][0].get("attributes", {}).get("input")
271
258
 
272
259
  if not input_data:
273
- print(
274
- f"Warning: Example has no input field or spans with input attribute: {example}"
260
+ print(f"Warning: Example has no input field or spans with input attribute: {example}"
275
261
  )
276
262
  # Run engine anyway -- this could make sense if it's all about the parameters
277
263
 
@@ -326,10 +312,7 @@ class ExperimentRunner:
326
312
  )
327
313
 
328
314
  if not response.ok:
329
- error_text = response.text if hasattr(response, "text") else "Unknown error"
330
- raise Exception(
331
- f"Failed to fetch summary results: {response.status_code} {response.reason} - {error_text}"
332
- )
315
+ raise Exception(format_http_error(response, "fetch summary results"))
333
316
 
334
317
  experiment2 = response.json()
335
318
  return experiment2.get("summary_results", {})
aiqa/http_utils.py ADDED
@@ -0,0 +1,143 @@
1
+ """
2
+ Shared HTTP utilities for AIQA client.
3
+ Provides common functions for building headers, handling errors, and accessing environment variables.
4
+ Supports AIQA-specific env vars (AIQA_SERVER_URL, AIQA_API_KEY) with fallback to OTLP standard vars.
5
+ """
6
+
7
+ import os
8
+ from typing import Dict, Optional
9
+
10
+
11
+ def build_headers(api_key: Optional[str] = None) -> Dict[str, str]:
12
+ """
13
+ Build HTTP headers for AIQA API requests.
14
+
15
+ Checks AIQA_API_KEY first, then falls back to OTEL_EXPORTER_OTLP_HEADERS if not set.
16
+
17
+ Args:
18
+ api_key: Optional API key. If not provided, will try to get from AIQA_API_KEY env var,
19
+ then from OTEL_EXPORTER_OTLP_HEADERS.
20
+
21
+ Returns:
22
+ Dictionary with Content-Type, Accept-Encoding, and optionally Authorization header.
23
+ """
24
+ headers = {
25
+ "Content-Type": "application/json",
26
+ "Accept-Encoding": "gzip, deflate, br", # Request compression (aiohttp handles decompression automatically)
27
+ }
28
+
29
+ # Check parameter first
30
+ if api_key:
31
+ headers["Authorization"] = f"ApiKey {api_key}"
32
+ return headers
33
+
34
+ # Check AIQA_API_KEY env var
35
+ aiqa_api_key = os.getenv("AIQA_API_KEY")
36
+ if aiqa_api_key:
37
+ headers["Authorization"] = f"ApiKey {aiqa_api_key}"
38
+ return headers
39
+
40
+ # Fallback to OTLP headers (format: "key1=value1,key2=value2")
41
+ otlp_headers = os.getenv("OTEL_EXPORTER_OTLP_HEADERS")
42
+ if otlp_headers:
43
+ # Parse comma-separated key=value pairs
44
+ for header_pair in otlp_headers.split(","):
45
+ header_pair = header_pair.strip()
46
+ if "=" in header_pair:
47
+ key, value = header_pair.split("=", 1)
48
+ key = key.strip()
49
+ value = value.strip()
50
+ if key.lower() == "authorization":
51
+ headers["Authorization"] = value
52
+ else:
53
+ headers[key] = value
54
+
55
+ return headers
56
+
57
+
58
+ def get_server_url(server_url: Optional[str] = None) -> str:
59
+ """
60
+ Get server URL from parameter or environment variable, with trailing slash removed.
61
+
62
+ Checks AIQA_SERVER_URL first, then falls back to OTEL_EXPORTER_OTLP_ENDPOINT if not set.
63
+
64
+ Args:
65
+ server_url: Optional server URL. If not provided, will get from AIQA_SERVER_URL env var,
66
+ then from OTEL_EXPORTER_OTLP_ENDPOINT.
67
+
68
+ Returns:
69
+ Server URL with trailing slash removed. Defaults to https://server-aiqa.winterwell.com if not set.
70
+ """
71
+ # Check parameter first
72
+ if server_url:
73
+ return server_url.rstrip("/")
74
+
75
+ # Check AIQA_SERVER_URL env var
76
+ url = os.getenv("AIQA_SERVER_URL")
77
+ if url:
78
+ return url.rstrip("/")
79
+
80
+ # Fallback to OTLP endpoint
81
+ url = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
82
+ if url:
83
+ return url.rstrip("/")
84
+
85
+ # Default fallback
86
+ return "https://server-aiqa.winterwell.com"
87
+
88
+
89
+ def get_api_key(api_key: Optional[str] = None) -> str:
90
+ """
91
+ Get API key from parameter or environment variable.
92
+
93
+ Checks AIQA_API_KEY first, then falls back to OTEL_EXPORTER_OTLP_HEADERS if not set.
94
+
95
+ Args:
96
+ api_key: Optional API key. If not provided, will get from AIQA_API_KEY env var,
97
+ then from OTEL_EXPORTER_OTLP_HEADERS (looking for Authorization header).
98
+
99
+ Returns:
100
+ API key or empty string if not set.
101
+ """
102
+ # Check parameter first
103
+ if api_key:
104
+ return api_key
105
+
106
+ # Check AIQA_API_KEY env var
107
+ aiqa_api_key = os.getenv("AIQA_API_KEY")
108
+ if aiqa_api_key:
109
+ return aiqa_api_key
110
+
111
+ # Fallback to OTLP headers (look for Authorization header)
112
+ otlp_headers = os.getenv("OTEL_EXPORTER_OTLP_HEADERS")
113
+ if otlp_headers:
114
+ for header_pair in otlp_headers.split(","):
115
+ header_pair = header_pair.strip()
116
+ if "=" in header_pair:
117
+ key, value = header_pair.split("=", 1)
118
+ key = key.strip()
119
+ value = value.strip()
120
+ if key.lower() == "authorization":
121
+ # Extract API key from "ApiKey <key>" or just return the value
122
+ if value.startswith("ApiKey "):
123
+ return value[7:]
124
+ return value
125
+
126
+ return ""
127
+
128
+
129
+ def format_http_error(response, operation: str) -> str:
130
+ """
131
+ Format an HTTP error message from a response object.
132
+
133
+ Args:
134
+ response: Response object with status_code, reason, and text attributes
135
+ operation: Description of the operation that failed (e.g., "fetch dataset")
136
+
137
+ Returns:
138
+ Formatted error message string.
139
+ """
140
+ error_text = response.text if hasattr(response, "text") else "Unknown error"
141
+ status_code = getattr(response, "status_code", getattr(response, "status", "unknown"))
142
+ reason = getattr(response, "reason", "")
143
+ return f"Failed to {operation}: {status_code} {reason} - {error_text}"