-
Notifications
You must be signed in to change notification settings - Fork 1.7k
[OPIK-8102] [BE] [FE] feat: expose Gemini thinking config for Vertex AI and google_ai #8024
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
AndreiCautisanu
merged 18 commits into
main
from
andreic/OPIK-8102-gemini-thinking-config
Sep 3, 2026
+1,837
−75
Merged
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 69628ad
feat(llm): expose Gemini thinking config for Vertex AI and google_ai
AndreiCautisanu 423c12a
fix(llm): correct Gemini thinking level round-trip and provider diver…
3821018
fix(llm): preselect a Gemini thinking level so the shown value is sent
092c59b
fix(llm): fold the Gemini thinking level for optimization runs too
429857e
Merge branch 'main' into andreic/OPIK-8102-gemini-thinking-config
AndreiCautisanu 3ef0d4e
fix(llm): cover every thinking-capable Gemini model, per Google's sup…
78a4173
fix(llm): never send thinking_level and thinking_budget together
2854406
fix(llm): send the displayed thinking level for prompts persisted wit…
fefa77a
fix(llm): translate thinking level to a budget for Gemini 2.5
08dd3f5
feat(llm): add an Auto thinking level for pre-Gemini-3 models
ea3fb80
fix(llm): address FE and BE review findings on Gemini thinking
fcb6d5c
Merge branch 'main' into andreic/OPIK-8102-gemini-thinking-config
AndreiCautisanu 1e21fa8
fix(llm): keep persisted thinking data when the form contributes no l…
cc47af6
fix(llm): stop forwarding include_thoughts, which is billed and then …
bf2a86e
docs(llm): scope the experiment custom_parameters comment to Gemini/V…
a3d116a
fix(llm): let an explicit budget survive level=off on Gemini 3
5cf8302
test(llm): make the Vertex negative assertions falsifiable, move the …
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
163 changes: 163 additions & 0 deletions
163
apps/opik-backend/src/main/java/com/comet/opik/infrastructure/llm/GeminiThinkingParams.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
|
AndreiCautisanu marked this conversation as resolved.
|
||
| * </ul> | ||
| */ | ||
| public record GeminiThinkingParams(Level level, Integer budgetTokens, Boolean includeThoughts) { | ||
|
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); | ||
|
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; | ||
|
AndreiCautisanu marked this conversation as resolved.
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() { | ||
|
thiagohora marked this conversation as resolved.
|
||
| if (budgetTokens != null) { | ||
| return budgetTokens; | ||
| } | ||
| return level == null ? null : level.budgetTokens; | ||
|
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 | ||
|
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) { | ||
|
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; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
63 changes: 63 additions & 0 deletions
63
...nd/src/main/java/com/comet/opik/infrastructure/llm/gemini/GeminiThinkingConfigMapper.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)) { | ||
|
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); | ||
|
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); | ||
|
AndreiCautisanu marked this conversation as resolved.
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); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.