Skip to content

Commit 584ea83

Browse files
HenryHenry
authored andcommitted
fix: cap LLM output at 16k tokens
1 parent 951e8aa commit 584ea83

4 files changed

Lines changed: 111 additions & 2 deletions

File tree

backend_api_python/app/services/llm.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
logger = get_logger(__name__)
1818

19+
DEFAULT_MAX_TOKENS = 16_384
20+
1921

2022
class LLMAPIError(ValueError):
2123
"""Provider HTTP error with status and request metadata preserved."""
@@ -198,6 +200,20 @@ def get_code_generation_model(self, provider: LLMProvider = None) -> str:
198200
return model
199201
return self.get_default_model(provider)
200202

203+
def get_max_tokens(self) -> int:
204+
"""Get the shared maximum output-token budget for every LLM provider."""
205+
config = load_addon_config()
206+
configured = config.get('llm', {}).get('max_tokens', DEFAULT_MAX_TOKENS)
207+
try:
208+
return max(1, int(configured))
209+
except (TypeError, ValueError):
210+
logger.warning(
211+
"Invalid LLM_MAX_TOKENS=%r; using %s",
212+
configured,
213+
DEFAULT_MAX_TOKENS,
214+
)
215+
return DEFAULT_MAX_TOKENS
216+
201217
def is_configured(self, provider: LLMProvider = None) -> bool:
202218
"""Return whether the provider has enough configuration to make a request."""
203219
p = provider or self.provider
@@ -301,6 +317,7 @@ def _call_openai_compatible(self, messages: list, model: str, temperature: float
301317
"model": model,
302318
"messages": messages,
303319
"temperature": temperature,
320+
"max_tokens": self.get_max_tokens(),
304321
}
305322

306323
# AtlasCloud documents the OpenAI-compatible ChatCompletion shape, but
@@ -458,6 +475,7 @@ def _call_google_gemini(self, messages: list, model: str, temperature: float,
458475
"contents": contents,
459476
"generationConfig": {
460477
"temperature": temperature,
478+
"maxOutputTokens": self.get_max_tokens(),
461479
"responseMimeType": "application/json",
462480
}
463481
}
@@ -496,6 +514,7 @@ def _call_litellm(self, messages: list, model: str, temperature: float,
496514
"model": model,
497515
"messages": messages,
498516
"temperature": temperature,
517+
"max_tokens": self.get_max_tokens(),
499518
"timeout": timeout,
500519
"drop_params": True,
501520
}
@@ -535,6 +554,7 @@ def _stream_openai_compatible(self, messages: list, model: str, temperature: flo
535554
"model": model,
536555
"messages": messages,
537556
"temperature": temperature,
557+
"max_tokens": self.get_max_tokens(),
538558
"stream": True,
539559
}
540560
response = self._llm_post(url, headers=headers, json_payload=data, timeout=timeout, stream=True)

backend_api_python/app/utils/config_loader.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ def env_get(name: str) -> Optional[str]:
9292
('OPENROUTER_API_URL', 'openrouter.api_url', 'string'),
9393
('OPENROUTER_MODEL', 'openrouter.model', 'string'),
9494
('OPENROUTER_TEMPERATURE', 'openrouter.temperature', 'float'),
95-
('OPENROUTER_MAX_TOKENS', 'openrouter.max_tokens', 'int'),
95+
('LLM_MAX_TOKENS', 'llm.max_tokens', 'int'),
9696
('OPENROUTER_TIMEOUT', 'openrouter.timeout', 'int'),
9797
('OPENROUTER_CONNECT_TIMEOUT', 'openrouter.connect_timeout', 'int'),
9898

backend_api_python/env.example

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,9 @@ SIGNAL_NOTIFY_TIMEOUT_SEC=6
487487
# LLM advanced tuning
488488
OPENROUTER_API_URL=https://openrouter.ai/api/v1/chat/completions
489489
OPENROUTER_TEMPERATURE=0.7
490-
OPENROUTER_MAX_TOKENS=4000
490+
# Shared maximum output-token budget for all LLM providers.
491+
# Providers charge for tokens actually generated, not this full upper bound.
492+
LLM_MAX_TOKENS=16384
491493
OPENROUTER_TIMEOUT=300
492494
OPENROUTER_CONNECT_TIMEOUT=30
493495
OPENAI_BASE_URL=https://api.openai.com/v1

backend_api_python/tests/test_llm_litellm_provider.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,16 @@ def test_litellm_env_mapping(monkeypatch):
2626
assert cfg["litellm"]["base_url"] == "https://litellm.example/v1"
2727

2828

29+
def test_uniform_llm_max_tokens_env_mapping(monkeypatch):
30+
monkeypatch.setenv("LLM_MAX_TOKENS", "16384")
31+
_reset_config_cache()
32+
33+
cfg = load_addon_config()
34+
35+
assert cfg["llm"]["max_tokens"] == 16384
36+
assert LLMService().get_max_tokens() == 16384
37+
38+
2939
def test_atlascloud_env_mapping(monkeypatch):
3040
monkeypatch.setenv("ATLASCLOUD_API_KEY", "atlas-key")
3141
monkeypatch.setenv("ATLASCLOUD_MODEL", "openai/gpt-5.4")
@@ -90,9 +100,82 @@ def fake_post(url, headers, json, timeout):
90100
assert captured["url"] == "https://api.atlascloud.ai/v1/chat/completions"
91101
assert captured["headers"]["Authorization"] == "Bearer atlas-key"
92102
assert captured["json"]["model"] == "deepseek-v3"
103+
assert captured["json"]["max_tokens"] == 16384
93104
assert "response_format" not in captured["json"]
94105

95106

107+
def test_google_gemini_uses_uniform_max_tokens(monkeypatch):
108+
captured = {}
109+
110+
class FakeResponse:
111+
def raise_for_status(self):
112+
return None
113+
114+
def json(self):
115+
return {
116+
"candidates": [
117+
{"content": {"parts": [{"text": "{\"ok\": true}"}]}}
118+
]
119+
}
120+
121+
service = LLMService(provider="google")
122+
monkeypatch.setattr(service, "get_max_tokens", lambda: 16384)
123+
124+
def fake_post(url, **kwargs):
125+
captured.update({"url": url, **kwargs})
126+
return FakeResponse()
127+
128+
monkeypatch.setattr(service, "_llm_post", fake_post)
129+
130+
out = service._call_google_gemini(
131+
[{"role": "user", "content": "hello"}],
132+
"gemini-1.5-flash",
133+
0.7,
134+
"google-key",
135+
"https://generativelanguage.googleapis.com/v1beta",
136+
30,
137+
)
138+
139+
assert out == "{\"ok\": true}"
140+
assert captured["json_payload"]["generationConfig"]["maxOutputTokens"] == 16384
141+
142+
143+
def test_openai_compatible_stream_uses_uniform_max_tokens(monkeypatch):
144+
captured = {}
145+
146+
class FakeResponse:
147+
status_code = 200
148+
149+
def iter_lines(self, decode_unicode=False):
150+
return [b"data: [DONE]"]
151+
152+
def close(self):
153+
return None
154+
155+
service = LLMService(provider="openrouter")
156+
monkeypatch.setattr(service, "get_max_tokens", lambda: 16384)
157+
158+
def fake_post(url, **kwargs):
159+
captured.update({"url": url, **kwargs})
160+
return FakeResponse()
161+
162+
monkeypatch.setattr(service, "_llm_post", fake_post)
163+
164+
chunks = list(
165+
service._stream_openai_compatible(
166+
[{"role": "user", "content": "hello"}],
167+
"openai/gpt-5.4",
168+
0.7,
169+
"openrouter-key",
170+
"https://openrouter.ai/api/v1",
171+
30,
172+
)
173+
)
174+
175+
assert chunks == []
176+
assert captured["json_payload"]["max_tokens"] == 16384
177+
178+
96179
@pytest.mark.parametrize(
97180
("payload", "expected"),
98181
[
@@ -400,9 +483,12 @@ def completion(**kwargs):
400483

401484

402485
def test_litellm_response_content(monkeypatch):
486+
captured = {}
487+
403488
class FakeLiteLLM:
404489
@staticmethod
405490
def completion(**kwargs):
491+
captured.update(kwargs)
406492
return SimpleNamespace(
407493
choices=[SimpleNamespace(message=SimpleNamespace(content="hello"))]
408494
)
@@ -421,3 +507,4 @@ def completion(**kwargs):
421507
)
422508

423509
assert out == "hello"
510+
assert captured["max_tokens"] == 16384

0 commit comments

Comments
 (0)