feat: expose max_output_tokens in GenerationConfig across providers - #159
Open
nstuivenberg wants to merge 6 commits into
Open
feat: expose max_output_tokens in GenerationConfig across providers#159nstuivenberg wants to merge 6 commits into
nstuivenberg wants to merge 6 commits into
Conversation
added 4 commits
August 21, 2026 15:12
…roviders Adds an optional maxOutputTokens setting to GenerationSettings and a matching maxOutputTokens(Int) DSL function on GenerationConfig, wired through to the Gemini, VertexAI and OpenAI request mappers. Ollama inherits it via OpenAIModel. Without it providers fall back to their own default output limits, which silently truncates large structured responses.
…rom serialized Gemini requests when unset
…platform These modules declare kotest-runner-junit5 and contain specs, but never configure useJUnitPlatform() on their jvmTest task, so the specs compiled and were silently never executed. Only :src:core and :src:tools:openapi configured it.
…the max output token limit A candidate that hits MAX_TOKENS still carries partial content, which flowed into the mapper and surfaced downstream as an opaque JSON parse error on structured output. Also extracts the client Json configuration into geminiJson so the serialization tests assert against the production configuration instead of a reimplementation.
nstuivenberg
requested review from
Fputker,
ceesjansenflock,
nsmnds,
sjorsdev and
wilmveel
as code owners
August 21, 2026 13:55
nstuivenberg
marked this pull request as draft
August 21, 2026 13:58
added 2 commits
August 21, 2026 16:06
…ng OpenAI models Reasoning models reject max_tokens and require max_completion_tokens, while classic OpenAI models, custom identifiers, Ollama and other OpenAI-compatible servers understand max_tokens and silently ignore max_completion_tokens. The classification is exhaustive over OpenAIModelIdentifier so a new identifier fails to compile until it is classified.
Adds an optional maxOutputTokens field to ConfigDto in gateway.ws and maps it from the agent generation settings when publishing a run, so the configured limit travels with the run trace.
nstuivenberg
marked this pull request as ready for review
August 21, 2026 14:16
nsmnds
approved these changes
Aug 21, 2026
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Problem
None of the providers (Gemini, VertexAI, OpenAI) let callers configure the LLM's max output token limit —
GenerationSettings/GenerationConfignever exposed it. Whenmax_output_tokensis omitted, provider defaults kick in silently; for Gemini this is 8,192 tokens regardless of the model's actual ceiling (confirmed by community reports of the identical pattern, e.g.google-gemini/gemini-cli#23081, and by Google's own docs).This caused a real production incident: a Kotlin backend using this library to extract structured price-matrix data via
gemini-3.1-flash-liteintermittently failed with a JSON parse error ("Expected quotation mark, had EOF") on large documents — the model's structured-output response was silently truncated mid-JSON at the undocumented 8K-token default, and the failure only surfaced three layers downstream as an opaque deserialization error.Example
A simplified, generic version of the real-world case that motivated this PR: a structured-output agent extracting a price matrix out of a supplier document. Small documents fit inside Gemini's implicit 8K default; a large or combined document's matrix does not, and before this PR there was no way to raise the ceiling.
Fix
GenerationSettings(core) gains an optionalmaxOutputTokens: Int? = null.GenerationConfigDSL gains a matchingmaxOutputTokens(Int)builder function, mirroring the existingthinkingBudget(Int)pattern.GenerationConfig, serialized asmax_output_tokens.GenerateContentConfig.Builder.maxOutputTokens(...), only when configured.max_tokensvsmax_completion_tokens: which parameter is sent is now decided per model identifier. Reasoning and GPT-5-family identifiers (o1,o1-pro,o3,o3-pro,o3-mini,o4-mini,gpt-5*) getmax_completion_tokens, since they reject the oldermax_tokenswith a 400. Classic chat identifiers (gpt-4o,gpt-4o-mini,gpt-4.1*,gpt-4-turbo,gpt-3.5-turbo, the search-preview models) andOpenAIModelIdentifier.Customgetmax_tokens. The branch is exhaustive over the sealed identifier hierarchy, so a newly added identifier won't compile until it's classified. Ollama reusesOpenAIModelwith a plainModelIdentifier, which falls into themax_tokensbranch.finishReason == MAX_TOKENS,GeminiResponseMappernow fails fast with an explicit, actionable error pointing atgenerationConfig { maxOutputTokens(...) }, instead of letting a truncated payload reach the caller (or the JSON parser). Raising the ceiling reduces how often this happens; this change makes the remaining cases diagnosable.GeminiClient's Json config to a shared internalgeminiJsonvalue, reused by the new serialization tests, so "field omitted when null" is verified against the actual production serializer rather than a reimplementation.ConfigDtoinsrc/platform/wirespec/gateway.wsgains an optionalmaxOutputTokens: Integer?, populated byRequestMapper, so the configured limit is visible in published run traces during diagnosis (alongside the existingthinkingBudget).gemini,openai, andvertexaiall declaredkotest-runner-junit5but never calleduseJUnitPlatform()on theirjvmTesttask, so their Kotest specs compiled but silently never executed (0 tests run, green build). Added the missing configuration so all three modules' existing and new specs actually run.Backward compatibility
maxOutputTokensdefaults tonulleverywhere (matching this PR's requirement of not getting in the way of existing users of the API) — no existing caller needs to change anything, and request payloads are byte-for-byte unchanged unless a caller opts in via the new DSL function. Verified (not assumed) that an unset value is omitted from the serialized Gemini request rather than sent as an explicitnull, and that neither OpenAI token parameter is emitted when unset.Note:
GenerationSettingsgaining a constructor parameter is source-compatible (default value) but not binary-compatible for precompiled JVM/Android consumers who construct it positionally or viacopy()without recompiling against this version. Given the project is pre-1.0 (0.11.0-SNAPSHOT) this seems like an acceptable trade-off, flagging it in case maintainers track binary compatibility more strictly than that.The
ConfigDtochange adds an optional field to an outgoing payload sent to the Aigentic platform gateway. It should be compatible with a backend that ignores unknown/absent optional fields, but I can't verify that against the live gateway from here — maintainers should confirm the backend side accepts it before release.Known limitations / follow-ups (out of scope for this PR)
maxCompletionTokensis set too low and the model exhausts the budget on internal reasoning before producing visible content — this PR doesn't address that failure mode.thinkingBudgetandmaxOutputTokens, even though on Gemini/VertexAI they can share the same token budget when thinking is enabled.max_tokens(the parameter its OpenAI-compatible layer documents) rather thanmax_completion_tokens, and there's a unit test asserting that request shape forCustomidentifiers — but this has not been verified end-to-end against a live Ollama instance.:src:platformhas the same missinguseJUnitPlatform()gap that the three provider modules had: it declareskotest-runner-junit5forjvmTestbut never configures the JUnit platform, so its Kotest specs — including theRequestMapperTestadded in this PR — compile but don't execute. I left the fix out of this PR because addinguseJUnitPlatform()there surfaces 5 pre-existing, unrelated failures inTestExecutorTestthat look like MockK drift rather than anything caused by this change. Flagging it for maintainers as a separate cleanup.Testing
New specs, all running on the JUnit platform after the build fix:
core—AgentConfigTest: the DSL buildsmaxOutputTokens(alongsidethinkingBudget), and defaults tonull.gemini—GeminiRequestMapperKtTest: the value reachesgenerationConfig, is omitted when unset, and the serialized body contains"max_output_tokens":65536when set / nomax_output_tokenskey when unset (asserted against the productiongeminiJsonserializer).gemini—GeminiResponseMapperKtTest: aMAX_TOKENScandidate raises an error mentioning truncation; aSTOPcandidate still maps to aMessage.StructuredOutputas before.openai—ChatCompletionsRequestTest:max_completion_tokensforO1andGPT5,max_tokensforGPT4OandCustom, and neither field set whenmaxOutputTokensis unset.vertexai—RequestMapperTest:maxOutputTokens()isOptional.of(65536)when configured andOptional.empty()when not.platform—RequestMapperTestassertsConfigDto.maxOutputTokensis populated / left null. Caveat: as noted above, this module doesn't run its Kotest specs yet, so this test compiles but does not currently execute in CI../gradlew buildpasses forcore,gemini,openai, andvertexai, with the JUnit platform fix confirmed to make the provider specs actually execute (previously silently skipped). The:src:platformmodule has a pre-existing, task-ordering-dependent Gradle validation failure on the fullbuildtask that reproduces identically on an unmodifiedmain— unrelated to this change.