fix(gemini): default native maxOutputTokens + strip OpenAI extra_body on Gemini endpoints (#39730)

* fix: respect disabled auto-compaction on context overflow

Port from anomalyco/opencode#30749.

When compression.enabled is false, NO automatic compaction trigger may
fire. The proactive token-threshold paths (preflight + post-response
should_compress gate) already honoured the setting, but the three
provider-overflow recovery paths in the agent loop — long-context-tier
429, 413 payload-too-large, and context-overflow — called
_compress_context() unconditionally, silently compressing and rotating
the session against the user's explicit choice.

Add a single guard at the top of the overflow-recovery dispatch: when
compression is disabled and the error is one of those three overflow
classes, surface a terminal error (compaction_disabled: True) telling the
user to /compress manually, /new, switch to a larger-context model, or
reduce attachments. Manual /compress (force=True) is unaffected — it never
enters this loop.

Tests: new TestOverflowWithCompactionDisabled (413 + 400 overflow don't
compress when disabled; control case still compresses when enabled).
Existing overflow-recovery tests updated to enable compaction explicitly
(they verify the recovery fires); fixture defaults flipped to True to
match production (compression.enabled defaults to True).

* fix(gemini): default native maxOutputTokens + strip OpenAI extra_body on Gemini endpoints

Two distinct failures hit users on the gemini provider with only Google
AI Studio keys set.

1. Truncation loop: build_gemini_request() only set maxOutputTokens when
   max_tokens was non-None. Hermes passes None to mean "unlimited", but
   Gemini's native generateContent does NOT treat an absent maxOutputTokens
   as full budget — it applies a low internal default and stops early with
   finishReason=MAX_TOKENS, truncating tool calls. The agent then retries
   3x and refuses the incomplete call. Now default to the published 65,535
   ceiling (shared by all current Gemini text models) when max_tokens=None.

2. HTTP 400 on Gemini endpoint: the chat_completions transport assembles
   profile extra_body (Nous portal 'tags', reasoning, provider prefs) and
   sends it via the OpenAI client to whatever base_url is resolved. When a
   profile that emits extra_body (e.g. Nous) is active but the endpoint is a
   native Gemini base_url — typical when only Google creds exist and a
   fallback/aux call lands on Gemini — Google rejects the unknown 'tags'
   field with a non-retryable 400. Strip all non-thinking_config extra_body
   keys when the resolved endpoint is native Gemini.

Verified E2E against real transport code: tags stripped on native Gemini,
preserved on Nous and the /openai compat endpoint; maxOutputTokens=65535
on None, explicit values respected.
This commit is contained in:
Teknium
2026-06-05 03:53:59 -07:00
committed by GitHub
parent 6bf55a473e
commit ec46f5912e
7 changed files with 277 additions and 2 deletions
+24
View File
@@ -326,3 +326,27 @@ def test_stream_event_translation_keeps_identical_calls_in_distinct_parts():
assert tool_chunks[0].choices[0].delta.tool_calls[0].index == 0
assert tool_chunks[1].choices[0].delta.tool_calls[0].index == 1
assert tool_chunks[0].choices[0].delta.tool_calls[0].id != tool_chunks[1].choices[0].delta.tool_calls[0].id
def test_max_tokens_none_defaults_to_gemini_output_ceiling():
"""max_tokens=None must send the model's full output ceiling, not omit it.
Gemini's native generateContent applies a low internal default when
maxOutputTokens is absent, truncating tool calls mid-stream. Hermes passes
None to mean "unlimited", so the adapter must translate that to the
published 65,535 ceiling rather than leaving the field unset.
"""
from agent.gemini_native_adapter import (
build_gemini_request,
GEMINI_DEFAULT_MAX_OUTPUT_TOKENS,
)
req = build_gemini_request(messages=[{"role": "user", "content": "hi"}], max_tokens=None)
assert req["generationConfig"]["maxOutputTokens"] == GEMINI_DEFAULT_MAX_OUTPUT_TOKENS == 65535
def test_explicit_max_tokens_is_respected():
from agent.gemini_native_adapter import build_gemini_request
req = build_gemini_request(messages=[{"role": "user", "content": "hi"}], max_tokens=4096)
assert req["generationConfig"]["maxOutputTokens"] == 4096
@@ -859,3 +859,53 @@ class TestChatCompletionsCacheStats:
r = SimpleNamespace(usage=SimpleNamespace(prompt_tokens_details=details))
result = transport.extract_cache_stats(r)
assert result == {"cached_tokens": 500, "creation_tokens": 100}
class TestChatCompletionsGeminiNativeExtraBodyStrip:
"""Profile extra_body (e.g. Nous portal tags) must not reach a native
Gemini endpoint — Google's REST API rejects unknown fields with HTTP 400.
"""
def _nous_profile(self):
from providers import get_provider_profile
return get_provider_profile("nous")
def test_tags_stripped_when_endpoint_is_native_gemini(self, transport):
kw = transport.build_kwargs(
"anthropic/claude-sonnet-4.6",
[{"role": "user", "content": "hi"}],
None,
provider_profile=self._nous_profile(),
base_url="https://generativelanguage.googleapis.com/v1beta",
session_id="s1",
max_tokens=None,
)
eb = kw.get("extra_body")
assert not eb or "tags" not in eb
def test_tags_preserved_on_nous_endpoint(self, transport):
kw = transport.build_kwargs(
"hermes-3-405b",
[{"role": "user", "content": "hi"}],
None,
provider_profile=self._nous_profile(),
base_url="https://inference.nousresearch.com/v1",
session_id="s1",
max_tokens=None,
)
eb = kw.get("extra_body")
assert eb and "tags" in eb
def test_tags_pass_through_on_gemini_openai_compat(self, transport):
# /openai compat endpoint is not "native" — unchanged behavior.
kw = transport.build_kwargs(
"anthropic/claude-sonnet-4.6",
[{"role": "user", "content": "hi"}],
None,
provider_profile=self._nous_profile(),
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
session_id="s1",
max_tokens=None,
)
eb = kw.get("extra_body")
assert eb and "tags" in eb