From 0536f419814e69e2487c5d2e9956cd6b5a3c5b93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D1=87=D1=83=D0=B1=D0=B5=D0=B9=20=D0=9F=D0=B0?= =?UTF-8?q?=D0=B2=D0=B5=D0=BB=20=D0=9C=D0=B8=D1=85=D0=B0=D0=B9=D0=BB=D0=BE?= =?UTF-8?q?=D0=B2=D0=B8=D1=87?= Date: Mon, 21 Sep 2026 12:26:39 +0300 Subject: [PATCH 1/6] My fixies --- .../resource-scheduling-and-context-cache.md | 4 ++-- docs/serving.md | 2 +- include/ninfer/types.h | 5 ++-- src/ops/linear/nvfp4/nvfp4_w4a4_plan.h | 4 ++-- .../nvfp4/nvfp4_linear_swiglu_plan.cpp | 22 +++--------------- .../nvfp4/nvfp4_linear_swiglu_w4a4_tma.cu | 17 ++++++++------ .../nvfp4/nvfp4_linear_swiglu_w4a4_tma.cuh | 15 +++++++----- .../dense/causal_cache/small_t.cu | 14 ++++++++--- .../dense/causal_cache/small_t.cuh | 2 ++ .../dense/causal_cache/small_t_bf16.cuh | 3 ++- .../dense/causal_cache/small_t_fp8.cuh | 3 ++- .../dense/causal_cache/small_t_i8.cuh | 3 ++- .../dense/causal_cache/small_t_k8v4.cuh | 3 ++- .../dense/causal_cache/small_t_nvfp4.cuh | 3 ++- .../context_cache/context_portfolio_value.h | 2 ++ .../context_cache/materialization_planner.h | 2 ++ .../engine/context_cache/resource_manager.h | 23 ++++++++++++++++--- .../context_cache/shared_capture_planner.h | 2 ++ src/runtime/engine/model_instance.cpp | 5 ++-- src/serve/anthropic_messages_response.cpp | 8 ++++++- src/serve/openai_chat_response.cpp | 18 ++++++++++++--- .../qwen3_5/test_engine_prefix_real.cpp | 4 ++-- 22 files changed, 106 insertions(+), 58 deletions(-) diff --git a/docs/maintainer/resource-scheduling-and-context-cache.md b/docs/maintainer/resource-scheduling-and-context-cache.md index 5543200b96..7f0f95f2ce 100644 --- a/docs/maintainer/resource-scheduling-and-context-cache.md +++ b/docs/maintainer/resource-scheduling-and-context-cache.md @@ -510,8 +510,8 @@ candidates:全部 tools 之后、连续 leading System/Developer 之后,以 因此每个请求最多七个 prepared candidates。这个固定上限不是启动配置。 Shared catalog 是 Engine-wide 公共容量,不是每条 lineage 的配额。启用 context cache 时,默认 logical -capacity 同时覆盖 active concurrency 下限和单请求最多四个显式 markers,即 -`max(max_concurrency, kMaximumExplicitPromptCacheMarkers)`;显式配置仍完整覆盖默认值。这个下限允许较早的 +capacity 同时覆盖 active concurrency 下限和单请求最多七个 prepared candidates,即 +`max(max_concurrency, kMaximumPreparedPromptCacheCandidatesPerRequest)`;显式配置仍完整覆盖默认值。这个下限允许较早的 稳定层与较晚的滚动 marker 同时成为 owner,但是否 capture、保留或替换仍只由通用 portfolio/pressure planning 决定,不提供 Claude、compact 或 token-position 特例。 diff --git a/docs/serving.md b/docs/serving.md index 329763fe1b..7c20649bef 100644 --- a/docs/serving.md +++ b/docs/serving.md @@ -785,7 +785,7 @@ The table lists executable defaults. The startup example selects a long-context | `--host-state-slots N` | pinned Host StateImage capacity | `8` | | `--host-kv-mib N` | shared pinned Host Main/Backend KV byte capacity in MiB | `8192` | | `--max-private-continuations N` | private continuation descriptor capacity | `2 * max-concurrency` | -| `--max-shared-prefixes N` | Engine-wide shared stable-prefix descriptor capacity | `max(max-concurrency, 4)` | +| `--max-shared-prefixes N` | Engine-wide shared stable-prefix descriptor capacity | `max(max-concurrency, 7)` | | `--max-long-anchors-per-continuation N` | private long-anchor limit per continuation | `2` | | `--no-thinking` | disable thinking by default | thinking on | | `--preserve-thinking` | preserve closed-turn assistant reasoning by default | off | diff --git a/include/ninfer/types.h b/include/ninfer/types.h index a2e8b75481..b9eec8d014 100644 --- a/include/ninfer/types.h +++ b/include/ninfer/types.h @@ -19,7 +19,8 @@ using TokenId = std::int32_t; inline constexpr std::uint32_t kMaximumConcurrency = 8; inline constexpr std::size_t kMaximumContextCacheSessionKeyBytes = 256; -inline constexpr std::size_t kMaximumExplicitPromptCacheMarkers = 4; +inline constexpr std::size_t kMaximumExplicitPromptCacheMarkers = 4; +inline constexpr std::size_t kMaximumPreparedPromptCacheCandidatesPerRequest = 7; // Aggregate encoded image/video payload retained by one prompt, independent of item count. inline constexpr std::size_t kMaximumPromptMediaBytes = 256ULL << 20; inline constexpr std::size_t kDefaultMediaCacheBytes = 1ULL << 30; @@ -127,7 +128,7 @@ struct StartupObserver { struct ContextCacheOptions { // Engine resolves every optional once at construction. With C=max_concurrency, the enabled - // defaults are H=C, R=8, Host KV=8 GiB, P=2C, S=max(C,4) and L=2; + // defaults are H=C, R=8, Host KV=8 GiB, P=2C, S=max(C,7) and L=2; // Engine::options() returns those effective values. bool enabled = true; // Extra Device checkpoint StateImage slots H. Total Device StateImage capacity is C + H. diff --git a/src/ops/linear/nvfp4/nvfp4_w4a4_plan.h b/src/ops/linear/nvfp4/nvfp4_w4a4_plan.h index a83b3a0e51..4957fb42d8 100644 --- a/src/ops/linear/nvfp4/nvfp4_w4a4_plan.h +++ b/src/ops/linear/nvfp4/nvfp4_w4a4_plan.h @@ -39,8 +39,8 @@ Nvfp4W4a4Workspace allocate_nvfp4_w4a4_workspace(Arena& arena, std::int32_t toke if (input_rows <= 0 || (input_rows % 64) != 0) { throw std::invalid_argument("nvfp4 W4A4 workspace: invalid K"); } - const std::size_t code_bytes = - nvfp4_w4a4_checked_bytes(tokens, static_cast(input_rows) / 2); + const std::size_t code_bytes = nvfp4_w4a4_checked_bytes( + nvfp4_w4a4_padded_tokens(tokens), static_cast(input_rows) / 2); // The tiled layout addresses whole tiles, so the scale plane is allocated for the padded token // count: at most 255 tokens of scales, under 0.3 MiB on the widest registered K, and paid // whichever layout the quantizer then writes. diff --git a/src/ops/linear_swiglu/nvfp4/nvfp4_linear_swiglu_plan.cpp b/src/ops/linear_swiglu/nvfp4/nvfp4_linear_swiglu_plan.cpp index ec56e64ca5..9ff69f46d2 100644 --- a/src/ops/linear_swiglu/nvfp4/nvfp4_linear_swiglu_plan.cpp +++ b/src/ops/linear_swiglu/nvfp4/nvfp4_linear_swiglu_plan.cpp @@ -37,12 +37,7 @@ Nvfp4LinearSwiGluRoute resolve_route(LinearPolicy policy, std::int32_t tokens) { if (tokens == 1) { return Nvfp4LinearSwiGluRoute::DecodeFusedA16; } if (tokens <= 4) { return Nvfp4LinearSwiGluRoute::SmallTFusedA16; } if (tokens <= kFusedMaxTokens) { return Nvfp4LinearSwiGluRoute::FusedW4A4; } - // This route dispatches its own fused kernel rather than a Linear shape's, so it carries its - // own condition; the call site below forces the matching scale layout. - if (tokens >= kNvfp4TmaBlockM && (tokens % kNvfp4TmaBlockM) == 0) { - return Nvfp4LinearSwiGluRoute::TmaFusedW4A4; - } - return Nvfp4LinearSwiGluRoute::LinearW4A4Post; + return Nvfp4LinearSwiGluRoute::TmaFusedW4A4; } struct Nvfp4LinearSwiGluWorkspace { @@ -96,19 +91,8 @@ std::size_t nvfp4_linear_swiglu_workspace_capacity_bytes(LinearPolicy policy, if (min_tokens <= kFusedMaxTokens && max_tokens >= 5) { maximum = fused_workspace_bytes(std::min(max_tokens, kFusedMaxTokens)); } - if (max_tokens >= kNvfp4TmaBlockM) { - const std::int32_t largest_fused = max_tokens - (max_tokens % kNvfp4TmaBlockM); - if (largest_fused >= std::max(min_tokens, kNvfp4TmaBlockM)) { - maximum = std::max(maximum, fused_workspace_bytes(largest_fused)); - } - } - - std::int32_t last_baseline = max_tokens; - if (resolve_route(policy, last_baseline) == Nvfp4LinearSwiGluRoute::TmaFusedW4A4) { - --last_baseline; - } - if (last_baseline >= std::max(min_tokens, kFusedMaxTokens + 1)) { - maximum = std::max(maximum, baseline_workspace_bytes(last_baseline)); + if (max_tokens > kFusedMaxTokens) { + maximum = std::max(maximum, fused_workspace_bytes(max_tokens)); } return maximum; } diff --git a/src/ops/linear_swiglu/nvfp4/nvfp4_linear_swiglu_w4a4_tma.cu b/src/ops/linear_swiglu/nvfp4/nvfp4_linear_swiglu_w4a4_tma.cu index 1a3f87b288..bf76c2d8ea 100644 --- a/src/ops/linear_swiglu/nvfp4/nvfp4_linear_swiglu_w4a4_tma.cu +++ b/src/ops/linear_swiglu/nvfp4/nvfp4_linear_swiglu_w4a4_tma.cu @@ -30,11 +30,14 @@ Nvfp4W4a4TmaDescriptors make_descriptors(const std::uint8_t* activation_codes, constexpr std::uint32_t kPairN = Schedule::kBlockN / 2; constexpr std::uint64_t kWeightScaleBytes = static_cast(Geometry::kOutputRows) * Geometry::kInputRows / 16; + const std::uint32_t padded_tokens = + static_cast((tokens + Schedule::kBlockM - 1) / Schedule::kBlockM) * + Schedule::kBlockM; Nvfp4W4a4TmaDescriptors descriptors{}; descriptors.a_codes = nvfp4_make_tma_2d( const_cast(activation_codes), CU_TENSOR_MAP_DATA_TYPE_UINT8, - Geometry::kCodeBytesPerRow, tokens, Geometry::kCodeBytesPerRow, kCodeColumns, + Geometry::kCodeBytesPerRow, padded_tokens, Geometry::kCodeBytesPerRow, kCodeColumns, Schedule::kBlockM, CU_TENSOR_MAP_SWIZZLE_64B, "encode LinearSwiGLU activation codes TMA"); descriptors.b_codes = nvfp4_make_tma_2d( const_cast(weight_codes), CU_TENSOR_MAP_DATA_TYPE_UINT8, @@ -43,7 +46,7 @@ Nvfp4W4a4TmaDescriptors make_descriptors(const std::uint8_t* activation_codes, descriptors.a_scales = nvfp4_make_tma_2d(const_cast(activation_scales), CU_TENSOR_MAP_DATA_TYPE_UINT8, Schedule::kBlockM, - (static_cast(tokens) / Schedule::kBlockM) * + (static_cast(padded_tokens) / Schedule::kBlockM) * kScaleTilesPerPlane * kScaleTileGroups, Schedule::kBlockM, Schedule::kBlockM, kScaleTileGroups, CU_TENSOR_MAP_SWIZZLE_NONE, "encode LinearSwiGLU activation scales TMA"); @@ -61,9 +64,8 @@ void launch_nvfp4_linear_swiglu_w4a4_tma(const std::uint8_t* activation_codes, const std::uint8_t* weight_codes, const std::uint8_t* weight_scales, __nv_bfloat16* output, std::int32_t tokens, float alpha, cudaStream_t stream) { - if (tokens < M256N128S3::kBlockM || (tokens % M256N128S3::kBlockM) != 0) { - throw std::invalid_argument( - "nvfp4 LinearSwiGLU TMA requires a positive M256 full-tile token count"); + if (tokens <= 0) { + throw std::invalid_argument("nvfp4 LinearSwiGLU TMA requires a positive token count"); } using Geometry = Nvfp4N34816K5120; @@ -79,9 +81,10 @@ void launch_nvfp4_linear_swiglu_w4a4_tma(const std::uint8_t* activation_codes, const Nvfp4W4a4TmaDescriptors descriptors = make_descriptors( activation_codes, activation_scales, weight_codes, weight_scales, tokens); constexpr int kPairN = M256N128S3::kBlockN / 2; - const dim3 grid((Geometry::kOutputRows / 2) / kPairN, tokens / M256N128S3::kBlockM); + const dim3 grid((Geometry::kOutputRows / 2) / kPairN, + (tokens + M256N128S3::kBlockM - 1) / M256N128S3::kBlockM); nvfp4_linear_swiglu_w4a4_tma_kernel - <<>>(descriptors, alpha, output); + <<>>(descriptors, alpha, output, tokens); CUDA_CHECK(cudaGetLastError()); } diff --git a/src/ops/linear_swiglu/nvfp4/nvfp4_linear_swiglu_w4a4_tma.cuh b/src/ops/linear_swiglu/nvfp4/nvfp4_linear_swiglu_w4a4_tma.cuh index 2ad6f3de5f..0bbf5d3eca 100644 --- a/src/ops/linear_swiglu/nvfp4/nvfp4_linear_swiglu_w4a4_tma.cuh +++ b/src/ops/linear_swiglu/nvfp4/nvfp4_linear_swiglu_w4a4_tma.cuh @@ -50,7 +50,8 @@ __global__ __launch_bounds__( Nvfp4W4a4TmaDescriptors descriptors, float alpha, - __nv_bfloat16* __restrict__ output) { + __nv_bfloat16* __restrict__ output, + int tokens) { static_assert(Geometry::kOutputRows == 34816); static_assert(Geometry::kInputRows == 5120); static_assert((Geometry::kInputRows % Schedule::kBlockK) == 0); @@ -272,11 +273,13 @@ __global__ __launch_bounds__( const int token_local = task / kVectorsPerRow; const int row_vector = task - token_local * kVectorsPerRow; const int token = token_begin + token_local; - const uint4 values = - load_vec(shared_output + token_local * kOutputStride + row_vector * 8); - store_vec(output + static_cast(token) * kIntermediate + pair_begin + - row_vector * 8, - values); + if (token < tokens) { + const uint4 values = + load_vec(shared_output + token_local * kOutputStride + row_vector * 8); + store_vec(output + static_cast(token) * kIntermediate + pair_begin + + row_vector * 8, + values); + } } } diff --git a/src/ops/softmax_attention/dense/causal_cache/small_t.cu b/src/ops/softmax_attention/dense/causal_cache/small_t.cu index 711eea9a4c..529f352261 100644 --- a/src/ops/softmax_attention/dense/causal_cache/small_t.cu +++ b/src/ops/softmax_attention/dense/causal_cache/small_t.cu @@ -40,6 +40,9 @@ std::int32_t causal_small_t_split_upper_bound(std::int32_t window) { if (window > 8198) { include_tier(16390, 256 / Geometry::SmallTSplitScale); } if (window > 16390) { include_tier(window, 480 / Geometry::SmallTSplitScale); } + const std::int32_t page_limit = div_up(window, 3840 / Geometry::SmallTSplitScale); + splits = std::max(splits, page_limit); + return (splits < Geometry::SmallTMaximumSplits) ? splits : Geometry::SmallTMaximumSplits; } @@ -225,6 +228,7 @@ std::int32_t causal_attention_split_capacity(std::int32_t q_heads, std::int32_t if (q_heads == CausalD256H24Kv4::QHeads) { const int capacity = causal_small_t_launch_capacity(envelope, tokens, cache_storage); + const int page_limit = div_up(static_cast(envelope.max_visible_keys), 3968); if (batch_size > 1) { // Keep complete grids within one or two 170-SM waves. Rounding from 160 CTAs // leaves room for the indivisible 4*B group, including B=3/5/6/7. @@ -240,13 +244,17 @@ std::int32_t causal_attention_split_capacity(std::int32_t q_heads, std::int32_t const int grid_limit = div_up(target_ctas, 4 * batch_size); // A split stages at most 64 physical-page IDs. Leave two 64-key pages for // key-tile rounding and page alignment at the 262144-key resource limit. - const int page_limit = div_up(static_cast(envelope.max_visible_keys), 3968); return std::min(capacity, std::max({4, grid_limit, page_limit})); } - return capacity; + return std::min(static_cast(CausalD256H24Kv4::SmallTMaximumSplits), + std::max(capacity, page_limit)); } if (q_heads == CausalD256H16Kv2::QHeads) { - return causal_small_t_launch_capacity(envelope, tokens, cache_storage); + const int capacity = + causal_small_t_launch_capacity(envelope, tokens, cache_storage); + const int page_limit = div_up(static_cast(envelope.max_visible_keys), 3968); + return std::min(static_cast(CausalD256H16Kv2::SmallTMaximumSplits), + std::max(capacity, page_limit)); } throw std::invalid_argument( "causal_softmax_attention split capacity: unsupported head geometry"); diff --git a/src/ops/softmax_attention/dense/causal_cache/small_t.cuh b/src/ops/softmax_attention/dense/causal_cache/small_t.cuh index 4cb8127178..71f5c42b54 100644 --- a/src/ops/softmax_attention/dense/causal_cache/small_t.cuh +++ b/src/ops/softmax_attention/dense/causal_cache/small_t.cuh @@ -89,6 +89,8 @@ __device__ __forceinline__ int causal_small_t_default_splits(int window) { } constexpr int kMinSplits = 4 * Geometry::SmallTSplitScale; int splits = div_up(window, target_keys_per_split); + const int page_limit = div_up(window, 3840 / Geometry::SmallTSplitScale); + splits = splits > page_limit ? splits : page_limit; splits = splits > kMinSplits ? splits : kMinSplits; return splits < Geometry::SmallTMaximumSplits ? splits : Geometry::SmallTMaximumSplits; } diff --git a/src/ops/softmax_attention/dense/causal_cache/small_t_bf16.cuh b/src/ops/softmax_attention/dense/causal_cache/small_t_bf16.cuh index b4f0fc1b71..224b8da95a 100644 --- a/src/ops/softmax_attention/dense/causal_cache/small_t_bf16.cuh +++ b/src/ops/softmax_attention/dense/causal_cache/small_t_bf16.cuh @@ -141,7 +141,8 @@ __launch_bounds__(128, 2) __global__ void causal_attention_small_t_tc_partial_bf const int first_tile = (split_start / Bc) * Bc; const int key_blocks = div_up(split_end - first_tile, Bc); const int first_page = first_tile >> kPagedKVPageShift; - const int page_count = ((split_end - 1) >> kPagedKVPageShift) - first_page + 1; + const int page_count = + min(((split_end - 1) >> kPagedKVPageShift) - first_page + 1, PageIds); for (int page = tid; page < page_count; page += Threads) { physical_pages_s[page] = block_table[first_page + page]; } diff --git a/src/ops/softmax_attention/dense/causal_cache/small_t_fp8.cuh b/src/ops/softmax_attention/dense/causal_cache/small_t_fp8.cuh index 47c08ed6c6..4c0b465968 100644 --- a/src/ops/softmax_attention/dense/causal_cache/small_t_fp8.cuh +++ b/src/ops/softmax_attention/dense/causal_cache/small_t_fp8.cuh @@ -178,7 +178,8 @@ __launch_bounds__(WarpsPerCta * 32, MinBlocksPerSm) __global__ const int first_tile = (split_start / Bc) * Bc; const int key_blocks = div_up(split_end - first_tile, Bc); const int first_page = first_tile >> kPagedKVPageShift; - const int page_count = ((split_end - 1) >> kPagedKVPageShift) - first_page + 1; + const int page_count = + min(((split_end - 1) >> kPagedKVPageShift) - first_page + 1, PageIds); for (int page = tid; page < page_count; page += Threads) { physical_pages_s[page] = block_table[first_page + page]; } diff --git a/src/ops/softmax_attention/dense/causal_cache/small_t_i8.cuh b/src/ops/softmax_attention/dense/causal_cache/small_t_i8.cuh index 97b610957a..b7e334b710 100644 --- a/src/ops/softmax_attention/dense/causal_cache/small_t_i8.cuh +++ b/src/ops/softmax_attention/dense/causal_cache/small_t_i8.cuh @@ -187,7 +187,8 @@ __launch_bounds__(WarpsPerCta * 32, MinBlocksPerSm) __global__ const int first_tile = (split_start / Bc) * Bc; const int key_blocks = div_up(split_end - first_tile, Bc); const int first_page = first_tile >> kPagedKVPageShift; - const int page_count = ((split_end - 1) >> kPagedKVPageShift) - first_page + 1; + const int page_count = + min(((split_end - 1) >> kPagedKVPageShift) - first_page + 1, PageIds); for (int page = tid; page < page_count; page += Threads) { physical_pages_s[page] = block_table[first_page + page]; } diff --git a/src/ops/softmax_attention/dense/causal_cache/small_t_k8v4.cuh b/src/ops/softmax_attention/dense/causal_cache/small_t_k8v4.cuh index 651139706d..fb42d96a29 100644 --- a/src/ops/softmax_attention/dense/causal_cache/small_t_k8v4.cuh +++ b/src/ops/softmax_attention/dense/causal_cache/small_t_k8v4.cuh @@ -166,7 +166,8 @@ __launch_bounds__(WarpsPerCta * 32, MinBlocksPerSm) __global__ const int first_tile = (split_start / Bc) * Bc; const int key_blocks = div_up(split_end - first_tile, Bc); const int first_page = first_tile >> kPagedKVPageShift; - const int page_count = ((split_end - 1) >> kPagedKVPageShift) - first_page + 1; + const int page_count = + min(((split_end - 1) >> kPagedKVPageShift) - first_page + 1, PageIds); for (int page = tid; page < page_count; page += Threads) { physical_pages_s[page] = block_table[first_page + page]; } diff --git a/src/ops/softmax_attention/dense/causal_cache/small_t_nvfp4.cuh b/src/ops/softmax_attention/dense/causal_cache/small_t_nvfp4.cuh index 4e9fcf2d0a..d47280cef0 100644 --- a/src/ops/softmax_attention/dense/causal_cache/small_t_nvfp4.cuh +++ b/src/ops/softmax_attention/dense/causal_cache/small_t_nvfp4.cuh @@ -174,7 +174,8 @@ __launch_bounds__(WarpsPerCta * 32, MinBlocksPerSm) __global__ const int first_tile = (split_start / Bc) * Bc; const int key_blocks = div_up(split_end - first_tile, Bc); const int first_page = first_tile >> kPagedKVPageShift; - const int page_count = ((split_end - 1) >> kPagedKVPageShift) - first_page + 1; + const int page_count = + min(((split_end - 1) >> kPagedKVPageShift) - first_page + 1, PageIds); for (int page = tid; page < page_count; page += Threads) { physical_pages_s[page] = block_table[first_page + page]; } diff --git a/src/runtime/engine/context_cache/context_portfolio_value.h b/src/runtime/engine/context_cache/context_portfolio_value.h index f8b9678981..657dc3bd67 100644 --- a/src/runtime/engine/context_cache/context_portfolio_value.h +++ b/src/runtime/engine/context_cache/context_portfolio_value.h @@ -24,6 +24,7 @@ struct ContextPortfolioCheckpointValue { std::uint64_t rebuild_ns = 0; std::uint64_t baseline_recovery_ns = 0; std::uint64_t target_recovery_ns = 0; + bool unreachable = false; }; struct ContextPortfolioValueResult { @@ -59,6 +60,7 @@ class ContextPortfolioValue { } for (const ContextPortfolioCheckpointValue& checkpoint : checkpoints) { + if (checkpoint.unreachable) { continue; } const auto owner = std::find_if( owner_scratch_.begin(), owner_scratch_.end(), [&](const OwnerValue& item) { return item.owner == checkpoint.owner; }); diff --git a/src/runtime/engine/context_cache/materialization_planner.h b/src/runtime/engine/context_cache/materialization_planner.h index 9fc1e65393..034151371f 100644 --- a/src/runtime/engine/context_cache/materialization_planner.h +++ b/src/runtime/engine/context_cache/materialization_planner.h @@ -27,6 +27,7 @@ struct MaterializationCheckpointPolicy { std::uint32_t demand_mask = 0; std::uint64_t rebuild_ns = 0; std::uint64_t baseline_recovery_ns = 0; + bool unreachable = false; }; struct MaterializationOwnerPolicy { @@ -1170,6 +1171,7 @@ class MaterializationPlanner { .rebuild_ns = policy.rebuild_ns, .baseline_recovery_ns = policy.baseline_recovery_ns, .target_recovery_ns = target_recovery, + .unreachable = policy.unreachable, }); if (target_recovery > policy.baseline_recovery_ns) { portfolio_degraded = true; diff --git a/src/runtime/engine/context_cache/resource_manager.h b/src/runtime/engine/context_cache/resource_manager.h index b75a68f1e2..a4639a9efb 100644 --- a/src/runtime/engine/context_cache/resource_manager.h +++ b/src/runtime/engine/context_cache/resource_manager.h @@ -598,7 +598,7 @@ class ResourceManager { }); break; } - if (pressure_evidence) { + if (pressure_evidence || !candidate.physically_feasible || scenarios.empty()) { for (std::uint32_t slot = 0; slot < shared_catalog_count_; ++slot) { SharedCatalogEntry& entry = shared_catalog_[slot]; if (entry.state != SharedCatalogState::Catalogued || !entry.handle || @@ -635,6 +635,7 @@ class ResourceManager { .baseline_recovery_ns = price_checkpoint_recovery_work( cost_model_, program.checkpoint_recovery_work(*entry.handle, checkpoint.ref)), + .unreachable = false, }); }; for (std::uint32_t slot = 0; slot < catalog_count_; ++slot) { @@ -704,6 +705,7 @@ class ResourceManager { .baseline_recovery_ns = price_checkpoint_recovery_work( cost_model_, program.checkpoint_recovery_work( *entry.handle, entry.summary.checkpoint.ref)), + .unreachable = false, }); } @@ -730,7 +732,7 @@ class ResourceManager { } return found->id; }; - if (pressure_evidence) { + if (pressure_evidence || !scenario.assessment.physically_feasible) { private_owners.reserve(catalog_count_); private_owner_ids.reserve(catalog_count_); shared_owners.reserve(shared_catalog_count_); @@ -981,6 +983,7 @@ class ResourceManager { publication.session = active.session; publication.retention = active.retention; migrate_observations(publication, result.summary, active.retention); + publication.publication_order = active.publication_order; advance_revision(publication.revision); if (publication.session && active.update_session_index) { if (!publish_session(*publication.session, active.publication_slot, publication.id, @@ -1160,7 +1163,8 @@ class ResourceManager { std::optional handle; std::optional session; std::vector observations; - RetentionClass retention = RetentionClass::RecentPrivate; + RetentionClass retention = RetentionClass::RecentPrivate; + std::uint64_t publication_order = 0; }; struct SharedCatalogEntry { @@ -1716,12 +1720,16 @@ class ResourceManager { const std::uint64_t rebuild = cost_model_.prefill_ns(checkpoint.rebuild_work); const std::uint64_t recovery = price_checkpoint_recovery_work( cost_model_, program.checkpoint_recovery_work(handle, checkpoint.ref)); + const std::optional incoming = + base.prefix_shortlist_key(checkpoint.shortlist_key.frontier); + const bool unreachable = !incoming || *incoming != checkpoint.shortlist_key; projected_checkpoints.push_back(ContextPortfolioCheckpointValue{ .owner = owner, .demand_mask = demand_mask_for(checkpoint.shortlist_key, provisional_demand), .rebuild_ns = rebuild, .baseline_recovery_ns = recovery, .target_recovery_ns = recovery, + .unreachable = unreachable, }); }; for (std::uint32_t slot = 0; slot < catalog_count_; ++slot) { @@ -1900,6 +1908,9 @@ class ResourceManager { throw std::logic_error("catalogued checkpoint has no policy observation"); } selected_hits = std::max(selected_hits, observation->selected_hit_count); + const std::optional incoming = + base.prefix_shortlist_key(checkpoint.shortlist_key.frontier); + const bool unreachable = !incoming || *incoming != checkpoint.shortlist_key; checkpoint_policies.push_back(MaterializationCheckpointPolicy{ .owner = owner, .checkpoint = checkpoint.ref, @@ -1912,6 +1923,7 @@ class ResourceManager { .baseline_recovery_ns = price_checkpoint_recovery_work( cost_model_, program.checkpoint_recovery_work(*entry.handle, checkpoint.ref)), + .unreachable = unreachable, }); }; if (entry.summary.endpoint) { append_checkpoint(*entry.summary.endpoint); } @@ -1958,6 +1970,10 @@ class ResourceManager { .private_retention_weight = 0, .explicit_shared_credit = entry.explicit_credit, }); + const std::optional incoming = + base.prefix_shortlist_key(entry.summary.checkpoint.shortlist_key.frontier); + const bool unreachable = + !incoming || *incoming != entry.summary.checkpoint.shortlist_key; checkpoint_policies.push_back(MaterializationCheckpointPolicy{ .owner = owner, .checkpoint = entry.summary.checkpoint.ref, @@ -1970,6 +1986,7 @@ class ResourceManager { .baseline_recovery_ns = price_checkpoint_recovery_work( cost_model_, program.checkpoint_recovery_work( *entry.handle, entry.summary.checkpoint.ref)), + .unreachable = unreachable, }); } diff --git a/src/runtime/engine/context_cache/shared_capture_planner.h b/src/runtime/engine/context_cache/shared_capture_planner.h index e3fa12fd06..53e718a01e 100644 --- a/src/runtime/engine/context_cache/shared_capture_planner.h +++ b/src/runtime/engine/context_cache/shared_capture_planner.h @@ -43,6 +43,7 @@ class SharedCapturePlanner { std::uint32_t demand_mask = 0; std::uint64_t rebuild_ns = 0; std::uint64_t baseline_recovery_ns = 0; + bool unreachable = false; }; struct Input { @@ -341,6 +342,7 @@ class SharedCapturePlanner { .rebuild_ns = policy.rebuild_ns, .baseline_recovery_ns = policy.baseline_recovery_ns, .target_recovery_ns = target_recovery, + .unreachable = policy.unreachable, }); } checkpoint_scratch_.push_back(ContextPortfolioCheckpointValue{ diff --git a/src/runtime/engine/model_instance.cpp b/src/runtime/engine/model_instance.cpp index e606d9a371..599cfb0310 100644 --- a/src/runtime/engine/model_instance.cpp +++ b/src/runtime/engine/model_instance.cpp @@ -112,8 +112,9 @@ EngineOptions normalize_engine_options(EngineOptions options) { const std::uint64_t default_private = 2ULL * concurrency; cache.max_private_continuations = cache.max_private_continuations.value_or(static_cast(default_private)); - cache.max_shared_prefixes = cache.max_shared_prefixes.value_or( - std::max(concurrency, static_cast(kMaximumExplicitPromptCacheMarkers))); + cache.max_shared_prefixes = cache.max_shared_prefixes.value_or(std::max( + concurrency, + static_cast(kMaximumPreparedPromptCacheCandidatesPerRequest))); cache.max_long_anchors_per_continuation = cache.max_long_anchors_per_continuation.value_or(2U); if (*cache.max_private_continuations < concurrency) { diff --git a/src/serve/anthropic_messages_response.cpp b/src/serve/anthropic_messages_response.cpp index 0774f447be..7dfbb78d8e 100644 --- a/src/serve/anthropic_messages_response.cpp +++ b/src/serve/anthropic_messages_response.cpp @@ -58,6 +58,12 @@ struct StopPresentation { }; StopPresentation stop_presentation(const GenerationOutcome& outcome) { + if (outcome.finish_reason == ninfer::FinishReason::OutputLimit) { + return StopPresentation{.reason = "max_tokens"}; + } + if (outcome.finish_reason == ninfer::FinishReason::ContextCapacity) { + return StopPresentation{.reason = "model_context_window_exceeded"}; + } if (!outcome.tool_calls.empty()) { return StopPresentation{.reason = "tool_use"}; } switch (outcome.finish_reason) { case ninfer::FinishReason::OutputLimit: @@ -69,7 +75,7 @@ StopPresentation stop_presentation(const GenerationOutcome& outcome) { throw std::logic_error("stop-string terminal result has no matched declaration"); } return StopPresentation{.reason = "stop_sequence", - .sequence = *outcome.matched_stop_string}; + .sequence = *outcome.matched_stop_string}; case ninfer::FinishReason::StopToken: case ninfer::FinishReason::None: return StopPresentation{.reason = "end_turn"}; diff --git a/src/serve/openai_chat_response.cpp b/src/serve/openai_chat_response.cpp index 5841d4b51b..a7fe475f28 100644 --- a/src/serve/openai_chat_response.cpp +++ b/src/serve/openai_chat_response.cpp @@ -236,13 +236,18 @@ std::string make_chat_completion_response(const OpenAIChatResponseIdentity& iden message["tool_calls"] = tool_calls_json(calls, false); } + const bool output_limited = outcome.finish_reason == ninfer::FinishReason::OutputLimit || + outcome.finish_reason == ninfer::FinishReason::ContextCapacity; + const char* reason_str = + output_limited ? "length" + : (has_tool_calls ? "tool_calls" : finish_reason(outcome.finish_reason)); + Json payload = base_payload(identity, "chat.completion"); payload["choices"] = Json::array( {Json{{"index", 0}, {"message", std::move(message)}, {"logprobs", nullptr}, - {"finish_reason", - has_tool_calls ? Json("tool_calls") : Json(finish_reason(outcome.finish_reason))}}}); + {"finish_reason", reason_str}}}); payload["usage"] = usage_json(usage_from(outcome)); payload["timings"] = timings_json(outcome_timings(outcome)); return payload.dump(); @@ -368,13 +373,20 @@ std::vector OpenAIChatStream::finish(const GenerationOutcome& outco include_usage_, output_timings)); } - if (!outcome.tool_calls.empty()) { + const bool output_limited = outcome.finish_reason == ninfer::FinishReason::OutputLimit || + outcome.finish_reason == ninfer::FinishReason::ContextCapacity; + if (!outcome.tool_calls.empty() && !output_limited) { const std::vector calls = materialize_tool_calls(outcome.tool_calls); events.push_back(chunk(identity_, Json{{"tool_calls", tool_calls_json(calls, true)}}, nullptr, include_usage_, output_timings)); events.push_back(chunk(identity_, Json::object(), "tool_calls", include_usage_, include_usage_ ? Json(nullptr) : final_timings)); } else { + if (!outcome.tool_calls.empty()) { + const std::vector calls = materialize_tool_calls(outcome.tool_calls); + events.push_back(chunk(identity_, Json{{"tool_calls", tool_calls_json(calls, true)}}, + nullptr, include_usage_, output_timings)); + } events.push_back(chunk(identity_, Json::object(), finish_reason(outcome.finish_reason), include_usage_, include_usage_ ? Json(nullptr) : final_timings)); } diff --git a/tests/models/qwen3_5/test_engine_prefix_real.cpp b/tests/models/qwen3_5/test_engine_prefix_real.cpp index 85aa5dc7ac..7f70eb8916 100644 --- a/tests/models/qwen3_5/test_engine_prefix_real.cpp +++ b/tests/models/qwen3_5/test_engine_prefix_real.cpp @@ -706,8 +706,8 @@ int exercise_anthropic_prefix_regression(const char* artifact) { ninfer::Engine engine(anthropic_prefix_regression_engine_options(artifact)); if (!engine.options().context_cache.max_shared_prefixes || *engine.options().context_cache.max_shared_prefixes != - ninfer::kMaximumExplicitPromptCacheMarkers) { - std::cerr << "single-concurrency Engine did not expose four default shared prefixes\n"; + ninfer::kMaximumPreparedPromptCacheCandidatesPerRequest) { + std::cerr << "single-concurrency Engine did not expose seven default shared prefixes\n"; return 1; } From b82036c2e156282438b44bba7ade6d8282d70a67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D1=87=D1=83=D0=B1=D0=B5=D0=B9=20=D0=9F=D0=B0?= =?UTF-8?q?=D0=B2=D0=B5=D0=BB=20=D0=9C=D0=B8=D1=85=D0=B0=D0=B9=D0=BB=D0=BE?= =?UTF-8?q?=D0=B2=D0=B8=D1=87?= Date: Mon, 21 Sep 2026 12:54:43 +0300 Subject: [PATCH 2/6] Fixies --- .../dense/causal_cache/small_t.cuh | 7 +- .../dense/causal_cache/small_t_bf16.cuh | 7 +- .../dense/causal_cache/small_t_fp8.cuh | 7 +- .../dense/causal_cache/small_t_i8.cuh | 7 +- .../dense/causal_cache/small_t_k8v4.cuh | 7 +- .../dense/causal_cache/small_t_nvfp4.cuh | 7 +- .../context_cache/materialization_planner.h | 127 ++++++++++-------- .../engine/context_cache/resource_manager.h | 9 +- tests/ops/linear_swiglu/test_nvfp4.cpp | 12 +- tests/test_resource_manager.cpp | 78 ++++++++++- 10 files changed, 180 insertions(+), 88 deletions(-) diff --git a/src/ops/softmax_attention/dense/causal_cache/small_t.cuh b/src/ops/softmax_attention/dense/causal_cache/small_t.cuh index 71f5c42b54..77fec566c2 100644 --- a/src/ops/softmax_attention/dense/causal_cache/small_t.cuh +++ b/src/ops/softmax_attention/dense/causal_cache/small_t.cuh @@ -15,6 +15,7 @@ #include #include +#include #include namespace ninfer::ops { @@ -124,10 +125,8 @@ __device__ __forceinline__ int causal_small_t_active_splits(int window, int laun template __device__ __forceinline__ int causal_small_t_quantized_active_splits(int window, int launch_capacity, int tokens) { - int splits = causal_small_t_default_splits(window); - if constexpr (Geometry::SmallTSplitScale == 1) { - if (tokens == 1 && window > 8198) { splits = Geometry::SmallTMaximumSplits; } - } + (void)tokens; + const int splits = causal_small_t_default_splits(window); return splits < launch_capacity ? splits : launch_capacity; } diff --git a/src/ops/softmax_attention/dense/causal_cache/small_t_bf16.cuh b/src/ops/softmax_attention/dense/causal_cache/small_t_bf16.cuh index 224b8da95a..a80103362e 100644 --- a/src/ops/softmax_attention/dense/causal_cache/small_t_bf16.cuh +++ b/src/ops/softmax_attention/dense/causal_cache/small_t_bf16.cuh @@ -141,9 +141,10 @@ __launch_bounds__(128, 2) __global__ void causal_attention_small_t_tc_partial_bf const int first_tile = (split_start / Bc) * Bc; const int key_blocks = div_up(split_end - first_tile, Bc); const int first_page = first_tile >> kPagedKVPageShift; - const int page_count = - min(((split_end - 1) >> kPagedKVPageShift) - first_page + 1, PageIds); - for (int page = tid; page < page_count; page += Threads) { + const int page_count = ((split_end - 1) >> kPagedKVPageShift) - first_page + 1; + assert(page_count <= PageIds); + const int bounded_pages = min(page_count, PageIds); + for (int page = tid; page < bounded_pages; page += Threads) { physical_pages_s[page] = block_table[first_page + page]; } diff --git a/src/ops/softmax_attention/dense/causal_cache/small_t_fp8.cuh b/src/ops/softmax_attention/dense/causal_cache/small_t_fp8.cuh index 4c0b465968..42dfcf4489 100644 --- a/src/ops/softmax_attention/dense/causal_cache/small_t_fp8.cuh +++ b/src/ops/softmax_attention/dense/causal_cache/small_t_fp8.cuh @@ -178,9 +178,10 @@ __launch_bounds__(WarpsPerCta * 32, MinBlocksPerSm) __global__ const int first_tile = (split_start / Bc) * Bc; const int key_blocks = div_up(split_end - first_tile, Bc); const int first_page = first_tile >> kPagedKVPageShift; - const int page_count = - min(((split_end - 1) >> kPagedKVPageShift) - first_page + 1, PageIds); - for (int page = tid; page < page_count; page += Threads) { + const int page_count = ((split_end - 1) >> kPagedKVPageShift) - first_page + 1; + assert(page_count <= PageIds); + const int bounded_pages = min(page_count, PageIds); + for (int page = tid; page < bounded_pages; page += Threads) { physical_pages_s[page] = block_table[first_page + page]; } __syncthreads(); diff --git a/src/ops/softmax_attention/dense/causal_cache/small_t_i8.cuh b/src/ops/softmax_attention/dense/causal_cache/small_t_i8.cuh index b7e334b710..0da282cd52 100644 --- a/src/ops/softmax_attention/dense/causal_cache/small_t_i8.cuh +++ b/src/ops/softmax_attention/dense/causal_cache/small_t_i8.cuh @@ -187,9 +187,10 @@ __launch_bounds__(WarpsPerCta * 32, MinBlocksPerSm) __global__ const int first_tile = (split_start / Bc) * Bc; const int key_blocks = div_up(split_end - first_tile, Bc); const int first_page = first_tile >> kPagedKVPageShift; - const int page_count = - min(((split_end - 1) >> kPagedKVPageShift) - first_page + 1, PageIds); - for (int page = tid; page < page_count; page += Threads) { + const int page_count = ((split_end - 1) >> kPagedKVPageShift) - first_page + 1; + assert(page_count <= PageIds); + const int bounded_pages = min(page_count, PageIds); + for (int page = tid; page < bounded_pages; page += Threads) { physical_pages_s[page] = block_table[first_page + page]; } diff --git a/src/ops/softmax_attention/dense/causal_cache/small_t_k8v4.cuh b/src/ops/softmax_attention/dense/causal_cache/small_t_k8v4.cuh index fb42d96a29..2536b73c42 100644 --- a/src/ops/softmax_attention/dense/causal_cache/small_t_k8v4.cuh +++ b/src/ops/softmax_attention/dense/causal_cache/small_t_k8v4.cuh @@ -166,9 +166,10 @@ __launch_bounds__(WarpsPerCta * 32, MinBlocksPerSm) __global__ const int first_tile = (split_start / Bc) * Bc; const int key_blocks = div_up(split_end - first_tile, Bc); const int first_page = first_tile >> kPagedKVPageShift; - const int page_count = - min(((split_end - 1) >> kPagedKVPageShift) - first_page + 1, PageIds); - for (int page = tid; page < page_count; page += Threads) { + const int page_count = ((split_end - 1) >> kPagedKVPageShift) - first_page + 1; + assert(page_count <= PageIds); + const int bounded_pages = min(page_count, PageIds); + for (int page = tid; page < bounded_pages; page += Threads) { physical_pages_s[page] = block_table[first_page + page]; } __syncthreads(); diff --git a/src/ops/softmax_attention/dense/causal_cache/small_t_nvfp4.cuh b/src/ops/softmax_attention/dense/causal_cache/small_t_nvfp4.cuh index d47280cef0..6c76451b3d 100644 --- a/src/ops/softmax_attention/dense/causal_cache/small_t_nvfp4.cuh +++ b/src/ops/softmax_attention/dense/causal_cache/small_t_nvfp4.cuh @@ -174,9 +174,10 @@ __launch_bounds__(WarpsPerCta * 32, MinBlocksPerSm) __global__ const int first_tile = (split_start / Bc) * Bc; const int key_blocks = div_up(split_end - first_tile, Bc); const int first_page = first_tile >> kPagedKVPageShift; - const int page_count = - min(((split_end - 1) >> kPagedKVPageShift) - first_page + 1, PageIds); - for (int page = tid; page < page_count; page += Threads) { + const int page_count = ((split_end - 1) >> kPagedKVPageShift) - first_page + 1; + assert(page_count <= PageIds); + const int bounded_pages = min(page_count, PageIds); + for (int page = tid; page < bounded_pages; page += Threads) { physical_pages_s[page] = block_table[first_page + page]; } __syncthreads(); diff --git a/src/runtime/engine/context_cache/materialization_planner.h b/src/runtime/engine/context_cache/materialization_planner.h index 034151371f..8c37165c44 100644 --- a/src/runtime/engine/context_cache/materialization_planner.h +++ b/src/runtime/engine/context_cache/materialization_planner.h @@ -37,6 +37,7 @@ struct MaterializationOwnerPolicy { std::uint64_t last_hit_epoch = 0; std::uint32_t private_retention_weight = 0; bool explicit_shared_credit = false; + std::uint64_t publication_order = 0; }; template @@ -329,9 +330,10 @@ class MaterializationPlanner { .target = target, .candidate_index = candidate_index, .lower_bound_ns = cost.lower_bound_ns, - .affected_selected_hits = cost.affected_selected_hits, - .newest_affected_hit_epoch = cost.newest_affected_hit_epoch, - .owner_evictions = cost.owner_evictions, + .affected_selected_hits = cost.affected_selected_hits, + .newest_affected_hit_epoch = cost.newest_affected_hit_epoch, + .newest_affected_publication_order = cost.newest_affected_publication_order, + .owner_evictions = cost.owner_evictions, .checkpoint_drops = cost.checkpoint_drops, .copy_operations = cost.copy_operations, .transferred_bytes = cost.transferred_bytes, @@ -768,28 +770,30 @@ class MaterializationPlanner { static constexpr std::uint32_t kTargetBudget = 4096; struct FoldedCost { - std::uint64_t now_ns = 0; - std::uint64_t future_loss_ns = 0; - std::uint64_t total_ns = 0; - std::uint64_t lower_bound_ns = 0; - std::uint64_t affected_selected_hits = 0; - std::uint64_t newest_affected_hit_epoch = 0; - std::uint32_t owner_evictions = 0; - std::uint32_t checkpoint_drops = 0; - std::uint32_t copy_operations = 0; - std::uint64_t transferred_bytes = 0; - std::uint64_t remaining_text_prefill = 0; - std::uint64_t remaining_vision_prefill = 0; - std::uint32_t reused_prompt_tokens = 0; - bool current_session_binding = false; - std::uint32_t candidate_ordinal = 0; - std::uint32_t target_ordinal = 0; + std::uint64_t now_ns = 0; + std::uint64_t future_loss_ns = 0; + std::uint64_t total_ns = 0; + std::uint64_t lower_bound_ns = 0; + std::uint64_t affected_selected_hits = 0; + std::uint64_t newest_affected_hit_epoch = 0; + std::uint64_t newest_affected_publication_order = 0; + std::uint32_t owner_evictions = 0; + std::uint32_t checkpoint_drops = 0; + std::uint32_t copy_operations = 0; + std::uint64_t transferred_bytes = 0; + std::uint64_t remaining_text_prefill = 0; + std::uint64_t remaining_vision_prefill = 0; + std::uint32_t reused_prompt_tokens = 0; + bool current_session_binding = false; + std::uint32_t candidate_ordinal = 0; + std::uint32_t target_ordinal = 0; [[nodiscard]] auto key() const noexcept { return std::tuple{ total_ns, affected_selected_hits, newest_affected_hit_epoch, + newest_affected_publication_order, owner_evictions, checkpoint_drops, copy_operations, @@ -829,46 +833,48 @@ class MaterializationPlanner { struct QueueEntry { PressureTargetHandle target{}; - std::uint32_t candidate_index = 0; - std::uint64_t lower_bound_ns = 0; - std::uint64_t affected_selected_hits = 0; - std::uint64_t newest_affected_hit_epoch = 0; - std::uint32_t owner_evictions = 0; - std::uint32_t checkpoint_drops = 0; - std::uint32_t copy_operations = 0; - std::uint64_t transferred_bytes = 0; - std::uint64_t remaining_prefill = 0; - std::uint64_t remaining_vision_prefill = 0; - std::uint32_t reused_prompt_tokens = 0; - bool current_session_binding = false; - std::uint32_t candidate_ordinal = 0; - std::uint32_t stable_target_ordinal = 0; + std::uint32_t candidate_index = 0; + std::uint64_t lower_bound_ns = 0; + std::uint64_t affected_selected_hits = 0; + std::uint64_t newest_affected_hit_epoch = 0; + std::uint64_t newest_affected_publication_order = 0; + std::uint32_t owner_evictions = 0; + std::uint32_t checkpoint_drops = 0; + std::uint32_t copy_operations = 0; + std::uint64_t transferred_bytes = 0; + std::uint64_t remaining_prefill = 0; + std::uint64_t remaining_vision_prefill = 0; + std::uint32_t reused_prompt_tokens = 0; + bool current_session_binding = false; + std::uint32_t candidate_ordinal = 0; + std::uint32_t stable_target_ordinal = 0; }; struct GuidanceCost { - std::uint64_t estimated_total_ns = 0; - bool recovery_complete = false; - bool requires_exact_feedback = false; - bool logical_ready = false; - std::uint32_t estimated_remaining_steps = 0; - std::uint32_t unsatisfied_constraints = 0; - std::uint64_t normalized_residual_q20 = 0; - std::uint64_t affected_selected_hits = 0; - std::uint64_t newest_affected_hit_epoch = 0; - std::uint64_t retention_weight = 0; - std::uint32_t explicit_shared_losses = 0; - std::uint32_t owner_evictions = 0; - std::uint32_t checkpoint_drops = 0; - std::uint32_t degradation_units = 0; - std::uint64_t estimated_immediate_ns = 0; - std::uint32_t copy_operations = 0; - std::uint64_t transferred_bytes = 0; - std::uint64_t remaining_prefill = 0; - std::uint64_t remaining_vision_prefill = 0; - std::uint32_t reused_prompt_tokens = 0; - bool current_session_binding = false; - std::uint32_t candidate_ordinal = 0; - std::uint32_t stable_target_ordinal = 0; + std::uint64_t estimated_total_ns = 0; + bool recovery_complete = false; + bool requires_exact_feedback = false; + bool logical_ready = false; + std::uint32_t estimated_remaining_steps = 0; + std::uint32_t unsatisfied_constraints = 0; + std::uint64_t normalized_residual_q20 = 0; + std::uint64_t affected_selected_hits = 0; + std::uint64_t newest_affected_hit_epoch = 0; + std::uint64_t newest_affected_publication_order = 0; + std::uint64_t retention_weight = 0; + std::uint32_t explicit_shared_losses = 0; + std::uint32_t owner_evictions = 0; + std::uint32_t checkpoint_drops = 0; + std::uint32_t degradation_units = 0; + std::uint64_t estimated_immediate_ns = 0; + std::uint32_t copy_operations = 0; + std::uint64_t transferred_bytes = 0; + std::uint64_t remaining_prefill = 0; + std::uint64_t remaining_vision_prefill = 0; + std::uint32_t reused_prompt_tokens = 0; + bool current_session_binding = false; + std::uint32_t candidate_ordinal = 0; + std::uint32_t stable_target_ordinal = 0; [[nodiscard]] auto key() const noexcept { return std::tuple{ @@ -878,6 +884,7 @@ class MaterializationPlanner { owner_evictions, checkpoint_drops, newest_affected_hit_epoch, + newest_affected_publication_order, estimated_remaining_steps, unsatisfied_constraints, normalized_residual_q20, @@ -1036,6 +1043,8 @@ class MaterializationPlanner { planning_saturating_add(cost.affected_selected_hits, policy->selected_hit_count); cost.newest_affected_hit_epoch = std::max(cost.newest_affected_hit_epoch, policy->last_hit_epoch); + cost.newest_affected_publication_order = + std::max(cost.newest_affected_publication_order, policy->publication_order); planning_saturating_add(cost.retention_weight, policy->private_retention_weight); if (policy->explicit_shared_credit) { ++cost.explicit_shared_losses; } } @@ -1120,7 +1129,11 @@ class MaterializationPlanner { if (policy == nullptr) { throw std::logic_error("pressure target references an unknown logical owner"); } - if (outcome.disposition == VictimDisposition::Evicted) { ++cost.owner_evictions; } + if (outcome.disposition == VictimDisposition::Evicted) { + ++cost.owner_evictions; + cost.newest_affected_publication_order = + std::max(cost.newest_affected_publication_order, policy->publication_order); + } } impact_scratch_.clear(); diff --git a/src/runtime/engine/context_cache/resource_manager.h b/src/runtime/engine/context_cache/resource_manager.h index a4639a9efb..01d265e09f 100644 --- a/src/runtime/engine/context_cache/resource_manager.h +++ b/src/runtime/engine/context_cache/resource_manager.h @@ -1937,6 +1937,7 @@ class ResourceManager { .selected_hit_count = selected_hits, .last_hit_epoch = newest_hit_epoch(entry), .private_retention_weight = private_retention_weight(entry.retention), + .publication_order = entry.publication_order, }); } for (std::uint32_t slot = 0; slot < shared_catalog_count_; ++slot) { @@ -2067,9 +2068,11 @@ class ResourceManager { private_has_active_edge(slot)) { return std::nullopt; } - if (publication_slot == kInvalidCatalogSlot && - outcome.disposition == VictimDisposition::Evicted) { - publication_slot = slot; + if (outcome.disposition == VictimDisposition::Evicted) { + if (publication_slot == kInvalidCatalogSlot || + entry.publication_order < catalog_[publication_slot].publication_order) { + publication_slot = slot; + } } } else { const std::uint32_t slot = record->capability.slot; diff --git a/tests/ops/linear_swiglu/test_nvfp4.cpp b/tests/ops/linear_swiglu/test_nvfp4.cpp index 53cb824459..c421844b38 100644 --- a/tests/ops/linear_swiglu/test_nvfp4.cpp +++ b/tests/ops/linear_swiglu/test_nvfp4.cpp @@ -11,18 +11,18 @@ int main() { try { constexpr std::array kA16Cases{1, 4, 8, 16}; - // 255, 256 and 257 straddle the fused route floor. This change does not move that - // route, but 257 reaches it through the baseline composition, whose own linear now - // runs the ragged TMA path. - constexpr std::array kA4Cases{2, 4, 5, 16, 56, 64, 65, 96, 97, - 112, 128, 129, 255, 256, 257, 512, 1024}; + // Test boundary and ragged token sizes across tile multiples (128, 256, 512, 1024, 1408) + // to verify TMA fused ragged SwiGLU correctness against the FP32/FP64 mathematical oracle. + constexpr std::array kA4Cases{ + 2, 4, 5, 16, 56, 64, 65, 96, 97, 112, 128, 129, + 255, 256, 257, 300, 511, 512, 513, 1023, 1024, 1025, 1408}; int failures = 0; failures += run_profile("LinearSwiGLU NVFP4_A16", {QType::NVFP4, 34816, 5120, 17408, 1801U, ActivationCompute::A16}, kA16Cases); failures += run_profile("LinearSwiGLU NVFP4_A4", {QType::NVFP4, 34816, 5120, 17408, 1803U, ActivationCompute::A4}, - kA4Cases, std::array{65, 97, 128, 129}); + kA4Cases, std::array{65, 97, 128, 129, 257, 513, 1025}); std::cout << (failures == 0 ? "OK" : "FAIL") << " LinearSwiGLU NVFP4 correctness\n"; return failures == 0 ? 0 : 1; } catch (const std::exception& error) { diff --git a/tests/test_resource_manager.cpp b/tests/test_resource_manager.cpp index 0d3a07a00a..d85ac15d2f 100644 --- a/tests/test_resource_manager.cpp +++ b/tests/test_resource_manager.cpp @@ -818,6 +818,8 @@ class FakeProgram { FakeActiveCaptureResult result; result.status = cancellation.requested() ? ContextTransactionStatus::Aborted : capture_status; + result.capacity_preparation_committed = + pending_capture_replacement_ && result.status == ContextTransactionStatus::Published; if (pending_plan_) { for (std::size_t index = 0; index < pending_plan_->private_actions.size(); ++index) { @@ -981,6 +983,7 @@ class FakeProgram { transaction_kind_ = TransactionKind::None; pending_plan_.reset(); pending_capture_publish_shared_ = false; + pending_capture_replacement_ = false; } [[nodiscard]] bool has_context_transaction() const noexcept { @@ -1051,20 +1054,23 @@ class FakeProgram { [[nodiscard]] ContextTransactionReserveStatus reserve_active_capture(FakeCaptureOffer&&, const FakeSharedPrefixHandle*, - const FakeSharedPrefixHandle*, std::optional, bool, + const FakeSharedPrefixHandle* shared_replacement, + std::optional, bool publish_shared, CancellationFlagView cancellation) { if (cancellation.requested() || abort_capture_start) { return ContextTransactionReserveStatus::Aborted; } pending_plan_.reset(); - pending_capture_publish_shared_ = false; + pending_capture_publish_shared_ = publish_shared; + pending_capture_replacement_ = shared_replacement != nullptr; transaction_kind_ = TransactionKind::Capture; advance_revision(); return ContextTransactionReserveStatus::Reserved; } [[nodiscard]] ContextTransactionReserveStatus reserve_active_capture_with_pressure( - FakeCaptureOffer&&, const FakeSharedPrefixHandle*, const FakeSharedPrefixHandle*, + FakeCaptureOffer&&, const FakeSharedPrefixHandle*, + const FakeSharedPrefixHandle* shared_replacement, std::optional, bool publish_shared, FakeResourcePlan&& pressure, CancellationFlagView cancellation) { if (cancellation.requested() || abort_capture_start || pressure.revision != revision_) { @@ -1079,6 +1085,7 @@ class FakeProgram { } pending_plan_.emplace(std::move(pressure)); pending_capture_publish_shared_ = publish_shared; + pending_capture_replacement_ = shared_replacement != nullptr; transaction_kind_ = TransactionKind::Capture; advance_revision(); return ContextTransactionReserveStatus::Reserved; @@ -1194,6 +1201,7 @@ class FakeProgram { std::optional pending_plan_; std::unique_ptr capture_pressure_candidate_; bool pending_capture_publish_shared_ = false; + bool pending_capture_replacement_ = false; }; FakePressurePlanningSession::FakePressurePlanningSession( @@ -3452,6 +3460,66 @@ void test_publication_only_pressure_constructs_adoptable_target() { } +void test_private_continuation_saturation_evicts_oldest_publication_order() { + FakeManager manager = make_manager(1, 2); + FakeProgram program; + const auto req1 = start_active(manager, program, 100, make_base(100, FakeCacheSessionKey{1}), 10); + (void)finish_active(manager, program, req1); + const auto req2 = start_active(manager, program, 200, make_base(200, FakeCacheSessionKey{2}), 20); + (void)finish_active(manager, program, req2); + + require(manager.catalog_state(0) == FakeManager::CatalogState::Catalogued && + manager.catalog_state(1) == FakeManager::CatalogState::Catalogued, + "private catalog was not saturated with 2 sessions"); + + auto result = manager.inspect(program, FakePreparedPrompt{300}, make_base(300, FakeCacheSessionKey{3}), 30); + require(result.choice.has_value(), "private saturation did not produce a feasible materialization choice"); + + program.abort_start = true; + (void)manager.reserve_materialization(program, std::move(*result.choice), FakePreparedPrompt{300}, {}); + require(program.started_action_ids.size() == 1 && program.started_action_ids.front() == 2000U + req1.sequence.id, + "private continuation saturation did not evict oldest session 1 (publication order 10)"); +} + +void test_shared_catalog_continuous_cycles_regression_251() { + FakeManager manager = make_manager(1, 2, 7); + FakeProgram program; + program.finish_frontier = 64; + + for (std::uint32_t i = 1; i <= 50; ++i) { + if (i > 1) { + FakeRequestBasePlan prev_base = make_base(1000 + i - 1); + auto reuse_inspection = manager.inspect(program, FakePreparedPrompt{1000 + i - 1}, prev_base, i * 10 - 5); + require(reuse_inspection.choice.has_value(), "prefix reuse failed on continuous cycle"); + } + + FakeRequestBasePlan shared_base = make_base(1000 + i); + shared_base.cache.opportunities.push_back(FakeContextCache::Opportunity{ + .kind = ninfer::PromptCacheMarkerKind::SharedStablePrefix, + .evidence = ninfer::SharedCandidateEvidence::ExplicitBoundary, + .frontier = 64, + }); + + const ActiveRequest active = start_active(manager, program, 1000 + i, shared_base, i * 10); + program.capture_assessment = FakeCaptureAssessment{ + .shortlist_key = FakeShortlistKey{.digest = 1000 + i, .frontier = 64}, + .shared_evidence = ninfer::SharedCandidateEvidence::ExplicitBoundary, + .protected_rebuild_work = PrefillWork{.tokens = 64}, + .publishes_shared = true, + .physically_feasible = true, + }; + + const auto reserved = manager.reserve_active_capture(program, active.lane, FakeCaptureOffer{.id = i}, 0, {}); + require(reserved == FakeManager::ActiveCaptureReserveResult::Reserved, + "shared capture failed to reserve at continuous iteration"); + auto progress = manager.progress_context_transaction(program, {}); + const auto outcome = std::get(std::move(progress)); + require(outcome.status == ContextTransactionStatus::Published, + "shared capture failed to publish at continuous iteration"); + (void)finish_active(manager, program, active, 64); + } +} + } // namespace int main() { @@ -3525,6 +3593,10 @@ int main() { run_test("backfill proof and stats", test_backfill_proof_and_stats_follow_program_revision); run_test("shortlist exact verification", test_shortlist_collision_requires_program_exact_verification); + run_test("private saturation oldest eviction", + test_private_continuation_saturation_evicts_oldest_publication_order); + run_test("shared catalog continuous cycles regression 251", + test_shared_catalog_continuous_cycles_regression_251); if (failures != 0) { return 1; } std::cout << "ok\n"; return 0; From b631456607c26bbf15ca20666c65e143e525f73e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D1=87=D1=83=D0=B1=D0=B5=D0=B9=20=D0=9F=D0=B0?= =?UTF-8?q?=D0=B2=D0=B5=D0=BB=20=D0=9C=D0=B8=D1=85=D0=B0=D0=B9=D0=BB=D0=BE?= =?UTF-8?q?=D0=B2=D0=B8=D1=87?= Date: Mon, 21 Sep 2026 13:18:50 +0300 Subject: [PATCH 3/6] fix --- .../qwen3_5/frontend/tool_call_parser.cpp | 318 +++++++++++++----- .../qwen3_5/frontend/tool_call_parser.h | 1 + .../dense/causal_cache/small_t_bf16.cuh | 3 +- .../dense/causal_cache/small_t_fp8.cuh | 3 +- .../dense/causal_cache/small_t_i8.cuh | 3 +- .../dense/causal_cache/small_t_k8v4.cuh | 3 +- .../dense/causal_cache/small_t_nvfp4.cuh | 3 +- tests/test_tool_call_parser.cpp | 74 ++++ 8 files changed, 314 insertions(+), 94 deletions(-) diff --git a/src/models/qwen3_5/frontend/tool_call_parser.cpp b/src/models/qwen3_5/frontend/tool_call_parser.cpp index 124281d417..ce14096716 100644 --- a/src/models/qwen3_5/frontend/tool_call_parser.cpp +++ b/src/models/qwen3_5/frontend/tool_call_parser.cpp @@ -19,10 +19,6 @@ using TypeSet = Contract::TypeSet; constexpr std::string_view kToolOpen = ""; constexpr std::string_view kToolClose = ""; -constexpr std::string_view kFunctionOpen = "= 2) { + if ((str.front() == '"' && str.back() == '"') || + (str.front() == '\'' && str.back() == '\'')) { + return trim_format_whitespace(str.substr(1, str.size() - 2)); + } + } + return str; +} + +std::string_view extract_name_from_tag_header(std::string_view header) { + header = trim_format_whitespace(header); + if (header.starts_with('=')) { + return unquote(header.substr(1)); + } + std::size_t name_pos = header.find("name"); + if (name_pos != std::string_view::npos) { + std::size_t eq_pos = header.find('=', name_pos + 4); + if (eq_pos != std::string_view::npos) { + std::string_view val = trim_format_whitespace(header.substr(eq_pos + 1)); + if (!val.empty() && (val.front() == '"' || val.front() == '\'')) { + char q = val.front(); + std::size_t q_end = val.find(q, 1); + if (q_end != std::string_view::npos) { + return val.substr(1, q_end - 1); + } + } + std::size_t end = 0; + while (end < val.size() && !is_format_whitespace(val[end]) && val[end] != '/' && val[end] != '>') { + ++end; + } + return val.substr(0, end); + } + } + return unquote(header); +} + +bool is_param_open_at(std::string_view text, std::size_t pos, std::size_t& tag_end) { + std::size_t header_begin = 0; + if (starts_with_at(text, pos, "= text.size()) { return false; } + if (text[header_begin] != '=' && text[header_begin] != ' ' && text[header_begin] != '\t' && + text[header_begin] != '\r' && text[header_begin] != '\n' && text[header_begin] != '>') { + return false; + } + const std::size_t end = text.find('>', header_begin); + if (end != std::string_view::npos && end != header_begin) { + tag_end = end; + return true; + } + return false; +} + +bool is_param_close_at(std::string_view text, std::size_t pos, std::size_t& tag_len) { + if (starts_with_at(text, pos, "")) { + tag_len = 12; + return true; + } + if (starts_with_at(text, pos, "")) { + tag_len = 8; + return true; + } + return false; +} + +static constexpr std::string_view kToolMarkers[] = { + "", + "", + "", + "", + " max_name_length) { return false; } return std::all_of(name.begin(), name.end(), [](char byte) { @@ -420,15 +528,34 @@ class QwenToolRegionParser { if (pos == text_.size()) { return calls.empty() ? FallbackReason::MalformedStructure : FallbackReason::None; } - if (!starts_with_at(text_, pos, kToolOpen)) { + if (starts_with_at(text_, pos, "")) { + RawToolCall call; + const FallbackReason failure = parse_tool_call(pos, call); + if (failure != FallbackReason::None) { return failure; } + calls.push_back(std::move(call)); + } else if (starts_with_at(text_, pos, "")) { + pos += 16; + bool had_calls = false; + for (;;) { + skip_format_whitespace(text_, pos); + if (consume(pos, "")) { break; } + if (pos == text_.size()) { return FallbackReason::MalformedStructure; } + RawToolCall call; + const FallbackReason failure = parse_function(pos, call); + if (failure != FallbackReason::None) { return failure; } + calls.push_back(std::move(call)); + had_calls = true; + } + if (!had_calls) { return FallbackReason::MalformedStructure; } + } else if (starts_with_at(text_, pos, "")) { return FallbackReason::MalformedStructure; } skip_format_whitespace(text_, pos); const FallbackReason failure = parse_function(pos, call); if (failure != FallbackReason::None) { return failure; } skip_format_whitespace(text_, pos); - return consume(pos, kToolClose) ? FallbackReason::None : FallbackReason::MalformedStructure; + return consume(pos, "") ? FallbackReason::None : FallbackReason::MalformedStructure; } FallbackReason parse_function(std::size_t& pos, RawToolCall& call) const { - if (!consume(pos, kFunctionOpen)) { return FallbackReason::MalformedStructure; } - const std::size_t name_begin = pos; - const std::size_t name_end = text_.find('>', name_begin); - if (name_end == std::string_view::npos || name_end == name_begin) { + std::size_t header_begin = 0; + std::string_view fn_close = ""; + if (starts_with_at(text_, pos, "', header_begin); + if (tag_end == std::string_view::npos || tag_end == header_begin) { return FallbackReason::InvalidToolName; } - call.name = text_.substr(name_begin, name_end - name_begin); + const std::string_view header = text_.substr(header_begin, tag_end - header_begin); + call.name = extract_name_from_tag_header(header); if (!valid_function_name(call.name, max_name_length_)) { return FallbackReason::InvalidToolName; } @@ -463,76 +601,88 @@ class QwenToolRegionParser { find_tool_contract(contract_, call.name) == nullptr) { return FallbackReason::UndeclaredTool; } - pos = name_end + 1; + pos = tag_end + 1; for (;;) { skip_format_whitespace(text_, pos); - if (consume(pos, kFunctionClose)) { return FallbackReason::None; } + if (consume(pos, fn_close) || consume(pos, "") || consume(pos, "")) { + return FallbackReason::None; + } const FallbackReason failure = parse_parameter(pos, call); if (failure != FallbackReason::None) { return failure; } } } FallbackReason parse_parameter(std::size_t& pos, RawToolCall& call) const { - if (!consume(pos, kParamOpen)) { return FallbackReason::MalformedStructure; } - const std::size_t name_begin = pos; - const std::size_t name_end = text_.find('>', name_begin); - if (name_end == std::string_view::npos || name_end == name_begin) { + std::size_t header_begin = 0; + if (starts_with_at(text_, pos, "', header_begin); + if (tag_end == std::string_view::npos || tag_end == header_begin) { + return FallbackReason::MalformedStructure; + } + const std::string_view header = text_.substr(header_begin, tag_end - header_begin); + const std::string_view name = extract_name_from_tag_header(header); + if (name.empty()) { + return FallbackReason::MalformedStructure; } - const std::size_t value_begin = name_end + 1; + const std::size_t value_begin = tag_end + 1; std::size_t value_end = 0; - if (!find_parameter_close(value_begin, value_end)) { + std::size_t close_len = 0; + if (!find_parameter_close(value_begin, value_end, close_len)) { return FallbackReason::MalformedStructure; } - call.parameters.push_back(RawParameter{ - .name = name, .value = text_.substr(value_begin, value_end - value_begin)}); - pos = value_end + kParamClose.size(); - return FallbackReason::None; - } - - bool find_parameter_open_before(std::size_t scan, std::size_t limit, - std::size_t& open_end) const { - std::size_t candidate = text_.find(kParamOpen, scan); - while (candidate != std::string_view::npos && candidate < limit) { - const std::size_t name_begin = candidate + kParamOpen.size(); - const std::size_t name_end = text_.find('>', name_begin); - if (name_end != std::string_view::npos && name_end < limit && name_end != name_begin) { - open_end = name_end + 1; - return true; + const std::string_view value = text_.substr(value_begin, value_end - value_begin); + + const auto existing = + std::find_if(call.parameters.begin(), call.parameters.end(), + [&](const RawParameter& candidate) { return candidate.name == name; }); + if (existing != call.parameters.end()) { + if (existing->value == value || + trim_format_whitespace(existing->value) == trim_format_whitespace(value)) { + pos = value_end + close_len; + return FallbackReason::None; } - candidate = text_.find(kParamOpen, candidate + 1); + return FallbackReason::DuplicateParameter; } - return false; + + call.parameters.push_back(RawParameter{.name = name, .value = value}); + pos = value_end + close_len; + return FallbackReason::None; } - bool find_parameter_close(std::size_t value_begin, std::size_t& value_end) const { + bool find_parameter_close(std::size_t value_begin, std::size_t& value_end, + std::size_t& close_len) const { std::size_t depth = 1; std::size_t scan = value_begin; - for (;;) { - const std::size_t close = text_.find(kParamClose, scan); - if (close == std::string_view::npos) { return false; } - - std::size_t nested_open_end = 0; - if (find_parameter_open_before(scan, close, nested_open_end)) { - ++depth; - scan = nested_open_end; + while (scan < text_.size()) { + std::size_t tag_size = 0; + if (is_param_close_at(text_, scan, tag_size)) { + --depth; + if (depth == 0) { + value_end = scan; + close_len = tag_size; + return true; + } + scan += tag_size; continue; } - - --depth; - if (depth == 0) { - value_end = close; - return true; + std::size_t open_tag_end = 0; + if (is_param_open_at(text_, scan, open_tag_end)) { + ++depth; + scan = open_tag_end + 1; + continue; } - scan = close + kParamClose.size(); + ++scan; } + return false; } std::string_view text_; @@ -598,7 +748,7 @@ build_tool_call_output_contract(std::span tool_jsons, bool en ParsedToolCallOutput parse_qwen_tool_call_output(const std::string& text, std::size_t max_tool_name_length, const ToolCallOutputContract& contract) { - const std::size_t first = text.find(kToolOpen); + const std::size_t first = find_first_tool_marker(text); if (first == std::string::npos) { return fallback(text); } ParsedToolCallOutput out; @@ -640,28 +790,28 @@ std::string ToolCallOutputDecoder::feed(std::string_view text) { std::string visible; for (std::size_t index = 0; index < text.size(); ++index) { const char byte = text[index]; - if (marker_prefix_bytes_ != 0) { - if (byte == kToolOpen[marker_prefix_bytes_]) { - ++marker_prefix_bytes_; - if (marker_prefix_bytes_ == kToolOpen.size()) { - tool_region_ = std::move(trailing_whitespace_); - trailing_whitespace_.clear(); - tool_region_.append(kToolOpen); - tool_region_.append(text.substr(index + 1)); - marker_prefix_bytes_ = 0; - saw_tool_marker_ = true; - break; - } - continue; + if (!pending_tag_.empty()) { + pending_tag_.push_back(byte); + if (matches_any_marker(pending_tag_)) { + tool_region_ = std::move(trailing_whitespace_); + trailing_whitespace_.clear(); + tool_region_.append(pending_tag_); + pending_tag_.clear(); + tool_region_.append(text.substr(index + 1)); + saw_tool_marker_ = true; + break; } - visible.append(trailing_whitespace_); - trailing_whitespace_.clear(); - visible.append(kToolOpen.substr(0, marker_prefix_bytes_)); - marker_prefix_bytes_ = 0; + if (!is_prefix_of_any_marker(pending_tag_)) { + visible.append(trailing_whitespace_); + trailing_whitespace_.clear(); + visible.append(pending_tag_); + pending_tag_.clear(); + } + continue; } - if (byte == kToolOpen.front()) { - marker_prefix_bytes_ = 1; + if (byte == '<') { + pending_tag_.push_back(byte); } else if (is_format_whitespace(byte)) { trailing_whitespace_.push_back(byte); } else { @@ -683,15 +833,15 @@ ToolCallOutputDecoder::Terminal ToolCallOutputDecoder::finish() { if (saw_tool_marker_ && parsed.is_tool_call_response) { trailing_whitespace_.clear(); tool_region_.clear(); - marker_prefix_bytes_ = 0; + pending_tag_.clear(); return Terminal{.content = {}, .tool_calls = std::move(parsed.tool_calls), .diagnostics = parsed.diagnostics}; } std::string tail = std::move(trailing_whitespace_); - tail.append(kToolOpen.substr(0, marker_prefix_bytes_)); - marker_prefix_bytes_ = 0; + tail.append(pending_tag_); + pending_tag_.clear(); tail += tool_region_; tool_region_.clear(); return Terminal{ diff --git a/src/models/qwen3_5/frontend/tool_call_parser.h b/src/models/qwen3_5/frontend/tool_call_parser.h index 6624669aac..a468958613 100644 --- a/src/models/qwen3_5/frontend/tool_call_parser.h +++ b/src/models/qwen3_5/frontend/tool_call_parser.h @@ -87,6 +87,7 @@ class ToolCallOutputDecoder { std::shared_ptr contract_; std::string trailing_whitespace_; std::string tool_region_; + std::string pending_tag_; std::size_t marker_prefix_bytes_ = 0; std::size_t max_tool_name_length_ = 0; bool saw_tool_marker_ = false; diff --git a/src/ops/softmax_attention/dense/causal_cache/small_t_bf16.cuh b/src/ops/softmax_attention/dense/causal_cache/small_t_bf16.cuh index a80103362e..41afee8322 100644 --- a/src/ops/softmax_attention/dense/causal_cache/small_t_bf16.cuh +++ b/src/ops/softmax_attention/dense/causal_cache/small_t_bf16.cuh @@ -143,8 +143,7 @@ __launch_bounds__(128, 2) __global__ void causal_attention_small_t_tc_partial_bf const int first_page = first_tile >> kPagedKVPageShift; const int page_count = ((split_end - 1) >> kPagedKVPageShift) - first_page + 1; assert(page_count <= PageIds); - const int bounded_pages = min(page_count, PageIds); - for (int page = tid; page < bounded_pages; page += Threads) { + for (int page = tid; page < page_count; page += Threads) { physical_pages_s[page] = block_table[first_page + page]; } diff --git a/src/ops/softmax_attention/dense/causal_cache/small_t_fp8.cuh b/src/ops/softmax_attention/dense/causal_cache/small_t_fp8.cuh index 42dfcf4489..62e6442329 100644 --- a/src/ops/softmax_attention/dense/causal_cache/small_t_fp8.cuh +++ b/src/ops/softmax_attention/dense/causal_cache/small_t_fp8.cuh @@ -180,8 +180,7 @@ __launch_bounds__(WarpsPerCta * 32, MinBlocksPerSm) __global__ const int first_page = first_tile >> kPagedKVPageShift; const int page_count = ((split_end - 1) >> kPagedKVPageShift) - first_page + 1; assert(page_count <= PageIds); - const int bounded_pages = min(page_count, PageIds); - for (int page = tid; page < bounded_pages; page += Threads) { + for (int page = tid; page < page_count; page += Threads) { physical_pages_s[page] = block_table[first_page + page]; } __syncthreads(); diff --git a/src/ops/softmax_attention/dense/causal_cache/small_t_i8.cuh b/src/ops/softmax_attention/dense/causal_cache/small_t_i8.cuh index 0da282cd52..52f2cc7de2 100644 --- a/src/ops/softmax_attention/dense/causal_cache/small_t_i8.cuh +++ b/src/ops/softmax_attention/dense/causal_cache/small_t_i8.cuh @@ -189,8 +189,7 @@ __launch_bounds__(WarpsPerCta * 32, MinBlocksPerSm) __global__ const int first_page = first_tile >> kPagedKVPageShift; const int page_count = ((split_end - 1) >> kPagedKVPageShift) - first_page + 1; assert(page_count <= PageIds); - const int bounded_pages = min(page_count, PageIds); - for (int page = tid; page < bounded_pages; page += Threads) { + for (int page = tid; page < page_count; page += Threads) { physical_pages_s[page] = block_table[first_page + page]; } diff --git a/src/ops/softmax_attention/dense/causal_cache/small_t_k8v4.cuh b/src/ops/softmax_attention/dense/causal_cache/small_t_k8v4.cuh index 2536b73c42..dd0cfe244e 100644 --- a/src/ops/softmax_attention/dense/causal_cache/small_t_k8v4.cuh +++ b/src/ops/softmax_attention/dense/causal_cache/small_t_k8v4.cuh @@ -168,8 +168,7 @@ __launch_bounds__(WarpsPerCta * 32, MinBlocksPerSm) __global__ const int first_page = first_tile >> kPagedKVPageShift; const int page_count = ((split_end - 1) >> kPagedKVPageShift) - first_page + 1; assert(page_count <= PageIds); - const int bounded_pages = min(page_count, PageIds); - for (int page = tid; page < bounded_pages; page += Threads) { + for (int page = tid; page < page_count; page += Threads) { physical_pages_s[page] = block_table[first_page + page]; } __syncthreads(); diff --git a/src/ops/softmax_attention/dense/causal_cache/small_t_nvfp4.cuh b/src/ops/softmax_attention/dense/causal_cache/small_t_nvfp4.cuh index 6c76451b3d..9490a680e6 100644 --- a/src/ops/softmax_attention/dense/causal_cache/small_t_nvfp4.cuh +++ b/src/ops/softmax_attention/dense/causal_cache/small_t_nvfp4.cuh @@ -176,8 +176,7 @@ __launch_bounds__(WarpsPerCta * 32, MinBlocksPerSm) __global__ const int first_page = first_tile >> kPagedKVPageShift; const int page_count = ((split_end - 1) >> kPagedKVPageShift) - first_page + 1; assert(page_count <= PageIds); - const int bounded_pages = min(page_count, PageIds); - for (int page = tid; page < bounded_pages; page += Threads) { + for (int page = tid; page < page_count; page += Threads) { physical_pages_s[page] = block_table[first_page + page]; } __syncthreads(); diff --git a/tests/test_tool_call_parser.cpp b/tests/test_tool_call_parser.cpp index 06c47f9b40..0d0b5e499f 100644 --- a/tests/test_tool_call_parser.cpp +++ b/tests/test_tool_call_parser.cpp @@ -733,6 +733,78 @@ int test_incremental_embedded_parameter_markup() { return failures; } +int test_claude_code_xml_markup_variants() { + const auto contract = + contract_for("TaskCreate", Json{{"description", Json{{"type", "string"}}}}); + int failures = 0; + + const std::string standard_xml = + "\n\n\n" + "Initial setup\n\n\n"; + const auto parsed_standard = fi::parse_qwen_tool_call_output(standard_xml, 128, contract); + failures += check(parsed_standard.is_tool_call_response && parsed_standard.tool_calls.size() == 1 && + parsed_standard.tool_calls.front().name == "TaskCreate", + "function name attribute syntax was not parsed"); + if (parsed_standard.tool_calls.size() == 1) { + const Json args = Json::parse(parsed_standard.tool_calls.front().arguments_json); + failures += check(args.at("description") == "Initial setup", + "function name attribute argument changed"); + } + + const std::string invoke_xml = + "\n\n\n" + "Create tasks\n\n\n"; + const auto parsed_invoke = fi::parse_qwen_tool_call_output(invoke_xml, 128, contract); + failures += check(parsed_invoke.is_tool_call_response && parsed_invoke.tool_calls.size() == 1 && + parsed_invoke.tool_calls.front().name == "TaskCreate", + "invoke tag syntax was not parsed"); + + const std::string function_calls_xml = + "\n\n\n" + "Function calls container\n\n\n"; + const auto parsed_function_calls = fi::parse_qwen_tool_call_output(function_calls_xml, 128, contract); + failures += check(parsed_function_calls.is_tool_call_response && parsed_function_calls.tool_calls.size() == 1 && + parsed_function_calls.tool_calls.front().name == "TaskCreate", + "function_calls container syntax was not parsed"); + + const std::string standalone_invoke = + "Plan is ready:\n\n\n" + "Standalone invoke\n\n"; + const auto parsed_standalone = fi::parse_qwen_tool_call_output(standalone_invoke, 128, contract); + failures += check(parsed_standalone.is_tool_call_response && parsed_standalone.content == "Plan is ready:" && + parsed_standalone.tool_calls.size() == 1 && + parsed_standalone.tool_calls.front().name == "TaskCreate", + "standalone invoke after plan was not parsed"); + + return failures; +} + +int test_duplicate_identical_parameters_coalesced() { + const auto contract = + contract_for("configure", Json{{"value", Json{{"type", "string"}}}}); + int failures = 0; + + const std::string identical_dup = + "\n\n\nfirst\n\n" + "\nfirst\n\n\n"; + const auto parsed_identical = fi::parse_qwen_tool_call_output(identical_dup, 64, contract); + failures += check(parsed_identical.is_tool_call_response && parsed_identical.tool_calls.size() == 1, + "duplicate identical parameter was not coalesced"); + if (parsed_identical.tool_calls.size() == 1) { + const Json args = Json::parse(parsed_identical.tool_calls.front().arguments_json); + failures += check(args.at("value") == "first", "coalesced parameter value changed"); + } + + const std::string conflicting_dup = + "\n\n\nfirst\n\n" + "\nsecond\n\n\n"; + failures += check_rejected(conflicting_dup, contract, + ninfer::ToolCallParseFallbackReason::DuplicateParameter, + "conflicting duplicate parameter was not rejected"); + + return failures; +} + } // namespace int main() { @@ -756,6 +828,8 @@ int main() { failures += test_incremental_valid_and_boolean(); failures += test_incremental_fallback_preserves_bytes(); failures += test_incremental_embedded_parameter_markup(); + failures += test_claude_code_xml_markup_variants(); + failures += test_duplicate_identical_parameters_coalesced(); if (failures == 0) { std::cout << "ok\n"; } return failures == 0 ? 0 : 1; } From cf6846e45a36f112335864ed29b7042cf47813e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D1=87=D1=83=D0=B1=D0=B5=D0=B9=20=D0=9F=D0=B0?= =?UTF-8?q?=D0=B2=D0=B5=D0=BB=20=D0=9C=D0=B8=D1=85=D0=B0=D0=B9=D0=BB=D0=BE?= =?UTF-8?q?=D0=B2=D0=B8=D1=87?= Date: Mon, 21 Sep 2026 13:26:16 +0300 Subject: [PATCH 4/6] fix --- src/models/qwen3_5/frontend/chat_template.cpp | 7 +- .../qwen3_5/frontend/tool_call_parser.cpp | 62 +++++++--- src/serve/anthropic_messages_request.cpp | 8 -- src/serve/openai_chat_request.cpp | 3 + src/serve/translate.cpp | 5 - tests/test_anthropic_schema.cpp | 5 +- tests/test_tool_call_parser.cpp | 114 ++++++++++++++++++ 7 files changed, 163 insertions(+), 41 deletions(-) diff --git a/src/models/qwen3_5/frontend/chat_template.cpp b/src/models/qwen3_5/frontend/chat_template.cpp index 92a90b47b5..984b842738 100644 --- a/src/models/qwen3_5/frontend/chat_template.cpp +++ b/src/models/qwen3_5/frontend/chat_template.cpp @@ -160,11 +160,8 @@ RenderedChat CompiledChatTemplate::render(const std::vector& messag Json context = template_parameters(options, special_tokens_); if (continuation) { const auto& final = messages.back(); - if (final.role != ChatRole::Assistant || final.has_media() || - !final.reasoning_content.empty() || !final.tool_calls.empty() || - context.value("enable_thinking", false)) { - throw std::invalid_argument("assistant continuation requires a final text-only " - "assistant message and disabled thinking"); + if (final.role != ChatRole::Assistant || final.has_media()) { + throw std::invalid_argument("assistant continuation requires a final assistant message without media"); } } context["continue_final_message"] = continuation; diff --git a/src/models/qwen3_5/frontend/tool_call_parser.cpp b/src/models/qwen3_5/frontend/tool_call_parser.cpp index ce14096716..23e349dc17 100644 --- a/src/models/qwen3_5/frontend/tool_call_parser.cpp +++ b/src/models/qwen3_5/frontend/tool_call_parser.cpp @@ -99,23 +99,43 @@ std::string_view extract_name_from_tag_header(std::string_view header) { if (header.starts_with('=')) { return unquote(header.substr(1)); } - std::size_t name_pos = header.find("name"); - if (name_pos != std::string_view::npos) { - std::size_t eq_pos = header.find('=', name_pos + 4); - if (eq_pos != std::string_view::npos) { - std::string_view val = trim_format_whitespace(header.substr(eq_pos + 1)); - if (!val.empty() && (val.front() == '"' || val.front() == '\'')) { - char q = val.front(); - std::size_t q_end = val.find(q, 1); + for (std::size_t i = 0; i < header.size();) { + if (is_format_whitespace(header[i])) { + ++i; + continue; + } + const std::size_t attr_begin = i; + while (i < header.size() && header[i] != '=' && !is_format_whitespace(header[i]) && header[i] != '>') { + ++i; + } + const std::string_view attr_name = header.substr(attr_begin, i - attr_begin); + skip_format_whitespace(header, i); + if (i < header.size() && header[i] == '=') { + ++i; + skip_format_whitespace(header, i); + if (i >= header.size()) break; + std::string_view val; + if (header[i] == '"' || header[i] == '\'') { + const char q = header[i]; + const std::size_t q_start = i + 1; + const std::size_t q_end = header.find(q, q_start); if (q_end != std::string_view::npos) { - return val.substr(1, q_end - 1); + val = header.substr(q_start, q_end - q_start); + i = q_end + 1; + } else { + val = header.substr(q_start); + i = header.size(); + } + } else { + const std::size_t val_begin = i; + while (i < header.size() && !is_format_whitespace(header[i]) && header[i] != '/' && header[i] != '>') { + ++i; } + val = header.substr(val_begin, i - val_begin); } - std::size_t end = 0; - while (end < val.size() && !is_format_whitespace(val[end]) && val[end] != '/' && val[end] != '>') { - ++end; + if (attr_name == "name") { + return val; } - return val.substr(0, end); } } return unquote(header); @@ -605,7 +625,7 @@ class QwenToolRegionParser { for (;;) { skip_format_whitespace(text_, pos); - if (consume(pos, fn_close) || consume(pos, "") || consume(pos, "")) { + if (consume(pos, fn_close)) { return FallbackReason::None; } const FallbackReason failure = parse_parameter(pos, call); @@ -615,10 +635,13 @@ class QwenToolRegionParser { FallbackReason parse_parameter(std::size_t& pos, RawToolCall& call) const { std::size_t header_begin = 0; + std::string_view param_close = ""; if (starts_with_at(text_, pos, "\n\n" + "\nCreate task\n\n" + "\n"; + const auto parsed = fi::parse_qwen_tool_call_output(text, 128, contract); + failures += check(parsed.is_tool_call_response && parsed.tool_calls.size() == 1 && + parsed.tool_calls.front().name == "TaskCreate", + "attribute token boundary failed to extract correct name"); + if (parsed.tool_calls.size() == 1) { + const Json args = Json::parse(parsed.tool_calls.front().arguments_json); + failures += check(args.at("description") == "Create task", + "parameter attribute token boundary failed"); + } + return failures; +} + +int test_mismatched_closing_tags_rejected() { + const auto contract = + contract_for("TaskCreate", Json{{"description", Json{{"type", "string"}}}}); + int failures = 0; + + const std::string fn_invoke_mismatch = + "\n\n\n" + "Value\n\n\n"; + failures += check_rejected(fn_invoke_mismatch, contract, + ninfer::ToolCallParseFallbackReason::MalformedStructure, + "function opening with invoke closing tag was accepted"); + + const std::string invoke_fn_mismatch = + "\n\n\n" + "Value\n\n\n"; + failures += check_rejected(invoke_fn_mismatch, contract, + ninfer::ToolCallParseFallbackReason::MalformedStructure, + "invoke opening with function closing tag was accepted"); + + const std::string param_mismatch = + "\n\n\n" + "Value\n\n\n"; + failures += check_rejected(param_mismatch, contract, + ninfer::ToolCallParseFallbackReason::MalformedStructure, + "parameter opening with param closing tag was accepted"); + + const std::string param_open_mismatch = + "\n\n\n" + "Value\n\n\n"; + failures += check_rejected(param_open_mismatch, contract, + ninfer::ToolCallParseFallbackReason::MalformedStructure, + "param opening with parameter closing tag was accepted"); + + return failures; +} + +int test_claude_code_plan_and_task_create_exact_repro() { + const std::string task_create_def = tool_definition( + "TaskCreate", + Json{{"description", Json{{"type", "string"}}}, + {"task_type", Json{{"type", "string"}}}, + {"priority", Json{{"type", "integer"}}}}); + const std::string task_update_def = tool_definition( + "TaskUpdate", + Json{{"taskId", Json{{"type", "string"}}}, + {"status", Json{{"type", "string"}}}}); + const auto contract = + contract_from_definitions({task_create_def, task_update_def}); + + const std::string full_response = + "I have analyzed the repository requirements. Here is the implementation plan:\n\n" + "### Plan\n" + "1. Inspect existing CUDA kernels in `src/ops/softmax_attention/`\n" + "2. Add test coverage for long context attention splits\n" + "3. Update frontend tool call decoder\n\n" + "Let me create the first task in the tracking system now:\n\n" + "\n" + "\n" + "\n" + "Implement split-KV page-safety and bounded loops\n" + "\n" + "\n" + "feature\n" + "\n" + "\n" + "1\n" + "\n" + "\n" + ""; + + const auto parsed = fi::parse_qwen_tool_call_output(full_response, 128, *contract); + int failures = 0; + failures += check(parsed.is_tool_call_response, "Claude Code Plan + TaskCreate failed to parse as tool call"); + failures += check(parsed.tool_calls.size() == 1, "tool call count != 1"); + failures += check(parsed.content.starts_with("I have analyzed"), "plan content prefix lost"); + failures += check(parsed.content.ends_with("tracking system now:"), "plan content tail lost"); + + if (parsed.tool_calls.size() == 1) { + const auto& call = parsed.tool_calls.front(); + failures += check(call.name == "TaskCreate", "tool name != TaskCreate"); + const Json args = Json::parse(call.arguments_json); + failures += check(args.at("description") == "Implement split-KV page-safety and bounded loops", + "TaskCreate description argument changed"); + failures += check(args.at("task_type") == "feature", "TaskCreate task_type argument changed"); + failures += check(args.at("priority") == 1, "TaskCreate priority argument changed"); + } + + return failures; +} + } // namespace int main() { @@ -830,6 +941,9 @@ int main() { failures += test_incremental_embedded_parameter_markup(); failures += test_claude_code_xml_markup_variants(); failures += test_duplicate_identical_parameters_coalesced(); + failures += test_attribute_token_boundary(); + failures += test_mismatched_closing_tags_rejected(); + failures += test_claude_code_plan_and_task_create_exact_repro(); if (failures == 0) { std::cout << "ok\n"; } return failures == 0 ? 0 : 1; } From 539a6f88df4fe8edbf1e7fa6701d5f56035a680d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D1=87=D1=83=D0=B1=D0=B5=D0=B9=20=D0=9F=D0=B0?= =?UTF-8?q?=D0=B2=D0=B5=D0=BB=20=D0=9C=D0=B8=D1=85=D0=B0=D0=B9=D0=BB=D0=BE?= =?UTF-8?q?=D0=B2=D0=B8=D1=87?= Date: Mon, 21 Sep 2026 13:31:32 +0300 Subject: [PATCH 5/6] fix reusable --- tests/test_openai_schema.cpp | 37 ++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/test_openai_schema.cpp b/tests/test_openai_schema.cpp index d5887eb3ab..9eb516d068 100644 --- a/tests/test_openai_schema.cpp +++ b/tests/test_openai_schema.cpp @@ -776,6 +776,42 @@ int test_common_objects() { return failures; } +int test_assistant_continuation_mode() { + int failures = 0; + + // Normal conversation ending with user -> NewAssistantTurn + Json normal = Json{ + {"model", "qwen"}, + {"messages", Json::array({ + Json{{"role", "system"}, {"content", "system prompt"}}, + Json{{"role", "user"}, {"content", "first question"}}, + Json{{"role", "assistant"}, {"content", "first answer"}}, + Json{{"role", "user"}, {"content", "second question"}} + })} + }; + const auto normal_req = parse(normal); + failures += check(normal_req.generation.continuation == ninfer::PromptContinuationMode::NewAssistantTurn, + "conversation ending with user was not NewAssistantTurn"); + + // Conversation ending with assistant -> ContinueFinalAssistant + Json trailing_assistant = Json{ + {"model", "qwen"}, + {"messages", Json::array({ + Json{{"role", "system"}, {"content", "system prompt"}}, + Json{{"role", "user"}, {"content", "question"}}, + Json{{"role", "assistant"}, {"content", "partial answer..."}} + })} + }; + const auto cont_req = parse(trailing_assistant); + failures += check(cont_req.generation.continuation == ninfer::PromptContinuationMode::ContinueFinalAssistant, + "conversation ending with assistant was not ContinueFinalAssistant"); + const auto cont_prompt = prompt(cont_req.generation); + failures += check(cont_prompt.options.continuation == ninfer::PromptContinuationMode::ContinueFinalAssistant, + "continuation mode did not reach PromptInput options"); + + return failures; +} + } // namespace int main() { @@ -791,6 +827,7 @@ int main() { failures += test_stream_response(); failures += test_stream_observations(); failures += test_common_objects(); + failures += test_assistant_continuation_mode(); if (failures == 0) { std::cout << "OpenAI Chat protocol tests passed\n"; } return failures == 0 ? 0 : 1; } From cae7b9a5cbef60bc420d9a55a1d1c6f9157be43f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D1=87=D1=83=D0=B1=D0=B5=D0=B9=20=D0=9F=D0=B0?= =?UTF-8?q?=D0=B2=D0=B5=D0=BB=20=D0=9C=D0=B8=D1=85=D0=B0=D0=B9=D0=BB=D0=BE?= =?UTF-8?q?=D0=B2=D0=B8=D1=87?= Date: Mon, 21 Sep 2026 16:02:23 +0300 Subject: [PATCH 6/6] fix text format hermes --- docs/serving.md | 4 ++-- src/serve/openai_chat_request.cpp | 7 ++++--- tests/test_openai_schema.cpp | 4 ++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/serving.md b/docs/serving.md index 7c20649bef..1590dd6ee0 100644 --- a/docs/serving.md +++ b/docs/serving.md @@ -109,7 +109,7 @@ The endpoint supports: - `temperature`, `top_p`, presence/frequency penalties, and signed integer `seed`; - the compatible `top_k` (`0..20`) and `min_p` (`0..1`) sampler extensions; - up to four non-empty stop strings, applied to both reasoning and answer output; -- `n:1`, text-only `modalities`, and `response_format: {"type":"text"}`; +- `n:1`, text-only `modalities`, and `response_format` (`{"type":"text"}`, `{"type":"json_object"}`, or `{"type":"json_schema"}`); - non-streaming responses and server-sent event streams; - `stream_options.include_usage`; - llama.cpp-compatible terminal `timings`, plus opt-in `timings_per_token` and @@ -123,7 +123,7 @@ The endpoint supports: - Assistant `reasoning_content` and `reasoning` history aliases. Options whose observable behavior the Engine cannot provide are rejected when they request that -behavior. This includes JSON constrained output, nonzero `logit_bias`, requested log probabilities, +behavior. This includes nonzero `logit_bias`, requested log probabilities, audio/file input or audio output, `strict:true`, required or named tool choice, `parallel_tool_calls:false` with enabled tools, explicit low/high image detail, web search, moderation, low/high verbosity, stored Chat Completions, and non-empty legacy `functions`. diff --git a/src/serve/openai_chat_request.cpp b/src/serve/openai_chat_request.cpp index 0fbcbe2446..3bbde2447f 100644 --- a/src/serve/openai_chat_request.cpp +++ b/src/serve/openai_chat_request.cpp @@ -136,10 +136,11 @@ void validate_standard_output_controls(const Json& body) { if (!format.is_object() || !format.contains("type") || !format.at("type").is_string()) { bad_request("response_format must contain a string type", "response_format"); } - if (format.at("type").get() != "text") { + const std::string type = format.at("type").get(); + if (type != "text" && type != "json_object" && type != "json_schema") { bad_request( - "this response_format requires constrained output, which NInfer cannot guarantee; " - "only {\"type\":\"text\"} is available", + "this response_format requires an unsupported output format; " + "only 'text', 'json_object', and 'json_schema' are accepted", "response_format", "response_format_not_supported"); } } diff --git a/tests/test_openai_schema.cpp b/tests/test_openai_schema.cpp index 9eb516d068..4b20a694da 100644 --- a/tests/test_openai_schema.cpp +++ b/tests/test_openai_schema.cpp @@ -146,7 +146,7 @@ int test_standard_field_policy() { rejected("logit_bias", Json{{"12", 1}}, "logit_bias_not_supported"); rejected("logprobs", true, "logprobs_not_supported"); rejected("top_logprobs", 2, "logprobs_not_supported"); - rejected("response_format", Json{{"type", "json_schema"}}, "response_format_not_supported"); + rejected("response_format", Json{{"type", "unsupported"}}, "response_format_not_supported"); rejected("modalities", Json::array({"text", "audio"}), "modality_not_supported"); rejected("web_search_options", Json::object(), "web_search_not_supported"); rejected("moderation", Json::object(), "moderation_not_supported"); @@ -159,7 +159,7 @@ int test_standard_field_policy() { neutral["logit_bias"] = Json{{"12", 0}, {"13", 0.0}}; neutral["logprobs"] = false; neutral["top_logprobs"] = 0; - neutral["response_format"] = Json{{"type", "text"}}; + neutral["response_format"] = Json{{"type", "json_object"}}; neutral["modalities"] = Json::array({"text"}); neutral["audio"] = Json{{"voice", "alloy"}}; neutral["prediction"] = Json{{"type", "content"}, {"content", "expected"}};