From c208f2a0c9a1925e91138fd9261eb9f01244105d Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Sat, 22 Aug 2026 15:59:17 +0800 Subject: [PATCH 01/10] fix(model): harden context capacity budgets --- backend/agents/create_agent_info.py | 25 ++- backend/apps/model_managment_app.py | 22 +++ backend/consts/exceptions.py | 9 + .../model_capacity_validation_service.py | 169 ++++++++++++++++++ backend/services/model_management_service.py | 50 ++++++ .../components/model/ModelAddDialog.tsx | 81 +-------- .../components/model/ModelCapacityFields.tsx | 61 +------ .../components/model/ModelEditDialog.tsx | 17 +- frontend/lib/modelCapacityPayload.ts | 54 ++++++ sdk/nexent/core/models/capacity_budget.py | 26 +-- test/backend/agents/test_create_agent_info.py | 52 +++++- test/backend/app/test_model_managment_app.py | 33 ++++ .../test_model_capacity_validation_service.py | 88 +++++++++ .../services/test_model_management_service.py | 92 ++++++++++ test/sdk/core/models/test_capacity_budget.py | 68 +++---- test/sdk/monitor/test_monitoring.py | 7 +- 16 files changed, 672 insertions(+), 182 deletions(-) create mode 100644 backend/services/model_capacity_validation_service.py create mode 100644 frontend/lib/modelCapacityPayload.ts create mode 100644 test/backend/services/test_model_capacity_validation_service.py diff --git a/backend/agents/create_agent_info.py b/backend/agents/create_agent_info.py index ed359258f5..432f27584e 100644 --- a/backend/agents/create_agent_info.py +++ b/backend/agents/create_agent_info.py @@ -25,6 +25,7 @@ resolve_capacity, ) from nexent.core.models.capacity_budget import ( + BudgetResolverError, RequestBudgetOverrides, SafeInputBudgetCalculator, UncertaintyReserveBasisUnknown, @@ -80,7 +81,7 @@ NEXENT_SANDBOX_WORKSPACE_VOLUME, ) from consts.model import ToolParamsRequest -from consts.exceptions import ValidationError +from consts.exceptions import ModelCapacityConfigError, ValidationError logger = logging.getLogger("create_agent_info") logger.setLevel(logging.INFO) @@ -310,6 +311,28 @@ def _resolve_safe_input_budget( exc, ) return None + except BudgetResolverError as exc: + reason_by_type = { + "InvalidReservePolicy": "invalid_reserve_policy", + "RequestedOutputExceedsCapacity": "requested_output_exceeds_model", + "ReserveExceedsCapacity": "reserve_exceeds_capacity", + "NoSafeInputCapacity": "no_safe_input_capacity", + "SafeInputBudgetFingerprintMismatch": "budget_fingerprint_mismatch", + "CallerMaxTokensOverrideForbidden": "caller_output_override_forbidden", + "SafeInputBudgetCapacityMismatch": "capacity_snapshot_mismatch", + } + reason = reason_by_type.get(type(exc).__name__, "budget_resolution_failed") + logger.warning( + "W2 safe input budget rejected: tenant_id=%s model=%s reason=%s", + tenant_id, + capacity_snapshot.model_name, + reason, + ) + raise ModelCapacityConfigError( + f"capacity_config_invalid.{reason}", + "The selected model capacity cannot produce a safe Agent input budget. " + "Review the model context, input, output, and reserve settings.", + ) from exc logger.debug( "W2 safe input budget resolved: tenant_id=%s model=%s requested_output_tokens=%s " "soft_input_budget_tokens=%s hard_input_budget_tokens=%s fingerprint=%s warnings=%s", diff --git a/backend/apps/model_managment_app.py b/backend/apps/model_managment_app.py index ce448af6dd..3c9e900ca6 100644 --- a/backend/apps/model_managment_app.py +++ b/backend/apps/model_managment_app.py @@ -32,6 +32,7 @@ ManageProviderModelCreateRequest, ) from consts.const import CAPACITY_SUGGESTION_ENABLED +from consts.exceptions import ModelCapacityConfigError from fastapi import APIRouter, Header, Query, HTTPException from fastapi.responses import JSONResponse @@ -149,6 +150,9 @@ async def create_model(request: ModelRequest, authorization: Optional[str] = Hea return JSONResponse(status_code=HTTPStatus.OK, content={ "message": "Model created successfully" }) + except ModelCapacityConfigError as e: + logging.warning("Invalid model capacity configuration: %s", e.reason_code) + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) except ValueError as e: logging.error(f"Failed to create model: {str(e)}") raise HTTPException(status_code=HTTPStatus.CONFLICT, @@ -267,6 +271,9 @@ async def batch_create_models(request: BatchCreateModelsRequest, authorization: return JSONResponse(status_code=HTTPStatus.OK, content={ "message": "Batch create models successfully" }) + except ModelCapacityConfigError as e: + logging.warning("Invalid model capacity configuration: %s", e.reason_code) + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) except Exception as e: logging.error(f"Failed to batch create models: {str(e)}") raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, @@ -332,6 +339,9 @@ async def update_single_model( logging.error(f"Failed to update model: {str(e)}") raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) + except ModelCapacityConfigError as e: + logging.warning("Invalid model capacity configuration: %s", e.reason_code) + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) except ValueError as e: logging.error(f"Failed to update model: {str(e)}") raise HTTPException(status_code=HTTPStatus.CONFLICT, @@ -356,6 +366,9 @@ async def batch_update_models(request: List[dict], authorization: Optional[str] return JSONResponse(status_code=HTTPStatus.OK, content={ "message": "Batch update models successfully" }) + except ModelCapacityConfigError as e: + logging.warning("Invalid model capacity configuration: %s", e.reason_code) + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) except Exception as e: logging.error(f"Failed to batch update models: {str(e)}") raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, @@ -573,6 +586,9 @@ async def manage_create_model( "message": "Model created successfully", "data": {"tenant_id": request.tenant_id} }) + except ModelCapacityConfigError as e: + logging.warning("Invalid model capacity configuration: %s", e.reason_code) + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) except ValueError as e: logging.error(f"Failed to create model for tenant: {str(e)}") raise HTTPException(status_code=HTTPStatus.CONFLICT, detail=str(e)) @@ -622,6 +638,9 @@ async def manage_update_model( except LookupError as e: logging.error(f"Failed to update model for tenant: {str(e)}") raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) + except ModelCapacityConfigError as e: + logging.warning("Invalid model capacity configuration: %s", e.reason_code) + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) except ValueError as e: logging.error(f"Failed to update model for tenant: {str(e)}") raise HTTPException(status_code=HTTPStatus.CONFLICT, detail=str(e)) @@ -716,6 +735,9 @@ async def manage_batch_create_models( "models_count": len(request.models) } }) + except ModelCapacityConfigError as e: + logging.warning("Invalid model capacity configuration: %s", e.reason_code) + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) except Exception as e: logging.error(f"Failed to batch create models for tenant: {str(e)}") raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) diff --git a/backend/consts/exceptions.py b/backend/consts/exceptions.py index 4cd90be08c..12e0c48ec2 100644 --- a/backend/consts/exceptions.py +++ b/backend/consts/exceptions.py @@ -215,6 +215,15 @@ class ValidationError(Exception): pass +class ModelCapacityConfigError(ValidationError, ValueError): + """Raised when a model capacity contract is internally inconsistent.""" + + def __init__(self, reason_code: str, message: str, *, field: str = None): + self.reason_code = reason_code + self.field = field + super().__init__(f"{reason_code}: {message}") + + class TenantResourceLimitError(ValidationError, ValueError): """Raised when a platform or tenant hard resource limit is reached.""" diff --git a/backend/services/model_capacity_validation_service.py b/backend/services/model_capacity_validation_service.py new file mode 100644 index 0000000000..a786c79c83 --- /dev/null +++ b/backend/services/model_capacity_validation_service.py @@ -0,0 +1,169 @@ +"""Validation and read-only auditing for persisted model capacity contracts.""" + +from __future__ import annotations + +import math +from typing import Any, Mapping, Optional + +from consts.exceptions import ModelCapacityConfigError + + +CAPACITY_MODEL_TYPES = frozenset({"llm", "vlm", "vlm2", "vlm3"}) +CAPACITY_FIELDS = ( + "context_window_tokens", + "max_input_tokens", + "max_output_tokens", + "default_output_reserve_tokens", +) +DEFAULT_REQUESTED_OUTPUT_TOKENS = 4096 + + +def _fail(reason: str, message: str, *, field: Optional[str] = None) -> None: + raise ModelCapacityConfigError( + f"capacity_config_invalid.{reason}", message, field=field + ) + + +def merged_capacity_contract( + payload: Mapping[str, Any], existing: Optional[Mapping[str, Any]] = None +) -> dict[str, Any]: + """Return capacity-relevant values after applying a partial payload.""" + merged = dict(existing or {}) + merged.update(payload) + if merged.get("model_type") is None and existing is not None: + merged["model_type"] = existing.get("model_type") + return merged + + +def validate_capacity_contract( + payload: Mapping[str, Any], + *, + existing: Optional[Mapping[str, Any]] = None, +) -> dict[str, Any]: + """Validate one create or merged partial-update capacity contract. + + Unknown capacity is valid during the P0 migration. When a hard input + constraint is present, the resulting W2 estimated budget must be positive. + """ + contract = merged_capacity_contract(payload, existing) + if contract.get("model_type") not in CAPACITY_MODEL_TYPES: + return contract + + for field in CAPACITY_FIELDS: + value = contract.get(field) + if value is None: + continue + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + _fail( + "non_positive_or_non_integer", + f"{field} must be a positive integer", + field=field, + ) + + context = contract.get("context_window_tokens") + max_input = contract.get("max_input_tokens") + max_output = contract.get("max_output_tokens") + default_output = contract.get("default_output_reserve_tokens") + + if context is not None and max_output is not None and max_output >= context: + _fail( + "max_output_not_below_context", + "max_output_tokens must be lower than context_window_tokens", + field="max_output_tokens", + ) + if context is not None and max_input is not None and max_input > context: + _fail( + "max_input_exceeds_context", + "max_input_tokens must not exceed context_window_tokens", + field="max_input_tokens", + ) + if ( + default_output is not None + and max_output is not None + and default_output > max_output + ): + _fail( + "default_output_exceeds_max_output", + "default_output_reserve_tokens must not exceed max_output_tokens", + field="default_output_reserve_tokens", + ) + + if context is None and max_input is None: + return contract + + requested_output = default_output or DEFAULT_REQUESTED_OUTPUT_TOKENS + if max_output is not None and requested_output > max_output: + _fail( + "requested_output_exceeds_max_output", + "resolved requested output exceeds max_output_tokens", + field="default_output_reserve_tokens", + ) + + limits = [] + if max_input is not None: + limits.append(max_input) + if context is not None: + limits.append(context - requested_output) + provider_input_limit = min(limits) + if provider_input_limit <= 0: + _fail( + "non_positive_provider_input_limit", + "capacity values leave no provider input capacity", + ) + + uncertainty_reserve = math.ceil(provider_input_limit * 0.10) + if provider_input_limit - uncertainty_reserve <= 0: + _fail( + "non_positive_hard_input_budget", + "capacity values leave no safe input budget", + ) + return contract + + +def audit_capacity_record(record: Mapping[str, Any]) -> dict[str, Any]: + """Classify one row without mutating it or exposing credentials.""" + reasons: list[str] = [] + status = "valid" + try: + contract = validate_capacity_contract(record) + except ModelCapacityConfigError as exc: + contract = dict(record) + status = "invalid" + reasons.append(exc.reason_code) + + if contract.get("model_type") in CAPACITY_MODEL_TYPES: + context = contract.get("context_window_tokens") + max_input = contract.get("max_input_tokens") + max_output = contract.get("max_output_tokens") + source = contract.get("capacity_source") + if context is None and max_input is None: + status = "unknown" if status == "valid" else status + reasons.append("capacity_unknown") + if context == 32768 and max_output == 4096 and source == "operator": + status = "suspicious" if status == "valid" else status + reasons.append("operator_shaped_ui_default") + if ( + isinstance(context, int) + and isinstance(max_input, int) + and max_input > 0 + and max_input < math.ceil(context * 0.10) + ): + status = "suspicious" if status == "valid" else status + reasons.append("independent_input_unusually_small") + + return { + "model_id": record.get("model_id"), + "model_name": record.get("model_name"), + "model_type": record.get("model_type"), + "status": status, + "reasons": reasons, + } + + +def audit_capacity_records(records: list[Mapping[str, Any]]) -> dict[str, Any]: + """Return sanitized row classifications and aggregate counts.""" + rows = [audit_capacity_record(record) for record in records] + counts: dict[str, int] = {} + for row in rows: + counts[row["status"]] = counts.get(row["status"], 0) + 1 + return {"counts": counts, "rows": rows} diff --git a/backend/services/model_management_service.py b/backend/services/model_management_service.py index 0044db2b1b..347f9a1909 100644 --- a/backend/services/model_management_service.py +++ b/backend/services/model_management_service.py @@ -22,11 +22,17 @@ create_model_record, delete_model_record, get_model_by_name_factory, + get_model_by_model_id, get_models_by_display_name, get_model_records, get_models_by_tenant_factory_type, update_model_record ) +from consts.exceptions import ModelCapacityConfigError +from services.model_capacity_validation_service import ( + audit_capacity_records, + validate_capacity_contract, +) from services.model_provider_service import ( prepare_model_dict, merge_existing_model_attributes, @@ -46,6 +52,17 @@ CAPACITY_COVERAGE_MODEL_TYPES = {"llm", "vlm", "vlm2", "vlm3"} +def get_capacity_audit(tenant_id: str) -> Dict[str, Any]: + """Return a sanitized, read-only capacity audit for one tenant.""" + records = get_model_records(None, tenant_id) + scoped = [ + record + for record in records + if record.get("model_type") in CAPACITY_COVERAGE_MODEL_TYPES + ] + return audit_capacity_records(scoped) + + # OpenTelemetry counter for silent catalog-matcher failures during the # capacity-coverage scan. The matcher is called per row so we cannot raise -- # but the silent fallback to suggestion_available=False would hide a corrupt @@ -309,6 +326,7 @@ async def create_model_for_tenant(user_id: str, tenant_id: str, model_data: Dict ) _coerce_legacy_max_tokens_alias(model_data) + validate_capacity_contract(model_data) # Use NOT_DETECTED status as default model_data["connect_status"] = model_data.get( @@ -372,6 +390,8 @@ async def create_model_for_tenant(user_id: str, tenant_id: str, model_data: Dict create_model_record(model_data, user_id, tenant_id) logging.debug( f"Model {model_data['display_name']} created successfully") + except ModelCapacityConfigError: + raise except Exception as e: logging.error(f"Failed to create model: {str(e)}") raise Exception(f"Failed to create model: {str(e)}") @@ -433,6 +453,20 @@ async def batch_create_models_for_tenant(user_id: str, tenant_id: str, batch_pay for model in existing_model_list } + # Validate the full incoming synchronization set before the first + # delete or write, so an invalid row cannot leave a partially-mutated + # provider catalog. + for model in model_list: + candidate = dict(model) + candidate["model_type"] = model_type + candidate_repo, candidate_name_only = split_repo_name( + candidate.get("id", "") + ) + existing = existing_model_map.get( + add_repo_to_name(candidate_repo, candidate_name_only) + ) + validate_capacity_contract(candidate, existing=existing) + # Delete existing models not present. # The membership key MUST match how existing_model_map (a few lines # above) and the create-or-update branch (a few lines below) build @@ -506,8 +540,11 @@ async def batch_create_models_for_tenant(user_id: str, tenant_id: str, batch_pay model_url=model_url, model_api_key=model_api_key, ) + validate_capacity_contract(model_dict) create_model_record(model_dict, user_id, tenant_id) logging.debug(f"Model {model['id']} created successfully") + except ModelCapacityConfigError: + raise except Exception as e: logging.error(f"Failed to batch create models: {str(e)}") raise Exception(f"Failed to batch create models: {str(e)}") @@ -590,6 +627,8 @@ async def update_single_model_for_tenant( existing_model_type not in ("embedding", "multi_embedding"): model_data["max_tokens"] = model_data["max_output_tokens"] + validate_capacity_contract(model_data, existing=existing_models[0]) + if has_multi_embedding: # Update both embedding and multi_embedding records for model in existing_models: @@ -626,6 +665,13 @@ async def batch_update_models_for_tenant(user_id: str, tenant_id: str, model_lis # Check if model_id is a numeric string (primary key) if model_id_or_name and model_id_or_name.isdigit(): + current_model = get_model_by_model_id( + int(model_id_or_name), tenant_id=tenant_id + ) + if current_model is None: + logging.warning("Model not found: model_id=%s", model_id_or_name) + continue + validate_capacity_contract(model, existing=current_model) update_model_record(int(model_id_or_name), update_data, user_id, tenant_id) else: # Parse "model_repo/model_name" format from frontend's model_id field @@ -643,9 +689,13 @@ async def batch_update_models_for_tenant(user_id: str, tenant_id: str, model_lis logging.warning(f"Model not found: model_name={model_name}, model_repo={model_repo}, tenant_id={tenant_id}") continue + validate_capacity_contract(model, existing=model_record) + update_model_record(model_record["model_id"], update_data, user_id, tenant_id) logging.info("[DEBUG] Batch update models successfully") + except ModelCapacityConfigError: + raise except Exception as e: logging.error(f"Failed to batch update models: {str(e)}") raise Exception(f"Failed to batch update models: {str(e)}") diff --git a/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx b/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx index 36f19c9941..828291a8d8 100644 --- a/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx +++ b/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx @@ -22,6 +22,7 @@ import { import { useConfig } from "@/hooks/useConfig"; import { useCapacitySuggestion } from "@/hooks/useCapacitySuggestion"; import { getConnectivityMeta, ConnectivityStatusType } from "@/lib/utils"; +import { buildSnakeCapacityPayload } from "@/lib/modelCapacityPayload"; import { modelService } from "@/services/modelService"; import { ModelType, @@ -50,8 +51,6 @@ import { capacityFieldKeys, capacityFormFromSuggestion, capacityFormFromModel, - DEFAULT_CONTEXT_WINDOW_TOKENS, - DEFAULT_MAX_OUTPUT_TOKENS, emptyCapacityForm, ModelCapacityFields, ModelCapacityFormState, @@ -563,34 +562,8 @@ export const ModelAddDialog = ({ if (needsMaxTokens && !isValidMaxTokens(form.maxTokens)) { return false; } - // Per-row capacity gate for LLM/VLM batch import. After moving - // context_window/max_output to optional-with-defaults, the batch top - // defaults are guaranteed to be populated (capacityFormToSnakePayload - // substitutes DEFAULT_* on empty), so `effectiveContextWindow` and - // `effectiveMaxOutput` cannot be falsy in normal flow. Keeping the - // gate as defense-in-depth for future row sources (e.g., a catalog - // entry that pre-fills both row columns NULL and somehow bypasses - // the substitute) -- cheap to keep, costly to discover missing. - // - // We deliberately do NOT fall back to model.max_tokens here. Per the - // W1/W2 production plan the legacy column is unconditionally seeded - // with DEFAULT_LLM_MAX_TOKENS (4096) by the provider adapters, so - // treating it as a stand-in for max_output_tokens would mask missing - // W2 metadata and let any row pass validation. - if (supportsCapacityFields) { - const batchDefaults = capacityFormToSnakePayload(form); - for (const model of modelList) { - if (!selectedModelIds.has(model.id)) continue; - if (!rowSupportsCapacityFields(model)) continue; - const effectiveContextWindow = - model.context_window_tokens ?? batchDefaults.context_window_tokens; - const effectiveMaxOutput = - model.max_output_tokens ?? batchDefaults.max_output_tokens; - if (!effectiveContextWindow || !effectiveMaxOutput) { - return false; - } - } - } + // Capacity may remain unknown during the migration. Present values are + // validated above and again by the backend contract validator. // If provider is ModelEngine, require the ModelEngine URL as well. if (form.provider === "modelengine") { return ( @@ -803,41 +776,8 @@ export const ModelAddDialog = ({ // fallback in batch mode when the row itself has no capacity overrides AND // as the single-add wire payload. // - // `applyDefaults` controls whether empty context_window/max_output get the - // shared UI defaults substituted. Defaults true for write-time paths - // (single-add, batch fallback for missing rows, per-row gear). The Settings - // Modal's "no-op edit" path passes false so that opening the gear and - // saving without touching anything does not clobber an existing - // `context_window_tokens=128000` (from catalog) with the 32K default. - const capacityFormToSnakePayload = ( - capacity: ModelCapacityFormState, - options?: { applyDefaults?: boolean } - ) => { - const applyDefaults = options?.applyDefaults !== false; - const toInt = (raw: string) => { - const trimmed = raw.trim(); - if (!/^[1-9]\d*$/.test(trimmed)) return undefined; - return Number.parseInt(trimmed, 10); - }; - const tokenizer = capacity.tokenizerFamily.trim(); - const contextWindow = - toInt(capacity.contextWindowTokens) ?? - (applyDefaults ? DEFAULT_CONTEXT_WINDOW_TOKENS : undefined); - const maxOutput = - toInt(capacity.maxOutputTokens) ?? - (applyDefaults ? DEFAULT_MAX_OUTPUT_TOKENS : undefined); - const hasAny = capacityFieldKeys.some((k) => capacity[k].trim() !== ""); - return { - context_window_tokens: contextWindow, - max_input_tokens: toInt(capacity.maxInputTokens), - max_output_tokens: maxOutput, - default_output_reserve_tokens: toInt(capacity.defaultOutputReserveTokens), - tokenizer_family: tokenizer || undefined, - // When defaults substituted, the row carries a deterministic operator - // value. When not (Settings Modal no-op preserve mode), only mark - // operator-sourced if the operator actually typed something. - capacity_source: applyDefaults || hasAny ? "operator" : undefined, - }; + const capacityFormToSnakePayload = (capacity: ModelCapacityFormState) => { + return buildSnakeCapacityPayload(capacity); }; const buildBatchModelData = (model: any, modelType: ModelType) => { @@ -1063,11 +1003,8 @@ export const ModelAddDialog = ({ if (useCapacity) { // Persist capacity fields onto the row in their snake_case API shape so // buildBatchModelData can forward them without further translation. - // Defaults always apply at save: the gear modal preloads modelCapacity - // from the row's existing values (or batch defaults), so "no-op save" - // already carries non-empty inputs and goes through toInt unchanged. - // Only the row-NULL + empty-batch-default case lands DEFAULT_*, which - // is the desired "empty input means default" semantic. + // The gear modal preloads existing values. A fully blank form keeps the + // row unknown instead of manufacturing an operator-owned default. const payload = capacityFormToSnakePayload(modelCapacity); const hasAny = capacityFieldKeys.some( (k) => modelCapacity[k].trim() !== "" @@ -1903,9 +1840,7 @@ export const ModelAddDialog = ({ onChange={(field, value) => handleFormChange(field, value)} validationError={capacityValidationError} formMode="add" - // context_window/max_output are no longer required; an empty - // input lands the shared DEFAULT_* values at save time - // (see capacityFormToSnakePayload). + // Capacity fields are optional; blank input remains unknown. suggestion={ capacitySuggestionEnabled && !form.isBatchImport ? topSuggestion diff --git a/frontend/app/[locale]/models/components/model/ModelCapacityFields.tsx b/frontend/app/[locale]/models/components/model/ModelCapacityFields.tsx index 3c027c066f..0e9ac8caf6 100644 --- a/frontend/app/[locale]/models/components/model/ModelCapacityFields.tsx +++ b/frontend/app/[locale]/models/components/model/ModelCapacityFields.tsx @@ -2,6 +2,7 @@ import { Alert, AutoComplete, Button, Input, Space, Tag, Tooltip } from "antd"; import { useTranslation } from "react-i18next"; import type { CapacitySuggestion } from "@/types/modelConfig"; +import { buildCamelCapacityPayload } from "@/lib/modelCapacityPayload"; // W11 spec L767-790. Common token-count presets surfaced as a fallback // preset selector when no catalog suggestion populates the field. The @@ -37,12 +38,7 @@ const OUTPUT_RESERVE_PRESET_OPTIONS = [ ]; export type CapacitySource = - | "operator" - | "profile" - | "provider_candidate" - | "legacy" - | "unknown" - | string; + "operator" | "profile" | "provider_candidate" | "legacy" | "unknown" | string; export interface ModelCapacityFormState { contextWindowTokens: string; @@ -80,12 +76,9 @@ interface ModelCapacityFieldsProps { */ legacyMaxTokensCandidate?: number; /** - * When true (default), the context_window/max_output inputs render a gray - * placeholder showing the value the save handler would substitute if the - * field were left empty. Pass false in bulk-apply broadcast mode where - * empty means "do not broadcast this field"; showing a default-value hint - * there would be misleading. Tied to `buildCapacityPayload`'s - * `applyDefaults` option -- callers should pass matching booleans. + * When true (default), context_window/max_output inputs render gray + * estimated-value placeholders. Placeholders are never serialized. Pass + * false in bulk-apply mode where even a visual estimate is misleading. */ applyDefaultsOnEmpty?: boolean; /** Currently accepted suggestion, used to detect fuzzy canonicalization mismatch */ @@ -196,42 +189,8 @@ export const hasCapacityValues = (value: ModelCapacityFormState): boolean => export const buildCapacityPayload = ( value: ModelCapacityFormState, - options?: { applyDefaults?: boolean } -) => { - // applyDefaults=true (default): single-row write paths (add/edit single, - // batch top-defaults, batch per-row gear, per-row gear in delete dialog). - // When the user leaves context_window/max_output empty, substitute the - // defaults so the bare-capacity gates and badge see a populated row. - // applyDefaults=false: bulk-apply broadcast mode in ProviderConfigEditDialog - // ("修改配置"). Empty inputs mean "don't broadcast this value", preserving - // each row's existing capacity. We must NOT substitute defaults here. - const applyDefaults = options?.applyDefaults !== false; - const hasValues = hasCapacityValues(value); - if (!hasValues && !applyDefaults) return {}; - - const contextWindowTokens = - toOptionalPositiveInt(value.contextWindowTokens) ?? - (applyDefaults ? DEFAULT_CONTEXT_WINDOW_TOKENS : undefined); - const maxOutputTokens = - toOptionalPositiveInt(value.maxOutputTokens) ?? - (applyDefaults ? DEFAULT_MAX_OUTPUT_TOKENS : undefined); - - return { - contextWindowTokens, - maxInputTokens: toOptionalPositiveInt(value.maxInputTokens), - maxOutputTokens, - // Mirror max_output_tokens into the deprecated max_tokens column so - // legacy readers stay consistent. W1 step 4 makes them aliases server-side; - // keeping both columns populated avoids a brittle dependency on the - // Pydantic validator firing on every code path. - ...(maxOutputTokens !== undefined ? { maxTokens: maxOutputTokens } : {}), - defaultOutputReserveTokens: toOptionalPositiveInt( - value.defaultOutputReserveTokens - ), - tokenizerFamily: value.tokenizerFamily.trim() || undefined, - capacitySource: "operator", - }; -}; + _options?: { applyDefaults?: boolean } +) => buildCamelCapacityPayload(value); export const capacityFormFromModel = (model: { contextWindowTokens?: number; @@ -316,10 +275,8 @@ export const ModelCapacityFields = ({ const requiredSet = new Set(requiredFields); const isAddMode = formMode === "add"; - // Per-field default-value hints. Rendered as native input placeholders - // (gray text) only when the parent opts into default substitution. The - // gray text is purely a UX nudge -- the form state stays "" until the - // user types, and `buildCapacityPayload` does the substitution at save. + // Per-field estimates rendered as native input placeholders. They are + // never serialized unless the operator explicitly enters or selects them. const defaultPlaceholders: Partial< Record > = applyDefaultsOnEmpty diff --git a/frontend/app/[locale]/models/components/model/ModelEditDialog.tsx b/frontend/app/[locale]/models/components/model/ModelEditDialog.tsx index 1dd3f4a67c..8e565a10f8 100644 --- a/frontend/app/[locale]/models/components/model/ModelEditDialog.tsx +++ b/frontend/app/[locale]/models/components/model/ModelEditDialog.tsx @@ -686,8 +686,7 @@ export const ModelEditDialog = ({ validationError={capacityValidationError} capacitySource={model.capacitySource} capabilityProfileVersion={model.capabilityProfileVersion} - // context_window/max_output no longer required; empty input - // lands DEFAULT_* via buildCapacityPayload at save time. + // Capacity fields are optional; blank input remains unknown. suggestion={capacitySuggestionEnabled ? capacitySuggestion : null} suggestionLoading={checkingCapacitySuggestion} onUseSuggestion={() => @@ -991,7 +990,7 @@ export const ProviderConfigEditDialog = ({ const needsLegacyMaxTokens = isRerankModel || isVoiceModel; // Neither mode marks any field required: // - per-row mode (supportsCapacityFields): context_window/max_output are - // optional and get DEFAULT_* substituted at save by buildCapacityPayload + // optional and blank values remain unknown // - bulk-apply mode (supportsBulkCapacity): optional broadcast -- "fill // to override; leave empty to keep each row's current value" const capacityRequiredFields: Array = []; @@ -1067,14 +1066,8 @@ export const ProviderConfigEditDialog = ({ : undefined, } : {}), - // Both per-model and bulk-apply modes write capacity via - // buildCapacityPayload. Per-model (supportsCapacityFields) opts - // into default substitution: empty context_window/max_output land - // DEFAULT_CONTEXT_WINDOW_TOKENS / DEFAULT_MAX_OUTPUT_TOKENS at the - // wire. Bulk-apply (supportsBulkCapacity) passes applyDefaults=false - // so empty fields stay omitted ("don't broadcast this value"), and - // an apiKey-only bulk edit doesn't accidentally null out per-row - // capacity by writing 32K/4K across N rows. + // Both modes omit blank capacity fields, preserving unknown rows and + // preventing API-key-only edits from rewriting capacity facts. ...(supportsCapacityFields ? buildCapacityPayload(capacityForm) : supportsBulkCapacity @@ -1162,7 +1155,7 @@ export const ProviderConfigEditDialog = ({ validationError={capacityValidationError} capacitySource={initialCapacity?.capacitySource} capabilityProfileVersion={initialCapacity?.capabilityProfileVersion} - // context_window/max_output optional; DEFAULT_* substitute at save. + // Capacity fields are optional; blank values remain unknown. legacyMaxTokensCandidate={ initialCapacity?.contextWindowTokens && initialCapacity?.maxOutputTokens diff --git a/frontend/lib/modelCapacityPayload.ts b/frontend/lib/modelCapacityPayload.ts new file mode 100644 index 0000000000..d3a9fa70d9 --- /dev/null +++ b/frontend/lib/modelCapacityPayload.ts @@ -0,0 +1,54 @@ +export interface CapacityFormValue { + contextWindowTokens: string; + maxInputTokens: string; + maxOutputTokens: string; + defaultOutputReserveTokens: string; + tokenizerFamily: string; +} + +const CAPACITY_KEYS: Array = [ + "contextWindowTokens", + "maxInputTokens", + "maxOutputTokens", + "defaultOutputReserveTokens", + "tokenizerFamily", +]; + +const toOptionalPositiveInt = (raw: string): number | undefined => { + const trimmed = raw.trim(); + if (!/^[1-9]\d*$/.test(trimmed)) return undefined; + return Number.parseInt(trimmed, 10); +}; + +export const hasCapacityInput = (value: CapacityFormValue): boolean => + CAPACITY_KEYS.some((key) => value[key].trim() !== ""); + +export const buildCamelCapacityPayload = (value: CapacityFormValue) => { + if (!hasCapacityInput(value)) return {}; + const maxOutputTokens = toOptionalPositiveInt(value.maxOutputTokens); + return { + contextWindowTokens: toOptionalPositiveInt(value.contextWindowTokens), + maxInputTokens: toOptionalPositiveInt(value.maxInputTokens), + maxOutputTokens, + ...(maxOutputTokens !== undefined ? { maxTokens: maxOutputTokens } : {}), + defaultOutputReserveTokens: toOptionalPositiveInt( + value.defaultOutputReserveTokens + ), + tokenizerFamily: value.tokenizerFamily.trim() || undefined, + capacitySource: "operator" as const, + }; +}; + +export const buildSnakeCapacityPayload = (value: CapacityFormValue) => { + if (!hasCapacityInput(value)) return {}; + return { + context_window_tokens: toOptionalPositiveInt(value.contextWindowTokens), + max_input_tokens: toOptionalPositiveInt(value.maxInputTokens), + max_output_tokens: toOptionalPositiveInt(value.maxOutputTokens), + default_output_reserve_tokens: toOptionalPositiveInt( + value.defaultOutputReserveTokens + ), + tokenizer_family: value.tokenizerFamily.trim() || undefined, + capacity_source: "operator" as const, + }; +}; diff --git a/sdk/nexent/core/models/capacity_budget.py b/sdk/nexent/core/models/capacity_budget.py index 5eb1a0d02a..0e3f51b931 100644 --- a/sdk/nexent/core/models/capacity_budget.py +++ b/sdk/nexent/core/models/capacity_budget.py @@ -10,13 +10,13 @@ from .capacity_resolver import ModelCapacitySnapshot -W2_RESOLVER_VERSION = "1.0.0" +W2_RESOLVER_VERSION = "1.1.0" W2_FINGERPRINT_SCHEMA_VERSION = 1 OutputReserveSource = Literal["model_default", "agent", "request"] UncertaintyReserveBasis = Literal[ - "context_window_10pct", "approved_profile", "none" + "provider_input_limit_10pct", "approved_profile", "none" ] SoftLimitRatioSource = Literal["code_default", "tenant_config"] BudgetFieldSource = Literal[ @@ -267,7 +267,11 @@ def calculate_safe_input_budget( ) uncertainty_reserve_tokens, uncertainty_reserve_basis, warnings = ( - self._uncertainty_reserve(capacity_snapshot, reserve_policy) + self._uncertainty_reserve( + capacity_snapshot, + reserve_policy, + provider_input_limit=provider_input_limit, + ) ) if uncertainty_reserve_tokens > provider_input_limit: @@ -360,6 +364,8 @@ def _uncertainty_reserve( self, capacity_snapshot: ModelCapacitySnapshot, reserve_policy: CapacityReservePolicy, + *, + provider_input_limit: int, ) -> tuple[int, UncertaintyReserveBasis, list[str]]: unknown_required_behavior = self._UNKNOWN_CAPABILITIES_REQUIRING_RESERVE.intersection( capacity_snapshot.unknown_capabilities @@ -375,11 +381,9 @@ def _uncertainty_reserve( if not unknown_required_behavior: return 0, "none", [] - if capacity_snapshot.context_window_tokens is None: - raise UncertaintyReserveBasisUnknown( - "context_window_tokens is required for the unified 10 percent " - "uncertainty reserve" - ) - - reserve = math.ceil(capacity_snapshot.context_window_tokens * 0.10) - return reserve, "context_window_10pct", ["uncertainty_reserve_active"] + reserve = math.ceil(provider_input_limit * 0.10) + return ( + reserve, + "provider_input_limit_10pct", + ["uncertainty_reserve_active"], + ) diff --git a/test/backend/agents/test_create_agent_info.py b/test/backend/agents/test_create_agent_info.py index 84d75bd5f8..7888e9283b 100644 --- a/test/backend/agents/test_create_agent_info.py +++ b/test/backend/agents/test_create_agent_info.py @@ -45,6 +45,13 @@ class ToolExecutionException(Exception): pass +class MockModelCapacityConfigError(ValidationError): + def __init__(self, reason_code, message, *, field=None): + self.reason_code = reason_code + self.field = field + super().__init__(f"{reason_code}: {message}") + + consts_model_module = types.ModuleType("consts.model") consts_model_module.HistoryItem = HistoryItem @@ -71,6 +78,7 @@ class MockToolParamsRequest(BaseModel): # Mock consts.exceptions module with ValidationError consts_exceptions_module = types.ModuleType("consts.exceptions") consts_exceptions_module.ValidationError = ValidationError +consts_exceptions_module.ModelCapacityConfigError = MockModelCapacityConfigError consts_exceptions_module.MCPConnectionError = MCPConnectionError consts_exceptions_module.NotFoundException = NotFoundException consts_exceptions_module.ToolExecutionException = ToolExecutionException @@ -383,7 +391,11 @@ def calculate_safe_input_budget( ) -class MockUncertaintyReserveBasisUnknown(Exception): +class MockBudgetResolverError(Exception): + """Mock W2 base exception.""" + + +class MockUncertaintyReserveBasisUnknown(MockBudgetResolverError): """Mock W2 exception raised when context_window_tokens is missing.""" @@ -396,6 +408,7 @@ class MockUncertaintyReserveBasisUnknown(Exception): ) sys.modules['nexent.core.models.capacity_budget'] = _create_stub_module( "nexent.core.models.capacity_budget", + BudgetResolverError=MockBudgetResolverError, RequestBudgetOverrides=MockRequestBudgetOverrides, SafeInputBudgetCalculator=MockSafeInputBudgetCalculator, UncertaintyReserveBasisUnknown=MockUncertaintyReserveBasisUnknown, @@ -5140,6 +5153,43 @@ def test_resolve_safe_input_budget_returns_none_for_uncertain_basis(self): assert result is None + @pytest.mark.parametrize( + ("exception_name", "reason"), + [ + ("InvalidReservePolicy", "invalid_reserve_policy"), + ("RequestedOutputExceedsCapacity", "requested_output_exceeds_model"), + ("ReserveExceedsCapacity", "reserve_exceeds_capacity"), + ("NoSafeInputCapacity", "no_safe_input_capacity"), + ("SafeInputBudgetFingerprintMismatch", "budget_fingerprint_mismatch"), + ("CallerMaxTokensOverrideForbidden", "caller_output_override_forbidden"), + ("SafeInputBudgetCapacityMismatch", "capacity_snapshot_mismatch"), + ("FutureBudgetError", "budget_resolution_failed"), + ], + ) + def test_ac_007_resolve_safe_input_budget_maps_budget_error( + self, exception_name, reason + ): + capacity = MockModelCapacitySnapshot(model_name="invalid-model") + calculator = MagicMock() + exception_type = type(exception_name, (MockBudgetResolverError,), {}) + calculator.calculate_safe_input_budget.side_effect = exception_type("internal details") + with patch( + "backend.agents.create_agent_info.SafeInputBudgetCalculator", + return_value=calculator, + ): + with pytest.raises( + create_agent_info_module.ModelCapacityConfigError, + match=f"capacity_config_invalid.{reason}", + ) as exc_info: + _resolve_safe_input_budget( + capacity_snapshot=capacity, + tenant_id="tenant-1", + agent_requested_output_tokens=None, + request_requested_output_tokens=None, + ) + + assert "internal details" not in str(exc_info.value) + def test_inject_plan_tools_adds_tools_once(self): tools = [] mock_tool_config.reset_mock() diff --git a/test/backend/app/test_model_managment_app.py b/test/backend/app/test_model_managment_app.py index 619586da3f..dde997cba7 100644 --- a/test/backend/app/test_model_managment_app.py +++ b/test/backend/app/test_model_managment_app.py @@ -351,6 +351,39 @@ async def _create(*args, **kwargs): mock_record.assert_not_called() +@pytest.mark.asyncio +async def test_ac_004_create_model_maps_capacity_error_to_bad_request( + client, auth_header, user_credentials, sample_model_data, mocker +): + from consts.exceptions import ModelCapacityConfigError + + mocker.patch( + "backend.apps.model_managment_app.get_current_user_id", + return_value=user_credentials, + ) + + async def _reject(*args, **kwargs): + raise ModelCapacityConfigError( + "capacity_config_invalid.max_output_not_below_context", + "max output must be lower than context", + ) + + mocker.patch( + "backend.apps.model_managment_app.create_model_for_tenant", + side_effect=_reject, + ) + + response = client.post( + "/model/create", json=sample_model_data, headers=auth_header + ) + + assert response.status_code == HTTPStatus.BAD_REQUEST + assert ( + "capacity_config_invalid.max_output_not_below_context" + in response.json()["detail"] + ) + + @pytest.mark.asyncio async def test_create_model_conflict(client, auth_header, user_credentials, sample_model_data, mocker): """Test model creation with name conflict.""" diff --git a/test/backend/services/test_model_capacity_validation_service.py b/test/backend/services/test_model_capacity_validation_service.py new file mode 100644 index 0000000000..ffab0263e2 --- /dev/null +++ b/test/backend/services/test_model_capacity_validation_service.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import pytest + +from consts.exceptions import ModelCapacityConfigError +from services.model_capacity_validation_service import ( + audit_capacity_records, + validate_capacity_contract, +) + + +def _llm(**overrides): + row = { + "model_id": 1, + "model_name": "test-model", + "model_type": "llm", + "context_window_tokens": 32_768, + "max_input_tokens": None, + "max_output_tokens": 4_096, + "default_output_reserve_tokens": 1_024, + "capacity_source": "operator", + } + row.update(overrides) + return row + + +@pytest.mark.parametrize( + ("overrides", "reason"), + [ + ({"context_window_tokens": 0}, "non_positive_or_non_integer"), + ({"max_output_tokens": 32_768}, "max_output_not_below_context"), + ({"max_input_tokens": 40_000}, "max_input_exceeds_context"), + ( + {"default_output_reserve_tokens": 8_192}, + "default_output_exceeds_max_output", + ), + ], +) +def test_ac_004_invalid_contracts_return_stable_reasons(overrides, reason): + with pytest.raises(ModelCapacityConfigError) as exc_info: + validate_capacity_contract(_llm(**overrides)) + + assert exc_info.value.reason_code == f"capacity_config_invalid.{reason}" + + +def test_ac_004_partial_update_validates_merged_effective_row(): + with pytest.raises(ModelCapacityConfigError) as exc_info: + validate_capacity_contract( + {"max_output_tokens": 40_000}, existing=_llm() + ) + + assert exc_info.value.reason_code.endswith("max_output_not_below_context") + + +def test_ac_005_unknown_capacity_is_preserved_as_valid_migration_state(): + contract = validate_capacity_contract( + _llm( + context_window_tokens=None, + max_input_tokens=None, + max_output_tokens=None, + default_output_reserve_tokens=None, + capacity_source=None, + ) + ) + + assert contract["context_window_tokens"] is None + assert contract["capacity_source"] is None + + +def test_ac_008_audit_is_sanitized_and_does_not_mutate_rows(): + records = [ + _llm(), + _llm(model_id=2, context_window_tokens=None, max_output_tokens=None), + _llm(model_id=3, default_output_reserve_tokens=None), + _llm(model_id=4, max_output_tokens=32_768), + ] + original = [dict(row) for row in records] + + report = audit_capacity_records(records) + + assert records == original + assert report["counts"] == { + "suspicious": 2, + "unknown": 1, + "invalid": 1, + } + assert all("api_key" not in row for row in report["rows"]) + assert report["rows"][2]["reasons"] == ["operator_shaped_ui_default"] diff --git a/test/backend/services/test_model_management_service.py b/test/backend/services/test_model_management_service.py index 1f1e2e1d8b..1894d19743 100644 --- a/test/backend/services/test_model_management_service.py +++ b/test/backend/services/test_model_management_service.py @@ -2328,3 +2328,95 @@ async def test_cmt_embedding_fallback_reinfers_model_factory(): second_call_url = mock_infer.call_args_list[1][0][1] assert second_call_url == "https://dashscope.aliyuncs.com/v1/embeddings" + +@pytest.mark.asyncio +async def test_ac_004_create_rejects_invalid_capacity_before_persistence(): + svc = import_svc() + payload = { + "model_name": "bad-llm", + "display_name": "Bad LLM", + "base_url": "https://example.test/v1", + "model_type": "llm", + "api_key": "secret", + "context_window_tokens": 4096, + "max_output_tokens": 4096, + } + + with mock.patch.object(svc, "get_models_by_display_name", return_value=[]), \ + mock.patch.object(svc, "create_model_record") as create_record: + with pytest.raises(Exception, match="max_output_not_below_context"): + await svc.create_model_for_tenant("u1", "t1", payload) + + create_record.assert_not_called() + + +@pytest.mark.asyncio +async def test_ac_004_partial_update_rejects_invalid_merged_capacity(): + svc = import_svc() + existing = { + "model_id": 7, + "model_name": "llm", + "display_name": "LLM", + "model_type": "llm", + "context_window_tokens": 8192, + "max_output_tokens": 2048, + } + + with mock.patch.object(svc, "get_models_by_display_name", return_value=[existing]), \ + mock.patch.object(svc, "update_model_record") as update_record: + with pytest.raises(Exception, match="max_output_not_below_context"): + await svc.update_single_model_for_tenant( + "u1", "t1", "LLM", {"max_output_tokens": 8192} + ) + + update_record.assert_not_called() + + +@pytest.mark.asyncio +async def test_ac_004_batch_rejects_all_rows_before_delete_or_write(): + svc = import_svc() + payload = { + "provider": "dashscope", + "type": "llm", + "api_key": "secret", + "models": [ + { + "id": "bad-llm", + "context_window_tokens": 8192, + "max_output_tokens": 8192, + "capacity_source": "operator", + } + ], + } + + with mock.patch.object(svc, "get_models_by_tenant_factory_type", return_value=[]), \ + mock.patch.object(svc, "delete_model_record") as delete_record, \ + mock.patch.object(svc, "create_model_record") as create_record, \ + mock.patch.object(svc, "update_model_record") as update_record: + with pytest.raises(Exception, match="max_output_not_below_context"): + await svc.batch_create_models_for_tenant("u1", "t1", payload) + + delete_record.assert_not_called() + create_record.assert_not_called() + update_record.assert_not_called() + + +def test_ac_008_capacity_audit_entry_point_is_read_only_and_scoped(): + svc = import_svc() + records = [ + { + "model_id": 1, + "model_name": "unknown-llm", + "model_type": "llm", + "api_key": "must-not-leak", + }, + {"model_id": 2, "model_name": "embed", "model_type": "embedding"}, + ] + + with mock.patch.object(svc, "get_model_records", return_value=records) as read: + report = svc.get_capacity_audit("tenant-1") + + read.assert_called_once_with(None, "tenant-1") + assert report["counts"] == {"unknown": 1} + assert report["rows"][0]["model_id"] == 1 + assert "api_key" not in report["rows"][0] diff --git a/test/sdk/core/models/test_capacity_budget.py b/test/sdk/core/models/test_capacity_budget.py index 7f55be0970..350746a1ee 100644 --- a/test/sdk/core/models/test_capacity_budget.py +++ b/test/sdk/core/models/test_capacity_budget.py @@ -61,8 +61,8 @@ def _fingerprint(**overrides) -> str: "model_name": "gpt-4o", "requested_output_tokens": 4096, "output_reserve_source": "model_default", - "uncertainty_reserve_tokens": 12800, - "uncertainty_reserve_basis": "context_window_10pct", + "uncertainty_reserve_tokens": 12391, + "uncertainty_reserve_basis": "provider_input_limit_10pct", "approved_profile_reserve_tokens": None, "soft_limit_ratio": 0.8, "soft_limit_ratio_source": "code_default", @@ -130,7 +130,7 @@ def _capacity_snapshot(**overrides) -> ModelCapacitySnapshot: return ModelCapacitySnapshot(**payload) -def test_calculator_combined_window_uses_10_percent_uncertainty_reserve(): +def test_calculator_combined_window_uses_effective_limit_for_uncertainty_reserve(): calculator = SafeInputBudgetCalculator() snap = calculator.calculate_safe_input_budget( @@ -139,10 +139,10 @@ def test_calculator_combined_window_uses_10_percent_uncertainty_reserve(): ) assert snap.provider_input_limit_tokens == 128_000 - 4_096 - assert snap.uncertainty_reserve_tokens == 12_800 - assert snap.uncertainty_reserve_basis == "context_window_10pct" - assert snap.hard_input_budget_tokens == 111_104 - assert snap.soft_input_budget_tokens == 88_883 + assert snap.uncertainty_reserve_tokens == 12_391 + assert snap.uncertainty_reserve_basis == "provider_input_limit_10pct" + assert snap.hard_input_budget_tokens == 111_513 + assert snap.soft_input_budget_tokens == 89_210 assert snap.requested_output_tokens == 4_096 assert snap.output_reserve_source == "model_default" assert snap.w1_fingerprint == "w1fingerprint" @@ -162,7 +162,7 @@ def test_calculator_recomputes_provider_limit_for_request_override(): assert snap.requested_output_tokens == 8_192 assert snap.output_reserve_source == "request" assert snap.provider_input_limit_tokens == 128_000 - 8_192 - assert snap.hard_input_budget_tokens == (128_000 - 8_192) - 12_800 + assert snap.hard_input_budget_tokens == (128_000 - 8_192) - 11_981 def test_calculator_rejects_request_override_that_lowers_reserve(): @@ -209,19 +209,21 @@ def test_calculator_uses_approved_profile_reserve_for_separate_input_limit(): assert snap.hard_input_budget_tokens == 32_256 -def test_calculator_requires_context_window_for_10_percent_reserve(): +def test_calculator_uses_independent_input_limit_for_10_percent_reserve(): calculator = SafeInputBudgetCalculator() - with pytest.raises(UncertaintyReserveBasisUnknown): - calculator.calculate_safe_input_budget( - capacity_snapshot=_capacity_snapshot( - context_window_tokens=None, - max_input_tokens=32_768, - provider_input_limit_tokens=32_768, - unknown_capabilities=["tokenizer"], - ), - reserve_policy=CapacityReservePolicy(), - ) + snap = calculator.calculate_safe_input_budget( + capacity_snapshot=_capacity_snapshot( + context_window_tokens=None, + max_input_tokens=32_768, + provider_input_limit_tokens=32_768, + unknown_capabilities=["tokenizer"], + ), + reserve_policy=CapacityReservePolicy(), + ) + + assert snap.uncertainty_reserve_tokens == 3_277 + assert snap.hard_input_budget_tokens == 29_491 def test_calculator_rejects_requested_output_above_capacity(): @@ -235,19 +237,25 @@ def test_calculator_rejects_requested_output_above_capacity(): ) -def test_calculator_rejects_reserve_larger_than_provider_limit(): +def test_ac_001_small_independent_limit_uses_compatible_reserve(): calculator = SafeInputBudgetCalculator() - with pytest.raises(ReserveExceedsCapacity): - calculator.calculate_safe_input_budget( - capacity_snapshot=_capacity_snapshot( - context_window_tokens=10_000, - max_input_tokens=100, - provider_input_limit_tokens=100, - unknown_capabilities=["tokenizer"], - ), - reserve_policy=CapacityReservePolicy(), - ) + snap = calculator.calculate_safe_input_budget( + capacity_snapshot=_capacity_snapshot( + context_window_tokens=262_144, + max_input_tokens=16_384, + max_output_tokens=65_536, + requested_output_tokens=8_192, + provider_input_limit_tokens=16_384, + unknown_capabilities=["tokenizer"], + ), + reserve_policy=CapacityReservePolicy(), + ) + + assert snap.provider_input_limit_tokens == 16_384 + assert snap.uncertainty_reserve_tokens == 1_639 + assert snap.uncertainty_reserve_basis == "provider_input_limit_10pct" + assert snap.hard_input_budget_tokens == 14_745 def test_calculator_rejects_no_safe_input_capacity_after_output_reserve(): diff --git a/test/sdk/monitor/test_monitoring.py b/test/sdk/monitor/test_monitoring.py index b059ae8ff1..4e7e7d5c95 100644 --- a/test/sdk/monitor/test_monitoring.py +++ b/test/sdk/monitor/test_monitoring.py @@ -1633,7 +1633,7 @@ def test_safe_input_budget_snapshot_fields_are_enqueued(self): "output_reserve_source": "model_default", "provider_input_limit_tokens": 127000, "uncertainty_reserve_tokens": 12800, - "uncertainty_reserve_basis": "context_window_10pct", + "uncertainty_reserve_basis": "provider_input_limit_10pct", "soft_limit_ratio": 0.8, "soft_input_budget_tokens": 91360, "hard_input_budget_tokens": 114200, @@ -1653,7 +1653,10 @@ def test_safe_input_budget_snapshot_fields_are_enqueued(self): assert record["budget_output_reserve_source"] == "model_default" assert record["budget_provider_input_limit_tokens"] == 127000 assert record["budget_uncertainty_reserve_tokens"] == 12800 - assert record["budget_uncertainty_reserve_basis"] == "context_window_10pct" + assert ( + record["budget_uncertainty_reserve_basis"] + == "provider_input_limit_10pct" + ) assert record["budget_soft_limit_ratio"] == 0.8 assert record["budget_soft_input_budget_tokens"] == 91360 assert record["budget_hard_input_budget_tokens"] == 114200 From 798e3c7778f037994a2f9a5754efbfd0de95733b Mon Sep 17 00:00:00 2001 From: Jinglong Wang Date: Sat, 22 Aug 2026 18:59:50 +0800 Subject: [PATCH 02/10] fix(model): align capacity UI boundary validation --- .../[locale]/models/components/model/ModelAddDialog.tsx | 2 +- .../models/components/model/ModelCapacityFields.tsx | 8 ++++---- .../[locale]/models/components/model/ModelEditDialog.tsx | 2 +- frontend/public/locales/en/common.json | 2 +- frontend/public/locales/zh/common.json | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx b/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx index 828291a8d8..8c67476cfc 100644 --- a/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx +++ b/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx @@ -1811,7 +1811,7 @@ export const ModelAddDialog = ({ )} diff --git a/frontend/app/[locale]/models/components/model/ModelCapacityFields.tsx b/frontend/app/[locale]/models/components/model/ModelCapacityFields.tsx index 0e9ac8caf6..da25b2d22f 100644 --- a/frontend/app/[locale]/models/components/model/ModelCapacityFields.tsx +++ b/frontend/app/[locale]/models/components/model/ModelCapacityFields.tsx @@ -160,7 +160,7 @@ export const validateCapacityForm = ( if ( contextWindowTokens !== undefined && maxOutputTokens !== undefined && - maxOutputTokens > contextWindowTokens + maxOutputTokens >= contextWindowTokens ) { return "model.dialog.capacity.error.outputExceedsWindow"; } @@ -371,7 +371,7 @@ export const ModelCapacityFields = ({ + )} ); diff --git a/frontend/app/[locale]/models/components/model/ModelEditDialog.tsx b/frontend/app/[locale]/models/components/model/ModelEditDialog.tsx index 8e565a10f8..6e48069ddf 100644 --- a/frontend/app/[locale]/models/components/model/ModelEditDialog.tsx +++ b/frontend/app/[locale]/models/components/model/ModelEditDialog.tsx @@ -1173,7 +1173,7 @@ export const ProviderConfigEditDialog = ({ Date: Mon, 24 Aug 2026 11:07:20 +0800 Subject: [PATCH 03/10] feat(models): govern context capacity metadata --- backend/apps/model_managment_app.py | 285 ++++++++- backend/consts/capability_profiles.py | 35 +- backend/consts/model.py | 39 +- backend/database/db_models.py | 10 + backend/database/model_management_db.py | 49 ++ .../model_capacity_governance_service.py | 319 ++++++++++ .../model_capacity_suggestion_service.py | 59 +- backend/services/model_management_service.py | 545 ++++++++++++++---- .../services/model_profile_match_service.py | 175 ++++++ .../model_token_count_probe_service.py | 363 ++++++++++++ ....5.0_0824_context_budget_p1_governance.sql | 35 ++ .../components/model/ModelAddDialog.tsx | 39 +- .../components/model/ModelCapacityFields.tsx | 97 +++- .../components/model/ModelDeleteDialog.tsx | 2 +- .../components/model/ModelEditDialog.tsx | 114 +++- frontend/lib/modelCapacityPayload.ts | 4 +- frontend/services/api.ts | 6 + frontend/services/modelService.ts | 219 +++++++ frontend/types/modelConfig.ts | 99 +++- sdk/nexent/core/models/capacity_resolver.py | 71 +++ sdk/nexent/core/models/model_identity.py | 232 ++++++++ sdk/nexent/core/models/tokenizer_registry.py | 183 +++++- test/backend/app/test_model_managment_app.py | 288 ++++++--- .../database/test_model_managment_db.py | 49 ++ .../test_model_capacity_governance_service.py | 271 +++++++++ .../test_model_capacity_suggestion_service.py | 77 ++- .../services/test_model_management_service.py | 212 +++++-- .../test_model_profile_match_service.py | 49 ++ .../test_model_token_count_probe_service.py | 257 +++++++++ .../test_context_budget_p1_migration.py | 22 + .../test_capability_profile_governance.py | 78 +++ test/sdk/core/models/test_model_identity.py | 59 ++ .../core/models/test_tokenizer_governance.py | 144 +++++ 33 files changed, 4206 insertions(+), 280 deletions(-) create mode 100644 backend/services/model_capacity_governance_service.py create mode 100644 backend/services/model_profile_match_service.py create mode 100644 backend/services/model_token_count_probe_service.py create mode 100644 deploy/sql/migrations/v2.5.0_0824_context_budget_p1_governance.sql create mode 100644 sdk/nexent/core/models/model_identity.py create mode 100644 test/backend/services/test_model_capacity_governance_service.py create mode 100644 test/backend/services/test_model_profile_match_service.py create mode 100644 test/backend/services/test_model_token_count_probe_service.py create mode 100644 test/deploy/test_context_budget_p1_migration.py create mode 100644 test/sdk/core/models/test_capability_profile_governance.py create mode 100644 test/sdk/core/models/test_model_identity.py create mode 100644 test/sdk/core/models/test_tokenizer_governance.py diff --git a/backend/apps/model_managment_app.py b/backend/apps/model_managment_app.py index 3c9e900ca6..93477c2df7 100644 --- a/backend/apps/model_managment_app.py +++ b/backend/apps/model_managment_app.py @@ -16,23 +16,29 @@ from consts.model import ( BatchCreateModelsRequest, + CapacityAdoptRequest, + CapacityAdoptionPreviewRequest, CapacitySuggestionFields, ModelRequest, ModelCapacitySuggestionRequest, ModelCapacitySuggestionResponse, ProviderModelRequest, + TokenCountProbeRequest, ManageTenantModelListRequest, ManageTenantModelListResponse, ManageTenantModelCreateRequest, ManageTenantModelUpdateRequest, ManageTenantModelDeleteRequest, ManageTenantModelHealthcheckRequest, + ManageCapacityAdoptRequest, + ManageCapacityAdoptionPreviewRequest, + ManageTokenCountProbeRequest, ManageBatchCreateModelsRequest, ManageProviderModelListRequest, ManageProviderModelCreateRequest, ) from consts.const import CAPACITY_SUGGESTION_ENABLED -from consts.exceptions import ModelCapacityConfigError +from consts.exceptions import ModelCapacityConfigError, UnauthorizedError from fastapi import APIRouter, Header, Query, HTTPException from fastapi.responses import JSONResponse @@ -44,6 +50,10 @@ verify_model_config_connectivity, ) from services.model_capacity_suggestion_service import suggest_capacity +from services.model_profile_match_service import ( + resolve_model_profiles, + serialize_profile_match, +) from services.model_management_service import ( create_model_for_tenant, create_provider_models_for_tenant, @@ -58,15 +68,34 @@ get_capacity_coverage, pop_capacity_accept_signal, _record_capacity_suggestion_accept, + adopt_capacity_for_tenant, + preview_capacity_adoption_for_tenant, + probe_token_count_for_tenant, ) from utils.auth_utils import get_current_user_id +from database.user_tenant_db import get_user_tenant_by_user_id router = APIRouter(prefix="/model") logger = logging.getLogger("model_management_app") -def _capacity_suggestion_response_to_model(result) -> ModelCapacitySuggestionResponse: +def _require_super_admin(authorization: Optional[str]) -> tuple[str, str]: + user_id, tenant_id = _get_authenticated_user(authorization) + info = get_user_tenant_by_user_id(user_id) + if not info or (info.get("user_role") or "").upper() not in {"SU", "SUPER_ADMIN"}: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Super administrator role required") + return user_id, tenant_id + + +def _get_authenticated_user(authorization: Optional[str]) -> tuple[str, str]: + try: + return get_current_user_id(authorization) + except UnauthorizedError as exc: + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Unauthorized") from exc + + +def _capacity_suggestion_response_to_model(result, resolution=None) -> ModelCapacitySuggestionResponse: suggestions = None if result.suggestions is not None: suggestions = CapacitySuggestionFields( @@ -74,9 +103,27 @@ def _capacity_suggestion_response_to_model(result) -> ModelCapacitySuggestionRes max_input_tokens=result.suggestions.max_input_tokens, max_output_tokens=result.suggestions.max_output_tokens, default_output_reserve_tokens=result.suggestions.default_output_reserve_tokens, - tokenizer_family=result.suggestions.tokenizer_family, + tokenizer_family=( + resolution.capacity_suggestions.get("tokenizer_family") + if resolution is not None + else result.suggestions.tokenizer_family + ), ) + metadata_proposal = None + if resolution is not None and resolution.capacity_match.auto_applicable: + metadata_proposal = { + "schema_version": 1, + "fields": { + field: { + "source": "catalog", + "confidence": resolution.capacity_match.confidence, + "profile_version": resolution.capacity_match.selected_profile, + } + for field, value in resolution.capacity_suggestions.items() + if value is not None + }, + } return ModelCapacitySuggestionResponse( suggestions=suggestions, match_kind=result.match_kind.value, @@ -86,6 +133,16 @@ def _capacity_suggestion_response_to_model(result) -> ModelCapacitySuggestionRes canonical_model_name=result.canonical_model_name, capability_profile_version=result.capability_profile_version, capacity_source_on_accept=result.capacity_source_on_accept, + canonical_identity=( + dict(resolution.identity_metadata) if resolution is not None else None + ), + capacity_match=( + serialize_profile_match(resolution.capacity_match) if resolution is not None else None + ), + tokenizer_match=( + serialize_profile_match(resolution.tokenizer_match) if resolution is not None else None + ), + governance_metadata_proposal=metadata_proposal, ) @@ -98,7 +155,14 @@ def _suggest_capacity_for_request(request: ModelCapacitySuggestionRequest) -> Mo api_key=request.api_key, enabled=CAPACITY_SUGGESTION_ENABLED, ) - return _capacity_suggestion_response_to_model(result) + resolution = resolve_model_profiles( + model_name=request.model_name, + provider=request.provider_hint or result.suggested_provider, + base_url=request.base_url, + model_type=request.model_type, + capacity_result=result, + ) + return _capacity_suggestion_response_to_model(result, resolution) def _capacity_suggestion_for_model_request(request: ModelRequest): @@ -138,11 +202,20 @@ async def create_model(request: ModelRequest, authorization: Optional[str] = Hea """ try: user_id, tenant_id = get_current_user_id(authorization) + explicit_fields = set(request.model_fields_set) model_data = request.model_dump() accept_signal = pop_capacity_accept_signal(model_data) logger.debug( f"Start to create model, user_id: {user_id}, tenant_id: {tenant_id}") - await create_model_for_tenant(user_id, tenant_id, model_data) + await create_model_for_tenant( + user_id, + tenant_id, + model_data, + explicit_fields=explicit_fields, + accepted_profile_version=( + accept_signal.get("capability_profile_version") if accept_signal else None + ), + ) if accept_signal is not None: _record_capacity_suggestion_accept( accept_signal["match_kind"], request.model_factory @@ -214,6 +287,162 @@ async def get_model_capacity_coverage(authorization: Optional[str] = Header(None raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) +@router.post("/capacity-adoption-preview") +async def preview_capacity_adoption( + request: CapacityAdoptionPreviewRequest, + authorization: Optional[str] = Header(None), +): + try: + _, tenant_id = _get_authenticated_user(authorization) + result = await preview_capacity_adoption_for_tenant( + tenant_id, + request.display_name, + expected_matcher_version=request.expected_matcher_version, + ) + return JSONResponse(status_code=HTTPStatus.OK, content={ + "message": "Successfully previewed capacity adoption", + "data": jsonable_encoder(result), + }) + except LookupError as e: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) + except ModelCapacityConfigError as e: + raise HTTPException(status_code=HTTPStatus.CONFLICT, detail=str(e)) + except HTTPException: + raise + except Exception as e: + logger.exception("Capacity adoption preview failed") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Capacity adoption preview failed") from e + + +@router.post("/capacity-adopt") +async def adopt_capacity( + request: CapacityAdoptRequest, + authorization: Optional[str] = Header(None), +): + try: + user_id, tenant_id = _get_authenticated_user(authorization) + result = await adopt_capacity_for_tenant( + user_id, + tenant_id, + request.display_name, + expected_profile_version=request.expected_profile_version, + expected_matcher_version=request.expected_matcher_version, + fields=request.fields, + reset_manual_fields=request.reset_manual_fields, + ) + return JSONResponse(status_code=HTTPStatus.OK, content={ + "message": "Successfully adopted capacity profile", + "data": jsonable_encoder(result), + }) + except LookupError as e: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) + except ModelCapacityConfigError as e: + raise HTTPException(status_code=HTTPStatus.CONFLICT, detail=str(e)) + except HTTPException: + raise + except Exception as e: + logger.exception("Capacity adoption failed") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Capacity adoption failed") from e + + +@router.post("/token-count-probe") +async def probe_token_count( + request: TokenCountProbeRequest, + authorization: Optional[str] = Header(None), +): + try: + user_id, tenant_id = _get_authenticated_user(authorization) + result = await probe_token_count_for_tenant( + user_id, tenant_id, request.display_name, force=request.force + ) + return JSONResponse(status_code=HTTPStatus.OK, content={ + "message": "Token-count capability probe completed", + "data": jsonable_encoder(result), + }) + except LookupError as e: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) + except ValueError as e: + reason = str(e) if str(e) in {"ssrf_rejected", "connection_failed"} else "probe_rejected" + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=reason) + except HTTPException: + raise + except Exception as e: + logger.exception("Token count probe failed") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Token count probe failed") from e + + +@router.post("/manage/capacity-adoption-preview") +async def manage_preview_capacity_adoption( + request: ManageCapacityAdoptionPreviewRequest, + authorization: Optional[str] = Header(None), +): + _require_super_admin(authorization) + try: + result = await preview_capacity_adoption_for_tenant( + request.tenant_id, + request.display_name, + expected_matcher_version=request.expected_matcher_version, + ) + return JSONResponse(status_code=HTTPStatus.OK, content={ + "message": "Successfully previewed capacity adoption", + "data": jsonable_encoder(result), + }) + except LookupError as e: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) + except ModelCapacityConfigError as e: + raise HTTPException(status_code=HTTPStatus.CONFLICT, detail=str(e)) + + +@router.post("/manage/capacity-adopt") +async def manage_adopt_capacity( + request: ManageCapacityAdoptRequest, + authorization: Optional[str] = Header(None), +): + user_id, _ = _require_super_admin(authorization) + try: + result = await adopt_capacity_for_tenant( + user_id, + request.tenant_id, + request.display_name, + expected_profile_version=request.expected_profile_version, + expected_matcher_version=request.expected_matcher_version, + fields=request.fields, + reset_manual_fields=request.reset_manual_fields, + ) + return JSONResponse(status_code=HTTPStatus.OK, content={ + "message": "Successfully adopted capacity profile", + "data": jsonable_encoder(result), + }) + except LookupError as e: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) + except ModelCapacityConfigError as e: + raise HTTPException(status_code=HTTPStatus.CONFLICT, detail=str(e)) + + +@router.post("/manage/token-count-probe") +async def manage_probe_token_count( + request: ManageTokenCountProbeRequest, + authorization: Optional[str] = Header(None), +): + user_id, _ = _require_super_admin(authorization) + try: + result = await probe_token_count_for_tenant( + user_id, + request.tenant_id, + request.display_name, + force=request.force, + ) + return JSONResponse(status_code=HTTPStatus.OK, content={ + "message": "Token-count capability probe completed", + "data": jsonable_encoder(result), + }) + except LookupError as e: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail=str(e)) + except ValueError as e: + reason = str(e) if str(e) in {"ssrf_rejected", "connection_failed"} else "probe_rejected" + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=reason) + + @router.post("/provider/create") async def create_provider_model(request: ProviderModelRequest, authorization: Optional[str] = Header(None)): """Create or refresh provider models for the current tenant in memory only. @@ -259,11 +488,14 @@ async def batch_create_models(request: BatchCreateModelsRequest, authorization: # Strip W11 accept-signal fields off every model entry before the # batch reaches the service/DB layer. Same audit-only contract as # the single-create path: pop now, emit the SLO counter on success. - accept_signals = [ - signal - for model in batch_model_config.get("models", []) - if (signal := pop_capacity_accept_signal(model)) is not None - ] + accept_signals = [] + for model in batch_model_config.get("models", []): + signal = pop_capacity_accept_signal(model) + if signal is not None: + accept_signals.append(signal) + model["_accepted_profile_version"] = signal.get( + "capability_profile_version" + ) await batch_create_models_for_tenant(user_id, tenant_id, batch_model_config) provider = batch_model_config.get("provider") for signal in accept_signals: @@ -326,8 +558,18 @@ async def update_single_model( """ try: user_id, tenant_id = get_current_user_id(authorization) + explicit_fields = set(request) accept_signal = pop_capacity_accept_signal(request) - await update_single_model_for_tenant(user_id, tenant_id, display_name, request) + await update_single_model_for_tenant( + user_id, + tenant_id, + display_name, + request, + explicit_fields=explicit_fields, + accepted_profile_version=( + accept_signal.get("capability_profile_version") if accept_signal else None + ), + ) if accept_signal is not None: _record_capacity_suggestion_accept( accept_signal["match_kind"], request.get("model_factory") @@ -569,6 +811,7 @@ async def manage_create_model( logger.debug( f"Start to create model for tenant, user_id: {user_id}, target_tenant_id: {request.tenant_id}") + explicit_fields = set(request.model_fields_set) - {"tenant_id"} model_data = request.model_dump(exclude={'tenant_id'}) # Strip W11 accept-signal fields before the dict reaches the # service (which calls create_model_record -> SQLAlchemy insert). @@ -577,7 +820,15 @@ async def manage_create_model( # operator-accepted suggestions saved by SU/asset-owner via # /manage/* would silently miss the accept_total SLO numerator. accept_signal = pop_capacity_accept_signal(model_data) - await create_model_for_tenant(user_id, request.tenant_id, model_data) + await create_model_for_tenant( + user_id, + request.tenant_id, + model_data, + explicit_fields=explicit_fields, + accepted_profile_version=( + accept_signal.get("capability_profile_version") if accept_signal else None + ), + ) if accept_signal is not None: _record_capacity_suggestion_accept( accept_signal["match_kind"], request.model_factory @@ -620,12 +871,20 @@ async def manage_update_model( f"Start to update model for tenant, user_id: {user_id}, target_tenant_id: {request.tenant_id}, " f"current_display_name: {request.current_display_name}") + explicit_fields = set(request.model_fields_set) - {"tenant_id", "current_display_name"} model_data = request.model_dump(exclude={'tenant_id', 'current_display_name'}, exclude_unset=True) # Same audit-only contract as /manage/create above: pop before # the dict reaches update_model_record, emit after persist. accept_signal = pop_capacity_accept_signal(model_data) await update_single_model_for_tenant( - user_id, request.tenant_id, request.current_display_name, model_data + user_id, + request.tenant_id, + request.current_display_name, + model_data, + explicit_fields=explicit_fields, + accepted_profile_version=( + accept_signal.get("capability_profile_version") if accept_signal else None + ), ) if accept_signal is not None: _record_capacity_suggestion_accept( diff --git a/backend/consts/capability_profiles.py b/backend/consts/capability_profiles.py index 2791c7791a..dae58ebfaa 100644 --- a/backend/consts/capability_profiles.py +++ b/backend/consts/capability_profiles.py @@ -22,7 +22,7 @@ logger = logging.getLogger(__name__) -CATALOG_REVISION = "2026-06-27.1" +CATALOG_REVISION = "2026-08-24.1" CATALOG: Dict[ProfileKey, CapabilityProfile] = { @@ -55,6 +55,16 @@ max_output_tokens=16_384, default_output_reserve_tokens=4_096, tokenizer_family="qwen", + aliases=("qwen-plus",), + exclusions=("qwen-vl", "qwen-omni"), + evidence=("aliyun-model-studio-model-catalog-2026-08",), + verified_at="2026-08-01T00:00:00Z", + shared_context=True, + independent_input=False, + max_output=16_384, + reasoning_behavior="unknown", + overhead_behavior="bounded", + confidence="high", ), ("dashscope", "qwen-turbo"): CapabilityProfile( provider="dashscope", @@ -66,6 +76,29 @@ default_output_reserve_tokens=4_096, tokenizer_family="qwen", ), + # Verified 2026-08-24 against the official model-specific DashScope page: + # https://help.aliyun.com/zh/model-studio/qwen3-7-plus + ("dashscope", "qwen3.7-plus"): CapabilityProfile( + provider="dashscope", + model_name="qwen3.7-plus", + capability_profile_version="dashscope/qwen3.7-plus@1", + window_shape="combined", + context_window_tokens=1_000_000, + max_input_tokens=991_808, + max_output_tokens=131_072, + default_output_reserve_tokens=8_192, + tokenizer_family="qwen", + aliases=("qwen3.7-plus", "qwen-3.7-plus"), + exclusions=("qwen3.7-max", "qwen3.7-flash"), + evidence=("https://help.aliyun.com/zh/model-studio/qwen3-7-plus",), + verified_at="2026-08-24T00:00:00Z", + shared_context=True, + independent_input=False, + max_output=131_072, + reasoning_behavior="reserved", + overhead_behavior="bounded", + confidence="high", + ), # Sources cross-checked 2026-06-23: # https://help.aliyun.com/zh/model-studio/models (Bailian model catalog) # https://llm-stats.com/models/qwen3.7-max (1.0M input, 65.5K output) diff --git a/backend/consts/model.py b/backend/consts/model.py index 5332fe1e7d..a95f42e9d8 100644 --- a/backend/consts/model.py +++ b/backend/consts/model.py @@ -165,6 +165,7 @@ class ModelRequest(BaseModel): tokenizer_family: Optional[str] = None capacity_source: Optional[str] = None capability_profile_version: Optional[str] = None + capacity_mode: Optional[Literal["auto", "manual"]] = None # W11 accept-signal fields (audit/metrics only — never persisted). Sent by # the frontend when the operator clicks "Use suggestion" and saves; the # app layer pops them before the dict reaches the service/DB layer and @@ -197,7 +198,11 @@ class ModelCapacitySuggestionResponse(BaseModel): suggested_provider: Optional[str] = None canonical_model_name: Optional[str] = None capability_profile_version: Optional[str] = None - capacity_source_on_accept: Optional[Literal["operator"]] = None + capacity_source_on_accept: Optional[Literal["operator", "profile"]] = None + canonical_identity: Optional[Dict[str, Any]] = None + capacity_match: Optional[Dict[str, Any]] = None + tokenizer_match: Optional[Dict[str, Any]] = None + governance_metadata_proposal: Optional[Dict[str, Any]] = None class CapacityCoverageBareModel(BaseModel): @@ -215,6 +220,36 @@ class CapacityCoverageResponse(BaseModel): bare_models: List[CapacityCoverageBareModel] = Field(default_factory=list) +class CapacityAdoptionPreviewRequest(BaseModel): + display_name: str = Field(..., min_length=1, max_length=256) + expected_matcher_version: Optional[str] = None + + +class CapacityAdoptRequest(BaseModel): + display_name: str = Field(..., min_length=1, max_length=256) + expected_profile_version: str = Field(..., min_length=1, max_length=256) + expected_matcher_version: Optional[str] = None + fields: Optional[List[str]] = None + reset_manual_fields: List[str] = Field(default_factory=list) + + +class TokenCountProbeRequest(BaseModel): + display_name: str = Field(..., min_length=1, max_length=256) + force: bool = False + + +class ManageCapacityAdoptionPreviewRequest(CapacityAdoptionPreviewRequest): + tenant_id: str = Field(..., min_length=1) + + +class ManageCapacityAdoptRequest(CapacityAdoptRequest): + tenant_id: str = Field(..., min_length=1) + + +class ManageTokenCountProbeRequest(TokenCountProbeRequest): + tenant_id: str = Field(..., min_length=1) + + class ProviderModelRequest(BaseModel): provider: str model_type: str @@ -1308,6 +1343,7 @@ class ManageTenantModelCreateRequest(BaseModel): tokenizer_family: Optional[str] = Field(None, description="Token-counting strategy or tokenizer identifier") capacity_source: Optional[str] = Field(None, description="Source of the persisted capacity value") capability_profile_version: Optional[str] = Field(None, description="Version of the approved capability profile") + capacity_mode: Optional[Literal["auto", "manual"]] = Field(None, description="Capacity inheritance mode") # W11 accept-signal fields. Same audit-only contract as ModelRequest: # the app layer pops them off model_data before the dict reaches the # service/DB layer and forwards them to @@ -1347,6 +1383,7 @@ class ManageTenantModelUpdateRequest(BaseModel): tokenizer_family: Optional[str] = Field(None, description="Token-counting strategy or tokenizer identifier") capacity_source: Optional[str] = Field(None, description="Source of the persisted capacity value") capability_profile_version: Optional[str] = Field(None, description="Version of the approved capability profile") + capacity_mode: Optional[Literal["auto", "manual"]] = Field(None, description="Capacity inheritance mode") # W11 accept-signal fields. See ManageTenantModelCreateRequest for the # contract. The app layer pops them before calling the service so # update_model_record never sees them. diff --git a/backend/database/db_models.py b/backend/database/db_models.py index 5bd138ca70..e8e5c104e8 100644 --- a/backend/database/db_models.py +++ b/backend/database/db_models.py @@ -447,6 +447,16 @@ class ModelRecord(TableBase): String(100), doc="Source of the persisted capacity value. Optional values: operator, profile, provider_candidate, legacy, default, unknown.") capability_profile_version = Column( String(100), doc="Version of the approved provider/model capability profile used by the request, e.g. openai/gpt-4o@1.") + canonical_model_id = Column( + String(512), doc="Versioned canonical model identity used by profile matchers.") + capacity_field_metadata = Column( + JSONB, doc="Versioned field-level capacity provenance metadata without secrets.") + model_identity_metadata = Column( + JSONB, doc="Versioned canonical identity and capacity-match evidence.") + tokenizer_match_metadata = Column( + JSONB, doc="Versioned independent tokenizer match and conformance state.") + token_count_probe_metadata = Column( + JSONB, doc="Versioned sanitized Provider token-count probe state.") class ModelMonitoringRecord(SimpleTableBase): diff --git a/backend/database/model_management_db.py b/backend/database/model_management_db.py index 16c76bd2d9..772cf50c7c 100644 --- a/backend/database/model_management_db.py +++ b/backend/database/model_management_db.py @@ -122,6 +122,55 @@ def delete_model_record(model_id: int, user_id: str, tenant_id: str) -> bool: return result.rowcount > 0 +def apply_model_mutations( + *, + creates: List[Dict[str, Any]], + updates: List[tuple[int, Dict[str, Any]]], + deletes: List[int], + user_id: str, + tenant_id: str, +) -> None: + """Apply a validated model batch in one database transaction.""" + with get_db_session() as session: + for model_data in creates: + cleaned = db_client.clean_string_values(model_data) + cleaned["create_time"] = func.current_timestamp() + cleaned["tenant_id"] = tenant_id + if user_id: + cleaned = add_creation_tracking(cleaned, user_id) + session.execute(insert(ModelRecord).values(cleaned)) + + for model_id, update_data in updates: + cleaned = db_client.clean_string_values(update_data) + cleaned["update_time"] = func.current_timestamp() + if user_id: + cleaned = add_update_tracking(cleaned, user_id) + session.execute( + update(ModelRecord) + .where( + ModelRecord.model_id == model_id, + ModelRecord.tenant_id == tenant_id, + ) + .values(cleaned) + ) + + for model_id in deletes: + cleaned = { + "delete_flag": "Y", + "update_time": func.current_timestamp(), + } + if user_id: + cleaned = add_update_tracking(cleaned, user_id) + session.execute( + update(ModelRecord) + .where( + ModelRecord.model_id == model_id, + ModelRecord.tenant_id == tenant_id, + ) + .values(cleaned) + ) + + def get_model_records(filters: Optional[Dict[str, Any]], tenant_id: str) -> List[Dict[str, Any]]: """ Get a list of model records diff --git a/backend/services/model_capacity_governance_service.py b/backend/services/model_capacity_governance_service.py new file mode 100644 index 0000000000..88a7f547b1 --- /dev/null +++ b/backend/services/model_capacity_governance_service.py @@ -0,0 +1,319 @@ +"""Field-level capacity provenance, catalog adoption, and legacy normalization.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Iterable, Mapping, Optional + +from consts.exceptions import ModelCapacityConfigError +from services.model_capacity_validation_service import CAPACITY_FIELDS, CAPACITY_MODEL_TYPES + + +GOVERNANCE_SCHEMA_VERSION = 1 +GOVERNED_FIELDS = (*CAPACITY_FIELDS, "tokenizer_family") +FIELD_SOURCES = frozenset({"catalog", "provider", "operator", "legacy", "unknown"}) +_LEGACY_SOURCE_MAP = { + "profile": "catalog", + "provider_candidate": "provider", + "operator": "operator", + "legacy": "legacy", + "default": "unknown", + "unknown": "unknown", +} +_ROW_SOURCE_MAP = { + "catalog": "profile", + "provider": "provider_candidate", + "operator": "operator", + "legacy": "legacy", + "unknown": "unknown", +} + + +@dataclass(frozen=True) +class GovernanceMergeResult: + values: dict[str, Any] + metadata: dict[str, Any] + audit_delta: tuple[dict[str, Any], ...] + row_capacity_source: Optional[str] + capability_profile_version: Optional[str] + + +def _fail(reason: str, message: str, *, field: Optional[str] = None) -> None: + raise ModelCapacityConfigError(reason, message, field=field) + + +def normalize_legacy_capacity_ingress( + payload: Mapping[str, Any], + *, + explicit_fields: Iterable[str], +) -> tuple[dict[str, Any], set[str], bool]: + """Normalize legacy max_tokens once and remove it as a capacity write authority.""" + normalized = dict(payload) + explicit = set(explicit_fields) + model_type = normalized.get("model_type") + if model_type not in CAPACITY_MODEL_TYPES: + return normalized, explicit, False + + legacy_explicit = "max_tokens" in explicit + output_explicit = "max_output_tokens" in explicit + legacy_value = normalized.get("max_tokens") + output_value = normalized.get("max_output_tokens") + used_legacy = False + + if legacy_explicit and output_explicit and legacy_value is not None and output_value is not None: + if legacy_value != output_value: + _fail( + "capacity_legacy_conflict", + "max_tokens conflicts with max_output_tokens", + field="max_output_tokens", + ) + elif legacy_explicit and not output_explicit and legacy_value not in (None, 0): + normalized["max_output_tokens"] = legacy_value + explicit.add("max_output_tokens") + used_legacy = True + + normalized.pop("max_tokens", None) + explicit.discard("max_tokens") + return normalized, explicit, used_legacy + + +def _valid_metadata(metadata: Optional[Mapping[str, Any]]) -> dict[str, Any]: + if not metadata or metadata.get("schema_version") != GOVERNANCE_SCHEMA_VERSION: + return {"schema_version": GOVERNANCE_SCHEMA_VERSION, "fields": {}} + fields = {} + for field, item in (metadata.get("fields") or {}).items(): + if field not in GOVERNED_FIELDS or not isinstance(item, Mapping): + continue + source = item.get("source") + if source not in FIELD_SOURCES: + continue + fields[field] = dict(item) + return {"schema_version": GOVERNANCE_SCHEMA_VERSION, "fields": fields} + + +def derive_legacy_metadata(record: Mapping[str, Any]) -> dict[str, Any]: + """Return response-only metadata for an old row without mutating it.""" + metadata = _valid_metadata(record.get("capacity_field_metadata")) + if metadata["fields"]: + return metadata + source = _LEGACY_SOURCE_MAP.get(record.get("capacity_source"), "legacy") + profile_version = record.get("capability_profile_version") + for field in GOVERNED_FIELDS: + if record.get(field) is None: + continue + metadata["fields"][field] = { + "source": source, + "confidence": "unknown" if source in {"legacy", "operator", "unknown"} else "medium", + **({"profile_version": profile_version} if profile_version else {}), + } + return metadata + + +def _field_metadata( + source: str, + *, + profile_version: Optional[str] = None, + evidence_id: Optional[str] = None, + verified_at: Optional[str] = None, +) -> dict[str, Any]: + item = { + "source": source, + "confidence": "high" if source == "catalog" else "medium" if source == "provider" else "unknown", + "updated_at": datetime.now(timezone.utc).isoformat(), + } + if profile_version: + item["profile_version"] = profile_version + if evidence_id: + item["evidence_id"] = evidence_id + if verified_at: + item["verified_at"] = verified_at + return item + + +def _project_row_source(fields: Mapping[str, Mapping[str, Any]]) -> Optional[str]: + sources = {item.get("source") for item in fields.values()} + for source in ("operator", "provider", "catalog", "legacy", "unknown"): + if source in sources: + return _ROW_SOURCE_MAP[source] + return None + + +def merge_capacity_governance( + payload: Mapping[str, Any], + *, + explicit_fields: Iterable[str], + existing: Optional[Mapping[str, Any]] = None, + accepted_profile_version: Optional[str] = None, + accepted_profile_fields: Iterable[str] = (), + provider_fields: Iterable[str] = (), + profile_evidence_id: Optional[str] = None, + profile_verified_at: Optional[str] = None, + legacy_ingress_used: bool = False, +) -> GovernanceMergeResult: + """Merge explicitly changed fields and preserve provenance for all others.""" + previous = dict(existing or {}) + values = dict(previous) + values.update(payload) + explicit = set(explicit_fields) + metadata = derive_legacy_metadata(previous) if existing else _valid_metadata(None) + fields = dict(metadata["fields"]) + accepted = set(accepted_profile_fields) + provider = set(provider_fields) + audit: list[dict[str, Any]] = [] + + for field in GOVERNED_FIELDS: + if field not in explicit: + continue + old_value = previous.get(field) + new_value = payload.get(field) + previous_source = (fields.get(field) or {}).get("source") + provenance_changes = ( + field in accepted + and accepted_profile_version is not None + and new_value is not None + and previous_source != "catalog" + ) + if existing and new_value == old_value and not provenance_changes: + continue + if field in provider and previous_source == "operator": + continue + if new_value is None: + fields.pop(field, None) + new_source = "unknown" + elif field in accepted and accepted_profile_version: + new_source = "catalog" + fields[field] = _field_metadata( + new_source, + profile_version=accepted_profile_version, + evidence_id=profile_evidence_id, + verified_at=profile_verified_at, + ) + elif field in provider: + new_source = "provider" + fields[field] = _field_metadata(new_source) + elif legacy_ingress_used and field == "max_output_tokens": + new_source = "legacy" + fields[field] = _field_metadata(new_source) + else: + new_source = "operator" + fields[field] = _field_metadata(new_source) + audit.append( + { + "field": field, + "previous_source": previous_source, + "new_source": new_source, + "value_changed": old_value != new_value, + } + ) + + metadata = {"schema_version": GOVERNANCE_SCHEMA_VERSION, "fields": fields} + row_source = _project_row_source(fields) + profile_versions = { + item.get("profile_version") + for item in fields.values() + if item.get("source") == "catalog" and item.get("profile_version") + } + profile_version = next(iter(profile_versions)) if len(profile_versions) == 1 else None + return GovernanceMergeResult( + values=values, + metadata=metadata, + audit_delta=tuple(audit), + row_capacity_source=row_source, + capability_profile_version=profile_version, + ) + + +def catalog_adoption_preview( + record: Mapping[str, Any], + proposed_values: Mapping[str, Any], + *, + proposed_profile_version: str, +) -> dict[str, Any]: + metadata = derive_legacy_metadata(record) + fields = metadata["fields"] + diff = {} + for field in GOVERNED_FIELDS: + if field not in proposed_values: + continue + source = (fields.get(field) or {}).get("source", "unknown") + current = record.get(field) + proposed = proposed_values.get(field) + diff[field] = { + "current_value": current, + "current_source": source, + "proposed_value": proposed, + "proposed_source": "catalog", + "changed": current != proposed, + "blocked_by_manual": source == "operator", + "applicable": source in {"catalog", "unknown"} and current != proposed, + } + return { + "schema_version": GOVERNANCE_SCHEMA_VERSION, + "current_profile_version": record.get("capability_profile_version"), + "proposed_profile_version": proposed_profile_version, + "fields": diff, + } + + +def apply_catalog_adoption( + record: Mapping[str, Any], + proposed_values: Mapping[str, Any], + *, + proposed_profile_version: str, + expected_profile_version: str, + current_matcher_version: str, + expected_matcher_version: Optional[str] = None, + fields: Optional[Iterable[str]] = None, + reset_manual_fields: Iterable[str] = (), + profile_evidence_id: Optional[str] = None, + profile_verified_at: Optional[str] = None, +) -> GovernanceMergeResult: + """Version-checked adoption that preserves manual fields by default.""" + if expected_profile_version != proposed_profile_version: + _fail("capacity_profile_stale", "catalog profile changed since preview") + if expected_matcher_version and expected_matcher_version != current_matcher_version: + _fail("capacity_matcher_stale", "model matcher changed since preview") + + requested = set(fields) if fields is not None else set(GOVERNED_FIELDS) + reset_manual = set(reset_manual_fields) + invalid = (requested | reset_manual).difference(GOVERNED_FIELDS) + if invalid: + invalid_field = sorted(invalid)[0] + _fail( + "capacity_adoption_field_invalid", + f"unsupported adoption field: {invalid_field}", + field=invalid_field, + ) + if not reset_manual.issubset(requested): + _fail( + "capacity_manual_reset_not_selected", + "reset_manual_fields must also be selected for adoption", + ) + + metadata = derive_legacy_metadata(record) + payload: dict[str, Any] = {} + accepted: set[str] = set() + for field in requested: + if field not in proposed_values: + continue + source = (metadata["fields"].get(field) or {}).get("source", "unknown") + if source == "operator" and field not in reset_manual: + continue + if source not in {"catalog", "unknown", "operator"}: + continue + proposed = proposed_values[field] + if proposed == record.get(field) and source == "catalog": + continue + payload[field] = proposed + accepted.add(field) + + return merge_capacity_governance( + payload, + explicit_fields=accepted, + existing=record, + accepted_profile_version=proposed_profile_version, + accepted_profile_fields=accepted, + profile_evidence_id=profile_evidence_id, + profile_verified_at=profile_verified_at, + ) diff --git a/backend/services/model_capacity_suggestion_service.py b/backend/services/model_capacity_suggestion_service.py index 8fa9b20634..79508f16a8 100644 --- a/backend/services/model_capacity_suggestion_service.py +++ b/backend/services/model_capacity_suggestion_service.py @@ -1,11 +1,14 @@ import logging -import re import time from dataclasses import dataclass from enum import Enum from typing import Any, Mapping, Optional from consts.const import CAPACITY_SUGGESTION_ENABLED +from nexent.core.models.model_identity import ( + identities_are_safe_aliases, + parse_model_identity, +) logger = logging.getLogger(__name__) @@ -159,7 +162,10 @@ def _normalize_provider(provider: Optional[str]) -> Optional[str]: def normalize_model_name(model_name: str) -> str: - return re.sub(r"[-_./\s]+", "", model_name.strip().lower()) + """Compatibility helper returning the separator-aware canonical path.""" + if not model_name.strip(): + return "" + return parse_model_identity(model_name).canonical_id def _normalize_catalog_exact_name(model_name: str) -> str: @@ -195,7 +201,9 @@ def _result_from_profile( suggested_provider=provider, canonical_model_name=model_name, capability_profile_version=profile.capability_profile_version, - capacity_source_on_accept="operator", + capacity_source_on_accept=( + "profile" if getattr(profile, "auto_applicable", False) else "operator" + ), ) @@ -224,12 +232,13 @@ def _unique_final_segment_match( catalog: Mapping[ProfileKey, CapabilityProfileLike], provider: str, ) -> Optional[tuple[ProfileKey, CapabilityProfileLike]]: - requested = normalize_model_name(model_name) + requested = parse_model_identity(model_name, provider) matches: list[tuple[ProfileKey, CapabilityProfileLike]] = [] for key, profile in _provider_catalog(catalog, provider).items(): catalog_model = key[1] final_segment = catalog_model.split("/")[-1] - if normalize_model_name(final_segment) == requested: + candidate = parse_model_identity(final_segment, provider) + if identities_are_safe_aliases(requested, candidate): matches.append((key, profile)) if len(matches) == 1: @@ -242,10 +251,11 @@ def _fuzzy_catalog_match( catalog: Mapping[ProfileKey, CapabilityProfileLike], provider: str, ) -> Optional[tuple[ProfileKey, CapabilityProfileLike]]: - requested = normalize_model_name(model_name) + requested = parse_model_identity(model_name, provider) matches: list[tuple[ProfileKey, CapabilityProfileLike]] = [] for key, profile in _provider_catalog(catalog, provider).items(): - if normalize_model_name(key[1]) == requested: + candidate = parse_model_identity(key[1], provider) + if requested.canonical_id == candidate.canonical_id: matches.append((key, profile)) if len(matches) == 1: @@ -254,6 +264,31 @@ def _fuzzy_catalog_match( return _unique_final_segment_match(model_name, catalog, provider) +def _explicit_alias_match( + model_name: str, + catalog: Mapping[ProfileKey, CapabilityProfileLike], + provider: str, +) -> Optional[tuple[ProfileKey, CapabilityProfileLike]]: + requested = parse_model_identity(model_name, provider) + matches: list[tuple[ProfileKey, CapabilityProfileLike]] = [] + for key, profile in _provider_catalog(catalog, provider).items(): + exclusions = getattr(profile, "exclusions", ()) or () + if any( + identities_are_safe_aliases( + requested, parse_model_identity(exclusion, provider) + ) + for exclusion in exclusions + ): + continue + aliases = getattr(profile, "aliases", ()) or () + if any( + identities_are_safe_aliases(requested, parse_model_identity(alias, provider)) + for alias in aliases + ): + matches.append((key, profile)) + return matches[0] if len(matches) == 1 else None + + def _unique_catalog_provider_for_model( model_name: str, catalog: Mapping[ProfileKey, CapabilityProfileLike], @@ -377,6 +412,16 @@ def _suggest_capacity_inner( CapacitySuggestionMatchKind.CATALOG_EXACT, ) + alias_match = _explicit_alias_match(clean_model_name, active_catalog, provider) + if alias_match: + alias_key, profile = alias_match + return _result_from_profile( + alias_key[0], + alias_key[1], + profile, + CapacitySuggestionMatchKind.CATALOG_EXACT, + ) + fuzzy_match = _fuzzy_catalog_match(clean_model_name, active_catalog, provider) if fuzzy_match: fuzzy_key, profile = fuzzy_match diff --git a/backend/services/model_management_service.py b/backend/services/model_management_service.py index 347f9a1909..0411621db8 100644 --- a/backend/services/model_management_service.py +++ b/backend/services/model_management_service.py @@ -19,6 +19,7 @@ ) from database.model_management_db import ( + apply_model_mutations, create_model_record, delete_model_record, get_model_by_name_factory, @@ -33,6 +34,16 @@ audit_capacity_records, validate_capacity_contract, ) +from services.model_capacity_governance_service import ( + GOVERNED_FIELDS, + apply_catalog_adoption, + catalog_adoption_preview, + derive_legacy_metadata, + merge_capacity_governance, + normalize_legacy_capacity_ingress, +) +from services.model_token_count_probe_service import run_token_count_probe +from nexent.core.models.model_identity import MATCHER_VERSION from services.model_provider_service import ( prepare_model_dict, merge_existing_model_attributes, @@ -40,6 +51,10 @@ ) from services.model_health_service import embedding_dimension_check, _infer_model_factory from services.model_capacity_suggestion_service import CapacitySuggestionMatchKind, suggest_capacity +from services.model_profile_match_service import ( + resolve_model_profiles, + serialize_profile_match, +) from utils.model_name_utils import ( add_repo_to_name, split_repo_name, @@ -189,27 +204,98 @@ def _has_display_name_conflict(existing_models: List[Dict[str, Any]], model_type return True -def _coerce_legacy_max_tokens_alias(model_data: Dict[str, Any]) -> None: - """Keep the deprecated `max_tokens` column in lockstep with `max_output_tokens`. +def _apply_capacity_governance( + model_data: Dict[str, Any], + *, + explicit_fields: set[str], + existing: Optional[Dict[str, Any]] = None, + accepted_profile_version: Optional[str] = None, + provider_fields: Optional[set[str]] = None, +) -> Dict[str, Any]: + """Apply P1 one-way legacy normalization and field-level provenance.""" + normalized, explicit, used_legacy = normalize_legacy_capacity_ingress( + model_data, + explicit_fields=explicit_fields, + ) + governed_explicit = set(GOVERNED_FIELDS).intersection(explicit) + # Unrelated edits must be provenance-neutral. Legacy rows are materialized + # only on their first authorized capacity write, not when a name, + # description, credential, or connectivity setting changes. + if not governed_explicit and not accepted_profile_version: + return normalized + accepted_fields = ( + governed_explicit + if accepted_profile_version + else set() + ) + result = merge_capacity_governance( + normalized, + explicit_fields=explicit, + existing=existing, + accepted_profile_version=accepted_profile_version, + accepted_profile_fields=accepted_fields, + provider_fields=provider_fields or (), + legacy_ingress_used=used_legacy, + ) + if result.audit_delta: + logger.info( + "capacity_governance_merge fields=%s sources=%s", + ",".join(item["field"] for item in result.audit_delta), + ",".join(item["new_source"] for item in result.audit_delta), + ) + output = dict(normalized) + output["capacity_field_metadata"] = result.metadata + output["capacity_source"] = result.row_capacity_source + output["capability_profile_version"] = result.capability_profile_version + return output + + +def _apply_model_profile_resolution( + model_data: Dict[str, Any], + *, + explicit_fields: set[str], + full_model_name: str, + accepted_profile_version: Optional[str], +) -> tuple[Dict[str, Any], set[str], Optional[str]]: + """Persist independent match evidence and apply verified auto facts.""" + output = dict(model_data) + capacity_mode = output.pop("capacity_mode", None) + explicit = set(explicit_fields) + explicit.discard("capacity_mode") + resolution = resolve_model_profiles( + model_name=full_model_name, + provider=output.get("model_factory"), + base_url=output.get("base_url"), + model_type=output.get("model_type"), + ) + output["canonical_model_id"] = resolution.canonical_model_id + output["model_identity_metadata"] = dict(resolution.identity_metadata) + output["tokenizer_match_metadata"] = serialize_profile_match( + resolution.tokenizer_match + ) - W1 step 7 deprecates `max_tokens` as the LLM/VLM output-cap alias of - `max_output_tokens`. Legacy clients that still write `max_tokens` - independently let the two columns diverge in the DB; that divergence - later surfaces at the W2 dispatch boundary as - `CallerMaxTokensOverrideForbidden` because the SDK auto-fills - `max_tokens` from the model record while the W2 snapshot computes its - output cap from `max_output_tokens`. + selected_version = resolution.capacity_match.selected_profile + if accepted_profile_version: + if accepted_profile_version != selected_version: + raise ModelCapacityConfigError( + "capacity_profile_stale", + "accepted capability profile no longer matches this model", + field="accepted_capability_profile_version", + ) + if not resolution.capacity_match.auto_applicable: + raise ModelCapacityConfigError( + "capacity_profile_unverified", + "accepted capability profile is not evidence-complete for automatic use", + field="accepted_capability_profile_version", + ) - Defense in depth at the service layer: when a caller sends a non-None - `max_output_tokens`, force `max_tokens` to mirror it. Embedding rows are - exempt because they repurpose `max_tokens` as the vector dimension. - """ - max_output = model_data.get("max_output_tokens") - if max_output is None: - return - if model_data.get("model_type") in ("embedding", "multi_embedding"): - return - model_data["max_tokens"] = max_output + if capacity_mode == "auto" and resolution.capacity_match.auto_applicable: + for field, value in resolution.capacity_suggestions.items(): + if field in GOVERNED_FIELDS and value is not None and output.get(field) is None: + output[field] = value + explicit.add(field) + accepted_profile_version = selected_version + return output, explicit, accepted_profile_version def _is_bare_capacity_model(model: Dict[str, Any]) -> bool: @@ -287,12 +373,21 @@ def get_capacity_coverage(tenant_id: str) -> Dict[str, Any]: } -async def create_model_for_tenant(user_id: str, tenant_id: str, model_data: Dict[str, Any]): +async def create_model_for_tenant( + user_id: str, + tenant_id: str, + model_data: Dict[str, Any], + *, + explicit_fields: Optional[set[str]] = None, + accepted_profile_version: Optional[str] = None, +): """Create a single model record for the given tenant. Raises ValueError on display name conflict or invalid input. """ try: + explicit = set(explicit_fields or model_data.keys()) + full_model_name = model_data.get("model_name", "") # Replace localhost with host.docker.internal for local llm model_base_url = model_data.get("base_url", "") if LOCALHOST_NAME in model_base_url or LOCALHOST_IP in model_base_url: @@ -325,7 +420,17 @@ async def create_model_for_tenant(user_id: str, tenant_id: str, model_data: Dict model_name=model_data.get("model_name", "") ) - _coerce_legacy_max_tokens_alias(model_data) + model_data, explicit, accepted_profile_version = _apply_model_profile_resolution( + model_data, + explicit_fields=explicit, + full_model_name=full_model_name, + accepted_profile_version=accepted_profile_version, + ) + model_data = _apply_capacity_governance( + model_data, + explicit_fields=explicit, + accepted_profile_version=accepted_profile_version, + ) validate_capacity_contract(model_data) # Use NOT_DETECTED status as default @@ -467,72 +572,64 @@ async def batch_create_models_for_tenant(user_id: str, tenant_id: str, batch_pay ) validate_capacity_contract(candidate, existing=existing) - # Delete existing models not present. - # The membership key MUST match how existing_model_map (a few lines - # above) and the create-or-update branch (a few lines below) build - # their lookup key, otherwise the two halves disagree about what - # "the same model" means. Both of those use add_repo_to_name, which - # omits the slash when model_repo is empty. The naive - # `model_repo + "/" + model_name` here always prepends "/" for the - # empty-repo case (DashScope catalogs return bare names like - # "glm-4.7" and rows land with model_repo=""), so "/glm-4.7" never - # matched the catalog's "glm-4.7" entry -- every existing row was - # treated as "not in the incoming list" and silently soft-deleted on - # every batch_create. Use the same helper to keep both halves - # speaking the same language. - for model in existing_model_list: - model_full_name = add_repo_to_name( - model_repo=model["model_repo"], - model_name=model["model_name"], - ) - if model_full_name not in model_list_ids: - delete_model_record(model["model_id"], user_id, tenant_id) - - # Create or update new models - for model in model_list: + deletes = [ + model["model_id"] + for model in existing_model_list + if add_repo_to_name(model["model_repo"], model["model_name"]) + not in model_list_ids + ] + creates: list[Dict[str, Any]] = [] + updates: list[tuple[int, Dict[str, Any]]] = [] + + # Prepare and validate every mutation before opening the transaction. + for incoming in model_list: + model = dict(incoming) model["model_type"] = model_type - _, model_name = split_repo_name( - model["id"]) if model.get("id") else ("", "") - model_repo, model_name_only = split_repo_name( - model.get("id", "")) if model.get("id") else ("", "") + model_repo, model_name_only = split_repo_name(model.get("id", "")) model_display_name = add_repo_to_name(model_repo, model_name_only) - if model_name: - existing_model = existing_model_map.get(model_display_name) - if existing_model: - update_data = {} - # Check if max_tokens has changed - existing_max_tokens = existing_model.get("max_tokens") + existing_model = existing_model_map.get(model_display_name) + accepted_version = model.pop("_accepted_profile_version", None) + + if existing_model: + update_data: Dict[str, Any] = {} + if model_type not in CAPACITY_COVERAGE_MODEL_TYPES: new_max_tokens = model.get("max_tokens") - if new_max_tokens is not None and existing_max_tokens != new_max_tokens: + if new_max_tokens is not None and existing_model.get("max_tokens") != new_max_tokens: update_data["max_tokens"] = new_max_tokens - # Same gap as prepare_model_dict had for the create branch: - # the batch refresh path only touched legacy max_tokens, so - # editing a row's capacity via batch-add (e.g. tweaking the - # top-level batch defaults and re-confirming) silently - # dropped the W1/W2 capacity updates. We mirror the - # operator-vs-candidate rule from prepare_model_dict here: - # only persist W1/W2 capacity when the payload is marked - # capacity_source="operator", so provider-discovered hints - # don't auto-overwrite an existing row on a refresh. - if model.get("capacity_source") == "operator": - for field in ( - "context_window_tokens", - "max_input_tokens", - "max_output_tokens", - "default_output_reserve_tokens", - "tokenizer_family", - "capability_profile_version", - ): - new_value = model.get(field) - if new_value is None: - continue - if existing_model.get(field) != new_value: - update_data[field] = new_value - if existing_model.get("capacity_source") != "operator": - update_data["capacity_source"] = "operator" - if update_data: - update_model_record(existing_model["model_id"], update_data, user_id) - continue + if ( + model.get("capacity_source") == "operator" + or model.get("capacity_source") == "provider_candidate" + or accepted_version + or (model_type in CAPACITY_COVERAGE_MODEL_TYPES and "max_tokens" in model) + ): + governed = { + key: value + for key, value in model.items() + if key in GOVERNED_FIELDS or key in {"model_type", "max_tokens"} + } + explicit = set(governed) + governed = _apply_capacity_governance( + governed, + explicit_fields=explicit, + existing=existing_model, + accepted_profile_version=accepted_version, + provider_fields=( + set(GOVERNED_FIELDS).intersection(explicit) + if model.get("capacity_source") == "provider_candidate" + else None + ), + ) + update_data.update( + { + key: value + for key, value in governed.items() + if key != "model_type" + } + ) + if update_data: + validate_capacity_contract(update_data, existing=existing_model) + updates.append((existing_model["model_id"], update_data)) + continue model_dict = await prepare_model_dict( provider=provider, @@ -540,9 +637,33 @@ async def batch_create_models_for_tenant(user_id: str, tenant_id: str, batch_pay model_url=model_url, model_api_key=model_api_key, ) + explicit = set(model) + model_dict, explicit, accepted_version = _apply_model_profile_resolution( + model_dict, + explicit_fields=explicit, + full_model_name=model.get("id", ""), + accepted_profile_version=accepted_version, + ) + model_dict = _apply_capacity_governance( + model_dict, + explicit_fields=explicit, + accepted_profile_version=accepted_version, + provider_fields=( + set(GOVERNED_FIELDS).intersection(explicit) + if model.get("capacity_source") == "provider_candidate" + else None + ), + ) validate_capacity_contract(model_dict) - create_model_record(model_dict, user_id, tenant_id) - logging.debug(f"Model {model['id']} created successfully") + creates.append(model_dict) + + apply_model_mutations( + creates=creates, + updates=updates, + deletes=deletes, + user_id=user_id, + tenant_id=tenant_id, + ) except ModelCapacityConfigError: raise except Exception as e: @@ -569,11 +690,165 @@ async def list_provider_models_for_tenant(tenant_id: str, provider: str, model_t raise Exception(f"Failed to list provider models: {str(e)}") +def _single_capacity_model(display_name: str, tenant_id: str) -> Dict[str, Any]: + records = get_models_by_display_name(display_name, tenant_id) + candidates = [ + record for record in records if record.get("model_type") in CAPACITY_COVERAGE_MODEL_TYPES + ] + if len(candidates) != 1: + raise LookupError(f"Capacity model not found: {display_name}") + return candidates[0] + + +def _resolution_for_record(record: Dict[str, Any]): + return resolve_model_profiles( + model_name=add_repo_to_name( + record.get("model_repo", ""), record.get("model_name", "") + ), + provider=record.get("model_factory"), + base_url=record.get("base_url"), + model_type=record.get("model_type"), + ) + + +async def preview_capacity_adoption_for_tenant( + tenant_id: str, + display_name: str, + *, + expected_matcher_version: Optional[str] = None, +) -> Dict[str, Any]: + record = _single_capacity_model(display_name, tenant_id) + resolution = _resolution_for_record(record) + if expected_matcher_version and expected_matcher_version != MATCHER_VERSION: + raise ModelCapacityConfigError( + "capacity_matcher_stale", "model matcher changed since request" + ) + if not resolution.capacity_match.auto_applicable or not resolution.capacity_match.selected_profile: + raise ModelCapacityConfigError( + "capacity_profile_unverified", + "no unique evidence-complete capacity profile is available", + ) + preview = catalog_adoption_preview( + record, + resolution.capacity_suggestions, + proposed_profile_version=resolution.capacity_match.selected_profile, + ) + preview.update( + { + "display_name": display_name, + "canonical_model_id": resolution.canonical_model_id, + "matcher_version": MATCHER_VERSION, + "capacity_match": serialize_profile_match(resolution.capacity_match), + } + ) + return preview + + +async def adopt_capacity_for_tenant( + user_id: str, + tenant_id: str, + display_name: str, + *, + expected_profile_version: str, + expected_matcher_version: Optional[str] = None, + fields: Optional[List[str]] = None, + reset_manual_fields: Optional[List[str]] = None, +) -> Dict[str, Any]: + record = _single_capacity_model(display_name, tenant_id) + resolution = _resolution_for_record(record) + if not resolution.capacity_match.auto_applicable or not resolution.capacity_match.selected_profile: + raise ModelCapacityConfigError( + "capacity_profile_unverified", + "no unique evidence-complete capacity profile is available", + ) + result = apply_catalog_adoption( + record, + resolution.capacity_suggestions, + proposed_profile_version=resolution.capacity_match.selected_profile, + expected_profile_version=expected_profile_version, + current_matcher_version=MATCHER_VERSION, + expected_matcher_version=expected_matcher_version, + fields=fields, + reset_manual_fields=reset_manual_fields or (), + ) + validate_capacity_contract(result.values) + changed_fields = {item["field"] for item in result.audit_delta} + update_data = {field: result.values.get(field) for field in changed_fields} + update_data.update( + { + "capacity_field_metadata": result.metadata, + "capacity_source": result.row_capacity_source, + "capability_profile_version": result.capability_profile_version, + "canonical_model_id": resolution.canonical_model_id, + "model_identity_metadata": dict(resolution.identity_metadata), + } + ) + update_model_record(record["model_id"], update_data, user_id) + logger.info( + "capacity_catalog_adoption model_id=%s profile_version=%s matcher_version=%s fields=%s", + record["model_id"], + result.capability_profile_version or "mixed", + MATCHER_VERSION, + ",".join(sorted(changed_fields)) or "none", + ) + return { + "display_name": display_name, + "profile_version": result.capability_profile_version, + "matcher_version": MATCHER_VERSION, + "updated_fields": sorted(changed_fields), + "audit": list(result.audit_delta), + } + + +async def probe_token_count_for_tenant( + user_id: str, + tenant_id: str, + display_name: str, + *, + force: bool = False, +) -> Dict[str, Any]: + record = _single_capacity_model(display_name, tenant_id) + resolution = _resolution_for_record(record) + factory = (record.get("model_factory") or "").lower() + if factory == "anthropic": + protocol = "anthropic" + elif factory in {"gemini", "google"}: + protocol = "gemini" + elif factory in { + "openai", "openai-api-compatible", "dashscope", "silicon", + "siliconflow", "modelengine", "deepseek", + }: + protocol = "openai" + else: + protocol = "unknown" + metadata = await run_token_count_probe( + inference_protocol=protocol, + base_url=record.get("base_url") or "", + model_name=add_repo_to_name( + record.get("model_repo", ""), record.get("model_name", "") + ), + canonical_model_id=resolution.canonical_model_id, + api_key=record.get("api_key") or "", + credential_scope=f"{tenant_id}:{record['model_id']}:{user_id}", + fingerprint_salt=tenant_id, + existing=record.get("token_count_probe_metadata"), + force=force, + ) + if metadata != record.get("token_count_probe_metadata"): + update_model_record( + record["model_id"], {"token_count_probe_metadata": metadata}, user_id + ) + return metadata + + async def update_single_model_for_tenant( user_id: str, tenant_id: str, current_display_name: str, - model_data: Dict[str, Any] + model_data: Dict[str, Any], + *, + explicit_fields: Optional[set[str]] = None, + accepted_profile_version: Optional[str] = None, ): """Update model(s) by current display_name. If embedding/multi_embedding, update both types. @@ -617,15 +892,43 @@ async def update_single_model_for_tenant( else: model_data["ssl_verify"] = True - # Carry model_type from the existing record so the legacy-alias - # coercion can distinguish LLM/VLM updates from embedding updates - # even when the caller payload omits model_type. We don't store the - # injected model_type back on model_data because the update path - # explicitly strips it later. existing_model_type = existing_models[0].get("model_type") if existing_models else None - if model_data.get("max_output_tokens") is not None and \ - existing_model_type not in ("embedding", "multi_embedding"): - model_data["max_tokens"] = model_data["max_output_tokens"] + explicit = set(explicit_fields or model_data.keys()) + if explicit.intersection({"model_name", "model_repo", "base_url", "model_factory", "capacity_mode"}) or accepted_profile_version: + resolution_input = dict(existing_models[0]) + resolution_input.update(model_data) + full_model_name = add_repo_to_name( + resolution_input.get("model_repo", ""), + resolution_input.get("model_name", ""), + ) + model_data, explicit, accepted_profile_version = _apply_model_profile_resolution( + resolution_input, + explicit_fields=explicit, + full_model_name=full_model_name, + accepted_profile_version=accepted_profile_version, + ) + # Retain only fields that are part of this partial update plus the + # newly evaluated match metadata and auto-applied capacity facts. + allowed = explicit.union( + { + "canonical_model_id", + "model_identity_metadata", + "tokenizer_match_metadata", + "model_type", + } + ) + model_data = {key: value for key, value in model_data.items() if key in allowed} + governance_input = dict(model_data) + model_type_was_supplied = "model_type" in governance_input + governance_input.setdefault("model_type", existing_model_type) + model_data = _apply_capacity_governance( + governance_input, + explicit_fields=explicit, + existing=existing_models[0], + accepted_profile_version=accepted_profile_version, + ) + if not model_type_was_supplied: + model_data.pop("model_type", None) validate_capacity_contract(model_data, existing=existing_models[0]) @@ -656,8 +959,8 @@ async def update_single_model_for_tenant( async def batch_update_models_for_tenant(user_id: str, tenant_id: str, model_list: List[Dict[str, Any]]): """Batch update models for a tenant by model_id or model_name.""" try: + prepared_updates: list[tuple[int, Dict[str, Any]]] = [] for model in model_list: - _coerce_legacy_max_tokens_alias(model) # Build update data excluding id fields update_data = {k: v for k, v in model.items() if k not in ["model_id", "model_name"]} @@ -671,8 +974,7 @@ async def batch_update_models_for_tenant(user_id: str, tenant_id: str, model_lis if current_model is None: logging.warning("Model not found: model_id=%s", model_id_or_name) continue - validate_capacity_contract(model, existing=current_model) - update_model_record(int(model_id_or_name), update_data, user_id, tenant_id) + target_model_id = int(model_id_or_name) else: # Parse "model_repo/model_name" format from frontend's model_id field if "/" in model_id_or_name: @@ -689,9 +991,46 @@ async def batch_update_models_for_tenant(user_id: str, tenant_id: str, model_lis logging.warning(f"Model not found: model_name={model_name}, model_repo={model_repo}, tenant_id={tenant_id}") continue - validate_capacity_contract(model, existing=model_record) - - update_model_record(model_record["model_id"], update_data, user_id, tenant_id) + current_model = model_record + target_model_id = model_record["model_id"] + + explicit = set(update_data) + if explicit.intersection({"base_url", "model_factory", "capacity_mode"}): + resolution_input = dict(current_model) + resolution_input.update(update_data) + resolved, explicit, _ = _apply_model_profile_resolution( + resolution_input, + explicit_fields=explicit, + full_model_name=add_repo_to_name( + resolution_input.get("model_repo", ""), + resolution_input.get("model_name", ""), + ), + accepted_profile_version=None, + ) + allowed = explicit.union( + {"canonical_model_id", "model_identity_metadata", "tokenizer_match_metadata"} + ) + update_data = { + key: value for key, value in resolved.items() if key in allowed + } + governance_input = dict(update_data) + governance_input["model_type"] = current_model.get("model_type") + governed = _apply_capacity_governance( + governance_input, + explicit_fields=explicit, + existing=current_model, + ) + governed.pop("model_type", None) + validate_capacity_contract(governed, existing=current_model) + prepared_updates.append((target_model_id, governed)) + + apply_model_mutations( + creates=[], + updates=prepared_updates, + deletes=[], + user_id=user_id, + tenant_id=tenant_id, + ) logging.info("[DEBUG] Batch update models successfully") except ModelCapacityConfigError: @@ -756,6 +1095,9 @@ async def list_models_for_tenant(tenant_id: str): } for record in records: + record["capacity_field_metadata"] = derive_legacy_metadata(record) + if record.get("model_type") in CAPACITY_COVERAGE_MODEL_TYPES: + record["max_tokens"] = record.get("max_output_tokens") record["model_name"] = add_repo_to_name( model_repo=record["model_repo"], model_name=record["model_name"], @@ -836,6 +1178,9 @@ async def list_models_for_admin( # Normalize model records normalized_models: List[Dict[str, Any]] = [] for record in records: + record["capacity_field_metadata"] = derive_legacy_metadata(record) + if record.get("model_type") in CAPACITY_COVERAGE_MODEL_TYPES: + record["max_tokens"] = record.get("max_output_tokens") record["model_name"] = add_repo_to_name( model_repo=record["model_repo"], model_name=record["model_name"], diff --git a/backend/services/model_profile_match_service.py b/backend/services/model_profile_match_service.py new file mode 100644 index 0000000000..d4bdb64136 --- /dev/null +++ b/backend/services/model_profile_match_service.py @@ -0,0 +1,175 @@ +"""Independent, versioned capacity and tokenizer resolution for model setup.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +import logging +from typing import Any, Mapping, Optional + +from consts.capability_profiles import CATALOG +from nexent.core.models.model_identity import MATCHER_VERSION, parse_model_identity +from nexent.core.models.tokenizer_registry import resolve_for_model +from services.model_capacity_suggestion_service import CapacitySuggestionResult, suggest_capacity + + +MATCH_SCHEMA_VERSION = 1 +logger = logging.getLogger("model_profile_match_service") + + +@dataclass(frozen=True) +class ProfileMatch: + selected_profile: Optional[str] + confidence: Optional[str] + source: str + reason: str + matcher_version: str + candidates: tuple[str, ...] = () + auto_applicable: bool = False + + +@dataclass(frozen=True) +class ModelProfileResolution: + canonical_model_id: str + identity_metadata: Mapping[str, Any] + capacity_match: ProfileMatch + tokenizer_match: ProfileMatch + tokenizer_family: Optional[str] + tokenizer_counting_mode: str + capacity_suggestions: Mapping[str, Any] + + +def _evaluated_at() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _capacity_profile(version: Optional[str]): + if not version: + return None + return next( + (profile for profile in CATALOG.values() if profile.capability_profile_version == version), + None, + ) + + +def resolve_model_profiles( + *, + model_name: str, + provider: Optional[str], + base_url: Optional[str], + model_type: Optional[str], + capacity_result: Optional[CapacitySuggestionResult] = None, +) -> ModelProfileResolution: + capacity = capacity_result or suggest_capacity( + model_name=model_name, + base_url=base_url, + provider_hint=provider, + model_type=model_type, + catalog=CATALOG, + ) + identity_provider = capacity.suggested_provider or provider + identity = parse_model_identity(model_name, identity_provider) + matched_profile = _capacity_profile(capacity.capability_profile_version) + identity_candidates = tuple( + sorted( + profile.capability_profile_version + for (catalog_provider, _), profile in CATALOG.items() + if (not identity_provider or catalog_provider == identity_provider) + and parse_model_identity(profile.model_name, catalog_provider).family + == identity.family + ) + ) if identity.family else () + unresolved_candidates = () if matched_profile else identity_candidates + capacity_match = ProfileMatch( + selected_profile=capacity.capability_profile_version, + confidence=capacity.match_confidence.value if capacity.match_confidence else None, + source="catalog" if capacity.capability_profile_version else "unknown", + reason=( + "capacity_profile_ambiguous" + if len(unresolved_candidates) > 1 + else capacity.match_kind.value + ), + matcher_version=MATCHER_VERSION, + candidates=unresolved_candidates, + auto_applicable=bool( + matched_profile + and matched_profile.auto_applicable + and capacity.match_confidence + and capacity.match_confidence.value == "high" + ), + ) + tokenizer = resolve_for_model(identity_provider, model_name) + tokenizer_match = ProfileMatch( + selected_profile=tokenizer.profile_id, + confidence=tokenizer.confidence, + source="catalog" if tokenizer.source == "profile" else "unknown", + reason=tokenizer.reason, + matcher_version=tokenizer.matcher_version, + candidates=tokenizer.candidates, + auto_applicable=tokenizer.counting_mode == "exact", + ) + capacity_suggestions = ( + asdict(capacity.suggestions) if capacity.suggestions is not None else {} + ) + # P1 deliberately separates the two matchers. A capacity catalog row may + # retain a legacy tokenizer hint for compatibility, but it is not an + # automatic tokenizer fact without an independently verified match. + capacity_suggestions["tokenizer_family"] = ( + tokenizer.family if tokenizer.counting_mode == "exact" else None + ) + identity_metadata = { + "schema_version": MATCH_SCHEMA_VERSION, + "canonical_id": identity.canonical_id, + "resolved": identity.resolved, + "ambiguity": identity.ambiguous or len(unresolved_candidates) > 1, + "confidence": identity.confidence, + "attributes": { + key: value + for key, value in identity.model_dump().items() + if key + not in { + "raw_model_id", + "canonical_id", + "resolved", + "ambiguous", + "confidence", + "matcher_version", + "evidence", + "candidates", + } + }, + "evidence": list(identity.evidence), + "candidates": list(unresolved_candidates or identity.candidates), + "matcher_version": identity.matcher_version, + "evaluated_at": _evaluated_at(), + } + result = ModelProfileResolution( + canonical_model_id=identity.canonical_id, + identity_metadata=identity_metadata, + capacity_match=capacity_match, + tokenizer_match=tokenizer_match, + tokenizer_family=tokenizer.family, + tokenizer_counting_mode=tokenizer.counting_mode, + capacity_suggestions=capacity_suggestions, + ) + logger.info( + "model_profile_resolution canonical_id=%s matcher_version=%s capacity_profile=%s capacity_confidence=%s capacity_reason=%s tokenizer_profile=%s tokenizer_confidence=%s tokenizer_reason=%s", + result.canonical_model_id, + MATCHER_VERSION, + result.capacity_match.selected_profile or "none", + result.capacity_match.confidence or "unknown", + result.capacity_match.reason, + result.tokenizer_match.selected_profile or "none", + result.tokenizer_match.confidence or "unknown", + result.tokenizer_match.reason, + ) + return result + + +def serialize_profile_match(match: ProfileMatch) -> dict[str, Any]: + return { + "schema_version": MATCH_SCHEMA_VERSION, + **asdict(match), + "candidates": list(match.candidates), + "evaluated_at": _evaluated_at(), + } diff --git a/backend/services/model_token_count_probe_service.py b/backend/services/model_token_count_probe_service.py new file mode 100644 index 0000000000..6651638a0f --- /dev/null +++ b/backend/services/model_token_count_probe_service.py @@ -0,0 +1,363 @@ +"""Safe, explicit discovery of optional provider token-count endpoints.""" + +from __future__ import annotations + +import hashlib +import ipaddress +import json +import logging +import socket +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any, Awaitable, Callable, Iterable, Mapping, Optional +from urllib.parse import urlsplit, urlunsplit + +import httpx + + +PROBE_SCHEMA_VERSION = 1 +PROBE_ADAPTER_VERSION = "1.0.0" +MAX_COUNT = 100_000_000 +MAX_RESPONSE_BYTES = 64 * 1024 +SUPPORTED_TTL = timedelta(days=7) +UNSUPPORTED_TTL = timedelta(days=1) +TEMPORARY_RETRY = timedelta(minutes=15) +PROBE_TEXT = "Nexent token count probe." +logger = logging.getLogger("model_token_count_probe_service") + +ProbeState = str + + +@dataclass(frozen=True) +class ProbeHTTPRequest: + protocol: str + url: str + headers: Mapping[str, str] + body: Mapping[str, Any] + + +@dataclass(frozen=True) +class ProbeHTTPResponse: + status_code: int + payload: Optional[Mapping[str, Any]] = None + redirect_location: Optional[str] = None + + +Transport = Callable[[ProbeHTTPRequest], Awaitable[ProbeHTTPResponse]] +Resolver = Callable[[str], Iterable[str]] + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _iso(value: datetime) -> str: + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _parse_time(value: Any) -> Optional[datetime]: + if not isinstance(value, str): + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + + +def _default_resolver(host: str) -> Iterable[str]: + return {item[4][0] for item in socket.getaddrinfo(host, None)} + + +def validate_probe_url( + url: str, + *, + allow_private: bool = False, + resolver: Resolver = _default_resolver, +) -> str: + """Validate and normalize a configured URL before credentials are attached.""" + parsed = urlsplit(url) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise ValueError("ssrf_rejected") + if parsed.username or parsed.password or parsed.query or parsed.fragment: + raise ValueError("ssrf_rejected") + try: + addresses = tuple(resolver(parsed.hostname)) + except (OSError, socket.gaierror) as exc: + raise ValueError("connection_failed") from exc + if not addresses: + raise ValueError("connection_failed") + if not allow_private: + for address in addresses: + ip = ipaddress.ip_address(address) + if ( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_multicast + or ip.is_reserved + or ip.is_unspecified + ): + raise ValueError("ssrf_rejected") + host = parsed.hostname.lower() + if ":" in host: + host = f"[{host}]" + if parsed.port: + host = f"{host}:{parsed.port}" + return urlunsplit((parsed.scheme.lower(), host, parsed.path.rstrip("/"), "", "")) + + +def endpoint_fingerprint(url: str) -> str: + parsed = urlsplit(url) + normalized = urlunsplit( + ( + parsed.scheme.lower(), + (parsed.hostname or "").lower() + + (f":{parsed.port}" if parsed.port else ""), + parsed.path.rstrip("/"), + "", + "", + ) + ) + return hashlib.sha256(normalized.encode()).hexdigest()[:24] + + +def credential_scope_fingerprint(scope_identity: str, *, salt: str) -> str: + """Hash non-secret tenant/scope identity; API-key material is never accepted.""" + return hashlib.sha256(f"{salt}:{scope_identity}".encode()).hexdigest()[:24] + + +def probe_fingerprint( + *, endpoint: str, model_identity: str, credential_scope: str, adapter_version: str +) -> str: + value = "\0".join((endpoint, model_identity, credential_scope, adapter_version)) + return hashlib.sha256(value.encode()).hexdigest()[:24] + + +def _join(base_url: str, suffix: str) -> str: + return f"{base_url.rstrip('/')}/{suffix.lstrip('/')}" + + +def build_probe_request( + protocol: str, + *, + base_url: str, + model_name: str, + api_key: str, +) -> ProbeHTTPRequest: + if protocol == "openai_responses": + return ProbeHTTPRequest( + protocol=protocol, + url=_join(base_url, "responses/input_tokens"), + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + body={"model": model_name, "input": PROBE_TEXT}, + ) + if protocol == "anthropic_messages": + return ProbeHTTPRequest( + protocol=protocol, + url=_join(base_url, "messages/count_tokens"), + headers={ + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + "Content-Type": "application/json", + }, + body={"model": model_name, "messages": [{"role": "user", "content": PROBE_TEXT}]}, + ) + if protocol == "gemini": + return ProbeHTTPRequest( + protocol=protocol, + url=_join(base_url, f"models/{model_name}:countTokens"), + headers={"x-goog-api-key": api_key, "Content-Type": "application/json"}, + body={"contents": [{"parts": [{"text": PROBE_TEXT}]}]}, + ) + raise ValueError("unsupported_protocol") + + +def _extract_count(protocol: str, payload: Optional[Mapping[str, Any]]) -> Optional[int]: + if not isinstance(payload, Mapping): + return None + if protocol in {"openai_responses", "anthropic_messages"}: + value = payload.get("input_tokens") + else: + value = payload.get("totalTokens") + return value if isinstance(value, int) and not isinstance(value, bool) else None + + +def classify_probe_response( + protocol: str, response: ProbeHTTPResponse +) -> tuple[ProbeState, str, Optional[int]]: + status = response.status_code + if response.redirect_location is not None or 300 <= status < 400: + return "temporarily_unavailable", "redirect_rejected", None + if status in {404, 405}: + return "unsupported", "unsupported_endpoint", None + if status in {401, 403}: + return "authorization_error", "authorization_failed", None + if status == 429: + return "temporarily_unavailable", "rate_limited", None + if status >= 500: + return "temporarily_unavailable", "provider_5xx", None + if not 200 <= status < 300: + return "temporarily_unavailable", "connection_failed", None + count = _extract_count(protocol, response.payload) + if count is None: + return "invalid_response", "invalid_schema", None + if count <= 0 or count > MAX_COUNT: + return "invalid_response", "invalid_count", None + return "supported", "supported", count + + +async def _httpx_transport(request: ProbeHTTPRequest) -> ProbeHTTPResponse: + timeout = httpx.Timeout(5.0, connect=3.0) + try: + async with httpx.AsyncClient( + timeout=timeout, follow_redirects=False, trust_env=False + ) as client: + response = await client.post( + request.url, headers=dict(request.headers), json=dict(request.body) + ) + location = response.headers.get("location") if 300 <= response.status_code < 400 else None + if len(response.content) > MAX_RESPONSE_BYTES: + return ProbeHTTPResponse(response.status_code, payload=None, redirect_location=location) + try: + payload = response.json() + except (ValueError, json.JSONDecodeError): + payload = None + return ProbeHTTPResponse(response.status_code, payload=payload, redirect_location=location) + except httpx.TimeoutException as exc: + raise TimeoutError("timeout") from exc + except httpx.RequestError as exc: + raise ConnectionError("connection_failed") from exc + + +def should_reuse_probe( + existing: Optional[Mapping[str, Any]], + *, + current_fingerprint: str, + now: datetime, + force: bool, +) -> bool: + if force or not existing or existing.get("fingerprint") != current_fingerprint: + return False + if existing.get("status") not in {"supported", "unsupported"}: + return False + stale_at = _parse_time(existing.get("stale_at")) + return stale_at is not None and stale_at > now + + +async def run_token_count_probe( + *, + inference_protocol: str, + base_url: str, + model_name: str, + canonical_model_id: str, + api_key: str, + credential_scope: str, + fingerprint_salt: str, + existing: Optional[Mapping[str, Any]] = None, + force: bool = False, + allow_private: bool = False, + resolver: Resolver = _default_resolver, + transport: Transport = _httpx_transport, + now: Optional[datetime] = None, +) -> dict[str, Any]: + """Run a directed/ordered explicit probe and return sanitized metadata.""" + checked_at = now or _utcnow() + safe_base = validate_probe_url( + base_url, allow_private=allow_private, resolver=resolver + ) + endpoint_fp = endpoint_fingerprint(safe_base) + scope_fp = credential_scope_fingerprint(credential_scope, salt=fingerprint_salt) + fingerprint = probe_fingerprint( + endpoint=endpoint_fp, + model_identity=canonical_model_id, + credential_scope=scope_fp, + adapter_version=PROBE_ADAPTER_VERSION, + ) + if should_reuse_probe( + existing, current_fingerprint=fingerprint, now=checked_at, force=force + ): + return dict(existing or {}) + + directed = { + "openai": ("openai_responses",), + "anthropic": ("anthropic_messages",), + "gemini": ("gemini",), + } + protocols = directed.get( + inference_protocol, + ("openai_responses", "anthropic_messages", "gemini"), + ) + outcomes: list[dict[str, Any]] = [] + selected: Optional[str] = None + selected_count: Optional[int] = None + for protocol in protocols: + request = build_probe_request( + protocol, base_url=safe_base, model_name=model_name, api_key=api_key + ) + # Every derived target is checked and must remain on the configured origin. + safe_target = validate_probe_url( + request.url, allow_private=allow_private, resolver=resolver + ) + if urlsplit(safe_target).netloc != urlsplit(safe_base).netloc: + state, reason, count = "temporarily_unavailable", "redirect_rejected", None + else: + try: + response = await transport(request) + state, reason, count = classify_probe_response(protocol, response) + except TimeoutError: + state, reason, count = "temporarily_unavailable", "timeout", None + except ConnectionError: + state, reason, count = "temporarily_unavailable", "connection_failed", None + outcomes.append({"protocol": protocol, "state": state, "reason": reason}) + if state == "supported" and selected is None: + selected, selected_count = protocol, count + # Unknown protocols are ordered discovery: retain prior failures and + # stop after the first valid dialect to avoid unnecessary credential use. + if selected is not None and inference_protocol not in directed: + break + + if selected is not None: + status, reason = "supported", "supported" + stale_at = checked_at + SUPPORTED_TTL + retry_at = None + elif outcomes and all(item["state"] == "unsupported" for item in outcomes): + status, reason = "unsupported", "unsupported_endpoint" + stale_at = checked_at + UNSUPPORTED_TTL + retry_at = None + else: + last = outcomes[-1] if outcomes else {"state": "unknown", "reason": "unknown"} + status, reason = last["state"], last["reason"] + stale_at = checked_at + retry_at = checked_at + TEMPORARY_RETRY if status == "temporarily_unavailable" else None + + metadata = { + "schema_version": PROBE_SCHEMA_VERSION, + "status": status, + "reason": reason, + "selected_protocol": selected, + "capabilities": { + "text": "supported" if selected else status, + "tools": "unknown", + "media": "unknown", + }, + **({"probe_token_count": selected_count} if selected_count is not None else {}), + "outcomes": outcomes, + "checked_at": _iso(checked_at), + "stale_at": _iso(stale_at), + **({"retry_at": _iso(retry_at)} if retry_at else {}), + "adapter_version": PROBE_ADAPTER_VERSION, + "endpoint_fingerprint": endpoint_fp, + "model_identity": canonical_model_id, + "credential_scope_fingerprint": scope_fp, + "fingerprint": fingerprint, + } + logger.info( + "token_count_probe protocol=%s state=%s reason=%s endpoint_fingerprint=%s model_identity=%s adapter_version=%s", + selected or inference_protocol, + status, + reason, + endpoint_fp, + canonical_model_id, + PROBE_ADAPTER_VERSION, + ) + return metadata diff --git a/deploy/sql/migrations/v2.5.0_0824_context_budget_p1_governance.sql b/deploy/sql/migrations/v2.5.0_0824_context_budget_p1_governance.sql new file mode 100644 index 0000000000..7ee4b06a7c --- /dev/null +++ b/deploy/sql/migrations/v2.5.0_0824_context_budget_p1_governance.sql @@ -0,0 +1,35 @@ +-- Context-budget P1 nullable governance metadata. +-- Fresh installs and upgrades both replay this idempotent migration after the +-- deploy/sql/init.sql baseline. No row backfill or network work is performed. + +SET search_path TO nexent; + +BEGIN; + +ALTER TABLE nexent.model_record_t + ADD COLUMN IF NOT EXISTS canonical_model_id VARCHAR(512) DEFAULT NULL; + +ALTER TABLE nexent.model_record_t + ADD COLUMN IF NOT EXISTS capacity_field_metadata JSONB DEFAULT NULL; + +ALTER TABLE nexent.model_record_t + ADD COLUMN IF NOT EXISTS model_identity_metadata JSONB DEFAULT NULL; + +ALTER TABLE nexent.model_record_t + ADD COLUMN IF NOT EXISTS tokenizer_match_metadata JSONB DEFAULT NULL; + +ALTER TABLE nexent.model_record_t + ADD COLUMN IF NOT EXISTS token_count_probe_metadata JSONB DEFAULT NULL; + +COMMENT ON COLUMN nexent.model_record_t.canonical_model_id IS + 'Versioned canonical model identity used for independent capacity and tokenizer matching.'; +COMMENT ON COLUMN nexent.model_record_t.capacity_field_metadata IS + 'Versioned field-level source, confidence, evidence, and verification metadata. Contains no secrets.'; +COMMENT ON COLUMN nexent.model_record_t.model_identity_metadata IS + 'Versioned canonical parsing and capacity-match evidence. Contains no credentials or raw payloads.'; +COMMENT ON COLUMN nexent.model_record_t.tokenizer_match_metadata IS + 'Versioned independent tokenizer profile match and conformance state.'; +COMMENT ON COLUMN nexent.model_record_t.token_count_probe_metadata IS + 'Versioned sanitized Provider token-count capability probe state. Contains no keys or raw bodies.'; + +COMMIT; diff --git a/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx b/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx index 8c67476cfc..53e8439c54 100644 --- a/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx +++ b/frontend/app/[locale]/models/components/model/ModelAddDialog.tsx @@ -711,6 +711,9 @@ export const ModelAddDialog = ({ result.capacitySuggestion ) { setTopSuggestion(result.capacitySuggestion); + if (result.capacitySuggestion.capacityMatch?.autoApplicable) { + applyCapacitySuggestion(result.capacitySuggestion); + } } } @@ -824,12 +827,9 @@ export const ModelAddDialog = ({ return { ...model, ...resolved, - // Mirror max_output_tokens into legacy max_tokens. Backend has a coercion - // helper but mirroring here keeps the wire payload self-consistent. - max_tokens: - resolved.max_output_tokens ?? - model.max_tokens ?? - parseMaxTokens(form.maxTokens), + // P1 makes max_tokens ingress-only for capacity models. Explicitly + // remove provider-list legacy values from the serialized batch row. + max_tokens: undefined, }; }; @@ -1032,9 +1032,6 @@ export const ModelAddDialog = ({ capacity_source: hasAny ? payload.capacity_source : model.capacity_source, - // Mirror max_output_tokens into legacy max_tokens so the - // backend coercion path stays consistent for rows that bypass it. - max_tokens: payload.max_output_tokens ?? model.max_tokens, accepted_suggestion_match_kind: gearAccepted?.matchKind || undefined, accepted_capability_profile_version: @@ -1085,10 +1082,7 @@ export const ModelAddDialog = ({ // Determine the maximum tokens value. // For LLM/VLM (supportsCapacityFields), the legacy form.maxTokens // input is hidden and must not be read here per the W1/W2 plan - // ("Never use legacy max_tokens"). Seed the legacy column with 0; - // buildCapacityPayload(form) spreads max_tokens := max_output_tokens - // a few lines below, keeping the deprecated NOT NULL column aligned - // with the W2 source of truth. + // ("Never use legacy max_tokens"). let maxTokensValue = supportsCapacityFields ? 0 : parseMaxTokens(form.maxTokens) || 0; @@ -1107,7 +1101,8 @@ export const ModelAddDialog = ({ const acceptSignalKwargs = topAccepted ? { acceptedSuggestionMatchKind: topAccepted.matchKind, - ...(topAccepted.capabilityProfileVersion + ...(topAccepted.capabilityProfileVersion && + topAccepted.capacityMatch?.autoApplicable ? { acceptedCapabilityProfileVersion: topAccepted.capabilityProfileVersion, @@ -1124,9 +1119,12 @@ export const ModelAddDialog = ({ type: modelType, url: form.url, apiKey: form.apiKey.trim() === "" ? "sk-no-api-key" : form.apiKey, - maxTokens: maxTokensValue, + ...(!supportsCapacityFields ? { maxTokens: maxTokensValue } : {}), displayName: form.displayName || form.name, ...(supportsCapacityFields ? buildCapacityPayload(form) : {}), + ...(supportsCapacityFields && capacitySuggestionEnabled + ? { capacityMode: "auto" } + : {}), ...acceptSignalKwargs, }; @@ -1166,9 +1164,12 @@ export const ModelAddDialog = ({ type: modelType, url: form.url, apiKey: form.apiKey.trim() === "" ? "sk-no-api-key" : form.apiKey, - maxTokens: maxTokensValue, + ...(!supportsCapacityFields ? { maxTokens: maxTokensValue } : {}), displayName: form.displayName || form.name, ...(supportsCapacityFields ? buildCapacityPayload(form) : {}), + ...(supportsCapacityFields && capacitySuggestionEnabled + ? { capacityMode: "auto" } + : {}), ...acceptSignalKwargs, }; @@ -1849,6 +1850,12 @@ export const ModelAddDialog = ({ suggestionLoading={topChecking} onUseSuggestion={() => applyCapacitySuggestion(topSuggestion)} acceptedSuggestion={topAccepted} + capacityFieldMetadata={ + topAccepted?.governanceMetadataProposal || + topSuggestion?.governanceMetadataProposal + } + canonicalModelId={topSuggestion?.canonicalIdentity?.canonicalId} + tokenizerMatchMetadata={topSuggestion?.tokenizerMatch} disabled={verifyingConnectivity} /> diff --git a/frontend/app/[locale]/models/components/model/ModelCapacityFields.tsx b/frontend/app/[locale]/models/components/model/ModelCapacityFields.tsx index da25b2d22f..e052a5030f 100644 --- a/frontend/app/[locale]/models/components/model/ModelCapacityFields.tsx +++ b/frontend/app/[locale]/models/components/model/ModelCapacityFields.tsx @@ -1,7 +1,12 @@ import { Alert, AutoComplete, Button, Input, Space, Tag, Tooltip } from "antd"; import { useTranslation } from "react-i18next"; -import type { CapacitySuggestion } from "@/types/modelConfig"; +import type { + CapacityFieldMetadata, + CapacitySuggestion, + ProfileMatchMetadata, + TokenCountProbeMetadata, +} from "@/types/modelConfig"; import { buildCamelCapacityPayload } from "@/lib/modelCapacityPayload"; // W11 spec L767-790. Common token-count presets surfaced as a fallback @@ -56,6 +61,14 @@ interface ModelCapacityFieldsProps { validationError?: string | null; capacitySource?: CapacitySource | null; capabilityProfileVersion?: string | null; + capacityFieldMetadata?: CapacityFieldMetadata | null; + canonicalModelId?: string | null; + tokenizerMatchMetadata?: ProfileMatchMetadata | null; + tokenCountProbeMetadata?: TokenCountProbeMetadata | null; + onReviewAutomaticUpdate?: () => void; + reviewingAutomaticUpdate?: boolean; + onProbeTokenCount?: () => void; + probingTokenCount?: boolean; /** * 'add' shows a flat panel with the four user-facing fields * (context_window, max_input, max_output, tokenizer) and supports required @@ -231,6 +244,14 @@ export const ModelCapacityFields = ({ validationError, capacitySource, capabilityProfileVersion, + capacityFieldMetadata, + canonicalModelId, + tokenizerMatchMetadata, + tokenCountProbeMetadata, + onReviewAutomaticUpdate, + reviewingAutomaticUpdate = false, + onProbeTokenCount, + probingTokenCount = false, formMode = "edit", requiredFields = [], suggestion, @@ -306,6 +327,14 @@ export const ModelCapacityFields = ({ presetOptions?: { value: string; label: string }[] ) => { const showPreset = presetOptions && !fieldHasSuggestion(field); + const provenance = capacityFieldMetadata?.fields[field]; + const sourceLabels: Record = { + catalog: "Automatic", + provider: "Provider", + operator: "Manual", + legacy: "Legacy", + unknown: "Unknown", + }; const inputControl = showPreset ? ( * )} + {provenance?.source && ( + + {t(`model.dialog.capacity.fieldSource.${provenance.source}`, { + defaultValue: + sourceLabels[provenance.source] || provenance.source, + })} + + )} {inputControl} @@ -367,6 +417,51 @@ export const ModelCapacityFields = ({ )} + {(canonicalModelId || + tokenizerMatchMetadata || + tokenCountProbeMetadata) && ( +
+ {canonicalModelId &&
Model identity: {canonicalModelId}
} + {tokenizerMatchMetadata && ( +
+ Tokenizer:{" "} + {tokenizerMatchMetadata.autoApplicable ? "Exact" : "Estimated"} + {tokenizerMatchMetadata.reason + ? ` · ${tokenizerMatchMetadata.reason}` + : ""} +
+ )} + {tokenCountProbeMetadata && ( +
+ Count endpoint: {tokenCountProbeMetadata.status} ·{" "} + {tokenCountProbeMetadata.reason} +
+ )} + + {onReviewAutomaticUpdate && ( + + )} + {onProbeTokenCount && ( + + )} + +
+ )} + {showLegacyMaxTokensPrompt ? ( diff --git a/frontend/app/[locale]/models/components/model/ModelEditDialog.tsx b/frontend/app/[locale]/models/components/model/ModelEditDialog.tsx index 6e48069ddf..74cecf622b 100644 --- a/frontend/app/[locale]/models/components/model/ModelEditDialog.tsx +++ b/frontend/app/[locale]/models/components/model/ModelEditDialog.tsx @@ -7,7 +7,11 @@ import { MODEL_TYPES, MODEL_STATUS } from "@/const/modelConfig"; import { useConfig } from "@/hooks/useConfig"; import { useCapacitySuggestion } from "@/hooks/useCapacitySuggestion"; import { modelService } from "@/services/modelService"; -import { ModelOption, ModelType } from "@/types/modelConfig"; +import { + ModelOption, + ModelType, + TokenCountProbeMetadata, +} from "@/types/modelConfig"; import { getConnectivityMeta, ConnectivityStatusType } from "@/lib/utils"; import { ModelChunkSizeSlider, @@ -80,6 +84,11 @@ export const ModelEditDialog = ({ ...emptyCapacityForm, }); const [loading, setLoading] = useState(false); + const [reviewingAutomaticUpdate, setReviewingAutomaticUpdate] = + useState(false); + const [probingTokenCount, setProbingTokenCount] = useState(false); + const [probeMetadata, setProbeMetadata] = + useState(null); const [verifyingConnectivity, setVerifyingConnectivity] = useState(false); const [capacitySuggestionEnabled, setCapacitySuggestionEnabled] = useState(true); @@ -100,8 +109,87 @@ export const ModelEditDialog = ({ // should trigger an API call. const autoSuggestFiredRef = useRef(false); + const reviewAutomaticUpdate = async () => { + if (!model) return; + setReviewingAutomaticUpdate(true); + try { + const preview = await modelService.previewCapacityAdoption( + model.displayName, + model.modelIdentityMetadata?.matcherVersion, + tenantId + ); + const changed = Object.entries(preview.fields).filter( + ([, item]) => item.changed + ); + Modal.confirm({ + title: "Review automatic capacity update", + content: ( +
+ {changed.length === 0 ? ( +
No catalog changes are available.
+ ) : ( + changed.map(([field, item]) => ( +
+ {field} + + {String(item.currentValue ?? "Unknown")} →{" "} + {String(item.proposedValue ?? "Unknown")} + {item.blockedByManual ? " · Manual value preserved" : ""} + +
+ )) + )} +
+ ), + okText: "Adopt automatic values", + okButtonProps: { + disabled: changed.every(([, item]) => !item.applicable), + }, + onOk: async () => { + await modelService.adoptCapacity({ + displayName: model.displayName, + expectedProfileVersion: preview.proposedProfileVersion, + expectedMatcherVersion: preview.matcherVersion, + tenantId, + }); + message.success("Automatic capacity values adopted"); + await onSuccess(); + }, + }); + } catch (error) { + message.error( + error instanceof Error + ? error.message + : "Failed to review automatic update" + ); + } finally { + setReviewingAutomaticUpdate(false); + } + }; + + const probeTokenCount = async () => { + if (!model) return; + setProbingTokenCount(true); + try { + const result = await modelService.probeTokenCount( + model.displayName, + true, + tenantId + ); + setProbeMetadata(result); + message.success(`Token-count endpoint: ${result.status}`); + } catch (error) { + message.error( + error instanceof Error ? error.message : "Token-count probe failed" + ); + } finally { + setProbingTokenCount(false); + } + }; + useEffect(() => { if (model) { + setProbeMetadata(model.tokenCountProbeMetadata || null); setForm({ type: model.type, name: model.name, @@ -363,10 +451,7 @@ export const ModelEditDialog = ({ // Determine max tokens. // For LLM/VLM (supportsCapacityFields), the legacy form.maxTokens // input is hidden and must not be read here per the W1/W2 plan - // ("Never use legacy max_tokens"). Seed the legacy column with 0; - // buildCapacityPayload(form) spreads max_tokens := max_output_tokens - // a few lines below, keeping the deprecated NOT NULL column aligned - // with the W2 source of truth. + // ("Never use legacy max_tokens"). let maxTokensValue = supportsCapacityFields ? 0 : parseMaxTokens(form.maxTokens) || 0; @@ -424,7 +509,8 @@ export const ModelEditDialog = ({ ? { acceptedSuggestionMatchKind: acceptedCapacitySuggestion.matchKind, - ...(acceptedCapacitySuggestion.capabilityProfileVersion + ...(acceptedCapacitySuggestion.capabilityProfileVersion && + acceptedCapacitySuggestion.capacityMatch?.autoApplicable ? { acceptedCapabilityProfileVersion: acceptedCapacitySuggestion.capabilityProfileVersion, @@ -481,7 +567,8 @@ export const ModelEditDialog = ({ ? { acceptedSuggestionMatchKind: acceptedCapacitySuggestion.matchKind, - ...(acceptedCapacitySuggestion.capabilityProfileVersion + ...(acceptedCapacitySuggestion.capabilityProfileVersion && + acceptedCapacitySuggestion.capacityMatch?.autoApplicable ? { acceptedCapabilityProfileVersion: acceptedCapacitySuggestion.capabilityProfileVersion, @@ -686,6 +773,16 @@ export const ModelEditDialog = ({ validationError={capacityValidationError} capacitySource={model.capacitySource} capabilityProfileVersion={model.capabilityProfileVersion} + capacityFieldMetadata={model.capacityFieldMetadata} + canonicalModelId={model.canonicalModelId} + tokenizerMatchMetadata={model.tokenizerMatchMetadata} + tokenCountProbeMetadata={ + probeMetadata || model.tokenCountProbeMetadata + } + onReviewAutomaticUpdate={reviewAutomaticUpdate} + reviewingAutomaticUpdate={reviewingAutomaticUpdate} + onProbeTokenCount={probeTokenCount} + probingTokenCount={probingTokenCount} // Capacity fields are optional; blank input remains unknown. suggestion={capacitySuggestionEnabled ? capacitySuggestion : null} suggestionLoading={checkingCapacitySuggestion} @@ -1076,7 +1173,8 @@ export const ProviderConfigEditDialog = ({ ...(supportsCapacityFields && acceptedCapacitySuggestion ? { acceptedSuggestionMatchKind: acceptedCapacitySuggestion.matchKind, - ...(acceptedCapacitySuggestion.capabilityProfileVersion + ...(acceptedCapacitySuggestion.capabilityProfileVersion && + acceptedCapacitySuggestion.capacityMatch?.autoApplicable ? { acceptedCapabilityProfileVersion: acceptedCapacitySuggestion.capabilityProfileVersion, diff --git a/frontend/lib/modelCapacityPayload.ts b/frontend/lib/modelCapacityPayload.ts index d3a9fa70d9..a8cdc731d7 100644 --- a/frontend/lib/modelCapacityPayload.ts +++ b/frontend/lib/modelCapacityPayload.ts @@ -25,12 +25,10 @@ export const hasCapacityInput = (value: CapacityFormValue): boolean => export const buildCamelCapacityPayload = (value: CapacityFormValue) => { if (!hasCapacityInput(value)) return {}; - const maxOutputTokens = toOptionalPositiveInt(value.maxOutputTokens); return { contextWindowTokens: toOptionalPositiveInt(value.contextWindowTokens), maxInputTokens: toOptionalPositiveInt(value.maxInputTokens), - maxOutputTokens, - ...(maxOutputTokens !== undefined ? { maxTokens: maxOutputTokens } : {}), + maxOutputTokens: toOptionalPositiveInt(value.maxOutputTokens), defaultOutputReserveTokens: toOptionalPositiveInt( value.defaultOutputReserveTokens ), diff --git a/frontend/services/api.ts b/frontend/services/api.ts index 01428851c8..d3f95aa722 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -275,6 +275,12 @@ export const API_ENDPOINTS = { verifyModelConfig: `${API_BASE_URL}/model/temporary_healthcheck`, suggestCapacity: `${API_BASE_URL}/model/suggest-capacity`, capacityCoverage: `${API_BASE_URL}/model/capacity-coverage`, + capacityAdoptionPreview: `${API_BASE_URL}/model/capacity-adoption-preview`, + capacityAdopt: `${API_BASE_URL}/model/capacity-adopt`, + tokenCountProbe: `${API_BASE_URL}/model/token-count-probe`, + manageCapacityAdoptionPreview: `${API_BASE_URL}/model/manage/capacity-adoption-preview`, + manageCapacityAdopt: `${API_BASE_URL}/model/manage/capacity-adopt`, + manageTokenCountProbe: `${API_BASE_URL}/model/manage/token-count-probe`, updateSingleModel: (displayName: string) => `${API_BASE_URL}/model/update?display_name=${encodeURIComponent(displayName)}`, updateBatchModel: `${API_BASE_URL}/model/batch_update`, diff --git a/frontend/services/modelService.ts b/frontend/services/modelService.ts index a2f13d296e..89323fc4a6 100644 --- a/frontend/services/modelService.ts +++ b/frontend/services/modelService.ts @@ -10,6 +10,11 @@ import { ModelSource, CapacitySuggestion, CapacityCoverage, + CapacityAdoptionPreview, + CapacityFieldMetadata, + ModelIdentityMetadata, + ProfileMatchMetadata, + TokenCountProbeMetadata, } from "@/types/modelConfig"; import { getAuthHeaders } from "@/lib/auth"; @@ -26,6 +31,76 @@ import { } from "@/const/modelConfig"; import log from "@/lib/logger"; +const CAPACITY_FIELD_NAMES: Record = { + context_window_tokens: "contextWindowTokens", + max_input_tokens: "maxInputTokens", + max_output_tokens: "maxOutputTokens", + default_output_reserve_tokens: "defaultOutputReserveTokens", + tokenizer_family: "tokenizerFamily", +}; + +const mapCapacityFieldMetadata = ( + metadata: any +): CapacityFieldMetadata | null => { + if (!metadata || metadata.schema_version !== 1) return null; + return { + schemaVersion: metadata.schema_version, + fields: Object.fromEntries( + Object.entries(metadata.fields || {}).map( + ([field, item]: [string, any]) => [ + CAPACITY_FIELD_NAMES[field] || field, + { + source: item.source, + confidence: item.confidence, + profileVersion: item.profile_version, + evidenceId: item.evidence_id, + verifiedAt: item.verified_at, + updatedAt: item.updated_at, + }, + ] + ) + ), + } as CapacityFieldMetadata; +}; + +const mapIdentityMetadata = (metadata: any): ModelIdentityMetadata | null => + metadata + ? { + schemaVersion: metadata.schema_version, + canonicalId: metadata.canonical_id, + resolved: metadata.resolved, + ambiguity: metadata.ambiguity, + confidence: metadata.confidence, + matcherVersion: metadata.matcher_version, + } + : null; + +const mapProfileMatch = (metadata: any): ProfileMatchMetadata | null => + metadata + ? { + schemaVersion: metadata.schema_version, + selectedProfile: metadata.selected_profile, + confidence: metadata.confidence, + source: metadata.source, + reason: metadata.reason, + matcherVersion: metadata.matcher_version, + candidates: metadata.candidates, + autoApplicable: metadata.auto_applicable, + } + : null; + +const mapProbeMetadata = (metadata: any): TokenCountProbeMetadata | null => + metadata + ? { + schemaVersion: metadata.schema_version, + status: metadata.status, + reason: metadata.reason, + selectedProtocol: metadata.selected_protocol, + checkedAt: metadata.checked_at, + staleAt: metadata.stale_at, + } + : null; + const mapCapacityFieldsFromApi = (model: any) => ({ contextWindowTokens: model.context_window_tokens, maxInputTokens: model.max_input_tokens, @@ -34,6 +109,13 @@ const mapCapacityFieldsFromApi = (model: any) => ({ tokenizerFamily: model.tokenizer_family, capacitySource: model.capacity_source, capabilityProfileVersion: model.capability_profile_version, + capacityFieldMetadata: mapCapacityFieldMetadata( + model.capacity_field_metadata + ), + canonicalModelId: model.canonical_model_id, + modelIdentityMetadata: mapIdentityMetadata(model.model_identity_metadata), + tokenizerMatchMetadata: mapProfileMatch(model.tokenizer_match_metadata), + tokenCountProbeMetadata: mapProbeMetadata(model.token_count_probe_metadata), }); const buildCapacityRequestBody = (model: { @@ -45,6 +127,7 @@ const buildCapacityRequestBody = (model: { capacitySource?: string; acceptedSuggestionMatchKind?: string; acceptedCapabilityProfileVersion?: string; + capacityMode?: "auto" | "manual"; }) => ({ ...(model.contextWindowTokens !== undefined ? { context_window_tokens: model.contextWindowTokens } @@ -64,6 +147,9 @@ const buildCapacityRequestBody = (model: { ...(model.capacitySource !== undefined ? { capacity_source: model.capacitySource } : {}), + ...(model.capacityMode !== undefined + ? { capacity_mode: model.capacityMode } + : {}), // W11 accept-signal: audit-only fields the app layer pops before the // service write so model_capacity_suggestion_accept_total can count // accepted catalog matches. @@ -100,6 +186,12 @@ const mapCapacitySuggestionFromApi = ( canonicalModelName: suggestion.canonical_model_name, capabilityProfileVersion: suggestion.capability_profile_version, capacitySourceOnAccept: suggestion.capacity_source_on_accept, + canonicalIdentity: mapIdentityMetadata(suggestion.canonical_identity), + capacityMatch: mapProfileMatch(suggestion.capacity_match), + tokenizerMatch: mapProfileMatch(suggestion.tokenizer_match), + governanceMetadataProposal: mapCapacityFieldMetadata( + suggestion.governance_metadata_proposal + ), }; }; @@ -220,6 +312,7 @@ export const modelService = { capacitySource?: string; acceptedSuggestionMatchKind?: string; acceptedCapabilityProfileVersion?: string; + capacityMode?: "auto" | "manual"; }): Promise => { try { const requestBody: any = { @@ -444,6 +537,7 @@ export const modelService = { capacitySource?: string; acceptedSuggestionMatchKind?: string; acceptedCapabilityProfileVersion?: string; + capacityMode?: "auto" | "manual"; }): Promise => { try { const response = await fetch( @@ -856,6 +950,129 @@ export const modelService = { } }, + previewCapacityAdoption: async ( + displayName: string, + expectedMatcherVersion?: string, + tenantId?: string + ): Promise => { + const response = await fetch( + tenantId + ? API_ENDPOINTS.model.manageCapacityAdoptionPreview + : API_ENDPOINTS.model.capacityAdoptionPreview, + { + method: "POST", + headers: getAuthHeaders(), + body: JSON.stringify({ + display_name: displayName, + ...(expectedMatcherVersion + ? { expected_matcher_version: expectedMatcherVersion } + : {}), + ...(tenantId ? { tenant_id: tenantId } : {}), + }), + } + ); + const result = await response.json(); + if (response.status !== STATUS_CODES.SUCCESS || !result.data) { + throw new ModelError( + result.detail || "Failed to preview capacity adoption", + response.status + ); + } + const data = result.data; + return { + displayName: data.display_name, + canonicalModelId: data.canonical_model_id, + matcherVersion: data.matcher_version, + currentProfileVersion: data.current_profile_version, + proposedProfileVersion: data.proposed_profile_version, + fields: Object.fromEntries( + Object.entries(data.fields || {}).map( + ([field, item]: [string, any]) => [ + CAPACITY_FIELD_NAMES[field] || field, + { + currentValue: item.current_value, + currentSource: item.current_source, + proposedValue: item.proposed_value, + proposedSource: item.proposed_source, + changed: item.changed, + blockedByManual: item.blocked_by_manual, + applicable: item.applicable, + }, + ] + ) + ), + }; + }, + + adoptCapacity: async (params: { + displayName: string; + expectedProfileVersion: string; + expectedMatcherVersion?: string; + fields?: string[]; + resetManualFields?: string[]; + tenantId?: string; + }): Promise<{ updatedFields: string[] }> => { + const response = await fetch( + params.tenantId + ? API_ENDPOINTS.model.manageCapacityAdopt + : API_ENDPOINTS.model.capacityAdopt, + { + method: "POST", + headers: getAuthHeaders(), + body: JSON.stringify({ + display_name: params.displayName, + expected_profile_version: params.expectedProfileVersion, + ...(params.expectedMatcherVersion + ? { expected_matcher_version: params.expectedMatcherVersion } + : {}), + ...(params.fields ? { fields: params.fields } : {}), + reset_manual_fields: params.resetManualFields || [], + ...(params.tenantId ? { tenant_id: params.tenantId } : {}), + }), + } + ); + const result = await response.json(); + if (response.status !== STATUS_CODES.SUCCESS || !result.data) { + throw new ModelError( + result.detail || "Failed to adopt capacity", + response.status + ); + } + return { updatedFields: result.data.updated_fields || [] }; + }, + + probeTokenCount: async ( + displayName: string, + force = false, + tenantId?: string + ): Promise => { + const response = await fetch( + tenantId + ? API_ENDPOINTS.model.manageTokenCountProbe + : API_ENDPOINTS.model.tokenCountProbe, + { + method: "POST", + headers: getAuthHeaders(), + body: JSON.stringify({ + display_name: displayName, + force, + ...(tenantId ? { tenant_id: tenantId } : {}), + }), + } + ); + const result = await response.json(); + if (response.status !== STATUS_CODES.SUCCESS || !result.data) { + throw new ModelError( + result.detail || "Token-count probe failed", + response.status + ); + } + const mapped = mapProbeMetadata(result.data); + if (!mapped) + throw new ModelError("Token-count probe returned no metadata", 500); + return mapped; + }, + // Get LLM model list for generation getLLMModels: async (): Promise => { try { @@ -993,6 +1210,7 @@ export const modelService = { capacitySource?: string; acceptedSuggestionMatchKind?: string; acceptedCapabilityProfileVersion?: string; + capacityMode?: "auto" | "manual"; }): Promise => { try { const requestBody: any = { @@ -1077,6 +1295,7 @@ export const modelService = { capacitySource?: string; acceptedSuggestionMatchKind?: string; acceptedCapabilityProfileVersion?: string; + capacityMode?: "auto" | "manual"; }): Promise => { try { const response = await fetch( diff --git a/frontend/types/modelConfig.ts b/frontend/types/modelConfig.ts index df195c0183..e11066b471 100644 --- a/frontend/types/modelConfig.ts +++ b/frontend/types/modelConfig.ts @@ -1,9 +1,6 @@ // Model connection status type export type ModelConnectStatus = - | "not_detected" - | "detecting" - | "available" - | "unavailable"; + "not_detected" | "detecting" | "available" | "unavailable"; // API response type export interface ApiResponse { @@ -48,6 +45,11 @@ export interface ModelOption { tokenizerFamily?: string; capacitySource?: string; capabilityProfileVersion?: string; + capacityFieldMetadata?: CapacityFieldMetadata | null; + canonicalModelId?: string | null; + modelIdentityMetadata?: ModelIdentityMetadata | null; + tokenizerMatchMetadata?: ProfileMatchMetadata | null; + tokenCountProbeMetadata?: TokenCountProbeMetadata | null; source: ModelSource; apiKey: string; apiUrl: string; @@ -110,6 +112,65 @@ export interface SingleModelConfig { tokenizerFamily?: string; capacitySource?: string; capabilityProfileVersion?: string; + capacityFieldMetadata?: CapacityFieldMetadata | null; + canonicalModelId?: string | null; + modelIdentityMetadata?: ModelIdentityMetadata | null; + tokenizerMatchMetadata?: ProfileMatchMetadata | null; + tokenCountProbeMetadata?: TokenCountProbeMetadata | null; +} + +export type CapacityFieldSource = + "catalog" | "provider" | "operator" | "legacy" | "unknown"; + +export interface CapacityFieldProvenance { + source: CapacityFieldSource; + confidence?: "high" | "medium" | "low" | "unknown"; + profileVersion?: string; + evidenceId?: string; + verifiedAt?: string; + updatedAt?: string; +} + +export interface CapacityFieldMetadata { + schemaVersion: number; + fields: Partial< + Record + >; +} + +export interface ModelIdentityMetadata { + schemaVersion: number; + canonicalId?: string; + resolved?: boolean; + ambiguity?: boolean; + confidence?: string; + matcherVersion?: string; +} + +export interface ProfileMatchMetadata { + schemaVersion: number; + selectedProfile?: string | null; + confidence?: string | null; + source: string; + reason: string; + matcherVersion: string; + candidates?: string[]; + autoApplicable?: boolean; +} + +export interface TokenCountProbeMetadata { + schemaVersion: number; + status: + | "supported" + | "unsupported" + | "authorization_error" + | "temporarily_unavailable" + | "invalid_response" + | "unknown"; + reason: string; + selectedProtocol?: string | null; + checkedAt?: string; + staleAt?: string; } export interface CapacitySuggestionFields { @@ -121,10 +182,7 @@ export interface CapacitySuggestionFields { } export type CapacitySuggestionMatchKind = - | "catalog_exact" - | "catalog_fuzzy" - | "provider_discovery" - | "none"; + "catalog_exact" | "catalog_fuzzy" | "provider_discovery" | "none"; export type CapacitySuggestionConfidence = "high" | "medium" | "low"; @@ -136,7 +194,30 @@ export interface CapacitySuggestion { suggestedProvider?: string | null; canonicalModelName?: string | null; capabilityProfileVersion?: string | null; - capacitySourceOnAccept?: "operator" | null; + capacitySourceOnAccept?: "operator" | "profile" | null; + canonicalIdentity?: ModelIdentityMetadata | null; + capacityMatch?: ProfileMatchMetadata | null; + tokenizerMatch?: ProfileMatchMetadata | null; + governanceMetadataProposal?: CapacityFieldMetadata | null; +} + +export interface CapacityAdoptionFieldDiff { + currentValue?: number | string | null; + currentSource: CapacityFieldSource; + proposedValue?: number | string | null; + proposedSource: "catalog"; + changed: boolean; + blockedByManual: boolean; + applicable: boolean; +} + +export interface CapacityAdoptionPreview { + displayName: string; + canonicalModelId: string; + matcherVersion: string; + currentProfileVersion?: string | null; + proposedProfileVersion: string; + fields: Record; } export interface CapacityCoverageBareModel { diff --git a/sdk/nexent/core/models/capacity_resolver.py b/sdk/nexent/core/models/capacity_resolver.py index da83e6a668..91e854d7f8 100644 --- a/sdk/nexent/core/models/capacity_resolver.py +++ b/sdk/nexent/core/models/capacity_resolver.py @@ -22,6 +22,7 @@ ReasoningWindowBehavior = Literal["none", "reserved", "unknown"] ProviderOverheadBehavior = Literal["negligible", "bounded", "unknown"] PromptCacheCapability = Literal["none", "supported", "unknown"] +ProfileConfidence = Literal["high", "medium", "low", "unknown"] ProfileKey = Tuple[str, str] @@ -59,6 +60,76 @@ class CapabilityProfile(BaseModel): provider_overhead_behavior: ProviderOverheadBehavior = "unknown" prompt_cache: PromptCacheCapability = "unknown" + # P1 catalog-governance declarations. They intentionally default to an + # incomplete/suggestion-only state so old catalog rows cannot silently be + # promoted to verified automatic facts during rollout. + aliases: Tuple[str, ...] = () + exclusions: Tuple[str, ...] = () + evidence: Tuple[str, ...] = () + verified_at: Optional[str] = None + shared_context: Optional[bool] = None + independent_input: Optional[bool] = None + max_output: Optional[int] = None + reasoning_behavior: Optional[ReasoningWindowBehavior] = None + overhead_behavior: Optional[ProviderOverheadBehavior] = None + confidence: ProfileConfidence = "unknown" + + def automatic_validation_errors(self) -> Tuple[str, ...]: + """Return fail-closed reasons preventing automatic catalog use.""" + errors: list[str] = [] + if not self.aliases: + errors.append("aliases_missing") + if not self.evidence or any(not item.strip() for item in self.evidence): + errors.append("evidence_missing") + if not self.verified_at: + errors.append("verified_at_missing") + if self.shared_context is None: + errors.append("shared_context_missing") + if self.independent_input is None: + errors.append("independent_input_missing") + if self.max_output is None or self.max_output <= 0: + errors.append("max_output_missing") + elif self.max_output_tokens is not None and self.max_output != self.max_output_tokens: + errors.append("max_output_conflict") + if self.reasoning_behavior is None: + errors.append("reasoning_behavior_missing") + if self.overhead_behavior is None: + errors.append("overhead_behavior_missing") + if self.confidence != "high": + errors.append("confidence_not_high") + if self.shared_context is True and self.window_shape != "combined": + errors.append("shared_context_conflict") + if self.independent_input is True and self.max_input_tokens is None: + errors.append("independent_input_limit_missing") + return tuple(errors) + + @property + def auto_applicable(self) -> bool: + return not self.automatic_validation_errors() + + +def validate_capability_catalog( + catalog: Mapping[ProfileKey, CapabilityProfile], +) -> Mapping[ProfileKey, Tuple[str, ...]]: + """Validate catalog identity and automatic-use declarations. + + Incomplete legacy rows are returned with reasons and remain + suggestion-only. A row that claims high confidence fails closed because a + verified label without complete evidence is a catalog authoring error. + """ + diagnostics: dict[ProfileKey, Tuple[str, ...]] = {} + for key, profile in catalog.items(): + if key != (profile.provider, profile.model_name): + raise ValueError(f"catalog_key_mismatch:{key!r}") + reasons = profile.automatic_validation_errors() + if profile.confidence == "high" and reasons: + raise ValueError( + f"incomplete_verified_profile:{profile.capability_profile_version}:" + f"{','.join(reasons)}" + ) + diagnostics[key] = reasons + return diagnostics + class ModelCapacitySnapshot(BaseModel): """Immutable per-request capacity resolution result. diff --git a/sdk/nexent/core/models/model_identity.py b/sdk/nexent/core/models/model_identity.py new file mode 100644 index 0000000000..13a5e161d2 --- /dev/null +++ b/sdk/nexent/core/models/model_identity.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +import re +from typing import List, Optional, Tuple + +from pydantic import BaseModel, ConfigDict, Field + + +MATCHER_VERSION = "1.0.0" + +_TOKEN_PATTERN = re.compile( + r"\d+(?:\.\d+)?[a-z]+|[a-z]+\d+(?:\.\d+)?|[a-z]+|\d+(?:\.\d+)*" +) +_SIZE_PATTERN = re.compile(r"^\d+(?:\.\d+)?[bmk]$") +_CONTEXT_EXTENSION_PATTERN = re.compile(r"^\d+(?:\.\d+)?(?:k|m)$") +_DATE_PATTERN = re.compile(r"^(?:20)?\d{4}(?:\d{2})?$") + +_FAMILY_PREFIXES = ( + "deepseek", + "hunyuan", + "mistral", + "moonshot", + "qwen", + "llama", + "gemma", + "kimi", + "glm", + "phi", + "yi", +) +_MODALITY_TOKENS = {"vl", "vision", "omni", "audio", "image", "video"} +_TUNE_TOKENS = {"base", "chat", "instruct", "it", "coder", "captioner"} +_REASONING_TOKENS = {"thinking", "reasoner", "reasoning", "r1"} +_QUANTIZATION_TOKENS = {"awq", "gptq", "gguf", "fp8", "int4", "int8", "bnb"} + + +def _tokens(value: str) -> Tuple[str, ...]: + parsed = _TOKEN_PATTERN.findall(value.strip().lower()) + expanded: list[str] = [] + for token in parsed: + split = next( + ( + (prefix, token[len(prefix):]) + for prefix in _FAMILY_PREFIXES + if token.startswith(prefix) + and token != prefix + and token[len(prefix):] + and token[len(prefix)].isdigit() + ), + None, + ) + if split: + expanded.extend(split) + else: + expanded.append(token) + return tuple(expanded) + + +def _first_matching(tokens: Tuple[str, ...], candidates: set[str]) -> Optional[str]: + return next((token for token in tokens if token in candidates), None) + + +class CanonicalModelIdentity(BaseModel): + """Versioned, separator-aware identity used by independent profile matchers.""" + + model_config = ConfigDict(frozen=True) + + raw_model_id: str + provider: Optional[str] = None + canonical_id: str + family: Optional[str] = None + version: Optional[str] = None + size: Optional[str] = None + modality: Optional[str] = None + tune: Optional[str] = None + reasoning: Optional[str] = None + distillation: Optional[str] = None + quantization: Optional[str] = None + date: Optional[str] = None + context_extension: Optional[str] = None + namespace: Tuple[str, ...] = () + tokens: Tuple[str, ...] = () + resolved: bool = False + ambiguous: bool = False + confidence: str = "low" + candidates: Tuple[str, ...] = () + matcher_version: str = MATCHER_VERSION + evidence: List[str] = Field(default_factory=list) + + @property + def variant_signature(self) -> Tuple[Optional[str], ...]: + return ( + self.family, + self.version, + self.size, + self.modality, + self.tune, + self.reasoning, + self.distillation, + self.context_extension, + ) + + +def parse_model_identity(model_id: str, provider: Optional[str] = None) -> CanonicalModelIdentity: + """Parse a provider model ID without collapsing meaningful token boundaries.""" + raw = model_id.strip() + if not raw: + raise ValueError("model_id is required") + + normalized_provider = provider.strip().lower() if provider and provider.strip() else None + path_parts = [part for part in re.split(r"/+", raw) if part] + namespace = tuple(part.strip().lower() for part in path_parts[:-1]) + leaf_tokens = _tokens(path_parts[-1]) + all_tokens = tuple(token for part in path_parts for token in _tokens(part)) + + family = next( + ( + prefix + for token in all_tokens + for prefix in _FAMILY_PREFIXES + if token == prefix or token.startswith(prefix) + ), + None, + ) + version = None + if family: + family_index = next( + (index for index, token in enumerate(all_tokens) if token == family or token.startswith(family)), + -1, + ) + family_token = all_tokens[family_index] if family_index >= 0 else "" + suffix = family_token[len(family):] + if suffix and suffix[0].isdigit(): + version = suffix + elif family_index >= 0: + version = next( + ( + token[1:] if token.startswith("v") else token + for token in all_tokens[family_index + 1:] + if ( + token[0].isdigit() + or (token.startswith("v") and token[1:].replace(".", "").isdigit()) + ) + and not _SIZE_PATTERN.match(token) + and token not in _REASONING_TOKENS + ), + None, + ) + + size = next((token for token in all_tokens if _SIZE_PATTERN.match(token)), None) + context_extension = next( + ( + token + for token in all_tokens + if _CONTEXT_EXTENSION_PATTERN.match(token) and token != size + ), + None, + ) + modality = _first_matching(all_tokens, _MODALITY_TOKENS) + tune = _first_matching(all_tokens, _TUNE_TOKENS) + reasoning = _first_matching(all_tokens, _REASONING_TOKENS) + quantization = _first_matching(all_tokens, _QUANTIZATION_TOKENS) + date = next((token for token in reversed(all_tokens) if _DATE_PATTERN.match(token)), None) + + distillation = None + if "distill" in all_tokens: + index = all_tokens.index("distill") + lineage = all_tokens[index + 1:] + distillation = "-".join(lineage) if lineage else "distill" + + canonical_path = "/".join("-".join(_tokens(part)) for part in path_parts) + canonical_id = f"{normalized_provider}:{canonical_path}" if normalized_provider else canonical_path + evidence = ["separator_aware_tokens"] + for field_name, value in ( + ("family", family), + ("version", version), + ("size", size), + ("modality", modality), + ("tune", tune), + ("reasoning", reasoning), + ("distillation", distillation), + ("quantization", quantization), + ("date", date), + ("context_extension", context_extension), + ): + if value: + evidence.append(f"{field_name}:{value}") + + return CanonicalModelIdentity( + raw_model_id=raw, + provider=normalized_provider, + canonical_id=canonical_id, + family=family, + version=version, + size=size, + modality=modality, + tune=tune, + reasoning=reasoning, + distillation=distillation, + quantization=quantization, + date=date, + context_extension=context_extension, + namespace=namespace, + tokens=leaf_tokens, + resolved=family is not None, + confidence="high" if family and version else "medium" if family else "low", + evidence=evidence, + ) + + +def identities_are_safe_aliases( + requested: CanonicalModelIdentity, + candidate: CanonicalModelIdentity, +) -> bool: + """Return true only when leaf tokens and every capacity-relevant variant agree.""" + if requested.tokens != candidate.tokens: + return False + for field_name in ( + "family", + "version", + "size", + "modality", + "tune", + "reasoning", + "distillation", + "context_extension", + ): + requested_value = getattr(requested, field_name) + candidate_value = getattr(candidate, field_name) + if requested_value and candidate_value and requested_value != candidate_value: + return False + return True diff --git a/sdk/nexent/core/models/tokenizer_registry.py b/sdk/nexent/core/models/tokenizer_registry.py index 6a8f7d2e9a..efc7351a3b 100644 --- a/sdk/nexent/core/models/tokenizer_registry.py +++ b/sdk/nexent/core/models/tokenizer_registry.py @@ -3,9 +3,12 @@ import json import logging import re -from typing import Dict, Optional, Protocol, Sequence, Tuple, runtime_checkable +from typing import Dict, List, Literal, Optional, Protocol, Sequence, Tuple, runtime_checkable + +from pydantic import BaseModel, ConfigDict, Field from .capacity_resolver import CountingMode +from .model_identity import MATCHER_VERSION, identities_are_safe_aliases, parse_model_identity logger = logging.getLogger("tokenizer_registry") @@ -54,6 +57,59 @@ def count_tokens(self, messages: Sequence[dict]) -> int: REGISTRY: Dict[str, TokenizerAdapter] = {} +class TokenizerConformanceFixture(BaseModel): + model_config = ConfigDict(frozen=True) + + fixture_id: str + messages: Tuple[dict, ...] + expected_tokens: int = Field(gt=0) + + +class TokenizerConformanceReport(BaseModel): + model_config = ConfigDict(frozen=True) + + family: str + adapter_version: str + fixture_version: str + sample_count: int + mean_absolute_error_ratio: float + max_error_ratio: float + passed: bool + + +class TokenizerProfile(BaseModel): + model_config = ConfigDict(frozen=True) + + profile_id: str + family: str + aliases: Tuple[str, ...] + exclusions: Tuple[str, ...] = () + adapter_version: str + package_version: str + fixture_version: str + priority: int = 0 + matcher_version: str = MATCHER_VERSION + verification_status: Literal["verified", "unverified"] = "unverified" + + +class TokenizerMatchResult(BaseModel): + adapter: TokenizerAdapter = Field(exclude=True) + counting_mode: CountingMode + profile_id: Optional[str] = None + family: Optional[str] = None + confidence: Optional[Literal["high", "medium", "low"]] = None + source: Literal["profile", "fallback"] + matcher_version: str = MATCHER_VERSION + reason: str + candidates: Tuple[str, ...] = () + + model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) + + +PROFILES: Dict[str, TokenizerProfile] = {} +CONFORMANCE: Dict[str, TokenizerConformanceReport] = {} + + def register(adapter: TokenizerAdapter) -> None: """Register a verified adapter. Called once at import time by adapter modules.""" family = adapter.family @@ -67,6 +123,131 @@ def register(adapter: TokenizerAdapter) -> None: REGISTRY[family] = adapter +def register_profile(profile: TokenizerProfile) -> None: + if not is_valid_family_identifier(profile.family): + raise ValueError(f"Invalid tokenizer family {profile.family!r}") + if profile.profile_id in PROFILES: + raise ValueError(f"Tokenizer profile {profile.profile_id!r} is already registered") + if not profile.aliases: + raise ValueError("Tokenizer profile requires at least one alias") + PROFILES[profile.profile_id] = profile + + +def run_conformance( + adapter: TokenizerAdapter, + fixtures: Sequence[TokenizerConformanceFixture], + *, + adapter_version: str, + fixture_version: str, + minimum_samples: int = 100, + maximum_mean_error_ratio: float = 0.005, + maximum_single_error_ratio: float = 0.02, +) -> TokenizerConformanceReport: + errors: List[float] = [] + for fixture in fixtures: + actual = adapter.count_tokens(fixture.messages) + errors.append(abs(actual - fixture.expected_tokens) / fixture.expected_tokens) + mean_error = sum(errors) / len(errors) if errors else 1.0 + max_error = max(errors, default=1.0) + report = TokenizerConformanceReport( + family=adapter.family, + adapter_version=adapter_version, + fixture_version=fixture_version, + sample_count=len(fixtures), + mean_absolute_error_ratio=mean_error, + max_error_ratio=max_error, + passed=( + len(fixtures) >= minimum_samples + and mean_error <= maximum_mean_error_ratio + and max_error <= maximum_single_error_ratio + ), + ) + CONFORMANCE[adapter.family] = report + return report + + +def _profile_matches(profile: TokenizerProfile, provider: Optional[str], model_name: str) -> bool: + requested = parse_model_identity(model_name, provider) + if any( + identities_are_safe_aliases(requested, parse_model_identity(exclusion, provider)) + for exclusion in profile.exclusions + ): + return False + return any( + identities_are_safe_aliases(requested, parse_model_identity(alias, provider)) + for alias in profile.aliases + ) + + +def resolve_for_model(provider: Optional[str], model_name: str) -> TokenizerMatchResult: + """Resolve a verified profile independently from capacity matching.""" + matches = [ + profile + for profile in PROFILES.values() + if profile.matcher_version == MATCHER_VERSION + and _profile_matches(profile, provider, model_name) + ] + if not matches: + return TokenizerMatchResult( + adapter=FALLBACK, + counting_mode="estimated", + source="fallback", + reason="tokenizer_profile_not_found", + ) + + highest_priority = max(profile.priority for profile in matches) + winners = [profile for profile in matches if profile.priority == highest_priority] + if len(winners) != 1: + return TokenizerMatchResult( + adapter=FALLBACK, + counting_mode="estimated", + source="fallback", + reason="tokenizer_profile_ambiguous", + candidates=tuple(sorted(profile.profile_id for profile in winners)), + ) + + profile = winners[0] + adapter = REGISTRY.get(profile.family) + if adapter is None: + reason = "tokenizer_adapter_unavailable" + elif profile.verification_status != "verified": + reason = "tokenizer_profile_unverified" + else: + report = CONFORMANCE.get(profile.family) + reason = ( + "tokenizer_conformance_missing" + if report is None + else "tokenizer_conformance_failed" + if not report.passed + else "" + ) + if report and ( + report.adapter_version != profile.adapter_version + or report.fixture_version != profile.fixture_version + ): + reason = "tokenizer_conformance_stale" + + if reason: + return TokenizerMatchResult( + adapter=FALLBACK, + counting_mode="estimated", + profile_id=profile.profile_id, + family=profile.family, + confidence="low", + source="fallback", + reason=reason, + ) + return TokenizerMatchResult( + adapter=adapter, + counting_mode="exact", + profile_id=profile.profile_id, + family=profile.family, + confidence="high", + source="profile", + reason="verified_profile_and_conformance", + ) + + def resolve(family: Optional[str]) -> Tuple[TokenizerAdapter, CountingMode]: """Return (adapter, counting_mode) for the requested tokenizer family. diff --git a/test/backend/app/test_model_managment_app.py b/test/backend/app/test_model_managment_app.py index dde997cba7..007572c72c 100644 --- a/test/backend/app/test_model_managment_app.py +++ b/test/backend/app/test_model_managment_app.py @@ -2,8 +2,8 @@ import os import pytest from unittest.mock import patch, MagicMock, ANY -from fastapi.testclient import TestClient -from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from fastapi import FastAPI, HTTPException from http import HTTPStatus # Add project root to sys.path so that the top-level `backend` package is importable @@ -29,7 +29,7 @@ @pytest.fixture(scope="function") -def client(mocker): +async def client(mocker): """Create test client with mocked dependencies.""" # Mock boto3 and MinioClient before importing mocker.patch('boto3.client') @@ -53,7 +53,10 @@ def _get_vector_db_core(): # minimal stub # Create test client app = FastAPI() app.include_router(router) - return TestClient(app) + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://testserver" + ) as async_client: + yield async_client # Test fixtures @@ -107,7 +110,7 @@ async def test_suggest_capacity_success(client, auth_header, user_credentials, m ) ) - response = client.post( + response = await client.post( "/model/suggest-capacity", json={ "model_name": "gpt-4o", @@ -142,7 +145,7 @@ async def test_suggest_capacity_real_serialization_uses_envelope(client, auth_he """ mocker.patch('backend.apps.model_managment_app.get_current_user_id', return_value=user_credentials) - response = client.post( + response = await client.post( "/model/suggest-capacity", json={ "model_name": "gpt-4o", @@ -169,6 +172,11 @@ async def test_suggest_capacity_real_serialization_uses_envelope(client, auth_he assert data["canonical_model_name"] == "gpt-4o" assert data["capability_profile_version"] == "openai/gpt-4o@1" assert data["capacity_source_on_accept"] == "operator" + assert data["canonical_identity"]["canonical_id"] == "openai:gpt-4o" + assert data["capacity_match"]["selected_profile"] == "openai/gpt-4o@1" + assert data["capacity_match"]["auto_applicable"] is False + assert data["tokenizer_match"]["source"] == "unknown" + assert data["suggestions"]["tokenizer_family"] is None # Nested capacity dict is also envelope-free at this level: it sits # directly under data.suggestions, mirroring the snake_case wire format # that mapCapacitySuggestionFromApi expects. @@ -201,7 +209,7 @@ async def test_capacity_coverage_real_serialization_uses_envelope(client, auth_h }, ) - response = client.get("/model/capacity-coverage", headers=auth_header) + response = await client.get("/model/capacity-coverage", headers=auth_header) assert response.status_code == HTTPStatus.OK body = response.json() @@ -225,7 +233,7 @@ async def test_suggest_capacity_bad_request(client, auth_header, user_credential side_effect=ValueError("model_name is required"), ) - response = client.post( + response = await client.post( "/model/suggest-capacity", json={"model_name": "gpt-4o"}, headers=auth_header, @@ -235,6 +243,144 @@ async def test_suggest_capacity_bad_request(client, auth_header, user_credential assert "model_name is required" in response.json()["detail"] +@pytest.mark.asyncio +async def test_capacity_adoption_preview_success(client, auth_header, user_credentials, mocker): + mocker.patch('backend.apps.model_managment_app.get_current_user_id', return_value=user_credentials) + preview = mocker.patch( + 'backend.apps.model_managment_app.preview_capacity_adoption_for_tenant', + return_value={"matcher_version": "1.0.0", "fields": {}}, + ) + response = await client.post( + "/model/capacity-adoption-preview", + json={"display_name": "Qwen Plus", "expected_matcher_version": "1.0.0"}, + headers=auth_header, + ) + assert response.status_code == HTTPStatus.OK + assert response.json()["data"]["matcher_version"] == "1.0.0" + preview.assert_awaited_once_with( + "test_tenant", "Qwen Plus", expected_matcher_version="1.0.0" + ) + + +@pytest.mark.asyncio +async def test_capacity_adopt_success(client, auth_header, user_credentials, mocker): + mocker.patch('backend.apps.model_managment_app.get_current_user_id', return_value=user_credentials) + adopt = mocker.patch( + 'backend.apps.model_managment_app.adopt_capacity_for_tenant', + return_value={"updated_fields": ["context_window_tokens"]}, + ) + response = await client.post( + "/model/capacity-adopt", + json={ + "display_name": "Qwen Plus", + "expected_profile_version": "dashscope/qwen-plus@2", + "expected_matcher_version": "1.0.0", + "fields": ["context_window_tokens"], + "reset_manual_fields": [], + }, + headers=auth_header, + ) + assert response.status_code == HTTPStatus.OK + adopt.assert_awaited_once_with( + "test_user", + "test_tenant", + "Qwen Plus", + expected_profile_version="dashscope/qwen-plus@2", + expected_matcher_version="1.0.0", + fields=["context_window_tokens"], + reset_manual_fields=[], + ) + + +@pytest.mark.asyncio +async def test_token_count_probe_success_is_sanitized(client, auth_header, user_credentials, mocker): + mocker.patch('backend.apps.model_managment_app.get_current_user_id', return_value=user_credentials) + probe = mocker.patch( + 'backend.apps.model_managment_app.probe_token_count_for_tenant', + return_value={"schema_version": 1, "status": "unsupported", "reason": "unsupported_endpoint"}, + ) + response = await client.post( + "/model/token-count-probe", + json={"display_name": "Qwen Plus", "force": True}, + headers=auth_header, + ) + assert response.status_code == HTTPStatus.OK + assert "secret" not in response.text + probe.assert_awaited_once_with( + "test_user", "test_tenant", "Qwen Plus", force=True + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "path,payload", + [ + ("/model/capacity-adoption-preview", {"display_name": "Qwen"}), + ("/model/capacity-adopt", {"display_name": "Qwen", "expected_profile_version": "v1"}), + ("/model/token-count-probe", {"display_name": "Qwen"}), + ], +) +async def test_p1_governance_actions_require_authorization(client, path, payload, mocker): + mocker.patch( + 'backend.apps.model_managment_app.get_current_user_id', + side_effect=HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Unauthorized"), + ) + response = await client.post(path, json=payload) + assert response.status_code == HTTPStatus.UNAUTHORIZED + + +@pytest.mark.asyncio +async def test_p1_missing_token_domain_error_maps_to_401(client, mocker): + from consts.exceptions import UnauthorizedError + + mocker.patch( + 'backend.apps.model_managment_app.get_current_user_id', + side_effect=UnauthorizedError("No authorization header provided"), + ) + response = await client.post( + "/model/token-count-probe", json={"display_name": "Qwen"} + ) + assert response.status_code == HTTPStatus.UNAUTHORIZED + assert "authorization header" not in response.text.lower() + + +@pytest.mark.asyncio +async def test_manage_probe_requires_super_admin(client, auth_header, user_credentials, mocker): + mocker.patch('backend.apps.model_managment_app.get_current_user_id', return_value=user_credentials) + mocker.patch( + 'backend.apps.model_managment_app.get_user_tenant_by_user_id', + return_value={"user_role": "ADMIN"}, + ) + probe = mocker.patch('backend.apps.model_managment_app.probe_token_count_for_tenant') + response = await client.post( + "/model/manage/token-count-probe", + json={"tenant_id": "other", "display_name": "Qwen"}, + headers=auth_header, + ) + assert response.status_code == HTTPStatus.FORBIDDEN + probe.assert_not_called() + + +@pytest.mark.asyncio +async def test_manage_probe_super_admin_targets_requested_tenant(client, auth_header, user_credentials, mocker): + mocker.patch('backend.apps.model_managment_app.get_current_user_id', return_value=user_credentials) + mocker.patch( + 'backend.apps.model_managment_app.get_user_tenant_by_user_id', + return_value={"user_role": "SU"}, + ) + probe = mocker.patch( + 'backend.apps.model_managment_app.probe_token_count_for_tenant', + return_value={"schema_version": 1, "status": "supported", "reason": "supported"}, + ) + response = await client.post( + "/model/manage/token-count-probe", + json={"tenant_id": "target", "display_name": "Qwen", "force": True}, + headers=auth_header, + ) + assert response.status_code == HTTPStatus.OK + probe.assert_awaited_once_with("test_user", "target", "Qwen", force=True) + + @pytest.mark.asyncio async def test_capacity_coverage_success(client, auth_header, user_credentials, mocker): """Test capacity coverage endpoint uses current tenant.""" @@ -257,7 +403,7 @@ async def test_capacity_coverage_success(client, auth_header, user_credentials, }, ) - response = client.get("/model/capacity-coverage", headers=auth_header) + response = await client.get("/model/capacity-coverage", headers=auth_header) assert response.status_code == HTTPStatus.OK body = response.json() @@ -281,7 +427,7 @@ async def _create(*args, **kwargs): mock_create = mocker.patch('backend.apps.model_managment_app.create_model_for_tenant', side_effect=_create) - response = client.post( + response = await client.post( "/model/create", json=sample_model_data, headers=auth_header) assert response.status_code == HTTPStatus.OK @@ -314,7 +460,7 @@ async def _create(*args, **kwargs): "accepted_suggestion_match_kind": "catalog_exact", "accepted_capability_profile_version": "openai/gpt-4o@1", } - response = client.post("/model/create", json=payload, headers=auth_header) + response = await client.post("/model/create", json=payload, headers=auth_header) assert response.status_code == HTTPStatus.OK @@ -345,7 +491,7 @@ async def _create(*args, **kwargs): mocker.patch('backend.apps.model_managment_app.create_model_for_tenant', side_effect=_create) mock_record = mocker.patch('backend.apps.model_managment_app._record_capacity_suggestion_accept') - response = client.post("/model/create", json=sample_model_data, headers=auth_header) + response = await client.post("/model/create", json=sample_model_data, headers=auth_header) assert response.status_code == HTTPStatus.OK mock_record.assert_not_called() @@ -373,7 +519,7 @@ async def _reject(*args, **kwargs): side_effect=_reject, ) - response = client.post( + response = await client.post( "/model/create", json=sample_model_data, headers=auth_header ) @@ -394,7 +540,7 @@ async def test_create_model_conflict(client, auth_header, user_credentials, samp side_effect=ValueError("Name 'Test Model' is already in use, please choose another display name") ) - response = client.post( + response = await client.post( "/model/create", json=sample_model_data, headers=auth_header) assert response.status_code == HTTPStatus.CONFLICT @@ -414,7 +560,7 @@ async def test_create_model_exception(client, auth_header, user_credentials, sam side_effect=Exception("DB failure") ) - response = client.post( + response = await client.post( "/model/create", json=sample_model_data, headers=auth_header) assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR @@ -437,7 +583,7 @@ async def test_create_provider_model_success(client, auth_header, user_credentia # Fix: Add required model_type field request_data = {"provider": "silicon", "model_type": "llm", "api_key": "test_key"} - response = client.post( + response = await client.post( "/model/provider/create", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.OK @@ -460,7 +606,7 @@ async def test_create_provider_model_exception(client, auth_header, user_credent # Fix: Add required model_type field request_data = {"provider": "silicon", "model_type": "llm", "api_key": "test_key"} - response = client.post( + response = await client.post( "/model/provider/create", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR @@ -487,7 +633,7 @@ async def _batch(*args, **kwargs): "type": "llm", "api_key": "k", } - response = client.post( + response = await client.post( "/model/provider/batch_create", json=payload, headers=auth_header) assert response.status_code == HTTPStatus.OK @@ -512,7 +658,7 @@ async def test_provider_batch_create_exception(client, auth_header, user_credent "type": "llm", "api_key": "k", } - response = client.post( + response = await client.post( "/model/provider/batch_create", json=payload, headers=auth_header) assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR @@ -560,7 +706,7 @@ async def _batch(*args, **kwargs): "type": "llm", "api_key": "k", } - response = client.post( + response = await client.post( "/model/provider/batch_create", json=payload, headers=auth_header) assert response.status_code == HTTPStatus.OK @@ -582,7 +728,7 @@ async def _delete(*args, **kwargs): mock_del = mocker.patch('backend.apps.model_managment_app.delete_model_for_tenant', side_effect=_delete) - response = client.post( + response = await client.post( "/model/delete", params={"display_name": "Test Model"}, headers=auth_header) assert response.status_code == HTTPStatus.OK @@ -602,7 +748,7 @@ async def test_delete_model_not_found(client, auth_header, user_credentials, moc side_effect=LookupError("Model not found: Missing") ) - response = client.post( + response = await client.post( "/model/delete", params={"display_name": "Missing"}, headers=auth_header) assert response.status_code == HTTPStatus.NOT_FOUND @@ -638,7 +784,7 @@ async def mock_list_models(*args, **kwargs): mock_list = mocker.patch('backend.apps.model_managment_app.list_models_for_tenant', side_effect=mock_list_models) - response = client.get("/model/list", headers=auth_header) + response = await client.get("/model/list", headers=auth_header) assert response.status_code == HTTPStatus.OK data = response.json() @@ -674,7 +820,7 @@ async def mock_list_llm_models(*args, **kwargs): mock_list = mocker.patch('backend.apps.model_managment_app.list_llm_models_for_tenant', side_effect=mock_list_llm_models) - response = client.get("/model/llm_list", headers=auth_header) + response = await client.get("/model/llm_list", headers=auth_header) assert response.status_code == HTTPStatus.OK data = response.json() @@ -697,7 +843,7 @@ async def mock_list_llm_models(*args, **kwargs): mocker.patch('backend.apps.model_managment_app.list_llm_models_for_tenant', side_effect=mock_list_llm_models) - response = client.get("/model/llm_list", headers=auth_header) + response = await client.get("/model/llm_list", headers=auth_header) assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR data = response.json() @@ -715,7 +861,7 @@ async def mock_list_llm_models(*args, **kwargs): mock_list = mocker.patch('backend.apps.model_managment_app.list_llm_models_for_tenant', side_effect=mock_list_llm_models) - response = client.get("/model/llm_list", headers=auth_header) + response = await client.get("/model/llm_list", headers=auth_header) assert response.status_code == HTTPStatus.OK data = response.json() @@ -735,7 +881,7 @@ async def test_check_model_health_success(client, auth_header, user_credentials, return_value={"connectivity": True, "connect_status": "available"} ) - response = client.post( + response = await client.post( "/model/healthcheck", params={"display_name": "Test Model", "model_type": "embedding"}, headers=auth_header @@ -758,7 +904,7 @@ async def test_check_model_health_lookup_error(client, auth_header, user_credent side_effect=LookupError("missing") ) - response = client.post( + response = await client.post( "/model/healthcheck", params={"display_name": "X", "model_type": "embedding"}, headers=auth_header @@ -782,7 +928,7 @@ async def test_verify_model_config_success(client, auth_header, sample_model_dat }, ) - response = client.post( + response = await client.post( "/model/temporary_healthcheck", json=sample_model_data) assert response.status_code == HTTPStatus.OK @@ -809,7 +955,7 @@ async def test_verify_model_config_failure_with_error(client, auth_header, sampl ) mock_suggest = mocker.patch('backend.apps.model_managment_app._capacity_suggestion_for_model_request') - response = client.post( + response = await client.post( "/model/temporary_healthcheck", json=sample_model_data) assert response.status_code == HTTPStatus.OK @@ -833,7 +979,7 @@ async def test_verify_model_config_exception(client, auth_header, sample_model_d side_effect=Exception("err") ) - response = client.post( + response = await client.post( "/model/temporary_healthcheck", json=sample_model_data) assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR @@ -858,7 +1004,7 @@ async def mock_update_single(*args, **kwargs): "model_type": "llm", "provider": "huggingface" } - response = client.post( + response = await client.post( "/model/update", params={"display_name": "Updated Test Model"}, json=update_data, @@ -873,6 +1019,8 @@ async def mock_update_single(*args, **kwargs): user_credentials[1], "Updated Test Model", update_data, + explicit_fields=set(update_data), + accepted_profile_version=None, ) @@ -895,7 +1043,7 @@ async def test_update_single_model_conflict(client, auth_header, user_credential "model_type": "llm", "provider": "huggingface" } - response = client.post( + response = await client.post( "/model/update", params={"display_name": "Conflicting Name"}, json=update_data, @@ -911,6 +1059,8 @@ async def test_update_single_model_conflict(client, auth_header, user_credential user_credentials[1], "Conflicting Name", update_data, + explicit_fields=set(update_data), + accepted_profile_version=None, ) @@ -929,7 +1079,7 @@ async def mock_batch_update(*args, **kwargs): {"model_id": "id1", "api_key": "k1", "max_tokens": 100}, {"model_id": "id2", "api_key": "k2", "max_tokens": 200}, ] - response = client.post( + response = await client.post( "/model/batch_update", json=models, headers=auth_header) assert response.status_code == HTTPStatus.OK @@ -949,7 +1099,7 @@ async def mock_batch_update(*args, **kwargs): mock_batch_update = mocker.patch('backend.apps.model_managment_app.batch_update_models_for_tenant', side_effect=mock_batch_update) models = [{"model_id": "id1", "api_key": "k1"}] - response = client.post( + response = await client.post( "/model/batch_update", json=models, headers=auth_header) assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR @@ -999,7 +1149,7 @@ async def mock_list_models_for_admin(*args, **kwargs): "page": 1, "page_size": 20 } - response = client.post("/model/manage/list", json=request_data, headers=auth_header) + response = await client.post("/model/manage/list", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.OK data = response.json() @@ -1048,7 +1198,7 @@ async def mock_list_models_for_admin(*args, **kwargs): "page": 2, "page_size": 10 } - response = client.post("/model/manage/list", json=request_data, headers=auth_header) + response = await client.post("/model/manage/list", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.OK data = response.json() @@ -1076,7 +1226,7 @@ async def mock_list_models_for_admin(*args, **kwargs): "page": 1, "page_size": 20 } - response = client.post("/model/manage/list", json=request_data, headers=auth_header) + response = await client.post("/model/manage/list", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR data = response.json() @@ -1107,7 +1257,7 @@ async def mock_list_models_for_admin(*args, **kwargs): "page": 1, "page_size": 20 } - response = client.post("/model/manage/list", json=request_data, headers=auth_header) + response = await client.post("/model/manage/list", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.OK data = response.json() @@ -1138,7 +1288,7 @@ async def _create(*args, **kwargs): "max_tokens": 4096, "display_name": "LLaMA Model" } - response = client.post("/model/manage/create", json=request_data, headers=auth_header) + response = await client.post("/model/manage/create", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.OK data = response.json() @@ -1148,7 +1298,9 @@ async def _create(*args, **kwargs): mock_create.assert_called_once_with( user_credentials[0], "target_tenant", - ANY # The dict may contain additional optional fields like chunk settings + ANY, # The dict may contain additional optional fields like chunk settings + explicit_fields=ANY, + accepted_profile_version=None, ) @@ -1193,7 +1345,7 @@ async def _create(*args, **kwargs): "accepted_suggestion_match_kind": "catalog_exact", "accepted_capability_profile_version": "openai/gpt-4o@1", } - response = client.post( + response = await client.post( "/model/manage/create", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.OK @@ -1220,7 +1372,7 @@ async def _create(*args, **kwargs): "base_url": "http://localhost:8000", "api_key": "test_key" } - response = client.post("/model/manage/create", json=request_data, headers=auth_header) + response = await client.post("/model/manage/create", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.CONFLICT assert "Model name already exists" in response.json()["detail"] @@ -1243,7 +1395,7 @@ async def _create(*args, **kwargs): "base_url": "http://localhost:8000", "api_key": "test_key" } - response = client.post("/model/manage/create", json=request_data, headers=auth_header) + response = await client.post("/model/manage/create", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR @@ -1267,7 +1419,7 @@ async def _update(*args, **kwargs): "api_key": "new_api_key", "max_tokens": 8192 } - response = client.post("/model/manage/update", json=request_data, headers=auth_header) + response = await client.post("/model/manage/update", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.OK data = response.json() @@ -1278,7 +1430,9 @@ async def _update(*args, **kwargs): user_credentials[0], "target_tenant", "Old Model Name", - ANY # The dict may contain additional optional fields like chunk settings + ANY, # The dict may contain additional optional fields like chunk settings + explicit_fields=ANY, + accepted_profile_version=None, ) @@ -1320,7 +1474,7 @@ async def _update(*args, **kwargs): "accepted_suggestion_match_kind": "catalog_fuzzy", "accepted_capability_profile_version": "openai/gpt-4o@1", } - response = client.post( + response = await client.post( "/model/manage/update", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.OK @@ -1345,7 +1499,7 @@ async def _update(*args, **kwargs): "current_display_name": "nonexistent-model", "display_name": "Updated Name" } - response = client.post("/model/manage/update", json=request_data, headers=auth_header) + response = await client.post("/model/manage/update", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.NOT_FOUND @@ -1365,7 +1519,7 @@ async def _update(*args, **kwargs): "current_display_name": "test-model", "display_name": "duplicate-name" } - response = client.post("/model/manage/update", json=request_data, headers=auth_header) + response = await client.post("/model/manage/update", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.CONFLICT @@ -1385,7 +1539,7 @@ async def _delete(*args, **kwargs): "tenant_id": "target_tenant", "display_name": "test-model" } - response = client.post("/model/manage/delete", json=request_data, headers=auth_header) + response = await client.post("/model/manage/delete", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.OK data = response.json() @@ -1409,7 +1563,7 @@ async def _delete(*args, **kwargs): "tenant_id": "target_tenant", "display_name": "nonexistent-model" } - response = client.post("/model/manage/delete", json=request_data, headers=auth_header) + response = await client.post("/model/manage/delete", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.NOT_FOUND @@ -1428,7 +1582,7 @@ async def _delete(*args, **kwargs): "tenant_id": "target_tenant", "display_name": "test-model" } - response = client.post("/model/manage/delete", json=request_data, headers=auth_header) + response = await client.post("/model/manage/delete", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR @@ -1466,7 +1620,7 @@ async def _batch_create(*args, **kwargs): } ] } - response = client.post("/model/manage/batch_create", json=request_data, headers=auth_header) + response = await client.post("/model/manage/batch_create", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.OK data = response.json() @@ -1520,7 +1674,7 @@ async def _batch_create(*args, **kwargs): "api_key": "", "models": [] } - response = client.post("/model/manage/batch_create", json=request_data, headers=auth_header) + response = await client.post("/model/manage/batch_create", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.OK data = response.json() @@ -1548,7 +1702,7 @@ async def _batch_create(*args, **kwargs): {"id": "silicon/test-model", "max_tokens": 4096} ] } - response = client.post("/model/manage/batch_create", json=request_data, headers=auth_header) + response = await client.post("/model/manage/batch_create", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR @@ -1569,7 +1723,7 @@ async def test_manage_healthcheck_success(client, auth_header, user_credentials, "display_name": "test-model", "model_type": "llm" } - response = client.post("/model/manage/healthcheck", json=request_data, headers=auth_header) + response = await client.post("/model/manage/healthcheck", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.OK data = response.json() @@ -1594,7 +1748,7 @@ async def test_manage_healthcheck_without_model_type_is_backward_compatible( "tenant_id": "target_tenant", "display_name": "test-model" } - response = client.post("/model/manage/healthcheck", json=request_data, headers=auth_header) + response = await client.post("/model/manage/healthcheck", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.OK mock_check.assert_called_once_with("test-model", "target_tenant", None) @@ -1614,7 +1768,7 @@ async def test_manage_healthcheck_model_not_found(client, auth_header, user_cred "tenant_id": "target_tenant", "display_name": "nonexistent-model" } - response = client.post("/model/manage/healthcheck", json=request_data, headers=auth_header) + response = await client.post("/model/manage/healthcheck", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.NOT_FOUND assert "Model configuration not found" in response.json()["detail"] @@ -1634,7 +1788,7 @@ async def test_manage_healthcheck_invalid_config(client, auth_header, user_crede "tenant_id": "target_tenant", "display_name": "test-model" } - response = client.post("/model/manage/healthcheck", json=request_data, headers=auth_header) + response = await client.post("/model/manage/healthcheck", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.BAD_REQUEST assert "Invalid model configuration" in response.json()["detail"] @@ -1654,7 +1808,7 @@ async def test_manage_healthcheck_exception(client, auth_header, user_credential "tenant_id": "target_tenant", "display_name": "test-model" } - response = client.post("/model/manage/healthcheck", json=request_data, headers=auth_header) + response = await client.post("/model/manage/healthcheck", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR @@ -1694,7 +1848,7 @@ async def mock_list_provider_models(*args, **kwargs): "provider": "silicon", "model_type": "llm" } - response = client.post("/model/manage/provider/list", json=request_data, headers=auth_header) + response = await client.post("/model/manage/provider/list", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.OK data = response.json() @@ -1718,7 +1872,7 @@ async def mock_list_provider_models(*args, **kwargs): "provider": "silicon", "model_type": "llm" } - response = client.post("/model/manage/provider/list", json=request_data, headers=auth_header) + response = await client.post("/model/manage/provider/list", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR @@ -1738,7 +1892,7 @@ async def mock_list_provider_models(*args, **kwargs): "provider": "silicon", "model_type": "embedding" } - response = client.post("/model/manage/provider/list", json=request_data, headers=auth_header) + response = await client.post("/model/manage/provider/list", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.OK data = response.json() @@ -1778,7 +1932,7 @@ async def mock_create_provider_models(*args, **kwargs): "api_key": "test_api_key", "base_url": "" } - response = client.post("/model/manage/provider/create", json=request_data, headers=auth_header) + response = await client.post("/model/manage/provider/create", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.OK data = response.json() @@ -1815,7 +1969,7 @@ async def mock_create_provider_models(*args, **kwargs): "api_key": "test_api_key", "base_url": "https://api.modelengine.example.com" } - response = client.post("/model/manage/provider/create", json=request_data, headers=auth_header) + response = await client.post("/model/manage/provider/create", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.OK mock_create.assert_called_once_with( @@ -1841,7 +1995,7 @@ async def mock_create_provider_models(*args, **kwargs): "api_key": "test_api_key", "base_url": "" } - response = client.post("/model/manage/provider/create", json=request_data, headers=auth_header) + response = await client.post("/model/manage/provider/create", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR @@ -1863,7 +2017,7 @@ async def mock_create_provider_models(*args, **kwargs): "api_key": "test_api_key", "base_url": "" } - response = client.post("/model/manage/provider/create", json=request_data, headers=auth_header) + response = await client.post("/model/manage/provider/create", json=request_data, headers=auth_header) assert response.status_code == HTTPStatus.OK data = response.json() diff --git a/test/backend/database/test_model_managment_db.py b/test/backend/database/test_model_managment_db.py index ad2583b43e..ee7786a3e4 100644 --- a/test/backend/database/test_model_managment_db.py +++ b/test/backend/database/test_model_managment_db.py @@ -724,3 +724,52 @@ def test_get_model_by_model_id_ignore_delete_without_tenant_id(monkeypatch): # Filter by model_id alone; the absence of tenant_id means we still call # scalars() exactly once. assert session.scalars.call_count == 1 + + +def test_apply_model_mutations_uses_one_transaction(monkeypatch): + session = MagicMock() + context = MagicMock() + context.__enter__.return_value = session + context.__exit__.return_value = None + monkeypatch.setattr(model_mgmt_db, "get_db_session", lambda: context) + monkeypatch.setattr( + model_mgmt_db.db_client, + "clean_string_values", + lambda value: dict(value), + ) + + model_mgmt_db.apply_model_mutations( + creates=[{"model_name": "new"}], + updates=[(1, {"display_name": "updated"})], + deletes=[2], + user_id="u1", + tenant_id="t1", + ) + + assert context.__enter__.call_count == 1 + assert context.__exit__.call_count == 1 + assert session.execute.call_count == 3 + + +def test_apply_model_mutations_propagates_failure_to_transaction_context(monkeypatch): + session = MagicMock() + session.execute.side_effect = [MagicMock(), RuntimeError("write failed")] + context = MagicMock() + context.__enter__.return_value = session + context.__exit__.return_value = None + monkeypatch.setattr(model_mgmt_db, "get_db_session", lambda: context) + monkeypatch.setattr( + model_mgmt_db.db_client, + "clean_string_values", + lambda value: dict(value), + ) + + with pytest.raises(RuntimeError, match="write failed"): + model_mgmt_db.apply_model_mutations( + creates=[{"model_name": "new"}], + updates=[(1, {"display_name": "updated"})], + deletes=[2], + user_id="u1", + tenant_id="t1", + ) + assert context.__exit__.call_args.args[0] is RuntimeError diff --git a/test/backend/services/test_model_capacity_governance_service.py b/test/backend/services/test_model_capacity_governance_service.py new file mode 100644 index 0000000000..1bec3984a6 --- /dev/null +++ b/test/backend/services/test_model_capacity_governance_service.py @@ -0,0 +1,271 @@ +import os +import sys + +import pytest + +backend_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../backend")) +if backend_dir not in sys.path: + sys.path.append(backend_dir) + +from consts.exceptions import ModelCapacityConfigError +from services.model_capacity_governance_service import ( + apply_catalog_adoption, + catalog_adoption_preview, + derive_legacy_metadata, + merge_capacity_governance, + normalize_legacy_capacity_ingress, +) + + +CATALOG_VALUES = { + "context_window_tokens": 128_000, + "max_output_tokens": 16_384, + "default_output_reserve_tokens": 4_096, + "tokenizer_family": "o200k_base", +} + + +def catalog_row(): + result = merge_capacity_governance( + {"model_type": "llm", **CATALOG_VALUES}, + explicit_fields={"model_type", *CATALOG_VALUES}, + accepted_profile_version="openai/gpt-4o@2", + accepted_profile_fields=CATALOG_VALUES, + profile_evidence_id="openai-model-doc", + profile_verified_at="2026-08-01T00:00:00Z", + ) + return { + **result.values, + "capacity_field_metadata": result.metadata, + "capacity_source": result.row_capacity_source, + "capability_profile_version": result.capability_profile_version, + } + + +def test_ac_p1_001_catalog_create_tracks_each_field(): + row = catalog_row() + + assert row["capacity_source"] == "profile" + assert row["capability_profile_version"] == "openai/gpt-4o@2" + assert set(row["capacity_field_metadata"]["fields"]) == set(CATALOG_VALUES) + assert all( + item["source"] == "catalog" + for item in row["capacity_field_metadata"]["fields"].values() + ) + + +def test_ac_p1_001_only_changed_field_becomes_operator(): + existing = catalog_row() + result = merge_capacity_governance( + {"max_output_tokens": 8_192, "context_window_tokens": 128_000}, + explicit_fields={"max_output_tokens", "context_window_tokens"}, + existing=existing, + ) + + assert result.metadata["fields"]["max_output_tokens"]["source"] == "operator" + assert result.metadata["fields"]["context_window_tokens"]["source"] == "catalog" + assert result.metadata["fields"]["tokenizer_family"]["source"] == "catalog" + assert result.row_capacity_source == "operator" + assert result.capability_profile_version == "openai/gpt-4o@2" + assert [item["field"] for item in result.audit_delta] == ["max_output_tokens"] + + +def test_ac_p1_001_omitted_fields_and_equal_echo_preserve_metadata(): + existing = catalog_row() + result = merge_capacity_governance( + {"context_window_tokens": 128_000, "base_url": "https://new.example/v1"}, + explicit_fields={"context_window_tokens", "base_url"}, + existing=existing, + ) + + assert result.metadata == existing["capacity_field_metadata"] + assert result.audit_delta == () + + +def test_ac_p1_002_explicit_clear_becomes_unknown_without_fabrication(): + existing = catalog_row() + result = merge_capacity_governance( + {"max_input_tokens": None}, + explicit_fields={"max_input_tokens"}, + existing=existing, + ) + + assert result.values["max_input_tokens"] is None + assert "max_input_tokens" not in result.metadata["fields"] + + +def test_ac_p1_010_lone_legacy_max_tokens_normalizes_once(): + payload, explicit, used_legacy = normalize_legacy_capacity_ingress( + {"model_type": "llm", "max_tokens": 4096}, + explicit_fields={"model_type", "max_tokens"}, + ) + result = merge_capacity_governance( + payload, + explicit_fields=explicit, + legacy_ingress_used=used_legacy, + ) + + assert payload["max_output_tokens"] == 4096 + assert "max_tokens" not in payload + assert result.metadata["fields"]["max_output_tokens"]["source"] == "legacy" + + +def test_ac_p1_010_conflicting_dual_legacy_values_rejected(): + with pytest.raises(ModelCapacityConfigError) as exc_info: + normalize_legacy_capacity_ingress( + {"model_type": "llm", "max_tokens": 4096, "max_output_tokens": 8192}, + explicit_fields={"model_type", "max_tokens", "max_output_tokens"}, + ) + + assert exc_info.value.reason_code == "capacity_legacy_conflict" + + +def test_ac_p1_010_embedding_max_tokens_is_not_capacity_alias(): + payload, explicit, used_legacy = normalize_legacy_capacity_ingress( + {"model_type": "embedding", "max_tokens": 1024}, + explicit_fields={"model_type", "max_tokens"}, + ) + + assert payload["max_tokens"] == 1024 + assert explicit == {"model_type", "max_tokens"} + assert not used_legacy + + +def test_ac_p1_009_catalog_preview_is_side_effect_free_and_blocks_manual(): + record = catalog_row() + overridden = merge_capacity_governance( + {"max_output_tokens": 8192}, + explicit_fields={"max_output_tokens"}, + existing=record, + ) + record.update(overridden.values) + record["capacity_field_metadata"] = overridden.metadata + before = dict(record) + + preview = catalog_adoption_preview( + record, + {"context_window_tokens": 256_000, "max_output_tokens": 32_768}, + proposed_profile_version="openai/gpt-4o@3", + ) + + assert preview["fields"]["context_window_tokens"]["applicable"] + assert preview["fields"]["max_output_tokens"]["blocked_by_manual"] + assert not preview["fields"]["max_output_tokens"]["applicable"] + assert record == before + + +def test_ac_p1_002_legacy_metadata_is_derived_without_mutation(): + record = { + "context_window_tokens": 32_768, + "max_output_tokens": 4096, + "capacity_source": "operator", + } + before = dict(record) + + metadata = derive_legacy_metadata(record) + + assert metadata["fields"]["context_window_tokens"]["source"] == "operator" + assert record == before + + +def test_ac_p1_009_default_adoption_updates_catalog_and_preserves_manual(): + record = catalog_row() + overridden = merge_capacity_governance( + {"max_output_tokens": 8192}, + explicit_fields={"max_output_tokens"}, + existing=record, + ) + record.update(overridden.values) + record["capacity_field_metadata"] = overridden.metadata + + adopted = apply_catalog_adoption( + record, + {"context_window_tokens": 256_000, "max_output_tokens": 32_768}, + proposed_profile_version="openai/gpt-4o@3", + expected_profile_version="openai/gpt-4o@3", + current_matcher_version="1.0.0", + expected_matcher_version="1.0.0", + ) + assert adopted.values["context_window_tokens"] == 256_000 + assert adopted.values["max_output_tokens"] == 8192 + assert adopted.metadata["fields"]["context_window_tokens"]["source"] == "catalog" + assert adopted.metadata["fields"]["max_output_tokens"]["source"] == "operator" + + +def test_ac_p1_009_manual_reset_requires_explicit_field_and_is_audited(): + record = catalog_row() + overridden = merge_capacity_governance( + {"max_output_tokens": 8192}, + explicit_fields={"max_output_tokens"}, + existing=record, + ) + record.update(overridden.values) + record["capacity_field_metadata"] = overridden.metadata + adopted = apply_catalog_adoption( + record, + {"max_output_tokens": 32_768}, + proposed_profile_version="openai/gpt-4o@3", + expected_profile_version="openai/gpt-4o@3", + current_matcher_version="1.0.0", + fields={"max_output_tokens"}, + reset_manual_fields={"max_output_tokens"}, + ) + assert adopted.values["max_output_tokens"] == 32_768 + assert adopted.metadata["fields"]["max_output_tokens"]["source"] == "catalog" + assert adopted.audit_delta[0]["previous_source"] == "operator" + + +def test_ac_p1_009_manual_reset_changes_provenance_when_value_is_identical(): + record = catalog_row() + overridden = merge_capacity_governance( + {"max_output_tokens": 32_768}, + explicit_fields={"max_output_tokens"}, + existing=record, + ) + record.update(overridden.values) + record["capacity_field_metadata"] = overridden.metadata + + adopted = apply_catalog_adoption( + record, + {"max_output_tokens": 32_768}, + proposed_profile_version="openai/gpt-4o@3", + expected_profile_version="openai/gpt-4o@3", + current_matcher_version="1.0.0", + fields={"max_output_tokens"}, + reset_manual_fields={"max_output_tokens"}, + ) + + assert adopted.values["max_output_tokens"] == 32_768 + assert adopted.metadata["fields"]["max_output_tokens"]["source"] == "catalog" + assert adopted.audit_delta == ( + { + "field": "max_output_tokens", + "previous_source": "operator", + "new_source": "catalog", + "value_changed": False, + }, + ) + + +@pytest.mark.parametrize( + ("kwargs", "reason"), + [ + ({"expected_profile_version": "old"}, "capacity_profile_stale"), + ({"expected_matcher_version": "old"}, "capacity_matcher_stale"), + ], +) +def test_ac_p1_009_stale_adoption_is_rejected(kwargs, reason): + arguments = { + "proposed_profile_version": "openai/gpt-4o@3", + "expected_profile_version": "openai/gpt-4o@3", + "current_matcher_version": "1.0.0", + "expected_matcher_version": "1.0.0", + } + arguments.update(kwargs) + with pytest.raises(ModelCapacityConfigError) as exc_info: + apply_catalog_adoption( + catalog_row(), + {"context_window_tokens": 256_000}, + **arguments, + ) + assert exc_info.value.reason_code == reason diff --git a/test/backend/services/test_model_capacity_suggestion_service.py b/test/backend/services/test_model_capacity_suggestion_service.py index d2c6d349aa..09af3f06ee 100644 --- a/test/backend/services/test_model_capacity_suggestion_service.py +++ b/test/backend/services/test_model_capacity_suggestion_service.py @@ -29,6 +29,9 @@ def __init__( max_input_tokens=None, default_output_reserve_tokens=4096, tokenizer_family="test-tokenizer", + aliases=(), + exclusions=(), + auto_applicable=False, ): self.context_window_tokens = context_window_tokens self.max_input_tokens = max_input_tokens @@ -36,6 +39,9 @@ def __init__( self.default_output_reserve_tokens = default_output_reserve_tokens self.tokenizer_family = tokenizer_family self.capability_profile_version = capability_profile_version + self.aliases = aliases + self.exclusions = exclusions + self.auto_applicable = auto_applicable CATALOG = { @@ -84,6 +90,47 @@ def test_suggest_capacity_catalog_exact_case_insensitive(): assert result.canonical_model_name == "gpt-4o" +def test_explicit_alias_matches_before_structural_fallback(): + catalog = { + ("dashscope", "qwen-plus"): Profile( + 131_072, + 16_384, + "dashscope/qwen-plus@2", + aliases=("qwen-plus-latest",), + exclusions=("qwen-plus-vl",), + auto_applicable=True, + ) + } + result = suggest_capacity( + model_name="qwen-plus-latest", + provider_hint="dashscope", + model_type="llm", + catalog=catalog, + ) + assert result.match_kind == CapacitySuggestionMatchKind.CATALOG_EXACT + assert result.capacity_source_on_accept == "profile" + + +def test_explicit_exclusion_overrides_alias(): + catalog = { + ("dashscope", "qwen-plus"): Profile( + 131_072, + 16_384, + "dashscope/qwen-plus@2", + aliases=("qwen-plus-vl",), + exclusions=("qwen-plus-vl",), + auto_applicable=True, + ) + } + result = suggest_capacity( + model_name="qwen-plus-vl", + provider_hint="dashscope", + model_type="llm", + catalog=catalog, + ) + assert result.match_kind == CapacitySuggestionMatchKind.NONE + + def test_suggest_capacity_catalog_fuzzy_normalized_name(): result = suggest_capacity( model_name="Deepseek V4 Flash", @@ -300,22 +347,22 @@ def test_suggest_capacity_no_op_when_instruments_disabled(): @pytest.mark.parametrize("raw, expected", [ - ("gpt-4o", "gpt4o"), - ("GPT-4o", "gpt4o"), - ("glm-5.1", "glm51"), - ("glm5.1", "glm51"), - ("Deepseek V4 Flash", "deepseekv4flash"), - ("deepseek-ai/DeepSeek-V4-Flash", "deepseekaideepseekv4flash"), - ("Kimi-K2.6", "kimik26"), - ("Pro/moonshotai/Kimi-K2.6", "promoonshotaikimik26"), - ("qwen-plus", "qwenplus"), - (" gpt-4o ", "gpt4o"), - ("model_name.v2", "modelnamev2"), - ("a-b_c.d/e f", "abcdef"), + ("gpt-4o", "gpt-4o"), + ("GPT-4o", "gpt-4o"), + ("glm-5.1", "glm-5.1"), + ("glm5.1", "glm-5.1"), + ("Deepseek V4 Flash", "deepseek-v4-flash"), + ("deepseek-ai/DeepSeek-V4-Flash", "deepseek-ai/deepseek-v4-flash"), + ("Kimi-K2.6", "kimi-k2.6"), + ("Pro/moonshotai/Kimi-K2.6", "pro/moonshotai/kimi-k2.6"), + ("qwen-plus", "qwen-plus"), + (" gpt-4o ", "gpt-4o"), + ("model_name.v2", "model-name-v2"), + ("a-b_c.d/e f", "a-b-c-d/e-f"), ("", ""), (" ", ""), ]) -def test_normalize_model_name_strips_lowercases_and_collapses_separators(raw, expected): +def test_normalize_model_name_preserves_boundaries_and_lowercases(raw, expected): assert normalize_model_name(raw) == expected @@ -492,6 +539,10 @@ def test_model_capacity_suggestion_response_catalog_exact_shape(_pydantic_models "canonical_model_name", "capability_profile_version", "capacity_source_on_accept", + "canonical_identity", + "capacity_match", + "tokenizer_match", + "governance_metadata_proposal", } assert dumped["match_kind"] == "catalog_exact" assert dumped["match_confidence"] == "high" diff --git a/test/backend/services/test_model_management_service.py b/test/backend/services/test_model_management_service.py index 1894d19743..469a8caaf5 100644 --- a/test/backend/services/test_model_management_service.py +++ b/test/backend/services/test_model_management_service.py @@ -17,7 +17,13 @@ if "nexent" not in sys.modules: sys.modules["nexent"] = mock.MagicMock() if "nexent.core" not in sys.modules: - sys.modules["nexent.core"] = mock.MagicMock() + nexent_core_mod = types.ModuleType("nexent.core") + # Keep the lightweight agent stubs below while allowing service tests to + # import real, dependency-free SDK model helpers. + nexent_core_mod.__path__ = [os.path.abspath(os.path.join( + os.path.dirname(__file__), "../../../sdk/nexent/core" + ))] + sys.modules["nexent.core"] = nexent_core_mod if "nexent.core.agents" not in sys.modules: sys.modules["nexent.core.agents"] = mock.MagicMock() if "nexent.core.agents.agent_model" not in sys.modules: @@ -293,6 +299,7 @@ def _get_model_by_name_factory(*args, **kwargs): db_mm_mod.create_model_record = _noop +db_mm_mod.apply_model_mutations = _noop db_mm_mod.delete_model_record = _noop db_mm_mod.get_model_by_display_name = _noop db_mm_mod.get_model_by_name_factory = _get_model_by_name_factory @@ -455,6 +462,141 @@ async def test_create_model_for_tenant_success_llm(): assert mock_create.call_count == 1 +@pytest.mark.asyncio +async def test_create_auto_mode_persists_verified_catalog_and_match_metadata(): + svc = import_svc() + payload = { + "model_name": "qwen-plus", + "display_name": "Qwen Plus Auto", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "api_key": "secret", + "model_factory": "dashscope", + "model_type": "llm", + "capacity_mode": "auto", + } + with mock.patch.object(svc, "get_models_by_display_name", return_value=[]), \ + mock.patch.object(svc, "create_model_record") as mock_create, \ + mock.patch.object(svc, "split_repo_name", return_value=("", "qwen-plus")): + await svc.create_model_for_tenant( + "u1", "t1", payload, explicit_fields=set(payload) + ) + saved = mock_create.call_args.args[0] + assert saved["context_window_tokens"] == 131_072 + assert saved["max_output_tokens"] == 16_384 + assert saved["capacity_source"] == "profile" + assert saved["capability_profile_version"] == "dashscope/qwen-plus@1" + assert saved["capacity_field_metadata"]["fields"]["max_output_tokens"]["source"] == "catalog" + assert saved["canonical_model_id"] == "dashscope:qwen-plus" + assert saved["model_identity_metadata"]["schema_version"] == 1 + assert saved["tokenizer_match_metadata"]["reason"] == "tokenizer_profile_not_found" + assert "capacity_mode" not in saved + + +@pytest.mark.asyncio +async def test_capacity_adoption_preview_is_read_only_and_marks_manual_block(): + svc = import_svc() + record = { + "model_id": 1, + "model_type": "llm", + "model_factory": "dashscope", + "model_repo": "", + "model_name": "qwen-plus", + "display_name": "Qwen Plus", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "context_window_tokens": 65_536, + "max_output_tokens": 8_192, + "default_output_reserve_tokens": 4_096, + "capacity_field_metadata": { + "schema_version": 1, + "fields": { + "context_window_tokens": {"source": "catalog", "profile_version": "dashscope/qwen-plus@0"}, + "max_output_tokens": {"source": "operator"}, + "default_output_reserve_tokens": {"source": "catalog", "profile_version": "dashscope/qwen-plus@0"}, + }, + }, + "capability_profile_version": "dashscope/qwen-plus@0", + } + with mock.patch.object(svc, "get_models_by_display_name", return_value=[record]), \ + mock.patch.object(svc, "update_model_record") as mock_update: + preview = await svc.preview_capacity_adoption_for_tenant("t1", "Qwen Plus") + assert preview["proposed_profile_version"] == "dashscope/qwen-plus@1" + assert preview["fields"]["context_window_tokens"]["applicable"] is True + assert preview["fields"]["max_output_tokens"]["blocked_by_manual"] is True + mock_update.assert_not_called() + + +@pytest.mark.asyncio +async def test_capacity_adoption_persists_only_eligible_fields(): + svc = import_svc() + record = { + "model_id": 1, + "model_type": "llm", + "model_factory": "dashscope", + "model_repo": "", + "model_name": "qwen-plus", + "display_name": "Qwen Plus", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "context_window_tokens": 65_536, + "max_output_tokens": 8_192, + "default_output_reserve_tokens": 4_096, + "capacity_field_metadata": { + "schema_version": 1, + "fields": { + "context_window_tokens": {"source": "catalog", "profile_version": "dashscope/qwen-plus@0"}, + "max_output_tokens": {"source": "operator"}, + "default_output_reserve_tokens": {"source": "catalog", "profile_version": "dashscope/qwen-plus@0"}, + }, + }, + "capability_profile_version": "dashscope/qwen-plus@0", + } + with mock.patch.object(svc, "get_models_by_display_name", return_value=[record]), \ + mock.patch.object(svc, "update_model_record") as mock_update: + result = await svc.adopt_capacity_for_tenant( + "u1", + "t1", + "Qwen Plus", + expected_profile_version="dashscope/qwen-plus@1", + expected_matcher_version="1.0.0", + ) + saved = mock_update.call_args.args[1] + assert saved["context_window_tokens"] == 131_072 + assert "max_output_tokens" not in saved + assert saved["capacity_field_metadata"]["fields"]["max_output_tokens"]["source"] == "operator" + assert result["updated_fields"] == ["context_window_tokens"] + + +@pytest.mark.asyncio +async def test_explicit_probe_persists_only_sanitized_metadata(): + svc = import_svc() + record = { + "model_id": 1, + "model_type": "llm", + "model_factory": "dashscope", + "model_repo": "", + "model_name": "qwen-plus", + "display_name": "Qwen Plus", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "api_key": "secret-marker", + } + metadata = { + "schema_version": 1, + "status": "unsupported", + "reason": "unsupported_endpoint", + "fingerprint": "safe", + } + with mock.patch.object(svc, "get_models_by_display_name", return_value=[record]), \ + mock.patch.object(svc, "run_token_count_probe", return_value=metadata) as mock_probe, \ + mock.patch.object(svc, "update_model_record") as mock_update: + result = await svc.probe_token_count_for_tenant( + "u1", "t1", "Qwen Plus", force=True + ) + assert result == metadata + mock_probe.assert_awaited_once() + saved = mock_update.call_args.args[1] + assert saved == {"token_count_probe_metadata": metadata} + assert "secret-marker" not in repr(saved) + + @pytest.mark.asyncio async def test_create_model_for_tenant_open_router_disables_ssl(): """When base_url contains 'open/router' ssl_verify should be set to False and model_factory to 'modelengine'.""" @@ -872,19 +1014,19 @@ async def test_batch_create_models_for_tenant_flow(): ] with mock.patch.object(svc, "get_models_by_tenant_factory_type", return_value=existing) as mock_get_existing, \ - mock.patch.object(svc, "delete_model_record") as mock_delete, \ - mock.patch.object(svc, "update_model_record") as mock_update, \ mock.patch.object(svc, "prepare_model_dict", new=mock.AsyncMock(return_value={"prepared": True})) as mock_prep, \ - mock.patch.object(svc, "create_model_record") as mock_create: + mock.patch.object(svc, "apply_model_mutations") as mock_apply: await svc.batch_create_models_for_tenant("u1", "t1", batch_payload) mock_get_existing.assert_called_once_with("t1", "silicon", "llm") - mock_delete.assert_called_once_with("del-id", "u1", "t1") - mock_update.assert_called_once_with( - "keep-id", {"max_tokens": 4096}, "u1") mock_prep.assert_awaited() - mock_create.assert_called_once() + mutation = mock_apply.call_args.kwargs + assert mutation["deletes"] == ["del-id"] + assert len(mutation["creates"]) == 1 + assert mutation["updates"][0][0] == "keep-id" + assert mutation["updates"][0][1]["max_output_tokens"] == 4096 + assert "max_tokens" not in mutation["updates"][0][1] @pytest.mark.asyncio @@ -1024,11 +1166,8 @@ async def test_update_single_model_for_tenant_success_single_model(): ) -async def test_update_single_model_for_tenant_mirrors_max_output_into_legacy_max_tokens(): - """LLM updates carrying max_output_tokens must mirror into the legacy - max_tokens column so the SDK's pre-W2 auto-fill cannot read a stale value - and trip CallerMaxTokensOverrideForbidden at the W2 dispatch boundary. - """ +async def test_update_single_model_for_tenant_does_not_reverse_mirror_legacy_max_tokens(): + """P1 makes max_tokens a one-way ingress alias for capacity models.""" svc = import_svc() existing_models = [ @@ -1047,7 +1186,8 @@ async def test_update_single_model_for_tenant_mirrors_max_output_into_legacy_max update_args = mock_update.call_args.args[1] assert update_args["max_output_tokens"] == 131072 - assert update_args["max_tokens"] == 131072 + assert "max_tokens" not in update_args + assert update_args["capacity_field_metadata"]["fields"]["max_output_tokens"]["source"] == "operator" async def test_update_single_model_for_tenant_preserves_embedding_max_tokens(): @@ -1136,11 +1276,12 @@ async def test_batch_update_models_for_tenant_success(): svc = import_svc() models = [{"model_id": "1", "max_tokens": 4096}, {"model_id": "2", "max_tokens": 8192}] - with mock.patch.object(svc, "update_model_record") as mock_update: + with mock.patch.object(svc, "apply_model_mutations") as mock_apply: await svc.batch_update_models_for_tenant("u1", "t1", models) - assert mock_update.call_count == 2 - mock_update.assert_any_call(1, {"max_tokens": 4096}, "u1", "t1") - mock_update.assert_any_call(2, {"max_tokens": 8192}, "u1", "t1") + assert mock_apply.call_args.kwargs["updates"] == [ + (1, {"max_tokens": 4096}), + (2, {"max_tokens": 8192}), + ] async def test_batch_update_models_for_tenant_by_name_factory(): @@ -1152,17 +1293,17 @@ async def test_batch_update_models_for_tenant_by_name_factory(): svc, "get_model_by_name_factory", return_value={"model_id": 42}, - ) as mock_lookup, mock.patch.object(svc, "update_model_record") as mock_update: + ) as mock_lookup, mock.patch.object(svc, "apply_model_mutations") as mock_apply: await svc.batch_update_models_for_tenant("u1", "t1", models) mock_lookup.assert_called_once_with("gpt-4", "openai", "t1") - mock_update.assert_called_once_with(42, {"max_tokens": 4096}, "u1", "t1") + assert mock_apply.call_args.kwargs["updates"] == [(42, {"max_tokens": 4096})] async def test_batch_update_models_for_tenant_exception(): svc = import_svc() models = [{"model_id": "1"}] - with mock.patch.object(svc, "update_model_record", side_effect=Exception("oops")): + with mock.patch.object(svc, "apply_model_mutations", side_effect=Exception("oops")): with pytest.raises(Exception) as exc: await svc.batch_update_models_for_tenant("u1", "t1", models) assert "Failed to batch update models" in str(exc.value) @@ -1790,13 +1931,11 @@ async def test_batch_create_models_for_tenant_update_branch_persists_operator_ca mock.patch.object(svc, "delete_model_record"), \ mock.patch.object(svc, "split_repo_name", return_value=("dashscope", "glm-5.2")), \ mock.patch.object(svc, "add_repo_to_name", return_value="dashscope/glm-5.2"), \ - mock.patch.object(svc, "update_model_record") as mock_update, \ - mock.patch.object(svc, "create_model_record"): + mock.patch.object(svc, "apply_model_mutations") as mock_apply: await svc.batch_create_models_for_tenant("u1", "t1", batch_payload) - mock_update.assert_called_once() - called_model_id, called_update_data, *_ = mock_update.call_args[0] + called_model_id, called_update_data = mock_apply.call_args.kwargs["updates"][0] assert called_model_id == 42 assert called_update_data["context_window_tokens"] == 200000 assert called_update_data["max_output_tokens"] == 31920 @@ -1806,8 +1945,8 @@ async def test_batch_create_models_for_tenant_update_branch_persists_operator_ca @pytest.mark.asyncio -async def test_batch_create_models_for_tenant_update_branch_skips_provider_candidate_capacity(): - """Provider-discovered hints must not auto-overwrite an existing row. +async def test_batch_create_models_for_tenant_tracks_provider_candidate_fields(): + """Provider facts fill unknown fields without claiming operator provenance. Even when the catalog response contains rich inference_metadata, those values stay tagged capacity_source="provider_candidate" until the @@ -1847,21 +1986,16 @@ async def test_batch_create_models_for_tenant_update_branch_skips_provider_candi mock.patch.object(svc, "delete_model_record"), \ mock.patch.object(svc, "split_repo_name", return_value=("dashscope", "glm-5.1")), \ mock.patch.object(svc, "add_repo_to_name", return_value="dashscope/glm-5.1"), \ - mock.patch.object(svc, "update_model_record") as mock_update, \ - mock.patch.object(svc, "create_model_record"): + mock.patch.object(svc, "apply_model_mutations") as mock_apply: await svc.batch_create_models_for_tenant("u1", "t1", batch_payload) - # max_tokens didn't change between existing (8192) and incoming - # (8192), so no update is needed at all. If the implementation - # were treating provider_candidate as authoritative, update would - # fire with the W2 fields. - if mock_update.called: - _, called_update_data, *_ = mock_update.call_args[0] - assert "context_window_tokens" not in called_update_data - assert "max_output_tokens" not in called_update_data - assert "tokenizer_family" not in called_update_data - assert called_update_data.get("capacity_source") != "provider_candidate" + _, called_update_data = mock_apply.call_args.kwargs["updates"][0] + assert called_update_data["context_window_tokens"] == 128000 + assert called_update_data["max_output_tokens"] == 8192 + assert called_update_data["capacity_source"] == "provider_candidate" + fields = called_update_data["capacity_field_metadata"]["fields"] + assert fields["context_window_tokens"]["source"] == "provider" def test_get_capacity_coverage_filters_bare_llm_vlm_rows(): diff --git a/test/backend/services/test_model_profile_match_service.py b/test/backend/services/test_model_profile_match_service.py new file mode 100644 index 0000000000..1140973c23 --- /dev/null +++ b/test/backend/services/test_model_profile_match_service.py @@ -0,0 +1,49 @@ +from backend.services.model_profile_match_service import ( + resolve_model_profiles, + serialize_profile_match, +) +from nexent.core.models.model_identity import MATCHER_VERSION +from nexent.core.models import tokenizer_registry +from nexent.core.models.tokenizer_registry import TokenizerProfile + + +def test_capacity_match_does_not_imply_tokenizer_match(monkeypatch): + monkeypatch.setattr(tokenizer_registry, "PROFILES", {}) + result = resolve_model_profiles( + model_name="qwen-plus", + provider="dashscope", + base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", + model_type="llm", + ) + assert result.capacity_match.selected_profile == "dashscope/qwen-plus@1" + assert result.tokenizer_match.selected_profile is None + assert result.tokenizer_counting_mode == "estimated" + assert result.canonical_model_id == "dashscope:qwen-plus" + assert result.identity_metadata["matcher_version"] == MATCHER_VERSION + + +def test_unverified_tokenizer_profile_still_falls_back(monkeypatch): + monkeypatch.setattr(tokenizer_registry, "PROFILES", {}) + tokenizer_registry.PROFILES["qwen-test@1"] = TokenizerProfile( + profile_id="qwen-test@1", + family="qwen", + aliases=("qwen-plus",), + adapter_version="1", + package_version="1", + fixture_version="1", + verification_status="unverified", + ) + result = resolve_model_profiles( + model_name="qwen-plus", + provider="dashscope", + base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", + model_type="llm", + ) + assert result.capacity_match.selected_profile is not None + assert result.tokenizer_match.selected_profile == "qwen-test@1" + assert result.tokenizer_match.reason in { + "tokenizer_adapter_unavailable", + "tokenizer_profile_unverified", + } + assert result.tokenizer_match.auto_applicable is False + assert serialize_profile_match(result.tokenizer_match)["schema_version"] == 1 diff --git a/test/backend/services/test_model_token_count_probe_service.py b/test/backend/services/test_model_token_count_probe_service.py new file mode 100644 index 0000000000..c71cadc369 --- /dev/null +++ b/test/backend/services/test_model_token_count_probe_service.py @@ -0,0 +1,257 @@ +from datetime import datetime, timedelta, timezone + +import pytest + +from backend.services.model_token_count_probe_service import ( + PROBE_ADAPTER_VERSION, + ProbeHTTPResponse, + classify_probe_response, + endpoint_fingerprint, + run_token_count_probe, + validate_probe_url, +) + + +NOW = datetime(2026, 8, 24, tzinfo=timezone.utc) +PUBLIC = lambda _host: ("8.8.8.8",) + + +@pytest.mark.parametrize( + ("response", "state", "reason"), + [ + (ProbeHTTPResponse(200, {"input_tokens": 7}), "supported", "supported"), + (ProbeHTTPResponse(404), "unsupported", "unsupported_endpoint"), + (ProbeHTTPResponse(405), "unsupported", "unsupported_endpoint"), + (ProbeHTTPResponse(401), "authorization_error", "authorization_failed"), + (ProbeHTTPResponse(403), "authorization_error", "authorization_failed"), + (ProbeHTTPResponse(429), "temporarily_unavailable", "rate_limited"), + (ProbeHTTPResponse(503), "temporarily_unavailable", "provider_5xx"), + (ProbeHTTPResponse(302, redirect_location="https://evil.test"), "temporarily_unavailable", "redirect_rejected"), + (ProbeHTTPResponse(200, {}), "invalid_response", "invalid_schema"), + (ProbeHTTPResponse(200, {"input_tokens": 0}), "invalid_response", "invalid_count"), + (ProbeHTTPResponse(200, {"input_tokens": 100_000_001}), "invalid_response", "invalid_count"), + ], +) +def test_probe_status_classification(response, state, reason): + actual_state, actual_reason, _ = classify_probe_response("openai_responses", response) + assert (actual_state, actual_reason) == (state, reason) + + +@pytest.mark.asyncio +async def test_known_protocol_probes_only_directed_dialect(): + calls = [] + + async def transport(request): + calls.append(request) + return ProbeHTTPResponse(200, {"input_tokens": 9}) + + result = await run_token_count_probe( + inference_protocol="openai", + base_url="https://api.example.test/v1", + model_name="qwen3.7-plus", + canonical_model_id="dashscope/qwen3.7-plus", + api_key="secret-marker", + credential_scope="tenant:model:1", + fingerprint_salt="test-salt", + resolver=PUBLIC, + transport=transport, + now=NOW, + ) + assert [item.protocol for item in calls] == ["openai_responses"] + assert result["status"] == "supported" + assert result["selected_protocol"] == "openai_responses" + assert result["capabilities"] == { + "text": "supported", "tools": "unknown", "media": "unknown" + } + assert "secret-marker" not in repr(result) + + +@pytest.mark.asyncio +async def test_probe_logs_never_contain_credentials_or_raw_payload(caplog): + async def transport(_request): + return ProbeHTTPResponse(401, {"error": "secret-marker-response"}) + + with caplog.at_level("INFO"): + result = await run_token_count_probe( + inference_protocol="openai", + base_url="https://api.example.test/v1", + model_name="model", + canonical_model_id="openai/model", + api_key="secret-marker-key", + credential_scope="scope", + fingerprint_salt="salt", + resolver=PUBLIC, + transport=transport, + now=NOW, + ) + assert result["reason"] == "authorization_failed" + assert "secret-marker" not in caplog.text + + +@pytest.mark.asyncio +async def test_unknown_protocol_tries_dialects_in_order_and_retains_outcomes(): + calls = [] + + async def transport(request): + calls.append(request.protocol) + if request.protocol == "openai_responses": + return ProbeHTTPResponse(404) + if request.protocol == "anthropic_messages": + return ProbeHTTPResponse(200, {"input_tokens": 11}) + raise AssertionError("must stop after the first supported dialect") + + result = await run_token_count_probe( + inference_protocol="unknown", + base_url="https://api.example.test/v1", + model_name="model", + canonical_model_id="unknown/model", + api_key="secret-marker", + credential_scope="tenant:model:2", + fingerprint_salt="test-salt", + resolver=PUBLIC, + transport=transport, + now=NOW, + ) + assert calls == ["openai_responses", "anthropic_messages"] + assert [item["state"] for item in result["outcomes"]] == ["unsupported", "supported"] + + +@pytest.mark.asyncio +async def test_all_unknown_dialects_unsupported_gets_negative_ttl(): + async def transport(_request): + return ProbeHTTPResponse(405) + + result = await run_token_count_probe( + inference_protocol="unknown", + base_url="https://api.example.test/v1", + model_name="model", + canonical_model_id="unknown/model", + api_key="key", + credential_scope="scope", + fingerprint_salt="salt", + resolver=PUBLIC, + transport=transport, + now=NOW, + ) + assert len(result["outcomes"]) == 3 + assert result["status"] == "unsupported" + assert result["stale_at"] == "2026-08-25T00:00:00Z" + + +@pytest.mark.asyncio +async def test_timeout_is_temporary_and_not_negative_capability(): + async def transport(_request): + raise TimeoutError + + result = await run_token_count_probe( + inference_protocol="openai", + base_url="https://api.example.test/v1", + model_name="model", + canonical_model_id="openai/model", + api_key="key", + credential_scope="scope", + fingerprint_salt="salt", + resolver=PUBLIC, + transport=transport, + now=NOW, + ) + assert result["status"] == "temporarily_unavailable" + assert result["reason"] == "timeout" + assert result["retry_at"] == "2026-08-24T00:15:00Z" + + +@pytest.mark.asyncio +async def test_valid_cached_evidence_is_reused_without_transport_call(): + calls = 0 + + async def transport(_request): + nonlocal calls + calls += 1 + return ProbeHTTPResponse(500) + + initial = await run_token_count_probe( + inference_protocol="openai", + base_url="https://api.example.test/v1", + model_name="model", + canonical_model_id="openai/model", + api_key="key", + credential_scope="scope", + fingerprint_salt="salt", + resolver=PUBLIC, + transport=lambda request: _supported(request), + now=NOW, + ) + reused = await run_token_count_probe( + inference_protocol="openai", + base_url="https://api.example.test/v1", + model_name="model", + canonical_model_id="openai/model", + api_key="changed-secret-does-not-enter-fingerprint", + credential_scope="scope", + fingerprint_salt="salt", + existing=initial, + resolver=PUBLIC, + transport=transport, + now=NOW + timedelta(hours=1), + ) + assert reused == initial + assert calls == 0 + + +async def _supported(_request): + return ProbeHTTPResponse(200, {"input_tokens": 5}) + + +@pytest.mark.asyncio +async def test_model_or_endpoint_fingerprint_change_invalidates_cache(): + calls = 0 + + async def transport(_request): + nonlocal calls + calls += 1 + return ProbeHTTPResponse(200, {"input_tokens": 5}) + + existing = await run_token_count_probe( + inference_protocol="openai", + base_url="https://api.example.test/v1", + model_name="model", + canonical_model_id="openai/model", + api_key="key", + credential_scope="scope", + fingerprint_salt="salt", + resolver=PUBLIC, + transport=transport, + now=NOW, + ) + calls = 0 + changed = await run_token_count_probe( + inference_protocol="openai", + base_url="https://api.example.test/v2", + model_name="model2", + canonical_model_id="openai/model2", + api_key="key", + credential_scope="scope", + fingerprint_salt="salt", + existing=existing, + resolver=PUBLIC, + transport=transport, + now=NOW + timedelta(hours=1), + ) + assert calls == 1 + assert changed["fingerprint"] != existing["fingerprint"] + + +def test_ssrf_and_credential_bearing_urls_are_rejected(): + with pytest.raises(ValueError, match="ssrf_rejected"): + validate_probe_url("http://127.0.0.1/v1", resolver=lambda _host: ("127.0.0.1",)) + with pytest.raises(ValueError, match="ssrf_rejected"): + validate_probe_url("https://user:pass@example.test/v1", resolver=PUBLIC) + with pytest.raises(ValueError, match="ssrf_rejected"): + validate_probe_url("https://example.test/v1?api_key=secret", resolver=PUBLIC) + + +def test_endpoint_fingerprint_omits_userinfo_query_and_fragment(): + fingerprint = endpoint_fingerprint("https://user:secret@example.test/v1?q=secret#fragment") + assert len(fingerprint) == 24 + assert "secret" not in fingerprint + assert PROBE_ADAPTER_VERSION == "1.0.0" diff --git a/test/deploy/test_context_budget_p1_migration.py b/test/deploy/test_context_budget_p1_migration.py new file mode 100644 index 0000000000..9bd900863d --- /dev/null +++ b/test/deploy/test_context_budget_p1_migration.py @@ -0,0 +1,22 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +MIGRATION = ROOT / "deploy/sql/migrations/v2.5.0_0824_context_budget_p1_governance.sql" + + +def test_ac_p1_011_governance_migration_is_nullable_idempotent_and_secret_free(): + sql = MIGRATION.read_text(encoding="utf-8").lower() + + for column in ( + "canonical_model_id", + "capacity_field_metadata", + "model_identity_metadata", + "tokenizer_match_metadata", + "token_count_probe_metadata", + ): + assert f"add column if not exists {column}" in sql + assert "update nexent.model_record_t" not in sql + assert "api_key" not in sql + assert "begin;" in sql + assert "commit;" in sql diff --git a/test/sdk/core/models/test_capability_profile_governance.py b/test/sdk/core/models/test_capability_profile_governance.py new file mode 100644 index 0000000000..865d583599 --- /dev/null +++ b/test/sdk/core/models/test_capability_profile_governance.py @@ -0,0 +1,78 @@ +import pytest + +from nexent.core.models.capacity_resolver import ( + CapabilityProfile, + validate_capability_catalog, +) + + +def _profile(**overrides): + values = { + "provider": "dashscope", + "model_name": "qwen-plus", + "capability_profile_version": "dashscope/qwen-plus@2", + "window_shape": "combined", + "context_window_tokens": 131_072, + "max_output_tokens": 16_384, + "default_output_reserve_tokens": 4_096, + "tokenizer_family": "qwen", + "aliases": ("qwen-plus",), + "exclusions": ("qwen-vl",), + "evidence": ("aliyun-model-doc-2026-08",), + "verified_at": "2026-08-01T00:00:00Z", + "shared_context": True, + "independent_input": False, + "max_output": 16_384, + "reasoning_behavior": "unknown", + "overhead_behavior": "bounded", + "confidence": "high", + } + values.update(overrides) + return CapabilityProfile(**values) + + +def test_complete_evidence_backed_profile_is_auto_applicable(): + profile = _profile() + result = validate_capability_catalog({("dashscope", "qwen-plus"): profile}) + assert profile.auto_applicable is True + assert result[("dashscope", "qwen-plus")] == () + + +def test_incomplete_legacy_profile_remains_suggestion_only(): + profile = _profile( + aliases=(), evidence=(), verified_at=None, confidence="unknown" + ) + result = validate_capability_catalog({("dashscope", "qwen-plus"): profile}) + assert profile.auto_applicable is False + assert "evidence_missing" in result[("dashscope", "qwen-plus")] + + +def test_incomplete_profile_cannot_claim_verified_high_confidence(): + profile = _profile(evidence=()) + with pytest.raises(ValueError, match="incomplete_verified_profile"): + validate_capability_catalog({("dashscope", "qwen-plus"): profile}) + + +def test_declared_max_output_must_match_capacity_value(): + profile = _profile(max_output=8_192) + with pytest.raises(ValueError, match="max_output_conflict"): + validate_capability_catalog({("dashscope", "qwen-plus"): profile}) + + +def test_catalog_key_must_match_profile_identity(): + with pytest.raises(ValueError, match="catalog_key_mismatch"): + validate_capability_catalog({("other", "qwen-plus"): _profile()}) + + +def test_production_catalog_verified_rows_are_complete_and_legacy_rows_are_suggestion_only(): + from consts.capability_profiles import CATALOG + + diagnostics = validate_capability_catalog(CATALOG) + qwen = CATALOG[("dashscope", "qwen-plus")] + assert qwen.auto_applicable is True + assert diagnostics[("dashscope", "qwen-plus")] == () + qwen_37 = CATALOG[("dashscope", "qwen3.7-plus")] + assert qwen_37.auto_applicable is True + assert qwen_37.max_input_tokens == 991_808 + assert diagnostics[("dashscope", "qwen3.7-plus")] == () + assert CATALOG[("openai", "gpt-4o")].auto_applicable is False diff --git a/test/sdk/core/models/test_model_identity.py b/test/sdk/core/models/test_model_identity.py new file mode 100644 index 0000000000..9350363aba --- /dev/null +++ b/test/sdk/core/models/test_model_identity.py @@ -0,0 +1,59 @@ +import pytest + +from nexent.core.models.model_identity import ( + MATCHER_VERSION, + identities_are_safe_aliases, + parse_model_identity, +) + + +@pytest.mark.parametrize( + ("model_id", "expected"), + [ + ("Qwen2-7B-Instruct", {"family": "qwen", "version": "2", "size": "7b", "tune": "instruct"}), + ("Qwen2.5-VL-7B-Instruct", {"family": "qwen", "version": "2.5", "modality": "vl"}), + ("DeepSeek-R1-Distill-Qwen-32B", {"family": "deepseek", "reasoning": "r1", "size": "32b"}), + ("Llama-3.1-8B-Instruct-AWQ", {"family": "llama", "version": "3.1", "quantization": "awq"}), + ("Qwen2.5-72B-Instruct-128K", {"family": "qwen", "context_extension": "128k"}), + ], +) +def test_ac_p1_003_parse_capacity_relevant_variants(model_id, expected): + identity = parse_model_identity(model_id, "silicon") + + assert identity.matcher_version == MATCHER_VERSION + for field_name, value in expected.items(): + assert getattr(identity, field_name) == value + + +@pytest.mark.parametrize( + ("left", "right"), + [ + ("Qwen2-7B-Instruct", "Qwen2.5-7B-Instruct"), + ("Qwen2.5-7B-Instruct", "Qwen2.5-VL-7B-Instruct"), + ("DeepSeek-R1", "DeepSeek-R1-Distill-Qwen-32B"), + ("Qwen2.5-72B-Instruct", "Qwen2.5-72B-Instruct-128K"), + ], +) +def test_ac_p1_003_conflicting_variants_are_not_aliases(left, right): + assert not identities_are_safe_aliases( + parse_model_identity(left, "silicon"), + parse_model_identity(right, "silicon"), + ) + + +def test_ac_p1_003_separator_variants_are_safe_aliases(): + assert identities_are_safe_aliases( + parse_model_identity("Deepseek V4 Flash", "silicon"), + parse_model_identity("DeepSeek-V4-Flash", "silicon"), + ) + + +def test_ac_p1_003_provider_namespace_is_part_of_canonical_id(): + assert parse_model_identity("qwen-plus", "dashscope").canonical_id != parse_model_identity( + "qwen-plus", "other" + ).canonical_id + + +def test_ac_p1_003_empty_model_id_rejected(): + with pytest.raises(ValueError, match="model_id is required"): + parse_model_identity(" ") diff --git a/test/sdk/core/models/test_tokenizer_governance.py b/test/sdk/core/models/test_tokenizer_governance.py new file mode 100644 index 0000000000..ee82116363 --- /dev/null +++ b/test/sdk/core/models/test_tokenizer_governance.py @@ -0,0 +1,144 @@ +from dataclasses import dataclass + +import pytest + +from nexent.core.models import tokenizer_registry as registry +from nexent.core.models.tokenizer_registry import ( + TokenizerConformanceFixture, + TokenizerProfile, + register, + register_profile, + resolve_for_model, + run_conformance, +) + + +@dataclass +class ExactFixtureAdapter: + family: str = "fixture_exact" + + def count_tokens(self, messages): + return int(messages[0]["expected"]) + + +@pytest.fixture(autouse=True) +def isolated_registry(): + old_registry = dict(registry.REGISTRY) + old_profiles = dict(registry.PROFILES) + old_conformance = dict(registry.CONFORMANCE) + registry.REGISTRY.clear() + registry.PROFILES.clear() + registry.CONFORMANCE.clear() + yield + registry.REGISTRY.clear() + registry.REGISTRY.update(old_registry) + registry.PROFILES.clear() + registry.PROFILES.update(old_profiles) + registry.CONFORMANCE.clear() + registry.CONFORMANCE.update(old_conformance) + + +def profile(**overrides): + values = { + "profile_id": "qwen2.5-text@1", + "family": "fixture_exact", + "aliases": ("Qwen2.5-7B-Instruct",), + "exclusions": ("Qwen2.5-VL-7B-Instruct",), + "adapter_version": "1.0", + "package_version": "test-1", + "fixture_version": "fixtures-1", + "verification_status": "verified", + "priority": 10, + } + values.update(overrides) + return TokenizerProfile(**values) + + +def passing_fixtures(count=100): + return tuple( + TokenizerConformanceFixture( + fixture_id=f"fixture-{index}", + messages=({"expected": 10 + index},), + expected_tokens=10 + index, + ) + for index in range(count) + ) + + +def test_ac_p1_004_unique_verified_conforming_adapter_is_exact(): + adapter = ExactFixtureAdapter() + register(adapter) + register_profile(profile()) + report = run_conformance( + adapter, + passing_fixtures(), + adapter_version="1.0", + fixture_version="fixtures-1", + ) + + result = resolve_for_model("silicon", "Qwen2.5-7B-Instruct") + + assert report.passed + assert result.counting_mode == "exact" + assert result.profile_id == "qwen2.5-text@1" + assert result.reason == "verified_profile_and_conformance" + + +@pytest.mark.parametrize( + ("setup", "reason"), + [ + ("missing_adapter", "tokenizer_adapter_unavailable"), + ("unverified", "tokenizer_profile_unverified"), + ("missing_report", "tokenizer_conformance_missing"), + ("failed_report", "tokenizer_conformance_failed"), + ("stale_report", "tokenizer_conformance_stale"), + ], +) +def test_ac_p1_004_non_verified_paths_fall_back(setup, reason): + adapter = ExactFixtureAdapter() + if setup != "missing_adapter": + register(adapter) + register_profile(profile(verification_status="unverified" if setup == "unverified" else "verified")) + if setup in {"failed_report", "stale_report"}: + run_conformance( + adapter, + passing_fixtures(1 if setup == "failed_report" else 100), + adapter_version="old" if setup == "stale_report" else "1.0", + fixture_version="fixtures-1", + ) + + result = resolve_for_model("silicon", "Qwen2.5-7B-Instruct") + + assert result.counting_mode == "estimated" + assert result.reason == reason + + +def test_ac_p1_004_exclusion_wins_over_family_alias(): + register_profile(profile()) + + result = resolve_for_model("silicon", "Qwen2.5-VL-7B-Instruct") + + assert result.counting_mode == "estimated" + assert result.reason == "tokenizer_profile_not_found" + + +def test_ac_p1_004_equal_priority_matches_are_ambiguous(): + register_profile(profile()) + register_profile(profile(profile_id="qwen2.5-other@1")) + + result = resolve_for_model("silicon", "Qwen2.5-7B-Instruct") + + assert result.counting_mode == "estimated" + assert result.reason == "tokenizer_profile_ambiguous" + assert result.candidates == ("qwen2.5-other@1", "qwen2.5-text@1") + + +def test_ac_p1_004_conformance_requires_full_fixture_floor(): + report = run_conformance( + ExactFixtureAdapter(), + passing_fixtures(99), + adapter_version="1.0", + fixture_version="fixtures-1", + ) + + assert not report.passed From 22a5baad317fe6bcda6e179999401fea887630de Mon Sep 17 00:00:00 2001 From: Jinglong Wang Date: Mon, 24 Aug 2026 12:01:41 +0800 Subject: [PATCH 04/10] feat(agent): meter final context requests --- backend/agents/create_agent_info.py | 34 +- sdk/nexent/core/agents/agent_model.py | 16 + sdk/nexent/core/agents/context/manager.py | 81 ++- sdk/nexent/core/agents/context/runtime.py | 4 + sdk/nexent/core/agents/core_agent.py | 56 +- sdk/nexent/core/agents/nexent_agent.py | 11 + sdk/nexent/core/context_runtime/contracts.py | 5 + .../core/models/final_request_budget.py | 594 ++++++++++++++++++ sdk/nexent/core/models/openai_llm.py | 429 ++++++++++++- .../core/models/provider_request_count.py | 154 +++++ sdk/nexent/monitor/monitoring.py | 1 + test/backend/agents/test_create_agent_info.py | 15 +- .../agents/test_context_helper_contracts.py | 37 ++ test/sdk/core/agents/test_core_agent.py | 57 ++ test/sdk/core/agents/test_nexent_agent.py | 30 + .../core/models/test_final_request_budget.py | 153 +++++ test/sdk/core/models/test_openai_llm.py | 236 ++++++- .../models/test_provider_request_count.py | 126 ++++ 18 files changed, 2004 insertions(+), 35 deletions(-) create mode 100644 sdk/nexent/core/models/final_request_budget.py create mode 100644 sdk/nexent/core/models/provider_request_count.py create mode 100644 test/sdk/core/models/test_final_request_budget.py create mode 100644 test/sdk/core/models/test_provider_request_count.py diff --git a/backend/agents/create_agent_info.py b/backend/agents/create_agent_info.py index 432f27584e..b4d06413fc 100644 --- a/backend/agents/create_agent_info.py +++ b/backend/agents/create_agent_info.py @@ -903,7 +903,11 @@ async def create_model_config_list(tenant_id): default_output_reserve_tokens=record.get("default_output_reserve_tokens"), tokenizer_family=record.get("tokenizer_family"), capacity_source=record.get("capacity_source"), - capability_profile_version=record.get("capability_profile_version"))) + capability_profile_version=record.get("capability_profile_version"), + canonical_model_id=record.get("canonical_model_id"), + model_identity_metadata=record.get("model_identity_metadata"), + tokenizer_match_metadata=record.get("tokenizer_match_metadata"), + token_count_probe_metadata=record.get("token_count_probe_metadata"))) # fit for old version, main_model and sub_model use default model main_model_config = tenant_config_manager.get_model_config( key=MODEL_CONFIG_MAPPING["llm"], tenant_id=tenant_id) @@ -919,7 +923,19 @@ async def create_model_config_list(tenant_id): model_factory=main_model_config.get("model_factory"), timeout_seconds=main_model_config.get("timeout_seconds"), concurrency_limit=main_model_config.get("concurrency_limit"), - prompt_cache=main_prompt_cache)) + prompt_cache=main_prompt_cache, + max_output_tokens=main_model_config.get("max_output_tokens"), + max_tokens=main_model_config.get("max_tokens"), + context_window_tokens=main_model_config.get("context_window_tokens"), + max_input_tokens=main_model_config.get("max_input_tokens"), + default_output_reserve_tokens=main_model_config.get("default_output_reserve_tokens"), + tokenizer_family=main_model_config.get("tokenizer_family"), + capacity_source=main_model_config.get("capacity_source"), + capability_profile_version=main_model_config.get("capability_profile_version"), + canonical_model_id=main_model_config.get("canonical_model_id"), + model_identity_metadata=main_model_config.get("model_identity_metadata"), + tokenizer_match_metadata=main_model_config.get("tokenizer_match_metadata"), + token_count_probe_metadata=main_model_config.get("token_count_probe_metadata"))) model_list.append( ModelConfig(cite_name="sub_model", api_key=main_model_config.get("api_key", ""), @@ -930,7 +946,19 @@ async def create_model_config_list(tenant_id): model_factory=main_model_config.get("model_factory"), timeout_seconds=main_model_config.get("timeout_seconds"), concurrency_limit=main_model_config.get("concurrency_limit"), - prompt_cache=main_prompt_cache)) + prompt_cache=main_prompt_cache, + max_output_tokens=main_model_config.get("max_output_tokens"), + max_tokens=main_model_config.get("max_tokens"), + context_window_tokens=main_model_config.get("context_window_tokens"), + max_input_tokens=main_model_config.get("max_input_tokens"), + default_output_reserve_tokens=main_model_config.get("default_output_reserve_tokens"), + tokenizer_family=main_model_config.get("tokenizer_family"), + capacity_source=main_model_config.get("capacity_source"), + capability_profile_version=main_model_config.get("capability_profile_version"), + canonical_model_id=main_model_config.get("canonical_model_id"), + model_identity_metadata=main_model_config.get("model_identity_metadata"), + tokenizer_match_metadata=main_model_config.get("tokenizer_match_metadata"), + token_count_probe_metadata=main_model_config.get("token_count_probe_metadata"))) return model_list diff --git a/sdk/nexent/core/agents/agent_model.py b/sdk/nexent/core/agents/agent_model.py index 80dcdd860c..f710fcf43c 100644 --- a/sdk/nexent/core/agents/agent_model.py +++ b/sdk/nexent/core/agents/agent_model.py @@ -82,6 +82,22 @@ class ModelConfig(BaseModel): description="Version of the approved provider/model capability profile selected by the resolver, e.g. 'openai/gpt-4o@1'.", default=None, ) + canonical_model_id: Optional[str] = Field( + description="P1 canonical model identity used to isolate P2 request calibration.", + default=None, + ) + model_identity_metadata: Optional[Dict[str, Any]] = Field( + description="Sanitized P1 model-identity and matcher evidence.", + default=None, + ) + tokenizer_match_metadata: Optional[Dict[str, Any]] = Field( + description="Sanitized P1 tokenizer match and conformance evidence.", + default=None, + ) + token_count_probe_metadata: Optional[Dict[str, Any]] = Field( + description="Sanitized P1 Provider full-request count capability evidence.", + default=None, + ) timeout_seconds: Optional[float] = Field( description="Request timeout in seconds. If None, uses provider default.", default=None diff --git a/sdk/nexent/core/agents/context/manager.py b/sdk/nexent/core/agents/context/manager.py index 6d4e270d59..cad41d1946 100644 --- a/sdk/nexent/core/agents/context/manager.py +++ b/sdk/nexent/core/agents/context/manager.py @@ -119,6 +119,7 @@ def assemble_final_context( task: str | None = None, final_answer_templates: Optional[Dict[str, Any]] = None, run_context: ManagedRunContext | None = None, + target_input_budget_tokens: int | None = None, ) -> FinalContext: run_context = run_context or self.prepare_run_context(memory, "") policy = resolve_policy(self.config.policy_layers) @@ -143,6 +144,13 @@ def assemble_final_context( ) canonical_tools = self._canonical_tools(tools or ()) raw_tokens = self._estimate_items(items, purpose_stable, purpose_dynamic, canonical_tools) + soft_budget = self._soft_input_budget_tokens() + hard_budget = self._hard_input_budget_tokens() + if target_input_budget_tokens is not None: + if target_input_budget_tokens <= 0: + raise ValueError("target_input_budget_tokens must be positive") + soft_budget = min(soft_budget, target_input_budget_tokens) + hard_budget = min(hard_budget, target_input_budget_tokens) final_items = list(items) history_triggered = False new_coverage = None @@ -151,7 +159,7 @@ def assemble_final_context( if ( policy.processing_mode == ContextProcessingMode.ADAPTIVE_COMPACT - and raw_tokens > self._soft_input_budget_tokens() + and raw_tokens > soft_budget ): summary = next((item for item in final_items if item.type == ContextItemType.HISTORY_SUMMARY), None) turns = [item for item in final_items if item.type == ContextItemType.CONVERSATION_TURN] @@ -187,6 +195,7 @@ def assemble_final_context( purpose_dynamic, canonical_tools, model=model, + target_tokens=soft_budget, ) final_items.sort(key=lambda item: item.layout_key) @@ -198,10 +207,25 @@ def assemble_final_context( final_tokens = self._message_tokens(messages) + self._tools_tokens(canonical_tools) self._last_uncompressed_token_count = raw_tokens self._last_compressed_token_count = final_tokens - hard = self._hard_input_budget_tokens() + hard = hard_budget over_hard = final_tokens > hard compact_exhausted = over_hard + budget_failure_reason = None if over_hard: + budget_failure_reason = self._budget_failure_reason( + final_items, + purpose_stable, + purpose_dynamic, + canonical_tools, + hard_budget=hard, + compression_attempted=( + bool(self._step_local_log) + or any( + str(item.metadata.get("representation", "raw")) != "raw" + for item in final_items + ) + ), + ) logger.warning("Context remains over hard budget after safe compact: %s > %s", final_tokens, hard) representations = tuple((item.id, str(item.metadata.get("representation", "raw"))) for item in final_items) @@ -233,7 +257,7 @@ def assemble_final_context( if run_context.selection_decision else None, processing_mode=policy.processing_mode.value, - soft_budget=self._soft_input_budget_tokens(), + soft_budget=soft_budget, hard_budget=hard, raw_token_estimate=raw_tokens, final_token_estimate=final_tokens, @@ -255,6 +279,7 @@ def assemble_final_context( representation_cache_misses=misses, compact_exhausted=compact_exhausted, over_hard_budget=over_hard, + budget_failure_reason=budget_failure_reason, messages_fingerprint=self._fingerprint(messages), tools_fingerprint=self._fingerprint(canonical_tools), system_messages_fingerprint=self._fingerprint(system_messages), @@ -270,15 +295,51 @@ def assemble_final_context( ), ) + def _budget_failure_reason( + self, + items, + purpose_stable, + purpose_dynamic, + tools, + *, + hard_budget: int, + compression_attempted: bool, + ) -> str: + if any( + self._estimate_items([item], [], [], []) > hard_budget + for item in items + ): + return "single_context_item_oversize" + fixed_types = { + ContextItemType.SYSTEM, + ContextItemType.TOOL, + ContextItemType.SKILL, + ContextItemType.MANAGED_AGENT, + ContextItemType.EXTERNAL_AGENT, + } + fixed_items = [item for item in items if item.type in fixed_types] + if ( + self._estimate_items( + fixed_items, purpose_stable, purpose_dynamic, tools + ) + > hard_budget + ): + return "fixed_context_over_budget" + if compression_attempted: + return "compaction_no_reduction" + return "final_request_over_hard_budget" + def consume_history_summary_event(self) -> dict[str, Any] | None: """Return a newly-created summary checkpoint once for stream display.""" event = self._pending_history_summary_event self._pending_history_summary_event = None return deepcopy(event) if event is not None else None - def _compact_to_soft_budget(self, items, purpose_stable, purpose_dynamic, tools, *, model): + def _compact_to_soft_budget( + self, items, purpose_stable, purpose_dynamic, tools, *, model, target_tokens: int + ): result = list(items) - if self._estimate_items(result, purpose_stable, purpose_dynamic, tools) <= self._soft_input_budget_tokens(): + if self._estimate_items(result, purpose_stable, purpose_dynamic, tools) <= target_tokens: return result keep_recent = max(0, self.config.keep_recent_steps) actions = [item for item in result if item.type == ContextItemType.CURRENT_ACTION] @@ -309,19 +370,21 @@ def _compact_to_soft_budget(self, items, purpose_stable, purpose_dynamic, tools, result[index] = compact if ( self._estimate_items(result, purpose_stable, purpose_dynamic, tools) - <= self._soft_input_budget_tokens() + <= target_tokens ): return result if self.config.enable_long_term_memory_selection and long_term_items: - return self._select_long_term_memories(result, long_term_items, model=model) + return self._select_long_term_memories( + result, long_term_items, model=model, target_tokens=target_tokens + ) return result - def _select_long_term_memories(self, result, memory_items, *, model): + def _select_long_term_memories(self, result, memory_items, *, model, target_tokens: int): task_item = next((item for item in result if item.type == ContextItemType.CURRENT_TASK), None) task = json.dumps(task_item.content, ensure_ascii=False, default=str) if task_item else "" model_id = str(getattr(model, "model_id", None) or getattr(model, "model_name", None) or model.__class__.__name__) - target_tokens = max(64, self._soft_input_budget_tokens() // 4) + target_tokens = max(64, target_tokens // 4) versions = tuple(sorted(str(item.metadata.get("version_id") or item.id) for item in memory_items)) cache_key = (*versions, task, target_tokens, model_id) cached = self._memory_compact_cache.get(cache_key) diff --git a/sdk/nexent/core/agents/context/runtime.py b/sdk/nexent/core/agents/context/runtime.py index 44bacc8168..7ea90f2980 100644 --- a/sdk/nexent/core/agents/context/runtime.py +++ b/sdk/nexent/core/agents/context/runtime.py @@ -68,6 +68,7 @@ def prepare_step( memory: AgentMemory, current_run_start_idx: int, tools: Sequence[ModelTool] | None = None, + target_input_budget_tokens: int | None = None, ) -> FinalContext: final_context = self.context_manager.assemble_final_context( model=model, @@ -76,6 +77,7 @@ def prepare_step( tools=tools, purpose="step", run_context=self._ensure_run_context(memory), + target_input_budget_tokens=target_input_budget_tokens, ) self._evidence.record_call(final_context.evidence) return final_context @@ -89,6 +91,7 @@ def prepare_final_answer( task: str, final_answer_templates: Mapping[str, Mapping[str, str]], tools: Sequence[ModelTool] | None = None, + target_input_budget_tokens: int | None = None, ) -> FinalContext: final_context = self.context_manager.assemble_final_context( model=model, @@ -99,6 +102,7 @@ def prepare_final_answer( task=task, final_answer_templates=final_answer_templates, run_context=self._ensure_run_context(memory), + target_input_budget_tokens=target_input_budget_tokens, ) self._evidence.record_call(final_context.evidence) return final_context diff --git a/sdk/nexent/core/agents/core_agent.py b/sdk/nexent/core/agents/core_agent.py index dcfc136364..7102454c00 100644 --- a/sdk/nexent/core/agents/core_agent.py +++ b/sdk/nexent/core/agents/core_agent.py @@ -756,8 +756,11 @@ def _ensure_context_within_hard_budget(final_context: Any) -> None: """Stop before the provider call when safe compaction cannot fit input.""" evidence = final_context.evidence if evidence.over_hard_budget is True: + reason = getattr( + evidence, "budget_failure_reason", None + ) or "final_request_over_hard_budget" raise ValueError( - "Context input remains over the model hard budget after compaction: " + f"{reason}: Context input remains over the model hard budget after compaction: " f"{evidence.final_token_estimate} > {evidence.hard_budget} tokens" ) @@ -788,6 +791,7 @@ def _step_stream(self, memory_step: ActionStep) -> Generator[Any]: self._emit_history_summary_event() self._ensure_context_within_hard_budget(final_context) input_messages = final_context.messages + self.model.last_context_evidence = final_context.evidence chars_per_token = self.context_runtime.chars_per_token # Baseline for the per-step compression ratio. ``final_context.messages`` # is already the compressed payload, so use the ContextManager's raw @@ -831,8 +835,32 @@ def _step_stream(self, memory_step: ActionStep) -> Generator[Any]: self._append_verification_feedback(memory_step, decision.verification_result) try: - chat_message: ChatMessage = self.model(input_messages, - stop_sequences=stop_sequences, **additional_args) + model_call_args = dict(additional_args) + rebuild_allowed = guardrail_engine is None or decision.effective_action == "pass" + if ( + rebuild_allowed + and getattr(self.model, "safe_input_budget_snapshot", None) is not None + ): + def rebuild_context(target_tokens: int): + rebuilt = self.context_runtime.prepare_step( + model=self.model, + memory=self.memory, + current_run_start_idx=self._history_step_count, + tools=self._context_tools(), + target_input_budget_tokens=target_tokens, + ) + get_monitoring_manager().record_final_context_evidence( + rebuilt.evidence, step_number=self.step_number + ) + self._ensure_context_within_hard_budget(rebuilt) + return rebuilt + + model_call_args["context_rebuild"] = rebuild_context + chat_message: ChatMessage = self.model( + input_messages, + stop_sequences=stop_sequences, + **model_call_args, + ) memory_step.model_output_message = chat_message model_output = chat_message.content memory_step.token_usage = chat_message.token_usage @@ -1481,6 +1509,7 @@ def _handle_max_steps_reached(self, task: str) -> Any: self._emit_history_summary_event() self._ensure_context_within_hard_budget(final_context) messages = final_context.messages + self.model.last_context_evidence = final_context.evidence # Create the final memory step with error final_memory_step = ActionStep( @@ -1499,7 +1528,26 @@ def _handle_max_steps_reached(self, task: str) -> Any: # Use streaming call (model.__call__) to generate final answer # This will trigger observer.add_model_new_token() and # observer.add_model_reasoning_content() in OpenAIModel - chat_message: ChatMessage = self.model(messages) + model_call_args = {} + if getattr(self.model, "safe_input_budget_snapshot", None) is not None: + def rebuild_final_context(target_tokens: int): + rebuilt = self.context_runtime.prepare_final_answer( + model=self.model, + memory=self.memory, + current_run_start_idx=self._history_step_count, + tools=self._context_tools(), + task=task, + final_answer_templates=self.prompt_templates, + target_input_budget_tokens=target_tokens, + ) + get_monitoring_manager().record_final_context_evidence( + rebuilt.evidence, step_number=self.step_number + ) + self._ensure_context_within_hard_budget(rebuilt) + return rebuilt + + model_call_args["context_rebuild"] = rebuild_final_context + chat_message: ChatMessage = self.model(messages, **model_call_args) # Update role and content from the completed message role = chat_message.role diff --git a/sdk/nexent/core/agents/nexent_agent.py b/sdk/nexent/core/agents/nexent_agent.py index da248448b9..75453a93cc 100644 --- a/sdk/nexent/core/agents/nexent_agent.py +++ b/sdk/nexent/core/agents/nexent_agent.py @@ -250,6 +250,17 @@ def create_model(self, model_cite_name: str): max_output_tokens=model_config.max_output_tokens, timeout_seconds=model_config.timeout_seconds, prompt_cache=model_config.prompt_cache, + **{ + key: value + for key, value in { + "canonical_model_id": model_config.canonical_model_id, + "model_identity_metadata": model_config.model_identity_metadata, + "tokenizer_match_metadata": model_config.tokenizer_match_metadata, + "token_count_probe_metadata": model_config.token_count_probe_metadata, + "tokenizer_family": model_config.tokenizer_family, + }.items() + if value is not None + }, ) model.stop_event = self.stop_event return model diff --git a/sdk/nexent/core/context_runtime/contracts.py b/sdk/nexent/core/context_runtime/contracts.py index ba3b86d322..027b61f66b 100644 --- a/sdk/nexent/core/context_runtime/contracts.py +++ b/sdk/nexent/core/context_runtime/contracts.py @@ -56,6 +56,7 @@ class ContextEvidence: representation_cache_misses: int = 0 compact_exhausted: bool = False over_hard_budget: bool = False + budget_failure_reason: str | None = None model_call_count: int = 0 loop_status: str | None = None messages_fingerprint: str | None = None @@ -96,6 +97,7 @@ def prepare_step( memory: AgentMemory, current_run_start_idx: int, tools: Sequence[ModelTool] | None = None, + target_input_budget_tokens: int | None = None, ) -> FinalContext: """Return all model messages for the current step.""" @@ -108,6 +110,7 @@ def prepare_final_answer( task: str, final_answer_templates: Mapping[str, Mapping[str, str]], tools: Sequence[ModelTool] | None = None, + target_input_budget_tokens: int | None = None, ) -> FinalContext: """Return all model messages for final-answer generation.""" @@ -168,6 +171,7 @@ def prepare_step( memory: AgentMemory, current_run_start_idx: int, tools: Sequence[ModelTool] | None = None, + target_input_budget_tokens: int | None = None, ) -> FinalContext: raise RuntimeError(_UNCONFIGURED_RUNTIME_ERROR) @@ -180,6 +184,7 @@ def prepare_final_answer( task: str, final_answer_templates: Mapping[str, Mapping[str, str]], tools: Sequence[ModelTool] | None = None, + target_input_budget_tokens: int | None = None, ) -> FinalContext: raise RuntimeError(_UNCONFIGURED_RUNTIME_ERROR) diff --git a/sdk/nexent/core/models/final_request_budget.py b/sdk/nexent/core/models/final_request_budget.py new file mode 100644 index 0000000000..cb419fcc81 --- /dev/null +++ b/sdk/nexent/core/models/final_request_budget.py @@ -0,0 +1,594 @@ +"""Trusted final Provider-request metering and bounded usage calibration. + +The request passed here is the already-rendered adapter payload. This module +never stores request content: evidence consists only of hashes, classifications +and numerical counts. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import threading +import time +import uuid +from collections import deque +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, field +from typing import Any, Literal, Optional + +from ..utils.token_estimation import estimate_tokens_text + + +ESTIMATOR_VERSION = "final-request-v1" +DEFAULT_CORRECTION_MULTIPLIER = 1.15 +MIN_CALIBRATION_SAMPLES = 20 +MAX_CALIBRATION_SAMPLES = 256 +CALIBRATION_TTL_SECONDS = 24 * 60 * 60 +MAX_OBSERVED_RATIO = 4.0 +MAX_GATE_MULTIPLIER = 2.0 + +RequestShape = Literal["text", "tools", "media", "tools_media"] +CountSource = Literal["provider", "tokenizer", "estimated"] +SideEffectState = Literal[ + "pristine", "response_started", "tool_effect", "application_effect", "persisted" +] + +_TRANSPORT_KEYS = frozenset( + { + "stream", + "stream_options", + "timeout", + "request_timeout", + "http_client", + } +) +_REASONING_KEYS = frozenset( + { + "reasoning", + "reasoning_effort", + "thinking", + "enable_thinking", + "chat_template_kwargs", + } +) +_MEDIA_TYPES = frozenset( + { + "image", + "image_url", + "input_image", + "audio", + "input_audio", + "file", + "document", + } +) + + +class FinalRequestBudgetError(Exception): + reason_code = "final_request_budget_error" + + +class FinalRequestOverHardBudget(FinalRequestBudgetError): + reason_code = "final_request_over_hard_budget" + + def __init__( + self, + *, + actual: int, + hard_budget: int, + preflight: Optional["FinalRequestPreflight"] = None, + ) -> None: + self.actual = actual + self.hard_budget = hard_budget + self.preflight = preflight + super().__init__( + f"{self.reason_code}: final request {actual} exceeds hard budget {hard_budget}" + ) + + +class StaleRequestBudgetIdentity(FinalRequestBudgetError): + reason_code = "stale_request_budget_identity" + + +class FinalRequestSoftBudgetExceeded(FinalRequestBudgetError): + reason_code = "final_request_over_soft_budget" + + def __init__(self, preflight: "FinalRequestPreflight") -> None: + self.preflight = preflight + super().__init__( + f"{self.reason_code}: final request {preflight.soft_count} exceeds " + f"soft budget {preflight.soft_budget}" + ) + + +class ProviderContextOverflow(FinalRequestBudgetError): + reason_code = "provider_context_overflow" + + +class ProviderContextOverflowRetryUnsafe(FinalRequestBudgetError): + reason_code = "provider_context_overflow_retry_unsafe" + + +class ProviderContextOverflowRetryExhausted(FinalRequestBudgetError): + reason_code = "provider_context_overflow_retry_exhausted" + + +class CompactionNoReduction(FinalRequestBudgetError): + reason_code = "compaction_no_reduction" + + +class RequestSideEffectGuard: + """Monotonic proof that a physical request is still safe to rebuild.""" + + _ORDER = { + "pristine": 0, + "response_started": 1, + "tool_effect": 2, + "application_effect": 3, + "persisted": 4, + } + + def __init__(self) -> None: + self._state: SideEffectState = "pristine" + self._lock = threading.Lock() + + @property + def state(self) -> SideEffectState: + with self._lock: + return self._state + + @property + def recovery_safe(self) -> bool: + return self.state == "pristine" + + def mark(self, state: SideEffectState) -> None: + with self._lock: + if self._ORDER[state] > self._ORDER[self._state]: + self._state = state + + +def is_provider_context_overflow(error: BaseException) -> bool: + """Classify structured OpenAI-compatible overflow errors conservatively.""" + code = getattr(error, "code", None) + body = getattr(error, "body", None) + if isinstance(body, Mapping): + nested = body.get("error") if isinstance(body.get("error"), Mapping) else body + code = code or nested.get("code") or nested.get("type") + if str(code or "").lower() in { + "context_length_exceeded", + "max_tokens_exceeded", + "input_too_long", + }: + return True + message = str(error).lower() + return any( + marker in message + for marker in ( + "context_length_exceeded", + "maximum context length", + "input tokens exceed", + "too many tokens", + ) + ) + + +@dataclass(frozen=True) +class FinalRequestIdentity: + endpoint_fingerprint: str + credential_scope_fingerprint: str + canonical_model_id: str + provider: str + model_name: str + w1_fingerprint: str + w2_fingerprint: str + + @property + def fingerprint(self) -> str: + return _fingerprint( + { + "endpoint": self.endpoint_fingerprint, + "scope": self.credential_scope_fingerprint, + "canonical_model": self.canonical_model_id, + "provider": self.provider, + "model": self.model_name, + "w1": self.w1_fingerprint, + "w2": self.w2_fingerprint, + } + ) + + +@dataclass(frozen=True) +class RequestComponentCounts: + message_text: int = 0 + message_framing: int = 0 + tools: int = 0 + media: int = 0 + reasoning: int = 0 + other_semantic: int = 0 + + @property + def raw_total(self) -> int: + return max( + 1, + self.message_text + + self.message_framing + + self.tools + + self.media + + self.reasoning + + self.other_semantic, + ) + + +@dataclass(frozen=True) +class FinalRequestShape: + fingerprint: str + request_shape: RequestShape + reasoning_mode: str + semantic_request: Mapping[str, Any] = field(repr=False, compare=False) + components: RequestComponentCounts = field(default_factory=RequestComponentCounts) + + +@dataclass(frozen=True) +class CalibrationKey: + endpoint_fingerprint: str + credential_scope_fingerprint: str + canonical_model_id: str + request_shape: RequestShape + reasoning_mode: str + estimator_version: str = ESTIMATOR_VERSION + + @property + def fingerprint(self) -> str: + return _fingerprint(self.__dict__) + + +@dataclass(frozen=True) +class CalibrationStats: + sample_count: int = 0 + mature: bool = False + p95: Optional[float] = None + p99: Optional[float] = None + + +@dataclass(frozen=True) +class FinalRequestPreflight: + request_id: str + request_fingerprint: str + identity_fingerprint: str + request_shape: RequestShape + reasoning_mode: str + count_source: CountSource + components: RequestComponentCounts + raw_estimate: int + provider_count: Optional[int] + soft_count: int + hard_count: int + soft_budget: int + hard_budget: int + soft_exceeded: bool + hard_exceeded: bool + calibration_key_fingerprint: str + calibration_sample_count: int + calibration_p95: Optional[float] + calibration_p99: Optional[float] + fallback_reason: Optional[str] + retry_ordinal: int = 0 + estimator_version: str = ESTIMATOR_VERSION + + +@dataclass(frozen=True) +class _Sample: + request_id: str + ratio: float + observed_at: float + + +class CalibrationStore: + """Bounded, expiring and request-id-deduplicated calibration samples.""" + + def __init__( + self, + *, + max_samples: int = MAX_CALIBRATION_SAMPLES, + ttl_seconds: int = CALIBRATION_TTL_SECONDS, + minimum_samples: int = MIN_CALIBRATION_SAMPLES, + clock: Callable[[], float] = time.time, + ) -> None: + self.max_samples = max_samples + self.ttl_seconds = ttl_seconds + self.minimum_samples = minimum_samples + self._clock = clock + self._samples: dict[CalibrationKey, deque[_Sample]] = {} + self._lock = threading.Lock() + + def observe( + self, + key: CalibrationKey, + *, + request_id: str, + raw_estimate: int, + provider_prompt_tokens: int, + ) -> bool: + if raw_estimate <= 0 or provider_prompt_tokens <= 0: + return False + now = self._clock() + ratio = min( + MAX_OBSERVED_RATIO, + max(0.5, provider_prompt_tokens / raw_estimate), + ) + with self._lock: + samples = self._prune_locked(key, now) + if any(sample.request_id == request_id for sample in samples): + return False + samples.append(_Sample(request_id, ratio, now)) + while len(samples) > self.max_samples: + samples.popleft() + return True + + def stats(self, key: CalibrationKey) -> CalibrationStats: + with self._lock: + samples = list(self._prune_locked(key, self._clock())) + ratios = sorted(sample.ratio for sample in samples) + if not ratios: + return CalibrationStats() + return CalibrationStats( + sample_count=len(ratios), + mature=len(ratios) >= self.minimum_samples, + p95=_nearest_rank(ratios, 0.95), + p99=_nearest_rank(ratios, 0.99), + ) + + def clear(self) -> None: + with self._lock: + self._samples.clear() + + def _prune_locked(self, key: CalibrationKey, now: float) -> deque[_Sample]: + samples = self._samples.setdefault(key, deque()) + cutoff = now - self.ttl_seconds + while samples and samples[0].observed_at < cutoff: + samples.popleft() + if not samples: + self._samples.pop(key, None) + samples = self._samples.setdefault(key, deque()) + return samples + + +GLOBAL_CALIBRATION_STORE = CalibrationStore() + + +def build_final_request_shape(completion_kwargs: Mapping[str, Any]) -> FinalRequestShape: + semantic = { + str(key): _normalize(value) + for key, value in completion_kwargs.items() + if key not in _TRANSPORT_KEYS + } + messages = semantic.get("messages") + message_list = messages if isinstance(messages, list) else [] + message_text_parts: list[str] = [] + media_count = 0 + for message in message_list: + message_text_parts.append(_extract_text(message)) + media_count += _count_media(message) + framing = (3 * len(message_list) + (3 if message_list else 0)) + tools = semantic.get("tools") or semantic.get("functions") + tool_tokens = _json_tokens(tools) if tools else 0 + media_tokens = media_count * 256 + + reasoning_values: dict[str, Any] = {} + for key in _REASONING_KEYS: + if key in semantic: + reasoning_values[key] = semantic[key] + extra_body = semantic.get("extra_body") + if isinstance(extra_body, Mapping): + for key, value in extra_body.items(): + if key in _REASONING_KEYS: + reasoning_values[key] = value + + consumed = {"messages", "tools", "functions", *reasoning_values.keys()} + other = { + key: value + for key, value in semantic.items() + if key not in consumed and key != "extra_body" + } + if isinstance(extra_body, Mapping): + remaining_extra = { + key: value for key, value in extra_body.items() if key not in _REASONING_KEYS + } + if remaining_extra: + other["extra_body"] = remaining_extra + + has_tools = bool(tools) + has_media = media_count > 0 + shape: RequestShape = ( + "tools_media" if has_tools and has_media else + "tools" if has_tools else + "media" if has_media else + "text" + ) + reasoning_mode = _fingerprint(reasoning_values)[:12] if reasoning_values else "default" + components = RequestComponentCounts( + message_text=estimate_tokens_text("".join(message_text_parts)) if message_text_parts else 0, + message_framing=framing, + tools=tool_tokens, + media=media_tokens, + reasoning=_json_tokens(reasoning_values) if reasoning_values else 0, + other_semantic=_json_tokens(other) if other else 0, + ) + return FinalRequestShape( + fingerprint=_fingerprint(semantic), + request_shape=shape, + reasoning_mode=reasoning_mode, + semantic_request=semantic, + components=components, + ) + + +class FinalRequestMeter: + def __init__(self, calibration_store: CalibrationStore = GLOBAL_CALIBRATION_STORE) -> None: + self.calibration_store = calibration_store + + def measure( + self, + completion_kwargs: Mapping[str, Any], + *, + identity: FinalRequestIdentity, + soft_budget: int, + hard_budget: int, + provider_count: Optional[int] = None, + tokenizer_count: Optional[int] = None, + fallback_reason: Optional[str] = None, + retry_ordinal: int = 0, + request_id: Optional[str] = None, + ) -> FinalRequestPreflight: + shape = build_final_request_shape(completion_kwargs) + raw = shape.components.raw_total + key = CalibrationKey( + endpoint_fingerprint=identity.endpoint_fingerprint, + credential_scope_fingerprint=identity.credential_scope_fingerprint, + canonical_model_id=identity.canonical_model_id, + request_shape=shape.request_shape, + reasoning_mode=shape.reasoning_mode, + ) + stats = self.calibration_store.stats(key) + if provider_count is not None: + if provider_count <= 0: + raise ValueError("provider_count must be positive") + source: CountSource = "provider" + soft_count = hard_count = provider_count + else: + base = tokenizer_count if tokenizer_count is not None else raw + if base <= 0: + raise ValueError("tokenizer_count must be positive") + source = "tokenizer" if tokenizer_count is not None else "estimated" + if stats.mature: + soft_multiplier = _gate_multiplier(stats.p95) + hard_multiplier = _gate_multiplier(stats.p99) + else: + soft_multiplier = hard_multiplier = DEFAULT_CORRECTION_MULTIPLIER + soft_count = math.ceil(base * soft_multiplier) + hard_count = math.ceil(base * hard_multiplier) + preflight = FinalRequestPreflight( + request_id=request_id or uuid.uuid4().hex, + request_fingerprint=shape.fingerprint, + identity_fingerprint=identity.fingerprint, + request_shape=shape.request_shape, + reasoning_mode=shape.reasoning_mode, + count_source=source, + components=shape.components, + raw_estimate=raw, + provider_count=provider_count, + soft_count=soft_count, + hard_count=hard_count, + soft_budget=soft_budget, + hard_budget=hard_budget, + soft_exceeded=soft_count > soft_budget, + hard_exceeded=hard_count > hard_budget, + calibration_key_fingerprint=key.fingerprint, + calibration_sample_count=stats.sample_count, + calibration_p95=stats.p95, + calibration_p99=stats.p99, + fallback_reason=fallback_reason, + retry_ordinal=retry_ordinal, + ) + if preflight.hard_exceeded: + raise FinalRequestOverHardBudget( + actual=hard_count, + hard_budget=hard_budget, + preflight=preflight, + ) + return preflight + + def observe_usage( + self, + preflight: FinalRequestPreflight, + identity: FinalRequestIdentity, + *, + provider_prompt_tokens: int, + ) -> bool: + key = CalibrationKey( + endpoint_fingerprint=identity.endpoint_fingerprint, + credential_scope_fingerprint=identity.credential_scope_fingerprint, + canonical_model_id=identity.canonical_model_id, + request_shape=preflight.request_shape, + reasoning_mode=preflight.reasoning_mode, + ) + if key.fingerprint != preflight.calibration_key_fingerprint: + raise StaleRequestBudgetIdentity("calibration identity changed") + return self.calibration_store.observe( + key, + request_id=preflight.request_id, + raw_estimate=preflight.raw_estimate, + provider_prompt_tokens=provider_prompt_tokens, + ) + + +def _nearest_rank(values: Sequence[float], quantile: float) -> float: + index = max(0, math.ceil(quantile * len(values)) - 1) + return values[index] + + +def _gate_multiplier(value: Optional[float]) -> float: + return min(MAX_GATE_MULTIPLIER, max(1.0, value or 1.0)) + + +def _json_tokens(value: Any) -> int: + encoded = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return estimate_tokens_text(encoded) + + +def _fingerprint(value: Any) -> str: + encoded = json.dumps( + _normalize(value), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + return hashlib.sha256(encoded.encode()).hexdigest() + + +def _normalize(value: Any, *, _depth: int = 0) -> Any: + if _depth >= 32: + return {"__max_depth__": type(value).__name__} + if value is None or isinstance(value, (bool, int, float, str)): + return value + if isinstance(value, Mapping): + return { + str(key): _normalize(item, _depth=_depth + 1) + for key, item in sorted(value.items(), key=lambda pair: str(pair[0])) + } + if isinstance(value, (list, tuple)): + return [_normalize(item, _depth=_depth + 1) for item in value] + if hasattr(value, "model_dump"): + return _normalize(value.model_dump(mode="json"), _depth=_depth + 1) + return str(value) + + +def _extract_text(value: Any) -> str: + if isinstance(value, str): + return value + if isinstance(value, Mapping): + if str(value.get("type", "")).lower() in _MEDIA_TYPES: + return "" + return "".join( + _extract_text(item) + for key, item in value.items() + if key not in {"image_url", "url", "data", "audio", "file"} + ) + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return "".join(_extract_text(item) for item in value) + return "" + + +def _count_media(value: Any) -> int: + if isinstance(value, Mapping): + own = 1 if str(value.get("type", "")).lower() in _MEDIA_TYPES else 0 + return own + sum( + _count_media(item) + for key, item in value.items() + if key not in {"image_url", "url", "data", "audio", "file"} + ) + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return sum(_count_media(item) for item in value) + return 0 diff --git a/sdk/nexent/core/models/openai_llm.py b/sdk/nexent/core/models/openai_llm.py index 9986f4688a..3604708b7f 100644 --- a/sdk/nexent/core/models/openai_llm.py +++ b/sdk/nexent/core/models/openai_llm.py @@ -12,9 +12,8 @@ import asyncio import time import json -from typing import List, Optional, Dict, Any +from typing import Callable, List, Optional, Dict, Any -from openai.types.chat.chat_completion_message import ChatCompletionMessage from smolagents import Tool from smolagents.models import OpenAIServerModel, ChatMessage, MessageRole @@ -33,6 +32,21 @@ resolve_prompt_cache_profile, ) from .message_utils import prepare_messages_for_smolagents_text_flattening +from .final_request_budget import ( + FinalRequestIdentity, + FinalRequestMeter, + FinalRequestPreflight, + FinalRequestOverHardBudget, + FinalRequestSoftBudgetExceeded, + CompactionNoReduction, + ProviderContextOverflowRetryExhausted, + ProviderContextOverflowRetryUnsafe, + RequestSideEffectGuard, + build_final_request_shape, + is_provider_context_overflow, +) +from .provider_request_count import count_final_request, endpoint_fingerprint +from .tokenizer_registry import resolve as resolve_tokenizer logger = logging.getLogger("openai_llm") @@ -91,6 +105,14 @@ def __init__(self, observer: MessageObserver = MessageObserver, temperature=0.2, """ capacity_snapshot: Optional[Dict[str, Any]] = kwargs.pop("capacity_snapshot", None) prompt_cache: Optional[Dict[str, Any]] = kwargs.pop("prompt_cache", None) + self.canonical_model_id = kwargs.pop("canonical_model_id", None) + self.model_identity_metadata = kwargs.pop("model_identity_metadata", None) + self.tokenizer_match_metadata = kwargs.pop("tokenizer_match_metadata", None) + self.tokenizer_family = kwargs.pop("tokenizer_family", None) + self.token_count_probe_metadata = kwargs.pop("token_count_probe_metadata", None) + self._token_count_api_base = str(kwargs.get("api_base") or "") + self._token_count_api_key = str(kwargs.get("api_key") or "") + self._token_count_ssl_verify = bool(ssl_verify) self.observer = observer self.temperature = temperature @@ -106,6 +128,8 @@ def __init__(self, observer: MessageObserver = MessageObserver, temperature=0.2, self.last_prompt_cache_usage = None self.last_cached_input_token_count = 0 self.last_response_diagnostics = None + self.last_final_request_preflight: Optional[FinalRequestPreflight] = None + self._final_request_meter = FinalRequestMeter() self.safe_input_budget_snapshot = safe_input_budget_snapshot self.capacity_snapshot = capacity_snapshot if max_output_tokens is None and max_tokens is not None: @@ -151,8 +175,14 @@ def __init__(self, observer: MessageObserver = MessageObserver, temperature=0.2, def __call__(self, messages: List[Dict[str, Any]], stop_sequences: Optional[List[str]] = None, response_format: dict[str, str] | None = None, tools_to_call_from: Optional[List[Tool]] = None, _token_tracker=None, safe_input_budget_snapshot: Optional[SafeInputBudgetSnapshot] = None, + context_rebuild: Optional[Callable[[int], Any]] = None, + _budget_retry_ordinal: int = 0, + _soft_rebuild_attempted: bool = False, + _previous_preflight: Optional[FinalRequestPreflight] = None, + _side_effect_guard: Optional[RequestSideEffectGuard] = None, **kwargs, ) -> ChatMessage: _monitoring_operation.set("chat_completion") + side_effect_guard = _side_effect_guard or RequestSideEffectGuard() if _token_tracker is None: trusted_budget_snapshot = ( @@ -191,6 +221,11 @@ def __call__(self, messages: List[Dict[str, Any]], stop_sequences: Optional[List tools_to_call_from=tools_to_call_from, _token_tracker=token_tracker, safe_input_budget_snapshot=safe_input_budget_snapshot, + context_rebuild=context_rebuild, + _budget_retry_ordinal=_budget_retry_ordinal, + _soft_rebuild_attempted=_soft_rebuild_attempted, + _previous_preflight=_previous_preflight, + _side_effect_guard=side_effect_guard, **kwargs, ) @@ -259,6 +294,8 @@ def __call__(self, messages: List[Dict[str, Any]], stop_sequences: Optional[List trusted_budget_snapshot = ( safe_input_budget_snapshot or self.safe_input_budget_snapshot ) + if trusted_budget_snapshot is None: + self.last_final_request_preflight = None # Bound completion length unless the caller passed their own override # via kwargs (which already landed in completion_kwargs above). @@ -316,12 +353,47 @@ def __call__(self, messages: List[Dict[str, Any]], stop_sequences: Optional[List } ) - current_request = self._dispatch_chat_completion( - safe_input_budget_snapshot=trusted_budget_snapshot, - capacity_snapshot=self.capacity_snapshot, - stream=True, - **dispatch_kwargs, - ) + try: + current_request = self._dispatch_chat_completion( + safe_input_budget_snapshot=trusted_budget_snapshot, + capacity_snapshot=self.capacity_snapshot, + retry_ordinal=_budget_retry_ordinal, + request_rebuild_available=context_rebuild is not None, + soft_rebuild_attempted=_soft_rebuild_attempted, + previous_preflight=_previous_preflight, + stream=True, + **dispatch_kwargs, + ) + except FinalRequestSoftBudgetExceeded as soft_error: + return self._rebuild_and_retry( + soft_error.preflight, + context_rebuild=context_rebuild, + token_tracker=token_tracker, + stop_sequences=stop_sequences, + response_format=response_format, + tools_to_call_from=tools_to_call_from, + trusted_budget_snapshot=trusted_budget_snapshot, + retry_ordinal=_budget_retry_ordinal, + soft_rebuild_attempted=True, + call_kwargs=kwargs, + ) + except Exception as error: + if not is_provider_context_overflow(error): + raise + if trusted_budget_snapshot is None: + raise + return self._recover_provider_overflow( + error, + preflight=self.last_final_request_preflight, + context_rebuild=context_rebuild, + token_tracker=token_tracker, + stop_sequences=stop_sequences, + response_format=response_format, + tools_to_call_from=tools_to_call_from, + trusted_budget_snapshot=trusted_budget_snapshot, + retry_ordinal=_budget_retry_ordinal, + call_kwargs=kwargs, + ) # Validate response type: ensure we got a proper iterator, not error strings or dicts # Some APIs return error strings like "error: rate limit" or JSON dicts on failure @@ -341,6 +413,8 @@ def __call__(self, messages: List[Dict[str, Any]], stop_sequences: Optional[List reasoning_char_count = 0 empty_choices_chunk_count = 0 nonstandard_chunk_count = 0 + tool_call_chunk_count = 0 + tool_call_fragments: Dict[int, Dict[str, Any]] = {} # Reset output mode self.observer.current_mode = ProcessType.MODEL_OUTPUT_THINKING @@ -351,6 +425,7 @@ def __call__(self, messages: List[Dict[str, Any]], stop_sequences: Optional[List try: for chunk in current_request: + side_effect_guard.mark("response_started") # Safety check: skip non-standard chunks that lack expected attributes # This handles edge cases where API returns error responses as chunks if not hasattr(chunk, 'choices'): @@ -371,9 +446,37 @@ def __call__(self, messages: List[Dict[str, Any]], stop_sequences: Optional[List if chunk_finish_reason is not None: finish_reason = str(chunk_finish_reason) - new_token = chunk.choices[0].delta.content + delta = chunk.choices[0].delta + delta_role = getattr(delta, "role", None) + if delta_role is not None: + role = delta_role + new_token = delta.content reasoning_content = getattr( - chunk.choices[0].delta, 'reasoning_content', None) + delta, 'reasoning_content', None) + delta_tool_calls = getattr(delta, "tool_calls", None) or [] + for position, tool_call in enumerate(delta_tool_calls): + index = getattr(tool_call, "index", position) + if not isinstance(index, int): + index = position + fragment = tool_call_fragments.setdefault( + index, + {"id": "", "type": "function", "name": "", "arguments": ""}, + ) + call_id = getattr(tool_call, "id", None) + call_type = getattr(tool_call, "type", None) + function = getattr(tool_call, "function", None) + if call_id: + fragment["id"] += str(call_id) + if call_type: + fragment["type"] = str(call_type) + if function is not None: + name = getattr(function, "name", None) + arguments = getattr(function, "arguments", None) + if name: + fragment["name"] += str(name) + if arguments: + fragment["arguments"] += str(arguments) + tool_call_chunk_count += 1 # Handle reasoning_content if it exists and is not null if reasoning_content is not None: @@ -398,7 +501,6 @@ def __call__(self, messages: List[Dict[str, Any]], stop_sequences: Optional[List self.observer.add_model_new_token(new_token) token_join.append(new_token) - role = chunk.choices[0].delta.role chunk_list.append(chunk) if self.stop_event.is_set(): @@ -411,6 +513,17 @@ def __call__(self, messages: List[Dict[str, Any]], stop_sequences: Optional[List # Send end marker self.observer.flush_remaining_tokens() model_output = "".join(token_join) + tool_calls = [ + { + "id": fragment["id"] or f"tool_call_{index}", + "type": fragment["type"], + "function": { + "name": fragment["name"], + "arguments": fragment["arguments"], + }, + } + for index, fragment in sorted(tool_call_fragments.items()) + ] self.last_finish_reason = finish_reason if finish_reason == "length": logger.warning( @@ -452,6 +565,23 @@ def __call__(self, messages: List[Dict[str, Any]], stop_sequences: Optional[List f"input_tokens={input_tokens}, output_tokens={output_tokens}" ) + if ( + usage is not None + and trusted_budget_snapshot is not None + and self.last_final_request_preflight is not None + ): + calibration_added = self._final_request_meter.observe_usage( + self.last_final_request_preflight, + self._build_final_request_identity(trusted_budget_snapshot), + provider_prompt_tokens=input_tokens, + ) + self._monitoring.set_span_attributes( + **{ + "context.final_request.calibration_observed": calibration_added, + "context.final_request.provider_prompt_tokens": input_tokens, + } + ) + cache_usage = extract_prompt_cache_usage( usage, input_tokens, capability_profile=selected_cache_profile ) @@ -483,6 +613,8 @@ def __call__(self, messages: List[Dict[str, Any]], stop_sequences: Optional[List "reasoning_char_count": reasoning_char_count, "empty_choices_chunk_count": empty_choices_chunk_count, "nonstandard_chunk_count": nonstandard_chunk_count, + "tool_call_chunk_count": tool_call_chunk_count, + "tool_call_count": len(tool_calls), "input_tokens": input_tokens, "output_tokens": output_tokens, } @@ -500,7 +632,7 @@ def __call__(self, messages: List[Dict[str, Any]], stop_sequences: Optional[List "chunk_count": len(chunk_list) }) - if not model_output.strip(): + if not model_output.strip() and not tool_calls: logger.warning( "event=empty_model_response model_id=%s provider=%s " "finish_reason=%s chunk_count=%d content_chunk_count=%d " @@ -526,9 +658,11 @@ def __call__(self, messages: List[Dict[str, Any]], stop_sequences: Optional[List f"output_tokens={output_tokens})" ) - message = ChatMessage.from_dict( - ChatCompletionMessage(role=role if role else "assistant", # If there is no explicit role, default to "assistant" - content=model_output).model_dump(include={"role", "content", "tool_calls"})) + message = ChatMessage.from_dict({ + "role": role if role else "assistant", + "content": model_output or None, + "tool_calls": tool_calls or None, + }) from smolagents.monitoring import TokenUsage @@ -546,8 +680,26 @@ def __call__(self, messages: List[Dict[str, Any]], stop_sequences: Optional[List self._monitoring.add_span_event("error_occurred", {"error_type": type( e).__name__, "error_message": str(e)}) - if "context_length_exceeded" in str(e): - raise ValueError(f"Token limit exceeded: {str(e)}") + if is_provider_context_overflow(e): + if trusted_budget_snapshot is None: + raise ValueError(f"Token limit exceeded: {str(e)}") from e + if not side_effect_guard.recovery_safe: + self._record_recovery_state("retry_unsafe_after_response") + raise ProviderContextOverflowRetryUnsafe( + "provider overflow arrived after response streaming started" + ) from e + return self._recover_provider_overflow( + e, + preflight=self.last_final_request_preflight, + context_rebuild=context_rebuild, + token_tracker=token_tracker, + stop_sequences=stop_sequences, + response_format=response_format, + tools_to_call_from=tools_to_call_from, + trusted_budget_snapshot=trusted_budget_snapshot, + retry_ordinal=_budget_retry_ordinal, + call_kwargs=kwargs, + ) raise e def _dispatch_chat_completion( @@ -555,6 +707,10 @@ def _dispatch_chat_completion( *, safe_input_budget_snapshot: Optional[SafeInputBudgetSnapshot | Dict[str, Any]] = None, capacity_snapshot: Optional[Dict[str, Any]] = None, + retry_ordinal: int = 0, + request_rebuild_available: bool = False, + soft_rebuild_attempted: bool = False, + previous_preflight: Optional[FinalRequestPreflight] = None, **completion_kwargs: Any, ) -> Any: """Dispatch the OpenAI chat completion request. @@ -574,6 +730,13 @@ def _dispatch_chat_completion( budget_snapshot=snapshot, capacity_snapshot=capacity_snapshot, ) + request_model = completion_kwargs.get("model") + if request_model is not None and str(request_model) != snapshot.model_name: + raise SafeInputBudgetCapacityMismatch( + field="model_name", + expected=snapshot.model_name, + actual=str(request_model), + ) trusted_max_tokens = snapshot.requested_output_tokens caller_max_tokens = completion_kwargs.get("max_tokens") if caller_max_tokens is not None and caller_max_tokens != trusted_max_tokens: @@ -582,8 +745,240 @@ def _dispatch_chat_completion( caller_value=caller_max_tokens, ) completion_kwargs["max_tokens"] = trusted_max_tokens + identity = self._build_final_request_identity(snapshot) + final_shape = build_final_request_shape(completion_kwargs) + provider_count, fallback_reason = count_final_request( + final_shape, + metadata=self.token_count_probe_metadata, + base_url=self._token_count_api_base, + api_key=self._token_count_api_key, + model_name=self.model_id, + canonical_model_id=identity.canonical_model_id, + ssl_verify=self._token_count_ssl_verify, + ) + tokenizer_count = self._verified_tokenizer_count(final_shape) + try: + preflight = self._final_request_meter.measure( + completion_kwargs, + identity=identity, + soft_budget=snapshot.soft_input_budget_tokens, + hard_budget=snapshot.hard_input_budget_tokens, + provider_count=provider_count, + tokenizer_count=tokenizer_count, + fallback_reason=fallback_reason, + retry_ordinal=retry_ordinal, + ) + except FinalRequestOverHardBudget as budget_error: + if budget_error.preflight is not None: + self.last_final_request_preflight = budget_error.preflight + self._record_final_request_preflight( + budget_error.preflight, + recovery_state="hard_stop", + ) + raise + if retry_ordinal > 0 and previous_preflight is not None and ( + preflight.request_fingerprint == previous_preflight.request_fingerprint + or preflight.hard_count >= previous_preflight.hard_count + ): + self._record_recovery_state("no_reduction") + raise CompactionNoReduction( + "compaction_no_reduction: rebuilt final request did not strictly shrink" + ) + self.last_final_request_preflight = preflight + self._record_final_request_preflight( + preflight, + recovery_state=( + "recovered" + if retry_ordinal > 0 + else "soft_rebuilt" + if soft_rebuild_attempted + else "not_attempted" + ), + ) + if ( + preflight.soft_exceeded + and request_rebuild_available + and not soft_rebuild_attempted + ): + raise FinalRequestSoftBudgetExceeded(preflight) return self.client.chat.completions.create(**completion_kwargs) + def _verified_tokenizer_count(self, shape: Any) -> Optional[int]: + metadata = self.tokenizer_match_metadata or {} + if ( + shape.request_shape != "text" + or not self.tokenizer_family + or metadata.get("auto_applicable") is not True + ): + return None + adapter, mode = resolve_tokenizer(self.tokenizer_family) + if mode != "exact": + return None + messages = shape.semantic_request.get("messages") or [] + message_count = adapter.count_tokens(messages) + return max( + 1, + message_count + + shape.components.reasoning + + shape.components.other_semantic, + ) + + def _recover_provider_overflow( + self, + error: BaseException, + *, + preflight: Optional[FinalRequestPreflight], + context_rebuild: Optional[Callable[[int], Any]], + token_tracker: Any, + stop_sequences: Optional[List[str]], + response_format: Optional[dict[str, str]], + tools_to_call_from: Optional[List[Tool]], + trusted_budget_snapshot: Optional[SafeInputBudgetSnapshot | Dict[str, Any]], + retry_ordinal: int, + call_kwargs: Dict[str, Any], + ) -> ChatMessage: + if retry_ordinal >= 1: + self._record_recovery_state("retry_exhausted") + raise ProviderContextOverflowRetryExhausted( + "provider context overflow persisted after the single safe retry" + ) from error + if context_rebuild is None or preflight is None: + self._record_recovery_state("retry_unsafe") + raise ProviderContextOverflowRetryUnsafe( + "provider context overflow cannot be rebuilt from source" + ) from error + self._monitoring.add_span_event( + "provider_context_overflow", + {"retry_ordinal": retry_ordinal, "recovery_safe": True}, + ) + self._record_recovery_state("retrying") + return self._rebuild_and_retry( + preflight, + context_rebuild=context_rebuild, + token_tracker=token_tracker, + stop_sequences=stop_sequences, + response_format=response_format, + tools_to_call_from=tools_to_call_from, + trusted_budget_snapshot=trusted_budget_snapshot, + retry_ordinal=retry_ordinal + 1, + soft_rebuild_attempted=True, + call_kwargs=call_kwargs, + ) + + def _rebuild_and_retry( + self, + preflight: FinalRequestPreflight, + *, + context_rebuild: Optional[Callable[[int], Any]], + token_tracker: Any, + stop_sequences: Optional[List[str]], + response_format: Optional[dict[str, str]], + tools_to_call_from: Optional[List[Tool]], + trusted_budget_snapshot: Optional[SafeInputBudgetSnapshot | Dict[str, Any]], + retry_ordinal: int, + soft_rebuild_attempted: bool, + call_kwargs: Dict[str, Any], + ) -> ChatMessage: + if context_rebuild is None: + self._record_recovery_state("retry_unsafe") + raise ProviderContextOverflowRetryUnsafe( + "final request needs source-backed rebuild but none is available" + ) + target = max( + 1, + min(preflight.soft_budget, int(preflight.hard_count * 0.9)), + ) + rebuilt = context_rebuild(target) + rebuilt_messages = getattr(rebuilt, "messages", rebuilt) + if not isinstance(rebuilt_messages, list): + raise TypeError("context_rebuild must return FinalContext or a message list") + evidence = getattr(rebuilt, "evidence", None) + if evidence is not None: + self.last_context_evidence = evidence + return self.__call__( + messages=rebuilt_messages, + stop_sequences=stop_sequences, + response_format=response_format, + tools_to_call_from=tools_to_call_from, + _token_tracker=token_tracker, + safe_input_budget_snapshot=trusted_budget_snapshot, + context_rebuild=context_rebuild, + _budget_retry_ordinal=retry_ordinal, + _soft_rebuild_attempted=soft_rebuild_attempted, + _previous_preflight=preflight, + _side_effect_guard=RequestSideEffectGuard(), + **call_kwargs, + ) + + def _build_final_request_identity( + self, + snapshot: SafeInputBudgetSnapshot | Dict[str, Any], + ) -> FinalRequestIdentity: + resolved = self._coerce_safe_input_budget_snapshot(snapshot) + if resolved is None: # pragma: no cover - protected by callers + raise ValueError("safe input budget snapshot is required") + probe = self.token_count_probe_metadata or {} + runtime_endpoint = ( + endpoint_fingerprint(self._token_count_api_base) + if self._token_count_api_base + else "unknown_endpoint" + ) + return FinalRequestIdentity( + endpoint_fingerprint=runtime_endpoint, + credential_scope_fingerprint=str( + probe.get("credential_scope_fingerprint") or "unknown_scope" + ), + canonical_model_id=str( + self.canonical_model_id + or probe.get("model_identity") + or f"{resolved.provider}:{resolved.model_name}" + ), + provider=resolved.provider, + model_name=resolved.model_name, + w1_fingerprint=resolved.w1_fingerprint, + w2_fingerprint=resolved.fingerprint, + ) + + def _record_recovery_state(self, state: str) -> None: + self._monitoring.set_span_attributes( + **{"context.final_request.recovery_state": state} + ) + + def _record_final_request_preflight( + self, + preflight: FinalRequestPreflight, + *, + recovery_state: str, + ) -> None: + components = preflight.components + self._monitoring.set_span_attributes( + **{ + "context.final_request.fingerprint": preflight.request_fingerprint, + "context.final_request.identity_fingerprint": preflight.identity_fingerprint, + "context.final_request.shape": preflight.request_shape, + "context.final_request.count_source": preflight.count_source, + "context.final_request.raw_estimate": preflight.raw_estimate, + "context.final_request.soft_count": preflight.soft_count, + "context.final_request.hard_count": preflight.hard_count, + "context.final_request.soft_exceeded": preflight.soft_exceeded, + "context.final_request.hard_exceeded": preflight.hard_exceeded, + "context.final_request.components.message_text": components.message_text, + "context.final_request.components.message_framing": components.message_framing, + "context.final_request.components.tools": components.tools, + "context.final_request.components.media": components.media, + "context.final_request.components.reasoning": components.reasoning, + "context.final_request.components.other_semantic": components.other_semantic, + "context.final_request.calibration_key": preflight.calibration_key_fingerprint, + "context.final_request.calibration_samples": preflight.calibration_sample_count, + "context.final_request.calibration_p95": preflight.calibration_p95 or 0.0, + "context.final_request.calibration_p99": preflight.calibration_p99 or 0.0, + "context.final_request.estimator_version": preflight.estimator_version, + "context.final_request.retry_ordinal": preflight.retry_ordinal, + "context.final_request.recovery_state": recovery_state, + "context.final_request.count_fallback_reason": preflight.fallback_reason or "", + } + ) + @staticmethod def _verify_w1_w2_consistency( *, diff --git a/sdk/nexent/core/models/provider_request_count.py b/sdk/nexent/core/models/provider_request_count.py new file mode 100644 index 0000000000..a3b710d4a1 --- /dev/null +++ b/sdk/nexent/core/models/provider_request_count.py @@ -0,0 +1,154 @@ +"""Runtime use of a previously verified Provider full-request count capability.""" + +from __future__ import annotations + +import hashlib +from datetime import datetime, timezone +from typing import Any, Mapping, Optional +from urllib.parse import urlsplit, urlunsplit + +import httpx + +from .final_request_budget import FinalRequestShape + + +COUNT_ADAPTER_VERSION = "1.0.0" +MAX_COUNT = 100_000_000 +MAX_RESPONSE_BYTES = 64 * 1024 + + +def endpoint_fingerprint(url: str) -> str: + parsed = urlsplit(url) + normalized = urlunsplit( + ( + parsed.scheme.lower(), + (parsed.hostname or "").lower() + + (f":{parsed.port}" if parsed.port else ""), + parsed.path.rstrip("/"), + "", + "", + ) + ) + return hashlib.sha256(normalized.encode()).hexdigest()[:24] + + +def count_final_request( + shape: FinalRequestShape, + *, + metadata: Optional[Mapping[str, Any]], + base_url: str, + api_key: str, + model_name: str, + canonical_model_id: str, + ssl_verify: bool = True, + timeout_seconds: float = 5.0, +) -> tuple[Optional[int], Optional[str]]: + """Return a Provider count or a stable, non-mutating fallback reason.""" + reason = _capability_reason( + shape, + metadata=metadata, + base_url=base_url, + canonical_model_id=canonical_model_id, + ) + if reason: + return None, reason + protocol = str((metadata or {}).get("selected_protocol")) + if protocol != "openai_responses": + return None, "runtime_count_protocol_not_implemented" + + count_url = f"{base_url.rstrip('/')}/responses/input_tokens" + if urlsplit(count_url).netloc != urlsplit(base_url).netloc: + return None, "count_origin_mismatch" + semantic = shape.semantic_request + body: dict[str, Any] = { + "model": semantic.get("model") or model_name, + "input": semantic.get("messages") or [], + } + if semantic.get("tools"): + body["tools"] = semantic["tools"] + try: + with httpx.Client( + timeout=httpx.Timeout(timeout_seconds, connect=min(timeout_seconds, 3.0)), + follow_redirects=False, + trust_env=False, + verify=ssl_verify, + ) as client: + response = client.post( + count_url, + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + json=body, + ) + except httpx.TimeoutException: + return None, "count_timeout" + except httpx.RequestError: + return None, "count_connection_failed" + if 300 <= response.status_code < 400: + return None, "count_redirect_rejected" + if response.status_code in {401, 403}: + return None, "count_authorization_failed" + if response.status_code == 429: + return None, "count_rate_limited" + if response.status_code in {404, 405}: + return None, "count_unsupported_endpoint" + if response.status_code >= 500: + return None, "count_provider_5xx" + if not 200 <= response.status_code < 300: + return None, "count_unexpected_status" + if len(response.content) > MAX_RESPONSE_BYTES: + return None, "count_response_too_large" + try: + payload = response.json() + except ValueError: + return None, "count_invalid_schema" + count = payload.get("input_tokens") if isinstance(payload, Mapping) else None + if not isinstance(count, int) or isinstance(count, bool) or not 0 < count <= MAX_COUNT: + return None, "count_invalid_value" + return count, None + + +def _capability_reason( + shape: FinalRequestShape, + *, + metadata: Optional[Mapping[str, Any]], + base_url: str, + canonical_model_id: str, +) -> Optional[str]: + parsed = urlsplit(base_url) + if ( + parsed.scheme not in {"http", "https"} + or not parsed.hostname + or parsed.username + or parsed.password + or parsed.query + or parsed.fragment + ): + return "count_ssrf_rejected" + if not metadata: + return "count_capability_missing" + if metadata.get("status") != "supported": + return f"count_capability_{metadata.get('status') or 'unknown'}" + if metadata.get("adapter_version") != COUNT_ADAPTER_VERSION: + return "count_capability_stale_adapter" + if metadata.get("endpoint_fingerprint") != endpoint_fingerprint(base_url): + return "count_capability_endpoint_mismatch" + if metadata.get("model_identity") != canonical_model_id: + return "count_capability_model_mismatch" + stale_at = metadata.get("stale_at") + try: + expiry = datetime.fromisoformat(str(stale_at).replace("Z", "+00:00")) + except (TypeError, ValueError): + return "count_capability_stale" + if expiry <= datetime.now(timezone.utc): + return "count_capability_stale" + capabilities = metadata.get("capabilities") or {} + required = ( + ("tools", "media") if shape.request_shape == "tools_media" else + ("tools",) if shape.request_shape == "tools" else + ("media",) if shape.request_shape == "media" else + ("text",) + ) + if shape.reasoning_mode != "default": + required = (*required, "reasoning") + if any(capabilities.get(item) != "supported" for item in required): + return "count_capability_shape_unsupported" + return None diff --git a/sdk/nexent/monitor/monitoring.py b/sdk/nexent/monitor/monitoring.py index 41dfee046d..5db137c073 100644 --- a/sdk/nexent/monitor/monitoring.py +++ b/sdk/nexent/monitor/monitoring.py @@ -1387,6 +1387,7 @@ def record_final_context_evidence(self, evidence: Any, step_number: int) -> None "context.tokens.pre_compression": getattr(evidence, "raw_token_estimate", 0), "context.tokens.post_compression": getattr(evidence, "final_token_estimate", 0), "context.budget.hard_exceeded": bool(getattr(evidence, "over_hard_budget", False)), + "context.budget.failure_reason": getattr(evidence, "budget_failure_reason", "") or "", "context.compression.attempted": bool(getattr(evidence, "compression_attempted", False)), "context.compression.fallback_compaction": bool(getattr(evidence, "fallback_compaction_used", False)), "context.compression.records": json.dumps(compression_records, ensure_ascii=False, sort_keys=True), diff --git a/test/backend/agents/test_create_agent_info.py b/test/backend/agents/test_create_agent_info.py index 7888e9283b..1683f39a88 100644 --- a/test/backend/agents/test_create_agent_info.py +++ b/test/backend/agents/test_create_agent_info.py @@ -3774,7 +3774,14 @@ async def test_create_model_config_list(self): mock_manager.get_model_config.return_value = { "api_key": "main_key", "model_name": "main_model", - "base_url": "http://main.url" + "base_url": "http://main.url", + "context_window_tokens": 1_000_000, + "max_input_tokens": 991_808, + "default_output_reserve_tokens": 8_192, + "canonical_model_id": "qwen:qwen3.7-plus", + "tokenizer_family": "qwen", + "tokenizer_match_metadata": {"auto_applicable": True}, + "token_count_probe_metadata": {"status": "supported"}, } # Mock utility functions @@ -3817,12 +3824,18 @@ async def test_create_model_config_list(self): assert calls[2][1]['api_key'] == "main_key" assert calls[2][1]['model_name'] == "main_model_name" assert calls[2][1]['url'] == "http://main.url" + assert calls[2][1]['context_window_tokens'] == 1_000_000 + assert calls[2][1]['canonical_model_id'] == "qwen:qwen3.7-plus" + assert calls[2][1]['token_count_probe_metadata'] == {"status": "supported"} # Fourth call: sub_model assert calls[3][1]['cite_name'] == "sub_model" assert calls[3][1]['api_key'] == "main_key" assert calls[3][1]['model_name'] == "main_model_name" assert calls[3][1]['url'] == "http://main.url" + assert calls[3][1]['context_window_tokens'] == 1_000_000 + assert calls[3][1]['canonical_model_id'] == "qwen:qwen3.7-plus" + assert calls[3][1]['token_count_probe_metadata'] == {"status": "supported"} @pytest.mark.asyncio async def test_create_model_config_list_empty_database(self): diff --git a/test/sdk/core/agents/test_context_helper_contracts.py b/test/sdk/core/agents/test_context_helper_contracts.py index c167315d0c..baaa17cc11 100644 --- a/test/sdk/core/agents/test_context_helper_contracts.py +++ b/test/sdk/core/agents/test_context_helper_contracts.py @@ -1,6 +1,7 @@ from dataclasses import dataclass from enum import Enum from types import SimpleNamespace +from unittest.mock import MagicMock import pytest @@ -547,6 +548,42 @@ def test_context_manager_management_and_diagnostic_helpers(): manager._purpose_messages(purpose="final_answer", task="task", final_answer_templates=None) +def test_ac_p2_005_rebuild_target_tightens_both_context_budgets(): + manager = ContextManager( + ContextManagerConfig( + token_threshold=100, + soft_input_budget_tokens=80, + hard_input_budget_tokens=100, + chars_per_token=1.0, + ) + ) + memory = MagicMock(system_prompt=None, steps=[]) + run_context = manager.prepare_run_context( + memory, + "", + items=[ + ContextItemInput( + id="system:large", + type="system", + content={"text": "x" * 60}, + ) + ], + ) + + rebuilt = manager.assemble_final_context( + model=MagicMock(), + memory=memory, + current_run_start_idx=0, + run_context=run_context, + target_input_budget_tokens=40, + ) + + assert rebuilt.evidence.soft_budget == 40 + assert rebuilt.evidence.hard_budget == 40 + assert rebuilt.evidence.over_hard_budget is True + assert rebuilt.evidence.budget_failure_reason == "single_context_item_oversize" + + @dataclass class _Payload: value: int diff --git a/test/sdk/core/agents/test_core_agent.py b/test/sdk/core/agents/test_core_agent.py index a7da52127b..1f2fc955f7 100644 --- a/test/sdk/core/agents/test_core_agent.py +++ b/test/sdk/core/agents/test_core_agent.py @@ -2264,6 +2264,44 @@ def test_step_stream_uses_context_runtime_for_uncompressed_est(self): assert agent._last_uncompressed_est == 5000 + def test_ac_p2_011_step_stream_supplies_source_backed_rebuild_for_w2(self): + module = self._load_core_agent_in_isolation() + CoreAgent = module.CoreAgent + agent = object.__new__(CoreAgent) + agent.agent_name = "test" + agent.observer = MagicMock() + agent.step_number = 1 + agent.memory = MagicMock(steps=[], system_prompt=None) + agent.logger = MagicMock() + agent.monitor = MagicMock() + agent.context_runtime = self._context_runtime_mock() + agent.context_runtime.chars_per_token = 1.0 + agent.context_runtime.token_counts.return_value = {"uncompressed": 10, "compressed": 10} + initial = MagicMock(messages=[MagicMock()]) + initial.evidence.over_hard_budget = False + rebuilt = MagicMock(messages=[MagicMock()]) + rebuilt.evidence.over_hard_budget = False + agent.context_runtime.prepare_step.side_effect = [initial, rebuilt] + response = MagicMock(content="ok") + agent.model = MagicMock(return_value=response) + agent.model.safe_input_budget_snapshot = {"fingerprint": "w2"} + agent._history_step_count = 0 + agent._context_tools = MagicMock(return_value=[]) + agent._use_structured_outputs_internally = False + action_step = MagicMock() + + stream = agent._step_stream(action_step) + try: + next(stream) + except (StopIteration, ValueError): + pass + + callback = agent.model.call_args.kwargs["context_rebuild"] + assert callback(123) is rebuilt + assert agent.context_runtime.prepare_step.call_args.kwargs[ + "target_input_budget_tokens" + ] == 123 + def test_step_stream_falls_back_without_uncompressed_runtime_count(self): """_step_stream estimates messages when the runtime has no raw sample.""" module = self._load_core_agent_in_isolation() @@ -2914,6 +2952,25 @@ def test_handle_max_steps_reached_uses_context_runtime_final_answer(self): # Model should be called with messages from ContextRuntime. assert agent.model.called + def test_ac_p2_011_final_answer_supplies_source_backed_rebuild_for_w2(self): + agent, _module = self._create_agent_for_handle_max_steps_test() + initial = agent.context_runtime.prepare_final_answer.return_value + rebuilt = MagicMock(messages=[{"role": "user", "content": "short"}]) + rebuilt.evidence.over_hard_budget = False + agent.context_runtime.prepare_final_answer.side_effect = [initial, rebuilt] + response = MagicMock(role="assistant", content="Summary.", token_usage=None) + agent.model = MagicMock(return_value=response) + agent.model.safe_input_budget_snapshot = {"fingerprint": "w2"} + agent._finalize_step = MagicMock() + + agent._handle_max_steps_reached("my task prompt") + + callback = agent.model.call_args.kwargs["context_rebuild"] + assert callback(321) is rebuilt + assert agent.context_runtime.prepare_final_answer.call_args.kwargs[ + "target_input_budget_tokens" + ] == 321 + # ---------------------------------------------------------------------------- # Tests for _log_model_call_parameters method diff --git a/test/sdk/core/agents/test_nexent_agent.py b/test/sdk/core/agents/test_nexent_agent.py index c3a58ac344..2943d53af2 100644 --- a/test/sdk/core/agents/test_nexent_agent.py +++ b/test/sdk/core/agents/test_nexent_agent.py @@ -726,6 +726,36 @@ def test_create_model_deep_thinking_success(nexent_agent_with_models, mock_deep_ assert result.stop_event == nexent_agent_with_models.stop_event +def test_ac_p2_011_create_model_threads_verified_count_identity_metadata( + nexent_agent_instance, +): + """Managed and root agents share the same metadata-bearing model factory.""" + config = ModelConfig( + cite_name="verified_model", + api_key="test_api_key", + model_name="qwen3.7-plus", + url="https://example.invalid/v1", + model_factory="openai", + canonical_model_id="qwen:qwen3.7-plus", + tokenizer_family="qwen", + model_identity_metadata={"status": "matched"}, + tokenizer_match_metadata={"auto_applicable": True}, + token_count_probe_metadata={"status": "supported"}, + ) + nexent_agent_instance.model_config_list = [config] + mock_openai_model_class.reset_mock() + mock_openai_model_class.return_value = MagicMock() + + nexent_agent_instance.create_model("verified_model") + + kwargs = mock_openai_model_class.call_args.kwargs + assert kwargs["canonical_model_id"] == "qwen:qwen3.7-plus" + assert kwargs["tokenizer_family"] == "qwen" + assert kwargs["model_identity_metadata"] == {"status": "matched"} + assert kwargs["tokenizer_match_metadata"] == {"auto_applicable": True} + assert kwargs["token_count_probe_metadata"] == {"status": "supported"} + + def test_create_model_not_found(nexent_agent_with_models): """Test create_model raises ValueError when model cite_name is not found.""" with pytest.raises(ValueError, match="Model nonexistent_model not found"): diff --git a/test/sdk/core/models/test_final_request_budget.py b/test/sdk/core/models/test_final_request_budget.py new file mode 100644 index 0000000000..522f5bed12 --- /dev/null +++ b/test/sdk/core/models/test_final_request_budget.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import pytest + +from nexent.core.models.final_request_budget import ( + CalibrationKey, + CalibrationStore, + FinalRequestIdentity, + FinalRequestMeter, + FinalRequestOverHardBudget, + RequestSideEffectGuard, + build_final_request_shape, +) + + +def _identity(model="qwen3.7-plus"): + return FinalRequestIdentity( + endpoint_fingerprint="endpoint", + credential_scope_fingerprint="scope", + canonical_model_id=f"dashscope:{model}", + provider="dashscope", + model_name=model, + w1_fingerprint="w1", + w2_fingerprint="w2", + ) + + +def test_ac_p2_001_shape_fingerprint_ignores_transport_but_not_semantics(): + base = {"model": "m", "messages": [{"role": "user", "content": "hello"}]} + first = build_final_request_shape({**base, "stream": True, "stream_options": {"include_usage": True}}) + second = build_final_request_shape({**base, "stream": False}) + changed = build_final_request_shape({**base, "response_format": {"type": "json_object"}}) + + assert first.fingerprint == second.fingerprint + assert changed.fingerprint != first.fingerprint + + +def test_ac_p2_002_unified_components_cover_tools_media_reasoning_and_other(): + shape = build_final_request_shape( + { + "model": "m", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "你好 code {}"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,secret"}}, + ], + } + ], + "tools": [{"type": "function", "function": {"name": "search", "parameters": {"type": "object"}}}], + "extra_body": {"chat_template_kwargs": {"enable_thinking": True}, "semantic_flag": "x"}, + } + ) + + assert shape.request_shape == "tools_media" + assert shape.components.message_text > 0 + assert shape.components.message_framing > 0 + assert shape.components.tools > 0 + assert shape.components.media == 256 + assert shape.components.reasoning > 0 + assert shape.components.other_semantic > 0 + assert "secret" not in repr(shape) + + +def test_ac_p2_004_exact_boundary_and_no_over_hard_dispatch_decision(): + meter = FinalRequestMeter(CalibrationStore(minimum_samples=2)) + kwargs = {"model": "m", "messages": [{"role": "user", "content": "x"}]} + + at_boundary = meter.measure( + kwargs, identity=_identity(), soft_budget=9, hard_budget=10, provider_count=10 + ) + assert at_boundary.soft_exceeded is True + assert at_boundary.hard_exceeded is False + + with pytest.raises(FinalRequestOverHardBudget) as error: + meter.measure( + kwargs, identity=_identity(), soft_budget=9, hard_budget=10, provider_count=11 + ) + assert error.value.reason_code == "final_request_over_hard_budget" + + +def test_ac_p2_008_usage_is_deduplicated_by_own_physical_request_id(): + store = CalibrationStore(minimum_samples=2) + meter = FinalRequestMeter(store) + identity = _identity() + preflight = meter.measure( + {"model": "m", "messages": [{"role": "user", "content": "hello"}]}, + identity=identity, + soft_budget=1000, + hard_budget=1000, + request_id="physical-1", + ) + + assert meter.observe_usage(preflight, identity, provider_prompt_tokens=20) is True + assert meter.observe_usage(preflight, identity, provider_prompt_tokens=20) is False + key = CalibrationKey( + "endpoint", "scope", "dashscope:qwen3.7-plus", preflight.request_shape, preflight.reasoning_mode + ) + assert store.stats(key).sample_count == 1 + + +def test_ac_p2_009_mature_p95_soft_and_p99_hard_are_independent(): + now = [1000.0] + store = CalibrationStore(minimum_samples=20, clock=lambda: now[0]) + identity = _identity() + key = CalibrationKey("endpoint", "scope", "dashscope:qwen3.7-plus", "text", "default") + ratios = [1.0] * 18 + [1.5, 1.9] + for index, ratio in enumerate(ratios): + store.observe( + key, + request_id=f"r-{index}", + raw_estimate=100, + provider_prompt_tokens=int(100 * ratio), + ) + stats = store.stats(key) + assert stats.mature is True + assert stats.p95 == 1.5 + assert stats.p99 == 1.9 + + meter = FinalRequestMeter(store) + preflight = meter.measure( + {"messages": [{"role": "user", "content": "x"}]}, + identity=identity, + soft_budget=10_000, + hard_budget=10_000, + ) + assert preflight.soft_count == pytest.approx(preflight.raw_estimate * 1.5, abs=1) + assert preflight.hard_count == pytest.approx(preflight.raw_estimate * 1.9, abs=1) + + +def test_ac_p2_009_calibration_isolated_expiring_and_outlier_capped(): + now = [1000.0] + store = CalibrationStore(minimum_samples=1, ttl_seconds=10, clock=lambda: now[0]) + key = CalibrationKey("e", "s", "m", "text", "default") + other = CalibrationKey("e", "s", "other", "text", "default") + store.observe(key, request_id="one", raw_estimate=1, provider_prompt_tokens=100) + + assert store.stats(key).p99 == 4.0 + assert store.stats(other).sample_count == 0 + now[0] = 1011.0 + assert store.stats(key).sample_count == 0 + + +def test_ac_p2_005_side_effect_guard_is_monotonic_and_only_pristine_is_safe(): + guard = RequestSideEffectGuard() + assert guard.recovery_safe is True + guard.mark("response_started") + assert guard.recovery_safe is False + guard.mark("pristine") + assert guard.state == "response_started" + guard.mark("tool_effect") + assert guard.state == "tool_effect" diff --git a/test/sdk/core/models/test_openai_llm.py b/test/sdk/core/models/test_openai_llm.py index 96766053e5..260820b6ed 100644 --- a/test/sdk/core/models/test_openai_llm.py +++ b/test/sdk/core/models/test_openai_llm.py @@ -264,10 +264,13 @@ class SimpleChatMessage: def __init__(self, role=None, content=None, tool_calls=None): self.role = role self.content = content + self.tool_calls = tool_calls self.raw = None @staticmethod def from_dict(d): - return SimpleChatMessage(role=d.get("role"), content=d.get("content")) + return SimpleChatMessage( + role=d.get("role"), content=d.get("content"), tool_calls=d.get("tool_calls") + ) mock_models_module.ChatMessage = SimpleChatMessage mock_models_module.MessageRole = MagicMock() mock_smolagents.models = mock_models_module @@ -948,6 +951,56 @@ def test_call_rejects_reasoning_only_response_and_records_diagnostics( assert "event=empty_model_response" in caplog.text +def test_call_assembles_streamed_tool_calls_without_false_empty_response( + openai_model_instance, +): + """A tool-only completion is a valid model response and preserves fragments.""" + function_start = types.SimpleNamespace(name="marker_lookup", arguments='{"marker":"') + function_end = types.SimpleNamespace(name=None, arguments='acceptance"}') + first_delta = types.SimpleNamespace( + content=None, + role="assistant", + reasoning_content=None, + tool_calls=[types.SimpleNamespace( + index=0, id="call_123", type="function", function=function_start + )], + ) + second_delta = types.SimpleNamespace( + content=None, + role=None, + reasoning_content=None, + tool_calls=[types.SimpleNamespace( + index=0, id=None, type=None, function=function_end + )], + ) + chunks = [ + types.SimpleNamespace( + choices=[types.SimpleNamespace(delta=first_delta, finish_reason=None)], + usage=None, + ), + types.SimpleNamespace( + choices=[types.SimpleNamespace(delta=second_delta, finish_reason="tool_calls")], + usage=types.SimpleNamespace(prompt_tokens=12, completion_tokens=7), + ), + ] + + with patch.object(openai_model_instance, "_prepare_completion_kwargs", return_value={}): + openai_model_instance.client.chat.completions.create.return_value = chunks + result = openai_model_instance([{"role": "user", "content": "use tool"}]) + + assert result.content is None + assert result.tool_calls == [{ + "id": "call_123", + "type": "function", + "function": { + "name": "marker_lookup", + "arguments": '{"marker":"acceptance"}', + }, + }] + assert openai_model_instance.last_response_diagnostics["tool_call_count"] == 1 + assert openai_model_instance.last_finish_reason == "tool_calls" + + def test_call_with_reasoning_content_and_content_together(openai_model_instance): """Test __call__ method handles chunks with both reasoning_content and content simultaneously""" @@ -1542,6 +1595,12 @@ def test_dispatch_with_w2_snapshot_sets_requested_output_tokens(openai_model_ins max_tokens=256, ) + dispatched = openai_model_instance.client.chat.completions.create.call_args.kwargs + assert ( + openai_llm_module.build_final_request_shape(dispatched).fingerprint + == openai_model_instance.last_final_request_preflight.request_fingerprint + ) + def test_dispatch_with_matching_caller_max_tokens_is_allowed(openai_model_instance): openai_model_instance._dispatch_chat_completion( @@ -1659,6 +1718,24 @@ def test_dispatch_rejects_cross_model_w2_snapshot(openai_model_instance): openai_model_instance.client.chat.completions.create.assert_not_called() +def test_ac_p2_007_dispatch_rejects_model_switched_after_budget_resolution( + openai_model_instance, +): + snapshot = _safe_input_budget_snapshot(256) + with pytest.raises(openai_llm_module.SafeInputBudgetCapacityMismatch) as exc_info: + openai_model_instance._dispatch_chat_completion( + safe_input_budget_snapshot=snapshot, + stream=True, + model="smaller-model", + messages=[], + ) + + assert exc_info.value.field == "model_name" + assert exc_info.value.expected == "gpt-test" + assert exc_info.value.actual == "smaller-model" + openai_model_instance.client.chat.completions.create.assert_not_called() + + def test_dispatch_skips_w1_w2_consistency_when_capacity_snapshot_absent(openai_model_instance): snapshot = _safe_input_budget_snapshot(256) @@ -1688,6 +1765,163 @@ def test_safe_input_budget_trace_attributes_are_prefixed(): assert attrs["w2.hard_input_budget_tokens"] == 1000 +def _successful_stream(content="ok", prompt_tokens=10): + chunk = MagicMock() + chunk.choices = [MagicMock()] + chunk.choices[0].delta.content = content + chunk.choices[0].delta.reasoning_content = None + chunk.choices[0].delta.role = "assistant" + chunk.choices[0].finish_reason = "stop" + chunk.usage = MagicMock() + chunk.usage.prompt_tokens = prompt_tokens + chunk.usage.completion_tokens = 1 + return [chunk] + + +def _p2_prepare_kwargs(messages=None, model=None, **kwargs): + return { + "model": "gpt-test", + "messages": [ + message.model_dump() + if hasattr(message, "model_dump") + else {"role": getattr(message.role, "value", message.role), "content": message.content} + for message in (messages or []) + ], + } + + +def _recorded_recovery_states(model): + return [ + call.kwargs["context.final_request.recovery_state"] + for call in model._monitoring.set_span_attributes.call_args_list + if "context.final_request.recovery_state" in call.kwargs + ] + + +def test_ac_p2_005_create_overflow_rebuilds_and_retries_once(openai_model_instance): + snapshot = _safe_input_budget_snapshot(128) + openai_model_instance.safe_input_budget_snapshot = snapshot + streams = [ + Exception("context_length_exceeded: too many tokens"), + _successful_stream(), + ] + openai_model_instance.client.chat.completions.create.side_effect = streams + rebuild = MagicMock(return_value=[{"role": "user", "content": "short"}]) + + with patch.object(openai_model_instance, "_prepare_completion_kwargs", side_effect=_p2_prepare_kwargs): + result = openai_model_instance( + [{"role": "user", "content": "x" * 400}], + context_rebuild=rebuild, + ) + + assert result is not None + assert openai_model_instance.client.chat.completions.create.call_count == 2 + rebuild.assert_called_once() + assert openai_model_instance.last_final_request_preflight.retry_ordinal == 1 + assert _recorded_recovery_states(openai_model_instance)[-1] == "recovered" + + +def test_ac_p2_006_second_overflow_is_terminal(openai_model_instance): + snapshot = _safe_input_budget_snapshot(128) + openai_model_instance.safe_input_budget_snapshot = snapshot + openai_model_instance.client.chat.completions.create.side_effect = [ + Exception("context_length_exceeded: first"), + Exception("context_length_exceeded: second"), + ] + rebuild = MagicMock(return_value=[{"role": "user", "content": "short"}]) + + with pytest.raises( + openai_llm_module.ProviderContextOverflowRetryExhausted + ) as error: + with patch.object(openai_model_instance, "_prepare_completion_kwargs", side_effect=_p2_prepare_kwargs): + openai_model_instance( + [{"role": "user", "content": "x" * 400}], + context_rebuild=rebuild, + ) + + assert error.value.reason_code == "provider_context_overflow_retry_exhausted" + assert openai_model_instance.client.chat.completions.create.call_count == 2 + assert rebuild.call_count == 1 + assert _recorded_recovery_states(openai_model_instance)[-1] == "retry_exhausted" + + +def test_ac_p2_006_overflow_after_stream_chunk_never_retries(openai_model_instance): + snapshot = _safe_input_budget_snapshot(128) + openai_model_instance.safe_input_budget_snapshot = snapshot + + def partial_stream(): + yield _successful_stream("partial")[0] + raise Exception("context_length_exceeded: late") + + openai_model_instance.client.chat.completions.create.return_value = partial_stream() + rebuild = MagicMock(return_value=[{"role": "user", "content": "short"}]) + + with patch.object(openai_model_instance, "_prepare_completion_kwargs", side_effect=_p2_prepare_kwargs): + with pytest.raises(openai_llm_module.ProviderContextOverflowRetryUnsafe) as error: + openai_model_instance( + [{"role": "user", "content": "hello"}], + context_rebuild=rebuild, + ) + + assert error.value.reason_code == "provider_context_overflow_retry_unsafe" + assert openai_model_instance.client.chat.completions.create.call_count == 1 + rebuild.assert_not_called() + assert _recorded_recovery_states(openai_model_instance)[-1] == "retry_unsafe_after_response" + + +def test_ac_p2_004_soft_excess_rebuilds_before_provider_call(openai_model_instance): + snapshot = _safe_input_budget_snapshot(128) + openai_model_instance.safe_input_budget_snapshot = snapshot + openai_model_instance.client.chat.completions.create.return_value = _successful_stream() + rebuild = MagicMock(return_value=[{"role": "user", "content": "short"}]) + + with patch.object(openai_model_instance, "_prepare_completion_kwargs", side_effect=_p2_prepare_kwargs): + result = openai_model_instance( + [{"role": "user", "content": "x" * 3000}], + context_rebuild=rebuild, + ) + + assert result is not None + rebuild.assert_called_once() + assert openai_model_instance.client.chat.completions.create.call_count == 1 + assert _recorded_recovery_states(openai_model_instance)[-1] == "soft_rebuilt" + + +def test_ac_p2_010_preflight_telemetry_is_content_free(openai_model_instance): + snapshot = _safe_input_budget_snapshot(128) + openai_model_instance._dispatch_chat_completion( + safe_input_budget_snapshot=snapshot, + stream=True, + messages=[{"role": "user", "content": "private prompt value"}], + ) + + attributes = openai_model_instance._monitoring.set_span_attributes.call_args.kwargs + assert attributes["context.final_request.fingerprint"] + assert attributes["context.final_request.count_source"] == "estimated" + assert attributes["context.final_request.components.message_text"] > 0 + assert attributes["context.final_request.recovery_state"] == "not_attempted" + assert "private prompt value" not in str(attributes) + + +def test_ac_p2_003_verified_tokenizer_is_second_preflight_source(openai_model_instance): + snapshot = _safe_input_budget_snapshot(128) + openai_model_instance.tokenizer_family = "verified_family" + openai_model_instance.tokenizer_match_metadata = {"auto_applicable": True} + adapter = MagicMock() + adapter.count_tokens.return_value = 19 + + with patch.object(openai_llm_module, "resolve_tokenizer", return_value=(adapter, "exact")): + openai_model_instance._dispatch_chat_completion( + safe_input_budget_snapshot=snapshot, + stream=True, + messages=[{"role": "user", "content": "hello"}], + ) + + assert openai_model_instance.last_final_request_preflight.count_source == "tokenizer" + assert openai_model_instance.last_final_request_preflight.provider_count is None + adapter.count_tokens.assert_called_once() + + def test_call_without_tracker_creates_tracker(openai_model_instance): """When no _token_tracker is passed, __call__ creates one from monitoring manager.""" mock_tracker = MagicMock() diff --git a/test/sdk/core/models/test_provider_request_count.py b/test/sdk/core/models/test_provider_request_count.py new file mode 100644 index 0000000000..ad42e2292b --- /dev/null +++ b/test/sdk/core/models/test_provider_request_count.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock, patch + +import httpx + +from nexent.core.models.final_request_budget import build_final_request_shape +from nexent.core.models.provider_request_count import count_final_request, endpoint_fingerprint + + +BASE = "https://example.test/v1" + + +def _metadata(**overrides): + value = { + "status": "supported", + "adapter_version": "1.0.0", + "selected_protocol": "openai_responses", + "endpoint_fingerprint": endpoint_fingerprint(BASE), + "model_identity": "dashscope:qwen", + "stale_at": (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(), + "capabilities": {"text": "supported", "tools": "unknown", "media": "unknown"}, + } + value.update(overrides) + return value + + +def test_ac_p2_003_provider_count_precedence_for_matching_text_shape(): + shape = build_final_request_shape({"model": "qwen", "messages": [{"role": "user", "content": "hello"}]}) + response = MagicMock(status_code=200, content=b'{"input_tokens": 17}') + response.json.return_value = {"input_tokens": 17} + client = MagicMock() + client.__enter__.return_value.post.return_value = response + with patch("nexent.core.models.provider_request_count.httpx.Client", return_value=client) as client_type: + count, reason = count_final_request( + shape, + metadata=_metadata(), + base_url=BASE, + api_key="secret", + model_name="qwen", + canonical_model_id="dashscope:qwen", + ) + assert (count, reason) == (17, None) + assert client_type.call_args.kwargs["follow_redirects"] is False + assert client.__enter__.return_value.post.call_args.kwargs["json"]["input"] == [ + {"content": "hello", "role": "user"} + ] + + +def test_ac_p2_003_text_capability_does_not_authorize_tools_shape(): + shape = build_final_request_shape({"messages": [], "tools": [{"type": "function"}]}) + count, reason = count_final_request( + shape, + metadata=_metadata(), + base_url=BASE, + api_key="secret", + model_name="qwen", + canonical_model_id="dashscope:qwen", + ) + assert count is None + assert reason == "count_capability_shape_unsupported" + + +def test_ac_p2_003_text_capability_does_not_authorize_reasoning_template(): + shape = build_final_request_shape( + { + "messages": [], + "extra_body": {"chat_template_kwargs": {"enable_thinking": False}}, + } + ) + count, reason = count_final_request( + shape, + metadata=_metadata(), + base_url=BASE, + api_key="secret", + model_name="qwen", + canonical_model_id="dashscope:qwen", + ) + assert count is None + assert reason == "count_capability_shape_unsupported" + + +def test_ac_p2_003_temporary_count_failure_falls_back_with_reason(): + shape = build_final_request_shape({"messages": []}) + with patch( + "nexent.core.models.provider_request_count.httpx.Client", + side_effect=httpx.ReadTimeout("late"), + ): + count, reason = count_final_request( + shape, + metadata=_metadata(), + base_url=BASE, + api_key="secret", + model_name="qwen", + canonical_model_id="dashscope:qwen", + ) + assert (count, reason) == (None, "count_timeout") + + +def test_ac_p2_003_stale_or_cross_model_capability_is_not_used(): + shape = build_final_request_shape({"messages": []}) + count, reason = count_final_request( + shape, + metadata=_metadata(model_identity="other:model"), + base_url=BASE, + api_key="secret", + model_name="qwen", + canonical_model_id="dashscope:qwen", + ) + assert count is None + assert reason == "count_capability_model_mismatch" + + +def test_ac_p2_003_runtime_count_rejects_credentialed_or_queried_base_url(): + shape = build_final_request_shape({"messages": []}) + count, reason = count_final_request( + shape, + metadata=_metadata(), + base_url="https://user@example.test/v1?redirect=evil", + api_key="secret", + model_name="qwen", + canonical_model_id="dashscope:qwen", + ) + assert count is None + assert reason == "count_ssrf_rejected" From fa3a5d8c5c69c2ab5cf8d9c5967275276c3a2655 Mon Sep 17 00:00:00 2001 From: Jinglong Wang Date: Mon, 24 Aug 2026 14:50:49 +0800 Subject: [PATCH 05/10] feat(context): expose budget health and operations --- backend/agents/create_agent_info.py | 6 + backend/apps/model_managment_app.py | 49 ++- backend/apps/monitoring_app.py | 75 +++++ backend/database/db_models.py | 3 + .../model_capacity_catalog_service.py | 94 ++++++ .../services/model_capacity_health_service.py | 106 ++++++ backend/services/model_management_service.py | 34 ++ ...0_0824_context_budget_p3_observability.sql | 7 + .../chat/streaming/chatStreamHandler.tsx | 146 +++++++- .../[locale]/chat/streaming/taskWindow.tsx | 57 ++++ .../conversation-thread-list-adapter.tsx | 19 +- .../adapter/remote-chat-model-adapter.ts | 36 ++ .../app/[locale]/newchat/ui/token-usage.tsx | 97 +++++- .../ContextBudgetOperationsWidget.tsx | 85 +++++ .../resources/ModelCapacityCoverageWidget.tsx | 313 +++++++++++++++--- .../components/resources/ModelList.tsx | 2 + .../components/common/tokenUsageIndicator.tsx | 37 +++ frontend/const/chatConfig.ts | 12 +- frontend/hooks/model/useCapacityHealth.ts | 18 + frontend/lib/chatMessageExtractor.ts | 50 ++- frontend/public/locales/en/common.json | 59 ++++ frontend/public/locales/en/custom.json | 2 +- frontend/public/locales/zh/common.json | 59 ++++ frontend/public/locales/zh/custom.json | 2 +- frontend/services/api.ts | 3 + frontend/services/modelService.ts | 96 ++++++ frontend/services/monitoringService.ts | 17 + frontend/types/chat.ts | 36 ++ frontend/types/modelConfig.ts | 53 +++ frontend/types/monitoring.ts | 17 + .../core/agents/context_budget_event.py | 54 +++ sdk/nexent/core/agents/core_agent.py | 2 + sdk/nexent/core/models/openai_llm.py | 48 ++- sdk/nexent/core/utils/observer.py | 2 + sdk/nexent/monitor/monitoring.py | 31 ++ test/backend/agents/test_create_agent_info.py | 28 ++ test/backend/app/test_model_managment_app.py | 27 ++ test/backend/app/test_monitoring_app.py | 49 +++ .../test_model_capacity_catalog_service.py | 48 +++ .../test_model_capacity_health_service.py | 95 ++++++ .../test_context_budget_p3_migration.py | 16 + .../core/agents/test_context_budget_event.py | 76 +++++ .../monitor/test_context_budget_evidence.py | 28 ++ 43 files changed, 2017 insertions(+), 77 deletions(-) create mode 100644 backend/services/model_capacity_catalog_service.py create mode 100644 backend/services/model_capacity_health_service.py create mode 100644 deploy/sql/migrations/v2.5.0_0824_context_budget_p3_observability.sql create mode 100644 frontend/app/[locale]/resource-manage/components/resources/ContextBudgetOperationsWidget.tsx create mode 100644 frontend/hooks/model/useCapacityHealth.ts create mode 100644 sdk/nexent/core/agents/context_budget_event.py create mode 100644 test/backend/services/test_model_capacity_catalog_service.py create mode 100644 test/backend/services/test_model_capacity_health_service.py create mode 100644 test/deploy/test_context_budget_p3_migration.py create mode 100644 test/sdk/core/agents/test_context_budget_event.py create mode 100644 test/sdk/monitor/test_context_budget_evidence.py diff --git a/backend/agents/create_agent_info.py b/backend/agents/create_agent_info.py index b4d06413fc..8bb06d1a3b 100644 --- a/backend/agents/create_agent_info.py +++ b/backend/agents/create_agent_info.py @@ -363,6 +363,12 @@ def _resolve_input_budget( provider_raw = model_info.get("model_factory") provider = provider_raw.lower().strip() if isinstance(provider_raw, str) else "" model_id = model_info.get("model_name") or "" + persisted_profile_version = model_info.get("capability_profile_version") + if persisted_profile_version: + for (catalog_provider, catalog_model), profile in CAPABILITY_CATALOG.items(): + if profile.capability_profile_version == persisted_profile_version: + provider, model_id = catalog_provider, catalog_model + break provider_missing_detail = None if not provider: provider_missing_detail = ( diff --git a/backend/apps/model_managment_app.py b/backend/apps/model_managment_app.py index 93477c2df7..1a273fa5a2 100644 --- a/backend/apps/model_managment_app.py +++ b/backend/apps/model_managment_app.py @@ -50,6 +50,7 @@ verify_model_config_connectivity, ) from services.model_capacity_suggestion_service import suggest_capacity +from services.model_capacity_catalog_service import catalog_status from services.model_profile_match_service import ( resolve_model_profiles, serialize_profile_match, @@ -66,6 +67,7 @@ list_llm_models_for_tenant, list_models_for_admin, get_capacity_coverage, + get_capacity_health, pop_capacity_accept_signal, _record_capacity_suggestion_accept, adopt_capacity_for_tenant, @@ -88,6 +90,18 @@ def _require_super_admin(authorization: Optional[str]) -> tuple[str, str]: return user_id, tenant_id +def _require_model_manager(authorization: Optional[str]) -> tuple[str, str]: + user_id, tenant_id = _get_authenticated_user(authorization) + info = get_user_tenant_by_user_id(user_id) + role = (info.get("user_role") if info else "") or "" + if role.upper() not in {"ADMIN", "DEV", "SPEED", "SU", "SUPER_ADMIN"}: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Model management role required", + ) + return user_id, tenant_id + + def _get_authenticated_user(authorization: Optional[str]) -> tuple[str, str]: try: return get_current_user_id(authorization) @@ -287,13 +301,42 @@ async def get_model_capacity_coverage(authorization: Optional[str] = Header(None raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) +@router.get("/capacity-health") +async def get_model_capacity_health(authorization: Optional[str] = Header(None)): + """Return tenant-isolated P3 capacity health and remediation metadata.""" + try: + _, tenant_id = get_current_user_id(authorization) + result = get_capacity_health(tenant_id) + return JSONResponse(status_code=HTTPStatus.OK, content={ + "message": "Successfully retrieved model capacity health", + "data": jsonable_encoder(result), + }) + except HTTPException: + raise + except Exception as e: + logger.exception("Capacity health query failed") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Capacity health query failed", + ) from e + + +@router.get("/capacity-catalog-status") +async def get_capacity_catalog_status(authorization: Optional[str] = Header(None)): + get_current_user_id(authorization) + return JSONResponse(status_code=HTTPStatus.OK, content={ + "message": "Successfully retrieved capacity catalog status", + "data": jsonable_encoder(catalog_status()), + }) + + @router.post("/capacity-adoption-preview") async def preview_capacity_adoption( request: CapacityAdoptionPreviewRequest, authorization: Optional[str] = Header(None), ): try: - _, tenant_id = _get_authenticated_user(authorization) + _, tenant_id = _require_model_manager(authorization) result = await preview_capacity_adoption_for_tenant( tenant_id, request.display_name, @@ -320,7 +363,7 @@ async def adopt_capacity( authorization: Optional[str] = Header(None), ): try: - user_id, tenant_id = _get_authenticated_user(authorization) + user_id, tenant_id = _require_model_manager(authorization) result = await adopt_capacity_for_tenant( user_id, tenant_id, @@ -351,7 +394,7 @@ async def probe_token_count( authorization: Optional[str] = Header(None), ): try: - user_id, tenant_id = _get_authenticated_user(authorization) + user_id, tenant_id = _require_model_manager(authorization) result = await probe_token_count_for_tenant( user_id, tenant_id, request.display_name, force=request.force ) diff --git a/backend/apps/monitoring_app.py b/backend/apps/monitoring_app.py index f89f4312f4..bf8621d46e 100644 --- a/backend/apps/monitoring_app.py +++ b/backend/apps/monitoring_app.py @@ -113,6 +113,68 @@ def _query_model_metrics_from_db( return [] +def _query_context_budget_metrics_from_db( + time_range: str, tenant_id: str | None = None +) -> list[dict[str, Any]]: + """Aggregate content-free P3 evidence by Provider/model/profile version.""" + time_filter = _compute_time_range_filter(time_range) + tenant_filter = "AND m.tenant_id = :tenant_id" if tenant_id else "" + params = {"tenant_id": tenant_id} if tenant_id else {} + query_sql = f""" + SELECT + COALESCE(m.context_budget_evidence->>'provider_protocol', 'unknown') AS provider_protocol, + m.model_name, + COALESCE(m.capability_profile_version, 'unknown') AS capability_profile_version, + COUNT(*) FILTER (WHERE m.context_budget_evidence IS NOT NULL) AS request_count, + COUNT(*) FILTER (WHERE COALESCE((m.context_budget_evidence->>'provider_overflow')::boolean, FALSE)) AS overflow_count, + COUNT(*) FILTER (WHERE COALESCE((m.context_budget_evidence->>'compression_attempted')::boolean, FALSE)) AS compacted_count, + ROUND(AVG(CASE WHEN COALESCE((m.context_budget_evidence->>'compression_attempted')::boolean, FALSE) + AND (m.context_budget_evidence->>'context_raw_tokens')::numeric > 0 + THEN 1 - (m.context_budget_evidence->>'context_final_tokens')::numeric + / (m.context_budget_evidence->>'context_raw_tokens')::numeric END), 4) AS avg_compression_ratio, + COUNT(*) FILTER (WHERE (m.context_budget_evidence->>'provider_prompt_usage_tokens')::numeric > 0) AS estimate_sample_count, + ROUND(AVG(CASE WHEN (m.context_budget_evidence->>'provider_prompt_usage_tokens')::numeric > 0 + THEN ABS((m.context_budget_evidence->>'raw_estimate_tokens')::numeric + - (m.context_budget_evidence->>'provider_prompt_usage_tokens')::numeric) + / (m.context_budget_evidence->>'provider_prompt_usage_tokens')::numeric END), 4) AS mean_absolute_estimate_error, + COUNT(*) FILTER (WHERE COALESCE((m.context_budget_evidence->>'recovery_attempted')::boolean, FALSE)) AS recovery_attempt_count, + COUNT(*) FILTER (WHERE COALESCE((m.context_budget_evidence->>'recovery_succeeded')::boolean, FALSE)) AS recovery_success_count + FROM nexent.model_monitoring_record_t m + WHERE {time_filter} {tenant_filter} AND m.delete_flag = 'N' + AND m.context_budget_evidence IS NOT NULL + GROUP BY provider_protocol, m.model_name, capability_profile_version + ORDER BY request_count DESC + """ + try: + with get_monitoring_db_session() as session: + rows = session.execute(text(query_sql), params).fetchall() + output = [] + for row in rows: + requests = int(row.request_count or 0) + attempts = int(row.recovery_attempt_count or 0) + compacted = int(row.compacted_count or 0) + output.append({ + "provider_protocol": row.provider_protocol, + "model_name": row.model_name, + "capability_profile_version": row.capability_profile_version, + "request_count": requests, + "overflow_count": int(row.overflow_count or 0), + "overflow_rate": (int(row.overflow_count or 0) / requests) if requests else None, + "compacted_count": compacted, + "compaction_incidence": (compacted / requests) if requests else None, + "avg_compression_ratio": float(row.avg_compression_ratio) if row.avg_compression_ratio is not None else None, + "estimate_sample_count": int(row.estimate_sample_count or 0), + "mean_absolute_estimate_error": float(row.mean_absolute_estimate_error) if row.mean_absolute_estimate_error is not None else None, + "recovery_attempt_count": attempts, + "recovery_success_count": int(row.recovery_success_count or 0), + "recovery_success_rate": (int(row.recovery_success_count or 0) / attempts) if attempts else None, + }) + return output + except Exception as exc: + logger.error("Failed to query context budget metrics: %s", exc) + return [] + + @router.get("/models", response_model=ConversationResponse) async def list_models_endpoint( time_range: Annotated[str, Query( @@ -147,3 +209,16 @@ async def get_monitoring_status_endpoint(): message="success", data=get_monitoring_status(), ) + + +@router.get("/context-budget", response_model=ConversationResponse) +async def get_context_budget_metrics_endpoint( + time_range: Annotated[str, Query(description="Time range: 24h, 7d, 30d")] = "24h", + authorization: Annotated[str | None, Header()] = None, +): + _, tenant_id = get_current_user_id(authorization) + return ConversationResponse( + code=0, + message="success", + data=_query_context_budget_metrics_from_db(time_range, tenant_id), + ) diff --git a/backend/database/db_models.py b/backend/database/db_models.py index e8e5c104e8..f9171bb112 100644 --- a/backend/database/db_models.py +++ b/backend/database/db_models.py @@ -569,6 +569,9 @@ class ModelMonitoringRecord(SimpleTableBase): budget_warnings = Column( JSONB, doc="Structured W2 budget warnings active for this request" ) + context_budget_evidence = Column( + JSONB, doc="Content-free P3 final request, compaction, overflow and recovery evidence" + ) generation_rate = Column( Float, doc="Token generation rate (tokens per second)") is_streaming = Column( diff --git a/backend/services/model_capacity_catalog_service.py b/backend/services/model_capacity_catalog_service.py new file mode 100644 index 0000000000..74b85dca3b --- /dev/null +++ b/backend/services/model_capacity_catalog_service.py @@ -0,0 +1,94 @@ +"""Trusted catalog lifecycle and stage-only refresh boundary for P3.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from threading import Lock +from typing import Any, Callable, Mapping, Optional + +from consts.capability_profiles import CATALOG, CATALOG_REVISION +from services.model_capacity_health_service import catalog_freshness + + +@dataclass(frozen=True) +class StagedCatalogCandidate: + revision: str + source_identity: str + staged_at: str + added: tuple[str, ...] + changed: tuple[str, ...] + removed: tuple[str, ...] + + +_lock = Lock() +_candidate: Optional[StagedCatalogCandidate] = None + + +def _active_profiles() -> dict[str, Any]: + return {profile.capability_profile_version: profile for profile in CATALOG.values()} + + +def catalog_status() -> dict[str, Any]: + profiles = _active_profiles() + lifecycle: dict[str, int] = {} + for profile in profiles.values(): + state = catalog_freshness(profile.verified_at)[0] + lifecycle[state] = lifecycle.get(state, 0) + 1 + with _lock: + candidate = _candidate + return { + "active_revision": CATALOG_REVISION, + "profile_count": len(profiles), + "lifecycle_counts": lifecycle, + "candidate": candidate.__dict__ if candidate else None, + } + + +def stage_trusted_candidate( + document: Mapping[str, Any], *, source_identity: str, signature_verified: bool, +) -> StagedCatalogCandidate: + """Validate and stage facts only; never changes CATALOG or tenant records.""" + if not signature_verified or not source_identity.strip(): + raise ValueError("catalog_source_untrusted") + revision = document.get("revision") + profiles = document.get("profiles") + if not isinstance(revision, str) or not revision.strip() or revision == CATALOG_REVISION: + raise ValueError("catalog_revision_invalid") + if not isinstance(profiles, Mapping): + raise ValueError("catalog_profiles_invalid") + normalized: dict[str, Mapping[str, Any]] = {} + for version, profile in profiles.items(): + if not isinstance(version, str) or not isinstance(profile, Mapping): + raise ValueError("catalog_profile_invalid") + required = {"provider", "model_name", "context_window_tokens", "max_output_tokens", "verified_at", "evidence"} + if not required.issubset(profile) or not profile.get("evidence") or catalog_freshness(profile.get("verified_at"))[0] == "expired": + raise ValueError("catalog_profile_incomplete") + if int(profile["context_window_tokens"]) <= int(profile["max_output_tokens"]): + raise ValueError("catalog_capacity_invalid") + normalized[version] = profile + active = _active_profiles() + active_versions, proposed_versions = set(active), set(normalized) + candidate = StagedCatalogCandidate( + revision=revision, source_identity=source_identity, + staged_at=datetime.now(timezone.utc).isoformat(), + added=tuple(sorted(proposed_versions - active_versions)), + changed=tuple(sorted(version for version in proposed_versions & active_versions if normalized[version] != active[version].model_dump())), + removed=tuple(sorted(active_versions - proposed_versions)), + ) + global _candidate + with _lock: + _candidate = candidate + return candidate + + +def refresh_catalog_candidate( + loader: Callable[[], Mapping[str, Any]], *, source_identity: str, + verifier: Callable[[Mapping[str, Any]], bool], +) -> StagedCatalogCandidate: + """Scheduler-safe adapter entrypoint: load, verify, then stage only.""" + document = loader() + return stage_trusted_candidate( + document, source_identity=source_identity, + signature_verified=bool(verifier(document)), + ) diff --git a/backend/services/model_capacity_health_service.py b/backend/services/model_capacity_health_service.py new file mode 100644 index 0000000000..2ce660d2b8 --- /dev/null +++ b/backend/services/model_capacity_health_service.py @@ -0,0 +1,106 @@ +"""Deterministic, content-free capacity health classification for P3.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from enum import Enum +from typing import Any, Mapping, Optional + +REVIEW_DUE_DAYS = 150 +EXPIRED_DAYS = 180 + + +class CapacityHealthStatus(str, Enum): + HEALTHY = "healthy" + REVIEW_DUE = "review_due" + EXPIRED = "expired" + ESTIMATED = "estimated" + UNCONFIGURED = "unconfigured" + INVALID = "invalid" + PROBE_DEGRADED = "probe_degraded" + + +def _parse_utc(value: Optional[str]) -> Optional[datetime]: + if not value: + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except (TypeError, ValueError): + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def catalog_freshness( + verified_at: Optional[str], *, now: Optional[datetime] = None +) -> tuple[str, Optional[str], Optional[str]]: + checked = _parse_utc(verified_at) + if checked is None: + return "expired", None, None + current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + age_days = (current - checked).total_seconds() / 86400 + review_at = datetime.fromtimestamp( + checked.timestamp() + REVIEW_DUE_DAYS * 86400, tz=timezone.utc + ).isoformat() + expires_at = datetime.fromtimestamp( + checked.timestamp() + EXPIRED_DAYS * 86400, tz=timezone.utc + ).isoformat() + state = "expired" if age_days >= EXPIRED_DAYS else "review_due" if age_days >= REVIEW_DUE_DAYS else "current" + return state, review_at, expires_at + + +def classify_capacity_health( + record: Mapping[str, Any], *, match: Optional[Mapping[str, Any]] = None, + profile_verified_at: Optional[str] = None, now: Optional[datetime] = None, +) -> dict[str, Any]: + reasons: list[str] = [] + context_window = record.get("context_window_tokens") + max_output = record.get("max_output_tokens") + max_input = record.get("max_input_tokens") + if context_window is not None and context_window <= 0: + reasons.append("context_window_invalid") + if max_output is not None and max_output <= 0: + reasons.append("max_output_invalid") + if max_input is not None and max_input <= 0: + reasons.append("max_input_invalid") + if context_window and max_output and max_output >= context_window: + reasons.append("output_not_below_context") + + match_data = dict(match or {}) + auto_applicable = bool(match_data.get("auto_applicable")) + lifecycle, review_at, expires_at = catalog_freshness(profile_verified_at, now=now) + probe = record.get("token_count_probe_metadata") or {} + probe_status = probe.get("status") or probe.get("state") + capacity_source = record.get("capacity_source") or "unknown" + + if reasons: + status, action = CapacityHealthStatus.INVALID, "edit" + elif context_window is None or max_output is None: + status, action = CapacityHealthStatus.UNCONFIGURED, "review_profile" if auto_applicable else "edit" + reasons.append("required_capacity_missing") + elif lifecycle == "expired" and record.get("capability_profile_version"): + status, action = CapacityHealthStatus.EXPIRED, "review_profile" if auto_applicable else "review_evidence" + reasons.append("catalog_evidence_expired") + elif probe_status in {"degraded", "temporary_failure", "failed", "stale"}: + status, action = CapacityHealthStatus.PROBE_DEGRADED, "retry_probe" + reasons.append("token_count_probe_degraded") + elif capacity_source in {"unknown", "legacy"} or ( + not record.get("tokenizer_family") and probe_status != "supported" + ): + status, action = CapacityHealthStatus.ESTIMATED, "review_profile" if auto_applicable else "edit" + reasons.append("capacity_or_counting_estimated") + elif lifecycle == "review_due" and record.get("capability_profile_version"): + status, action = CapacityHealthStatus.REVIEW_DUE, "review_profile" if auto_applicable else "review_evidence" + reasons.append("catalog_evidence_review_due") + else: + status, action = CapacityHealthStatus.HEALTHY, "none" + reasons.append("capacity_verified") + return { + "status": status.value, "reasons": reasons, "action": action, + "match_kind": match_data.get("match_kind") or "none", + "suggestion_available": auto_applicable, + "profile_version": record.get("capability_profile_version"), + "verified_at": profile_verified_at, "review_at": review_at, + "expires_at": expires_at, "probe_status": probe_status, + } diff --git a/backend/services/model_management_service.py b/backend/services/model_management_service.py index 0411621db8..8a027fdddf 100644 --- a/backend/services/model_management_service.py +++ b/backend/services/model_management_service.py @@ -1,5 +1,6 @@ import logging import threading +from datetime import datetime, timezone from typing import List, Dict, Any, Optional from consts.const import ( @@ -55,6 +56,8 @@ resolve_model_profiles, serialize_profile_match, ) +from services.model_capacity_health_service import classify_capacity_health +from consts.capability_profiles import CATALOG, CATALOG_REVISION from utils.model_name_utils import ( add_repo_to_name, split_repo_name, @@ -373,6 +376,37 @@ def get_capacity_coverage(tenant_id: str) -> Dict[str, Any]: } +def get_capacity_health(tenant_id: str) -> Dict[str, Any]: + """Return deterministic P3 health for every tenant LLM/VLM.""" + profiles = {item.capability_profile_version: item for item in CATALOG.values()} + items: list[dict[str, Any]] = [] + counts: dict[str, int] = {} + for record in get_model_records(None, tenant_id): + if record.get("model_type") not in CAPACITY_COVERAGE_MODEL_TYPES: + continue + resolution = _resolution_for_record(record) + match = serialize_profile_match(resolution.capacity_match) + profile = profiles.get(record.get("capability_profile_version")) or profiles.get( + resolution.capacity_match.selected_profile + ) + health = classify_capacity_health( + record, match=match, profile_verified_at=getattr(profile, "verified_at", None) + ) + item = { + "model_id": record.get("model_id"), "display_name": record.get("display_name"), + "model_name": add_repo_to_name(record.get("model_repo", ""), record.get("model_name", "")), + "model_factory": record.get("model_factory"), "model_type": record.get("model_type"), + "matcher_version": MATCHER_VERSION, **health, + } + items.append(item) + counts[item["status"]] = counts.get(item["status"], 0) + 1 + return { + "catalog_revision": CATALOG_REVISION, + "generated_at": datetime.now(timezone.utc).isoformat(), + "total": len(items), "counts": counts, "items": items, + } + + async def create_model_for_tenant( user_id: str, tenant_id: str, diff --git a/deploy/sql/migrations/v2.5.0_0824_context_budget_p3_observability.sql b/deploy/sql/migrations/v2.5.0_0824_context_budget_p3_observability.sql new file mode 100644 index 0000000000..dde5eec8db --- /dev/null +++ b/deploy/sql/migrations/v2.5.0_0824_context_budget_p3_observability.sql @@ -0,0 +1,7 @@ +SET search_path TO nexent; +BEGIN; +ALTER TABLE nexent.model_monitoring_record_t + ADD COLUMN IF NOT EXISTS context_budget_evidence JSONB DEFAULT NULL; +COMMENT ON COLUMN nexent.model_monitoring_record_t.context_budget_evidence IS + 'Content-free P3 request-budget, compaction, overflow and recovery evidence.'; +COMMIT; diff --git a/frontend/app/[locale]/chat/streaming/chatStreamHandler.tsx b/frontend/app/[locale]/chat/streaming/chatStreamHandler.tsx index 97bb24a737..df29509a6f 100644 --- a/frontend/app/[locale]/chat/streaming/chatStreamHandler.tsx +++ b/frontend/app/[locale]/chat/streaming/chatStreamHandler.tsx @@ -1,7 +1,12 @@ // Tool function for processing chat streaming response import { chatConfig } from "@/const/chatConfig"; -import { ChatMessageType, AgentStep } from "@/types/chat"; +import { + ChatMessageType, + AgentStep, + ContextBudgetMetrics, + TokenMetrics, +} from "@/types/chat"; import log from "@/lib/logger"; import { MESSAGE_ROLES } from "@/const/chatConfig"; @@ -97,8 +102,15 @@ type ReconstructionState = { finalAnswer: string; steps: AgentStep[]; stepCounter: number; + metricsByStep: Map; + budgetEventsByStep: Map; }; +const isContextBudgetTimelineEvent = (budget: ContextBudgetMetrics): boolean => + Boolean(budget?.compression?.attempted) || + Number(budget?.retry_ordinal || 0) > 0 || + !["not_needed", "not_attempted"].includes(budget?.recovery_state); + // Helper to create a new step const createNewStep = ( stepCounter: number, @@ -287,7 +299,6 @@ const processThinkingCodeUnit = ( const isSkippedUnitType = (unitType: string): boolean => { const skippedTypes = [ "search_content_placeholder", - "token_count", "parse", "execution_logs", "agent_new_run", @@ -319,6 +330,8 @@ export function reconstructFromStreamingMessage( finalAnswer: streamingMessage.message_content || "", steps: [], stepCounter: 0, + metricsByStep: new Map(), + budgetEventsByStep: new Map(), }; // Sort units by index (should already be sorted) @@ -353,6 +366,60 @@ export function reconstructFromStreamingMessage( state.finalAnswer = unit.unit_content; break; + case "token_count": + try { + const metrics = JSON.parse(unit.unit_content) as TokenMetrics; + const stepId = `step-${metrics.step_number}`; + const existing = state.metricsByStep.get(stepId); + state.metricsByStep.set(stepId, { + ...(existing || {}), + ...metrics, + context_budget: existing?.context_budget, + }); + } catch { + /* Ignore malformed optional metrics from older runtimes. */ + } + break; + + case "context_budget": + try { + const budget = JSON.parse(unit.unit_content) as ContextBudgetMetrics; + const stepId = `step-${budget.step_number}`; + const existing = state.metricsByStep.get(stepId); + state.metricsByStep.set( + stepId, + existing + ? { ...existing, context_budget: budget } + : { + step_number: budget.step_number, + duration: 0, + step_input_tokens: null, + step_output_tokens: null, + total_output_tokens: 0, + estimated_context_tokens: budget.final_tokens, + token_threshold: budget.soft_budget, + hard_input_budget_tokens: budget.hard_budget, + context_processing_mode: null, + output_finish_reason: null, + context_budget: budget, + } + ); + if (isContextBudgetTimelineEvent(budget)) { + const events = state.budgetEventsByStep.get(stepId) || []; + events.push({ + id: `context-budget-${unit.unit_index}`, + type: chatConfig.messageTypes.CONTEXT_BUDGET, + content: unit.unit_content, + expanded: true, + timestamp: Date.now(), + }); + state.budgetEventsByStep.set(stepId, events); + } + } catch { + /* Ignore malformed optional events for forward compatibility. */ + } + break; + default: { if (isSkippedUnitType(unit.unit_type)) { break; @@ -377,6 +444,16 @@ export function reconstructFromStreamingMessage( // Don't forget to save the last currentStep if it has contents finalizeCurrentStep(state); + state.steps = state.steps.map((step) => { + const metrics = state.metricsByStep.get(step.id); + const budgetEvents = state.budgetEventsByStep.get(step.id) || []; + return { + ...step, + ...(metrics ? { metrics } : {}), + contents: [...budgetEvents, ...step.contents], + }; + }); + return { currentStep: state.steps[state.steps.length - 1] || null, lastContentType: state.lastContentType, @@ -535,6 +612,7 @@ export const handleStreamResponse = async ( resumeConfig && (messageType === chatConfig.messageTypes.STEP_COUNT || messageType === chatConfig.messageTypes.TOKEN_COUNT || + messageType === chatConfig.messageTypes.CONTEXT_BUDGET || messageType === chatConfig.messageTypes.SEARCH_CONTENT_PLACEHOLDER || messageType === chatConfig.messageTypes.PARSE || @@ -599,16 +677,76 @@ export const handleStreamResponse = async ( // If currentStep matches the metrics step number, set directly if (currentStep && currentStep.id === metricsStepId) { - currentStep.metrics = metricsData; + currentStep.metrics = { + ...(currentStep.metrics || {}), + ...metricsData, + context_budget: + currentStep.metrics?.context_budget || + metricsData.context_budget, + }; } else { // currentStep was already reset to a new step, store metrics for later application - pendingMetrics.set(metricsStepId, metricsData); + const existing = pendingMetrics.get(metricsStepId) || {}; + pendingMetrics.set(metricsStepId, { + ...existing, + ...metricsData, + context_budget: + existing.context_budget || metricsData.context_budget, + }); } } catch { // Failed to parse metrics } break; + case chatConfig.messageTypes.CONTEXT_BUDGET: + try { + const budgetData = JSON.parse(messageContent); + const metricsStepId = `step-${budgetData.step_number}`; + if (currentStep && currentStep.id === metricsStepId) { + currentStep.metrics = { + ...(currentStep.metrics || { + step_number: budgetData.step_number, + duration: 0, + step_input_tokens: null, + step_output_tokens: null, + total_output_tokens: 0, + estimated_context_tokens: budgetData.final_tokens, + token_threshold: budgetData.soft_budget, + hard_input_budget_tokens: budgetData.hard_budget, + context_processing_mode: null, + output_finish_reason: null, + }), + context_budget: budgetData, + }; + if ( + isContextBudgetTimelineEvent(budgetData) && + !currentStep.contents.some( + (content) => + content.id === + `context-budget-${budgetData.step_number}-${budgetData.retry_ordinal}` + ) + ) { + currentStep.contents.push({ + id: `context-budget-${budgetData.step_number}-${budgetData.retry_ordinal}`, + type: chatConfig.messageTypes.CONTEXT_BUDGET, + content: messageContent, + expanded: true, + timestamp: Date.now(), + }); + } + } else { + const existing = pendingMetrics.get(metricsStepId); + pendingMetrics.set(metricsStepId, { + ...(existing || {}), + context_budget: budgetData, + }); + } + } catch { + /* optional forward-compatible event */ + } + break; + case chatConfig.messageTypes.MODEL_OUTPUT: case chatConfig.messageTypes.MODEL_OUTPUT_THINKING: case chatConfig.messageTypes.MODEL_OUTPUT_DEEP_THINKING: diff --git a/frontend/app/[locale]/chat/streaming/taskWindow.tsx b/frontend/app/[locale]/chat/streaming/taskWindow.tsx index a53c35f976..a71fbd0ab2 100644 --- a/frontend/app/[locale]/chat/streaming/taskWindow.tsx +++ b/frontend/app/[locale]/chat/streaming/taskWindow.tsx @@ -419,6 +419,63 @@ type KnowledgeSiteInfo = { // Define the handlers for different types of messages to improve extensibility const messageHandlers: MessageHandler[] = [ + { + canHandle: (message) => + message.type === chatConfig.messageTypes.CONTEXT_BUDGET, + render: (message, t) => { + try { + const budget = JSON.parse(message.content || "{}"); + const raw = Number(budget.raw_tokens || 0); + const final = Number(budget.final_tokens || 0); + const saved = Number(budget.compression?.saved_tokens || 0); + const ratio = Number(budget.compression?.ratio || 0); + const reasons = Array.isArray(budget.compression?.reasons) + ? budget.compression.reasons.filter( + (reason: unknown): reason is string => typeof reason === "string" + ) + : []; + return ( +
+
+
+
+ {t("taskWindow.contextBudget.savings", { + raw: raw.toLocaleString(), + final: final.toLocaleString(), + saved: saved.toLocaleString(), + percent: Math.round(ratio * 100), + })} +
+ {reasons.length > 0 && ( +
+ {t("taskWindow.contextBudget.reason", { + reason: reasons + .map((reason: string) => + t(`taskWindow.contextBudget.reasons.${reason}`, reason) + ) + .join(", "), + })} +
+ )} + {budget.recovery_state && + !["not_needed", "not_attempted"].includes( + budget.recovery_state + ) && ( +
+ {t("taskWindow.contextBudget.recovery", { + state: budget.recovery_state, + })} +
+ )} +
+ ); + } catch { + return null; + } + }, + }, { canHandle: (message) => message.type === chatConfig.messageTypes.HISTORY_SUMMARY, diff --git a/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx b/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx index 6194da4f0f..56c1810c39 100644 --- a/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx +++ b/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx @@ -39,6 +39,7 @@ import { skillFileUploadsRegistry, remoteChatModelAdapter, parseStepTokenCount, + parseContextBudget, parsePlan, parsePlanStepUpdate, planRegistry, @@ -63,8 +64,7 @@ type HistoricalChatMode = "planning" | "execution"; let activeHistoricalConversationId: string | undefined; let activeHistoricalChatModeConversationId: string | undefined; let historicalChatModeListener: - | ((mode: HistoricalChatMode) => void) - | undefined; + ((mode: HistoricalChatMode) => void) | undefined; const historicalChatModeCache = new Map(); export const restoreHistoricalPlan = (conversationId?: string): void => { @@ -221,7 +221,7 @@ const buildBranchableHistory = ( const branchableMessages: BranchableHistoryMessage[] = []; let visibleHeadId: string | null = null; - for (let groupStart = 0; groupStart < messages.length; ) { + for (let groupStart = 0; groupStart < messages.length;) { const role = messages[groupStart].role; let groupEnd = groupStart + 1; while (groupEnd < messages.length && messages[groupEnd].role === role) { @@ -586,6 +586,16 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { if (parsed) stepTokenCounts.push(parsed); continue; } + if (part.type === "context_budget") { + const budget = parseContextBudget(part.content); + if (budget) { + const step = [...stepTokenCounts] + .reverse() + .find((item) => item.stepNumber === budget.step_number); + if (step) step.contextBudget = budget; + } + continue; + } // Restore per-tool search sources from the persisted placeholder. // The backend keeps the full results in `searchByUnitId`, keyed by @@ -944,8 +954,7 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { if (typeof searchItem === "object" && searchItem !== null) { const item = searchItem as Record; const scoreDetails = item.score_details as - | Record - | undefined; + Record | undefined; const searchImageKey = `${item.tool_sign ?? ""}${item.cite_index ?? ""}`; if ( scoreDetails?.chunk_type === "image" || diff --git a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts index 97968e8262..5c4a22f888 100644 --- a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts +++ b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts @@ -328,6 +328,32 @@ export interface StepTokenCount { estimatedContextTokens: number; tokenThreshold: number | null; contextWindowTokens: number | null; + contextBudget?: ContextBudgetEvent; +} + +export interface ContextBudgetEvent { + schema_version: 1; + step_number: number; + raw_tokens: number; + final_tokens: number; + soft_budget: number; + hard_budget: number; + hard_count: number; + components: Record; + count_source: string; + compression: { attempted: boolean; saved_tokens: number; ratio: number }; + recovery_state: string; +} + +export function parseContextBudget(content: string): ContextBudgetEvent | null { + try { + const data = JSON.parse(content); + return data?.schema_version === 1 && typeof data.step_number === "number" + ? (data as ContextBudgetEvent) + : null; + } catch { + return null; + } } /** @@ -1937,6 +1963,16 @@ export const remoteChatModelAdapter: ChatModelAdapter = { storedTiming = buildTimingFromTokenCount(chunk.content); continue; // Don't yield for internal data chunks } + if (chunk.type === "context_budget") { + const budget = parseContextBudget(chunk.content); + if (budget) { + const step = [...stepTokenCounts] + .reverse() + .find((item) => item.stepNumber === budget.step_number); + if (step) step.contextBudget = budget; + } + continue; + } if (chunk.type === "plan") { const plan = parsePlan(chunk.content); diff --git a/frontend/app/[locale]/newchat/ui/token-usage.tsx b/frontend/app/[locale]/newchat/ui/token-usage.tsx index 3da6d9e08b..5d257c5eaa 100644 --- a/frontend/app/[locale]/newchat/ui/token-usage.tsx +++ b/frontend/app/[locale]/newchat/ui/token-usage.tsx @@ -36,14 +36,18 @@ export const TokenUsage: FC = ({ className }) => { > {usagePercent}% - {t("chat.tokenUsage.used")} + + {t("chat.tokenUsage.used")} + {/* Expanded details popover */} {expanded && (
- {t("chat.tokenUsage.details")} + + {t("chat.tokenUsage.details")} + {/* Expanded details popover */} @@ -211,9 +221,12 @@ export const SingleTurnTokenUsage: FC = ({ className {/* Stacked progress bar */}
- {t("chat.tokenUsage.context")} + + {t("chat.tokenUsage.context")} + - {totalTokensUsed.toLocaleString()} / {maxTokens.toLocaleString()} + {totalTokensUsed.toLocaleString()} /{" "} + {maxTokens.toLocaleString()}
@@ -230,12 +243,18 @@ export const SingleTurnTokenUsage: FC = ({ className style={{ width: `${Math.min(stepPercent, 100 - (index > 0 ? steps.slice(0, index).reduce((sum, s) => sum + ((s.stepInputTokens + s.stepOutputTokens) / maxTokens) * 100, 0) : 0))}%`, }} - title={t("chat.tokenUsage.stepSummary", { step: step.stepNumber, input: step.stepInputTokens, output: step.stepOutputTokens })} + title={t("chat.tokenUsage.stepSummary", { + step: step.stepNumber, + input: step.stepInputTokens, + output: step.stepOutputTokens, + })} > {/* Input portion (blue) */}
{/* Output portion (amber) */}
= ({ className
- {t("chat.tokenUsage.input")} + + {t("chat.tokenUsage.input")} +
- {t("chat.tokenUsage.output")} + + {t("chat.tokenUsage.output")} +
@@ -276,12 +299,56 @@ export const SingleTurnTokenUsage: FC = ({ className {/* Step details */}
+ {budget && ( +
+
+ {t("chat.tokenUsage.finalRequest")} + + {budget.hard_count.toLocaleString()} /{" "} + {budget.hard_budget.toLocaleString()} + +
+ {Object.entries(budget.components) + .filter(([, value]) => value > 0) + .map(([name, value]) => ( +
+ {name.replaceAll("_", " ")} + {value.toLocaleString()} +
+ ))} +
+ {t("chat.tokenUsage.countSource")} + {budget.count_source} +
+ {budget.compression.attempted && ( +
+ {t("chat.tokenUsage.compactionSaved")} + + {budget.compression.saved_tokens.toLocaleString()} ( + {Math.round(budget.compression.ratio * 100)}%) + +
+ )} + {budget.recovery_state !== "not_needed" && ( +
+ {t("chat.tokenUsage.recovery")} + {budget.recovery_state} +
+ )} +
+ )}
- {t("chat.tokenUsage.total")} + + {t("chat.tokenUsage.total")} + - {totalTokensUsed.toLocaleString()} / {maxTokens.toLocaleString()} + {totalTokensUsed.toLocaleString()} /{" "} + {maxTokens.toLocaleString()}
diff --git a/frontend/app/[locale]/resource-manage/components/resources/ContextBudgetOperationsWidget.tsx b/frontend/app/[locale]/resource-manage/components/resources/ContextBudgetOperationsWidget.tsx new file mode 100644 index 0000000000..c7999d5769 --- /dev/null +++ b/frontend/app/[locale]/resource-manage/components/resources/ContextBudgetOperationsWidget.tsx @@ -0,0 +1,85 @@ +"use client"; + +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Card, Table, Tag } from "antd"; +import { useQuery } from "@tanstack/react-query"; +import { monitoringService } from "@/services/monitoringService"; + +const percent = (value: number | null) => + value === null ? "—" : `${(value * 100).toFixed(1)}%`; + +export default function ContextBudgetOperationsWidget({ + timeRange, +}: { + timeRange: string; +}) { + const { t } = useTranslation("common"); + const { data = [], isLoading } = useQuery({ + queryKey: ["contextBudgetMonitoring", timeRange], + queryFn: () => monitoringService.fetchContextBudget(timeRange), + staleTime: 30_000, + }); + if (!isLoading && data.length === 0) return null; + return ( + {timeRange}} + > + + `${row.provider_protocol}:${row.model_name}:${row.capability_profile_version}` + } + dataSource={data} + columns={[ + { + title: t("monitoring.contextBudget.model"), + render: (_value, row) => ( + + {row.provider_protocol} / {row.model_name} +
+ + {row.capability_profile_version} + +
+ ), + }, + { + title: t("monitoring.contextBudget.requests"), + dataIndex: "request_count", + }, + { + title: t("monitoring.contextBudget.overflow"), + dataIndex: "overflow_rate", + render: percent, + }, + { + title: t("monitoring.contextBudget.compaction"), + dataIndex: "compaction_incidence", + render: percent, + }, + { + title: t("monitoring.contextBudget.reduction"), + dataIndex: "avg_compression_ratio", + render: percent, + }, + { + title: t("monitoring.contextBudget.estimateError"), + dataIndex: "mean_absolute_estimate_error", + render: percent, + }, + { + title: t("monitoring.contextBudget.recovery"), + dataIndex: "recovery_success_rate", + render: percent, + }, + ]} + /> + + ); +} diff --git a/frontend/app/[locale]/resource-manage/components/resources/ModelCapacityCoverageWidget.tsx b/frontend/app/[locale]/resource-manage/components/resources/ModelCapacityCoverageWidget.tsx index 783c486673..9f0bdf09e4 100644 --- a/frontend/app/[locale]/resource-manage/components/resources/ModelCapacityCoverageWidget.tsx +++ b/frontend/app/[locale]/resource-manage/components/resources/ModelCapacityCoverageWidget.tsx @@ -1,66 +1,295 @@ "use client"; -import React from "react"; +import React, { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Card, Button, Skeleton, Flex } from "antd"; -import { AlertTriangle } from "lucide-react"; - -import { useCapacityCoverage } from "@/hooks/model/useCapacityCoverage"; +import { + Button, + Card, + Descriptions, + Flex, + message, + Modal, + Skeleton, + Table, + Tag, +} from "antd"; +import { Activity, ShieldCheck } from "lucide-react"; +import { useCapacityHealth } from "@/hooks/model/useCapacityHealth"; import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; import { useDeployment } from "@/components/providers/deploymentProvider"; +import { useQuery } from "@tanstack/react-query"; import { canManageModels } from "@/lib/auth"; +import { modelService } from "@/services/modelService"; +import type { + CapacityAdoptionPreview, + CapacityHealthItem, + CapacityHealthStatus, +} from "@/types/modelConfig"; -interface Props { - onViewAll?: () => void; -} +const COLORS: Record = { + healthy: "green", + review_due: "gold", + expired: "red", + estimated: "orange", + unconfigured: "red", + invalid: "red", + probe_degraded: "volcano", +}; -export default function ModelCapacityCoverageWidget({ onViewAll }: Props) { +export default function ModelCapacityCoverageWidget() { const { t } = useTranslation("common"); const { user } = useAuthorizationContext(); const { isSpeedMode } = useDeployment(); - const visibleToOperator = canManageModels(user?.role, isSpeedMode); - - const { coverage, isLoading } = useCapacityCoverage({ - enabled: visibleToOperator, + const visible = canManageModels(user?.role, isSpeedMode); + const { health, isLoading, invalidate } = useCapacityHealth({ + enabled: visible, }); - - if (!visibleToOperator) return null; - if (isLoading) { + const { data: catalogStatus } = useQuery({ + queryKey: ["modelCapacityCatalogStatus"], + queryFn: modelService.getCapacityCatalogStatus, + staleTime: 60_000, + enabled: visible, + }); + const [open, setOpen] = useState(false); + const [selected, setSelected] = useState(null); + const [preview, setPreview] = useState(null); + const [busy, setBusy] = useState(false); + const unhealthy = useMemo( + () => health?.items.filter((item) => item.status !== "healthy") || [], + [health] + ); + if (!visible) return null; + if (isLoading) return ( ); - } - if (!coverage || coverage.bareCount === 0) return null; + if (!health) return null; + const review = async (item: CapacityHealthItem) => { + setSelected(item); + setPreview(null); + setOpen(true); + if (!item.suggestionAvailable) return; + setBusy(true); + try { + setPreview( + await modelService.previewCapacityAdoption( + item.displayName, + item.matcherVersion + ) + ); + } catch (error: unknown) { + message.error( + (error instanceof Error ? error.message : "") || + t("modelConfig.capacityHealth.previewFailed") + ); + } finally { + setBusy(false); + } + }; + const adopt = async () => { + if (!selected || !preview) return; + setBusy(true); + try { + await modelService.adoptCapacity({ + displayName: selected.displayName, + expectedProfileVersion: preview.proposedProfileVersion, + expectedMatcherVersion: preview.matcherVersion, + }); + message.success(t("modelConfig.capacityHealth.applied")); + setOpen(false); + await invalidate(); + } catch (error: unknown) { + message.error( + (error instanceof Error ? error.message : "") || + t("modelConfig.capacityHealth.applyFailed") + ); + } finally { + setBusy(false); + } + }; return ( - - - - - - - {t("dashboard.capacityCoverage.title")} - - - {t("dashboard.capacityCoverage.subtitle", { - bareCount: coverage.bareCount, - total: coverage.totalLlmVlm, - })} - + <> + + + + {unhealthy.length ? ( + + ) : ( + + )} +
+
+ {t("modelConfig.capacityHealth.title")} +
+
+ {t("modelConfig.capacityHealth.summary", { + healthy: health.counts.healthy || 0, + total: health.total, + revision: health.catalogRevision, + })} +
+ {catalogStatus && ( +
+ {t("modelConfig.capacityHealth.catalogLifecycle", { + current: catalogStatus.lifecycleCounts.current || 0, + reviewDue: catalogStatus.lifecycleCounts.review_due || 0, + expired: catalogStatus.lifecycleCounts.expired || 0, + })} + {catalogStatus.candidate && ( + + {t("modelConfig.capacityHealth.catalogCandidate", { + revision: catalogStatus.candidate.revision, + added: catalogStatus.candidate.added.length, + changed: catalogStatus.candidate.changed.length, + removed: catalogStatus.candidate.removed.length, + })} + + )} +
+ )} +
+ {unhealthy.length > 0 && ( + + )}
- {onViewAll && ( - +
+ setOpen(false)} + okText={t("modelConfig.capacityHealth.applyReviewed")} + okButtonProps={{ disabled: !preview }} + confirmLoading={busy} + onOk={adopt} + > +
item.status)) + ).map((status) => ({ + text: t(`modelConfig.capacityHealth.statuses.${status}`), + value: status, + })), + onFilter: (value, item) => item.status === value, + render: (value: CapacityHealthStatus) => ( + + {t(`modelConfig.capacityHealth.statuses.${value}`)} + + ), + }, + { + title: t("modelConfig.capacityHealth.reason"), + dataIndex: "reasons", + render: (values: string[]) => + values + .map((value) => + t(`modelConfig.capacityHealth.reasons.${value}`) + ) + .join(", "), + }, + { + title: t("modelConfig.capacityHealth.action"), + render: (_: unknown, item: CapacityHealthItem) => ( + + ), + }, + ]} + /> + {selected && ( + + )} + {preview && ( +
({ + field, + ...diff, + }))} + columns={[ + { + title: t("modelConfig.capacityHealth.field"), + dataIndex: "field", + }, + { + title: t("modelConfig.capacityHealth.current"), + dataIndex: "currentValue", + render: (v) => String(v ?? "—"), + }, + { + title: t("modelConfig.capacityHealth.proposed"), + dataIndex: "proposedValue", + render: (v) => String(v ?? "—"), + }, + { + title: t("modelConfig.capacityHealth.protection"), + dataIndex: "blockedByManual", + render: (v) => + v ? ( + + {t("modelConfig.capacityHealth.manualProtected")} + + ) : ( + {t("modelConfig.capacityHealth.applicable")} + ), + }, + ]} + /> )} - - + + ); } diff --git a/frontend/app/[locale]/resource-manage/components/resources/ModelList.tsx b/frontend/app/[locale]/resource-manage/components/resources/ModelList.tsx index c735370b03..87b71d4a74 100644 --- a/frontend/app/[locale]/resource-manage/components/resources/ModelList.tsx +++ b/frontend/app/[locale]/resource-manage/components/resources/ModelList.tsx @@ -35,6 +35,7 @@ import { MODEL_TYPES } from "@/const/modelConfig"; import { ModelAddDialog } from "../../../models/components/model/ModelAddDialog"; import { ModelEditDialog } from "../../../models/components/model/ModelEditDialog"; import ModelCapacityCoverageWidget from "./ModelCapacityCoverageWidget"; +import ContextBudgetOperationsWidget from "./ContextBudgetOperationsWidget"; interface UnifiedModelRow extends ModelOption { request_count?: number; error_rate?: number; @@ -495,6 +496,7 @@ export default function ModelList({ tenantId }: { tenantId: string | null }) { return (
+
{processingMode}
)} + {budget && ( + <> +
+ Final request breakdown +
+ {Object.entries(budget.components) + .filter(([, value]) => value > 0) + .map(([name, value]) => ( +
+ + {name.replaceAll("_", " ")} + + {formatNumber(value)} +
+ ))} +
+ Count source + {budget.count_source} +
+ {budget.compression.attempted && ( +
+ Compaction saved + + {formatNumber(budget.compression.saved_tokens)} ( + {Math.round(budget.compression.ratio * 100)}%) + +
+ )} + {budget.recovery_state !== "not_needed" && ( +
+ Recovery + {budget.recovery_state} +
+ )} + + )} {isDefaultThreshold && (
* estimated limit
)} diff --git a/frontend/const/chatConfig.ts b/frontend/const/chatConfig.ts index a08b00aeaf..edb9caba05 100644 --- a/frontend/const/chatConfig.ts +++ b/frontend/const/chatConfig.ts @@ -1,4 +1,4 @@ - import { resourcesCustom } from "@/app/i18n"; +import { resourcesCustom } from "@/app/i18n"; // Chat related configuration export const chatConfig = { @@ -33,7 +33,11 @@ export const chatConfig = { // File limit configuration maxFileCount: 50, - maxFileSize: (Number((resourcesCustom?.zh?.custom as any)?.['FILE_UPLOAD_SIZE_LIMIT']) || 10) * 1024 * 1024, // Maximum 10MB - 100MB per file + maxFileSize: + (Number((resourcesCustom?.zh?.custom as any)?.["FILE_UPLOAD_SIZE_LIMIT"]) || + 10) * + 1024 * + 1024, // Maximum 10MB - 100MB per file // Supported image file extensions imageExtensions: ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp"], @@ -149,6 +153,7 @@ export const chatConfig = { PREPROCESS: "preprocess" as const, FILES: "files" as const, HISTORY_SUMMARY: "history_summary" as const, + CONTEXT_BUDGET: "context_budget" as const, }, // Content type constants for last content type tracking @@ -183,8 +188,7 @@ export const chatConfig = { // Type definitions for better type safety export type Opinion = - | (typeof chatConfig.opinion)[keyof typeof chatConfig.opinion] - | null; + (typeof chatConfig.opinion)[keyof typeof chatConfig.opinion] | null; export type MessageType = (typeof chatConfig.messageTypes)[keyof typeof chatConfig.messageTypes]; export type ContentType = diff --git a/frontend/hooks/model/useCapacityHealth.ts b/frontend/hooks/model/useCapacityHealth.ts new file mode 100644 index 0000000000..b139a28c20 --- /dev/null +++ b/frontend/hooks/model/useCapacityHealth.ts @@ -0,0 +1,18 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { modelService } from "@/services/modelService"; + +export function useCapacityHealth(options?: { enabled?: boolean }) { + const queryClient = useQueryClient(); + const query = useQuery({ + queryKey: ["modelCapacityHealth"], + queryFn: modelService.getCapacityHealth, + staleTime: 60_000, + enabled: options?.enabled ?? true, + }); + return { + ...query, + health: query.data, + invalidate: () => + queryClient.invalidateQueries({ queryKey: ["modelCapacityHealth"] }), + }; +} diff --git a/frontend/lib/chatMessageExtractor.ts b/frontend/lib/chatMessageExtractor.ts index fb61fa0d81..21a72cfb9d 100644 --- a/frontend/lib/chatMessageExtractor.ts +++ b/frontend/lib/chatMessageExtractor.ts @@ -302,7 +302,13 @@ export function extractAssistantMsgFromResponse( const currentStep = steps[steps.length - 1]; if (currentStep) { try { - currentStep.metrics = JSON.parse(msg.content); + const metrics = JSON.parse(msg.content); + currentStep.metrics = { + ...(currentStep.metrics || {}), + ...metrics, + context_budget: + currentStep.metrics?.context_budget || metrics.context_budget, + }; } catch { currentStep.metrics = null; } @@ -310,6 +316,48 @@ export function extractAssistantMsgFromResponse( break; } + case chatConfig.messageTypes.CONTEXT_BUDGET: { + const currentStep = steps[steps.length - 1]; + if (currentStep) { + try { + const contextBudget = JSON.parse(msg.content); + currentStep.metrics = { + ...(currentStep.metrics || { + step_number: contextBudget.step_number, + duration: 0, + step_input_tokens: null, + step_output_tokens: null, + total_output_tokens: 0, + estimated_context_tokens: contextBudget.final_tokens, + token_threshold: contextBudget.soft_budget, + hard_input_budget_tokens: contextBudget.hard_budget, + context_processing_mode: null, + output_finish_reason: null, + }), + context_budget: contextBudget, + }; + if ( + contextBudget?.compression?.attempted || + Number(contextBudget?.retry_ordinal || 0) > 0 || + !["not_needed", "not_attempted"].includes( + contextBudget?.recovery_state + ) + ) { + currentStep.contents.push({ + id: `context-budget-${dialog_msg.message_id}-${currentStep.contents.length}`, + type: chatConfig.messageTypes.CONTEXT_BUDGET, + content: msg.content, + expanded: true, + timestamp: Date.now(), + }); + } + } catch { + /* forward-compatible: ignore malformed optional event */ + } + } + break; + } + case chatConfig.messageTypes.HISTORY_SUMMARY: { const currentStep = getOrCreateCurrentStep(steps, "History Summary"); currentStep.contents.push({ diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 05ebc4cdc5..b460d7bc01 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -1351,6 +1351,65 @@ "modelConfig.capacityCoverage.warning": "{{bareCount}} of {{total}} LLM/VLM models are missing capacity — output token cap is not enforced.", "modelConfig.capacityCoverage.description": "{{suggestionCount}} have an approved capacity suggestion ready to apply. Click Manage, then click the warning icon on each affected row to repair.", "modelConfig.capacityCoverage.manage": "Manage", + "modelConfig.capacityHealth.title": "Model capacity health", + "modelConfig.capacityHealth.summary": "{{healthy}} of {{total}} models healthy · catalog {{revision}}", + "modelConfig.capacityHealth.catalogLifecycle": "Profiles: {{current}} current · {{reviewDue}} review due · {{expired}} expired", + "modelConfig.capacityHealth.catalogCandidate": "Staged {{revision}} (+{{added}} / ~{{changed}} / -{{removed}})", + "taskWindow.contextBudget.optimized": "Context optimized", + "taskWindow.contextBudget.savings": "{{raw}} → {{final}} tokens · saved {{saved}} ({{percent}}%)", + "taskWindow.contextBudget.reason": "Reason: {{reason}}", + "taskWindow.contextBudget.reasons.history_summary": "history summarized", + "taskWindow.contextBudget.reasons.history_incremental": "new history summarized", + "taskWindow.contextBudget.reasons.long_term_memory_selection": "long-term memory selected", + "taskWindow.contextBudget.reasons.representation_compaction": "compact representation selected", + "taskWindow.contextBudget.recovery": "Recovery: {{state}}", + "modelConfig.capacityHealth.review": "Review {{count}} issues", + "modelConfig.capacityHealth.model": "Model", + "modelConfig.capacityHealth.status": "Health", + "modelConfig.capacityHealth.reason": "Reason", + "modelConfig.capacityHealth.action": "Action", + "modelConfig.capacityHealth.reviewFix": "Review and fix", + "modelConfig.capacityHealth.profile": "Profile", + "modelConfig.capacityHealth.verifiedAt": "Evidence verified", + "modelConfig.capacityHealth.field": "Field", + "modelConfig.capacityHealth.current": "Current", + "modelConfig.capacityHealth.proposed": "Proposed", + "modelConfig.capacityHealth.protection": "Protection", + "modelConfig.capacityHealth.manualProtected": "Manual value protected", + "modelConfig.capacityHealth.applicable": "Will adopt", + "modelConfig.capacityHealth.applyReviewed": "Apply reviewed changes", + "modelConfig.capacityHealth.applied": "Capacity profile applied", + "modelConfig.capacityHealth.previewFailed": "Could not load a safe adoption preview", + "modelConfig.capacityHealth.applyFailed": "Capacity profile was not applied", + "modelConfig.capacityHealth.statuses.healthy": "Healthy", + "modelConfig.capacityHealth.statuses.review_due": "Review due", + "modelConfig.capacityHealth.statuses.expired": "Evidence expired", + "modelConfig.capacityHealth.statuses.estimated": "Estimated", + "modelConfig.capacityHealth.statuses.unconfigured": "Unconfigured", + "modelConfig.capacityHealth.statuses.invalid": "Invalid", + "modelConfig.capacityHealth.statuses.probe_degraded": "Count probe degraded", + "modelConfig.capacityHealth.reasons.capacity_verified": "Capacity verified", + "modelConfig.capacityHealth.reasons.required_capacity_missing": "Required capacity is missing", + "modelConfig.capacityHealth.reasons.catalog_evidence_expired": "Catalog evidence expired", + "modelConfig.capacityHealth.reasons.catalog_evidence_review_due": "Catalog evidence needs review", + "modelConfig.capacityHealth.reasons.token_count_probe_degraded": "Provider count probe needs attention", + "modelConfig.capacityHealth.reasons.capacity_or_counting_estimated": "Capacity or token counting is estimated", + "modelConfig.capacityHealth.reasons.context_window_invalid": "Context window is invalid", + "modelConfig.capacityHealth.reasons.max_output_invalid": "Maximum output is invalid", + "modelConfig.capacityHealth.reasons.max_input_invalid": "Maximum input is invalid", + "modelConfig.capacityHealth.reasons.output_not_below_context": "Maximum output must be below the context window", + "monitoring.contextBudget.title": "Context budget operations", + "monitoring.contextBudget.model": "Provider / model", + "monitoring.contextBudget.requests": "Requests", + "monitoring.contextBudget.overflow": "Overflow rate", + "monitoring.contextBudget.compaction": "Compacted", + "monitoring.contextBudget.reduction": "Avg. reduction", + "monitoring.contextBudget.estimateError": "Estimate error", + "monitoring.contextBudget.recovery": "Recovery success", + "chat.tokenUsage.finalRequest": "Final request", + "chat.tokenUsage.countSource": "Count source", + "chat.tokenUsage.compactionSaved": "Compaction saved", + "chat.tokenUsage.recovery": "Overflow recovery", "modelConfig.button.editCustomModel": "Edit or Delete Model", "modelConfig.button.checkConnectivity": "Check Model Connectivity", "modelConfig.button.sync": "Sync", diff --git a/frontend/public/locales/en/custom.json b/frontend/public/locales/en/custom.json index 6f92ebff53..5ccb1b07ad 100644 --- a/frontend/public/locales/en/custom.json +++ b/frontend/public/locales/en/custom.json @@ -4,4 +4,4 @@ "pageSubtitle": "One prompt, infinite possibilities", "pageDescription": "No orchestration, no complex drag-and-drop required. Integrate data, models, and tools into one intelligent hub.", "FILE_UPLOAD_SIZE_LIMIT": 10 -} +} \ No newline at end of file diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index a5f6aed608..004dad729f 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -1320,6 +1320,65 @@ "modelConfig.capacityCoverage.warning": "{{total}} 个 LLM/VLM 模型中有 {{bareCount}} 个未配置容量,输出 token 限额未启用。", "modelConfig.capacityCoverage.description": "其中 {{suggestionCount}} 个有已审核容量建议可一键应用。点击\"管理\"打开列表,逐行点击警告图标即可修复。", "modelConfig.capacityCoverage.manage": "管理", + "modelConfig.capacityHealth.title": "模型容量健康度", + "modelConfig.capacityHealth.summary": "{{total}} 个模型中 {{healthy}} 个健康 · 目录 {{revision}}", + "modelConfig.capacityHealth.catalogLifecycle": "配置档案:{{current}} 个当前有效 · {{reviewDue}} 个待复核 · {{expired}} 个已过期", + "modelConfig.capacityHealth.catalogCandidate": "已暂存 {{revision}}(+{{added}} / ~{{changed}} / -{{removed}})", + "taskWindow.contextBudget.optimized": "上下文已优化", + "taskWindow.contextBudget.savings": "{{raw}} → {{final}} 个令牌 · 节省 {{saved}}({{percent}}%)", + "taskWindow.contextBudget.reason": "原因:{{reason}}", + "taskWindow.contextBudget.reasons.history_summary": "已汇总历史会话", + "taskWindow.contextBudget.reasons.history_incremental": "已增量汇总新历史", + "taskWindow.contextBudget.reasons.long_term_memory_selection": "已筛选长期记忆", + "taskWindow.contextBudget.reasons.representation_compaction": "已选择紧凑表示", + "taskWindow.contextBudget.recovery": "恢复状态:{{state}}", + "modelConfig.capacityHealth.review": "检查 {{count}} 个问题", + "modelConfig.capacityHealth.model": "模型", + "modelConfig.capacityHealth.status": "健康状态", + "modelConfig.capacityHealth.reason": "原因", + "modelConfig.capacityHealth.action": "操作", + "modelConfig.capacityHealth.reviewFix": "检查并修复", + "modelConfig.capacityHealth.profile": "能力配置版本", + "modelConfig.capacityHealth.verifiedAt": "证据验证时间", + "modelConfig.capacityHealth.field": "字段", + "modelConfig.capacityHealth.current": "当前值", + "modelConfig.capacityHealth.proposed": "建议值", + "modelConfig.capacityHealth.protection": "保护状态", + "modelConfig.capacityHealth.manualProtected": "人工值受保护", + "modelConfig.capacityHealth.applicable": "将采用", + "modelConfig.capacityHealth.applyReviewed": "应用已检查的更改", + "modelConfig.capacityHealth.applied": "已采用容量配置", + "modelConfig.capacityHealth.previewFailed": "无法加载安全的采用预览", + "modelConfig.capacityHealth.applyFailed": "未采用容量配置", + "modelConfig.capacityHealth.statuses.healthy": "健康", + "modelConfig.capacityHealth.statuses.review_due": "需要复核", + "modelConfig.capacityHealth.statuses.expired": "证据已过期", + "modelConfig.capacityHealth.statuses.estimated": "估算", + "modelConfig.capacityHealth.statuses.unconfigured": "未配置", + "modelConfig.capacityHealth.statuses.invalid": "配置无效", + "modelConfig.capacityHealth.statuses.probe_degraded": "计数探测异常", + "modelConfig.capacityHealth.reasons.capacity_verified": "容量已验证", + "modelConfig.capacityHealth.reasons.required_capacity_missing": "缺少必要容量字段", + "modelConfig.capacityHealth.reasons.catalog_evidence_expired": "目录证据已过期", + "modelConfig.capacityHealth.reasons.catalog_evidence_review_due": "目录证据需要复核", + "modelConfig.capacityHealth.reasons.token_count_probe_degraded": "Provider 计数探测需要处理", + "modelConfig.capacityHealth.reasons.capacity_or_counting_estimated": "容量或 Token 计数为估算值", + "modelConfig.capacityHealth.reasons.context_window_invalid": "上下文窗口无效", + "modelConfig.capacityHealth.reasons.max_output_invalid": "最大输出无效", + "modelConfig.capacityHealth.reasons.max_input_invalid": "最大输入无效", + "modelConfig.capacityHealth.reasons.output_not_below_context": "最大输出必须小于上下文窗口", + "monitoring.contextBudget.title": "上下文预算运营指标", + "monitoring.contextBudget.model": "Provider / 模型", + "monitoring.contextBudget.requests": "请求数", + "monitoring.contextBudget.overflow": "溢出率", + "monitoring.contextBudget.compaction": "压缩占比", + "monitoring.contextBudget.reduction": "平均压缩率", + "monitoring.contextBudget.estimateError": "估算误差", + "monitoring.contextBudget.recovery": "恢复成功率", + "chat.tokenUsage.finalRequest": "最终请求", + "chat.tokenUsage.countSource": "计数来源", + "chat.tokenUsage.compactionSaved": "压缩节省", + "chat.tokenUsage.recovery": "溢出恢复", "modelConfig.button.editCustomModel": "修改或删除模型", "modelConfig.button.checkConnectivity": "检查模型连通性", "modelConfig.button.sync": "同步", diff --git a/frontend/public/locales/zh/custom.json b/frontend/public/locales/zh/custom.json index bd887c4f2a..6692862e47 100644 --- a/frontend/public/locales/zh/custom.json +++ b/frontend/public/locales/zh/custom.json @@ -4,4 +4,4 @@ "pageSubtitle": "一个提示词,无限种可能", "pageDescription": "无需编排,无需复杂拖拉拽,将数据、模型和工具整合到一个智能中心中。", "FILE_UPLOAD_SIZE_LIMIT": 10 -} +} \ No newline at end of file diff --git a/frontend/services/api.ts b/frontend/services/api.ts index d3f95aa722..56cd788e4a 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -275,6 +275,8 @@ export const API_ENDPOINTS = { verifyModelConfig: `${API_BASE_URL}/model/temporary_healthcheck`, suggestCapacity: `${API_BASE_URL}/model/suggest-capacity`, capacityCoverage: `${API_BASE_URL}/model/capacity-coverage`, + capacityHealth: `${API_BASE_URL}/model/capacity-health`, + capacityCatalogStatus: `${API_BASE_URL}/model/capacity-catalog-status`, capacityAdoptionPreview: `${API_BASE_URL}/model/capacity-adoption-preview`, capacityAdopt: `${API_BASE_URL}/model/capacity-adopt`, tokenCountProbe: `${API_BASE_URL}/model/token-count-probe`, @@ -729,6 +731,7 @@ export const API_ENDPOINTS = { }, monitoring: { models: `${API_BASE_URL}/monitoring/models`, + contextBudget: `${API_BASE_URL}/monitoring/context-budget`, status: `${API_BASE_URL}/monitoring/status`, }, notifications: { diff --git a/frontend/services/modelService.ts b/frontend/services/modelService.ts index 89323fc4a6..f86f740ece 100644 --- a/frontend/services/modelService.ts +++ b/frontend/services/modelService.ts @@ -10,6 +10,9 @@ import { ModelSource, CapacitySuggestion, CapacityCoverage, + CapacityHealth, + CapacityHealthItem, + CapacityCatalogStatus, CapacityAdoptionPreview, CapacityFieldMetadata, ModelIdentityMetadata, @@ -208,6 +211,56 @@ const mapCapacityCoverageFromApi = (coverage: any): CapacityCoverage => ({ })), }); +interface CapacityHealthApiItem { + model_id: number; + display_name: string; + model_name: string; + model_factory?: string | null; + model_type: CapacityHealthItem["modelType"]; + status: CapacityHealthItem["status"]; + reasons?: string[]; + action: CapacityHealthItem["action"]; + matcher_version: string; + profile_version?: string | null; + verified_at?: string | null; + review_at?: string | null; + expires_at?: string | null; + suggestion_available?: boolean; +} + +interface CapacityHealthApiResponse { + catalog_revision?: string; + generated_at?: string; + total?: number; + counts?: CapacityHealth["counts"]; + items?: CapacityHealthApiItem[]; +} + +const mapCapacityHealthFromApi = ( + data: CapacityHealthApiResponse +): CapacityHealth => ({ + catalogRevision: data?.catalog_revision || "unknown", + generatedAt: data?.generated_at || "", + total: data?.total || 0, + counts: data?.counts || {}, + items: (data?.items || []).map((item) => ({ + modelId: item.model_id, + displayName: item.display_name, + modelName: item.model_name, + modelFactory: item.model_factory, + modelType: item.model_type, + status: item.status, + reasons: item.reasons || [], + action: item.action, + matcherVersion: item.matcher_version, + profileVersion: item.profile_version, + verifiedAt: item.verified_at, + reviewAt: item.review_at, + expiresAt: item.expires_at, + suggestionAvailable: Boolean(item.suggestion_available), + })), +}); + type ModelConnectivityResult = { connectivity: boolean; modelName?: string; @@ -950,6 +1003,49 @@ export const modelService = { } }, + getCapacityHealth: async (): Promise => { + const response = await fetch(API_ENDPOINTS.model.capacityHealth, { + headers: getAuthHeaders(), + }); + const result = await response.json(); + if (response.status !== STATUS_CODES.SUCCESS || !result.data) { + throw new ModelError( + result.detail || "Failed to load capacity health", + response.status + ); + } + return mapCapacityHealthFromApi(result.data); + }, + + getCapacityCatalogStatus: async (): Promise => { + const response = await fetch(API_ENDPOINTS.model.capacityCatalogStatus, { + headers: getAuthHeaders(), + }); + const result = await response.json(); + if (response.status !== STATUS_CODES.SUCCESS || !result.data) { + throw new ModelError( + result.detail || "Failed to load capacity catalog status", + response.status + ); + } + const data = result.data; + return { + activeRevision: data.active_revision, + profileCount: data.profile_count || 0, + lifecycleCounts: data.lifecycle_counts || {}, + candidate: data.candidate + ? { + revision: data.candidate.revision, + sourceIdentity: data.candidate.source_identity, + stagedAt: data.candidate.staged_at, + added: data.candidate.added || [], + changed: data.candidate.changed || [], + removed: data.candidate.removed || [], + } + : null, + }; + }, + previewCapacityAdoption: async ( displayName: string, expectedMatcherVersion?: string, diff --git a/frontend/services/monitoringService.ts b/frontend/services/monitoringService.ts index 2e3bd16bbe..843ab32f9e 100644 --- a/frontend/services/monitoringService.ts +++ b/frontend/services/monitoringService.ts @@ -7,6 +7,7 @@ import type { ModelMonitoringItem, MonitoringFilter, MonitoringStatus, + ContextBudgetMonitoringItem, } from "@/types/monitoring"; function buildQueryString( @@ -53,4 +54,20 @@ export const monitoringService = { return []; } }, + + fetchContextBudget: async ( + timeRange = "24h" + ): Promise => { + try { + const response = await fetch( + `${API_ENDPOINTS.monitoring.contextBudget}?time_range=${encodeURIComponent(timeRange)}`, + { headers: getAuthHeaders() } + ); + const result = await response.json(); + return result.code === 0 && result.data ? result.data : []; + } catch (error) { + log.warn("Failed to fetch context budget metrics:", error); + return []; + } + }, }; diff --git a/frontend/types/chat.ts b/frontend/types/chat.ts index 63600d333b..494a14faa3 100644 --- a/frontend/types/chat.ts +++ b/frontend/types/chat.ts @@ -16,6 +16,41 @@ export interface TokenMetrics { hard_input_budget_tokens: number | null; context_processing_mode: "adaptive_compact" | "passthrough" | null; output_finish_reason: string | null; + context_budget?: ContextBudgetMetrics; +} + +export interface ContextBudgetMetrics { + schema_version: 1; + purpose: string; + step_number: number; + raw_tokens: number; + final_tokens: number; + soft_budget: number; + hard_budget: number; + hard_count: number; + components: { + message_text: number; + message_framing: number; + tools: number; + media: number; + reasoning: number; + other_semantic: number; + }; + count_source: string; + compression: { + attempted: boolean; + saved_tokens: number; + ratio: number; + fallback_compaction: boolean; + reasons: Array< + | "history_summary" + | "history_incremental" + | "long_term_memory_selection" + | "representation_compaction" + >; + }; + recovery_state: string; + retry_ordinal: number; } // Step related types @@ -45,6 +80,7 @@ export interface StepContent { | typeof chatConfig.messageTypes.PREPROCESS | typeof chatConfig.messageTypes.VERIFICATION | typeof chatConfig.messageTypes.HISTORY_SUMMARY + | typeof chatConfig.messageTypes.CONTEXT_BUDGET | typeof chatConfig.messageTypes.MAX_STEPS_REACHED; content: string; expanded: boolean; diff --git a/frontend/types/modelConfig.ts b/frontend/types/modelConfig.ts index e11066b471..e0b297c1de 100644 --- a/frontend/types/modelConfig.ts +++ b/frontend/types/modelConfig.ts @@ -235,6 +235,59 @@ export interface CapacityCoverage { bareModels: CapacityCoverageBareModel[]; } +export type CapacityHealthStatus = + | "healthy" + | "review_due" + | "expired" + | "estimated" + | "unconfigured" + | "invalid" + | "probe_degraded"; + +export interface CapacityHealthItem { + modelId: number; + displayName: string; + modelName: string; + modelFactory?: string | null; + modelType: "llm" | "vlm" | "vlm2" | "vlm3"; + status: CapacityHealthStatus; + reasons: string[]; + action: + "none" | "edit" | "review_profile" | "review_evidence" | "retry_probe"; + matcherVersion: string; + profileVersion?: string | null; + verifiedAt?: string | null; + reviewAt?: string | null; + expiresAt?: string | null; + suggestionAvailable: boolean; +} + +export interface CapacityHealth { + catalogRevision: string; + generatedAt: string; + total: number; + counts: Partial>; + items: CapacityHealthItem[]; +} + +export interface CapacityCatalogCandidate { + revision: string; + sourceIdentity: string; + stagedAt: string; + added: string[]; + changed: string[]; + removed: string[]; +} + +export interface CapacityCatalogStatus { + activeRevision: string; + profileCount: number; + lifecycleCounts: Partial< + Record<"current" | "review_due" | "expired", number> + >; + candidate?: CapacityCatalogCandidate | null; +} + // Model configuration interface export interface ModelConfig { llm: SingleModelConfig; diff --git a/frontend/types/monitoring.ts b/frontend/types/monitoring.ts index a4936ea5bb..0a79c882ac 100644 --- a/frontend/types/monitoring.ts +++ b/frontend/types/monitoring.ts @@ -24,3 +24,20 @@ export interface MonitoringStatus { dashboard_port?: string | number | null; dashboard_path?: string | null; } + +export interface ContextBudgetMonitoringItem { + provider_protocol: string; + model_name: string; + capability_profile_version: string; + request_count: number; + overflow_count: number; + overflow_rate: number | null; + compacted_count: number; + compaction_incidence: number | null; + avg_compression_ratio: number | null; + estimate_sample_count: number; + mean_absolute_estimate_error: number | null; + recovery_attempt_count: number; + recovery_success_count: number; + recovery_success_rate: number | null; +} diff --git a/sdk/nexent/core/agents/context_budget_event.py b/sdk/nexent/core/agents/context_budget_event.py new file mode 100644 index 0000000000..eb6320625f --- /dev/null +++ b/sdk/nexent/core/agents/context_budget_event.py @@ -0,0 +1,54 @@ +"""Build the content-free P3 conversation budget event.""" + +from __future__ import annotations + +from typing import Any + + +_COMPRESSION_REASON_BY_CALL_TYPE = { + "history_summary": "history_summary", + "history_incremental": "history_incremental", + "long_term_memory_block_selection": "long_term_memory_selection", +} + + +def _compression_reasons(context_evidence: Any) -> list[str]: + """Project internal compression records onto a stable, content-free enum.""" + reasons: list[str] = [] + for record in getattr(context_evidence, "compression_records", ()) or (): + call_type = getattr(record, "call_type", None) + if call_type is None and isinstance(record, dict): + call_type = record.get("call_type") + reason = _COMPRESSION_REASON_BY_CALL_TYPE.get(call_type) + if reason is not None and reason not in reasons: + reasons.append(reason) + if bool(getattr(context_evidence, "fallback_compaction_used", False)): + reasons.append("representation_compaction") + return reasons + + +def build_context_budget_event(preflight: Any, context_evidence: Any, *, step_number: int, recovery_state: str = "not_needed") -> dict[str, Any]: + components = preflight.components + raw_context = int(getattr(context_evidence, "raw_token_estimate", 0) or 0) + final_context = int(getattr(context_evidence, "final_token_estimate", 0) or 0) + saved = max(raw_context - final_context, 0) + return { + "schema_version": 1, "purpose": getattr(context_evidence, "purpose", "step"), + "step_number": int(step_number), "raw_tokens": raw_context, "final_tokens": final_context, + "soft_budget": int(preflight.soft_budget), "hard_budget": int(preflight.hard_budget), + "hard_count": int(preflight.hard_count), + "components": { + "message_text": int(components.message_text), "message_framing": int(components.message_framing), + "tools": int(components.tools), "media": int(components.media), + "reasoning": int(components.reasoning), "other_semantic": int(components.other_semantic), + }, + "count_source": getattr(preflight.count_source, "value", str(preflight.count_source)), + "compression": { + "attempted": bool(getattr(context_evidence, "compression_attempted", False)), + "saved_tokens": saved, "ratio": saved / raw_context if raw_context else 0.0, + "fallback_compaction": bool(getattr(context_evidence, "fallback_compaction_used", False)), + "reasons": _compression_reasons(context_evidence), + }, + "recovery_state": recovery_state, "request_fingerprint": preflight.request_fingerprint, + "budget_fingerprint": preflight.identity_fingerprint, "retry_ordinal": int(preflight.retry_ordinal), + } diff --git a/sdk/nexent/core/agents/core_agent.py b/sdk/nexent/core/agents/core_agent.py index 7102454c00..67bd5a862b 100644 --- a/sdk/nexent/core/agents/core_agent.py +++ b/sdk/nexent/core/agents/core_agent.py @@ -792,6 +792,7 @@ def _step_stream(self, memory_step: ActionStep) -> Generator[Any]: self._ensure_context_within_hard_budget(final_context) input_messages = final_context.messages self.model.last_context_evidence = final_context.evidence + self.model.context_budget_step_number = self.step_number chars_per_token = self.context_runtime.chars_per_token # Baseline for the per-step compression ratio. ``final_context.messages`` # is already the compressed payload, so use the ContextManager's raw @@ -1510,6 +1511,7 @@ def _handle_max_steps_reached(self, task: str) -> Any: self._ensure_context_within_hard_budget(final_context) messages = final_context.messages self.model.last_context_evidence = final_context.evidence + self.model.context_budget_step_number = self.step_number # Create the final memory step with error final_memory_step = ActionStep( diff --git a/sdk/nexent/core/models/openai_llm.py b/sdk/nexent/core/models/openai_llm.py index 3604708b7f..e3dedadb0c 100644 --- a/sdk/nexent/core/models/openai_llm.py +++ b/sdk/nexent/core/models/openai_llm.py @@ -129,6 +129,8 @@ def __init__(self, observer: MessageObserver = MessageObserver, temperature=0.2, self.last_cached_input_token_count = 0 self.last_response_diagnostics = None self.last_final_request_preflight: Optional[FinalRequestPreflight] = None + self.last_recovery_state = "not_needed" + self.context_budget_step_number = 0 self._final_request_meter = FinalRequestMeter() self.safe_input_budget_snapshot = safe_input_budget_snapshot self.capacity_snapshot = capacity_snapshot @@ -339,7 +341,7 @@ def __call__(self, messages: List[Dict[str, Any]], stop_sequences: Optional[List **{ "llm.prompt_cache.stable_prefix_fingerprint": getattr( context_evidence, "stable_prefix_fingerprint", None - ), + ) or "", "llm.prompt_cache.prefix_change_reasons": json.dumps( list(getattr(context_evidence, "prefix_change_reasons", ())), ensure_ascii=False, @@ -724,6 +726,8 @@ def _dispatch_chat_completion( identity to catch a stale or cross-model W2 snapshot before the provider call. """ + from ...monitor.monitoring import set_monitoring_final_request_evidence + set_monitoring_final_request_evidence(None) snapshot = self._coerce_safe_input_budget_snapshot(safe_input_budget_snapshot) if snapshot is not None: self._verify_w1_w2_consistency( @@ -940,6 +944,14 @@ def _build_final_request_identity( ) def _record_recovery_state(self, state: str) -> None: + self.last_recovery_state = state + from ...monitor.monitoring import update_monitoring_final_request_evidence + update_monitoring_final_request_evidence( + recovery_state=state, + provider_overflow=state in {"retrying", "retry_exhausted", "retry_unsafe", "retry_unsafe_after_response"}, + recovery_attempted=state in {"retrying", "recovered", "retry_exhausted"}, + recovery_succeeded=state == "recovered", + ) self._monitoring.set_span_attributes( **{"context.final_request.recovery_state": state} ) @@ -950,7 +962,41 @@ def _record_final_request_preflight( *, recovery_state: str, ) -> None: + self.last_recovery_state = recovery_state components = preflight.components + from ...monitor.monitoring import set_monitoring_final_request_evidence + context_evidence = getattr(self, "last_context_evidence", None) + set_monitoring_final_request_evidence({ + "schema_version": 1, + "provider_protocol": getattr(self, "model_factory", None), + "count_source": getattr(preflight.count_source, "value", str(preflight.count_source)), + "raw_estimate_tokens": preflight.raw_estimate, + "hard_count_tokens": preflight.hard_count, + "context_raw_tokens": int(getattr(context_evidence, "raw_token_estimate", 0) or 0), + "context_final_tokens": int(getattr(context_evidence, "final_token_estimate", 0) or 0), + "compression_attempted": bool(getattr(context_evidence, "compression_attempted", False)), + "request_fingerprint": preflight.request_fingerprint, + "retry_ordinal": preflight.retry_ordinal, + "recovery_state": recovery_state, + "provider_overflow": recovery_state in {"retrying", "retry_exhausted", "retry_unsafe"}, + "recovery_attempted": preflight.retry_ordinal > 0, + "recovery_succeeded": recovery_state == "recovered", + }) + context_budget_type = getattr(ProcessType, "CONTEXT_BUDGET", None) + if self.observer is not None and context_budget_type is not None: + # Keep Agent-layer event projection optional for lightweight SDK + # integrations that import the model package without Agent modules. + from ..agents.context_budget_event import build_context_budget_event + + budget_event = build_context_budget_event( + preflight, context_evidence, + step_number=self.context_budget_step_number, + recovery_state=recovery_state, + ) + self.observer.add_message( + "", context_budget_type, + json.dumps(budget_event, ensure_ascii=False), + ) self._monitoring.set_span_attributes( **{ "context.final_request.fingerprint": preflight.request_fingerprint, diff --git a/sdk/nexent/core/utils/observer.py b/sdk/nexent/core/utils/observer.py index 3cb55b8b2e..5b47af4041 100644 --- a/sdk/nexent/core/utils/observer.py +++ b/sdk/nexent/core/utils/observer.py @@ -41,6 +41,7 @@ class ProcessType(Enum): OTHER = "other" # temporary other fields TOKEN_COUNT = "token_count" # record the number of tokens used in each step HISTORY_SUMMARY = "history_summary" # newly-created context compression checkpoint + CONTEXT_BUDGET = "context_budget" # content-free P3 final request budget snapshot SEARCH_CONTENT = "search_content" # search content in tool PICTURE_WEB = "picture_web" # record the image after联网搜索 @@ -230,6 +231,7 @@ def _init_message_transformers(self): ProcessType.SEARCH_CONTENT: default_transformer, ProcessType.TOKEN_COUNT: TokenCountTransformer(), ProcessType.HISTORY_SUMMARY: default_transformer, + ProcessType.CONTEXT_BUDGET: default_transformer, ProcessType.PICTURE_WEB: default_transformer, ProcessType.AGENT_FINISH: default_transformer, ProcessType.CARD: default_transformer, diff --git a/sdk/nexent/monitor/monitoring.py b/sdk/nexent/monitor/monitoring.py index 5db137c073..d5d1c6d4ed 100644 --- a/sdk/nexent/monitor/monitoring.py +++ b/sdk/nexent/monitor/monitoring.py @@ -76,6 +76,19 @@ "_monitoring_capacity_snapshot", default=None) _monitoring_safe_input_budget_snapshot: ContextVar[Optional[Dict[str, Any]]] = ContextVar( "_monitoring_safe_input_budget_snapshot", default=None) +_monitoring_final_request_evidence: ContextVar[Optional[Dict[str, Any]]] = ContextVar( + "_monitoring_final_request_evidence", default=None) + + +def set_monitoring_final_request_evidence(evidence: Optional[Dict[str, Any]]) -> None: + """Set allowlisted P3 evidence for the current physical request.""" + _monitoring_final_request_evidence.set(dict(evidence or {})) + + +def update_monitoring_final_request_evidence(**values: Any) -> None: + current = dict(_monitoring_final_request_evidence.get() or {}) + current.update({key: value for key, value in values.items() if value is not None}) + _monitoring_final_request_evidence.set(current) def set_monitoring_context( @@ -2108,6 +2121,21 @@ def _enrich_record_with_safe_input_budget_snapshot(record: Dict[str, Any]) -> No record.update(budget_fields) +def _enrich_record_with_final_request_evidence(record: Dict[str, Any]) -> None: + evidence = dict(_monitoring_final_request_evidence.get() or {}) + if not evidence: + return + prompt_usage = record.get("input_tokens") + if isinstance(prompt_usage, int) and prompt_usage > 0: + evidence["provider_prompt_usage_tokens"] = prompt_usage + error_text = str(record.get("error_message") or "").lower() + if any(marker in error_text for marker in ( + "context_length_exceeded", "maximum context length", "input tokens exceed", + )): + evidence["provider_overflow"] = True + record["context_budget_evidence"] = evidence + + def record_model_call( model_type: str, model_name: str, @@ -2192,6 +2220,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): _enrich_record_with_capacity_snapshot(record) _enrich_record_with_safe_input_budget_snapshot(record) + _enrich_record_with_final_request_evidence(record) buffer = get_monitoring_buffer() if buffer and buffer.is_enabled: @@ -2423,6 +2452,7 @@ def _enqueue_client_monitoring_record( _enrich_record_with_capacity_snapshot(record) _enrich_record_with_safe_input_budget_snapshot(record) + _enrich_record_with_final_request_evidence(record) buffer.add_record(record) except Exception: @@ -2511,6 +2541,7 @@ def _enrich_record_with_context(record, tracker, kwargs): _enrich_record_with_capacity_snapshot(record) _enrich_record_with_safe_input_budget_snapshot(record) + _enrich_record_with_final_request_evidence(record) return tenant_id diff --git a/test/backend/agents/test_create_agent_info.py b/test/backend/agents/test_create_agent_info.py index 1683f39a88..42c27b74e8 100644 --- a/test/backend/agents/test_create_agent_info.py +++ b/test/backend/agents/test_create_agent_info.py @@ -599,6 +599,34 @@ def test_resolve_input_budget_returns_monitoring_dict_then_resolver_snapshot(sel assert isinstance(resolved_capacity_snapshot, MockModelCapacitySnapshot) assert safe_budget_snapshot["model_name"] == resolved_capacity_snapshot.model_name + def test_persisted_profile_identity_overrides_compatibility_factory(self): + profile = types.SimpleNamespace( + capability_profile_version="dashscope/qwen3.7-plus@1" + ) + snapshot = MockModelCapacitySnapshot( + model_name="qwen3.7-plus", + capability_profile_version="dashscope/qwen3.7-plus@1", + ) + with patch.object( + create_agent_info_module, + "CAPABILITY_CATALOG", + {("dashscope", "qwen3.7-plus"): profile}, + ), patch.object( + create_agent_info_module, + "resolve_capacity", + return_value=snapshot, + ) as resolver: + _resolve_input_budget( + { + "model_factory": "OpenAI-API-Compatible", + "model_name": "qwen3.7-plus", + "capability_profile_version": "dashscope/qwen3.7-plus@1", + } + ) + + assert resolver.call_args.kwargs["provider"] == "dashscope" + assert resolver.call_args.kwargs["model_id"] == "qwen3.7-plus" + class TestGetSkillsForTemplate: """Tests for the _get_skills_for_template function""" diff --git a/test/backend/app/test_model_managment_app.py b/test/backend/app/test_model_managment_app.py index 007572c72c..cc85cd1d42 100644 --- a/test/backend/app/test_model_managment_app.py +++ b/test/backend/app/test_model_managment_app.py @@ -246,6 +246,7 @@ async def test_suggest_capacity_bad_request(client, auth_header, user_credential @pytest.mark.asyncio async def test_capacity_adoption_preview_success(client, auth_header, user_credentials, mocker): mocker.patch('backend.apps.model_managment_app.get_current_user_id', return_value=user_credentials) + mocker.patch('backend.apps.model_managment_app.get_user_tenant_by_user_id', return_value={"user_role": "ADMIN"}) preview = mocker.patch( 'backend.apps.model_managment_app.preview_capacity_adoption_for_tenant', return_value={"matcher_version": "1.0.0", "fields": {}}, @@ -265,6 +266,7 @@ async def test_capacity_adoption_preview_success(client, auth_header, user_crede @pytest.mark.asyncio async def test_capacity_adopt_success(client, auth_header, user_credentials, mocker): mocker.patch('backend.apps.model_managment_app.get_current_user_id', return_value=user_credentials) + mocker.patch('backend.apps.model_managment_app.get_user_tenant_by_user_id', return_value={"user_role": "ADMIN"}) adopt = mocker.patch( 'backend.apps.model_managment_app.adopt_capacity_for_tenant', return_value={"updated_fields": ["context_window_tokens"]}, @@ -295,6 +297,7 @@ async def test_capacity_adopt_success(client, auth_header, user_credentials, moc @pytest.mark.asyncio async def test_token_count_probe_success_is_sanitized(client, auth_header, user_credentials, mocker): mocker.patch('backend.apps.model_managment_app.get_current_user_id', return_value=user_credentials) + mocker.patch('backend.apps.model_managment_app.get_user_tenant_by_user_id', return_value={"user_role": "ADMIN"}) probe = mocker.patch( 'backend.apps.model_managment_app.probe_token_count_for_tenant', return_value={"schema_version": 1, "status": "unsupported", "reason": "unsupported_endpoint"}, @@ -329,6 +332,30 @@ async def test_p1_governance_actions_require_authorization(client, path, payload assert response.status_code == HTTPStatus.UNAUTHORIZED +@pytest.mark.asyncio +@pytest.mark.parametrize( + "path,payload", + [ + ("/model/capacity-adoption-preview", {"display_name": "Qwen"}), + ("/model/capacity-adopt", {"display_name": "Qwen", "expected_profile_version": "v1"}), + ("/model/token-count-probe", {"display_name": "Qwen"}), + ], +) +async def test_p3_governance_actions_require_model_manager_role( + client, auth_header, user_credentials, path, payload, mocker +): + mocker.patch( + 'backend.apps.model_managment_app.get_current_user_id', + return_value=user_credentials, + ) + mocker.patch( + 'backend.apps.model_managment_app.get_user_tenant_by_user_id', + return_value={"user_role": "USER"}, + ) + response = await client.post(path, json=payload, headers=auth_header) + assert response.status_code == HTTPStatus.FORBIDDEN + + @pytest.mark.asyncio async def test_p1_missing_token_domain_error_maps_to_401(client, mocker): from consts.exceptions import UnauthorizedError diff --git a/test/backend/app/test_monitoring_app.py b/test/backend/app/test_monitoring_app.py index ff82a9e2ac..f202f3fe8a 100644 --- a/test/backend/app/test_monitoring_app.py +++ b/test/backend/app/test_monitoring_app.py @@ -88,6 +88,55 @@ def test_return_format(self, mock_session_fn): assert isinstance(record["total_tokens"], int) +class TestContextBudgetMetrics: + @patch("apps.monitoring_app.get_monitoring_db_session") + def test_rates_and_null_denominators(self, mock_session_fn): + from apps.monitoring_app import _query_context_budget_metrics_from_db + + row = MagicMock() + row.provider_protocol = "dashscope" + row.model_name = "qwen3.7-plus" + row.capability_profile_version = "dashscope/qwen3.7-plus@1" + row.request_count = 4 + row.overflow_count = 1 + row.compacted_count = 2 + row.avg_compression_ratio = 0.25 + row.estimate_sample_count = 4 + row.mean_absolute_estimate_error = 0.08 + row.recovery_attempt_count = 1 + row.recovery_success_count = 1 + session = MagicMock() + mock_session_fn.return_value.__enter__ = MagicMock(return_value=session) + mock_session_fn.return_value.__exit__ = MagicMock(return_value=None) + session.execute.return_value.fetchall.return_value = [row] + + result = _query_context_budget_metrics_from_db("24h", tenant_id="tenant-a")[0] + + assert result["overflow_rate"] == 0.25 + assert result["compaction_incidence"] == 0.5 + assert result["recovery_success_rate"] == 1.0 + sql, params = session.execute.call_args.args + assert "tenant_id = :tenant_id" in str(sql) + assert "compression_attempted')::boolean" in str(sql) + assert params == {"tenant_id": "tenant-a"} + + @patch("apps.monitoring_app.get_monitoring_db_session") + def test_non_applicable_recovery_rate_is_null(self, mock_session_fn): + from apps.monitoring_app import _query_context_budget_metrics_from_db + + row = MagicMock(provider_protocol="test", model_name="m", capability_profile_version="unknown") + row.request_count = 1 + row.overflow_count = row.compacted_count = row.estimate_sample_count = 0 + row.avg_compression_ratio = row.mean_absolute_estimate_error = None + row.recovery_attempt_count = row.recovery_success_count = 0 + session = MagicMock() + mock_session_fn.return_value.__enter__ = MagicMock(return_value=session) + mock_session_fn.return_value.__exit__ = MagicMock(return_value=None) + session.execute.return_value.fetchall.return_value = [row] + result = _query_context_budget_metrics_from_db("7d", tenant_id="t")[0] + assert result["recovery_success_rate"] is None + + class TestListModelsEndpoint: """Verify list_models_endpoint does not accept model_type parameter.""" diff --git a/test/backend/services/test_model_capacity_catalog_service.py b/test/backend/services/test_model_capacity_catalog_service.py new file mode 100644 index 0000000000..0679ad342c --- /dev/null +++ b/test/backend/services/test_model_capacity_catalog_service.py @@ -0,0 +1,48 @@ +import pytest + +from consts.capability_profiles import CATALOG_REVISION +from services.model_capacity_catalog_service import ( + catalog_status, + refresh_catalog_candidate, + stage_trusted_candidate, +) + + +def valid_document(): + return { + "revision": "2099-01-01.1", + "profiles": { + "test/model@1": { + "provider": "test", "model_name": "model", + "context_window_tokens": 1000, "max_output_tokens": 100, + "verified_at": "2098-12-31T00:00:00Z", "evidence": ["signed-evidence-id"], + } + }, + } + + +def test_untrusted_candidate_is_rejected(): + with pytest.raises(ValueError, match="catalog_source_untrusted"): + stage_trusted_candidate(valid_document(), source_identity="official", signature_verified=False) + + +@pytest.mark.parametrize("mutation", ["missing_evidence", "invalid_capacity", "same_revision"]) +def test_invalid_candidate_is_not_staged(mutation): + doc = valid_document() + if mutation == "missing_evidence": doc["profiles"]["test/model@1"]["evidence"] = [] + if mutation == "invalid_capacity": doc["profiles"]["test/model@1"]["max_output_tokens"] = 1000 + if mutation == "same_revision": doc["revision"] = CATALOG_REVISION + with pytest.raises(ValueError): + stage_trusted_candidate(doc, source_identity="official", signature_verified=True) + + +def test_refresh_stages_diff_without_changing_active_catalog(): + before = catalog_status()["active_revision"] + candidate = refresh_catalog_candidate( + valid_document, source_identity="nexent-official", + verifier=lambda document: document["revision"] == "2099-01-01.1", + ) + after = catalog_status() + assert candidate.added == ("test/model@1",) + assert after["candidate"]["revision"] == "2099-01-01.1" + assert after["active_revision"] == before == CATALOG_REVISION diff --git a/test/backend/services/test_model_capacity_health_service.py b/test/backend/services/test_model_capacity_health_service.py new file mode 100644 index 0000000000..14f96baac6 --- /dev/null +++ b/test/backend/services/test_model_capacity_health_service.py @@ -0,0 +1,95 @@ +from datetime import datetime, timezone + +import pytest + +from services.model_capacity_health_service import ( + catalog_freshness, + classify_capacity_health, +) + + +NOW = datetime(2026, 8, 24, tzinfo=timezone.utc) + + +@pytest.mark.parametrize( + ("verified_at", "expected"), + [ + ("2026-03-28T00:00:00Z", "current"), + ("2026-03-27T00:00:00Z", "review_due"), + ("2026-02-25T00:00:00Z", "expired"), + (None, "expired"), + ("bad", "expired"), + ], +) +def test_catalog_freshness_boundaries(verified_at, expected): + assert catalog_freshness(verified_at, now=NOW)[0] == expected + + +def base_record(**updates): + record = { + "context_window_tokens": 1_000_000, + "max_output_tokens": 131_072, + "max_input_tokens": 991_808, + "tokenizer_family": "qwen", + "capacity_source": "profile", + "capability_profile_version": "dashscope/qwen3.7-plus@1", + "token_count_probe_metadata": {"status": "supported"}, + } + record.update(updates) + return record + + +def classify(record, verified_at="2026-08-24T00:00:00Z", auto=True): + return classify_capacity_health( + record, + match={"match_kind": "exact", "auto_applicable": auto}, + profile_verified_at=verified_at, + now=NOW, + ) + + +def test_healthy_verified_model(): + result = classify(base_record()) + assert result["status"] == "healthy" + assert result["action"] == "none" + + +def test_supported_provider_count_is_healthy_without_local_tokenizer(): + result = classify(base_record(tokenizer_family=None)) + assert result["status"] == "healthy" + assert result["reasons"] == ["capacity_verified"] + + +def test_invalid_has_highest_precedence(): + result = classify(base_record(max_output_tokens=1_000_000), verified_at="2020-01-01T00:00:00Z") + assert result["status"] == "invalid" + assert result["reasons"] == ["output_not_below_context"] + + +def test_unconfigured_offers_review_only_for_verified_match(): + result = classify(base_record(context_window_tokens=None)) + assert result["status"] == "unconfigured" + assert result["action"] == "review_profile" + assert classify(base_record(context_window_tokens=None), auto=False)["action"] == "edit" + + +def test_expired_precedes_degraded_probe(): + result = classify( + base_record(token_count_probe_metadata={"status": "failed"}), + verified_at="2026-02-25T00:00:00Z", + ) + assert result["status"] == "expired" + + +def test_probe_and_estimated_states_are_actionable(): + degraded = classify(base_record(token_count_probe_metadata={"status": "temporary_failure"})) + estimated = classify(base_record(capacity_source="legacy")) + assert (degraded["status"], degraded["action"]) == ("probe_degraded", "retry_probe") + assert (estimated["status"], estimated["action"]) == ("estimated", "review_profile") + + +def test_review_due_is_non_destructive_health_state(): + result = classify(base_record(), verified_at="2026-03-27T00:00:00Z") + assert result["status"] == "review_due" + assert result["action"] == "review_profile" + assert result["profile_version"] == base_record()["capability_profile_version"] diff --git a/test/deploy/test_context_budget_p3_migration.py b/test/deploy/test_context_budget_p3_migration.py new file mode 100644 index 0000000000..78c25b7ccd --- /dev/null +++ b/test/deploy/test_context_budget_p3_migration.py @@ -0,0 +1,16 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +MIGRATION = ROOT / "deploy/sql/migrations/v2.5.0_0824_context_budget_p3_observability.sql" + + +def test_ac_p3_006_observability_migration_is_nullable_idempotent_and_content_free(): + sql = MIGRATION.read_text(encoding="utf-8").lower() + + assert "add column if not exists context_budget_evidence jsonb default null" in sql + assert "update nexent.model_monitoring_record_t" not in sql + for content_field in ("api_key", "prompt", "messages", "tool_arguments", "endpoint"): + assert content_field not in sql + assert "begin;" in sql + assert "commit;" in sql diff --git a/test/sdk/core/agents/test_context_budget_event.py b/test/sdk/core/agents/test_context_budget_event.py new file mode 100644 index 0000000000..c1ef8c8e81 --- /dev/null +++ b/test/sdk/core/agents/test_context_budget_event.py @@ -0,0 +1,76 @@ +import json +from types import SimpleNamespace + +from nexent.core.agents.context_budget_event import build_context_budget_event +from nexent.core.agents.summary_cache import CompressionCallRecord + + +def test_context_budget_event_is_content_free_and_totals_savings(): + preflight = SimpleNamespace( + components=SimpleNamespace(message_text=40, message_framing=5, tools=7, media=0, reasoning=3, other_semantic=2), + count_source=SimpleNamespace(value="provider"), soft_budget=90, hard_budget=100, + hard_count=57, request_fingerprint="request-hash", identity_fingerprint="budget-hash", retry_ordinal=1, + ) + evidence = SimpleNamespace( + purpose="step", raw_token_estimate=80, final_token_estimate=50, + compression_attempted=True, fallback_compaction_used=False, + compression_records=(CompressionCallRecord(call_type="history_summary"),), + ) + event = build_context_budget_event(preflight, evidence, step_number=2, recovery_state="recovered") + assert event["compression"] == { + "attempted": True, + "saved_tokens": 30, + "ratio": 0.375, + "fallback_compaction": False, + "reasons": ["history_summary"], + } + assert sum(event["components"].values()) == 57 + assert event["count_source"] == "provider" + serialized = json.dumps(event) + for secret in ("messages", "prompt", "api_key", "tool_arguments", "endpoint"): + assert secret not in serialized + + +def test_context_budget_event_handles_missing_context_evidence(): + preflight = SimpleNamespace( + components=SimpleNamespace(message_text=1, message_framing=1, tools=0, media=0, reasoning=0, other_semantic=0), + count_source="estimator", soft_budget=9, hard_budget=10, hard_count=2, + request_fingerprint="r", identity_fingerprint="b", retry_ordinal=0, + ) + event = build_context_budget_event(preflight, None, step_number=1) + assert event["raw_tokens"] == event["final_tokens"] == 0 + assert event["compression"]["ratio"] == 0 + + +def test_context_budget_event_allowlists_compression_reasons(): + preflight = SimpleNamespace( + components=SimpleNamespace(message_text=1, message_framing=1, tools=0, media=0, reasoning=0, other_semantic=0), + count_source="estimator", soft_budget=9, hard_budget=10, hard_count=2, + request_fingerprint="r", identity_fingerprint="b", retry_ordinal=0, + ) + evidence = SimpleNamespace( + raw_token_estimate=8, + final_token_estimate=4, + compression_attempted=True, + fallback_compaction_used=True, + compression_records=( + CompressionCallRecord(call_type="history_incremental"), + CompressionCallRecord(call_type="history_incremental"), + {"call_type": "long_term_memory_block_selection"}, + CompressionCallRecord( + call_type="attacker-controlled-value", + details={"error": "prompt and credential text must not escape"}, + ), + ), + ) + + event = build_context_budget_event(preflight, evidence, step_number=3) + + assert event["compression"]["reasons"] == [ + "history_incremental", + "long_term_memory_selection", + "representation_compaction", + ] + serialized = json.dumps(event) + assert "attacker-controlled-value" not in serialized + assert "credential text" not in serialized diff --git a/test/sdk/monitor/test_context_budget_evidence.py b/test/sdk/monitor/test_context_budget_evidence.py new file mode 100644 index 0000000000..75feb451bf --- /dev/null +++ b/test/sdk/monitor/test_context_budget_evidence.py @@ -0,0 +1,28 @@ +from nexent.monitor.monitoring import ( + _enrich_record_with_final_request_evidence, + set_monitoring_final_request_evidence, + update_monitoring_final_request_evidence, +) + + +def test_physical_request_evidence_is_allowlisted_and_joins_own_usage(): + set_monitoring_final_request_evidence({ + "schema_version": 1, + "raw_estimate_tokens": 120, + "hard_count_tokens": 138, + "request_fingerprint": "hash", + "compression_attempted": True, + }) + update_monitoring_final_request_evidence(recovery_attempted=True, recovery_succeeded=True) + record = {"input_tokens": 125} + _enrich_record_with_final_request_evidence(record) + assert record["context_budget_evidence"]["provider_prompt_usage_tokens"] == 125 + assert record["context_budget_evidence"]["recovery_succeeded"] is True + assert "prompt" not in record["context_budget_evidence"] + + +def test_missing_evidence_leaves_legacy_record_unchanged(): + set_monitoring_final_request_evidence(None) + record = {"input_tokens": 10} + _enrich_record_with_final_request_evidence(record) + assert "context_budget_evidence" not in record From a33295a4729689e0443eb2e707bd3bf943cfdda7 Mon Sep 17 00:00:00 2001 From: Jinglong Wang Date: Mon, 24 Aug 2026 15:34:56 +0800 Subject: [PATCH 06/10] fix(context): allow reviewed legacy capacity adoption --- .../model_capacity_governance_service.py | 5 +-- .../resources/ModelCapacityCoverageWidget.tsx | 22 +++++++++---- frontend/public/locales/en/common.json | 1 + frontend/public/locales/zh/common.json | 1 + .../test_model_capacity_governance_service.py | 31 +++++++++++++++++++ 5 files changed, 52 insertions(+), 8 deletions(-) diff --git a/backend/services/model_capacity_governance_service.py b/backend/services/model_capacity_governance_service.py index 88a7f547b1..01fcc03c6d 100644 --- a/backend/services/model_capacity_governance_service.py +++ b/backend/services/model_capacity_governance_service.py @@ -246,7 +246,8 @@ def catalog_adoption_preview( "proposed_source": "catalog", "changed": current != proposed, "blocked_by_manual": source == "operator", - "applicable": source in {"catalog", "unknown"} and current != proposed, + "applicable": source in {"catalog", "unknown", "legacy"} + and (current != proposed or source != "catalog"), } return { "schema_version": GOVERNANCE_SCHEMA_VERSION, @@ -300,7 +301,7 @@ def apply_catalog_adoption( source = (metadata["fields"].get(field) or {}).get("source", "unknown") if source == "operator" and field not in reset_manual: continue - if source not in {"catalog", "unknown", "operator"}: + if source not in {"catalog", "unknown", "legacy", "operator"}: continue proposed = proposed_values[field] if proposed == record.get(field) and source == "catalog": diff --git a/frontend/app/[locale]/resource-manage/components/resources/ModelCapacityCoverageWidget.tsx b/frontend/app/[locale]/resource-manage/components/resources/ModelCapacityCoverageWidget.tsx index 9f0bdf09e4..cf1ce0473c 100644 --- a/frontend/app/[locale]/resource-manage/components/resources/ModelCapacityCoverageWidget.tsx +++ b/frontend/app/[locale]/resource-manage/components/resources/ModelCapacityCoverageWidget.tsx @@ -90,7 +90,12 @@ export default function ModelCapacityCoverageWidget() { } }; const adopt = async () => { - if (!selected || !preview) return; + if ( + !selected || + !preview || + !Object.values(preview.fields).some((field) => field.applicable) + ) + return; setBusy(true); try { await modelService.adoptCapacity({ @@ -171,7 +176,11 @@ export default function ModelCapacityCoverageWidget() { title={t("modelConfig.capacityHealth.title")} onCancel={() => setOpen(false)} okText={t("modelConfig.capacityHealth.applyReviewed")} - okButtonProps={{ disabled: !preview }} + okButtonProps={{ + disabled: + !preview || + !Object.values(preview.fields).some((field) => field.applicable), + }} confirmLoading={busy} onOk={adopt} > @@ -276,14 +285,15 @@ export default function ModelCapacityCoverageWidget() { }, { title: t("modelConfig.capacityHealth.protection"), - dataIndex: "blockedByManual", - render: (v) => - v ? ( + render: (_: unknown, item) => + item.blockedByManual ? ( {t("modelConfig.capacityHealth.manualProtected")} - ) : ( + ) : item.applicable ? ( {t("modelConfig.capacityHealth.applicable")} + ) : ( + {t("modelConfig.capacityHealth.noChange")} ), }, ]} diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index b460d7bc01..d1795f9298 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -1377,6 +1377,7 @@ "modelConfig.capacityHealth.protection": "Protection", "modelConfig.capacityHealth.manualProtected": "Manual value protected", "modelConfig.capacityHealth.applicable": "Will adopt", + "modelConfig.capacityHealth.noChange": "No change", "modelConfig.capacityHealth.applyReviewed": "Apply reviewed changes", "modelConfig.capacityHealth.applied": "Capacity profile applied", "modelConfig.capacityHealth.previewFailed": "Could not load a safe adoption preview", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 004dad729f..2219533d90 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -1346,6 +1346,7 @@ "modelConfig.capacityHealth.protection": "保护状态", "modelConfig.capacityHealth.manualProtected": "人工值受保护", "modelConfig.capacityHealth.applicable": "将采用", + "modelConfig.capacityHealth.noChange": "无需变更", "modelConfig.capacityHealth.applyReviewed": "应用已检查的更改", "modelConfig.capacityHealth.applied": "已采用容量配置", "modelConfig.capacityHealth.previewFailed": "无法加载安全的采用预览", diff --git a/test/backend/services/test_model_capacity_governance_service.py b/test/backend/services/test_model_capacity_governance_service.py index 1bec3984a6..eae5226a12 100644 --- a/test/backend/services/test_model_capacity_governance_service.py +++ b/test/backend/services/test_model_capacity_governance_service.py @@ -247,6 +247,37 @@ def test_ac_p1_009_manual_reset_changes_provenance_when_value_is_identical(): ) +def test_ac_p3_002_reviewed_legacy_adoption_changes_same_value_provenance(): + record = { + "model_type": "llm", + **CATALOG_VALUES, + "capacity_source": "legacy", + } + preview = catalog_adoption_preview( + record, + CATALOG_VALUES, + proposed_profile_version="openai/gpt-4o@2", + ) + + assert all(item["applicable"] for item in preview["fields"].values()) + adopted = apply_catalog_adoption( + record, + CATALOG_VALUES, + proposed_profile_version="openai/gpt-4o@2", + expected_profile_version="openai/gpt-4o@2", + current_matcher_version="1.0.0", + ) + + assert adopted.row_capacity_source == "profile" + assert adopted.capability_profile_version == "openai/gpt-4o@2" + assert all( + item["source"] == "catalog" + for item in adopted.metadata["fields"].values() + ) + assert {item["field"] for item in adopted.audit_delta} == set(CATALOG_VALUES) + assert all(not item["value_changed"] for item in adopted.audit_delta) + + @pytest.mark.parametrize( ("kwargs", "reason"), [ From 3ba057264aef6fb07987b7f983ecf37f49abc85d Mon Sep 17 00:00:00 2001 From: Jinglong Wang Date: Mon, 24 Aug 2026 17:00:53 +0800 Subject: [PATCH 07/10] fix(context): remove resource usage statistics --- .../ContextBudgetOperationsWidget.tsx | 85 ------------------- .../components/resources/ModelList.tsx | 2 - frontend/public/locales/en/common.json | 8 -- frontend/public/locales/zh/common.json | 8 -- frontend/services/api.ts | 1 - frontend/services/monitoringService.ts | 17 ---- frontend/types/monitoring.ts | 17 ---- 7 files changed, 138 deletions(-) delete mode 100644 frontend/app/[locale]/resource-manage/components/resources/ContextBudgetOperationsWidget.tsx diff --git a/frontend/app/[locale]/resource-manage/components/resources/ContextBudgetOperationsWidget.tsx b/frontend/app/[locale]/resource-manage/components/resources/ContextBudgetOperationsWidget.tsx deleted file mode 100644 index c7999d5769..0000000000 --- a/frontend/app/[locale]/resource-manage/components/resources/ContextBudgetOperationsWidget.tsx +++ /dev/null @@ -1,85 +0,0 @@ -"use client"; - -import React from "react"; -import { useTranslation } from "react-i18next"; -import { Card, Table, Tag } from "antd"; -import { useQuery } from "@tanstack/react-query"; -import { monitoringService } from "@/services/monitoringService"; - -const percent = (value: number | null) => - value === null ? "—" : `${(value * 100).toFixed(1)}%`; - -export default function ContextBudgetOperationsWidget({ - timeRange, -}: { - timeRange: string; -}) { - const { t } = useTranslation("common"); - const { data = [], isLoading } = useQuery({ - queryKey: ["contextBudgetMonitoring", timeRange], - queryFn: () => monitoringService.fetchContextBudget(timeRange), - staleTime: 30_000, - }); - if (!isLoading && data.length === 0) return null; - return ( - {timeRange}} - > -
- `${row.provider_protocol}:${row.model_name}:${row.capability_profile_version}` - } - dataSource={data} - columns={[ - { - title: t("monitoring.contextBudget.model"), - render: (_value, row) => ( - - {row.provider_protocol} / {row.model_name} -
- - {row.capability_profile_version} - -
- ), - }, - { - title: t("monitoring.contextBudget.requests"), - dataIndex: "request_count", - }, - { - title: t("monitoring.contextBudget.overflow"), - dataIndex: "overflow_rate", - render: percent, - }, - { - title: t("monitoring.contextBudget.compaction"), - dataIndex: "compaction_incidence", - render: percent, - }, - { - title: t("monitoring.contextBudget.reduction"), - dataIndex: "avg_compression_ratio", - render: percent, - }, - { - title: t("monitoring.contextBudget.estimateError"), - dataIndex: "mean_absolute_estimate_error", - render: percent, - }, - { - title: t("monitoring.contextBudget.recovery"), - dataIndex: "recovery_success_rate", - render: percent, - }, - ]} - /> - - ); -} diff --git a/frontend/app/[locale]/resource-manage/components/resources/ModelList.tsx b/frontend/app/[locale]/resource-manage/components/resources/ModelList.tsx index 87b71d4a74..c735370b03 100644 --- a/frontend/app/[locale]/resource-manage/components/resources/ModelList.tsx +++ b/frontend/app/[locale]/resource-manage/components/resources/ModelList.tsx @@ -35,7 +35,6 @@ import { MODEL_TYPES } from "@/const/modelConfig"; import { ModelAddDialog } from "../../../models/components/model/ModelAddDialog"; import { ModelEditDialog } from "../../../models/components/model/ModelEditDialog"; import ModelCapacityCoverageWidget from "./ModelCapacityCoverageWidget"; -import ContextBudgetOperationsWidget from "./ContextBudgetOperationsWidget"; interface UnifiedModelRow extends ModelOption { request_count?: number; error_rate?: number; @@ -496,7 +495,6 @@ export default function ModelList({ tenantId }: { tenantId: string | null }) { return (
-
=> { - try { - const response = await fetch( - `${API_ENDPOINTS.monitoring.contextBudget}?time_range=${encodeURIComponent(timeRange)}`, - { headers: getAuthHeaders() } - ); - const result = await response.json(); - return result.code === 0 && result.data ? result.data : []; - } catch (error) { - log.warn("Failed to fetch context budget metrics:", error); - return []; - } - }, }; diff --git a/frontend/types/monitoring.ts b/frontend/types/monitoring.ts index 0a79c882ac..a4936ea5bb 100644 --- a/frontend/types/monitoring.ts +++ b/frontend/types/monitoring.ts @@ -24,20 +24,3 @@ export interface MonitoringStatus { dashboard_port?: string | number | null; dashboard_path?: string | null; } - -export interface ContextBudgetMonitoringItem { - provider_protocol: string; - model_name: string; - capability_profile_version: string; - request_count: number; - overflow_count: number; - overflow_rate: number | null; - compacted_count: number; - compaction_incidence: number | null; - avg_compression_ratio: number | null; - estimate_sample_count: number; - mean_absolute_estimate_error: number | null; - recovery_attempt_count: number; - recovery_success_count: number; - recovery_success_rate: number | null; -} From 6a806ff07e18b5b6b3bd43d42a9855eaa458b55e Mon Sep 17 00:00:00 2001 From: Jinglong Wang Date: Mon, 24 Aug 2026 17:15:44 +0800 Subject: [PATCH 08/10] fix(context): remove capacity health summary --- .../resources/ModelCapacityCoverageWidget.tsx | 305 ------------------ .../components/resources/ModelList.tsx | 2 - frontend/hooks/model/useCapacityHealth.ts | 18 -- 3 files changed, 325 deletions(-) delete mode 100644 frontend/app/[locale]/resource-manage/components/resources/ModelCapacityCoverageWidget.tsx delete mode 100644 frontend/hooks/model/useCapacityHealth.ts diff --git a/frontend/app/[locale]/resource-manage/components/resources/ModelCapacityCoverageWidget.tsx b/frontend/app/[locale]/resource-manage/components/resources/ModelCapacityCoverageWidget.tsx deleted file mode 100644 index cf1ce0473c..0000000000 --- a/frontend/app/[locale]/resource-manage/components/resources/ModelCapacityCoverageWidget.tsx +++ /dev/null @@ -1,305 +0,0 @@ -"use client"; - -import React, { useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { - Button, - Card, - Descriptions, - Flex, - message, - Modal, - Skeleton, - Table, - Tag, -} from "antd"; -import { Activity, ShieldCheck } from "lucide-react"; -import { useCapacityHealth } from "@/hooks/model/useCapacityHealth"; -import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; -import { useDeployment } from "@/components/providers/deploymentProvider"; -import { useQuery } from "@tanstack/react-query"; -import { canManageModels } from "@/lib/auth"; -import { modelService } from "@/services/modelService"; -import type { - CapacityAdoptionPreview, - CapacityHealthItem, - CapacityHealthStatus, -} from "@/types/modelConfig"; - -const COLORS: Record = { - healthy: "green", - review_due: "gold", - expired: "red", - estimated: "orange", - unconfigured: "red", - invalid: "red", - probe_degraded: "volcano", -}; - -export default function ModelCapacityCoverageWidget() { - const { t } = useTranslation("common"); - const { user } = useAuthorizationContext(); - const { isSpeedMode } = useDeployment(); - const visible = canManageModels(user?.role, isSpeedMode); - const { health, isLoading, invalidate } = useCapacityHealth({ - enabled: visible, - }); - const { data: catalogStatus } = useQuery({ - queryKey: ["modelCapacityCatalogStatus"], - queryFn: modelService.getCapacityCatalogStatus, - staleTime: 60_000, - enabled: visible, - }); - const [open, setOpen] = useState(false); - const [selected, setSelected] = useState(null); - const [preview, setPreview] = useState(null); - const [busy, setBusy] = useState(false); - const unhealthy = useMemo( - () => health?.items.filter((item) => item.status !== "healthy") || [], - [health] - ); - if (!visible) return null; - if (isLoading) - return ( - - - - ); - if (!health) return null; - - const review = async (item: CapacityHealthItem) => { - setSelected(item); - setPreview(null); - setOpen(true); - if (!item.suggestionAvailable) return; - setBusy(true); - try { - setPreview( - await modelService.previewCapacityAdoption( - item.displayName, - item.matcherVersion - ) - ); - } catch (error: unknown) { - message.error( - (error instanceof Error ? error.message : "") || - t("modelConfig.capacityHealth.previewFailed") - ); - } finally { - setBusy(false); - } - }; - const adopt = async () => { - if ( - !selected || - !preview || - !Object.values(preview.fields).some((field) => field.applicable) - ) - return; - setBusy(true); - try { - await modelService.adoptCapacity({ - displayName: selected.displayName, - expectedProfileVersion: preview.proposedProfileVersion, - expectedMatcherVersion: preview.matcherVersion, - }); - message.success(t("modelConfig.capacityHealth.applied")); - setOpen(false); - await invalidate(); - } catch (error: unknown) { - message.error( - (error instanceof Error ? error.message : "") || - t("modelConfig.capacityHealth.applyFailed") - ); - } finally { - setBusy(false); - } - }; - return ( - <> - - - - {unhealthy.length ? ( - - ) : ( - - )} -
-
- {t("modelConfig.capacityHealth.title")} -
-
- {t("modelConfig.capacityHealth.summary", { - healthy: health.counts.healthy || 0, - total: health.total, - revision: health.catalogRevision, - })} -
- {catalogStatus && ( -
- {t("modelConfig.capacityHealth.catalogLifecycle", { - current: catalogStatus.lifecycleCounts.current || 0, - reviewDue: catalogStatus.lifecycleCounts.review_due || 0, - expired: catalogStatus.lifecycleCounts.expired || 0, - })} - {catalogStatus.candidate && ( - - {t("modelConfig.capacityHealth.catalogCandidate", { - revision: catalogStatus.candidate.revision, - added: catalogStatus.candidate.added.length, - changed: catalogStatus.candidate.changed.length, - removed: catalogStatus.candidate.removed.length, - })} - - )} -
- )} -
-
- {unhealthy.length > 0 && ( - - )} -
-
- setOpen(false)} - okText={t("modelConfig.capacityHealth.applyReviewed")} - okButtonProps={{ - disabled: - !preview || - !Object.values(preview.fields).some((field) => field.applicable), - }} - confirmLoading={busy} - onOk={adopt} - > -
item.status)) - ).map((status) => ({ - text: t(`modelConfig.capacityHealth.statuses.${status}`), - value: status, - })), - onFilter: (value, item) => item.status === value, - render: (value: CapacityHealthStatus) => ( - - {t(`modelConfig.capacityHealth.statuses.${value}`)} - - ), - }, - { - title: t("modelConfig.capacityHealth.reason"), - dataIndex: "reasons", - render: (values: string[]) => - values - .map((value) => - t(`modelConfig.capacityHealth.reasons.${value}`) - ) - .join(", "), - }, - { - title: t("modelConfig.capacityHealth.action"), - render: (_: unknown, item: CapacityHealthItem) => ( - - ), - }, - ]} - /> - {selected && ( - - )} - {preview && ( -
({ - field, - ...diff, - }))} - columns={[ - { - title: t("modelConfig.capacityHealth.field"), - dataIndex: "field", - }, - { - title: t("modelConfig.capacityHealth.current"), - dataIndex: "currentValue", - render: (v) => String(v ?? "—"), - }, - { - title: t("modelConfig.capacityHealth.proposed"), - dataIndex: "proposedValue", - render: (v) => String(v ?? "—"), - }, - { - title: t("modelConfig.capacityHealth.protection"), - render: (_: unknown, item) => - item.blockedByManual ? ( - - {t("modelConfig.capacityHealth.manualProtected")} - - ) : item.applicable ? ( - {t("modelConfig.capacityHealth.applicable")} - ) : ( - {t("modelConfig.capacityHealth.noChange")} - ), - }, - ]} - /> - )} - - - ); -} diff --git a/frontend/app/[locale]/resource-manage/components/resources/ModelList.tsx b/frontend/app/[locale]/resource-manage/components/resources/ModelList.tsx index c735370b03..4bee2be5eb 100644 --- a/frontend/app/[locale]/resource-manage/components/resources/ModelList.tsx +++ b/frontend/app/[locale]/resource-manage/components/resources/ModelList.tsx @@ -34,7 +34,6 @@ import type { ModelMonitoringItem } from "@/types/monitoring"; import { MODEL_TYPES } from "@/const/modelConfig"; import { ModelAddDialog } from "../../../models/components/model/ModelAddDialog"; import { ModelEditDialog } from "../../../models/components/model/ModelEditDialog"; -import ModelCapacityCoverageWidget from "./ModelCapacityCoverageWidget"; interface UnifiedModelRow extends ModelOption { request_count?: number; error_rate?: number; @@ -494,7 +493,6 @@ export default function ModelList({ tenantId }: { tenantId: string | null }) { return (
-
- queryClient.invalidateQueries({ queryKey: ["modelCapacityHealth"] }), - }; -} From 85bdd10e6b6ed71de81c45b156d11a12827a4410 Mon Sep 17 00:00:00 2001 From: Jinglong Wang Date: Mon, 24 Aug 2026 18:15:20 +0800 Subject: [PATCH 09/10] fix(context): clarify budget telemetry and compaction --- backend/database/conversation_db.py | 14 +- .../services/providers/dashscope_provider.py | 4 +- .../conversation-thread-list-adapter.tsx | 14 +- .../adapter/remote-chat-model-adapter.ts | 18 ++ .../app/[locale]/newchat/ui/token-usage.tsx | 208 +++++++++--------- frontend/public/locales/en/common.json | 12 + frontend/public/locales/zh/common.json | 12 + sdk/nexent/core/agents/core_agent.py | 2 - test/backend/database/test_conversation_db.py | 37 ++++ .../providers/test_dashscope_provider.py | 14 +- test/sdk/core/agents/test_core_agent.py | 10 +- 11 files changed, 230 insertions(+), 115 deletions(-) diff --git a/backend/database/conversation_db.py b/backend/database/conversation_db.py index f229076884..fd1e051314 100644 --- a/backend/database/conversation_db.py +++ b/backend/database/conversation_db.py @@ -72,7 +72,11 @@ def _parse_history_summary_content(content: Any) -> Optional[Dict[str, Any]]: """Return a valid summary payload, or ``None`` for malformed/stale units.""" try: payload = json.loads(content) if isinstance(content, str) else content - if not isinstance(payload, dict) or not isinstance(payload.get("summary"), dict): + if not isinstance(payload, dict): + return None + summary = payload.get("summary") + if not isinstance(summary, (dict, str)) or ( + isinstance(summary, str) and not summary.strip()): return None boundary = payload.get("covered_through_message_id") if isinstance(boundary, bool) or int(boundary) <= 0: @@ -1721,14 +1725,16 @@ def update_message_minio_files(message_id: int, skill_file_uploads: List[Dict[st def save_history_summary( conversation_id: int, user_id: str, tenant_id: str, - summary: Dict[str, Any], covered_through_message_id: int, + summary: Dict[str, Any] | str, covered_through_message_id: int, previous_summary_unit_id: Optional[int] = None, trigger: Optional[str] = None, ) -> int: """Persist a validated checkpoint on its last covered assistant message.""" - if not user_id or not tenant_id or not isinstance(summary, dict): + if (not user_id or not tenant_id + or not isinstance(summary, (dict, str)) + or (isinstance(summary, str) and not summary.strip())): raise HistorySummaryPersistenceError( - "user_id, tenant_id and an object summary are required") + "user_id, tenant_id and a summary are required") conversation_id = int(conversation_id) covered_through_message_id = int(covered_through_message_id) user_tenant = _get_user_tenant(user_id) diff --git a/backend/services/providers/dashscope_provider.py b/backend/services/providers/dashscope_provider.py index d87e40a2cf..e21ea22aa6 100644 --- a/backend/services/providers/dashscope_provider.py +++ b/backend/services/providers/dashscope_provider.py @@ -38,7 +38,9 @@ def _extract_capacity_hints(raw: Dict) -> Dict: - return _extract_capacity_hints_from_raw(raw, nested_keys=("inference_metadata",)) + return _extract_capacity_hints_from_raw( + raw, nested_keys=("model_info", "inference_metadata") + ) def _modality_set(value) -> set: diff --git a/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx b/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx index 56c1810c39..22ebd66cdf 100644 --- a/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx +++ b/frontend/app/[locale]/newchat/adapter/conversation-thread-list-adapter.tsx @@ -45,6 +45,7 @@ import { planRegistry, type PlanData, type SearchSource, + type ContextBudgetEvent, type StepTokenCount, } from "./remote-chat-model-adapter"; @@ -383,6 +384,7 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { // the same data into the global registry, but historical restores have // no streaming run to read from. const stepTokenCounts: StepTokenCount[] = []; + const pendingContextBudgets = new Map(); // Populate conversationSourcesRegistry for historical assistant messages // and build the matching `source` parts that drive the @@ -583,7 +585,16 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { // `SingleTurnTokenUsage` via message metadata. if (part.type === "token_count") { const parsed = parseStepTokenCount(part.content); - if (parsed) stepTokenCounts.push(parsed); + if (parsed) { + const pendingBudget = pendingContextBudgets.get( + parsed.stepNumber + ); + if (pendingBudget) { + parsed.contextBudget = pendingBudget; + pendingContextBudgets.delete(parsed.stepNumber); + } + stepTokenCounts.push(parsed); + } continue; } if (part.type === "context_budget") { @@ -593,6 +604,7 @@ export class RemoteConversationHistoryAdapter implements ThreadHistoryAdapter { .reverse() .find((item) => item.stepNumber === budget.step_number); if (step) step.contextBudget = budget; + else pendingContextBudgets.set(budget.step_number, budget); } continue; } diff --git a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts index 5c4a22f888..ddab5641ea 100644 --- a/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts +++ b/frontend/app/[locale]/newchat/adapter/remote-chat-model-adapter.ts @@ -328,6 +328,7 @@ export interface StepTokenCount { estimatedContextTokens: number; tokenThreshold: number | null; contextWindowTokens: number | null; + outputFinishReason: string | null; contextBudget?: ContextBudgetEvent; } @@ -397,6 +398,7 @@ export function parseStepTokenCount(content: string): StepTokenCount | null { estimated_context_tokens?: number; token_threshold?: number | null; context_window_tokens?: number | null; + output_finish_reason?: string | null; }; return { stepNumber: data.step_number ?? 0, @@ -407,6 +409,7 @@ export function parseStepTokenCount(content: string): StepTokenCount | null { estimatedContextTokens: data.estimated_context_tokens ?? 0, tokenThreshold: data.token_threshold ?? null, contextWindowTokens: data.context_window_tokens ?? null, + outputFinishReason: data.output_finish_reason ?? null, }; } catch { return null; @@ -1909,6 +1912,7 @@ export const remoteChatModelAdapter: ChatModelAdapter = { let firstTokenTime: number | undefined; let toolCallCount = 0; let storedTiming: ReturnType | null = null; + const pendingContextBudgets = new Map(); try { while (true) { @@ -1961,6 +1965,19 @@ export const remoteChatModelAdapter: ChatModelAdapter = { // Handle token_count - store timing for final yield if (chunk.type === "token_count") { storedTiming = buildTimingFromTokenCount(chunk.content); + const parsedStep = parseStepTokenCount(chunk.content); + if (parsedStep) { + const step = [...stepTokenCounts] + .reverse() + .find((item) => item.stepNumber === parsedStep.stepNumber); + const pendingBudget = pendingContextBudgets.get( + parsedStep.stepNumber + ); + if (step && pendingBudget) { + step.contextBudget = pendingBudget; + pendingContextBudgets.delete(parsedStep.stepNumber); + } + } continue; // Don't yield for internal data chunks } if (chunk.type === "context_budget") { @@ -1970,6 +1987,7 @@ export const remoteChatModelAdapter: ChatModelAdapter = { .reverse() .find((item) => item.stepNumber === budget.step_number); if (step) step.contextBudget = budget; + else pendingContextBudgets.set(budget.step_number, budget); } continue; } diff --git a/frontend/app/[locale]/newchat/ui/token-usage.tsx b/frontend/app/[locale]/newchat/ui/token-usage.tsx index 5d257c5eaa..417ccad2c6 100644 --- a/frontend/app/[locale]/newchat/ui/token-usage.tsx +++ b/frontend/app/[locale]/newchat/ui/token-usage.tsx @@ -127,6 +127,23 @@ interface SingleTurnTokenUsageProps { className?: string; } +const CONTEXT_COMPONENTS = [ + ["message_text", "chat.tokenUsage.components.messageText", "bg-blue-500"], + [ + "message_framing", + "chat.tokenUsage.components.messageFraming", + "bg-cyan-500", + ], + ["tools", "chat.tokenUsage.components.tools", "bg-violet-500"], + ["media", "chat.tokenUsage.components.media", "bg-pink-500"], + ["reasoning", "chat.tokenUsage.components.reasoning", "bg-amber-500"], + [ + "other_semantic", + "chat.tokenUsage.components.otherSemantic", + "bg-emerald-500", + ], +] as const; + /** * Displays per-step token consumption with a stacked progress bar. * Each step shows input tokens (blue) + output tokens (amber) relative to the token threshold. @@ -167,13 +184,42 @@ export const SingleTurnTokenUsage: FC = ({ const stepCount = steps.length; - // Calculate total tokens used (sum of step_input_tokens + step_output_tokens for all steps) - const totalTokensUsed = steps.reduce( - (sum, step) => sum + step.stepInputTokens + step.stepOutputTokens, + const finalInputTokens = budget?.final_tokens ?? latestStep.stepInputTokens; + const compositionDenominator = Math.max(1, finalInputTokens); + const effectiveLimit = budget?.hard_budget ?? maxTokens; + const outputTokens = latestStep.totalOutputTokens; + const usagePercent = Math.round((finalInputTokens / effectiveLimit) * 100); + const knownComponents = budget + ? CONTEXT_COMPONENTS.map(([key, label, color]) => ({ + key, + label, + color, + tokens: Math.max(0, budget.components[key] ?? 0), + })).filter((item) => item.tokens > 0) + : []; + const knownComponentTotal = knownComponents.reduce( + (sum, item) => sum + item.tokens, 0 ); - - const usagePercent = Math.round((totalTokensUsed / maxTokens) * 100); + const unclassifiedTokens = Math.max( + 0, + finalInputTokens - knownComponentTotal + ); + const composition = budget + ? [ + ...knownComponents, + ...(unclassifiedTokens > 0 + ? [ + { + key: "unclassified", + label: "chat.tokenUsage.components.unclassified", + color: "bg-slate-400", + tokens: unclassifiedTokens, + }, + ] + : []), + ] + : []; return (
@@ -191,7 +237,7 @@ export const SingleTurnTokenUsage: FC = ({ {/* Expanded details popover */} {expanded && ( -
+
{t("chat.tokenUsage.turnDetails")} @@ -218,107 +264,73 @@ export const SingleTurnTokenUsage: FC = ({
- {/* Stacked progress bar */} + {/* Final request and context composition */}
- - {t("chat.tokenUsage.context")} + + {t("chat.tokenUsage.finalRequest")} - {totalTokensUsed.toLocaleString()} /{" "} - {maxTokens.toLocaleString()} + {finalInputTokens.toLocaleString()} /{" "} + {effectiveLimit.toLocaleString()}
-
- {steps.map((step, index) => { - const stepTotal = step.stepInputTokens + step.stepOutputTokens; - const stepPercent = (stepTotal / maxTokens) * 100; - const inputPercent = (step.stepInputTokens / maxTokens) * 100; - const outputPercent = (step.stepOutputTokens / maxTokens) * 100; - - return ( -
0 ? steps.slice(0, index).reduce((sum, s) => sum + ((s.stepInputTokens + s.stepOutputTokens) / maxTokens) * 100, 0) : 0))}%`, - }} - title={t("chat.tokenUsage.stepSummary", { - step: step.stepNumber, - input: step.stepInputTokens, - output: step.stepOutputTokens, - })} - > - {/* Input portion (blue) */} -
- {/* Output portion (amber) */} -
- {/* Step number label on hover */} -
- - {step.stepNumber} - -
-
- ); - })} +
+ {composition.map((item) => ( +
+ ))}
- {/* Legend */} -
-
-
- - - {t("chat.tokenUsage.input")} - -
-
- - - {t("chat.tokenUsage.output")} - -
-
+
+ + {t("chat.tokenUsage.composition")} + {t("chat.tokenUsage.steps", { count: stepCount })}
- {/* Step details */}
{budget && ( -
-
- {t("chat.tokenUsage.finalRequest")} - - {budget.hard_count.toLocaleString()} /{" "} - {budget.hard_budget.toLocaleString()} - +
+ {composition.map((item) => ( +
+ + + {t(item.label)} + + + {item.tokens.toLocaleString()} ·{" "} + {Math.round((item.tokens / compositionDenominator) * 100)} + % + +
+ ))} +
+ {t("chat.tokenUsage.responseOutput")}
- {Object.entries(budget.components) - .filter(([, value]) => value > 0) - .map(([name, value]) => ( -
- {name.replaceAll("_", " ")} - {value.toLocaleString()} -
- ))} +
+ {t("chat.tokenUsage.generated")} + {outputTokens.toLocaleString()} +
+ {latestStep.outputFinishReason && ( +
+ {t("chat.tokenUsage.finishReason")} + {latestStep.outputFinishReason} +
+ )}
{t("chat.tokenUsage.countSource")} {budget.count_source} @@ -340,17 +352,11 @@ export const SingleTurnTokenUsage: FC = ({ )}
)} -
- - - {t("chat.tokenUsage.total")} - - - - {totalTokensUsed.toLocaleString()} /{" "} - {maxTokens.toLocaleString()} - -
+ {!budget && ( +
+ {t("chat.tokenUsage.breakdownUnavailable")} +
+ )}
)} diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 7a27c07af7..ed9a48962f 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -281,6 +281,18 @@ "chat.tokenUsage.total": "Total", "chat.tokenUsage.steps": "{{count}} steps", "chat.tokenUsage.stepSummary": "Step {{step}}: {{input}} in + {{output}} out", + "chat.tokenUsage.composition": "Context composition", + "chat.tokenUsage.responseOutput": "Response output", + "chat.tokenUsage.generated": "Generated tokens", + "chat.tokenUsage.finishReason": "Finished by", + "chat.tokenUsage.breakdownUnavailable": "A component breakdown is unavailable for this response.", + "chat.tokenUsage.components.messageText": "System & conversation", + "chat.tokenUsage.components.messageFraming": "Message framing", + "chat.tokenUsage.components.tools": "Tools & tool calls", + "chat.tokenUsage.components.media": "Images & media", + "chat.tokenUsage.components.reasoning": "Reasoning controls", + "chat.tokenUsage.components.otherSemantic": "Other context", + "chat.tokenUsage.components.unclassified": "Unclassified", "chat.messageTiming.generatedIn": "Generated in {{time}}", "chat.messageTiming.firstToken": "TTFT: {{time}}", "chat.messageTiming.total": "Total: {{time}}", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 746505bade..c04f70bd76 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -281,6 +281,18 @@ "chat.tokenUsage.total": "总计", "chat.tokenUsage.steps": "{{count}} 步", "chat.tokenUsage.stepSummary": "第 {{step}} 步:输入 {{input}},输出 {{output}}", + "chat.tokenUsage.composition": "上下文构成", + "chat.tokenUsage.responseOutput": "响应输出", + "chat.tokenUsage.generated": "生成 Token", + "chat.tokenUsage.finishReason": "结束原因", + "chat.tokenUsage.breakdownUnavailable": "此响应没有可用的上下文构成明细。", + "chat.tokenUsage.components.messageText": "系统指令与会话", + "chat.tokenUsage.components.messageFraming": "消息结构", + "chat.tokenUsage.components.tools": "工具与工具调用", + "chat.tokenUsage.components.media": "图片与媒体", + "chat.tokenUsage.components.reasoning": "推理控制", + "chat.tokenUsage.components.otherSemantic": "其他上下文", + "chat.tokenUsage.components.unclassified": "未分类", "chat.messageTiming.generatedIn": "生成耗时 {{time}}", "chat.messageTiming.firstToken": "首个 Token:{{time}}", "chat.messageTiming.total": "总计:{{time}}", diff --git a/sdk/nexent/core/agents/core_agent.py b/sdk/nexent/core/agents/core_agent.py index 67bd5a862b..8663bd0a68 100644 --- a/sdk/nexent/core/agents/core_agent.py +++ b/sdk/nexent/core/agents/core_agent.py @@ -252,8 +252,6 @@ def _looks_like_incomplete_action_output( """ if not isinstance(text, str) or not text.strip(): return False - if finish_reason == "length": - return True if _looks_like_invalid_action_output(text): return True diff --git a/test/backend/database/test_conversation_db.py b/test/backend/database/test_conversation_db.py index d52de7fa5c..2f321dcb35 100644 --- a/test/backend/database/test_conversation_db.py +++ b/test/backend/database/test_conversation_db.py @@ -2865,6 +2865,12 @@ def test_parse_history_summary_requires_summary_and_positive_boundary(): assert _parse_history_summary_content('{"covered_through_message_id":24}') is None assert _parse_history_summary_content( '{"summary":{},"covered_through_message_id":0}') is None + assert _parse_history_summary_content( + '{"summary":"## Task overview\\nDone",' + '"covered_through_message_id":"25"}' + )["summary"] == "## Task overview\nDone" + assert _parse_history_summary_content( + '{"summary":" ","covered_through_message_id":25}') is None assert _parse_history_summary_content("not-json") is None @@ -2913,6 +2919,37 @@ def test_save_history_summary_appends_after_last_unit(monkeypatch, mock_session_ assert payload["trigger"] == "soft_budget_exceeded" +def test_save_history_summary_accepts_structured_markdown( + monkeypatch, mock_session_ctx, fresh_insert_mock): + from types import SimpleNamespace + session, ctx = mock_session_ctx + monkeypatch.setattr( + "backend.database.conversation_db._get_user_tenant", + lambda _user_id: {"tenant_id": "tenant-a"}) + message_index_column = MagicMock() + message_index_column.__gt__.return_value = MagicMock() + message_index_column.__le__.return_value = MagicMock() + monkeypatch.setattr(ConversationMessage, "message_index", message_index_column) + owner_result = MagicMock() + owner_result.first.return_value = SimpleNamespace(conversation_id=1) + covered_result = MagicMock() + covered_result.first.return_value = SimpleNamespace( + message_id=24, message_index=3, message_role="assistant", + status="completed") + insert_result = MagicMock() + insert_result.scalar_one.return_value = 1001 + session.execute.side_effect = [owner_result, covered_result, insert_result] + session.scalar.side_effect = [0, 4] + monkeypatch.setattr("backend.database.conversation_db.get_db_session", lambda: ctx) + + save_history_summary( + 1, "user-a", "tenant-a", "## Task overview\nDone", 24, + trigger="soft_budget_exceeded") + + payload = __import__("json").loads(fresh_insert_mock["unit_content"]) + assert payload["summary"] == "## Task overview\nDone" + + def test_save_history_summary_rejects_incomplete_covered_range( monkeypatch, mock_session_ctx): from types import SimpleNamespace diff --git a/test/backend/services/providers/test_dashscope_provider.py b/test/backend/services/providers/test_dashscope_provider.py index 7836e6c11f..e02126d4c5 100644 --- a/test/backend/services/providers/test_dashscope_provider.py +++ b/test/backend/services/providers/test_dashscope_provider.py @@ -315,15 +315,18 @@ async def test_get_models_llm_surfaces_capacity_hints(self, mocker: MockFixture) "output": { "models": [ { - "model": "qwen-plus", + "model": "qwen3.7-max", "description": "Advanced text generation", "inference_metadata": { "request_modality": ["Text"], "response_modality": ["Text"], - "context_length": 131072, - "max_output_tokens": "8192", "tokenizer_family": "qwen", }, + "model_info": { + "context_window": 1000000, + "max_input_tokens": 991808, + "max_output_tokens": 131072, + }, } ] } @@ -337,8 +340,9 @@ async def test_get_models_llm_surfaces_capacity_hints(self, mocker: MockFixture) "api_key": "test-api-key", }) - assert result[0]["context_window_tokens"] == 131072 - assert result[0]["max_output_tokens"] == 8192 + assert result[0]["context_window_tokens"] == 1000000 + assert result[0]["max_input_tokens"] == 991808 + assert result[0]["max_output_tokens"] == 131072 assert result[0]["tokenizer_family"] == "qwen" assert result[0]["capacity_source"] == "provider_candidate" diff --git a/test/sdk/core/agents/test_core_agent.py b/test/sdk/core/agents/test_core_agent.py index 1f2fc955f7..96d0071454 100644 --- a/test/sdk/core/agents/test_core_agent.py +++ b/test/sdk/core/agents/test_core_agent.py @@ -375,10 +375,18 @@ def test_complete_answer_that_names_tool_is_not_misclassified(): ) is False -def test_length_truncated_non_code_output_is_not_a_final_answer(): +def test_length_truncated_prose_is_not_misclassified_as_an_action(): assert core_agent_module._looks_like_incomplete_action_output( "这是一个尚未完成的回答", finish_reason="length", + ) is False + + +def test_length_truncated_action_preamble_still_requires_a_tool_call(): + assert core_agent_module._looks_like_incomplete_action_output( + "思考:我需要先调用 knowledge_base_search", + available_tool_names={"knowledge_base_search"}, + finish_reason="length", ) is True def test_parse_code_blobs_run_format(): From 7d7ad482fa3028941332fbc0e074ce8993ec47ca Mon Sep 17 00:00:00 2001 From: Jinglong Wang Date: Mon, 24 Aug 2026 20:07:41 +0800 Subject: [PATCH 10/10] fix: harden provider capacity fallbacks --- backend/consts/capability_profiles.py | 22 ++++++- .../model_capacity_governance_service.py | 33 +++++++--- backend/services/model_management_service.py | 5 +- backend/services/model_provider_service.py | 15 +++-- .../core/models/final_request_budget.py | 31 ++++++++- .../test_model_capacity_governance_service.py | 63 ++++++++++++++++++ .../services/test_model_management_service.py | 64 +++++++++++++++++-- .../services/test_model_provider_service.py | 39 ++--------- .../test_capability_profile_governance.py | 4 ++ .../core/models/test_final_request_budget.py | 36 +++++++++++ 10 files changed, 253 insertions(+), 59 deletions(-) diff --git a/backend/consts/capability_profiles.py b/backend/consts/capability_profiles.py index dae58ebfaa..3b0a635384 100644 --- a/backend/consts/capability_profiles.py +++ b/backend/consts/capability_profiles.py @@ -22,7 +22,7 @@ logger = logging.getLogger(__name__) -CATALOG_REVISION = "2026-08-24.1" +CATALOG_REVISION = "2026-08-24.2" CATALOG: Dict[ProfileKey, CapabilityProfile] = { @@ -122,15 +122,33 @@ default_output_reserve_tokens=8_192, tokenizer_family="chatglm", ), + # Verified 2026-08-24 against SiliconFlow's model center and launch note. + # The list-models API exposes identity only, so this complete catalog row is + # the capacity fallback for the hosted model. + # https://www.siliconflow.cn/models + # https://www.siliconflow.cn/news/grz0d71bw8xguh4n6lnjqkw9 ("silicon", "Qwen/Qwen3.6-27B"): CapabilityProfile( provider="silicon", model_name="Qwen/Qwen3.6-27B", - capability_profile_version="silicon/qwen3.6-27b@1", + capability_profile_version="silicon/qwen3.6-27b@2", window_shape="combined", context_window_tokens=262_144, max_output_tokens=65_536, default_output_reserve_tokens=8_192, tokenizer_family="qwen", + aliases=("Qwen/Qwen3.6-27B", "Qwen3.6-27B"), + exclusions=("Qwen3.6-35B-A3B",), + evidence=( + "https://www.siliconflow.cn/models", + "https://www.siliconflow.cn/news/grz0d71bw8xguh4n6lnjqkw9", + ), + verified_at="2026-08-24T00:00:00Z", + shared_context=True, + independent_input=False, + max_output=65_536, + reasoning_behavior="reserved", + overhead_behavior="bounded", + confidence="high", ), ("silicon", "Pro/moonshotai/Kimi-K2.6"): CapabilityProfile( provider="silicon", diff --git a/backend/services/model_capacity_governance_service.py b/backend/services/model_capacity_governance_service.py index 01fcc03c6d..82c7c23df7 100644 --- a/backend/services/model_capacity_governance_service.py +++ b/backend/services/model_capacity_governance_service.py @@ -61,7 +61,19 @@ def normalize_legacy_capacity_ingress( output_value = normalized.get("max_output_tokens") used_legacy = False - if legacy_explicit and output_explicit and legacy_value is not None and output_value is not None: + provider_capacity = normalized.get("capacity_source") == "provider_candidate" + if ( + legacy_explicit + and output_explicit + and legacy_value is not None + and output_value is not None + and provider_capacity + ): + # Provider adapters retain `max_tokens` as a generation default for + # compatibility while exposing authoritative capacity in + # `max_output_tokens`. It is not a second capacity declaration. + pass + elif legacy_explicit and output_explicit and legacy_value is not None and output_value is not None: if legacy_value != output_value: _fail( "capacity_legacy_conflict", @@ -168,19 +180,25 @@ def merge_capacity_governance( old_value = previous.get(field) new_value = payload.get(field) previous_source = (fields.get(field) or {}).get("source") - provenance_changes = ( - field in accepted - and accepted_profile_version is not None - and new_value is not None - and previous_source != "catalog" + provenance_changes = new_value is not None and ( + ( + field in accepted + and accepted_profile_version is not None + and previous_source != "catalog" + ) + or (field in provider and previous_source != "provider") ) if existing and new_value == old_value and not provenance_changes: continue if field in provider and previous_source == "operator": + values[field] = old_value continue if new_value is None: fields.pop(field, None) new_source = "unknown" + elif field in provider: + new_source = "provider" + fields[field] = _field_metadata(new_source) elif field in accepted and accepted_profile_version: new_source = "catalog" fields[field] = _field_metadata( @@ -189,9 +207,6 @@ def merge_capacity_governance( evidence_id=profile_evidence_id, verified_at=profile_verified_at, ) - elif field in provider: - new_source = "provider" - fields[field] = _field_metadata(new_source) elif legacy_ingress_used and field == "max_output_tokens": new_source = "legacy" fields[field] = _field_metadata(new_source) diff --git a/backend/services/model_management_service.py b/backend/services/model_management_service.py index 8a027fdddf..cd08d2b222 100644 --- a/backend/services/model_management_service.py +++ b/backend/services/model_management_service.py @@ -247,6 +247,8 @@ def _apply_capacity_governance( ",".join(item["new_source"] for item in result.audit_delta), ) output = dict(normalized) + for field in governed_explicit: + output[field] = result.values.get(field) output["capacity_field_metadata"] = result.metadata output["capacity_source"] = result.row_capacity_source output["capability_profile_version"] = result.capability_profile_version @@ -639,7 +641,8 @@ async def batch_create_models_for_tenant(user_id: str, tenant_id: str, batch_pay governed = { key: value for key, value in model.items() - if key in GOVERNED_FIELDS or key in {"model_type", "max_tokens"} + if key in GOVERNED_FIELDS + or key in {"model_type", "max_tokens", "capacity_source"} } explicit = set(governed) governed = _apply_capacity_governance( diff --git a/backend/services/model_provider_service.py b/backend/services/model_provider_service.py index 32ca5a5323..466170c2b9 100644 --- a/backend/services/model_provider_service.py +++ b/backend/services/model_provider_service.py @@ -117,12 +117,11 @@ async def prepare_model_dict(provider: str, model: dict, model_url: str, model_a # in (all None) and every freshly batch-created row lands with # context_window_tokens=NULL, max_output_tokens=NULL even though # the user filled the panel -- the glm-5.1/glm-5.2 incident. - # - capacity_source="provider_candidate" (or anything else): per the - # W1 design these are advisory UI hints surfaced from the catalog - # by _extract_capacity_hints. They are shown to the user as - # suggestions but not auto-persisted; only operator acceptance - # should write them. + # - capacity_source="provider_candidate": these are authoritative + # field-scoped provider facts. Persist only the fields returned by the + # provider; governance keeps operator-owned fields authoritative. is_operator_capacity = model.get("capacity_source") == "operator" + is_provider_capacity = model.get("capacity_source") == "provider_candidate" capacity_kwargs = ( { "context_window_tokens": model.get("context_window_tokens"), @@ -130,10 +129,12 @@ async def prepare_model_dict(provider: str, model: dict, model_url: str, model_a "max_output_tokens": model.get("max_output_tokens"), "default_output_reserve_tokens": model.get("default_output_reserve_tokens"), "tokenizer_family": model.get("tokenizer_family"), - "capacity_source": "operator", + "capacity_source": ( + "operator" if is_operator_capacity else "provider_candidate" + ), "capability_profile_version": model.get("capability_profile_version"), } - if is_operator_capacity + if is_operator_capacity or is_provider_capacity else {} ) diff --git a/sdk/nexent/core/models/final_request_budget.py b/sdk/nexent/core/models/final_request_budget.py index cb419fcc81..c6f3af2738 100644 --- a/sdk/nexent/core/models/final_request_budget.py +++ b/sdk/nexent/core/models/final_request_budget.py @@ -28,6 +28,7 @@ CALIBRATION_TTL_SECONDS = 24 * 60 * 60 MAX_OBSERVED_RATIO = 4.0 MAX_GATE_MULTIPLIER = 2.0 +TOOL_PROTOCOL_OVERHEAD_TOKENS = 208 RequestShape = Literal["text", "tools", "media", "tools_media"] CountSource = Literal["provider", "tokenizer", "estimated"] @@ -44,6 +45,22 @@ "http_client", } ) +_NON_TOKEN_CONTROL_KEYS = frozenset( + { + "model", + "max_tokens", + "max_completion_tokens", + "temperature", + "top_p", + "n", + "stop", + "frequency_penalty", + "presence_penalty", + "seed", + "parallel_tool_calls", + "tool_choice", + } +) _REASONING_KEYS = frozenset( { "reasoning", @@ -373,7 +390,11 @@ def build_final_request_shape(completion_kwargs: Mapping[str, Any]) -> FinalRequ media_count += _count_media(message) framing = (3 * len(message_list) + (3 if message_list else 0)) tools = semantic.get("tools") or semantic.get("functions") - tool_tokens = _json_tokens(tools) if tools else 0 + tool_tokens = ( + _json_tokens(tools) + TOOL_PROTOCOL_OVERHEAD_TOKENS + if tools + else 0 + ) media_tokens = media_count * 256 reasoning_values: dict[str, Any] = {} @@ -386,7 +407,13 @@ def build_final_request_shape(completion_kwargs: Mapping[str, Any]) -> FinalRequ if key in _REASONING_KEYS: reasoning_values[key] = value - consumed = {"messages", "tools", "functions", *reasoning_values.keys()} + consumed = { + "messages", + "tools", + "functions", + *_NON_TOKEN_CONTROL_KEYS, + *reasoning_values.keys(), + } other = { key: value for key, value in semantic.items() diff --git a/test/backend/services/test_model_capacity_governance_service.py b/test/backend/services/test_model_capacity_governance_service.py index eae5226a12..72dd242cea 100644 --- a/test/backend/services/test_model_capacity_governance_service.py +++ b/test/backend/services/test_model_capacity_governance_service.py @@ -82,6 +82,69 @@ def test_ac_p1_001_omitted_fields_and_equal_echo_preserve_metadata(): assert result.audit_delta == () +def test_ac_p5_003_provider_wins_catalog_but_not_operator(): + existing = catalog_row() + existing["max_output_tokens"] = 8_192 + existing["capacity_field_metadata"]["fields"]["max_output_tokens"] = { + "source": "operator" + } + result = merge_capacity_governance( + { + "context_window_tokens": 262_144, + "max_output_tokens": 65_536, + }, + explicit_fields={"context_window_tokens", "max_output_tokens"}, + existing=existing, + accepted_profile_version="silicon/qwen3.6-27b@2", + accepted_profile_fields={"context_window_tokens", "max_output_tokens"}, + provider_fields={"context_window_tokens", "max_output_tokens"}, + ) + + assert result.values["context_window_tokens"] == 262_144 + assert result.metadata["fields"]["context_window_tokens"]["source"] == "provider" + assert result.values["max_output_tokens"] == 8_192 + assert result.metadata["fields"]["max_output_tokens"]["source"] == "operator" + assert result.row_capacity_source == "operator" + + +def test_ac_p5_003_provider_capacity_ignores_legacy_generation_default(): + normalized, explicit, used_legacy = normalize_legacy_capacity_ingress( + { + "model_type": "llm", + "capacity_source": "provider_candidate", + "max_tokens": 4_096, + "max_output_tokens": 131_072, + }, + explicit_fields={"max_tokens", "max_output_tokens"}, + ) + + assert normalized["max_output_tokens"] == 131_072 + assert "max_tokens" not in normalized + assert explicit == {"max_output_tokens"} + assert used_legacy is False + + +def test_ac_p5_003_equal_provider_value_replaces_catalog_provenance(): + existing = catalog_row() + result = merge_capacity_governance( + {"context_window_tokens": existing["context_window_tokens"]}, + explicit_fields={"context_window_tokens"}, + existing=existing, + provider_fields={"context_window_tokens"}, + ) + + assert result.values["context_window_tokens"] == existing["context_window_tokens"] + assert result.metadata["fields"]["context_window_tokens"]["source"] == "provider" + assert result.audit_delta == ( + { + "field": "context_window_tokens", + "previous_source": "catalog", + "new_source": "provider", + "value_changed": False, + }, + ) + + def test_ac_p1_002_explicit_clear_becomes_unknown_without_fabrication(): existing = catalog_row() result = merge_capacity_governance( diff --git a/test/backend/services/test_model_management_service.py b/test/backend/services/test_model_management_service.py index 469a8caaf5..f51be1aa26 100644 --- a/test/backend/services/test_model_management_service.py +++ b/test/backend/services/test_model_management_service.py @@ -1948,11 +1948,9 @@ async def test_batch_create_models_for_tenant_update_branch_persists_operator_ca async def test_batch_create_models_for_tenant_tracks_provider_candidate_fields(): """Provider facts fill unknown fields without claiming operator provenance. - Even when the catalog response contains rich inference_metadata, those - values stay tagged capacity_source="provider_candidate" until the - operator accepts them. Refreshing the provider list must not - silently rewrite a row's operator-set capacity (or its NULLs) with - catalog hints. + The provider's compatibility `max_tokens` generation default is not a + competing capacity declaration. Field governance still prevents refresh + from rewriting operator-owned capacity. """ svc = import_svc() @@ -1972,7 +1970,7 @@ async def test_batch_create_models_for_tenant_tracks_provider_candidate_fields() "models": [ { "id": "dashscope/glm-5.1", - "max_tokens": 8192, + "max_tokens": 4096, "context_window_tokens": 128000, "max_output_tokens": 8192, "tokenizer_family": "qwen", @@ -1998,6 +1996,60 @@ async def test_batch_create_models_for_tenant_tracks_provider_candidate_fields() assert fields["context_window_tokens"]["source"] == "provider" +@pytest.mark.asyncio +async def test_ac_p5_003_provider_refresh_keeps_different_operator_values(): + svc = import_svc() + existing_row = { + "model_id": 7, + "model_repo": "", + "model_name": "qwen3.7-plus", + "max_tokens": 4096, + "context_window_tokens": 16_384, + "max_input_tokens": 15_360, + "max_output_tokens": 1_024, + "default_output_reserve_tokens": 512, + "capacity_source": "operator", + "capacity_field_metadata": { + "schema_version": 1, + "fields": { + field: {"source": "operator"} + for field in ( + "context_window_tokens", + "max_input_tokens", + "max_output_tokens", + "default_output_reserve_tokens", + ) + }, + }, + } + batch_payload = { + "provider": "dashscope", + "type": "llm", + "api_key": "dash-key", + "models": [ + { + "id": "qwen3.7-plus", + "max_tokens": 4096, + "context_window_tokens": 1_000_000, + "max_input_tokens": 991_808, + "max_output_tokens": 131_072, + "capacity_source": "provider_candidate", + } + ], + } + + with mock.patch.object(svc, "get_models_by_tenant_factory_type", return_value=[existing_row]), \ + mock.patch.object(svc, "apply_model_mutations") as mock_apply: + await svc.batch_create_models_for_tenant("u1", "t1", batch_payload) + + _, update = mock_apply.call_args.kwargs["updates"][0] + assert update["context_window_tokens"] == 16_384 + assert update["max_input_tokens"] == 15_360 + assert update["max_output_tokens"] == 1_024 + assert "default_output_reserve_tokens" not in update + assert update["capacity_source"] == "operator" + + def test_get_capacity_coverage_filters_bare_llm_vlm_rows(): svc = import_svc() diff --git a/test/backend/services/test_model_provider_service.py b/test/backend/services/test_model_provider_service.py index 1df83a45df..22cf3284e2 100644 --- a/test/backend/services/test_model_provider_service.py +++ b/test/backend/services/test_model_provider_service.py @@ -506,18 +506,8 @@ async def test_prepare_model_dict_excludes_w11_accept_signal_fields(): @pytest.mark.asyncio -async def test_prepare_model_dict_does_not_persist_provider_capacity_candidates(): - """Provider capacity candidates remain UI hints until an operator saves them. - - Per the W1/W2 plan, _extract_capacity_hints tags provider-discovered - capacity values with capacity_source="provider_candidate" so the - catalog UI can show them as suggestions. They must not auto-persist - on batch_create; only operator acceptance (capacity_source="operator") - can write to the row. The original assertion only checked the dumped - result, which is trivially controlled by the mock; the strengthened - assertion below pins ModelRequest's constructor kwargs so the - contract is enforced regardless of what model_dump returns. - """ +async def test_ac_p5_003_prepare_model_dict_persists_provider_capacity_candidates(): + """Provider facts reach governance instead of being replaced by catalog.""" with mock.patch( "backend.services.model_provider_service.split_repo_name", return_value=("openai", "gpt-4"), @@ -550,33 +540,18 @@ async def test_prepare_model_dict_does_not_persist_provider_capacity_candidates( "capacity_source": "provider_candidate", } - result = await prepare_model_dict( + await prepare_model_dict( "openai", model, "https://api.openai.com/v1", "test-key", ) - # Result-level: the dumped dict (controlled by the mock) doesn't - # carry capacity hints downstream. - assert "context_window_tokens" not in result - assert "max_output_tokens" not in result - assert "tokenizer_family" not in result - assert "capacity_source" not in result - - # Contract-level: prepare_model_dict must NOT thread provider - # candidates into ModelRequest. Without this assertion the bug - # we just fixed -- threading every W2 field through unconditionally - # -- would slip past the result-level check because the mock - # absorbs any kwargs silently. _, kwargs = mock_model_request.call_args - assert "context_window_tokens" not in kwargs - assert "max_output_tokens" not in kwargs - assert "max_input_tokens" not in kwargs - assert "default_output_reserve_tokens" not in kwargs - assert "tokenizer_family" not in kwargs - assert "capacity_source" not in kwargs - assert "capability_profile_version" not in kwargs + assert kwargs["context_window_tokens"] == 128000 + assert kwargs["max_output_tokens"] == 16384 + assert kwargs["tokenizer_family"] == "o200k_base" + assert kwargs["capacity_source"] == "provider_candidate" @pytest.mark.asyncio diff --git a/test/sdk/core/models/test_capability_profile_governance.py b/test/sdk/core/models/test_capability_profile_governance.py index 865d583599..81def05249 100644 --- a/test/sdk/core/models/test_capability_profile_governance.py +++ b/test/sdk/core/models/test_capability_profile_governance.py @@ -73,6 +73,10 @@ def test_production_catalog_verified_rows_are_complete_and_legacy_rows_are_sugge assert diagnostics[("dashscope", "qwen-plus")] == () qwen_37 = CATALOG[("dashscope", "qwen3.7-plus")] assert qwen_37.auto_applicable is True + + silicon_qwen = CATALOG[("silicon", "Qwen/Qwen3.6-27B")] + assert silicon_qwen.auto_applicable is True + assert silicon_qwen.capability_profile_version == "silicon/qwen3.6-27b@2" assert qwen_37.max_input_tokens == 991_808 assert diagnostics[("dashscope", "qwen3.7-plus")] == () assert CATALOG[("openai", "gpt-4o")].auto_applicable is False diff --git a/test/sdk/core/models/test_final_request_budget.py b/test/sdk/core/models/test_final_request_budget.py index 522f5bed12..64f89b3c8a 100644 --- a/test/sdk/core/models/test_final_request_budget.py +++ b/test/sdk/core/models/test_final_request_budget.py @@ -63,6 +63,42 @@ def test_ac_p2_002_unified_components_cover_tools_media_reasoning_and_other(): assert "secret" not in repr(shape) +def test_ac_p5_002_generation_controls_are_not_prompt_tokens(): + base = build_final_request_shape({ + "messages": [{"role": "user", "content": "hello"}], + }) + controlled = build_final_request_shape({ + "model": "Qwen/Qwen3.6-27B", + "messages": [{"role": "user", "content": "hello"}], + "max_tokens": 1, + "temperature": 0, + "top_p": 0.9, + "seed": 7, + }) + + assert controlled.components.raw_total == base.components.raw_total + assert controlled.fingerprint != base.fingerprint + + +def test_ac_p5_002_tools_include_provider_protocol_envelope(): + without_tools = build_final_request_shape({ + "messages": [{"role": "user", "content": "weather"}], + }) + with_tools = build_final_request_shape({ + "messages": [{"role": "user", "content": "weather"}], + "tools": [{ + "type": "function", + "function": { + "name": "weather", + "parameters": {"type": "object"}, + }, + }], + }) + + assert with_tools.components.tools >= 208 + assert with_tools.components.raw_total > without_tools.components.raw_total + 208 + + def test_ac_p2_004_exact_boundary_and_no_over_hard_dispatch_decision(): meter = FinalRequestMeter(CalibrationStore(minimum_samples=2)) kwargs = {"model": "m", "messages": [{"role": "user", "content": "x"}]}