From ae93ebfdc079eaa087c8937f5268d8456d7aac43 Mon Sep 17 00:00:00 2001 From: xavidop Date: Sun, 5 Jul 2026 02:35:29 +0200 Subject: [PATCH 1/2] fix: devui issues --- .../main/java/com/google/genkit/ai/Model.java | 10 +- .../java/com/google/genkit/ai/ModelInfo.java | 17 ++ .../java/com/google/genkit/ai/Prompt.java | 54 ++++- .../java/com/google/genkit/ai/PromptTest.java | 65 ++++++ .../com/google/genkit/core/SchemaUtils.java | 21 ++ .../google/genkit/core/SchemaUtilsTest.java | 67 ++++++ genkit/pom.xml | 3 +- .../main/java/com/google/genkit/Genkit.java | 20 +- .../com/google/genkit/prompt/DotPrompt.java | 77 ++++++- .../com/google/genkit/prompt/Picoschema.java | 214 ++++++++++++++++++ .../com/google/genkit/GenerateSpanTest.java | 164 ++++++++++++++ .../google/genkit/prompt/DotPromptTest.java | 72 ++++++ .../google/genkit/prompt/PicoschemaTest.java | 151 ++++++++++++ .../plugins/anthropic/AnthropicModel.java | 5 + .../plugins/awsbedrock/AwsBedrockModel.java | 5 + .../plugins/compatoai/CompatOAIModel.java | 5 + .../plugins/compatoai/CompatOAIModelTest.java | 49 ++++ .../plugins/googlegenai/GeminiModel.java | 6 + .../genkit/plugins/ollama/OllamaModel.java | 5 + .../genkit/plugins/openai/OpenAIModel.java | 5 + samples/dotprompt/run.sh | 4 +- 21 files changed, 1002 insertions(+), 17 deletions(-) create mode 100644 core/src/test/java/com/google/genkit/core/SchemaUtilsTest.java create mode 100644 genkit/src/main/java/com/google/genkit/prompt/Picoschema.java create mode 100644 genkit/src/test/java/com/google/genkit/GenerateSpanTest.java create mode 100644 genkit/src/test/java/com/google/genkit/prompt/PicoschemaTest.java diff --git a/ai/src/main/java/com/google/genkit/ai/Model.java b/ai/src/main/java/com/google/genkit/ai/Model.java index 250b3711c..3ba02d6c8 100644 --- a/ai/src/main/java/com/google/genkit/ai/Model.java +++ b/ai/src/main/java/com/google/genkit/ai/Model.java @@ -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; @@ -92,6 +93,8 @@ default ActionDesc getDesc() { return ActionDesc.builder() .type(ActionType.MODEL) .name(getName()) + .inputSchema(getInputSchema()) + .outputSchema(getOutputSchema()) .metadata(getMetadata()) .build(); } @@ -117,12 +120,15 @@ default ActionRunResult runJsonWithTelemetry( @Override default Map 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 getOutputSchema() { - return null; + // The model action returns a ModelResponse (message, finishReason, usage, ...). + return SchemaUtils.inferSchema(ModelResponse.class); } @Override diff --git a/ai/src/main/java/com/google/genkit/ai/ModelInfo.java b/ai/src/main/java/com/google/genkit/ai/ModelInfo.java index cca2873ad..d6a51f2fa 100644 --- a/ai/src/main/java/com/google/genkit/ai/ModelInfo.java +++ b/ai/src/main/java/com/google/genkit/ai/ModelInfo.java @@ -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. */ @@ -36,6 +37,14 @@ public class ModelInfo { @JsonProperty("versions") private List 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 customOptions; + /** Default constructor. */ public ModelInfo() {} @@ -49,6 +58,14 @@ public void setLabel(String label) { this.label = label; } + public Map getCustomOptions() { + return customOptions; + } + + public void setCustomOptions(Map customOptions) { + this.customOptions = customOptions; + } + public ModelCapabilities getSupports() { return supports; } diff --git a/ai/src/main/java/com/google/genkit/ai/Prompt.java b/ai/src/main/java/com/google/genkit/ai/Prompt.java index f5655b366..e71b28311 100644 --- a/ai/src/main/java/com/google/genkit/ai/Prompt.java +++ b/ai/src/main/java/com/google/genkit/ai/Prompt.java @@ -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; @@ -47,6 +48,7 @@ public class Prompt implements Action { private final String model; private final String template; private final Map inputSchema; + private final Map outputSchema; private final GenerationConfig config; private final BiFunction renderer; private final Map metadata; @@ -73,11 +75,43 @@ public Prompt( GenerationConfig config, Class inputClass, BiFunction 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 inputSchema, + Map outputSchema, + GenerationConfig config, + Class inputClass, + BiFunction 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; @@ -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); @@ -147,6 +184,7 @@ public ActionDesc getDesc() { .type(ActionType.EXECUTABLE_PROMPT) .name(name) .inputSchema(inputSchema) + .outputSchema(outputSchema) .metadata(metadata) .build(); } @@ -189,7 +227,7 @@ public Map getInputSchema() { @Override public Map getOutputSchema() { - return null; + return outputSchema; } @Override @@ -240,6 +278,7 @@ public static class Builder { private String model; private String template; private Map inputSchema; + private Map outputSchema; private GenerationConfig config; private Class inputClass; private BiFunction renderer; @@ -269,6 +308,11 @@ public Builder inputSchema(Map inputSchema) { return this; } + public Builder outputSchema(Map outputSchema) { + this.outputSchema = outputSchema; + return this; + } + public Builder config(GenerationConfig config) { this.config = config; return this; @@ -292,7 +336,7 @@ public Prompt 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); } } } diff --git a/ai/src/test/java/com/google/genkit/ai/PromptTest.java b/ai/src/test/java/com/google/genkit/ai/PromptTest.java index e0585cdad..25c0f86ef 100644 --- a/ai/src/test/java/com/google/genkit/ai/PromptTest.java +++ b/ai/src/test/java/com/google/genkit/ai/PromptTest.java @@ -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 prompt = + Prompt.builder() + .name("review") + .model("openai/gpt-4o") + .template("Review {{code}}") + .inputClass(ReviewInput.class) + .renderer((ctx, input) -> ModelRequest.builder().addUserMessage(input.code).build()) + .build(); + + Map inputSchema = prompt.getInputSchema(); + assertNotNull(inputSchema, "input schema should be inferred from the input class"); + Map properties = (Map) 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 promptMetadata = (Map) prompt.getMetadata().get("prompt"); + assertNotNull(((Map) promptMetadata.get("input")).get("schema")); + } + + @Test + void testExplicitInputSchemaWinsOverInputClass() { + Map explicit = Map.of("type", "object", "properties", Map.of()); + Prompt prompt = + Prompt.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 output = Map.of("type", "object", "properties", Map.of()); + Prompt prompt = + Prompt.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 promptMetadata = (Map) prompt.getMetadata().get("prompt"); + assertNotNull(((Map) promptMetadata.get("output")).get("schema")); + } } diff --git a/core/src/main/java/com/google/genkit/core/SchemaUtils.java b/core/src/main/java/com/google/genkit/core/SchemaUtils.java index fa7724529..c01c2c061 100644 --- a/core/src/main/java/com/google/genkit/core/SchemaUtils.java +++ b/core/src/main/java/com/google/genkit/core/SchemaUtils.java @@ -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; + }); + SchemaGeneratorConfig config = configBuilder.build(); schemaGenerator = new SchemaGenerator(config); } diff --git a/core/src/test/java/com/google/genkit/core/SchemaUtilsTest.java b/core/src/test/java/com/google/genkit/core/SchemaUtilsTest.java new file mode 100644 index 000000000..bfcaf0030 --- /dev/null +++ b/core/src/test/java/com/google/genkit/core/SchemaUtilsTest.java @@ -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 schema = SchemaUtils.inferSchema(TripInput.class); + + assertEquals("object", schema.get("type")); + Map properties = (Map) schema.get("properties"); + assertTrue(properties.containsKey("destination")); + assertTrue(properties.containsKey("duration")); + + List required = (List) 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)); + } +} diff --git a/genkit/pom.xml b/genkit/pom.xml index 9a3849bad..c616676b4 100644 --- a/genkit/pom.xml +++ b/genkit/pom.xml @@ -98,11 +98,10 @@ mockito-core test - + com.fasterxml.jackson.dataformat jackson-dataformat-yaml - test diff --git a/genkit/src/main/java/com/google/genkit/Genkit.java b/genkit/src/main/java/com/google/genkit/Genkit.java index 9e639b010..5d010a320 100644 --- a/genkit/src/main/java/com/google/genkit/Genkit.java +++ b/genkit/src/main/java/com/google/genkit/Genkit.java @@ -909,8 +909,26 @@ private ModelResponse generateInternal( // Chain WrapGenerate hooks around the core iteration generateRef[0] = chainGenerateHooks(middlewares, rawGenerate); + // Wrap the whole generate operation (tool-calling loop, middleware, output conformance) in its + // own "generate" span so traces read flow -> generate -> model, matching the /util/generate + // action and the JS/Go SDKs. Because Tracer.runInNewSpan makes the span current (via the + // OpenTelemetry context), the per-turn model span(s) created downstream nest under this + // generate + // span, and this generate span nests under the surrounding flow/agent span when one is present. + // Its input/output (GenerateActionOptions / ModelResponse) differ from the raw model + // request/response, which is precisely why generate warrants its own span. + final GenerateActionOptions spanInput = actionOpts; + final ActionContext genCtx = ctx; + SpanMetadata generateSpan = SpanMetadata.builder().name("generate").type("util").build(); + // Start generation with high-level options (messageIndex starts at 0, propagate streamCallback) - return generateRef[0].apply(ctx, new GenerateParams(actionOpts, 0, 0, streamCallback)); + return Tracer.runInNewSpan( + genCtx, + generateSpan, + spanInput, + (spanCtx, opts) -> + generateRef[0].apply( + genCtx.withSpanContext(spanCtx), new GenerateParams(opts, 0, 0, streamCallback))); } /** diff --git a/genkit/src/main/java/com/google/genkit/prompt/DotPrompt.java b/genkit/src/main/java/com/google/genkit/prompt/DotPrompt.java index 2c5f092f2..b335e672a 100644 --- a/genkit/src/main/java/com/google/genkit/prompt/DotPrompt.java +++ b/genkit/src/main/java/com/google/genkit/prompt/DotPrompt.java @@ -18,6 +18,9 @@ package com.google.genkit.prompt; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.github.jknack.handlebars.Context; import com.github.jknack.handlebars.Handlebars; import com.github.jknack.handlebars.Template; @@ -35,6 +38,7 @@ import com.google.genkit.core.ActionContext; import com.google.genkit.core.ActionType; import com.google.genkit.core.GenkitException; +import com.google.genkit.core.JsonUtils; import com.google.genkit.core.Registry; import java.io.IOException; import java.io.InputStream; @@ -104,11 +108,15 @@ public Charset getCharset() { /** Shared Handlebars instance with registered partials. */ private static final Handlebars sharedHandlebars = new Handlebars(partialLoader); + /** Mapper for parsing the YAML frontmatter of .prompt files. */ + private static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory()); + private final String name; private final String variant; private final String model; private final String template; private final Map inputSchema; + private final Map outputSchema; private final GenerationConfig config; private final Handlebars handlebars; @@ -129,11 +137,34 @@ public DotPrompt( String template, Map inputSchema, GenerationConfig config) { + this(name, variant, model, template, inputSchema, null, config); + } + + /** + * Creates a new DotPrompt. + * + * @param name the prompt name + * @param variant the prompt variant + * @param model the default model name + * @param template the Handlebars template + * @param inputSchema the input JSON schema + * @param outputSchema the output JSON schema + * @param config the default generation config + */ + public DotPrompt( + String name, + String variant, + String model, + String template, + Map inputSchema, + Map outputSchema, + GenerationConfig config) { this.name = name; this.variant = variant; this.model = model; this.template = template; this.inputSchema = inputSchema; + this.outputSchema = outputSchema; this.config = config; this.handlebars = sharedHandlebars; // Use shared instance with registered partials } @@ -276,6 +307,7 @@ public static DotPrompt parse(String name, String content) throws GenkitE String template = content; String model = null; Map inputSchema = null; + Map outputSchema = null; GenerationConfig config = null; if (content.startsWith("---")) { @@ -284,11 +316,39 @@ public static DotPrompt parse(String name, String content) throws GenkitE String frontmatter = content.substring(3, endIndex).trim(); template = content.substring(endIndex + 3).trim(); - // Simple YAML parsing for common fields - for (String line : frontmatter.split("\n")) { - line = line.trim(); - if (line.startsWith("model:")) { - model = line.substring(6).trim(); + try { + Map fm = + YAML_MAPPER.readValue(frontmatter, new TypeReference>() {}); + if (fm != null) { + Object modelVal = fm.get("model"); + if (modelVal != null) { + model = String.valueOf(modelVal); + } + + // Convert the input/output Picoschema (frontmatter) into JSON Schema. + Object input = fm.get("input"); + if (input instanceof Map) { + inputSchema = Picoschema.convert(((Map) input).get("schema")); + } + Object output = fm.get("output"); + if (output instanceof Map) { + outputSchema = Picoschema.convert(((Map) output).get("schema")); + } + + Object cfg = fm.get("config"); + if (cfg instanceof Map) { + config = JsonUtils.getObjectMapper().convertValue(cfg, GenerationConfig.class); + } + } + } catch (Exception e) { + // Malformed or non-standard frontmatter: fall back to reading just the model line so the + // prompt still loads (input/output schemas stay null and can be inferred from the Java + // input class in toPrompt()). + for (String line : frontmatter.split("\n")) { + line = line.trim(); + if (line.startsWith("model:")) { + model = line.substring(6).trim(); + } } } } @@ -310,7 +370,7 @@ public static DotPrompt parse(String name, String content) throws GenkitE variant = name.substring(dotIndex + 1); } - return new DotPrompt<>(name, variant, model, template, inputSchema, config); + return new DotPrompt<>(name, variant, model, template, inputSchema, outputSchema, config); } /** @@ -380,6 +440,7 @@ public Prompt toPrompt(Class inputClass) { .model(model) .template(template) .inputSchema(inputSchema) + .outputSchema(outputSchema) .config(config) .inputClass(inputClass) .renderer((ctx, input) -> toModelRequest(input)) @@ -541,6 +602,10 @@ public Map getInputSchema() { return inputSchema; } + public Map getOutputSchema() { + return outputSchema; + } + public GenerationConfig getConfig() { return config; } diff --git a/genkit/src/main/java/com/google/genkit/prompt/Picoschema.java b/genkit/src/main/java/com/google/genkit/prompt/Picoschema.java new file mode 100644 index 000000000..9507d4ec9 --- /dev/null +++ b/genkit/src/main/java/com/google/genkit/prompt/Picoschema.java @@ -0,0 +1,214 @@ +/* + * 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.prompt; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Converts Picoschema (the compact schema dialect used in {@code .prompt} frontmatter) into + * standard JSON Schema. + * + *

Picoschema is a shorthand for describing an object's fields. Given a YAML block already parsed + * into Java objects, this converter produces a JSON-Schema {@code Map} suitable for the Dev UI and + * generic action runners. + * + *

Supported syntax: + * + *

    + *
  • Scalar fields: {@code fieldName: string} (types: string, boolean, null, number, integer, + * any). + *
  • Descriptions: {@code fieldName: string, a human description} — text after the first comma. + *
  • Optional fields: {@code fieldName?: string} — excluded from {@code required}. + *
  • Wrappers: {@code items(array): string}, {@code obj(object): {...}}, {@code color(enum): + * [RED, GREEN]}. A comma in the parenthetical adds a description, e.g. {@code tags(array, + * list of tags): string}. + *
  • Nested objects: a field whose value is a map is treated as a nested object. + *
+ * + *

This is a pragmatic implementation covering the common cases; if a value already looks like a + * full JSON Schema (has a JSON-schema {@code type} plus {@code properties}/{@code items}/{@code + * $schema}) it is passed through unchanged. + */ +public final class Picoschema { + + private static final Set SCALAR_TYPES = + Set.of("string", "boolean", "null", "number", "integer", "any"); + + private static final Set JSON_SCHEMA_TYPES = + Set.of("object", "array", "string", "boolean", "null", "number", "integer"); + + private Picoschema() {} + + /** + * Converts a parsed Picoschema definition into a JSON Schema map. + * + * @param pico the Picoschema value (map, string, or null) + * @return the JSON schema as a map, or {@code null} if {@code pico} is null + */ + @SuppressWarnings("unchecked") + public static Map convert(Object pico) { + if (pico == null) { + return null; + } + if (pico instanceof Map) { + Map map = (Map) pico; + if (looksLikeJsonSchema(map)) { + return map; + } + return convertObject(map); + } + if (pico instanceof String) { + return convertScalar((String) pico); + } + return new LinkedHashMap<>(Map.of("type", "object")); + } + + private static boolean looksLikeJsonSchema(Map map) { + Object type = map.get("type"); + boolean hasSchemaType = type instanceof String && JSON_SCHEMA_TYPES.contains(type); + return map.containsKey("$schema") + || (hasSchemaType && (map.containsKey("properties") || map.containsKey("items"))); + } + + private static Map convertObject(Map fields) { + Map properties = new LinkedHashMap<>(); + List required = new ArrayList<>(); + for (Map.Entry entry : fields.entrySet()) { + ParsedKey key = parseKey(entry.getKey()); + properties.put(key.name, convertField(key, entry.getValue())); + if (!key.optional) { + required.add(key.name); + } + } + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("properties", properties); + if (!required.isEmpty()) { + schema.put("required", required); + } + schema.put("additionalProperties", false); + return schema; + } + + @SuppressWarnings("unchecked") + private static Map convertField(ParsedKey key, Object value) { + Map schema; + switch (key.wrapper) { + case "array": + schema = new LinkedHashMap<>(); + schema.put("type", "array"); + schema.put("items", convertItems(value)); + break; + case "object": + schema = + (value instanceof Map) + ? convertObject((Map) value) + : new LinkedHashMap<>(Map.of("type", "object")); + break; + case "enum": + schema = new LinkedHashMap<>(); + schema.put("enum", value instanceof List ? value : List.of()); + break; + default: + if (value instanceof Map) { + schema = convertObject((Map) value); + } else if (value instanceof String) { + schema = convertScalar((String) value); + } else { + schema = new LinkedHashMap<>(Map.of("type", "object")); + } + } + if (key.description != null && !schema.containsKey("description")) { + schema.put("description", key.description); + } + return schema; + } + + @SuppressWarnings("unchecked") + private static Object convertItems(Object value) { + if (value instanceof Map) { + return convertObject((Map) value); + } + if (value instanceof String) { + return convertScalar((String) value); + } + return new LinkedHashMap<>(Map.of("type", "object")); + } + + private static Map convertScalar(String spec) { + String type = spec.trim(); + String description = null; + int comma = spec.indexOf(','); + if (comma >= 0) { + type = spec.substring(0, comma).trim(); + description = spec.substring(comma + 1).trim(); + } + Map schema = new LinkedHashMap<>(); + if ("any".equals(type)) { + // 'any' imposes no type constraint. + } else if (SCALAR_TYPES.contains(type)) { + schema.put("type", type); + } else { + // Unknown scalar type: default to string rather than emitting an invalid schema. + schema.put("type", "string"); + } + if (description != null && !description.isEmpty()) { + schema.put("description", description); + } + return schema; + } + + /** Parses a Picoschema key such as {@code name}, {@code name?}, or {@code name(array, desc)}. */ + private static ParsedKey parseKey(String rawKey) { + ParsedKey parsed = new ParsedKey(); + String key = rawKey.trim(); + + int paren = key.indexOf('('); + if (paren >= 0 && key.endsWith(")")) { + String inner = key.substring(paren + 1, key.length() - 1).trim(); + key = key.substring(0, paren).trim(); + int comma = inner.indexOf(','); + if (comma >= 0) { + parsed.wrapper = inner.substring(0, comma).trim(); + parsed.description = inner.substring(comma + 1).trim(); + } else { + parsed.wrapper = inner; + } + } + + if (key.endsWith("?")) { + parsed.optional = true; + key = key.substring(0, key.length() - 1).trim(); + } + + parsed.name = key; + return parsed; + } + + private static final class ParsedKey { + private String name; + private boolean optional = false; + private String wrapper = ""; + private String description = null; + } +} diff --git a/genkit/src/test/java/com/google/genkit/GenerateSpanTest.java b/genkit/src/test/java/com/google/genkit/GenerateSpanTest.java new file mode 100644 index 000000000..ab70959dd --- /dev/null +++ b/genkit/src/test/java/com/google/genkit/GenerateSpanTest.java @@ -0,0 +1,164 @@ +/* + * 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; + +import static org.junit.jupiter.api.Assertions.*; + +import com.google.genkit.ai.Candidate; +import com.google.genkit.ai.FinishReason; +import com.google.genkit.ai.GenerateOptions; +import com.google.genkit.ai.Message; +import com.google.genkit.ai.Model; +import com.google.genkit.ai.ModelInfo; +import com.google.genkit.ai.ModelRequest; +import com.google.genkit.ai.ModelResponse; +import com.google.genkit.ai.ModelResponseChunk; +import com.google.genkit.core.ActionContext; +import com.google.genkit.core.Flow; +import com.google.genkit.core.tracing.Tracer; +import io.opentelemetry.context.Context; +import io.opentelemetry.sdk.trace.ReadWriteSpan; +import io.opentelemetry.sdk.trace.ReadableSpan; +import io.opentelemetry.sdk.trace.SpanProcessor; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; + +/** + * Regression test for #185: a generate call made from within a flow must be wrapped in its own + * "generate" span, producing a flow -> generate -> model hierarchy (previously it was flow + * -> model). + */ +class GenerateSpanTest { + + /** Minimal model that returns a canned reply in a single turn (no tool calls). */ + private static Model fakeModel() { + return new Model() { + @Override + public String getName() { + return "test/fake"; + } + + @Override + public ModelInfo getInfo() { + return new ModelInfo(); + } + + @Override + public ModelResponse run(ActionContext ctx, ModelRequest request) { + return run(ctx, request, null); + } + + @Override + public ModelResponse run( + ActionContext ctx, ModelRequest request, Consumer streamCallback) { + Candidate candidate = new Candidate(Message.model("hello"), FinishReason.STOP); + ModelResponse response = new ModelResponse(List.of(candidate)); + response.setFinishReason(FinishReason.STOP); + response.setRequest(request); + return response; + } + }; + } + + @Test + void generateWithinFlowNestsModelUnderGenerateSpan() { + List captured = Collections.synchronizedList(new ArrayList<>()); + SpanProcessor collector = + new SpanProcessor() { + @Override + public void onStart(Context parentContext, ReadWriteSpan span) {} + + @Override + public boolean isStartRequired() { + return false; + } + + @Override + public void onEnd(ReadableSpan span) { + captured.add(span); + } + + @Override + public boolean isEndRequired() { + return true; + } + }; + Tracer.registerSpanProcessor(collector); + + Genkit genkit = Genkit.builder().build(); + genkit.registerModel(fakeModel()); + + Flow flow = + genkit.defineFlow( + "generateSpanFlow", + String.class, + String.class, + (ctx, input) -> { + ModelResponse resp = + genkit.generate( + GenerateOptions.builder().model("test/fake").prompt(input).build()); + return resp.getText(); + }); + + ActionContext ctx = ActionContext.builder().registry(genkit.getRegistry()).build(); + flow.run(ctx, "hi"); + + // Isolate this run's spans by the flow span's trace id. + ReadableSpan flowSpan = + captured.stream() + .filter(s -> "generateSpanFlow".equals(s.getName())) + .findFirst() + .orElseThrow(() -> new AssertionError("flow span not found")); + String traceId = flowSpan.getSpanContext().getTraceId(); + + Map byId = new java.util.HashMap<>(); + for (ReadableSpan s : captured) { + if (traceId.equals(s.getSpanContext().getTraceId())) { + byId.put(s.getSpanContext().getSpanId(), s); + } + } + + ReadableSpan generateSpan = + byId.values().stream() + .filter(s -> "generate".equals(s.getName())) + .findFirst() + .orElseThrow(() -> new AssertionError("generate span not found — still flow -> model")); + ReadableSpan modelSpan = + byId.values().stream() + .filter(s -> "test/fake".equals(s.getName())) + .findFirst() + .orElseThrow(() -> new AssertionError("model span not found")); + + // generate is a child of the flow span. + assertEquals( + flowSpan.getSpanContext().getSpanId(), + generateSpan.getParentSpanContext().getSpanId(), + "generate span should be a child of the flow span"); + + // model is a child of the generate span (not directly under the flow). + assertEquals( + generateSpan.getSpanContext().getSpanId(), + modelSpan.getParentSpanContext().getSpanId(), + "model span should be a child of the generate span"); + } +} diff --git a/genkit/src/test/java/com/google/genkit/prompt/DotPromptTest.java b/genkit/src/test/java/com/google/genkit/prompt/DotPromptTest.java index 0a5256165..4e90bd41f 100644 --- a/genkit/src/test/java/com/google/genkit/prompt/DotPromptTest.java +++ b/genkit/src/test/java/com/google/genkit/prompt/DotPromptTest.java @@ -75,4 +75,76 @@ void testToPrompt() throws GenkitException { assertEquals("recipe", promptMetadata.get("name")); assertEquals("robot", promptMetadata.get("variant")); } + + @Test + @SuppressWarnings("unchecked") + void testParseFrontmatterInputSchema() throws GenkitException { + // Regression test for #184: the prompt runner needs an input schema derived from the .prompt + // frontmatter so it can render an input box. + String content = + "---\n" + + "model: openai/gpt-4o-mini\n" + + "input:\n" + + " schema:\n" + + " code: string\n" + + " language: string\n" + + " analysisType?: string\n" + + "---\n" + + "Review this {{language}} code: {{code}}"; + DotPrompt> dotPrompt = DotPrompt.parse("code-review", content); + + Map inputSchema = dotPrompt.getInputSchema(); + assertNotNull(inputSchema, "input schema should be parsed from frontmatter"); + assertEquals("object", inputSchema.get("type")); + + Map properties = (Map) inputSchema.get("properties"); + assertTrue(properties.containsKey("code")); + assertTrue(properties.containsKey("language")); + assertTrue(properties.containsKey("analysisType")); + + java.util.List required = (java.util.List) inputSchema.get("required"); + assertTrue(required.contains("code")); + assertTrue(required.contains("language")); + assertFalse(required.contains("analysisType"), "optional field must not be required"); + } + + @Test + @SuppressWarnings("unchecked") + void testParseFrontmatterOutputSchema() throws GenkitException { + String content = + "---\n" + + "model: openai/gpt-4o-mini\n" + + "output:\n" + + " format: json\n" + + " schema:\n" + + " summary: string\n" + + " score: integer, from 1 to 10\n" + + " issues(array):\n" + + " severity: string\n" + + " line?: integer\n" + + "---\n" + + "Body"; + DotPrompt> dotPrompt = DotPrompt.parse("code-review", content); + + Map outputSchema = dotPrompt.getOutputSchema(); + assertNotNull(outputSchema, "output schema should be parsed from frontmatter"); + Map properties = (Map) outputSchema.get("properties"); + assertEquals("integer", ((Map) properties.get("score")).get("type")); + assertEquals( + "from 1 to 10", ((Map) properties.get("score")).get("description")); + // issues(array) -> array of objects + Map issues = (Map) properties.get("issues"); + assertEquals("array", issues.get("type")); + Map items = (Map) issues.get("items"); + assertEquals("object", items.get("type")); + } + + @Test + void testFrontmatterlessPromptStillParses() throws GenkitException { + // Backwards compatibility: a prompt whose frontmatter only has model: must still load. + DotPrompt> dotPrompt = + DotPrompt.parse("plain", "---\nmodel: openai/gpt-4o\n---\nHello"); + assertEquals("openai/gpt-4o", dotPrompt.getModel()); + assertNull(dotPrompt.getInputSchema()); + } } diff --git a/genkit/src/test/java/com/google/genkit/prompt/PicoschemaTest.java b/genkit/src/test/java/com/google/genkit/prompt/PicoschemaTest.java new file mode 100644 index 000000000..6979c4858 --- /dev/null +++ b/genkit/src/test/java/com/google/genkit/prompt/PicoschemaTest.java @@ -0,0 +1,151 @@ +/* + * 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.prompt; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** Unit tests for the {@link Picoschema} converter (#184). */ +class PicoschemaTest { + + @Test + void nullReturnsNull() { + assertNull(Picoschema.convert(null)); + } + + @Test + @SuppressWarnings("unchecked") + void scalarFieldsBecomeTypedProperties() { + Map pico = new LinkedHashMap<>(); + pico.put("code", "string"); + pico.put("count", "integer"); + + Map schema = Picoschema.convert(pico); + + assertEquals("object", schema.get("type")); + Map props = (Map) schema.get("properties"); + assertEquals("string", ((Map) props.get("code")).get("type")); + assertEquals("integer", ((Map) props.get("count")).get("type")); + List required = (List) schema.get("required"); + assertTrue(required.contains("code")); + assertTrue(required.contains("count")); + } + + @Test + @SuppressWarnings("unchecked") + void optionalFieldExcludedFromRequired() { + Map pico = new LinkedHashMap<>(); + pico.put("required", "string"); + pico.put("maybe?", "string"); + + Map schema = Picoschema.convert(pico); + List required = (List) schema.get("required"); + assertTrue(required.contains("required")); + assertFalse(required.contains("maybe")); + Map props = (Map) schema.get("properties"); + assertTrue(props.containsKey("maybe"), "optional field must still be a property"); + } + + @Test + @SuppressWarnings("unchecked") + void scalarDescriptionAfterComma() { + Map pico = new LinkedHashMap<>(); + pico.put("score", "integer, from 1 to 10"); + + Map schema = Picoschema.convert(pico); + Map props = (Map) schema.get("properties"); + Map score = (Map) props.get("score"); + assertEquals("integer", score.get("type")); + assertEquals("from 1 to 10", score.get("description")); + } + + @Test + @SuppressWarnings("unchecked") + void arrayWrapperOfObjects() { + Map item = new LinkedHashMap<>(); + item.put("severity", "string"); + item.put("line?", "integer"); + Map pico = new LinkedHashMap<>(); + pico.put("issues(array)", item); + + Map schema = Picoschema.convert(pico); + Map props = (Map) schema.get("properties"); + Map issues = (Map) props.get("issues"); + assertEquals("array", issues.get("type")); + Map items = (Map) issues.get("items"); + assertEquals("object", items.get("type")); + Map itemProps = (Map) items.get("properties"); + assertTrue(itemProps.containsKey("severity")); + assertTrue(itemProps.containsKey("line")); + } + + @Test + @SuppressWarnings("unchecked") + void arrayWrapperOfScalars() { + Map pico = new LinkedHashMap<>(); + pico.put("tags(array)", "string"); + + Map schema = Picoschema.convert(pico); + Map props = (Map) schema.get("properties"); + Map tags = (Map) props.get("tags"); + assertEquals("array", tags.get("type")); + assertEquals("string", ((Map) tags.get("items")).get("type")); + } + + @Test + @SuppressWarnings("unchecked") + void nestedObjectFromMap() { + Map nested = new LinkedHashMap<>(); + nested.put("level", "string"); + nested.put("score", "integer"); + Map pico = new LinkedHashMap<>(); + pico.put("complexity", nested); + + Map schema = Picoschema.convert(pico); + Map props = (Map) schema.get("properties"); + Map complexity = (Map) props.get("complexity"); + assertEquals("object", complexity.get("type")); + assertTrue(((Map) complexity.get("properties")).containsKey("level")); + } + + @Test + @SuppressWarnings("unchecked") + void enumWrapper() { + Map pico = new LinkedHashMap<>(); + pico.put("color(enum)", List.of("RED", "GREEN", "BLUE")); + + Map schema = Picoschema.convert(pico); + Map props = (Map) schema.get("properties"); + Map color = (Map) props.get("color"); + assertEquals(List.of("RED", "GREEN", "BLUE"), color.get("enum")); + } + + @Test + void rawJsonSchemaPassthrough() { + Map raw = new LinkedHashMap<>(); + raw.put("type", "object"); + raw.put("properties", Map.of("x", Map.of("type", "string"))); + // Already a JSON schema: returned unchanged. + assertSame(raw, Picoschema.convert(raw)); + } +} diff --git a/plugins/anthropic/src/main/java/com/google/genkit/plugins/anthropic/AnthropicModel.java b/plugins/anthropic/src/main/java/com/google/genkit/plugins/anthropic/AnthropicModel.java index 89abf3364..aa61fb677 100644 --- a/plugins/anthropic/src/main/java/com/google/genkit/plugins/anthropic/AnthropicModel.java +++ b/plugins/anthropic/src/main/java/com/google/genkit/plugins/anthropic/AnthropicModel.java @@ -25,6 +25,7 @@ import com.google.genkit.ai.*; import com.google.genkit.core.ActionContext; import com.google.genkit.core.GenkitException; +import com.google.genkit.core.SchemaUtils; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; @@ -84,6 +85,10 @@ private ModelInfo createModelInfo() { caps.setOutput(Set.of("text", "json")); info.setSupports(caps); + // Expose the generation config schema so the Dev UI model playground can render configuration + // inputs (temperature, topP, maxOutputTokens, ...). + info.setCustomOptions(SchemaUtils.inferSchema(GenerationConfig.class)); + return info; } diff --git a/plugins/aws-bedrock/src/main/java/com/google/genkit/plugins/awsbedrock/AwsBedrockModel.java b/plugins/aws-bedrock/src/main/java/com/google/genkit/plugins/awsbedrock/AwsBedrockModel.java index e3ba72c2a..5d151b161 100644 --- a/plugins/aws-bedrock/src/main/java/com/google/genkit/plugins/awsbedrock/AwsBedrockModel.java +++ b/plugins/aws-bedrock/src/main/java/com/google/genkit/plugins/awsbedrock/AwsBedrockModel.java @@ -25,6 +25,7 @@ import com.google.genkit.ai.*; import com.google.genkit.core.ActionContext; import com.google.genkit.core.GenkitException; +import com.google.genkit.core.SchemaUtils; import java.io.IOException; import java.util.*; import java.util.concurrent.TimeUnit; @@ -85,6 +86,10 @@ private ModelInfo createModelInfo() { caps.setOutput(Set.of("text", "json")); info.setSupports(caps); + // Expose the generation config schema so the Dev UI model playground can render configuration + // inputs (temperature, topP, maxOutputTokens, ...). + info.setCustomOptions(SchemaUtils.inferSchema(GenerationConfig.class)); + return info; } diff --git a/plugins/compat-oai/src/main/java/com/google/genkit/plugins/compatoai/CompatOAIModel.java b/plugins/compat-oai/src/main/java/com/google/genkit/plugins/compatoai/CompatOAIModel.java index ae2576d44..5efa3e573 100644 --- a/plugins/compat-oai/src/main/java/com/google/genkit/plugins/compatoai/CompatOAIModel.java +++ b/plugins/compat-oai/src/main/java/com/google/genkit/plugins/compatoai/CompatOAIModel.java @@ -25,6 +25,7 @@ import com.google.genkit.ai.*; import com.google.genkit.core.ActionContext; import com.google.genkit.core.GenkitException; +import com.google.genkit.core.SchemaUtils; import java.io.IOException; import java.util.*; import java.util.concurrent.CountDownLatch; @@ -103,6 +104,10 @@ private ModelInfo createModelInfo() { caps.setOutput(Set.of("text", "json")); info.setSupports(caps); + // Expose the generation config schema so the Dev UI model playground can render configuration + // inputs (temperature, topP, maxOutputTokens, ...). + info.setCustomOptions(SchemaUtils.inferSchema(GenerationConfig.class)); + return info; } diff --git a/plugins/compat-oai/src/test/java/com/google/genkit/plugins/compatoai/CompatOAIModelTest.java b/plugins/compat-oai/src/test/java/com/google/genkit/plugins/compatoai/CompatOAIModelTest.java index ed43cd616..333eaf032 100644 --- a/plugins/compat-oai/src/test/java/com/google/genkit/plugins/compatoai/CompatOAIModelTest.java +++ b/plugins/compat-oai/src/test/java/com/google/genkit/plugins/compatoai/CompatOAIModelTest.java @@ -20,6 +20,8 @@ import static org.junit.jupiter.api.Assertions.*; +import com.google.genkit.core.ActionDesc; +import java.util.Map; import org.junit.jupiter.api.Test; class CompatOAIModelTest { @@ -78,4 +80,51 @@ void testModelWithSeparateApiModelName() { assertNotNull(model); assertEquals("test-provider/model-v1", model.getName()); } + + private static CompatOAIModel newModel() { + CompatOAIPluginOptions options = + CompatOAIPluginOptions.builder() + .apiKey("test-key") + .baseUrl("https://api.test.com/v1") + .build(); + return new CompatOAIModel("test-provider/model-v1", "Test Model", options); + } + + @Test + @SuppressWarnings("unchecked") + void testModelInfoExposesCustomOptions() { + // Regression test for #183: the model playground needs metadata.model.customOptions to render + // configuration inputs. + CompatOAIModel model = newModel(); + + Map customOptions = model.getInfo().getCustomOptions(); + assertNotNull(customOptions, "customOptions schema should be present"); + assertEquals("object", customOptions.get("type")); + + Map properties = (Map) customOptions.get("properties"); + assertNotNull(properties, "customOptions should describe config properties"); + assertTrue(properties.containsKey("temperature"), "temperature should be configurable"); + assertTrue(properties.containsKey("topP"), "topP should be configurable"); + assertTrue(properties.containsKey("maxOutputTokens"), "maxOutputTokens should be configurable"); + } + + @Test + void testModelActionHasInputAndOutputSchemas() { + // Regression test for #183: model actions should expose input/output schemas. + ActionDesc desc = newModel().getDesc(); + + assertNotNull(desc.getInputSchema(), "model action should expose an input schema"); + assertNotNull(desc.getOutputSchema(), "model action should expose an output schema"); + } + + @Test + void testCustomOptionsSurfacedInMetadata() { + // The Dev UI reads customOptions from metadata.model.customOptions. + CompatOAIModel model = newModel(); + + Map metadata = model.getMetadata(); + assertTrue(metadata.get("model") instanceof com.google.genkit.ai.ModelInfo); + com.google.genkit.ai.ModelInfo info = (com.google.genkit.ai.ModelInfo) metadata.get("model"); + assertNotNull(info.getCustomOptions()); + } } diff --git a/plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/GeminiModel.java b/plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/GeminiModel.java index 46a234eed..60d818cd0 100644 --- a/plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/GeminiModel.java +++ b/plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/GeminiModel.java @@ -33,6 +33,7 @@ import com.google.genai.types.Schema; import com.google.genai.types.ThinkingConfig; import com.google.genai.types.Type; +import com.google.genkit.ai.GenerationConfig; import com.google.genkit.ai.Media; import com.google.genkit.ai.Message; import com.google.genkit.ai.Model; @@ -47,6 +48,7 @@ import com.google.genkit.ai.Usage; import com.google.genkit.core.ActionContext; import com.google.genkit.core.GenkitException; +import com.google.genkit.core.SchemaUtils; import java.util.*; import java.util.function.Consumer; @@ -123,6 +125,10 @@ private ModelInfo createModelInfo() { caps.setOutput(Set.of("text", "json")); info.setSupports(caps); + // Expose the generation config schema so the Dev UI model playground can render configuration + // inputs (temperature, topP, maxOutputTokens, ...). + info.setCustomOptions(SchemaUtils.inferSchema(GenerationConfig.class)); + return info; } diff --git a/plugins/ollama/src/main/java/com/google/genkit/plugins/ollama/OllamaModel.java b/plugins/ollama/src/main/java/com/google/genkit/plugins/ollama/OllamaModel.java index b8a18c05c..56ac3e801 100644 --- a/plugins/ollama/src/main/java/com/google/genkit/plugins/ollama/OllamaModel.java +++ b/plugins/ollama/src/main/java/com/google/genkit/plugins/ollama/OllamaModel.java @@ -25,6 +25,7 @@ import com.google.genkit.ai.*; import com.google.genkit.core.ActionContext; import com.google.genkit.core.GenkitException; +import com.google.genkit.core.SchemaUtils; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; @@ -84,6 +85,10 @@ private ModelInfo createModelInfo() { caps.setOutput(Set.of("text", "json")); info.setSupports(caps); + // Expose the generation config schema so the Dev UI model playground can render configuration + // inputs (temperature, topP, maxOutputTokens, ...). + info.setCustomOptions(SchemaUtils.inferSchema(GenerationConfig.class)); + return info; } diff --git a/plugins/openai/src/main/java/com/google/genkit/plugins/openai/OpenAIModel.java b/plugins/openai/src/main/java/com/google/genkit/plugins/openai/OpenAIModel.java index 8d9d04104..6699e74ba 100644 --- a/plugins/openai/src/main/java/com/google/genkit/plugins/openai/OpenAIModel.java +++ b/plugins/openai/src/main/java/com/google/genkit/plugins/openai/OpenAIModel.java @@ -25,6 +25,7 @@ import com.google.genkit.ai.*; import com.google.genkit.core.ActionContext; import com.google.genkit.core.GenkitException; +import com.google.genkit.core.SchemaUtils; import java.io.IOException; import java.util.*; import java.util.List; @@ -82,6 +83,10 @@ private ModelInfo createModelInfo() { caps.setOutput(Set.of("text", "json")); info.setSupports(caps); + // Expose the generation config schema so the Dev UI model playground can render configuration + // inputs (temperature, topP, maxOutputTokens, ...). + info.setCustomOptions(SchemaUtils.inferSchema(GenerationConfig.class)); + return info; } diff --git a/samples/dotprompt/run.sh b/samples/dotprompt/run.sh index 7a055a49c..f8e65dcaf 100755 --- a/samples/dotprompt/run.sh +++ b/samples/dotprompt/run.sh @@ -1,4 +1,6 @@ #!/bin/bash # Run script for Genkit DotPrompt Sample cd "$(dirname "$0")" -mvn exec:java +# Compile before running so the sample's classes always match the installed Genkit libraries. +# (Running exec:java against stale target/classes causes NoSuchMethodError after a library rebuild.) +mvn compile exec:java From 56622437a0c0099e196eea8dd7152d84564cb20a Mon Sep 17 00:00:00 2001 From: xavidop Date: Sun, 5 Jul 2026 02:54:49 +0200 Subject: [PATCH 2/2] fix: dotprompt fixes --- .../src/main/resources/prompts/travel-planner.prompt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/samples/dotprompt/src/main/resources/prompts/travel-planner.prompt b/samples/dotprompt/src/main/resources/prompts/travel-planner.prompt index 7e5591409..e69bc0b15 100644 --- a/samples/dotprompt/src/main/resources/prompts/travel-planner.prompt +++ b/samples/dotprompt/src/main/resources/prompts/travel-planner.prompt @@ -1,5 +1,9 @@ --- model: openai/gpt-4o-mini +config: + temperature: 0.2 + topP: 0.5 + maxOutputTokens: 400 input: schema: destination: string