From 3448c3426e2ce5ecc9183dd03100b52c534b63e5 Mon Sep 17 00:00:00 2001 From: Hyeonsang Cho Date: Fri, 25 Sep 2026 15:06:33 +0900 Subject: [PATCH] fix(config): honor $VAR substitution for request_admission integer fields (#5838) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(config): honor $VAR substitution for request_admission integer fields AppConfig.resolve_env_variables replaces every `$VAR` reference with the raw environment string. RequestAdmissionConfig.requests_per_minute and max_queue_size are declared `strict=True` so a bool or float cannot slip in, but strict mode also refuses the string "60", so `requests_per_minute: $RPM` failed the whole config load with "Input should be a valid integer" even when RPM=60 — contradicting config.example.yaml's promise that environment variables work for every field. They were the only strict fields in the config schema; every other integer coerces in lax mode. Wrap both fields in a BeforeValidator that converts a decimal integer literal arriving as a string and leaves everything else untouched, so the strict check behind it still rejects bools, floats, "60.0" and non-numeric strings. Tests pin the accepted literals, the rejections that must survive, and the end-to-end path from a config.yaml `$VAR` reference through AppConfig.from_file. * docs(changelog): reference #5838 in the request_admission entries --- CHANGELOG.md | 8 +++ CHANGELOG_zh.md | 6 ++ backend/docs/CONFIGURATION.md | 4 ++ .../harness/deerflow/config/AGENTS.md | 5 +- .../harness/deerflow/config/model_config.py | 29 ++++++-- backend/tests/test_model_request_admission.py | 66 +++++++++++++++++++ 6 files changed, 113 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e23ef542f..ac579ab80f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -957,6 +957,13 @@ This release closes that milestone with **765 merged pull requests**. ### Fixed +- **config:** `request_admission.requests_per_minute` and `max_queue_size` now + accept `$VAR` environment references like every other field. Both are strict + integers so a bool or float is still rejected, but `$VAR` substitution always + produces a string, so `requests_per_minute: $RPM` failed the whole config load + with "Input should be a valid integer" even when `RPM=60`. A decimal literal + delivered as a string is now converted before the strict check; any other + string is still rejected. ([#5838]) - **scheduler:** Pausing a scheduled task no longer loses the pause when a dispatch is in flight on SQLite. `release_dispatch_lease` guards on the lease owner — which pausing clears — but read the row without taking SQLite's @@ -4420,4 +4427,5 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#5734]: https://github.com/bytedance/deer-flow/pull/5734 [#5776]: https://github.com/bytedance/deer-flow/pull/5776 [#5777]: https://github.com/bytedance/deer-flow/pull/5777 +[#5838]: https://github.com/bytedance/deer-flow/pull/5838 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 2571686c02..912c2732c9 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -727,6 +727,11 @@ ### 修复 +- **配置:** `request_admission.requests_per_minute` 与 `max_queue_size` 现在与其他字段一样 + 接受 `$VAR` 环境变量引用。这两个字段是严格整数,布尔值与浮点数仍会被拒绝;但 `$VAR` + 替换得到的永远是字符串,因此即使 `RPM=60`,`requests_per_minute: $RPM` 也会让整个配置 + 加载失败并报 "Input should be a valid integer"。现在以字符串形式到达的十进制整数字面量会在 + 严格校验之前被转换;其他字符串仍会被拒绝。([#5838]) - **调度器:** 在 SQLite 上,调度分发进行中暂停计划任务时不再丢失暂停状态。 `release_dispatch_lease` 依据租约持有者做校验(暂停会清除该字段),但读取任务行时没有先获取 SQLite 的写锁,因此过期的读取会通过校验,并把任务状态写回 `enabled` 且不改动 `next_run_at`, @@ -3572,3 +3577,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#5734]: https://github.com/bytedance/deer-flow/pull/5734 [#5776]: https://github.com/bytedance/deer-flow/pull/5776 [#5777]: https://github.com/bytedance/deer-flow/pull/5777 +[#5838]: https://github.com/bytedance/deer-flow/pull/5838 diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index 839a3ce67e..df282c2c85 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -63,6 +63,10 @@ request_admission: max_queue_size: 256 ``` +Like every other field, these accept `$VAR` environment references; an integer +field such as `requests_per_minute: $RPM` validates when the variable holds a +decimal integer. + Calls wait in a bounded FIFO before dispatch. At 60 RPM, admissions are spaced at least one second apart, even after idle periods. The first call can proceed immediately. Async waiting is cancellable; a cancelled or expired waiter spends diff --git a/backend/packages/harness/deerflow/config/AGENTS.md b/backend/packages/harness/deerflow/config/AGENTS.md index 4bc0c40aff..daba31590c 100644 --- a/backend/packages/harness/deerflow/config/AGENTS.md +++ b/backend/packages/harness/deerflow/config/AGENTS.md @@ -62,7 +62,10 @@ Config values starting with `$` are resolved as environment variables (e.g., `$O `ModelConfig.request_admission` is optional and is not a provider parameter. Its positive RPM, finite wait deadline, queue bound and optional quota-group name -configure process-local model pacing. Models sharing an explicit group must use +configure process-local model pacing. `requests_per_minute` and `max_queue_size` are strict +integers (bools and floats are rejected) behind a `BeforeValidator` that converts a +decimal literal delivered as a string, because `$VAR` substitution always yields +`str`; any other string still fails validation. Models sharing an explicit group must use identical policies. Restart after changing, disabling or regrouping an active policy; conflicting policies fail construction rather than silently resetting an active budget. This nested model option is enforced by its limiter registry, diff --git a/backend/packages/harness/deerflow/config/model_config.py b/backend/packages/harness/deerflow/config/model_config.py index 4db8594675..ba956cae62 100644 --- a/backend/packages/harness/deerflow/config/model_config.py +++ b/backend/packages/harness/deerflow/config/model_config.py @@ -1,16 +1,37 @@ -from typing import Literal +import re +from typing import Annotated, Literal -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, field_validator, model_validator + +_INTEGER_LITERAL_PATTERN = re.compile(r"[0-9]+") + + +def _accept_integer_literal_string(value: object) -> object: + """Turn an integer literal that arrived as a string into an ``int``. + + ``AppConfig.resolve_env_variables`` substitutes every ``$VAR`` reference + with the raw environment string, so a strict integer field would reject + ``requests_per_minute: $RPM`` even when ``RPM=60``. Only a plain decimal + literal is converted; everything else is returned untouched so the strict + validation behind it still rejects bools, floats and non-integer strings. + """ + if isinstance(value, str) and _INTEGER_LITERAL_PATTERN.fullmatch(value.strip()): + return int(value.strip()) + return value + + +# A strict ``int`` (no bool/float coercion) that still honors ``$VAR`` substitution. +StrictIntFromEnv = Annotated[int, BeforeValidator(_accept_integer_literal_string)] class RequestAdmissionConfig(BaseModel): """Optional per-process pacing of model requests, shared by quota group.""" model_config = ConfigDict(extra="forbid", frozen=True) - requests_per_minute: int = Field(gt=0, strict=True) + requests_per_minute: StrictIntFromEnv = Field(gt=0, strict=True) group: str | None = Field(default=None, min_length=1, max_length=100, pattern=r"^[A-Za-z0-9_.-]+$") max_wait_seconds: float = Field(default=300, gt=0, allow_inf_nan=False) - max_queue_size: int = Field(default=256, gt=0, strict=True) + max_queue_size: StrictIntFromEnv = Field(default=256, gt=0, strict=True) _EFFORT_TOKEN_PATTERN = r"^[A-Za-z0-9_.-]{1,32}$" diff --git a/backend/tests/test_model_request_admission.py b/backend/tests/test_model_request_admission.py index 3632361a42..80175ada41 100644 --- a/backend/tests/test_model_request_admission.py +++ b/backend/tests/test_model_request_admission.py @@ -5,6 +5,7 @@ import threading from concurrent.futures import ThreadPoolExecutor import pytest +import yaml from pydantic import ValidationError from deerflow.config.model_config import RequestAdmissionConfig @@ -97,6 +98,71 @@ def test_invalid_configuration(values): RequestAdmissionConfig(**values) +@pytest.mark.parametrize( + "values", + [ + {"requests_per_minute": "60"}, + {"requests_per_minute": " 60 "}, + {"requests_per_minute": 1, "max_queue_size": "512"}, + ], +) +def test_integer_literal_strings_are_accepted(values): + """``$VAR`` substitution always yields ``str``; an integer literal must still validate.""" + config = RequestAdmissionConfig(**values) + assert config.requests_per_minute == int(str(values["requests_per_minute"]).strip()) + assert config.max_queue_size == int(str(values.get("max_queue_size", 256)).strip()) + assert type(config.requests_per_minute) is int + assert type(config.max_queue_size) is int + + +@pytest.mark.parametrize( + "values", + [ + {"requests_per_minute": 60.0}, + {"requests_per_minute": "60.0"}, + {"requests_per_minute": "sixty"}, + {"requests_per_minute": ""}, + {"requests_per_minute": "0"}, + {"requests_per_minute": 1, "max_queue_size": True}, + {"requests_per_minute": 1, "max_queue_size": 256.0}, + {"requests_per_minute": 1, "max_queue_size": "-1"}, + ], +) +def test_non_integer_inputs_remain_rejected(values): + """Accepting env-substituted integer strings must not reopen the strict check for bools, floats, or other strings.""" + with pytest.raises(ValidationError): + RequestAdmissionConfig(**values) + + +def test_env_substitution_reaches_request_admission_from_config_file(monkeypatch, tmp_path): + """``config.example.yaml`` promises ``$VAR`` for every field; the two strict integers must honor it end to end.""" + from deerflow.config.app_config import AppConfig + + monkeypatch.setenv("DEER_FLOW_TEST_RPM", "60") + monkeypatch.setenv("DEER_FLOW_TEST_QUEUE", "512") + path = tmp_path / "config.yaml" + path.write_text( + yaml.safe_dump( + { + "sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}, + "models": [ + { + "name": "paced", + "use": "langchain_openai:ChatOpenAI", + "model": "gpt-test", + "request_admission": {"requests_per_minute": "$DEER_FLOW_TEST_RPM", "max_queue_size": "$DEER_FLOW_TEST_QUEUE"}, + } + ], + } + ), + encoding="utf-8", + ) + + policy = AppConfig.from_file(str(path)).get_model_config("paced").request_admission + + assert policy == RequestAdmissionConfig(requests_per_minute=60, max_queue_size=512) + + def test_sync_timeout_removes_waiter(clock, monkeypatch): limiter = admission.RequestAdmission(RequestAdmissionConfig(requests_per_minute=1, max_wait_seconds=1)) limiter.acquire()