Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
622b2e4
[OPIK-8102] [BE] build: bump google-cloud-vertexai to 1.52.0 for thin…
AndreiCautisanu Aug 26, 2026
69628ad
feat(llm): expose Gemini thinking config for Vertex AI and google_ai
AndreiCautisanu Aug 26, 2026
423c12a
fix(llm): correct Gemini thinking level round-trip and provider diver…
Aug 27, 2026
3821018
fix(llm): preselect a Gemini thinking level so the shown value is sent
Aug 27, 2026
092c59b
fix(llm): fold the Gemini thinking level for optimization runs too
Aug 27, 2026
429857e
Merge branch 'main' into andreic/OPIK-8102-gemini-thinking-config
AndreiCautisanu Aug 27, 2026
3ef0d4e
fix(llm): cover every thinking-capable Gemini model, per Google's sup…
Aug 27, 2026
78a4173
fix(llm): never send thinking_level and thinking_budget together
Aug 27, 2026
2854406
fix(llm): send the displayed thinking level for prompts persisted wit…
Aug 27, 2026
fefa77a
fix(llm): translate thinking level to a budget for Gemini 2.5
Aug 27, 2026
08dd3f5
feat(llm): add an Auto thinking level for pre-Gemini-3 models
Aug 27, 2026
ea3fb80
fix(llm): address FE and BE review findings on Gemini thinking
Aug 28, 2026
fcb6d5c
Merge branch 'main' into andreic/OPIK-8102-gemini-thinking-config
AndreiCautisanu Aug 28, 2026
1e21fa8
fix(llm): keep persisted thinking data when the form contributes no l…
Sep 2, 2026
cc47af6
fix(llm): stop forwarding include_thoughts, which is billed and then …
Sep 2, 2026
bf2a86e
docs(llm): scope the experiment custom_parameters comment to Gemini/V…
Sep 2, 2026
a3d116a
fix(llm): let an explicit budget survive level=off on Gemini 3
Sep 2, 2026
5cf8302
test(llm): make the Vertex negative assertions falsifiable, move the …
Sep 3, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.comet.opik.api.ExperimentExecutionRequest;
import com.comet.opik.domain.template.MustacheParser;
import com.comet.opik.utils.JsonUtils;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import dev.langchain4j.model.openai.internal.chat.AssistantMessage;
Expand Down Expand Up @@ -148,5 +149,14 @@ private void applyConfigs(ChatCompletionRequest.Builder builder, Map<String, Jso
if (presencePenalty != null && presencePenalty.isNumber()) {
builder.presencePenalty(presencePenalty.doubleValue());
}

// Provider-specific parameters the flat config above cannot express (Gemini thinking, Anthropic
// extended thinking). Only an object converts to a Map — Jackson throws on an array or scalar.
var customParameters = configs.get("custom_parameters");
if (customParameters != null && customParameters.isObject()) {
builder.customParameters(JsonUtils.getMapper()
Comment thread
thiagohora marked this conversation as resolved.
Comment thread
thiagohora marked this conversation as resolved.
.convertValue(customParameters, new TypeReference<Map<String, Object>>() {
}));
Comment thread
AndreiCautisanu marked this conversation as resolved.
Outdated
}
Comment thread
thiagohora marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package com.comet.opik.infrastructure.llm;

import com.fasterxml.jackson.databind.JsonNode;
import org.apache.commons.lang3.StringUtils;

import java.util.Arrays;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Pattern;

/**
* Gemini thinking configuration decoded from {@code custom_parameters.thinking}, shared by the Google AI Studio and
* Vertex AI providers.
* <p>
* A level reaches the wire as a level only on AI Studio with Gemini 3 or later. Everything else takes the budget it
* translates to, which is what {@link #budgetForLevel()} is for:
* <ul>
* <li>Vertex, at any version — its {@code GenerationConfig.ThinkingConfig} protobuf carries only
* {@code thinking_budget} and {@code include_thoughts}, with no level field at all.</li>
* <li>Gemini 2.5 on either provider — {@code thinking_level} is Gemini 3+ only and earlier models reject it
* outright, so 2.5 is level-driven in the UI but budget-driven on the wire.</li>
Comment thread
AndreiCautisanu marked this conversation as resolved.
* </ul>
*/
public record GeminiThinkingParams(Level level, Integer budgetTokens, Boolean includeThoughts) {
Comment thread
AndreiCautisanu marked this conversation as resolved.

public static final GeminiThinkingParams ABSENT = new GeminiThinkingParams(null, null, null);

// Bounded, and required to be a real version token (followed by "." or "-"). The judge path takes
// the model name as free text from the API: an unbounded group would overflow Integer.parseInt,
// and a merely bounded one would read "gemini-99999999999-flash" as version 999.
private static final Pattern GEMINI_MAJOR_VERSION = Pattern.compile("gemini-(\\d{1,3})(?=[.-])");

/**
* Thinking levels accepted by the Google AI Studio API, with the budget each one maps to on Vertex.
* <p>
* Google documents levels rather than budget numbers, so these budgets are Opik's own interpretation, spaced to
* keep the ordering meaningful. {@code OFF} exists because Gemini 2.5 Flash Lite ships with thinking disabled and
* an explicit zero budget is the only way to express "keep it off" once a level is being sent.
*/
public enum Level {
OFF(0),
MINIMAL(512),
LOW(2048),
MEDIUM(8192),
HIGH(24576);
Comment thread
AndreiCautisanu marked this conversation as resolved.

private final int budgetTokens;

Level(int budgetTokens) {
this.budgetTokens = budgetTokens;
}

public String wireValue() {
return name().toLowerCase(Locale.ROOT);
}

static Optional<Level> parse(String value) {
return Arrays.stream(values())
.filter(level -> level.name().equalsIgnoreCase(value))
.findFirst();
}
}

/**
* Whether a model takes {@code thinking_level} rather than the legacy {@code thinking_budget}.
* <p>
* Only Gemini 3 and later do: "If you use the thinking_level parameter with a model earlier than Gemini 3, the
* model returns an error." Gemini 2.5 is level-capable in the product sense — the UI offers levels for it — but on
* the wire a level has to be translated into a budget, exactly as it is for Vertex.
* <p>
* Matched on the model id rather than an allowlist so a newly synced Gemini 3+ model is not silently treated as
* 2.5. Ids look like {@code gemini-3.7-flash} or {@code vertex_ai/gemini-2.5-pro}, so the major version is the
* digits following the first {@code gemini-} in the id.
*/
public static boolean modelAcceptsLevel(String model) {
if (StringUtils.isBlank(model)) {
return false;
}

var matcher = GEMINI_MAJOR_VERSION.matcher(model);
return matcher.find() && Integer.parseInt(matcher.group(1)) >= 3;
Comment thread
AndreiCautisanu marked this conversation as resolved.
Comment thread
AndreiCautisanu marked this conversation as resolved.
}

public boolean isAbsent() {
return level == null && budgetTokens == null && includeThoughts == null;
}

/**
* The budget to send to Vertex: an explicit budget wins over the level it would otherwise be derived from.
*/
public Integer budgetForLevel() {
Comment thread
thiagohora marked this conversation as resolved.
if (budgetTokens != null) {
return budgetTokens;
}
return level == null ? null : level.budgetTokens;
Comment thread
AndreiCautisanu marked this conversation as resolved.
}

/**
* Decodes the judge/online-evaluation shape, where custom parameters arrive as a {@link JsonNode}.
*/
public static GeminiThinkingParams from(JsonNode customParameters) {
if (customParameters == null || !customParameters.isObject()) {
return ABSENT;
}

var thinking = customParameters.get("thinking");
if (thinking == null || !thinking.isObject()) {
return ABSENT;
}

return new GeminiThinkingParams(
parseLevel(asText(thinking.get("level"))),
parseBudget(thinking.get("budget_tokens")),
asBoolean(thinking.get("include_thoughts")));
}

/**
* Decodes the playground shape, where custom parameters arrive as a plain {@link Map} off the proxied request.
*/
public static GeminiThinkingParams from(Map<String, Object> customParameters) {
if (customParameters == null || !(customParameters.get("thinking") instanceof Map<?, ?> thinking)) {
return ABSENT;
}

return new GeminiThinkingParams(
parseLevel(thinking.get("level") instanceof String level ? level : null),
parseBudget(thinking.get("budget_tokens")),
thinking.get("include_thoughts") instanceof Boolean includeThoughts ? includeThoughts : null);
}

private static Level parseLevel(String value) {
return StringUtils.isBlank(value) ? null : Level.parse(value).orElse(null);
}

// A budget of 0 is meaningful — it disables thinking — so only negative values are rejected. -1 requests dynamic
Comment thread
AndreiCautisanu marked this conversation as resolved.
// thinking on Google's side, but langchain4j forwards budgets verbatim and Opik has no UI for it, so it is not
// accepted here rather than being silently coerced.
// isIntegralNumber() rather than canConvertToInt() alone: the latter is true for floating-point values, which
// would silently truncate a budget of 1.5 to 1 instead of rejecting it.
private static Integer parseBudget(JsonNode node) {
Comment thread
thiagohora marked this conversation as resolved.
return node != null && node.isIntegralNumber() && node.canConvertToInt() && node.asInt() >= 0
? node.asInt()
: null;
}

private static Integer parseBudget(Object value) {
if (!(value instanceof Integer || value instanceof Long || value instanceof Short)) {
return null;
}

long budget = ((Number) value).longValue();
return budget >= 0 && budget <= Integer.MAX_VALUE ? (int) budget : null;
}

private static String asText(JsonNode node) {
return node != null && node.isTextual() ? node.asText() : null;
}

private static Boolean asBoolean(JsonNode node) {
return node != null && node.isBoolean() ? node.asBoolean() : null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ interface GeminiChatModelMapper {
@Mapping(expression = "java(request.temperature())", target = "temperature")
@Mapping(expression = "java(request.topP())", target = "topP")
@Mapping(expression = "java(Boolean.FALSE)", target = "returnThinking")
@Mapping(expression = "java(GeminiThinkingConfigMapper.fromCustomParameters(request.model(), request.customParameters()))", target = "thinkingConfig")
Comment thread
AndreiCautisanu marked this conversation as resolved.
Comment thread
thiagohora marked this conversation as resolved.
GoogleAiGeminiChatModel toGeminiChatModel(
@NonNull String apiKey, @NonNull ChatCompletionRequest request, @NonNull Duration timeout, int maxRetries,
boolean logRequests, boolean logResponses);
Expand All @@ -37,6 +38,7 @@ GoogleAiGeminiChatModel toGeminiChatModel(
@Mapping(expression = "java(request.temperature())", target = "temperature")
@Mapping(expression = "java(request.topP())", target = "topP")
@Mapping(expression = "java(Boolean.FALSE)", target = "returnThinking")
@Mapping(expression = "java(GeminiThinkingConfigMapper.fromCustomParameters(request.model(), request.customParameters()))", target = "thinkingConfig")
Comment thread
thiagohora marked this conversation as resolved.
GoogleAiGeminiStreamingChatModel toGeminiStreamingChatModel(
@NonNull String apiKey, @NonNull ChatCompletionRequest request, @NonNull Duration timeout, int maxRetries,
boolean logRequests, boolean logResponses);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.comet.opik.api.evaluators.LlmAsJudgeModelParameters;
import com.comet.opik.domain.llm.langchain4j.OpikGeminiChatModel;
import com.comet.opik.infrastructure.LlmProviderClientConfig;
import com.comet.opik.infrastructure.llm.GeminiThinkingParams;
import com.comet.opik.infrastructure.llm.LlmProviderClientApiConfig;
import com.comet.opik.infrastructure.llm.LlmProviderClientGenerator;
import com.google.common.base.Preconditions;
Expand Down Expand Up @@ -63,6 +64,10 @@ public ChatModel generateChat(LlmProviderClientApiConfig config,
Optional.ofNullable(modelParameters.temperature()).ifPresent(modelBuilder::temperature);
Optional.ofNullable(modelParameters.seed()).ifPresent(modelBuilder::seed);

GeminiThinkingConfigMapper
.toThinkingConfig(modelParameters.name(), GeminiThinkingParams.from(modelParameters.customParameters()))
.ifPresent(modelBuilder::thinkingConfig);

GoogleAiGeminiChatModel geminiModel = modelBuilder.build();

// Wrap in OpikGeminiChatModel to convert VideoContent -> ImageContent
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.comet.opik.infrastructure.llm.gemini;

import com.comet.opik.infrastructure.llm.GeminiThinkingParams;
import dev.langchain4j.model.googleai.GeminiThinkingConfig;

import java.util.Map;
import java.util.Optional;

import static com.comet.opik.infrastructure.llm.GeminiThinkingParams.Level;

class GeminiThinkingConfigMapper {

private GeminiThinkingConfigMapper() {
}

/**
* Builds the Google AI Studio thinking config for a model.
* <p>
* {@code thinking_level} is Gemini 3+ only — "If you use the thinking_level parameter with a model earlier than
* Gemini 3, the model returns an error" — so on 2.5 a level is translated into the budget it maps to, the same
* translation Vertex needs at every version. A level of {@code off} is always a zero budget: there is no "off"
* level to send, and zero is how Gemini 2.5 Flash Lite already represents thinking being disabled.
* <p>
* {@code thinking_level} and the legacy {@code thinking_budget} are mutually exclusive — sending both returns a
* 400 — so exactly one is ever set.
*/
static Optional<GeminiThinkingConfig> toThinkingConfig(String model, GeminiThinkingParams params) {
if (params.isAbsent()) {
return Optional.empty();
}

// Gemini 3+ cannot disable thinking and does not accept a budget, so an "off" level there is
// better ignored than translated into a zero budget the API would reject. The UI never offers
// "off" for those models, but the judge path takes custom_parameters verbatim from the API.
if (params.level() == Level.OFF && GeminiThinkingParams.modelAcceptsLevel(model)) {
Comment thread
thiagohora marked this conversation as resolved.
Outdated
return Optional.empty();
}

var builder = GeminiThinkingConfig.builder();
boolean levelOnTheWire = params.level() != null
&& params.level() != Level.OFF
&& GeminiThinkingParams.modelAcceptsLevel(model);
Comment thread
AndreiCautisanu marked this conversation as resolved.

if (levelOnTheWire) {
builder.thinkingLevel(params.level().wireValue());
} else {
// budgetForLevel() resolves an explicit budget first, then the level's budget, so `off` lands on 0 and a
// 2.5 level lands on its mapped budget.
Optional.ofNullable(params.budgetForLevel()).ifPresent(builder::thinkingBudget);
Comment thread
AndreiCautisanu marked this conversation as resolved.
Comment thread
AndreiCautisanu marked this conversation as resolved.
}

Optional.ofNullable(params.includeThoughts()).ifPresent(builder::includeThoughts);

return Optional.of(builder.build());
}

/**
* Playground entry point, used from the MapStruct mapper where custom parameters are a plain map.
*/
static GeminiThinkingConfig fromCustomParameters(String model, Map<String, Object> customParameters) {
return toThinkingConfig(model, GeminiThinkingParams.from(customParameters)).orElse(null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@

import com.comet.opik.api.evaluators.LlmAsJudgeModelParameters;
import com.comet.opik.infrastructure.LlmProviderClientConfig;
import com.comet.opik.infrastructure.llm.GeminiThinkingParams;
import com.comet.opik.infrastructure.llm.LlmProviderClientApiConfig;
import com.comet.opik.infrastructure.llm.LlmProviderClientGenerator;
import com.comet.opik.utils.JsonUtils;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.google.auth.oauth2.ServiceAccountCredentials;
import com.google.cloud.vertexai.VertexAI;
import com.google.cloud.vertexai.api.GenerationConfig;
Expand All @@ -22,6 +26,7 @@
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;

Expand Down Expand Up @@ -116,9 +121,28 @@ private GenerationConfig getGenerationConfig(ChatCompletionRequest request) {
Optional.ofNullable(request.seed())
.ifPresent(generationConfig::setSeed);

thinkingConfig(GeminiThinkingParams.from(request.customParameters()))
.ifPresent(generationConfig::setThinkingConfig);

return generationConfig.build();
}

/**
* Vertex's {@code ThinkingConfig} has no level field, so a level is translated into the budget it maps to.
*/
private static Optional<GenerationConfig.ThinkingConfig> thinkingConfig(GeminiThinkingParams params) {
if (params.isAbsent()) {
return Optional.empty();
}

var thinkingConfig = GenerationConfig.ThinkingConfig.newBuilder();

Optional.ofNullable(params.budgetForLevel()).ifPresent(thinkingConfig::setThinkingBudget);
Optional.ofNullable(params.includeThoughts()).ifPresent(thinkingConfig::setIncludeThoughts);
Comment thread
thiagohora marked this conversation as resolved.
Outdated

return Optional.of(thinkingConfig.build());
Comment thread
AndreiCautisanu marked this conversation as resolved.
}

/**
* The location is free-text in the provider configuration but ends up in the {@code locations/%s} resource path as
* well as the host, so it has to be canonicalised before either is derived from it. The configured endpoint keys
Expand Down Expand Up @@ -180,6 +204,16 @@ public ChatModel generateChat(@NonNull LlmProviderClientApiConfig apiKey,
Optional.ofNullable(modelParameters.temperature()).ifPresent(requestBuilder::temperature);
Optional.ofNullable(modelParameters.seed()).ifPresent(requestBuilder::seed);

// Round-tripped through the request so the generation config is derived in one place for both paths.
// Only an object converts to a Map: custom_parameters is unvalidated free-form JSON, and Jackson throws
// IllegalArgumentException on an array or scalar, which would fail the whole run rather than be ignored.
Optional.ofNullable(modelParameters.customParameters())
.filter(JsonNode::isObject)
.map(customParameters -> JsonUtils.getMapper()
.convertValue(customParameters, new TypeReference<Map<String, Object>>() {
Comment thread
thiagohora marked this conversation as resolved.
}))
.ifPresent(requestBuilder::customParameters);

return newVertexAIClient(apiKey, requestBuilder.build());
}
}
Loading
Loading