diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 08d851ec1e..ba3ad25722 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -98,11 +98,23 @@ jobs: - name: Lint with ruff run: uv run --directory py ruff check --select I . + - name: Type check with Ty + run: uv run --directory py ty check --exit-zero . + - name: Check licenses run: ./bin/check_license - name: Run Python tests run: uv run --python ${{ matrix.python-version }} --active --isolated --directory py pytest -xvs --log-level=DEBUG . + - name: Run Python tests (tox) + run: | + clean_version=$(echo "${{ matrix.python-version }}" | tr -d '.') + uv run --directory py tox -e "py$clean_version" + + - name: Run Python tests (nox) + run: | + uv run --directory py nox -s "tests-${{ matrix.python-version }}" + - name: Build distributions run: ./py/bin/build_dists diff --git a/bin/run_lint b/bin/run_lint index 66144ea11d..f682deae3b 100755 --- a/bin/run_lint +++ b/bin/run_lint @@ -24,7 +24,7 @@ PY_DIR="${TOP_DIR}/py" JS_DIR="${TOP_DIR}/js"j uv run --directory "${PY_DIR}" ruff check --select I --fix --preview --unsafe-fixes . -uv run --directory "${PY_DIR}" mypy . +uv run --directory "${PY_DIR}" ty check --exclude samples . # Disabled because there are many lint errors. #pushd "${GO_DIR}" &>/dev/null diff --git a/go/ai/example_test.go b/go/ai/example_test.go new file mode 100644 index 0000000000..afcdbbc6bb --- /dev/null +++ b/go/ai/example_test.go @@ -0,0 +1,160 @@ +// 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 + +// Package ai_test provides examples for ai package helper functions. +// +// The ai package contains helper types and functions used with genkit. +// Most generation and definition functions are in the genkit package; +// see that package for the primary API documentation. +package ai_test + +import ( + "fmt" + + "github.com/firebase/genkit/go/ai" +) + +// This example demonstrates creating different types of message parts. +func ExampleNewTextPart() { + // Create a text part + part := ai.NewTextPart("Hello, world!") + fmt.Println(part.Text) + // Output: Hello, world! +} + +// This example demonstrates creating a message with text content. +func ExampleNewUserTextMessage() { + // Create a user message with text + msg := ai.NewUserTextMessage("What is the capital of France?") + fmt.Println("Role:", msg.Role) + fmt.Println("Text:", msg.Content[0].Text) + // Output: + // Role: user + // Text: What is the capital of France? +} + +// This example demonstrates creating system and model messages. +func ExampleNewSystemTextMessage() { + // Create a system message + sysMsg := ai.NewSystemTextMessage("You are a helpful assistant.") + fmt.Println("System role:", sysMsg.Role) + + // Create a model response message + modelMsg := ai.NewModelTextMessage("I'm here to help!") + fmt.Println("Model role:", modelMsg.Role) + // Output: + // System role: system + // Model role: model +} + +// This example demonstrates creating a data part for raw string content. +func ExampleNewDataPart() { + // Create a data part with raw string content + part := ai.NewDataPart(`{"name": "Alice", "age": 30}`) + fmt.Println("Is data part:", part.IsData()) + fmt.Println("Content:", part.Text) + // Output: + // Is data part: true + // Content: {"name": "Alice", "age": 30} +} + +// This example demonstrates accessing text from a Part. +func ExamplePart_Text() { + // Create a part with text + part := ai.NewTextPart("Sample text content") + + // Access the text field directly + fmt.Println(part.Text) + // Output: Sample text content +} + +// This example demonstrates the Document type used in RAG applications. +func ExampleDocument() { + // Create a document with text content + doc := &ai.Document{ + Content: []*ai.Part{ + ai.NewTextPart("This is the document content."), + }, + Metadata: map[string]any{ + "source": "knowledge-base", + "page": 42, + }, + } + + fmt.Println("Content:", doc.Content[0].Text) + fmt.Println("Source:", doc.Metadata["source"]) + // Output: + // Content: This is the document content. + // Source: knowledge-base +} + +// This example demonstrates creating an Embedding for vector search. +func ExampleEmbedding() { + // Create an embedding (typically returned by an embedder) + embedding := &ai.Embedding{ + Embedding: []float32{0.1, 0.2, 0.3, 0.4, 0.5}, + Metadata: map[string]any{ + "source": "document-1", + }, + } + + fmt.Printf("Embedding dimensions: %d\n", len(embedding.Embedding)) + fmt.Printf("First value: %.1f\n", embedding.Embedding[0]) + // Output: + // Embedding dimensions: 5 + // First value: 0.1 +} + +// This example demonstrates creating a media part for images or other media. +func ExampleNewMediaPart() { + // Create a media part with base64-encoded image data + // In practice, you would encode actual image bytes + imageData := "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ..." + part := ai.NewMediaPart("image/png", imageData) + + fmt.Println("Is media:", part.IsMedia()) + fmt.Println("Content type:", part.ContentType) + // Output: + // Is media: true + // Content type: image/png +} + +// This example demonstrates creating a model reference with configuration. +func ExampleNewModelRef() { + // Create a reference to a model with custom configuration + // The config type depends on the model provider + modelRef := ai.NewModelRef("googleai/gemini-2.5-flash", map[string]any{ + "temperature": 0.7, + }) + + fmt.Println("Model name:", modelRef.Name()) + // Output: Model name: googleai/gemini-2.5-flash +} + +// This example demonstrates building a multi-turn conversation. +func ExampleNewUserMessage() { + // Build a conversation with multiple parts + userMsg := ai.NewUserMessage( + ai.NewTextPart("What's in this image?"), + ai.NewMediaPart("image/jpeg", "base64data..."), + ) + + fmt.Println("Role:", userMsg.Role) + fmt.Println("Parts:", len(userMsg.Content)) + // Output: + // Role: user + // Parts: 2 +} diff --git a/go/ai/gen.go b/go/ai/gen.go index cbfe5cd9c6..e391ef2215 100644 --- a/go/ai/gen.go +++ b/go/ai/gen.go @@ -18,97 +18,45 @@ package ai -type BaseDataPoint struct { - Context map[string]any `json:"context,omitempty"` - Input map[string]any `json:"input,omitempty"` - Output map[string]any `json:"output,omitempty"` - Reference map[string]any `json:"reference,omitempty"` - TestCaseID string `json:"testCaseId,omitempty"` - TraceIDs []string `json:"traceIds,omitempty"` -} - -type BaseEvalDataPoint struct { - Context map[string]any `json:"context,omitempty"` - Input map[string]any `json:"input,omitempty"` - Output map[string]any `json:"output,omitempty"` - Reference map[string]any `json:"reference,omitempty"` - TestCaseID string `json:"testCaseId,omitempty"` - TraceIDs []string `json:"traceIds,omitempty"` -} - -type CandidateError struct { - Code CandidateErrorCode `json:"code,omitempty"` - Index float64 `json:"index,omitempty"` - Message string `json:"message,omitempty"` -} - -type CandidateErrorCode string - -const ( - CandidateErrorCodeBlocked CandidateErrorCode = "blocked" - CandidateErrorCodeOther CandidateErrorCode = "other" - CandidateErrorCodeUnknown CandidateErrorCode = "unknown" -) - -type CommonRerankerOptions struct { - // Number of documents to rerank - K float64 `json:"k,omitempty"` -} - -type CommonRetrieverOptions struct { - // Number of documents to retrieve - K float64 `json:"k,omitempty"` -} - type customPart struct { - Custom map[string]any `json:"custom,omitempty"` - Data any `json:"data,omitempty"` + // Custom contains custom key-value data specific to this part. + Custom map[string]any `json:"custom,omitempty"` + // Data contains additional arbitrary data. + Data any `json:"data,omitempty"` + // Metadata contains arbitrary key-value data for this part. Metadata map[string]any `json:"metadata,omitempty"` } type dataPart struct { - Data any `json:"data,omitempty"` + // Data contains arbitrary structured data. + Data any `json:"data,omitempty"` + // Metadata contains arbitrary key-value data for this part. Metadata map[string]any `json:"metadata,omitempty"` } +// EmbedRequest represents a request to generate embeddings for documents. type EmbedRequest struct { - Input []*Document `json:"input,omitempty"` - Options any `json:"options,omitempty"` + // Input is the array of documents to generate embeddings for. + Input []*Document `json:"input,omitempty"` + // Options contains embedder-specific configuration parameters. + Options any `json:"options,omitempty"` } +// EmbedResponse contains the generated embeddings from an embed request. type EmbedResponse struct { + // Embeddings is the array of generated embedding vectors with metadata. Embeddings []*Embedding `json:"embeddings,omitempty"` } +// Embedding represents a vector embedding with associated metadata. type Embedding struct { - Embedding []float32 `json:"embedding,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` -} - -type EvalFnResponse struct { - Evaluation any `json:"evaluation,omitempty"` - SampleIndex float64 `json:"sampleIndex,omitempty"` - SpanID string `json:"spanId,omitempty"` - TestCaseID string `json:"testCaseId,omitempty"` - TraceID string `json:"traceId,omitempty"` -} - -type EvalRequest struct { - Dataset []*BaseDataPoint `json:"dataset,omitempty"` - EvalRunID string `json:"evalRunId,omitempty"` - Options any `json:"options,omitempty"` + // Embedding is the vector representation of the input. + Embedding []float32 `json:"embedding,omitempty"` + // Metadata identifies which part of a document this embedding corresponds to. + Metadata map[string]any `json:"metadata,omitempty"` } -type EvalResponse []any - -type EvalStatusEnum string - -const ( - EvalStatusEnumUNKNOWN EvalStatusEnum = "UNKNOWN" - EvalStatusEnumPASS EvalStatusEnum = "PASS" - EvalStatusEnumFAIL EvalStatusEnum = "FAIL" -) - +// FinishReason indicates why generation stopped. type FinishReason string const ( @@ -120,26 +68,47 @@ const ( FinishReasonUnknown FinishReason = "unknown" ) +// GenerateActionOptions holds configuration for a generate action request. type GenerateActionOptions struct { - Config any `json:"config,omitempty"` - Docs []*Document `json:"docs,omitempty"` - MaxTurns int `json:"maxTurns,omitempty"` - Messages []*Message `json:"messages,omitempty"` - Model string `json:"model,omitempty"` - Output *GenerateActionOutputConfig `json:"output,omitempty"` - Resume *GenerateActionResume `json:"resume,omitempty"` - ReturnToolRequests bool `json:"returnToolRequests,omitempty"` - StepName string `json:"stepName,omitempty"` - ToolChoice ToolChoice `json:"toolChoice,omitempty"` - Tools []string `json:"tools,omitempty"` -} - + // Config contains configuration parameters for the generation request. + Config any `json:"config,omitempty"` + // Docs provides retrieved documents to be used as context for this generation. + Docs []*Document `json:"docs,omitempty"` + // MaxTurns is the maximum number of tool call iterations that can be performed + // in a single generate call. Defaults to 5. + MaxTurns int `json:"maxTurns,omitempty"` + // Messages contains the conversation history for multi-turn prompting when supported. + Messages []*Message `json:"messages,omitempty"` + // Model is a model name (e.g., "vertexai/gemini-1.0-pro"). + Model string `json:"model,omitempty"` + // Output specifies the desired output format. Defaults to the model's default if unspecified. + Output *GenerateActionOutputConfig `json:"output,omitempty"` + // Resume provides options for resuming an interrupted generation. + Resume *GenerateActionResume `json:"resume,omitempty"` + // ReturnToolRequests, when true, returns tool calls for manual processing instead of + // automatically resolving them. + ReturnToolRequests bool `json:"returnToolRequests,omitempty"` + // StepName is a custom step name for this generate call to display in trace views. + // Defaults to "generate". + StepName string `json:"stepName,omitempty"` + // ToolChoice controls tool calling mode. Auto lets the model decide, required forces + // the model to choose a tool, and none forces the model not to use any tools. Defaults to auto. + ToolChoice ToolChoice `json:"toolChoice,omitempty"` + // Tools is a list of registered tool names for this generation if supported. + Tools []string `json:"tools,omitempty"` +} + +// GenerateActionResume holds options for resuming an interrupted generation. type GenerateActionResume struct { - Metadata map[string]any `json:"metadata,omitempty"` - Respond []*toolResponsePart `json:"respond,omitempty"` - Restart []*toolRequestPart `json:"restart,omitempty"` + // Metadata contains additional context for resuming the generation. + Metadata map[string]any `json:"metadata,omitempty"` + // Respond contains tool response parts to send to the model when resuming. + Respond []*toolResponsePart `json:"respond,omitempty"` + // Restart contains tool request parts to restart when resuming. + Restart []*toolRequestPart `json:"restart,omitempty"` } +// ToolChoice controls how the model uses tools. type ToolChoice string const ( @@ -148,67 +117,113 @@ const ( ToolChoiceNone ToolChoice = "none" ) +// GenerateActionOutputConfig specifies the desired output format for a generate action. type GenerateActionOutputConfig struct { - Constrained bool `json:"constrained,omitempty"` - ContentType string `json:"contentType,omitempty"` - Format string `json:"format,omitempty"` - Instructions *string `json:"instructions,omitempty"` - JsonSchema map[string]any `json:"jsonSchema,omitempty"` + // Constrained indicates whether to enforce strict adherence to the schema. + Constrained bool `json:"constrained,omitempty"` + // ContentType specifies the MIME type of the output content. + ContentType string `json:"contentType,omitempty"` + // Format specifies the desired output format (e.g., "json", "text"). + Format string `json:"format,omitempty"` + // Instructions provides additional guidance for the output format. + Instructions *string `json:"instructions,omitempty"` + // JsonSchema is a JSON Schema describing the desired structure of JSON output. + JsonSchema map[string]any `json:"jsonSchema,omitempty"` } -// GenerationCommonConfig holds configuration for generation. +// GenerationCommonConfig holds configuration parameters for model generation requests. type GenerationCommonConfig struct { - MaxOutputTokens int `json:"maxOutputTokens,omitempty"` - StopSequences []string `json:"stopSequences,omitempty"` - Temperature float64 `json:"temperature,omitempty"` - TopK int `json:"topK,omitempty"` - TopP float64 `json:"topP,omitempty"` - Version string `json:"version,omitempty"` -} - -// GenerationUsage provides information about the generation process. + // MaxOutputTokens limits the maximum number of tokens generated in the response. + MaxOutputTokens int `json:"maxOutputTokens,omitempty"` + // StopSequences specifies sequences that will cause generation to stop when encountered. + StopSequences []string `json:"stopSequences,omitempty"` + // Temperature controls randomness in generation. Higher values (e.g., 0.9) make output more random, + // while lower values (e.g., 0.1) make it more deterministic. Typical range is 0.0 to 1.0. + Temperature float64 `json:"temperature,omitempty"` + // TopK limits sampling to the K most likely tokens at each step. + TopK int `json:"topK,omitempty"` + // TopP (nucleus sampling) limits sampling to tokens whose cumulative probability exceeds P. + TopP float64 `json:"topP,omitempty"` + // Version specifies a particular version of a model family, + // e.g., "gemini-1.0-pro-001" for the "gemini-1.0-pro" family. + Version string `json:"version,omitempty"` +} + +// GenerationUsage provides information about resource consumption during generation. type GenerationUsage struct { - CachedContentTokens int `json:"cachedContentTokens,omitempty"` - Custom map[string]float64 `json:"custom,omitempty"` - InputAudioFiles int `json:"inputAudioFiles,omitempty"` - InputCharacters int `json:"inputCharacters,omitempty"` - InputImages int `json:"inputImages,omitempty"` - InputTokens int `json:"inputTokens,omitempty"` - InputVideos int `json:"inputVideos,omitempty"` - OutputAudioFiles int `json:"outputAudioFiles,omitempty"` - OutputCharacters int `json:"outputCharacters,omitempty"` - OutputImages int `json:"outputImages,omitempty"` - OutputTokens int `json:"outputTokens,omitempty"` - OutputVideos int `json:"outputVideos,omitempty"` - ThoughtsTokens int `json:"thoughtsTokens,omitempty"` - TotalTokens int `json:"totalTokens,omitempty"` -} - + // CachedContentTokens counts tokens that were served from cache. + CachedContentTokens int `json:"cachedContentTokens,omitempty"` + // Custom contains additional usage metrics specific to the model provider. + Custom map[string]float64 `json:"custom,omitempty"` + // InputAudioFiles is the number of audio files in the input. + InputAudioFiles int `json:"inputAudioFiles,omitempty"` + // InputCharacters is the number of characters in the input. + InputCharacters int `json:"inputCharacters,omitempty"` + // InputImages is the number of images in the input. + InputImages int `json:"inputImages,omitempty"` + // InputTokens is the number of tokens in the input prompt. + InputTokens int `json:"inputTokens,omitempty"` + // InputVideos is the number of videos in the input. + InputVideos int `json:"inputVideos,omitempty"` + // OutputAudioFiles is the number of audio files generated in the output. + OutputAudioFiles int `json:"outputAudioFiles,omitempty"` + // OutputCharacters is the number of characters generated in the output. + OutputCharacters int `json:"outputCharacters,omitempty"` + // OutputImages is the number of images generated in the output. + OutputImages int `json:"outputImages,omitempty"` + // OutputTokens is the number of tokens generated in the response. + OutputTokens int `json:"outputTokens,omitempty"` + // OutputVideos is the number of videos generated in the output. + OutputVideos int `json:"outputVideos,omitempty"` + // ThoughtsTokens counts tokens used in reasoning or thinking processes. + ThoughtsTokens int `json:"thoughtsTokens,omitempty"` + // TotalTokens is the sum of input and output tokens. + TotalTokens int `json:"totalTokens,omitempty"` +} + +// Media represents media content with a URL and content type. type Media struct { + // ContentType specifies the MIME type of the media. Inferred from the data URI if not provided. ContentType string `json:"contentType,omitempty"` - Url string `json:"url,omitempty"` + // Url is a "data:" or "https:" URI containing the media content. + Url string `json:"url,omitempty"` } type mediaPart struct { - Media *Media `json:"media,omitempty"` + // Media contains the media content and metadata. + Media *Media `json:"media,omitempty"` + // Metadata contains arbitrary key-value data for this part. Metadata map[string]any `json:"metadata,omitempty"` } -// Message is the contents of a model response. +// Message represents the contents of a model message in a conversation. type Message struct { - Content []*Part `json:"content,omitempty"` + // Content holds the message parts (text, media, tool calls, etc.). + Content []*Part `json:"content,omitempty"` + // Metadata contains arbitrary key-value data associated with this message. Metadata map[string]any `json:"metadata,omitempty"` - Role Role `json:"role,omitempty"` + // Role indicates which entity (system, user, model, or tool) generated this message. + Role Role `json:"role,omitempty"` } +// ModelInfo contains metadata about a model's capabilities and characteristics. type ModelInfo struct { + // ConfigSchema defines the model-specific configuration schema. ConfigSchema map[string]any `json:"configSchema,omitempty"` - Label string `json:"label,omitempty"` - Stage ModelStage `json:"stage,omitempty"` - Supports *ModelSupports `json:"supports,omitempty"` - Versions []string `json:"versions,omitempty"` -} - + // Label is a friendly display name for this model (e.g., "Google AI - Gemini Pro"). + Label string `json:"label,omitempty"` + // Stage indicates the development stage of this model. + // Featured models are recommended for general use, stable models are well-tested, + // unstable models are experimental, legacy models are not recommended for new projects, + // and deprecated models may be removed in future versions. + Stage ModelStage `json:"stage,omitempty"` + // Supports describes the capabilities that this model supports. + Supports *ModelSupports `json:"supports,omitempty"` + // Versions lists acceptable names for this model (e.g., different versions). + Versions []string `json:"versions,omitempty"` +} + +// ModelStage indicates the development stage of a model. type ModelStage string const ( @@ -219,19 +234,31 @@ const ( ModelStageDeprecated ModelStage = "deprecated" ) +// ModelSupports describes the capabilities that a model supports. type ModelSupports struct { + // Constrained indicates the level of constrained generation support (none, all, or no-tools). Constrained ConstrainedSupport `json:"constrained,omitempty"` - ContentType []string `json:"contentType,omitempty"` - Context bool `json:"context,omitempty"` - LongRunning bool `json:"longRunning,omitempty"` - Media bool `json:"media,omitempty"` - Multiturn bool `json:"multiturn,omitempty"` - Output []string `json:"output,omitempty"` - SystemRole bool `json:"systemRole,omitempty"` - ToolChoice bool `json:"toolChoice,omitempty"` - Tools bool `json:"tools,omitempty"` -} - + // ContentType lists the content types the model supports for output. + ContentType []string `json:"contentType,omitempty"` + // Context indicates whether the model can natively support document-based context grounding. + Context bool `json:"context,omitempty"` + // LongRunning indicates whether the model supports long-running operations. + LongRunning bool `json:"longRunning,omitempty"` + // Media indicates whether the model can process media as part of the prompt (multimodal input). + Media bool `json:"media,omitempty"` + // Multiturn indicates whether the model can process historical messages passed with a prompt. + Multiturn bool `json:"multiturn,omitempty"` + // Output lists the types of data the model can generate. + Output []string `json:"output,omitempty"` + // SystemRole indicates whether the model can accept messages with role "system". + SystemRole bool `json:"systemRole,omitempty"` + // ToolChoice indicates whether the model supports controlling tool choice (e.g., forced tool calling). + ToolChoice bool `json:"toolChoice,omitempty"` + // Tools indicates whether the model can perform tool calls. + Tools bool `json:"tools,omitempty"` +} + +// ConstrainedSupport indicates the level of constrained generation support. type ConstrainedSupport string const ( @@ -242,118 +269,176 @@ const ( // A ModelRequest is a request to generate completions from a model. type ModelRequest struct { - Config any `json:"config,omitempty"` - Docs []*Document `json:"docs,omitempty"` - Messages []*Message `json:"messages,omitempty"` + // Config holds model-specific configuration parameters. + Config any `json:"config,omitempty"` + // Docs provides retrieved documents to be used as context for this generation. + Docs []*Document `json:"docs,omitempty"` + // Messages contains the conversation history for the model. + Messages []*Message `json:"messages,omitempty"` // Output describes the desired response format. - Output *ModelOutputConfig `json:"output,omitempty"` - ToolChoice ToolChoice `json:"toolChoice,omitempty"` + Output *ModelOutputConfig `json:"output,omitempty"` + // ToolChoice controls how the model uses tools (auto, required, or none). + ToolChoice ToolChoice `json:"toolChoice,omitempty"` // Tools lists the available tools that the model can ask the client to run. Tools []*ToolDefinition `json:"tools,omitempty"` } -// A ModelResponse is a model's response to a [ModelRequest]. +// A ModelResponse is a model's response to a ModelRequest. type ModelResponse struct { - Custom any `json:"custom,omitempty"` - FinishMessage string `json:"finishMessage,omitempty"` - FinishReason FinishReason `json:"finishReason,omitempty"` + // Custom contains model-specific extra information. Deprecated: use Raw instead. + Custom any `json:"custom,omitempty"` + // FinishMessage provides additional details about why generation finished. + FinishMessage string `json:"finishMessage,omitempty"` + // FinishReason indicates why generation stopped (e.g., stop, length, blocked). + FinishReason FinishReason `json:"finishReason,omitempty"` // LatencyMs is the time the request took in milliseconds. - LatencyMs float64 `json:"latencyMs,omitempty"` - Message *Message `json:"message,omitempty"` + LatencyMs float64 `json:"latencyMs,omitempty"` + // Message contains the generated response content. + Message *Message `json:"message,omitempty"` + // Operation provides information about a long-running background task if applicable. Operation *Operation `json:"operation,omitempty"` - Raw any `json:"raw,omitempty"` - // Request is the [ModelRequest] struct used to trigger this response. + // Raw contains the unprocessed model-specific response data. + Raw any `json:"raw,omitempty"` + // Request is the ModelRequest struct used to trigger this response. Request *ModelRequest `json:"request,omitempty"` // Usage describes how many resources were used by this generation request. Usage *GenerationUsage `json:"usage,omitempty"` formatHandler StreamingFormatHandler } -// A ModelResponseChunk is the portion of the [ModelResponse] +// A ModelResponseChunk is the portion of the ModelResponse // that is passed to a streaming callback. type ModelResponseChunk struct { - Aggregated bool `json:"aggregated,omitempty"` - Content []*Part `json:"content,omitempty"` - Custom any `json:"custom,omitempty"` - Index int `json:"index"` - Role Role `json:"role,omitempty"` + // Aggregated indicates whether the chunk includes all data from previous chunks. + // If false, the chunk is considered incremental. + Aggregated bool `json:"aggregated,omitempty"` + // Content is the chunk of message parts to stream right now. + Content []*Part `json:"content,omitempty"` + // Custom contains model-specific extra information attached to this chunk. + Custom any `json:"custom,omitempty"` + // Index of the message this chunk belongs to. + Index int `json:"index"` + // Role indicates the entity that generated this chunk. + Role Role `json:"role,omitempty"` formatHandler StreamingFormatHandler } +// MultipartToolResponse represents a tool response with both structured output and content parts. type MultipartToolResponse struct { + // Content holds additional message parts providing context or details. Content []*Part `json:"content,omitempty"` - Output any `json:"output,omitempty"` + // Output contains the structured output data from the tool. + Output any `json:"output,omitempty"` } +// Operation represents a long-running background task. type Operation struct { - Action string `json:"action,omitempty"` - Done bool `json:"done,omitempty"` - Error *OperationError `json:"error,omitempty"` - Id string `json:"id,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` - Output any `json:"output,omitempty"` + // Action is the name of the action being performed by this operation. + Action string `json:"action,omitempty"` + // Done indicates whether the operation has completed. + Done bool `json:"done,omitempty"` + // Error contains error information if the operation failed. + Error *OperationError `json:"error,omitempty"` + // Id is the unique identifier for this operation. + Id string `json:"id,omitempty"` + // Metadata contains additional information about the operation. + Metadata map[string]any `json:"metadata,omitempty"` + // Output contains the result of the operation if it has completed successfully. + Output any `json:"output,omitempty"` } +// OperationError contains error information for a failed operation. type OperationError struct { + // Message describes the error that occurred. Message string `json:"message,omitempty"` } // OutputConfig describes the structure that the model's output -// should conform to. If Format is [OutputFormatJSON], then Schema +// should conform to. If Format is OutputFormatJSON, then Schema // can describe the desired form of the generated JSON. type ModelOutputConfig struct { - Constrained bool `json:"constrained,omitempty"` - ContentType string `json:"contentType,omitempty"` - Format string `json:"format,omitempty"` - Schema map[string]any `json:"schema,omitempty"` + // Constrained indicates whether to enforce strict adherence to the schema. + Constrained bool `json:"constrained,omitempty"` + // ContentType specifies the MIME type of the output content. + ContentType string `json:"contentType,omitempty"` + // Format specifies the desired output format (e.g., "json", "text"). + Format string `json:"format,omitempty"` + // Schema is a JSON Schema describing the desired structure of the output. + Schema map[string]any `json:"schema,omitempty"` } +// PathMetadata contains metadata about a single execution path in a trace. type PathMetadata struct { - Error string `json:"error,omitempty"` + // Error contains error information if the path failed. + Error string `json:"error,omitempty"` + // Latency is the execution time for this path in milliseconds. Latency float64 `json:"latency,omitempty"` - Path string `json:"path,omitempty"` - Status string `json:"status,omitempty"` + // Path is the identifier for this execution path. + Path string `json:"path,omitempty"` + // Status indicates the outcome of this path. + Status string `json:"status,omitempty"` } +// RankedDocumentData represents a document with a relevance score from reranking. type RankedDocumentData struct { - Content []*Part `json:"content,omitempty"` + // Content holds the document's parts (text and media). + Content []*Part `json:"content,omitempty"` + // Metadata contains the reranking score and other arbitrary key-value data. Metadata *RankedDocumentMetadata `json:"metadata,omitempty"` } +// RankedDocumentMetadata contains the relevance score and other metadata for a reranked document. type RankedDocumentMetadata struct { + // Score is the relevance score assigned by the reranker. Score float64 `json:"score,omitempty"` } type reasoningPart struct { - Metadata map[string]any `json:"metadata,omitempty"` - Reasoning string `json:"reasoning,omitempty"` + // Metadata contains arbitrary key-value data for this part. + Metadata map[string]any `json:"metadata,omitempty"` + // Reasoning contains the reasoning text of the message. + Reasoning string `json:"reasoning,omitempty"` } +// RerankerRequest represents a request to rerank documents based on relevance. type RerankerRequest struct { + // Documents is the array of documents to rerank. Documents []*Document `json:"documents,omitempty"` - Options any `json:"options,omitempty"` - Query *Document `json:"query,omitempty"` + // Options contains reranker-specific configuration parameters. + Options any `json:"options,omitempty"` + // Query is the document to use for reranking. + Query *Document `json:"query,omitempty"` } +// RerankerResponse contains the reranked documents with relevance scores. type RerankerResponse struct { + // Documents is the array of reranked documents with scores. Documents []*RankedDocumentData `json:"documents,omitempty"` } type resourcePart struct { + // Metadata contains arbitrary key-value data for this part. Metadata map[string]any `json:"metadata,omitempty"` - Resource *ResourcePart `json:"resource,omitempty"` + // Resource contains a reference to an external resource by URI. + Resource *ResourcePart `json:"resource,omitempty"` } type ResourcePart struct { + // Uri is the URI of the external resource. Uri string `json:"uri,omitempty"` } +// RetrieverRequest represents a request to retrieve relevant documents. type RetrieverRequest struct { - Options any `json:"options,omitempty"` - Query *Document `json:"query,omitempty"` + // Options contains retriever-specific configuration parameters. + Options any `json:"options,omitempty"` + // Query is the document to use for retrieval. + Query *Document `json:"query,omitempty"` } +// RetrieverResponse contains the retrieved documents from a retriever request. type RetrieverResponse struct { + // Documents is the array of retrieved documents. Documents []*Document `json:"documents,omitempty"` } @@ -372,63 +457,83 @@ const ( RoleTool Role = "tool" ) +// ScoreDetails provides additional context and explanation for an evaluation score. type ScoreDetails struct { + // Reasoning explains the rationale behind the score. Reasoning string `json:"reasoning,omitempty"` } type textPart struct { + // Metadata contains arbitrary key-value data for this part. Metadata map[string]any `json:"metadata,omitempty"` - Text string `json:"text,omitempty"` + // Text contains the textual content. + Text string `json:"text,omitempty"` } // A ToolDefinition describes a tool. type ToolDefinition struct { + // Description explains what the tool does and when to use it. Description string `json:"description,omitempty"` - // Valid JSON Schema representing the input of the tool. + // InputSchema is a valid JSON Schema representing the input parameters of the tool. InputSchema map[string]any `json:"inputSchema,omitempty"` - // additional metadata for this tool definition + // Metadata contains additional information about this tool definition. Metadata map[string]any `json:"metadata,omitempty"` - Name string `json:"name,omitempty"` - // Valid JSON Schema describing the output of the tool. + // Name is the unique identifier for this tool. + Name string `json:"name,omitempty"` + // OutputSchema is a valid JSON Schema describing the output of the tool. OutputSchema map[string]any `json:"outputSchema,omitempty"` } // A ToolRequest is a message from the model to the client that it should run a -// specific tool and pass a [ToolResponse] to the model on the next chat request it makes. -// Any ToolRequest will correspond to some [ToolDefinition] previously sent by the client. +// specific tool and pass a ToolResponse to the model on the next chat request it makes. +// Any ToolRequest will correspond to some ToolDefinition previously sent by the client. type ToolRequest struct { - // Input is a JSON object describing the input values to the tool. - // An example might be map[string]any{"country":"USA", "president":3}. - Input any `json:"input,omitempty"` - Name string `json:"name,omitempty"` - Partial bool `json:"partial,omitempty"` - Ref string `json:"ref,omitempty"` + // Input is a JSON object containing the input parameters for the tool. + // For example: map[string]any{"country":"USA", "president":3}. + Input any `json:"input,omitempty"` + // Name is the name of the tool to call. + Name string `json:"name,omitempty"` + // Partial indicates whether this is a partial streaming chunk. + Partial bool `json:"partial,omitempty"` + // Ref is the call ID or reference for this specific request. + Ref string `json:"ref,omitempty"` } type toolRequestPart struct { - Metadata map[string]any `json:"metadata,omitempty"` - ToolRequest *ToolRequest `json:"toolRequest,omitempty"` + // Metadata contains arbitrary key-value data for this part. + Metadata map[string]any `json:"metadata,omitempty"` + // ToolRequest is a request for a tool to be executed, usually provided by a model. + ToolRequest *ToolRequest `json:"toolRequest,omitempty"` } // A ToolResponse is a message from the client to the model containing // the results of running a specific tool on the arguments passed to the client -// by the model in a [ToolRequest]. +// by the model in a ToolRequest. type ToolResponse struct { + // Content holds additional message parts that provide context or details about the tool response. Content []*Part `json:"content,omitempty"` - Name string `json:"name,omitempty"` + // Name is the name of the tool that was executed. + Name string `json:"name,omitempty"` // Output is a JSON object describing the results of running the tool. - // An example might be map[string]any{"name":"Thomas Jefferson", "born":1743}. - Output any `json:"output,omitempty"` - Ref string `json:"ref,omitempty"` + // For example: map[string]any{"name":"Thomas Jefferson", "born":1743}. + Output any `json:"output,omitempty"` + // Ref is the call ID or reference matching the original request. + Ref string `json:"ref,omitempty"` } type toolResponsePart struct { - Metadata map[string]any `json:"metadata,omitempty"` - ToolResponse *ToolResponse `json:"toolResponse,omitempty"` + // Metadata contains arbitrary key-value data for this part. + Metadata map[string]any `json:"metadata,omitempty"` + // ToolResponse is a provided response to a tool call. + ToolResponse *ToolResponse `json:"toolResponse,omitempty"` } +// TraceMetadata contains metadata about a trace execution. type TraceMetadata struct { - FeatureName string `json:"featureName,omitempty"` - Paths []*PathMetadata `json:"paths,omitempty"` - Timestamp float64 `json:"timestamp,omitempty"` + // FeatureName identifies the feature being traced. + FeatureName string `json:"featureName,omitempty"` + // Paths contains metadata for each path executed during the trace. + Paths []*PathMetadata `json:"paths,omitempty"` + // Timestamp is when the trace was created. + Timestamp float64 `json:"timestamp,omitempty"` } diff --git a/go/ai/generate.go b/go/ai/generate.go index 0d3d75e7db..08359c99c7 100644 --- a/go/ai/generate.go +++ b/go/ai/generate.go @@ -640,6 +640,10 @@ func GenerateDataStream[Out any](ctx context.Context, r api.Registry, opts ...Ge yield(nil, err) return err } + // Skip yielding if there's no parseable output yet (e.g., incomplete JSON during streaming). + if base.IsNil(streamValue) { + return nil + } if !yield(&StreamValue[Out, Out]{Chunk: streamValue}, nil) { return errGenerateStop } diff --git a/go/ai/prompt.go b/go/ai/prompt.go index e4ef99a643..4d0151c4c8 100644 --- a/go/ai/prompt.go +++ b/go/ai/prompt.go @@ -32,6 +32,7 @@ import ( "github.com/firebase/genkit/go/core" "github.com/firebase/genkit/go/core/api" "github.com/firebase/genkit/go/core/logger" + "github.com/firebase/genkit/go/core/x/session" "github.com/firebase/genkit/go/internal/base" "github.com/google/dotprompt/go/dotprompt" "github.com/invopop/jsonschema" @@ -588,14 +589,19 @@ func renderPrompt(ctx context.Context, opts promptOptions, templateText string, // renderDotpromptToMessages executes a dotprompt prompt function and converts the result to a slice of messages func renderDotpromptToMessages(ctx context.Context, promptFn dotprompt.PromptFunction, input map[string]any, additionalMetadata *dotprompt.PromptMetadata) ([]*Message, error) { // Prepare the context for rendering - context := map[string]any{} + templateContext := map[string]any{} actionCtx := core.FromContext(ctx) - maps.Copy(context, actionCtx) + maps.Copy(templateContext, actionCtx) + + // Inject session state if available (accessible via {{@state.field}} in templates) + if state := session.StateFromContext(ctx); state != nil { + templateContext["state"] = state + } // Call the prompt function with the input and context rendered, err := promptFn(&dotprompt.DataArgument{ Input: input, - Context: context, + Context: templateContext, }, additionalMetadata) if err != nil { return nil, fmt.Errorf("failed to render prompt: %w", err) @@ -776,7 +782,11 @@ func LoadPromptFromSource(r api.Registry, source, name, namespace string) (Promp } if inputSchema, ok := metadata.Input.Schema.(map[string]any); ok { - opts.InputSchema = inputSchema + if ref, ok := inputSchema["$ref"].(string); ok { + opts.InputSchema = core.SchemaRef(ref) + } else { + opts.InputSchema = inputSchema + } } if metadata.Output.Format != "" { @@ -794,6 +804,17 @@ func LoadPromptFromSource(r api.Registry, source, name, namespace string) (Promp } } + if outputSchema, ok := metadata.Output.Schema.(map[string]any); ok { + if ref, ok := outputSchema["$ref"].(string); ok { + opts.OutputSchema = core.SchemaRef(ref) + } else { + opts.OutputSchema = outputSchema + } + if opts.OutputFormat == "" { + opts.OutputFormat = OutputFormatJSON + } + } + key := promptKey(name, variant, namespace) prompt := DefinePrompt(r, key, opts, WithPrompt(parsedPrompt.Template)) @@ -950,6 +971,10 @@ func (dp *DataPrompt[In, Out]) ExecuteStream(ctx context.Context, input In, opts yield(nil, err) return err } + // Skip yielding if there's no parseable output yet (e.g., incomplete JSON during streaming). + if base.IsNil(streamValue) { + return nil + } if !yield(&StreamValue[Out, Out]{Chunk: streamValue}, nil) { return errGenerateStop } diff --git a/go/ai/prompt_test.go b/go/ai/prompt_test.go index 7bc0ed3a5b..af6857be21 100644 --- a/go/ai/prompt_test.go +++ b/go/ai/prompt_test.go @@ -26,6 +26,7 @@ import ( "github.com/firebase/genkit/go/core" "github.com/firebase/genkit/go/core/api" + "github.com/firebase/genkit/go/core/x/session" "github.com/firebase/genkit/go/internal/base" "github.com/firebase/genkit/go/internal/registry" "github.com/google/go-cmp/cmp" @@ -1368,6 +1369,68 @@ Simple prompt t.Fatal("Prompt 'simple' was not registered") } }) + + t.Run("prompt with inline output schema", func(t *testing.T) { + reg := registry.New() + ConfigureFormats(reg) + + source := `--- +model: test/chat +output: + format: json + schema: + type: object + properties: + title: + type: string + description: + type: string + required: + - title + - description +--- +Generate something +` + prompt, err := LoadPromptFromSource(reg, source, "outputSchemaPrompt", "") + if err != nil { + t.Fatalf("LoadPromptFromRaw failed: %v", err) + } + if prompt == nil { + t.Fatal("LoadPromptFromRaw returned nil prompt") + } + + actionOpts, err := prompt.Render(context.Background(), nil) + if err != nil { + t.Fatalf("Render failed: %v", err) + } + + // Verify that the output config is set correctly + if actionOpts.Output == nil { + t.Fatal("Expected Output config to be set") + } + if actionOpts.Output.Format != OutputFormatJSON { + t.Errorf("Expected output format 'json', got %q", actionOpts.Output.Format) + } + if actionOpts.Output.JsonSchema == nil { + t.Fatal("Expected output JsonSchema to be set for inline schema") + } + + // Verify the schema structure + schema := actionOpts.Output.JsonSchema + if schema["type"] != "object" { + t.Errorf("Expected schema type 'object', got %v", schema["type"]) + } + properties, ok := schema["properties"].(map[string]any) + if !ok { + t.Fatal("Expected schema properties to be a map") + } + if _, ok := properties["title"]; !ok { + t.Error("Expected schema to have 'title' property") + } + if _, ok := properties["description"]; !ok { + t.Error("Expected schema to have 'description' property") + } + }) } // TestDefinePartialAndHelperJourney demonstrates a complete user journey for defining @@ -2168,6 +2231,143 @@ func TestPromptExecuteStream(t *testing.T) { }) } +// TestSessionStateInjection tests that session state is automatically injected +// into prompt templates and accessible via {{@state.field}} syntax. +func TestSessionStateInjection(t *testing.T) { + r := registry.New() + ConfigureFormats(r) + + // Define a test state type + type UserState struct { + Name string `json:"name"` + Preferences map[string]string `json:"preferences"` + } + + t.Run("session state accessible in prompt template", func(t *testing.T) { + var capturedPrompt string + + testModel := DefineModel(r, "test/sessionStateModel", &ModelOptions{ + Supports: &ModelSupports{Multiturn: true}, + }, func(ctx context.Context, req *ModelRequest, cb ModelStreamCallback) (*ModelResponse, error) { + capturedPrompt = req.Messages[0].Text() + return &ModelResponse{ + Request: req, + Message: NewModelTextMessage("response"), + }, nil + }) + + // Create a prompt that uses {{@state.name}} syntax + p := DefinePrompt(r, "sessionStatePrompt", + WithModel(testModel), + WithPrompt("Hello {{@state.name}}, your theme is {{@state.preferences.theme}}"), + ) + + // Create a session with state + ctx := context.Background() + sess, err := session.New(ctx, session.WithInitialState(UserState{ + Name: "Alice", + Preferences: map[string]string{"theme": "dark"}, + })) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + // Attach session to context + ctx = session.NewContext(ctx, sess) + + // Execute prompt with session in context + _, err = p.Execute(ctx) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + // Verify the session state was injected into the template + expected := "Hello Alice, your theme is dark" + if capturedPrompt != expected { + t.Errorf("Expected prompt %q, got %q", expected, capturedPrompt) + } + }) + + t.Run("prompt works without session in context", func(t *testing.T) { + var capturedPrompt string + + testModel := DefineModel(r, "test/noSessionModel", &ModelOptions{ + Supports: &ModelSupports{Multiturn: true}, + }, func(ctx context.Context, req *ModelRequest, cb ModelStreamCallback) (*ModelResponse, error) { + capturedPrompt = req.Messages[0].Text() + return &ModelResponse{ + Request: req, + Message: NewModelTextMessage("response"), + }, nil + }) + + // Create a prompt that uses regular input variables (not session state) + p := DefinePrompt(r, "noSessionPrompt", + WithModel(testModel), + WithPrompt("Hello {{name}}"), + WithInputType(struct { + Name string `json:"name"` + }{}), + ) + + // Execute without session in context + ctx := context.Background() + _, err := p.Execute(ctx, WithInput(map[string]any{"name": "Bob"})) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + expected := "Hello Bob" + if capturedPrompt != expected { + t.Errorf("Expected prompt %q, got %q", expected, capturedPrompt) + } + }) + + t.Run("session state and input variables can be used together", func(t *testing.T) { + var capturedPrompt string + + testModel := DefineModel(r, "test/mixedModel", &ModelOptions{ + Supports: &ModelSupports{Multiturn: true}, + }, func(ctx context.Context, req *ModelRequest, cb ModelStreamCallback) (*ModelResponse, error) { + capturedPrompt = req.Messages[0].Text() + return &ModelResponse{ + Request: req, + Message: NewModelTextMessage("response"), + }, nil + }) + + // Create a prompt that uses both input and session state + p := DefinePrompt(r, "mixedPrompt", + WithModel(testModel), + WithPrompt("User {{@state.name}} asks: {{question}}"), + WithInputType(struct { + Question string `json:"question"` + }{}), + ) + + // Create session + ctx := context.Background() + sess, err := session.New(ctx, session.WithInitialState(UserState{ + Name: "Charlie", + })) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + ctx = session.NewContext(ctx, sess) + + // Execute with both session and input + _, err = p.Execute(ctx, WithInput(map[string]any{"question": "What is the weather?"})) + if err != nil { + t.Fatalf("Execute failed: %v", err) + } + + expected := "User Charlie asks: What is the weather?" + if capturedPrompt != expected { + t.Errorf("Expected prompt %q, got %q", expected, capturedPrompt) + } + }) +} + // TestDefineExecuteOptionInteractions tests the complex interactions between // options set at DefinePrompt time vs Execute time. func TestDefineExecuteOptionInteractions(t *testing.T) { diff --git a/go/core/doc.go b/go/core/doc.go new file mode 100644 index 0000000000..e4528df7f6 --- /dev/null +++ b/go/core/doc.go @@ -0,0 +1,230 @@ +// 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 + +/* +Package core implements Genkit's foundational action system and runtime machinery. + +This package is primarily intended for plugin developers and Genkit internals. +Application developers should use the genkit package instead, which provides +a higher-level, more convenient API. + +# Actions + +Actions are the fundamental building blocks of Genkit. Every operation - flows, +model calls, tool invocations, retrieval - is implemented as an action. Actions +provide: + + - Type-safe input/output with JSON schema validation + - Automatic tracing and observability + - Consistent error handling + - Registration in the action registry + +Define a non-streaming action: + + action := core.DefineAction(registry, "myAction", + func(ctx context.Context, input string) (string, error) { + return "processed: " + input, nil + }, + ) + + result, err := action.Run(context.Background(), "hello") + +Define a streaming action that sends chunks during execution: + + streamingAction := core.DefineStreamingAction(registry, "countdown", + func(ctx context.Context, start int, cb core.StreamCallback[string]) (string, error) { + for i := start; i > 0; i-- { + if cb != nil { + if err := cb(ctx, fmt.Sprintf("T-%d", i)); err != nil { + return "", err + } + } + time.Sleep(time.Second) + } + return "Liftoff!", nil + }, + ) + +# Flows + +Flows are user-defined actions that orchestrate AI operations. They are the +primary way application developers define business logic in Genkit: + + flow := core.DefineFlow(registry, "myFlow", + func(ctx context.Context, input string) (string, error) { + // Use Run to create traced sub-steps + result, err := core.Run(ctx, "step1", func() (string, error) { + return process(input), nil + }) + if err != nil { + return "", err + } + return result, nil + }, + ) + +Streaming flows can send intermediate results to callers: + + streamingFlow := core.DefineStreamingFlow(registry, "generateReport", + func(ctx context.Context, input Input, cb core.StreamCallback[Progress]) (Report, error) { + for i := 0; i < 100; i += 10 { + if cb != nil { + cb(ctx, Progress{Percent: i}) + } + // ... work ... + } + return Report{...}, nil + }, + ) + +# Traced Steps with Run + +Use [Run] within flows to create traced sub-operations. Each Run call creates +a span in the trace that's visible in the Genkit Developer UI: + + result, err := core.Run(ctx, "fetchData", func() (Data, error) { + return fetchFromAPI() + }) + + processed, err := core.Run(ctx, "processData", func() (Result, error) { + return process(result) + }) + +# Middleware + +Actions support middleware for cross-cutting concerns like logging, metrics, +or authentication: + + loggingMiddleware := func(next core.StreamingFunc[string, string, struct{}]) core.StreamingFunc[string, string, struct{}] { + return func(ctx context.Context, input string, cb core.StreamCallback[struct{}]) (string, error) { + log.Printf("Input: %s", input) + output, err := next(ctx, input, cb) + log.Printf("Output: %s, Error: %v", output, err) + return output, err + } + } + +Chain multiple middleware together: + + combined := core.ChainMiddleware(loggingMiddleware, metricsMiddleware) + wrappedFn := combined(originalFunc) + +# Schema Management + +Register JSON schemas for use in prompts and validation: + + // Define a schema from a map + core.DefineSchema(registry, "Person", map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + "age": map[string]any{"type": "integer"}, + }, + "required": []any{"name"}, + }) + + // Define a schema from a Go type (recommended) + core.DefineSchemaFor[Person](registry) + +Schemas can be referenced in .prompt files by name. + +# Plugin Development + +Plugins extend Genkit's functionality by providing models, tools, retrievers, +and other capabilities. Implement the [api.Plugin] interface: + + type MyPlugin struct { + APIKey string + } + + func (p *MyPlugin) Name() string { + return "myplugin" + } + + func (p *MyPlugin) Init(ctx context.Context) []api.Action { + // Initialize the plugin and return actions to register + model := ai.DefineModel(...) + tool := ai.DefineTool(...) + return []api.Action{model, tool} + } + +For plugins that resolve actions dynamically (e.g., listing available models +from an API), implement [api.DynamicPlugin]: + + type DynamicModelPlugin struct{} + + func (p *DynamicModelPlugin) ListActions(ctx context.Context) []api.ActionDesc { + // Return descriptors of available actions + return []api.ActionDesc{ + {Key: "/model/myplugin/model-a", Name: "model-a"}, + {Key: "/model/myplugin/model-b", Name: "model-b"}, + } + } + + func (p *DynamicModelPlugin) ResolveAction(atype api.ActionType, name string) api.Action { + // Create and return the action on demand + return createModel(name) + } + +# Background Actions + +For long-running operations, use background actions that return immediately +with an operation ID that can be polled for completion: + + bgAction := core.DefineBackgroundAction(registry, "longTask", + func(ctx context.Context, input Input) (Output, error) { + // Start the operation + return startLongOperation(input) + }, + func(ctx context.Context, op *core.Operation[Output]) (*core.Operation[Output], error) { + // Check operation status + return checkOperationStatus(op) + }, + ) + +# Error Handling + +Return user-facing errors with appropriate status codes: + + if err := validate(input); err != nil { + return nil, core.NewPublicError(core.INVALID_ARGUMENT, "Invalid input", map[string]any{ + "field": "email", + "error": err.Error(), + }) + } + +For internal errors that should be logged but not exposed to users: + + return nil, core.NewError(core.INTERNAL, "database connection failed: %v", err) + +# Context + +Access action context for metadata and configuration: + + ctx := core.FromContext(ctx) + if ctx != nil { + // Access action-specific context values + } + +Set action context for nested operations: + + ctx = core.WithActionContext(ctx, core.ActionContext{ + "requestId": requestID, + }) + +For more information, see https://genkit.dev/docs/plugins +*/ +package core diff --git a/go/core/example_test.go b/go/core/example_test.go new file mode 100644 index 0000000000..c6212c3e9d --- /dev/null +++ b/go/core/example_test.go @@ -0,0 +1,197 @@ +// 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 + +package core_test + +import ( + "context" + "fmt" + "strings" + + "github.com/firebase/genkit/go/core" + "github.com/firebase/genkit/go/internal/registry" +) + +// This example demonstrates defining a simple flow. +func ExampleDefineFlow() { + r := registry.New() + + // Define a flow that processes input + flow := core.DefineFlow(r, "uppercase", + func(ctx context.Context, input string) (string, error) { + return strings.ToUpper(input), nil + }, + ) + + // Run the flow + result, err := flow.Run(context.Background(), "hello") + if err != nil { + fmt.Println("Error:", err) + return + } + fmt.Println(result) + // Output: HELLO +} + +// This example demonstrates defining a streaming flow. +func ExampleDefineStreamingFlow() { + r := registry.New() + + // Define a streaming flow that counts down + flow := core.DefineStreamingFlow(r, "countdown", + func(ctx context.Context, start int, cb core.StreamCallback[int]) (string, error) { + for i := start; i > 0; i-- { + if cb != nil { + if err := cb(ctx, i); err != nil { + return "", err + } + } + } + return "Done!", nil + }, + ) + + // Use Stream() iterator to receive chunks + iter := flow.Stream(context.Background(), 3) + iter(func(val *core.StreamingFlowValue[string, int], err error) bool { + if err != nil { + fmt.Println("Error:", err) + return false + } + if val.Done { + fmt.Println("Result:", val.Output) + } else { + fmt.Println("Count:", val.Stream) + } + return true + }) + // Output: + // Count: 3 + // Count: 2 + // Count: 1 + // Result: Done! +} + +// This example demonstrates using Run to create traced sub-steps. +func ExampleRun() { + r := registry.New() + + // Define a flow that uses Run for traced steps + flow := core.DefineFlow(r, "pipeline", + func(ctx context.Context, input string) (string, error) { + // Each Run creates a traced step visible in the Dev UI + upper, err := core.Run(ctx, "toUpper", func() (string, error) { + return strings.ToUpper(input), nil + }) + if err != nil { + return "", err + } + + result, err := core.Run(ctx, "addPrefix", func() (string, error) { + return "RESULT: " + upper, nil + }) + return result, err + }, + ) + + result, err := flow.Run(context.Background(), "hello") + if err != nil { + fmt.Println("Error:", err) + return + } + fmt.Println(result) + // Output: RESULT: HELLO +} + +// This example demonstrates defining a schema from a Go type. +func ExampleDefineSchemaFor() { + r := registry.New() + + // Define a struct type + type Person struct { + Name string `json:"name"` + Age int `json:"age"` + } + + // Register the schema + core.DefineSchemaFor[Person](r) + + // The schema is now registered and can be referenced in .prompt files + fmt.Println("Schema registered") + // Output: Schema registered +} + +// This example demonstrates defining a schema from a map. +func ExampleDefineSchema() { + r := registry.New() + + // Define a JSON schema as a map + core.DefineSchema(r, "Address", map[string]any{ + "type": "object", + "properties": map[string]any{ + "street": map[string]any{"type": "string"}, + "city": map[string]any{"type": "string"}, + "zip": map[string]any{"type": "string"}, + }, + "required": []any{"street", "city"}, + }) + + fmt.Println("Schema registered: Address") + // Output: Schema registered: Address +} + +// This example demonstrates using ChainMiddleware to combine middleware. +func ExampleChainMiddleware() { + // Define a middleware that wraps function calls + logMiddleware := func(next core.StreamingFunc[string, string, struct{}]) core.StreamingFunc[string, string, struct{}] { + return func(ctx context.Context, input string, cb core.StreamCallback[struct{}]) (string, error) { + fmt.Println("Before:", input) + result, err := next(ctx, input, cb) + fmt.Println("After:", result) + return result, err + } + } + + // The original function + originalFn := func(ctx context.Context, input string, cb core.StreamCallback[struct{}]) (string, error) { + return strings.ToUpper(input), nil + } + + // Chain and apply middleware + wrapped := core.ChainMiddleware(logMiddleware)(originalFn) + + result, _ := wrapped(context.Background(), "hello", nil) + fmt.Println("Final:", result) + // Output: + // Before: hello + // After: HELLO + // Final: HELLO +} + +// This example demonstrates creating user-facing errors. +func ExampleNewPublicError() { + // Create a user-facing error with details + err := core.NewPublicError(core.INVALID_ARGUMENT, "Invalid email format", map[string]any{ + "field": "email", + "value": "not-an-email", + }) + + fmt.Println("Status:", err.Status) + fmt.Println("Message:", err.Message) + // Output: + // Status: INVALID_ARGUMENT + // Message: Invalid email format +} diff --git a/go/core/logger/doc.go b/go/core/logger/doc.go new file mode 100644 index 0000000000..b3e421abc6 --- /dev/null +++ b/go/core/logger/doc.go @@ -0,0 +1,110 @@ +// 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 + +/* +Package logger provides context-scoped structured logging for Genkit. + +This package wraps the standard library's [log/slog] package to provide +context-aware logging throughout Genkit operations. Logs are automatically +associated with the current action or flow context. + +# Usage + +Retrieve the logger from context within action or flow handlers: + + func myFlow(ctx context.Context, input string) (string, error) { + log := logger.FromContext(ctx) + + log.Info("Processing input", "size", len(input)) + log.Debug("Input details", "value", input) + + result, err := process(input) + if err != nil { + log.Error("Processing failed", "error", err) + return "", err + } + + log.Info("Processing complete", "resultSize", len(result)) + return result, nil + } + +# Log Levels + +Control the global log level to filter output: + + // Show debug logs (verbose) + logger.SetLevel(slog.LevelDebug) + + // Show info and above (default) + logger.SetLevel(slog.LevelInfo) + + // Show only warnings and errors + logger.SetLevel(slog.LevelWarn) + + // Show only errors + logger.SetLevel(slog.LevelError) + + // Get the current log level + level := logger.GetLevel() + +# Context Integration + +The logger is automatically available in action and flow contexts. It +inherits from the context passed to [genkit.Init] and flows through +all nested operations. + +For custom operations outside of actions/flows, attach a logger to context: + + log := slog.Default() + ctx = logger.WithContext(ctx, log) + +# slog Compatibility + +The logger returned by [FromContext] is a standard [*slog.Logger] and +supports all slog methods: + + log := logger.FromContext(ctx) + + // Structured logging with attributes + log.Info("User action", + "userId", userID, + "action", "login", + "duration", elapsed, + ) + + // Grouped attributes + log.Info("Request completed", + slog.Group("request", + "method", r.Method, + "path", r.URL.Path, + ), + slog.Group("response", + "status", status, + "bytes", written, + ), + ) + + // With pre-set attributes + requestLog := log.With("requestId", requestID) + requestLog.Info("Starting") + // ... later ... + requestLog.Info("Finished") + +This package is primarily used by Genkit internals but is useful for +plugin developers who need consistent logging that integrates with +Genkit's observability features. +*/ +package logger diff --git a/go/core/schemas.config b/go/core/schemas.config index 746d10ca4c..70798f2eb3 100644 --- a/go/core/schemas.config +++ b/go/core/schemas.config @@ -1,6 +1,866 @@ # This file holds configuration for the genkit-schema.json file # generated by the npm export:schemas script. +# ============================================================================ +# DOCUMENTATION SECTION +# All type and field documentation in one consolidated location +# ============================================================================ + +# ---------------------------------------------------------------------------- +# Core Message Types +# ---------------------------------------------------------------------------- + +Role doc +Role indicates which entity is responsible for the content of a message. +. + +RoleSystem doc +RoleSystem indicates this message is user-independent context. +. + +RoleUser doc +RoleUser indicates this message was generated by the client. +. + +RoleModel doc +RoleModel indicates this message was generated by the model during a previous interaction. +. + +RoleTool doc +RoleTool indicates this message was generated by a local tool, likely triggered by a request +from the model in one of its previous responses. +. + +Message doc +Message represents the contents of a model message in a conversation. +. + +Message.role doc +Role indicates which entity (system, user, model, or tool) generated this message. +. + +Message.content doc +Content holds the message parts (text, media, tool calls, etc.). +. + +Message.metadata doc +Metadata contains arbitrary key-value data associated with this message. +. + +# ---------------------------------------------------------------------------- +# Part Types (Message Content) +# ---------------------------------------------------------------------------- + +TextPart.text doc +Text contains the textual content. +. + +TextPart.metadata doc +Metadata contains arbitrary key-value data for this part. +. + +MediaPart.media doc +Media contains the media content and metadata. +. + +MediaPart.metadata doc +Metadata contains arbitrary key-value data for this part. +. + +ToolRequestPart.toolRequest doc +ToolRequest is a request for a tool to be executed, usually provided by a model. +. + +ToolRequestPart.metadata doc +Metadata contains arbitrary key-value data for this part. +. + +ToolResponsePart.toolResponse doc +ToolResponse is a provided response to a tool call. +. + +ToolResponsePart.metadata doc +Metadata contains arbitrary key-value data for this part. +. + +DataPart.data doc +Data contains arbitrary structured data. +. + +DataPart.metadata doc +Metadata contains arbitrary key-value data for this part. +. + +ReasoningPart.reasoning doc +Reasoning contains the reasoning text of the message. +. + +ReasoningPart.metadata doc +Metadata contains arbitrary key-value data for this part. +. + +CustomPart.custom doc +Custom contains custom key-value data specific to this part. +. + +CustomPart.data doc +Data contains additional arbitrary data. +. + +CustomPart.metadata doc +Metadata contains arbitrary key-value data for this part. +. + +ResourcePart.resource doc +Resource contains a reference to an external resource by URI. +. + +ResourcePart.metadata doc +Metadata contains arbitrary key-value data for this part. +. + +ResourcePartResource.uri doc +Uri is the URI of the external resource. +. + +# ---------------------------------------------------------------------------- +# Media Types +# ---------------------------------------------------------------------------- + +Media doc +Media represents media content with a URL and content type. +. + +Media.contentType doc +ContentType specifies the MIME type of the media. Inferred from the data URI if not provided. +. + +Media.url doc +Url is a "data:" or "https:" URI containing the media content. +. + +# ---------------------------------------------------------------------------- +# Tool Types +# ---------------------------------------------------------------------------- + +ToolRequest doc +A ToolRequest is a message from the model to the client that it should run a +specific tool and pass a ToolResponse to the model on the next chat request it makes. +Any ToolRequest will correspond to some ToolDefinition previously sent by the client. +. + +ToolRequest.ref doc +Ref is the call ID or reference for this specific request. +. + +ToolRequest.name doc +Name is the name of the tool to call. +. + +ToolRequest.input doc +Input is a JSON object containing the input parameters for the tool. +For example: map[string]any{"country":"USA", "president":3}. +. + +ToolRequest.partial doc +Partial indicates whether this is a partial streaming chunk. +. + +ToolResponse doc +A ToolResponse is a message from the client to the model containing +the results of running a specific tool on the arguments passed to the client +by the model in a ToolRequest. +. + +ToolResponse.ref doc +Ref is the call ID or reference matching the original request. +. + +ToolResponse.name doc +Name is the name of the tool that was executed. +. + +ToolResponse.output doc +Output is a JSON object describing the results of running the tool. +For example: map[string]any{"name":"Thomas Jefferson", "born":1743}. +. + +ToolResponse.content doc +Content holds additional message parts that provide context or details about the tool response. +. + +ToolDefinition doc +A ToolDefinition describes a tool. +. + +ToolDefinition.name doc +Name is the unique identifier for this tool. +. + +ToolDefinition.description doc +Description explains what the tool does and when to use it. +. + +ToolDefinition.inputSchema doc +InputSchema is a valid JSON Schema representing the input parameters of the tool. +. + +ToolDefinition.outputSchema doc +OutputSchema is a valid JSON Schema describing the output of the tool. +. + +ToolDefinition.metadata doc +Metadata contains additional information about this tool definition. +. + +# ---------------------------------------------------------------------------- +# Generation Configuration +# ---------------------------------------------------------------------------- + +GenerationCommonConfig doc +GenerationCommonConfig holds configuration parameters for model generation requests. +. + +GenerationCommonConfig.version doc +Version specifies a particular version of a model family, +e.g., "gemini-1.0-pro-001" for the "gemini-1.0-pro" family. +. + +GenerationCommonConfig.temperature doc +Temperature controls randomness in generation. Higher values (e.g., 0.9) make output more random, +while lower values (e.g., 0.1) make it more deterministic. Typical range is 0.0 to 1.0. +. + +GenerationCommonConfig.maxOutputTokens doc +MaxOutputTokens limits the maximum number of tokens generated in the response. +. + +GenerationCommonConfig.topK doc +TopK limits sampling to the K most likely tokens at each step. +. + +GenerationCommonConfig.topP doc +TopP (nucleus sampling) limits sampling to tokens whose cumulative probability exceeds P. +. + +GenerationCommonConfig.stopSequences doc +StopSequences specifies sequences that will cause generation to stop when encountered. +. + +# ---------------------------------------------------------------------------- +# Generation Usage and Metrics +# ---------------------------------------------------------------------------- + +GenerationUsage doc +GenerationUsage provides information about resource consumption during generation. +. + +GenerationUsage.inputTokens doc +InputTokens is the number of tokens in the input prompt. +. + +GenerationUsage.outputTokens doc +OutputTokens is the number of tokens generated in the response. +. + +GenerationUsage.totalTokens doc +TotalTokens is the sum of input and output tokens. +. + +GenerationUsage.inputCharacters doc +InputCharacters is the number of characters in the input. +. + +GenerationUsage.outputCharacters doc +OutputCharacters is the number of characters generated in the output. +. + +GenerationUsage.inputImages doc +InputImages is the number of images in the input. +. + +GenerationUsage.outputImages doc +OutputImages is the number of images generated in the output. +. + +GenerationUsage.inputVideos doc +InputVideos is the number of videos in the input. +. + +GenerationUsage.outputVideos doc +OutputVideos is the number of videos generated in the output. +. + +GenerationUsage.inputAudioFiles doc +InputAudioFiles is the number of audio files in the input. +. + +GenerationUsage.outputAudioFiles doc +OutputAudioFiles is the number of audio files generated in the output. +. + +GenerationUsage.thoughtsTokens doc +ThoughtsTokens counts tokens used in reasoning or thinking processes. +. + +GenerationUsage.cachedContentTokens doc +CachedContentTokens counts tokens that were served from cache. +. + +GenerationUsage.custom doc +Custom contains additional usage metrics specific to the model provider. +. + +# ---------------------------------------------------------------------------- +# Model Request and Response +# ---------------------------------------------------------------------------- + +ModelRequest doc +A ModelRequest is a request to generate completions from a model. +. + +ModelRequest.messages doc +Messages contains the conversation history for the model. +. + +ModelRequest.config doc +Config holds model-specific configuration parameters. +. + +ModelRequest.docs doc +Docs provides retrieved documents to be used as context for this generation. +. + +ModelRequest.output doc +Output describes the desired response format. +. + +ModelRequest.tools doc +Tools lists the available tools that the model can ask the client to run. +. + +ModelRequest.toolChoice doc +ToolChoice controls how the model uses tools (auto, required, or none). +. + +ModelResponse doc +A ModelResponse is a model's response to a ModelRequest. +. + +ModelResponse.message doc +Message contains the generated response content. +. + +ModelResponse.finishReason doc +FinishReason indicates why generation stopped (e.g., stop, length, blocked). +. + +ModelResponse.finishMessage doc +FinishMessage provides additional details about why generation finished. +. + +ModelResponse.latencyMs doc +LatencyMs is the time the request took in milliseconds. +. + +ModelResponse.usage doc +Usage describes how many resources were used by this generation request. +. + +ModelResponse.custom doc +Custom contains model-specific extra information. Deprecated: use Raw instead. +. + +ModelResponse.raw doc +Raw contains the unprocessed model-specific response data. +. + +ModelResponse.request doc +Request is the ModelRequest struct used to trigger this response. +. + +ModelResponse.operation doc +Operation provides information about a long-running background task if applicable. +. + +ModelResponseChunk doc +A ModelResponseChunk is the portion of the ModelResponse +that is passed to a streaming callback. +. + +ModelResponseChunk.role doc +Role indicates the entity that generated this chunk. +. + +ModelResponseChunk.index doc +Index of the message this chunk belongs to. +. + +ModelResponseChunk.content doc +Content is the chunk of message parts to stream right now. +. + +ModelResponseChunk.custom doc +Custom contains model-specific extra information attached to this chunk. +. + +ModelResponseChunk.aggregated doc +Aggregated indicates whether the chunk includes all data from previous chunks. +If false, the chunk is considered incremental. +. + +# ---------------------------------------------------------------------------- +# Model Information and Capabilities +# ---------------------------------------------------------------------------- + +ModelInfo doc +ModelInfo contains metadata about a model's capabilities and characteristics. +. + +ModelInfo.versions doc +Versions lists acceptable names for this model (e.g., different versions). +. + +ModelInfo.label doc +Label is a friendly display name for this model (e.g., "Google AI - Gemini Pro"). +. + +ModelInfo.configSchema doc +ConfigSchema defines the model-specific configuration schema. +. + +ModelInfo.supports doc +Supports describes the capabilities that this model supports. +. + +ModelInfo.stage doc +Stage indicates the development stage of this model. +Featured models are recommended for general use, stable models are well-tested, +unstable models are experimental, legacy models are not recommended for new projects, +and deprecated models may be removed in future versions. +. + +ModelInfoSupports doc +ModelSupports describes the capabilities that a model supports. +. + +ModelInfoSupports.multiturn doc +Multiturn indicates whether the model can process historical messages passed with a prompt. +. + +ModelInfoSupports.media doc +Media indicates whether the model can process media as part of the prompt (multimodal input). +. + +ModelInfoSupports.tools doc +Tools indicates whether the model can perform tool calls. +. + +ModelInfoSupports.systemRole doc +SystemRole indicates whether the model can accept messages with role "system". +. + +ModelInfoSupports.output doc +Output lists the types of data the model can generate. +. + +ModelInfoSupports.contentType doc +ContentType lists the content types the model supports for output. +. + +ModelInfoSupports.context doc +Context indicates whether the model can natively support document-based context grounding. +. + +ModelInfoSupports.constrained doc +Constrained indicates the level of constrained generation support (none, all, or no-tools). +. + +ModelInfoSupports.toolChoice doc +ToolChoice indicates whether the model supports controlling tool choice (e.g., forced tool calling). +. + +ModelInfoSupports.longRunning doc +LongRunning indicates whether the model supports long-running operations. +. + +# ---------------------------------------------------------------------------- +# Output Configuration +# ---------------------------------------------------------------------------- + +OutputConfig doc +OutputConfig describes the structure that the model's output +should conform to. If Format is OutputFormatJSON, then Schema +can describe the desired form of the generated JSON. +. + +OutputConfig.format doc +Format specifies the desired output format (e.g., "json", "text"). +. + +OutputConfig.schema doc +Schema is a JSON Schema describing the desired structure of the output. +. + +OutputConfig.constrained doc +Constrained indicates whether to enforce strict adherence to the schema. +. + +OutputConfig.contentType doc +ContentType specifies the MIME type of the output content. +. + +# ---------------------------------------------------------------------------- +# Operation Types +# ---------------------------------------------------------------------------- + +Operation doc +Operation represents a long-running background task. +. + +Operation.action doc +Action is the name of the action being performed by this operation. +. + +Operation.id doc +Id is the unique identifier for this operation. +. + +Operation.done doc +Done indicates whether the operation has completed. +. + +Operation.output doc +Output contains the result of the operation if it has completed successfully. +. + +Operation.error doc +Error contains error information if the operation failed. +. + +Operation.metadata doc +Metadata contains additional information about the operation. +. + +OperationError doc +OperationError contains error information for a failed operation. +. + +OperationError.message doc +Message describes the error that occurred. +. + +# ---------------------------------------------------------------------------- +# Document Types +# ---------------------------------------------------------------------------- + +# Note: Document type is hand-written in ai/document.go, not generated + +# ---------------------------------------------------------------------------- +# Embedding Types +# ---------------------------------------------------------------------------- + +Embedding doc +Embedding represents a vector embedding with associated metadata. +. + +Embedding.embedding doc +Embedding is the vector representation of the input. +. + +Embedding.metadata doc +Metadata identifies which part of a document this embedding corresponds to. +. + +EmbedRequest doc +EmbedRequest represents a request to generate embeddings for documents. +. + +EmbedRequest.input doc +Input is the array of documents to generate embeddings for. +. + +EmbedRequest.options doc +Options contains embedder-specific configuration parameters. +. + +EmbedResponse doc +EmbedResponse contains the generated embeddings from an embed request. +. + +EmbedResponse.embeddings doc +Embeddings is the array of generated embedding vectors with metadata. +. + +# ---------------------------------------------------------------------------- +# Evaluator Types (ScoreDetails only - other eval types are omitted) +# ---------------------------------------------------------------------------- + +ScoreDetails doc +ScoreDetails provides additional context and explanation for an evaluation score. +. + +ScoreDetails.reasoning doc +Reasoning explains the rationale behind the score. +. + +# ---------------------------------------------------------------------------- +# Retriever Types +# ---------------------------------------------------------------------------- + +RetrieverRequest doc +RetrieverRequest represents a request to retrieve relevant documents. +. + +RetrieverRequest.query doc +Query is the document to use for retrieval. +. + +RetrieverRequest.options doc +Options contains retriever-specific configuration parameters. +. + +RetrieverResponse doc +RetrieverResponse contains the retrieved documents from a retriever request. +. + +RetrieverResponse.documents doc +Documents is the array of retrieved documents. +. + +# ---------------------------------------------------------------------------- +# Reranker Types +# ---------------------------------------------------------------------------- + +RerankerRequest doc +RerankerRequest represents a request to rerank documents based on relevance. +. + +RerankerRequest.query doc +Query is the document to use for reranking. +. + +RerankerRequest.documents doc +Documents is the array of documents to rerank. +. + +RerankerRequest.options doc +Options contains reranker-specific configuration parameters. +. + +RerankerResponse doc +RerankerResponse contains the reranked documents with relevance scores. +. + +RerankerResponse.documents doc +Documents is the array of reranked documents with scores. +. + +RankedDocumentData doc +RankedDocumentData represents a document with a relevance score from reranking. +. + +RankedDocumentData.content doc +Content holds the document's parts (text and media). +. + +RankedDocumentData.metadata doc +Metadata contains the reranking score and other arbitrary key-value data. +. + +RankedDocumentMetadata doc +RankedDocumentMetadata contains the relevance score and other metadata for a reranked document. +. + +RankedDocumentMetadata.score doc +Score is the relevance score assigned by the reranker. +. + +# ---------------------------------------------------------------------------- +# GenerateAction Types +# ---------------------------------------------------------------------------- + +GenerateActionOptions doc +GenerateActionOptions holds configuration for a generate action request. +. + +GenerateActionOptions.model doc +Model is a model name (e.g., "vertexai/gemini-1.0-pro"). +. + +GenerateActionOptions.docs doc +Docs provides retrieved documents to be used as context for this generation. +. + +GenerateActionOptions.messages doc +Messages contains the conversation history for multi-turn prompting when supported. +. + +GenerateActionOptions.tools doc +Tools is a list of registered tool names for this generation if supported. +. + +GenerateActionOptions.toolChoice doc +ToolChoice controls tool calling mode. Auto lets the model decide, required forces +the model to choose a tool, and none forces the model not to use any tools. Defaults to auto. +. + +GenerateActionOptions.config doc +Config contains configuration parameters for the generation request. +. + +GenerateActionOptions.output doc +Output specifies the desired output format. Defaults to the model's default if unspecified. +. + +GenerateActionOptions.resume doc +Resume provides options for resuming an interrupted generation. +. + +GenerateActionOptions.returnToolRequests doc +ReturnToolRequests, when true, returns tool calls for manual processing instead of +automatically resolving them. +. + +GenerateActionOptions.maxTurns doc +MaxTurns is the maximum number of tool call iterations that can be performed +in a single generate call. Defaults to 5. +. + +GenerateActionOptions.stepName doc +StepName is a custom step name for this generate call to display in trace views. +Defaults to "generate". +. + +GenerateActionOptionsResume doc +GenerateActionResume holds options for resuming an interrupted generation. +. + +GenerateActionOptionsResume.respond doc +Respond contains tool response parts to send to the model when resuming. +. + +GenerateActionOptionsResume.restart doc +Restart contains tool request parts to restart when resuming. +. + +GenerateActionOptionsResume.metadata doc +Metadata contains additional context for resuming the generation. +. + +GenerateActionOutputConfig doc +GenerateActionOutputConfig specifies the desired output format for a generate action. +. + +GenerateActionOutputConfig.format doc +Format specifies the desired output format (e.g., "json", "text"). +. + +GenerateActionOutputConfig.contentType doc +ContentType specifies the MIME type of the output content. +. + +GenerateActionOutputConfig.instructions doc +Instructions provides additional guidance for the output format. +. + +GenerateActionOutputConfig.jsonSchema doc +JsonSchema is a JSON Schema describing the desired structure of JSON output. +. + +GenerateActionOutputConfig.constrained doc +Constrained indicates whether to enforce strict adherence to the schema. +. + +GenerateActionOptionsToolChoice doc +ToolChoice controls how the model uses tools. +. + +# ---------------------------------------------------------------------------- +# Finish Reason Enum +# ---------------------------------------------------------------------------- + +FinishReason doc +FinishReason indicates why generation stopped. +. + +# ---------------------------------------------------------------------------- +# Model Stage Enum +# ---------------------------------------------------------------------------- + +ModelInfoStage doc +ModelStage indicates the development stage of a model. +. + +# ---------------------------------------------------------------------------- +# Constrained Support Enum +# ---------------------------------------------------------------------------- + +ModelInfoSupportsConstrained doc +ConstrainedSupport indicates the level of constrained generation support. +. + +# ---------------------------------------------------------------------------- +# Trace Metadata Types +# ---------------------------------------------------------------------------- + +TraceMetadata doc +TraceMetadata contains metadata about a trace execution. +. + +TraceMetadata.featureName doc +FeatureName identifies the feature being traced. +. + +TraceMetadata.paths doc +Paths contains metadata for each path executed during the trace. +. + +TraceMetadata.timestamp doc +Timestamp is when the trace was created. +. + +PathMetadata doc +PathMetadata contains metadata about a single execution path in a trace. +. + +PathMetadata.path doc +Path is the identifier for this execution path. +. + +PathMetadata.status doc +Status indicates the outcome of this path. +. + +PathMetadata.latency doc +Latency is the execution time for this path in milliseconds. +. + +PathMetadata.error doc +Error contains error information if the path failed. +. + +# ---------------------------------------------------------------------------- +# Multipart Tool Response +# ---------------------------------------------------------------------------- + +MultipartToolResponse doc +MultipartToolResponse represents a tool response with both structured output and content parts. +. + +MultipartToolResponse.output doc +Output contains the structured output data from the tool. +. + +MultipartToolResponse.content doc +Content holds additional message parts providing context or details. +. + +# ============================================================================ +# CONFIGURATION SECTION +# Type mappings, omissions, and other non-documentation directives +# ============================================================================ + # DocumentData type was hand-written. DocumentData omit @@ -28,52 +888,30 @@ TimeEventAnnotation omit TraceData omit SpanStartEvent omit SpanEndEvent omit -SpanEventBase omit +# Typo in schema definition... +SpantEventBase omit TraceEvent omit GenerationCommonConfig.maxOutputTokens type int GenerationCommonConfig.topK type int -Role doc -Role indicates which entity is responsible for the content of a message. -. -RoleSystem doc -RoleSystem indicates this message is user-independent context. -. -RoleUser doc -RoleUser indicates this message was generated by the client. -. -RoleModel doc -RoleModel indicates this message was generated by the model during a previous interaction. -. -RoleTool doc -RoleTool indicates this message was generated by a local tool, likely triggered by a request -from the model in one of its previous responses. -. - -ToolRequest.input doc -Input is a JSON object describing the input values to the tool. -An example might be map[string]any{"country":"USA", "president":3}. -. -ToolResponse.output doc -Output is a JSON object describing the results of running the tool. -An example might be map[string]any{"name":"Thomas Jefferson", "born":1743}. -. - -ToolRequest doc -A ToolRequest is a message from the model to the client that it should run a -specific tool and pass a [ToolResponse] to the model on the next chat request it makes. -Any ToolRequest will correspond to some [ToolDefinition] previously sent by the client. -. -ToolResponse doc -A ToolResponse is a message from the client to the model containing -the results of running a specific tool on the arguments passed to the client -by the model in a [ToolRequest]. -. - +# Unused evaluation types +BaseDataPoint omit +BaseEvalDataPoint omit +EvalFnResponse omit +EvalRequest omit +EvalResponse omit +EvalStatusEnum omit +# Unused error types +CandidateError omit +CandidateErrorCode omit Candidate omit +# Unused retriever/reranker option types +CommonRerankerOptions omit +CommonRetrieverOptions omit + DocumentData pkg ai GenerateResponse omit @@ -96,9 +934,7 @@ GenerationUsage.outputTokens type int GenerationUsage.totalTokens type int GenerationUsage.thoughtsTokens type int GenerationUsage.cachedContentTokens type int -GenerationUsage doc -GenerationUsage provides information about the generation process. -. + GenerationCommonConfig pkg ai Message pkg ai @@ -213,8 +1049,6 @@ RoleUser pkg ai RoleModel pkg ai RoleTool pkg ai -EvalResponse type []any - # GenerateActionOptions GenerateActionOptions pkg ai GenerateActionOptions.model type string @@ -235,18 +1069,6 @@ GenerateActionOutputConfig.jsonSchema name Schema GenerateActionOutputConfig.jsonSchema type map[string]any GenerateActionOutputConfig.constrained type bool -BaseDataPoint.context type map[string]any -BaseDataPoint.input type map[string]any -BaseDataPoint.output type map[string]any -BaseDataPoint.reference type map[string]any -BaseDataPoint.traceIds type []string - -BaseEvalDataPoint.context type map[string]any -BaseEvalDataPoint.input type map[string]any -BaseEvalDataPoint.output type map[string]any -BaseEvalDataPoint.reference type map[string]any -BaseEvalDataPoint.traceIds type []string - # ModelRequest ModelRequest pkg ai ModelRequest.config type any @@ -279,52 +1101,6 @@ ModelResponseChunk.index type int ModelResponseChunk.role type Role ModelResponseChunk field formatHandler StreamingFormatHandler -GenerationCommonConfig doc -GenerationCommonConfig holds configuration for generation. -. - -Message doc -Message is the contents of a model response. -. - -ToolDefinition doc -A ToolDefinition describes a tool. -. - -ModelRequest doc -A ModelRequest is a request to generate completions from a model. -. -ModelRequest.output doc -Output describes the desired response format. -. -ModelRequest.tools doc -Tools lists the available tools that the model can ask the client to run. -. - -OutputConfig doc -OutputConfig describes the structure that the model's output -should conform to. If Format is [OutputFormatJSON], then Schema -can describe the desired form of the generated JSON. -. - -ModelResponse doc -A ModelResponse is a model's response to a [ModelRequest]. -. -ModelResponse.latencyMs doc -LatencyMs is the time the request took in milliseconds. -. -ModelResponse.request doc -Request is the [ModelRequest] struct used to trigger this response. -. -ModelResponse.usage doc -Usage describes how many resources were used by this generation request. -. - -ModelResponseChunk doc -A ModelResponseChunk is the portion of the [ModelResponse] -that is passed to a streaming callback. -. - Score omit Embedding.embedding type []float32 diff --git a/go/core/tracing/doc.go b/go/core/tracing/doc.go new file mode 100644 index 0000000000..aae609c517 --- /dev/null +++ b/go/core/tracing/doc.go @@ -0,0 +1,109 @@ +// 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 + +/* +Package tracing provides execution trace support for Genkit operations. + +This package implements OpenTelemetry-based tracing for Genkit actions and flows. +Traces capture the execution path, inputs, outputs, and timing of operations, +enabling observability and debugging through the Genkit Developer UI and +external telemetry systems. + +# Automatic Tracing + +Actions and flows defined with Genkit are automatically traced. Each action +execution creates a span with input/output data, timing, and any errors. +Use [core.Run] within flows to create traced sub-steps: + + // In a real scenario, 'r' would be the registry from your Genkit instance. + var r api.Registry + flow := core.DefineFlow(r, "myFlow", + func(ctx context.Context, input string) (string, error) { + // This creates a traced step named "processData" + result, err := core.Run(ctx, "processData", func() (string, error) { + return process(input), nil + }) + return result, err + }, + ) + +# Tracer Access + +Access the OpenTelemetry tracer provider for custom instrumentation: + + provider := tracing.TracerProvider() + + // Get a tracer for custom spans + tracer := tracing.Tracer() + +# Telemetry Export + +Configure trace export to send telemetry to external systems. For immediate +export (suitable for local storage): + + tracing.WriteTelemetryImmediate(client) + +For batched export (more efficient for network calls): + + shutdown := tracing.WriteTelemetryBatch(client) + defer shutdown(ctx) + +# Dev UI Integration + +When the GENKIT_ENV environment variable is set to "dev", traces are +automatically sent to the Genkit Developer UI's telemetry server. The Dev UI +provides: + + - Visual trace exploration with timing breakdown + - Input/output inspection for each action + - Error highlighting and stack traces + - Performance analysis across flow executions + +Set GENKIT_TELEMETRY_SERVER to configure a custom telemetry endpoint. + +# Span Metadata + +Create spans with rich metadata for better observability: + + metadata := &tracing.SpanMetadata{ + Name: "processDocument", + Type: "action", + Subtype: "retriever", + } + + output, err := tracing.RunInNewSpan(ctx, metadata, input, + func(ctx context.Context, in Input) (Output, error) { + // Operation runs within the traced span + return process(in), nil + }, + ) + +# Trace Information + +Extract trace context for correlation with external systems: + + info := tracing.GetTraceInfo(ctx) + if info != nil { + log.Printf("TraceID: %s, SpanID: %s", info.TraceID, info.SpanID) + } + +This package is primarily intended for Genkit internals and advanced plugin +development. Most application developers will interact with tracing through +the automatic instrumentation provided by the genkit package. + +For more information on observability, see https://genkit.dev/docs/observability +*/ +package tracing diff --git a/go/core/x/session/session.go b/go/core/x/session/session.go new file mode 100644 index 0000000000..8a9f0387f9 --- /dev/null +++ b/go/core/x/session/session.go @@ -0,0 +1,358 @@ +// 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 + +// Package session provides experimental session management APIs for Genkit. +// +// A session encapsulates a stateful execution environment with strongly-typed +// state that can be persisted across requests. Sessions are useful for maintaining +// user preferences, conversation context, or any application state that needs +// to survive between interactions. +// +// APIs in this package are under active development and may change in any +// minor version release. Use with caution in production environments. +// +// When these APIs stabilize, they will be moved to the core package +// and these exports will be deprecated. +package session + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + + "github.com/google/uuid" +) + +// Session represents a stateful environment with typed state. +// The type parameter S defines the shape of the session state and must be +// JSON-serializable for persistence. +type Session[S any] struct { + id string + state S + store Store[S] + mu sync.RWMutex +} + +// Data is the serializable session state persisted by Store. +type Data[S any] struct { + ID string `json:"id"` + State S `json:"state,omitempty"` +} + +// Store persists session data to a backend (database, file, memory, etc). +// Implementations must be safe for concurrent use. +type Store[S any] interface { + // Get retrieves session data by ID. Returns nil if not found. + Get(ctx context.Context, sessionID string) (*Data[S], error) + // Save persists session data, creating or updating as needed. + Save(ctx context.Context, sessionID string, data *Data[S]) error +} + +// options holds configuration for creating a Session. +type options[S any] struct { + ID string + InitialState S + Store Store[S] + hasID bool + hasState bool + hasStore bool +} + +// Option configures a Session during creation. +type Option[S any] interface { + apply(*options[S]) error +} + +// apply implements Option for options, enabling composition. +func (o *options[S]) apply(opts *options[S]) error { + if o.hasID { + if opts.hasID { + return errors.New("cannot set ID more than once (WithID)") + } + opts.ID = o.ID + opts.hasID = true + } + + if o.hasState { + if opts.hasState { + return errors.New("cannot set initial state more than once (WithInitialState)") + } + opts.InitialState = o.InitialState + opts.hasState = true + } + + if o.hasStore { + if opts.hasStore { + return errors.New("cannot set store more than once (WithStore)") + } + opts.Store = o.Store + opts.hasStore = true + } + + return nil +} + +// WithID sets a custom session ID. If not provided, a UUID is generated. +func WithID[S any](id string) Option[S] { + return &options[S]{ID: id, hasID: true} +} + +// WithInitialState sets the initial state for a new session. +func WithInitialState[S any](state S) Option[S] { + return &options[S]{InitialState: state, hasState: true} +} + +// WithStore sets the persistence backend for the session. +// If not provided, the session is not persisted and exists only in memory. +func WithStore[S any](store Store[S]) Option[S] { + return &options[S]{Store: store, hasStore: true} +} + +// New creates a new session with the provided options. +// If a store is provided via [WithStore], the session is persisted immediately. +// If no store is provided, the session exists only in memory for the current +// request and can be propagated via context using [NewContext]. +// If no ID is provided, a new UUID is generated. +// If no initial state is provided, the session is created with an empty state. +func New[S any](ctx context.Context, opts ...Option[S]) (*Session[S], error) { + o := &options[S]{} + for _, opt := range opts { + if err := opt.apply(o); err != nil { + return nil, fmt.Errorf("session.New: %w", err) + } + } + + id := o.ID + if !o.hasID { + id = uuid.New().String() + } + + // Only persist if a store was explicitly provided + if o.hasStore { + data := &Data[S]{ + ID: id, + State: o.InitialState, + } + if err := o.Store.Save(ctx, id, data); err != nil { + return nil, fmt.Errorf("session.New: failed to persist initial state: %w", err) + } + } + + return &Session[S]{ + id: id, + state: o.InitialState, + store: o.Store, // nil if no store provided + }, nil +} + +// Load loads an existing session from the store. +// Returns an error if the session is not found or if loading fails. +func Load[S any](ctx context.Context, store Store[S], sessionID string) (*Session[S], error) { + data, err := store.Get(ctx, sessionID) + if err != nil { + return nil, err + } + if data == nil { + return nil, &NotFoundError{SessionID: sessionID} + } + + return &Session[S]{ + id: data.ID, + state: data.State, + store: store, + }, nil +} + +// ID returns the session's unique identifier. +func (s *Session[S]) ID() string { + return s.id +} + +// State returns the current session state. +// The returned value is a copy; modifications do not affect the session. +func (s *Session[S]) State() S { + s.mu.RLock() + defer s.mu.RUnlock() + return deepCopyState(s.state) +} + +// deepCopyState creates a deep copy of the state using JSON marshaling. +// Panics if serialization fails, as this indicates a programming error +// (the state type S must be JSON-serializable per the Session contract). +func deepCopyState[S any](state S) S { + bytes, err := json.Marshal(state) + if err != nil { + panic(fmt.Sprintf("session.State: failed to marshal state: %v", err)) + } + + var copied S + if err := json.Unmarshal(bytes, &copied); err != nil { + panic(fmt.Sprintf("session.State: failed to unmarshal state: %v", err)) + } + + return copied +} + +// UpdateState updates the session state and persists it to the store (if configured). +func (s *Session[S]) UpdateState(ctx context.Context, state S) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.state = state + + if s.store != nil { + data := &Data[S]{ + ID: s.id, + State: state, + } + if err := s.store.Save(ctx, s.id, data); err != nil { + return err + } + } + + return nil +} + +// contextKey is a private type for context keys to avoid collisions. +type contextKey struct{} + +// sessionContextKey is the key used to store sessions in context. +var sessionContextKey = contextKey{} + +// sessionHolder wraps a session with its type erased for context storage. +type sessionHolder struct { + session any +} + +// NewContext returns a new context with the session attached. +func NewContext[S any](ctx context.Context, s *Session[S]) context.Context { + return context.WithValue(ctx, sessionContextKey, &sessionHolder{session: s}) +} + +// FromContext retrieves the current session from context. +// Returns nil if no session is in context or if the type doesn't match. +func FromContext[S any](ctx context.Context) *Session[S] { + holder, ok := ctx.Value(sessionContextKey).(*sessionHolder) + if !ok || holder == nil { + return nil + } + session, ok := holder.session.(*Session[S]) + if !ok { + return nil + } + return session +} + +// stateGetter is an internal interface for retrieving state without type parameters. +type stateGetter interface { + getState() any +} + +// getState implements stateGetter, returning the session state as any. +func (s *Session[S]) getState() any { + return s.State() +} + +// StateFromContext retrieves the current session state from context without +// requiring knowledge of the state type. This is useful for template rendering +// where the state type is not known at compile time. +// Returns nil if no session is in context. +func StateFromContext(ctx context.Context) any { + holder, ok := ctx.Value(sessionContextKey).(*sessionHolder) + if !ok || holder == nil { + return nil + } + if getter, ok := holder.session.(stateGetter); ok { + return getter.getState() + } + return nil +} + +// NotFoundError is returned when a session cannot be found in the store. +type NotFoundError struct { + SessionID string +} + +func (e *NotFoundError) Error() string { + return "session not found: " + e.SessionID +} + +// InMemoryStore is a thread-safe in-memory implementation of Store. +// Useful for testing or single-instance deployments where persistence is not required. +type InMemoryStore[S any] struct { + data map[string]*Data[S] + mu sync.RWMutex +} + +// NewInMemoryStore creates a new in-memory session store. +func NewInMemoryStore[S any]() *InMemoryStore[S] { + return &InMemoryStore[S]{ + data: make(map[string]*Data[S]), + } +} + +// Get retrieves session data by ID. +func (s *InMemoryStore[S]) Get(_ context.Context, sessionID string) (*Data[S], error) { + s.mu.RLock() + defer s.mu.RUnlock() + + data, exists := s.data[sessionID] + if !exists { + return nil, nil + } + + // Return a copy to prevent external modifications + copied, err := copyData(data) + if err != nil { + return nil, err + } + return copied, nil +} + +// Save persists session data. +func (s *InMemoryStore[S]) Save(_ context.Context, sessionID string, data *Data[S]) error { + s.mu.Lock() + defer s.mu.Unlock() + + // Store a copy to prevent external modifications + copied, err := copyData(data) + if err != nil { + return err + } + s.data[sessionID] = copied + return nil +} + +// copyData creates a deep copy of Data using JSON marshaling. +func copyData[S any](data *Data[S]) (*Data[S], error) { + if data == nil { + return nil, nil + } + + bytes, err := json.Marshal(data) + if err != nil { + return nil, err + } + + var copied Data[S] + if err := json.Unmarshal(bytes, &copied); err != nil { + return nil, err + } + + return &copied, nil +} diff --git a/go/core/x/session/session_test.go b/go/core/x/session/session_test.go new file mode 100644 index 0000000000..b34100a44c --- /dev/null +++ b/go/core/x/session/session_test.go @@ -0,0 +1,782 @@ +// 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 + +package session + +import ( + "context" + "errors" + "strings" + "sync" + "testing" +) + +// UserState is a test state type with various field types. +type UserState struct { + Name string `json:"name"` + Count int `json:"count"` + Preferences map[string]string `json:"preferences,omitempty"` +} + +func TestNew_DefaultID(t *testing.T) { + ctx := context.Background() + sess, err := New[UserState](ctx) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + if sess.ID() == "" { + t.Error("Expected session to have a generated ID") + } +} + +func TestNew_WithID(t *testing.T) { + ctx := context.Background() + customID := "my-custom-id" + sess, err := New(ctx, WithID[UserState](customID)) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + if sess.ID() != customID { + t.Errorf("Expected ID %q, got %q", customID, sess.ID()) + } +} + +func TestNew_WithInitialState(t *testing.T) { + ctx := context.Background() + initial := UserState{Name: "Alice", Count: 42} + sess, err := New(ctx, WithInitialState(initial)) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + got := sess.State() + if got.Name != initial.Name { + t.Errorf("Expected Name %q, got %q", initial.Name, got.Name) + } + if got.Count != initial.Count { + t.Errorf("Expected Count %d, got %d", initial.Count, got.Count) + } +} + +func TestNew_WithStore(t *testing.T) { + ctx := context.Background() + store := NewInMemoryStore[UserState]() + sess, err := New(ctx, WithStore(store)) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + if sess.store != store { + t.Error("Expected store to be set") + } +} + +func TestNew_MultipleOptions(t *testing.T) { + ctx := context.Background() + store := NewInMemoryStore[UserState]() + customID := "multi-option-id" + initial := UserState{Name: "Bob", Count: 100} + + sess, err := New(ctx, + WithID[UserState](customID), + WithInitialState(initial), + WithStore(store), + ) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + if sess.ID() != customID { + t.Errorf("Expected ID %q, got %q", customID, sess.ID()) + } + if sess.State().Name != initial.Name { + t.Errorf("Expected Name %q, got %q", initial.Name, sess.State().Name) + } + if sess.store != store { + t.Error("Expected store to be set") + } +} + +func TestNew_DuplicateID(t *testing.T) { + ctx := context.Background() + _, err := New(ctx, + WithID[UserState]("first"), + WithID[UserState]("second"), + ) + if err == nil { + t.Fatal("Expected error for duplicate WithID") + } + if !strings.Contains(err.Error(), "cannot set ID more than once") { + t.Errorf("Expected duplicate ID error, got: %v", err) + } +} + +func TestNew_DuplicateInitialState(t *testing.T) { + ctx := context.Background() + _, err := New(ctx, + WithInitialState(UserState{Name: "First"}), + WithInitialState(UserState{Name: "Second"}), + ) + if err == nil { + t.Fatal("Expected error for duplicate WithInitialState") + } + if !strings.Contains(err.Error(), "cannot set initial state more than once") { + t.Errorf("Expected duplicate state error, got: %v", err) + } +} + +func TestNew_DuplicateStore(t *testing.T) { + ctx := context.Background() + store1 := NewInMemoryStore[UserState]() + store2 := NewInMemoryStore[UserState]() + _, err := New(ctx, + WithStore(store1), + WithStore(store2), + ) + if err == nil { + t.Fatal("Expected error for duplicate WithStore") + } + if !strings.Contains(err.Error(), "cannot set store more than once") { + t.Errorf("Expected duplicate store error, got: %v", err) + } +} + +func TestSession_State(t *testing.T) { + ctx := context.Background() + initial := UserState{ + Name: "Alice", + Count: 10, + Preferences: map[string]string{"theme": "dark"}, + } + sess, err := New(ctx, WithInitialState(initial)) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + t.Run("returns correct values", func(t *testing.T) { + got := sess.State() + if got.Name != initial.Name { + t.Errorf("Expected Name %q, got %q", initial.Name, got.Name) + } + if got.Count != initial.Count { + t.Errorf("Expected Count %d, got %d", initial.Count, got.Count) + } + if got.Preferences["theme"] != "dark" { + t.Errorf("Expected theme %q, got %q", "dark", got.Preferences["theme"]) + } + }) + + t.Run("modifications to returned copy do not affect session", func(t *testing.T) { + // Get a copy of the state + copy1 := sess.State() + + // Modify the map in the returned copy + copy1.Preferences["theme"] = "light" + copy1.Preferences["newKey"] = "newValue" + copy1.Name = "Modified" + copy1.Count = 999 + + // Get another copy and verify the session's internal state is unchanged + copy2 := sess.State() + + if copy2.Name != "Alice" { + t.Errorf("Session state was mutated: expected Name %q, got %q", "Alice", copy2.Name) + } + if copy2.Count != 10 { + t.Errorf("Session state was mutated: expected Count %d, got %d", 10, copy2.Count) + } + if copy2.Preferences["theme"] != "dark" { + t.Errorf("Session state was mutated: expected theme %q, got %q", "dark", copy2.Preferences["theme"]) + } + if _, exists := copy2.Preferences["newKey"]; exists { + t.Errorf("Session state was mutated: unexpected key 'newKey' in Preferences") + } + }) +} + +func TestSession_UpdateState_NoStore(t *testing.T) { + ctx := context.Background() + sess, err := New(ctx, WithInitialState(UserState{Name: "Alice"})) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + // Verify no store is set when not provided + if sess.store != nil { + t.Fatal("Expected no store when not provided") + } + + newState := UserState{Name: "Bob", Count: 5} + if err := sess.UpdateState(ctx, newState); err != nil { + t.Fatalf("UpdateState failed: %v", err) + } + + // State should still be updated in memory + got := sess.State() + if got.Name != newState.Name { + t.Errorf("Expected Name %q, got %q", newState.Name, got.Name) + } + if got.Count != newState.Count { + t.Errorf("Expected Count %d, got %d", newState.Count, got.Count) + } +} + +func TestSession_UpdateState_WithStore(t *testing.T) { + ctx := context.Background() + store := NewInMemoryStore[UserState]() + sess, err := New(ctx, + WithID[UserState]("test-session"), + WithInitialState(UserState{Name: "Alice"}), + WithStore(store), + ) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + newState := UserState{Name: "Bob", Count: 5} + if err := sess.UpdateState(ctx, newState); err != nil { + t.Fatalf("UpdateState failed: %v", err) + } + + // Verify state is updated in session + got := sess.State() + if got.Name != newState.Name { + t.Errorf("Expected Name %q, got %q", newState.Name, got.Name) + } + + // Verify state is persisted in store + data, err := store.Get(ctx, "test-session") + if err != nil { + t.Fatalf("Store.Get failed: %v", err) + } + if data == nil { + t.Fatal("Expected data in store, got nil") + } + if data.State.Name != newState.Name { + t.Errorf("Store: expected Name %q, got %q", newState.Name, data.State.Name) + } +} + +func TestLoad_Success(t *testing.T) { + store := NewInMemoryStore[UserState]() + ctx := context.Background() + + // Save some data + data := &Data[UserState]{ + ID: "existing-session", + State: UserState{Name: "Charlie", Count: 99}, + } + if err := store.Save(ctx, data.ID, data); err != nil { + t.Fatalf("Store.Save failed: %v", err) + } + + // Load the session + loaded, err := Load(ctx, store, "existing-session") + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if loaded.ID() != "existing-session" { + t.Errorf("Expected ID %q, got %q", "existing-session", loaded.ID()) + } + if loaded.State().Name != "Charlie" { + t.Errorf("Expected Name %q, got %q", "Charlie", loaded.State().Name) + } + if loaded.State().Count != 99 { + t.Errorf("Expected Count %d, got %d", 99, loaded.State().Count) + } +} + +func TestLoad_NotFound(t *testing.T) { + store := NewInMemoryStore[UserState]() + ctx := context.Background() + + _, err := Load(ctx, store, "non-existent") + if err == nil { + t.Fatal("Expected error for non-existent session") + } + + var notFoundErr *NotFoundError + if !errors.As(err, ¬FoundErr) { + t.Errorf("Expected NotFoundError, got %T: %v", err, err) + } + if notFoundErr.SessionID != "non-existent" { + t.Errorf("Expected SessionID %q, got %q", "non-existent", notFoundErr.SessionID) + } +} + +func TestNewContext_FromContext(t *testing.T) { + ctx := context.Background() + sess, err := New(ctx, + WithID[UserState]("ctx-test"), + WithInitialState(UserState{Name: "Diana"}), + ) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + // Attach session to context + ctx = NewContext(ctx, sess) + + // Retrieve from context + retrieved := FromContext[UserState](ctx) + if retrieved == nil { + t.Fatal("Expected session from context, got nil") + } + if retrieved.ID() != "ctx-test" { + t.Errorf("Expected ID %q, got %q", "ctx-test", retrieved.ID()) + } + if retrieved.State().Name != "Diana" { + t.Errorf("Expected Name %q, got %q", "Diana", retrieved.State().Name) + } +} + +func TestStateFromContext(t *testing.T) { + t.Run("returns state when session exists", func(t *testing.T) { + ctx := context.Background() + initial := UserState{ + Name: "Alice", + Count: 42, + Preferences: map[string]string{"theme": "dark"}, + } + sess, err := New(ctx, WithInitialState(initial)) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + ctx = NewContext(ctx, sess) + + state := StateFromContext(ctx) + if state == nil { + t.Fatal("Expected state from context, got nil") + } + + // StateFromContext returns the state as any, so we need to type assert + userState, ok := state.(UserState) + if !ok { + t.Fatalf("Expected UserState, got %T", state) + } + + if userState.Name != "Alice" { + t.Errorf("Expected Name %q, got %q", "Alice", userState.Name) + } + if userState.Count != 42 { + t.Errorf("Expected Count %d, got %d", 42, userState.Count) + } + if userState.Preferences["theme"] != "dark" { + t.Errorf("Expected theme %q, got %q", "dark", userState.Preferences["theme"]) + } + }) + + t.Run("returns nil when no session in context", func(t *testing.T) { + ctx := context.Background() + state := StateFromContext(ctx) + if state != nil { + t.Errorf("Expected nil for empty context, got %v", state) + } + }) + + t.Run("returns deep copy that cannot mutate session", func(t *testing.T) { + ctx := context.Background() + initial := UserState{ + Name: "Bob", + Preferences: map[string]string{"lang": "en"}, + } + sess, err := New(ctx, WithInitialState(initial)) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + ctx = NewContext(ctx, sess) + + // Get state via StateFromContext + state := StateFromContext(ctx) + userState := state.(UserState) + + // Modify the returned state + userState.Name = "Modified" + userState.Preferences["lang"] = "fr" + + // Verify the session's internal state is unchanged + originalState := sess.State() + if originalState.Name != "Bob" { + t.Errorf("Session state was mutated: expected Name %q, got %q", "Bob", originalState.Name) + } + if originalState.Preferences["lang"] != "en" { + t.Errorf("Session state was mutated: expected lang %q, got %q", "en", originalState.Preferences["lang"]) + } + }) +} + +func TestFromContext_NoSession(t *testing.T) { + ctx := context.Background() + + retrieved := FromContext[UserState](ctx) + if retrieved != nil { + t.Errorf("Expected nil for empty context, got %v", retrieved) + } +} + +func TestFromContext_WrongType(t *testing.T) { + ctx := context.Background() + // Create session with one type + type OtherState struct { + Value string + } + sess, err := New(ctx, WithInitialState(OtherState{Value: "test"})) + if err != nil { + t.Fatalf("New failed: %v", err) + } + ctx = NewContext(ctx, sess) + + // Try to retrieve with different type + retrieved := FromContext[UserState](ctx) + if retrieved != nil { + t.Errorf("Expected nil for wrong type, got %v", retrieved) + } +} + +func TestInMemoryStore_GetSave(t *testing.T) { + store := NewInMemoryStore[UserState]() + ctx := context.Background() + + // Initially empty + data, err := store.Get(ctx, "test-id") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if data != nil { + t.Errorf("Expected nil for non-existent key, got %v", data) + } + + // Save data + original := &Data[UserState]{ + ID: "test-id", + State: UserState{Name: "Eve", Count: 7}, + } + if err := store.Save(ctx, "test-id", original); err != nil { + t.Fatalf("Save failed: %v", err) + } + + // Retrieve data + retrieved, err := store.Get(ctx, "test-id") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if retrieved == nil { + t.Fatal("Expected data, got nil") + } + if retrieved.ID != original.ID { + t.Errorf("Expected ID %q, got %q", original.ID, retrieved.ID) + } + if retrieved.State.Name != original.State.Name { + t.Errorf("Expected Name %q, got %q", original.State.Name, retrieved.State.Name) + } +} + +func TestInMemoryStore_Isolation(t *testing.T) { + store := NewInMemoryStore[UserState]() + ctx := context.Background() + + // Save data + original := &Data[UserState]{ + ID: "isolation-test", + State: UserState{Name: "Frank", Count: 1}, + } + if err := store.Save(ctx, "isolation-test", original); err != nil { + t.Fatalf("Save failed: %v", err) + } + + // Modify original after save + original.State.Name = "Modified" + + // Retrieved data should not be affected + retrieved, err := store.Get(ctx, "isolation-test") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if retrieved.State.Name != "Frank" { + t.Errorf("Expected Name %q (isolation), got %q", "Frank", retrieved.State.Name) + } + + // Modify retrieved data + retrieved.State.Name = "Also Modified" + + // Get again - should still be original + retrieved2, err := store.Get(ctx, "isolation-test") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if retrieved2.State.Name != "Frank" { + t.Errorf("Expected Name %q (isolation), got %q", "Frank", retrieved2.State.Name) + } +} + +func TestInMemoryStore_Overwrite(t *testing.T) { + store := NewInMemoryStore[UserState]() + ctx := context.Background() + + // Save initial data + initial := &Data[UserState]{ + ID: "overwrite-test", + State: UserState{Name: "Grace", Count: 1}, + } + if err := store.Save(ctx, "overwrite-test", initial); err != nil { + t.Fatalf("Save failed: %v", err) + } + + // Overwrite with new data + updated := &Data[UserState]{ + ID: "overwrite-test", + State: UserState{Name: "Grace Updated", Count: 2}, + } + if err := store.Save(ctx, "overwrite-test", updated); err != nil { + t.Fatalf("Save failed: %v", err) + } + + // Retrieve and verify + retrieved, err := store.Get(ctx, "overwrite-test") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if retrieved.State.Name != "Grace Updated" { + t.Errorf("Expected Name %q, got %q", "Grace Updated", retrieved.State.Name) + } + if retrieved.State.Count != 2 { + t.Errorf("Expected Count %d, got %d", 2, retrieved.State.Count) + } +} + +func TestSession_ConcurrentAccess(t *testing.T) { + ctx := context.Background() + store := NewInMemoryStore[UserState]() + sess, err := New(ctx, + WithID[UserState]("concurrent-test"), + WithInitialState(UserState{Name: "Initial", Count: 0}), + WithStore(store), + ) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + const numGoroutines = 10 + const numUpdates = 100 + + var wg sync.WaitGroup + wg.Add(numGoroutines) + + for i := 0; i < numGoroutines; i++ { + go func(id int) { + defer wg.Done() + for j := 0; j < numUpdates; j++ { + // Read state + _ = sess.State() + + // Update state + _ = sess.UpdateState(ctx, UserState{ + Name: "Goroutine", + Count: id*numUpdates + j, + }) + } + }(i) + } + + wg.Wait() + + // Verify no data corruption + state := sess.State() + if state.Name != "Goroutine" { + t.Errorf("Expected Name %q, got %q", "Goroutine", state.Name) + } +} + +func TestInMemoryStore_ConcurrentAccess(t *testing.T) { + store := NewInMemoryStore[UserState]() + ctx := context.Background() + + const numGoroutines = 10 + const numOperations = 100 + + var wg sync.WaitGroup + wg.Add(numGoroutines) + + for i := 0; i < numGoroutines; i++ { + go func(id int) { + defer wg.Done() + key := "shared-key" + for j := 0; j < numOperations; j++ { + // Save + data := &Data[UserState]{ + ID: key, + State: UserState{Name: "Concurrent", Count: id*numOperations + j}, + } + _ = store.Save(ctx, key, data) + + // Get + _, _ = store.Get(ctx, key) + } + }(i) + } + + wg.Wait() + + // Verify we can still read + data, err := store.Get(ctx, "shared-key") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if data == nil { + t.Fatal("Expected data, got nil") + } +} + +func TestNotFoundError(t *testing.T) { + err := &NotFoundError{SessionID: "test-123"} + + expected := "session not found: test-123" + if err.Error() != expected { + t.Errorf("Expected error message %q, got %q", expected, err.Error()) + } +} + +func TestSession_ZeroState(t *testing.T) { + ctx := context.Background() + // Create session without initial state + sess, err := New[UserState](ctx) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + state := sess.State() + if state.Name != "" { + t.Errorf("Expected empty Name, got %q", state.Name) + } + if state.Count != 0 { + t.Errorf("Expected zero Count, got %d", state.Count) + } + if state.Preferences != nil { + t.Errorf("Expected nil Preferences, got %v", state.Preferences) + } +} + +func TestSession_ComplexState(t *testing.T) { + ctx := context.Background() + type NestedState struct { + Inner struct { + Value string `json:"value"` + } `json:"inner"` + List []int `json:"list"` + } + + store := NewInMemoryStore[NestedState]() + initial := NestedState{ + List: []int{1, 2, 3}, + } + initial.Inner.Value = "nested" + + sess, err := New(ctx, + WithID[NestedState]("complex-state"), + WithInitialState(initial), + WithStore(store), + ) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + // Update with nested modifications + newState := NestedState{ + List: []int{4, 5, 6, 7}, + } + newState.Inner.Value = "updated nested" + + if err := sess.UpdateState(ctx, newState); err != nil { + t.Fatalf("UpdateState failed: %v", err) + } + + // Verify nested state is correct + got := sess.State() + if got.Inner.Value != "updated nested" { + t.Errorf("Expected Inner.Value %q, got %q", "updated nested", got.Inner.Value) + } + if len(got.List) != 4 { + t.Errorf("Expected List length %d, got %d", 4, len(got.List)) + } + + // Verify persistence + data, err := store.Get(ctx, "complex-state") + if err != nil { + t.Fatalf("Store.Get failed: %v", err) + } + if data.State.Inner.Value != "updated nested" { + t.Errorf("Store: expected Inner.Value %q, got %q", "updated nested", data.State.Inner.Value) + } +} + +// mockFailingStore is a store that fails on Save for testing error handling. +type mockFailingStore[S any] struct { + saveErr error +} + +func (s *mockFailingStore[S]) Get(_ context.Context, _ string) (*Data[S], error) { + return nil, nil +} + +func (s *mockFailingStore[S]) Save(_ context.Context, _ string, _ *Data[S]) error { + return s.saveErr +} +func TestNew_StoreError(t *testing.T) { + ctx := context.Background() + expectedErr := errors.New("store failure") + store := &mockFailingStore[UserState]{saveErr: expectedErr} + _, err := New(ctx, + WithID[UserState]("error-test"), + WithStore(store), + ) + if err == nil { + t.Fatal("Expected error from failing store") + } + if !strings.Contains(err.Error(), "failed to persist initial state") { + t.Errorf("Expected persist error, got: %v", err) + } + if !errors.Is(err, expectedErr) { + t.Errorf("Expected wrapped error %v, got %v", expectedErr, err) + } +} + +func TestSession_UpdateState_StoreError(t *testing.T) { + ctx := context.Background() + store := NewInMemoryStore[UserState]() + sess, err := New(ctx, + WithID[UserState]("error-test"), + WithStore(store), + ) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + expectedErr := errors.New("store failure") + sess.store = &mockFailingStore[UserState]{saveErr: expectedErr} + + err = sess.UpdateState(ctx, UserState{Name: "Test"}) + if err == nil { + t.Fatal("Expected error from failing store") + } + if err != expectedErr { + t.Errorf("Expected error %v, got %v", expectedErr, err) + } +} diff --git a/go/genkit/doc.go b/go/genkit/doc.go new file mode 100644 index 0000000000..f72b7c1e7b --- /dev/null +++ b/go/genkit/doc.go @@ -0,0 +1,408 @@ +// 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 + +/* +Package genkit provides a framework for building AI-powered applications in Go. + +Genkit is an open-source framework that helps you build, deploy, and monitor +production-ready AI features. It provides a unified interface for working with +large language models (LLMs), managing prompts, defining workflows, and integrating +with various AI service providers. + +For comprehensive documentation, tutorials, and examples, visit https://genkit.dev + +# Getting Started + +Initialize Genkit with a plugin to connect to an AI provider: + + ctx := context.Background() + g := genkit.Init(ctx, + genkit.WithPlugins(&googlegenai.GoogleAI{}), + ) + +Generate text with a simple prompt: + + text, err := genkit.GenerateText(ctx, g, + ai.WithModelName("googleai/gemini-2.5-flash"), + ai.WithPrompt("Tell me a joke"), + ) + if err != nil { + log.Fatal(err) + } + fmt.Println(text) + +# Models + +Models represent AI language models that generate content. Use plugins to access +models from providers like Google AI, Vertex AI, Anthropic, or Ollama. Models are +referenced by name and can include provider-specific configuration: + + resp, err := genkit.Generate(ctx, g, + ai.WithModelName("googleai/gemini-2.5-flash"), + ai.WithPrompt("Explain quantum computing in simple terms"), + ) + +You can set a default model during initialization: + + g := genkit.Init(ctx, + genkit.WithPlugins(&googlegenai.GoogleAI{}), + genkit.WithDefaultModel("googleai/gemini-2.5-flash"), + ) + +# Flows + +Flows are reusable, observable functions that orchestrate AI operations. They +provide automatic tracing, can be exposed as HTTP endpoints, and support both +streaming and non-streaming execution. + +Define a simple flow: + + jokesFlow := genkit.DefineFlow(g, "jokesFlow", + func(ctx context.Context, topic string) (string, error) { + return genkit.GenerateText(ctx, g, + ai.WithPrompt("Share a joke about %s.", topic), + ) + }, + ) + + joke, err := jokesFlow.Run(ctx, "programming") + +Define a streaming flow that sends chunks as they're generated: + + streamingFlow := genkit.DefineStreamingFlow(g, "streamingJokes", + func(ctx context.Context, topic string, sendChunk ai.ModelStreamCallback) (string, error) { + resp, err := genkit.Generate(ctx, g, + ai.WithPrompt("Share a joke about %s.", topic), + ai.WithStreaming(sendChunk), + ) + if err != nil { + return "", err + } + return resp.Text(), nil + }, + ) + +Use [Run] within flows to create traced sub-steps for observability: + + genkit.DefineFlow(g, "pipeline", + func(ctx context.Context, input string) (string, error) { + result, err := genkit.Run(ctx, "processStep", func() (string, error) { + return process(input), nil + }) + return result, err + }, + ) + +# Prompts + +Prompts can be defined programmatically or loaded from .prompt files (Dotprompt format). +They encapsulate model configuration, input schemas, and template logic for reuse. + +Define a prompt in code: + + jokePrompt := genkit.DefinePrompt(g, "joke", + ai.WithModelName("googleai/gemini-2.5-flash"), + ai.WithInputType(JokeRequest{Topic: "default topic"}), + ai.WithPrompt("Share a joke about {{topic}}."), + ) + + stream := jokePrompt.ExecuteStream(ctx, ai.WithInput(map[string]any{"topic": "cats"})) + for result, err := range stream { + if err != nil { + return err + } + if result.Done { + fmt.Println(result.Response.Text()) + } + } + +For type-safe prompts with structured input and output, use [DefineDataPrompt]: + + type RecipeRequest struct { + Cuisine string `json:"cuisine"` + Dish string `json:"dish"` + ServingSize int `json:"servingSize"` + } + + type Recipe struct { + Title string `json:"title"` + Ingredients []string `json:"ingredients"` + Instructions []string `json:"instructions"` + } + + recipePrompt := genkit.DefineDataPrompt[RecipeRequest, *Recipe](g, "recipe", + ai.WithSystem("You are an experienced chef."), + ai.WithPrompt("Create a {{cuisine}} {{dish}} recipe for {{servingSize}} people."), + ) + + for result, err := range recipePrompt.ExecuteStream(ctx, RecipeRequest{ + Cuisine: "Italian", Dish: "pasta", ServingSize: 4, + }) { + // result.Chunk is *Recipe, result.Output is final *Recipe + } + +Load prompts from .prompt files by specifying a prompt directory: + + g := genkit.Init(ctx, + genkit.WithPlugins(&googlegenai.GoogleAI{}), + genkit.WithPromptDir("./prompts"), + ) + + // Look up a loaded prompt + jokePrompt := genkit.LookupPrompt(g, "joke") + + // Or with type parameters for structured I/O + recipePrompt := genkit.LookupDataPrompt[RecipeRequest, *Recipe](g, "recipe") + +When using .prompt files with custom output schemas, register the schema first: + + genkit.DefineSchemaFor[Recipe](g) + +# Tools + +Tools extend model capabilities by allowing them to call functions during generation. +Define tools that the model can invoke to perform actions or retrieve information: + + weatherTool := genkit.DefineTool(g, "getWeather", + "Gets the current weather for a city", + func(ctx *ai.ToolContext, city string) (string, error) { + // Fetch weather data... + return "Sunny, 72°F", nil + }, + ) + + resp, err := genkit.Generate(ctx, g, + ai.WithPrompt("What's the weather in Paris?"), + ai.WithTools(weatherTool), + ) + +# Structured Output + +Generate structured data that conforms to Go types using [GenerateData] or +[GenerateDataStream]. Use jsonschema struct tags to provide descriptions and +constraints that help the model understand the expected output: + + type Joke struct { + Joke string `json:"joke" jsonschema:"description=The joke text"` + Category string `json:"category" jsonschema:"description=The joke category"` + } + + joke, resp, err := genkit.GenerateData[*Joke](ctx, g, + ai.WithPrompt("Tell me a programming joke"), + ) + +For streaming structured output: + + stream := genkit.GenerateDataStream[*Recipe](ctx, g, + ai.WithPrompt("Create a pasta recipe"), + ) + for result, err := range stream { + if err != nil { + return nil, err + } + if result.Done { + return result.Output, nil + } + // result.Chunk contains partial Recipe as it streams + fmt.Printf("Got %d ingredients so far\n", len(result.Chunk.Ingredients)) + } + +# Streaming + +Genkit supports streaming at multiple levels. Use [GenerateStream] for streaming +model responses: + + stream := genkit.GenerateStream(ctx, g, + ai.WithPrompt("Write a short story"), + ) + for result, err := range stream { + if err != nil { + log.Fatal(err) + } + if result.Done { + fmt.Println("\n--- Complete ---") + } else { + fmt.Print(result.Chunk.Text()) + } + } + +Use [DefineStreamingFlow] for flows that stream custom data types: + + genkit.DefineStreamingFlow(g, "countdown", + func(ctx context.Context, count int, sendChunk func(context.Context, int) error) (string, error) { + for i := count; i > 0; i-- { + if err := sendChunk(ctx, i); err != nil { + return "", err + } + time.Sleep(time.Second) + } + return "Liftoff!", nil + }, + ) + +# Development Mode and Dev UI + +Set GENKIT_ENV=dev to enable development features including the Reflection API +server that powers the Genkit Developer UI: + + $ export GENKIT_ENV=dev + $ go run main.go + +Then run the Dev UI to inspect flows, test prompts, and view traces: + + $ npx genkit start -- go run main.go + +The Dev UI provides: + - Interactive flow testing with input/output inspection + - Prompt playground for iterating on prompts + - Trace viewer for debugging and performance analysis + - Action browser for exploring registered actions + +# HTTP Server Integration + +Expose flows as HTTP endpoints for production deployment using [Handler]: + + mux := http.NewServeMux() + for _, flow := range genkit.ListFlows(g) { + mux.HandleFunc("POST /"+flow.Name(), genkit.Handler(flow)) + } + log.Fatal(server.Start(ctx, "127.0.0.1:8080", mux)) + +Handlers support streaming responses via Server-Sent Events when the client +sends Accept: text/event-stream. For durable streaming that survives reconnects, +use [WithStreamManager]: + + mux.HandleFunc("POST /countdown", genkit.Handler(countdown, + genkit.WithStreamManager(streaming.NewInMemoryStreamManager( + streaming.WithTTL(10*time.Minute), + )), + )) + +# Plugins + +Genkit's functionality is extended through plugins that provide models, tools, +retrievers, and other capabilities. Common plugins include: + + - googlegenai: Google AI (Gemini models) + - vertexai: Google Cloud Vertex AI + - ollama: Local Ollama models + +Initialize plugins during [Init]: + + g := genkit.Init(ctx, + genkit.WithPlugins( + &googlegenai.GoogleAI{}, + &vertexai.VertexAI{ProjectID: "my-project"}, + ), + ) + +# Messages and Parts + +Build conversation messages using helper functions from the [ai] package. These +are used with [ai.WithMessages] or when building custom conversation flows: + + // Create messages for a conversation + messages := []*ai.Message{ + ai.NewSystemTextMessage("You are a helpful assistant."), + ai.NewUserTextMessage("Hello!"), + ai.NewModelTextMessage("Hi there! How can I help?"), + } + + resp, err := genkit.Generate(ctx, g, + ai.WithMessages(messages...), + ai.WithPrompt("What can you do?"), + ) + +For multi-modal content, combine text and media parts: + + userMsg := ai.NewUserMessage( + ai.NewTextPart("What's in this image?"), + ai.NewMediaPart("image/png", base64ImageData), + ) + +Available message constructors in the [ai] package: + + - [ai.NewUserTextMessage], [ai.NewUserMessage]: User messages + - [ai.NewModelTextMessage], [ai.NewModelMessage]: Model responses + - [ai.NewSystemTextMessage], [ai.NewSystemMessage]: System instructions + +Available part constructors in the [ai] package: + + - [ai.NewTextPart]: Text content + - [ai.NewMediaPart]: Images, audio, video (base64-encoded) + - [ai.NewDataPart]: Raw data strings + - [ai.NewToolRequestPart], [ai.NewToolResponsePart]: Tool interactions + +# Generation Options + +Generation functions ([Generate], [GenerateText], [GenerateData], [GenerateStream]) +accept options from the [ai] package to control behavior. The most common options: + +Model and Configuration: + + - [ai.WithModel]: Specify the model (accepts [ai.ModelRef] or plugin model refs) + - [ai.WithModelName]: Specify model by name string (e.g., "googleai/gemini-2.5-flash") + - [ai.WithConfig]: Set generation parameters (temperature, max tokens, etc.) + +Prompting: + + - [ai.WithPrompt]: Set the user prompt (supports format strings) + - [ai.WithSystem]: Set system instructions + - [ai.WithMessages]: Provide conversation history + +Tools and Output: + + - [ai.WithTools]: Enable tools the model can call + - [ai.WithOutputType]: Request structured output matching a Go type + - [ai.WithOutputFormat]: Specify output format (json, text, etc.) + +Streaming: + + - [ai.WithStreaming]: Enable streaming with a callback function + +Example combining multiple options: + + resp, err := genkit.Generate(ctx, g, + ai.WithModelName("googleai/gemini-2.5-flash"), + ai.WithSystem("You are a helpful coding assistant."), + ai.WithMessages(conversationHistory...), + ai.WithPrompt("Explain this code: %s", code), + ai.WithTools(searchTool, calculatorTool), + // Config is provider-specific (e.g., genai.GenerateContentConfig for Google AI) + ) + +# Unregistered Components + +For advanced use cases, the [ai] package provides New* functions to create +components without registering them in Genkit. This is useful for plugins +or when you need to pass components directly: + + - [ai.NewTool]: Create an unregistered tool + - [ai.NewModel]: Create an unregistered model + - [ai.NewRetriever]: Create an unregistered retriever + - [ai.NewEmbedder]: Create an unregistered embedder + +Use the corresponding Define* functions in this package to create and register +components for use with Genkit's action system, tracing, and Dev UI. + +# Additional Resources + + - Documentation: https://genkit.dev + - Go Getting Started: https://genkit.dev/go/docs/get-started-go + - Samples: https://github.com/firebase/genkit/tree/main/go/samples + - GitHub: https://github.com/firebase/genkit +*/ +package genkit diff --git a/go/genkit/example_test.go b/go/genkit/example_test.go new file mode 100644 index 0000000000..917e8dc49c --- /dev/null +++ b/go/genkit/example_test.go @@ -0,0 +1,322 @@ +// 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 + +package genkit_test + +import ( + "context" + "fmt" + "log" + "net/http" + "strings" + + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/core" + "github.com/firebase/genkit/go/genkit" +) + +// This example shows basic initialization and flow definition. +func Example() { + ctx := context.Background() + + // Initialize Genkit (without plugins for this example) + g := genkit.Init(ctx) + + // Define a simple flow + greetFlow := genkit.DefineFlow(g, "greet", + func(ctx context.Context, name string) (string, error) { + return fmt.Sprintf("Hello, %s!", name), nil + }, + ) + + // Run the flow + greeting, err := greetFlow.Run(ctx, "World") + if err != nil { + log.Fatal(err) + } + fmt.Println(greeting) + // Output: Hello, World! +} + +// This example demonstrates defining a simple non-streaming flow. +func ExampleDefineFlow() { + ctx := context.Background() + g := genkit.Init(ctx) + + // Define a flow that processes input + uppercaseFlow := genkit.DefineFlow(g, "uppercase", + func(ctx context.Context, input string) (string, error) { + return strings.ToUpper(input), nil + }, + ) + + // Run the flow + result, err := uppercaseFlow.Run(ctx, "hello") + if err != nil { + log.Fatal(err) + } + fmt.Println(result) + // Output: HELLO +} + +// This example demonstrates defining a streaming flow that sends +// chunks to the caller as they are produced. +func ExampleDefineStreamingFlow() { + ctx := context.Background() + g := genkit.Init(ctx) + + // Define a streaming flow that counts down + countdownFlow := genkit.DefineStreamingFlow(g, "countdown", + func(ctx context.Context, start int, sendChunk func(context.Context, int) error) (string, error) { + for i := start; i > 0; i-- { + if err := sendChunk(ctx, i); err != nil { + return "", err + } + } + return "Liftoff!", nil + }, + ) + + // Stream results using the iterator + iter := countdownFlow.Stream(ctx, 3) + iter(func(val *core.StreamingFlowValue[string, int], err error) bool { + if err != nil { + log.Fatal(err) + } + if val.Done { + fmt.Println("Final:", val.Output) + } else { + fmt.Println("Count:", val.Stream) + } + return true + }) + // Output: + // Count: 3 + // Count: 2 + // Count: 1 + // Final: Liftoff! +} + +// This example demonstrates using Run to create traced sub-steps +// within a flow for better observability. +func ExampleRun() { + ctx := context.Background() + g := genkit.Init(ctx) + + // Define a flow with traced sub-steps + pipelineFlow := genkit.DefineFlow(g, "pipeline", + func(ctx context.Context, input string) (string, error) { + // Each Run call creates a traced step visible in the Dev UI + upper, err := genkit.Run(ctx, "uppercase", func() (string, error) { + return strings.ToUpper(input), nil + }) + if err != nil { + return "", err + } + + result, err := genkit.Run(ctx, "addPrefix", func() (string, error) { + return "Processed: " + upper, nil + }) + return result, err + }, + ) + + result, err := pipelineFlow.Run(ctx, "hello") + if err != nil { + log.Fatal(err) + } + fmt.Println(result) + // Output: Processed: HELLO +} + +// This example demonstrates defining a tool that models can call +// during generation. +func ExampleDefineTool() { + ctx := context.Background() + g := genkit.Init(ctx) + + // Define a tool that adds two numbers + _ = genkit.DefineTool(g, "add", + "Adds two numbers together", + func(ctx *ai.ToolContext, input struct { + A float64 `json:"a" jsonschema:"description=First number"` + B float64 `json:"b" jsonschema:"description=Second number"` + }) (float64, error) { + return input.A + input.B, nil + }, + ) + + // The tool is now registered and can be used with ai.WithTools() + // when calling genkit.Generate() + fmt.Println("Tool registered: add") + // Output: Tool registered: add +} + +// This example demonstrates defining a reusable prompt with a template. +func ExampleDefinePrompt() { + ctx := context.Background() + g := genkit.Init(ctx) + + // Define a prompt with Handlebars template syntax + prompt := genkit.DefinePrompt(g, "greeting", + ai.WithPrompt("Say hello to {{name}} in a {{style}} way."), + ) + + // Render the prompt (without executing - useful for inspection) + rendered, err := prompt.Render(ctx, map[string]any{ + "name": "Alice", + "style": "friendly", + }) + if err != nil { + log.Fatal(err) + } + // The rendered prompt contains the messages that would be sent + fmt.Println(rendered.Messages[0].Content[0].Text) + // Output: Say hello to Alice in a friendly way. +} + +// This example demonstrates registering a Go type as a named schema. +func ExampleDefineSchemaFor() { + ctx := context.Background() + g := genkit.Init(ctx) + + // Define a struct type + type Person struct { + Name string `json:"name" jsonschema:"description=The person's name"` + Age int `json:"age" jsonschema:"description=The person's age"` + } + + // Register the schema - this makes it available for .prompt files + // that reference it by name (e.g., "output: { schema: Person }") + genkit.DefineSchemaFor[Person](g) + + fmt.Println("Schema registered: Person") + // Output: Schema registered: Person +} + +// This example demonstrates creating an HTTP server that exposes +// all registered flows as endpoints. +func ExampleListFlows_httpServer() { + ctx := context.Background() + g := genkit.Init(ctx) + + // Define some flows + genkit.DefineFlow(g, "echo", func(ctx context.Context, s string) (string, error) { + return s, nil + }) + + genkit.DefineFlow(g, "reverse", func(ctx context.Context, s string) (string, error) { + runes := []rune(s) + for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 { + runes[i], runes[j] = runes[j], runes[i] + } + return string(runes), nil + }) + + // Create HTTP handlers for all flows + mux := http.NewServeMux() + for _, flow := range genkit.ListFlows(g) { + mux.HandleFunc("POST /"+flow.Name(), genkit.Handler(flow)) + } + + // The mux now has: + // - POST /echo + // - POST /reverse + fmt.Printf("Registered %d flow handlers\n", len(genkit.ListFlows(g))) + // Output: Registered 2 flow handlers +} + +// This example demonstrates using Handler to expose a single flow +// as an HTTP endpoint. +func ExampleHandler() { + ctx := context.Background() + g := genkit.Init(ctx) + + // Define a flow + greetFlow := genkit.DefineFlow(g, "greet", + func(ctx context.Context, name string) (string, error) { + return fmt.Sprintf("Hello, %s!", name), nil + }, + ) + + // Create an HTTP handler for the flow + mux := http.NewServeMux() + mux.HandleFunc("POST /greet", genkit.Handler(greetFlow)) + + // The handler accepts JSON: {"data": "World"} + // and returns JSON: {"result": "Hello, World!"} + fmt.Println("Handler registered at POST /greet") + // Output: Handler registered at POST /greet +} + +// This example demonstrates using type-safe data prompts with +// strongly-typed input and output. +func ExampleDefineDataPrompt() { + ctx := context.Background() + g := genkit.Init(ctx) + + // Define input and output types + type JokeRequest struct { + Topic string `json:"topic"` + } + + type Joke struct { + Setup string `json:"setup"` + Punchline string `json:"punchline"` + } + + // Define a type-safe prompt + // Note: In production, you'd also set ai.WithModel(...) + _ = genkit.DefineDataPrompt[JokeRequest, *Joke](g, "joke", + ai.WithPrompt("Tell a joke about {{topic}}. Return JSON with setup and punchline."), + ) + + // The prompt can now be executed with: + // for result, err := range jokePrompt.ExecuteStream(ctx, JokeRequest{Topic: "cats"}) { + // if result.Done { + // fmt.Println(result.Output.Setup) + // fmt.Println(result.Output.Punchline) + // } + // } + + fmt.Println("DataPrompt registered: joke") + // Output: DataPrompt registered: joke +} + +// This example demonstrates looking up a prompt that was loaded +// from a .prompt file. +func ExampleLookupPrompt() { + ctx := context.Background() + + // In production, you would initialize with a prompt directory: + // g := genkit.Init(ctx, genkit.WithPromptDir("./prompts")) + + g := genkit.Init(ctx) + + // Define a prompt programmatically (simulating a loaded prompt) + genkit.DefinePrompt(g, "greeting", + ai.WithPrompt("Hello {{name}}!"), + ) + + // Look up the prompt by name + prompt := genkit.LookupPrompt(g, "greeting") + if prompt == nil { + log.Fatal("Prompt not found") + } + + fmt.Println("Found prompt:", prompt.Name()) + // Output: Found prompt: greeting +} diff --git a/go/genkit/genkit.go b/go/genkit/genkit.go index 8123debf37..83429ca0d1 100644 --- a/go/genkit/genkit.go +++ b/go/genkit/genkit.go @@ -427,12 +427,14 @@ func ListTools(g *Genkit) []ai.Tool { // DefineModel defines a custom model implementation, registers it as a [core.Action] // of type Model, and returns an [ai.Model] interface. // -// The `provider` and `name` arguments form the unique identifier for the model -// (e.g., "myProvider/myModel"). The `info` argument provides metadata about the -// model's capabilities ([ai.ModelInfo]). The `fn` argument ([ai.ModelFunc]) -// implements the actual generation logic, handling input requests ([ai.ModelRequest]) -// and producing responses ([ai.ModelResponse]), potentially streaming chunks -// ([ai.ModelResponseChunk]) via the callback. +// The `name` argument is the unique identifier for the model (e.g., "myProvider/myModel"). +// The `opts` argument provides metadata about the model's capabilities ([ai.ModelOptions]). +// The `fn` argument ([ai.ModelFunc]) implements the actual generation logic, handling +// input requests ([ai.ModelRequest]) and producing responses ([ai.ModelResponse]), +// potentially streaming chunks ([ai.ModelResponseChunk]) via the callback. +// +// For models that don't need to be registered (e.g., for plugin development or testing), +// use [ai.NewModel] instead. // // Example: // @@ -510,7 +512,7 @@ func LookupBackgroundModel(g *Genkit, name string) ai.BackgroundModel { } // DefineTool defines a tool that can be used by models during generation, -// registers it as a [core.Action] of type Tool, and returns an [ai.ToolDef]. +// registers it as a [core.Action] of type Tool, and returns an [ai.Tool]. // Tools allow models to interact with external systems or perform specific computations. // // The `name` is the identifier the model uses to request the tool. The `description` @@ -520,7 +522,13 @@ func LookupBackgroundModel(g *Genkit, name string) ai.BackgroundModel { // `inputSchema` and `outputSchema` in the tool's definition, which guide the model // on how to provide input and interpret output. // -// Use [ai.WithInputSchema] to provide a custom JSON schema instead of inferring from the type parameter. +// For tools that don't need to be registered (e.g., dynamically created tools), +// use [ai.NewTool] instead. +// +// # Options +// +// - [ai.WithInputSchema]: Provide a custom JSON schema instead of inferring from the type parameter +// - [ai.WithInputSchemaName]: Reference a pre-registered schema by name // // Example: // @@ -563,38 +571,6 @@ func DefineTool[In, Out any](g *Genkit, name, description string, fn ai.ToolFunc // input of type `any`, and returning an output of type `Out`. // // Deprecated: Use [DefineTool] with [ai.WithInputSchema] instead. -// -// Example: -// -// // Define a custom input schema -// inputSchema := map[string]any{ -// "type": "object", -// "properties": map[string]any{ -// "city": map[string]any{"type": "string"}, -// "unit": map[string]any{ -// "type": "string", -// "enum": []any{"C", "F"}, -// }, -// }, -// "required": []string{"city"}, -// } -// -// // Define the tool with the schema -// weatherTool := genkit.DefineTool(g, "getWeather", -// "Fetches the weather for a given city with unit preference", -// func(ctx *ai.ToolContext, input any) (string, error) { -// // Parse and validate input -// data := input.(map[string]any) -// city := data["city"].(string) -// unit := "C" // default -// if u, ok := data["unit"].(string); ok { -// unit = u -// } -// // Implementation... -// return fmt.Sprintf("Weather in %s: 25°%s", city, unit), nil -// }, -// ai.WithToolInputSchema(inputSchema), -// ) func DefineToolWithInputSchema[Out any](g *Genkit, name, description string, inputSchema map[string]any, fn ai.ToolFunc[any, Out]) ai.Tool { return ai.DefineTool(g.reg, name, description, fn, ai.WithInputSchema(inputSchema)) } @@ -610,7 +586,13 @@ func DefineToolWithInputSchema[Out any](g *Genkit, name, description string, inp // returning an [ai.MultipartToolResponse] which contains both the output and optional // content parts. // -// Use [ai.WithInputSchema] to provide a custom JSON schema instead of inferring from the type parameter. +// For multipart tools that don't need to be registered (e.g., dynamically created tools), +// use [ai.NewMultipartTool] instead. +// +// # Options +// +// - [ai.WithInputSchema]: Provide a custom JSON schema instead of inferring from the type parameter +// - [ai.WithInputSchemaName]: Reference a pre-registered schema by name // // Example: // @@ -661,18 +643,55 @@ func LookupTool(g *Genkit, name string) ai.Tool { } // DefinePrompt defines a prompt programmatically, registers it as a [core.Action] -// of type Prompt, and returns an executable [ai.prompt]. +// of type Prompt, and returns an executable [ai.Prompt]. // // This provides an alternative to defining prompts in `.prompt` files, offering // more flexibility through Go code. Prompts encapsulate configuration (model, parameters), // message templates (system, user, history), input/output schemas, and associated tools. // // Prompts can be executed in two main ways: -// 1. Render + Generate: Call [Prompt.Render] to get [ai.GenerateActionOptions], +// 1. Render + Generate: Call [ai.Prompt.Render] to get [ai.GenerateActionOptions], // modify them if needed, and pass them to [GenerateWithRequest]. -// 2. Execute: Call [Prompt.Execute] directly, passing input and execution options. -// -// Options ([ai.PromptOption]) are used to configure the prompt during definition. +// 2. Execute: Call [ai.Prompt.Execute] directly, passing input and execution options. +// +// For prompts that don't need to be registered (e.g., for single-use or testing), +// use [ai.NewPrompt] instead. +// +// # Options +// +// Model and Configuration: +// - [ai.WithModel]: Specify the model (accepts [ai.Model] or [ai.ModelRef]) +// - [ai.WithModelName]: Specify model by name string +// - [ai.WithConfig]: Set generation parameters (temperature, max tokens, etc.) +// +// Prompt Content: +// - [ai.WithPrompt]: Set the user prompt template (supports {{variable}} syntax) +// - [ai.WithPromptFn]: Set a function that generates the user prompt dynamically +// - [ai.WithSystem]: Set system instructions template +// - [ai.WithSystemFn]: Set a function that generates system instructions dynamically +// - [ai.WithMessages]: Provide static conversation history +// - [ai.WithMessagesFn]: Provide a function that generates conversation history +// +// Input Schema: +// - [ai.WithInputType]: Set input schema from a Go type (provides default values) +// - [ai.WithInputSchema]: Provide a custom JSON schema for input +// - [ai.WithInputSchemaName]: Reference a pre-registered schema by name +// +// Output Schema: +// - [ai.WithOutputType]: Set output schema from a Go type +// - [ai.WithOutputSchema]: Provide a custom JSON schema for output +// - [ai.WithOutputSchemaName]: Reference a pre-registered schema by name +// - [ai.WithOutputFormat]: Specify output format (json, text, etc.) +// +// Tools and Resources: +// - [ai.WithTools]: Enable tools the model can call +// - [ai.WithToolChoice]: Control whether tool calls are required, optional, or disabled +// - [ai.WithMaxTurns]: Set maximum tool call iterations +// - [ai.WithResources]: Attach resources available during generation +// +// Metadata: +// - [ai.WithDescription]: Set a description for the prompt +// - [ai.WithMetadata]: Set arbitrary metadata // // Example: // @@ -687,12 +706,12 @@ func LookupTool(g *Genkit, name string) ai.Tool { // // Define the prompt // capitalPrompt := genkit.DefinePrompt(g, "findCapital", // ai.WithDescription("Finds the capital of a country."), -// ai.WithModelName("googleai/gemini-2.5-flash"), // Specify the model +// ai.WithModelName("googleai/gemini-2.5-flash"), // ai.WithSystem("You are a helpful geography assistant."), // ai.WithPrompt("What is the capital of {{country}}?"), // ai.WithInputType(GeoInput{Country: "USA"}), // ai.WithOutputType(GeoOutput{}), -// ai.WithConfig(&ai.GenerationCommonConfig{Temperature: 0.5}), +// // Config is provider-specific, e.g., genai.GenerateContentConfig for Google AI // ) // // // Option 1: Render + Generate (using default input "USA") @@ -777,6 +796,14 @@ func DefineSchemaFor[T any](g *Genkit) { // It automatically infers input schema from the In type parameter and configures // output schema and JSON format from the Out type parameter (unless Out is string). // +// This is a convenience wrapper around [DefinePrompt] that provides compile-time +// type safety for both input and output. For prompts that don't need to be registered, +// use [ai.NewDataPrompt] instead. +// +// DefineDataPrompt accepts the same options as [DefinePrompt]. See [DefinePrompt] for +// the full list of available options. Note that input and output schemas are automatically +// inferred from the type parameters. +// // Example: // // type GeoInput struct { @@ -826,8 +853,7 @@ func LookupDataPrompt[In, Out any](g *Genkit, name string) *ai.DataPrompt[In, Ou // // handle error // } // -// // Optional: Modify actionOpts here if needed -// // actionOpts.Config = &ai.GenerationCommonConfig{ Temperature: 0.8 } +// // Optional: Modify actionOpts here if needed (config is provider-specific) // // resp, err := genkit.GenerateWithRequest(ctx, g, actionOpts, nil, nil) // No middleware or streaming // if err != nil { @@ -842,12 +868,50 @@ func GenerateWithRequest(ctx context.Context, g *Genkit, actionOpts *ai.Generate // provided via [ai.GenerateOption] arguments. It's a convenient way to make // generation calls without pre-defining a prompt object. // +// # Options +// +// Model and Configuration: +// - [ai.WithModel]: Specify the model (accepts [ai.Model] or [ai.ModelRef]) +// - [ai.WithModelName]: Specify model by name string (e.g., "googleai/gemini-2.5-flash") +// - [ai.WithConfig]: Set generation parameters (temperature, max tokens, etc.) +// +// Prompting: +// - [ai.WithPrompt]: Set the user prompt (supports format strings) +// - [ai.WithPromptFn]: Set a function that generates the user prompt dynamically +// - [ai.WithSystem]: Set system instructions +// - [ai.WithSystemFn]: Set a function that generates system instructions dynamically +// - [ai.WithMessages]: Provide conversation history +// - [ai.WithMessagesFn]: Provide a function that generates conversation history +// +// Tools and Resources: +// - [ai.WithTools]: Enable tools the model can call +// - [ai.WithToolChoice]: Control whether tool calls are required, optional, or disabled +// - [ai.WithMaxTurns]: Set maximum tool call iterations +// - [ai.WithReturnToolRequests]: Return tool requests instead of executing them +// - [ai.WithResources]: Attach resources available during generation +// +// Output: +// - [ai.WithOutputType]: Request structured output matching a Go type +// - [ai.WithOutputSchema]: Provide a custom JSON schema for output +// - [ai.WithOutputSchemaName]: Reference a pre-registered schema by name +// - [ai.WithOutputFormat]: Specify output format (json, text, etc.) +// - [ai.WithOutputEnums]: Constrain output to specific enum values +// +// Context and Streaming: +// - [ai.WithDocs]: Provide context documents +// - [ai.WithTextDocs]: Provide context as text strings +// - [ai.WithStreaming]: Enable streaming with a callback function +// - [ai.WithMiddleware]: Apply middleware to the model request/response +// +// Tool Continuation: +// - [ai.WithToolResponses]: Resume generation with tool response parts +// - [ai.WithToolRestarts]: Resume generation by restarting tool requests +// // Example: // // resp, err := genkit.Generate(ctx, g, // ai.WithModelName("googleai/gemini-2.5-flash"), // ai.WithPrompt("Write a short poem about clouds."), -// ai.WithConfig(&genai.GenerateContentConfig{MaxOutputTokens: 50}), // ) // if err != nil { // log.Fatalf("Generate failed: %v", err) @@ -869,6 +933,9 @@ func Generate(ctx context.Context, g *Genkit, opts ...ai.GenerateOption) (*ai.Mo // // Otherwise the Chunk field of the passed [ai.ModelStreamValue] holds a streamed chunk. // +// GenerateStream accepts the same options as [Generate]. See [Generate] for the full +// list of available options. +// // Example: // // for result, err := range genkit.GenerateStream(ctx, g, @@ -888,11 +955,15 @@ func GenerateStream(ctx context.Context, g *Genkit, opts ...ai.GenerateOption) i } // GenerateOperation performs a model generation request using a flexible set of options -// provided via [ai.GenerateOption] arguments. It's a convenient way to make -// generation calls without pre-defining a prompt object. +// provided via [ai.GenerateOption] arguments. It's designed for long-running generation +// tasks that may not complete immediately. // // Unlike [Generate], this function returns a [ai.ModelOperation] which can be used to -// check the status of the operation and get the result. +// check the status of the operation and get the result. Use [CheckModelOperation] to +// poll for completion. +// +// GenerateOperation accepts the same options as [Generate]. See [Generate] for the full +// list of available options. // // Example: // @@ -928,7 +999,9 @@ func CheckModelOperation(ctx context.Context, g *Genkit, op *ai.ModelOperation) // GenerateText performs a model generation request similar to [Generate], but // directly returns the generated text content as a string. It's a convenience // wrapper for cases where only the textual output is needed. -// It accepts the same [ai.GenerateOption] arguments as [Generate]. +// +// GenerateText accepts the same options as [Generate]. See [Generate] for the full +// list of available options. // // Example: // @@ -944,16 +1017,13 @@ func GenerateText(ctx context.Context, g *Genkit, opts ...ai.GenerateOption) (st } // GenerateData performs a model generation request, expecting structured output -// (typically JSON) that conforms to the schema of the provided `value` argument. -// It attempts to unmarshal the model's response directly into the `value`. -// The `value` argument must be a pointer to a struct or map. +// (typically JSON) that conforms to the schema inferred from the Out type parameter. +// It automatically sets output type and JSON format, unmarshals the response, and +// returns the typed result. // -// Use [ai.WithOutputType] or [ai.WithOutputFormat](ai.OutputFormatJSON) in the -// options to instruct the model to generate JSON. [ai.WithOutputType] is preferred -// as it infers the JSON schema from the `value` type and passes it to the model. -// -// It returns the full [ai.ModelResponse] along with any error. The generated data -// populates the `value` pointed to. +// GenerateData accepts the same options as [Generate]. See [Generate] for the full +// list of available options. Note that output options like [ai.WithOutputType] are +// automatically applied based on the Out type parameter. // // Example: // @@ -987,6 +1057,10 @@ func GenerateData[Out any](ctx context.Context, g *Genkit, opts ...ai.GenerateOp // // Otherwise the Chunk field of the passed [ai.StreamValue] holds a streamed chunk. // +// GenerateDataStream accepts the same options as [Generate]. See [Generate] for the full +// list of available options. Note that output options are automatically applied based on +// the Out type parameter. +// // Example: // // type Story struct { @@ -994,7 +1068,7 @@ func GenerateData[Out any](ctx context.Context, g *Genkit, opts ...ai.GenerateOp // Content string `json:"content"` // } // -// for result, err := range genkit.GenerateDataStream[Story, *ai.ModelResponseChunk](ctx, g, +// for result, err := range genkit.GenerateDataStream[Story](ctx, g, // ai.WithPrompt("Write a short story about a brave knight."), // ) { // if err != nil { @@ -1015,10 +1089,18 @@ func GenerateDataStream[Out any](ctx context.Context, g *Genkit, opts ...ai.Gene // relevant documents from registered retrievers without directly calling the // retriever instance. // +// # Options +// +// - [ai.WithRetriever]: Specify the retriever (accepts [ai.Retriever] or [ai.RetrieverRef]) +// - [ai.WithRetrieverName]: Specify retriever by name string +// - [ai.WithConfig]: Set retriever-specific configuration +// - [ai.WithTextDocs]: Provide query text as documents +// - [ai.WithDocs]: Provide query as [ai.Document] instances +// // Example: // // resp, err := genkit.Retrieve(ctx, g, -// ai.WithRetriever(ai.NewRetrieverRef("myRetriever", nil)), +// ai.WithRetrieverName("myRetriever"), // ai.WithTextDocs("What is the capital of France?"), // ) // if err != nil { @@ -1036,10 +1118,18 @@ func Retrieve(ctx context.Context, g *Genkit, opts ...ai.RetrieverOption) (*ai.R // provided via [ai.EmbedderOption] arguments. It's a convenient way to generate // embeddings from registered embedders without directly calling the embedder instance. // +// # Options +// +// - [ai.WithEmbedder]: Specify the embedder (accepts [ai.Embedder] or [ai.EmbedderRef]) +// - [ai.WithEmbedderName]: Specify embedder by name string +// - [ai.WithConfig]: Set embedder-specific configuration +// - [ai.WithTextDocs]: Provide text to embed +// - [ai.WithDocs]: Provide [ai.Document] instances to embed +// // Example: // // resp, err := genkit.Embed(ctx, g, -// ai.WithEmbedder(ai.NewEmbedderRef("myEmbedder", nil)), +// ai.WithEmbedderName("myEmbedder"), // ai.WithTextDocs("Hello, world!"), // ) // if err != nil { @@ -1058,9 +1148,12 @@ func Embed(ctx context.Context, g *Genkit, opts ...ai.EmbedderOption) (*ai.Embed // Retrievers are used to find documents relevant to a given query, often by // performing similarity searches in a vector database. // -// The `provider` and `name` form the unique identifier. The `ret` function +// The `name` is the unique identifier for the retriever. The `fn` function // contains the logic to process an [ai.RetrieverRequest] (containing the query) // and return an [ai.RetrieverResponse] (containing the relevant documents). +// +// For retrievers that don't need to be registered (e.g., for plugin development), +// use [ai.NewRetriever] instead. func DefineRetriever(g *Genkit, name string, opts *ai.RetrieverOptions, fn ai.RetrieverFunc) ai.Retriever { return ai.DefineRetriever(g.reg, name, opts, fn) } @@ -1076,9 +1169,12 @@ func LookupRetriever(g *Genkit, name string) ai.Retriever { // [core.Action] of type Embedder, and returns an [ai.Embedder]. // Embedders convert text documents or queries into numerical vector representations (embeddings). // -// The `provider` and `name` are specified in the `opts` parameter which forms the unique identifier. -// The `embed` function contains the logic to process an [ai.EmbedRequest] (containing documents or a query) +// The `name` is the unique identifier for the embedder. +// The `fn` function contains the logic to process an [ai.EmbedRequest] (containing documents or a query) // and return an [ai.EmbedResponse] (containing the corresponding embeddings). +// +// For embedders that don't need to be registered (e.g., for plugin development), +// use [ai.NewEmbedder] instead. func DefineEmbedder(g *Genkit, name string, opts *ai.EmbedderOptions, fn ai.EmbedderFunc) ai.Embedder { return ai.DefineEmbedder(g.reg, name, opts, fn) } @@ -1144,6 +1240,14 @@ func LookupEvaluator(g *Genkit, name string) ai.Evaluator { // evaluations using registered evaluators without directly calling the // evaluator instance. // +// # Options +// +// - [ai.WithEvaluator]: Specify the evaluator (accepts [ai.Evaluator] or [ai.EvaluatorRef]) +// - [ai.WithEvaluatorName]: Specify evaluator by name string +// - [ai.WithDataset]: Provide the dataset of examples to evaluate +// - [ai.WithID]: Set a unique identifier for this evaluation run +// - [ai.WithConfig]: Set evaluator-specific configuration +// // Example: // // dataset := []*ai.Example{ @@ -1154,8 +1258,8 @@ func LookupEvaluator(g *Genkit, name string) ai.Evaluator { // } // // resp, err := genkit.Evaluate(ctx, g, -// ai.WithEvaluator(ai.NewEvaluatorRef("myEvaluator", nil)), -// ai.WithDataset(dataset), +// ai.WithEvaluatorName("myEvaluator"), +// ai.WithDataset(dataset...), // ) // if err != nil { // log.Fatalf("Evaluate failed: %v", err) diff --git a/go/go.mod b/go/go.mod index 3472c0f4cb..2750230ab0 100644 --- a/go/go.mod +++ b/go/go.mod @@ -41,7 +41,7 @@ require ( golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 golang.org/x/tools v0.34.0 google.golang.org/api v0.236.0 - google.golang.org/genai v1.40.0 + google.golang.org/genai v1.41.0 ) require ( diff --git a/go/go.sum b/go/go.sum index e7abcc1495..c832ade7c6 100644 --- a/go/go.sum +++ b/go/go.sum @@ -537,8 +537,8 @@ google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9Ywl google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw= google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI= -google.golang.org/genai v1.40.0 h1:kYxyQSH+vsib8dvsgyLJzsVEIv5k3ZmHJyVqdvGncmc= -google.golang.org/genai v1.40.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= +google.golang.org/genai v1.41.0 h1:ayXl75LjTmqTu0y94yr96d17gIb4zF8gWVzX2TgioEY= +google.golang.org/genai v1.41.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= diff --git a/go/internal/base/json.go b/go/internal/base/json.go index 4f413ab8f7..117855a6d3 100644 --- a/go/internal/base/json.go +++ b/go/internal/base/json.go @@ -138,18 +138,29 @@ func SchemaAsMap(s *jsonschema.Schema) map[string]any { return m } -// jsonMarkdownRegex specifically looks for "json" language identifier -var jsonMarkdownRegex = regexp.MustCompile("(?s)```json(.*?)```") +// jsonMarkdownRegex matches fenced code blocks with "json" language identifier (case-insensitive). +var jsonMarkdownRegex = regexp.MustCompile("(?si)```json\\s*(.*?)```") + +// plainMarkdownRegex matches fenced code blocks without any language identifier. +var plainMarkdownRegex = regexp.MustCompile("(?s)```\\s*\\n(.*?)```") // ExtractJSONFromMarkdown returns the contents of the first fenced code block in -// the markdown text md. If there is none, it returns md. +// the markdown text md. It matches code blocks with "json" identifier (case-insensitive) +// or code blocks without any language identifier. If there is no matching block, it returns md. func ExtractJSONFromMarkdown(md string) string { + // First try to match explicit json code blocks matches := jsonMarkdownRegex.FindStringSubmatch(md) - if len(matches) < 2 { - return md + if len(matches) >= 2 { + return strings.TrimSpace(matches[1]) + } + + // Fall back to plain code blocks (no language identifier) + matches = plainMarkdownRegex.FindStringSubmatch(md) + if len(matches) >= 2 { + return strings.TrimSpace(matches[1]) } - // capture group 1 matches the actual fenced JSON block - return strings.TrimSpace(matches[1]) + + return md } // GetJSONObjectLines splits a string by newlines, trims whitespace from each line, diff --git a/go/internal/base/json_test.go b/go/internal/base/json_test.go index b018849af2..eda537c9ba 100644 --- a/go/internal/base/json_test.go +++ b/go/internal/base/json_test.go @@ -78,6 +78,31 @@ func TestExtractJSONFromMarkdown(t *testing.T) { in: "```json\n{\"a\": 1}\n``` ```yaml\nkey: 1\nanother-key: 2```", want: "{\"a\": 1}", }, + { + desc: "uppercase JSON identifier", + in: "```JSON\n{\"a\": 1}\n```", + want: "{\"a\": 1}", + }, + { + desc: "mixed case Json identifier", + in: "```Json\n{\"a\": 1}\n```", + want: "{\"a\": 1}", + }, + { + desc: "plain code block without identifier", + in: "```\n{\"a\": 1}\n```", + want: "{\"a\": 1}", + }, + { + desc: "plain code block with text before", + in: "Here is the result:\n\n```\n{\"title\": \"Pizza\"}\n```", + want: "{\"title\": \"Pizza\"}", + }, + { + desc: "json block preferred over plain block", + in: "```\n{\"plain\": true}\n``` then ```json\n{\"json\": true}\n```", + want: "{\"json\": true}", + }, } for _, tc := range tests { t.Run(tc.desc, func(t *testing.T) { diff --git a/go/internal/base/misc.go b/go/internal/base/misc.go index 9e3afa1d93..f4fdb7af32 100644 --- a/go/internal/base/misc.go +++ b/go/internal/base/misc.go @@ -18,6 +18,7 @@ package base import ( "net/url" + "reflect" ) // An Environment is the execution context in which the program is running. @@ -38,3 +39,16 @@ func Zero[T any]() T { func Clean(id string) string { return url.PathEscape(id) } + +// IsNil returns true if v is nil or a nil pointer/interface/map/slice/channel/func. +func IsNil[T any](v T) bool { + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Invalid: + return true + case reflect.Ptr, reflect.Interface, reflect.Map, reflect.Slice, reflect.Chan, reflect.Func: + return rv.IsNil() + default: + return false + } +} diff --git a/go/plugins/anthropic/anthropic.go b/go/plugins/anthropic/anthropic.go index 493a6c76b9..e93f1abde9 100644 --- a/go/plugins/anthropic/anthropic.go +++ b/go/plugins/anthropic/anthropic.go @@ -169,8 +169,9 @@ func newModel(client anthropic.Client, name string, opts ai.ModelOptions) ai.Mod // configToMap converts a config struct to a map[string]any. func configToMap(config any) map[string]any { r := jsonschema.Reflector{ - DoNotReference: false, // Prevent $ref usage + DoNotReference: true, // Prevent $ref usage AllowAdditionalProperties: false, + ExpandedStruct: true, RequiredFromJSONSchemaTags: true, } // The anthropic SDK uses a number of wrapper types for float, int, etc. @@ -201,5 +202,6 @@ func configToMap(config any) map[string]any { } schema := r.Reflect(config) result := base.SchemaAsMap(schema) + return result } diff --git a/go/plugins/firebase/x/option.go b/go/plugins/firebase/x/option.go new file mode 100644 index 0000000000..9de3443d60 --- /dev/null +++ b/go/plugins/firebase/x/option.go @@ -0,0 +1,76 @@ +// 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 + +package x + +import ( + "errors" + "time" +) + +const ( + // DefaultTTL is the default time-to-live for Firestore documents. + DefaultTTL = 5 * time.Minute +) + +// firestoreOptions holds common configuration for Firestore-based services. +type firestoreOptions struct { + Collection string + TTL time.Duration +} + +// applyFirestore applies common Firestore options. +func (o *firestoreOptions) applyFirestore(opts *firestoreOptions) error { + if o.Collection != "" { + if opts.Collection != "" { + return errors.New("cannot set collection more than once (WithCollection)") + } + opts.Collection = o.Collection + } + + if o.TTL > 0 { + if opts.TTL > 0 { + return errors.New("cannot set TTL more than once (WithTTL)") + } + opts.TTL = o.TTL + } + + return nil +} + +// applyStreamManager implements StreamManagerOption for firestoreOptions. +func (o *firestoreOptions) applyStreamManager(opts *streamManagerOptions) error { + return o.applyFirestore(&opts.firestoreOptions) +} + +// applySessionStore implements SessionStoreOption for firestoreOptions. +func (o *firestoreOptions) applySessionStore(opts *sessionStoreOptions) error { + return o.applyFirestore(&opts.firestoreOptions) +} + +// WithCollection sets the Firestore collection name where documents are stored. +// This option is required for all Firestore-based services. +func WithCollection(collection string) *firestoreOptions { + return &firestoreOptions{Collection: collection} +} + +// WithTTL sets how long documents are retained before Firestore auto-deletes them. +// Requires a TTL policy on the collection for the "expiresAt" field. +// Default is 5 minutes. +// See: https://firebase.google.com/docs/firestore/ttl +func WithTTL(ttl time.Duration) *firestoreOptions { + return &firestoreOptions{TTL: ttl} +} diff --git a/go/plugins/firebase/x/session_store.go b/go/plugins/firebase/x/session_store.go new file mode 100644 index 0000000000..57ce322f4b --- /dev/null +++ b/go/plugins/firebase/x/session_store.go @@ -0,0 +1,184 @@ +// 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 + +package x + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "cloud.google.com/go/firestore" + "github.com/firebase/genkit/go/core/x/session" + "github.com/firebase/genkit/go/genkit" + "github.com/firebase/genkit/go/plugins/firebase" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// SessionStoreOption configures a FirestoreSessionStore. +// Implemented by firestoreOptions (WithCollection, WithTTL). +type SessionStoreOption interface { + applySessionStore(*sessionStoreOptions) error +} + +// sessionStoreOptions holds configuration for FirestoreSessionStore. +type sessionStoreOptions struct { + firestoreOptions +} + +// applySessionStore implements SessionStoreOption for sessionStoreOptions. +func (o *sessionStoreOptions) applySessionStore(opts *sessionStoreOptions) error { + return o.firestoreOptions.applyFirestore(&opts.firestoreOptions) +} + +// FirestoreSessionStore implements [session.Store[S]] using Firestore as the backend. +// Session state is persisted in Firestore documents, allowing sessions to survive +// server restarts and be accessible across multiple instances. +type FirestoreSessionStore[S any] struct { + client *firestore.Client + collection string + ttl time.Duration +} + +// sessionDocument represents the structure of a session document in Firestore. +type sessionDocument struct { + State json.RawMessage `firestore:"state"` + CreatedAt time.Time `firestore:"createdAt"` + UpdatedAt time.Time `firestore:"updatedAt"` + ExpiresAt *time.Time `firestore:"expiresAt,omitempty"` +} + +// NewFirestoreSessionStore creates a Firestore-backed session store. +// Requires the Firebase plugin to be initialized in the Genkit instance. +func NewFirestoreSessionStore[S any](ctx context.Context, g *genkit.Genkit, opts ...SessionStoreOption) (*FirestoreSessionStore[S], error) { + storeOpts := &sessionStoreOptions{} + for _, opt := range opts { + if err := opt.applySessionStore(storeOpts); err != nil { + return nil, fmt.Errorf("firebase.NewFirestoreSessionStore: error applying options: %w", err) + } + } + if storeOpts.Collection == "" { + return nil, errors.New("firebase.NewFirestoreSessionStore: Collection name is required.\n" + + " Specify the Firestore collection where session documents will be stored:\n" + + " firebase.NewFirestoreSessionStore[MyState](ctx, g, firebase.WithCollection(\"genkit-sessions\"))") + } + if storeOpts.TTL == 0 { + storeOpts.TTL = DefaultTTL + } + + plugin := genkit.LookupPlugin(g, "firebase") + if plugin == nil { + return nil, errors.New("firebase.NewFirestoreSessionStore: Firebase plugin not found.\n" + + " Pass the Firebase plugin to genkit.Init():\n" + + " g := genkit.Init(ctx, genkit.WithPlugins(&firebase.Firebase{ProjectId: \"your-project\"}))") + } + f, ok := plugin.(*firebase.Firebase) + if !ok { + return nil, fmt.Errorf("firebase.NewFirestoreSessionStore: unexpected plugin type %T", plugin) + } + + client, err := f.Firestore(ctx) + if err != nil { + return nil, fmt.Errorf("firebase.NewFirestoreSessionStore: %w", err) + } + + return &FirestoreSessionStore[S]{ + client: client, + collection: storeOpts.Collection, + ttl: storeOpts.TTL, + }, nil +} + +// Get retrieves session data by ID from Firestore. +// Returns nil if the session does not exist. +func (s *FirestoreSessionStore[S]) Get(ctx context.Context, sessionID string) (*session.Data[S], error) { + docRef := s.client.Collection(s.collection).Doc(sessionID) + + snapshot, err := docRef.Get(ctx) + if err != nil { + if status.Code(err) == codes.NotFound { + return nil, nil + } + return nil, fmt.Errorf("firebase.FirestoreSessionStore.Get: %w", err) + } + if !snapshot.Exists() { + return nil, nil + } + + var doc sessionDocument + if err := snapshot.DataTo(&doc); err != nil { + return nil, fmt.Errorf("firebase.FirestoreSessionStore.Get: failed to parse document: %w", err) + } + + var state S + if len(doc.State) > 0 { + if err := json.Unmarshal(doc.State, &state); err != nil { + return nil, fmt.Errorf("firebase.FirestoreSessionStore.Get: failed to unmarshal state: %w", err) + } + } + + return &session.Data[S]{ + ID: sessionID, + State: state, + }, nil +} + +// Save persists session data to Firestore, creating or updating as needed. +// CreatedAt is only set when the document is first created; subsequent saves +// only update UpdatedAt and ExpiresAt. +func (s *FirestoreSessionStore[S]) Save(ctx context.Context, sessionID string, data *session.Data[S]) error { + docRef := s.client.Collection(s.collection).Doc(sessionID) + + stateJSON, err := json.Marshal(data.State) + if err != nil { + return fmt.Errorf("firebase.FirestoreSessionStore.Save: failed to marshal state: %w", err) + } + + now := time.Now() + expiresAt := now.Add(s.ttl) + + err = s.client.RunTransaction(ctx, func(ctx context.Context, tx *firestore.Transaction) error { + snapshot, err := tx.Get(docRef) + if err != nil && status.Code(err) != codes.NotFound { + return err + } + + if !snapshot.Exists() { + // Document doesn't exist - create it with CreatedAt + return tx.Create(docRef, sessionDocument{ + State: stateJSON, + CreatedAt: now, + UpdatedAt: now, + ExpiresAt: &expiresAt, + }) + } + + // Document exists - update without modifying CreatedAt + return tx.Update(docRef, []firestore.Update{ + {Path: "state", Value: stateJSON}, + {Path: "updatedAt", Value: now}, + {Path: "expiresAt", Value: &expiresAt}, + }) + }) + if err != nil { + return fmt.Errorf("firebase.FirestoreSessionStore.Save: %w", err) + } + + return nil +} diff --git a/go/plugins/firebase/x/session_store_test.go b/go/plugins/firebase/x/session_store_test.go new file mode 100644 index 0000000000..7b7c998e45 --- /dev/null +++ b/go/plugins/firebase/x/session_store_test.go @@ -0,0 +1,439 @@ +// 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 + +package x + +import ( + "context" + "flag" + "testing" + "time" + + "cloud.google.com/go/firestore" + "github.com/firebase/genkit/go/core/x/session" + "github.com/firebase/genkit/go/genkit" + "github.com/firebase/genkit/go/plugins/firebase" + "google.golang.org/api/iterator" +) + +var ( + testSessionProjectID = flag.String("test-session-project-id", "", "GCP Project ID to use for session store tests") + testSessionCollection = flag.String("test-session-collection", "genkit-sessions", "Firestore collection to use for session store tests") +) + +/* + * Pre-requisites to run this test: + * + * 1. **Option A - Use Firestore Emulator (Recommended for local development):** + * Start the Firestore emulator: + * ```bash + * export FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 + * gcloud emulators firestore start --host-port=127.0.0.1:8080 + * ``` + * + * 2. **Option B - Use a Real Firestore Database:** + * - Set up a Firebase project with Firestore enabled + * - Authenticate using: + * ```bash + * gcloud auth application-default login + * ``` + * + * 3. **Running the Test:** + * ```bash + * go test -test-session-project-id= -test-session-collection=genkit-sessions + * ``` + */ + +// TestState is a test state type with various field types. +type TestState struct { + Name string `json:"name"` + Count int `json:"count"` + Preferences map[string]string `json:"preferences,omitempty"` +} + +func skipIfNoFirestoreSession(t *testing.T) { + if *testSessionProjectID == "" { + t.Skip("Skipping test: -test-session-project-id flag not provided") + } +} + +func setupTestSessionStore(t *testing.T) (*FirestoreSessionStore[TestState], *firestore.Client, func()) { + skipIfNoFirestoreSession(t) + + ctx := context.Background() + g := genkit.Init(ctx, genkit.WithPlugins(&firebase.Firebase{ProjectId: *testSessionProjectID})) + + f := genkit.LookupPlugin(g, "firebase").(*firebase.Firebase) + client, err := f.Firestore(ctx) + if err != nil { + t.Fatalf("Failed to get Firestore client: %v", err) + } + + store, err := NewFirestoreSessionStore[TestState](ctx, g, + WithCollection(*testSessionCollection), + ) + if err != nil { + t.Fatalf("Failed to create session store: %v", err) + } + + cleanup := func() { + deleteSessionCollection(ctx, client, *testSessionCollection, t) + } + + return store, client, cleanup +} + +func deleteSessionCollection(ctx context.Context, client *firestore.Client, collectionName string, t *testing.T) { + iter := client.Collection(collectionName).Documents(ctx) + for { + doc, err := iter.Next() + if err == iterator.Done { + break + } + if err != nil { + t.Logf("Failed to iterate documents for deletion: %v", err) + return + } + _, err = doc.Ref.Delete(ctx) + if err != nil { + t.Logf("Failed to delete document %s: %v", doc.Ref.ID, err) + } + } +} + +func TestNewFirestoreSessionStore_MissingCollection(t *testing.T) { + skipIfNoFirestoreSession(t) + + ctx := context.Background() + g := genkit.Init(ctx, genkit.WithPlugins(&firebase.Firebase{ProjectId: *testSessionProjectID})) + + _, err := NewFirestoreSessionStore[TestState](ctx, g) + if err == nil { + t.Fatal("Expected error when collection is missing") + } +} + +func TestFirestoreSessionStore_SaveAndGet(t *testing.T) { + store, _, cleanup := setupTestSessionStore(t) + defer cleanup() + + ctx := context.Background() + sessionID := "test-session-save-get" + + // Initially empty + data, err := store.Get(ctx, sessionID) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if data != nil { + t.Errorf("Expected nil for non-existent session, got %v", data) + } + + // Save data + original := &session.Data[TestState]{ + ID: sessionID, + State: TestState{ + Name: "Alice", + Count: 42, + Preferences: map[string]string{"theme": "dark"}, + }, + } + if err := store.Save(ctx, sessionID, original); err != nil { + t.Fatalf("Save failed: %v", err) + } + + // Retrieve data + retrieved, err := store.Get(ctx, sessionID) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if retrieved == nil { + t.Fatal("Expected data, got nil") + } + if retrieved.ID != sessionID { + t.Errorf("Expected ID %q, got %q", sessionID, retrieved.ID) + } + if retrieved.State.Name != original.State.Name { + t.Errorf("Expected Name %q, got %q", original.State.Name, retrieved.State.Name) + } + if retrieved.State.Count != original.State.Count { + t.Errorf("Expected Count %d, got %d", original.State.Count, retrieved.State.Count) + } + if retrieved.State.Preferences["theme"] != "dark" { + t.Errorf("Expected theme %q, got %q", "dark", retrieved.State.Preferences["theme"]) + } +} + +func TestFirestoreSessionStore_Overwrite(t *testing.T) { + store, client, cleanup := setupTestSessionStore(t) + defer cleanup() + + ctx := context.Background() + sessionID := "test-session-overwrite" + + // Save initial data + initial := &session.Data[TestState]{ + ID: sessionID, + State: TestState{Name: "Alice", Count: 1}, + } + if err := store.Save(ctx, sessionID, initial); err != nil { + t.Fatalf("Save failed: %v", err) + } + + // Get the initial document to capture CreatedAt and UpdatedAt + snapshot1, err := client.Collection(*testSessionCollection).Doc(sessionID).Get(ctx) + if err != nil { + t.Fatalf("Failed to get initial document: %v", err) + } + initialData := snapshot1.Data() + initialCreatedAt, ok := initialData["createdAt"].(time.Time) + if !ok { + t.Fatal("Expected createdAt to be a timestamp") + } + initialUpdatedAt, ok := initialData["updatedAt"].(time.Time) + if !ok { + t.Fatal("Expected updatedAt to be a timestamp") + } + + // Wait a moment to ensure timestamp difference is detectable + time.Sleep(10 * time.Millisecond) + + // Overwrite with new data + updated := &session.Data[TestState]{ + ID: sessionID, + State: TestState{Name: "Alice Updated", Count: 2}, + } + if err := store.Save(ctx, sessionID, updated); err != nil { + t.Fatalf("Save failed: %v", err) + } + + // Get the updated document to verify timestamps + snapshot2, err := client.Collection(*testSessionCollection).Doc(sessionID).Get(ctx) + if err != nil { + t.Fatalf("Failed to get updated document: %v", err) + } + updatedData := snapshot2.Data() + updatedCreatedAt, ok := updatedData["createdAt"].(time.Time) + if !ok { + t.Fatal("Expected createdAt to be a timestamp after update") + } + updatedUpdatedAt, ok := updatedData["updatedAt"].(time.Time) + if !ok { + t.Fatal("Expected updatedAt to be a timestamp after update") + } + + // Verify CreatedAt is preserved (not modified during overwrite) + if !updatedCreatedAt.Equal(initialCreatedAt) { + t.Errorf("CreatedAt was modified during overwrite: initial=%v, after=%v", initialCreatedAt, updatedCreatedAt) + } + + // Verify UpdatedAt is modified (should be later than initial) + if !updatedUpdatedAt.After(initialUpdatedAt) { + t.Errorf("UpdatedAt should be later after overwrite: initial=%v, after=%v", initialUpdatedAt, updatedUpdatedAt) + } + + // Retrieve and verify state data + retrieved, err := store.Get(ctx, sessionID) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if retrieved.State.Name != "Alice Updated" { + t.Errorf("Expected Name %q, got %q", "Alice Updated", retrieved.State.Name) + } + if retrieved.State.Count != 2 { + t.Errorf("Expected Count %d, got %d", 2, retrieved.State.Count) + } +} + +func TestFirestoreSessionStore_ExpiresAt(t *testing.T) { + store, client, cleanup := setupTestSessionStore(t) + defer cleanup() + + ctx := context.Background() + sessionID := "test-session-expires" + + data := &session.Data[TestState]{ + ID: sessionID, + State: TestState{Name: "ExpiresTest"}, + } + if err := store.Save(ctx, sessionID, data); err != nil { + t.Fatalf("Save failed: %v", err) + } + + // Verify expiresAt is set in Firestore + snapshot, err := client.Collection(*testSessionCollection).Doc(sessionID).Get(ctx) + if err != nil { + t.Fatalf("Failed to get document: %v", err) + } + + docData := snapshot.Data() + if docData["expiresAt"] == nil { + t.Error("Expected expiresAt to be set") + } +} + +func TestFirestoreSessionStore_WithTTL(t *testing.T) { + skipIfNoFirestoreSession(t) + + ctx := context.Background() + g := genkit.Init(ctx, genkit.WithPlugins(&firebase.Firebase{ProjectId: *testSessionProjectID})) + + f := genkit.LookupPlugin(g, "firebase").(*firebase.Firebase) + client, err := f.Firestore(ctx) + if err != nil { + t.Fatalf("Failed to get Firestore client: %v", err) + } + defer deleteSessionCollection(ctx, client, *testSessionCollection, t) + + customTTL := 1 * time.Hour + store, err := NewFirestoreSessionStore[TestState](ctx, g, + WithCollection(*testSessionCollection), + WithTTL(customTTL), + ) + if err != nil { + t.Fatalf("Failed to create session store: %v", err) + } + + if store.ttl != customTTL { + t.Errorf("Expected TTL %v, got %v", customTTL, store.ttl) + } +} + +func TestFirestoreSessionStore_EmptyState(t *testing.T) { + store, _, cleanup := setupTestSessionStore(t) + defer cleanup() + + ctx := context.Background() + sessionID := "test-session-empty" + + // Save session with zero-value state + data := &session.Data[TestState]{ + ID: sessionID, + State: TestState{}, + } + if err := store.Save(ctx, sessionID, data); err != nil { + t.Fatalf("Save failed: %v", err) + } + + // Retrieve and verify + retrieved, err := store.Get(ctx, sessionID) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if retrieved == nil { + t.Fatal("Expected data, got nil") + } + if retrieved.State.Name != "" { + t.Errorf("Expected empty Name, got %q", retrieved.State.Name) + } + if retrieved.State.Count != 0 { + t.Errorf("Expected zero Count, got %d", retrieved.State.Count) + } +} + +func TestFirestoreSessionStore_ComplexState(t *testing.T) { + skipIfNoFirestoreSession(t) + + ctx := context.Background() + g := genkit.Init(ctx, genkit.WithPlugins(&firebase.Firebase{ProjectId: *testSessionProjectID})) + + f := genkit.LookupPlugin(g, "firebase").(*firebase.Firebase) + client, err := f.Firestore(ctx) + if err != nil { + t.Fatalf("Failed to get Firestore client: %v", err) + } + defer deleteSessionCollection(ctx, client, *testSessionCollection, t) + + type NestedState struct { + Inner struct { + Value string `json:"value"` + } `json:"inner"` + List []int `json:"list"` + } + + store, err := NewFirestoreSessionStore[NestedState](ctx, g, + WithCollection(*testSessionCollection), + ) + if err != nil { + t.Fatalf("Failed to create session store: %v", err) + } + + sessionID := "test-session-complex" + + // Save complex state + state := NestedState{ + List: []int{1, 2, 3, 4, 5}, + } + state.Inner.Value = "nested value" + + data := &session.Data[NestedState]{ + ID: sessionID, + State: state, + } + if err := store.Save(ctx, sessionID, data); err != nil { + t.Fatalf("Save failed: %v", err) + } + + // Retrieve and verify + retrieved, err := store.Get(ctx, sessionID) + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if retrieved == nil { + t.Fatal("Expected data, got nil") + } + if retrieved.State.Inner.Value != "nested value" { + t.Errorf("Expected Inner.Value %q, got %q", "nested value", retrieved.State.Inner.Value) + } + if len(retrieved.State.List) != 5 { + t.Errorf("Expected List length %d, got %d", 5, len(retrieved.State.List)) + } +} + +func TestFirestoreSessionStore_IntegrationWithSession(t *testing.T) { + store, _, cleanup := setupTestSessionStore(t) + defer cleanup() + + ctx := context.Background() + + // Create a session with the Firestore store + sess, err := session.New(ctx, + session.WithID[TestState]("integration-test"), + session.WithInitialState(TestState{Name: "Integration", Count: 0}), + session.WithStore(store), + ) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + // Update state (should persist to Firestore) + if err := sess.UpdateState(ctx, TestState{Name: "Updated", Count: 10}); err != nil { + t.Fatalf("UpdateState failed: %v", err) + } + + // Load session from store + loaded, err := session.Load(ctx, store, "integration-test") + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if loaded.State().Name != "Updated" { + t.Errorf("Expected Name %q, got %q", "Updated", loaded.State().Name) + } + if loaded.State().Count != 10 { + t.Errorf("Expected Count %d, got %d", 10, loaded.State().Count) + } +} diff --git a/go/plugins/firebase/x/stream_manager.go b/go/plugins/firebase/x/stream_manager.go index edc0154637..7adb06ad54 100644 --- a/go/plugins/firebase/x/stream_manager.go +++ b/go/plugins/firebase/x/stream_manager.go @@ -41,30 +41,27 @@ import ( const ( streamBufferSize = 100 defaultTimeout = 60 * time.Second - defaultTTL = 5 * time.Minute streamEventChunk = "chunk" streamEventDone = "done" streamEventError = "error" ) -// FirestoreStreamManagerOption configures a FirestoreStreamManager. -type FirestoreStreamManagerOption interface { - applyFirestoreStreamManager(*firestoreStreamManagerOptions) error +// StreamManagerOption configures a FirestoreStreamManager. +// Implemented by firestoreOptions (WithCollection, WithTTL) and streamManagerOptions (WithTimeout). +type StreamManagerOption interface { + applyStreamManager(*streamManagerOptions) error } -// firestoreStreamManagerOptions holds configuration for FirestoreStreamManager. -type firestoreStreamManagerOptions struct { - Collection string - Timeout time.Duration - TTL time.Duration +// streamManagerOptions holds configuration for FirestoreStreamManager. +type streamManagerOptions struct { + firestoreOptions + Timeout time.Duration } -func (o *firestoreStreamManagerOptions) applyFirestoreStreamManager(opts *firestoreStreamManagerOptions) error { - if o.Collection != "" { - if opts.Collection != "" { - return errors.New("cannot set collection more than once (WithCollection)") - } - opts.Collection = o.Collection +// applyStreamManager implements StreamManagerOption for streamManagerOptions. +func (o *streamManagerOptions) applyStreamManager(opts *streamManagerOptions) error { + if err := o.firestoreOptions.applyFirestore(&opts.firestoreOptions); err != nil { + return err } if o.Timeout > 0 { @@ -74,34 +71,14 @@ func (o *firestoreStreamManagerOptions) applyFirestoreStreamManager(opts *firest opts.Timeout = o.Timeout } - if o.TTL > 0 { - if opts.TTL > 0 { - return errors.New("cannot set TTL more than once (WithFirestoreTTL)") - } - opts.TTL = o.TTL - } - return nil } -// WithCollection sets the Firestore collection name where stream documents are stored. -// This option is required. -func WithCollection(collection string) FirestoreStreamManagerOption { - return &firestoreStreamManagerOptions{Collection: collection} -} - // WithTimeout sets how long a subscriber waits for new events before giving up. // If no activity occurs within this duration, subscribers receive a DEADLINE_EXCEEDED error. // Default is 60 seconds. -func WithTimeout(timeout time.Duration) FirestoreStreamManagerOption { - return &firestoreStreamManagerOptions{Timeout: timeout} -} - -// WithTTL sets how long completed streams are retained before Firestore auto-deletes them. -// Requires a TTL policy on the collection for the "expiresAt" field. Default is 5 minutes. -// See: https://firebase.google.com/docs/firestore/ttl -func WithTTL(ttl time.Duration) FirestoreStreamManagerOption { - return &firestoreStreamManagerOptions{TTL: ttl} +func WithTimeout(timeout time.Duration) StreamManagerOption { + return &streamManagerOptions{Timeout: timeout} } // FirestoreStreamManager implements [streaming.StreamManager] using Firestore as the backend. @@ -137,11 +114,12 @@ type streamError struct { Message string `firestore:"message"` } -// NewFirestoreStreamManager creates a FirestoreStreamManager for durable streaming. -func NewFirestoreStreamManager(ctx context.Context, g *genkit.Genkit, opts ...FirestoreStreamManagerOption) (*FirestoreStreamManager, error) { - streamOpts := &firestoreStreamManagerOptions{} +// NewFirestoreStreamManager creates a [FirestoreStreamManager] for durable streaming. +// Requires the Firebase plugin to be initialized in the Genkit instance. +func NewFirestoreStreamManager(ctx context.Context, g *genkit.Genkit, opts ...StreamManagerOption) (*FirestoreStreamManager, error) { + streamOpts := &streamManagerOptions{} for _, opt := range opts { - if err := opt.applyFirestoreStreamManager(streamOpts); err != nil { + if err := opt.applyStreamManager(streamOpts); err != nil { return nil, fmt.Errorf("firebase.NewFirestoreStreamManager: error applying options: %w", err) } } @@ -154,7 +132,7 @@ func NewFirestoreStreamManager(ctx context.Context, g *genkit.Genkit, opts ...Fi streamOpts.Timeout = defaultTimeout } if streamOpts.TTL == 0 { - streamOpts.TTL = defaultTTL + streamOpts.TTL = DefaultTTL } plugin := genkit.LookupPlugin(g, "firebase") diff --git a/go/plugins/googlegenai/gemini.go b/go/plugins/googlegenai/gemini.go index c3ca5297b0..ecffaac1a1 100644 --- a/go/plugins/googlegenai/gemini.go +++ b/go/plugins/googlegenai/gemini.go @@ -800,12 +800,30 @@ func translateCandidate(cand *genai.Candidate) (*ai.ModelResponse, error) { m.FinishReason = ai.FinishReasonStop case genai.FinishReasonMaxTokens: m.FinishReason = ai.FinishReasonLength - case genai.FinishReasonSafety: + case genai.FinishReasonSafety, + genai.FinishReasonRecitation, + genai.FinishReasonLanguage, + genai.FinishReasonBlocklist, + genai.FinishReasonProhibitedContent, + genai.FinishReasonSPII, + genai.FinishReasonImageSafety, + genai.FinishReasonImageProhibitedContent, + genai.FinishReasonImageRecitation: m.FinishReason = ai.FinishReasonBlocked - case genai.FinishReasonRecitation: - m.FinishReason = ai.FinishReasonBlocked - case genai.FinishReasonOther: + case genai.FinishReasonMalformedFunctionCall, + genai.FinishReasonUnexpectedToolCall, + genai.FinishReasonNoImage, + genai.FinishReasonImageOther, + genai.FinishReasonOther: + m.FinishReason = ai.FinishReasonOther + case "MISSING_THOUGHT_SIGNATURE": + // Gemini 3 returns this when thought signatures are missing from the request. + // The SDK may not have this constant yet, so we match on the string value. m.FinishReason = ai.FinishReasonOther + default: + if cand.FinishReason != "" && cand.FinishReason != genai.FinishReasonUnspecified { + m.FinishReason = ai.FinishReasonUnknown + } } m.FinishMessage = cand.FinishMessage @@ -982,9 +1000,10 @@ func toGeminiPart(p *ai.Part) (*genai.Part, error) { if err != nil { return nil, err } - return genai.NewPartFromFunctionResponseWithParts(toolResp.Name, output, toolRespParts), nil + gp = genai.NewPartFromFunctionResponseWithParts(toolResp.Name, output, toolRespParts) + } else { + gp = genai.NewPartFromFunctionResponse(toolResp.Name, output) } - return genai.NewPartFromFunctionResponse(toolResp.Name, output), nil case p.IsToolRequest(): toolReq := p.ToolRequest var input map[string]any diff --git a/go/plugins/googlegenai/gemini_test.go b/go/plugins/googlegenai/gemini_test.go index 9aae76a054..b319d91f08 100644 --- a/go/plugins/googlegenai/gemini_test.go +++ b/go/plugins/googlegenai/gemini_test.go @@ -793,3 +793,319 @@ func genToolName(length int, chars string) string { } return string(r) } + +// TestThoughtSignatureRoundTrip tests that thought signatures are properly preserved +// when converting between Genkit and Gemini part formats. +func TestThoughtSignatureRoundTrip(t *testing.T) { + testSignature := []byte("test-thought-signature-abc123") + + t.Run("text part preserves signature", func(t *testing.T) { + // Create a Genkit text part with a signature + genkitPart := ai.NewTextPart("Hello world") + genkitPart.Metadata = map[string]any{"signature": testSignature} + + // Convert to Gemini part + geminiPart, err := toGeminiPart(genkitPart) + if err != nil { + t.Fatalf("toGeminiPart failed: %v", err) + } + + // Verify signature was restored + if geminiPart.ThoughtSignature == nil { + t.Error("expected ThoughtSignature to be set on Gemini part") + } + if string(geminiPart.ThoughtSignature) != string(testSignature) { + t.Errorf("signature mismatch: got %q, want %q", geminiPart.ThoughtSignature, testSignature) + } + }) + + t.Run("reasoning part preserves signature", func(t *testing.T) { + // Create a Genkit reasoning part (signature is embedded via NewReasoningPart) + genkitPart := ai.NewReasoningPart("I'm thinking about this...", testSignature) + + // Convert to Gemini part + geminiPart, err := toGeminiPart(genkitPart) + if err != nil { + t.Fatalf("toGeminiPart failed: %v", err) + } + + // Verify it's marked as a thought + if !geminiPart.Thought { + t.Error("expected Thought to be true on Gemini part") + } + + // Verify signature was restored + if geminiPart.ThoughtSignature == nil { + t.Error("expected ThoughtSignature to be set on Gemini part") + } + if string(geminiPart.ThoughtSignature) != string(testSignature) { + t.Errorf("signature mismatch: got %q, want %q", geminiPart.ThoughtSignature, testSignature) + } + }) + + t.Run("tool request part preserves signature", func(t *testing.T) { + // Create a Genkit tool request part with a signature + genkitPart := ai.NewToolRequestPart(&ai.ToolRequest{ + Name: "myTool", + Input: map[string]any{"arg": "value"}, + }) + genkitPart.Metadata = map[string]any{"signature": testSignature} + + // Convert to Gemini part + geminiPart, err := toGeminiPart(genkitPart) + if err != nil { + t.Fatalf("toGeminiPart failed: %v", err) + } + + // Verify it's a function call + if geminiPart.FunctionCall == nil { + t.Fatal("expected FunctionCall to be set on Gemini part") + } + + // Verify signature was restored + if geminiPart.ThoughtSignature == nil { + t.Error("expected ThoughtSignature to be set on Gemini part") + } + if string(geminiPart.ThoughtSignature) != string(testSignature) { + t.Errorf("signature mismatch: got %q, want %q", geminiPart.ThoughtSignature, testSignature) + } + }) + + t.Run("tool response part preserves signature", func(t *testing.T) { + // Create a Genkit tool response part with a signature + genkitPart := ai.NewToolResponsePart(&ai.ToolResponse{ + Name: "myTool", + Output: map[string]any{"result": "success"}, + }) + genkitPart.Metadata = map[string]any{"signature": testSignature} + + // Convert to Gemini part + geminiPart, err := toGeminiPart(genkitPart) + if err != nil { + t.Fatalf("toGeminiPart failed: %v", err) + } + + // Verify it's a function response + if geminiPart.FunctionResponse == nil { + t.Fatal("expected FunctionResponse to be set on Gemini part") + } + + // Verify signature was restored + if geminiPart.ThoughtSignature == nil { + t.Error("expected ThoughtSignature to be set on Gemini part") + } + if string(geminiPart.ThoughtSignature) != string(testSignature) { + t.Errorf("signature mismatch: got %q, want %q", geminiPart.ThoughtSignature, testSignature) + } + }) + + t.Run("multipart tool response preserves signature", func(t *testing.T) { + // Create a multipart tool response with media content + genkitPart := ai.NewToolResponsePart(&ai.ToolResponse{ + Name: "generateImage", + Output: map[string]any{"status": "success"}, + Content: []*ai.Part{ + ai.NewMediaPart("image/png", "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="), + }, + }) + genkitPart.Metadata = map[string]any{ + "multipart": true, + "signature": testSignature, + } + + // Convert to Gemini part + geminiPart, err := toGeminiPart(genkitPart) + if err != nil { + t.Fatalf("toGeminiPart failed: %v", err) + } + + // Verify it's a function response + if geminiPart.FunctionResponse == nil { + t.Fatal("expected FunctionResponse to be set on Gemini part") + } + + // Verify signature was restored + if geminiPart.ThoughtSignature == nil { + t.Error("expected ThoughtSignature to be set on Gemini part") + } + if string(geminiPart.ThoughtSignature) != string(testSignature) { + t.Errorf("signature mismatch: got %q, want %q", geminiPart.ThoughtSignature, testSignature) + } + }) +} + +// TestTranslateCandidateThoughtSignature tests that thought signatures from Gemini +// responses are properly extracted and stored in Genkit parts. +func TestTranslateCandidateThoughtSignature(t *testing.T) { + testSignature := []byte("response-thought-signature-xyz789") + + t.Run("extracts signature from text part", func(t *testing.T) { + candidate := &genai.Candidate{ + FinishReason: genai.FinishReasonStop, + Content: &genai.Content{ + Role: "model", + Parts: []*genai.Part{ + { + Text: "Hello world", + ThoughtSignature: testSignature, + }, + }, + }, + } + + resp, err := translateCandidate(candidate) + if err != nil { + t.Fatalf("translateCandidate failed: %v", err) + } + + if len(resp.Message.Content) != 1 { + t.Fatalf("expected 1 part, got %d", len(resp.Message.Content)) + } + + part := resp.Message.Content[0] + if part.Metadata == nil { + t.Fatal("expected Metadata to be set") + } + + sig, ok := part.Metadata["signature"].([]byte) + if !ok { + t.Fatal("expected signature in metadata") + } + if string(sig) != string(testSignature) { + t.Errorf("signature mismatch: got %q, want %q", sig, testSignature) + } + }) + + t.Run("extracts signature from thought part", func(t *testing.T) { + candidate := &genai.Candidate{ + FinishReason: genai.FinishReasonStop, + Content: &genai.Content{ + Role: "model", + Parts: []*genai.Part{ + { + Text: "Let me think about this...", + Thought: true, + ThoughtSignature: testSignature, + }, + }, + }, + } + + resp, err := translateCandidate(candidate) + if err != nil { + t.Fatalf("translateCandidate failed: %v", err) + } + + if len(resp.Message.Content) != 1 { + t.Fatalf("expected 1 part, got %d", len(resp.Message.Content)) + } + + part := resp.Message.Content[0] + if !part.IsReasoning() { + t.Error("expected part to be reasoning") + } + if part.Metadata == nil { + t.Fatal("expected Metadata to be set") + } + + sig, ok := part.Metadata["signature"].([]byte) + if !ok { + t.Fatal("expected signature in metadata") + } + if string(sig) != string(testSignature) { + t.Errorf("signature mismatch: got %q, want %q", sig, testSignature) + } + }) + + t.Run("extracts signature from function call part", func(t *testing.T) { + candidate := &genai.Candidate{ + FinishReason: genai.FinishReasonStop, + Content: &genai.Content{ + Role: "model", + Parts: []*genai.Part{ + { + FunctionCall: &genai.FunctionCall{ + Name: "myTool", + Args: map[string]any{"arg": "value"}, + }, + ThoughtSignature: testSignature, + }, + }, + }, + } + + resp, err := translateCandidate(candidate) + if err != nil { + t.Fatalf("translateCandidate failed: %v", err) + } + + if len(resp.Message.Content) != 1 { + t.Fatalf("expected 1 part, got %d", len(resp.Message.Content)) + } + + part := resp.Message.Content[0] + if !part.IsToolRequest() { + t.Error("expected part to be tool request") + } + if part.Metadata == nil { + t.Fatal("expected Metadata to be set") + } + + sig, ok := part.Metadata["signature"].([]byte) + if !ok { + t.Fatal("expected signature in metadata") + } + if string(sig) != string(testSignature) { + t.Errorf("signature mismatch: got %q, want %q", sig, testSignature) + } + }) +} + +// TestFinishReasonMapping tests the mapping of Gemini finish reasons to Genkit finish reasons. +func TestFinishReasonMapping(t *testing.T) { + testCases := []struct { + name string + geminiReason genai.FinishReason + expectedReason ai.FinishReason + }{ + {"stop", genai.FinishReasonStop, ai.FinishReasonStop}, + {"max tokens", genai.FinishReasonMaxTokens, ai.FinishReasonLength}, + {"safety", genai.FinishReasonSafety, ai.FinishReasonBlocked}, + {"recitation", genai.FinishReasonRecitation, ai.FinishReasonBlocked}, + {"language", genai.FinishReasonLanguage, ai.FinishReasonBlocked}, + {"blocklist", genai.FinishReasonBlocklist, ai.FinishReasonBlocked}, + {"prohibited content", genai.FinishReasonProhibitedContent, ai.FinishReasonBlocked}, + {"spii", genai.FinishReasonSPII, ai.FinishReasonBlocked}, + {"image safety", genai.FinishReasonImageSafety, ai.FinishReasonBlocked}, + {"image prohibited content", genai.FinishReasonImageProhibitedContent, ai.FinishReasonBlocked}, + {"image recitation", genai.FinishReasonImageRecitation, ai.FinishReasonBlocked}, + {"malformed function call", genai.FinishReasonMalformedFunctionCall, ai.FinishReasonOther}, + {"unexpected tool call", genai.FinishReasonUnexpectedToolCall, ai.FinishReasonOther}, + {"no image", genai.FinishReasonNoImage, ai.FinishReasonOther}, + {"image other", genai.FinishReasonImageOther, ai.FinishReasonOther}, + {"other", genai.FinishReasonOther, ai.FinishReasonOther}, + {"missing thought signature", "MISSING_THOUGHT_SIGNATURE", ai.FinishReasonOther}, + {"unknown reason", "SOME_FUTURE_REASON", ai.FinishReasonUnknown}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + candidate := &genai.Candidate{ + FinishReason: tc.geminiReason, + Content: &genai.Content{ + Role: "model", + Parts: []*genai.Part{{Text: "test"}}, + }, + } + + resp, err := translateCandidate(candidate) + if err != nil { + t.Fatalf("translateCandidate failed: %v", err) + } + + if resp.FinishReason != tc.expectedReason { + t.Errorf("finish reason mismatch: got %q, want %q", resp.FinishReason, tc.expectedReason) + } + }) + } +} diff --git a/go/samples/basic-prompts/main.go b/go/samples/basic-prompts/main.go index ccbc308a13..c4d2d009de 100644 --- a/go/samples/basic-prompts/main.go +++ b/go/samples/basic-prompts/main.go @@ -12,6 +12,31 @@ // See the License for the specific language governing permissions and // limitations under the License. +// This sample demonstrates prompts using both inline code definitions and +// .prompt files (Dotprompt). It shows simple prompts, structured output with +// typed schemas, and complex prompts with Handlebars conditionals. +// +// To run: +// +// go run . +// +// In another terminal, test a simple joke flow: +// +// curl -N -X POST http://localhost:8080/simpleJokePromptFlow \ +// -H "Content-Type: application/json" \ +// -d '{"data": "bananas"}' +// +// Test a structured joke flow (returns JSON): +// +// curl -N -X POST http://localhost:8080/structuredJokePromptFlow \ +// -H "Content-Type: application/json" \ +// -d '{"data": {"topic": "bananas"}}' +// +// Test a recipe flow: +// +// curl -N -X POST http://localhost:8080/recipePromptFlow \ +// -H "Content-Type: application/json" \ +// -d '{"data": {"dish": "tacos", "cuisine": "Mexican", "servingSize": 4}}' package main import ( diff --git a/go/samples/basic-structured/main.go b/go/samples/basic-structured/main.go index 428636de4d..57e1de4079 100644 --- a/go/samples/basic-structured/main.go +++ b/go/samples/basic-structured/main.go @@ -12,6 +12,31 @@ // See the License for the specific language governing permissions and // limitations under the License. +// This sample demonstrates structured input/output with strongly-typed Go +// structs. It shows GenerateStream for simple output and GenerateDataStream +// for typed JSON output with streaming partial results. +// +// To run: +// +// go run . +// +// In another terminal, test a simple joke flow: +// +// curl -N -X POST http://localhost:8080/simpleJokesFlow \ +// -H "Content-Type: application/json" \ +// -d '{"data": "bananas"}' +// +// Test a structured joke flow (returns JSON): +// +// curl -N -X POST http://localhost:8080/structuredJokesFlow \ +// -H "Content-Type: application/json" \ +// -d '{"data": {"topic": "bananas"}}' +// +// Test a recipe flow: +// +// curl -N -X POST http://localhost:8080/recipeFlow \ +// -H "Content-Type: application/json" \ +// -d '{"data": {"dish": "tacos", "cuisine": "Mexican", "servingSize": 4}}' package main import ( diff --git a/go/samples/basic/main.go b/go/samples/basic/main.go index 2031340ac5..c2fb382399 100644 --- a/go/samples/basic/main.go +++ b/go/samples/basic/main.go @@ -12,6 +12,24 @@ // See the License for the specific language governing permissions and // limitations under the License. +// This sample demonstrates basic Genkit flows: a non-streaming flow and a +// streaming flow that generate jokes about a given topic. +// +// To run: +// +// go run . +// +// In another terminal, test the non-streaming flow: +// +// curl -X POST http://localhost:8080/jokesFlow \ +// -H "Content-Type: application/json" \ +// -d '{"data": "bananas"}' +// +// Test the streaming flow: +// +// curl -N -X POST http://localhost:8080/streamingJokesFlow \ +// -H "Content-Type: application/json" \ +// -d '{"data": "bananas"}' package main import ( diff --git a/go/samples/durable-streaming-firestore/README.md b/go/samples/durable-streaming-firestore/README.md deleted file mode 100644 index 8ca1648e8b..0000000000 --- a/go/samples/durable-streaming-firestore/README.md +++ /dev/null @@ -1,140 +0,0 @@ -# Durable Streaming with Firestore - -This sample demonstrates durable streaming using Firestore as the backend. Unlike in-memory streaming, Firestore-backed streams: - -- **Survive server restarts** - Clients can reconnect to streams after server restarts -- **Work across instances** - Multiple server instances can serve the same stream -- **Auto-cleanup** - Completed streams are automatically deleted via Firestore TTL policies - -## Prerequisites - -1. **Firebase Project**: You need a Firebase/GCP project with Firestore enabled. - -2. **Authentication**: Authenticate with your Google Cloud project: - ```bash - gcloud auth application-default login - ``` - -3. **(Recommended) TTL Policy**: Configure a TTL policy on your Firestore collection for automatic cleanup of old streams. This requires setting a TTL on the `expiresAt` field: - - ```bash - gcloud firestore fields ttls update expiresAt \ - --collection-group=genkit-streams \ - --enable-ttl \ - --project=YOUR_PROJECT_ID - ``` - - See: https://firebase.google.com/docs/firestore/ttl - -## Environment Variables - -| Variable | Required | Default | Description | -|----------|----------|---------|-------------| -| `FIREBASE_PROJECT_ID` | Yes | - | Your Firebase/GCP project ID | -| `FIRESTORE_STREAMS_COLLECTION` | No | `genkit-streams` | Firestore collection for stream documents | - -## Running the Sample - -1. Set your project ID: - ```bash - export FIREBASE_PROJECT_ID=your-project-id - ``` - -2. Start the server: - ```bash - go run . - ``` - -## Testing - -### Start a streaming request - -```bash -curl -N -i -H "Accept: text/event-stream" \ - -d '{"data": 5}' \ - http://localhost:8080/countdown -``` - -Note the `X-Genkit-Stream-Id` header in the response - you'll need this to reconnect. - -### Reconnect to an existing stream - -Use the stream ID from the previous response: - -```bash -curl -N -H "Accept: text/event-stream" \ - -H "X-Genkit-Stream-Id: " \ - -d '{"data": 5}' \ - http://localhost:8080/countdown -``` - -The subscription will: -- Replay any buffered chunks that were already sent -- Continue with live updates if the stream is still in progress -- Return all chunks plus the final result if the stream has already completed - -### Test server restart resilience - -1. Start a countdown with a high number: - ```bash - curl -N -i -H "Accept: text/event-stream" -d '{"data": 30}' http://localhost:8080/countdown - ``` - -2. Copy the `X-Genkit-Stream-Id` header value - -3. Stop the server (Ctrl+C) - -4. Restart the server: `go run .` - -5. Reconnect using the stream ID: - ```bash - curl -N -H "Accept: text/event-stream" -H "X-Genkit-Stream-Id: " -d '{"data": 30}' http://localhost:8080/countdown - ``` - -You'll receive all previously buffered chunks, demonstrating that the stream state persisted across the server restart. - -## Configuration Options - -The `FirestoreStreamManager` supports these options: - -| Option | Default | Description | -|--------|---------|-------------| -| `WithCollection(name)` | (required) | Firestore collection for stream documents | -| `WithTimeout(duration)` | 60s | How long subscribers wait for new events before timeout | -| `WithTTL(duration)` | 5m | How long completed streams are retained before auto-deletion | - -Example: -```go -streamManager, err := firebasex.NewFirestoreStreamManager(ctx, g, - firebasex.WithCollection("my-streams"), - firebasex.WithTimeout(2*time.Minute), - firebasex.WithTTL(1*time.Hour), -) -``` - -## How It Works - -1. When a streaming request arrives, a Firestore document is created with the stream ID -2. As the flow produces chunks, they're appended to the document's `stream` array -3. Subscribers use Firestore's real-time listeners to receive updates -4. When the flow completes, a final "done" entry is added with the output -5. The `expiresAt` field is set based on TTL, and Firestore automatically deletes the document - -## License - -``` -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. -``` - diff --git a/go/samples/durable-streaming-firestore/main.go b/go/samples/durable-streaming-firestore/main.go index 988ccda790..80fb0b47a2 100644 --- a/go/samples/durable-streaming-firestore/main.go +++ b/go/samples/durable-streaming-firestore/main.go @@ -18,7 +18,27 @@ // Unlike in-memory streaming, Firestore-backed streams survive server restarts // and can be accessed across multiple server instances. // -// See README.md for setup instructions. +// Prerequisites: +// - Firebase/GCP project with Firestore enabled +// - Run: gcloud auth application-default login +// - Set: export FIREBASE_PROJECT_ID=your-project-id +// +// To run: +// +// go run . +// +// In another terminal, start a streaming request: +// +// curl -N -i -H "Accept: text/event-stream" \ +// -d '{"data": 5}' \ +// http://localhost:8088/countdown +// +// Note the X-Genkit-Stream-Id header. To reconnect to the same stream: +// +// curl -N -H "Accept: text/event-stream" \ +// -H "X-Genkit-Stream-Id: " \ +// -d '{"data": 5}' \ +// http://localhost:8088/countdown package main import ( diff --git a/go/samples/durable-streaming/main.go b/go/samples/durable-streaming/main.go index 36323990a3..2c22f4b3b6 100644 --- a/go/samples/durable-streaming/main.go +++ b/go/samples/durable-streaming/main.go @@ -36,7 +36,6 @@ // // The subscription will replay any buffered chunks and then continue with live updates. // If the stream has already completed, all chunks plus the final result are returned. - package main import ( diff --git a/go/samples/session/main.go b/go/samples/session/main.go new file mode 100644 index 0000000000..c1f6d7b0b9 --- /dev/null +++ b/go/samples/session/main.go @@ -0,0 +1,129 @@ +// 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. + +// This sample demonstrates how to use sessions to maintain state across +// multiple requests. It implements a shopping cart where items persist +// between calls using the session API. +// +// To run: +// +// go run . +// +// In another terminal, test (items persist across requests): +// +// curl -X POST http://localhost:8080/manageCart \ +// -H "Content-Type: application/json" \ +// -d '{"data": "Add apples and bananas to my cart"}' +// +// curl -X POST http://localhost:8080/manageCart \ +// -H "Content-Type: application/json" \ +// -d '{"data": "What is in my cart?"}' +package main + +import ( + "context" + "fmt" + "log" + "net/http" + + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/core/x/session" + "github.com/firebase/genkit/go/genkit" + "github.com/firebase/genkit/go/plugins/googlegenai" + "github.com/firebase/genkit/go/plugins/server" + "google.golang.org/genai" +) + +// CartState holds the shopping cart items. +type CartState struct { + Items []string `json:"items"` +} + +func main() { + ctx := context.Background() + g := genkit.Init(ctx, genkit.WithPlugins(&googlegenai.GoogleAI{})) + + // Create in-memory store (shared across requests). + store := session.NewInMemoryStore[CartState]() + + // Fixed session ID for simplicity. + const sessionID = "shopping-session" + + // Define addToCart tool - adds an item to the cart stored in session state. + addToCartTool := genkit.DefineTool(g, "addToCart", + "Adds items to the shopping cart", + func(ctx *ai.ToolContext, input struct{ Items []string }) ([]string, error) { + sess := session.FromContext[CartState](ctx.Context) + if sess == nil { + return nil, fmt.Errorf("no session in context") + } + state := sess.State() + state.Items = append(state.Items, input.Items...) + if err := sess.UpdateState(ctx.Context, state); err != nil { + return nil, err + } + return state.Items, nil + }, + ) + + // Define getCart tool - returns all items currently in the cart. + getCartTool := genkit.DefineTool(g, "getCart", + "Returns all items currently in the shopping cart", + func(ctx *ai.ToolContext, input struct{}) ([]string, error) { + sess := session.FromContext[CartState](ctx.Context) + if sess == nil { + return nil, fmt.Errorf("no session in context") + } + return sess.State().Items, nil + }, + ) + + // Define flow that uses session to maintain cart state across requests. + genkit.DefineFlow(g, "manageCart", func(ctx context.Context, input string) (string, error) { + // Load existing session or create new one. + sess, err := session.Load(ctx, store, sessionID) + if err != nil { + // Session doesn't exist, create it. + sess, err = session.New(ctx, + session.WithID[CartState](sessionID), + session.WithStore(store), + session.WithInitialState(CartState{Items: []string{}}), + ) + if err != nil { + return "", err + } + } + + // Attach session to context for tools. + ctx = session.NewContext(ctx, sess) + + return genkit.GenerateText(ctx, g, + ai.WithModel(googlegenai.ModelRef("gemini-2.5-flash", &genai.GenerateContentConfig{ + ThinkingConfig: &genai.ThinkingConfig{ + ThinkingBudget: genai.Ptr[int32](0), + }, + })), + ai.WithSystem("You are a helpful shopping assistant. Use the provided tools to manage the user's cart."), + ai.WithTools(addToCartTool, getCartTool), + ai.WithPrompt(input), + ) + }) + + // Start server. + mux := http.NewServeMux() + for _, a := range genkit.ListFlows(g) { + mux.HandleFunc("POST /"+a.Name(), genkit.Handler(a)) + } + log.Fatal(server.Start(ctx, "127.0.0.1:8080", mux)) +} diff --git a/js/plugins/anthropic/src/runner/base.ts b/js/plugins/anthropic/src/runner/base.ts index e6b7132e28..36c5a8a175 100644 --- a/js/plugins/anthropic/src/runner/base.ts +++ b/js/plugins/anthropic/src/runner/base.ts @@ -50,8 +50,6 @@ import { RunnerTypes, } from './types.js'; -const ANTHROPIC_THINKING_CUSTOM_KEY = 'anthropicThinking'; - /** * Shared runner logic for Anthropic SDK integrations. * @@ -298,35 +296,11 @@ export abstract class BaseRunner { }; } - protected createThinkingPart(thinking: string, signature?: string): Part { - const custom = - signature !== undefined - ? { - [ANTHROPIC_THINKING_CUSTOM_KEY]: { signature }, - } - : undefined; - return custom - ? { - reasoning: thinking, - custom, - } - : { - reasoning: thinking, - }; - } - protected getThinkingSignature(part: Part): string | undefined { - const custom = part.custom as Record | undefined; - const thinkingValue = custom?.[ANTHROPIC_THINKING_CUSTOM_KEY]; - if ( - typeof thinkingValue === 'object' && - thinkingValue !== null && - 'signature' in thinkingValue && - typeof (thinkingValue as { signature: unknown }).signature === 'string' - ) { - return (thinkingValue as { signature: string }).signature; - } - return undefined; + const metadata = part.metadata as Record | undefined; + return typeof metadata?.thoughtSignature === 'string' + ? metadata.thoughtSignature + : undefined; } protected getRedactedThinkingData(part: Part): string | undefined { @@ -363,24 +337,6 @@ export abstract class BaseRunner { return undefined; } - protected toWebSearchToolResultPart(params: { - toolUseId: string; - content: unknown; - type: string; - }): Part { - const { toolUseId, content, type } = params; - return { - text: `[Anthropic server tool result ${toolUseId}] ${JSON.stringify(content)}`, - custom: { - anthropicServerToolResult: { - type, - toolUseId, - content, - }, - }, - }; - } - /** * Converts a Genkit Part to the corresponding Anthropic content block. * Each runner implements this to return its specific API type. diff --git a/js/plugins/anthropic/src/runner/beta.ts b/js/plugins/anthropic/src/runner/beta.ts index 099a589909..4efcd1fd13 100644 --- a/js/plugins/anthropic/src/runner/beta.ts +++ b/js/plugins/anthropic/src/runner/beta.ts @@ -46,27 +46,22 @@ import { KNOWN_CLAUDE_MODELS, extractVersion } from '../models.js'; import { AnthropicConfigSchema, type ClaudeRunnerParams } from '../types.js'; import { removeUndefinedProperties } from '../utils.js'; import { BaseRunner } from './base.js'; +import { + betaServerToolUseBlockToPart, + unsupportedServerToolError, +} from './converters/beta.js'; +import { + inputJsonDeltaError, + redactedThinkingBlockToPart, + textBlockToPart, + textDeltaToPart, + thinkingBlockToPart, + thinkingDeltaToPart, + toolUseBlockToPart, + webSearchToolResultBlockToPart, +} from './converters/shared.js'; import { RunnerTypes } from './types.js'; -/** - * Server-managed tool blocks emitted by the beta API that Genkit cannot yet - * interpret. We fail fast on these so callers do not accidentally treat them as - * locally executable tool invocations. - */ -/** - * Server tool types that exist in beta but are not yet supported. - * Note: server_tool_use and web_search_tool_result ARE supported (same as stable API). - */ -const BETA_UNSUPPORTED_SERVER_TOOL_BLOCK_TYPES = new Set([ - 'web_fetch_tool_result', - 'code_execution_tool_result', - 'bash_code_execution_tool_result', - 'text_editor_code_execution_tool_result', - 'mcp_tool_result', - 'mcp_tool_use', - 'container_upload', -]); - const BETA_APIS = [ // 'message-batches-2024-09-24', // 'prompt-caching-2024-07-31', @@ -118,9 +113,6 @@ function toAnthropicSchema( return out; } -const unsupportedServerToolError = (blockType: string): string => - `Anthropic beta runner does not yet support server-managed tool block '${blockType}'. Please retry against the stable API or wait for dedicated support.`; - interface BetaRunnerTypes extends RunnerTypes { Message: BetaMessage; Stream: BetaMessageStream; @@ -167,7 +159,7 @@ export class BetaRunner extends BaseRunner { const signature = this.getThinkingSignature(part); if (!signature) { throw new Error( - 'Anthropic thinking parts require a signature when sending back to the API. Preserve the `custom.anthropicThinking.signature` value from the original response.' + 'Anthropic thinking parts require a signature when sending back to the API. Preserve the `metadata.thoughtSignature` value from the original response.' ); } return { @@ -462,23 +454,19 @@ export class BetaRunner extends BaseRunner { protected toGenkitPart(event: BetaRawMessageStreamEvent): Part | undefined { if (event.type === 'content_block_start') { - const blockType = (event.content_block as { type?: string }).type; - if ( - blockType && - BETA_UNSUPPORTED_SERVER_TOOL_BLOCK_TYPES.has(blockType) - ) { - throw new Error(unsupportedServerToolError(blockType)); - } return this.fromBetaContentBlock(event.content_block); } if (event.type === 'content_block_delta') { if (event.delta.type === 'text_delta') { - return { text: event.delta.text }; + return textDeltaToPart(event.delta); } if (event.delta.type === 'thinking_delta') { - return { reasoning: event.delta.thinking }; + return thinkingDeltaToPart(event.delta); } - // server/client tool input_json_delta not supported yet + if (event.delta.type === 'input_json_delta') { + throw inputJsonDeltaError(); + } + // signature_delta - ignore return undefined; } return undefined; @@ -486,65 +474,44 @@ export class BetaRunner extends BaseRunner { private fromBetaContentBlock(contentBlock: BetaContentBlock): Part { switch (contentBlock.type) { - case 'tool_use': { - return { - toolRequest: { - ref: contentBlock.id, - name: contentBlock.name ?? 'unknown_tool', - input: contentBlock.input, - }, - }; - } - - case 'mcp_tool_use': - throw new Error(unsupportedServerToolError(contentBlock.type)); - - case 'server_tool_use': { - const baseName = contentBlock.name ?? 'unknown_tool'; - const serverToolName = - 'server_name' in contentBlock && contentBlock.server_name - ? `${contentBlock.server_name}/${baseName}` - : baseName; - return { - text: `[Anthropic server tool ${serverToolName}] input: ${JSON.stringify(contentBlock.input)}`, - custom: { - anthropicServerToolUse: { - id: contentBlock.id, - name: serverToolName, - input: contentBlock.input, - }, - }, - }; - } + case 'text': + return textBlockToPart(contentBlock); - case 'web_search_tool_result': - return this.toWebSearchToolResultPart({ - type: contentBlock.type, - toolUseId: contentBlock.tool_use_id, - content: contentBlock.content, + case 'tool_use': + // Beta API may have undefined name, fallback to 'unknown_tool' + return toolUseBlockToPart({ + id: contentBlock.id, + name: contentBlock.name ?? 'unknown_tool', + input: contentBlock.input, }); - case 'text': - return { text: contentBlock.text }; - case 'thinking': - return this.createThinkingPart( - contentBlock.thinking, - contentBlock.signature - ); + return thinkingBlockToPart(contentBlock); case 'redacted_thinking': - return { custom: { redactedThinking: contentBlock.data } }; + return redactedThinkingBlockToPart(contentBlock); + + case 'server_tool_use': + return betaServerToolUseBlockToPart(contentBlock); + + case 'web_search_tool_result': + return webSearchToolResultBlockToPart(contentBlock); + + // Unsupported beta server tool types + case 'mcp_tool_use': + case 'mcp_tool_result': + case 'web_fetch_tool_result': + case 'code_execution_tool_result': + case 'bash_code_execution_tool_result': + case 'text_editor_code_execution_tool_result': + case 'container_upload': + case 'tool_search_tool_result': + throw new Error(unsupportedServerToolError(contentBlock.type)); default: { - if (BETA_UNSUPPORTED_SERVER_TOOL_BLOCK_TYPES.has(contentBlock.type)) { - throw new Error(unsupportedServerToolError(contentBlock.type)); - } const unknownType = (contentBlock as { type: string }).type; logger.warn( - `Unexpected Anthropic beta content block type: ${unknownType}. Returning empty text. Content block: ${JSON.stringify( - contentBlock - )}` + `Unexpected Anthropic beta content block type: ${unknownType}. Returning empty text. Content block: ${JSON.stringify(contentBlock)}` ); return { text: '' }; } diff --git a/js/plugins/anthropic/src/runner/converters/beta.ts b/js/plugins/anthropic/src/runner/converters/beta.ts new file mode 100644 index 0000000000..c597dbb848 --- /dev/null +++ b/js/plugins/anthropic/src/runner/converters/beta.ts @@ -0,0 +1,54 @@ +/** + * 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. + */ + +/** + * Converters for beta API content blocks. + */ + +import type { Part } from 'genkit'; + +/** + * Converts a server_tool_use block to a Genkit Part. + * In the beta API, name may be undefined and server_name prefix is supported. + */ +export function betaServerToolUseBlockToPart(block: { + id: string; + name?: string; + input: unknown; + server_name?: string; +}): Part { + const baseName = block.name ?? 'unknown_tool'; + const serverToolName = block.server_name + ? `${block.server_name}/${baseName}` + : baseName; + return { + text: `[Anthropic server tool ${serverToolName}] input: ${JSON.stringify(block.input)}`, + metadata: { + anthropicServerToolUse: { + id: block.id, + name: serverToolName, + input: block.input, + }, + }, + }; +} + +/** + * Error message for unsupported server tool block types in the beta API. + */ +export function unsupportedServerToolError(blockType: string): string { + return `Anthropic beta runner does not yet support server-managed tool block '${blockType}'. Please retry against the stable API or wait for dedicated support.`; +} diff --git a/js/plugins/anthropic/src/runner/converters/shared.ts b/js/plugins/anthropic/src/runner/converters/shared.ts new file mode 100644 index 0000000000..6d6faf6091 --- /dev/null +++ b/js/plugins/anthropic/src/runner/converters/shared.ts @@ -0,0 +1,113 @@ +/** + * 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. + */ + +/** + * Shared utilities for converting Anthropic content blocks to Genkit Parts. + * Uses structural typing so both stable and beta API types work with these functions. + */ + +import type { Part } from 'genkit'; + +/** + * Converts a text block to a Genkit Part. + */ +export function textBlockToPart(block: { text: string }): Part { + return { text: block.text }; +} + +/** + * Converts a tool_use block to a Genkit Part. + */ +export function toolUseBlockToPart(block: { + id: string; + name: string; + input: unknown; +}): Part { + return { + toolRequest: { + ref: block.id, + name: block.name, + input: block.input, + }, + }; +} + +/** + * Converts a thinking block to a Genkit Part, including signature metadata if present. + */ +export function thinkingBlockToPart(block: { + thinking: string; + signature?: string; +}): Part { + if (block.signature !== undefined) { + return { + reasoning: block.thinking, + metadata: { thoughtSignature: block.signature }, + }; + } + return { reasoning: block.thinking }; +} + +/** + * Converts a redacted thinking block to a Genkit Part. + */ +export function redactedThinkingBlockToPart(block: { data: string }): Part { + return { custom: { redactedThinking: block.data } }; +} + +/** + * Converts a web_search_tool_result block to a Genkit Part. + */ +export function webSearchToolResultBlockToPart(block: { + tool_use_id: string; + content: unknown; +}): Part { + return { + text: `[Anthropic server tool result ${block.tool_use_id}] ${JSON.stringify(block.content)}`, + metadata: { + anthropicServerToolResult: { + type: 'web_search_tool_result', + toolUseId: block.tool_use_id, + content: block.content, + }, + }, + }; +} + +// --- Delta converters for streaming --- + +/** + * Converts a text_delta to a Genkit Part. + */ +export function textDeltaToPart(delta: { text: string }): Part { + return { text: delta.text }; +} + +/** + * Converts a thinking_delta to a Genkit Part. + */ +export function thinkingDeltaToPart(delta: { thinking: string }): Part { + return { reasoning: delta.thinking }; +} + +/** + * Error for unsupported input_json_delta in streaming. + */ +export function inputJsonDeltaError(): Error { + return new Error( + 'Anthropic streaming tool input (input_json_delta) is not yet supported. Please disable streaming or upgrade this plugin.' + ); +} diff --git a/js/plugins/anthropic/src/runner/converters/stable.ts b/js/plugins/anthropic/src/runner/converters/stable.ts new file mode 100644 index 0000000000..d6b3508d42 --- /dev/null +++ b/js/plugins/anthropic/src/runner/converters/stable.ts @@ -0,0 +1,42 @@ +/** + * 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. + */ + +/** + * Converters for stable API content blocks. + */ + +import type { Part } from 'genkit'; + +/** + * Converts a server_tool_use block to a Genkit Part. + * In the stable API, name is always present. + */ +export function serverToolUseBlockToPart(block: { + id: string; + name: string; + input: unknown; +}): Part { + return { + text: `[Anthropic server tool ${block.name}] input: ${JSON.stringify(block.input)}`, + metadata: { + anthropicServerToolUse: { + id: block.id, + name: block.name, + input: block.input, + }, + }, + }; +} diff --git a/js/plugins/anthropic/src/runner/stable.ts b/js/plugins/anthropic/src/runner/stable.ts index 1496029ebd..61c921b97c 100644 --- a/js/plugins/anthropic/src/runner/stable.ts +++ b/js/plugins/anthropic/src/runner/stable.ts @@ -44,6 +44,17 @@ import { KNOWN_CLAUDE_MODELS, extractVersion } from '../models.js'; import { AnthropicConfigSchema, type ClaudeRunnerParams } from '../types.js'; import { removeUndefinedProperties } from '../utils.js'; import { BaseRunner } from './base.js'; +import { + inputJsonDeltaError, + redactedThinkingBlockToPart, + textBlockToPart, + textDeltaToPart, + thinkingBlockToPart, + thinkingDeltaToPart, + toolUseBlockToPart, + webSearchToolResultBlockToPart, +} from './converters/shared.js'; +import { serverToolUseBlockToPart } from './converters/stable.js'; import { RunnerTypes as BaseRunnerTypes } from './types.js'; interface RunnerTypes extends BaseRunnerTypes { @@ -84,7 +95,7 @@ export class Runner extends BaseRunner { const signature = this.getThinkingSignature(part); if (!signature) { throw new Error( - 'Anthropic thinking parts require a signature when sending back to the API. Preserve the `custom.anthropicThinking.signature` value from the original response.' + 'Anthropic thinking parts require a signature when sending back to the API. Preserve the `metadata.thoughtSignature` value from the original response.' ); } return { @@ -336,18 +347,16 @@ export class Runner extends BaseRunner { if (event.type === 'content_block_delta') { const delta = event.delta; - if (delta.type === 'input_json_delta') { - throw new Error( - 'Anthropic streaming tool input (input_json_delta) is not yet supported. Please disable streaming or upgrade this plugin.' - ); - } - if (delta.type === 'text_delta') { - return { text: delta.text }; + return textDeltaToPart(delta); } if (delta.type === 'thinking_delta') { - return { reasoning: delta.thinking }; + return thinkingDeltaToPart(delta); + } + + if (delta.type === 'input_json_delta') { + throw inputJsonDeltaError(); } // signature_delta - ignore @@ -359,42 +368,23 @@ export class Runner extends BaseRunner { const block = event.content_block; switch (block.type) { - case 'server_tool_use': - return { - text: `[Anthropic server tool ${block.name}] input: ${JSON.stringify(block.input)}`, - custom: { - anthropicServerToolUse: { - id: block.id, - name: block.name, - input: block.input, - }, - }, - }; - - case 'web_search_tool_result': - return this.toWebSearchToolResultPart({ - type: block.type, - toolUseId: block.tool_use_id, - content: block.content, - }); - case 'text': - return { text: block.text }; + return textBlockToPart(block); + + case 'tool_use': + return toolUseBlockToPart(block); case 'thinking': - return this.createThinkingPart(block.thinking, block.signature); + return thinkingBlockToPart(block); case 'redacted_thinking': - return { custom: { redactedThinking: block.data } }; + return redactedThinkingBlockToPart(block); - case 'tool_use': - return { - toolRequest: { - ref: block.id, - name: block.name, - input: block.input, - }, - }; + case 'server_tool_use': + return serverToolUseBlockToPart(block); + + case 'web_search_tool_result': + return webSearchToolResultBlockToPart(block); default: { const unknownType = (block as { type: string }).type; @@ -412,47 +402,30 @@ export class Runner extends BaseRunner { protected fromAnthropicContentBlock(contentBlock: ContentBlock): Part { switch (contentBlock.type) { - case 'server_tool_use': - return { - text: `[Anthropic server tool ${contentBlock.name}] input: ${JSON.stringify(contentBlock.input)}`, - custom: { - anthropicServerToolUse: { - id: contentBlock.id, - name: contentBlock.name, - input: contentBlock.input, - }, - }, - }; - - case 'web_search_tool_result': - return this.toWebSearchToolResultPart({ - type: contentBlock.type, - toolUseId: contentBlock.tool_use_id, - content: contentBlock.content, - }); + case 'text': + return textBlockToPart(contentBlock); case 'tool_use': - return { - toolRequest: { - ref: contentBlock.id, - name: contentBlock.name, - input: contentBlock.input, - }, - }; - - case 'text': - return { text: contentBlock.text }; + return toolUseBlockToPart(contentBlock); case 'thinking': - return this.createThinkingPart( - contentBlock.thinking, - contentBlock.signature - ); + return thinkingBlockToPart(contentBlock); case 'redacted_thinking': - return { custom: { redactedThinking: contentBlock.data } }; + return redactedThinkingBlockToPart(contentBlock); + + case 'server_tool_use': + return serverToolUseBlockToPart(contentBlock); + + case 'web_search_tool_result': + return webSearchToolResultBlockToPart(contentBlock); default: { + // Exhaustive check (uncomment when all types are handled): + // const _exhaustive: never = contentBlock; + // throw new Error( + // `Unhandled block type: ${(_exhaustive as { type: string }).type}` + // ); const unknownType = (contentBlock as { type: string }).type; logger.warn( `Unexpected Anthropic content block type: ${unknownType}. Returning empty text. Content block: ${JSON.stringify(contentBlock)}` diff --git a/js/plugins/anthropic/tests/beta_runner_test.ts b/js/plugins/anthropic/tests/beta_runner_test.ts index 0d549b938c..98e4226e86 100644 --- a/js/plugins/anthropic/tests/beta_runner_test.ts +++ b/js/plugins/anthropic/tests/beta_runner_test.ts @@ -321,7 +321,7 @@ describe('BetaRunner', () => { const toolPart = exposed.toGenkitPart(serverToolEvent); assert.deepStrictEqual(toolPart, { text: '[Anthropic server tool srv/myTool] input: {"foo":"bar"}', - custom: { + metadata: { anthropicServerToolUse: { id: 'toolu_test', name: 'srv/myTool', @@ -732,7 +732,7 @@ describe('BetaRunner', () => { }); assert.deepStrictEqual(thinkingPart, { reasoning: 'pondering', - custom: { anthropicThinking: { signature: 'sig_456' } }, + metadata: { thoughtSignature: 'sig_456' }, }); const redactedPart = (runner as any).fromBetaContentBlock({ @@ -766,7 +766,7 @@ describe('BetaRunner', () => { }); assert.deepStrictEqual(serverToolPart, { text: '[Anthropic server tool srv/serverTool] input: {"arg":"value"}', - custom: { + metadata: { anthropicServerToolUse: { id: 'srv_tool_1', name: 'srv/serverTool', diff --git a/js/plugins/anthropic/tests/integration_test.ts b/js/plugins/anthropic/tests/integration_test.ts index 209a455870..13d88b373f 100644 --- a/js/plugins/anthropic/tests/integration_test.ts +++ b/js/plugins/anthropic/tests/integration_test.ts @@ -345,7 +345,7 @@ describe('Anthropic Integration', () => { ); assert.ok(reasoningPart, 'Expected reasoning part in assistant message'); assert.strictEqual( - reasoningPart?.custom?.anthropicThinking?.signature, + (reasoningPart?.metadata as Record)?.thoughtSignature, 'sig_reasoning_123' ); }); diff --git a/js/plugins/anthropic/tests/stable_runner_test.ts b/js/plugins/anthropic/tests/stable_runner_test.ts index 72797251b9..28e1834e2a 100644 --- a/js/plugins/anthropic/tests/stable_runner_test.ts +++ b/js/plugins/anthropic/tests/stable_runner_test.ts @@ -684,7 +684,7 @@ describe('fromAnthropicContentBlockChunk', () => { }, expectedOutput: { reasoning: 'Let me reason through this.', - custom: { anthropicThinking: { signature: 'sig_123' } }, + metadata: { thoughtSignature: 'sig_123' }, }, }, { diff --git a/js/plugins/google-genai/tests/model-tests-tts.yaml b/js/plugins/google-genai/tests/model-tests-tts.yaml index 2910084e48..726e2736d7 100644 --- a/js/plugins/google-genai/tests/model-tests-tts.yaml +++ b/js/plugins/google-genai/tests/model-tests-tts.yaml @@ -1,3 +1,19 @@ +# Copyright 2026 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 + - model: googleai/imagen-4.0-generate-001 supports: - output-image diff --git a/py/bin/sanitize_schema_typing.py b/py/bin/sanitize_schema_typing.py index 6138127e9f..48c6baf919 100644 --- a/py/bin/sanitize_schema_typing.py +++ b/py/bin/sanitize_schema_typing.py @@ -45,7 +45,7 @@ from _ast import AST from datetime import datetime from pathlib import Path -from typing import Type, cast +from typing import Any, Type, cast class ClassTransformer(ast.NodeTransformer): @@ -118,7 +118,18 @@ def has_model_config(self, node: ast.ClassDef) -> ast.Assign | None: return item return None - def visit_ClassDef(self, _node: ast.ClassDef) -> ast.ClassDef: # noqa: N802 + def visit_AnnAssign(self, node: ast.AnnAssign) -> ast.AnnAssign: + """Visit and transform annotated assignment.""" + if isinstance(node.annotation, ast.Name) and node.annotation.id == 'Role': + node.annotation = ast.BinOp( + left=ast.Name(id='Role', ctx=ast.Load()), + op=ast.BitOr(), + right=ast.Name(id='str', ctx=ast.Load()), + ) + self.modified = True + return node + + def visit_ClassDef(self, node: ast.ClassDef) -> Any: """Visit and transform a class definition node. Args: @@ -128,11 +139,16 @@ def visit_ClassDef(self, _node: ast.ClassDef) -> ast.ClassDef: # noqa: N802 The transformed ClassDef node. """ # First apply base class transformations recursively - node = super().generic_visit(_node) + node = cast(ast.ClassDef, super().generic_visit(node)) new_body: list[ast.stmt | ast.Constant | ast.Assign] = [] # Handle Docstrings - if not node.body or not isinstance(node.body[0], ast.Expr) or not isinstance(node.body[0].value, ast.Constant): + if ( + not node.body + or not isinstance(node.body[0], ast.Expr) + or not isinstance(node.body[0].value, ast.Constant) + or not isinstance(node.body[0].value.value, str) + ): # Generate a more descriptive docstring based on class type if self.is_rootmodel_class(node): docstring = f'Root model for {node.name.lower().replace("_", " ")}.' @@ -151,13 +167,21 @@ def visit_ClassDef(self, _node: ast.ClassDef) -> ast.ClassDef: # noqa: N802 # Handle model_config for BaseModel and RootModel existing_model_config_assign = self.has_model_config(node) + existing_model_config_call = None if existing_model_config_assign and isinstance(existing_model_config_assign.value, ast.Call): existing_model_config_call = existing_model_config_assign.value # Determine start index for iterating original body (skip docstring) body_start_index = ( - 1 if (node.body and isinstance(node.body[0], ast.Expr) and isinstance(node.body[0].value, ast.Str)) else 0 + 1 + if ( + node.body + and isinstance(node.body[0], ast.Expr) + and isinstance(node.body[0].value, ast.Constant) + and isinstance(node.body[0].value.value, str) + ) + else 0 ) if self.is_rootmodel_class(node): diff --git a/py/noxfile.py b/py/noxfile.py index 0e4a68ad90..7f715a3043 100644 --- a/py/noxfile.py +++ b/py/noxfile.py @@ -74,5 +74,5 @@ def lint(session: nox.Session) -> None: session.run('uv', 'run', 'ruff', 'format', '--check', '.', external=True) session.log('Running ruff checks') session.run('uv', 'run', 'ruff', 'check', '--preview', '--unsafe-fixes', '--fix', '.', external=True) - # session.log("Running mypy checks") # mypy has many errors currently - # session.run("mypy", external=True) + session.log('Running Ty checks') + session.run('uv', 'run', 'ty', 'check', '.', external=True) diff --git a/py/packages/genkit/pyproject.toml b/py/packages/genkit/pyproject.toml index ab4f17a288..b7921df274 100644 --- a/py/packages/genkit/pyproject.toml +++ b/py/packages/genkit/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -52,7 +51,7 @@ dependencies = [ "anyio>=4.9.0", ] description = "Genkit AI Framework" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "genkit" readme = "README.md" requires-python = ">=3.10" diff --git a/py/packages/genkit/src/genkit/ai/_registry.py b/py/packages/genkit/src/genkit/ai/_registry.py index aed365d003..e2032e6a78 100644 --- a/py/packages/genkit/src/genkit/ai/_registry.py +++ b/py/packages/genkit/src/genkit/ai/_registry.py @@ -57,6 +57,7 @@ from genkit.blocks.model import ModelFn, ModelMiddleware from genkit.blocks.prompt import ( define_helper, + define_partial, define_prompt, lookup_prompt, ) @@ -194,6 +195,18 @@ def define_helper(self, name: str, fn: Callable) -> None: """ define_helper(self.registry, name, fn) + def define_partial(self, name: str, source: str) -> None: + """Define a Handlebars partial template in the registry. + + Partials are reusable template fragments that can be included + in other prompts using {{>partialName}} syntax. + + Args: + name: The name of the partial. + source: The template source code for the partial. + """ + define_partial(self.registry, name, source) + def tool(self, name: str | None = None, description: str | None = None) -> Callable[[Callable], Callable]: """Decorator to register a function as a tool. diff --git a/py/packages/genkit/src/genkit/blocks/generate.py b/py/packages/genkit/src/genkit/blocks/generate.py index 754af90d4e..ef4a099fe0 100644 --- a/py/packages/genkit/src/genkit/blocks/generate.py +++ b/py/packages/genkit/src/genkit/blocks/generate.py @@ -637,7 +637,11 @@ async def _resolve_tool_request(tool: Action, tool_request_part: ToolRequestPart Part( tool_request=tool_request_part.tool_request, metadata={ - **(tool_request_part.metadata if tool_request_part.metadata else {}), + **( + tool_request_part.metadata.root + if isinstance(tool_request_part.metadata, Metadata) + else tool_request_part.metadata or {} + ), 'interrupt': (interrupt_error.metadata if interrupt_error.metadata else True), }, ), @@ -815,7 +819,7 @@ def _find_corresponding_tool_response(responses: list[ToolResponsePart], request """ for p in responses: if p.tool_response.name == request.tool_request.name and p.tool_response.ref == request.tool_request.ref: - return p + return Part(root=p) return None diff --git a/py/packages/genkit/src/genkit/blocks/resource.py b/py/packages/genkit/src/genkit/blocks/resource.py index 72a0ce247d..8e5e398dd9 100644 --- a/py/packages/genkit/src/genkit/blocks/resource.py +++ b/py/packages/genkit/src/genkit/blocks/resource.py @@ -232,8 +232,8 @@ async def wrapped_fn(input_data: ResourceInput, ctx: ActionRunContext) -> Resour p = p.root if hasattr(p, 'metadata'): - if p.metadata is None: - p.metadata = {} + if p.metadata is None or isinstance(p.metadata, dict): + p.metadata = Metadata(root=p.metadata or {}) if isinstance(p.metadata, Metadata): p_metadata = p.metadata.root diff --git a/py/packages/genkit/src/genkit/blocks/tools.py b/py/packages/genkit/src/genkit/blocks/tools.py index 8c8d0d12e7..d0e5613892 100644 --- a/py/packages/genkit/src/genkit/blocks/tools.py +++ b/py/packages/genkit/src/genkit/blocks/tools.py @@ -17,7 +17,7 @@ from typing import Any from genkit.core.action import ActionRunContext -from genkit.core.typing import Part, ToolRequestPart, ToolResponse +from genkit.core.typing import Metadata, Part, ToolRequestPart, ToolResponse class ToolRunContext(ActionRunContext): @@ -96,6 +96,13 @@ def tool_response( """ # TODO: validate against tool schema tool_request = interrupt.root.tool_request if isinstance(interrupt, Part) else interrupt.tool_request + + interrupt_metadata = True + if isinstance(metadata, Metadata): + interrupt_metadata = metadata.root + elif metadata: + interrupt_metadata = metadata + return Part( tool_response=ToolResponse( name=tool_request.name, @@ -103,6 +110,6 @@ def tool_response( output=response_data, ), metadata={ - 'interruptResponse': metadata if metadata else True, + 'interruptResponse': interrupt_metadata, }, ) diff --git a/py/packages/genkit/src/genkit/core/typing.py b/py/packages/genkit/src/genkit/core/typing.py index dce5a4f7f1..68fd541101 100644 --- a/py/packages/genkit/src/genkit/core/typing.py +++ b/py/packages/genkit/src/genkit/core/typing.py @@ -913,7 +913,7 @@ class Message(BaseModel): """Model for message data.""" model_config = ConfigDict(extra='forbid', populate_by_name=True) - role: Role + role: Role | str content: list[Part] metadata: dict[str, Any] | None = None diff --git a/py/packages/genkit/tests/genkit/blocks/prompt_test.py b/py/packages/genkit/tests/genkit/blocks/prompt_test.py index 6ccc660128..0699b9d26b 100644 --- a/py/packages/genkit/tests/genkit/blocks/prompt_test.py +++ b/py/packages/genkit/tests/genkit/blocks/prompt_test.py @@ -259,7 +259,6 @@ async def docs_resolver(input, context): ] -@pytest.mark.skip(reason='issues when running on CI') @pytest.mark.asyncio @pytest.mark.parametrize( 'test_case, prompt, input, input_option, context, want_rendered', @@ -371,6 +370,25 @@ async def test_load_and_use_partial() -> None: assert 'Hello from partial' in response.text or 'space' in response.text +@pytest.mark.asyncio +async def test_define_partial_programmatically() -> None: + """Test defining partials programmatically using ai.define_partial().""" + ai, *_ = setup_test() + + # Define a partial programmatically + ai.define_partial('myGreeting', 'Greetings, {{name}}!') + + # Create a prompt that uses the partial + my_prompt = ai.define_prompt( + messages='{{>myGreeting}} Welcome to Genkit.', + ) + + response = await my_prompt(input={'name': 'Developer'}) + + # The partial should be included in the output + assert 'Greetings' in response.text and 'Developer' in response.text + + @pytest.mark.asyncio async def test_prompt_with_messages_list() -> None: """Test prompt with explicit messages list.""" diff --git a/py/packages/genkit/tests/genkit/veneer/resource_test.py b/py/packages/genkit/tests/genkit/veneer/resource_test.py index 88e0283856..e3324e5681 100644 --- a/py/packages/genkit/tests/genkit/veneer/resource_test.py +++ b/py/packages/genkit/tests/genkit/veneer/resource_test.py @@ -33,7 +33,7 @@ async def test_define_resource_veneer(): ai = Genkit(plugins=[]) async def my_resource_fn(input, ctx): - return {'content': [Part(TextPart(text=f'Content for {input.uri}'))]} + return {'content': [Part(root=TextPart(text=f'Content for {input.uri}'))]} act = ai.define_resource({'uri': 'http://example.com/foo'}, my_resource_fn) diff --git a/py/packages/genkit/tests/genkit/veneer/veneer_test.py b/py/packages/genkit/tests/genkit/veneer/veneer_test.py index 4a05e84b99..cc7854b1a8 100644 --- a/py/packages/genkit/tests/genkit/veneer/veneer_test.py +++ b/py/packages/genkit/tests/genkit/veneer/veneer_test.py @@ -1158,8 +1158,8 @@ async def test_generate_simulates_doc_grounding( assert (await response).request.messages[0] == want_msg -class TestFormat(FormatDef): - """Test format for testing the format.""" +class MockBananaFormat(FormatDef): + """Mock format for testing the format.""" def __init__(self): """Initialize the format.""" @@ -1200,7 +1200,7 @@ async def test_define_format(setup_test: SetupFixture) -> None: """Test that the define format function works.""" ai, _, pm, *_ = setup_test - ai.define_format(TestFormat()) + ai.define_format(MockBananaFormat()) class TestSchema(BaseModel): foo: int = Field(None, description='foo field') diff --git a/py/plugins/anthropic/pyproject.toml b/py/plugins/anthropic/pyproject.toml index b9875c4d11..0ab15ba504 100644 --- a/py/plugins/anthropic/pyproject.toml +++ b/py/plugins/anthropic/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -35,7 +34,7 @@ classifiers = [ ] dependencies = ["genkit", "anthropic>=0.40.0"] description = "Genkit Anthropic Plugin" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "genkit-plugin-anthropic" readme = "README.md" requires-python = ">=3.10" diff --git a/py/plugins/compat-oai/pyproject.toml b/py/plugins/compat-oai/pyproject.toml index 44a4fe8282..3dd804f716 100644 --- a/py/plugins/compat-oai/pyproject.toml +++ b/py/plugins/compat-oai/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -35,7 +34,7 @@ classifiers = [ ] dependencies = ["genkit", "openai", "strenum>=0.4.15; python_version < '3.11'"] description = "Genkit OpenAI API Compatible" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "genkit-plugin-compat-oai" readme = "README.md" requires-python = ">=3.10" diff --git a/py/plugins/compat-oai/src/genkit/plugins/compat_oai/models/model.py b/py/plugins/compat-oai/src/genkit/plugins/compat_oai/models/model.py index bf1f7315aa..dedbc1b6be 100644 --- a/py/plugins/compat-oai/src/genkit/plugins/compat_oai/models/model.py +++ b/py/plugins/compat-oai/src/genkit/plugins/compat_oai/models/model.py @@ -140,6 +140,11 @@ def _get_openai_request_config(self, request: GenerateRequest) -> dict: } if request.tools: openai_config['tools'] = self._get_tools_definition(request.tools) + if any(msg.role == Role.TOOL for msg in request.messages): + # After a tool response, stop forcing additional tool calls. + openai_config['tool_choice'] = 'none' + elif request.tool_choice: + openai_config['tool_choice'] = request.tool_choice if request.output: openai_config['response_format'] = self._get_response_format(request.output) if request.config: diff --git a/py/plugins/compat-oai/src/genkit/plugins/compat_oai/typing.py b/py/plugins/compat-oai/src/genkit/plugins/compat_oai/typing.py index d8ac810dea..64be50c825 100644 --- a/py/plugins/compat-oai/src/genkit/plugins/compat_oai/typing.py +++ b/py/plugins/compat-oai/src/genkit/plugins/compat_oai/typing.py @@ -24,7 +24,7 @@ else: # noqa from enum import StrEnum # noqa -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field class OpenAIConfig(BaseModel): @@ -38,6 +38,10 @@ class OpenAIConfig(BaseModel): stop: str | list[str] | None = None max_tokens: int | None = None stream: bool | None = None + frequency_penalty: float | None = Field(default=None, ge=-2, le=2) + presence_penalty: float | None = Field(default=None, ge=-2, le=2) + logprobs: bool | None = None + top_logprobs: int | None = Field(default=None, ge=0, le=20) class SupportedOutputFormat(StrEnum): diff --git a/py/plugins/deepseek/pyproject.toml b/py/plugins/deepseek/pyproject.toml new file mode 100644 index 0000000000..d4cbe0ef82 --- /dev/null +++ b/py/plugins/deepseek/pyproject.toml @@ -0,0 +1,47 @@ +# Copyright 2026 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", + "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", +] +dependencies = ["genkit", "genkit-plugin-compat-oai", "openai>=1.0.0"] +description = "Genkit DeepSeek Plugin" +license = "Apache-2.0" +name = "genkit-plugin-deepseek" +requires-python = ">=3.10" +version = "0.1.0" + +[build-system] +build-backend = "hatchling.build" +requires = ["hatchling"] + +[tool.hatch.build.targets.wheel] +packages = ["src/genkit", "src/genkit/plugins"] diff --git a/py/plugins/deepseek/src/genkit/plugins/deepseek/__init__.py b/py/plugins/deepseek/src/genkit/plugins/deepseek/__init__.py new file mode 100644 index 0000000000..24021a619e --- /dev/null +++ b/py/plugins/deepseek/src/genkit/plugins/deepseek/__init__.py @@ -0,0 +1,22 @@ +# Copyright 2026 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 + +"""DeepSeek plugin for Genkit.""" + +from .models import deepseek_name +from .plugin import DeepSeek + +__all__ = ['DeepSeek', 'deepseek_name'] diff --git a/py/plugins/deepseek/src/genkit/plugins/deepseek/client.py b/py/plugins/deepseek/src/genkit/plugins/deepseek/client.py new file mode 100644 index 0000000000..56ed84f247 --- /dev/null +++ b/py/plugins/deepseek/src/genkit/plugins/deepseek/client.py @@ -0,0 +1,40 @@ +# Copyright 2026 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 + +"""DeepSeek API client.""" + +from openai import OpenAI as _OpenAI + + +class DeepSeekClient: + """DeepSeek API client initialization.""" + + def __new__(cls, **deepseek_params) -> _OpenAI: + """Initialize the DeepSeek client. + + Args: + **deepseek_params: Client configuration parameters including: + - api_key: DeepSeek API key. + - base_url: API base URL (defaults to https://api.deepseek.com). + - Additional OpenAI client parameters. + + Returns: + Configured OpenAI client instance. + """ + api_key = deepseek_params.pop('api_key') + base_url = deepseek_params.pop('base_url', 'https://api.deepseek.com') + + return _OpenAI(api_key=api_key, base_url=base_url, **deepseek_params) diff --git a/py/plugins/deepseek/src/genkit/plugins/deepseek/model_info.py b/py/plugins/deepseek/src/genkit/plugins/deepseek/model_info.py new file mode 100644 index 0000000000..9601f58c61 --- /dev/null +++ b/py/plugins/deepseek/src/genkit/plugins/deepseek/model_info.py @@ -0,0 +1,58 @@ +# Copyright 2026 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 + +"""DeepSeek model information and metadata.""" + +from genkit.types import ModelInfo, Supports + +__all__ = ['SUPPORTED_DEEPSEEK_MODELS', 'get_default_model_info'] + +# Model capabilities matching JS implementation +_DEEPSEEK_SUPPORTS = Supports( + multiturn=True, + tools=True, + media=False, + system_role=True, + output=['text', 'json'], +) + +SUPPORTED_DEEPSEEK_MODELS: dict[str, ModelInfo] = { + 'deepseek-reasoner': ModelInfo( + label='DeepSeek - Reasoner', + versions=['deepseek-reasoner'], + supports=_DEEPSEEK_SUPPORTS, + ), + 'deepseek-chat': ModelInfo( + label='DeepSeek - Chat', + versions=['deepseek-chat'], + supports=_DEEPSEEK_SUPPORTS, + ), +} + + +def get_default_model_info(name: str) -> ModelInfo: + """Get default model information for unknown DeepSeek models. + + Args: + name: Model name. + + Returns: + Default ModelInfo with standard DeepSeek capabilities. + """ + return ModelInfo( + label=f'DeepSeek - {name}', + supports=_DEEPSEEK_SUPPORTS, + ) diff --git a/py/plugins/deepseek/src/genkit/plugins/deepseek/models.py b/py/plugins/deepseek/src/genkit/plugins/deepseek/models.py new file mode 100644 index 0000000000..65a5b76cb0 --- /dev/null +++ b/py/plugins/deepseek/src/genkit/plugins/deepseek/models.py @@ -0,0 +1,124 @@ +# Copyright 2026 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 + +"""DeepSeek model integration for Genkit.""" + +from collections.abc import Callable +from typing import Any + +from genkit.ai import GenkitRegistry +from genkit.plugins.compat_oai.models.model import OpenAIModel +from genkit.plugins.compat_oai.typing import OpenAIConfig +from genkit.plugins.deepseek.client import DeepSeekClient +from genkit.plugins.deepseek.model_info import ( + SUPPORTED_DEEPSEEK_MODELS, + get_default_model_info, +) + +DEEPSEEK_PLUGIN_NAME = 'deepseek' + + +def deepseek_name(name: str) -> str: + """Create a DeepSeek action name. + + Args: + name: Base name for the action. + + Returns: + The fully qualified DeepSeek action name. + """ + return f'{DEEPSEEK_PLUGIN_NAME}/{name}' + + +class DeepSeekModel: + """Manages DeepSeek model integration for Genkit. + + This class provides integration with DeepSeek's OpenAI-compatible API, + allowing DeepSeek models to be exposed as Genkit models. It handles + client initialization, model information retrieval, and dynamic model + definition within the Genkit registry. + + Follows the Model Garden pattern for implementation consistency. + """ + + def __init__( + self, + model: str, + api_key: str, + registry: GenkitRegistry, + **deepseek_params, + ) -> None: + """Initialize the DeepSeek instance. + + Args: + model: The name of the specific DeepSeek model (e.g., 'deepseek-chat'). + api_key: DeepSeek API key for authentication. + registry: An instance of GenkitRegistry to register the model. + **deepseek_params: Additional parameters for the DeepSeek client. + """ + self.name = model + self.ai = registry + client_params = {'api_key': api_key, **deepseek_params} + self.client = DeepSeekClient(**client_params) + + def get_model_info(self) -> dict[str, Any] | None: + """Retrieve metadata and supported features for the specified model. + + This method looks up the model's information from a predefined list + of supported DeepSeek models or provides default information. + + Returns: + A dictionary containing the model's 'name' and 'supports' features. + The 'supports' key contains a dictionary representing the model's + capabilities (e.g., tools, streaming). + """ + model_info = SUPPORTED_DEEPSEEK_MODELS.get(self.name, get_default_model_info(self.name)) + return { + 'name': model_info.label, + 'supports': model_info.supports.model_dump(), + } + + def to_deepseek_model(self) -> Callable: + """Convert the DeepSeek model into a Genkit-compatible model function. + + This method wraps the underlying DeepSeek client and its generation + logic into a callable that adheres to the OpenAI model interface + expected by Genkit. + + Returns: + A callable function (the generate method of an OpenAIModel instance) + that can be used by Genkit. + """ + deepseek_model = OpenAIModel(self.name, self.client, self.ai) + return deepseek_model.generate + + def define_model(self) -> None: + """Define and register the DeepSeek model with the Genkit registry. + + This method orchestrates the retrieval of model metadata and the + creation of the generation function, then registers this model + within the Genkit framework using self.ai.define_model. + """ + model_info = self.get_model_info() + generate_fn = self.to_deepseek_model() + self.ai.define_model( + name=deepseek_name(self.name), + fn=generate_fn, + config_schema=OpenAIConfig, + metadata={ + 'model': model_info, + }, + ) diff --git a/py/plugins/deepseek/src/genkit/plugins/deepseek/plugin.py b/py/plugins/deepseek/src/genkit/plugins/deepseek/plugin.py new file mode 100644 index 0000000000..2943838c87 --- /dev/null +++ b/py/plugins/deepseek/src/genkit/plugins/deepseek/plugin.py @@ -0,0 +1,140 @@ +# Copyright 2026 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 + +"""DeepSeek Plugin for Genkit.""" + +import os +from functools import cached_property + +from genkit.ai import GenkitRegistry, Plugin +from genkit.blocks.model import model_action_metadata +from genkit.core.action import ActionMetadata +from genkit.core.action.types import ActionKind +from genkit.core.error import GenkitError +from genkit.plugins.compat_oai.typing import OpenAIConfig +from genkit.plugins.deepseek.model_info import SUPPORTED_DEEPSEEK_MODELS +from genkit.plugins.deepseek.models import DEEPSEEK_PLUGIN_NAME, DeepSeekModel, deepseek_name + + +class DeepSeek(Plugin): + """DeepSeek plugin for Genkit. + + This plugin provides integration with DeepSeek's OpenAI-compatible API, + enabling the use of DeepSeek models within the Genkit framework. + """ + + name = DEEPSEEK_PLUGIN_NAME + + def __init__( + self, + api_key: str | None = None, + models: list[str] | None = None, + **deepseek_params, + ) -> None: + """Initialize the plugin and set up its configuration. + + Args: + api_key: The DeepSeek API key. If not provided, it attempts to load + from the DEEPSEEK_API_KEY environment variable. + models: An optional list of model names to register with the plugin. + If None, all supported models will be registered. + **deepseek_params: Additional parameters for the DeepSeek client. + + Raises: + GenkitError: If no API key is provided via parameter or environment. + """ + self.api_key = api_key if api_key is not None else os.getenv('DEEPSEEK_API_KEY') + + if not self.api_key: + raise GenkitError(message='Please provide api_key or set DEEPSEEK_API_KEY environment variable.') + + self.models = models + self.deepseek_params = deepseek_params + + def initialize(self, ai: GenkitRegistry) -> None: + """Initialize the plugin by registering specified models. + + Args: + ai: The Genkit registry where models will be registered. + """ + models = self.models + if models is None: + models = list(SUPPORTED_DEEPSEEK_MODELS.keys()) + + for model in models: + deepseek_model = DeepSeekModel( + model=model, + api_key=self.api_key, + registry=ai, + **self.deepseek_params, + ) + deepseek_model.define_model() + + def resolve_action( + self, + ai: GenkitRegistry, + kind: ActionKind, + name: str, + ) -> None: + """Resolve and register an action dynamically. + + Args: + ai: The Genkit registry. + kind: The kind of action to resolve. + name: The name of the action to resolve. + """ + if kind == ActionKind.MODEL: + self._resolve_model(ai=ai, name=name) + + def _resolve_model(self, ai: GenkitRegistry, name: str) -> None: + """Resolve and define a DeepSeek model within the Genkit registry. + + This internal method handles the logic for registering DeepSeek models + dynamically based on the provided name. It extracts a clean name, + instantiates the DeepSeek class, and registers it with the registry. + + Args: + ai: The Genkit AI registry instance to define the model in. + name: The name of the model to resolve. This name might include a + prefix indicating it's from the DeepSeek plugin. + """ + clean_name = name.replace(DEEPSEEK_PLUGIN_NAME + '/', '') if name.startswith(DEEPSEEK_PLUGIN_NAME) else name + + deepseek_model = DeepSeekModel( + model=clean_name, + api_key=self.api_key, + registry=ai, + **self.deepseek_params, + ) + deepseek_model.define_model() + + @cached_property + def list_actions(self) -> list[ActionMetadata]: + """Generate a list of available DeepSeek models. + + Returns: + list[ActionMetadata]: A list of ActionMetadata objects for each + supported DeepSeek model, including name, metadata, and config schema. + """ + actions_list = [] + for model, model_info in SUPPORTED_DEEPSEEK_MODELS.items(): + actions_list.append( + model_action_metadata( + name=deepseek_name(model), info=model_info.model_dump(), config_schema=OpenAIConfig + ) + ) + + return actions_list diff --git a/py/plugins/deepseek/tests/test_deepseek_plugin.py b/py/plugins/deepseek/tests/test_deepseek_plugin.py new file mode 100644 index 0000000000..150d1d23e6 --- /dev/null +++ b/py/plugins/deepseek/tests/test_deepseek_plugin.py @@ -0,0 +1,185 @@ +# Copyright 2026 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 + +"""Tests for DeepSeek plugin.""" + +import os +from unittest.mock import MagicMock, patch + +import pytest + +from genkit.core.error import GenkitError +from genkit.core.registry import ActionKind +from genkit.plugins.deepseek import DeepSeek, deepseek_name + + +def test_deepseek_name(): + """Test name helper function.""" + assert deepseek_name('deepseek-chat') == 'deepseek/deepseek-chat' + assert deepseek_name('deepseek-reasoner') == 'deepseek/deepseek-reasoner' + + +def test_plugin_initialization_with_api_key(): + """Test plugin initializes with API key.""" + plugin = DeepSeek(api_key='test-key') + assert plugin.name == 'deepseek' + assert plugin.api_key == 'test-key' + + +def test_plugin_initialization_from_env(): + """Test plugin reads API key from environment.""" + with patch.dict(os.environ, {'DEEPSEEK_API_KEY': 'env-key'}): + plugin = DeepSeek() + assert plugin.api_key == 'env-key' + + +def test_plugin_initialization_without_api_key(): + """Test plugin raises error without API key.""" + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(GenkitError) as exc_info: + DeepSeek() + assert 'DEEPSEEK_API_KEY' in str(exc_info.value) + + +@patch('genkit.plugins.deepseek.models.DeepSeekClient') +def test_plugin_initialize(mock_client): + """Test plugin registers models during initialization.""" + plugin = DeepSeek(api_key='test-key', models=['deepseek-chat']) + mock_registry = MagicMock() + + plugin.initialize(mock_registry) + + # Should call define_model for the specified model + mock_registry.define_model.assert_called_once() + + +@patch('genkit.plugins.deepseek.models.DeepSeekClient') +def test_plugin_resolve_action(mock_client): + """Test plugin resolves models dynamically.""" + plugin = DeepSeek(api_key='test-key', models=[]) + mock_registry = MagicMock() + + plugin.resolve_action(mock_registry, ActionKind.MODEL, 'deepseek/deepseek-chat') + + # Should register the requested model + mock_registry.define_model.assert_called_once() + + +def test_plugin_list_actions(): + """Test plugin lists available models.""" + plugin = DeepSeek(api_key='test-key') + actions = plugin.list_actions + + assert len(actions) == 2 + action_names = [action.name for action in actions] + assert 'deepseek/deepseek-reasoner' in action_names + assert 'deepseek/deepseek-chat' in action_names + + +@patch('genkit.plugins.deepseek.models.DeepSeekClient') +def test_plugin_with_custom_params(mock_client): + """Test plugin accepts custom parameters.""" + plugin = DeepSeek( + api_key='test-key', + models=['deepseek-chat'], + timeout=60, + max_retries=3, + ) + + assert plugin.deepseek_params['timeout'] == 60 + assert plugin.deepseek_params['max_retries'] == 3 + + +@patch('genkit.plugins.deepseek.models.DeepSeekClient') +def test_plugin_initialize_no_models(mock_client): + """Test plugin registers all supported models when models is None.""" + from genkit.plugins.deepseek.model_info import SUPPORTED_DEEPSEEK_MODELS + + plugin = DeepSeek(api_key='test-key') + mock_registry = MagicMock() + + # When models is None, all supported models should be registered + plugin.initialize(mock_registry) + + assert mock_registry.define_model.call_count == len(SUPPORTED_DEEPSEEK_MODELS) + + +def test_plugin_resolve_action_non_model_kind(): + """Test resolve_action does nothing for non-MODEL kinds.""" + plugin = DeepSeek(api_key='test-key') + mock_registry = MagicMock() + + # Using PROMPT kind to test the case where kind != MODEL + plugin.resolve_action(mock_registry, ActionKind.PROMPT, 'some-prompt') + + # Should not attempt to register anything + mock_registry.define_model.assert_not_called() + + +@patch('genkit.plugins.deepseek.models.DeepSeekClient') +def test_plugin_resolve_action_without_prefix(mock_client): + """Test plugin resolves models without plugin prefix.""" + plugin = DeepSeek(api_key='test-key', models=[]) + mock_registry = MagicMock() + + # Pass name without 'deepseek/' prefix + plugin.resolve_action(mock_registry, ActionKind.MODEL, 'deepseek-chat') + + mock_registry.define_model.assert_called_once() + + +@patch('genkit.plugins.deepseek.client.DeepSeekClient.__new__') +def test_deepseek_client_initialization(mock_new): + """Test DeepSeekClient creates OpenAI client with correct params.""" + from genkit.plugins.deepseek.client import DeepSeekClient + + # Set up mock to return a fake client + mock_client_instance = MagicMock() + mock_new.return_value = mock_client_instance + + # Create a DeepSeekClient + result = DeepSeekClient(api_key='test-key', timeout=30) + + # Verify __new__ was called with correct parameters + mock_new.assert_called_once() + + +def test_deepseek_client_with_custom_base_url(): + """Test DeepSeekClient accepts custom base_url.""" + from openai import OpenAI + + from genkit.plugins.deepseek.client import DeepSeekClient + + with patch.object(OpenAI, '__init__', return_value=None) as mock_init: + DeepSeekClient(api_key='test-key', base_url='https://custom.api.deepseek.com') + mock_init.assert_called_once_with( + api_key='test-key', + base_url='https://custom.api.deepseek.com', + ) + + +def test_deepseek_client_default_base_url(): + """Test DeepSeekClient uses default base_url when not provided.""" + from openai import OpenAI + + from genkit.plugins.deepseek.client import DeepSeekClient + + with patch.object(OpenAI, '__init__', return_value=None) as mock_init: + DeepSeekClient(api_key='test-key') + mock_init.assert_called_once_with( + api_key='test-key', + base_url='https://api.deepseek.com', + ) diff --git a/py/plugins/deepseek/tests/test_model_info.py b/py/plugins/deepseek/tests/test_model_info.py new file mode 100644 index 0000000000..dd61b137ba --- /dev/null +++ b/py/plugins/deepseek/tests/test_model_info.py @@ -0,0 +1,55 @@ +# Copyright 2026 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 + +"""Tests for DeepSeek model information.""" + +import pytest + +from genkit.plugins.deepseek.model_info import SUPPORTED_DEEPSEEK_MODELS, get_default_model_info + + +def test_supported_models_exist(): + """Test that supported models are defined.""" + assert 'deepseek-reasoner' in SUPPORTED_DEEPSEEK_MODELS + assert 'deepseek-chat' in SUPPORTED_DEEPSEEK_MODELS + + +def test_model_order(): + """Test models are in correct order (matching JS).""" + keys = list(SUPPORTED_DEEPSEEK_MODELS.keys()) + assert keys[0] == 'deepseek-reasoner' + assert keys[1] == 'deepseek-chat' + + +def test_model_info_structure(): + """Test model info has required fields.""" + for model_name, model_info in SUPPORTED_DEEPSEEK_MODELS.items(): + assert model_info.label + assert model_info.supports + assert model_info.supports.multiturn is True + assert model_info.supports.tools is True + assert model_info.supports.media is False + assert model_info.supports.system_role is True + assert 'text' in model_info.supports.output + assert 'json' in model_info.supports.output + + +def test_get_default_model_info(): + """Test getting default info for unknown models.""" + info = get_default_model_info('deepseek-future-model') + assert 'deepseek-future-model' in info.label + assert info.supports.multiturn is True + assert info.supports.tools is True diff --git a/py/plugins/dev-local-vectorstore/pyproject.toml b/py/plugins/dev-local-vectorstore/pyproject.toml index 7302053da9..fe965b2399 100644 --- a/py/plugins/dev-local-vectorstore/pyproject.toml +++ b/py/plugins/dev-local-vectorstore/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -40,7 +39,7 @@ dependencies = [ "strenum>=0.4.15; python_version < '3.11'", ] description = "Genkit Local Vector Store Plugin" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "genkit-plugin-dev-local-vectorstore" readme = "README.md" requires-python = ">=3.10" diff --git a/py/plugins/evaluators/pyproject.toml b/py/plugins/evaluators/pyproject.toml index 257fad1fb8..c7b9385d19 100644 --- a/py/plugins/evaluators/pyproject.toml +++ b/py/plugins/evaluators/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", @@ -38,7 +37,7 @@ dependencies = [ "strenum>=0.4.15; python_version < '3.11'", ] description = "Genkit Evaluators Plugin for RAGAS" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "genkit-plugin-evaluators" readme = "README.md" requires-python = ">=3.10" diff --git a/py/plugins/firebase/pyproject.toml b/py/plugins/firebase/pyproject.toml index 05bb7c57fa..fd747dbb31 100644 --- a/py/plugins/firebase/pyproject.toml +++ b/py/plugins/firebase/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -39,7 +38,7 @@ dependencies = [ "strenum>=0.4.15; python_version < '3.11'", ] description = "Genkit Firebase Plugin" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "genkit-plugin-firebase" readme = "README.md" requires-python = ">=3.10" diff --git a/py/plugins/flask/pyproject.toml b/py/plugins/flask/pyproject.toml index 43bb01a88d..0b43ad401e 100644 --- a/py/plugins/flask/pyproject.toml +++ b/py/plugins/flask/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -41,7 +40,7 @@ dependencies = [ "flask", ] description = "Genkit Firebase Plugin" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "genkit-plugin-flask" readme = "README.md" requires-python = ">=3.10" diff --git a/py/plugins/google-cloud/pyproject.toml b/py/plugins/google-cloud/pyproject.toml index 2a58f3e6f8..43985cab8a 100644 --- a/py/plugins/google-cloud/pyproject.toml +++ b/py/plugins/google-cloud/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -40,7 +39,7 @@ dependencies = [ "strenum>=0.4.15; python_version < '3.11'", ] description = "Genkit Google Cloud Plugin" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "genkit-plugin-google-cloud" readme = "README.md" requires-python = ">=3.10" diff --git a/py/plugins/google-genai/pyproject.toml b/py/plugins/google-genai/pyproject.toml index bc4df8a955..e98788f12a 100644 --- a/py/plugins/google-genai/pyproject.toml +++ b/py/plugins/google-genai/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -41,7 +40,7 @@ dependencies = [ "strenum>=0.4.15; python_version < '3.11'", ] description = "Genkit Google GenAI Plugin" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "genkit-plugin-google-genai" readme = "README.md" requires-python = ">=3.10" diff --git a/py/plugins/google-genai/src/genkit/plugins/google_genai/__init__.py b/py/plugins/google-genai/src/genkit/plugins/google_genai/__init__.py index 7aefb18cf2..1c67d97670 100644 --- a/py/plugins/google-genai/src/genkit/plugins/google_genai/__init__.py +++ b/py/plugins/google-genai/src/genkit/plugins/google_genai/__init__.py @@ -20,7 +20,12 @@ GeminiEmbeddingModels, VertexEmbeddingModels, ) -from genkit.plugins.google_genai.models.gemini import GeminiConfigSchema, GoogleAIGeminiVersion, VertexAIGeminiVersion +from genkit.plugins.google_genai.models.gemini import ( + GeminiConfigSchema, + GeminiImageConfigSchema, + GoogleAIGeminiVersion, + VertexAIGeminiVersion, +) from genkit.plugins.google_genai.models.imagen import ImagenVersion @@ -45,5 +50,6 @@ def package_name() -> str: VertexAIGeminiVersion.__name__, EmbeddingTaskType.__name__, GeminiConfigSchema.__name__, + GeminiImageConfigSchema.__name__, ImagenVersion.__name__, ] diff --git a/py/plugins/google-genai/src/genkit/plugins/google_genai/models/gemini.py b/py/plugins/google-genai/src/genkit/plugins/google_genai/models/gemini.py index e1f4403f47..1fd91bb0ad 100644 --- a/py/plugins/google-genai/src/genkit/plugins/google_genai/models/gemini.py +++ b/py/plugins/google-genai/src/genkit/plugins/google_genai/models/gemini.py @@ -182,6 +182,10 @@ class GeminiConfigSchema(genai_types.GenerateContentConfig): code_execution: bool | None = None response_modalities: list[str] | None = None + thinking_config: dict[str, Any] | None = None + file_search: dict[str, Any] | None = None + url_context: dict[str, Any] | None = None + api_version: str | None = None class GeminiTtsConfigSchema(GeminiConfigSchema): @@ -678,6 +682,11 @@ def _create_tool(self, tool: ToolDefinition) -> genai_types.Tool: Genai tool compatible with Gemini API. """ params = self._convert_schema_property(tool.input_schema) + # Fix for no-arg tools: parameters cannot be None if we want the tool to be callable? + # Actually Google GenAI expects type=OBJECT for params usually. + if not params: + params = genai_types.Schema(type=genai_types.Type.OBJECT, properties={}) + function = genai_types.FunctionDeclaration( name=tool.name, description=tool.description, @@ -741,7 +750,7 @@ def _convert_schema_property( if schema_type == genai_types.Type.OBJECT: schema.properties = {} - properties = input_schema['properties'] + properties = input_schema.get('properties', {}) for key in properties: nested_schema = self._convert_schema_property(properties[key], defs) schema.properties[key] = nested_schema @@ -844,13 +853,56 @@ async def generate(self, request: GenerateRequest, ctx: ActionRunContext) -> Gen if cached_content: request_cfg.cached_content = cached_content.name + client = self._client + # If config specifies an api_version different from default (e.g. 'v1alpha'), + # Create a temporary client with that version, since api_version is a client-level setting. + api_version = None + if request.config: + api_version = getattr(request.config, 'api_version', None) + if not api_version and isinstance(request.config, dict): + api_version = request.config.get('api_version') + + if api_version: + # TODO: Request public API from google-genai maintainers. + # Currently, there is no public way to access the configured api_key, project, or location + # from an existing Client instance. We need to access the private _api_client to + # clone the configuration when overriding the api_version. + api_client = self._client._api_client + kwargs = { + 'vertexai': api_client.vertexai, + 'http_options': {'api_version': api_version}, + } + if api_client.vertexai: + # Vertex AI mode: requires project/location (api_key is optional/unlikely) + if api_client.project: + kwargs['project'] = api_client.project + if api_client.location: + kwargs['location'] = api_client.location + if api_client._credentials: + kwargs['credentials'] = api_client._credentials + # Don't pass api_key if we are in Vertex AI mode with credentials/project + else: + # Google AI mode: primarily uses api_key + if api_client.api_key: + kwargs['api_key'] = api_client.api_key + # Do NOT pass project/location/credentials if in Google AI mode to be safe + if api_client._credentials and not kwargs.get('api_key'): + # Fallback if no api_key but credentials present (unlikely for pure Google AI but possible) + kwargs['credentials'] = api_client._credentials + + client = genai.Client(**kwargs) + if ctx.is_streaming: response = await self._streaming_generate( - request_contents=request_contents, request_cfg=request_cfg, ctx=ctx, model_name=model_name + request_contents=request_contents, + request_cfg=request_cfg, + ctx=ctx, + model_name=model_name, + client=client, ) else: response = await self._generate( - request_contents=request_contents, request_cfg=request_cfg, model_name=model_name + request_contents=request_contents, request_cfg=request_cfg, model_name=model_name, client=client ) response.usage = self._create_usage_stats(request=request, response=response) @@ -862,6 +914,7 @@ async def _generate( request_contents: list[genai_types.Content], request_cfg: genai_types.GenerateContentConfig, model_name: str, + client: genai.Client | None = None, ) -> GenerateResponse: """Call google-genai generate. @@ -885,7 +938,8 @@ async def _generate( fallback=lambda _: '[!! failed to serialize !!]', ), ) - response = await self._client.aio.models.generate_content( + client = client or self._client + response = await client.aio.models.generate_content( model=model_name, contents=request_contents, config=request_cfg ) span.set_attribute('genkit:output', dump_json(response)) @@ -905,6 +959,7 @@ async def _streaming_generate( request_cfg: genai_types.GenerateContentConfig | None, ctx: ActionRunContext, model_name: str, + client: genai.Client | None = None, ) -> GenerateResponse: """Call google-genai generate for streaming. @@ -926,7 +981,8 @@ async def _streaming_generate( 'model': model_name, }), ) - generator = self._client.aio.models.generate_content_stream( + client = client or self._client + generator = client.aio.models.generate_content_stream( model=model_name, contents=request_contents, config=request_cfg ) accumulated_content = [] @@ -989,7 +1045,11 @@ async def _build_messages( continue content_parts: list[genai_types.Part] = [] for p in msg.content: - content_parts.append(PartConverter.to_gemini(p)) + converted = PartConverter.to_gemini(p) + if isinstance(converted, list): + content_parts.extend(converted) + else: + content_parts.append(converted) request_contents.append(genai_types.Content(parts=content_parts, role=msg.role)) if msg.metadata and msg.metadata.get('cache'): @@ -1046,11 +1106,27 @@ def _genkit_to_googleai_cfg(self, request: GenerateRequest) -> genai_types.Gener stop_sequences=request_config.stop_sequences, ) elif isinstance(request_config, GeminiConfigSchema): - cfg = request_config + config_dict = request_config.model_dump(exclude_none=True) + # Remove extra fields not in GenerateContentConfig + for extra in ['code_execution', 'file_search', 'url_context', 'api_version']: + config_dict.pop(extra, None) + cfg = genai_types.GenerateContentConfig(**config_dict) + if request_config.code_execution: tools.extend([genai_types.Tool(code_execution=genai_types.ToolCodeExecution())]) elif isinstance(request_config, dict): - cfg = genai_types.GenerateContentConfig(**request_config) + if 'image_config' in request_config: + validation_config = GeminiImageConfigSchema(**request_config) + elif 'speech_config' in request_config: + validation_config = GeminiTtsConfigSchema(**request_config) + else: + validation_config = GeminiConfigSchema(**request_config) + + config_dict = validation_config.model_dump(exclude_none=True) + # Remove extra fields + for extra in ['code_execution', 'file_search', 'url_context', 'api_version']: + config_dict.pop(extra, None) + cfg = genai_types.GenerateContentConfig(**config_dict) if request.output: if not cfg: diff --git a/py/plugins/google-genai/src/genkit/plugins/google_genai/models/utils.py b/py/plugins/google-genai/src/genkit/plugins/google_genai/models/utils.py index ec2ab72ed1..76cabf75b3 100644 --- a/py/plugins/google-genai/src/genkit/plugins/google_genai/models/utils.py +++ b/py/plugins/google-genai/src/genkit/plugins/google_genai/models/utils.py @@ -90,6 +90,49 @@ def to_gemini(cls, part: Part) -> genai.types.Part: thought_signature=cls._extract_thought_signature(part.root.metadata), ) if isinstance(part.root, ToolResponsePart): + tool_output = part.root.tool_response.output + parts_to_return = [] + + # Check for multimodal content structure {content: [{media: ...}]} + if isinstance(tool_output, dict) and 'content' in tool_output: + content_list = tool_output['content'] + if isinstance(content_list, list): + # Create a copy to avoid mutating original if that matters, + # but here we just want to separate content from other fields. + clean_output = tool_output.copy() + clean_output.pop('content') + + # Heuristic: if media found, extract it to separate parts. + has_media = False + for item in content_list: + if isinstance(item, dict) and 'media' in item: + has_media = True + media_info = item['media'] + url = media_info.get('url') + content_type = media_info.get('contentType') or media_info.get('content_type') + + if url and url.startswith(cls.DATA): + _, data_str = url.split(',', 1) + data = base64.b64decode(data_str) + parts_to_return.append( + genai.types.Part(inline_data=genai.types.Blob(mime_type=content_type, data=data)) + ) + + if has_media: + # Append the function response part FIRST (contextually correct) + parts_to_return.insert( + 0, + genai.types.Part( + function_response=genai.types.FunctionResponse( + id=part.root.tool_response.ref, + name=part.root.tool_response.name.replace('/', '__'), + response=clean_output, + ) + ), + ) + return parts_to_return + + # Default behavior for standard tool responses return genai.types.Part( function_response=genai.types.FunctionResponse( id=part.root.tool_response.ref, @@ -167,7 +210,7 @@ def from_gemini(cls, part: genai.types.Part, ref: str | None = None) -> Part: metadata=cls._encode_thought_signature(part.thought_signature), ) ) - if part.text: + if part.text is not None: return Part(root=TextPart(text=part.text)) if part.function_call: return Part( diff --git a/py/plugins/mcp/pyproject.toml b/py/plugins/mcp/pyproject.toml index 24d42c1f09..6ea44f68ec 100644 --- a/py/plugins/mcp/pyproject.toml +++ b/py/plugins/mcp/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -35,7 +34,7 @@ classifiers = [ ] dependencies = ["genkit", "mcp"] description = "Genkit MCP Plugin" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "genkit-plugins-mcp" readme = "README.md" requires-python = ">=3.10" diff --git a/py/plugins/ollama/pyproject.toml b/py/plugins/ollama/pyproject.toml index d9ee166da9..53edda87e7 100644 --- a/py/plugins/ollama/pyproject.toml +++ b/py/plugins/ollama/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -35,7 +34,7 @@ classifiers = [ ] dependencies = ["genkit", "ollama~=0.4", "structlog>=25.2.0"] description = "Genkit Ollama Plugin" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "genkit-plugin-ollama" readme = "README.md" requires-python = ">=3.10" diff --git a/py/plugins/vertex-ai/pyproject.toml b/py/plugins/vertex-ai/pyproject.toml index d7c901752e..1f8638cb47 100644 --- a/py/plugins/vertex-ai/pyproject.toml +++ b/py/plugins/vertex-ai/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -44,7 +43,7 @@ dependencies = [ "google-cloud-firestore", ] description = "Genkit Google Cloud Vertex AI Plugin" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "genkit-plugin-vertex-ai" readme = "README.md" requires-python = ">=3.10" diff --git a/py/plugins/xai/pyproject.toml b/py/plugins/xai/pyproject.toml index 15843b3727..bcfc93269f 100644 --- a/py/plugins/xai/pyproject.toml +++ b/py/plugins/xai/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -35,7 +34,7 @@ classifiers = [ ] dependencies = ["genkit", "xai-sdk>=0.0.1"] description = "Genkit xAI Plugin" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "genkit-plugin-xai" readme = "README.md" requires-python = ">=3.10" diff --git a/py/pyproject.toml b/py/pyproject.toml index 83b23b02ba..5ee43b379b 100644 --- a/py/pyproject.toml +++ b/py/pyproject.toml @@ -15,11 +15,13 @@ # SPDX-License-Identifier: Apache-2.0 [project] +authors = [{ name = "Google" }] dependencies = [ "dotpromptz==0.1.4", "genkit", "genkit-plugin-anthropic", "genkit-plugin-compat-oai", + "genkit-plugin-deepseek", "genkit-plugin-dev-local-vectorstore", "genkit-plugin-evaluators", "genkit-plugin-firebase", @@ -34,7 +36,7 @@ dependencies = [ "strenum>=0.4.15; python_version < '3.11'", ] description = "Workspace for Genkit packages" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "genkit-workspace" readme = "README.md" requires-python = ">=3.10" @@ -61,7 +63,7 @@ dev = [ "nox-uv>=0.2.2", ] -lint = ["mypy>=1.15", "ruff>=0.9"] +lint = ["ty>=0.0.1", "ruff>=0.9"] [tool.hatch.build.targets.wheel] packages = [] @@ -96,6 +98,7 @@ omit = [ "**/typing.py", # Often auto-generated or complex types "**/types.py", # Often auto-generated or complex types ] +source = ["packages", "plugins"] # uv based package management. [tool.uv] @@ -106,6 +109,7 @@ evaluator-demo = { workspace = true } genkit = { workspace = true } genkit-plugin-anthropic = { workspace = true } genkit-plugin-compat-oai = { workspace = true } +genkit-plugin-deepseek = { workspace = true } genkit-plugin-dev-local-vectorstore = { workspace = true } genkit-plugin-evaluators = { workspace = true } genkit-plugin-firebase = { workspace = true } @@ -199,29 +203,6 @@ line-ending = "lf" quote-style = "single" skip-magic-trailing-comma = false -# Static type checking. -[tool.mypy] -disallow_incomplete_defs = true -disallow_untyped_defs = true -exclude = ["samples/"] -explicit_package_bases = true -mypy_path = [ - "packages/genkit/src", - "plugins/chroma/src", - "plugins/compat-oai/src", - "plugins/dev-local-vectorstore/src", - "plugins/firebase/src", - "plugins/flask/src", - "plugins/google-cloud/src", - "plugins/google-genai/src", - "plugins/ollama/src", - "plugins/pinecone/src", - "plugins/vertex-ai/src", -] -namespace_packages = true -strict = true -warn_unused_configs = true - [tool.datamodel-codegen] #collapse-root-models = true # Don't use; produces Any as types. #strict-types = ["str", "int", "float", "bool", "bytes"] # Don't use; produces StrictStr, StrictInt, etc. diff --git a/py/samples/anthropic-hello/pyproject.toml b/py/samples/anthropic-hello/pyproject.toml index 17ec62d435..6588a3f712 100644 --- a/py/samples/anthropic-hello/pyproject.toml +++ b/py/samples/anthropic-hello/pyproject.toml @@ -15,6 +15,7 @@ # SPDX-License-Identifier: Apache-2.0 [project] +authors = [{ name = "Google" }] dependencies = [ "genkit", "genkit-plugin-anthropic", diff --git a/py/samples/compat-oai-hello/pyproject.toml b/py/samples/compat-oai-hello/pyproject.toml index 2c9af3e410..6ec5e1f157 100644 --- a/py/samples/compat-oai-hello/pyproject.toml +++ b/py/samples/compat-oai-hello/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -41,7 +40,7 @@ dependencies = [ "httpx>=0.28.1", ] description = "OpenAI sample" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "compat-oai-hello" readme = "README.md" requires-python = ">=3.10" diff --git a/py/samples/deepseek-hello/README.md b/py/samples/deepseek-hello/README.md new file mode 100644 index 0000000000..477f8ccc77 --- /dev/null +++ b/py/samples/deepseek-hello/README.md @@ -0,0 +1,19 @@ +## DeepSeek Sample + +1. Setup environment and install dependencies: +```bash +uv venv +source .venv/bin/activate + +uv sync +``` + +2. Set DeepSeek API key (get one from [DeepSeek Platform](https://platform.deepseek.com/)): +```bash +export DEEPSEEK_API_KEY=your-api-key +``` + +3. Run the sample: +```bash +genkit start -- uv run src/main.py +``` diff --git a/py/samples/deepseek-hello/pyproject.toml b/py/samples/deepseek-hello/pyproject.toml new file mode 100644 index 0000000000..cb48c544d8 --- /dev/null +++ b/py/samples/deepseek-hello/pyproject.toml @@ -0,0 +1,38 @@ +# Copyright 2026 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" }] +dependencies = [ + "genkit", + "genkit-plugin-deepseek", + "pydantic>=2.0.0", + "structlog>=24.0.0", +] +description = "DeepSeek Hello Sample" +name = "deepseek-hello" +requires-python = ">=3.10" +version = "0.1.0" + +[tool.uv.sources] +genkit-plugin-deepseek = { workspace = true } + +[build-system] +build-backend = "hatchling.build" +requires = ["hatchling"] + +[tool.hatch.build.targets.wheel] +packages = ["src"] diff --git a/py/samples/deepseek-hello/run.sh b/py/samples/deepseek-hello/run.sh new file mode 100755 index 0000000000..02a864050f --- /dev/null +++ b/py/samples/deepseek-hello/run.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Copyright 2026 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 + +exec genkit start -- uv run src/main.py "$@" diff --git a/py/samples/deepseek-hello/src/main.py b/py/samples/deepseek-hello/src/main.py new file mode 100644 index 0000000000..bfc714d437 --- /dev/null +++ b/py/samples/deepseek-hello/src/main.py @@ -0,0 +1,279 @@ +# Copyright 2026 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 + +"""DeepSeek hello sample. + +Key features demonstrated in this sample: + +| Feature Description | Example Function / Code Snippet | +|-----------------------------------------|-----------------------------------------| +| Plugin Initialization | `ai = Genkit(plugins=[DeepSeek(...)])` | +| Default Model Configuration | `ai = Genkit(model=deepseek_name(...))` | +| Defining Flows | `@ai.flow()` decorator | +| Defining Tools | `@ai.tool()` decorator | +| Pydantic for Tool Input Schema | `WeatherInput` | +| Simple Generation (Prompt String) | `say_hi` | +| Streaming Response | `streaming_flow` | +| Generation with Tools | `weather_flow` | +| Reasoning Model (deepseek-reasoner) | `reasoning_flow` | +| Generation with Config | `custom_config_flow` | +| Multi-turn Chat | `chat_flow` | +""" + +import structlog +from pydantic import BaseModel, Field + +from genkit.ai import Genkit +from genkit.core.action import ActionRunContext +from genkit.plugins.deepseek import DeepSeek, deepseek_name +from genkit.types import Message, Part, Role, TextPart, ToolResponse + +logger = structlog.get_logger(__name__) + +ai = Genkit( + plugins=[DeepSeek()], + model=deepseek_name('deepseek-chat'), +) + + +class WeatherInput(BaseModel): + """Input schema for the weather tool.""" + + location: str = Field(description='The city and state, e.g. San Francisco, CA') + + +@ai.tool() +def get_weather(input: WeatherInput) -> str: + """Get weather of a location, the user should supply a location first. + + Args: + input: Weather input with location (city and state, e.g. San Francisco, CA). + + Returns: + Weather information with temperature in degrees Fahrenheit. + """ + # Mocked weather data + weather_data = { + 'San Francisco, CA': {'temp': 72, 'condition': 'sunny', 'humidity': 65}, + 'Seattle, WA': {'temp': 55, 'condition': 'rainy', 'humidity': 85}, + } + + location = input.location + data = weather_data.get(location, {'temp': 70, 'condition': 'partly cloudy', 'humidity': 55}) + + return f'The weather in {location} is {data["temp"]}°F and {data["condition"]}. Humidity is {data["humidity"]}%.' + + +@ai.flow() +async def say_hi(name: str) -> str: + """Generate a simple greeting. + + Args: + name: Name to greet. + + Returns: + Greeting message. + """ + response = await ai.generate(prompt=f'Say hello to {name}!') + return response.text + + +@ai.flow() +async def streaming_flow(topic: str, ctx: ActionRunContext) -> str: + """Generate with streaming response. + + Args: + topic: Topic to generate about. + ctx: Action run context for streaming chunks to client. + + Returns: + Generated text. + """ + response = await ai.generate( + prompt=f'Tell me a fun fact about {topic}', + on_chunk=ctx.send_chunk, + ) + return response.text + + +@ai.flow() +async def weather_flow(location: str) -> str: + """Get weather using compat-oai auto tool calling.""" + + response = await ai.generate( + model=deepseek_name('deepseek-chat'), + prompt=f'What is the weather in {location}?', + system=( + 'You have a tool called get_weather. ' + "It takes an object with a 'location' field. " + 'Always use this tool when asked about weather.' + ), + tools=['get_weather'], + tool_choice='required', + max_turns=2, + ) + + return response.text + + +@ai.flow() +async def reasoning_flow(prompt: str | None = None) -> str: + """Solve reasoning problems using deepseek-reasoner model. + + Args: + prompt: The reasoning question to solve. Defaults to a classic logic problem. + + Returns: + The reasoning and answer. + """ + if prompt is None: + prompt = 'What is heavier, one kilo of steel or one kilo of feathers?' + + response = await ai.generate( + model=deepseek_name('deepseek-reasoner'), + prompt=prompt, + ) + return response.text + + +@ai.flow() +async def custom_config_flow(task: str | None = None) -> str: + """Demonstrate custom model configurations for different tasks. + + Shows how different config parameters affect generation behavior: + - 'creative': High temperature for diverse, creative outputs + - 'precise': Low temperature with penalties for consistent, focused outputs + - 'detailed': Extended output with frequency penalty to avoid repetition + + Args: + task: Type of task - 'creative', 'precise', or 'detailed' + + Returns: + Generated response showing the effect of different configs. + """ + if task is None: + task = 'creative' + + prompts = { + 'creative': 'Write a creative story opener about a robot discovering art', + 'precise': 'List the exact steps to make a cup of tea', + 'detailed': 'Explain how photosynthesis works in detail', + } + + configs = { + 'creative': { + 'temperature': 1.5, # High temperature for creativity + 'max_tokens': 200, + 'top_p': 0.95, + }, + 'precise': { + 'temperature': 0.1, # Low temperature for consistency + 'max_tokens': 150, + 'presence_penalty': 0.5, # Encourage covering all steps + }, + 'detailed': { + 'temperature': 0.7, + 'max_tokens': 400, # More tokens for detailed explanation + 'frequency_penalty': 0.8, # Reduce repetitive phrasing + }, + } + + prompt = prompts.get(task, prompts['creative']) + config = configs.get(task, configs['creative']) + + response = await ai.generate( + prompt=prompt, + config=config, + ) + return response.text + + +@ai.flow() +async def chat_flow() -> str: + """Multi-turn chat example demonstrating context retention. + + Returns: + Final chat response. + """ + history = [] + + # First turn - User shares information + prompt1 = "Hi! I'm planning a trip to Tokyo next month. I'm really excited because I love Japanese cuisine, especially ramen and sushi." + response1 = await ai.generate( + prompt=prompt1, + system='You are a helpful travel assistant.', + ) + history.append(Message(role=Role.USER, content=[TextPart(text=prompt1)])) + history.append(response1.message) + await logger.ainfo('chat_flow turn 1', result=response1.text) + + # Second turn - Ask question requiring context from first turn + response2 = await ai.generate( + messages=history + [Message(role=Role.USER, content=[TextPart(text='What foods did I say I enjoy?')])], + system='You are a helpful travel assistant.', + ) + history.append(Message(role=Role.USER, content=[TextPart(text='What foods did I say I enjoy?')])) + history.append(response2.message) + await logger.ainfo('chat_flow turn 2', result=response2.text) + + # Third turn - Ask question requiring context from both previous turns + response3 = await ai.generate( + messages=history + + [ + Message( + role=Role.USER, + content=[TextPart(text='Based on our conversation, suggest one restaurant I should visit.')], + ) + ], + system='You are a helpful travel assistant.', + ) + return response3.text + + +async def main() -> None: + """Main entry point for the DeepSeek sample.""" + # Simple greeting + result = await say_hi('World') + await logger.ainfo('say_hi', result=result) + + # Streaming response + result = await streaming_flow('apple') + await logger.ainfo('streaming_flow', result=result) + + # Weather with tools + result = await weather_flow('Seattle, WA') + await logger.ainfo('weather_flow', result=result) + + # Reasoning model + result = await reasoning_flow() + await logger.ainfo('reasoning_flow', result=result) + + # Custom config - demonstrate different configurations + await logger.ainfo('Testing creative config...') + result = await custom_config_flow('creative') + await logger.ainfo('custom_config_flow (creative)', result=result) + + await logger.ainfo('Testing precise config...') + result = await custom_config_flow('precise') + await logger.ainfo('custom_config_flow (precise)', result=result) + + # Multi-turn chat + result = await chat_flow() + await logger.ainfo('chat_flow', result=result) + + +if __name__ == '__main__': + ai.run_main(main()) diff --git a/py/samples/dev-local-vectorstore-hello/pyproject.toml b/py/samples/dev-local-vectorstore-hello/pyproject.toml index dafc44ebaa..3a8a5911d8 100644 --- a/py/samples/dev-local-vectorstore-hello/pyproject.toml +++ b/py/samples/dev-local-vectorstore-hello/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -41,7 +40,7 @@ dependencies = [ "structlog>=25.2.0", ] description = "hello Genkit sample" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "dev-local-vectorstore-hello" readme = "README.md" requires-python = ">=3.10" diff --git a/py/samples/evaluator-demo/pyproject.toml b/py/samples/evaluator-demo/pyproject.toml index a80d91cc8b..771c966747 100644 --- a/py/samples/evaluator-demo/pyproject.toml +++ b/py/samples/evaluator-demo/pyproject.toml @@ -15,6 +15,7 @@ # SPDX-License-Identifier: Apache-2.0 [project] +authors = [{ name = "Google" }] dependencies = ["genkit", "pydantic>=2.0.0", "structlog>=24.0.0", "pypdf"] description = "Genkit Python Evaluation Demo" name = "eval-demo" diff --git a/py/samples/firestore-retreiver/pyproject.toml b/py/samples/firestore-retreiver/pyproject.toml index 485dea5882..1f856710f2 100644 --- a/py/samples/firestore-retreiver/pyproject.toml +++ b/py/samples/firestore-retreiver/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -35,7 +34,7 @@ classifiers = [ ] dependencies = ["genkit", "google-cloud-firestore"] description = "firestore-retreiver Genkit sample" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "firestore-retreiver" readme = "README.md" requires-python = ">=3.10" diff --git a/py/samples/flask-hello/pyproject.toml b/py/samples/flask-hello/pyproject.toml index 9397e7d598..e07d52625a 100644 --- a/py/samples/flask-hello/pyproject.toml +++ b/py/samples/flask-hello/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -40,7 +39,7 @@ dependencies = [ "flask", ] description = "hello Genkit sample" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "flask-hello" readme = "README.md" requires-python = ">=3.10" diff --git a/py/samples/google-genai-code-execution/pyproject.toml b/py/samples/google-genai-code-execution/pyproject.toml index d5dfa8f2db..267cf9ad6d 100644 --- a/py/samples/google-genai-code-execution/pyproject.toml +++ b/py/samples/google-genai-code-execution/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -40,7 +39,7 @@ dependencies = [ "structlog>=25.2.0", ] description = "Code execution sample" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "google-genai-code-execution" readme = "README.md" requires-python = ">=3.10" diff --git a/py/samples/google-genai-context-caching/pyproject.toml b/py/samples/google-genai-context-caching/pyproject.toml index 17035a9a72..a1008ab42e 100644 --- a/py/samples/google-genai-context-caching/pyproject.toml +++ b/py/samples/google-genai-context-caching/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -41,7 +40,7 @@ dependencies = [ "structlog>=25.2.0", ] description = "context-caching Genkit sample" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "google-genai-context-caching" readme = "README.md" requires-python = ">=3.10" diff --git a/py/samples/google-genai-hello/my_room.png b/py/samples/google-genai-hello/my_room.png new file mode 100644 index 0000000000..4b54e0e932 Binary files /dev/null and b/py/samples/google-genai-hello/my_room.png differ diff --git a/py/samples/google-genai-hello/palm_tree.png b/py/samples/google-genai-hello/palm_tree.png new file mode 100644 index 0000000000..79388bf0f8 Binary files /dev/null and b/py/samples/google-genai-hello/palm_tree.png differ diff --git a/py/samples/google-genai-hello/photo.jpg b/py/samples/google-genai-hello/photo.jpg new file mode 100644 index 0000000000..fcf6dfa6ad Binary files /dev/null and b/py/samples/google-genai-hello/photo.jpg differ diff --git a/py/samples/google-genai-hello/pyproject.toml b/py/samples/google-genai-hello/pyproject.toml index c85fd1db20..99204b2ebf 100644 --- a/py/samples/google-genai-hello/pyproject.toml +++ b/py/samples/google-genai-hello/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -42,7 +41,7 @@ dependencies = [ "structlog>=25.2.0", ] description = "Hello world sample" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "google-genai-hello" readme = "README.md" requires-python = ">=3.10" diff --git a/py/samples/google-genai-hello/src/main.py b/py/samples/google-genai-hello/src/main.py index de7eb51d2f..9c44f0be70 100755 --- a/py/samples/google-genai-hello/src/main.py +++ b/py/samples/google-genai-hello/src/main.py @@ -43,11 +43,20 @@ """ import argparse +import asyncio +import base64 +import os +import pathlib +import sys +from enum import Enum import structlog +from google import genai +from google.genai import types as genai_types from pydantic import BaseModel, Field from genkit.ai import Document, Genkit, ToolRunContext, tool_response +from genkit.core.action import ActionRunContext from genkit.plugins.evaluators import ( GenkitEvaluators, GenkitMetricType, @@ -58,12 +67,16 @@ from genkit.plugins.google_genai import ( EmbeddingTaskType, GeminiConfigSchema, + GeminiImageConfigSchema, GoogleAI, ) from genkit.types import ( - ActionRunContext, + GenerateRequest, GenerationCommonConfig, + Media, + MediaPart, Message, + Part, Role, TextPart, ) @@ -82,7 +95,7 @@ ]) ), ], - model='googleai/gemini-3-flash-preview', + model='googleai/gemini-flash-latest', ) @@ -93,16 +106,13 @@ class GablorkenInput(BaseModel): @ai.tool(name='gablorkenTool') -def gablorken_tool(input_: GablorkenInput) -> int: +def gablorken_tool(input_: GablorkenInput) -> dict[str, int]: """Calculate a gablorken. - Args: - input_: The input to calculate gablorken for. - Returns: The calculated gablorken. """ - return input_.value * 3 - 5 + return {'result': input_.value * 3 - 5} @ai.flow() @@ -160,7 +170,7 @@ async def simple_generate_with_interrupts(value: int) -> str: if len(response1.interrupts) == 0: return response1.text - tr = tool_response(response1.interrupts[0], 178) + tr = tool_response(response1.interrupts[0], {'output': 178}) response = await ai.generate( messages=response1.messages, tool_responses=[tr], @@ -193,8 +203,13 @@ async def say_hi(name: str): return resp.text +from typing import Annotated + +from pydantic import Field + + @ai.flow() -async def embed_docs(docs: list[str]): +async def embed_docs(docs: Annotated[list[str], Field(default=[''], description='List of texts to embed')] = ['']): """Generate an embedding for the words in a list. Args: @@ -324,14 +339,366 @@ async def generate_images(name: str, ctx): Returns: The generated response with a function. """ + result = await ai.generate( - model='googleai/gemini-2.5-flash-image', + model='googleai/gemini-3-flash-image-preview', prompt=f'tell me about {name} with photos', - config=GeminiConfigSchema(response_modalities=['text', 'image']).model_dump(exclude_none=True), + config=GeminiConfigSchema(response_modalities=['text', 'image'], api_version='v1alpha').model_dump( + exclude_none=True + ), ) return result +@ai.tool(name='screenshot') +def screenshot() -> dict: + """Takes a screenshot.""" + room_path = pathlib.Path(__file__).parent.parent / 'my_room.png' + with open(room_path, 'rb') as f: + room_b64 = base64.b64encode(f.read()).decode('utf-8') + + return { + 'output': 'success', + 'content': [{'media': {'url': f'data:image/png;base64,{room_b64}', 'contentType': 'image/png'}}], + } + + +@ai.flow() +async def multipart_tool_calling(): + """Multipart tool calling.""" + response = await ai.generate( + model='googleai/gemini-3-pro-preview', + tools=['screenshot'], + config=GenerationCommonConfig(temperature=1), + prompt="Tell me what I'm seeing on the screen.", + ) + return response.text + + +class ThinkingLevel(str, Enum): + LOW = 'LOW' + HIGH = 'HIGH' + + +@ai.flow() +async def thinking_level_pro(level: ThinkingLevel): + """Gemini 3.0 thinkingLevel config (Pro).""" + response = await ai.generate( + model='googleai/gemini-3-pro-preview', + prompt=( + 'Alice, Bob, and Carol each live in a different house on the ' + 'same street: red, green, and blue. The person who lives in the red house ' + 'owns a cat. Bob does not live in the green house. Carol owns a dog. The ' + 'green house is to the left of the red house. Alice does not own a cat. ' + 'The person in the blue house owns a fish. ' + 'Who lives in each house, and what pet do they own? Provide your ' + 'step-by-step reasoning.' + ), + config={ + 'thinking_config': { + 'include_thoughts': True, + 'thinking_level': level.value, + } + }, + ) + return response.text + + +class ThinkingLevelFlash(str, Enum): + MINIMAL = 'MINIMAL' + LOW = 'LOW' + MEDIUM = 'MEDIUM' + HIGH = 'HIGH' + + +@ai.flow() +async def thinking_level_flash(level: ThinkingLevelFlash): + """Gemini 3.0 thinkingLevel config (Flash).""" + response = await ai.generate( + model='googleai/gemini-3-flash-preview', + prompt=( + 'Alice, Bob, and Carol each live in a different house on the ' + 'same street: red, green, and blue. The person who lives in the red house ' + 'owns a cat. Bob does not live in the green house. Carol owns a dog. The ' + 'green house is to the left of the red house. Alice does not own a cat. ' + 'The person in the blue house owns a fish. ' + 'Who lives in each house, and what pet do they own? Provide your ' + 'step-by-step reasoning.' + ), + config={ + 'thinking_config': { + 'include_thoughts': True, + 'thinking_level': level.value, + } + }, + ) + return response.text + + +@ai.flow() +async def gemini_image_editing(): + """Image editing with Gemini.""" + plant_path = pathlib.Path(__file__).parent.parent / 'palm_tree.png' + room_path = pathlib.Path(__file__).parent.parent / 'my_room.png' + + with open(plant_path, 'rb') as f: + plant_b64 = base64.b64encode(f.read()).decode('utf-8') + with open(room_path, 'rb') as f: + room_b64 = base64.b64encode(f.read()).decode('utf-8') + + response = await ai.generate( + model='googleai/gemini-2.5-flash-image-preview', + prompt=[ + TextPart(text='add the plant to my room'), + MediaPart(media=Media(url=f'data:image/png;base64,{plant_b64}')), + MediaPart(media=Media(url=f'data:image/png;base64,{room_b64}')), + ], + config=GeminiImageConfigSchema( + response_modalities=['TEXT', 'IMAGE'], + image_config={'aspect_ratio': '1:1'}, + api_version='v1alpha', + ).model_dump(exclude_none=True), + ) + for part in response.message.content: + if isinstance(part.root, MediaPart): + return part.root.media + + return None + + +@ai.flow() +async def nano_banana_pro(): + """Nano banana pro config.""" + response = await ai.generate( + model='googleai/gemini-3-pro-image-preview', + prompt='Generate a picture of a sunset in the mountains by a lake', + config={ + 'response_modalities': ['TEXT', 'IMAGE'], + 'image_config': { + 'aspect_ratio': '21:9', + 'image_size': '4K', + }, + 'api_version': 'v1alpha', + }, + ) + for part in response.message.content: + if isinstance(part.root, MediaPart): + return part.root.media + return response.media + + +from typing import Any + + +@ai.flow() +async def photo_move_veo(_: Any, context: Any = None): + """An example of using Ver 3 model to make a static photo move.""" + # Find photo.jpg (or my_room.png) + room_path = pathlib.Path(__file__).parent / 'my_room.png' + if not room_path.exists(): + # Fallback search + room_path = pathlib.Path('samples/google-genai-hello/src/my_room.png') + if not room_path.exists(): + room_path = pathlib.Path('my_room.png') + + encoded_image = '' + if room_path.exists(): + with open(room_path, 'rb') as f: + encoded_image = base64.b64encode(f.read()).decode('utf-8') + else: + # Fallback dummy + encoded_image = ( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==' + ) + + api_key = os.environ.get('GEMINI_API_KEY') or os.environ.get('GOOGLE_GENAI_API_KEY') + if not api_key: + raise ValueError('GEMINI_API_KEY not set') + + # Use v1alpha for Veo + client = genai.Client(api_key=api_key, http_options={'api_version': 'v1alpha'}) + + # Prompt construction + prompt_parts = [ + genai_types.Part(text='make the subject in the photo move'), + genai_types.Part(inline_data=genai_types.Blob(mime_type='image/jpeg', data=base64.b64decode(encoded_image))), + ] + + # Send chunk equivalent + if context: + context.send_chunk(f'Starting generation with veo-3.0-generate-001...') + + try: + operation = await client.aio.models.generate_videos( + model='veo-3.0-generate-001', + prompt='make the subject in the photo move', + image=genai_types.Image(image_bytes=base64.b64decode(encoded_image), mime_type='image/png'), + config={ + # 'aspect_ratio': '9:16', + }, + ) + + if not operation: + raise ValueError('Expected operation to be returned') + + while not operation.done: + op_id = operation.name.split('/')[-1] if operation.name else 'unknown' + if context: + context.send_chunk(f'check status of operation {op_id}') + + # Poll + operation = await client.aio.operations.get(operation) + await asyncio.sleep(5) + + if operation.error: + if context: + context.send_chunk(f'Error: {operation.error.message}') + raise ValueError(f'Failed to generate video: {operation.error.message}') + + # Done + result_info = 'Video generated successfully.' + if hasattr(operation, 'result') and operation.result: + if hasattr(operation.result, 'generated_videos') and operation.result.generated_videos: + vid = operation.result.generated_videos[0] + if vid.video and vid.video.uri: + result_info += f' URI: {vid.video.uri}' + + if context: + context.send_chunk(f'Done! {result_info}') + + return operation + + except Exception as e: + raise ValueError(f'Flow failed: {e}') + + +@ai.flow() +async def gemini_media_resolution(): + """Media resolution.""" + # Placeholder base64 for sample + plant_path = pathlib.Path(__file__).parent.parent / 'palm_tree.png' + with open(plant_path, 'rb') as f: + plant_b64 = base64.b64encode(f.read()).decode('utf-8') + response = await ai.generate( + model='googleai/gemini-3-pro-preview', + prompt=[ + TextPart(text='What is in this picture?'), + MediaPart( + media=Media(url=f'data:image/png;base64,{plant_b64}'), + metadata={'mediaResolution': {'level': 'MEDIA_RESOLUTION_HIGH'}}, + ), + ], + config={'api_version': 'v1alpha'}, + ) + return response.text + + +@ai.flow() +async def search_grounding(): + """Search grounding.""" + response = await ai.generate( + model='googleai/gemini-3-flash-preview', + prompt='Who is Albert Einstein?', + config={'tools': [{'googleSearch': {}}], 'api_version': 'v1alpha'}, + ) + return response.text + + +@ai.flow() +async def url_context(): + """Url context.""" + response = await ai.generate( + model='googleai/gemini-3-flash-preview', + prompt='Compare the ingredients and cooking times from the recipes at https://www.foodnetwork.com/recipes/ina-garten/perfect-roast-chicken-recipe-1940592 and https://www.allrecipes.com/recipe/70679/simple-whole-roasted-chicken/', + config={'url_context': {}, 'api_version': 'v1alpha'}, + ) + return response.text + + +@ai.flow() +async def file_search(): + """File Search.""" + # TODO: add file search store + store_name = 'fileSearchStores/sample-store' + response = await ai.generate( + model='googleai/gemini-3-flash-preview', + prompt="What is the character's name in the story?", + config={ + 'file_search': { + 'file_search_store_names': [store_name], + 'metadata_filter': 'author=foo', + }, + 'api_version': 'v1alpha', + }, + ) + return response.text + + +@ai.flow() +async def multimodal_input(): + """Multimodal input.""" + photo_path = pathlib.Path(__file__).parent.parent / 'photo.jpg' + with open(photo_path, 'rb') as f: + photo_b64 = base64.b64encode(f.read()).decode('utf-8') + + response = await ai.generate( + model='googleai/gemini-2.5-flash', + prompt=[ + TextPart(text='describe this photo'), + MediaPart(media=Media(url=f'data:image/jpeg;base64,{photo_b64}', content_type='image/jpeg')), + ], + ) + return response.text + + +@ai.flow() +async def youtube_videos(): + """YouTube videos.""" + response = await ai.generate( + model='googleai/gemini-3-flash-preview', + prompt=[ + TextPart(text='transcribe this video'), + MediaPart(media=Media(url='https://www.youtube.com/watch?v=3p1P5grjXIQ', content_type='video/mp4')), + ], + config={'api_version': 'v1alpha'}, + ) + return response.text + + +class WeatherInput(BaseModel): + """Input for getting weather.""" + + location: str = Field(description='The city and state, e.g. San Francisco, CA') + + +@ai.tool(name='getWeather') +def get_weather(input_: WeatherInput) -> dict: + """Used to get current weather for a location.""" + return { + 'location': input_.location, + 'temperature_celcius': 21.5, + 'conditions': 'cloudy', + } + + +@ai.tool(name='celsiusToFahrenheit') +def celsius_to_fahrenheit(celsius: float) -> float: + """Converts Celsius to Fahrenheit.""" + return (celsius * 9) / 5 + 32 + + +@ai.flow() +async def tool_calling(location: Annotated[str, Field(default='Paris, France')]): + """Tool calling with Gemini.""" + response = await ai.generate( + model='googleai/gemini-2.5-flash', + tools=['getWeather', 'celsiusToFahrenheit'], + prompt=f"What's the weather in {location}? Convert the temperature to Fahrenheit.", + config=GenerationCommonConfig(temperature=1), + ) + return response.text + + async def main() -> None: """Main function.""" await logger.ainfo(await say_hi(', tell me a joke')) diff --git a/py/samples/google-genai-hello/src/main_vertexai.py b/py/samples/google-genai-hello/src/main_vertexai.py new file mode 100644 index 0000000000..9661566b07 --- /dev/null +++ b/py/samples/google-genai-hello/src/main_vertexai.py @@ -0,0 +1,262 @@ +# 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. + +"""Hello Google GenAI Vertex AI sample.""" + +import argparse +import base64 +import pathlib +from enum import Enum + +import structlog + +from genkit.ai import Genkit, Media, MediaPart, TextPart +from genkit.plugins.google_genai import GeminiImageConfigSchema, VertexAI +from genkit.types import GenerationCommonConfig + +logger = structlog.get_logger(__name__) + + +ai = Genkit( + plugins=[ + VertexAI(location='us-central1'), + ], + model='vertexai/gemini-2.5-flash', +) + + +class ThinkingLevel(str, Enum): + LOW = 'LOW' + HIGH = 'HIGH' + + +@ai.flow() +async def thinking_level_pro(level: ThinkingLevel): + """Gemini 3.0 thinkingLevel config (Pro).""" + response = await ai.generate( + model='vertexai/gemini-3-pro-preview', + prompt=( + 'Alice, Bob, and Carol each live in a different house on the ' + 'same street: red, green, and blue. The person who lives in the red house ' + 'owns a cat. Bob does not live in the green house. Carol owns a dog. The ' + 'green house is to the left of the red house. Alice does not own a cat. ' + 'The person in the blue house owns a fish. ' + 'Who lives in each house, and what pet do they own? Provide your ' + 'step-by-step reasoning.' + ), + config={ + 'thinking_config': { + 'include_thoughts': True, + 'thinking_level': level.value, + } + }, + ) + return response.text + + +class ThinkingLevelFlash(str, Enum): + MINIMAL = 'MINIMAL' + LOW = 'LOW' + MEDIUM = 'MEDIUM' + HIGH = 'HIGH' + + +@ai.flow() +async def thinking_level_flash(level: ThinkingLevelFlash): + """Gemini 3.0 thinkingLevel config (Flash).""" + response = await ai.generate( + model='vertexai/gemini-3-flash-preview', + prompt=( + 'Alice, Bob, and Carol each live in a different house on the ' + 'same street: red, green, and blue. The person who lives in the red house ' + 'owns a cat. Bob does not live in the green house. Carol owns a dog. The ' + 'green house is to the left of the red house. Alice does not own a cat. ' + 'The person in the blue house owns a fish. ' + 'Who lives in each house, and what pet do they own? Provide your ' + 'step-by-step reasoning.' + ), + config={ + 'thinking_config': { + 'include_thoughts': True, + 'thinking_level': level.value, + } + }, + ) + return response.text + + +@ai.flow() +async def video_understanding_metadata(): + """Video understanding with metadata.""" + response = await ai.generate( + model='vertexai/gemini-2.5-flash', + prompt=[ + MediaPart( + media=Media(url='gs://cloud-samples-data/video/animals.mp4', content_type='video/mp4'), + metadata={ + 'videoMetadata': { + 'fps': 0.5, + 'startOffset': '3.5s', + 'endOffset': '10.2s', + } + }, + ), + TextPart(text='describe this video'), + ], + ) + return response.text + + +@ai.flow() +async def maps_grounding(): + """Google maps grounding.""" + response = await ai.generate( + model='vertexai/gemini-2.5-flash', + prompt='Describe some sights near me', + config={ + 'tools': [{'googleMaps': {'enableWidget': True}}], + 'retrieval_config': { + 'latLng': { + 'latitude': 43.0896, + 'longitude': -79.0849, + }, + }, + }, + ) + return response.text + + +@ai.flow() +async def search_grounding(): + """Search grounding.""" + response = await ai.generate( + model='vertexai/gemini-2.5-flash', + prompt='Who is Albert Einstein?', + config={'tools': [{'googleSearch': {}}]}, + ) + return response.text + + +@ai.flow() +async def gemini_media_resolution(): + """Media resolution.""" + # Placeholder base64 for sample + plant_b64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=' + response = await ai.generate( + model='vertexai/gemini-3-pro-preview', + prompt=[ + TextPart(text='What is in this picture?'), + MediaPart( + media=Media(url=f'data:image/png;base64,{plant_b64}'), + metadata={'mediaResolution': {'level': 'MEDIA_RESOLUTION_HIGH'}}, + ), + ], + ) + return response.text + + +@ai.flow() +async def gemini_image_editing(): + """Image editing with Gemini.""" + plant_path = pathlib.Path(__file__).parent.parent / 'palm_tree.png' + room_path = pathlib.Path(__file__).parent.parent / 'my_room.png' + + with open(plant_path, 'rb') as f: + plant_b64 = base64.b64encode(f.read()).decode('utf-8') + with open(room_path, 'rb') as f: + room_b64 = base64.b64encode(f.read()).decode('utf-8') + + response = await ai.generate( + model='vertexai/gemini-2.5-flash-image-preview', + prompt=[ + TextPart(text='add the plant to my room'), + MediaPart(media=Media(url=f'data:image/png;base64,{plant_b64}')), + MediaPart(media=Media(url=f'data:image/png;base64,{room_b64}')), + ], + config=GeminiImageConfigSchema( + response_modalities=['TEXT', 'IMAGE'], + image_config={'aspect_ratio': '1:1'}, + ).model_dump(exclude_none=True), + ) + + for part in response.message.content: + if isinstance(part.root, MediaPart): + return part.root.media + + return None + + +@ai.flow() +async def nano_banana_pro(): + """Nano banana pro config.""" + response = await ai.generate( + model='vertexai/gemini-3-pro-image-preview', + prompt='Generate a picture of a sunset in the mountains by a lake', + config={ + 'response_modalities': ['TEXT', 'IMAGE'], + 'image_config': { + 'aspect_ratio': '3:4', + 'image_size': '1K', + }, + }, + ) + return response.media + + +@ai.flow() +async def imagen_image_generation(): + """A simple example of image generation with Gemini (Imagen).""" + response = await ai.generate( + model='vertexai/imagen-3.0-generate-002', + prompt='generate an image of a banana riding a bicycle', + ) + return response.media + + +@ai.tool(name='getWeather') +def get_weather(location: str) -> dict: + """Used to get current weather for a location.""" + return { + 'location': location, + 'temperature_celcius': 21.5, + 'conditions': 'cloudy', + } + + +@ai.tool(name='celsiusToFahrenheit') +def celsius_to_fahrenheit(celsius: float) -> float: + """Converts Celsius to Fahrenheit.""" + return (celsius * 9) / 5 + 32 + + +@ai.flow() +async def tool_calling(location: str = 'Paris, France'): + """Tool calling with Gemini.""" + response = await ai.generate( + model='vertexai/gemini-2.5-flash', + tools=['getWeather', 'celsiusToFahrenheit'], + prompt=f"What's the weather in {location}? Convert the temperature to Fahrenheit.", + config=GenerationCommonConfig(temperature=1), + ) + return response.text + + +async def main() -> None: + """Main function.""" + # Example run logic can go here or be empty for pure flow server + pass + + +if __name__ == '__main__': + ai.run_main(main()) diff --git a/py/samples/google-genai-image/pyproject.toml b/py/samples/google-genai-image/pyproject.toml index b264014272..5ddaab77e5 100644 --- a/py/samples/google-genai-image/pyproject.toml +++ b/py/samples/google-genai-image/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -40,7 +39,7 @@ dependencies = [ "pydantic>=2.10.5", ] description = "Vision API and Image Generation example" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "google-genai-image" readme = "README.md" requires-python = ">=3.10" diff --git a/py/samples/google-genai-image/src/main.py b/py/samples/google-genai-image/src/main.py index 27bbd3f1d1..d2f47d99e2 100755 --- a/py/samples/google-genai-image/src/main.py +++ b/py/samples/google-genai-image/src/main.py @@ -55,10 +55,7 @@ async def describe_image_with_gemini(data: str) -> str: The description of the image. """ if not (data.startswith('data:') and ',' in data): - raise ValueError( - f'Expected a data URI (e.g., "data:image/jpeg;base64,..."), ' - f'but got: {data[:50]}...' - ) + raise ValueError(f'Expected a data URI (e.g., "data:image/jpeg;base64,..."), but got: {data[:50]}...') result = await ai.generate( messages=[ diff --git a/py/samples/google-genai-vertexai-hello/pyproject.toml b/py/samples/google-genai-vertexai-hello/pyproject.toml index d2a14f41eb..3ffa5f5242 100644 --- a/py/samples/google-genai-vertexai-hello/pyproject.toml +++ b/py/samples/google-genai-vertexai-hello/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -40,7 +39,7 @@ dependencies = [ "structlog>=25.2.0", ] description = "Hello world sample on VertexAI API on GenAI" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "google-genai-vertexai-hello" readme = "README.md" requires-python = ">=3.10" diff --git a/py/samples/google-genai-vertexai-image/README.md b/py/samples/google-genai-vertexai-image/README.md index c0d5dba438..244a1be23f 100644 --- a/py/samples/google-genai-vertexai-image/README.md +++ b/py/samples/google-genai-vertexai-image/README.md @@ -9,12 +9,20 @@ Prerequisites: * A Google Cloud account with access to VertexAI service. * The `genkit` package. -To run this sample: +## Setup environment 1. Install the `genkit` package. -2. Install [GCP CLI](https://cloud.google.com/sdk/docs/install) -3. Put your GCP project and location in the code to run VertexAI there. -4. Run the sample. +2. Install [GCP CLI](https://cloud.google.com/sdk/docs/install). +3. Add your project to Google Cloud. Run the following code to log in and set up the configuration. +```bash +export GOOGLE_CLOUD_LOCATION=global +export GOOGLE_CLOUD_PROJECT=your-GCP-project-ID +gcloud init +``` +4. Run the following code to connect to VertexAI. +```bash +gcloud auth application-default login +``` ## Run the sample diff --git a/py/samples/google-genai-vertexai-image/pyproject.toml b/py/samples/google-genai-vertexai-image/pyproject.toml index 232c40b48b..37a8173728 100644 --- a/py/samples/google-genai-vertexai-image/pyproject.toml +++ b/py/samples/google-genai-vertexai-image/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -40,7 +39,7 @@ dependencies = [ "pydantic>=2.10.5", ] description = "Image Generation on VertexAI with GenAI library example" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "google-genai-vertexai-image" readme = "README.md" requires-python = ">=3.10" diff --git a/py/samples/menu/pyproject.toml b/py/samples/menu/pyproject.toml index 1ec32f5de9..7ba7975d49 100644 --- a/py/samples/menu/pyproject.toml +++ b/py/samples/menu/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -44,7 +43,7 @@ dependencies = [ "pydantic>=2.10.5", ] description = "menu Genkit sample" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "menu" readme = "README.md" requires-python = ">=3.10" diff --git a/py/samples/model-garden/pyproject.toml b/py/samples/model-garden/pyproject.toml index ed72ce87b5..98dc6490f0 100644 --- a/py/samples/model-garden/pyproject.toml +++ b/py/samples/model-garden/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", @@ -33,7 +32,7 @@ classifiers = [ ] dependencies = ["genkit", "genkit-plugin-vertex-ai", "pydantic>=2.10.5"] description = "Model Garden sample" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "model-garden-example" readme = "README.md" requires-python = ">=3.10" diff --git a/py/samples/multi-server/pyproject.toml b/py/samples/multi-server/pyproject.toml index d6718b4d54..7163fd5d6d 100644 --- a/py/samples/multi-server/pyproject.toml +++ b/py/samples/multi-server/pyproject.toml @@ -15,13 +15,13 @@ # SPDX-License-Identifier: Apache-2.0 [project] +authors = [{ name = "Google" }] classifiers = [ "Development Status :: 3 - Alpha", "Environment :: Console", "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -42,6 +42,7 @@ dependencies = [ "uvicorn>=0.34.0", ] description = "Sample implementation to exercise the Genkit multi server manager." +license = "Apache-2.0" name = "multi-server" readme = "README.md" requires-python = ">=3.10" diff --git a/py/samples/ollama-hello/pyproject.toml b/py/samples/ollama-hello/pyproject.toml index 57a92ec67c..1a6fc009b3 100644 --- a/py/samples/ollama-hello/pyproject.toml +++ b/py/samples/ollama-hello/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -40,7 +39,7 @@ dependencies = [ "structlog>=25.2.0", ] description = "Ollama hello sample" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "ollama-hello" readme = "README.md" requires-python = ">=3.10" diff --git a/py/samples/ollama-simple-embed/pyproject.toml b/py/samples/ollama-simple-embed/pyproject.toml index 28e8ee8d28..d48e850e20 100644 --- a/py/samples/ollama-simple-embed/pyproject.toml +++ b/py/samples/ollama-simple-embed/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -40,7 +39,7 @@ dependencies = [ "structlog>=25.2.0", ] description = "Ollama Simple Embed" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "ollama_simple_embed" readme = "README.md" requires-python = ">=3.10" diff --git a/py/samples/prompt_demo/pyproject.toml b/py/samples/prompt_demo/pyproject.toml index 37ef4a2eda..e5b36a1a72 100644 --- a/py/samples/prompt_demo/pyproject.toml +++ b/py/samples/prompt_demo/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -35,7 +34,7 @@ classifiers = [ ] dependencies = ["genkit", "structlog>=25.2.0", "genkit-plugin-google-genai"] description = "Genkit prompt demo" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "prompt-demo" requires-python = ">=3.10" version = "0.0.1" diff --git a/py/samples/short-n-long/pyproject.toml b/py/samples/short-n-long/pyproject.toml index fa46fd5242..1a6dace235 100644 --- a/py/samples/short-n-long/pyproject.toml +++ b/py/samples/short-n-long/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -43,7 +42,7 @@ dependencies = [ "uvloop>=0.21.0", ] description = "Short and long sample" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "short-n-long" readme = "README.md" requires-python = ">=3.10" diff --git a/py/samples/tool-interrupts/pyproject.toml b/py/samples/tool-interrupts/pyproject.toml index c4e5e57bbc..20391edb09 100644 --- a/py/samples/tool-interrupts/pyproject.toml +++ b/py/samples/tool-interrupts/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -35,7 +34,7 @@ classifiers = [ ] dependencies = ["genkit", "genkit-plugin-google-genai", "pydantic>=2.10.5"] description = "Tool interrupts sample" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "tool-interrupts" readme = "README.md" requires-python = ">=3.10" diff --git a/py/samples/vertex-ai-vector-search-bigquery/pyproject.toml b/py/samples/vertex-ai-vector-search-bigquery/pyproject.toml index 5330a5705b..9b15b76310 100644 --- a/py/samples/vertex-ai-vector-search-bigquery/pyproject.toml +++ b/py/samples/vertex-ai-vector-search-bigquery/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -43,7 +42,7 @@ dependencies = [ "strenum>=0.4.15; python_version < '3.11'", ] description = "An example demonstrating the use Vector Search API with BigQuery retriever for Vertex AI" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "vertex-ai-vector-search-bigquery" readme = "README.md" requires-python = ">=3.10" diff --git a/py/samples/vertex-ai-vector-search-firestore/pyproject.toml b/py/samples/vertex-ai-vector-search-firestore/pyproject.toml index 6ff3f349fd..99fd0c758d 100644 --- a/py/samples/vertex-ai-vector-search-firestore/pyproject.toml +++ b/py/samples/vertex-ai-vector-search-firestore/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -43,7 +42,7 @@ dependencies = [ "strenum>=0.4.15; python_version < '3.11'", ] description = "An example demonstrating the use Vector Search API with Firestore retriever for Vertex AI" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "vertex-ai-vector-search-firestore" readme = "README.md" requires-python = ">=3.10" diff --git a/py/samples/xai-hello/pyproject.toml b/py/samples/xai-hello/pyproject.toml index 65b7aff42b..5fa7f84d4b 100644 --- a/py/samples/xai-hello/pyproject.toml +++ b/py/samples/xai-hello/pyproject.toml @@ -15,6 +15,7 @@ # SPDX-License-Identifier: Apache-2.0 [project] +authors = [{ name = "Google" }] dependencies = [ "genkit", "genkit-plugin-xai", diff --git a/py/tests/smoke/pyproject.toml b/py/tests/smoke/pyproject.toml index 4605d56264..9e31d9de6c 100644 --- a/py/tests/smoke/pyproject.toml +++ b/py/tests/smoke/pyproject.toml @@ -15,13 +15,13 @@ # SPDX-License-Identifier: Apache-2.0 [project] +authors = [{ name = "Google" }] classifiers = [ "Development Status :: 3 - Alpha", "Environment :: Console", "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", - "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -42,7 +42,7 @@ dependencies = [ "strenum>=0.4.15; python_version < '3.11'", ] description = "Packaging smoke test" -license = { text = "Apache-2.0" } +license = "Apache-2.0" name = "smoke" readme = "README.md" requires-python = ">=3.10" diff --git a/py/uv.lock b/py/uv.lock index 51bc49f894..cca42bd6bf 100644 --- a/py/uv.lock +++ b/py/uv.lock @@ -12,6 +12,7 @@ resolution-markers = [ members = [ "anthropic-hello", "compat-oai-hello", + "deepseek-hello", "dev-local-vectorstore-hello", "eval-demo", "firestore-retreiver", @@ -19,6 +20,7 @@ members = [ "genkit", "genkit-plugin-anthropic", "genkit-plugin-compat-oai", + "genkit-plugin-deepseek", "genkit-plugin-dev-local-vectorstore", "genkit-plugin-evaluators", "genkit-plugin-firebase", @@ -939,6 +941,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] +[[package]] +name = "deepseek-hello" +version = "0.1.0" +source = { editable = "samples/deepseek-hello" } +dependencies = [ + { name = "genkit" }, + { name = "genkit-plugin-deepseek" }, + { name = "pydantic" }, + { name = "structlog" }, +] + +[package.metadata] +requires-dist = [ + { name = "genkit", editable = "packages/genkit" }, + { name = "genkit-plugin-deepseek", editable = "plugins/deepseek" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "structlog", specifier = ">=24.0.0" }, +] + [[package]] name = "defusedxml" version = "0.7.1" @@ -1626,6 +1647,23 @@ requires-dist = [ { name = "strenum", marker = "python_full_version < '3.11'", specifier = ">=0.4.15" }, ] +[[package]] +name = "genkit-plugin-deepseek" +version = "0.1.0" +source = { editable = "plugins/deepseek" } +dependencies = [ + { name = "genkit" }, + { name = "genkit-plugin-compat-oai" }, + { name = "openai" }, +] + +[package.metadata] +requires-dist = [ + { name = "genkit", editable = "packages/genkit" }, + { name = "genkit-plugin-compat-oai", editable = "plugins/compat-oai" }, + { name = "openai", specifier = ">=1.0.0" }, +] + [[package]] name = "genkit-plugin-dev-local-vectorstore" version = "0.4.0" @@ -1824,6 +1862,7 @@ dependencies = [ { name = "genkit" }, { name = "genkit-plugin-anthropic" }, { name = "genkit-plugin-compat-oai" }, + { name = "genkit-plugin-deepseek" }, { name = "genkit-plugin-dev-local-vectorstore" }, { name = "genkit-plugin-evaluators" }, { name = "genkit-plugin-firebase" }, @@ -1859,8 +1898,8 @@ dev = [ { name = "twine" }, ] lint = [ - { name = "mypy" }, { name = "ruff" }, + { name = "ty" }, ] [package.metadata] @@ -1869,6 +1908,7 @@ requires-dist = [ { name = "genkit", editable = "packages/genkit" }, { name = "genkit-plugin-anthropic", editable = "plugins/anthropic" }, { name = "genkit-plugin-compat-oai", editable = "plugins/compat-oai" }, + { name = "genkit-plugin-deepseek", editable = "plugins/deepseek" }, { name = "genkit-plugin-dev-local-vectorstore", editable = "plugins/dev-local-vectorstore" }, { name = "genkit-plugin-evaluators", editable = "plugins/evaluators" }, { name = "genkit-plugin-firebase", editable = "plugins/firebase" }, @@ -1904,8 +1944,8 @@ dev = [ { name = "twine", specifier = ">=6.1.0" }, ] lint = [ - { name = "mypy", specifier = ">=1.15" }, { name = "ruff", specifier = ">=0.9" }, + { name = "ty", specifier = ">=0.0.1" }, ] [[package]] @@ -3562,45 +3602,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/d1/3598d1e73385baaab427392856f915487db7aa10abadd436f8f2d3e3b0f9/multipart-1.2.1-py3-none-any.whl", hash = "sha256:c03dc203bc2e67f6b46a599467ae0d87cf71d7530504b2c1ff4a9ea21d8b8c8c", size = 13730, upload-time = "2024-11-29T08:45:44.557Z" }, ] -[[package]] -name = "mypy" -version = "1.16.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mypy-extensions" }, - { name = "pathspec" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d4/38/13c2f1abae94d5ea0354e146b95a1be9b2137a0d506728e0da037c4276f6/mypy-1.16.0.tar.gz", hash = "sha256:84b94283f817e2aa6350a14b4a8fb2a35a53c286f97c9d30f53b63620e7af8ab", size = 3323139, upload-time = "2025-05-29T13:46:12.532Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/5e/a0485f0608a3d67029d3d73cec209278b025e3493a3acfda3ef3a88540fd/mypy-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7909541fef256527e5ee9c0a7e2aeed78b6cda72ba44298d1334fe7881b05c5c", size = 10967416, upload-time = "2025-05-29T13:34:17.783Z" }, - { url = "https://files.pythonhosted.org/packages/4b/53/5837c221f74c0d53a4bfc3003296f8179c3a2a7f336d7de7bbafbe96b688/mypy-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e71d6f0090c2256c713ed3d52711d01859c82608b5d68d4fa01a3fe30df95571", size = 10087654, upload-time = "2025-05-29T13:32:37.878Z" }, - { url = "https://files.pythonhosted.org/packages/29/59/5fd2400352c3093bed4c09017fe671d26bc5bb7e6ef2d4bf85f2a2488104/mypy-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:936ccfdd749af4766be824268bfe22d1db9eb2f34a3ea1d00ffbe5b5265f5491", size = 11875192, upload-time = "2025-05-29T13:34:54.281Z" }, - { url = "https://files.pythonhosted.org/packages/ad/3e/4bfec74663a64c2012f3e278dbc29ffe82b121bc551758590d1b6449ec0c/mypy-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4086883a73166631307fdd330c4a9080ce24913d4f4c5ec596c601b3a4bdd777", size = 12612939, upload-time = "2025-05-29T13:33:14.766Z" }, - { url = "https://files.pythonhosted.org/packages/88/1f/fecbe3dcba4bf2ca34c26ca016383a9676711907f8db4da8354925cbb08f/mypy-1.16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:feec38097f71797da0231997e0de3a58108c51845399669ebc532c815f93866b", size = 12874719, upload-time = "2025-05-29T13:21:52.09Z" }, - { url = "https://files.pythonhosted.org/packages/f3/51/c2d280601cd816c43dfa512a759270d5a5ef638d7ac9bea9134c8305a12f/mypy-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:09a8da6a0ee9a9770b8ff61b39c0bb07971cda90e7297f4213741b48a0cc8d93", size = 9487053, upload-time = "2025-05-29T13:33:29.797Z" }, - { url = "https://files.pythonhosted.org/packages/24/c4/ff2f79db7075c274fe85b5fff8797d29c6b61b8854c39e3b7feb556aa377/mypy-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9f826aaa7ff8443bac6a494cf743f591488ea940dd360e7dd330e30dd772a5ab", size = 10884498, upload-time = "2025-05-29T13:18:54.066Z" }, - { url = "https://files.pythonhosted.org/packages/02/07/12198e83006235f10f6a7808917376b5d6240a2fd5dce740fe5d2ebf3247/mypy-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:82d056e6faa508501af333a6af192c700b33e15865bda49611e3d7d8358ebea2", size = 10011755, upload-time = "2025-05-29T13:34:00.851Z" }, - { url = "https://files.pythonhosted.org/packages/f1/9b/5fd5801a72b5d6fb6ec0105ea1d0e01ab2d4971893076e558d4b6d6b5f80/mypy-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:089bedc02307c2548eb51f426e085546db1fa7dd87fbb7c9fa561575cf6eb1ff", size = 11800138, upload-time = "2025-05-29T13:32:55.082Z" }, - { url = "https://files.pythonhosted.org/packages/2e/81/a117441ea5dfc3746431e51d78a4aca569c677aa225bca2cc05a7c239b61/mypy-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6a2322896003ba66bbd1318c10d3afdfe24e78ef12ea10e2acd985e9d684a666", size = 12533156, upload-time = "2025-05-29T13:19:12.963Z" }, - { url = "https://files.pythonhosted.org/packages/3f/38/88ec57c6c86014d3f06251e00f397b5a7daa6888884d0abf187e4f5f587f/mypy-1.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:021a68568082c5b36e977d54e8f1de978baf401a33884ffcea09bd8e88a98f4c", size = 12742426, upload-time = "2025-05-29T13:20:22.72Z" }, - { url = "https://files.pythonhosted.org/packages/bd/53/7e9d528433d56e6f6f77ccf24af6ce570986c2d98a5839e4c2009ef47283/mypy-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:54066fed302d83bf5128632d05b4ec68412e1f03ef2c300434057d66866cea4b", size = 9478319, upload-time = "2025-05-29T13:21:17.582Z" }, - { url = "https://files.pythonhosted.org/packages/70/cf/158e5055e60ca2be23aec54a3010f89dcffd788732634b344fc9cb1e85a0/mypy-1.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c5436d11e89a3ad16ce8afe752f0f373ae9620841c50883dc96f8b8805620b13", size = 11062927, upload-time = "2025-05-29T13:35:52.328Z" }, - { url = "https://files.pythonhosted.org/packages/94/34/cfff7a56be1609f5d10ef386342ce3494158e4d506516890142007e6472c/mypy-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f2622af30bf01d8fc36466231bdd203d120d7a599a6d88fb22bdcb9dbff84090", size = 10083082, upload-time = "2025-05-29T13:35:33.378Z" }, - { url = "https://files.pythonhosted.org/packages/b3/7f/7242062ec6288c33d8ad89574df87c3903d394870e5e6ba1699317a65075/mypy-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d045d33c284e10a038f5e29faca055b90eee87da3fc63b8889085744ebabb5a1", size = 11828306, upload-time = "2025-05-29T13:21:02.164Z" }, - { url = "https://files.pythonhosted.org/packages/6f/5f/b392f7b4f659f5b619ce5994c5c43caab3d80df2296ae54fa888b3d17f5a/mypy-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4968f14f44c62e2ec4a038c8797a87315be8df7740dc3ee8d3bfe1c6bf5dba8", size = 12702764, upload-time = "2025-05-29T13:20:42.826Z" }, - { url = "https://files.pythonhosted.org/packages/9b/c0/7646ef3a00fa39ac9bc0938626d9ff29d19d733011be929cfea59d82d136/mypy-1.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eb14a4a871bb8efb1e4a50360d4e3c8d6c601e7a31028a2c79f9bb659b63d730", size = 12896233, upload-time = "2025-05-29T13:18:37.446Z" }, - { url = "https://files.pythonhosted.org/packages/6d/38/52f4b808b3fef7f0ef840ee8ff6ce5b5d77381e65425758d515cdd4f5bb5/mypy-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:bd4e1ebe126152a7bbaa4daedd781c90c8f9643c79b9748caa270ad542f12bec", size = 9565547, upload-time = "2025-05-29T13:20:02.836Z" }, - { url = "https://files.pythonhosted.org/packages/97/9c/ca03bdbefbaa03b264b9318a98950a9c683e06472226b55472f96ebbc53d/mypy-1.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a9e056237c89f1587a3be1a3a70a06a698d25e2479b9a2f57325ddaaffc3567b", size = 11059753, upload-time = "2025-05-29T13:18:18.167Z" }, - { url = "https://files.pythonhosted.org/packages/36/92/79a969b8302cfe316027c88f7dc6fee70129490a370b3f6eb11d777749d0/mypy-1.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b07e107affb9ee6ce1f342c07f51552d126c32cd62955f59a7db94a51ad12c0", size = 10073338, upload-time = "2025-05-29T13:19:48.079Z" }, - { url = "https://files.pythonhosted.org/packages/14/9b/a943f09319167da0552d5cd722104096a9c99270719b1afeea60d11610aa/mypy-1.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6fb60cbd85dc65d4d63d37cb5c86f4e3a301ec605f606ae3a9173e5cf34997b", size = 11827764, upload-time = "2025-05-29T13:46:04.47Z" }, - { url = "https://files.pythonhosted.org/packages/ec/64/ff75e71c65a0cb6ee737287c7913ea155845a556c64144c65b811afdb9c7/mypy-1.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7e32297a437cc915599e0578fa6bc68ae6a8dc059c9e009c628e1c47f91495d", size = 12701356, upload-time = "2025-05-29T13:35:13.553Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ad/0e93c18987a1182c350f7a5fab70550852f9fabe30ecb63bfbe51b602074/mypy-1.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:afe420c9380ccec31e744e8baff0d406c846683681025db3531b32db56962d52", size = 12900745, upload-time = "2025-05-29T13:17:24.409Z" }, - { url = "https://files.pythonhosted.org/packages/28/5d/036c278d7a013e97e33f08c047fe5583ab4f1fc47c9a49f985f1cdd2a2d7/mypy-1.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:55f9076c6ce55dd3f8cd0c6fff26a008ca8e5131b89d5ba6d86bd3f47e736eeb", size = 9572200, upload-time = "2025-05-29T13:33:44.92Z" }, - { url = "https://files.pythonhosted.org/packages/99/a3/6ed10530dec8e0fdc890d81361260c9ef1f5e5c217ad8c9b21ecb2b8366b/mypy-1.16.0-py3-none-any.whl", hash = "sha256:29e1499864a3888bca5c1542f2d7232c6e586295183320caa95758fc84034031", size = 2265773, upload-time = "2025-05-29T13:35:18.762Z" }, -] - [[package]] name = "mypy-extensions" version = "1.1.0" @@ -5565,6 +5566,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7c/b6/74e927715a285743351233f33ea3c684528a0d374d2e43ff9ce9585b73fe/twine-6.1.0-py3-none-any.whl", hash = "sha256:a47f973caf122930bf0fbbf17f80b83bc1602c9ce393c7845f289a3001dc5384", size = 40791, upload-time = "2025-01-21T18:45:24.584Z" }, ] +[[package]] +name = "ty" +version = "0.0.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/45/5ae578480168d4b3c08cf8e5eac3caf8eb7acdb1a06a9bed7519564bd9b4/ty-0.0.11.tar.gz", hash = "sha256:ebcbc7d646847cb6610de1da4ffc849d8b800e29fd1e9ebb81ba8f3fbac88c25", size = 4920340, upload-time = "2026-01-09T21:06:01.592Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/34/b1d05cdcd01589a8d2e63011e0a1e24dcefdc2a09d024fee3e27755963f6/ty-0.0.11-py3-none-linux_armv6l.whl", hash = "sha256:68f0b8d07b0a2ea7ec63a08ba2624f853e4f9fa1a06fce47fb453fa279dead5a", size = 9521748, upload-time = "2026-01-09T21:06:13.221Z" }, + { url = "https://files.pythonhosted.org/packages/43/21/f52d93f4b3784b91bfbcabd01b84dc82128f3a9de178536bcf82968f3367/ty-0.0.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cbf82d7ef0618e9ae3cc3c37c33abcfa302c9b3e3b8ff11d71076f98481cb1a8", size = 9454903, upload-time = "2026-01-09T21:06:42.363Z" }, + { url = "https://files.pythonhosted.org/packages/ad/01/3a563dba8b1255e474c35e1c3810b7589e81ae8c41df401b6a37c8e2cde9/ty-0.0.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:121987c906e02264c3b511b95cb9f8a3cdd66f3283b8bbab678ca3525652e304", size = 8823417, upload-time = "2026-01-09T21:06:26.315Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b1/99b87222c05d3a28fb7bbfb85df4efdde8cb6764a24c1b138f3a615283dd/ty-0.0.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:999390b6cc045fe5e1b3da1c2c9ae8e8c0def23b69455e7c9191ba9ffd747023", size = 9290785, upload-time = "2026-01-09T21:05:59.028Z" }, + { url = "https://files.pythonhosted.org/packages/3d/9f/598809a8fff2194f907ba6de07ac3d7b7788342592d8f8b98b1b50c2fb49/ty-0.0.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed504d78eb613c49be3c848f236b345b6c13dc6bcfc4b202790a60a97e1d8f35", size = 9359392, upload-time = "2026-01-09T21:06:37.459Z" }, + { url = "https://files.pythonhosted.org/packages/71/3e/aeea2a97b38f3dcd9f8224bf83609848efa4bc2f484085508165567daa7b/ty-0.0.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7fedc8b43cc8a9991e0034dd205f957a8380dd29bfce36f2a35b5d321636dfd9", size = 9852973, upload-time = "2026-01-09T21:06:21.245Z" }, + { url = "https://files.pythonhosted.org/packages/72/40/86173116995e38f954811a86339ac4c00a2d8058cc245d3e4903bc4a132c/ty-0.0.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:0808bdfb7efe09881bf70249b85b0498fb8b75fbb036ce251c496c20adb10075", size = 10796113, upload-time = "2026-01-09T21:06:16.034Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/97c92c401dacae9baa3696163ebe8371635ebf34ba9fda781110d0124857/ty-0.0.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:07185b3e38b18c562056dfbc35fb51d866f872977ea1ebcd64ca24a001b5b4f1", size = 10432137, upload-time = "2026-01-09T21:06:07.498Z" }, + { url = "https://files.pythonhosted.org/packages/18/10/9ab43f3cfc5f7792f6bc97620f54d0a0a81ef700be84ea7f6be330936a99/ty-0.0.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5c72f1ada8eb5be984502a600f71d1a3099e12fb6f3c0607aaba2f86f0e9d80", size = 10240520, upload-time = "2026-01-09T21:06:34.823Z" }, + { url = "https://files.pythonhosted.org/packages/74/18/8dd4fe6df1fd66f3e83b4798eddb1d8482d9d9b105f25099b76703402ebb/ty-0.0.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:25f88e8789072830348cb59b761d5ced70642ed5600673b4bf6a849af71eca8b", size = 9973340, upload-time = "2026-01-09T21:06:39.657Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0b/fb2301450cf8f2d7164944d6e1e659cac9ec7021556cc173d54947cf8ef4/ty-0.0.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f370e1047a62dcedcd06e2b27e1f0b16c7f8ea2361d9070fcbf0d0d69baaa192", size = 9262101, upload-time = "2026-01-09T21:06:28.989Z" }, + { url = "https://files.pythonhosted.org/packages/f7/8c/d6374af023541072dee1c8bcfe8242669363a670b7619e6fffcc7415a995/ty-0.0.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:52be34047ed6177bfcef9247459a767ec03d775714855e262bca1fb015895e8a", size = 9382756, upload-time = "2026-01-09T21:06:24.097Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/edd1e63ffa8d49d720c475c2c1c779084e5efe50493afdc261938705d10a/ty-0.0.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b9e5762ccb3778779378020b8d78f936b3f52ea83f18785319cceba3ae85d8e6", size = 9553944, upload-time = "2026-01-09T21:06:18.426Z" }, + { url = "https://files.pythonhosted.org/packages/35/cd/4afdb0d182d23d07ff287740c4954cc6dde5c3aed150ec3f2a1d72b00f71/ty-0.0.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e9334646ee3095e778e3dbc45fdb2bddfc16acc7804283830ad84991ece16dd7", size = 10060365, upload-time = "2026-01-09T21:06:45.083Z" }, + { url = "https://files.pythonhosted.org/packages/d1/94/a009ad9d8b359933cfea8721c689c0331189be28650d74dcc6add4d5bb09/ty-0.0.11-py3-none-win32.whl", hash = "sha256:44cfb7bb2d6784bd7ffe7b5d9ea90851d9c4723729c50b5f0732d4b9a2013cfc", size = 9040448, upload-time = "2026-01-09T21:06:32.241Z" }, + { url = "https://files.pythonhosted.org/packages/df/04/5a5dfd0aec0ea99ead1e824ee6e347fb623c464da7886aa1e3660fb0f36c/ty-0.0.11-py3-none-win_amd64.whl", hash = "sha256:1bb205db92715d4a13343bfd5b0c59ce8c0ca0daa34fb220ec9120fc66ccbda7", size = 9780112, upload-time = "2026-01-09T21:06:04.69Z" }, + { url = "https://files.pythonhosted.org/packages/ad/07/47d4fccd7bcf5eea1c634d518d6cb233f535a85d0b63fcd66815759e2fa0/ty-0.0.11-py3-none-win_arm64.whl", hash = "sha256:4688bd87b2dc5c85da277bda78daba14af2e66f3dda4d98f3604e3de75519eba", size = 9194038, upload-time = "2026-01-09T21:06:10.152Z" }, +] + [[package]] name = "typeguard" version = "4.4.2"