spikard 0.0.1__tar.gz

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.
spikard-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,412 @@
1
+ Metadata-Version: 2.4
2
+ Name: spikard
3
+ Version: 0.0.1
4
+ Summary: Universal LLM client
5
+ Author-email: Na'aman Hirschfeld <nhirschfeld@gmail.com>
6
+ Project-URL: Repository, https://github.com/Goldziher/spikard
7
+ Keywords: ai,client,llm
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Intended Audience :: Information Technology
11
+ Classifier: Natural Language :: English
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development
21
+ Classifier: Topic :: Software Development :: Libraries
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.9
25
+ Description-Content-Type: text/markdown
26
+ Requires-Dist: anyio>=4.4.0
27
+ Requires-Dist: jsonschema>=4.22.0
28
+ Requires-Dist: msgspec>=0.18.6
29
+ Requires-Dist: typing-extensions>=4.12.2
30
+ Provides-Extra: anthropic
31
+ Requires-Dist: anthropic>=0.49.0; extra == "anthropic"
32
+ Provides-Extra: anthropic-vertex
33
+ Requires-Dist: anthropic[vertex]>=0.49.0; extra == "anthropic-vertex"
34
+ Provides-Extra: aws-bedrock
35
+ Requires-Dist: aioboto3>=14.1.0; extra == "aws-bedrock"
36
+ Provides-Extra: google-genai
37
+ Requires-Dist: google-genai>=1.8.0; extra == "google-genai"
38
+ Provides-Extra: google-vertex
39
+ Requires-Dist: google-cloud-aiplatform>=1.86.0; extra == "google-vertex"
40
+ Provides-Extra: groq
41
+ Requires-Dist: groq>=0.20.0; extra == "groq"
42
+ Provides-Extra: openai
43
+ Requires-Dist: openai>=1.69.0; extra == "openai"
44
+ Requires-Dist: tiktoken>=0.9.0; extra == "openai"
45
+
46
+ # Spikard
47
+
48
+ Spikard is a universal LLM client.
49
+
50
+ **What does this mean?** Each LLM provider has its own API. While many providers follow the OpenAI API format, others do not.
51
+ Spikard provides a simple universal interface allowing you to use any LLM provider with the same code.
52
+
53
+ **Why use Spikard?** You might have already encountered the need to use multiple LLM providers, or to switch between them.
54
+ In the end, there is quite a bit of redundant boilerplate involved. Spikard offers a permissively licensed (MIT), high quality and lightweight abstraction layer.
55
+
56
+ **Why not use my favorite framework <insert name>?** The point of this library is to be a building block, not a framework.
57
+ If your use case is for a framework, use a framework. If, on the other hand, you want a lightweight building block with minimal dependencies and excellent Python, this library might be for you.
58
+
59
+ **What the hell is a "Spikard?"** Great that you ask! Spikards are powerful magical items that look like spiked rings, each spike connecting a magic source in one of the shadows.
60
+ For further reading, grab a copy of the Amber cycle of books by Roger Zelazny.
61
+
62
+ ## Design Philosophy
63
+
64
+ The design philosophy is straightforward. There is an abstract LLM client class. This class offers a uniform interface for LLM clients, and it includes validation logic that is shared. It is then extended by provider-specific classes that implement the actual API calls.
65
+
66
+ - We are not creating specialized clients for the different providers. Rather, we use `optional-dependencies` to add the provider-specific client packages, which allows us to have a lean and lightweight package.
67
+ - We will try to always support the latest version of a client API library on a best effort basis.
68
+ - We rely on strict, extensive typing with overloads to ensure the best possible experience for users and strict static analysis.
69
+ - You can also implement your own LLM clients using the abstract LLM client class. Again, the point of this library is to be a building block.
70
+
71
+ This library is open to contributions- if you see a provider that is missing, please open an issue or submit a pull request.
72
+
73
+ ## Installation
74
+
75
+ Install the library with the providers you want to use, for example:
76
+
77
+ ```shell
78
+ pip install spikard[openai]
79
+ ```
80
+
81
+ Or multiple providers:
82
+
83
+ ```shell
84
+ pip install spikard[openai,anthropic]
85
+ ```
86
+
87
+ ## Architecture
88
+
89
+ Spikard follows a layered architecture with a consistent interface across all providers:
90
+
91
+ 1. **Base Layer**: `LLMClient` abstract base class in `base.py` defines the standard interface for all providers.
92
+ 1. **Provider Layer**: Provider-specific implementations extend the base class (e.g., `OpenAIClient`, `AzureOpenAIClient`).
93
+ 1. **Configuration Layer**: Each provider has its own configuration class (e.g., `OpenAIClientConfig`).
94
+ 1. **Response Layer**: All providers return responses in a standardized `LLMResponse` format.
95
+
96
+ This design allows for consistent usage patterns regardless of the underlying LLM provider while maintaining provider-specific configuration options.
97
+
98
+ ## Usage
99
+
100
+ ### Client Instantiation
101
+
102
+ ```python
103
+ from spikard.openai import OpenAIClient, OpenAIClientConfig
104
+
105
+ # all client expect a 'client_config' value, which is a specific subclass of 'LMClientConfig'
106
+ client = OpenAIClient(client_config=OpenAIClientConfig(api_key="sk_...."))
107
+ ```
108
+
109
+ ### Generating Content
110
+
111
+ All clients expose a single method called `generate_completion`. With some complex typing in place, this method correctly handles three scenarios:
112
+
113
+ - A text completion request (non-streaming) that returns a text content
114
+ - A text completion request (streaming) that returns an async iterator of text chunks
115
+ - A chat completion request that performs a tool call and returns structured output
116
+
117
+ ```python
118
+ from typing import TypedDict
119
+
120
+ from spikard.openai import OpenAIClient, OpenAIClientConfig, OpenAICompletionConfig, ToolDefinition
121
+
122
+ client = OpenAIClient(client_config=OpenAIClientConfig(api_key="sk_...."))
123
+
124
+ # generate a text completion
125
+ async def generate_completion() -> None:
126
+ response = await client.generate_completion(
127
+ messages=["Tell me about machine learning"],
128
+ system_prompt="You are a helpful AI assistant",
129
+ config=OpenAICompletionConfig(
130
+ model="gpt-4o",
131
+ ),
132
+ )
133
+
134
+ # response is an LLMResponse[str] value
135
+ print(response.content) # The response text
136
+ print(response.tokens) # Token count used
137
+ print(response.duration) # Generation duration
138
+
139
+ # stream a text completion
140
+ async def stream_completion() -> None:
141
+ async for response in await client.generate_completion(
142
+ messages=["Tell me about machine learning"],
143
+ system_prompt="You are a helpful AI assistant",
144
+ config=OpenAICompletionConfig(
145
+ model="gpt-4o",
146
+ ),
147
+ stream=True, # Enable streaming mode
148
+ ):
149
+ print(response.content) # The response text chunk
150
+ print(response.tokens) # Token count for this chunk
151
+ print(response.duration) # Generation duration, measured from the last response
152
+
153
+ # call a tool and generate structured output
154
+ async def call_tool() -> None:
155
+ # For tool calling we need to define a return type. This can be any type that can be represented as JSON, but
156
+ # it cannot be a union type. We are using msgspec for deserialization, and it does not support union types - although
157
+ # you can override this behavior via subclassing.
158
+
159
+ # A type can be for example a subclass of msgspec.Struct, a pydantic.BaseModel, a dataclass, a TypedDict,
160
+ # or a primitive such as dict[str, Any] or list[SomeType] etc.
161
+
162
+ from msgspec import Struct
163
+
164
+ class MyResponse(Struct):
165
+ name: str
166
+ age: int
167
+ hobbies: list[str]
168
+
169
+ # Since we are using a msgspec struct, we do not need to define the tool's JSON schema because we can infer it
170
+ response = await client.generate_completion(
171
+ messages=["Return a JSON object with name, age and hobbies"],
172
+ system_prompt="You are a helpful AI assistant",
173
+ config=OpenAICompletionConfig(
174
+ model="gpt-4o",
175
+ ),
176
+ response_type=MyResponse,
177
+ )
178
+
179
+ assert isinstance(response.content, MyResponse) # The response is a MyResponse object that is structurally valid
180
+ print(response.tokens) # Token count used
181
+ print(response.duration) # Generation duration
182
+
183
+ async def cool_tool_with_tool_definition() -> None:
184
+ # Sometimes we either want to manually create a JSON schema for some reason, or use a type that cannot (currently) be
185
+ # automatically inferred into a JSON schema. For example, let's say we are using a TypedDict to represent a simple JSON structure:
186
+
187
+ class MyResponse(TypedDict):
188
+ name: str
189
+ age: int
190
+ hobbies: list[str]
191
+
192
+ # In this case we need to define the tool definition manually:
193
+ tool_definition = ToolDefinition(
194
+ name="person_data", # Optional name for the tool
195
+ response_type=MyResponse,
196
+ description="Get information about a person", # Optional description
197
+ schema={
198
+ "type": "object",
199
+ "required": ["name", "age", "hobbies"],
200
+ "properties": {
201
+ "name": {"type": "string"},
202
+ "age": {"type": "integer"},
203
+ "hobbies": {
204
+ "type": "array",
205
+ "items": {"type": "string"},
206
+ },
207
+ },
208
+ },
209
+ )
210
+
211
+ # Now we can use the tool definition in the generate_completion call
212
+ response = await client.generate_completion(
213
+ messages=["Return a JSON object with name, age and hobbies"],
214
+ system_prompt="You are a helpful AI assistant",
215
+ config=OpenAICompletionConfig(
216
+ model="gpt-4o",
217
+ ),
218
+ tool_definition=tool_definition,
219
+ )
220
+
221
+ assert isinstance(response.content, MyResponse) # The response is a MyResponse dict that is structurally valid
222
+ print(response.tokens) # Token count used
223
+ print(response.duration) # Generation duration
224
+ ```
225
+
226
+ ## Callbacks
227
+
228
+ Callbacks can be used to validate and transform LLM responses. They are a powerful way to add custom logic to process responses before they are returned to your application.
229
+
230
+ ```python
231
+ from spikard import Callback, LLMResponse
232
+ from spikard.openai import OpenAIClient, OpenAIClientConfig, OpenAICompletionConfig
233
+
234
+ client = OpenAIClient(client_config=OpenAIClientConfig(api_key="sk_...."))
235
+
236
+ # A simple callback function that transforms the response content
237
+ def uppercase_callback(response: LLMResponse[str]) -> LLMResponse[str]:
238
+ """Convert the response text to uppercase."""
239
+ response.content = response.content.upper()
240
+ return response
241
+
242
+ # Using the callback with a text completion
243
+ async def generate_with_callback() -> None:
244
+ response = await client.generate_completion(
245
+ messages=["Tell me a short joke"],
246
+ system_prompt="You are a helpful AI assistant",
247
+ config=OpenAICompletionConfig(model="gpt-4o"),
248
+ callback=uppercase_callback, # Apply the callback to process the response
249
+ )
250
+
251
+ print(response.content) # The response text will be in uppercase
252
+
253
+ # Callbacks can also be asynchronous
254
+ async def validation_callback(response: LLMResponse[str]) -> LLMResponse[str]:
255
+ """Validate that the response contains specific text."""
256
+ if "error" in response.content.lower():
257
+ raise ValueError("Response contains error message")
258
+ return response
259
+
260
+ # Callbacks work with structured responses too
261
+ from msgspec import Struct
262
+
263
+ class WeatherInfo(Struct):
264
+ temperature: float
265
+ conditions: str
266
+ location: str
267
+
268
+ def temperature_converter(response: LLMResponse[WeatherInfo]) -> LLMResponse[WeatherInfo]:
269
+ """Convert temperature from Celsius to Fahrenheit."""
270
+ # Only modify the value if it looks like Celsius
271
+ if response.content.temperature < 50:
272
+ response.content.temperature = (response.content.temperature * 9 / 5) + 32
273
+ return response
274
+
275
+ async def get_weather() -> None:
276
+ response = await client.generate_completion(
277
+ messages=["What's the weather in New York?"],
278
+ config=OpenAICompletionConfig(model="gpt-4o"),
279
+ response_type=WeatherInfo,
280
+ callback=temperature_converter, # Process the structured response
281
+ )
282
+
283
+ print(f"Temperature: {response.content.temperature}°F")
284
+ print(f"Conditions: {response.content.conditions}")
285
+ ```
286
+
287
+ ## JSON Schema Validation
288
+
289
+ Spikard can enforce JSON schema validation for structured responses to ensure they conform to your expected format:
290
+
291
+ ```python
292
+ from msgspec import Struct
293
+ from spikard.openai import OpenAIClient, OpenAIClientConfig, OpenAICompletionConfig
294
+
295
+ class Person(Struct):
296
+ name: str
297
+ age: int
298
+ email: str # We expect a valid email
299
+
300
+ client = OpenAIClient(client_config=OpenAIClientConfig(api_key="sk_...."))
301
+
302
+ async def get_person_info() -> None:
303
+ # Enable schema validation with enforce_schema_validation=True
304
+ response = await client.generate_completion(
305
+ messages=["Generate information about a fictional person"],
306
+ config=OpenAICompletionConfig(model="gpt-4o"),
307
+ response_type=Person,
308
+ enforce_schema_validation=True, # Will validate against Person schema
309
+ )
310
+
311
+ # If the LLM returns invalid data (e.g., missing or wrong type fields),
312
+ # a ResponseValidationError will be raised
313
+ print(response.content.name)
314
+ print(response.content.age)
315
+ print(response.content.email)
316
+ ```
317
+
318
+ ## Custom msgspec Decoders
319
+
320
+ You can customize how Spikard deserializes responses by providing custom decoder mappings. This is useful for handling custom types or adding special deserialization logic:
321
+
322
+ ```python
323
+ from msgspec import Struct
324
+ from spikard.openai import OpenAIClient, OpenAIClientConfig, OpenAICompletionConfig
325
+
326
+ # Custom type that needs special handling
327
+ class GeoCoordinate:
328
+ def __init__(self, latitude: float, longitude: float):
329
+ self.latitude = latitude
330
+ self.longitude = longitude
331
+
332
+ def __str__(self) -> str:
333
+ return f"{self.latitude}, {self.longitude}"
334
+
335
+ class Location(Struct):
336
+ name: str
337
+ coordinates: GeoCoordinate
338
+ population: int
339
+
340
+ # Custom decoder function to convert from dict to custom GeoCoordinate type
341
+ def decode_geo_coordinate(value: dict) -> GeoCoordinate:
342
+ """Convert dictionary representation to GeoCoordinate object."""
343
+ # LLM might return coordinates in different formats, so handle various cases
344
+ if isinstance(value, dict):
345
+ if "latitude" in value and "longitude" in value:
346
+ return GeoCoordinate(value["latitude"], value["longitude"])
347
+ elif "lat" in value and "lng" in value:
348
+ return GeoCoordinate(value["lat"], value["lng"])
349
+ # Default fallback
350
+ return GeoCoordinate(0.0, 0.0)
351
+
352
+ # Create client with custom decoder for our GeoCoordinate type
353
+ client = OpenAIClient(
354
+ client_config=OpenAIClientConfig(api_key="sk_...."),
355
+ decoder_mapping={
356
+ GeoCoordinate: decode_geo_coordinate,
357
+ },
358
+ )
359
+
360
+ async def get_location_info() -> None:
361
+ response = await client.generate_completion(
362
+ messages=["Provide information about San Francisco with coordinates"],
363
+ config=OpenAICompletionConfig(model="gpt-4o"),
364
+ response_type=Location,
365
+ )
366
+
367
+ # The dictionary response for coordinates will be automatically converted
368
+ # to a GeoCoordinate object through our custom decoder
369
+ print(f"City: {response.content.name}")
370
+ print(f"Coordinates: {response.content.coordinates}") # Uses our custom __str__ method
371
+ print(f"Population: {response.content.population}")
372
+
373
+ # We can access the custom object's properties directly
374
+ print(f"Latitude: {response.content.coordinates.latitude}")
375
+ print(f"Longitude: {response.content.coordinates.longitude}")
376
+ ```
377
+
378
+ The decoder mapping allows you to handle complex types that aren't directly supported by msgspec's default deserialization process. When the LLM returns JSON data, Spikard will use your custom decoder functions to convert appropriate parts of the response to your custom object types.
379
+
380
+ ## Automatic Retries
381
+
382
+ Spikard provides built-in support for retrying failed API calls with configurable retry behavior:
383
+
384
+ ```python
385
+ from spikard import RetryConfig
386
+ from spikard.openai import OpenAIClient, OpenAIClientConfig, OpenAICompletionConfig
387
+
388
+ # Create a custom retry configuration
389
+ retry_config = RetryConfig(
390
+ max_retries=5, # Maximum number of retry attempts
391
+ initial_interval=2.0, # Initial wait time between retries in seconds
392
+ exponential=True, # Use exponential backoff (True) or fixed intervals (False)
393
+ exponent=2.0, # Base for exponential backoff calculation
394
+ max_interval=120.0, # Maximum wait time between retries
395
+ jitter=True, # Add random jitter to retry intervals to prevent thundering herd
396
+ jitter_factor=0.2, # Jitter range as a fraction of the base interval
397
+ )
398
+
399
+ client = OpenAIClient(client_config=OpenAIClientConfig(api_key="sk_...."))
400
+
401
+ async def generate_with_retry() -> None:
402
+ # Pass the retry_config to control retry behavior
403
+ response = await client.generate_completion(
404
+ messages=["Tell me about machine learning"],
405
+ config=OpenAICompletionConfig(model="gpt-4o"),
406
+ retry_config=retry_config, # Apply custom retry behavior
407
+ )
408
+
409
+ print(response.content)
410
+ ```
411
+
412
+ The retry mechanism automatically handles transient errors like rate limits, network issues, and temporary service unavailability. If the retry attempts are exhausted, a `RetryError` is raised containing the history of errors that occurred.