Skip to content

Migrate from Genkit Python 0.12 to 0.13

Genkit Python 0.13 makes the API feel native to Python ahead of 1.0, and it has breaking changes. For the design rationale and full changelog, see the 0.13.0 release notes.

This guide covers every change you need to make, with old and new code for each. Most apps only touch a few sections. Skim the headings and apply the ones whose old code appears in your project.

Upgrade genkit and every genkit-* package to 0.13 together. Keep them all on the same version.

Terminal window
uv add 'genkit==0.13.*' 'genkit-google-genai==0.13.*' # plus each genkit-* package you use

If you call Claude or Llama through Vertex AI Model Garden, install the extra for it:

Terminal window
uv add 'genkit-vertexai[anthropic]' # Claude
uv add 'genkit-vertexai[openai]' # Llama, Mistral and other OpenAI-compatible models

You can point a coding agent at this page:

Migrate this project from Genkit Python 0.12 to 0.13 by following https://genkit.dev/docs/python/migrating-from-0-12/ (markdown: https://github.com/genkit-ai/genkit/blob/main/py/docs/migrating-from-0.12.md).
Apply every section whose old code appears in this codebase, then run the type checker and tests and list anything you couldn't migrate.

After migrating, run your type checker (pyright, mypy or ty). Most of the renames in this guide show up there as errors.

Model helpers drop the _model suffix, and .embedding() is now .embedder().

Old:

GoogleAI.gemini_model('gemini-flash-latest')
GoogleAI.gemini_image_model('gemini-2.5-flash-image')
GoogleAI.gemini_tts_model('gemini-2.5-flash-preview-tts')
GoogleAI.embedding('gemini-embedding-001')
VertexAI.veo_model('veo-3.1-generate-001')
Anthropic.claude_model('claude-sonnet-4-6')

New:

GoogleAI.gemini('gemini-flash-latest')
GoogleAI.gemini_image('gemini-2.5-flash-image')
GoogleAI.gemini_tts('gemini-2.5-flash-preview-tts')
GoogleAI.embedder('gemini-embedding-001')
VertexAI.veo('veo-3.1-generate-001')
Anthropic.claude('claude-sonnet-4-6')

genkit now exports only what apps use. Every public name lives in exactly one namespace, and each namespace’s __init__.py lists its exports in __all__. If an import breaks, use the layout and lookup below to find its new home instead of guessing.

The layout

genkit app code: Genkit, Message, Part, Document, FlowContext, ToolContext, GenkitError, Failure, ...
genkit.model defining or inspecting models: Model, ModelRef, ModelRequest, ModelInfo, ModelSupports, ...
genkit.embedder defining or inspecting embedders: Embedder, EmbedderRef, EmbedRequest, ...
genkit.evaluator evaluator types
genkit.middleware writing middleware
genkit.formats custom output formats
genkit.plugin_api writing plugins: Plugin, Action, GenkitConfig, provider_error, ...
genkit.web serving flows: RequestData, ContextProvider, to_sse
genkit.telemetry tracing backends
genkit.testing fake models for tests
genkit.exp experimental Genkit class (adds define_agent), plus current_session
genkit.exp.agent experimental agent APIs

The rule: if an application uses it, it’s in genkit. If you need it to define a model, embedder, evaluator, middleware or format, it’s in that resource’s namespace. Plugin-only primitives are in genkit.plugin_api. Plugin packages export everything from their top-level module, such as genkit_google_genai; their submodules are private.

Find where a name went

Terminal window
# list one namespace's exports
python -c "import genkit.model as m; print(m.__all__)"
# find which namespace exports a name
python - <<'EOF'
import importlib
name = 'ModelSupports'
for mod in ['genkit', 'genkit.model', 'genkit.embedder', 'genkit.evaluator', 'genkit.middleware',
'genkit.formats', 'genkit.plugin_api', 'genkit.web', 'genkit.telemetry',
'genkit.testing', 'genkit.exp', 'genkit.exp.agent']:
if name in importlib.import_module(mod).__all__:
print(mod)
EOF

If a name isn’t exported anywhere, it was renamed or removed. Check the renames below and “Removed names”.

Renamed while moving

Old:

from genkit import Supports, Stage, ModelUsage, Constrained, ExecutablePrompt, RuntimeErrorReason

New:

from genkit import Prompt, ErrorReason
from genkit.model import ModelSupports, ModelStage, GenerationUsage, ConstrainedSupport

restart_tool and respond_to_interrupt are now methods on the interrupt part.

Old:

from genkit import restart_tool, respond_to_interrupt
restart = restart_tool(interrupt=interrupt, resumed_metadata={'approved': True})
reply = respond_to_interrupt({'status': 'declined'}, interrupt=interrupt)

New:

restart = interrupt.restart(resumed_metadata={'approved': True})
reply = interrupt.respond({'status': 'declined'})

Old:

from genkit import response
return response({'ok': True}, parts=[png])

New:

from genkit import MultipartToolResponse
return MultipartToolResponse(output={'ok': True}, content=[png])

Evaluator types are renamed, evaluation is a list, and define_evaluator takes the name and function positionally.

Old:

from genkit.evaluator import BaseDataPoint, Details, EvalFnResponse, EvalStatusEnum, Score
async def accuracy(dp: BaseDataPoint, _options=None) -> EvalFnResponse:
return EvalFnResponse(
test_case_id=dp.test_case_id or '',
evaluation=Score(score=1.0, status=EvalStatusEnum.PASS, details=Details(reasoning=why)),
)
ai.define_evaluator(name='byo/accuracy', display_name='Accuracy', definition='Matches the reference.', fn=accuracy)

New:

from genkit.evaluator import BaseDataPoint, EvaluatorResponse, Score, ScoreDetails, ScoreStatus
async def accuracy(dp: BaseDataPoint, _options=None) -> EvaluatorResponse:
return EvaluatorResponse(
test_case_id=dp.test_case_id or '',
evaluation=[Score(score=1.0, status=ScoreStatus.PASS, details=ScoreDetails(reasoning=why))],
)
ai.define_evaluator('byo/accuracy', accuracy, display_name='Accuracy', definition='Matches the reference.')

Strings instead of enums. Where 0.12 had enum classes you had to import (GoogleAIGeminiVersion, KnownGpt, KnownClaude, …), 0.13 takes plain strings. Model helpers and config fields are typed as Literal unions of the known values, so your IDE and type checker autocomplete and check them with no import. A model name the plugin doesn’t list yet still works as a plain string.

Old:

from genkit_google_genai import GoogleAIGeminiVersion
GoogleAI.gemini_model(GoogleAIGeminiVersion.GEMINI_3_FLASH_PREVIEW)

New:

GoogleAI.gemini('gemini-3-flash-preview') # autocompletes; any other string is still accepted
GeminiConfig(thinking_level='HIGH') # checked against 'MINIMAL' | 'LOW' | 'MEDIUM' | 'HIGH'

If you’re a coding agent, replace each enum member with its string value, which you can read from the 0.12 source or the Literal in the 0.13 signature.

Every removed name, and what to use instead:

  • Model enums (KnownGemini*, KnownGpt, KnownClaude, *Version, GeminiEmbeddingModels, VertexEmbeddingModels): pass the model name as a string, such as 'openai/gpt-4o'.
  • Name helpers (bedrock_name, anthropic_name, ollama_name, openai_model, genkit_eval_name, package_name): use the full 'plugin/model' string.
  • Candidate: use the response’s message.
  • Document.data, Document.data_type, Document.from_data(): build a Document with its constructor.
  • ai.registry: use the lookup methods on Genkit.
  • Plugin submodules such as genkit_openai.models: import from the plugin’s top-level package.

Constructors, plugin constructors, and every option after an action’s input are keyword-only. If you see TypeError: takes N positional arguments, name the argument.

Old:

ai = Genkit([GoogleAI()], GoogleAI.gemini_model('gemini-flash-latest'))
await my_flow.run(data, on_chunk, ctx)

New:

ai = Genkit(plugins=[GoogleAI()], model=GoogleAI.gemini('gemini-flash-latest'))
await my_flow.run(data, on_chunk=on_chunk, context=ctx)

Google Cloud plugins take project=, matching the google-cloud-* clients. Every plugin that talks to a server takes one base_url=.

Old:

ModelGarden(project_id='my-proj')
Ollama(server_address='http://localhost:11434')

New:

ModelGarden(project='my-proj')
Ollama(base_url='http://localhost:11434')

Old:

from genkit_google_genai import GeminiConfigSchema, GeminiImageConfigSchema, GeminiTtsConfigSchema, GemmaConfigSchema

New:

from genkit_google_genai import GeminiConfig, GeminiImageConfig, GeminiTtsConfig, GemmaConfig

EmbeddingTaskType and VertexAIEvaluationMetricType are no longer exported. Pass the string value instead.

Gemini config is flat. A nested key such as thinking_config now raises an error that names the flat field to use.

Old:

config={'thinking_config': {'thinking_level': 'HIGH', 'include_thoughts': True}}
config={'google_search_retrieval': True}
config={'speech_config': {'voice_config': {'prebuilt_voice_config': {'voice_name': 'Kore'}}}}

New:

config=GeminiConfig(thinking_level='HIGH', include_thoughts=True)
config=GeminiConfig(google_search=True)
config=GeminiTtsConfig(voice='Kore')

AnthropicConfig is flat. ThinkingConfig, OutputConfig, TaskBudget, RequestMetadata and the *ToolChoice types are removed.

Old:

config={'thinking': {'enabled': True, 'budgetTokens': 1024}}

New:

config=AnthropicConfig(thinking='enabled', thinking_budget=1024)
# effort, task_budget and user_id are flat fields too
config=AnthropicConfig(thinking='adaptive', effort='high', task_budget=20000, user_id='diner-42')

Tool choice moves out of provider config and onto generate.

Old:

await ai.generate(prompt=p, tools=[lookup], config={'tool_choice': {'type': 'any'}})

New:

await ai.generate(prompt=p, tools=[lookup], tool_choice='required') # 'auto' | 'required' | 'none'

Settings a config class doesn’t declare go in extra.

Old:

config={'some_provider_option': 1}

New:

config=BedrockConfig(extra={'some_provider_option': 1})

OpenAIConfig no longer has a user field.

Old:

await ai.embed(embedder=e, content=doc, options={'task_type': 'RETRIEVAL_DOCUMENT'})
result = await ai.evaluate(evaluator=ev, dataset=rows, options={...})

New:

await ai.embed(embedder=e, content=doc, config={'task_type': 'RETRIEVAL_DOCUMENT'})
results = await ai.evaluate(evaluator=ev, dataset=rows, config={...}) # now returns a list

An unknown config key now raises before the model is called. In 0.12 it was silently dropped, so this can surface typos you didn’t know about.

await ai.generate(prompt=p, config={'temprature': 0.2})
# raises GenkitError (INVALID_ARGUMENT) naming 'temprature'

Config defaults merge key by key: the model ref’s default, then the prompt’s config, then the call’s. Pass None to clear an inherited value.

An API key in model config now raises in every plugin. Move it to the plugin or to context.

Old:

await ai.generate(model=m, prompt=p, config={'api_key': user_key})

New:

# one key for the whole app
ai = Genkit(plugins=[OpenAI(api_key=os.environ['OPENAI_API_KEY'])])
# or a different key per request
await ai.generate(model=m, prompt=p, context={'secrets': {'api_key': user_key}})

Vertex AI Model Garden authenticates with ADC and rejects a per-request key. ai.embed() takes no context, so embedders always use the plugin’s key.

ActionRunContext is replaced by a typed context for each kind of action. The chunk type moves from @ai.flow(chunk_type=...) into the context’s type parameter.

Old:

from genkit import ActionRunContext
@ai.flow(chunk_type=Status)
async def run(n: int, ctx: ActionRunContext) -> str:
ctx.send_chunk(Status(step='search'))

New:

from genkit import FlowContext
@ai.flow()
async def run(n: int, ctx: FlowContext[Status]) -> str:
ctx.send_chunk(Status(step='search'))

Tools can now stream typed chunks too.

Old:

from genkit import ToolRunContext
@ai.tool()
async def prepare(dish: str, ctx: ToolRunContext) -> str: ...

New:

from genkit import ToolContext
@ai.tool()
async def prepare(dish: str, ctx: ToolContext[Progress]) -> str:
ctx.send_chunk(Progress(stage='searing', percent=40))

If you implement a model yourself:

Old:

from genkit import ActionRunContext
async def generate(request: ModelRequest, ctx: ActionRunContext) -> ModelResponse: ...

New:

from genkit.model import ModelContext
async def generate(request: ModelRequest, ctx: ModelContext) -> ModelResponse: ...

Genkit finds the context by its type annotation, and an action takes exactly one input argument.

ctx.abort_signal is removed. Cancellation is now standard asyncio.CancelledError, and it reaches the model call, any retry backoff, and every running tool.

Old:

@ai.tool()
async def slow(x: dict, ctx: ToolRunContext) -> dict:
while not done:
if ctx.abort_signal.is_set():
raise GenkitError(status='ABORTED', message='aborted')
await step()

New:

@ai.tool()
async def slow(x: dict) -> dict:
while not done:
await step() # cancellation raises asyncio.CancelledError here

To cancel a call, cancel its task:

task = asyncio.create_task(ai.generate(prompt=p))
task.cancel()

To stop a stream early, use async with (or call aclose()). A bare break without it leaves the flow running.

stream = my_flow.stream(x)
async with stream:
async for chunk in stream:
if enough(chunk):
break # cancels the flow; stream.response raises CANCELLED

Cancellation can now land at any await in your tool, so put cleanup for partial work in finally. If you catch asyncio.CancelledError yourself, re-raise it.

@ai.tool()
async def book_trip(trip: Trip) -> Booking:
hold = await reserve_seats(trip)
try:
return await confirm(hold)
finally:
if not hold.confirmed:
await release(hold)

response.error (and .error on operations and agent results) is now typed Failure, a public type you can import. It has the same fields as before: status, message, details and reason. The reason enum is renamed from RuntimeErrorReason to ErrorReason.

Old:

from genkit import RuntimeErrorReason
if res.error and res.error.reason == RuntimeErrorReason.INVALID_OUTPUT: ...

New:

from genkit import ErrorReason, Failure
if res.error and res.error.reason == ErrorReason.INVALID_OUTPUT: ...
def report(err: Failure) -> None: ...

GenkitError keeps fewer attributes, and its constructor is keyword-only.

Old:

from genkit import GenkitError
try:
...
except GenkitError as e:
log(e.original_message, e.http_code, e.trace_id, e.cause)

New:

from genkit import GenkitError
try:
...
except GenkitError as e:
log(e.message, e.status, e.__cause__)

Plugin-specific exceptions such as OllamaConnectionError are gone. Check the status on res.error instead.

Old:

try:
...
except GenkitError as e:
if isinstance(e.cause, OllamaConnectionError): ...

New:

res = await ai.generate(model=m, prompt=p)
if res.error and res.error.status == 'UNAVAILABLE': ...

Interrupt now subclasses BaseException, so except Exception: no longer catches it. If your code catches interrupts on purpose, catch Interrupt by name.

Old:

try:
require_approval(order)
except Exception as e: # caught Interrupt too
...

New:

try:
require_approval(order)
except Interrupt:
raise # only if you caught it on purpose
except Exception as e:
...

RequestData moves to genkit.web and exposes method, headers and input directly, the same way on Flask, FastAPI and Django.

Old:

from genkit.plugin_api import RequestData
async def auth(req: RequestData[HttpRequest]) -> dict:
return {'user': req.request.META.get('HTTP_AUTHORIZATION')}

New:

from genkit.web import RequestData
async def auth(req: RequestData) -> dict:
return {'user': req.headers.get('authorization')}

This only affects flows served over HTTP: genkit_flask_handler, the FastAPI and Django handlers, or genkit.web in your own route. Calling a flow or model in process (await my_flow.run(...), await ai.generate(...)) is unchanged, and you still get the full error.

  • In process, you get everything: a GenkitError with status, message, details and __cause__, or your own exception, unwrapped. Your try/except, middleware, tests and the Dev UI (with stack trace and trace link) all see it.
  • Over HTTP, the client gets a body built from that error by these rules:
What was raisedHTTP statusWhat the client gets
PublicError(status, message, details)Matches the status, such as 404 for NOT_FOUNDIts status, message and details
Invalid input to the served flow400Invalid input for flow 'save': qty: Input should be a valid integer: field names and Pydantic’s built-in messages, never the values sent
Anything else: other GenkitErrors, provider errors, your own exceptions500{"status": "INTERNAL", "message": "Internal Error"}

Streaming responses use the same body inside a data: {"error": {...}} SSE frame. Every failure is still logged on the server, tagged with the flow name: 5xx errors with the full traceback, 4xx errors as warnings.

In 0.12, the client got the message and details of any GenkitError, and the text and stack trace of any other exception.

If a client shows an error’s message or branches on its status, raise PublicError for it.

Old:

@ai.flow()
async def get_order(order_id: str) -> Order:
order = await db.find(order_id)
if order is None:
raise GenkitError(status='NOT_FOUND', message=f'Order {order_id} not found') # 0.13: client gets a 500
return order

New:

from genkit import PublicError
@ai.flow()
async def get_order(order_id: str) -> Order:
order = await db.find(order_id)
if order is None:
raise PublicError('NOT_FOUND', f'Order {order_id} not found') # client gets 404 and this message
return order

Everything in a PublicError’s message and details goes to the client as written, so keep secrets, IDs from other tenants, and internal details out of it.

A PublicError raised in a context provider is handled the same way, which is how you return a 401 or 403.

from genkit import PublicError
from genkit.web import RequestData
async def auth(req: RequestData) -> dict:
token = req.headers.get('authorization')
if not token:
raise PublicError('UNAUTHENTICATED', 'Missing bearer token') # 401
return {'user': await verify(token)}

For invalid input, the client sees Pydantic’s built-in messages. A custom validator’s own message shows as Invalid value, because it might contain server data. To show the caller your validator’s message, raise PublicError from the validator.

If you serve a flow from your own route, use the same rules with error_body and error_status:

from genkit.web import error_body, error_status
try:
result = await my_flow.run(data)
except Exception as e:
return JSONResponse(error_body(e), status_code=error_status(e))

Old:

add_gcp_telemetry(project_id='my-project', force_dev_export=True)

New:

enable_google_cloud_telemetry(project='my-project', force_dev_export=True)

The FIREBASE_PROJECT_ID fallback is gone, so set project= or GOOGLE_CLOUD_PROJECT.

Old:

from genkit.plugin_api import tracer, add_custom_exporter, AdjustingTraceExporter

New:

from genkit.telemetry import Instrumentation, configure_instrumentation

The Imagen catalog is retired. Use a Gemini image model.

Old:

GoogleAI.imagen_model('imagen-4.0-generate-001')

New:

GoogleAI.gemini_image('gemini-2.5-flash-image')

Old:

from genkit_vertexai.model_garden import ModelGardenPlugin
ai = Genkit(plugins=[ModelGardenPlugin()])

New:

from genkit_vertexai import ModelGarden
ai = Genkit(plugins=[ModelGarden()])

Install the [anthropic] or [openai] extra for the models you call (see the top of this guide).

Old:

from genkit_openai import openai_model
model = openai_model('gpt-4o')

New:

model = 'openai/gpt-4o'

Ollama() reads your pulled models and their capabilities from the server, so you no longer list them.

Old:

from genkit_ollama import EmbeddingDefinition, ModelDefinition, Ollama
Ollama(
models=[ModelDefinition(name='llama3.2')],
embedders=[EmbeddingDefinition(name='nomic-embed-text')],
server_address=os.getenv('OLLAMA_HOST'),
)

New:

from genkit_ollama import Ollama
Ollama(base_url=os.getenv('OLLAMA_HOST'))

Ollama no longer reads media from local file paths.

Old:

from genkit_amazon_bedrock import Bedrock, BedrockImageConfig, ModelDefinition
Bedrock(models=[ModelDefinition(name='us.amazon.nova-lite-v1:0')])

New:

from genkit_amazon_bedrock import Bedrock, StabilityImageConfig
Bedrock(models=['us.amazon.nova-lite-v1:0'])

genkit-flask no longer installs genkit-google-genai. Add it yourself if you use it.

Terminal window
uv add genkit-google-genai

Google GenAI and Ollama now fail on media URLs larger than 20 MB instead of downloading them.

ai.generate(prompt=...) and system= are sent exactly as written. Handlebars templates only render in define_prompt and .prompt files. This change raises no error, so search your code for {{ inside generate calls.

Old:

await ai.generate(prompt='Say hi to {{name}}')

New:

greet = ai.define_prompt(name='greet', prompt='Say hi to {{name}}')
await greet({'name': 'Ana'})

Calling a prompt takes the same keyword names as generate. The output schema is fixed by the prompt and can’t be overridden per call. Call config merges over the prompt’s config instead of replacing it.

recipe = ai.prompt('recipe')
await recipe({'dish': 'pie'}, config={'temperature': 0.2})

Prompt input is now validated, and defaults from .prompt files are filled in.

If a JSON reply is cut off, response.output is now None and response.error reports INVALID_OUTPUT. Before, you could get a partial object. Check for None where you read output.

res = await ai.generate(prompt=p, output_schema=City)
if res.output is None:
... # the reply was incomplete; see res.error

Flow input and output are now validated against their type annotations. A flow that returns the wrong shape now raises. For served flows, {"data": null} means “use the default”.

An action now raises the exception its body raised, unwrapped. If you caught GenkitError around flow.run() to handle your own exceptions, catch those types directly. Retry and Fallback only retry failures a plugin classified, such as UNAVAILABLE.

Skip these if you only use Genkit in an app.

Old:

from genkit.middleware import BaseMiddleware, GenerateMiddlewareContext
@ai.middleware(name='pii_redact')
class PiiRedact(BaseMiddleware[Cfg]):
async def model(self, params, ctx: GenerateMiddlewareContext, next_fn): ...

New:

from genkit.middleware import Middleware, MiddlewareContext
@ai.middleware
class PiiRedact(Middleware[Cfg]):
name = 'pii_redact'
async def model(self, params, ctx: MiddlewareContext, next_fn): ...

tools() returns Tool handles. GenerateMiddleware, MiddlewareRef, MiddlewarePlugin and new_middleware are removed.

Old:

from genkit.plugin_api import model, model_ref, background_model, embedder, embedder_ref, ModelConfig

New:

from genkit.model import Model, ModelRef, BackgroundModel, ModelConfig
from genkit.embedder import Embedder, EmbedderRef
from genkit.plugin_api import GenkitConfig

resolve() now receives the model ID without the plugin prefix.

Old:

async def resolve(self, kind, name):
model_id = name.removeprefix('myplugin/')

New:

async def resolve(self, kind, name):
model_id = name

Old:

from genkit.plugin_api import (
wrap_http_error, from_http_code, parse_retry_after_ms,
get_cached_client, GENKIT_CLIENT_HEADER, GENKIT_VERSION,
tracer, add_custom_exporter,
)

New:

from genkit import get_logger
from genkit.plugin_api import provider_error, loop_local_client, GENKIT_USER_AGENT
from genkit.telemetry import Instrumentation, configure_instrumentation

Also removed: Plugin.model(), Plugin.embedder() and Supports.context. generate_operation receives only what a background start uses. Custom formatters import Formatter, FormatterConfig and FormatHandler from genkit.formats.

APIs under an exp module can change in any release.

Old:

from genkit.exp import Agent, Session, InMemorySessionStore, AgentError
from genkit_fastapi import serve_agent
from genkit_middleware import Artifacts

New:

from genkit.exp.agent import Agent, Session, InMemorySessionStore
from genkit_fastapi.exp import serve_agent
from genkit_middleware.exp import Artifacts

AgentError is removed. A failed turn returns res.error, and a call that produces no turn raises GenkitError. Tools in a detached turn are cancelled with await task.abort() instead of checking abort_signal.

  1. Run your type checker. Most renames in this guide surface as errors.
  2. Run your tests.
  3. Search for {{ inside ai.generate( calls, and for reads of response.output that assume it’s never None. Neither change raises an error.