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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions ai/src/main/java/com/google/genkit/ai/Model.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import com.google.genkit.core.GenkitException;
import com.google.genkit.core.JsonUtils;
import com.google.genkit.core.Registry;
import com.google.genkit.core.SchemaUtils;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Consumer;
Expand Down Expand Up @@ -92,6 +93,8 @@ default ActionDesc getDesc() {
return ActionDesc.builder()
.type(ActionType.MODEL)
.name(getName())
.inputSchema(getInputSchema())
.outputSchema(getOutputSchema())
.metadata(getMetadata())
.build();
}
Expand All @@ -117,12 +120,15 @@ default ActionRunResult<JsonNode> runJsonWithTelemetry(

@Override
default Map<String, Object> getInputSchema() {
return null;
// The model action accepts a ModelRequest (messages, config, tools, output, ...). Exposing its
// schema lets the Dev UI and generic action runners understand the model's input shape.
return SchemaUtils.inferSchema(ModelRequest.class);
}

@Override
default Map<String, Object> getOutputSchema() {
return null;
// The model action returns a ModelResponse (message, finishReason, usage, ...).
return SchemaUtils.inferSchema(ModelResponse.class);
}

@Override
Expand Down
17 changes: 17 additions & 0 deletions ai/src/main/java/com/google/genkit/ai/ModelInfo.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
import java.util.Map;
import java.util.Set;

/** ModelInfo contains metadata about a model's capabilities. */
Expand All @@ -36,6 +37,14 @@ public class ModelInfo {
@JsonProperty("versions")
private List<String> versions;

/**
* JSON Schema describing the model's custom generation options (e.g. temperature, topP,
* maxOutputTokens). Surfaced to the Dev UI as {@code metadata.model.customOptions} so the model
* playground can render configuration inputs.
*/
@JsonProperty("customOptions")
private Map<String, Object> customOptions;

/** Default constructor. */
public ModelInfo() {}

Expand All @@ -49,6 +58,14 @@ public void setLabel(String label) {
this.label = label;
}

public Map<String, Object> getCustomOptions() {
return customOptions;
}

public void setCustomOptions(Map<String, Object> customOptions) {
this.customOptions = customOptions;
}

public ModelCapabilities getSupports() {
return supports;
}
Expand Down
54 changes: 49 additions & 5 deletions ai/src/main/java/com/google/genkit/ai/Prompt.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import com.google.genkit.core.GenkitException;
import com.google.genkit.core.JsonUtils;
import com.google.genkit.core.Registry;
import com.google.genkit.core.SchemaUtils;
import java.util.HashMap;
import java.util.Map;
import java.util.function.BiFunction;
Expand All @@ -47,6 +48,7 @@ public class Prompt<I> implements Action<I, ModelRequest, Void> {
private final String model;
private final String template;
private final Map<String, Object> inputSchema;
private final Map<String, Object> outputSchema;
private final GenerationConfig config;
private final BiFunction<ActionContext, I, ModelRequest> renderer;
private final Map<String, Object> metadata;
Expand All @@ -73,11 +75,43 @@ public Prompt(
GenerationConfig config,
Class<I> inputClass,
BiFunction<ActionContext, I, ModelRequest> renderer) {
this(name, variant, model, template, inputSchema, null, config, inputClass, renderer);
}

/**
* Creates a new Prompt.
*
* @param name the prompt name
* @param variant the prompt variant
* @param model the default model name
* @param template the prompt template
* @param inputSchema the input JSON schema (if null, inferred from {@code inputClass})
* @param outputSchema the output JSON schema
* @param config the default generation config
* @param inputClass the input class for JSON deserialization and schema inference
* @param renderer the function that renders the prompt
*/
public Prompt(
String name,
String variant,
String model,
String template,
Map<String, Object> inputSchema,
Map<String, Object> outputSchema,
GenerationConfig config,
Class<I> inputClass,
BiFunction<ActionContext, I, ModelRequest> renderer) {
this.name = name;
this.variant = variant;
this.model = model;
this.template = template;
this.inputSchema = inputSchema;
// Prefer an explicit input schema (e.g. parsed from .prompt frontmatter); otherwise infer it
// from the Java input class so the Dev UI prompt runner can render an input form.
this.inputSchema =
inputSchema != null
? inputSchema
: (inputClass != null ? SchemaUtils.inferSchema(inputClass) : null);
this.outputSchema = outputSchema;
this.config = config;
this.inputClass = inputClass;
this.renderer = renderer;
Expand All @@ -103,8 +137,11 @@ public Prompt(
}
promptMetadata.put("model", model);
promptMetadata.put("template", template);
if (inputSchema != null) {
promptMetadata.put("input", Map.of("schema", inputSchema));
if (this.inputSchema != null) {
promptMetadata.put("input", Map.of("schema", this.inputSchema));
}
if (this.outputSchema != null) {
promptMetadata.put("output", Map.of("schema", this.outputSchema));
}
if (config != null) {
promptMetadata.put("config", config);
Expand Down Expand Up @@ -147,6 +184,7 @@ public ActionDesc getDesc() {
.type(ActionType.EXECUTABLE_PROMPT)
.name(name)
.inputSchema(inputSchema)
.outputSchema(outputSchema)
.metadata(metadata)
.build();
}
Expand Down Expand Up @@ -189,7 +227,7 @@ public Map<String, Object> getInputSchema() {

@Override
public Map<String, Object> getOutputSchema() {
return null;
return outputSchema;
}

@Override
Expand Down Expand Up @@ -240,6 +278,7 @@ public static class Builder<I> {
private String model;
private String template;
private Map<String, Object> inputSchema;
private Map<String, Object> outputSchema;
private GenerationConfig config;
private Class<I> inputClass;
private BiFunction<ActionContext, I, ModelRequest> renderer;
Expand Down Expand Up @@ -269,6 +308,11 @@ public Builder<I> inputSchema(Map<String, Object> inputSchema) {
return this;
}

public Builder<I> outputSchema(Map<String, Object> outputSchema) {
this.outputSchema = outputSchema;
return this;
}

public Builder<I> config(GenerationConfig config) {
this.config = config;
return this;
Expand All @@ -292,7 +336,7 @@ public Prompt<I> build() {
throw new IllegalStateException("Prompt renderer is required");
}
return new Prompt<>(
name, variant, model, template, inputSchema, config, inputClass, renderer);
name, variant, model, template, inputSchema, outputSchema, config, inputClass, renderer);
}
}
}
65 changes: 65 additions & 0 deletions ai/src/test/java/com/google/genkit/ai/PromptTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,69 @@ void testPromptVariantMismatch() {
assertEquals("recipe", promptMetadata.get("name"));
assertEquals("robot", promptMetadata.get("variant"));
}

/** Sample input POJO used to verify schema inference from the Java input class. */
static class ReviewInput {
public String code;
public String language;
}

@Test
@SuppressWarnings("unchecked")
void testInputSchemaInferredFromInputClass() {
// Regression test for #184: when no explicit input schema is given, one is inferred from the
// Java input class so the Dev UI can render an input box.
Prompt<ReviewInput> prompt =
Prompt.<ReviewInput>builder()
.name("review")
.model("openai/gpt-4o")
.template("Review {{code}}")
.inputClass(ReviewInput.class)
.renderer((ctx, input) -> ModelRequest.builder().addUserMessage(input.code).build())
.build();

Map<String, Object> inputSchema = prompt.getInputSchema();
assertNotNull(inputSchema, "input schema should be inferred from the input class");
Map<String, Object> properties = (Map<String, Object>) inputSchema.get("properties");
assertTrue(properties.containsKey("code"));
assertTrue(properties.containsKey("language"));
assertNotNull(prompt.getDesc().getInputSchema());

// The inferred schema is also surfaced in metadata.prompt.input.schema.
Map<String, Object> promptMetadata = (Map<String, Object>) prompt.getMetadata().get("prompt");
assertNotNull(((Map<String, Object>) promptMetadata.get("input")).get("schema"));
}

@Test
void testExplicitInputSchemaWinsOverInputClass() {
Map<String, Object> explicit = Map.of("type", "object", "properties", Map.of());
Prompt<ReviewInput> prompt =
Prompt.<ReviewInput>builder()
.name("review")
.template("x")
.inputSchema(explicit)
.inputClass(ReviewInput.class)
.renderer((ctx, input) -> ModelRequest.builder().addUserMessage("x").build())
.build();

assertSame(explicit, prompt.getInputSchema());
}

@Test
@SuppressWarnings("unchecked")
void testOutputSchemaPropagated() {
Map<String, Object> output = Map.of("type", "object", "properties", Map.of());
Prompt<String> prompt =
Prompt.<String>builder()
.name("p")
.template("x")
.outputSchema(output)
.renderer((ctx, input) -> ModelRequest.builder().addUserMessage("x").build())
.build();

assertSame(output, prompt.getOutputSchema());
assertSame(output, prompt.getDesc().getOutputSchema());
Map<String, Object> promptMetadata = (Map<String, Object>) prompt.getMetadata().get("prompt");
assertNotNull(((Map<String, Object>) promptMetadata.get("output")).get("schema"));
}
}
21 changes: 21 additions & 0 deletions core/src/main/java/com/google/genkit/core/SchemaUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,27 @@ public final class SchemaUtils {
JacksonModule jacksonModule = new JacksonModule(JacksonOption.RESPECT_JSONPROPERTY_REQUIRED);
configBuilder.with(jacksonModule);

// Mark non-optional fields as "required" (mirrors Zod/JS behavior in the JS/Go SDKs). This is
// what lets the Dev UI build a default input skeleton: its flow/prompt runner only pre-fills
// properties that appear in the schema's "required" array. A field is treated as optional only
// when it is an Optional<> or annotated with a @Nullable annotation.
configBuilder
.forFields()
.withRequiredCheck(
field -> {
if (field.getType() != null
&& field.getType().getErasedType() == java.util.Optional.class) {
return false;
}
for (java.lang.annotation.Annotation annotation :
field.getRawMember().getAnnotations()) {
if ("Nullable".equals(annotation.annotationType().getSimpleName())) {
return false;
}
}
return true;
Comment thread
xavidop marked this conversation as resolved.
});

SchemaGeneratorConfig config = configBuilder.build();
schemaGenerator = new SchemaGenerator(config);
}
Expand Down
67 changes: 67 additions & 0 deletions core/src/test/java/com/google/genkit/core/SchemaUtilsTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* 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 com.google.genkit.core;

import static org.junit.jupiter.api.Assertions.*;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;

/** Unit tests for {@link SchemaUtils}. */
class SchemaUtilsTest {

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@interface Nullable {}

static class TripInput {
public String destination;
public int duration;
@Nullable public String notes;
}

@Test
@SuppressWarnings("unchecked")
void inferSchemaMarksNonOptionalFieldsRequired() {
// Regression test: the Dev UI only pre-fills a default input for properties listed in the
// schema's "required" array, so non-optional POJO fields must be marked required.
Map<String, Object> schema = SchemaUtils.inferSchema(TripInput.class);

assertEquals("object", schema.get("type"));
Map<String, Object> properties = (Map<String, Object>) schema.get("properties");
assertTrue(properties.containsKey("destination"));
assertTrue(properties.containsKey("duration"));

List<String> required = (List<String>) schema.get("required");
assertNotNull(required, "schema should declare a required array");
assertTrue(required.contains("destination"), "non-optional field should be required");
assertTrue(required.contains("duration"), "non-optional field should be required");
assertFalse(required.contains("notes"), "@Nullable field should not be required");
}

@Test
void inferSchemaReturnsNullForVoid() {
assertNull(SchemaUtils.inferSchema(Void.class));
}
}
3 changes: 1 addition & 2 deletions genkit/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,10 @@
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<!-- YAML parsing for the agent conformance spec (tests/specs/agent.yaml) -->
<!-- YAML parsing for .prompt frontmatter (DotPrompt) and the agent conformance spec test -->
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Loading
Loading