| @@ -39,6 +39,7 @@ from motor.coordinator.models.request import RequestType | |||
| 39 | from motor.coordinator.domain.request_manager import RequestManager | 39 | from motor.coordinator.domain.request_manager import RequestManager |
| 40 | from motor.coordinator.router.dispatch import handle_request | 40 | from motor.coordinator.router.dispatch import handle_request |
| 41 | from motor.coordinator.tracer.tracing import TracerManager | 41 | from motor.coordinator.tracer.tracing import TracerManager |
| 42 | +from motor.coordinator.domain.agent_hint import parse_manage_request | ||
| 42 | 43 | ||
| 43 | logger = get_logger(__name__) | 44 | logger = get_logger(__name__) |
| 44 | 45 | ||
| @@ -48,6 +49,40 @@ def get_request_manager(request: Request) -> RequestManager: | |||
| 48 | return request.app.state.request_manager | 49 | return request.app.state.request_manager |
| 49 | 50 | ||
| 50 | 51 | ||
| 52 | +def _has_session_target_edit(body_json: dict[str, Any]) -> bool: | ||
| 53 | + """Return True if any edit in agent_hint.context_management.edits targets 'session'. | ||
| 54 | + | ||
| 55 | + An edit is considered 'session-targeted' when its `target` field is either | ||
| 56 | + explicitly 'session' or absent (the V1.1 default in agent_hint.py is | ||
| 57 | + 'session' — see _EDIT_TARGET_DEFAULT). Malformed substructures are treated | ||
| 58 | + as 'no session edit' so the original validation still triggers. | ||
| 59 | + """ | ||
| 60 | + agent_hint = body_json.get("agent_hint") | ||
| 61 | + if not isinstance(agent_hint, dict): | ||
| 62 | + return False | ||
| 63 | + context_management = agent_hint.get("context_management") | ||
| 64 | + if not isinstance(context_management, dict): | ||
| 65 | + return False | ||
| 66 | + edits = context_management.get("edits") | ||
🟡 Medium Priority
两者不一致导致:
建议:在 ![]() ![]() | |||
| 67 | + if not isinstance(edits, list): | ||
| 68 | + return False | ||
| 69 | + for edit in edits: | ||
| 70 | + if isinstance(edit, dict) and edit.get("target", "session") == "session": | ||
| 71 | + return True | ||
| 72 | + return False | ||
| 73 | + | ||
| 74 | + | ||
| 75 | +def _is_manage_request(body_json: dict[str, Any]) -> bool: | ||
| 76 | + """Return True if agent_hint.context_management.manage_request is True.""" | ||
| 77 | + agent_hint = body_json.get("agent_hint") | ||
| 78 | + if not isinstance(agent_hint, dict): | ||
| 79 | + return False | ||
| 80 | + context_management = agent_hint.get("context_management") | ||
| 81 | + if not isinstance(context_management, dict): | ||
_is_manage_request 是函数对象,条件 _is_manage_request 恒为真,与消息列表无关。应调用 _is_manage_request(body_json)。 ![]() ![]() | |||
| 82 | + return False | ||
| 83 | + return parse_manage_request(context_management.get("manage_request")) | ||
| 84 | + | ||
| 85 | + | ||
| 51 | def _validate_anthropic_request(body_json: dict[str, Any], *, require_max_tokens: bool = True) -> None: | 86 | def _validate_anthropic_request(body_json: dict[str, Any], *, require_max_tokens: bool = True) -> None: |
| 52 | """Validate Anthropic-style request body. Raises HTTPException on invalid.""" | 87 | """Validate Anthropic-style request body. Raises HTTPException on invalid.""" |
| 53 | if not body_json.get("model"): | 88 | if not body_json.get("model"): |
| @@ -91,7 +126,14 @@ def _validate_openai_request(body_json: dict[str, Any], request_type: RequestTyp | |||
| 91 | ) | 126 | ) |
| 92 | if OpenAIField.MESSAGES not in body_json: | 127 | if OpenAIField.MESSAGES not in body_json: |
| 93 | return | 128 | return |
| 94 | - if not isinstance(body_json[OpenAIField.MESSAGES], list) or len(body_json[OpenAIField.MESSAGES]) == 0: | 129 | + if not isinstance(body_json[OpenAIField.MESSAGES], list): |
| 130 | + raise HTTPException( | ||
| 131 | + status_code=status.HTTP_400_BAD_REQUEST, | ||
| 132 | + detail=f"Invalid {OpenAIField.MESSAGES} field: must be a non-empty array", | ||
| 133 | + ) | ||
| 134 | + if len(body_json[OpenAIField.MESSAGES]) == 0 and not ( | ||
| 135 | + _is_manage_request(body_json) and _has_session_target_edit(body_json) | ||
| 136 | + ): | ||
| 95 | raise HTTPException( | 137 | raise HTTPException( |
| 96 | status_code=status.HTTP_400_BAD_REQUEST, | 138 | status_code=status.HTTP_400_BAD_REQUEST, |
| 97 | detail=f"Invalid {OpenAIField.MESSAGES} field: must be a non-empty array", | 139 | detail=f"Invalid {OpenAIField.MESSAGES} field: must be a non-empty array", |
| @@ -0,0 +1,682 @@ | |||
| 1 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. | ||
| 2 | +# MindIE is licensed under Mulan PSL v2. | ||
| 3 | +# You can use this software according to the terms and conditions of the Mulan PSL v2. | ||
| 4 | +# You may obtain a copy of Mulan PSL v2 at: | ||
| 5 | +# http://license.coscl.org.cn/MulanPSL2 | ||
| 6 | +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, | ||
| 7 | +# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, | ||
| 8 | +# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. | ||
| 9 | +# See the Mulan PSL v2 for more details. | ||
| 10 | +"""AgentHint for the request layer. | ||
| 11 | + | ||
| 12 | +Defines the Pydantic schemas that parse and validate the ``agent_hint`` block | ||
| 13 | +on incoming requests — session/parent-session identifiers, cache control, | ||
| 14 | +context management, latency control, and priority control — and exposes the | ||
| 15 | +translator that converts ``CacheControl.msg_offset`` and | ||
| 16 | +``ContextEdit.start/end`` from message-level indices into PagedAttention block | ||
| 17 | +coordinates consumed by the scheduler. | ||
| 18 | +""" | ||
| 19 | + | ||
| 20 | +from typing import Any | ||
G 严重程度: 建议 问题: 本文件缺少 Mulan PSL v2 license 头(对比同 PR 新增的 block_offset_translator.py 有完整头);且 原因: 仓库 pre-commit 强制 check-header(每个 Python 文件第一行必须是 license 头)、ruff F401(未使用 import)、trailing-whitespace 钩子,三项都会导致 CI 失败(本 PR 已挂 ci-pipeline-failed 标签)。同时本 PR 未附带任何测试文件,AGENTS.md 要求"每个 motor/ 改动必须附带测试",1723 行的偏移计算算法(DSV4 marker 扫描、char→token 锚定、对齐策略)无 UT 覆盖回归风险很高。 怎么改:
![]() ![]() | |||
| 21 | +from pydantic import BaseModel, Field, field_validator | ||
| 22 | +from motor.common.logger import get_logger | ||
| 23 | + | ||
| 24 | +logger = get_logger(__name__) | ||
| 25 | + | ||
| 26 | +# Header fallback field names (lowercase to match Starlette's header normalization) | ||
| 27 | +HEADER_SESSION_ID = "x-session-id" | ||
| 28 | +HEADER_PARENT_SESSION_ID = "x-parent-session-id" | ||
| 29 | +_AGENT_HINT_KNOWN_FIELDS = frozenset( | ||
| 30 | + { | ||
| 31 | + "session_id", | ||
| 32 | + "parent_session_id", | ||
| 33 | + "cache_control", | ||
| 34 | + "context_management", | ||
| 35 | + "latency_control", | ||
| 36 | + "priority_control", | ||
| 37 | + } | ||
| 38 | +) | ||
| 39 | +_CACHE_TYPE_ALLOWED = "ephemeral" | ||
| 40 | +_CACHE_TTL_DEFAULT = 300 | ||
| 41 | +_CACHE_TTL_MIN = 60 | ||
| 42 | +_CACHE_TTL_MAX = 3600 | ||
| 43 | +_CACHE_MSG_OFFSET_DEFAULT = None | ||
| 44 | +_EDIT_TYPES_ALLOWED = frozenset({"offload", "prefetch", "evict"}) | ||
| 45 | +_EDIT_TARGETS_ALLOWED = frozenset({"session", "messages", "tools"}) | ||
| 46 | +_EDIT_TARGET_DEFAULT = "session" | ||
| 47 | +_CACHE_SERVER_ONLY_FIELDS = frozenset({"block_offset", "intra_block_offset", "token_offset"}) | ||
| 48 | +_EDIT_SERVER_ONLY_FIELDS = frozenset( | ||
| 49 | + { | ||
| 50 | + "block_start", | ||
| 51 | + "block_intra_start", | ||
| 52 | + "start_token", | ||
| 53 | + "block_end", | ||
| 54 | + "block_intra_end", | ||
| 55 | + "end_token", | ||
| 56 | + } | ||
| 57 | +) | ||
| 58 | + | ||
| 59 | + | ||
| 60 | +class CacheControl(BaseModel): | ||
| 61 | + """KV cache control.""" | ||
| 62 | + | ||
| 63 | + type: str = Field(default="ephemeral", description="Cache type; only 'ephemeral' is supported.") | ||
| 64 | + ttl: int = Field(default=_CACHE_TTL_DEFAULT, description="Cache TTL in seconds; clamped to [60, 3600].") | ||
| 65 | + msg_offset: int | None = Field( | ||
| 66 | + default=_CACHE_MSG_OFFSET_DEFAULT, | ||
| 67 | + description=( | ||
| 68 | + "1-based message index at which cache_control takes effect; None means apply to the whole conversation." | ||
| 69 | + ), | ||
| 70 | + ) | ||
| 71 | + block_offset: int | None = Field( | ||
| 72 | + default=None, | ||
| 73 | + description="Block index (token_idx // block_size); filled by attach_block_offsets server-side.", | ||
| 74 | + ) | ||
| 75 | + intra_block_offset: int | None = Field( | ||
| 76 | + default=None, | ||
| 77 | + description="Intra-block offset (token_idx % block_size); filled by attach_block_offsets server-side.", | ||
| 78 | + ) | ||
| 79 | + token_offset: int | None = Field( | ||
| 80 | + default=None, | ||
| 81 | + description="Cumulative token count (= block_offset*block_size + intra_block_offset); filled by attach_block_offsets server-side.", | ||
| 82 | + ) | ||
| 83 | + | ||
| 84 | + | ||
| 85 | + | ||
| 86 | + def _validate_type(cls, value: Any) -> str: | ||
| 87 | + if value is None or (isinstance(value, str) and value == ""): | ||
| 88 | + return _CACHE_TYPE_ALLOWED | ||
| 89 | + value = str(value) | ||
| 90 | + if value != _CACHE_TYPE_ALLOWED: | ||
| 91 | + logger.warning( | ||
| 92 | + "Invalid cache_control.type=%r; dropping cache_control (only %r is supported).", | ||
| 93 | + value, | ||
| 94 | + _CACHE_TYPE_ALLOWED, | ||
| 95 | + ) | ||
| 96 | + raise ValueError(f"unsupported cache_control.type: {value!r}") | ||
| 97 | + return value | ||
| 98 | + | ||
| 99 | + | ||
| 100 | + | ||
| 101 | + def _validate_ttl(cls, value: Any) -> int: | ||
| 102 | + try: | ||
| 103 | + value = int(value) | ||
| 104 | + except (TypeError, ValueError): | ||
| 105 | + logger.warning( | ||
| 106 | + "Invalid cache_control.ttl=%s; using default %s", | ||
| 107 | + value, | ||
| 108 | + _CACHE_TTL_DEFAULT, | ||
| 109 | + ) | ||
| 110 | + return _CACHE_TTL_DEFAULT | ||
| 111 | + if value < _CACHE_TTL_MIN or value > _CACHE_TTL_MAX: | ||
| 112 | + clamped = max(_CACHE_TTL_MIN, min(_CACHE_TTL_MAX, value)) | ||
| 113 | + logger.warning( | ||
| 114 | + "cache_control.ttl=%s out of range [%s, %s]; clamped to %s", | ||
| 115 | + value, | ||
| 116 | + _CACHE_TTL_MIN, | ||
| 117 | + _CACHE_TTL_MAX, | ||
| 118 | + clamped, | ||
| 119 | + ) | ||
| 120 | + return clamped | ||
| 121 | + return value | ||
| 122 | + | ||
| 123 | + | ||
| 124 | + | ||
| 125 | + def _validate_msg_offset(cls, value: Any) -> int | None: | ||
| 126 | + if value is None: | ||
| 127 | + return None | ||
| 128 | + try: | ||
| 129 | + return int(value) | ||
| 130 | + except (TypeError, ValueError): | ||
| 131 | + logger.warning( | ||
| 132 | + "Invalid cache_control.msg_offset=%s; treating as unset (None).", | ||
| 133 | + value, | ||
| 134 | + ) | ||
| 135 | + return None | ||
| 136 | + | ||
| 137 | + | ||
| 138 | +class ContextEdit(BaseModel): | ||
| 139 | + """Context edit operation.""" | ||
| 140 | + | ||
| 141 | + type: str = Field(..., description="One of 'offload' / 'prefetch' / 'evict'; must be specified explicitly.") | ||
| 142 | + start: int | None = Field(default=None, description="Inclusive start msg index; None = 0.") | ||
| 143 | + end: int | None = Field( | ||
| 144 | + default=None, | ||
| 145 | + description="Exclusive end msg index (Python-slice convention: edits cover messages[start:end]); None = len(messages) (include all).", | ||
| 146 | + ) | ||
| 147 | + target: str = Field( | ||
| 148 | + default=_EDIT_TARGET_DEFAULT, | ||
| 149 | + description="What to operate on; 'session' (default) / 'messages' / 'tools'.", | ||
| 150 | + ) | ||
| 151 | + block_start: int | None = Field( | ||
| 152 | + default=None, | ||
| 153 | + description="block_idx for start message index; filled by server.", | ||
| 154 | + ) | ||
| 155 | + block_intra_start: int | None = Field( | ||
| 156 | + default=None, | ||
| 157 | + description="intra_block_offset for start message index; filled by server.", | ||
| 158 | + ) | ||
| 159 | + start_token: int | None = Field( | ||
| 160 | + default=None, | ||
| 161 | + description="token_idx for start message index; filled by server.", | ||
| 162 | + ) | ||
| 163 | + block_end: int | None = Field( | ||
| 164 | + default=None, | ||
| 165 | + description="block_idx for end message index; filled by server.", | ||
| 166 | + ) | ||
| 167 | + block_intra_end: int | None = Field( | ||
| 168 | + default=None, | ||
| 169 | + description="intra_block_offset for end message index; filled by server.", | ||
| 170 | + ) | ||
| 171 | + end_token: int | None = Field( | ||
| 172 | + default=None, | ||
| 173 | + description="token_idx for end message index; filled by server.", | ||
| 174 | + ) | ||
| 175 | + | ||
| 176 | + | ||
| 177 | + | ||
| 178 | + def _validate_type(cls, value: Any) -> str: | ||
| 179 | + value = str(value) | ||
| 180 | + if value not in _EDIT_TYPES_ALLOWED: | ||
| 181 | + logger.warning( | ||
| 182 | + "Unsupported context_edit.type=%r; expected one of %s. Dropping edit.", | ||
| 183 | + value, | ||
| 184 | + sorted(_EDIT_TYPES_ALLOWED), | ||
| 185 | + ) | ||
| 186 | + raise ValueError(f"unsupported context_edit.type: {value!r}") | ||
| 187 | + return value | ||
| 188 | + | ||
| 189 | + | ||
| 190 | + | ||
| 191 | + def _validate_start(cls, value: Any) -> int | None: | ||
| 192 | + try: | ||
| 193 | + if value is None: | ||
| 194 | + return None | ||
| 195 | + value = int(value) | ||
| 196 | + except (TypeError, ValueError): | ||
| 197 | + logger.warning("Invalid context_edit.start=%s; using default %s", value, None) | ||
| 198 | + return None | ||
| 199 | + if value < 0: | ||
| 200 | + logger.warning("context_edit.start=%s less than %s", value, 0) | ||
| 201 | + return value | ||
| 202 | + | ||
| 203 | + | ||
| 204 | + | ||
| 205 | + def _validate_end(cls, value: Any) -> int | None: | ||
| 206 | + try: | ||
| 207 | + if value is None: | ||
| 208 | + return None | ||
| 209 | + value = int(value) | ||
| 210 | + except (TypeError, ValueError): | ||
| 211 | + logger.warning("Invalid context_edit.end=%s; using default %s", value, None) | ||
| 212 | + return None | ||
| 213 | + if value < 0: | ||
| 214 | + logger.warning("context_edit.end=%s less than %s", value, 0) | ||
| 215 | + return value | ||
| 216 | + | ||
| 217 | + | ||
| 218 | + | ||
| 219 | + def _validate_target(cls, value: Any) -> str: | ||
| 220 | + if value is None: | ||
| 221 | + return _EDIT_TARGET_DEFAULT | ||
| 222 | + value = str(value) | ||
| 223 | + if value not in _EDIT_TARGETS_ALLOWED: | ||
| 224 | + logger.warning( | ||
| 225 | + "Invalid context_edit.target=%r; using default %r.", | ||
| 226 | + value, | ||
| 227 | + _EDIT_TARGET_DEFAULT, | ||
| 228 | + ) | ||
| 229 | + return _EDIT_TARGET_DEFAULT | ||
| 230 | + return value | ||
| 231 | + | ||
| 232 | + | ||
| 233 | +def parse_manage_request(value: Any) -> bool: | ||
| 234 | + """Parse a raw `manage_request` value into bool. | ||
| 235 | + | ||
| 236 | + Accepts JSON bool, 0/1 integers, and case-insensitive 'true'/'false' strings. | ||
| 237 | + Any other type falls back to False (with a warning logged). | ||
| 238 | + """ | ||
| 239 | + if isinstance(value, bool): | ||
| 240 | + return value | ||
| 241 | + if isinstance(value, int) and value in (0, 1): | ||
| 242 | + return bool(value) | ||
| 243 | + if isinstance(value, str) and value.lower() in ("true", "false"): | ||
| 244 | + return value.lower() == "true" | ||
| 245 | + logger.warning( | ||
| 246 | + "Invalid context_management.manage_request=%r; using False", | ||
| 247 | + value, | ||
| 248 | + ) | ||
| 249 | + return False | ||
| 250 | + | ||
| 251 | + | ||
| 252 | +class ContextManagement(BaseModel): | ||
| 253 | + """Context management.""" | ||
| 254 | + | ||
| 255 | + manage_request: bool = Field( | ||
| 256 | + default=False, | ||
| 257 | + description=( | ||
| 258 | + "True if this is a KVC management request; the request body itself " | ||
| 259 | + "is not executed, only the context edits are processed." | ||
| 260 | + ), | ||
| 261 | + ) | ||
| 262 | + edits: list[ContextEdit] = Field(default_factory=list, description="List of context edit operations.") | ||
| 263 | + | ||
| 264 | + | ||
| 265 | + | ||
| 266 | + def _validate_manage_request(cls, value: Any) -> bool: | ||
| 267 | + return parse_manage_request(value) | ||
| 268 | + | ||
| 269 | + | ||
| 270 | +class LatencyControl(BaseModel): | ||
| 271 | + """Latency/SLO hint (design-only).""" | ||
| 272 | + | ||
| 273 | + latency_sensitivity: int | None = Field(default=None, description="Latency sensitivity hint (ms).") | ||
| 274 | + # More fields may be added in future versions. | ||
| 275 | + | ||
| 276 | + | ||
| 277 | +class PriorityControl(BaseModel): | ||
| 278 | + """Priority hint (design-only).""" | ||
| 279 | + | ||
| 280 | + priority: int | None = Field(default=None, description="Priority hint; higher value means higher priority.") | ||
| 281 | + # More fields may be added in future versions. | ||
| 282 | + | ||
| 283 | + | ||
| 284 | +class AgentHintInfo(BaseModel): | ||
| 285 | + """ | ||
| 286 | + Structured info parsed from the OpenAI request's agent_hint. | ||
| 287 | + | ||
| 288 | + In the minimal version, only session_id / parent_session_id / cache_control | ||
| 289 | + are consumed by the Scheduler. context_management / latency_control / | ||
| 290 | + priority_control are parsed and populated for forward compatibility but | ||
| 291 | + do not currently drive scheduling decisions. | ||
| 292 | + """ | ||
| 293 | + | ||
| 294 | + session_id: str | None = Field(default=None, description="Session ID; supplied by client or auto-generated.") | ||
| 295 | + parent_session_id: str | None = Field( | ||
| 296 | + default=None, description="Parent session ID; usually the main agent's session." | ||
| 297 | + ) | ||
| 298 | + cache_control: CacheControl | None = Field(default=None, description="KV cache control hint.") | ||
| 299 | + context_management: ContextManagement | None = Field( | ||
| 300 | + default=None, description="Context management hint (design-only)." | ||
| 301 | + ) | ||
| 302 | + latency_control: LatencyControl | None = Field(default=None, description="Latency control hint (design-only).") | ||
| 303 | + priority_control: PriorityControl | None = Field(default=None, description="Priority control hint (design-only).") | ||
| 304 | + raw_extra: dict | None = Field( | ||
| 305 | + default_factory=dict, description="Pass-through dict for unrecognized extension fields in agent_hint." | ||
| 306 | + ) | ||
| 307 | + | ||
| 308 | + | ||
| 309 | +def _parse_cache_control( | ||
| 310 | + data: Any, | ||
| 311 | + messages: list | None = None, | ||
| 312 | +) -> CacheControl | None: | ||
| 313 | + if not isinstance(data, dict): | ||
| 314 | + return None | ||
| 315 | + data = {k: v for k, v in data.items() if k not in _CACHE_SERVER_ONLY_FIELDS} | ||
| 316 | + try: | ||
| 317 | + cache_control = CacheControl(**data) | ||
| 318 | + except Exception as e: | ||
| 319 | + logger.warning("Failed to parse cache_control: %s", e) | ||
| 320 | + return None | ||
| 321 | + | ||
| 322 | + n = len(messages) if isinstance(messages, list) else None | ||
| 323 | + msg_offset = cache_control.msg_offset | ||
| 324 | + if msg_offset is None: | ||
| 325 | + if n is not None and n > 0: | ||
| 326 | + cache_control.msg_offset = n | ||
| 327 | + else: | ||
| 328 | + if msg_offset < 1: | ||
G 严重程度: 建议 问题: 原因: 实测 怎么改: 修正 docstring 为 "1-based(TTL 保护 messages[0, msg_offset) 前缀边界)",或兼容 msg_offset=0 并给出明确告警。 ![]() ![]() | |||
| 329 | + logger.warning( | ||
| 330 | + "cache_control.msg_offset=%s out of range [1, %s]; dropping cache_control.", | ||
| 331 | + msg_offset, | ||
| 332 | + (n) if n is not None else "len(messages)", | ||
| 333 | + ) | ||
| 334 | + return None | ||
| 335 | + if n is not None and msg_offset > n: | ||
| 336 | + logger.warning( | ||
| 337 | + "cache_control.msg_offset=%s out of range [0, %s]; dropping cache_control.", | ||
| 338 | + msg_offset, | ||
| 339 | + n, | ||
| 340 | + ) | ||
| 341 | + return None | ||
| 342 | + return cache_control | ||
| 343 | + | ||
| 344 | + | ||
| 345 | +def _validate_edit_indices( | ||
| 346 | + edit: ContextEdit, | ||
| 347 | + messages: list | None, | ||
| 348 | + tools: list | None = None, | ||
| 349 | +) -> bool: | ||
| 350 | + """ | ||
| 351 | + Validate ContextEdit.start / ContextEdit.end: | ||
| 352 | + | ||
| 353 | + - start / end must be >= 0. | ||
| 354 | + - Bounds: | ||
| 355 | + * target="tools": [0, len(tools)]. | ||
| 356 | + * otherwise: [0, len(messages)]. | ||
| 357 | + - start < end when both are non-None; start >= end is invalid. | ||
| 358 | + | ||
| 359 | + Returns False (with WARNING) when any check fails; callers should drop | ||
| 360 | + the entry. | ||
| 361 | + """ | ||
| 362 | + msg_count: int | None | ||
| 363 | + if isinstance(messages, list): | ||
| 364 | + msg_count = len(messages) | ||
| 365 | + else: | ||
| 366 | + msg_count = None | ||
| 367 | + | ||
| 368 | + # target="tools" validates indices against len(tools), not len(messages). | ||
| 369 | + if edit.target == "tools" and isinstance(tools, list): | ||
| 370 | + bound = len(tools) | ||
| 371 | + bound_name = "len(tools)" | ||
| 372 | + use_tools_bound = True | ||
| 373 | + else: | ||
| 374 | + bound = msg_count | ||
| 375 | + bound_name = "len(messages)" | ||
| 376 | + use_tools_bound = False | ||
| 377 | + | ||
| 378 | + start = edit.start | ||
| 379 | + end = edit.end | ||
| 380 | + dropped = False | ||
| 381 | + | ||
| 382 | + if start is not None and start < 0: | ||
| 383 | + logger.warning( | ||
| 384 | + "context_edit.start=%s less than 0; dropping edit (type=%s, target=%s).", | ||
| 385 | + start, | ||
| 386 | + edit.type, | ||
| 387 | + edit.target, | ||
| 388 | + ) | ||
| 389 | + dropped = True | ||
| 390 | + if end is not None and end < 0: | ||
| 391 | + logger.warning( | ||
| 392 | + "context_edit.end=%s less than 0; dropping edit (type=%s, target=%s).", | ||
| 393 | + end, | ||
| 394 | + edit.type, | ||
| 395 | + edit.target, | ||
| 396 | + ) | ||
| 397 | + dropped = True | ||
| 398 | + | ||
| 399 | + if bound is not None: | ||
| 400 | + if start is not None and start > bound: | ||
| 401 | + logger.warning( | ||
| 402 | + "context_edit.start=%s > %s=%s; dropping edit (type=%s, target=%s).", | ||
| 403 | + start, | ||
| 404 | + bound_name, | ||
| 405 | + bound, | ||
| 406 | + edit.type, | ||
| 407 | + edit.target, | ||
| 408 | + ) | ||
| 409 | + dropped = True | ||
| 410 | + if end is not None and end > bound: | ||
| 411 | + logger.warning( | ||
| 412 | + "context_edit.end=%s > %s=%s; dropping edit (type=%s, target=%s).", | ||
| 413 | + end, | ||
| 414 | + bound_name, | ||
| 415 | + bound, | ||
| 416 | + edit.type, | ||
| 417 | + edit.target, | ||
| 418 | + ) | ||
| 419 | + dropped = True | ||
| 420 | + | ||
| 421 | + if start is not None and end is not None and start >= end: | ||
| 422 | + logger.warning( | ||
| 423 | + "context_edit.start=%s >= end=%s; dropping edit (type=%s, target=%s).", | ||
| 424 | + start, | ||
| 425 | + end, | ||
| 426 | + edit.type, | ||
| 427 | + edit.target, | ||
| 428 | + ) | ||
| 429 | + dropped = True | ||
| 430 | + | ||
| 431 | + # target="tools" with empty tools: bounds are meaningless; warn here for parity | ||
| 432 | + # with the offset translator, which also short-circuits. | ||
| 433 | + if use_tools_bound and bound == 0: | ||
| 434 | + logger.warning( | ||
| 435 | + "context_edit target='tools' but tools is empty; edit (type=%s) will be a no-op downstream.", | ||
| 436 | + edit.type, | ||
| 437 | + ) | ||
| 438 | + | ||
| 439 | + return not dropped | ||
| 440 | + | ||
| 441 | + | ||
| 442 | +def _parse_context_management( | ||
| 443 | + data: Any, | ||
| 444 | + messages: list | None = None, | ||
| 445 | + tools: list | None = None, | ||
| 446 | +) -> ContextManagement | None: | ||
| 447 | + if not isinstance(data, dict): | ||
| 448 | + return None | ||
| 449 | + try: | ||
| 450 | + try: | ||
| 451 | + manage_request = parse_manage_request(data.get("manage_request", False)) | ||
| 452 | + except TypeError: | ||
| 453 | + manage_request = False | ||
| 454 | + | ||
| 455 | + edits_raw = data.get("edits") or [] | ||
| 456 | + edits: list[ContextEdit] = [] | ||
| 457 | + for entry in edits_raw: | ||
| 458 | + if not isinstance(entry, dict): | ||
| 459 | + logger.warning( | ||
| 460 | + "Ignoring non-dict context_management.edits entry: %r", | ||
| 461 | + entry, | ||
| 462 | + ) | ||
| 463 | + continue | ||
| 464 | + forged = sorted(k for k in entry if k in _EDIT_SERVER_ONLY_FIELDS) | ||
| 465 | + if forged: | ||
| 466 | + logger.warning( | ||
| 467 | + "context_management.edits entry contains server-only " | ||
| 468 | + "offset field(s) %s; dropping entire edit (type=%r) to " | ||
| 469 | + "prevent client forgery.", | ||
| 470 | + forged, | ||
| 471 | + entry.get("type"), | ||
| 472 | + ) | ||
| 473 | + continue | ||
| 474 | + try: | ||
| 475 | + edit = ContextEdit(**entry) | ||
| 476 | + except Exception as exc: | ||
| 477 | + logger.warning( | ||
| 478 | + "Failed to parse context_management.edits entry: %s", | ||
| 479 | + exc, | ||
| 480 | + ) | ||
| 481 | + continue | ||
| 482 | + | ||
| 483 | + if not _validate_edit_indices(edit, messages, tools): | ||
| 484 | + continue | ||
| 485 | + edits.append(edit) | ||
| 486 | + | ||
| 487 | + if not manage_request and not edits: | ||
| 488 | + return None | ||
| 489 | + | ||
| 490 | + if manage_request and not edits: | ||
| 491 | + logger.warning( | ||
| 492 | + "context_management.manage_request=true with no usable edits; " | ||
| 493 | + "dropping context_management (set to None)." | ||
| 494 | + ) | ||
| 495 | + return None | ||
| 496 | + return ContextManagement(manage_request=manage_request, edits=edits) | ||
| 497 | + except Exception as exc: | ||
| 498 | + logger.warning("Failed to parse context_management: %s", exc) | ||
| 499 | + return None | ||
| 500 | + | ||
| 501 | + | ||
| 502 | +def _parse_priority_control(data: dict) -> PriorityControl | None: | ||
| 503 | + priority_control = None | ||
| 504 | + if isinstance(data, dict): | ||
| 505 | + try: | ||
| 506 | + priority_control = PriorityControl(priority=data.get("priority")) | ||
| 507 | + except Exception as e: | ||
| 508 | + logger.warning("Failed to parse priority_control: %s", e) | ||
| 509 | + return priority_control | ||
| 510 | + | ||
| 511 | + | ||
| 512 | +def _is_valid_session_id(value: Any) -> bool: | ||
| 513 | + """Valid session id: must be a non-empty string. None / empty / non-string all invalid.""" | ||
| 514 | + return isinstance(value, str) and value != "" | ||
| 515 | + | ||
| 516 | + | ||
| 517 | +def _resolve_session_ids( | ||
| 518 | + agent_hint_data: Any, | ||
| 519 | + headers: dict | None = None, | ||
| 520 | +) -> tuple[str | None, str | None]: | ||
| 521 | + """ | ||
| 522 | + Resolve and normalize session_id / parent_session_id. | ||
| 523 | + | ||
| 524 | + Per-field priority: | ||
| 525 | + 1. The field in agent_hint (request body). | ||
| 526 | + 2. Otherwise fall back to x-session-id / x-parent-session-id header | ||
| 527 | + (Starlette normalizes header keys to lowercase). | ||
| 528 | + | ||
| 529 | + Complement rule (single-agent clients typically send only one): | ||
| 530 | + - Only session_id valid -> parent_session_id := session_id. | ||
| 531 | + - Only parent_session_id valid -> session_id := parent_session_id. | ||
| 532 | + - Both valid -> keep as-is (parent/child hierarchy is | ||
| 533 | + the client's decision). | ||
| 534 | + - Both invalid -> normalize to None; do not generate IDs. | ||
| 535 | + If the client attempted to send them | ||
| 536 | + (body key present or headers present), | ||
| 537 | + emit a WARNING. | ||
| 538 | + | ||
| 539 | + Returns: | ||
| 540 | + (session_id, parent_session_id): both non-empty strings, or both None. | ||
| 541 | + """ | ||
| 542 | + # Non-dict agent_hint (e.g. str/list): treat as empty dict to keep .get safe. | ||
tools 为空时走 msg 分支校验索引,行为不一致,建议显式判断并跳过。 ![]() ![]() | |||
| 543 | + if not isinstance(agent_hint_data, dict): | ||
| 544 | + agent_hint_data = {} | ||
| 545 | + | ||
| 546 | + session_id = agent_hint_data.get("session_id") | ||
| 547 | + parent_session_id = agent_hint_data.get("parent_session_id") | ||
| 548 | + # Distinguish "client didn't send" from "client sent but invalid"; only the | ||
| 549 | + # latter (or present headers) should warn — avoids log spam on plain | ||
| 550 | + # requests without agent_hint. | ||
| 551 | + body_has_any_session_id = "session_id" in agent_hint_data or "parent_session_id" in agent_hint_data | ||
| 552 | + | ||
| 553 | + # Fall back to headers when a field's body value is invalid. | ||
| 554 | + if headers: | ||
| 555 | + if not _is_valid_session_id(session_id): | ||
| 556 | + session_id = headers.get(HEADER_SESSION_ID) | ||
P2:真实请求中的 Starlette/FastAPI headers 会把键规范化为小写(例如 x-session-id),但这里使用大小写敏感的 X-Session-Id / X-Parent-Session-Id 读取,导致仅通过 Header 传入的 session ID 被丢弃。当前 UT 传入的是手工构造的大写 dict,未覆盖真实 Request。建议统一使用小写键或不区分大小写的 Header API,并补集成测试。 ![]() ![]() | |||
| 557 | + if not _is_valid_session_id(parent_session_id): | ||
| 558 | + parent_session_id = headers.get(HEADER_PARENT_SESSION_ID) | ||
| 559 | + | ||
| 560 | + # Apply complement rule to fill in any missing field. | ||
| 561 | + session_valid = _is_valid_session_id(session_id) | ||
| 562 | + parent_valid = _is_valid_session_id(parent_session_id) | ||
| 563 | + if session_valid and not parent_valid: | ||
| 564 | + logger.warning("parent_session_id is invalid(missing/empty/non-string), set parent_session_id = session_id") | ||
| 565 | + parent_session_id = session_id | ||
| 566 | + elif parent_valid and not session_valid: | ||
| 567 | + logger.warning("session_id is invalid(missing/empty/non-string), set session_id = parent_session_id") | ||
| 568 | + session_id = parent_session_id | ||
| 569 | + elif not session_valid and not parent_valid: | ||
| 570 | + # Neither field has a usable value: normalize to None (overwrites dirty | ||
| 571 | + # values such as empty strings or non-strings). | ||
| 572 | + if body_has_any_session_id or headers: | ||
G 严重程度: 建议 问题: 告警条件 原因: 实测:一个完全不带 agent_hint 的普通请求即触发 怎么改: 改为只检查 session 相关 header:
![]() ![]() | |||
| 573 | + logger.warning( | ||
| 574 | + "session_id and parent_session_id are both missing/empty/non-string; passthrough (both will be None)" | ||
| 575 | + ) | ||
| 576 | + session_id = None | ||
| 577 | + parent_session_id = None | ||
| 578 | + | ||
| 579 | + return session_id, parent_session_id | ||
| 580 | + | ||
| 581 | + | ||
| 582 | +def parse_agent_hint( | ||
| 583 | + request_json: dict, | ||
| 584 | + headers: dict | None = None, | ||
| 585 | +) -> AgentHintInfo: | ||
| 586 | + agent_hint_data = request_json.get("agent_hint", {}) if isinstance(request_json, dict) else {} | ||
| 587 | + if not isinstance(agent_hint_data, dict): | ||
| 588 | + # agent_hint exists but is not a mapping (e.g. str/list) — treat as empty. | ||
| 589 | + logger.warning( | ||
| 590 | + "agent_hint is not a dict (got %s); falling back to defaults", | ||
| 591 | + type(agent_hint_data).__name__, | ||
| 592 | + ) | ||
| 593 | + agent_hint_data = {} | ||
| 594 | + | ||
| 595 | + session_id, parent_session_id = _resolve_session_ids(agent_hint_data, headers) | ||
| 596 | + | ||
| 597 | + messages = request_json.get("messages") if isinstance(request_json, dict) else None | ||
| 598 | + tools = request_json.get("tools") if isinstance(request_json, dict) else None | ||
| 599 | + | ||
| 600 | + cache_control = _parse_cache_control( | ||
| 601 | + agent_hint_data.get("cache_control"), | ||
| 602 | + messages=messages, | ||
| 603 | + ) | ||
| 604 | + | ||
| 605 | + context_management = _parse_context_management( | ||
| 606 | + agent_hint_data.get("context_management"), | ||
| 607 | + messages=messages, | ||
新文件未包含 Mulan PSL v2 license 头,违反项目硬性约束。 ![]() ![]() | |||
| 608 | + tools=tools, | ||
| 609 | + ) | ||
| 610 | + | ||
| 611 | + latency_control = None | ||
| 612 | + lc_data = agent_hint_data.get("latency_control") | ||
| 613 | + if isinstance(lc_data, dict): | ||
| 614 | + try: | ||
| 615 | + latency_control = LatencyControl(latency_sensitivity=lc_data.get("latency_sensitivity")) | ||
| 616 | + except Exception as e: | ||
| 617 | + logger.warning("Failed to parse latency_control: %s", e) | ||
| 618 | + | ||
| 619 | + priority_control = _parse_priority_control(agent_hint_data.get("priority_control")) | ||
| 620 | + | ||
| 621 | + raw_extra = {} | ||
ensure_minimum_messages 修改了局部列表但未回写 request_json,下游可能读不到。 ![]() ![]() | |||
| 622 | + for key, value in agent_hint_data.items(): | ||
| 623 | + if key not in _AGENT_HINT_KNOWN_FIELDS: | ||
| 624 | + raw_extra[key] = value | ||
| 625 | + | ||
| 626 | + return AgentHintInfo( | ||
| 627 | + session_id=session_id, | ||
| 628 | + parent_session_id=parent_session_id, | ||
| 629 | + cache_control=cache_control, | ||
| 630 | + context_management=context_management, | ||
| 631 | + latency_control=latency_control, | ||
| 632 | + priority_control=priority_control, | ||
| 633 | + raw_extra=raw_extra, | ||
| 634 | + ) | ||
| 635 | + | ||
| 636 | + | ||
| 637 | +_DEFAULT_SYSTEM_MESSAGE: dict[str, str] = {"role": "system", "content": "context management messages"} | ||
| 638 | +_DEFAULT_USER_MESSAGE: dict[str, str] = {"role": "user", "content": "context management messages"} | ||
| 639 | + | ||
| 640 | + | ||
| 641 | +def ensure_minimum_messages_for_session_edits( | ||
| 642 | + request_json: dict, | ||
| 643 | + req_data: dict, | ||
| 644 | +) -> None: | ||
| 645 | + agent_hint_data = request_json.get("agent_hint", {}) if isinstance(request_json, dict) else {} | ||
| 646 | + if not isinstance(agent_hint_data, dict): | ||
| 647 | + return | ||
| 648 | + cm_data = agent_hint_data.get("context_management") | ||
| 649 | + if not isinstance(cm_data, dict): | ||
| 650 | + return | ||
| 651 | + | ||
| 652 | + try: | ||
| 653 | + manage_request = parse_manage_request(cm_data.get("manage_request", False)) | ||
| 654 | + except Exception: | ||
| 655 | + manage_request = False | ||
| 656 | + if not manage_request: | ||
| 657 | + return | ||
| 658 | + | ||
| 659 | + edits_raw = cm_data.get("edits") or [] | ||
| 660 | + if not any(isinstance(e, dict) and e.get("target", "session") == "session" for e in edits_raw): | ||
| 661 | + return | ||
| 662 | + | ||
| 663 | + messages = request_json.get("messages") | ||
| 664 | + if messages is None: | ||
| 665 | + new_list = [_DEFAULT_SYSTEM_MESSAGE, _DEFAULT_USER_MESSAGE] | ||
G 严重程度: 提示 问题: 注入的假消息 原因: 若引擎不识别 怎么改: 确认引擎对 manage_request 的处理契约;必要时把 session_id 缺失但含 context_management 的请求也纳入 offset 计算,并在接口文档中明确该前置条件。 ![]() ![]() | |||
| 666 | + request_json["messages"] = new_list | ||
| 667 | + req_data["messages"] = new_list | ||
| 668 | + logger.warning( | ||
| 669 | + "Injecting default system and user message into empty/missing messages " | ||
| 670 | + "list for manage_request=true session-targeted edit; " | ||
| 671 | + "prevents apply_chat_template out-of-bounds crash downstream." | ||
| 672 | + ) | ||
| 673 | + return | ||
| 674 | + if isinstance(messages, list) and len(messages) == 0: | ||
| 675 | + messages.append(_DEFAULT_SYSTEM_MESSAGE) | ||
| 676 | + messages.append(_DEFAULT_USER_MESSAGE) | ||
| 677 | + logger.warning( | ||
| 678 | + "Injecting default system and user message into empty messages list " | ||
| 679 | + "for manage_request=true session-targeted edit; " | ||
| 680 | + "prevents apply_chat_template out-of-bounds crash downstream." | ||
| 681 | + ) | ||
| 682 | + return | ||
| @@ -0,0 +1,1202 @@ | |||
| 1 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. | ||
| 2 | +# MindIE is licensed under Mulan PSL v2. | ||
| 3 | +# You can use this software according to the terms and conditions of the Mulan PSL v2. | ||
| 4 | +# You may obtain a copy of Mulan PSL v2 at: | ||
| 5 | +# http://license.coscl.org.cn/MulanPSL2 | ||
| 6 | +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, | ||
| 7 | +# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, | ||
| 8 | +# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. | ||
| 9 | +# See the Mulan PSL v2 for more details. | ||
| 10 | + | ||
| 11 | +"""Translate ``CacheControl.msg_offset`` and ``ContextEdit.start/end`` from | ||
| 12 | +message-level indices into PagedAttention block coordinates. | ||
| 13 | +""" | ||
| 14 | + | ||
| 15 | +from __future__ import annotations | ||
| 16 | +from abc import ABC, abstractmethod | ||
| 17 | +from typing import Any, Callable | ||
| 18 | +from motor.common.logger import get_logger | ||
| 19 | +from motor.coordinator.api_client.conductor_api_client import ConductorApiClient | ||
| 20 | + | ||
| 21 | +logger = get_logger(__name__) | ||
| 22 | + | ||
| 23 | +_EDIT_TYPE_OFFSET_COMPUTERS: dict[str, Callable] = {} | ||
| 24 | + | ||
| 25 | + | ||
| 26 | +# DSV4 (vLLM DeepseekV4Tokenizer) chat template uses 3 special tokens as | ||
| 27 | +# message-boundary markers: | ||
| 28 | +# <|User|> : start of user/developer/tool-merged messages. | ||
| 29 | +# <|Assistant|> : transition at end of user-like messages (also the | ||
| 30 | +# logical start of an assistant message). | ||
| 31 | +# <|end of sentence|> : actual end of an assistant message. | ||
| 32 | +# Note: DSV4 user/system/developer messages carry NO eos; only assistant does. | ||
| 33 | +# Prefix boundaries are therefore tracked along two separate rails: the | ||
| 34 | +# "role-start marker" rail and the "eos" rail. | ||
| 35 | +_DSV4_USER_MARKER: str = "<|User|>" | ||
| 36 | +_DSV4_ASSISTANT_MARKER: str = "<|Assistant|>" | ||
| 37 | +_DSV4_EOS_MARKER: str = "<|end▁of▁sentence|>" | ||
| 38 | + | ||
| 39 | +_CHAT_MARKER_CANDIDATES = ( | ||
| 40 | + "<|im_start|>", # Qwen / Qwen2 / Qwen3 | ||
| 41 | + "<|begin_of_text|>", # Llama-3 | ||
| 42 | + "<|user|>", # Some chat-tuned models | ||
| 43 | + "<|sep|>", # Mistral / Mixtral | ||
| 44 | +) | ||
| 45 | + | ||
| 46 | + | ||
| 47 | +def _is_dsv4_tokenizer(tokenizer: Any) -> bool: | ||
| 48 | + """Detect vLLM's DeepSeekV4 tokenizer wrapper. | ||
| 49 | + | ||
| 50 | + vLLM's deepseek_v4.py:80 sets ``_DeepseekV4Tokenizer.__name__ = | ||
| 51 | + f"DSV4{...}"`` as a stable contract. But ``from_pretrained`` wraps the | ||
| 52 | + class again via ``get_cached_tokenizer``, which renames it to | ||
| 53 | + ``f"Cached{...}"`` — so the leaf class becomes ``CachedDSV4TokenizersBackend`` | ||
| 54 | + and a leaf-only check would miss. We walk the whole MRO to stay robust | ||
| 55 | + against extra wrapping layers. | ||
| 56 | + """ | ||
| 57 | + if tokenizer is None: | ||
| 58 | + return False | ||
| 59 | + try: | ||
| 60 | + mro = type(tokenizer).__mro__ | ||
| 61 | + except Exception: # noqa: BLE001 — defensive: type() shouldn't raise | ||
| 62 | + return False | ||
| 63 | + return any((getattr(cls, "__name__", "") or "").startswith("DSV4") for cls in mro) | ||
| 64 | + | ||
| 65 | + | ||
| 66 | +def _try_get_token_id(tokenizer: Any, tok_str: str) -> int | None: | ||
| 67 | + """Pure helper for the DSV4 markers. | ||
| 68 | + | ||
| 69 | + Returns the single-token id for ``tok_str``, or None if it is not a | ||
| 70 | + single token (or tokenization fails). | ||
| 71 | + """ | ||
| 72 | + if tokenizer is None: | ||
| 73 | + return None | ||
| 74 | + try: | ||
| 75 | + tid = tokenizer.convert_tokens_to_ids(tok_str) | ||
| 76 | + except Exception: # noqa: BLE001 — tokenizer API quirks | ||
| 77 | + return None | ||
| 78 | + if tid is None or getattr(tokenizer, "unk_token_id", None) == tid: | ||
| 79 | + return None | ||
| 80 | + try: | ||
| 81 | + enc = tokenizer.encode(tok_str, add_special_tokens=False) | ||
| 82 | + except Exception: # noqa: BLE001 | ||
| 83 | + return None | ||
| 84 | + return tid if (len(enc) == 1 and enc[0] == tid) else None | ||
| 85 | + | ||
| 86 | + | ||
| 87 | +# --------------------------------------------------------------------------- | ||
| 88 | +# Model-family block-offset calculators | ||
| 89 | +# --------------------------------------------------------------------------- | ||
| 90 | +# | ||
| 91 | +# Each model family has its own chat-template rendering, so the prefix / tools | ||
| 92 | +# offset algorithm is per-family. Encapsulated as ``BaseBlockOffsetCalculator`` | ||
| 93 | +# subclasses; adding a new model means one subclass + one factory line. | ||
| 94 | +# ``attach_block_offsets`` only calls ``preprocess / compute_messages / | ||
| 95 | +# compute_tools`` and is agnostic to model differences. | ||
| 96 | +# | ||
| 97 | +# Design choice: a single calculator (covering preprocess + messages + tools) | ||
| 98 | +# rather than two split calculators keeps "preprocess once" locked inside one | ||
| 99 | +# object, avoiding skip-or-duplicate inconsistency when prefix and tools would | ||
| 100 | +# otherwise run preprocessing independently. | ||
| 101 | + | ||
| 102 | + | ||
| 103 | +class BaseBlockOffsetCalculator(ABC): | ||
| 104 | + """Block-offset calculator for a model family. | ||
| 105 | + | ||
| 106 | + Owns its full chat-template logic (messages-to-token and tools-to-token | ||
| 107 | + offsets) inside the class, so callers only need to invoke the three | ||
| 108 | + public methods: ``preprocess / compute_messages / compute_tools``. | ||
| 109 | + """ | ||
| 110 | + | ||
| 111 | + | ||
| 112 | + def preprocess(self, messages, tools): | ||
| 113 | + """Model-family preprocessing of messages/tools. Default is identity; | ||
| 114 | + DSV4 flattens multipart content and sorts tool results. Returns | ||
| 115 | + ``(messages, tools)``. | ||
| 116 | + """ | ||
| 117 | + ... | ||
| 118 | + | ||
| 119 | + | ||
| 120 | + def compute_messages(self, messages, tools, tokenizer): | ||
| 121 | + """Returns a monotonic non-decreasing list of length ``len(messages)``, | ||
| 122 | + or ``None`` on failure (caller falls back to per-call partial tokenize). | ||
| 123 | + """ | ||
| 124 | + ... | ||
| 125 | + | ||
| 126 | + | ||
| 127 | + def compute_tools(self, messages, tools, tokenizer): | ||
| 128 | + """Returns a list of length ``len(tools) + 1`` (each tool's start | ||
| 129 | + plus a trailing sentinel), or ``None`` on failure. | ||
| 130 | + """ | ||
| 131 | + ... | ||
| 132 | + | ||
| 133 | + | ||
| 134 | +class Dsv4BlockOffsetCalculator(BaseBlockOffsetCalculator): | ||
| 135 | + """DSV4-specific calculator. | ||
| 136 | + | ||
| 137 | + Encapsulates the full DSV4 algorithm: | ||
| 138 | + - ``_try_build_messages_offset_table_via_marker_dsv4``: | ||
| 139 | + marker-scan fast path (<|User|> / <|Assistant|> / <|end of sentence|>). | ||
| 140 | + - ``_build_messages_offset_table_dsv4``: | ||
| 141 | + thin wrapper around the fast path. Returns ``None`` on failure — | ||
| 142 | + DSV4 tokenization is deterministic, so a missing marker means a | ||
| 143 | + template mismatch (no iterative fallback). | ||
| 144 | + - ``_build_tools_offset_table_dsv4``: | ||
| 145 | + single-pass tokenize + decode + name_marker + reverse ``{`` scan. | ||
| 146 | + - ``_resolve_tools_end_pos_dsv4``: | ||
| 147 | + tools-segment end sentinel (char position of the first ``<|User|>``); | ||
| 148 | + falls back to ``len(full_str)`` when it returns ``None``. | ||
| 149 | + """ | ||
| 150 | + | ||
| 151 | + def preprocess(self, messages, tools): | ||
| 152 | + from motor.coordinator.scheduler.policy.utils import ( | ||
| 153 | + preprocess_messages_for_dsv4, | ||
| 154 | + ) | ||
| 155 | + | ||
| 156 | + return preprocess_messages_for_dsv4(messages, tools) | ||
| 157 | + | ||
| 158 | + def compute_messages(self, messages, tools, tokenizer): | ||
| 159 | + return self._build_messages_offset_table_dsv4(messages, tools, tokenizer) | ||
| 160 | + | ||
| 161 | + def compute_tools(self, messages, tools, tokenizer): | ||
| 162 | + return self._build_tools_offset_table_dsv4(messages, tools, tokenizer) | ||
| 163 | + | ||
| 164 | + def _build_messages_offset_table_dsv4( | ||
| 165 | + self, | ||
| 166 | + messages, | ||
| 167 | + tools, | ||
| 168 | + tokenizer, | ||
| 169 | + ) -> list[int] | None: | ||
| 170 | + """DSV4 messages offset table: marker-only fast path; returns ``None`` on failure.""" | ||
| 171 | + fast = self._try_build_messages_offset_table_via_marker_dsv4( | ||
| 172 | + messages, | ||
| 173 | + tools, | ||
| 174 | + tokenizer, | ||
| 175 | + ) | ||
| 176 | + if fast is not None: | ||
| 177 | + return fast | ||
| 178 | + logger.debug( | ||
| 179 | + "dsv4 marker-based messages table unavailable (N=%d)", | ||
| 180 | + len(messages or []), | ||
| 181 | + ) | ||
| 182 | + return None | ||
| 183 | + | ||
| 184 | + def _try_build_messages_offset_table_via_marker_dsv4( | ||
| 185 | + self, | ||
| 186 | + messages, | ||
| 187 | + tools, | ||
| 188 | + tokenizer, | ||
| 189 | + ) -> list[int] | None: | ||
| 190 | + """DSV4 marker-scan fast path. | ||
| 191 | + | ||
| 192 | + Rules: | ||
| 193 | + - End of a user-like message (user/developer/tool/merged-tool) = | ||
| 194 | + the next role marker in the token stream (``<|User|>`` or | ||
| 195 | + ``<|Assistant|>``, whichever comes first). This handles both | ||
| 196 | + user→assistant (transition) and user→user (next ``<|User|>``). | ||
| 197 | + - End of an assistant message = the next ``<|end of sentence|>`` + 1. | ||
| 198 | + - When ``messages[0]`` is system, the system segment ends at the | ||
| 199 | + first ``<|User|>``. | ||
| 200 | + | ||
| 201 | + Returns ``None`` on any failure (missing marker, count mismatch, | ||
| 202 | + unknown role, tokenize error); callers decide what to do next. | ||
| 203 | + """ | ||
| 204 | + if not messages or not isinstance(messages, list): | ||
| 205 | + return None | ||
| 206 | + | ||
| 207 | + eos_id = _try_get_token_id(tokenizer, _DSV4_EOS_MARKER) | ||
| 208 | + user_id = _try_get_token_id(tokenizer, _DSV4_USER_MARKER) | ||
| 209 | + asst_id = _try_get_token_id(tokenizer, _DSV4_ASSISTANT_MARKER) | ||
| 210 | + if eos_id is None or user_id is None or asst_id is None: | ||
| 211 | + return None | ||
| 212 | + | ||
| 213 | + # Single full tokenize (same shape as _apply_chat_template_dsv4). | ||
| 214 | + try: | ||
| 215 | + ids = tokenizer.apply_chat_template( | ||
| 216 | + messages, | ||
| 217 | + tools=tools, | ||
| 218 | + add_generation_prompt=True, | ||
| 219 | + tokenize=True, | ||
| 220 | + return_dict=False, | ||
| 221 | + ) | ||
| 222 | + except TypeError: | ||
| 223 | + try: | ||
| 224 | + ids = tokenizer.apply_chat_template( | ||
| 225 | + messages, | ||
| 226 | + tools=tools, | ||
| 227 | + add_generation_prompt=True, | ||
| 228 | + tokenize=True, | ||
| 229 | + ) | ||
| 230 | + except Exception as exc: # noqa: BLE001 | ||
| 231 | + logger.debug("dsv4 marker tokenize (no return_dict) failed: %s", exc) | ||
| 232 | + return None | ||
| 233 | + except Exception as exc: # noqa: BLE001 | ||
| 234 | + logger.debug("dsv4 marker tokenize failed: %s", exc) | ||
| 235 | + return None | ||
| 236 | + | ||
| 237 | + if not isinstance(ids, list) or len(ids) == 0: | ||
| 238 | + return None | ||
| 239 | + | ||
| 240 | + # Positions for each of the 3 markers (single pass, O(L_total)). | ||
| 241 | + user_pos: list[int] = [i for i, t in enumerate(ids) if t == user_id] | ||
| 242 | + asst_pos: list[int] = [i for i, t in enumerate(ids) if t == asst_id] | ||
| 243 | + eos_pos: list[int] = [i for i, t in enumerate(ids) if t == eos_id] | ||
| 244 | + | ||
| 245 | + n = len(messages) | ||
| 246 | + has_system_at_start = n > 0 and isinstance(messages[0], dict) and messages[0].get("role") == "system" | ||
| 247 | + | ||
| 248 | + result: list[int] = [] | ||
| 249 | + user_pos_idx = 1 if has_system_at_start else 0 | ||
| 250 | + asst_pos_idx = 0 | ||
| 251 | + eos_consumed = 0 | ||
| 252 | + | ||
| 253 | + for i, msg in enumerate(messages): | ||
| 254 | + if not isinstance(msg, dict): | ||
| 255 | + return None | ||
| 256 | + role = msg.get("role") | ||
| 257 | + if i == 0 and has_system_at_start: | ||
| 258 | + if not user_pos: | ||
| 259 | + return None | ||
| 260 | + result.append(user_pos[0]) | ||
| 261 | + elif role in ("user", "developer", "tool"): | ||
| 262 | + consumed = False | ||
| 263 | + if asst_pos_idx < len(asst_pos): | ||
| 264 | + next_user = user_pos[user_pos_idx + 1] if user_pos_idx + 1 < len(user_pos) else None | ||
| 265 | + if next_user is None or asst_pos[asst_pos_idx] < next_user: | ||
| 266 | + result.append(asst_pos[asst_pos_idx]) | ||
| 267 | + asst_pos_idx += 1 | ||
| 268 | + consumed = True | ||
| 269 | + if not consumed: | ||
| 270 | + if user_pos_idx + 1 < len(user_pos): | ||
| 271 | + result.append(user_pos[user_pos_idx + 1]) | ||
| 272 | + consumed = True | ||
| 273 | + if not consumed: | ||
| 274 | + return None | ||
| 275 | + user_pos_idx += 1 | ||
| 276 | + elif role == "assistant": | ||
| 277 | + if eos_consumed >= len(eos_pos): | ||
| 278 | + return None | ||
| 279 | + result.append(eos_pos[eos_consumed] + 1) | ||
| 280 | + eos_consumed += 1 | ||
| 281 | + else: | ||
| 282 | + return None # Unknown role: fall back to per-call prefix. | ||
| 283 | + | ||
| 284 | + if len(result) != n: | ||
| 285 | + return None | ||
| 286 | + return result | ||
| 287 | + | ||
| 288 | + def _build_tools_offset_table_dsv4( | ||
| 289 | + self, | ||
| 290 | + messages, | ||
| 291 | + tools, | ||
| 292 | + tokenizer, | ||
| 293 | + ) -> list[int] | None: | ||
| 294 | + """DSV4 tools table built from a single render. | ||
| 295 | + | ||
| 296 | + Instead of N calls of | ||
| 297 | + ``apply_chat_template(tools=[t_i])``, do a single | ||
| 298 | + ``apply_chat_template(tools=tools, tokenize=True)`` to obtain the full | ||
| 299 | + ids, ``decode`` to a string, then locate each tool's start via | ||
| 300 | + ``name_marker`` + a reverse ``{`` scan. | ||
| 301 | + """ | ||
| 302 | + if tokenizer is None or not tools or not isinstance(tools, list): | ||
| 303 | + return None | ||
| 304 | + if not isinstance(messages, list): | ||
| 305 | + return None | ||
| 306 | + | ||
| 307 | + # 1. Single full tokenize. | ||
| 308 | + try: | ||
| 309 | + ids = tokenizer.apply_chat_template( | ||
| 310 | + messages, | ||
| 311 | + tools=tools, | ||
| 312 | + add_generation_prompt=True, | ||
| 313 | + tokenize=True, | ||
| 314 | + return_dict=False, | ||
| 315 | + ) | ||
| 316 | + except TypeError: | ||
| 317 | + try: | ||
| 318 | + ids = tokenizer.apply_chat_template( | ||
| 319 | + messages, | ||
| 320 | + tools=tools, | ||
| 321 | + add_generation_prompt=True, | ||
| 322 | + tokenize=True, | ||
| 323 | + ) | ||
| 324 | + except Exception as exc: # noqa: BLE001 — fail-open | ||
| 325 | + logger.debug("dsv4 tools table: tokenize failed: %s", exc) | ||
| 326 | + return None | ||
| 327 | + except Exception as exc: # noqa: BLE001 — fail-open | ||
| 328 | + logger.debug("dsv4 tools table: tokenize failed: %s", exc) | ||
| 329 | + return None | ||
| 330 | + | ||
| 331 | + if not isinstance(ids, list) or len(ids) == 0: | ||
| 332 | + return None | ||
| 333 | + | ||
| 334 | + # 2. Decode to string once. | ||
| 335 | + try: | ||
| 336 | + full_str = tokenizer.decode(ids) | ||
| 337 | + except Exception as exc: # noqa: BLE001 — fail-open | ||
| 338 | + logger.debug("dsv4 tools table: decode failed: %s", exc) | ||
| 339 | + return None | ||
| 340 | + if not isinstance(full_str, str) or not full_str: | ||
| 341 | + return None | ||
| 342 | + | ||
| 343 | + # 3. Per tool: name_marker + reverse '{' scan. | ||
| 344 | + char_positions: list[int] = [] | ||
| 345 | + for tool in tools: | ||
| 346 | + if not isinstance(tool, dict): | ||
| 347 | + return None | ||
| 348 | + fn = tool.get("function") or {} | ||
| 349 | + if not isinstance(fn, dict): | ||
| 350 | + return None | ||
| 351 | + tool_name = str(fn.get("name") or "") | ||
| 352 | + if not tool_name: | ||
| 353 | + return None | ||
| 354 | + search_from = char_positions[-1] if char_positions else 0 | ||
| 355 | + name_marker = f'"name": "{tool_name}"' | ||
| 356 | + nm_idx = full_str.find(name_marker, search_from) | ||
| 357 | + if nm_idx < 0: | ||
| 358 | + return None | ||
| 359 | + obj_open = full_str.rfind('{', search_from, nm_idx) | ||
| 360 | + if obj_open < 0: | ||
| 361 | + return None | ||
| 362 | + char_positions.append(obj_open) | ||
| 363 | + | ||
| 364 | + # 4. End of tools segment: reuse _resolve_tools_end_pos_dsv4. | ||
| 365 | + end_pos = self._resolve_tools_end_pos_dsv4(full_str, char_positions) | ||
| 366 | + if end_pos is None: | ||
| 367 | + end_pos = len(full_str) | ||
| 368 | + char_positions.append(end_pos) | ||
| 369 | + | ||
| 370 | + # 5. char → token. | ||
| 371 | + token_positions: list[int] = [] | ||
| 372 | + try: | ||
| 373 | + for cp in char_positions: | ||
| 374 | + prefix_ids = tokenizer.encode(full_str[:cp], add_special_tokens=False) | ||
| 375 | + token_positions.append(len(prefix_ids)) | ||
| 376 | + except Exception as exc: # noqa: BLE001 — fail-open | ||
| 377 | + logger.debug("dsv4 tools table: prefix encode failed: %s", exc) | ||
| 378 | + return None | ||
| 379 | + | ||
| 380 | + # 6. Monotonicity check. | ||
| 381 | + for i in range(1, len(token_positions)): | ||
| 382 | + if token_positions[i] < token_positions[i - 1]: | ||
| 383 | + return None | ||
| 384 | + | ||
| 385 | + return token_positions | ||
| 386 | + | ||
| 387 | + def _resolve_tools_end_pos_dsv4( | ||
| 388 | + self, | ||
| 389 | + full_str, | ||
| 390 | + char_positions, | ||
| 391 | + ) -> int | None: | ||
| 392 | + """DSV4 tools-segment char end (first ``<|User|>``). | ||
| 393 | + | ||
| 394 | + Returns ``None`` (caller falls back to ``len(full_str)``) when: | ||
| 395 | + - ``<|User|>`` is not in ``full_str``. | ||
| 396 | + - ``<|User|>`` position <= last tool start (anomalous; conservative). | ||
| 397 | + - ``full_str.find`` raises. | ||
| 398 | + """ | ||
| 399 | + if not char_positions: | ||
| 400 | + return None | ||
| 401 | + try: | ||
| 402 | + um = full_str.find(_DSV4_USER_MARKER) | ||
| 403 | + except Exception: # noqa: BLE001 — defensive | ||
| 404 | + return None | ||
| 405 | + if um <= char_positions[-1]: | ||
| 406 | + return None | ||
| 407 | + return um | ||
| 408 | + | ||
| 409 | + | ||
| 410 | +class StandardBlockOffsetCalculator(BaseBlockOffsetCalculator): | ||
| 411 | + """Generic calculator for Qwen / Llama-3 / Mistral. | ||
| 412 | + | ||
| 413 | + Encapsulates the standard algorithm: | ||
| 414 | + - ``_build_messages_offset_table``: chat-marker counting. | ||
| 415 | + - ``_detect_chat_marker_id``: first single-token marker id from | ||
| 416 | + ``_CHAT_MARKER_CANDIDATES``. | ||
| 417 | + - ``_build_tools_offset_table``: 1+N renders + char anchors + | ||
| 418 | + ``</tools>`` closer (original ``build_tools_offset_table`` body). | ||
| 419 | + """ | ||
| 420 | + | ||
| 421 | + def preprocess(self, messages, tools): | ||
| 422 | + return messages, tools # identity | ||
| 423 | + | ||
| 424 | + def compute_messages(self, messages, tools, tokenizer): | ||
| 425 | + return self._build_messages_offset_table(messages, tools, tokenizer) | ||
| 426 | + | ||
| 427 | + def compute_tools(self, messages, tools, tokenizer): | ||
| 428 | + return self._build_tools_offset_table(messages, tools, tokenizer) | ||
| 429 | + | ||
| 430 | + def _build_messages_offset_table( | ||
| 431 | + self, | ||
| 432 | + messages, | ||
| 433 | + tools, | ||
| 434 | + tokenizer, | ||
| 435 | + ) -> list[int] | None: | ||
| 436 | + """Generic chat-marker counting: cumulative token offsets per message index.""" | ||
| 437 | + if tokenizer is None: | ||
| 438 | + return None | ||
| 439 | + if not messages or not isinstance(messages, list): | ||
| 440 | + return None | ||
| 441 | + | ||
| 442 | + marker_id = self._detect_chat_marker_id(tokenizer) | ||
| 443 | + if marker_id is None: | ||
| 444 | + logger.warning( | ||
| 445 | + "no chat-template marker token found in tokenizer vocab; " | ||
| 446 | + "messages offset table unavailable, callers must fall back" | ||
| 447 | + ) | ||
| 448 | + return None | ||
| 449 | + | ||
| 450 | + try: | ||
| 451 | + ids = tokenizer.apply_chat_template( | ||
| 452 | + messages, | ||
| 453 | + tools=tools, | ||
| 454 | + add_generation_prompt=True, | ||
| 455 | + tokenize=True, | ||
| 456 | + return_dict=False, | ||
| 457 | + ) | ||
| 458 | + except TypeError: | ||
| 459 | + ids = tokenizer.apply_chat_template( | ||
| 460 | + messages, | ||
| 461 | + tools=tools, | ||
| 462 | + add_generation_prompt=True, | ||
| 463 | + tokenize=True, | ||
| 464 | + ) | ||
| 465 | + except Exception as exc: # noqa: BLE001 — fail-open | ||
| 466 | + logger.warning( | ||
| 467 | + "build_messages_offset_table: full tokenization failed: %s", | ||
| 468 | + exc, | ||
| 469 | + ) | ||
| 470 | + return None | ||
| 471 | + | ||
| 472 | + if not isinstance(ids, list) or len(ids) == 0: | ||
| 473 | + return None | ||
| 474 | + | ||
| 475 | + im_starts = [i for i, t in enumerate(ids) if t == marker_id] | ||
| 476 | + expected_with_gen = len(messages) + 1 | ||
| 477 | + expected_no_gen = len(messages) | ||
| 478 | + if len(im_starts) == expected_with_gen: | ||
| 479 | + return [im_starts[i + 1] for i in range(len(messages))] | ||
| 480 | + if len(im_starts) == expected_no_gen: | ||
| 481 | + return [im_starts[i + 1] if i + 1 < len(im_starts) else len(ids) for i in range(len(messages))] | ||
| 482 | + logger.warning( | ||
| 483 | + "build_messages_offset_table: marker count %d unexpected " | ||
| 484 | + "(messages=%d, expected %d or %d); table unavailable", | ||
| 485 | + len(im_starts), | ||
| 486 | + len(messages), | ||
| 487 | + expected_with_gen, | ||
| 488 | + expected_no_gen, | ||
| 489 | + ) | ||
| 490 | + return None | ||
| 491 | + | ||
| 492 | + def _detect_chat_marker_id(self, tokenizer) -> int | None: | ||
| 493 | + """Find the first single-token marker id in ``_CHAT_MARKER_CANDIDATES``.""" | ||
| 494 | + if tokenizer is None: | ||
| 495 | + return None | ||
| 496 | + for tok in _CHAT_MARKER_CANDIDATES: | ||
| 497 | + try: | ||
| 498 | + tid = tokenizer.convert_tokens_to_ids(tok) | ||
| 499 | + except Exception: # noqa: BLE001 — tokenizer API quirks | ||
| 500 | + continue | ||
| 501 | + if tid is not None and getattr(tokenizer, "unk_token_id", None) != tid: | ||
| 502 | + try: | ||
| 503 | + enc = tokenizer.encode(tok, add_special_tokens=False) | ||
| 504 | + except Exception: | ||
| 505 | + continue | ||
| 506 | + if len(enc) == 1 and enc[0] == tid: | ||
| 507 | + return tid | ||
| 508 | + return None | ||
| 509 | + | ||
| 510 | + def _build_tools_offset_table( | ||
| 511 | + self, | ||
| 512 | + messages, | ||
| 513 | + tools, | ||
| 514 | + tokenizer, | ||
| 515 | + ) -> list[int] | None: | ||
| 516 | + """1+N renders + char anchors + ``</tools>`` closer. | ||
| 517 | + | ||
| 518 | + Original ``build_tools_offset_table`` body moved here verbatim — same | ||
| 519 | + semantics, same control flow. The trailing sentinel falls back to | ||
| 520 | + ``len(full_str)`` when no closer matches (DSV4 template, no | ||
| 521 | + ``</tools>`` wrapping). | ||
| 522 | + """ | ||
| 523 | + if tokenizer is None: | ||
| 524 | + return None | ||
| 525 | + if not tools or not isinstance(tools, list): | ||
| 526 | + return None | ||
| 527 | + if not isinstance(messages, list): | ||
| 528 | + return None | ||
| 529 | + | ||
| 530 | + # 1. Render the full prompt as a string. | ||
| 531 | + try: | ||
| 532 | + full_str = tokenizer.apply_chat_template( | ||
| 533 | + messages, | ||
| 534 | + tools=tools, | ||
| 535 | + add_generation_prompt=True, | ||
| 536 | + tokenize=False, | ||
| 537 | + return_dict=False, | ||
| 538 | + ) | ||
| 539 | + except TypeError: | ||
| 540 | + try: | ||
| 541 | + full_str = tokenizer.apply_chat_template( | ||
| 542 | + messages, | ||
| 543 | + tools=tools, | ||
| 544 | + add_generation_prompt=True, | ||
| 545 | + tokenize=False, | ||
| 546 | + ) | ||
| 547 | + except Exception as exc: # noqa: BLE001 — fail-open | ||
| 548 | + logger.warning( | ||
| 549 | + "build_tools_offset_table: full tokenize failed: %s", | ||
| 550 | + exc, | ||
| 551 | + ) | ||
| 552 | + return None | ||
| 553 | + except Exception as exc: # noqa: BLE001 — fail-open | ||
| 554 | + logger.warning( | ||
| 555 | + "build_tools_offset_table: full tokenize failed: %s", | ||
| 556 | + exc, | ||
| 557 | + ) | ||
| 558 | + return None | ||
| 559 | + | ||
| 560 | + if not isinstance(full_str, str) or not full_str: | ||
| 561 | + return None | ||
| 562 | + | ||
| 563 | + # 2. For each tool, locate its character position in full_str. | ||
| 564 | + char_positions: list[int] = [] | ||
| 565 | + for i, tool in enumerate(tools): | ||
| 566 | + # 2a. Render this single tool to capture its exact rendered form. | ||
| 567 | + try: | ||
| 568 | + s_i = tokenizer.apply_chat_template( | ||
| 569 | + messages, | ||
| 570 | + tools=[tool], | ||
| 571 | + add_generation_prompt=True, | ||
| 572 | + tokenize=False, | ||
| 573 | + return_dict=False, | ||
| 574 | + ) | ||
| 575 | + except TypeError: | ||
| 576 | + try: | ||
| 577 | + s_i = tokenizer.apply_chat_template( | ||
| 578 | + messages, | ||
| 579 | + tools=[tool], | ||
| 580 | + add_generation_prompt=True, | ||
| 581 | + tokenize=False, | ||
| 582 | + ) | ||
| 583 | + except Exception as exc: # noqa: BLE001 | ||
| 584 | + logger.warning( | ||
| 585 | + "build_tools_offset_table: single-tool render for tool[%d] failed: %s", | ||
| 586 | + i, | ||
| 587 | + exc, | ||
| 588 | + ) | ||
| 589 | + return None | ||
| 590 | + except Exception as exc: # noqa: BLE001 | ||
| 591 | + logger.warning( | ||
| 592 | + "build_tools_offset_table: single-tool render for tool[%d] failed: %s", | ||
| 593 | + i, | ||
| 594 | + exc, | ||
| 595 | + ) | ||
| 596 | + return None | ||
| 597 | + | ||
| 598 | + if not isinstance(s_i, str) or not s_i: | ||
| 599 | + logger.warning( | ||
| 600 | + "build_tools_offset_table: single-tool render for tool[%d] returned empty/non-string", | ||
| 601 | + i, | ||
| 602 | + ) | ||
| 603 | + return None | ||
| 604 | + | ||
| 605 | + # 2b. Locate the tool's name in s_i (unique anchor). | ||
| 606 | + tool_name = "" | ||
| 607 | + if isinstance(tool, dict): | ||
| 608 | + fn = tool.get("function") or {} | ||
| 609 | + if isinstance(fn, dict): | ||
| 610 | + tool_name = str(fn.get("name") or "") | ||
| 611 | + if not tool_name: | ||
| 612 | + logger.warning( | ||
| 613 | + "build_tools_offset_table: tool[%d] has no function.name; cannot anchor", | ||
| 614 | + i, | ||
| 615 | + ) | ||
| 616 | + return None | ||
| 617 | + name_marker = f'"name": "{tool_name}"' | ||
| 618 | + s_i_name_idx = s_i.find(name_marker) | ||
| 619 | + if s_i_name_idx < 0: | ||
| 620 | + logger.warning( | ||
| 621 | + "build_tools_offset_table: name %r not found in single-tool render for tool[%d]", | ||
| 622 | + tool_name, | ||
| 623 | + i, | ||
| 624 | + ) | ||
| 625 | + return None | ||
| 626 | + | ||
| 627 | + # 2c. Locate this tool's JSON opener in full_str by walking forward | ||
| 628 | + # through `{"type": "function"` occurrences and matching the next name. | ||
| 629 | + search_from = char_positions[-1] if char_positions else 0 | ||
| 630 | + candidate_prefix = '{"type": "function"' | ||
| 631 | + idx = full_str.find(candidate_prefix, search_from) | ||
| 632 | + found = False | ||
| 633 | + while idx >= 0: | ||
| 634 | + next_name_idx = full_str.find('"name":', idx) | ||
| 635 | + if next_name_idx < 0: | ||
| 636 | + break | ||
| 637 | + name_start = full_str.find('"', next_name_idx + len('"name":')) | ||
| 638 | + if name_start < 0: | ||
| 639 | + break | ||
| 640 | + name_end = full_str.find('"', name_start + 1) | ||
| 641 | + if name_end < 0: | ||
| 642 | + break | ||
| 643 | + found_name = full_str[name_start + 1 : name_end] | ||
| 644 | + if found_name == tool_name: | ||
| 645 | + char_positions.append(idx) | ||
| 646 | + found = True | ||
| 647 | + break | ||
| 648 | + idx = full_str.find(candidate_prefix, idx + 1) | ||
| 649 | + if not found: | ||
| 650 | + # Fallback: chat template may not use {"type": "function" wrapper | ||
| 651 | + # (e.g., Llama-3 inlines tools differently). Use name_marker | ||
| 652 | + # itself as anchor and back-walk to the nearest `{`. | ||
| 653 | + nm = full_str.find(name_marker, search_from) | ||
| 654 | + if nm < 0: | ||
| 655 | + logger.warning( | ||
| 656 | + "build_tools_offset_table: tool[%d] (%s) not located in full_str", | ||
| 657 | + i, | ||
| 658 | + tool_name, | ||
| 659 | + ) | ||
| 660 | + return None | ||
| 661 | + obj_open = full_str.rfind('{', search_from, nm) | ||
| 662 | + if obj_open < 0: | ||
| 663 | + logger.warning( | ||
| 664 | + "build_tools_offset_table: tool[%d] (%s) opener `{` not found before name", | ||
| 665 | + i, | ||
| 666 | + tool_name, | ||
| 667 | + ) | ||
| 668 | + return None | ||
| 669 | + char_positions.append(obj_open) | ||
| 670 | + | ||
| 671 | + # 3. End-of-tools-segment char position: prefer `</tools>` closer if present. | ||
| 672 | + end_pos = len(full_str) | ||
| 673 | + for closer in ('</tools>', '</tools_section>'): | ||
| 674 | + idx = full_str.rfind(closer) | ||
| 675 | + if idx > char_positions[-1]: | ||
| 676 | + end_pos = idx | ||
| 677 | + break | ||
| 678 | + char_positions.append(end_pos) | ||
| 679 | + | ||
| 680 | + # 4. Convert char positions → token positions via prefix encodes. | ||
| 681 | + token_positions: list[int] = [] | ||
| 682 | + try: | ||
| 683 | + for cp in char_positions: | ||
| 684 | + prefix_ids = tokenizer.encode(full_str[:cp], add_special_tokens=False) | ||
| 685 | + token_positions.append(len(prefix_ids)) | ||
| 686 | + except Exception as exc: # noqa: BLE001 — fail-open | ||
| 687 | + logger.warning( | ||
| 688 | + "build_tools_offset_table: encode prefix failed: %s", | ||
| 689 | + exc, | ||
| 690 | + ) | ||
| 691 | + return None | ||
| 692 | + | ||
| 693 | + # 5. Sanity check: monotonic non-decreasing. | ||
| 694 | + for i in range(1, len(token_positions)): | ||
| 695 | + if token_positions[i] < token_positions[i - 1]: | ||
| 696 | + logger.warning( | ||
| 697 | + "build_tools_offset_table: non-monotonic token position at index %d (%d < %d)", | ||
| 698 | + i, | ||
| 699 | + token_positions[i], | ||
| 700 | + token_positions[i - 1], | ||
| 701 | + ) | ||
| 702 | + return None | ||
| 703 | + | ||
| 704 | + return token_positions | ||
| 705 | + | ||
| 706 | + | ||
| 707 | +def get_block_offset_calculator(tokenizer) -> BaseBlockOffsetCalculator: | ||
| 708 | + """Pick the right calculator for the tokenizer type. | ||
| 709 | + | ||
| 710 | + Centralized here so adding a new model family touches only this function. | ||
| 711 | + """ | ||
| 712 | + if _is_dsv4_tokenizer(tokenizer): | ||
| 713 | + return Dsv4BlockOffsetCalculator() | ||
| 714 | + return StandardBlockOffsetCalculator() | ||
| 715 | + | ||
| 716 | + | ||
| 717 | +def _register_edit_type(name: str) -> Callable[[Callable], Callable]: | ||
| 718 | + """Decorator: register a per-edit-type offset computer. | ||
| 719 | + | ||
| 720 | + Usage:: | ||
| 721 | + | ||
| 722 | + | ||
| 723 | + def _compact_offset(messages, tools, msg_idx, block_size, tokenizer): | ||
| 724 | + ... | ||
| 725 | + """ | ||
| 726 | + | ||
| 727 | + def deco(fn: Callable) -> Callable: | ||
| 728 | + _EDIT_TYPE_OFFSET_COMPUTERS[name] = fn | ||
| 729 | + return fn | ||
| 730 | + | ||
| 731 | + return deco | ||
| 732 | + | ||
| 733 | + | ||
| 734 | +def _prefix_diff_block_offset( | ||
| 735 | + messages: list, | ||
| 736 | + tools: list | None, | ||
| 737 | + msg_idx: int, | ||
| 738 | + block_size: int, | ||
| 739 | + tokenizer: Any, | ||
| 740 | + messages_table: list[int] | None = None, | ||
| 741 | +) -> tuple[int, int, int] | None: | ||
| 742 | + if not messages or not isinstance(messages, list): | ||
| 743 | + return None | ||
| 744 | + if msg_idx < 0 or msg_idx >= len(messages): | ||
| 745 | + return None | ||
| 746 | + if messages_table is None or msg_idx >= len(messages_table): | ||
| 747 | + # No messages_table or out-of-range: cannot derive precise coords | ||
| 748 | + # without per-call partial tokenize. Return None so attach_block_offsets | ||
| 749 | + # falls back to its own per-call partial tokenization. | ||
| 750 | + return None | ||
| 751 | + | ||
| 752 | + token_idx = messages_table[msg_idx] | ||
| 753 | + if token_idx <= 0: | ||
| 754 | + return None | ||
| 755 | + | ||
| 756 | + return (token_idx // block_size, token_idx % block_size, token_idx) | ||
🟡 Medium Priority
建议:在 ![]() ![]() | |||
| 757 | + | ||
| 758 | + | ||
| 759 | +def _align_block_for_op( | ||
| 760 | + token_idx: int, | ||
| 761 | + block_size: int, | ||
| 762 | + align_policy: str, | ||
| 763 | + boundary: str, | ||
| 764 | +) -> int: | ||
| 765 | + """Align an absolute token offset ``token_idx`` to a block index. | ||
| 766 | + | ||
| 767 | + Args: | ||
| 768 | + token_idx: Absolute token offset (range start or end). | ||
| 769 | + block_size: PagedAttention block size; <= 0 is invalid. | ||
| 770 | + align_policy: Alignment policy (see below). Dispatch paths pass | ||
| 771 | + ``"positive"`` (see ``*_offset_computer``s) or the raw ``edit_type`` | ||
| 772 | + string (tools branch of ``compute_edit_block_offset``). | ||
| 773 | + boundary: ``"start"`` / ``"end"`` — whether token_idx is the range's | ||
| 774 | + start or end. Only used by ``negative``; rounding direction | ||
| 775 | + differs for start vs end. | ||
| 776 | + | ||
| 777 | + Policy semantics: | ||
| 778 | + - ``negative`` (shrink): keep only full blocks inside [start, end]; | ||
| 779 | + prefer under-shooting to touching out-of-range tokens. | ||
| 780 | + * start mid-block (intra != 0) -> round up to next block. | ||
| 781 | + * end not at block tail (intra != size-1) -> round down to prev block. | ||
| 782 | + * Exactly on a boundary -> no rounding; result clamped to >= 0. | ||
| 783 | + - ``positive`` (truncate): use ``token_idx // block_size`` — the block | ||
| 784 | + containing token_idx, which may include out-of-range edge tokens. | ||
| 785 | + - Unknown values: behave like ``positive`` (fail-open, no exception). | ||
| 786 | + | ||
| 787 | + Returns: | ||
target=session 时直接返回 (None,None),attach 只写 start/end,下游拿不到块坐标。 ![]() ![]() | |||
| 788 | + Aligned block index (>= 0). Invalid block_size fails open to 0. | ||
| 789 | + """ | ||
| 790 | + # Invalid block_size (<=0): skip division and fail-open to block 0. | ||
| 791 | + if block_size <= 0: | ||
except TypeError分支中使用了isinstance(full_str, str),但TypeError可能由decode抛caused,此时full_str未赋值。 ![]() ![]() | |||
| 792 | + return 0 | ||
| 793 | + block = token_idx // block_size | ||
| 794 | + intra = token_idx % block_size | ||
| 795 | + match align_policy: | ||
| 796 | + case "negative": | ||
| 797 | + # Shrink: start rounds up, end rounds down — only full blocks. | ||
| 798 | + if boundary == "start": | ||
| 799 | + # intra == 0: start aligns with block head; that block is in range. | ||
| 800 | + aligned = block + (1 if intra != 0 else 0) | ||
| 801 | + elif boundary == "end": | ||
| 802 | + # intra == block_size-1: end aligns with block tail; that block is in range. | ||
| 803 | + aligned = block - (1 if intra != block_size - 1 else 0) | ||
| 804 | + else: | ||
| 805 | + # Invalid boundary: degenerate to truncation, no directional rounding. | ||
| 806 | + aligned = block | ||
| 807 | + # End rounding may yield -1 (token_idx mid-block 0); clamp to 0. | ||
| 808 | + return max(0, aligned) | ||
| 809 | + case "positive": | ||
| 810 | + # Truncation: take the block containing token_idx, regardless of boundary. | ||
| 811 | + return block | ||
| 812 | + case _: | ||
| 813 | + # Unknown policy == positive; safe for newly added unregistered edit_types. | ||
| 814 | + return block | ||
| 815 | + | ||
| 816 | + | ||
| 817 | + | ||
| 818 | +def _offload_offset_computer( | ||
| 819 | + messages: list, | ||
| 820 | + tools: list | None, | ||
| 821 | + msg_idx: int, | ||
| 822 | + block_size: int, | ||
| 823 | + tokenizer: Any, | ||
| 824 | + boundary: str, | ||
| 825 | + messages_table: list[int] | None = None, | ||
| 826 | +) -> tuple[int, int, int] | None: | ||
| 827 | + bo = _prefix_diff_block_offset( | ||
| 828 | + messages, | ||
| 829 | + tools, | ||
| 830 | + msg_idx, | ||
| 831 | + block_size, | ||
| 832 | + tokenizer, | ||
| 833 | + messages_table=messages_table, | ||
| 834 | + ) | ||
| 835 | + if bo is None: | ||
| 836 | + return None | ||
| 837 | + _, intra, token_idx = bo | ||
| 838 | + aligned = _align_block_for_op(token_idx, block_size, "positive", boundary) | ||
| 839 | + return (aligned, intra, token_idx) | ||
| 840 | + | ||
| 841 | + | ||
| 842 | + | ||
| 843 | +def _prefetch_offset_computer( | ||
| 844 | + messages: list, | ||
| 845 | + tools: list | None, | ||
| 846 | + msg_idx: int, | ||
| 847 | + block_size: int, | ||
| 848 | + tokenizer: Any, | ||
| 849 | + boundary: str, | ||
| 850 | + messages_table: list[int] | None = None, | ||
| 851 | +) -> tuple[int, int, int] | None: | ||
| 852 | + bo = _prefix_diff_block_offset( | ||
| 853 | + messages, | ||
| 854 | + tools, | ||
| 855 | + msg_idx, | ||
| 856 | + block_size, | ||
| 857 | + tokenizer, | ||
| 858 | + messages_table=messages_table, | ||
| 859 | + ) | ||
| 860 | + if bo is None: | ||
| 861 | + return None | ||
| 862 | + _, intra, token_idx = bo | ||
| 863 | + aligned = _align_block_for_op(token_idx, block_size, "positive", boundary) | ||
| 864 | + return (aligned, intra, token_idx) | ||
| 865 | + | ||
| 866 | + | ||
| 867 | + | ||
| 868 | +def _evict_offset_computer( | ||
| 869 | + messages: list, | ||
| 870 | + tools: list | None, | ||
| 871 | + msg_idx: int, | ||
| 872 | + block_size: int, | ||
| 873 | + tokenizer: Any, | ||
| 874 | + boundary: str, | ||
| 875 | + messages_table: list[int] | None = None, | ||
| 876 | +) -> tuple[int, int, int] | None: | ||
| 877 | + bo = _prefix_diff_block_offset( | ||
| 878 | + messages, | ||
| 879 | + tools, | ||
| 880 | + msg_idx, | ||
| 881 | + block_size, | ||
| 882 | + tokenizer, | ||
| 883 | + messages_table=messages_table, | ||
| 884 | + ) | ||
| 885 | + if bo is None: | ||
| 886 | + return None | ||
| 887 | + _, intra, token_idx = bo | ||
| 888 | + aligned = _align_block_for_op(token_idx, block_size, "positive", boundary) | ||
| 889 | + return (aligned, intra, token_idx) | ||
| 890 | + | ||
| 891 | + | ||
| 892 | +def compute_block_offset( | ||
| 893 | + messages: list, | ||
| 894 | + tools: list | None, | ||
| 895 | + msg_offset: int | None, | ||
| 896 | + block_size: int, | ||
| 897 | + tokenizer: Any, | ||
| 898 | + messages_table: list[int] | None = None, | ||
| 899 | +) -> tuple[int, int, int] | None: | ||
| 900 | + if not isinstance(block_size, int) or block_size <= 0: | ||
| 901 | + logger.warning("invalid block_size=%r; cannot compute block_offset", block_size) | ||
| 902 | + return None | ||
| 903 | + if not messages or not isinstance(messages, list): | ||
| 904 | + logger.warning("messages is empty or invalid; cannot compute block_offset") | ||
| 905 | + return None | ||
| 906 | + if messages_table is None: | ||
| 907 | + # No messages_table: cannot give precise coords. attach_block_offsets | ||
| 908 | + # already logged a WARNING upstream; just return None here. | ||
| 909 | + return None | ||
| 910 | + | ||
| 911 | + effective = msg_offset - 1 # msg_offset range [1, n]; TTL protects messages[0, msg_offset). | ||
| 912 | + if effective < 0 or effective >= len(messages_table): | ||
| 913 | + return None | ||
| 914 | + token_idx = messages_table[effective] | ||
| 915 | + | ||
| 916 | + if token_idx == 0: | ||
| 917 | + logger.warning( | ||
| 918 | + "token_idx == 0 for effective msg_offset=%d; tokenizer configuration suspect?", | ||
| 919 | + effective, | ||
| 920 | + ) | ||
| 921 | + return None | ||
| 922 | + | ||
| 923 | + return (token_idx // block_size, token_idx % block_size, token_idx) | ||
| 924 | + | ||
| 925 | + | ||
| 926 | +def compute_edit_block_offset( | ||
| 927 | + edit_type: str, | ||
| 928 | + messages: list, | ||
| 929 | + tools: list | None, | ||
| 930 | + start: int | None, | ||
| 931 | + end: int | None, | ||
| 932 | + block_size: int, | ||
| 933 | + tokenizer: Any, | ||
| 934 | + target: str = "messages", | ||
| 935 | + messages_table: list[int] | None = None, | ||
| 936 | + tools_table: list[int] | None = None, | ||
| 937 | +) -> tuple[ | ||
| 938 | + tuple[int, int, int] | None, | ||
| 939 | + tuple[int, int, int] | None, | ||
| 940 | +]: | ||
| 941 | + if not isinstance(block_size, int) or block_size <= 0: | ||
| 942 | + logger.warning("invalid block_size=%r; cannot compute edit_block_offset", block_size) | ||
| 943 | + return None | ||
| 944 | + | ||
| 945 | + if target == "session": | ||
| 946 | + logger.info( | ||
| 947 | + "target='session' for edit_type=%r; overriding start/end to [None, None] and skipping offset computation", | ||
| 948 | + edit_type, | ||
| 949 | + ) | ||
| 950 | + return (None, None) | ||
| 951 | + | ||
| 952 | + if target == "tools": | ||
| 953 | + if not tools or not isinstance(tools, list): | ||
| 954 | + logger.warning( | ||
| 955 | + "target='tools' but tools is empty/None; returning (None, None)", | ||
| 956 | + ) | ||
| 957 | + return (None, None) | ||
| 958 | + if not (isinstance(tools_table, list) and len(tools_table) == len(tools) + 1): | ||
| 959 | + logger.warning( | ||
| 960 | + "target='tools' but tools_offset_table unavailable " | ||
| 961 | + "(tools_table=%r, len(tools)=%d); returning (None, None)", | ||
| 962 | + tools_table if isinstance(tools_table, list) else type(tools_table).__name__, | ||
| 963 | + len(tools), | ||
| 964 | + ) | ||
| 965 | + return (None, None) | ||
| 966 | + | ||
| 967 | + n_tools = len(tools) | ||
| 968 | + eff_start = 0 if start is None else max(0, min(start, n_tools)) | ||
| 969 | + eff_end = n_tools if end is None else max(0, min(end, n_tools)) | ||
| 970 | + | ||
| 971 | + if start is not None and end is not None and start > end: | ||
| 972 | + logger.warning( | ||
| 973 | + "context_edit.start=%d > end=%d (target='tools'); not corrected", | ||
| 974 | + start, | ||
| 975 | + end, | ||
| 976 | + ) | ||
| 977 | + | ||
| 978 | + # tools_table[i] = absolute token offset of tools[i]'s first token | ||
| 979 | + # in the FULL render stream. | ||
| 980 | + if eff_start == 0: | ||
| 981 | + ts = tools_table[0] | ||
| 982 | + bo_start = (ts // block_size, ts % block_size, ts) | ||
| 983 | + else: | ||
| 984 | + ts = tools_table[eff_start] | ||
| 985 | + aligned = _align_block_for_op(ts, block_size, edit_type, "start") | ||
| 986 | + bo_start = (aligned, ts % block_size, ts) | ||
| 987 | + | ||
| 988 | + te = tools_table[eff_end] | ||
| 989 | + aligned_e = _align_block_for_op(te, block_size, edit_type, "end") | ||
| 990 | + bo_end = (aligned_e, te % block_size, te) | ||
| 991 | + | ||
| 992 | + if eff_start == eff_end: | ||
| 993 | + # Empty range (parity with messages path): bo_end signals "no end offset". | ||
| 994 | + return (bo_start, None) | ||
| 995 | + return (bo_start, bo_end) | ||
| 996 | + | ||
| 997 | + if edit_type not in _EDIT_TYPE_OFFSET_COMPUTERS: | ||
| 998 | + logger.warning( | ||
| 999 | + "Unknown edit_type=%r; skipping offset conversion (registered: %s)", | ||
| 1000 | + edit_type, | ||
| 1001 | + sorted(_EDIT_TYPE_OFFSET_COMPUTERS.keys()), | ||
| 1002 | + ) | ||
| 1003 | + return (None, None) | ||
| 1004 | + | ||
| 1005 | + computer = _EDIT_TYPE_OFFSET_COMPUTERS[edit_type] | ||
| 1006 | + n = len(messages) if isinstance(messages, list) else 0 | ||
| 1007 | + | ||
| 1008 | + if n == 0: | ||
| 1009 | + return (None, None) | ||
| 1010 | + | ||
| 1011 | + eff_start = 0 if start is None else (max(0, min(start, n - 1))) | ||
| 1012 | + eff_end = n if end is None else (max(0, min(end, n))) | ||
| 1013 | + | ||
| 1014 | + if start is not None and end is not None and start > end: | ||
| 1015 | + logger.warning( | ||
| 1016 | + "context_edit.start=%d > end=%d; not corrected (left to downstream)", | ||
| 1017 | + start, | ||
| 1018 | + end, | ||
| 1019 | + ) | ||
| 1020 | + | ||
| 1021 | + if eff_start == 0: | ||
| 1022 | + start_target = -1 # sentinel: handled below | ||
| 1023 | + else: | ||
| 1024 | + start_target = eff_start - 1 | ||
| 1025 | + | ||
| 1026 | + if start_target == -1: | ||
| 1027 | + bo_start = (0, 0, 0) | ||
| 1028 | + else: | ||
| 1029 | + bo_start = computer( | ||
| 1030 | + messages, | ||
| 1031 | + tools, | ||
| 1032 | + start_target, | ||
| 1033 | + block_size, | ||
| 1034 | + tokenizer, | ||
| 1035 | + boundary="start", | ||
| 1036 | + messages_table=messages_table, | ||
| 1037 | + ) | ||
| 1038 | + bo_end = computer( | ||
| 1039 | + messages, | ||
| 1040 | + tools, | ||
| 1041 | + eff_end - 1, | ||
| 1042 | + block_size, | ||
| 1043 | + tokenizer, | ||
| 1044 | + boundary="end", | ||
| 1045 | + messages_table=messages_table, | ||
| 1046 | + ) | ||
| 1047 | + return (bo_start, bo_end) | ||
| 1048 | + | ||
| 1049 | + | ||
| 1050 | +def attach_block_offsets(req_info, messages, tools, tokenizer=None) -> None: | ||
| 1051 | + """Fill server-side block coordinates on ``req_info``'s agent hint. | ||
| 1052 | + | ||
| 1053 | + ``tokenizer`` is injected by the caller (the scheduler layer owns | ||
| 1054 | + ``TokenizerManager``) so this domain module never imports back into | ||
| 1055 | + ``scheduler.policy``. | ||
| 1056 | + """ | ||
| 1057 | + try: | ||
| 1058 | + hint = getattr(req_info, "agent_hint_info", None) | ||
| 1059 | + if hint is None or not getattr(hint, "session_id", None): | ||
| 1060 | + return | ||
| 1061 | + | ||
| 1062 | + cm = getattr(hint, "context_management", None) | ||
| 1063 | + manage_request = bool(getattr(cm, "manage_request", False)) if cm is not None else False | ||
| 1064 | + if not manage_request: | ||
| 1065 | + return | ||
| 1066 | + | ||
| 1067 | + is_target_session = False | ||
| 1068 | + if cm is not None and cm.edits: | ||
| 1069 | + agent_hint = req_info.req_data.get("agent_hint") or {} | ||
| 1070 | + cm_dict = agent_hint.setdefault("context_management", {}) | ||
P2:当解析阶段过滤掉任意一个无效 edit 后,len(edits_list) != len(cm.edits),这里会重建为 [dict() ...]。后续循环只写入 block_* / token_* 字段,没有恢复 edit.type、target、start、end,导致下游无法判断 offload/prefetch/evict 及作用范围,编辑语义会丢失。建议重建时从解析后的 edit 模型补齐这些语义字段,并增加部分 edit 被过滤的测试。 ![]() ![]() | |||
| 1071 | + edits_list = cm_dict.get("edits") | ||
| 1072 | + | ||
| 1073 | + if not isinstance(edits_list, list) or len(edits_list) != len(cm.edits): | ||
| 1074 | + edits_list = [ | ||
| 1075 | + { | ||
| 1076 | + "type": edit.type, | ||
| 1077 | + "target": edit.target, | ||
| 1078 | + "start": edit.start, | ||
| 1079 | + "end": edit.end, | ||
| 1080 | + } | ||
| 1081 | + for edit in cm.edits | ||
| 1082 | + ] | ||
| 1083 | + | ||
| 1084 | + for i, edit in enumerate(cm.edits): | ||
| 1085 | + if edit.target == "session": | ||
| 1086 | + is_target_session = True | ||
| 1087 | + break | ||
| 1088 | + | ||
| 1089 | + cc = getattr(hint, "cache_control", None) | ||
| 1090 | + kv_conductor_config = ConductorApiClient.coordinator_config.scheduler_config.kv_conductor_config | ||
| 1091 | + block_size = getattr(kv_conductor_config, "block_size", 128) | ||
| 1092 | + if is_target_session and cc is None: | ||
| 1093 | + messages_table = None | ||
| 1094 | + tools_table = None | ||
| 1095 | + else: | ||
| 1096 | + logger.debug( | ||
| 1097 | + "attach_block_offsets: msgs=%d tools=%d", | ||
| 1098 | + len(messages or []), | ||
| 1099 | + len(tools or []), | ||
| 1100 | + ) | ||
| 1101 | + | ||
| 1102 | + calc = get_block_offset_calculator(tokenizer) | ||
| 1103 | + messages, tools = calc.preprocess(messages, tools) | ||
| 1104 | + messages_table = calc.compute_messages(messages, tools, tokenizer) | ||
| 1105 | + if messages_table is None: | ||
| 1106 | + logger.warning( | ||
| 1107 | + "attach_block_offsets: messages offset table is unavailable; " | ||
| 1108 | + "target='messages' edits will fall back to (None, None)." | ||
| 1109 | + ) | ||
| 1110 | + | ||
| 1111 | + tools_table = calc.compute_tools(messages, tools, tokenizer) | ||
| 1112 | + if tools_table is None and tools: | ||
| 1113 | + logger.warning( | ||
| 1114 | + "attach_block_offsets: tools offset table unavailable; " | ||
| 1115 | + "target='tools' edits will fall back to (None, None).", | ||
| 1116 | + ) | ||
| 1117 | + | ||
| 1118 | + if cc is not None: | ||
| 1119 | + bo = compute_block_offset( | ||
| 1120 | + messages=messages, | ||
| 1121 | + tools=tools, | ||
| 1122 | + msg_offset=cc.msg_offset, | ||
| 1123 | + block_size=block_size, | ||
| 1124 | + tokenizer=tokenizer, | ||
| 1125 | + messages_table=messages_table, | ||
| 1126 | + ) | ||
| 1127 | + if bo is not None: | ||
| 1128 | + block_idx, intra, token_idx = bo | ||
| 1129 | + cc.block_offset = block_idx | ||
| 1130 | + cc.intra_block_offset = intra | ||
| 1131 | + cc.token_offset = token_idx | ||
| 1132 | + | ||
| 1133 | + agent_hint = req_info.req_data.get("agent_hint") or {} | ||
| 1134 | + cc_dict = agent_hint.setdefault("cache_control", {}) | ||
| 1135 | + cc_dict["block_offset"] = block_idx | ||
| 1136 | + cc_dict["intra_block_offset"] = intra | ||
| 1137 | + cc_dict["token_offset"] = token_idx | ||
| 1138 | + req_info.req_data["agent_hint"] = agent_hint | ||
| 1139 | + | ||
| 1140 | + if cm is not None and cm.edits: | ||
| 1141 | + agent_hint = req_info.req_data.get("agent_hint") or {} | ||
| 1142 | + cm_dict = agent_hint.setdefault("context_management", {}) | ||
| 1143 | + edits_list = cm_dict.get("edits") | ||
| 1144 | + | ||
| 1145 | + if not isinstance(edits_list, list) or len(edits_list) != len(cm.edits): | ||
| 1146 | + edits_list = [ | ||
| 1147 | + { | ||
| 1148 | + "type": edit.type, | ||
| 1149 | + "target": edit.target, | ||
| 1150 | + "start": edit.start, | ||
| 1151 | + "end": edit.end, | ||
| 1152 | + } | ||
| 1153 | + for edit in cm.edits | ||
| 1154 | + ] | ||
| 1155 | + | ||
| 1156 | + for i, edit in enumerate(cm.edits): | ||
| 1157 | + if edit.target == "session": | ||
| 1158 | + n = len(messages) if isinstance(messages, list) else 0 | ||
| 1159 | + eff_end = max(0, n) | ||
| 1160 | + edit.start = 0 | ||
| 1161 | + edit.end = eff_end | ||
| 1162 | + if i < len(edits_list) and isinstance(edits_list[i], dict): | ||
| 1163 | + edits_list[i]["start"] = 0 | ||
| 1164 | + edits_list[i]["end"] = eff_end | ||
| 1165 | + continue | ||
| 1166 | + | ||
| 1167 | + bo_start, bo_end = compute_edit_block_offset( | ||
| 1168 | + edit_type=edit.type, | ||
| 1169 | + target=edit.target, | ||
| 1170 | + messages=messages, | ||
| 1171 | + tools=tools, | ||
| 1172 | + start=edit.start, | ||
| 1173 | + end=edit.end, | ||
| 1174 | + block_size=block_size, | ||
| 1175 | + tokenizer=tokenizer, | ||
| 1176 | + messages_table=messages_table, | ||
| 1177 | + tools_table=tools_table, | ||
| 1178 | + ) | ||
| 1179 | + if bo_start is not None: | ||
| 1180 | + bs, is_, ts = bo_start | ||
| 1181 | + edit.block_start = bs | ||
| 1182 | + edit.block_intra_start = is_ | ||
| 1183 | + edit.start_token = ts | ||
| 1184 | + if bo_end is not None: | ||
| 1185 | + be, ie, te = bo_end | ||
| 1186 | + edit.block_end = be | ||
| 1187 | + edit.block_intra_end = ie | ||
| 1188 | + edit.end_token = te | ||
| 1189 | + if bo_start is not None: | ||
| 1190 | + edits_list[i]["block_start"] = bo_start[0] | ||
| 1191 | + edits_list[i]["block_intra_start"] = bo_start[1] | ||
| 1192 | + edits_list[i]["start_token"] = bo_start[2] | ||
| 1193 | + if bo_end is not None: | ||
| 1194 | + edits_list[i]["block_end"] = bo_end[0] | ||
| 1195 | + edits_list[i]["block_intra_end"] = bo_end[1] | ||
| 1196 | + edits_list[i]["end_token"] = bo_end[2] | ||
| 1197 | + | ||
| 1198 | + cm_dict["edits"] = edits_list | ||
| 1199 | + req_info.req_data["agent_hint"] = agent_hint | ||
| 1200 | + except Exception as exc: # noqa: BLE001 — fail-open on hot path | ||
| 1201 | + logger.warning("attach_block_offsets failed, skipping: %s", exc) | ||
| 1202 | + return | ||
| @@ -1,4 +1,3 @@ | |||
| 1 | -# -*- coding: utf-8 -*- | ||
| 2 | # Copyright (c) Huawei Technologies Co., Ltd. 2025-2026. All rights reserved. | 1 | # Copyright (c) Huawei Technologies Co., Ltd. 2025-2026. All rights reserved. |
| 3 | # MindIE is licensed under Mulan PSL v2. | 2 | # MindIE is licensed under Mulan PSL v2. |
| 4 | # You can use this software according to the terms and conditions of the Mulan PSL v2. | 3 | # You can use this software according to the terms and conditions of the Mulan PSL v2. |
| @@ -20,6 +19,7 @@ from motor.common.resources.instance import PDRole | |||
| 20 | from motor.coordinator.domain.scheduling_constraint import SchedulingConstraint | 19 | from motor.coordinator.domain.scheduling_constraint import SchedulingConstraint |
| 21 | from motor.coordinator.tracer.tracing import TraceObj | 20 | from motor.coordinator.tracer.tracing import TraceObj |
| 22 | from motor.coordinator.models.constants import OpenAIField | 21 | from motor.coordinator.models.constants import OpenAIField |
| 22 | +from motor.coordinator.domain.agent_hint import AgentHintInfo | ||
| 23 | 23 | ||
| 24 | 24 | ||
| 25 | class RequestType(Enum): | 25 | class RequestType(Enum): |
| @@ -99,6 +99,10 @@ class RequestInfo(BaseModel): | |||
| 99 | default=None, | 99 | default=None, |
| 100 | description="Internal pin-to-instance constraint (e.g. precision probe); not from client API", | 100 | description="Internal pin-to-instance constraint (e.g. precision probe); not from client API", |
| 101 | ) | 101 | ) |
| 102 | + agent_hint_info: AgentHintInfo = Field( | ||
| 103 | + default_factory=AgentHintInfo, | ||
| 104 | + description="Structured information parsed from the request agent_hint for Scheduler available", | ||
| 105 | + ) | ||
| 102 | 106 | ||
| 103 | def __init__(self, **data): | 107 | def __init__(self, **data): |
| 104 | super().__init__(**data) | 108 | super().__init__(**data) |
| @@ -34,6 +34,10 @@ from motor.coordinator.models.constants import OpenAIField | |||
| 34 | from motor.coordinator.models.request import RequestInfo | 34 | from motor.coordinator.models.request import RequestInfo |
| 35 | from motor.coordinator.tracer.tracing import TracerManager | 35 | from motor.coordinator.tracer.tracing import TracerManager |
| 36 | from motor.coordinator.domain.request_manager import RequestManager | 36 | from motor.coordinator.domain.request_manager import RequestManager |
| 37 | +from motor.coordinator.domain.agent_hint import ( | ||
| 38 | + parse_agent_hint, | ||
| 39 | + ensure_minimum_messages_for_session_edits, | ||
| 40 | +) | ||
| 37 | from motor.coordinator.router.strategies.base import BaseRouter | 41 | from motor.coordinator.router.strategies.base import BaseRouter |
| 38 | from motor.coordinator.router.strategies.pd_hybrid import PDHybridRouter | 42 | from motor.coordinator.router.strategies.pd_hybrid import PDHybridRouter |
| 39 | from motor.coordinator.router.strategies.unified_pd import UnifiedPDRouter | 43 | from motor.coordinator.router.strategies.unified_pd import UnifiedPDRouter |
| @@ -363,6 +367,11 @@ async def __create_request_info( | |||
| 363 | req_data = request_json.copy() | 367 | req_data = request_json.copy() |
| 364 | client_expects_token_ids = bool(request_json.get("return_token_ids", False)) | 368 | client_expects_token_ids = bool(request_json.get("return_token_ids", False)) |
| 365 | 369 | ||
| 370 | + ensure_minimum_messages_for_session_edits(request_json, req_data) | ||
| 371 | + agent_hint_info = parse_agent_hint( | ||
| 372 | + request_json, | ||
| 373 | + headers=dict(raw_request.headers), | ||
| 374 | + ) | ||
| 366 | return RequestInfo( | 375 | return RequestInfo( |
| 367 | req_id=req_id, | 376 | req_id=req_id, |
| 368 | req_data=req_data, | 377 | req_data=req_data, |
| @@ -371,4 +380,5 @@ async def __create_request_info( | |||
| 371 | entry_api=api, | 380 | entry_api=api, |
| 372 | client_expects_token_ids=client_expects_token_ids, | 381 | client_expects_token_ids=client_expects_token_ids, |
| 373 | client_expects_chat_shape=(OpenAIField.MESSAGES in request_json), | 382 | client_expects_chat_shape=(OpenAIField.MESSAGES in request_json), |
| 383 | + agent_hint_info=agent_hint_info, | ||
| 374 | ) | 384 | ) |
| @@ -14,6 +14,7 @@ from pathlib import Path | |||
| 14 | from motor.common.resources.instance import Instance, PDRole | 14 | from motor.common.resources.instance import Instance, PDRole |
| 15 | from motor.common.resources.endpoint import Endpoint | 15 | from motor.common.resources.endpoint import Endpoint |
| 16 | from motor.coordinator.domain import InstanceProvider | 16 | from motor.coordinator.domain import InstanceProvider |
| 17 | +from motor.coordinator.domain.block_offset_translator import attach_block_offsets | ||
| 17 | from motor.coordinator.scheduler.policy.base import BaseSchedulingPolicy, WorkloadLedgerMixin | 18 | from motor.coordinator.scheduler.policy.base import BaseSchedulingPolicy, WorkloadLedgerMixin |
| 18 | from motor.config.coordinator import ( | 19 | from motor.config.coordinator import ( |
| 19 | CoordinatorConfig, | 20 | CoordinatorConfig, |
| @@ -215,7 +216,9 @@ class KvCacheAffinityPolicy(WorkloadLedgerMixin, BaseSchedulingPolicy): | |||
| 215 | messages = req_info.req_data.get(OpenAIField.MESSAGES, None) | 216 | messages = req_info.req_data.get(OpenAIField.MESSAGES, None) |
| 216 | tools = req_info.req_data.get(OpenAIField.TOOLS, None) | 217 | tools = req_info.req_data.get(OpenAIField.TOOLS, None) |
| 217 | if messages is not None: | 218 | if messages is not None: |
| 218 | - encoded_ids = TokenizerManager().apply_chat_template(messages, tools, req_data=req_info.req_data) | 219 | + tokenizer_manager = TokenizerManager() |
| 220 | + encoded_ids = tokenizer_manager.apply_chat_template(messages, tools, req_data=req_info.req_data) | ||
| 221 | + attach_block_offsets(req_info, messages, tools, tokenizer=tokenizer_manager.tokenizer) | ||
| 219 | else: | 222 | else: |
| 220 | prompt = req_info.req_data.get(OpenAIField.PROMPT, None) | 223 | prompt = req_info.req_data.get(OpenAIField.PROMPT, None) |
| 221 | if prompt is not None: | 224 | if prompt is not None: |
| @@ -0,0 +1,167 @@ | |||
| 1 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. | ||
试着用Motor的dev skill约束一下,测试用例行数比代码行都多太多了 ![]() ![]() | |||
| 2 | +# MindIE is licensed under Mulan PSL v2. | ||
| 3 | +# You can use this software according to the terms and conditions of the Mulan PSL v2. | ||
| 4 | +# You may obtain a copy of MulanPSL2 at: | ||
| 5 | +# http://license.coscl.org.cn/MulanPSL2 | ||
| 6 | +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, | ||
| 7 | +# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, | ||
| 8 | +# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. | ||
| 9 | +# See the Mulan PSL v2 for more details. | ||
| 10 | + | ||
| 11 | +"""Unit tests for agent hint parsing and normalization.""" | ||
| 12 | + | ||
| 13 | +import pytest | ||
| 14 | +from pydantic import ValidationError | ||
| 15 | + | ||
| 16 | +from motor.coordinator.domain.agent_hint import ( | ||
| 17 | + CacheControl, | ||
| 18 | + ContextEdit, | ||
| 19 | + ensure_minimum_messages_for_session_edits, | ||
| 20 | + parse_agent_hint, | ||
| 21 | + parse_manage_request, | ||
| 22 | +) | ||
| 23 | + | ||
| 24 | + | ||
| 25 | +def test_parse_agent_hint_resolves_header_ids_and_preserves_extensions(): | ||
| 26 | + """Body IDs take precedence while unknown fields remain available to callers.""" | ||
| 27 | + hint = parse_agent_hint( | ||
| 28 | + {"agent_hint": {"session_id": "body-session", "extension": {"enabled": True}}}, | ||
| 29 | + headers={"x-session-id": "header-session", "x-parent-session-id": "header-parent"}, | ||
| 30 | + ) | ||
| 31 | + | ||
| 32 | + assert hint.session_id == "body-session" | ||
| 33 | + assert hint.parent_session_id == "header-parent" | ||
| 34 | + assert hint.raw_extra == {"extension": {"enabled": True}} | ||
| 35 | + | ||
| 36 | + | ||
| 37 | +def test_parse_agent_hint_complements_single_session_id(): | ||
| 38 | + """A single valid identifier is mirrored to the missing identifier field.""" | ||
| 39 | + hint = parse_agent_hint({"agent_hint": {"parent_session_id": "session-1"}}) | ||
| 40 | + | ||
| 41 | + assert (hint.session_id, hint.parent_session_id) == ("session-1", "session-1") | ||
| 42 | + | ||
| 43 | + | ||
| 44 | + | ||
| 45 | + "ttl, expected", | ||
| 46 | + [("not-a-number", 300), (0, 60), (7200, 3600), (600, 600)], | ||
| 47 | +) | ||
| 48 | +def test_cache_control_normalizes_ttl(ttl, expected): | ||
| 49 | + """TTL input is converted to an integer and clamped to the supported range.""" | ||
| 50 | + assert CacheControl(ttl=ttl).ttl == expected | ||
| 51 | + | ||
| 52 | + | ||
| 53 | +def test_parse_agent_hint_drops_invalid_cache_offset_and_server_fields(): | ||
| 54 | + """Message offsets outside the request are rejected and server coordinates are ignored.""" | ||
| 55 | + hint = parse_agent_hint( | ||
| 56 | + { | ||
| 57 | + "messages": [{"role": "user"}], | ||
| 58 | + "agent_hint": { | ||
| 59 | + "session_id": "s", | ||
| 60 | + "cache_control": {"msg_offset": 2, "block_offset": 99}, | ||
| 61 | + }, | ||
| 62 | + } | ||
| 63 | + ) | ||
| 64 | + | ||
| 65 | + assert hint.cache_control is None | ||
| 66 | + | ||
| 67 | + | ||
| 68 | +def test_parse_agent_hint_filters_invalid_context_edits_by_message_and_tool_bounds(): | ||
| 69 | + """Only edits with valid half-open ranges for their target are retained.""" | ||
| 70 | + hint = parse_agent_hint( | ||
| 71 | + { | ||
| 72 | + "messages": [{"role": "user"}, {"role": "assistant"}], | ||
| 73 | + "tools": [{"type": "function"}], | ||
| 74 | + "agent_hint": { | ||
| 75 | + "session_id": "s", | ||
| 76 | + "context_management": { | ||
| 77 | + "edits": [ | ||
| 78 | + {"type": "offload", "start": 0, "end": 1}, | ||
| 79 | + {"type": "evict", "target": "tools", "start": 0, "end": 1}, | ||
| 80 | + {"type": "prefetch", "start": 2, "end": 2}, | ||
| 81 | + {"type": "offload", "start": 0, "end": 2, "target": "tools"}, | ||
| 82 | + ] | ||
| 83 | + }, | ||
| 84 | + }, | ||
| 85 | + } | ||
| 86 | + ) | ||
| 87 | + | ||
| 88 | + assert hint.context_management is not None | ||
| 89 | + assert [(edit.type, edit.target) for edit in hint.context_management.edits] == [ | ||
| 90 | + ("offload", "session"), | ||
| 91 | + ("evict", "tools"), | ||
| 92 | + ] | ||
| 93 | + | ||
| 94 | + | ||
| 95 | +def test_context_edit_requires_supported_type(): | ||
| 96 | + """Unsupported context operations fail schema validation rather than being executed.""" | ||
| 97 | + with pytest.raises(ValidationError): | ||
| 98 | + ContextEdit(type="delete") | ||
| 99 | + | ||
| 100 | + | ||
| 101 | +def test_ensure_minimum_messages_injects_session_edit_messages(): | ||
| 102 | + """Management session edits receive safe placeholder messages when the body omits them.""" | ||
| 103 | + request_json = {"agent_hint": {"context_management": {"manage_request": True, "edits": [{"type": "evict"}]}}} | ||
| 104 | + req_data = {} | ||
| 105 | + | ||
| 106 | + ensure_minimum_messages_for_session_edits(request_json, req_data) | ||
| 107 | + | ||
| 108 | + assert len(request_json["messages"]) == 2 | ||
| 109 | + assert req_data["messages"] is request_json["messages"] | ||
| 110 | + assert [message["role"] for message in request_json["messages"]] == ["system", "user"] | ||
| 111 | + | ||
| 112 | + | ||
| 113 | +def test_ensure_minimum_messages_does_not_change_non_management_request(): | ||
| 114 | + """Ordinary requests and non-session edits keep their original message payload.""" | ||
| 115 | + messages = [{"role": "user", "content": "hello"}] | ||
| 116 | + request_json = { | ||
| 117 | + "messages": messages, | ||
| 118 | + "agent_hint": {"context_management": {"manage_request": False, "edits": [{"type": "evict"}]}}, | ||
| 119 | + } | ||
| 120 | + req_data = {"messages": messages} | ||
| 121 | + | ||
| 122 | + ensure_minimum_messages_for_session_edits(request_json, req_data) | ||
| 123 | + | ||
| 124 | + assert request_json["messages"] == messages | ||
| 125 | + assert req_data["messages"] == messages | ||
| 126 | + | ||
| 127 | + | ||
| 128 | +def test_parse_agent_hint_resolves_lowercase_headers_from_real_request(): | ||
| 129 | + """Regression: Starlette normalizes header keys to lowercase; parse_agent_hint | ||
| 130 | + must read lowercase keys (matches the real Request.headers path used in dispatch). | ||
| 131 | + """ | ||
| 132 | + from starlette.datastructures import Headers | ||
| 133 | + | ||
| 134 | + # Simulate exactly what dispatch.py passes: dict(raw_request.headers). | ||
| 135 | + # ASGI servers (uvicorn/hypercorn) deliver lowercase header bytes. | ||
| 136 | + raw_request_headers = Headers(raw=[(b"x-session-id", b"req-sess"), (b"x-parent-session-id", b"req-parent")]) | ||
| 137 | + headers_dict = dict(raw_request_headers) | ||
| 138 | + | ||
| 139 | + hint = parse_agent_hint({"agent_hint": {}}, headers=headers_dict) | ||
| 140 | + | ||
| 141 | + assert hint.session_id == "req-sess" | ||
| 142 | + assert hint.parent_session_id == "req-parent" | ||
| 143 | + | ||
| 144 | + | ||
| 145 | + | ||
| 146 | + "value, expected", | ||
| 147 | + [ | ||
| 148 | + (True, True), | ||
| 149 | + (False, False), | ||
| 150 | + (1, True), | ||
| 151 | + (0, False), | ||
| 152 | + ("true", True), | ||
| 153 | + ("false", False), | ||
| 154 | + ("True", True), | ||
| 155 | + ("FALSE", False), | ||
| 156 | + ("TrUe", True), | ||
| 157 | + (None, False), | ||
| 158 | + ("", False), | ||
| 159 | + ("yes", False), | ||
| 160 | + (2, False), | ||
| 161 | + (1.5, False), | ||
| 162 | + ([], False), | ||
| 163 | + ], | ||
| 164 | +) | ||
| 165 | +def test_parse_manage_request_accepts_bool_int_and_string(value, expected): | ||
| 166 | + """JSON bool / 0-1 int / case-insensitive 'true'/'false' parse to bool; everything else falls back to False.""" | ||
| 167 | + assert parse_manage_request(value) is expected | ||
| @@ -0,0 +1,363 @@ | |||
| 1 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. | ||
license不太对。跑好pre-commit应该会自动帮你修复 ![]() ![]() | |||
| 2 | +# MindIE is licensed under Mulan PSL v2. | ||
| 3 | +# You can use this software according to the terms and conditions of the Mulan PSL v2. | ||
| 4 | +# You may obtain a copy of MulanPSL2 at: | ||
| 5 | +# http://license.coscl.org.cn/MulanPSL2 | ||
| 6 | +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, | ||
| 7 | +# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, | ||
| 8 | +# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. | ||
| 9 | +# See the Mulan PSL v2 for more details. | ||
| 10 | + | ||
| 11 | +"""Unit tests for message and tool block-offset translation.""" | ||
| 12 | + | ||
| 13 | +from types import SimpleNamespace | ||
| 14 | +from unittest.mock import patch | ||
| 15 | + | ||
| 16 | +import pytest | ||
| 17 | + | ||
| 18 | +from motor.coordinator.domain.agent_hint import CacheControl, ContextEdit, ContextManagement, parse_agent_hint | ||
| 19 | +from motor.coordinator.domain.block_offset_translator import ( | ||
| 20 | + Dsv4BlockOffsetCalculator, | ||
| 21 | + StandardBlockOffsetCalculator, | ||
| 22 | + _align_block_for_op, | ||
| 23 | + attach_block_offsets, | ||
| 24 | + compute_block_offset, | ||
| 25 | + compute_edit_block_offset, | ||
| 26 | + get_block_offset_calculator, | ||
| 27 | +) | ||
| 28 | + | ||
| 29 | + | ||
| 30 | +class MarkerTokenizer: | ||
| 31 | + """Small tokenizer double exposing the standard marker contract.""" | ||
| 32 | + | ||
| 33 | + unk_token_id = -1 | ||
| 34 | + | ||
| 35 | + def convert_tokens_to_ids(self, token): | ||
| 36 | + return {"<|im_start|>": 7}.get(token, -1) | ||
| 37 | + | ||
| 38 | + def encode(self, text, add_special_tokens=False): | ||
| 39 | + if text == "<|im_start|>": | ||
| 40 | + return [7] | ||
| 41 | + return list(range(len(text))) | ||
| 42 | + | ||
| 43 | + def apply_chat_template(self, messages, **kwargs): | ||
| 44 | + if kwargs.get("tokenize"): | ||
| 45 | + return [7, 11, 12, 7, 21, 7, 31] | ||
| 46 | + return "prefix" | ||
| 47 | + | ||
| 48 | + | ||
| 49 | +class Dsv4Tokenizer: | ||
| 50 | + """Tokenizer double whose class name exercises DSV4 calculator selection.""" | ||
| 51 | + | ||
| 52 | + unk_token_id = -1 | ||
| 53 | + | ||
| 54 | + def convert_tokens_to_ids(self, token): | ||
| 55 | + return {"<|User|>": 1, "<|Assistant|>": 2, "<|end▁of▁sentence|>": 3}.get(token, -1) | ||
| 56 | + | ||
| 57 | + def encode(self, text, add_special_tokens=False): | ||
| 58 | + if text == "<|User|>": | ||
| 59 | + return [1] | ||
| 60 | + if text == "<|Assistant|>": | ||
| 61 | + return [2] | ||
| 62 | + if text == "<|end▁of▁sentence|>": | ||
| 63 | + return [3] | ||
| 64 | + return list(range(len(text))) | ||
| 65 | + | ||
| 66 | + def apply_chat_template(self, messages, **kwargs): | ||
| 67 | + if kwargs.get("tokenize"): | ||
| 68 | + return [1, 10, 2, 20, 3, 1, 30] | ||
| 69 | + return "" | ||
| 70 | + | ||
| 71 | + | ||
| 72 | +Dsv4Tokenizer.__name__ = "DSV4Tokenizer" | ||
| 73 | + | ||
| 74 | + | ||
| 75 | +def test_get_block_offset_calculator_selects_model_family(): | ||
| 76 | + """DSV4 wrappers use the marker calculator while ordinary tokenizers use standard logic.""" | ||
| 77 | + assert isinstance(get_block_offset_calculator(Dsv4Tokenizer()), Dsv4BlockOffsetCalculator) | ||
| 78 | + assert isinstance(get_block_offset_calculator(MarkerTokenizer()), StandardBlockOffsetCalculator) | ||
| 79 | + | ||
| 80 | + | ||
| 81 | + | ||
| 82 | + "token_idx, block_size, policy, boundary, expected", | ||
| 83 | + [ | ||
| 84 | + (9, 8, "positive", "start", 1), | ||
| 85 | + (9, 8, "negative", "start", 2), | ||
| 86 | + (9, 8, "negative", "end", 0), | ||
| 87 | + (7, 8, "negative", "end", 0), | ||
| 88 | + (9, 8, "unknown", "start", 1), | ||
| 89 | + (9, 0, "positive", "start", 0), | ||
| 90 | + ], | ||
| 91 | +) | ||
| 92 | +def test_align_block_for_operation_policies(token_idx, block_size, policy, boundary, expected): | ||
| 93 | + """Alignment policy controls whether partial edge blocks are retained or removed.""" | ||
| 94 | + assert _align_block_for_op(token_idx, block_size, policy, boundary) == expected | ||
| 95 | + | ||
| 96 | + | ||
| 97 | +def test_compute_block_offset_uses_one_based_message_offset(): | ||
| 98 | + """The first message offset maps to the first token in the supplied offset table.""" | ||
| 99 | + result = compute_block_offset( | ||
| 100 | + messages=[{}, {}], | ||
| 101 | + tools=None, | ||
| 102 | + msg_offset=2, | ||
| 103 | + block_size=8, | ||
| 104 | + tokenizer=None, | ||
| 105 | + messages_table=[5, 17], | ||
| 106 | + ) | ||
| 107 | + | ||
| 108 | + assert result == (2, 1, 17) | ||
| 109 | + | ||
| 110 | + | ||
| 111 | +def test_compute_edit_block_offset_messages_uses_full_range_sentinels(): | ||
| 112 | + """Message edits use token zero for an open range and the selected end message for its end.""" | ||
| 113 | + result = compute_edit_block_offset( | ||
| 114 | + edit_type="offload", | ||
| 115 | + messages=[{}, {}, {}], | ||
| 116 | + tools=None, | ||
| 117 | + start=None, | ||
| 118 | + end=2, | ||
| 119 | + block_size=8, | ||
| 120 | + tokenizer=None, | ||
| 121 | + messages_table=[5, 17, 25], | ||
| 122 | + ) | ||
| 123 | + | ||
| 124 | + assert result == ((0, 0, 0), (2, 1, 17)) | ||
| 125 | + | ||
| 126 | + | ||
| 127 | +def test_compute_edit_block_offset_tools_supports_negative_alignment(): | ||
| 128 | + """Tool ranges shrink to complete blocks for evict operations.""" | ||
| 129 | + result = compute_edit_block_offset( | ||
| 130 | + edit_type="evict", | ||
| 131 | + messages=[{}], | ||
| 132 | + tools=[{"function": {"name": "a"}}, {"function": {"name": "b"}}], | ||
| 133 | + start=1, | ||
| 134 | + end=2, | ||
| 135 | + block_size=8, | ||
| 136 | + tokenizer=None, | ||
| 137 | + target="tools", | ||
| 138 | + tools_table=[3, 16, 23], | ||
| 139 | + ) | ||
| 140 | + | ||
| 141 | + assert result == ((2, 0, 16), (2, 7, 23)) | ||
| 142 | + | ||
| 143 | + | ||
| 144 | +def test_compute_edit_block_offset_returns_none_for_session_target(): | ||
| 145 | + """Session-level edits are represented by the whole-session operation, not coordinates.""" | ||
| 146 | + assert compute_edit_block_offset("offload", [{}], None, 1, 2, 8, None, target="session", messages_table=[5]) == ( | ||
| 147 | + None, | ||
| 148 | + None, | ||
| 149 | + ) | ||
| 150 | + | ||
| 151 | + | ||
| 152 | +def test_attach_block_offsets_populates_model_and_request_coordinates(): | ||
| 153 | + """Attached coordinates are written to both parsed models and the outbound request.""" | ||
| 154 | + edit = ContextEdit(type="offload", start=1, end=2, target="messages") | ||
| 155 | + hint = SimpleNamespace( | ||
| 156 | + session_id="s", | ||
| 157 | + cache_control=CacheControl(msg_offset=2), | ||
| 158 | + context_management=ContextManagement(manage_request=True, edits=[edit]), | ||
| 159 | + ) | ||
| 160 | + req_info = SimpleNamespace( | ||
| 161 | + agent_hint_info=hint, | ||
| 162 | + req_data={ | ||
| 163 | + "agent_hint": { | ||
| 164 | + "cache_control": {"msg_offset": 2}, | ||
| 165 | + "context_management": {"edits": [{"type": "offload", "start": 1, "end": 2}]}, | ||
| 166 | + } | ||
| 167 | + }, | ||
| 168 | + ) | ||
| 169 | + config = SimpleNamespace(scheduler_config=SimpleNamespace(kv_conductor_config=SimpleNamespace(block_size=8))) | ||
| 170 | + | ||
| 171 | + with ( | ||
| 172 | + patch("motor.coordinator.domain.block_offset_translator.ConductorApiClient.coordinator_config", config), | ||
| 173 | + patch("motor.coordinator.domain.block_offset_translator.get_block_offset_calculator") as get_calculator, | ||
| 174 | + ): | ||
| 175 | + calculator = get_calculator.return_value | ||
| 176 | + calculator.preprocess.return_value = ([{}, {}], None) | ||
| 177 | + calculator.compute_messages.return_value = [5, 17] | ||
| 178 | + calculator.compute_tools.return_value = None | ||
| 179 | + attach_block_offsets(req_info, [{}, {}], None, tokenizer=object()) | ||
| 180 | + | ||
| 181 | + assert hint.cache_control.token_offset == 17 | ||
| 182 | + assert hint.cache_control.block_offset == 2 | ||
| 183 | + assert edit.start_token == 5 | ||
| 184 | + assert edit.end_token == 17 | ||
| 185 | + assert req_info.req_data["agent_hint"]["cache_control"]["token_offset"] == 17 | ||
| 186 | + assert req_info.req_data["agent_hint"]["context_management"]["edits"][0]["end_token"] == 17 | ||
| 187 | + | ||
| 188 | + | ||
| 189 | +def test_attach_block_offsets_uses_parsed_pydantic_hint(): | ||
| 190 | + """Regression: hints built via parse_agent_hint() must populate offsets even though | ||
| 191 | + manage_request lives at context_management.manage_request, not at the top level | ||
| 192 | + on AgentHintInfo. The SimpleNamespace-based positive test alone cannot catch this. | ||
| 193 | + """ | ||
| 194 | + request_json = { | ||
| 195 | + "messages": [{"role": "user", "content": "hi"}, {"role": "assistant", "content": "hello"}], | ||
| 196 | + "agent_hint": { | ||
| 197 | + "session_id": "s", | ||
| 198 | + "cache_control": {"msg_offset": 2}, | ||
| 199 | + "context_management": { | ||
| 200 | + "manage_request": True, | ||
| 201 | + "edits": [{"type": "offload", "start": 1, "end": 2}], | ||
| 202 | + }, | ||
| 203 | + }, | ||
| 204 | + } | ||
| 205 | + hint = parse_agent_hint(request_json) | ||
| 206 | + | ||
| 207 | + # Schema guards: manage_request must NOT be hoisted to AgentHintInfo. | ||
| 208 | + assert not hasattr(hint, "manage_request") | ||
| 209 | + assert hint.context_management is not None | ||
| 210 | + assert hint.context_management.manage_request is True | ||
| 211 | + | ||
| 212 | + req_info = SimpleNamespace( | ||
| 213 | + agent_hint_info=hint, | ||
| 214 | + req_data={ | ||
| 215 | + "agent_hint": { | ||
| 216 | + "cache_control": {"msg_offset": 2}, | ||
| 217 | + "context_management": { | ||
| 218 | + "manage_request": True, | ||
| 219 | + "edits": [{"type": "offload", "start": 1, "end": 2}], | ||
| 220 | + }, | ||
| 221 | + } | ||
| 222 | + }, | ||
| 223 | + ) | ||
| 224 | + config = SimpleNamespace(scheduler_config=SimpleNamespace(kv_conductor_config=SimpleNamespace(block_size=8))) | ||
| 225 | + | ||
| 226 | + with ( | ||
| 227 | + patch("motor.coordinator.domain.block_offset_translator.ConductorApiClient.coordinator_config", config), | ||
| 228 | + patch("motor.coordinator.domain.block_offset_translator.get_block_offset_calculator") as get_calculator, | ||
| 229 | + ): | ||
| 230 | + calculator = get_calculator.return_value | ||
| 231 | + calculator.preprocess.return_value = ([{}, {}], None) | ||
| 232 | + calculator.compute_messages.return_value = [5, 17] | ||
| 233 | + calculator.compute_tools.return_value = None | ||
| 234 | + attach_block_offsets(req_info, [{}, {}], None, tokenizer=object()) | ||
| 235 | + | ||
| 236 | + # Real Pydantic path now reaches the offset-computation branch. | ||
| 237 | + assert hint.cache_control is not None | ||
| 238 | + assert hint.cache_control.token_offset == 17 | ||
| 239 | + assert hint.cache_control.block_offset == 2 | ||
| 240 | + assert req_info.req_data["agent_hint"]["cache_control"]["token_offset"] == 17 | ||
| 241 | + | ||
| 242 | + | ||
| 243 | +def test_attach_block_offsets_preserves_edit_semantics_after_partial_filter(): | ||
| 244 | + """Partial edit filtering at parse time must not strip type/target/start/end | ||
| 245 | + from surviving edits when attach_block_offsets rebuilds the list. | ||
| 246 | + | ||
| 247 | + Regression: the rebuild path previously produced dicts with only block_* fields, | ||
| 248 | + silently dropping offload/prefetch/evict type and target — downstream could no | ||
| 249 | + longer dispatch the edit or know its scope. | ||
| 250 | + """ | ||
| 251 | + # 2 messages; second edit has end > len(messages) so _validate_edit_indices drops it. | ||
| 252 | + request_json = { | ||
| 253 | + "messages": [{"role": "user"}, {"role": "assistant"}], | ||
| 254 | + "agent_hint": { | ||
| 255 | + "session_id": "s", | ||
| 256 | + "context_management": { | ||
| 257 | + "manage_request": True, | ||
| 258 | + "edits": [ | ||
| 259 | + {"type": "offload", "start": 0, "end": 1, "target": "messages"}, | ||
| 260 | + {"type": "evict", "start": 0, "end": 99, "target": "messages"}, | ||
| 261 | + {"type": "prefetch", "start": 0, "end": 2, "target": "messages"}, | ||
| 262 | + ], | ||
| 263 | + }, | ||
| 264 | + }, | ||
| 265 | + } | ||
| 266 | + hint = parse_agent_hint(request_json) | ||
| 267 | + assert hint.context_management is not None | ||
| 268 | + assert len(hint.context_management.edits) == 2 # Parser kept 2 of 3. | ||
| 269 | + | ||
| 270 | + # Simulate the dispatch path: req_data carries the RAW (unfiltered) request body | ||
| 271 | + # while cm.edits is filtered. Length mismatch triggers the rebuild branch. | ||
| 272 | + raw_edits = [dict(e) for e in request_json["agent_hint"]["context_management"]["edits"]] | ||
| 273 | + req_info = SimpleNamespace( | ||
| 274 | + agent_hint_info=hint, | ||
| 275 | + req_data={ | ||
| 276 | + "agent_hint": { | ||
| 277 | + "context_management": { | ||
| 278 | + "manage_request": True, | ||
| 279 | + "edits": raw_edits, | ||
| 280 | + } | ||
| 281 | + } | ||
| 282 | + }, | ||
| 283 | + ) | ||
| 284 | + config = SimpleNamespace(scheduler_config=SimpleNamespace(kv_conductor_config=SimpleNamespace(block_size=8))) | ||
| 285 | + | ||
| 286 | + with ( | ||
| 287 | + patch("motor.coordinator.domain.block_offset_translator.ConductorApiClient.coordinator_config", config), | ||
| 288 | + patch("motor.coordinator.domain.block_offset_translator.get_block_offset_calculator") as get_calculator, | ||
| 289 | + ): | ||
| 290 | + calculator = get_calculator.return_value | ||
| 291 | + calculator.preprocess.return_value = ([{}, {}], None) | ||
| 292 | + calculator.compute_messages.return_value = [5, 17] | ||
| 293 | + calculator.compute_tools.return_value = None | ||
| 294 | + attach_block_offsets(req_info, [{}, {}], None, tokenizer=object()) | ||
| 295 | + | ||
| 296 | + out_edits = req_info.req_data["agent_hint"]["context_management"]["edits"] | ||
| 297 | + # Length aligned to surviving edits, NOT the original raw list. | ||
| 298 | + assert len(out_edits) == 2 | ||
| 299 | + # Semantic fields preserved on each surviving edit (the regression surface). | ||
| 300 | + assert [(e["type"], e["target"], e["start"], e["end"]) for e in out_edits] == [ | ||
| 301 | + ("offload", "messages", 0, 1), | ||
| 302 | + ("prefetch", "messages", 0, 2), | ||
| 303 | + ] | ||
| 304 | + # Block offsets still populated. | ||
| 305 | + assert all("block_start" in e and "end_token" in e for e in out_edits) | ||
| 306 | + | ||
| 307 | + | ||
| 308 | +def test_attach_block_offsets_skips_non_management_hint(): | ||
| 309 | + """Non-management requests are left untouched and do not require tokenizer access.""" | ||
| 310 | + edit = ContextEdit(type="offload", start=0, end=1) | ||
| 311 | + req_info = SimpleNamespace( | ||
| 312 | + agent_hint_info=SimpleNamespace( | ||
| 313 | + session_id="s", manage_request=False, cache_control=None, context_management=ContextManagement(edits=[edit]) | ||
| 314 | + ), | ||
| 315 | + req_data={}, | ||
| 316 | + ) | ||
| 317 | + | ||
| 318 | + attach_block_offsets(req_info, [{}], None) | ||
| 319 | + | ||
| 320 | + assert edit.block_start is None | ||
| 321 | + | ||
| 322 | + | ||
| 323 | +def test_dsv4_message_offsets_follow_role_markers(): | ||
| 324 | + """DSV4 message boundaries use user, assistant, and EOS markers rather than generic markers.""" | ||
| 325 | + calculator = Dsv4BlockOffsetCalculator() | ||
| 326 | + | ||
| 327 | + assert calculator.compute_messages([{"role": "user"}, {"role": "assistant"}], [], Dsv4Tokenizer()) == [2, 5] | ||
| 328 | + | ||
| 329 | + | ||
| 330 | +def test_standard_message_offsets_fail_open_without_marker(): | ||
| 331 | + """A tokenizer without a recognized chat marker yields no offset table.""" | ||
| 332 | + tokenizer = SimpleNamespace( | ||
| 333 | + convert_tokens_to_ids=lambda _: -1, unk_token_id=-1, encode=lambda *_args, **_kwargs: [] | ||
| 334 | + ) | ||
| 335 | + | ||
| 336 | + assert StandardBlockOffsetCalculator().compute_messages([{}], None, tokenizer) is None | ||
| 337 | + | ||
| 338 | + | ||
| 339 | +def test_invalid_block_size_does_not_compute_offsets(): | ||
| 340 | + """Non-positive block sizes are rejected before any division occurs.""" | ||
| 341 | + assert compute_block_offset([{}], None, 1, 0, None, [1]) is None | ||
| 342 | + assert compute_edit_block_offset("offload", [{}], None, 0, 1, -1, None, messages_table=[1]) is None | ||
| 343 | + | ||
| 344 | + | ||
| 345 | + | ||
| 346 | +def test_edit_target_without_required_data_returns_empty_coordinates(target): | ||
| 347 | + """Missing target data fails open instead of creating forged coordinates.""" | ||
| 348 | + assert compute_edit_block_offset("offload", [{}], None, 0, 1, 8, None, target=target, messages_table=[1]) == ( | ||
| 349 | + None, | ||
| 350 | + None, | ||
| 351 | + ) | ||
| 352 | + | ||
| 353 | + | ||
| 354 | + | ||
| 355 | +def test_unknown_edit_type_is_ignored(edit_type): | ||
| 356 | + """Unregistered edit types do not invoke an arbitrary offset computer.""" | ||
| 357 | + assert compute_edit_block_offset(edit_type, [{}], None, 0, 1, 8, None, messages_table=[1]) == (None, None) | ||
| 358 | + | ||
| 359 | + | ||
| 360 | + | ||
| 361 | +def test_dsv4_tools_offsets_fail_open_without_usable_render(tokenizer): | ||
| 362 | + """Tool translation returns no table when the tokenizer cannot render a complete prompt.""" | ||
| 363 | + assert Dsv4BlockOffsetCalculator().compute_tools([{}], [{"function": {"name": "tool"}}], tokenizer) is None | ||
| @@ -1135,6 +1135,97 @@ class TestCoordinatorServerAdvanced: | |||
| 1135 | 1135 | ||
| 1136 | assert response.status_code == 400, f"Expected 400 for empty messages, got: {response.status_code}" | 1136 | assert response.status_code == 400, f"Expected 400 for empty messages, got: {response.status_code}" |
| 1137 | 1137 | ||
| 1138 | + def test_validate_openai_request_empty_messages_manage_false_session_target_rejected(self): | ||
| 1139 | + """Empty messages must be rejected when manage=false even if a session-targeted edit is present.""" | ||
| 1140 | + invalid_data = { | ||
| 1141 | + "model": "gpt-3.5-turbo", | ||
| 1142 | + "messages": [], | ||
| 1143 | + "agent_hint": { | ||
| 1144 | + "context_management": { | ||
| 1145 | + "manage_request": False, | ||
| 1146 | + "edits": [{"type": "evict", "target": "session"}], | ||
| 1147 | + } | ||
| 1148 | + }, | ||
| 1149 | + } | ||
| 1150 | + inference_client = TestClient(self.coordinator_server.inference_app) | ||
| 1151 | + response = inference_client.post( | ||
| 1152 | + "/v1/chat/completions", | ||
| 1153 | + json=invalid_data, | ||
| 1154 | + headers={"Content-Type": "application/json", "Authorization": f"Bearer {self.valid_api_key}"}, | ||
| 1155 | + ) | ||
| 1156 | + assert response.status_code == 400, ( | ||
| 1157 | + f"Expected 400 for empty messages with manage=false + target=session, got: {response.status_code}" | ||
| 1158 | + ) | ||
| 1159 | + assert "must be a non-empty array" in response.text | ||
| 1160 | + | ||
| 1161 | + def test_validate_openai_request_empty_messages_manage_true_messages_target_rejected(self): | ||
| 1162 | + """Empty messages must be rejected when manage=true but target is messages (not session).""" | ||
| 1163 | + invalid_data = { | ||
| 1164 | + "model": "gpt-3.5-turbo", | ||
| 1165 | + "messages": [], | ||
| 1166 | + "agent_hint": { | ||
| 1167 | + "context_management": { | ||
| 1168 | + "manage_request": True, | ||
| 1169 | + "edits": [{"type": "evict", "target": "messages"}], | ||
| 1170 | + } | ||
| 1171 | + }, | ||
| 1172 | + } | ||
| 1173 | + inference_client = TestClient(self.coordinator_server.inference_app) | ||
| 1174 | + response = inference_client.post( | ||
| 1175 | + "/v1/chat/completions", | ||
| 1176 | + json=invalid_data, | ||
| 1177 | + headers={"Content-Type": "application/json", "Authorization": f"Bearer {self.valid_api_key}"}, | ||
| 1178 | + ) | ||
| 1179 | + assert response.status_code == 400, ( | ||
| 1180 | + f"Expected 400 for empty messages with manage=true + target=messages, got: {response.status_code}" | ||
| 1181 | + ) | ||
| 1182 | + assert "must be a non-empty array" in response.text | ||
| 1183 | + | ||
| 1184 | + def test_validate_openai_request_empty_messages_manage_true_tools_target_rejected(self): | ||
| 1185 | + """Empty messages must be rejected when manage=true but target is tools (not session).""" | ||
| 1186 | + invalid_data = { | ||
| 1187 | + "model": "gpt-3.5-turbo", | ||
| 1188 | + "messages": [], | ||
| 1189 | + "agent_hint": { | ||
| 1190 | + "context_management": { | ||
| 1191 | + "manage_request": True, | ||
| 1192 | + "edits": [{"type": "evict", "target": "tools"}], | ||
| 1193 | + } | ||
| 1194 | + }, | ||
| 1195 | + } | ||
| 1196 | + inference_client = TestClient(self.coordinator_server.inference_app) | ||
| 1197 | + response = inference_client.post( | ||
| 1198 | + "/v1/chat/completions", | ||
| 1199 | + json=invalid_data, | ||
| 1200 | + headers={"Content-Type": "application/json", "Authorization": f"Bearer {self.valid_api_key}"}, | ||
| 1201 | + ) | ||
| 1202 | + assert response.status_code == 400, ( | ||
| 1203 | + f"Expected 400 for empty messages with manage=true + target=tools, got: {response.status_code}" | ||
| 1204 | + ) | ||
| 1205 | + assert "must be a non-empty array" in response.text | ||
| 1206 | + | ||
| 1207 | + def test_validate_openai_request_empty_messages_manage_true_session_target_allowed(self): | ||
| 1208 | + """Empty messages is the only valid bypass: manage=true AND target=session.""" | ||
| 1209 | + data = { | ||
| 1210 | + "model": "gpt-3.5-turbo", | ||
| 1211 | + "messages": [], | ||
| 1212 | + "agent_hint": { | ||
| 1213 | + "context_management": { | ||
| 1214 | + "manage_request": True, | ||
| 1215 | + "edits": [{"type": "evict", "target": "session"}], | ||
| 1216 | + } | ||
| 1217 | + }, | ||
| 1218 | + } | ||
| 1219 | + inference_client = TestClient(self.coordinator_server.inference_app) | ||
| 1220 | + response = inference_client.post( | ||
| 1221 | + "/v1/chat/completions", | ||
| 1222 | + json=data, | ||
| 1223 | + headers={"Content-Type": "application/json", "Authorization": f"Bearer {self.valid_api_key}"}, | ||
| 1224 | + ) | ||
| 1225 | + assert response.status_code != 400, ( | ||
| 1226 | + f"Empty messages with manage=true + target=session must bypass the non-empty check, got {response.status_code}: {response.text}" | ||
| 1227 | + ) | ||
| 1228 | + | ||
| 1138 | def test_validate_openai_request_invalid_message_format(self): | 1229 | def test_validate_openai_request_invalid_message_format(self): |
| 1139 | """Test _validate_openai_request with invalid message format""" | 1230 | """Test _validate_openai_request with invalid message format""" |
| 1140 | invalid_data = { | 1231 | invalid_data = { |


edit 缺 target 时默认 session,可能把 messages 编辑当 session 处理,建议确认。