Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion py/packages/genkit/src/genkit/_ai/_aio.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,8 +298,8 @@ def define_middleware(

def middleware(
self,
name: str,
*,
name: str,
description: str | None = None,
) -> Callable[[type[MiddlewareT]], type[MiddlewareT]]:
"""Decorator that registers a custom middleware on this app's registry."""
Expand Down
8 changes: 7 additions & 1 deletion py/packages/genkit/src/genkit/_core/_error.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,14 @@ def __init__(
self.status: StatusName = temp_status
self.http_code: int = http_status_code(temp_status)

# When this error wraps another (the common shape — the action runtime
# catches the underlying failure and re-raises as ``GenkitError(...,
# cause=original)``), surface the cause in the default string form so
# downstream consumers (logs, model-facing tool error messages, the Dev
# UI) see the real reason instead of the bare wrapper text.
source_prefix = f'{source}: ' if source else ''
super().__init__(f'{source_prefix}{self.status}: {message}')
cause_suffix = f': {cause}' if cause else ''
super().__init__(f'{source_prefix}{self.status}: {message}{cause_suffix}')
self.original_message: str = message

if not details:
Expand Down
10 changes: 10 additions & 0 deletions py/packages/genkit/tests/genkit/core/error_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ def test_genkit_error() -> None:
error_no_source = GenkitError(status='INTERNAL', message='Test message 2')
assert str(error_no_source) == 'INTERNAL: Test message 2'

# When wrapping another exception the cause should appear in str(...) too,
# so the model and any plain ``f"{e}"`` log line see the real reason.
wrapped = GenkitError(
status='INTERNAL',
message='Error while running action read_file',
cause=ValueError("File not found: 'workspace/foo.py'"),
)
assert str(wrapped) == ("INTERNAL: Error while running action read_file: File not found: 'workspace/foo.py'")
assert wrapped.original_message == 'Error while running action read_file'


def test_genkit_error_to_json() -> None:
# NOT_FOUND is a valid gRPC-style status (maps to HTTP 404).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,17 @@ async def to_gemini(cls, part: Part | DocumentPart) -> genai.types.Part | list[g
if extra_parts:
tool_output = clean_output

# Gemini's FunctionResponse requires a dict-shaped ``response``,
# but a tool can legitimately hand back any JSON value (string,
# list, int, None, ...). Envelope it as ``{name, content}`` so
# the wire payload is always a dict; the inbound converter
# unwraps the same envelope so callers see the original value.
gemini_tool_name = tool_response.name.replace('/', '__')
fn_part = genai.types.Part(
function_response=genai.types.FunctionResponse(
id=tool_response.ref,
name=tool_response.name.replace('/', '__'),
response=tool_output,
name=gemini_tool_name,
response={'name': gemini_tool_name, 'content': tool_output},
)
)
if extra_parts:
Expand Down Expand Up @@ -315,13 +321,19 @@ def from_gemini(cls, part: genai.types.Part, ref: str | None = None) -> Part:
)
)
if part.function_response:
# If the model echoes back the ``{name, content}`` envelope we
# used on the outbound side, peel it off so the caller sees the
# original tool output.
output = part.function_response.response
if isinstance(output, dict) and 'name' in output and 'content' in output:
output = output['content']
return Part(
root=ToolResponsePart(
tool_response=ToolResponse(
ref=getattr(part.function_response, 'id', None),
# restore slashes
name=(part.function_response.name or '').replace('__', '/'),
output=part.function_response.response,
output=output,
)
)
)
Expand Down
197 changes: 197 additions & 0 deletions py/plugins/middleware/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
# Genkit Middleware Plugin

A collection of middleware implementations for Firebase Genkit Python.

## Overview

This plugin provides five concrete middleware implementations for common use cases:

- **Retry**: Retries model API calls on transient errors with exponential backoff
- **Fallback**: Falls back to alternative models when the primary model fails
- **ToolApproval**: Requires explicit approval before executing tool calls
- **Skills**: Exposes a library of skills as system prompts and tools
- **Filesystem**: Provides sandboxed filesystem operations

## Quick start

Import the middleware classes you need and pass instances directly into `use=[]`:

```python
from genkit import Genkit
from genkit.plugins.middleware import Retry, Fallback, Middleware

ai = Genkit(plugins=[Middleware()])

response = await ai.generate(
model='googleai/gemini-flash-latest',
prompt='Hello!',
use=[
Retry(max_retries=5),
Fallback(models=['googleai/gemini-2.5-pro']),
],
)
```

These pre-packaged middlewares will be available to play with in the Dev UI by default.

## Installation

```bash
pip install genkit-plugin-middleware
```

## Usage

### Retry

Automatically retries model calls on transient failures with configurable exponential backoff:

```python
from genkit.plugins.middleware import Retry

retry = Retry(
max_retries=3,
statuses=['UNAVAILABLE', 'DEADLINE_EXCEEDED', 'RESOURCE_EXHAUSTED'],
initial_delay_ms=1000,
max_delay_ms=60000,
backoff_factor=2.0,
jitter=True, # set False for deterministic backoff (tests)
)

response = await ai.generate(
model='googleai/gemini-2.5-flash',
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gemini-flash-latest

prompt='Hello!',
use=[retry],
)
```

### Fallback

Falls back to alternative models on retryable errors:

```python
from genkit.plugins.middleware import Fallback

fallback = Fallback(
models=['googleai/gemini-2.5-pro', 'googleai/gemini-2.5-flash'],
statuses=['UNAVAILABLE', 'DEADLINE_EXCEEDED'],
)

response = await ai.generate(
model='googleai/gemini-2.5-ultra',
prompt='Hello!',
use=[fallback],
)
```

### ToolApproval

Requires approval before executing tools (useful for sensitive operations):

```python
from genkit.plugins.middleware import ToolApproval

approval = ToolApproval(
allowed_tools=['get_weather', 'search'], # These tools run without approval
)

response = await ai.generate(
model='googleai/gemini-2.5-flash',
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gemini-flash-latest

prompt='Delete the database',
tools=[delete_database_tool],
use=[approval],
)
```

When a non-allowed tool is called, execution is interrupted. Approve and re-run the
tool by restarting it with ``resumed_metadata`` that includes ``toolApproved``
(the middleware only treats explicit dict metadata as approval):

```python
first = await ai.generate(
model='googleai/gemini-flash-latest',
prompt='Delete the database',
tools=[delete_database_tool],
use=[approval],
)

response = await ai.generate(
model='googleai/gemini-flash-latest',
prompt='Delete the database',
messages=list(first.messages),
tools=[delete_database_tool],
use=[approval],
resume_restart=delete_database_tool.restart(
None,
interrupt=first.interrupts[0],
resumed_metadata={'toolApproved': True},
),
)
```

### Skills

Scans directories for SKILL.md files and exposes them as loadable instructions:

```python
from genkit.plugins.middleware import Skills

skills = Skills(
skill_paths=['skills', 'prompts/skills'],
)

response = await ai.generate(
model='googleai/gemini-flash-latest',
prompt='Help me with Python',
use=[skills],
)
```

Skills are discovered by scanning for directories containing `SKILL.md` files. Each `SKILL.md` can have optional YAML frontmatter:

```markdown
---
name: python-expert
description: Expert Python programming assistance
---

You are an expert Python programmer...
```

### Filesystem

Provides sandboxed file operations confined to a root directory:

```python
from genkit.plugins.middleware import Filesystem

fs = Filesystem(
root_dir='./workspace',
allow_write_access=True,
tool_name_prefix='',
)

response = await ai.generate(
model='googleai/gemini-flash-latest',
prompt='List files in the current directory',
use=[fs],
)
```

Provides four tools:
- `list_files`: List files in a directory
- `read_file`: Read file content
- `write_file`: Write to a file (requires `allow_write_access=True`)
- `edit_file`: Edit file with string replacements (requires `allow_write_access=True`)

## Development

```bash
cd py/plugins/middleware
pip install -e ".[dev]"
pytest tests/
```

## License

Apache 2.0
79 changes: 79 additions & 0 deletions py/plugins/middleware/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0

[project]
authors = [
{ name = "Google" },
]
classifiers = [
"Development Status :: 3 - Alpha",
"Environment :: Console",
"Environment :: Web Environment",
"Framework :: AsyncIO",
"Framework :: Pydantic",
"Framework :: Pydantic :: 2",
"Intended Audience :: Developers",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Topic :: Software Development :: Libraries",
"Typing :: Typed",
"License :: OSI Approved :: Apache Software License",
]
dependencies = [
"genkit>=0.5.2",
"pyyaml>=6.0",
]
description = "A collection of middleware implementations for Genkit."
keywords = [
"genkit",
"ai",
"llm",
"middleware",
]
license = "Apache-2.0"
name = "genkit-plugin-middleware"
readme = "README.md"
requires-python = ">=3.10"
version = "0.5.2"

[project.optional-dependencies]
dev = [
"pytest>=8.3.4",
"pytest-asyncio>=0.25.2",
"pytest-cov>=6.0.0",
"pytest-xdist>=3.6.1",
]

[project.urls]
"Bug Tracker" = "https://github.com/genkit-ai/genkit/issues"
"Documentation" = "https://firebase.google.com/docs/genkit"
"Homepage" = "https://github.com/genkit-ai/genkit"
"Repository" = "https://github.com/genkit-ai/genkit/tree/main/py"

[build-system]
build-backend = "hatchling.build"
requires = ["hatchling"]

[tool.hatch.build.targets.wheel]
only-include = ["src/genkit/plugins/middleware"]
sources = ["src"]
Loading
Loading