Skip to content

Commit

Permalink
feat(py/reflection-api): use starlette to implement the reflection api
Browse files Browse the repository at this point in the history
…#1705

Fixes to server response, logging, middeware, preamble, etc.

ISSUE: #1705

CHANGELOG:
- [x] Switch the reflection API server to use starlette.
- [ ] Use multiprocessing to start background jobs
- [ ] Log asynchronously in a structured format using structlog
- [ ] Add tests for the reflection API server
- [ ] Test against the genkit CLI runtime.
- [ ] Update schema to match.
- [ ] Make the server configurable.
  • Loading branch information
yesudeep committed Feb 18, 2025
1 parent 428215f commit 5238c47
Show file tree
Hide file tree
Showing 21 changed files with 1,197 additions and 563 deletions.
3 changes: 2 additions & 1 deletion bin/fmt
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ if [[ $? -ne 0 ]]; then
exit 1
fi

# Format all Python code.
# Format all Python code while organizing imports.
uv run --directory "${TOP_DIR}/py" ruff check --select I --fix .
uv run --directory "${TOP_DIR}/py" ruff format .
if [[ $? -ne 0 ]]; then
exit 1
Expand Down
14 changes: 14 additions & 0 deletions py/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,17 @@ uv run pytest .
## Run test app

See the README.md in samples/hello.


## Requests:

Using httpie.

### Reflection server

#### To enlist all the actions

``` bash
http GET http://localhost:3100/api/actions
```

90 changes: 85 additions & 5 deletions py/bin/sanitize_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,29 @@
# Copyright 2025 Google LLC
# SPDX-License-Identifier: Apache-2.0


"""Standalone convenience script used to massage the schemas.py.
The `py/packages/genkit/src/genkit/core/schemas.py` file is generated by
datamodel-codegen. However, since the tool doesn't currently provide options to
generate exactly the kind of code we need, we use this convenience script to
parse the Python source code, walk the AST, modify it to include the bits we
need and regenerate the code for eventual use within our codebase.
Transformations applied:
- We remove the model_config attribute from classes that ineherit from
RootModel.
- We add the `populate_by_name=True` parameter to ensure serialization uses
camelCase for attributes since the JS implementation uses camelCase and Python
uses snake_case. The codegen pass is configured to genearte snake_case for a
Pythonic API but serialize to camelCase in order to be compatible with
runtimes.
- We add a license header
- We add a header indicating that this file has been generated by a code generator
pass.
- We add the ability to use forward references.
"""

import ast
import sys
from datetime import datetime
Expand All @@ -26,11 +49,43 @@ def is_rootmodel_class(self, node: ast.ClassDef) -> bool:
return True
return False

def create_model_config(
self, extra_forbid: bool = True, populate_by_name: bool = True
) -> ast.Assign:
"""Create a model_config assignment with the specified options."""
keywords = []
if extra_forbid:
keywords.append(
ast.keyword(arg='extra', value=ast.Constant(value='forbid'))
)
if populate_by_name:
keywords.append(
ast.keyword(
arg='populate_by_name', value=ast.Constant(value=True)
)
)

return ast.Assign(
targets=[ast.Name(id='model_config')],
value=ast.Call(
func=ast.Name(id='ConfigDict'), args=[], keywords=keywords
),
)

def has_model_config(self, node: ast.ClassDef) -> bool:
"""Check if a class already has a model_config assignment."""
for item in node.body:
if isinstance(item, ast.Assign):
targets = item.targets
if len(targets) == 1 and isinstance(targets[0], ast.Name):
if targets[0].id == 'model_config':
return True
return False

def visit_ClassDef(self, node: ast.ClassDef) -> ast.ClassDef: # noqa: N802
"""Visit class definitions and remove model_config if class
inherits from RootModel."""
"""Visit class definitions and handle model_config based on class type."""
if self.is_rootmodel_class(node):
# Filter out model_config assignments
# Filter out model_config assignments for RootModel classes
new_body = []
for item in node.body:
if isinstance(item, ast.Assign):
Expand All @@ -41,10 +96,35 @@ def visit_ClassDef(self, node: ast.ClassDef) -> ast.ClassDef: # noqa: N802
else:
new_body.append(item)

if len(new_body) != len(node.body):
self.modified = True
if len(new_body) != len(node.body):
self.modified = True

node.body = new_body
else:
# For non-RootModel classes that inherit from BaseModel
if any(
isinstance(base, ast.Name) and base.id == 'BaseModel'
for base in node.bases
):
if not self.has_model_config(node):
# Add model_config with populate_by_name=True
node.body.insert(0, self.create_model_config())
self.modified = True
else:
# Update existing model_config to include populate_by_name=True
new_body = []
for item in node.body:
if isinstance(item, ast.Assign):
targets = item.targets
if len(targets) == 1 and isinstance(
targets[0], ast.Name
):
if targets[0].id == 'model_config':
new_body.append(self.create_model_config())
self.modified = True
continue
new_body.append(item)
node.body = new_body

return node

Expand Down
5 changes: 5 additions & 0 deletions py/engdoc/ROADMAP.org
Original file line number Diff line number Diff line change
Expand Up @@ -218,3 +218,8 @@
- [ ] Acquire from current owner
- [ ] PyPi project for genkit https://pypi.org/project/genkit/
- [ ] Acquire from current owner

** Integration Tests [/]
- [ ] Go
- [ ] Python
- [X] JS
4 changes: 4 additions & 0 deletions py/packages/genkit/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ dependencies = [
"pydantic>=2.10.5",
"requests>=2.32.3",
"dotprompt",
"starlette>=0.45.3",
"structlog>=25.1.0",
"uvicorn>=0.34.0",
"fastapi>=0.115.8",
]
description = "Genkit AI Framework"
license = { text = "Apache-2.0" }
Expand Down
101 changes: 78 additions & 23 deletions py/packages/genkit/src/genkit/core/action.py
Original file line number Diff line number Diff line change
@@ -1,43 +1,96 @@
# Copyright 2025 Google LLC
# SPDX-License-Identifier: Apache-2.

import inspect
import json
from collections.abc import Callable
from enum import Enum
from typing import Any

from genkit.core.tracing import tracer
from pydantic import BaseModel, ConfigDict, TypeAdapter
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter


class ActionKind(str, Enum):
"""Enumerates all the types of action that can be registered."""

CHATLLM = 'chat-llm'
CUSTOM = 'custom'
EMBEDDER = 'embedder'
EVALUATOR = 'evaluator'
FLOW = 'flow'
INDEXER = 'indexer'
MODEL = 'model'
PROMPT = 'prompt'
RETRIEVER = 'retriever'
TEXTLLM = 'text-llm'
TOOL = 'tool'
UTIL = 'util'


class ActionResponse(BaseModel):
model_config = ConfigDict(extra='forbid')
"""The response from an action."""

model_config = ConfigDict(extra='forbid', populate_by_name=True)

response: Any
trace_id: str
trace_id: str = Field(alias='traceId')


class Action:
class ActionMetadataKey(str, Enum):
"""Enumerates all the keys of the action metadata."""

INPUT_KEY = 'inputSchema'
OUTPUT_KEY = 'outputSchema'
RETURN = 'return'


class Action:
"""An action is a Typed JSON-based RPC-over-HTTP remote-callable function
that supports metadata, streaming, reflection and discovery.
It is strongly-typed, named, observable, uninterrupted operation that can be
in streaming or non-streaming mode. It wraps a function that takes an input,
and returns an output, optionally streaming values incrementally by invoking
a streaming callback.
An action can be registered in the registry and then be used in a flow.
It can be of different kinds as defined by the ActionKind enum.
"""

def __init__(
self,
action_type: str,
kind: ActionKind,
name: str,
fn: Callable,
description: str | None = None,
metadata: dict[str, Any] | None = None,
metadata: dict[ActionMetadataKey, Any] | None = None,
span_metadata: dict[str, str] | None = None,
):
"""Initialize an action.
Args:
kind: The kind of the action.
name: The name of the action.
fn: The function to call when the action is executed.
description: The description of the action.
metadata: The metadata of the action.
span_metadata: The span metadata of the action.
Raises:
ValueError: If the action kind is not supported.
"""
# TODO(Tatsiana Havina): separate a long constructor into methods.
self.type = action_type
self.kind: ActionKind = kind
self.name = name

def fn_to_call(*args, **kwargs):
def tracing_wrapper(*args, **kwargs):
"""Wraps the callable function in a tracing span and adds metadata
to it."""

with tracer.start_as_current_span(name) as span:
trace_id = str(span.get_span_context().trace_id)
span.set_attribute('genkit:type', action_type)
span.set_attribute('genkit:type', kind)
span.set_attribute('genkit:name', name)

if span_metadata is not None:
Expand All @@ -46,9 +99,8 @@ def fn_to_call(*args, **kwargs):

if len(args) > 0:
if isinstance(args[0], BaseModel):
span.set_attribute(
'genkit:input', args[0].model_dump_json()
)
encoded = args[0].model_dump_json(by_alias=True)
span.set_attribute('genkit:input', encoded)
else:
span.set_attribute('genkit:input', json.dumps(args[0]))

Expand All @@ -57,36 +109,39 @@ def fn_to_call(*args, **kwargs):
span.set_attribute('genkit:state', 'success')

if isinstance(output, BaseModel):
span.set_attribute(
'genkit:output', output.model_dump_json()
)
encoded = output.model_dump_json(by_alias=True)
span.set_attribute('genkit:output', encoded)
else:
span.set_attribute('genkit:output', json.dumps(output))

return ActionResponse(response=output, trace_id=trace_id)

self.fn = fn_to_call
self.fn = tracing_wrapper
self.description = description
self.metadata = metadata if metadata else {}

input_spec = inspect.getfullargspec(fn)
action_args = [k for k in input_spec.annotations if k != self.RETURN]
action_args = [
k for k in input_spec.annotations if k != ActionMetadataKey.RETURN
]

if len(action_args) > 1:
raise Exception('can only have one arg')
if len(action_args) > 0:
type_adapter = TypeAdapter(input_spec.annotations[action_args[0]])
self.input_schema = type_adapter.json_schema()
self.input_type = type_adapter
self.metadata[self.INPUT_KEY] = self.input_schema
self.metadata[ActionMetadataKey.INPUT_KEY] = self.input_schema
else:
self.input_schema = TypeAdapter(Any).json_schema()
self.metadata[self.INPUT_KEY] = self.input_schema
self.metadata[ActionMetadataKey.INPUT_KEY] = self.input_schema

if self.RETURN in input_spec.annotations:
type_adapter = TypeAdapter(input_spec.annotations[self.RETURN])
if ActionMetadataKey.RETURN in input_spec.annotations:
type_adapter = TypeAdapter(
input_spec.annotations[ActionMetadataKey.RETURN]
)
self.output_schema = type_adapter.json_schema()
self.metadata[self.OUTPUT_KEY] = self.output_schema
self.metadata[ActionMetadataKey.OUTPUT_KEY] = self.output_schema
else:
self.output_schema = TypeAdapter(Any).json_schema()
self.metadata[self.OUTPUT_KEY] = self.output_schema
self.metadata[ActionMetadataKey.OUTPUT_KEY] = self.output_schema
23 changes: 23 additions & 0 deletions py/packages/genkit/src/genkit/core/action_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env python3
#
# Copyright 2025 Google LLC
# SPDX-License-Identifier: Apache-2.0

from genkit.core.action import ActionKind


def test_action_enum_behaves_like_str() -> None:
"""Ensure the ActionType behaves like a string and to ensure we're using the
correct variants."""
assert ActionKind.CHATLLM == 'chat-llm'
assert ActionKind.CUSTOM == 'custom'
assert ActionKind.EMBEDDER == 'embedder'
assert ActionKind.EVALUATOR == 'evaluator'
assert ActionKind.FLOW == 'flow'
assert ActionKind.INDEXER == 'indexer'
assert ActionKind.MODEL == 'model'
assert ActionKind.PROMPT == 'prompt'
assert ActionKind.RETRIEVER == 'retriever'
assert ActionKind.TEXTLLM == 'text-llm'
assert ActionKind.TOOL == 'tool'
assert ActionKind.UTIL == 'util'
13 changes: 13 additions & 0 deletions py/packages/genkit/src/genkit/core/headers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Copyright 2025 Google LLC
# SPDX-License-Identifier: Apache-2.0

# -*- coding: utf-8 -*-

"""Contains definitions for custom headers used by the framework and other
related functionality."""

from enum import Enum


class HttpHeader(str, Enum):
X_GENKIT_VERSION = 'X-Genkit-Version'
1 change: 1 addition & 0 deletions py/packages/genkit/src/genkit/core/plugin_abc.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Copyright 2025 Google LLC
# SPDX-License-Identifier: Apache-2.0

from __future__ import annotations

import abc
Expand Down
Loading

0 comments on commit 5238c47

Please sign in to comment.