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,189 @@
1
+ """Structured output parsing and validation."""
2
+
3
+ import json
4
+ import logging
5
+ import re
6
+ from typing import TypeVar
7
+
8
+ from pydantic import BaseModel, ValidationError
9
+
10
+ from ..types import CompletionResponse
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ T = TypeVar("T", bound=BaseModel)
15
+
16
+
17
+ class StructuredOutputError(Exception):
18
+ """Error parsing or validating structured output."""
19
+
20
+ def __init__(
21
+ self,
22
+ message: str,
23
+ raw_content: str | None = None,
24
+ validation_errors: list[dict] | None = None,
25
+ ):
26
+ super().__init__(message)
27
+ self.raw_content = raw_content
28
+ self.validation_errors = validation_errors or []
29
+
30
+
31
+ def parse_json_response(content: str) -> dict:
32
+ """
33
+ Parse JSON from an LLM response.
34
+
35
+ Handles common cases where the model wraps JSON in markdown code blocks
36
+ or includes extra text.
37
+
38
+ Args:
39
+ content: The raw response content from the LLM
40
+
41
+ Returns:
42
+ Parsed JSON as a dict
43
+
44
+ Raises:
45
+ StructuredOutputError: If JSON cannot be parsed
46
+ """
47
+ # Strip whitespace
48
+ content = content.strip()
49
+
50
+ # Try direct parse first
51
+ try:
52
+ return json.loads(content)
53
+ except json.JSONDecodeError:
54
+ pass
55
+
56
+ # Try to extract JSON from markdown code blocks
57
+ # Match ```json ... ``` or ``` ... ```
58
+ json_block_pattern = r"```(?:json)?\s*([\s\S]*?)```"
59
+ matches = re.findall(json_block_pattern, content)
60
+
61
+ for match in matches:
62
+ try:
63
+ return json.loads(match.strip())
64
+ except json.JSONDecodeError:
65
+ continue
66
+
67
+ # Try to find JSON object boundaries
68
+ # Look for first { and last }
69
+ first_brace = content.find("{")
70
+ last_brace = content.rfind("}")
71
+
72
+ if first_brace != -1 and last_brace != -1 and last_brace > first_brace:
73
+ try:
74
+ return json.loads(content[first_brace : last_brace + 1])
75
+ except json.JSONDecodeError:
76
+ pass
77
+
78
+ # Try to find JSON array boundaries
79
+ first_bracket = content.find("[")
80
+ last_bracket = content.rfind("]")
81
+
82
+ if first_bracket != -1 and last_bracket != -1 and last_bracket > first_bracket:
83
+ try:
84
+ return json.loads(content[first_bracket : last_bracket + 1])
85
+ except json.JSONDecodeError:
86
+ pass
87
+
88
+ raise StructuredOutputError(
89
+ f"Could not parse JSON from response: {content[:200]}...",
90
+ raw_content=content,
91
+ )
92
+
93
+
94
+ def validate_response(
95
+ response: CompletionResponse,
96
+ model: type[T],
97
+ ) -> T:
98
+ """
99
+ Parse and validate a completion response against a Pydantic model.
100
+
101
+ Args:
102
+ response: The completion response to validate
103
+ model: The Pydantic model class to validate against
104
+
105
+ Returns:
106
+ A validated instance of the model
107
+
108
+ Raises:
109
+ StructuredOutputError: If parsing or validation fails
110
+ """
111
+ content = response.content
112
+
113
+ # Parse JSON
114
+ try:
115
+ data = parse_json_response(content)
116
+ except StructuredOutputError:
117
+ raise
118
+
119
+ # Validate against model
120
+ try:
121
+ return model.model_validate(data)
122
+ except ValidationError as e:
123
+ errors = e.errors()
124
+ error_messages = []
125
+ for err in errors:
126
+ loc = ".".join(str(x) for x in err["loc"])
127
+ msg = err["msg"]
128
+ error_messages.append(f" - {loc}: {msg}")
129
+
130
+ raise StructuredOutputError(
131
+ "Validation failed:\n" + "\n".join(error_messages),
132
+ raw_content=content,
133
+ validation_errors=[dict(e) for e in errors],
134
+ )
135
+
136
+
137
+ def format_validation_error_for_retry(error: StructuredOutputError) -> str:
138
+ """
139
+ Format a validation error as feedback for the model to retry.
140
+
141
+ This creates a message that explains what went wrong so the model
142
+ can correct its response.
143
+
144
+ Args:
145
+ error: The structured output error
146
+
147
+ Returns:
148
+ A formatted error message for the retry prompt
149
+ """
150
+ lines = ["Your previous response had the following errors:", ""]
151
+
152
+ if error.validation_errors:
153
+ for err in error.validation_errors:
154
+ loc = ".".join(str(x) for x in err.get("loc", []))
155
+ msg = err.get("msg", "Unknown error")
156
+ input_val = err.get("input")
157
+
158
+ if loc:
159
+ lines.append(f"- Field '{loc}': {msg}")
160
+ if input_val is not None:
161
+ lines.append(f" Got: {input_val!r}")
162
+ else:
163
+ lines.append(f"- {msg}")
164
+ else:
165
+ lines.append(f"- {str(error)}")
166
+
167
+ lines.append("")
168
+ lines.append("Please correct these errors and respond with valid JSON.")
169
+
170
+ return "\n".join(lines)
171
+
172
+
173
+ def extract_json_from_content(content: str) -> str | None:
174
+ """
175
+ Extract just the JSON portion from content that may contain other text.
176
+
177
+ Returns the JSON string if found, None otherwise.
178
+
179
+ Args:
180
+ content: The raw content
181
+
182
+ Returns:
183
+ The extracted JSON string or None
184
+ """
185
+ try:
186
+ data = parse_json_response(content)
187
+ return json.dumps(data)
188
+ except StructuredOutputError:
189
+ return None
@@ -0,0 +1,165 @@
1
+ """JSON schema generation from Pydantic models."""
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel
7
+
8
+
9
+ def generate_json_schema(model: type[BaseModel]) -> dict[str, Any]:
10
+ """
11
+ Generate a JSON schema from a Pydantic model.
12
+
13
+ This uses Pydantic's built-in schema generation and formats it
14
+ for use with LLM structured output features.
15
+
16
+ Args:
17
+ model: A Pydantic BaseModel class
18
+
19
+ Returns:
20
+ JSON schema dict suitable for LLM prompts or response_format
21
+ """
22
+ # Use Pydantic's built-in schema generation
23
+ schema = model.model_json_schema()
24
+
25
+ # Clean up schema for LLM consumption
26
+ schema = _simplify_schema(schema)
27
+
28
+ return schema
29
+
30
+
31
+ def _simplify_schema(schema: dict[str, Any]) -> dict[str, Any]:
32
+ """
33
+ Simplify a JSON schema for LLM consumption.
34
+
35
+ Removes Pydantic-specific fields and flattens $defs references
36
+ where possible for cleaner prompts.
37
+ """
38
+ # Remove Pydantic-specific metadata
39
+ keys_to_remove = ["title", "$defs", "definitions"]
40
+
41
+ # If there are $defs, inline them
42
+ defs = schema.get("$defs", schema.get("definitions", {}))
43
+
44
+ def resolve_refs(obj: Any) -> Any:
45
+ """Recursively resolve $ref references."""
46
+ if isinstance(obj, dict):
47
+ if "$ref" in obj:
48
+ ref_path = obj["$ref"]
49
+ # Extract the definition name from "#/$defs/Name"
50
+ if ref_path.startswith("#/$defs/"):
51
+ def_name = ref_path.split("/")[-1]
52
+ if def_name in defs:
53
+ # Return a copy of the resolved definition
54
+ resolved = resolve_refs(defs[def_name].copy())
55
+ # Remove title from inlined definitions
56
+ resolved.pop("title", None)
57
+ return resolved
58
+ elif ref_path.startswith("#/definitions/"):
59
+ def_name = ref_path.split("/")[-1]
60
+ if def_name in defs:
61
+ resolved = resolve_refs(defs[def_name].copy())
62
+ resolved.pop("title", None)
63
+ return resolved
64
+ return {k: resolve_refs(v) for k, v in obj.items() if k not in keys_to_remove}
65
+ elif isinstance(obj, list):
66
+ return [resolve_refs(item) for item in obj]
67
+ return obj
68
+
69
+ result = resolve_refs(schema)
70
+
71
+ # Remove top-level title if present
72
+ result.pop("title", None)
73
+
74
+ return result
75
+
76
+
77
+ def schema_to_prompt(model: type[BaseModel]) -> str:
78
+ """
79
+ Convert a Pydantic model to a prompt-friendly schema description.
80
+
81
+ This generates a human-readable description of the expected JSON
82
+ structure that can be included in system prompts.
83
+
84
+ Args:
85
+ model: A Pydantic BaseModel class
86
+
87
+ Returns:
88
+ A string describing the expected JSON format
89
+ """
90
+ schema = generate_json_schema(model)
91
+
92
+ lines = ["You must respond with valid JSON matching this schema:", ""]
93
+ lines.append("```json")
94
+ lines.append(json.dumps(schema, indent=2))
95
+ lines.append("```")
96
+ lines.append("")
97
+ lines.append("Important:")
98
+ lines.append("- Respond ONLY with the JSON object, no other text")
99
+ lines.append("- Ensure all required fields are present")
100
+ lines.append("- Use the exact field names and types specified")
101
+
102
+ return "\n".join(lines)
103
+
104
+
105
+ def get_field_descriptions(model: type[BaseModel]) -> dict[str, str]:
106
+ """
107
+ Extract field descriptions from a Pydantic model.
108
+
109
+ Args:
110
+ model: A Pydantic BaseModel class
111
+
112
+ Returns:
113
+ Dict mapping field names to their descriptions
114
+ """
115
+ descriptions = {}
116
+ for field_name, field_info in model.model_fields.items():
117
+ if field_info.description:
118
+ descriptions[field_name] = field_info.description
119
+ return descriptions
120
+
121
+
122
+ def format_schema_for_openai(model: type[BaseModel]) -> dict[str, Any]:
123
+ """
124
+ Format a schema for OpenAI's structured outputs feature.
125
+
126
+ OpenAI's structured outputs (response_format with json_schema)
127
+ requires a specific format with name and strict fields.
128
+
129
+ Args:
130
+ model: A Pydantic BaseModel class
131
+
132
+ Returns:
133
+ Dict formatted for OpenAI's response_format parameter
134
+ """
135
+ schema = model.model_json_schema()
136
+
137
+ return {
138
+ "type": "json_schema",
139
+ "json_schema": {
140
+ "name": model.__name__,
141
+ "strict": True,
142
+ "schema": schema,
143
+ },
144
+ }
145
+
146
+
147
+ def is_supported_type(model: type[BaseModel]) -> bool:
148
+ """
149
+ Check if a Pydantic model uses only supported types for structured outputs.
150
+
151
+ Most types are supported, but this can help identify potential issues.
152
+
153
+ Args:
154
+ model: A Pydantic BaseModel class
155
+
156
+ Returns:
157
+ True if all field types are supported
158
+ """
159
+ # For now, we support all types that Pydantic can serialize to JSON schema
160
+ # This could be expanded to check for specific unsupported types
161
+ try:
162
+ model.model_json_schema()
163
+ return True
164
+ except Exception:
165
+ return False
@@ -0,0 +1,11 @@
1
+ """Jinja templating for prompts."""
2
+
3
+ from .engine import TemplateEngine
4
+ from .filters import register_default_filters
5
+ from .registry import TemplateRegistry
6
+
7
+ __all__ = [
8
+ "TemplateEngine",
9
+ "TemplateRegistry",
10
+ "register_default_filters",
11
+ ]
@@ -0,0 +1,217 @@
1
+ """Jinja template engine for prompt templating."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from jinja2 import (
7
+ BaseLoader,
8
+ Environment,
9
+ FileSystemLoader,
10
+ StrictUndefined,
11
+ Template,
12
+ TemplateNotFound,
13
+ Undefined,
14
+ UndefinedError,
15
+ )
16
+
17
+ from ..types import TemplateError
18
+ from .filters import register_default_filters
19
+ from .registry import TemplateRegistry
20
+
21
+
22
+ class RegistryLoader(BaseLoader):
23
+ """Jinja loader that loads templates from a TemplateRegistry."""
24
+
25
+ def __init__(self, registry: TemplateRegistry):
26
+ self.registry = registry
27
+
28
+ def get_source(
29
+ self, environment: Environment, template: str
30
+ ) -> tuple[str, str | None, Any]:
31
+ if not self.registry.has(template):
32
+ raise TemplateNotFound(template)
33
+ source = self.registry.get_source(template)
34
+ return source, template, lambda: True
35
+
36
+
37
+ class TemplateEngine:
38
+ """
39
+ Main template engine for rendering prompts.
40
+
41
+ Features:
42
+ - File-based templates from a directory
43
+ - In-memory template registry
44
+ - Custom filters for common operations
45
+ - Strict undefined checking (errors on missing variables)
46
+ - Variable validation
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ template_dir: Path | str | None = None,
52
+ strict: bool = True,
53
+ ):
54
+ """
55
+ Initialize the template engine.
56
+
57
+ Args:
58
+ template_dir: Optional directory to load templates from
59
+ strict: If True, raise error on undefined variables
60
+ """
61
+ self.registry = TemplateRegistry()
62
+ self._template_dir = Path(template_dir) if template_dir else None
63
+
64
+ # Create loaders
65
+ loaders: list[BaseLoader] = [RegistryLoader(self.registry)]
66
+ if self._template_dir and self._template_dir.exists():
67
+ loaders.append(FileSystemLoader(str(self._template_dir)))
68
+
69
+ # Create Jinja environment
70
+ # Using ChoiceLoader to try registry first, then filesystem
71
+ from jinja2 import ChoiceLoader
72
+
73
+ self.env = Environment(
74
+ loader=ChoiceLoader(loaders),
75
+ undefined=StrictUndefined if strict else Undefined,
76
+ autoescape=False, # Don't HTML-escape (we're making prompts, not HTML)
77
+ trim_blocks=True,
78
+ lstrip_blocks=True,
79
+ )
80
+
81
+ # Register default filters
82
+ register_default_filters(self.env)
83
+
84
+ # Load templates from directory if provided
85
+ if self._template_dir and self._template_dir.exists():
86
+ self.registry.load_from_directory(self._template_dir)
87
+
88
+ def render(
89
+ self,
90
+ template: str,
91
+ variables: dict[str, Any] | None = None,
92
+ validate: bool = True,
93
+ ) -> str:
94
+ """
95
+ Render a template with variables.
96
+
97
+ Args:
98
+ template: Template name or inline template string
99
+ variables: Variables to pass to the template
100
+ validate: If True, validate that all variables are provided
101
+
102
+ Returns:
103
+ Rendered template string
104
+
105
+ Raises:
106
+ TemplateError: If template not found or rendering fails
107
+ """
108
+ variables = variables or {}
109
+
110
+ try:
111
+ # Try to get from registry/filesystem first
112
+ try:
113
+ tpl = self.env.get_template(template)
114
+ except TemplateNotFound:
115
+ # Treat as inline template string
116
+ tpl = self.env.from_string(template)
117
+
118
+ if validate:
119
+ self._validate_variables(tpl, variables)
120
+
121
+ return tpl.render(**variables)
122
+
123
+ except UndefinedError as e:
124
+ raise TemplateError(f"Missing template variable: {e}") from e
125
+ except Exception as e:
126
+ raise TemplateError(f"Template rendering failed: {e}") from e
127
+
128
+ def render_string(
129
+ self,
130
+ template_string: str,
131
+ variables: dict[str, Any] | None = None,
132
+ ) -> str:
133
+ """
134
+ Render an inline template string.
135
+
136
+ Args:
137
+ template_string: The template source string
138
+ variables: Variables to pass to the template
139
+
140
+ Returns:
141
+ Rendered string
142
+ """
143
+ variables = variables or {}
144
+ try:
145
+ tpl = self.env.from_string(template_string)
146
+ return tpl.render(**variables)
147
+ except UndefinedError as e:
148
+ raise TemplateError(f"Missing template variable: {e}") from e
149
+ except Exception as e:
150
+ raise TemplateError(f"Template rendering failed: {e}") from e
151
+
152
+ def register(self, name: str, template: str) -> None:
153
+ """
154
+ Register an in-memory template.
155
+
156
+ Args:
157
+ name: Template name
158
+ template: Template source string
159
+ """
160
+ self.registry.register(name, template)
161
+
162
+ def get_variables(self, template: str) -> set[str]:
163
+ """
164
+ Get the variables used in a template.
165
+
166
+ Note: This uses AST analysis and may not catch all dynamic variables.
167
+
168
+ Args:
169
+ template: Template name or inline template string
170
+
171
+ Returns:
172
+ Set of variable names
173
+ """
174
+ from jinja2 import meta
175
+
176
+ try:
177
+ try:
178
+ self.env.get_template(template) # Validate template exists
179
+ source = self.registry.get_source(template)
180
+ except TemplateNotFound:
181
+ source = template
182
+
183
+ ast = self.env.parse(source)
184
+ return meta.find_undeclared_variables(ast)
185
+ except Exception:
186
+ return set()
187
+
188
+ def _validate_variables(self, template: Template, variables: dict[str, Any]) -> None:
189
+ """Validate that all required variables are provided."""
190
+ from jinja2 import meta
191
+
192
+ if not hasattr(template, "source"):
193
+ return
194
+
195
+ try:
196
+ ast = self.env.parse(template.source)
197
+ required = meta.find_undeclared_variables(ast)
198
+ provided = set(variables.keys())
199
+ missing = required - provided
200
+
201
+ if missing:
202
+ raise TemplateError(
203
+ f"Missing required template variables: {', '.join(sorted(missing))}"
204
+ )
205
+ except TemplateError:
206
+ raise
207
+ except Exception:
208
+ # If we can't parse, let rendering catch the error
209
+ pass
210
+
211
+ def add_filter(self, name: str, func: Any) -> None:
212
+ """Add a custom filter to the environment."""
213
+ self.env.filters[name] = func
214
+
215
+ def add_global(self, name: str, value: Any) -> None:
216
+ """Add a global variable available in all templates."""
217
+ self.env.globals[name] = value