From 01ea7e98c70c67a4196af0478202f1ba67cf8ae2 Mon Sep 17 00:00:00 2001 From: Graffioh Date: Mon, 20 Jul 2026 18:57:24 +0200 Subject: [PATCH] fix(server): avoid parsing shadowed max-token aliases --- server/src/server/http_server.cpp | 17 ++++++++++++++--- server/src/server/http_server.h | 4 ++++ server/test/test_server_unit.cpp | 13 +++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/server/src/server/http_server.cpp b/server/src/server/http_server.cpp index 515af7ba0..1549cc339 100644 --- a/server/src/server/http_server.cpp +++ b/server/src/server/http_server.cpp @@ -378,6 +378,19 @@ static size_t json_array_size(const json & value) { return value.is_array() ? value.size() : 0; } +int resolve_max_output_tokens(const json & body, int default_max_tokens) { + if (body.contains("max_tokens")) { + return body.at("max_tokens").get(); + } + if (body.contains("max_output_tokens")) { + return body.at("max_output_tokens").get(); + } + if (body.contains("max_completion_tokens")) { + return body.at("max_completion_tokens").get(); + } + return default_max_tokens; +} + static bool env_flag_enabled(const char * name) { const char * raw = std::getenv(name); if (!raw || !*raw) return false; @@ -1409,9 +1422,7 @@ bool HttpServer::route_request(int fd, const HttpRequest & hr) { // default protects thinking-budget requests that omit max_tokens // from being capped at 4096 — thinking alone can consume that, // leaving no headroom for the visible reply. - req.max_output = body.value("max_tokens", - body.value("max_output_tokens", - body.value("max_completion_tokens", config_.default_max_tokens))); + req.max_output = resolve_max_output_tokens(body, config_.default_max_tokens); // Spec §4.4: clamp request max_tokens to --default-max-tokens. if (req.max_output > config_.default_max_tokens) { std::fprintf(stderr, diff --git a/server/src/server/http_server.h b/server/src/server/http_server.h index 957741b4d..0ea7e2213 100644 --- a/server/src/server/http_server.h +++ b/server/src/server/http_server.h @@ -228,6 +228,10 @@ struct ParsedRequest { DiskPrefixCachePolicy disk_cache_policy; }; +// Resolve the supported output-token aliases in precedence order. Only the +// selected field is parsed, so malformed lower-priority aliases are ignored. +int resolve_max_output_tokens(const json & body, int default_max_tokens); + // Build the /props response body. Exposed (non-static) so unit tests // can assert on its shape without spinning up a real socket. See // docs/specs/props-endpoint.md for the wire contract. diff --git a/server/test/test_server_unit.cpp b/server/test/test_server_unit.cpp index 7a5455b44..55876e8ce 100644 --- a/server/test/test_server_unit.cpp +++ b/server/test/test_server_unit.cpp @@ -1562,6 +1562,18 @@ static void test_pflash_raw_body_preserved() { TEST_ASSERT(req.raw_body["temperature"].get() > 0.6f); } +static void test_max_output_alias_precedence_ignores_shadowed_invalid_value() { + const json body = { + {"max_tokens", 100}, + {"max_output_tokens", 200}, + {"max_completion_tokens", "invalid"}, + }; + + TEST_ASSERT(resolve_max_output_tokens(body, 400) == 100); + TEST_ASSERT(resolve_max_output_tokens({{"max_output_tokens", 200}}, 400) == 200); + TEST_ASSERT(resolve_max_output_tokens(json::object(), 400) == 400); +} + static void test_pflash_placement_same_backend_local() { DevicePlacement target; target.backend = compiled_placement_backend(); @@ -4555,6 +4567,7 @@ int main() { RUN_TEST(test_pflash_curve_empty_uses_flat); RUN_TEST(test_pflash_upstream_proxy_config); RUN_TEST(test_pflash_raw_body_preserved); + RUN_TEST(test_max_output_alias_precedence_ignores_shadowed_invalid_value); RUN_TEST(test_pflash_placement_same_backend_local); RUN_TEST(test_pflash_placement_mixed_backend_remote); RUN_TEST(test_pflash_placement_auto_draft_follows_target);