flashlite 0.1.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.
Files changed (41) hide show
  1. flashlite/__init__.py +169 -0
  2. flashlite/cache/__init__.py +14 -0
  3. flashlite/cache/base.py +194 -0
  4. flashlite/cache/disk.py +285 -0
  5. flashlite/cache/memory.py +157 -0
  6. flashlite/client.py +671 -0
  7. flashlite/config.py +154 -0
  8. flashlite/conversation/__init__.py +30 -0
  9. flashlite/conversation/context.py +319 -0
  10. flashlite/conversation/manager.py +385 -0
  11. flashlite/conversation/multi_agent.py +378 -0
  12. flashlite/core/__init__.py +13 -0
  13. flashlite/core/completion.py +145 -0
  14. flashlite/core/messages.py +130 -0
  15. flashlite/middleware/__init__.py +18 -0
  16. flashlite/middleware/base.py +90 -0
  17. flashlite/middleware/cache.py +121 -0
  18. flashlite/middleware/logging.py +159 -0
  19. flashlite/middleware/rate_limit.py +211 -0
  20. flashlite/middleware/retry.py +149 -0
  21. flashlite/observability/__init__.py +34 -0
  22. flashlite/observability/callbacks.py +155 -0
  23. flashlite/observability/inspect_compat.py +266 -0
  24. flashlite/observability/logging.py +293 -0
  25. flashlite/observability/metrics.py +221 -0
  26. flashlite/py.typed +0 -0
  27. flashlite/structured/__init__.py +31 -0
  28. flashlite/structured/outputs.py +189 -0
  29. flashlite/structured/schema.py +165 -0
  30. flashlite/templating/__init__.py +11 -0
  31. flashlite/templating/engine.py +217 -0
  32. flashlite/templating/filters.py +143 -0
  33. flashlite/templating/registry.py +165 -0
  34. flashlite/tools/__init__.py +74 -0
  35. flashlite/tools/definitions.py +382 -0
  36. flashlite/tools/execution.py +353 -0
  37. flashlite/types.py +233 -0
  38. flashlite-0.1.0.dist-info/METADATA +173 -0
  39. flashlite-0.1.0.dist-info/RECORD +41 -0
  40. flashlite-0.1.0.dist-info/WHEEL +4 -0
  41. flashlite-0.1.0.dist-info/licenses/LICENSE.md +21 -0
@@ -0,0 +1,378 @@
1
+ """Multi-agent conversation support for agent-to-agent interactions."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from ..types import CompletionResponse
7
+
8
+ if TYPE_CHECKING:
9
+ from ..client import Flashlite
10
+
11
+
12
+ @dataclass
13
+ class Agent:
14
+ """
15
+ An agent with a name, persona, and optional model override.
16
+
17
+ Attributes:
18
+ name: Display name for the agent (used in transcript and message attribution)
19
+ system_prompt: The agent's personality, instructions, and behavior guidelines
20
+ model: Optional model override (uses MultiAgentChat default if None)
21
+
22
+ Example:
23
+ agent = Agent(
24
+ name="Scientist",
25
+ system_prompt="You are a curious scientist who loves experiments.",
26
+ model="gpt-4o", # Optional: use specific model for this agent
27
+ )
28
+ """
29
+
30
+ name: str
31
+ system_prompt: str
32
+ model: str | None = None
33
+
34
+
35
+ @dataclass
36
+ class ChatMessage:
37
+ """A message in the multi-agent conversation."""
38
+
39
+ agent_name: str
40
+ content: str
41
+ metadata: dict[str, Any] = field(default_factory=dict)
42
+
43
+
44
+ class MultiAgentChat:
45
+ """
46
+ Manages conversations between multiple AI agents.
47
+
48
+ This class enables agent-to-agent conversations where multiple AI agents
49
+ can discuss, debate, or collaborate. Each agent maintains its own persona
50
+ and sees the conversation from its perspective.
51
+
52
+ Key features:
53
+ - Multiple agents with different personas and optionally different models
54
+ - Automatic context building from each agent's perspective
55
+ - Round-robin or directed turn-taking
56
+ - Full conversation transcript with metadata
57
+ - Support for injecting external messages (moderator, user input)
58
+
59
+ How it works:
60
+ - Each agent has a system prompt defining their persona
61
+ - When an agent speaks, they see:
62
+ - Their own previous messages as "assistant" role
63
+ - Other agents' messages as "user" role with name attribution
64
+ - This creates natural back-and-forth conversation
65
+
66
+ Example:
67
+ client = Flashlite(default_model="gpt-4o-mini")
68
+ chat = MultiAgentChat(client)
69
+
70
+ # Add agents with different personas
71
+ chat.add_agent(Agent(
72
+ name="Optimist",
73
+ system_prompt="You see the positive side of everything. Be concise."
74
+ ))
75
+ chat.add_agent(Agent(
76
+ name="Skeptic",
77
+ system_prompt="You question assumptions. Be concise."
78
+ ))
79
+
80
+ # Start with a topic
81
+ chat.add_message("Moderator", "Discuss: Will AI help or hurt jobs?")
82
+
83
+ # Have agents take turns
84
+ await chat.speak("Optimist") # Optimist responds
85
+ await chat.speak("Skeptic") # Skeptic responds to Optimist
86
+ await chat.speak("Optimist") # Continue the debate
87
+
88
+ # Or use round-robin for structured turns
89
+ await chat.round_robin(rounds=2)
90
+
91
+ # Get formatted transcript
92
+ print(chat.format_transcript())
93
+ """
94
+
95
+ def __init__(
96
+ self,
97
+ client: "Flashlite",
98
+ default_model: str | None = None,
99
+ ):
100
+ """
101
+ Initialize a multi-agent chat.
102
+
103
+ Args:
104
+ client: Flashlite client for making completions
105
+ default_model: Default model for agents (uses client default if None)
106
+ """
107
+ self._client = client
108
+ self._default_model = default_model
109
+ self._agents: dict[str, Agent] = {}
110
+ self._transcript: list[ChatMessage] = []
111
+
112
+ def add_agent(self, agent: Agent) -> "MultiAgentChat":
113
+ """
114
+ Add an agent to the chat.
115
+
116
+ Args:
117
+ agent: Agent to add
118
+
119
+ Returns:
120
+ Self for method chaining
121
+
122
+ Example:
123
+ chat.add_agent(Agent("Alice", "You are helpful."))
124
+ .add_agent(Agent("Bob", "You are curious."))
125
+ """
126
+ self._agents[agent.name] = agent
127
+ return self
128
+
129
+ def remove_agent(self, agent_name: str) -> bool:
130
+ """
131
+ Remove an agent from the chat.
132
+
133
+ Args:
134
+ agent_name: Name of agent to remove
135
+
136
+ Returns:
137
+ True if agent was removed, False if not found
138
+ """
139
+ if agent_name in self._agents:
140
+ del self._agents[agent_name]
141
+ return True
142
+ return False
143
+
144
+ def add_message(
145
+ self,
146
+ agent_name: str,
147
+ content: str,
148
+ metadata: dict[str, Any] | None = None,
149
+ ) -> "MultiAgentChat":
150
+ """
151
+ Manually add a message to the transcript.
152
+
153
+ Useful for:
154
+ - Injecting moderator or facilitator prompts
155
+ - Adding user input to the conversation
156
+ - Simulating agent messages for testing
157
+
158
+ Args:
159
+ agent_name: Name to attribute the message to
160
+ content: Message content
161
+ metadata: Optional metadata to attach
162
+
163
+ Returns:
164
+ Self for method chaining
165
+ """
166
+ self._transcript.append(
167
+ ChatMessage(
168
+ agent_name=agent_name,
169
+ content=content,
170
+ metadata=metadata or {},
171
+ )
172
+ )
173
+ return self
174
+
175
+ async def speak(
176
+ self,
177
+ agent_name: str,
178
+ additional_context: str | None = None,
179
+ **kwargs: Any,
180
+ ) -> str:
181
+ """
182
+ Have an agent respond to the conversation.
183
+
184
+ The agent sees the full conversation history from their perspective:
185
+ - Their own previous messages appear as "assistant" messages
186
+ - Other agents' messages appear as "user" messages with name attribution
187
+
188
+ Args:
189
+ agent_name: Name of the agent to speak
190
+ additional_context: Optional extra context/instruction for this turn
191
+ **kwargs: Additional kwargs passed to client.complete()
192
+
193
+ Returns:
194
+ The agent's response content
195
+
196
+ Raises:
197
+ ValueError: If agent_name is not found
198
+ """
199
+ if agent_name not in self._agents:
200
+ raise ValueError(
201
+ f"Unknown agent: {agent_name}. Available agents: {list(self._agents.keys())}"
202
+ )
203
+
204
+ agent = self._agents[agent_name]
205
+
206
+ # Build messages from this agent's perspective
207
+ messages = self._build_messages_for(agent)
208
+
209
+ # Add any additional context as a user message
210
+ if additional_context:
211
+ messages.append({"role": "user", "content": additional_context})
212
+
213
+ # Make completion
214
+ response: CompletionResponse = await self._client.complete(
215
+ model=agent.model or self._default_model,
216
+ messages=messages,
217
+ **kwargs,
218
+ )
219
+
220
+ # Record in transcript with metadata
221
+ self._transcript.append(
222
+ ChatMessage(
223
+ agent_name=agent_name,
224
+ content=response.content,
225
+ metadata={
226
+ "model": response.model,
227
+ "tokens": response.usage.total_tokens if response.usage else None,
228
+ },
229
+ )
230
+ )
231
+
232
+ return response.content
233
+
234
+ def _build_messages_for(self, agent: Agent) -> list[dict[str, str]]:
235
+ """
236
+ Build the message history from a specific agent's perspective.
237
+
238
+ The agent's own messages become "assistant" role (what they said).
239
+ Other agents' messages become "user" role with speaker attribution.
240
+ """
241
+ messages: list[dict[str, str]] = []
242
+
243
+ # System prompt for this agent
244
+ messages.append({"role": "system", "content": agent.system_prompt})
245
+
246
+ # Add conversation history
247
+ for msg in self._transcript:
248
+ if msg.agent_name == agent.name:
249
+ # Agent's own previous messages
250
+ messages.append({"role": "assistant", "content": msg.content})
251
+ else:
252
+ # Other agents'/sources' messages - prefix with speaker name
253
+ messages.append({"role": "user", "content": f"[{msg.agent_name}]: {msg.content}"})
254
+
255
+ return messages
256
+
257
+ async def round_robin(
258
+ self,
259
+ rounds: int = 1,
260
+ **kwargs: Any,
261
+ ) -> list[str]:
262
+ """
263
+ Have all agents speak in turn for the specified number of rounds.
264
+
265
+ Each agent speaks once per round, in the order they were added.
266
+
267
+ Args:
268
+ rounds: Number of complete rounds (each agent speaks once per round)
269
+ **kwargs: Additional kwargs passed to speak()
270
+
271
+ Returns:
272
+ List of all responses in order
273
+ """
274
+ responses = []
275
+ agent_names = list(self._agents.keys())
276
+
277
+ for _ in range(rounds):
278
+ for name in agent_names:
279
+ response = await self.speak(name, **kwargs)
280
+ responses.append(response)
281
+
282
+ return responses
283
+
284
+ async def speak_sequence(
285
+ self,
286
+ agent_sequence: list[str],
287
+ **kwargs: Any,
288
+ ) -> list[str]:
289
+ """
290
+ Have agents speak in a specific sequence.
291
+
292
+ Args:
293
+ agent_sequence: List of agent names in desired speaking order
294
+ **kwargs: Additional kwargs passed to speak()
295
+
296
+ Returns:
297
+ List of responses in order
298
+ """
299
+ responses = []
300
+ for name in agent_sequence:
301
+ response = await self.speak(name, **kwargs)
302
+ responses.append(response)
303
+ return responses
304
+
305
+ @property
306
+ def transcript(self) -> list[ChatMessage]:
307
+ """Get a copy of the conversation transcript."""
308
+ return list(self._transcript)
309
+
310
+ @property
311
+ def agents(self) -> dict[str, Agent]:
312
+ """Get the registered agents."""
313
+ return dict(self._agents)
314
+
315
+ @property
316
+ def agent_names(self) -> list[str]:
317
+ """Get list of agent names."""
318
+ return list(self._agents.keys())
319
+
320
+ def format_transcript(self, include_metadata: bool = False) -> str:
321
+ """
322
+ Format the transcript as a readable string.
323
+
324
+ Args:
325
+ include_metadata: Whether to include metadata like tokens used
326
+
327
+ Returns:
328
+ Formatted transcript string
329
+ """
330
+ lines = []
331
+ for msg in self._transcript:
332
+ lines.append(f"[{msg.agent_name}]:")
333
+ # Indent content for readability
334
+ for line in msg.content.split("\n"):
335
+ lines.append(f" {line}")
336
+ if include_metadata and msg.metadata:
337
+ meta_str = ", ".join(f"{k}={v}" for k, v in msg.metadata.items() if v)
338
+ if meta_str:
339
+ lines.append(f" ({meta_str})")
340
+ lines.append("")
341
+ return "\n".join(lines)
342
+
343
+ def get_messages_for(self, agent_name: str) -> list[dict[str, str]]:
344
+ """
345
+ Get the messages list as a specific agent would see it.
346
+
347
+ Useful for debugging or custom processing.
348
+
349
+ Args:
350
+ agent_name: Name of agent to get perspective for
351
+
352
+ Returns:
353
+ Messages list from that agent's perspective
354
+ """
355
+ if agent_name not in self._agents:
356
+ raise ValueError(f"Unknown agent: {agent_name}")
357
+ return self._build_messages_for(self._agents[agent_name])
358
+
359
+ def clear(self) -> "MultiAgentChat":
360
+ """
361
+ Clear the conversation transcript.
362
+
363
+ Does not remove agents.
364
+
365
+ Returns:
366
+ Self for method chaining
367
+ """
368
+ self._transcript = []
369
+ return self
370
+
371
+ def __len__(self) -> int:
372
+ """Number of messages in transcript."""
373
+ return len(self._transcript)
374
+
375
+ def __repr__(self) -> str:
376
+ return (
377
+ f"MultiAgentChat(agents={list(self._agents.keys())}, messages={len(self._transcript)})"
378
+ )
@@ -0,0 +1,13 @@
1
+ """Core completion functionality."""
2
+
3
+ from .completion import complete, complete_sync
4
+ from .messages import assistant_message, format_messages, system_message, user_message
5
+
6
+ __all__ = [
7
+ "complete",
8
+ "complete_sync",
9
+ "format_messages",
10
+ "user_message",
11
+ "system_message",
12
+ "assistant_message",
13
+ ]
@@ -0,0 +1,145 @@
1
+ """Core completion logic using litellm."""
2
+
3
+ import asyncio
4
+ from typing import Any
5
+
6
+ import litellm
7
+
8
+ from ..types import (
9
+ CompletionError,
10
+ CompletionRequest,
11
+ CompletionResponse,
12
+ Messages,
13
+ UsageInfo,
14
+ )
15
+
16
+
17
+ async def complete(request: CompletionRequest) -> CompletionResponse:
18
+ """
19
+ Execute a completion request using litellm.
20
+
21
+ This is the lowest-level async completion function.
22
+ It handles the litellm API call and response parsing.
23
+
24
+ Args:
25
+ request: The completion request
26
+
27
+ Returns:
28
+ CompletionResponse with the model's response
29
+
30
+ Raises:
31
+ CompletionError: If the API call fails
32
+ """
33
+ kwargs = request.to_litellm_kwargs()
34
+
35
+ try:
36
+ # litellm.acompletion is the async version
37
+ response = await litellm.acompletion(**kwargs)
38
+
39
+ # Extract content from response
40
+ content = ""
41
+ finish_reason = None
42
+
43
+ if response.choices:
44
+ choice = response.choices[0]
45
+ if choice.message and choice.message.content:
46
+ content = choice.message.content
47
+ finish_reason = getattr(choice, "finish_reason", None)
48
+
49
+ # Parse usage info
50
+ usage = UsageInfo.from_litellm(
51
+ response.usage.model_dump() if response.usage else None
52
+ )
53
+
54
+ return CompletionResponse(
55
+ content=content,
56
+ model=response.model or request.model,
57
+ finish_reason=finish_reason,
58
+ usage=usage,
59
+ raw_response=response,
60
+ )
61
+
62
+ except litellm.exceptions.APIConnectionError as e:
63
+ raise CompletionError(f"API connection error: {e}", response=e) from e
64
+ except litellm.exceptions.RateLimitError as e:
65
+ raise CompletionError(
66
+ f"Rate limit exceeded: {e}",
67
+ status_code=429,
68
+ response=e,
69
+ ) from e
70
+ except litellm.exceptions.APIError as e:
71
+ raise CompletionError(
72
+ f"API error: {e}",
73
+ status_code=getattr(e, "status_code", None),
74
+ response=e,
75
+ ) from e
76
+ except Exception as e:
77
+ raise CompletionError(f"Completion failed: {e}", response=e) from e
78
+
79
+
80
+ def complete_sync(request: CompletionRequest) -> CompletionResponse:
81
+ """
82
+ Synchronous version of complete().
83
+
84
+ Runs the async completion in an event loop.
85
+ """
86
+ try:
87
+ loop = asyncio.get_running_loop()
88
+ except RuntimeError:
89
+ loop = None
90
+
91
+ if loop and loop.is_running():
92
+ # We're already in an async context - need to use a new thread
93
+ import concurrent.futures
94
+
95
+ with concurrent.futures.ThreadPoolExecutor() as executor:
96
+ future = executor.submit(asyncio.run, complete(request))
97
+ return future.result()
98
+ else:
99
+ # No running loop, we can use asyncio.run
100
+ return asyncio.run(complete(request))
101
+
102
+
103
+ async def complete_simple(
104
+ model: str,
105
+ messages: Messages,
106
+ **kwargs: Any,
107
+ ) -> CompletionResponse:
108
+ """
109
+ Simplified completion function for quick usage.
110
+
111
+ Args:
112
+ model: Model identifier
113
+ messages: List of messages
114
+ **kwargs: Additional parameters passed to CompletionRequest
115
+
116
+ Returns:
117
+ CompletionResponse
118
+ """
119
+ # Separate known kwargs from extra kwargs
120
+ known_params = {
121
+ "temperature",
122
+ "max_tokens",
123
+ "max_completion_tokens",
124
+ "top_p",
125
+ "stop",
126
+ "reasoning_effort",
127
+ }
128
+
129
+ request_kwargs: dict[str, Any] = {}
130
+ extra_kwargs: dict[str, Any] = {}
131
+
132
+ for key, value in kwargs.items():
133
+ if key in known_params:
134
+ request_kwargs[key] = value
135
+ else:
136
+ extra_kwargs[key] = value
137
+
138
+ request = CompletionRequest(
139
+ model=model,
140
+ messages=messages,
141
+ extra_kwargs=extra_kwargs,
142
+ **request_kwargs,
143
+ )
144
+
145
+ return await complete(request)
@@ -0,0 +1,130 @@
1
+ """Message formatting utilities."""
2
+
3
+
4
+ from ..types import Message, Messages, Role
5
+
6
+
7
+ def user_message(content: str, name: str | None = None) -> Message:
8
+ """Create a user message."""
9
+ msg: Message = {"role": "user", "content": content}
10
+ if name:
11
+ msg["name"] = name
12
+ return msg
13
+
14
+
15
+ def system_message(content: str) -> Message:
16
+ """Create a system message."""
17
+ return {"role": "system", "content": content}
18
+
19
+
20
+ def assistant_message(content: str, name: str | None = None) -> Message:
21
+ """Create an assistant message."""
22
+ msg: Message = {"role": "assistant", "content": content}
23
+ if name:
24
+ msg["name"] = name
25
+ return msg
26
+
27
+
28
+ def tool_message(content: str, tool_call_id: str) -> Message:
29
+ """Create a tool result message."""
30
+ return {
31
+ "role": "tool",
32
+ "content": content,
33
+ "tool_call_id": tool_call_id,
34
+ }
35
+
36
+
37
+ def format_messages(
38
+ messages: Messages | str | None = None,
39
+ system: str | None = None,
40
+ user: str | None = None,
41
+ ) -> list[Message]:
42
+ """
43
+ Flexibly format messages into a standard list format.
44
+
45
+ This allows multiple ways to specify messages:
46
+ - As a list of message dicts (pass through)
47
+ - As a single string (converted to user message)
48
+ - Using system/user kwargs for simple single-turn
49
+
50
+ Args:
51
+ messages: Existing messages list or single string
52
+ system: System prompt to prepend
53
+ user: User message to append
54
+
55
+ Returns:
56
+ Normalized list of message dicts
57
+ """
58
+ result: list[Message] = []
59
+
60
+ # Add system message if provided
61
+ if system:
62
+ result.append(system_message(system))
63
+
64
+ # Handle messages parameter
65
+ if messages is not None:
66
+ if isinstance(messages, str):
67
+ # Single string becomes user message
68
+ result.append(user_message(messages))
69
+ else:
70
+ # List of messages - copy to avoid mutation
71
+ result.extend(list(messages))
72
+
73
+ # Add user message if provided separately
74
+ if user:
75
+ result.append(user_message(user))
76
+
77
+ return result
78
+
79
+
80
+ def extract_content(message: Message) -> str:
81
+ """Extract text content from a message."""
82
+ content = message.get("content", "")
83
+ if isinstance(content, str):
84
+ return content
85
+ elif isinstance(content, list):
86
+ # Handle multimodal content (text parts)
87
+ text_parts = []
88
+ for part in content:
89
+ if isinstance(part, dict) and part.get("type") == "text":
90
+ text_parts.append(part.get("text", ""))
91
+ elif isinstance(part, str):
92
+ text_parts.append(part)
93
+ return "".join(text_parts)
94
+ return str(content) if content else ""
95
+
96
+
97
+ def validate_messages(messages: Messages) -> list[str]:
98
+ """
99
+ Validate a list of messages.
100
+
101
+ Returns list of validation errors (empty if valid).
102
+ """
103
+ errors: list[str] = []
104
+ valid_roles: set[Role] = {"system", "user", "assistant", "tool"}
105
+
106
+ for i, msg in enumerate(messages):
107
+ if not isinstance(msg, dict):
108
+ errors.append(f"Message {i}: must be a dict, got {type(msg).__name__}")
109
+ continue
110
+
111
+ role = msg.get("role")
112
+ if role not in valid_roles:
113
+ errors.append(f"Message {i}: invalid role '{role}', must be one of {valid_roles}")
114
+
115
+ if "content" not in msg and "tool_calls" not in msg:
116
+ errors.append(f"Message {i}: must have 'content' or 'tool_calls'")
117
+
118
+ if role == "tool" and "tool_call_id" not in msg:
119
+ errors.append(f"Message {i}: tool message must have 'tool_call_id'")
120
+
121
+ return errors
122
+
123
+
124
+ def count_messages_by_role(messages: Messages) -> dict[str, int]:
125
+ """Count messages by role."""
126
+ counts: dict[str, int] = {}
127
+ for msg in messages:
128
+ role = msg.get("role", "unknown")
129
+ counts[role] = counts.get(role, 0) + 1
130
+ return counts
@@ -0,0 +1,18 @@
1
+ """Middleware for request/response processing."""
2
+
3
+ from .base import Middleware, MiddlewareChain
4
+ from .cache import CacheConfig, CacheMiddleware
5
+ from .logging import CostTrackingMiddleware, LoggingMiddleware
6
+ from .rate_limit import RateLimitMiddleware
7
+ from .retry import RetryMiddleware
8
+
9
+ __all__ = [
10
+ "Middleware",
11
+ "MiddlewareChain",
12
+ "RetryMiddleware",
13
+ "RateLimitMiddleware",
14
+ "CacheMiddleware",
15
+ "CacheConfig",
16
+ "LoggingMiddleware",
17
+ "CostTrackingMiddleware",
18
+ ]