From 77a007dec46e3aba5e2543eca52ce17d9478d13a Mon Sep 17 00:00:00 2001 From: Phil Merrell Date: Fri, 1 May 2026 13:27:17 -0600 Subject: [PATCH 01/17] feat: per-model inference parameters with extended thinking support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the global temperature/max_tokens knobs with a per-model `supportedParams` map keyed by canonical name (temperature, top_p, top_k, max_tokens, thinking, reasoning_effort, ...). Admins author which params apply to each model, the runtime translates canonical names into the provider-native shape (Bedrock/OpenAI/Gemini), and users can override per-request from a new Settings → Advanced panel. Extended thinking on Anthropic Bedrock is the headline use case: - Stored as an int budget per model; runtime wraps it into the `{type, budget_tokens}` Anthropic request shape under the field Strands' BedrockConfig actually forwards (`additional_request_fields`, not the previously-attempted `additional_model_request_fields`). - Suppresses temperature/top_p/top_k while on (Anthropic constraint). - Validated up front: budget >= 1024 and < max_tokens, with inline errors on the admin form, an "unsatisfiable" disabled state on the user panel when max_tokens drops below the floor, and a final cross-param safety drop in the merge step so direct API callers never ship a Bedrock-rejecting request. Co-Authored-By: Claude Opus 4.7 --- backend/scripts/seed_bootstrap_data.py | 42 ++ backend/src/agents/main_agent/base_agent.py | 26 +- .../src/agents/main_agent/config/constants.py | 1 - .../agents/main_agent/core/model_config.py | 214 +++++--- .../streaming/stream_coordinator.py | 6 +- backend/src/apis/inference_api/chat/models.py | 7 +- backend/src/apis/inference_api/chat/routes.py | 188 +++++-- .../src/apis/inference_api/chat/service.py | 41 +- .../src/apis/shared/models/managed_models.py | 3 + backend/src/apis/shared/models/models.py | 99 +++- backend/src/apis/shared/sessions/models.py | 6 + .../main_agent/core/test_model_config.py | 119 +++- .../agents/main_agent/property/conftest.py | 7 +- .../property/test_pbt_agent_core.py | 3 +- .../agents/main_agent/test_fixtures_smoke.py | 2 +- .../admin/manage-models/model-form.page.html | 252 +++++++++ .../admin/manage-models/model-form.page.ts | 516 +++++++++++++++++- .../models/managed-model.model.ts | 137 +++++ .../model-settings/model-settings.html | 159 ++++++ .../model-settings/model-settings.ts | 336 +++++++++++- .../services/chat/chat-request.service.ts | 10 + .../session/services/model/model.service.ts | 79 +++ 22 files changed, 2079 insertions(+), 174 deletions(-) diff --git a/backend/scripts/seed_bootstrap_data.py b/backend/scripts/seed_bootstrap_data.py index 667656a3..607615b4 100644 --- a/backend/scripts/seed_bootstrap_data.py +++ b/backend/scripts/seed_bootstrap_data.py @@ -175,6 +175,38 @@ def seed_default_quota_assignment( return result +# Sensible inference-param defaults for general-purpose Claude chat models. +# Temperature 0.7 mirrors the previous always-on default and gives admins a +# starting point they can tighten per-model. Bounds match Anthropic's accepted +# range. max_tokens is supported but left at no-default — the model's own cap +# applies unless an admin explicitly sets one. +CLAUDE_CHAT_SUPPORTED_PARAMS: dict[str, Any] = { + "params": { + "temperature": { + "supported": True, + "min": Decimal("0"), + "max": Decimal("1"), + "default": Decimal("0.7"), + "locked": False, + }, + "top_p": { + "supported": True, + "min": Decimal("0"), + "max": Decimal("1"), + "default": None, + "locked": False, + }, + "max_tokens": { + "supported": True, + "min": Decimal("1"), + "max": None, + "default": None, + "locked": False, + }, + } +} + + # Default Bedrock models to seed DEFAULT_MODELS: list[dict[str, Any]] = [ { @@ -193,6 +225,7 @@ def seed_default_quota_assignment( "isReasoningModel": False, "supportsCaching": True, "isDefault": True, + "supportedParams": CLAUDE_CHAT_SUPPORTED_PARAMS, }, { "modelId": "global.anthropic.claude-sonnet-4-6", @@ -210,6 +243,7 @@ def seed_default_quota_assignment( "isReasoningModel": False, "supportsCaching": True, "isDefault": False, + "supportedParams": CLAUDE_CHAT_SUPPORTED_PARAMS, }, { "modelId": "amazon.nova-2-sonic-v1:0", @@ -227,6 +261,9 @@ def seed_default_quota_assignment( "isReasoningModel": False, "supportsCaching": False, "isDefault": False, + # Voice/bidi model: param shape differs from chat models. Leave + # supportedParams unset so the runtime passes through to whatever + # the BidiAgent path negotiates. }, ] @@ -296,6 +333,11 @@ def seed_default_models( "updatedAt": now, } + # Optional: per-model inference parameter capabilities. Stored as a + # nested map; absence means "passthrough" at runtime. + if "supportedParams" in model_def and model_def["supportedParams"] is not None: + item["supportedParams"] = model_def["supportedParams"] + try: table.put_item(Item=item) msg = f"Model '{model_def['modelName']}' ({model_id}) created" diff --git a/backend/src/agents/main_agent/base_agent.py b/backend/src/agents/main_agent/base_agent.py index e891d4dd..297d52dc 100644 --- a/backend/src/agents/main_agent/base_agent.py +++ b/backend/src/agents/main_agent/base_agent.py @@ -57,6 +57,7 @@ def __init__( caching_enabled: Optional[bool] = None, provider: Optional[str] = None, max_tokens: Optional[int] = None, + inference_params: Optional[Dict[str, Any]] = None, skip_persistence: bool = False, ): """ @@ -68,11 +69,14 @@ def __init__( auth_token: Raw OIDC token for forwarding to external MCP tools (optional) enabled_tools: List of tool IDs to enable. If None, all tools are enabled. model_id: Model ID to use (format depends on provider) - temperature: Model temperature (0.0 - 1.0) + temperature: Legacy. Folded into ``inference_params['temperature']`` if set. system_prompt: System prompt text caching_enabled: Whether to enable prompt caching (Bedrock only) provider: LLM provider ("bedrock", "openai", or "gemini") - max_tokens: Maximum tokens to generate (optional) + max_tokens: Legacy. Folded into ``inference_params['max_tokens']`` if set. + inference_params: Canonical-name -> value map for inference params + (temperature, top_p, top_k, max_tokens, thinking, ...). Wins over + the legacy ``temperature``/``max_tokens`` kwargs when both are set. skip_persistence: If True, don't persist messages (for preview sessions) """ # Basic state @@ -82,9 +86,20 @@ def __init__( self.enabled_tools = enabled_tools self.agent = None - # Initialize model configuration + # Merge legacy temperature/max_tokens into the canonical dict. Explicit + # ``inference_params`` values win over the positional kwargs so callers + # migrating to the new shape get predictable precedence. + resolved_params: Dict[str, Any] = dict(inference_params or {}) + if temperature is not None: + resolved_params.setdefault("temperature", temperature) + if max_tokens is not None: + resolved_params.setdefault("max_tokens", max_tokens) + self.model_config = ModelConfig.from_params( - model_id=model_id, temperature=temperature, caching_enabled=caching_enabled, provider=provider, max_tokens=max_tokens + model_id=model_id, + caching_enabled=caching_enabled, + provider=provider, + inference_params=resolved_params, ) # Frozen snapshot of agent-construction params, used when the turn @@ -95,9 +110,8 @@ def __init__( "enabled_tools": enabled_tools, "model_id": model_id, "provider": provider, - "temperature": temperature, "caching_enabled": caching_enabled, - "max_tokens": max_tokens, + "inference_params": dict(resolved_params), } # Load retry configuration from environment variables diff --git a/backend/src/agents/main_agent/config/constants.py b/backend/src/agents/main_agent/config/constants.py index b3f0c605..0b98f286 100644 --- a/backend/src/agents/main_agent/config/constants.py +++ b/backend/src/agents/main_agent/config/constants.py @@ -70,7 +70,6 @@ class Defaults: # --- Model --- MODEL_ID = "us.anthropic.claude-haiku-4-5-20251001-v1:0" - TEMPERATURE = 0.7 CACHING_ENABLED = True # --- AWS --- diff --git a/backend/src/agents/main_agent/core/model_config.py b/backend/src/agents/main_agent/core/model_config.py index d01d2458..c4cb515c 100644 --- a/backend/src/agents/main_agent/core/model_config.py +++ b/backend/src/agents/main_agent/core/model_config.py @@ -1,13 +1,16 @@ """ Model configuration for multi-provider LLM support (Bedrock, OpenAI, Gemini) """ +import logging import os from typing import Dict, Any, Optional -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import Enum from agents.main_agent.config.constants import EnvVars, Defaults +logger = logging.getLogger(__name__) + class ModelProvider(str, Enum): """Supported LLM providers""" @@ -16,6 +19,110 @@ class ModelProvider(str, Enum): GEMINI = "gemini" +# Canonical param name -> provider-native key path (dot-separated for nested SDK fields). +# A canonical param without an entry here is silently dropped for that provider. +_BEDROCK_PARAM_MAP: Dict[str, str] = { + "temperature": "temperature", + "top_p": "top_p", + # `top_k` and `thinking` aren't part of the Bedrock Converse standard + # request shape, so Strands routes them through `additional_request_fields` + # — anything else gets silently dropped by the SDK before hitting AWS. + "top_k": "additional_request_fields.top_k", + "max_tokens": "max_tokens", + "thinking": "additional_request_fields.thinking", +} + +_OPENAI_PARAM_MAP: Dict[str, str] = { + "temperature": "temperature", + "top_p": "top_p", + "max_tokens": "max_tokens", + "reasoning_effort": "reasoning_effort", +} + +_GEMINI_PARAM_MAP: Dict[str, str] = { + "temperature": "temperature", + "top_p": "top_p", + "top_k": "top_k", + "max_tokens": "max_output_tokens", + "thinking": "thinking_config", +} + +# Anthropic rejects these sampling params when extended thinking is enabled. +# Bedrock surfaces the same constraint; Gemini's docs are silent so we keep +# them. Suppression happens in `_apply_canonical_params` before dispatch. +_THINKING_INCOMPATIBLE = {"temperature", "top_p", "top_k"} + + +def _set_nested(target: Dict[str, Any], dotted_path: str, value: Any) -> None: + """Assign ``value`` into ``target`` at a dot-separated key path.""" + keys = dotted_path.split(".") + cursor = target + for key in keys[:-1]: + cursor = cursor.setdefault(key, {}) + cursor[keys[-1]] = value + + +def _shape_thinking_value(provider_label: str, value: Any) -> Any: + """Wrap a canonical ``thinking`` value into the provider-native object. + + The canonical value is an ``int`` budget (>= 1024), or falsy / 0 to disable. + Anthropic on Bedrock requires ``{type, budget_tokens}``; Gemini wants + ``{thinking_budget}``. Anything that's already a dict (admin pasting raw + SDK shape) is passed through verbatim. + """ + if isinstance(value, dict): + return value + if not value: + return None + if provider_label == "bedrock": + return {"type": "enabled", "budget_tokens": int(value)} + if provider_label == "gemini": + return {"thinking_budget": int(value)} + return value + + +def _apply_canonical_params( + target: Dict[str, Any], + canonical_params: Dict[str, Any], + provider_map: Dict[str, str], + provider_label: str, +) -> None: + """Translate canonical inference params into provider-native shape. + + Unsupported params are dropped with a warning so callers can layer admin + defaults plus user overrides without worrying about provider quirks. + Sampling params that conflict with extended thinking are also dropped + (Anthropic rejects ``temperature``/``top_p``/``top_k`` while thinking is on). + """ + thinking_value = canonical_params.get("thinking") + thinking_enabled = bool(thinking_value) and provider_map.get("thinking") is not None + + for name, value in canonical_params.items(): + if value is None: + continue + if thinking_enabled and name in _THINKING_INCOMPATIBLE: + logger.debug( + "Dropping '%s' for provider %s because extended thinking is enabled", + name, + provider_label, + ) + continue + native_path = provider_map.get(name) + if native_path is None: + logger.debug( + "Dropping unsupported inference param '%s' for provider %s", + name, + provider_label, + ) + continue + if name == "thinking": + shaped = _shape_thinking_value(provider_label, value) + if shaped is None: + continue + value = shaped + _set_nested(target, native_path, value) + + @dataclass class RetryConfig: """Configuration for model invocation retry behavior. @@ -68,12 +175,18 @@ def from_env(cls) -> "RetryConfig": @dataclass class ModelConfig: - """Configuration for multi-provider LLM models""" + """Configuration for multi-provider LLM models. + + Inference params (temperature, top_p, max_tokens, thinking, ...) live in + ``inference_params`` keyed by canonical name. They're translated into the + provider-native shape inside ``to__config()``. An empty dict + means "send no inference params" — Anthropic recommends this for newer + Opus models that reject ``temperature`` outright. + """ model_id: str = Defaults.MODEL_ID - temperature: float = Defaults.TEMPERATURE caching_enabled: bool = Defaults.CACHING_ENABLED provider: ModelProvider = ModelProvider.BEDROCK - max_tokens: Optional[int] = None + inference_params: Dict[str, Any] = field(default_factory=dict) retry_config: Optional[RetryConfig] = None def get_provider(self) -> ModelProvider: @@ -103,18 +216,9 @@ def get_provider(self) -> ModelProvider: return self.provider def to_bedrock_config(self) -> Dict[str, Any]: - """ - Convert to BedrockModel configuration dictionary - - Returns: - dict: Configuration for BedrockModel initialization - """ - from strands.models import CacheConfig - - config = { - "model_id": self.model_id, - "temperature": self.temperature - } + """Convert to BedrockModel kwargs, translating canonical inference params.""" + config: Dict[str, Any] = {"model_id": self.model_id} + _apply_canonical_params(config, self.inference_params, _BEDROCK_PARAM_MAP, "bedrock") # TODO: Re-enable once Bedrock supports cachePoint blocks alongside # non-PDF document blocks (.md, .docx, etc.). Currently causes: @@ -123,10 +227,9 @@ def to_bedrock_config(self) -> Dict[str, Any]: # to the Anthropic format. # See: https://github.com/strands-agents/sdk-python/pull/1438 # if self.caching_enabled: + # from strands.models import CacheConfig # config["cache_config"] = CacheConfig(strategy="auto") - # Configure botocore-level retries and timeouts for Bedrock API calls - # This is the first retry layer (HTTP-level), fires before Strands SDK retries if self.retry_config: from botocore.config import Config as BotocoreConfig config["boto_client_config"] = BotocoreConfig( @@ -141,93 +244,60 @@ def to_bedrock_config(self) -> Dict[str, Any]: return config def to_openai_config(self) -> Dict[str, Any]: - """ - Convert to OpenAI configuration dictionary - - Returns: - dict: Configuration for OpenAIModel initialization - """ - config = { - "model_id": self.model_id, - "params": { - "temperature": self.temperature, - } - } - - if self.max_tokens: - config["params"]["max_tokens"] = self.max_tokens - + """Convert to OpenAIModel kwargs, translating canonical inference params.""" + params: Dict[str, Any] = {} + _apply_canonical_params(params, self.inference_params, _OPENAI_PARAM_MAP, "openai") + config: Dict[str, Any] = {"model_id": self.model_id} + if params: + config["params"] = params return config def to_gemini_config(self) -> Dict[str, Any]: - """ - Convert to Gemini configuration dictionary - - Returns: - dict: Configuration for GeminiModel initialization - """ - config = { - "model_id": self.model_id, - "params": { - "temperature": self.temperature, - } - } - - if self.max_tokens: - config["params"]["max_output_tokens"] = self.max_tokens - + """Convert to GeminiModel kwargs, translating canonical inference params.""" + params: Dict[str, Any] = {} + _apply_canonical_params(params, self.inference_params, _GEMINI_PARAM_MAP, "gemini") + config: Dict[str, Any] = {"model_id": self.model_id} + if params: + config["params"] = params return config def to_dict(self) -> Dict[str, Any]: - """ - Convert to dictionary representation - - Returns: - dict: Configuration as dictionary - """ + """Serialize to a plain dict (for logging / debug).""" return { "model_id": self.model_id, - "temperature": self.temperature, "caching_enabled": self.caching_enabled, "provider": self.get_provider().value, - "max_tokens": self.max_tokens + "inference_params": dict(self.inference_params), } @classmethod def from_params( cls, model_id: Optional[str] = None, - temperature: Optional[float] = None, caching_enabled: Optional[bool] = None, provider: Optional[str] = None, - max_tokens: Optional[int] = None + inference_params: Optional[Dict[str, Any]] = None, ) -> "ModelConfig": - """ - Create ModelConfig from optional parameters + """Create ModelConfig from optional parameters. Args: model_id: Model ID (provider-specific format) - temperature: Model temperature (0.0 - 1.0) caching_enabled: Whether to enable prompt caching (Bedrock only) provider: Provider name ("bedrock", "openai", or "gemini") - max_tokens: Maximum tokens to generate - - Returns: - ModelConfig: Configuration instance with defaults applied + inference_params: Canonical-name -> value map (temperature, top_p, + max_tokens, thinking, ...). Each provider's translation table + drops unsupported keys silently. """ - # Parse provider provider_enum = ModelProvider.BEDROCK if provider: try: provider_enum = ModelProvider(provider.lower()) except ValueError: - # Invalid provider, will auto-detect from model_id - pass + pass # Invalid provider, fall back to default + auto-detect via model_id return cls( model_id=model_id or cls.model_id, - temperature=temperature if temperature is not None else cls.temperature, caching_enabled=caching_enabled if caching_enabled is not None else cls.caching_enabled, provider=provider_enum, - max_tokens=max_tokens + inference_params=dict(inference_params) if inference_params else {}, ) diff --git a/backend/src/agents/main_agent/streaming/stream_coordinator.py b/backend/src/agents/main_agent/streaming/stream_coordinator.py index 73fd7d88..79d6aebb 100644 --- a/backend/src/agents/main_agent/streaming/stream_coordinator.py +++ b/backend/src/agents/main_agent/streaming/stream_coordinator.py @@ -601,15 +601,17 @@ async def _persist_paused_turn_snapshot( try: now = datetime.now(timezone.utc) + inference_params = snapshot_source.get("inference_params") or {} snapshot = PausedTurnSnapshot( enabled_tools=snapshot_source.get("enabled_tools"), model_id=snapshot_source.get("model_id"), provider=snapshot_source.get("provider"), - temperature=snapshot_source.get("temperature"), + temperature=inference_params.get("temperature"), system_prompt=snapshot_source.get("system_prompt"), caching_enabled=snapshot_source.get("caching_enabled"), - max_tokens=snapshot_source.get("max_tokens"), + max_tokens=inference_params.get("max_tokens"), agent_type=snapshot_source.get("agent_type"), + inference_params=dict(inference_params) if inference_params else None, captured_at=now.isoformat(), expires_at=(now + timedelta(hours=1)).isoformat(), ) diff --git a/backend/src/apis/inference_api/chat/models.py b/backend/src/apis/inference_api/chat/models.py index 14071bb1..731befad 100644 --- a/backend/src/apis/inference_api/chat/models.py +++ b/backend/src/apis/inference_api/chat/models.py @@ -43,6 +43,11 @@ class InvocationRequest(BaseModel): file_upload_ids: Optional[List[str]] = None # Upload IDs to resolve from S3 provider: Optional[str] = None # LLM provider: "bedrock", "openai", or "gemini" max_tokens: Optional[int] = None # Maximum tokens to generate + # Per-request canonical inference param overrides (temperature, top_p, + # top_k, max_tokens, thinking, reasoning_effort, ...). Layered on top of + # the managed model's admin defaults. Unsupported params are dropped + # silently by the merge step in routes.py. + inference_params: Optional[Dict[str, Any]] = None # NOTE: Field name is 'rag_assistant_id' to avoid collision with AWS Bedrock # AgentCore Runtime's internal 'assistant_id' field handling. # AgentCore Runtime returns 424 when it sees a non-empty 'assistant_id' field, @@ -131,7 +136,7 @@ class ConverseRequest(BaseModel): model_id: str # Bedrock model ID (e.g. "us.anthropic.claude-haiku-4-5-20251001-v1:0") messages: List[ConverseMessage] system_prompt: Optional[str] = None - temperature: Optional[float] = 0.7 + temperature: Optional[float] = None max_tokens: Optional[int] = 4096 stream: bool = False # Whether to stream the response via SSE top_p: Optional[float] = None diff --git a/backend/src/apis/inference_api/chat/routes.py b/backend/src/apis/inference_api/chat/routes.py index 30fb74c0..7da8d763 100644 --- a/backend/src/apis/inference_api/chat/routes.py +++ b/backend/src/apis/inference_api/chat/routes.py @@ -63,45 +63,135 @@ def is_preview_session(session_id: str) -> bool: return session_id.startswith(PREVIEW_SESSION_PREFIX) -async def _resolve_caching_enabled(model_id: str | None, explicit_caching_enabled: bool | None) -> bool | None: +async def _find_managed_model(model_id: str | None): + """Best-effort lookup of a managed-model record by external model ID.""" + if not model_id: + return None + try: + managed_models = await list_managed_models() + for model in managed_models: + if model.model_id == model_id: + return model + except Exception: + logger.warning("Failed to look up managed model %s", model_id) + return None + + +def _merge_inference_params( + managed_model, + request_params: dict, +) -> dict: + """Merge admin-configured defaults with request-supplied inference params. + + For each canonical param the managed model declares: + * unsupported -> drop the request value (logged) and don't set a default + * supported with admin default -> use the default unless the request + provides a value within bounds; out-of-bounds values are clamped. + + Request keys for params the managed model says nothing about pass through + untouched — the per-provider translation table will drop unknowns. """ - Resolve whether caching should be enabled for a request. + merged: dict = {} + spec_map = {} + if managed_model and managed_model.supported_params: + spec_map = managed_model.supported_params.params or {} + + seen_keys: set[str] = set() + for name, spec in spec_map.items(): + seen_keys.add(name) + if not spec.supported: + if name in request_params: + logger.info( + "Dropping unsupported inference param '%s' for model %s", + name, + getattr(managed_model, "model_id", "?"), + ) + continue + + # Locked params always use the admin default — user overrides are + # dropped without error. Lets admins pin e.g. `temperature` for + # reproducibility while leaving `max_tokens` user-tunable. + if spec.locked: + if spec.default is not None: + merged[name] = spec.default + continue + + if name in request_params and request_params[name] is not None: + value = request_params[name] + if isinstance(value, (int, float)): + if spec.min is not None and value < spec.min: + value = spec.min + if spec.max is not None and value > spec.max: + value = spec.max + merged[name] = value + elif spec.default is not None: + merged[name] = spec.default + + # Pass through request keys the admin spec doesn't mention. The provider + # translation table will silently drop ones the SDK doesn't know. + for name, value in request_params.items(): + if name in seen_keys or value is None: + continue + merged[name] = value + + # Final cross-param safety check. Anthropic rejects requests where + # `thinking.budget_tokens >= max_tokens`, and the per-param clamping + # above can't catch it (each param is bounded independently). When + # both are set and inconsistent, drop `thinking` so the response still + # streams instead of erroring out — the user just doesn't get a + # reasoning trace this turn. Logged so the gap is visible in metrics. + thinking = merged.get("thinking") + max_tokens = merged.get("max_tokens") + if ( + isinstance(thinking, int) + and not isinstance(thinking, bool) + and isinstance(max_tokens, int) + and not isinstance(max_tokens, bool) + and thinking >= max_tokens + ): + logger.warning( + "Dropping thinking budget %d for model %s — not less than max_tokens %d", + thinking, + getattr(managed_model, "model_id", "?"), + max_tokens, + ) + merged.pop("thinking", None) - Priority: - 1. If explicitly set in request, use that value - 2. If model_id provided, look up the managed model's supports_caching field - 3. Otherwise return None (let agent use default) + return merged - Args: - model_id: The model ID from the request - explicit_caching_enabled: Explicit caching setting from request - Returns: - bool or None: Whether caching should be enabled +async def _resolve_model_settings( + model_id: str | None, + explicit_caching_enabled: bool | None, + request_inference_params: dict | None, +) -> tuple[bool | None, dict]: + """Resolve runtime model knobs from the managed-model registry. + + Returns ``(caching_enabled, inference_params)``. A single registry lookup + drives both, replacing the prior per-concern lookups. """ - # If explicitly set in request, use that value - if explicit_caching_enabled is not None: - return explicit_caching_enabled + request_params = dict(request_inference_params or {}) - # If no model_id, let agent use default if not model_id: - return None + return explicit_caching_enabled, request_params - # Look up the managed model to check supports_caching - try: - managed_models = await list_managed_models() - for model in managed_models: - if model.model_id == model_id: - logger.debug("Found managed model, checking supports_caching") - return model.supports_caching + managed_model = await _find_managed_model(model_id) - # Model not found in managed models - use default - logger.debug("Model not found in managed models, using default caching behavior") - return None + if explicit_caching_enabled is not None: + caching = explicit_caching_enabled + elif managed_model is not None: + caching = managed_model.supports_caching + else: + caching = None - except Exception as e: - logger.warning("Failed to look up managed model for caching") - return None + inference_params = _merge_inference_params(managed_model, request_params) + return caching, inference_params + + +async def _resolve_caching_enabled(model_id: str | None, explicit_caching_enabled: bool | None) -> bool | None: + """Backward-compat wrapper around :func:`_resolve_model_settings`.""" + caching, _ = await _resolve_model_settings(model_id, explicit_caching_enabled, None) + return caching # ============================================================ @@ -585,42 +675,60 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g ) caching_enabled = snapshot.caching_enabled + # Snapshot wins on resume so an authorized turn finishes against the + # exact param shape it was authorized for, even if admin defaults + # have since changed. Fall back to the legacy fields for snapshots + # written before inference_params was added. + resume_inference_params = snapshot.inference_params or {} + if not resume_inference_params: + if snapshot.temperature is not None: + resume_inference_params["temperature"] = snapshot.temperature + if snapshot.max_tokens is not None: + resume_inference_params["max_tokens"] = snapshot.max_tokens agent = await get_agent( session_id=input_data.session_id, user_id=user_id, auth_token=auth_token, enabled_tools=snapshot.enabled_tools, model_id=snapshot.model_id, - temperature=snapshot.temperature, system_prompt=snapshot.system_prompt, caching_enabled=snapshot.caching_enabled, provider=snapshot.provider, - max_tokens=snapshot.max_tokens, + inference_params=resume_inference_params, agent_type=snapshot.agent_type, ) else: - # Resolve caching_enabled based on managed model configuration - # This allows admins to disable caching for models that don't support it - caching_enabled = await _resolve_caching_enabled(model_id=input_data.model_id, explicit_caching_enabled=input_data.caching_enabled) + # Build the canonical request inference-params dict. The frontend + # sends ``inference_params`` directly; legacy ``temperature`` / + # ``max_tokens`` fields are folded in for older clients and + # treated as defaults that lose to anything in ``inference_params``. + request_inference_params: dict = dict(input_data.inference_params or {}) + if input_data.temperature is not None: + request_inference_params.setdefault("temperature", input_data.temperature) + if input_data.max_tokens is not None: + request_inference_params.setdefault("max_tokens", input_data.max_tokens) + + # Single registry lookup resolves caching + inference params, + # merging admin defaults with request overrides. + caching_enabled, inference_params = await _resolve_model_settings( + model_id=input_data.model_id, + explicit_caching_enabled=input_data.caching_enabled, + request_inference_params=request_inference_params, + ) if caching_enabled is False: logger.info("Prompt caching disabled for model") - # Get agent instance with user-specific configuration - # AgentCore Memory tracks preferences across sessions per user_id - # Supports multiple LLM providers: AWS Bedrock, OpenAI, and Google Gemini - # Use augmented message and assistant system prompt if assistant RAG was applied agent = await get_agent( session_id=input_data.session_id, user_id=user_id, auth_token=auth_token, enabled_tools=input_data.enabled_tools, model_id=input_data.model_id, - temperature=input_data.temperature, system_prompt=system_prompt, # Use assistant's instructions if available caching_enabled=caching_enabled, provider=input_data.provider, - max_tokens=input_data.max_tokens, + inference_params=inference_params, agent_type=input_data.agent_type, ) diff --git a/backend/src/apis/inference_api/chat/service.py b/backend/src/apis/inference_api/chat/service.py index 6d885a85..35e4fdf1 100644 --- a/backend/src/apis/inference_api/chat/service.py +++ b/backend/src/apis/inference_api/chat/service.py @@ -3,10 +3,11 @@ Contains business logic for chat operations, including agent creation and management. """ +import json import logging import hashlib import os -from typing import Optional, List, Tuple +from typing import Any, Dict, Optional, List, Tuple import boto3 @@ -37,16 +38,23 @@ def _hash_tools(tools: Optional[List[str]]) -> str: return hashlib.md5(tools_str.encode()).hexdigest()[:8] +def _hash_inference_params(params: Optional[Dict[str, Any]]) -> str: + """Stable hash of an inference-params dict for the agent cache key.""" + if not params: + return "none" + payload = json.dumps(params, sort_keys=True, default=str) + return hashlib.md5(payload.encode()).hexdigest()[:8] + + def _create_cache_key( session_id: str, user_id: Optional[str], enabled_tools: Optional[List[str]], model_id: Optional[str], - temperature: Optional[float], + inference_params: Optional[Dict[str, Any]], system_prompt: Optional[str], caching_enabled: Optional[bool], provider: Optional[str], - max_tokens: Optional[int], freshness_hash: str, agent_type: Optional[str], ) -> Tuple: @@ -58,7 +66,6 @@ def _create_cache_key( admin edits a tool's config, the hash changes and the cache misses, so the next turn builds a fresh agent with the new config. """ - # Hash the tools list for stable key tools_hash = _hash_tools(enabled_tools) # Hash system prompt if provided (can be very long) @@ -71,11 +78,10 @@ def _create_cache_key( user_id or session_id, tools_hash, model_id or "default", - temperature or 0.0, + _hash_inference_params(inference_params), prompt_hash, caching_enabled or False, provider or "bedrock", - max_tokens or 0, freshness_hash, agent_type or "chat", ) @@ -100,6 +106,7 @@ async def get_agent( provider: Optional[str] = None, max_tokens: Optional[int] = None, agent_type: Optional[str] = None, + inference_params: Optional[Dict[str, Any]] = None, ) -> BaseAgent: """ Get or create agent instance with current configuration for session @@ -114,17 +121,29 @@ async def get_agent( user_id: User identifier (defaults to session_id) enabled_tools: List of tool IDs to enable model_id: Model ID (provider-specific format) - temperature: Model temperature + temperature: Legacy. Folded into ``inference_params['temperature']``. system_prompt: System prompt text caching_enabled: Whether to enable prompt caching (Bedrock only) provider: LLM provider ("bedrock", "openai", or "gemini") - max_tokens: Maximum tokens to generate + max_tokens: Legacy. Folded into ``inference_params['max_tokens']``. + agent_type: Agent factory variant ("chat" or "skill") + inference_params: Canonical-name -> value map for inference params. + When provided, supersedes the legacy ``temperature``/``max_tokens`` + kwargs (the explicit dict wins on key conflicts). Returns: BaseAgent subclass instance (cached or newly created) """ from apis.shared.tools.freshness import get_freshness_hash + # Merge legacy temperature/max_tokens into inference_params so the cache + # key and BaseAgent see the same canonical dict. + merged_params: Dict[str, Any] = dict(inference_params or {}) + if temperature is not None: + merged_params.setdefault("temperature", temperature) + if max_tokens is not None: + merged_params.setdefault("max_tokens", max_tokens) + freshness_hash = await get_freshness_hash(enabled_tools or []) cache_key = _create_cache_key( @@ -132,11 +151,10 @@ async def get_agent( user_id=user_id, enabled_tools=enabled_tools, model_id=model_id, - temperature=temperature, + inference_params=merged_params, system_prompt=system_prompt, caching_enabled=caching_enabled, provider=provider, - max_tokens=max_tokens, freshness_hash=freshness_hash, agent_type=agent_type, ) @@ -160,11 +178,10 @@ async def get_agent( auth_token=auth_token, enabled_tools=enabled_tools, model_id=model_id, - temperature=temperature, system_prompt=system_prompt, caching_enabled=caching_enabled, provider=provider, - max_tokens=max_tokens, + inference_params=merged_params, ) # Stamp the type onto the construction snapshot so a paused turn can diff --git a/backend/src/apis/shared/models/managed_models.py b/backend/src/apis/shared/models/managed_models.py index bd067d0f..7c2e360b 100644 --- a/backend/src/apis/shared/models/managed_models.py +++ b/backend/src/apis/shared/models/managed_models.py @@ -219,6 +219,7 @@ async def _create_managed_model_cloud(model_data: ManagedModelCreate, table_name knowledge_cutoff_date=model_data.knowledge_cutoff_date, supports_caching=_resolve_supports_caching(model_data.supports_caching, model_data.provider), is_default=model_data.is_default, + supported_params=model_data.supported_params, created_at=now, updated_at=now, ) @@ -257,6 +258,8 @@ async def _create_managed_model_cloud(model_data: ManagedModelCreate, table_name item['cacheReadPricePerMillionTokens'] = model_data.cache_read_price_per_million_tokens if model_data.knowledge_cutoff_date is not None: item['knowledgeCutoffDate'] = model_data.knowledge_cutoff_date + if model_data.supported_params is not None: + item['supportedParams'] = model_data.supported_params.model_dump(by_alias=True, exclude_none=True) # Convert floats to Decimal for DynamoDB item = _python_to_dynamodb(item) diff --git a/backend/src/apis/shared/models/models.py b/backend/src/apis/shared/models/models.py index e58cd45b..87df87f6 100644 --- a/backend/src/apis/shared/models/models.py +++ b/backend/src/apis/shared/models/models.py @@ -4,11 +4,90 @@ app API and inference API deployments. """ -from pydantic import BaseModel, Field, ConfigDict -from typing import List, Optional +from pydantic import BaseModel, Field, ConfigDict, model_validator +from typing import Any, Dict, List, Optional from datetime import datetime +class ModelParamSpec(BaseModel): + """Capability + bounds for a single inference parameter on a model. + + Stored per-model in the registry. Drives both the admin form (what's + tweakable, what bounds to enforce) and the runtime gate (whether to + pass the param through to the provider SDK at all). + """ + model_config = ConfigDict(populate_by_name=True) + + supported: bool = True + min: Optional[float] = None + max: Optional[float] = None + default: Optional[Any] = Field( + None, + description="Value sent when the user doesn't override. Type depends on the param " + "(number for temperature/top_p, bool for thinking, etc.)." + ) + locked: bool = Field( + False, + description="If true, the admin default is final and user overrides are ignored. " + "Used by Phase 2 user-tweak surface; ignored today." + ) + + @model_validator(mode="after") + def _check_bounds(self) -> "ModelParamSpec": + if self.min is not None and self.max is not None and self.min > self.max: + raise ValueError("min must be <= max") + if isinstance(self.default, (int, float)): + if self.min is not None and self.default < self.min: + raise ValueError("default must be >= min") + if self.max is not None and self.default > self.max: + raise ValueError("default must be <= max") + return self + + +class SupportedParams(BaseModel): + """Per-model inference parameter capability map. + + Open-ended dict keyed by canonical param name (`temperature`, `top_p`, + `top_k`, `max_tokens`, `thinking`, `reasoning_effort`, ...). Each + provider's `ModelConfig.to__config()` translates canonical + names into the SDK-specific shape and silently drops unknown keys. + + For ``thinking``, ``ModelParamSpec.default`` carries the budget in + tokens (int >= 1024, or 0/None to disable). The provider translator + wraps it into the Anthropic ``{type: "enabled", budget_tokens: N}`` + shape on the way out. + """ + model_config = ConfigDict(populate_by_name=True) + + params: Dict[str, ModelParamSpec] = Field(default_factory=dict) + + @model_validator(mode="after") + def _check_thinking_invariants(self) -> "SupportedParams": + """Enforce Anthropic's extended-thinking rules at config time. + + Catches the two failure modes that would otherwise only surface as a + Bedrock 400 mid-conversation: budget below the 1024 floor, or budget + >= max_tokens. Skipped when thinking is unsupported or disabled. + """ + thinking = self.params.get("thinking") + if thinking is None or not thinking.supported: + return self + budget = thinking.default + if budget in (None, False, 0): + return self + # bool is a subclass of int — reject it explicitly so a stale `true` + # default from the old toggle schema fails loudly instead of being + # interpreted as a 1-token budget. + if isinstance(budget, bool) or not isinstance(budget, int): + raise ValueError("thinking default must be an int budget (>= 1024) or null/0") + if budget < 1024: + raise ValueError("thinking budget must be >= 1024") + max_tokens = self.params.get("max_tokens") + if max_tokens and isinstance(max_tokens.default, int) and budget >= max_tokens.default: + raise ValueError("thinking budget must be < max_tokens default") + return self + + class ManagedModelCreate(BaseModel): """Request model for creating a managed model.""" model_config = ConfigDict(populate_by_name=True) @@ -60,6 +139,12 @@ class ManagedModelCreate(BaseModel): alias="isDefault", description="Whether this is the default model for new sessions. Only one model can be default." ) + supported_params: Optional[SupportedParams] = Field( + None, + alias="supportedParams", + description="Per-model inference parameter capabilities (temperature, top_p, etc.). " + "When None, the runtime sends no inference params." + ) class ManagedModelUpdate(BaseModel): @@ -112,6 +197,11 @@ class ManagedModelUpdate(BaseModel): alias="isDefault", description="Whether this is the default model for new sessions." ) + supported_params: Optional[SupportedParams] = Field( + None, + alias="supportedParams", + description="Per-model inference parameter capabilities." + ) class ManagedModel(BaseModel): @@ -163,5 +253,10 @@ class ManagedModel(BaseModel): alias="isDefault", description="Whether this is the default model for new sessions. Only one model can be default." ) + supported_params: Optional[SupportedParams] = Field( + None, + alias="supportedParams", + description="Per-model inference parameter capabilities." + ) created_at: datetime = Field(..., alias="createdAt") updated_at: datetime = Field(..., alias="updatedAt") diff --git a/backend/src/apis/shared/sessions/models.py b/backend/src/apis/shared/sessions/models.py index 80911b77..ff7b909f 100644 --- a/backend/src/apis/shared/sessions/models.py +++ b/backend/src/apis/shared/sessions/models.py @@ -107,6 +107,12 @@ class PausedTurnSnapshot(BaseModel): caching_enabled: Optional[bool] = Field(default=None, alias="cachingEnabled") max_tokens: Optional[int] = Field(default=None, alias="maxTokens") agent_type: Optional[str] = Field(default=None, alias="agentType") + inference_params: Optional[Dict[str, Any]] = Field( + default=None, + alias="inferenceParams", + description="Canonical inference param dict captured at pause. When present, " + "supersedes the legacy temperature/max_tokens fields on resume." + ) captured_at: str = Field(..., alias="capturedAt", description="ISO 8601 timestamp when the turn paused") expires_at: str = Field(..., alias="expiresAt", description="ISO 8601 timestamp after which the snapshot is no longer valid for resume") diff --git a/backend/tests/agents/main_agent/core/test_model_config.py b/backend/tests/agents/main_agent/core/test_model_config.py index 7aac7789..9489f909 100644 --- a/backend/tests/agents/main_agent/core/test_model_config.py +++ b/backend/tests/agents/main_agent/core/test_model_config.py @@ -19,8 +19,9 @@ class TestModelConfigDefaults: def test_default_model_id(self, model_config: ModelConfig): assert model_config.model_id == "us.anthropic.claude-haiku-4-5-20251001-v1:0" - def test_default_temperature(self, model_config: ModelConfig): - assert model_config.temperature == 0.7 + def test_default_inference_params_is_empty(self, model_config: ModelConfig): + """No default temperature — newer reasoning models reject any value.""" + assert model_config.inference_params == {} def test_default_caching_enabled(self, model_config: ModelConfig): assert model_config.caching_enabled is True @@ -28,9 +29,6 @@ def test_default_caching_enabled(self, model_config: ModelConfig): def test_default_provider(self, model_config: ModelConfig): assert model_config.provider == ModelProvider.BEDROCK - def test_default_max_tokens(self, model_config: ModelConfig): - assert model_config.max_tokens is None - def test_default_retry_config(self, model_config: ModelConfig): assert model_config.retry_config is None @@ -107,7 +105,7 @@ def test_bedrock_config_with_caching_disabled_due_to_bedrock_limitation(self): result = cfg.to_bedrock_config() assert result["model_id"] == cfg.model_id - assert result["temperature"] == cfg.temperature + assert "temperature" not in result # No default temperature emitted assert "cache_config" not in result def test_bedrock_config_without_caching(self): @@ -116,9 +114,78 @@ def test_bedrock_config_without_caching(self): result = cfg.to_bedrock_config() assert result["model_id"] == cfg.model_id - assert result["temperature"] == cfg.temperature assert "cache_config" not in result + def test_bedrock_config_emits_temperature_only_when_set(self): + """Inference params only ride along when explicitly configured.""" + cfg = ModelConfig(inference_params={"temperature": 0.4, "top_p": 0.9}) + result = cfg.to_bedrock_config() + + assert result["temperature"] == 0.4 + assert result["top_p"] == 0.9 + + def test_bedrock_config_translates_thinking_to_nested_field(self): + """Canonical 'thinking' carries an int budget that gets wrapped into the + Anthropic ``{type, budget_tokens}`` shape under + additional_request_fields on Bedrock — that's the field name Strands' + BedrockConfig actually forwards to the Converse API.""" + cfg = ModelConfig(inference_params={"thinking": 4096}) + result = cfg.to_bedrock_config() + + assert result["additional_request_fields"]["thinking"] == { + "type": "enabled", + "budget_tokens": 4096, + } + assert "thinking" not in result + assert "additional_model_request_fields" not in result + + def test_bedrock_config_routes_top_k_through_additional_request_fields(self): + """top_k isn't on the Bedrock Converse standard shape — Strands needs it + in additional_request_fields or the SDK silently drops it.""" + cfg = ModelConfig(inference_params={"top_k": 40}) + result = cfg.to_bedrock_config() + + assert result["additional_request_fields"]["top_k"] == 40 + assert "top_k" not in result + + def test_bedrock_config_thinking_suppresses_sampling_params(self): + """Anthropic rejects temperature/top_p/top_k while extended thinking is on, + so the translator drops them before dispatch.""" + cfg = ModelConfig( + inference_params={ + "thinking": 2048, + "temperature": 0.7, + "top_p": 0.9, + "top_k": 40, + "max_tokens": 8192, + } + ) + result = cfg.to_bedrock_config() + + assert "temperature" not in result + assert "top_p" not in result + assert "top_k" not in result + assert result["max_tokens"] == 8192 + assert result["additional_request_fields"]["thinking"]["budget_tokens"] == 2048 + + def test_bedrock_config_thinking_disabled_passes_sampling_params_through(self): + """A 0 / None thinking value is a no-op — sampling params survive.""" + cfg = ModelConfig( + inference_params={"thinking": 0, "temperature": 0.5, "top_p": 0.8} + ) + result = cfg.to_bedrock_config() + + assert "additional_request_fields" not in result + assert result["temperature"] == 0.5 + assert result["top_p"] == 0.8 + + def test_bedrock_config_drops_unknown_canonical_param(self): + """Provider translation table silently drops keys it doesn't know.""" + cfg = ModelConfig(inference_params={"reasoning_effort": "high"}) + result = cfg.to_bedrock_config() + + assert "reasoning_effort" not in result + def test_bedrock_config_with_retry(self, retry_config: RetryConfig): """Req 1.7 — RetryConfig present → boto_client_config in output.""" cfg = ModelConfig(caching_enabled=False, retry_config=retry_config) @@ -142,7 +209,7 @@ class TestToOpenAIConfig: def test_openai_config_basic(self): """Req 1.8 — dict with model_id and params.temperature.""" - cfg = ModelConfig(model_id="gpt-4o", temperature=0.5) + cfg = ModelConfig(model_id="gpt-4o", inference_params={"temperature": 0.5}) result = cfg.to_openai_config() assert result["model_id"] == "gpt-4o" @@ -150,23 +217,30 @@ def test_openai_config_basic(self): def test_openai_config_with_max_tokens(self): """Req 1.9 — max_tokens appears in params.""" - cfg = ModelConfig(model_id="gpt-4o", max_tokens=1024) + cfg = ModelConfig(model_id="gpt-4o", inference_params={"max_tokens": 1024}) result = cfg.to_openai_config() assert result["params"]["max_tokens"] == 1024 - def test_openai_config_without_max_tokens(self): - cfg = ModelConfig(model_id="gpt-4o", max_tokens=None) + def test_openai_config_without_inference_params_omits_params_block(self): + cfg = ModelConfig(model_id="gpt-4o") + result = cfg.to_openai_config() + + assert "params" not in result + + def test_openai_config_drops_top_k(self): + """OpenAI doesn't support top_k — translation table drops it silently.""" + cfg = ModelConfig(model_id="gpt-4o", inference_params={"top_k": 40}) result = cfg.to_openai_config() - assert "max_tokens" not in result["params"] + assert "params" not in result class TestToGeminiConfig: """Validates: Requirement 1.9""" def test_gemini_config_basic(self): - cfg = ModelConfig(model_id="gemini-pro", temperature=0.3) + cfg = ModelConfig(model_id="gemini-pro", inference_params={"temperature": 0.3}) result = cfg.to_gemini_config() assert result["model_id"] == "gemini-pro" @@ -174,16 +248,16 @@ def test_gemini_config_basic(self): def test_gemini_config_with_max_tokens(self): """Req 1.9 — max_tokens → max_output_tokens in params.""" - cfg = ModelConfig(model_id="gemini-pro", max_tokens=2048) + cfg = ModelConfig(model_id="gemini-pro", inference_params={"max_tokens": 2048}) result = cfg.to_gemini_config() assert result["params"]["max_output_tokens"] == 2048 - def test_gemini_config_without_max_tokens(self): - cfg = ModelConfig(model_id="gemini-pro", max_tokens=None) + def test_gemini_config_without_inference_params_omits_params_block(self): + cfg = ModelConfig(model_id="gemini-pro") result = cfg.to_gemini_config() - assert "max_output_tokens" not in result["params"] + assert "params" not in result # --------------------------------------------------------------------------- @@ -203,10 +277,9 @@ def test_to_dict_keys(self, model_config: ModelConfig): d = model_config.to_dict() assert set(d.keys()) == { "model_id", - "temperature", "caching_enabled", "provider", - "max_tokens", + "inference_params", } @@ -222,24 +295,22 @@ def test_from_params_all_defaults(self): default = ModelConfig() assert cfg.model_id == default.model_id - assert cfg.temperature == default.temperature + assert cfg.inference_params == default.inference_params assert cfg.caching_enabled == default.caching_enabled assert cfg.provider == default.provider def test_from_params_custom_values(self): cfg = ModelConfig.from_params( model_id="gpt-4o", - temperature=0.2, caching_enabled=False, provider="openai", - max_tokens=512, + inference_params={"temperature": 0.2, "max_tokens": 512}, ) assert cfg.model_id == "gpt-4o" - assert cfg.temperature == 0.2 + assert cfg.inference_params == {"temperature": 0.2, "max_tokens": 512} assert cfg.caching_enabled is False assert cfg.provider == ModelProvider.OPENAI - assert cfg.max_tokens == 512 def test_from_params_invalid_provider_defaults_to_bedrock(self): """Req 1.12 — invalid provider string → BEDROCK.""" diff --git a/backend/tests/agents/main_agent/property/conftest.py b/backend/tests/agents/main_agent/property/conftest.py index 5a944b49..d7db8b78 100644 --- a/backend/tests/agents/main_agent/property/conftest.py +++ b/backend/tests/agents/main_agent/property/conftest.py @@ -47,12 +47,15 @@ def st_model_config(draw): caching_enabled = draw(st.booleans()) max_tokens = draw(st.one_of(st.none(), st.integers(min_value=1, max_value=8192))) + inference_params: dict = {"temperature": temperature} + if max_tokens is not None: + inference_params["max_tokens"] = max_tokens + return ModelConfig( model_id=model_id, - temperature=temperature, caching_enabled=caching_enabled, provider=provider, - max_tokens=max_tokens, + inference_params=inference_params, retry_config=None, # retry_config is not part of to_dict round-trip ) diff --git a/backend/tests/agents/main_agent/property/test_pbt_agent_core.py b/backend/tests/agents/main_agent/property/test_pbt_agent_core.py index d099dd46..bbb7fbef 100644 --- a/backend/tests/agents/main_agent/property/test_pbt_agent_core.py +++ b/backend/tests/agents/main_agent/property/test_pbt_agent_core.py @@ -35,10 +35,9 @@ def test_to_dict_from_params_round_trip(self, config: ModelConfig): d = config.to_dict() reconstructed = ModelConfig.from_params( model_id=d["model_id"], - temperature=d["temperature"], caching_enabled=d["caching_enabled"], provider=d["provider"], - max_tokens=d["max_tokens"], + inference_params=d["inference_params"], ) assert reconstructed.to_dict() == d diff --git a/backend/tests/agents/main_agent/test_fixtures_smoke.py b/backend/tests/agents/main_agent/test_fixtures_smoke.py index f350f881..0f11b634 100644 --- a/backend/tests/agents/main_agent/test_fixtures_smoke.py +++ b/backend/tests/agents/main_agent/test_fixtures_smoke.py @@ -11,7 +11,7 @@ def test_model_config_fixture(model_config): assert isinstance(model_config, ModelConfig) assert model_config.provider == ModelProvider.BEDROCK - assert model_config.temperature == 0.7 + assert model_config.inference_params == {} assert model_config.caching_enabled is True diff --git a/frontend/ai.client/src/app/admin/manage-models/model-form.page.html b/frontend/ai.client/src/app/admin/manage-models/model-form.page.html index 897db2c5..55e8bfa0 100644 --- a/frontend/ai.client/src/app/admin/manage-models/model-form.page.html +++ b/frontend/ai.client/src/app/admin/manage-models/model-form.page.html @@ -460,6 +460,258 @@

+ +
+ + + @if (inferenceParamsExpanded()) { +
+

+ Mark each parameter as supported or unsupported on this model, and set the default the runtime will send when the user doesn't override. Unsupported params are dropped from outbound requests. Untouched rows are treated as passthrough — the provider's server-side default applies. +

+ + @if (inferenceParamRows().length === 0) { +

No known parameters for this provider.

+ } + +
+ @for (meta of inferenceParamRows(); track meta.key; let i = $index) { +
+
+
+
{{ meta.label }}
+
+ {{ meta.key }} — {{ meta.description }} +
+
+ +
+ + @if (paramRowGroup(i).controls.supported.value) { +
+ @if (meta.kind === 'number' || meta.kind === 'integer' || meta.kind === 'thinkingBudget') { +
+ + +
+
+ + +
+
+ + +
+ } @else if (meta.kind === 'toggle') { +
+ +
+ } +
+ +
+
+ @if (paramRowErrors(i, 'known').length > 0) { + + } + } +
+ } +
+ + +
+

Custom Parameters

+

+ Add a parameter the catalog doesn't yet recognize. The runtime will pass it through to the provider SDK if its translation table knows the key, otherwise it's silently dropped. +

+ +
+ @for (row of modelForm.controls.customInferenceParams.controls; track $index; let i = $index) { +
+
+
+ + + @if (customParamGroup(i).controls.key.invalid && customParamGroup(i).controls.key.touched) { +

Use snake_case: lowercase letters, digits, underscores; must start with a letter.

+ } +
+ + +
+ + @if (customParamGroup(i).controls.supported.value) { +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ @if (paramRowErrors(i, 'custom').length > 0) { + + } + } +
+ } +
+ +
+
+ + +
+ +
+ @if (newCustomParamError()) { +

{{ newCustomParamError() }}

+ } +
+
+ } +
+
@if (modelForm.invalid) { diff --git a/frontend/ai.client/src/app/admin/manage-models/model-form.page.ts b/frontend/ai.client/src/app/admin/manage-models/model-form.page.ts index 44872257..46eff505 100644 --- a/frontend/ai.client/src/app/admin/manage-models/model-form.page.ts +++ b/frontend/ai.client/src/app/admin/manage-models/model-form.page.ts @@ -1,12 +1,145 @@ import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit } from '@angular/core'; import { Router, ActivatedRoute, RouterLink } from '@angular/router'; -import { FormBuilder, FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms'; +import { + AbstractControl, + FormBuilder, + FormGroup, + FormArray, + FormControl, + ValidationErrors, + Validators, + ReactiveFormsModule, +} from '@angular/forms'; import { NgIcon, provideIcons } from '@ng-icons/core'; -import { heroArrowLeft } from '@ng-icons/heroicons/outline'; -import { AVAILABLE_PROVIDERS, ManagedModelFormData, ModelProvider } from './models/managed-model.model'; +import { heroArrowLeft, heroChevronDown, heroChevronRight } from '@ng-icons/heroicons/outline'; +import { + AVAILABLE_PROVIDERS, + KNOWN_PARAMS, + KnownParamMeta, + ManagedModelFormData, + ModelParamSpec, + ModelProvider, + SupportedParams, +} from './models/managed-model.model'; import { ManagedModelsService } from './services/managed-models.service'; import { AppRolesService } from '../roles/services/app-roles.service'; +interface ParamRowGroup { + /** + * Canonical param name. Read-only on known rows (seeded from the catalog, + * no validators). Custom rows add `Validators.required + + * Validators.pattern(CUSTOM_PARAM_KEY_PATTERN)`. + * + * Carrying `key` on every row lets the FormArray-level validator look up + * `thinking` / `max_tokens` without depending on parallel signals. + */ + key: FormControl; + supported: FormControl; + min: FormControl; + max: FormControl; + defaultValue: FormControl; + locked: FormControl; +} + +/** + * Custom-param rows carry their canonical key as a form control so admins + * can stage params the frontend catalog doesn't yet know about (e.g. a brand + * new provider knob shipped before the next deploy). + */ +type CustomParamRowGroup = ParamRowGroup; + +const CUSTOM_PARAM_KEY_PATTERN = /^[a-z][a-z0-9_]*$/; + +/** + * Cross-field validator for a single inference param row. Mirrors the + * Pydantic `ModelParamSpec._check_bounds` rule on the backend so admins + * see the failure inline instead of a 422 from the save action. + * + * Returns `null` when the row is unsupported (we don't care about bounds + * on unsupported rows) or when nothing's wrong. Otherwise sets one of: + * - `minGreaterThanMax` — `min > max` + * - `defaultBelowMin` — numeric default < min + * - `defaultAboveMax` — numeric default > max + */ +function paramRowBoundsValidator(group: AbstractControl): ValidationErrors | null { + const supported = group.get('supported')?.value; + if (!supported) return null; + const min = group.get('min')?.value; + const max = group.get('max')?.value; + const def = group.get('defaultValue')?.value; + + const errors: Record = {}; + if (typeof min === 'number' && typeof max === 'number' && min > max) { + errors['minGreaterThanMax'] = true; + } + if (typeof def === 'number') { + if (typeof min === 'number' && def < min) errors['defaultBelowMin'] = true; + if (typeof max === 'number' && def > max) errors['defaultAboveMax'] = true; + } + return Object.keys(errors).length > 0 ? errors : null; +} + +/** + * FormArray-level validator that catches the two thinking-budget invariants + * Anthropic enforces (and that `SupportedParams._check_thinking_invariants` + * also enforces on the backend): + * - thinking budget must be >= 1024 + * - thinking budget must be < the max_tokens default + * + * Errors are placed on the `thinking` row so the inline error markup picks + * them up alongside per-row bounds errors. + */ +function thinkingInvariantsValidator(array: AbstractControl): ValidationErrors | null { + if (!(array instanceof FormArray)) return null; + let thinkingRow: FormGroup | undefined; + let maxTokensRow: FormGroup | undefined; + for (const row of array.controls as FormGroup[]) { + const key = row.get('key')?.value; + if (key === 'thinking') thinkingRow = row; + else if (key === 'max_tokens') maxTokensRow = row; + } + if (!thinkingRow) return null; + + const supported = thinkingRow.get('supported')?.value; + const def = thinkingRow.get('defaultValue')?.value; + if (!supported || def === null || def === undefined || def === '' || def === 0) { + // Clear any prior thinking-specific errors but keep bounds errors set + // by the per-row validator (which runs independently). + const existing = { ...(thinkingRow.errors ?? {}) }; + delete existing['thinkingBudgetTooLow']; + delete existing['thinkingBudgetExceedsMaxTokens']; + delete existing['thinkingBudgetNotNumeric']; + thinkingRow.setErrors(Object.keys(existing).length > 0 ? existing : null); + return null; + } + + const errors: Record = {}; + if (typeof def !== 'number' || Number.isNaN(def)) { + errors['thinkingBudgetNotNumeric'] = true; + } else { + if (def < 1024) errors['thinkingBudgetTooLow'] = true; + const maxTokensDef = maxTokensRow?.get('defaultValue')?.value; + if (typeof maxTokensDef === 'number' && def >= maxTokensDef) { + errors['thinkingBudgetExceedsMaxTokens'] = true; + } + } + + // Merge with any pre-existing per-row bounds errors so both are visible. + const merged = { ...(thinkingRow.errors ?? {}), ...errors }; + thinkingRow.setErrors(Object.keys(merged).length > 0 ? merged : null); + return null; +} + +/** + * Helper used as a key on each known-param row so the FormArray validator + * can find the `thinking` and `max_tokens` rows. Custom rows already store + * their key in a `key` control; known rows don't, so we tag them with a + * disabled control of the same name for symmetry. + */ +function knownParamKeyControl(fb: FormBuilder, key: string): FormControl { + return fb.control(key, { nonNullable: true }); +} + interface ModelFormGroup { modelId: FormControl; modelName: FormControl; @@ -27,12 +160,14 @@ interface ModelFormGroup { isReasoningModel: FormControl; knowledgeCutoffDate: FormControl; supportsCaching: FormControl; + inferenceParams: FormArray>; + customInferenceParams: FormArray>; } @Component({ selector: 'app-model-form-page', imports: [ReactiveFormsModule, RouterLink, NgIcon], - providers: [provideIcons({ heroArrowLeft })], + providers: [provideIcons({ heroArrowLeft, heroChevronDown, heroChevronRight })], templateUrl: './model-form.page.html', styleUrl: './model-form.page.css', changeDetection: ChangeDetectionStrategy.OnPush, @@ -57,6 +192,11 @@ export class ModelFormPage implements OnInit { readonly modelId = signal(null); readonly isSubmitting = signal(false); + // Inference-param row metadata, parallel to the ``inferenceParams`` FormArray. + // Provider switch rebuilds both together so each row is paired with its + // friendly label and input kind. + readonly inferenceParamRows = signal([]); + // Form group readonly modelForm: FormGroup = this.fb.group({ modelId: this.fb.control('', { nonNullable: true, validators: [Validators.required] }), @@ -78,11 +218,68 @@ export class ModelFormPage implements OnInit { isReasoningModel: this.fb.control(false, { nonNullable: true }), knowledgeCutoffDate: this.fb.control(null), supportsCaching: this.fb.control(false, { nonNullable: true }), + inferenceParams: this.fb.array>([], { + validators: [thinkingInvariantsValidator], + }), + customInferenceParams: this.fb.array>([]), }); + // Bound to the "Add custom parameter" input. Cleared after a successful add. + readonly newCustomParamKey = signal(''); + readonly newCustomParamError = signal(null); + + /** + * Param keys that came from the persisted record on load. Combined with + * each row's dirty flag to decide what `collectSupportedParams()` persists: + * untouched + not-loaded rows are dropped so the admin's "did nothing" + * means "passthrough", not "block everything". + */ + private loadedKnownKeys = new Set(); + + /** + * Inference Parameters section visibility. Collapsed by default — most + * admins won't need to touch this for chat-shaped models, and the runtime + * already supplies sensible per-model defaults via the seed/registry. + */ + readonly inferenceParamsExpanded = signal(false); + + toggleInferenceParams(): void { + this.inferenceParamsExpanded.update(v => !v); + } + + /** + * Count of parameters the admin has actually configured on this model. + * Drives the collapsed-section summary so the admin knows at a glance + * whether anything's set before they expand. + * + * Implemented as a method (not computed signal) so it re-evaluates on + * every CD pass — form-control changes don't tick signal dependencies, + * and we want the count to track checkbox/key edits in real time. + */ + configuredParamCount(): number { + let count = 0; + this.modelForm.controls.inferenceParams.controls.forEach((row, i) => { + const meta = this.inferenceParamRows()[i]; + if (!meta) return; + const wasLoaded = this.loadedKnownKeys.has(meta.key); + const wasTouched = row.dirty; + if ((wasLoaded || wasTouched) && row.controls.supported.value) { + count++; + } + }); + this.modelForm.controls.customInferenceParams.controls.forEach(row => { + if (row.controls.key.value) count++; + }); + return count; + } + readonly pageTitle = computed(() => this.isEditMode() ? 'Edit Model' : 'Add Model'); ngOnInit(): void { + // Seed the inference-params section for the default provider before any + // edit-mode load runs, so it can patch into existing rows. + this.rebuildInferenceParamRows(this.modelForm.controls.provider.value); + // Check if we're in edit mode const id = this.route.snapshot.paramMap.get('id'); if (id && id !== 'new') { @@ -106,6 +303,286 @@ export class ModelFormPage implements OnInit { }); } }); + + // Rebuild the inference-param rows whenever the provider changes so the + // visible knobs match what the selected SDK actually understands. + this.modelForm.controls.provider.valueChanges.subscribe(provider => { + this.rebuildInferenceParamRows(provider); + }); + } + + /** + * Build (or rebuild) the inference-params FormArray for a given provider. + * + * When the param key list is unchanged we **patch values in place** rather + * than clear+push. Replacing FormGroup instances is unsafe here because the + * already-rendered ``[formGroupName]="i"`` / ``formControlName="..."`` + * directives bind once and don't notice when a parent FormArray's child + * gets swapped — clicks would update the old, detached FormGroup and the + * ``@if (paramRowGroup(i).controls.supported.value)`` guard would never + * flip. A full rebuild only fires when the visible row set actually + * changes (i.e. real provider switch). + * + * Persisted keys outside the known catalog populate the custom-params + * FormArray instead — admins can stage params the frontend catalog doesn't + * yet recognize without losing them on save. + */ + private rebuildInferenceParamRows(provider: ModelProvider, existing?: SupportedParams | null): void { + const knownForProvider = KNOWN_PARAMS.filter(p => p.providers.includes(provider)); + const knownKeys = new Set(KNOWN_PARAMS.map(p => p.key)); + + // Track which keys came from the persisted record so the save path can + // round-trip them even if the admin doesn't interact with the row. + if (existing?.params) { + for (const k of Object.keys(existing.params)) { + this.loadedKnownKeys.add(k); + } + } + + const arr = this.modelForm.controls.inferenceParams; + const currentMetas = this.inferenceParamRows(); + const sameStructure = + currentMetas.length === knownForProvider.length && + currentMetas.every((m, i) => m.key === knownForProvider[i].key); + + if (sameStructure) { + // Patch persisted values into existing rows. Where there's no override, + // leave the seeded defaults alone so admins keep their suggested bounds. + knownForProvider.forEach((meta, i) => { + const row = arr.at(i) as FormGroup; + const fromExisting = existing?.params?.[meta.key]; + if (!fromExisting) return; + row.patchValue({ + supported: fromExisting.supported, + min: fromExisting.min ?? row.controls.min.value, + max: fromExisting.max ?? row.controls.max.value, + defaultValue: fromExisting.default ?? null, + locked: fromExisting.locked, + }); + }); + } else { + // Snapshot in-flight values keyed by param name so survivors of the + // provider switch keep what the admin typed. + const currentValues = new Map(); + arr.controls.forEach((row, i) => { + const key = currentMetas[i]?.key; + if (key) { + currentValues.set(key, this.rowToSpec(row)); + } + }); + arr.clear(); + for (const meta of knownForProvider) { + const fromExisting = existing?.params?.[meta.key]; + const fromCurrent = currentValues.get(meta.key); + const seed = fromExisting ?? fromCurrent ?? null; + arr.push(this.buildParamRow(meta, seed, provider)); + } + this.inferenceParamRows.set(knownForProvider); + } + + // Custom params: patch in place when key set matches, full rebuild + // otherwise (same FormGroup-identity reasoning as the known section). + const customArr = this.modelForm.controls.customInferenceParams; + const persistedCustomKeys = Object.keys(existing?.params ?? {}).filter(k => !knownKeys.has(k)); + const currentCustomKeys = customArr.controls.map(r => r.controls.key.value); + + const sameCustomStructure = + persistedCustomKeys.length === currentCustomKeys.length && + persistedCustomKeys.every((k, i) => k === currentCustomKeys[i]); + + if (sameCustomStructure && persistedCustomKeys.length > 0) { + persistedCustomKeys.forEach((key, i) => { + const row = customArr.at(i) as FormGroup; + const spec = existing!.params![key]; + row.patchValue({ + key, + supported: spec.supported, + min: spec.min ?? row.controls.min.value, + max: spec.max ?? row.controls.max.value, + defaultValue: spec.default ?? null, + locked: spec.locked, + }); + }); + } else if (persistedCustomKeys.length > 0 || currentCustomKeys.length > 0) { + // Preserve in-flight custom edits that aren't in the persisted record. + const currentCustom = new Map(); + customArr.controls.forEach(row => { + const v = row.getRawValue(); + if (v.key) { + currentCustom.set(v.key, this.rowToSpec(row as unknown as FormGroup)); + } + }); + customArr.clear(); + const seen = new Set(); + for (const key of persistedCustomKeys) { + seen.add(key); + customArr.push(this.buildCustomParamRow(key, existing!.params![key])); + } + currentCustom.forEach((spec, key) => { + if (!seen.has(key)) { + customArr.push(this.buildCustomParamRow(key, spec)); + } + }); + } + } + + private buildCustomParamRow(key: string, seed: ModelParamSpec | null): FormGroup { + return this.fb.group( + { + key: this.fb.control(key, { nonNullable: true, validators: [Validators.required, Validators.pattern(CUSTOM_PARAM_KEY_PATTERN)] }), + supported: this.fb.control(seed?.supported ?? true, { nonNullable: true }), + min: this.fb.control(seed?.min ?? null), + max: this.fb.control(seed?.max ?? null), + defaultValue: this.fb.control(seed?.default ?? null), + locked: this.fb.control(seed?.locked ?? false, { nonNullable: true }), + }, + { validators: [paramRowBoundsValidator] }, + ); + } + + /** Push a new custom-param row from the "Add custom parameter" input. */ + addCustomParam(): void { + const raw = this.newCustomParamKey().trim(); + if (!raw) { + this.newCustomParamError.set('Enter a parameter key.'); + return; + } + if (!CUSTOM_PARAM_KEY_PATTERN.test(raw)) { + this.newCustomParamError.set('Use snake_case: lowercase letters, digits, and underscores.'); + return; + } + const knownKeys = new Set(KNOWN_PARAMS.map(p => p.key)); + if (knownKeys.has(raw)) { + this.newCustomParamError.set(`'${raw}' is a built-in parameter — toggle it on above instead.`); + return; + } + const existingCustomKeys = this.modelForm.controls.customInferenceParams.controls + .map(r => r.controls.key.value); + if (existingCustomKeys.includes(raw)) { + this.newCustomParamError.set(`'${raw}' is already in the list.`); + return; + } + this.modelForm.controls.customInferenceParams.push(this.buildCustomParamRow(raw, null)); + this.newCustomParamKey.set(''); + this.newCustomParamError.set(null); + } + + removeCustomParam(index: number): void { + this.modelForm.controls.customInferenceParams.removeAt(index); + } + + customParamGroup(index: number): FormGroup { + return this.modelForm.controls.customInferenceParams.at(index) as FormGroup; + } + + onCustomKeyInput(value: string): void { + this.newCustomParamKey.set(value); + if (this.newCustomParamError()) { + this.newCustomParamError.set(null); + } + } + + private buildParamRow(meta: KnownParamMeta, seed: ModelParamSpec | null, provider: ModelProvider): FormGroup { + // Per-provider seeded bounds win over the catalog-wide fallbacks. Persisted + // values from `seed` always win over both — admin edits aren't clobbered. + const providerBounds = meta.defaults?.[provider]; + const seedMin = seed?.min ?? providerBounds?.min ?? meta.defaultMin ?? null; + const seedMax = seed?.max ?? providerBounds?.max ?? meta.defaultMax ?? null; + return this.fb.group( + { + // Catalog key is fixed for known rows — no validators, just a read-only + // tag the FormArray validator can pivot on. + key: knownParamKeyControl(this.fb, meta.key), + supported: this.fb.control(seed?.supported ?? false, { nonNullable: true }), + min: this.fb.control(seedMin), + max: this.fb.control(seedMax), + defaultValue: this.fb.control(seed?.default ?? null), + locked: this.fb.control(seed?.locked ?? false, { nonNullable: true }), + }, + { validators: [paramRowBoundsValidator] }, + ); + } + + private rowToSpec(row: FormGroup): ModelParamSpec { + const v = row.getRawValue(); + return { + supported: v.supported, + min: v.min, + max: v.max, + default: v.defaultValue, + locked: v.locked, + }; + } + + /** + * Convert the inference-params FormArrays + row metadata into the canonical + * `SupportedParams` shape the API expects. + * + * Known-param rows are only persisted when the admin has opined on them — + * either by loading from a persisted record (tracked in + * ``loadedKnownKeys``) or by interacting with the row in this session + * (FormGroup ``dirty``). Untouched rows are omitted, so the runtime sees + * an empty spec map and falls back to passthrough. That keeps "I didn't + * touch this section" symmetric for new vs existing models — neither + * silently flips behavior. + * + * Custom rows are always explicit (admin had to type a key or load one + * from DDB) so they're persisted whenever present. + */ + private collectSupportedParams(): SupportedParams | null { + const rows = this.inferenceParamRows(); + const params: Record = {}; + + this.modelForm.controls.inferenceParams.controls.forEach((row, i) => { + const meta = rows[i]; + if (!meta) return; + const wasLoaded = this.loadedKnownKeys.has(meta.key); + const wasTouched = row.dirty; + if (!wasLoaded && !wasTouched) return; + params[meta.key] = this.rowToSpec(row); + }); + + this.modelForm.controls.customInferenceParams.controls.forEach(row => { + const v = row.getRawValue(); + const key = (v.key ?? '').trim(); + if (!key) return; + params[key] = this.rowToSpec(row as unknown as FormGroup); + }); + + return Object.keys(params).length > 0 ? { params } : null; + } + + paramRowGroup(index: number): FormGroup { + return this.modelForm.controls.inferenceParams.at(index) as FormGroup; + } + + /** + * Returns the inline error messages to render under a known-param row, or + * `[]` for none. Errors come from two sources: per-row bounds checks + * (`paramRowBoundsValidator`) and thinking-specific cross-row checks + * (`thinkingInvariantsValidator` writes onto the thinking row). + * + * Surfaced unconditionally for supported rows — bounds problems are + * always immediate and don't need a `touched` gate to feel right. + */ + paramRowErrors(index: number, kind: 'known' | 'custom' = 'known'): string[] { + const arr = kind === 'known' + ? this.modelForm.controls.inferenceParams + : this.modelForm.controls.customInferenceParams; + const row = arr.at(index) as FormGroup | undefined; + if (!row || !row.errors || !row.get('supported')?.value) return []; + const out: string[] = []; + if (row.errors['minGreaterThanMax']) out.push('Min must be less than or equal to Max.'); + if (row.errors['defaultBelowMin']) out.push('Default must be greater than or equal to Min.'); + if (row.errors['defaultAboveMax']) out.push('Default must be less than or equal to Max.'); + if (row.errors['thinkingBudgetTooLow']) out.push('Thinking budget must be at least 1024 tokens.'); + if (row.errors['thinkingBudgetExceedsMaxTokens']) { + out.push('Thinking budget must be less than the Max Output Tokens default.'); + } + if (row.errors['thinkingBudgetNotNumeric']) { + out.push('Thinking budget must be a number — clear the value to disable, or enter an integer ≥ 1024.'); + } + return out; } /** @@ -137,6 +614,9 @@ export class ModelFormPage implements OnInit { knowledgeCutoffDate: model.knowledgeCutoffDate, supportsCaching: model.supportsCaching ?? true, }); + + // Repopulate the inference-params rows with any persisted spec. + this.rebuildInferenceParamRows(model.provider as ModelProvider, model.supportedParams ?? null); } catch (error) { console.error('Error loading model data:', error); alert('Failed to load model data. Please try again.'); @@ -198,7 +678,33 @@ export class ModelFormPage implements OnInit { this.isSubmitting.set(true); try { - const formData = this.modelForm.value as ManagedModelFormData; + // The FormArray for inference params lives outside the flat form-value + // shape that ManagedModelFormData expects, so we read fields directly + // from the typed form controls and attach `supportedParams` separately. + const v = this.modelForm.getRawValue(); + const formData: ManagedModelFormData = { + modelId: v.modelId, + modelName: v.modelName, + provider: v.provider, + providerName: v.providerName, + inputModalities: v.inputModalities, + outputModalities: v.outputModalities, + responseStreamingSupported: false, + maxInputTokens: v.maxInputTokens, + maxOutputTokens: v.maxOutputTokens, + allowedAppRoles: v.allowedAppRoles, + availableToRoles: v.availableToRoles, + enabled: v.enabled, + isDefault: v.isDefault, + inputPricePerMillionTokens: v.inputPricePerMillionTokens, + outputPricePerMillionTokens: v.outputPricePerMillionTokens, + cacheWritePricePerMillionTokens: v.cacheWritePricePerMillionTokens, + cacheReadPricePerMillionTokens: v.cacheReadPricePerMillionTokens, + isReasoningModel: v.isReasoningModel, + knowledgeCutoffDate: v.knowledgeCutoffDate, + supportsCaching: v.supportsCaching, + supportedParams: this.collectSupportedParams(), + }; if (this.isEditMode() && this.modelId()) { // Update existing model diff --git a/frontend/ai.client/src/app/admin/manage-models/models/managed-model.model.ts b/frontend/ai.client/src/app/admin/manage-models/models/managed-model.model.ts index 391c9214..97a66134 100644 --- a/frontend/ai.client/src/app/admin/manage-models/models/managed-model.model.ts +++ b/frontend/ai.client/src/app/admin/manage-models/models/managed-model.model.ts @@ -8,6 +8,36 @@ export type ModelProvider = 'bedrock' | 'openai' | 'gemini'; */ export const AVAILABLE_PROVIDERS: ModelProvider[] = ['bedrock', 'openai', 'gemini']; +/** + * Capability + bounds for a single inference parameter. + * + * Drives the admin form (which knobs are exposed, what bounds to enforce) + * and the runtime gate on the backend (whether to send the param to the + * provider SDK at all). `default` is what gets sent when the user doesn't + * override; `locked` reserves the slot for the future user-tweak surface. + */ +export interface ModelParamSpec { + supported: boolean; + min?: number | null; + max?: number | null; + default?: number | boolean | string | null; + locked?: boolean; +} + +/** + * Per-model inference parameter capability map, keyed by canonical name + * (e.g. `temperature`, `top_p`, `top_k`, `max_tokens`, `thinking`, + * `reasoning_effort`). + * + * Open-ended on purpose: each provider's translation table on the backend + * decides which canonical names map to native SDK fields, and silently + * drops the rest. Adding a new well-known param is a frontend catalog + * entry plus one backend mapping line — not a schema migration. + */ +export interface SupportedParams { + params: Record; +} + /** * Represents a managed model in the system. * This extends the Bedrock foundation model with additional metadata @@ -58,6 +88,8 @@ export interface ManagedModel { supportsCaching: boolean; /** Whether this is the default model for new sessions */ isDefault: boolean; + /** Per-model inference parameter capabilities (temperature, top_p, etc.) */ + supportedParams?: SupportedParams | null; /** Date the model was added to the system (ISO string from API) */ createdAt?: string | Date; /** Date the model was last updated (ISO string from API) */ @@ -110,8 +142,113 @@ export interface ManagedModelFormData { supportsCaching?: boolean; /** Whether this is the default model for new sessions */ isDefault: boolean; + /** Per-model inference parameter capabilities */ + supportedParams?: SupportedParams | null; } +/** + * Frontend catalog of well-known canonical inference params. + * + * Drives the admin form's per-param row: friendly label, input widget, and + * suggested bounds. The backend does the actual provider translation via + * its own table — names here just need to match what's in the backend's + * `__PARAM_MAP`. Add a new param here + on the backend; no + * schema migration required. + */ +export interface ParamBoundsDefaults { + min?: number; + max?: number; +} + +export interface KnownParamMeta { + key: string; + label: string; + description: string; + /** + * `thinkingBudget` is a number input gated by an on/off switch. The + * stored value is `null` (off) or an int budget (on). The runtime + * translator wraps the int into the provider-native shape. + */ + kind: 'number' | 'integer' | 'toggle' | 'thinkingBudget'; + /** Catalog-wide fallback range, used when no provider-specific entry applies. */ + defaultMin?: number; + defaultMax?: number; + /** + * Per-provider seeded bounds. Wins over `defaultMin`/`defaultMax` when the + * model's selected provider has an entry. Lets us serve the right range + * out of the box (e.g. temperature 0–1 on Bedrock vs 0–2 on OpenAI) without + * making the admin look up SDK docs. + */ + defaults?: Partial>; + /** Providers that translate this canonical name. Used to filter the form. */ + providers: ModelProvider[]; + /** + * Other canonical params that must be suppressed when this one is enabled + * (truthy). Used by the form/runtime to silently drop conflicting values + * — e.g. Anthropic rejects `temperature`/`top_p`/`top_k` while extended + * thinking is on. + */ + incompatibleWith?: string[]; +} + +export const KNOWN_PARAMS: KnownParamMeta[] = [ + { + key: 'temperature', + label: 'Temperature', + description: 'Sampling randomness. Lower = more deterministic.', + kind: 'number', + defaults: { + bedrock: { min: 0, max: 1 }, // Anthropic/Bedrock cap + openai: { min: 0, max: 2 }, // OpenAI accepts 0–2 + gemini: { min: 0, max: 1 }, + }, + providers: ['bedrock', 'openai', 'gemini'], + }, + { + key: 'top_p', + label: 'Top P', + description: 'Nucleus sampling cutoff.', + kind: 'number', + defaultMin: 0, + defaultMax: 1, + providers: ['bedrock', 'openai', 'gemini'], + }, + { + key: 'top_k', + label: 'Top K', + description: 'Top-k sampling cutoff. Not supported by OpenAI.', + kind: 'integer', + defaultMin: 1, + providers: ['bedrock', 'gemini'], + }, + { + key: 'max_tokens', + label: 'Max Output Tokens', + description: 'Maximum tokens in the model response.', + kind: 'integer', + defaultMin: 1, + providers: ['bedrock', 'openai', 'gemini'], + }, + { + key: 'thinking', + label: 'Extended Thinking', + description: + 'Token budget for extended reasoning. Must be ≥ 1024 and < max_tokens. ' + + 'Disables temperature, top_p, top_k while on (Anthropic constraint).', + kind: 'thinkingBudget', + defaultMin: 1024, + providers: ['bedrock', 'gemini'], + incompatibleWith: ['temperature', 'top_p', 'top_k'], + }, + { + key: 'reasoning_effort', + label: 'Reasoning Effort', + description: 'Reasoning depth (OpenAI o-series).', + kind: 'number', + providers: ['openai'], + }, +]; + /** * @deprecated Use AppRoles from the /admin/roles API instead. * These legacy JWT roles are kept for backward compatibility only. diff --git a/frontend/ai.client/src/app/components/model-settings/model-settings.html b/frontend/ai.client/src/app/components/model-settings/model-settings.html index 7e20ce3c..ed791784 100644 --- a/frontend/ai.client/src/app/components/model-settings/model-settings.html +++ b/frontend/ai.client/src/app/components/model-settings/model-settings.html @@ -124,6 +124,165 @@

+ + + @if (isAdvancedOpen()) { +
+

+ Per-model inference parameters. Values are clamped to the model's allowed range on the server. +

+ + @for (row of advancedRows(); track row.key) { +
+
+ + @if (row.isOverridden && !row.locked) { + + } +
+

+ {{ row.meta.description }} +

+ + @if (row.disabledByConflict) { +

+ Disabled while extended thinking is on. +

+ } + + @if (row.unsatisfiable) { +

+ {{ row.unsatisfiable.reason }} +

+ } + + @if (clampNotices()[row.key]) { +

+ {{ clampNotices()[row.key] }} +

+ } + + @switch (row.meta.kind) { + @case ('toggle') { + + } + @case ('thinkingBudget') { +
+ + +
+ } + @default { + + } + } +
+ } + + @if (overriddenCount() > 0) { + + } +
+ } +

+ } +
diff --git a/frontend/ai.client/src/app/components/model-settings/model-settings.ts b/frontend/ai.client/src/app/components/model-settings/model-settings.ts index 3977c1d5..de43e855 100644 --- a/frontend/ai.client/src/app/components/model-settings/model-settings.ts +++ b/frontend/ai.client/src/app/components/model-settings/model-settings.ts @@ -1,15 +1,45 @@ -import { Component, ChangeDetectionStrategy, inject, input, output, signal, effect, ElementRef, HostListener } from '@angular/core'; +import { Component, ChangeDetectionStrategy, inject, input, output, signal, computed, effect, ElementRef, HostListener } from '@angular/core'; import { NgIcon, provideIcons } from '@ng-icons/core'; -import { heroXMark, heroCheck, heroChevronDown } from '@ng-icons/heroicons/outline'; +import { heroXMark, heroCheck, heroChevronDown, heroChevronRight } from '@ng-icons/heroicons/outline'; import { ModelService } from '../../session/services/model/model.service'; import { ToolService } from '../../services/tool/tool.service'; -import { ManagedModel } from '../../admin/manage-models/models/managed-model.model'; +import { + KNOWN_PARAMS, + KnownParamMeta, + ManagedModel, + ModelParamSpec, + ModelProvider, +} from '../../admin/manage-models/models/managed-model.model'; + +/** Resolved row the template renders for a single inference param. */ +interface AdvancedParamRow { + key: string; + meta: KnownParamMeta; + spec: ModelParamSpec; + /** Effective min/max after merging catalog defaults with the model's spec. */ + min: number | null; + max: number | null; + /** Current effective value: user override if set, else the admin default. */ + value: unknown; + /** True when the user has overridden the admin default. */ + isOverridden: boolean; + /** Disabled because another active param's `incompatibleWith` includes us. */ + disabledByConflict: boolean; + /** Locked by the admin — show the value but block edits. */ + locked: boolean; + /** + * Set when the row's effective bounds collapse to nothing — e.g. thinking's + * floor (1024) is above the current `max_tokens − 1` cap. Surfacing this as + * a separate flag (vs. just disabling) lets the template explain *why*. + */ + unsatisfiable?: { reason: string }; +} @Component({ selector: 'app-model-settings', changeDetection: ChangeDetectionStrategy.OnPush, imports: [NgIcon], - providers: [provideIcons({ heroXMark, heroCheck, heroChevronDown })], + providers: [provideIcons({ heroXMark, heroCheck, heroChevronDown, heroChevronRight })], templateUrl: './model-settings.html', styleUrl: './model-settings.css', }) @@ -28,9 +58,117 @@ export class ModelSettings { protected isModelDropdownOpen = signal(false); protected focusedOptionIndex = signal(-1); + // Advanced section collapse state. Default closed so the panel doesn't + // grow taller for users who never touch inference params. + protected isAdvancedOpen = signal(false); + + // Per-param transient "clamped to N" notice keyed by param key. Cleared + // ~3s after it's set or the moment the user edits the row again. + protected clampNotices = signal>({}); + private clampTimers = new Map>(); + private static readonly CLAMP_NOTICE_MS = 3000; + // Output event when panel should close closed = output(); + /** Effective merged inference-param view for the current model. */ + protected readonly advancedRows = computed(() => { + const model = this.modelService.selectedModel(); + const spec = model?.supportedParams?.params ?? {}; + const overrides = this.modelService.selectedModelOverrides(); + + // Active = user override if set, else admin default. Drives the + // incompatibility gate (e.g. thinking suppresses sampling params). + const activeValues: Record = {}; + for (const [key, paramSpec] of Object.entries(spec)) { + if (!paramSpec.supported) continue; + const override = overrides[key]; + activeValues[key] = override !== undefined ? override : paramSpec.default; + } + + const conflictedKeys = new Set(); + for (const meta of KNOWN_PARAMS) { + if (!meta.incompatibleWith?.length) continue; + const value = activeValues[meta.key]; + if (!value) continue; + for (const conflict of meta.incompatibleWith) conflictedKeys.add(conflict); + } + + // Anthropic requires `thinking budget < max_tokens`. Compute the + // effective max_tokens (override > admin default > provider bounds) + // so the thinking row's max input never exceeds budget − 1. + const maxTokensSpec = spec['max_tokens']; + const maxTokensActive = activeValues['max_tokens']; + const maxTokensProviderBounds = + KNOWN_PARAMS.find((p) => p.key === 'max_tokens')?.defaults?.[ + (model?.provider ?? 'bedrock') as ModelProvider + ]; + const effectiveMaxTokens = + typeof maxTokensActive === 'number' + ? maxTokensActive + : typeof maxTokensSpec?.default === 'number' + ? maxTokensSpec.default + : (maxTokensSpec?.max ?? maxTokensProviderBounds?.max ?? null); + + const rows: AdvancedParamRow[] = []; + for (const meta of KNOWN_PARAMS) { + const paramSpec = spec[meta.key]; + if (!paramSpec || !paramSpec.supported) continue; + const provider = (model?.provider ?? 'bedrock') as ModelProvider; + if (!meta.providers.includes(provider)) continue; + const providerBounds = meta.defaults?.[provider]; + const min = + paramSpec.min ?? + providerBounds?.min ?? + meta.defaultMin ?? + null; + let max = + paramSpec.max ?? + providerBounds?.max ?? + meta.defaultMax ?? + null; + let unsatisfiable: { reason: string } | undefined; + // Tighten the thinking budget cap to max_tokens − 1 so the form can't + // produce a request the Bedrock validator will reject. If the resulting + // window collapses (cap < min), mark the row unsatisfiable so the + // template can disable the toggle and explain *why* — otherwise the + // user can set it to a value that's both inside the input's HTML + // bounds and rejected at request time. + if (meta.key === 'thinking' && effectiveMaxTokens !== null) { + const cap = effectiveMaxTokens - 1; + max = max === null ? cap : Math.min(max, cap); + if (min !== null && max !== null && max < min) { + unsatisfiable = { + reason: + `Set Max Output Tokens above ${min} to enable extended thinking ` + + `(currently ${effectiveMaxTokens}).`, + }; + } + } + const override = overrides[meta.key]; + const value = override !== undefined ? override : paramSpec.default ?? null; + rows.push({ + key: meta.key, + meta, + spec: paramSpec, + min, + max, + value, + isOverridden: override !== undefined, + disabledByConflict: conflictedKeys.has(meta.key), + locked: !!paramSpec.locked, + unsatisfiable, + }); + } + return rows; + }); + + protected readonly hasAdvancedParams = computed(() => this.advancedRows().length > 0); + + protected readonly overriddenCount = computed( + () => this.advancedRows().filter((row) => row.isOverridden).length, + ); + constructor() { // Track when panel is first opened and manage body scroll effect(() => { @@ -123,4 +261,194 @@ export class ModelSettings { toggleTool(toolId: string): void { this.toolService.toggleTool(toolId); } + + toggleAdvanced(): void { + this.isAdvancedOpen.update((open) => !open); + } + + /** + * Read a coerced value off a number/range input. Returns `null` when the + * field is empty so the override is cleared rather than stored as 0. + */ + protected readNumberInput(event: Event): number | null { + const target = event.target as HTMLInputElement | null; + if (!target || target.value === '') return null; + const parsed = Number(target.value); + return Number.isFinite(parsed) ? parsed : null; + } + + onParamNumberChange(row: AdvancedParamRow, event: Event): void { + if (row.locked || row.disabledByConflict) return; + const raw = this.readNumberInput(event); + if (raw === null) { + this.clearClampNotice(row.key); + this.modelService.setInferenceParamOverride(row.key, null); + this.maybeAdjustThinkingForMaxTokens(row.key); + return; + } + const clamped = this.applyBounds(raw, row.min, row.max); + if (clamped !== raw) { + this.flashClampNotice(row.key, this.formatClampMessage(row, clamped)); + // Reflect the clamped value back into the input so the visible value + // matches what we'll actually send. Browser number inputs already + // refuse out-of-range submissions, but typed values can survive blur. + const target = event.target as HTMLInputElement | null; + if (target) target.value = String(clamped); + } else { + this.clearClampNotice(row.key); + } + this.modelService.setInferenceParamOverride(row.key, clamped); + this.maybeAdjustThinkingForMaxTokens(row.key); + } + + onParamToggle(row: AdvancedParamRow): void { + if (row.locked || row.disabledByConflict) return; + const next = !row.value; + this.modelService.setInferenceParamOverride(row.key, next); + } + + /** + * Extended thinking enable/disable. The stored value is `null` (off) or an + * int budget (on). Default budget falls back to the admin default, then to + * the catalog `defaultMin` (1024 for thinking). + * + * Refuses to enable when ``row.unsatisfiable`` is set — i.e. when the + * effective max_tokens window can't accommodate the budget floor. The + * template also disables the toggle in that state; this guard is defense + * in depth for keyboard/programmatic toggles. + */ + onThinkingToggle(row: AdvancedParamRow): void { + if (row.locked || row.disabledByConflict) return; + if (row.value) { + this.modelService.setInferenceParamOverride(row.key, null); + return; + } + if (row.unsatisfiable) return; + const fallback = + (typeof row.spec.default === 'number' ? row.spec.default : null) ?? + row.meta.defaultMin ?? + 1024; + // Pin the seed budget to the row's effective range so we never store a + // value the input itself would reject. + const seeded = this.applyBounds(fallback, row.min, row.max); + this.modelService.setInferenceParamOverride(row.key, seeded); + } + + onThinkingBudgetChange(row: AdvancedParamRow, event: Event): void { + if (row.locked || row.disabledByConflict) return; + const raw = this.readNumberInput(event); + if (raw === null || raw <= 0) { + this.clearClampNotice(row.key); + this.modelService.setInferenceParamOverride(row.key, null); + return; + } + // Clamp to the row's effective bounds (which already incorporate the + // `max_tokens − 1` cap from the advancedRows computation). Defensive + // because number inputs accept out-of-range values via keyboard. + const floored = Math.floor(raw); + const clamped = this.applyBounds(floored, row.min, row.max); + if (clamped !== floored) { + this.flashClampNotice(row.key, this.formatClampMessage(row, clamped)); + const target = event.target as HTMLInputElement | null; + if (target) target.value = String(clamped); + } else { + this.clearClampNotice(row.key); + } + this.modelService.setInferenceParamOverride(row.key, clamped); + } + + resetParam(row: AdvancedParamRow): void { + if (row.locked) return; + this.modelService.setInferenceParamOverride(row.key, null); + } + + resetAllParams(): void { + this.modelService.clearInferenceParamOverrides(); + } + + /** Template helper: extended thinking is on when value is a positive number. */ + protected isThinkingEnabled(value: unknown): boolean { + return typeof value === 'number' && value > 0; + } + + private applyBounds(value: number, min: number | null, max: number | null): number { + if (min !== null && value < min) return min; + if (max !== null && value > max) return max; + return value; + } + + /** + * After a max_tokens edit, re-check the thinking row's invariant + * (budget < max_tokens) and clear the budget if the new ceiling can no + * longer accommodate it. Avoids the "user lowers max_tokens, thinking + * silently violates invariant, request 400s at Bedrock" failure mode. + * + * No-op for any other param edit. Kept lazy and side-effecting so the + * `advancedRows` computed stays read-only — mutating model overrides + * inside a computed would create a glitchy reactive cycle. + */ + private maybeAdjustThinkingForMaxTokens(changedKey: string): void { + if (changedKey !== 'max_tokens') return; + const thinking = this.advancedRows().find((r) => r.key === 'thinking'); + if (!thinking) return; + if (!this.isThinkingEnabled(thinking.value)) return; + // Row was just rebuilt off the latest max_tokens — `unsatisfiable` is set + // when the floor (1024) now exceeds max_tokens-1, and `max` is the new + // cap otherwise. Either way, drop the override and tell the user. + if (thinking.unsatisfiable) { + this.modelService.setInferenceParamOverride(thinking.key, null); + this.flashClampNotice( + thinking.key, + 'Extended thinking turned off — Max Output Tokens is below the 1024 budget floor.', + ); + return; + } + if (typeof thinking.value === 'number' && thinking.max !== null && thinking.value > thinking.max) { + const reduced = thinking.max; + this.modelService.setInferenceParamOverride(thinking.key, reduced); + this.flashClampNotice( + thinking.key, + `Reduced to ${reduced} to stay below Max Output Tokens.`, + ); + } + } + + /** + * Phrase the clamp notice based on which bound was hit and which row it was + * — the thinking row mentions the max_tokens coupling explicitly because + * that's the most likely surprise for the user. + */ + private formatClampMessage(row: AdvancedParamRow, clampedTo: number): string { + if (row.key === 'thinking' && row.max !== null && clampedTo === row.max) { + return `Reduced to ${clampedTo} to stay below Max Output Tokens.`; + } + if (row.min !== null && clampedTo === row.min) { + return `Raised to ${clampedTo} (minimum allowed by this model).`; + } + return `Reduced to ${clampedTo} (maximum allowed by this model).`; + } + + private flashClampNotice(key: string, message: string): void { + this.clampNotices.update((current) => ({ ...current, [key]: message })); + const existing = this.clampTimers.get(key); + if (existing) clearTimeout(existing); + const handle = setTimeout(() => { + this.clearClampNotice(key); + }, ModelSettings.CLAMP_NOTICE_MS); + this.clampTimers.set(key, handle); + } + + private clearClampNotice(key: string): void { + const existing = this.clampTimers.get(key); + if (existing) { + clearTimeout(existing); + this.clampTimers.delete(key); + } + this.clampNotices.update((current) => { + if (!(key in current)) return current; + const next = { ...current }; + delete next[key]; + return next; + }); + } } diff --git a/frontend/ai.client/src/app/session/services/chat/chat-request.service.ts b/frontend/ai.client/src/app/session/services/chat/chat-request.service.ts index 645e2c27..d08a7c99 100644 --- a/frontend/ai.client/src/app/session/services/chat/chat-request.service.ts +++ b/frontend/ai.client/src/app/session/services/chat/chat-request.service.ts @@ -160,6 +160,16 @@ export class ChatRequestService implements OnDestroy { provider: isDefaultModel ? null : selectedModel.provider, }; + // Per-model inference param overrides set in the Settings → Advanced + // panel. Backend layers these on top of admin defaults and clamps to the + // model's bounds; locked params drop the override silently. + if (!isDefaultModel) { + const overrides = this.modelService.getInferenceParamOverrides(); + if (Object.keys(overrides).length > 0) { + requestObject['inference_params'] = overrides; + } + } + // Add file upload IDs if present if (fileUploadIds && fileUploadIds.length > 0) { requestObject['file_upload_ids'] = fileUploadIds; diff --git a/frontend/ai.client/src/app/session/services/model/model.service.ts b/frontend/ai.client/src/app/session/services/model/model.service.ts index 17980fb0..6e10a670 100644 --- a/frontend/ai.client/src/app/session/services/model/model.service.ts +++ b/frontend/ai.client/src/app/session/services/model/model.service.ts @@ -21,6 +21,9 @@ export class ModelService { // Session storage key for persisting model selection private readonly SELECTED_MODEL_KEY = 'selectedModelId'; + // Session storage key for persisting per-model inference param overrides. + // Keyed by modelId so switching models doesn't bleed values across. + private readonly INFERENCE_OVERRIDES_KEY = 'inferenceParamOverrides'; // Default model used when no models are available (matches backend default) private readonly DEFAULT_MODEL: ManagedModel = { @@ -53,6 +56,14 @@ export class ModelService { // Selected model (defaults to first model when available, or system default) private readonly _selectedModel = signal(null); + // Per-model canonical inference param overrides. Outer key = modelId, inner + // key = canonical param name (temperature, top_p, thinking, ...). Sent on + // each chat request as `inference_params`; backend layers them on top of + // admin defaults and clamps to the model's bounds. + private readonly _inferenceOverrides = signal>>( + this.loadOverridesFromStorage(), + ); + // Public read-only signals readonly availableModels = this.models.asReadonly(); readonly selectedModel = computed(() => { @@ -69,6 +80,13 @@ export class ModelService { readonly modelsLoading = this.isLoading.asReadonly(); readonly modelsError = this.error.asReadonly(); + /** Inference param overrides for the currently selected model. */ + readonly selectedModelOverrides = computed>(() => { + const model = this.selectedModel(); + if (!model) return {}; + return this._inferenceOverrides()[model.modelId] ?? {}; + }); + constructor() { // Load models on initialization this.loadModels().catch(err => { @@ -233,4 +251,65 @@ export class ModelService { return null; } } + + /** + * Set a single inference param override on the currently selected model. + * Pass `null` / `undefined` to clear the override and fall back to the + * admin default. No-op if no model is selected. + */ + setInferenceParamOverride(paramKey: string, value: unknown): void { + const model = this.selectedModel(); + if (!model) return; + const next = { ...this._inferenceOverrides() }; + const modelOverrides = { ...(next[model.modelId] ?? {}) }; + if (value === null || value === undefined || value === '') { + delete modelOverrides[paramKey]; + } else { + modelOverrides[paramKey] = value; + } + if (Object.keys(modelOverrides).length === 0) { + delete next[model.modelId]; + } else { + next[model.modelId] = modelOverrides; + } + this._inferenceOverrides.set(next); + this.persistOverrides(next); + } + + /** Clear all inference param overrides for the currently selected model. */ + clearInferenceParamOverrides(): void { + const model = this.selectedModel(); + if (!model) return; + const next = { ...this._inferenceOverrides() }; + if (model.modelId in next) { + delete next[model.modelId]; + this._inferenceOverrides.set(next); + this.persistOverrides(next); + } + } + + /** Snapshot getter for non-signal contexts (e.g. request builders). */ + getInferenceParamOverrides(): Record { + return this.selectedModelOverrides(); + } + + private loadOverridesFromStorage(): Record> { + try { + const raw = sessionStorage.getItem(this.INFERENCE_OVERRIDES_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch (e) { + console.warn('Could not read inference overrides from sessionStorage:', e); + return {}; + } + } + + private persistOverrides(value: Record>): void { + try { + sessionStorage.setItem(this.INFERENCE_OVERRIDES_KEY, JSON.stringify(value)); + } catch (e) { + console.warn('Could not save inference overrides to sessionStorage:', e); + } + } } From 19472e57f3f6289b899f042a209919046229d990 Mon Sep 17 00:00:00 2001 From: Phil Merrell Date: Fri, 1 May 2026 13:35:38 -0600 Subject: [PATCH 02/17] test: stub getInferenceParamOverrides on ChatRequestService model mock Three tests in chat-request.service.spec.ts started failing after the new inference_params plumbing because the mock ModelService didn't declare the new method buildChatRequestObject now calls. Co-Authored-By: Claude Opus 4.7 --- .../src/app/session/services/chat/chat-request.service.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/ai.client/src/app/session/services/chat/chat-request.service.spec.ts b/frontend/ai.client/src/app/session/services/chat/chat-request.service.spec.ts index 1f3c8ace..851fa385 100644 --- a/frontend/ai.client/src/app/session/services/chat/chat-request.service.spec.ts +++ b/frontend/ai.client/src/app/session/services/chat/chat-request.service.spec.ts @@ -31,6 +31,7 @@ describe('ChatRequestService', () => { mockModelService = { getSelectedModel: vi.fn().mockReturnValue({ modelId: 'test-model', provider: 'test' }), isUsingDefaultModel: vi.fn().mockReturnValue(false), + getInferenceParamOverrides: vi.fn().mockReturnValue({}), }; mockToolService = { From 15b202ac60a675a347b0481ece0c0beb046783cb Mon Sep 17 00:00:00 2001 From: Phil Merrell Date: Fri, 1 May 2026 13:40:49 -0600 Subject: [PATCH 03/17] fix: sanitize user-controlled model_id before logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses CodeQL log-injection alert (Boise-State-Development/agentcore-public-stack#628) on the warning emitted when a model lookup fails. `model_id` is sourced from the chat request body, so a CRLF in the value could forge extra log lines downstream. Adds a local _sanitize_log helper (mirroring voice_routes._sanitize_log) and applies it to all three new log sites that interpolate model_id — including two where the value reaches us via the managed_model record but originated as user input on create. Co-Authored-By: Claude Opus 4.7 --- backend/src/apis/inference_api/chat/routes.py | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/backend/src/apis/inference_api/chat/routes.py b/backend/src/apis/inference_api/chat/routes.py index 7da8d763..7d272a8d 100644 --- a/backend/src/apis/inference_api/chat/routes.py +++ b/backend/src/apis/inference_api/chat/routes.py @@ -63,6 +63,15 @@ def is_preview_session(session_id: str) -> bool: return session_id.startswith(PREVIEW_SESSION_PREFIX) +def _sanitize_log(value: object) -> str: + """Strip CR/LF (and replace any other ASCII control char) so untrusted + request fields can't forge extra log lines. Mirrors voice_routes._sanitize_log. + """ + if value is None: + return "?" + return "".join(c if c >= " " or c == "\t" else " " for c in str(value)) + + async def _find_managed_model(model_id: str | None): """Best-effort lookup of a managed-model record by external model ID.""" if not model_id: @@ -73,7 +82,9 @@ async def _find_managed_model(model_id: str | None): if model.model_id == model_id: return model except Exception: - logger.warning("Failed to look up managed model %s", model_id) + # model_id is request-controlled; sanitize before logging to keep + # CRLF / control chars from forging extra log lines. + logger.warning("Failed to look up managed model %s", _sanitize_log(model_id)) return None @@ -101,10 +112,14 @@ def _merge_inference_params( seen_keys.add(name) if not spec.supported: if name in request_params: + # `name` is a registry-defined canonical key; managed_model.model_id + # comes from DDB but ultimately traces back to a user-supplied + # value on create. Sanitize defensively so CodeQL's log-injection + # check is satisfied uniformly across log sites. logger.info( "Dropping unsupported inference param '%s' for model %s", - name, - getattr(managed_model, "model_id", "?"), + _sanitize_log(name), + _sanitize_log(getattr(managed_model, "model_id", "?")), ) continue @@ -152,7 +167,7 @@ def _merge_inference_params( logger.warning( "Dropping thinking budget %d for model %s — not less than max_tokens %d", thinking, - getattr(managed_model, "model_id", "?"), + _sanitize_log(getattr(managed_model, "model_id", "?")), max_tokens, ) merged.pop("thinking", None) From fff7fa59b71e1f42c4704d5bcf025f6e3e1dc84f Mon Sep 17 00:00:00 2001 From: Phil Merrell Date: Fri, 1 May 2026 14:46:31 -0600 Subject: [PATCH 04/17] fix: preserve reasoningContent through session persistence for thinking + tool use The persistence-side _filter_empty_text in TurnBasedSessionManager dropped reasoningContent blocks. Anthropic requires the prior thinking block (with its signature) to be replayed verbatim while a tool-use cycle is open; losing it triggers `messages.X.content.Y.thinking.signature: Field required` on subsequent Bedrock calls. Replace the narrow allowlist with the full set of Bedrock Converse content block keys mirrored from Strands' BedrockModel._format_request_message_content, and warn when an unrecognized block is dropped so future Bedrock additions don't fail silently. Add a SessionMessage round-trip test through AgentCoreMemoryConverter that asserts the signature survives JSON serialization. Co-Authored-By: Claude Opus 4.7 --- .../session/turn_based_session_manager.py | 48 ++++++-- .../test_turn_based_session_manager.py | 114 ++++++++++++++++++ 2 files changed, 155 insertions(+), 7 deletions(-) diff --git a/backend/src/agents/main_agent/session/turn_based_session_manager.py b/backend/src/agents/main_agent/session/turn_based_session_manager.py index 2108cd78..413804fb 100644 --- a/backend/src/agents/main_agent/session/turn_based_session_manager.py +++ b/backend/src/agents/main_agent/session/turn_based_session_manager.py @@ -535,24 +535,58 @@ async def update_after_turn(self, input_tokens: int) -> None: # Message Processing Helpers # ========================================================================= + # Top-level keys for content blocks Bedrock Converse recognizes. + # Mirrors Strands BedrockModel._format_request_message_content + # (strands/models/bedrock.py). reasoningContent is critical for + # Anthropic extended-thinking + tool-use round-tripping: the block + # carries a `signature` field that must be replayed verbatim while a + # tool-use cycle is open. + _BEDROCK_CONTENT_BLOCK_KEYS = frozenset({ + "cachePoint", + "citationsContent", + "document", + "guardContent", + "image", + "reasoningContent", + "text", + "toolResult", + "toolUse", + "video", + }) + @staticmethod def _filter_empty_text(message: dict) -> dict: - """Filter out empty or invalid content blocks from a message.""" + """Drop empty text blocks; preserve every other Bedrock-recognized block. + + Empty/whitespace-only ``text`` blocks must be dropped — Bedrock Converse + rejects them. Every other recognized content block is passed through + unchanged. Blocks whose top-level key is not recognized are dropped and + logged so silent stripping (e.g. when Bedrock adds a new block type) + is observable. + """ if "content" not in message: return message content = message.get("content", []) if not isinstance(content, list): return message - def is_valid_block(block): + filtered = [] + for block in content: if not isinstance(block, dict): - return False + continue if "text" in block: text = block.get("text", "") - return isinstance(text, str) and text.strip() != "" - return any(key in block for key in ("toolUse", "toolResult", "image", "document")) - - filtered = [block for block in content if is_valid_block(block)] + if isinstance(text, str) and text.strip() != "": + filtered.append(block) + continue + if any(key in block for key in TurnBasedSessionManager._BEDROCK_CONTENT_BLOCK_KEYS): + filtered.append(block) + else: + logger.warning( + "Dropping unrecognized content block (keys=%s) before persistence. " + "If Bedrock has added a new block type, update _BEDROCK_CONTENT_BLOCK_KEYS.", + sorted(block.keys()), + ) return {**message, "content": filtered} def _has_tool_result(self, message: Dict) -> bool: diff --git a/backend/tests/agents/main_agent/session/test_turn_based_session_manager.py b/backend/tests/agents/main_agent/session/test_turn_based_session_manager.py index 82c833d3..a0c28d65 100644 --- a/backend/tests/agents/main_agent/session/test_turn_based_session_manager.py +++ b/backend/tests/agents/main_agent/session/test_turn_based_session_manager.py @@ -940,6 +940,120 @@ def test_non_list_content_unchanged(self, make_session_manager): msg = {"role": "user", "content": "string"} assert mgr._filter_empty_text(msg) == msg + @pytest.mark.parametrize("block", [ + {"reasoningContent": {"reasoningText": {"text": "thinking", "signature": "sig"}}}, + {"reasoningContent": {"redactedContent": b"opaque"}}, + {"image": {"format": "png", "source": {"bytes": b"x"}}}, + {"document": {"name": "f", "format": "pdf", "source": {"bytes": b"x"}}}, + {"video": {"format": "mp4", "source": {"bytes": b"x"}}}, + {"cachePoint": {"type": "default"}}, + {"guardContent": {"text": {"text": "x", "qualifiers": []}}}, + {"citationsContent": {"content": [{"text": "x"}]}}, + {"toolUse": {"toolUseId": "t1", "name": "calc", "input": {}}}, + {"toolResult": {"toolUseId": "t1", "content": [{"text": "ok"}]}}, + ]) + def test_keeps_all_bedrock_recognized_blocks(self, make_session_manager, block): + """Every Bedrock-recognized block type must survive persistence.""" + mgr = make_session_manager() + msg = {"role": "assistant", "content": [block]} + result = mgr._filter_empty_text(msg) + assert len(result["content"]) == 1 + assert result["content"][0] == block + + def test_drops_unrecognized_block_with_warning(self, make_session_manager, caplog): + """Unknown block keys are dropped — and the drop is logged.""" + import logging + mgr = make_session_manager() + msg = {"role": "assistant", "content": [{"madeUpFutureBlock": {"x": 1}}]} + with caplog.at_level(logging.WARNING, logger="agents.main_agent.session.turn_based_session_manager"): + result = mgr._filter_empty_text(msg) + assert result["content"] == [] + assert any("Dropping unrecognized content block" in r.message for r in caplog.records) + + +# =========================================================================== +# Reasoning content (extended thinking) round-trip +# =========================================================================== + +class TestReasoningContentRoundTrip: + """ + Verify reasoningContent (with signature) survives the full persistence path: + + TurnBasedSessionManager._filter_empty_text + → SessionMessage.to_dict + → AgentCoreMemoryConverter.message_to_payload (JSON serialize) + → AgentCoreMemoryConverter.events_to_messages (JSON deserialize) + → SessionMessage.to_message + + Anthropic requires the signature on the prior thinking block to be + preserved verbatim in subsequent payloads while a tool-use cycle is + open. If our filter or the SDK's serialization drops it, Bedrock + returns ``messages.X.content.Y.thinking.signature: Field required`` + on the next turn. See issue #204 follow-up. + """ + + SIGNATURE = "abc123-signed-by-anthropic" + + def _thinking_tool_use_message(self) -> dict: + return { + "role": "assistant", + "content": [ + { + "reasoningContent": { + "reasoningText": { + "text": "I should call the calculator.", + "signature": self.SIGNATURE, + } + } + }, + {"toolUse": {"toolUseId": "t1", "name": "calc", "input": {"x": 1}}}, + ], + } + + def test_signature_survives_agentcore_memory_round_trip(self): + """Real SDK round-trip: serialize → JSON → deserialize.""" + from strands.types.session import SessionMessage + from bedrock_agentcore.memory.integrations.strands.bedrock_converter import ( + AgentCoreMemoryConverter, + ) + + original = SessionMessage.from_message(self._thinking_tool_use_message(), index=0) + payload = AgentCoreMemoryConverter.message_to_payload(original) + assert len(payload) == 1 + text, role = payload[0] + + event = { + "payload": [{"conversational": {"content": {"text": text}, "role": role}}] + } + restored = AgentCoreMemoryConverter.events_to_messages([event]) + + assert len(restored) == 1 + msg = restored[0].to_message() + assert msg["role"] == "assistant" + rc_block = next(b for b in msg["content"] if "reasoningContent" in b) + assert rc_block["reasoningContent"]["reasoningText"]["signature"] == self.SIGNATURE + tu_block = next(b for b in msg["content"] if "toolUse" in b) + assert tu_block["toolUse"]["toolUseId"] == "t1" + + def test_append_message_forwards_reasoning_to_super(self, make_session_manager): + """append_message must hand the reasoningContent block to the SDK + super(); the legacy filter silently stripped it.""" + mgr = make_session_manager() + agent = MagicMock() + msg = self._thinking_tool_use_message() + with patch( + "agents.main_agent.session.turn_based_session_manager.AgentCoreMemorySessionManager.append_message" + ) as super_append: + mgr.append_message(msg, agent) + super_append.assert_called_once() + forwarded_msg = super_append.call_args.args[0] + rc_blocks = [b for b in forwarded_msg["content"] if "reasoningContent" in b] + assert len(rc_blocks) == 1, ( + "reasoningContent block was stripped before persistence — " + "this breaks Anthropic extended-thinking + tool-use round-tripping" + ) + assert rc_blocks[0]["reasoningContent"]["reasoningText"]["signature"] == self.SIGNATURE + # =========================================================================== # Task 10 — End-to-end compaction lifecycle From 446232d26e9a3d46c49da28828e656115142d51e Mon Sep 17 00:00:00 2001 From: Phil Merrell Date: Fri, 1 May 2026 20:25:01 -0600 Subject: [PATCH 05/17] Potential fix for pull request finding 'CodeQL / Log Injection' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Phil Merrell --- backend/src/apis/inference_api/chat/routes.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/src/apis/inference_api/chat/routes.py b/backend/src/apis/inference_api/chat/routes.py index 7d272a8d..684e21cf 100644 --- a/backend/src/apis/inference_api/chat/routes.py +++ b/backend/src/apis/inference_api/chat/routes.py @@ -64,12 +64,14 @@ def is_preview_session(session_id: str) -> bool: def _sanitize_log(value: object) -> str: - """Strip CR/LF (and replace any other ASCII control char) so untrusted - request fields can't forge extra log lines. Mirrors voice_routes._sanitize_log. + """Return a log-safe representation of untrusted values. + + We JSON-escape to ensure CR/LF and other control characters are emitted + as literals (e.g. ``\\n``) rather than raw bytes that could forge log lines. """ if value is None: return "?" - return "".join(c if c >= " " or c == "\t" else " " for c in str(value)) + return json.dumps(str(value), ensure_ascii=True)[1:-1] async def _find_managed_model(model_id: str | None): From 14fa858fd2906d1c7b28e8dd0104b5889484efa0 Mon Sep 17 00:00:00 2001 From: Oscar Filson Date: Thu, 30 Apr 2026 15:04:54 -0600 Subject: [PATCH 06/17] adjust tests for nightly success. --- .../ai.client/e2e/home-page/chat.user.spec.ts | 21 ++++++++-- .../e2e/home-page/file-upload-ui.user.spec.ts | 42 ++++++++++++++++--- .../e2e/settings/profile.user.spec.ts | 16 +++---- 3 files changed, 64 insertions(+), 15 deletions(-) diff --git a/frontend/ai.client/e2e/home-page/chat.user.spec.ts b/frontend/ai.client/e2e/home-page/chat.user.spec.ts index 1d125a01..9477c953 100644 --- a/frontend/ai.client/e2e/home-page/chat.user.spec.ts +++ b/frontend/ai.client/e2e/home-page/chat.user.spec.ts @@ -61,14 +61,29 @@ test.describe('Chat (user)', () => { await page.goto('/'); await expect(page.locator('textarea#user-message')).toBeVisible({ timeout: 15_000 }); + // Wait for the session list to finish loading before clicking + await expect(page.getByText('Loading sessions...')).toBeHidden({ timeout: 15_000 }); + // Click into the most recent conversation (created by the previous test) const sessionLink = page.locator('app-session-list a').first(); + await expect(sessionLink).toBeVisible({ timeout: 10_000 }); await sessionLink.click(); await page.waitForURL(/\/s\//, { timeout: 10_000 }); - // Should already have 1 user message and 1 assistant message from the first test - await expect(page.locator('app-user-message')).toHaveCount(1, { timeout: 10_000 }); - await expect(page.locator('app-assistant-message')).toHaveCount(1, { timeout: 10_000 }); + // The assistant message from the previous test may not be persisted in + // DynamoDB yet when the page first loads messages. If only the user + // message is present, reload to re-fetch from the backend. + await expect(page.locator('app-user-message')).toHaveCount(1, { timeout: 15_000 }); + + const assistantCount = await page.locator('app-assistant-message').count(); + if (assistantCount === 0) { + // Assistant message not yet persisted — wait briefly then reload + await page.waitForTimeout(3_000); + await page.reload(); + await expect(page.locator('textarea#user-message')).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('app-user-message')).toHaveCount(1, { timeout: 15_000 }); + } + await expect(page.locator('app-assistant-message')).toHaveCount(1, { timeout: 30_000 }); // Send a second message const response = await sendMessageAndWaitForResponse(page, 'Reply with exactly one word.'); diff --git a/frontend/ai.client/e2e/home-page/file-upload-ui.user.spec.ts b/frontend/ai.client/e2e/home-page/file-upload-ui.user.spec.ts index ccac0e2c..b6f9d48c 100644 --- a/frontend/ai.client/e2e/home-page/file-upload-ui.user.spec.ts +++ b/frontend/ai.client/e2e/home-page/file-upload-ui.user.spec.ts @@ -12,8 +12,21 @@ test.describe('File Upload UI (user)', () => { }); test('should accept a file via the file input', async ({ page }) => { - // Mock the upload endpoint so we don't actually upload - await page.route('**/files/upload**', (route) => + // Mock the full upload flow: presign → S3 PUT → complete + await page.route('**/files/presign', (route) => + route.fulfill({ + status: 200, + json: { + uploadId: 'mock-upload-id', + presignedUrl: 'https://fake-s3-bucket.s3.amazonaws.com/fake-presigned-url', + expiresAt: new Date(Date.now() + 3600_000).toISOString(), + }, + }), + ); + await page.route('**/fake-s3-bucket.s3.amazonaws.com/**', (route) => + route.fulfill({ status: 200 }), + ); + await page.route('**/files/mock-upload-id/complete', (route) => route.fulfill({ status: 200, json: { uploadId: 'mock-upload-id', filename: 'test.txt', status: 'completed' }, @@ -32,11 +45,27 @@ test.describe('File Upload UI (user)', () => { // A file card should appear in the attachments area const fileCard = page.locator('app-file-card'); await expect(fileCard.first()).toBeVisible({ timeout: 10_000 }); + + // Clean up route mocks + await page.unrouteAll(); }); test('should remove an attached file', async ({ page }) => { - // Mock the upload endpoint so we don't actually upload - await page.route('**/files/upload**', (route) => + // Mock the full upload flow: presign → S3 PUT → complete + await page.route('**/files/presign', (route) => + route.fulfill({ + status: 200, + json: { + uploadId: 'mock-upload-id', + presignedUrl: 'https://fake-s3-bucket.s3.amazonaws.com/fake-presigned-url', + expiresAt: new Date(Date.now() + 3600_000).toISOString(), + }, + }), + ); + await page.route('**/fake-s3-bucket.s3.amazonaws.com/**', (route) => + route.fulfill({ status: 200 }), + ); + await page.route('**/files/mock-upload-id/complete', (route) => route.fulfill({ status: 200, json: { uploadId: 'mock-upload-id', filename: 'test.txt', status: 'completed' }, @@ -62,6 +91,9 @@ test.describe('File Upload UI (user)', () => { await removeButton.click(); // File card should disappear - await expect(fileCard).toHaveCount(0, { timeout: 5_000 }); + await expect(fileCard).toHaveCount(0, { timeout: 10_000 }); + + // Clean up route mocks + await page.unrouteAll(); }); }); diff --git a/frontend/ai.client/e2e/settings/profile.user.spec.ts b/frontend/ai.client/e2e/settings/profile.user.spec.ts index 282f27b7..ccd2435b 100644 --- a/frontend/ai.client/e2e/settings/profile.user.spec.ts +++ b/frontend/ai.client/e2e/settings/profile.user.spec.ts @@ -48,7 +48,8 @@ test.describe('Settings / Profile (user)', () => { await expect(page.getByRole('heading', { name: 'test_user' })).toBeVisible({ timeout: 10_000 }); // Verify email (in the avatar/name header section, scoped to avoid the details row) - await expect(page.locator('dd').filter({ hasText: 'oscarfilson@boisestate.edu' })).toBeVisible({ timeout: 10_000 }); + //await expect(page.locator('dd').filter({ hasText: 'oscarfilson@boisestate.edu' })).toBeVisible({ timeout: 10_000 }); + // Commented out because test acc has no email in run // Verify managed by Entra ID badge await expect(page.getByText('Managed by Entra ID')).toBeVisible({ timeout: 10_000 }); @@ -65,14 +66,15 @@ test.describe('Settings / Profile (user)', () => { await expect(page.locator('dd').filter({ hasText: 'test_user' })).toBeVisible({ timeout: 10_000 }); }); - test('should show the email in the read-only details', async ({ page }) => { - await goToProfilePage(page); + // Commented out because the test_user has no email in the nightly + // test('should show the email in the read-only details', async ({ page }) => { + // await goToProfilePage(page); - const emailLabel = page.locator('dt').filter({ hasText: 'Email' }); - await expect(emailLabel).toBeVisible({ timeout: 10_000 }); + // const emailLabel = page.locator('dt').filter({ hasText: 'Email' }); + // await expect(emailLabel).toBeVisible({ timeout: 10_000 }); - await expect(page.locator('dd').filter({ hasText: 'oscarfilson@boisestate.edu' })).toBeVisible({ timeout: 10_000 }); - }); + // await expect(page.locator('dd').filter({ hasText: 'oscarfilson@boisestate.edu' })).toBeVisible({ timeout: 10_000 }); + // }); test('should show the My Files link and navigate to files page', async ({ page }) => { await goToProfilePage(page); From 366ec7a04e93ab63c47d290db3931e5555fc7864 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 08:55:21 -0600 Subject: [PATCH 07/17] chore(deps)(deps): bump the angular group across 1 directory with 10 updates (#149) Bumps the angular group with 10 updates in the /frontend/ai.client directory: | Package | From | To | | --- | --- | --- | | [@angular/cdk](https://github.com/angular/components) | `21.2.5` | `21.2.9` | | [@angular/common](https://github.com/angular/angular/tree/HEAD/packages/common) | `21.2.7` | `21.2.11` | | [@angular/compiler](https://github.com/angular/angular/tree/HEAD/packages/compiler) | `21.2.7` | `21.2.11` | | [@angular/core](https://github.com/angular/angular/tree/HEAD/packages/core) | `21.2.7` | `21.2.11` | | [@angular/forms](https://github.com/angular/angular/tree/HEAD/packages/forms) | `21.2.7` | `21.2.11` | | [@angular/platform-browser](https://github.com/angular/angular/tree/HEAD/packages/platform-browser) | `21.2.7` | `21.2.11` | | [@angular/router](https://github.com/angular/angular/tree/HEAD/packages/router) | `21.2.7` | `21.2.11` | | [@angular/build](https://github.com/angular/angular-cli) | `21.2.6` | `21.2.9` | | [@angular/cli](https://github.com/angular/angular-cli) | `21.2.6` | `21.2.9` | | [@angular/compiler-cli](https://github.com/angular/angular/tree/HEAD/packages/compiler-cli) | `21.2.7` | `21.2.11` | Updates `@angular/cdk` from 21.2.5 to 21.2.9 - [Release notes](https://github.com/angular/components/releases) - [Changelog](https://github.com/angular/components/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/components/compare/v21.2.5...v21.2.9) Updates `@angular/common` from 21.2.7 to 21.2.11 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.11/packages/common) Updates `@angular/compiler` from 21.2.7 to 21.2.11 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.11/packages/compiler) Updates `@angular/core` from 21.2.7 to 21.2.11 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.11/packages/core) Updates `@angular/forms` from 21.2.7 to 21.2.11 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.11/packages/forms) Updates `@angular/platform-browser` from 21.2.7 to 21.2.11 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.11/packages/platform-browser) Updates `@angular/router` from 21.2.7 to 21.2.11 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.11/packages/router) Updates `@angular/build` from 21.2.6 to 21.2.9 - [Release notes](https://github.com/angular/angular-cli/releases) - [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular-cli/compare/v21.2.6...v21.2.9) Updates `@angular/cli` from 21.2.6 to 21.2.9 - [Release notes](https://github.com/angular/angular-cli/releases) - [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular-cli/compare/v21.2.6...v21.2.9) Updates `@angular/compiler-cli` from 21.2.7 to 21.2.11 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.11/packages/compiler-cli) --- updated-dependencies: - dependency-name: "@angular/build" dependency-version: 21.2.7 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/cdk" dependency-version: 21.2.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/cli" dependency-version: 21.2.7 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/common" dependency-version: 21.2.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/compiler" dependency-version: 21.2.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/compiler-cli" dependency-version: 21.2.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/core" dependency-version: 21.2.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/forms" dependency-version: 21.2.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/platform-browser" dependency-version: 21.2.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/router" dependency-version: 21.2.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/ai.client/package-lock.json | 252 +++++++-------------------- frontend/ai.client/package.json | 20 +-- 2 files changed, 69 insertions(+), 203 deletions(-) diff --git a/frontend/ai.client/package-lock.json b/frontend/ai.client/package-lock.json index dbd6bf88..feedbf8a 100644 --- a/frontend/ai.client/package-lock.json +++ b/frontend/ai.client/package-lock.json @@ -8,13 +8,13 @@ "name": "ai.client", "version": "1.0.0-beta.23", "dependencies": { - "@angular/cdk": "21.2.5", - "@angular/common": "21.2.7", - "@angular/compiler": "21.2.7", - "@angular/core": "21.2.7", - "@angular/forms": "21.2.7", - "@angular/platform-browser": "21.2.7", - "@angular/router": "21.2.7", + "@angular/cdk": "21.2.9", + "@angular/common": "21.2.11", + "@angular/compiler": "21.2.11", + "@angular/core": "21.2.11", + "@angular/forms": "21.2.11", + "@angular/platform-browser": "21.2.11", + "@angular/router": "21.2.11", "@ctrl/ngx-emoji-mart": "9.3.0", "@microsoft/fetch-event-source": "2.0.1", "@ng-icons/core": "33.2.2", @@ -34,9 +34,9 @@ "devDependencies": { "@analogjs/vite-plugin-angular": "3.0.0-alpha.53", "@analogjs/vitest-angular": "3.0.0-alpha.30", - "@angular/build": "21.2.6", - "@angular/cli": "21.2.6", - "@angular/compiler-cli": "21.2.7", + "@angular/build": "21.2.9", + "@angular/cli": "21.2.9", + "@angular/compiler-cli": "21.2.11", "@playwright/test": "1.59.1", "@tailwindcss/postcss": "4.2.4", "@types/node": "25.6.0", @@ -353,7 +353,6 @@ "integrity": "sha512-OlPEtd5pPZSFdkXEIyZ93jsfBrkvUrVPb3xs4z2WPRnBRk9jyey40eKnmql86KRHfdn4WjHpmde4NDgtDpZRxQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@angular-devkit/core": "21.2.9", "rxjs": "7.8.2" @@ -372,7 +371,6 @@ "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.9.tgz", "integrity": "sha512-04rdOGEzjLWFHlyAwqtuikginFeQ2jfXS5HqqKNP0VtG6Uu9NUDAEW5UDvXgqkEMfCDwGZbmg2iRHxp3AmAKVw==", "license": "MIT", - "peer": true, "dependencies": { "ajv": "8.18.0", "ajv-formats": "3.0.1", @@ -400,7 +398,6 @@ "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.9.tgz", "integrity": "sha512-Gyyuq2Vet70AMkbC+e0L6rjzjZWjSOyKTlOJvd99GjjyWQf6eezjd8IcF17ppKJsML6YUagO2I6AlWROq5yJmg==", "license": "MIT", - "peer": true, "dependencies": { "@angular-devkit/core": "21.2.9", "jsonc-parser": "3.3.1", @@ -415,14 +412,14 @@ } }, "node_modules/@angular/build": { - "version": "21.2.6", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.6.tgz", - "integrity": "sha512-PJltYl9/INfz8nZ/KHf39nqlmt3c9PR0jJaZt6hhCPENyAf4PwQpm28erkJmbOYO864goIuws41lduYXyDqQ0Q==", + "version": "21.2.9", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.9.tgz", + "integrity": "sha512-XYP5ALB56NWvcQisznmvQdVU6WJdUCAuCAEN2eDZNVd9X1IqRNfewQfFH6FyHo7SrK4GHDReqm6xWW6rs0+weQ==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.2102.6", + "@angular-devkit/architect": "0.2102.9", "@babel/core": "7.29.0", "@babel/helper-annotate-as-pure": "7.27.3", "@babel/helper-split-export-declaration": "7.24.7", @@ -446,7 +443,7 @@ "source-map-support": "0.5.21", "tinyglobby": "0.2.15", "undici": "7.24.4", - "vite": "7.3.1", + "vite": "7.3.2", "watchpack": "2.5.1" }, "engines": { @@ -465,7 +462,7 @@ "@angular/platform-browser": "^21.0.0", "@angular/platform-server": "^21.0.0", "@angular/service-worker": "^21.0.0", - "@angular/ssr": "^21.2.6", + "@angular/ssr": "^21.2.9", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^21.0.0", @@ -514,53 +511,6 @@ } } }, - "node_modules/@angular/build/node_modules/@angular-devkit/architect": { - "version": "0.2102.6", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.6.tgz", - "integrity": "sha512-h4qybKypR7OuwcTHPQI1zRm7abXgmPiV49vI2UeMtVVY/GKzru9gMexcYmWabzEyBY8w6VSfWjV2X+eit2EhDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "21.2.6", - "rxjs": "7.8.2" - }, - "bin": { - "architect": "bin/cli.js" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@angular/build/node_modules/@angular-devkit/core": { - "version": "21.2.6", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.6.tgz", - "integrity": "sha512-u5gPTAY7MC02uACQE39xxiFcm1hslF+ih/f2borMWnhER0JNTpHjLiLRXFkq7or7+VVHU30zfhK4XNAuO4WTIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "8.18.0", - "ajv-formats": "3.0.1", - "jsonc-parser": "3.3.1", - "picomatch": "4.0.4", - "rxjs": "7.8.2", - "source-map": "0.7.6" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "peerDependencies": { - "chokidar": "^5.0.0" - }, - "peerDependenciesMeta": { - "chokidar": { - "optional": true - } - } - }, "node_modules/@angular/build/node_modules/@oxc-project/types": { "version": "0.113.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.113.0.tgz", @@ -849,9 +799,9 @@ } }, "node_modules/@angular/cdk": { - "version": "21.2.5", - "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.2.5.tgz", - "integrity": "sha512-F1sVqMAGYoiJNYYaR2cerqTo7IqpxQ3ZtMDxR3rtB0rSSd5UPOIQoqpsfSd6uH8FVnuzKaBII8Mg6YrjClFsng==", + "version": "21.2.9", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.2.9.tgz", + "integrity": "sha512-0JXsr8f7xjV2815esTSq4+zGqWMa0CyNT/DV1F7lYS6qkYXcFdYUzGcd/WjNL05VKkajkSkWmTi6uyVsOpYdGA==", "license": "MIT", "dependencies": { "parse5": "^8.0.0", @@ -865,19 +815,19 @@ } }, "node_modules/@angular/cli": { - "version": "21.2.6", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.6.tgz", - "integrity": "sha512-I5DOFcIT1HKymyy2f78fjgD0Iv6jG46GbBZ/VxejcnhjubFpuN4CwPdugXf9rIDs8KZQqBzDBFUbq11vnk8h0A==", + "version": "21.2.9", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.9.tgz", + "integrity": "sha512-KldNb7vCEVOeyEUK57dguP3dTjYeikBmAohjAouu8JLtY8OOI+tf/TA31Gco/rxZ3nGqBwkvrqpD4rcDf5AhUA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.2102.6", - "@angular-devkit/core": "21.2.6", - "@angular-devkit/schematics": "21.2.6", + "@angular-devkit/architect": "0.2102.9", + "@angular-devkit/core": "21.2.9", + "@angular-devkit/schematics": "21.2.9", "@inquirer/prompts": "7.10.1", "@listr2/prompt-adapter-inquirer": "3.0.5", "@modelcontextprotocol/sdk": "1.26.0", - "@schematics/angular": "21.2.6", + "@schematics/angular": "21.2.9", "@yarnpkg/lockfile": "1.1.0", "algoliasearch": "5.48.1", "ini": "6.0.0", @@ -899,93 +849,10 @@ "yarn": ">= 1.13.0" } }, - "node_modules/@angular/cli/node_modules/@angular-devkit/architect": { - "version": "0.2102.6", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.6.tgz", - "integrity": "sha512-h4qybKypR7OuwcTHPQI1zRm7abXgmPiV49vI2UeMtVVY/GKzru9gMexcYmWabzEyBY8w6VSfWjV2X+eit2EhDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "21.2.6", - "rxjs": "7.8.2" - }, - "bin": { - "architect": "bin/cli.js" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@angular/cli/node_modules/@angular-devkit/core": { - "version": "21.2.6", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.6.tgz", - "integrity": "sha512-u5gPTAY7MC02uACQE39xxiFcm1hslF+ih/f2borMWnhER0JNTpHjLiLRXFkq7or7+VVHU30zfhK4XNAuO4WTIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "8.18.0", - "ajv-formats": "3.0.1", - "jsonc-parser": "3.3.1", - "picomatch": "4.0.4", - "rxjs": "7.8.2", - "source-map": "0.7.6" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "peerDependencies": { - "chokidar": "^5.0.0" - }, - "peerDependenciesMeta": { - "chokidar": { - "optional": true - } - } - }, - "node_modules/@angular/cli/node_modules/@angular-devkit/schematics": { - "version": "21.2.6", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.6.tgz", - "integrity": "sha512-hk2duJlPJyiMaI9MVWA5XpmlpD9C4n8qgquV/MJ7/n+ZRSwW3w1ndL5qUmA1ki+4Da54v/Rc8Wt5tUS955+93w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "21.2.6", - "jsonc-parser": "3.3.1", - "magic-string": "0.30.21", - "ora": "9.3.0", - "rxjs": "7.8.2" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@angular/cli/node_modules/@schematics/angular": { - "version": "21.2.6", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.6.tgz", - "integrity": "sha512-KpLD8R2S762jbLdNEepE+b7KjhVOKPFHHdgNqhPv0NiGLdsvXSOx1e63JvFacoCZdmP7n3/gwmyT/utcVvnsag==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "21.2.6", - "@angular-devkit/schematics": "21.2.6", - "jsonc-parser": "3.3.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, "node_modules/@angular/common": { - "version": "21.2.7", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.7.tgz", - "integrity": "sha512-YFdnU5z8JloJjLYa52OyCOULQhqEE/ym7vKfABySWDsiVXZr9FNmKMeZi/lUcg7ZO22UbBihqW9a9D6VSHOo+g==", + "version": "21.2.11", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.11.tgz", + "integrity": "sha512-3Z3SABXpzM6fkX21WCRP6IwrjxNQVHM/3Fk2OXScExOAzpaOpS2bDgS4NB6rtCbmzKL/NFSp7ZPIZigfdqnWGw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -994,14 +861,14 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "21.2.7", + "@angular/core": "21.2.11", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "21.2.7", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.7.tgz", - "integrity": "sha512-4J0Nl5gGmr5SKgR3FHK4J6rdG0aP5zAsY3AJU8YXH+D98CeNTjQUD8XHsdD2cTwo08V5mDdFa5VCsREpMPJ5gQ==", + "version": "21.2.11", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.11.tgz", + "integrity": "sha512-/KdE0kPQr24K/aNsdIDS2or555+8CrQxyRB5MxPKy3/8d6EvilEY/UN7pB7A5xgRQtUPMea08ZzLFJVp1qNbDA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1011,9 +878,9 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "21.2.7", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.7.tgz", - "integrity": "sha512-r76vKBM7Wu0N8PTeec7340Gtv1wC7IBQGJOQnukshPgzaabgNKxmUiChGxi+RJNo/Tsdiw9ZfddcBgBjq79ZIg==", + "version": "21.2.11", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.11.tgz", + "integrity": "sha512-qp/LgptDYJvpEHVVdwBEtkcbybre/ftanu0qJMpH3mu5FC4HEEOChl+9m7UVrmL4jC1ZkoZcgtzsGKAQr8mw2g==", "dev": true, "license": "MIT", "dependencies": { @@ -1034,7 +901,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "21.2.7", + "@angular/compiler": "21.2.11", "typescript": ">=5.9 <6.1" }, "peerDependenciesMeta": { @@ -1044,9 +911,9 @@ } }, "node_modules/@angular/core": { - "version": "21.2.7", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.7.tgz", - "integrity": "sha512-4bnskeRNNOZMn3buVw47Zz9Py4B8AZgYHe5xBEMOY5/yrldb7OFje5gWCWls23P18FKwhl+Xx1hgnOEPSs29gw==", + "version": "21.2.11", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.11.tgz", + "integrity": "sha512-EULAfQ0m/I9hZJes74OFlrnfDWqlfV0esE0CkHehO5IEF9rd769+dfuGEAJAzrz+/6Q3PhS0bWDYiT68z1H8Ag==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1055,7 +922,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "21.2.7", + "@angular/compiler": "21.2.11", "rxjs": "^6.5.3 || ^7.4.0", "zone.js": "~0.15.0 || ~0.16.0" }, @@ -1069,9 +936,9 @@ } }, "node_modules/@angular/forms": { - "version": "21.2.7", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.7.tgz", - "integrity": "sha512-YD/h07cdEeAUs41ysTk6820T0lG/XiQmFiq02d3IsiHYI5Vaj2pg9Ti1wWZYEBM//hVAPTzV0dwdV7Q1Gxju1w==", + "version": "21.2.11", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.11.tgz", + "integrity": "sha512-F67V612wHxPXHrbp825VirYfGPKBUM8PvL9atN2Ku1fsdGSFPU3hTxu1HU8fKYLLBpKYVVuqFqzaU/qIpTXGYA==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -1081,16 +948,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "21.2.7", - "@angular/core": "21.2.7", - "@angular/platform-browser": "21.2.7", + "@angular/common": "21.2.11", + "@angular/core": "21.2.11", + "@angular/platform-browser": "21.2.11", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/platform-browser": { - "version": "21.2.7", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.7.tgz", - "integrity": "sha512-nklVhstRZL4wpYg9Cyae/Eyfa7LMpgb0TyD/F//qCuohhM8nM7F+O0ekykGD6H+I34jsvqx6yLS7MicndWVz7Q==", + "version": "21.2.11", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.11.tgz", + "integrity": "sha512-Uz/KwGjSEvbE8J9kNSSetzxhBWjCXv9OuxH1w2WkW6jLNU3vgvzuKX7SXDyUys6KJv5TqkClJ9BLeU11QbmJdw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1099,9 +966,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/animations": "21.2.7", - "@angular/common": "21.2.7", - "@angular/core": "21.2.7" + "@angular/animations": "21.2.11", + "@angular/common": "21.2.11", + "@angular/core": "21.2.11" }, "peerDependenciesMeta": { "@angular/animations": { @@ -1110,9 +977,9 @@ } }, "node_modules/@angular/router": { - "version": "21.2.7", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.7.tgz", - "integrity": "sha512-Ina6XgtpvXT1OsLAomURHJGQDOkIVGrguWAOZ7+gOjsJEjUfpxTktFter+/K59KMC2yv6yneLvYSn3AswTYx7A==", + "version": "21.2.11", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.11.tgz", + "integrity": "sha512-IB7/KuRDsxAjCOxYNccq2LdCTKuu59cx5MmOhrt+TarvkNE/xdlFkP7vtrCl44DJt0q7/tveWvsn5oqTw7rN7A==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1121,9 +988,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "21.2.7", - "@angular/core": "21.2.7", - "@angular/platform-browser": "21.2.7", + "@angular/common": "21.2.11", + "@angular/core": "21.2.11", + "@angular/platform-browser": "21.2.11", "rxjs": "^6.5.3 || ^7.4.0" } }, @@ -5078,7 +4945,6 @@ "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.9.tgz", "integrity": "sha512-1renEbBZz9Yw3A0GUOJ6x6E1jd2Vu/fX5tEGiFNbIoWaNwa71SlFTvKKqaYxiYQkrpc7oexVJ2ymuvOfgTbI1w==", "license": "MIT", - "peer": true, "dependencies": { "@angular-devkit/core": "21.2.9", "@angular-devkit/schematics": "21.2.9", diff --git a/frontend/ai.client/package.json b/frontend/ai.client/package.json index 59a8d03d..6d11132b 100644 --- a/frontend/ai.client/package.json +++ b/frontend/ai.client/package.json @@ -27,13 +27,13 @@ "private": true, "packageManager": "npm@11.2.0", "dependencies": { - "@angular/cdk": "21.2.5", - "@angular/common": "21.2.7", - "@angular/compiler": "21.2.7", - "@angular/core": "21.2.7", - "@angular/forms": "21.2.7", - "@angular/platform-browser": "21.2.7", - "@angular/router": "21.2.7", + "@angular/cdk": "21.2.9", + "@angular/common": "21.2.11", + "@angular/compiler": "21.2.11", + "@angular/core": "21.2.11", + "@angular/forms": "21.2.11", + "@angular/platform-browser": "21.2.11", + "@angular/router": "21.2.11", "@ctrl/ngx-emoji-mart": "9.3.0", "@microsoft/fetch-event-source": "2.0.1", "@ng-icons/core": "33.2.2", @@ -53,9 +53,9 @@ "devDependencies": { "@analogjs/vite-plugin-angular": "3.0.0-alpha.53", "@analogjs/vitest-angular": "3.0.0-alpha.30", - "@angular/build": "21.2.6", - "@angular/cli": "21.2.6", - "@angular/compiler-cli": "21.2.7", + "@angular/build": "21.2.9", + "@angular/cli": "21.2.9", + "@angular/compiler-cli": "21.2.11", "@playwright/test": "1.59.1", "@tailwindcss/postcss": "4.2.4", "@types/node": "25.6.0", From 043a6afad617d1b3cef25132f558c89190a66c70 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 08:56:08 -0600 Subject: [PATCH 08/17] chore(deps)(deps-dev): bump @types/node (#147) Bumps the infra-minor-patch group with 1 update in the /infrastructure directory: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node). Updates `@types/node` from 25.5.2 to 25.6.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-version: 25.6.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: infra-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- infrastructure/package-lock.json | 16 ++++++++-------- infrastructure/package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/infrastructure/package-lock.json b/infrastructure/package-lock.json index 242b8294..18c6670a 100644 --- a/infrastructure/package-lock.json +++ b/infrastructure/package-lock.json @@ -16,7 +16,7 @@ }, "devDependencies": { "@types/jest": "30.0.0", - "@types/node": "25.5.2", + "@types/node": "25.6.0", "aws-cdk": "2.1120.0", "jest": "30.3.0", "ts-jest": "29.4.9", @@ -1248,13 +1248,13 @@ } }, "node_modules/@types/node": { - "version": "25.5.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", - "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~7.19.0" } }, "node_modules/@types/stack-utils": { @@ -4797,9 +4797,9 @@ } }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "dev": true, "license": "MIT" }, diff --git a/infrastructure/package.json b/infrastructure/package.json index 0aa8a325..9f1d6227 100644 --- a/infrastructure/package.json +++ b/infrastructure/package.json @@ -12,7 +12,7 @@ }, "devDependencies": { "@types/jest": "30.0.0", - "@types/node": "25.5.2", + "@types/node": "25.6.0", "aws-cdk": "2.1120.0", "jest": "30.3.0", "ts-jest": "29.4.9", From 5d495a8f0507d06b5b64b0ae2eb2784e1e234ff6 Mon Sep 17 00:00:00 2001 From: derrickfink Date: Fri, 1 May 2026 10:02:12 -0600 Subject: [PATCH 09/17] feat(documents): add XLSX-specific chunker for improved tabular data ingestion - Add xlsx_chunker.py module to convert Excel sheets to CSV and chunk by rows - Integrate XLSX detection and routing in docling_processor.py to bypass Docling's slow table parsing - Prepend sheet names to chunks for multi-sheet workbooks to preserve context in embeddings - Skip empty sheets and handle None values during row conversion - Use existing row-based CSV chunker for consistent token-based chunking across tabular formats - Improves performance and memory usage for Excel files while maintaining header-per-chunk structure for better RAG embeddings --- .../ingestion/processors/docling_processor.py | 14 ++++ .../ingestion/processors/xlsx_chunker.py | 68 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 backend/src/apis/app_api/documents/ingestion/processors/xlsx_chunker.py diff --git a/backend/src/apis/app_api/documents/ingestion/processors/docling_processor.py b/backend/src/apis/app_api/documents/ingestion/processors/docling_processor.py index 250e6ac2..d0c57893 100644 --- a/backend/src/apis/app_api/documents/ingestion/processors/docling_processor.py +++ b/backend/src/apis/app_api/documents/ingestion/processors/docling_processor.py @@ -185,6 +185,20 @@ async def process_with_docling( logger.info(f"CSV chunking complete. Total chunks: {len(chunks)}") return chunks + # XLSX-specific path: convert sheets to CSV, then use row-based chunker + if mime_type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" or (filename and filename.lower().endswith(".xlsx")): + logger.info("Detected XLSX file, using XLSX-specific chunker (bypassing Docling)") + _ensure_tiktoken_cache() + from .xlsx_chunker import chunk_xlsx + + chunks = chunk_xlsx(file_bytes, max_tokens=900) + + if progress_callback: + await progress_callback(len(chunks)) + + logger.info(f"XLSX chunking complete. Total chunks: {len(chunks)}") + return chunks + # Import inside function to avoid heavy load at cold start if not needed immediately import torch diff --git a/backend/src/apis/app_api/documents/ingestion/processors/xlsx_chunker.py b/backend/src/apis/app_api/documents/ingestion/processors/xlsx_chunker.py new file mode 100644 index 00000000..d9c74a7d --- /dev/null +++ b/backend/src/apis/app_api/documents/ingestion/processors/xlsx_chunker.py @@ -0,0 +1,68 @@ +"""XLSX-specific chunker for RAG ingestion. + +Converts each sheet in an Excel workbook to CSV, then delegates to the +existing row-based CSV chunker. This avoids Docling's slow and +memory-intensive table parsing while preserving header-per-chunk structure +that produces better embeddings for tabular data. +""" + +import csv +import io +import logging +from typing import List + +from openpyxl import load_workbook + +from .csv_chunker import chunk_csv + +logger = logging.getLogger(__name__) + + +def chunk_xlsx(file_bytes: bytes, max_tokens: int = 900) -> List[str]: + """ + Chunk an XLSX file by converting each sheet to CSV and chunking rows. + + Each sheet is processed independently. The sheet name is prepended as + context to every chunk from that sheet so the embedding captures which + sheet the data belongs to. + + Args: + file_bytes: Raw XLSX file content. + max_tokens: Maximum token count per chunk (passed to chunk_csv). + + Returns: + List of text chunks across all sheets. + """ + wb = load_workbook(io.BytesIO(file_bytes), read_only=True, data_only=True) + all_chunks: List[str] = [] + + for sheet_name in wb.sheetnames: + ws = wb[sheet_name] + + # Convert sheet rows to properly-quoted CSV + buf = io.StringIO() + writer = csv.writer(buf) + row_count = 0 + for row in ws.iter_rows(values_only=True): + if all(cell is None for cell in row): + continue + writer.writerow([str(cell) if cell is not None else "" for cell in row]) + row_count += 1 + + if row_count == 0: + logger.info(f"Sheet '{sheet_name}' is empty, skipping") + continue + + csv_bytes = buf.getvalue().encode("utf-8") + sheet_chunks = chunk_csv(csv_bytes, max_tokens=max_tokens) + + # Prepend sheet name for multi-sheet context + if len(wb.sheetnames) > 1: + sheet_chunks = [f"Sheet: {sheet_name}\n{chunk}" for chunk in sheet_chunks] + + logger.info(f"Sheet '{sheet_name}': {row_count} rows -> {len(sheet_chunks)} chunks") + all_chunks.extend(sheet_chunks) + + wb.close() + logger.info(f"XLSX chunked into {len(all_chunks)} total chunks across {len(wb.sheetnames)} sheets") + return all_chunks From fa5b897ba074f427b0182cca2a671ecd54caf8eb Mon Sep 17 00:00:00 2001 From: derrickfink Date: Fri, 1 May 2026 10:49:20 -0600 Subject: [PATCH 10/17] feat(embeddings): implement batch processing for S3 Vector storage - Replace single-batch vector upload with batched processing (50 vectors per batch) - Add batch size constant to prevent exceeding S3 Vectors request body limits - Replace print statement with logger.info for consistent logging - Add progress logging at 500-vector intervals and batch completion - Improve docstring to clarify batch processing behavior - Refactor vector payload construction to use list comprehension within batch loop - Prevents request failures when storing large numbers of embeddings in a single operation --- .../shared/embeddings/bedrock_embeddings.py | 47 ++++++++++--------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/backend/src/apis/shared/embeddings/bedrock_embeddings.py b/backend/src/apis/shared/embeddings/bedrock_embeddings.py index d9459270..962031b9 100644 --- a/backend/src/apis/shared/embeddings/bedrock_embeddings.py +++ b/backend/src/apis/shared/embeddings/bedrock_embeddings.py @@ -107,33 +107,36 @@ async def store_embeddings_in_s3( assistant_id: str, document_id: str, chunks: List[str], embeddings: List[List[float]], metadata: Dict[str, Any] ) -> str: """ - Store embeddings directly into the S3 Vector Index (NOT just a file in S3) + Store embeddings directly into the S3 Vector Index in batches. """ s3vectors = boto3.client("s3vectors", region_name=AWS_REGION) vector_bucket = _get_vector_store_bucket() vector_index = _get_vector_store_index() - print(f"Storing {len(chunks)} chunks for {document_id} in {vector_bucket} with index {vector_index}") - - vectors_payload = [] - - for i, chunk in enumerate(chunks): - vector_key = f"{document_id}#{i}" - vector_entry = { - "key": vector_key, - "data": { - "float32": embeddings[i] - }, - "metadata": { - "text": chunk, - "document_id": document_id, - "assistant_id": assistant_id, - "source": metadata.get("filename", "unknown"), - }, - } - vectors_payload.append(vector_entry) - - s3vectors.put_vectors(vectorBucketName=vector_bucket, indexName=vector_index, vectors=vectors_payload) + logger.info(f"Storing {len(chunks)} chunks for {document_id} in {vector_bucket} (index: {vector_index})") + + BATCH_SIZE = 50 # Safe batch size to stay under S3 Vectors request body limit + + for batch_start in range(0, len(chunks), BATCH_SIZE): + batch_end = min(batch_start + BATCH_SIZE, len(chunks)) + batch_payload = [] + + for i in range(batch_start, batch_end): + batch_payload.append({ + "key": f"{document_id}#{i}", + "data": {"float32": embeddings[i]}, + "metadata": { + "text": chunks[i], + "document_id": document_id, + "assistant_id": assistant_id, + "source": metadata.get("filename", "unknown"), + }, + }) + + s3vectors.put_vectors(vectorBucketName=vector_bucket, indexName=vector_index, vectors=batch_payload) + + if batch_end % 500 == 0 or batch_end == len(chunks): + logger.info(f"Stored {batch_end}/{len(chunks)} vectors") return f"Indexed {len(chunks)} chunks for {document_id}" From 8ca8d2ee7d602616b5e4e18d0179e1d01e68d725 Mon Sep 17 00:00:00 2001 From: derrickfink Date: Fri, 1 May 2026 11:10:48 -0600 Subject: [PATCH 11/17] ci(rag-ingestion): add shared embeddings to workflow path filters - Add 'backend/src/apis/shared/embeddings/**' to push trigger paths - Add 'backend/src/apis/shared/embeddings/**' to pull_request trigger paths - Ensures RAG ingestion workflow runs when shared embeddings module changes --- .github/workflows/rag-ingestion.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/rag-ingestion.yml b/.github/workflows/rag-ingestion.yml index b439907f..16145642 100644 --- a/.github/workflows/rag-ingestion.yml +++ b/.github/workflows/rag-ingestion.yml @@ -7,6 +7,7 @@ on: - develop paths: - 'backend/src/apis/app_api/documents/ingestion/**' + - 'backend/src/apis/shared/embeddings/**' - 'backend/pyproject.toml' - 'backend/Dockerfile.rag-ingestion' - 'infrastructure/lib/rag-ingestion-stack.ts' @@ -15,6 +16,7 @@ on: pull_request: paths: - 'backend/src/apis/app_api/documents/ingestion/**' + - 'backend/src/apis/shared/embeddings/**' - 'backend/pyproject.toml' - 'backend/Dockerfile.rag-ingestion' - 'infrastructure/lib/rag-ingestion-stack.ts' From 5f36ff332b3d81353b4dad1e8c640a2227947704 Mon Sep 17 00:00:00 2001 From: derrickfink Date: Fri, 1 May 2026 12:42:27 -0600 Subject: [PATCH 12/17] feat(documents): improve XLSX header detection to skip title/banner rows - Add _is_likely_header() helper to identify real column headers using heuristics (text-dominant cells, non-empty threshold) - Add _find_header_row_index_from_rows() to locate the first actual header row, skipping sparse title/banner rows at sheet start - Refactor chunk_xlsx() to collect all non-empty rows first, then detect header offset before CSV conversion - Update logging to report actual data row count instead of total non-empty rows - Improves chunking accuracy for XLSX files with metadata, titles, or banner rows before the actual data table --- .../ingestion/processors/xlsx_chunker.py | 116 ++++++++++++++++-- 1 file changed, 106 insertions(+), 10 deletions(-) diff --git a/backend/src/apis/app_api/documents/ingestion/processors/xlsx_chunker.py b/backend/src/apis/app_api/documents/ingestion/processors/xlsx_chunker.py index d9c74a7d..70ea6827 100644 --- a/backend/src/apis/app_api/documents/ingestion/processors/xlsx_chunker.py +++ b/backend/src/apis/app_api/documents/ingestion/processors/xlsx_chunker.py @@ -18,6 +18,96 @@ logger = logging.getLogger(__name__) +def _is_likely_header(row: tuple, next_row: tuple = None) -> bool: + """ + Determine if a row looks like a column header. + + Uses two signals: + 1. The row is predominantly non-numeric text strings + 2. If a next row is available, it should look different (contain numbers/dates), + confirming this row is labels and the next is data + + Returns False for rows that are mostly empty (title/banner rows). + """ + non_empty = [cell for cell in row if cell is not None and str(cell).strip()] + total_cells = len(row) if row else 0 + + # Skip rows where most cells are empty (title/banner rows) + if total_cells > 0 and len(non_empty) < total_cells * 0.5: + return False + + if not non_empty: + return False + + # Check if most cells are non-numeric text + text_count = 0 + for cell in non_empty: + if isinstance(cell, str): + stripped = cell.strip() + try: + float(stripped.replace(",", "")) + continue # It's a number in string form + except ValueError: + pass + text_count += 1 + # Non-string types (int, float, datetime) are not header-like + + if text_count <= len(non_empty) * 0.7: + return False + + # If we have a next row, verify it looks like data (has numbers or dates) + if next_row is not None: + next_non_empty = [cell for cell in next_row if cell is not None and str(cell).strip()] + numeric_count = 0 + for cell in next_non_empty: + if isinstance(cell, (int, float)): + numeric_count += 1 + elif isinstance(cell, str): + try: + float(cell.strip().replace(",", "")) + numeric_count += 1 + except ValueError: + pass + # Data row should have at least some numbers + if next_non_empty and numeric_count > 0: + return True + # If next row is also all text, this might not be the header + if next_non_empty and numeric_count == 0: + return False + + return True + + +def _find_header_row_index_from_rows(rows: list) -> int: + """ + Find the first row that looks like a real column header. + + A header row is one where: + - Most cells are populated (not a sparse title row) + - Most cells are text strings (not numbers/dates) + - The following row contains data (numbers, dates, mixed types) + + Falls back to row 0 if no clear header is found. + + Args: + rows: List of non-empty row tuples. + + Returns: + 0-based index of the header row within the list. + """ + if len(rows) <= 1: + return 0 + + for i, row in enumerate(rows[:10]): + next_row = rows[i + 1] if i + 1 < len(rows) else None + if _is_likely_header(row, next_row): + if i > 0: + logger.info(f"Skipping {i} non-header row(s) before detected header") + return i + + return 0 + + def chunk_xlsx(file_bytes: bytes, max_tokens: int = 900) -> List[str]: """ Chunk an XLSX file by converting each sheet to CSV and chunking rows. @@ -39,20 +129,26 @@ def chunk_xlsx(file_bytes: bytes, max_tokens: int = 900) -> List[str]: for sheet_name in wb.sheetnames: ws = wb[sheet_name] - # Convert sheet rows to properly-quoted CSV - buf = io.StringIO() - writer = csv.writer(buf) - row_count = 0 + # Collect non-empty rows (read_only mode only allows one iteration) + non_empty_rows = [] for row in ws.iter_rows(values_only=True): - if all(cell is None for cell in row): - continue - writer.writerow([str(cell) if cell is not None else "" for cell in row]) - row_count += 1 + if not all(cell is None for cell in row): + non_empty_rows.append(row) - if row_count == 0: + if not non_empty_rows: logger.info(f"Sheet '{sheet_name}' is empty, skipping") continue + # Detect real header row (skip title/banner rows) + header_offset = _find_header_row_index_from_rows(non_empty_rows) + data_rows = non_empty_rows[header_offset:] + + # Convert to CSV + buf = io.StringIO() + writer = csv.writer(buf) + for row in data_rows: + writer.writerow([str(cell) if cell is not None else "" for cell in row]) + csv_bytes = buf.getvalue().encode("utf-8") sheet_chunks = chunk_csv(csv_bytes, max_tokens=max_tokens) @@ -60,7 +156,7 @@ def chunk_xlsx(file_bytes: bytes, max_tokens: int = 900) -> List[str]: if len(wb.sheetnames) > 1: sheet_chunks = [f"Sheet: {sheet_name}\n{chunk}" for chunk in sheet_chunks] - logger.info(f"Sheet '{sheet_name}': {row_count} rows -> {len(sheet_chunks)} chunks") + logger.info(f"Sheet '{sheet_name}': {len(data_rows)} rows -> {len(sheet_chunks)} chunks") all_chunks.extend(sheet_chunks) wb.close() From 186bad5cd8fe0aca25b97803b2a1cc5b298443b0 Mon Sep 17 00:00:00 2001 From: Phil Merrell Date: Fri, 1 May 2026 20:20:50 -0600 Subject: [PATCH 13/17] fix(chat): align resume cache key with original turn so paused agent isn't orphaned (#207) * fix(chat): align resume cache key with original turn so paused agent isn't orphaned The agent cache keyed on the unbuilt `system_prompt` parameter, but the construction snapshot persisted the *built* prompt. Resume requests passed the built form back into `get_agent`, hashing to a different cache slot than the original turn. The resume rebuilt a fresh agent (cache MISS), leaving the original (paused) agent stuck under the original key. The next non-resume turn then cache-hit the paused agent, and Strands raised "prompt_type= | must resume from interrupt with list of interruptResponse's". Snapshot the unbuilt prompt so resume hashes to the same key as the original turn. Add defense-in-depth: when `get_agent` cache-hits a paused agent on a non-resume request, evict and rebuild instead of serving the stale state. Co-Authored-By: Claude Opus 4.7 * test(chat): cover OAuth-resume cache fix and clean up eviction helper - Add `tests/apis/inference_api/test_chat_service.py` exercising the `get_agent` cache behavior the fix depends on: snapshot replay lands on the same cache slot, paused-agent eviction fires on non-resume, resume preserves the paused agent, healthy agents still cache-hit. - Extract `_is_paused_on_interrupt` helper. `getattr` with defaults cannot raise, so the broad try/except around the check was dead and hid real bugs behind a `logger.exception`. Replace with a direct call. - Expand the snapshot comment in `BaseAgent.__init__` to flag the cache-evicted-resume date-drift trade-off (rebuilt agent re-renders `Current date:` rather than preserving the original turn's date). Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Opus 4.7 --- backend/src/agents/main_agent/base_agent.py | 20 +- backend/src/apis/inference_api/chat/routes.py | 2 + .../src/apis/inference_api/chat/service.py | 33 +++- .../apis/inference_api/test_chat_service.py | 185 ++++++++++++++++++ 4 files changed, 235 insertions(+), 5 deletions(-) create mode 100644 backend/tests/apis/inference_api/test_chat_service.py diff --git a/backend/src/agents/main_agent/base_agent.py b/backend/src/agents/main_agent/base_agent.py index 297d52dc..8d795276 100644 --- a/backend/src/agents/main_agent/base_agent.py +++ b/backend/src/agents/main_agent/base_agent.py @@ -126,9 +126,23 @@ def __init__( self.prompt_builder = SystemPromptBuilder() self.system_prompt = self.prompt_builder.build(include_date=True) - # Capture the resolved system prompt — what we'd need to pass back to - # ``get_agent`` to land on the same cache key on resume. - self._construction_snapshot["system_prompt"] = self.system_prompt + # Snapshot the *unbuilt* system_prompt — i.e. the same value the + # caller passed to ``get_agent`` originally. The cache key hashes + # this raw value (see ``_create_cache_key``), so storing the built + # prompt here causes resume to land on a different cache slot than + # the original turn. That leaves the original (paused) agent stuck + # in the cache; a later non-resume turn cache-hits to it and + # Strands raises "must resume from interrupt with list of + # interruptResponse's" because _interrupt_state is still activated. + # + # Trade-off: if the cache evicts between pause and resume AND the + # original ``system_prompt`` was None, the rebuilt agent re-renders + # the date via ``include_date=True`` and may pick up *today's* date + # rather than the original turn's. Snapshot TTL is 1h, so this only + # matters across a midnight crossing. Resume conversation context is + # restored from AgentCore Memory regardless, so the model still sees + # prior turns; only the system-prompt date line shifts. + self._construction_snapshot["system_prompt"] = system_prompt # Initialize tool registry and filter self.tool_registry = create_default_registry() diff --git a/backend/src/apis/inference_api/chat/routes.py b/backend/src/apis/inference_api/chat/routes.py index 684e21cf..6bea39ce 100644 --- a/backend/src/apis/inference_api/chat/routes.py +++ b/backend/src/apis/inference_api/chat/routes.py @@ -713,6 +713,7 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g provider=snapshot.provider, inference_params=resume_inference_params, agent_type=snapshot.agent_type, + is_resume=True, ) else: # Build the canonical request inference-params dict. The frontend @@ -747,6 +748,7 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g provider=input_data.provider, inference_params=inference_params, agent_type=input_data.agent_type, + is_resume=False, ) # Resume requests must target interrupts that the cached agent diff --git a/backend/src/apis/inference_api/chat/service.py b/backend/src/apis/inference_api/chat/service.py index 35e4fdf1..c37e733c 100644 --- a/backend/src/apis/inference_api/chat/service.py +++ b/backend/src/apis/inference_api/chat/service.py @@ -94,6 +94,18 @@ def _create_cache_key( _CACHE_MAX_SIZE = 100 +def _is_paused_on_interrupt(agent: BaseAgent) -> bool: + """Return True if the wrapped Strands agent is mid-interrupt. + + Used by ``get_agent`` to decide whether to evict a stale paused agent + from the cache. ``getattr`` chains with defaults can never raise, so + no try/except is needed. + """ + inner = getattr(agent, "agent", None) + state = getattr(inner, "_interrupt_state", None) + return bool(state is not None and getattr(state, "activated", False)) + + async def get_agent( session_id: str, user_id: Optional[str] = None, @@ -107,6 +119,7 @@ async def get_agent( max_tokens: Optional[int] = None, agent_type: Optional[str] = None, inference_params: Optional[Dict[str, Any]] = None, + is_resume: bool = False, ) -> BaseAgent: """ Get or create agent instance with current configuration for session @@ -161,8 +174,24 @@ async def get_agent( # Check cache if cache_key in _agent_cache: - logger.debug("✅ Agent cache hit") - return _agent_cache[cache_key] + cached = _agent_cache[cache_key] + # Defense in depth: a non-resume request should never be served a + # paused agent. If we ever desync the cache key between the original + # turn and a resume (e.g. snapshot stores a normalized form of one + # of the params), the resume rebuilds under a new key while the + # paused agent stays in the original slot — and a later non-resume + # turn cache-hits to it. Strands then raises "must resume from + # interrupt with list of interruptResponse's". Discard and rebuild. + if not is_resume and _is_paused_on_interrupt(cached): + logger.warning( + "Cached agent is paused on an interrupt but request is not a resume; " + "evicting and rebuilding (session=%s user=%s)", + session_id, user_id, + ) + del _agent_cache[cache_key] + else: + logger.debug("✅ Agent cache hit") + return cached # Cache miss - create new agent logger.debug("⚠️ Agent cache miss - creating new instance") diff --git a/backend/tests/apis/inference_api/test_chat_service.py b/backend/tests/apis/inference_api/test_chat_service.py new file mode 100644 index 00000000..64d2b6dd --- /dev/null +++ b/backend/tests/apis/inference_api/test_chat_service.py @@ -0,0 +1,185 @@ +"""Tests for ``apis.inference_api.chat.service.get_agent`` cache behavior. + +Covers the OAuth-resume cache fix (#207): + +1. Cache key alignment — when ``system_prompt`` is ``None`` on the original + turn and the persisted snapshot also stores ``None``, the resume call + hashes to the same cache slot and reuses the paused agent. +2. Defense-in-depth eviction — a non-resume request that lands on a cached + agent whose ``_interrupt_state.activated`` is True must drop the cached + instance and build a fresh one. +3. Resume requests must NOT trigger the eviction path; the whole point of + resuming is to reuse the paused agent. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from apis.inference_api.chat import service + + +@pytest.fixture(autouse=True) +def _clear_cache(): + """Each test starts with an empty agent cache.""" + service.clear_agent_cache() + yield + service.clear_agent_cache() + + +def _fake_agent(*, system_prompt=None, activated: bool = False) -> MagicMock: + """Build a stand-in for BaseAgent that exposes the attrs ``get_agent`` reads. + + Mirrors the real shape: ``BaseAgent.agent`` is the wrapped Strands agent + and Strands stores interrupt state on ``agent._interrupt_state``. The + construction snapshot mirrors ``BaseAgent.__init__`` post-fix — it stores + the *unbuilt* ``system_prompt`` so resume hashes back to the same cache + slot. + """ + inner = SimpleNamespace(_interrupt_state=SimpleNamespace(activated=activated)) + wrapper = MagicMock(spec=["agent", "_construction_snapshot"]) + wrapper.agent = inner + wrapper._construction_snapshot = {"system_prompt": system_prompt} + return wrapper + + +@pytest.fixture +def mock_create_agent(): + """Patch out the agent factory so ``get_agent`` returns a fresh fake each call. + + The fake's snapshot mirrors the real ``BaseAgent`` post-fix: it stores + the unbuilt ``system_prompt`` parameter (not a rendered output). + """ + with patch.object(service, "create_agent") as mock: + mock.side_effect = lambda **kwargs: _fake_agent( + system_prompt=kwargs.get("system_prompt") + ) + yield mock + + +@pytest.fixture +def mock_freshness_hash(): + """Stable freshness hash so cache keys depend only on the inputs we care about.""" + with patch( + "apis.shared.tools.freshness.get_freshness_hash", + new=AsyncMock(return_value="fresh"), + ) as mock: + yield mock + + +@pytest.mark.asyncio +async def test_resume_replay_from_snapshot_hits_same_cache_slot( + mock_create_agent, mock_freshness_hash +): + """The regression fixed in #207. Original turn with ``system_prompt=None`` + pauses on OAuth consent; ``stream_coordinator`` writes the construction + snapshot to DynamoDB; the resume request reads ``snapshot.system_prompt`` + and feeds it back into ``get_agent``. With the fix, the snapshot stores + the unbuilt prompt (``None``), which hashes to the same cache key as the + original turn — so resume reuses the paused agent. With the bug + (snapshot stored the rendered base+date string), the cache key would + diverge and resume would rebuild, orphaning the paused agent. + """ + # Original turn: system_prompt=None + first = await service.get_agent( + session_id="s1", + user_id="u1", + system_prompt=None, + is_resume=False, + ) + first.agent._interrupt_state.activated = True + + # Production replay: stream_coordinator persists _construction_snapshot + # and the resume request feeds snapshot.system_prompt back into get_agent. + snapshot_system_prompt = first._construction_snapshot["system_prompt"] + assert snapshot_system_prompt is None, ( + "post-fix snapshot must store the unbuilt prompt (None), not a " + "rendered string — otherwise resume hashes to a different cache slot" + ) + + second = await service.get_agent( + session_id="s1", + user_id="u1", + system_prompt=snapshot_system_prompt, + is_resume=True, + ) + + assert second is first, "resume should return the same cached (paused) agent" + assert mock_create_agent.call_count == 1 + + +@pytest.mark.asyncio +async def test_non_resume_evicts_paused_cached_agent( + mock_create_agent, mock_freshness_hash +): + """If a paused agent ever ends up cached on a non-resume cache lookup + (the bug we're hardening against), evict it and build fresh. Strands would + otherwise reject the next plain user message with ``must resume from + interrupt with list of interruptResponse's``. + """ + paused = await service.get_agent( + session_id="s1", + user_id="u1", + is_resume=False, + ) + paused.agent._interrupt_state.activated = True + + rebuilt = await service.get_agent( + session_id="s1", + user_id="u1", + is_resume=False, + ) + + assert rebuilt is not paused, "non-resume must not be served the paused agent" + assert mock_create_agent.call_count == 2 + + +@pytest.mark.asyncio +async def test_resume_does_not_evict_paused_cached_agent( + mock_create_agent, mock_freshness_hash +): + """The eviction path is gated on ``is_resume=False``. A genuine resume + request must reuse the paused agent so Strands' ``_interrupt_state.resume`` + receives the original interrupt entry list — otherwise we'd rebuild the + agent and the resume would have nothing to resume against. + """ + paused = await service.get_agent( + session_id="s1", + user_id="u1", + is_resume=False, + ) + paused.agent._interrupt_state.activated = True + + resumed = await service.get_agent( + session_id="s1", + user_id="u1", + is_resume=True, + ) + + assert resumed is paused + assert mock_create_agent.call_count == 1 + + +@pytest.mark.asyncio +async def test_non_resume_keeps_non_paused_cached_agent( + mock_create_agent, mock_freshness_hash +): + """Sanity check: the eviction path only fires when ``activated`` is True. + A normal cache hit on a healthy agent stays a cache hit. + """ + first = await service.get_agent( + session_id="s1", + user_id="u1", + is_resume=False, + ) + second = await service.get_agent( + session_id="s1", + user_id="u1", + is_resume=False, + ) + + assert second is first + assert mock_create_agent.call_count == 1 From 538ab18c6032ba3371bf0b07b73e696b6114ca4c Mon Sep 17 00:00:00 2001 From: Phil Merrell Date: Fri, 1 May 2026 20:22:54 -0600 Subject: [PATCH 14/17] fix(costs): coerce cost_delta to a finite Decimal in summary writers (#208) MessageMetadata.cost is Optional[Union[float, Dict[str, float]]]. The streaming path produces a breakdown dict ({"total": ..., "inputCost": ...}), which flowed through `cost = message_metadata.cost or 0.0` unchanged and hit `Decimal(str(cost_delta))` in the DynamoDB summary writer, raising decimal.InvalidOperation. The per-message cost record was unaffected (its dict goes through _convert_floats_to_decimal recursively); only the rollup path crashed, leaving the summary silently stale. Two layers of defense: - Upstream: _coerce_cost_total normalizes dict/float/None/NaN/inf to a finite float total before the summary call. Also use `or 0` (not .get(..., 0)) on pricing_dict reads, since managed-model rows can store an explicit None for cache_read pricing. - Boundary: _safe_decimal in dynamodb_storage replaces every Decimal(str(cost_delta | cache_savings_delta)) site (5 total) so a bad value collapses to Decimal("0") instead of aborting the rollup. --- backend/src/apis/shared/sessions/metadata.py | 37 ++++++++- .../apis/shared/storage/dynamodb_storage.py | 40 ++++++++-- .../costs/test_dynamodb_storage_costs.py | 80 +++++++++++++++++++ .../tests/shared/test_sessions_metadata.py | 64 +++++++++++++++ 4 files changed, 209 insertions(+), 12 deletions(-) diff --git a/backend/src/apis/shared/sessions/metadata.py b/backend/src/apis/shared/sessions/metadata.py index a440d05d..3c7e0b39 100644 --- a/backend/src/apis/shared/sessions/metadata.py +++ b/backend/src/apis/shared/sessions/metadata.py @@ -9,6 +9,7 @@ import logging import json +import math import os import base64 from typing import Iterable, List, Optional, Tuple, Any, Dict @@ -55,6 +56,29 @@ def _convert_decimal_to_float(obj: Any) -> Any: return obj +def _coerce_cost_total(raw: Any) -> float: + """Normalize a ``MessageMetadata.cost`` value to a finite float total. + + ``MessageMetadata.cost`` is ``Optional[Union[float, Dict[str, float]]]`` — + the streaming path stores a breakdown dict (``{"total": ..., "inputCost": ...}``) + while the legacy path stores a bare float. Downstream summary writers + only want the scalar total; passing the dict through caused + ``Decimal(str(...))`` to throw ``ConversionSyntax`` at the DynamoDB + boundary. NaN/inf and non-numeric values collapse to 0.0. + """ + if isinstance(raw, dict): + raw = raw.get("total") + if raw is None: + return 0.0 + try: + value = float(raw) + except (TypeError, ValueError): + return 0.0 + if not math.isfinite(value): + return 0.0 + return value + + async def store_message_metadata( session_id: str, @@ -301,8 +325,10 @@ async def _update_cost_summary_async( import asyncio from datetime import datetime - # Extract cost and usage from metadata - cost = message_metadata.cost or 0.0 + # Extract cost and usage from metadata. cost may be a breakdown dict + # ({"total": ..., "inputCost": ...}) on the streaming path or a bare + # float on the legacy path; the summary writer needs the scalar total. + cost = _coerce_cost_total(message_metadata.cost) token_usage = message_metadata.token_usage usage_delta = {} @@ -342,8 +368,11 @@ async def _update_cost_summary_async( logger.debug(f"🔍 Pricing dict: {pricing_dict}") - input_price = pricing_dict.get("inputPricePerMtok", 0) - cache_read_price = pricing_dict.get("cacheReadPricePerMtok", 0) + # `or 0` (not `.get(..., 0)`) — managed-model rows can + # store an explicit None for cache_read pricing, which + # would otherwise propagate into arithmetic below. + input_price = pricing_dict.get("inputPricePerMtok") or 0 + cache_read_price = pricing_dict.get("cacheReadPricePerMtok") or 0 # Calculate savings: what we would have paid vs what we actually paid standard_cost = (cache_read_tokens / 1_000_000) * input_price diff --git a/backend/src/apis/shared/storage/dynamodb_storage.py b/backend/src/apis/shared/storage/dynamodb_storage.py index 050f92ab..3206c722 100644 --- a/backend/src/apis/shared/storage/dynamodb_storage.py +++ b/backend/src/apis/shared/storage/dynamodb_storage.py @@ -29,7 +29,31 @@ import os from typing import Optional, List, Dict, Any from datetime import datetime, timedelta, timezone -from decimal import Decimal +from decimal import Decimal, InvalidOperation + + +def _safe_decimal(value: Any) -> Decimal: + """Coerce ``value`` to a finite ``Decimal``, falling back to ``Decimal("0")``. + + The cost-summary writers receive ``cost_delta`` / ``cache_savings_delta`` + from upstream call sites that occasionally hand over a dict, ``None``, + or a non-finite float (NaN / inf). ``Decimal(str(...))`` raises + ``decimal.InvalidOperation`` for those inputs, and DynamoDB rejects + NaN / inf even when conversion succeeds. Treating bad input as zero + keeps the rollup write atomic and idempotent rather than crashing the + whole non-critical summary update. + """ + if value is None: + return Decimal("0") + if isinstance(value, Decimal): + return value if value.is_finite() else Decimal("0") + try: + result = Decimal(str(value)) + except (InvalidOperation, ValueError, TypeError): + return Decimal("0") + if not result.is_finite(): + return Decimal("0") + return result try: import boto3 @@ -332,13 +356,13 @@ async def update_user_cost_summary( """ expression_values = { - ":cost": Decimal(str(cost_delta)), + ":cost": _safe_decimal(cost_delta), ":one": 1, ":input": usage_delta.get("inputTokens", 0), ":output": usage_delta.get("outputTokens", 0), ":cacheRead": usage_delta.get("cacheReadInputTokens", 0), ":cacheWrite": usage_delta.get("cacheWriteInputTokens", 0), - ":savings": Decimal(str(cache_savings_delta)), + ":savings": _safe_decimal(cache_savings_delta), ":now": timestamp, ":periodStart": f"{period}-01T00:00:00Z", ":periodEnd": f"{period}-31T23:59:59Z", @@ -467,7 +491,7 @@ async def _update_model_breakdown( "#cacheWriteTokens": "cacheWriteTokens" }, ExpressionAttributeValues={ - ":cost": Decimal(str(cost_delta)), + ":cost": _safe_decimal(cost_delta), ":one": 1, ":input": usage_delta.get("inputTokens", 0), ":output": usage_delta.get("outputTokens", 0), @@ -842,7 +866,7 @@ async def update_daily_rollup( """ expression_values = { - ":cost": Decimal(str(cost_delta)), + ":cost": _safe_decimal(cost_delta), ":one": 1, ":input": usage_delta.get("inputTokens", 0), ":output": usage_delta.get("outputTokens", 0), @@ -916,13 +940,13 @@ async def update_monthly_rollup( """ expression_values = { - ":cost": Decimal(str(cost_delta)), + ":cost": _safe_decimal(cost_delta), ":one": 1, ":input": usage_delta.get("inputTokens", 0), ":output": usage_delta.get("outputTokens", 0), ":cacheRead": usage_delta.get("cacheReadInputTokens", 0), ":cacheWrite": usage_delta.get("cacheWriteInputTokens", 0), - ":savings": Decimal(str(cache_savings_delta)), + ":savings": _safe_decimal(cache_savings_delta), ":now": datetime.now(timezone.utc).isoformat(), ":type": "monthly" } @@ -995,7 +1019,7 @@ async def update_model_rollup( """ expression_values = { - ":cost": Decimal(str(cost_delta)), + ":cost": _safe_decimal(cost_delta), ":one": 1, ":input": usage_delta.get("inputTokens", 0), ":output": usage_delta.get("outputTokens", 0), diff --git a/backend/tests/costs/test_dynamodb_storage_costs.py b/backend/tests/costs/test_dynamodb_storage_costs.py index 615580d7..52b89ad2 100644 --- a/backend/tests/costs/test_dynamodb_storage_costs.py +++ b/backend/tests/costs/test_dynamodb_storage_costs.py @@ -207,6 +207,86 @@ async def test_multiple_models_accumulate_separately(self, storage, sample_usage assert bd["claude_sonnet"]["requests"] == 1 +# ── defensive cost_delta coercion ──────────────────────────────────────────── + + +class TestUpdateUserCostSummaryDefensiveCoercion: + """Regression tests for the Decimal(str(cost_delta)) ConversionSyntax crash. + + Upstream callers occasionally hand the summary writer a dict (the new + streaming-cost breakdown), None (uncomputed cost), or a non-finite float + (from None pricing fields). All of these used to raise InvalidOperation + and abort the rollup write. They must now degrade to a 0 contribution. + """ + + @pytest.mark.asyncio + async def test_none_cost_delta_does_not_raise(self, storage, sample_usage_delta): + await storage.update_user_cost_summary( + user_id="u1", period=PERIOD, + cost_delta=None, usage_delta=sample_usage_delta, + timestamp=TIMESTAMP, + ) + result = await storage.get_user_cost_summary("u1", PERIOD) + assert result["totalCost"] == pytest.approx(0.0) + assert result["totalRequests"] == 1 + + @pytest.mark.asyncio + async def test_dict_cost_delta_does_not_raise(self, storage, sample_usage_delta): + await storage.update_user_cost_summary( + user_id="u1", period=PERIOD, + cost_delta={"total": 0.0, "inputCost": 0.0, "outputCost": 0.0}, + usage_delta=sample_usage_delta, + timestamp=TIMESTAMP, + ) + result = await storage.get_user_cost_summary("u1", PERIOD) + assert result["totalCost"] == pytest.approx(0.0) + assert result["totalRequests"] == 1 + + @pytest.mark.asyncio + async def test_nan_cost_delta_does_not_raise(self, storage, sample_usage_delta): + await storage.update_user_cost_summary( + user_id="u1", period=PERIOD, + cost_delta=float("nan"), usage_delta=sample_usage_delta, + timestamp=TIMESTAMP, + ) + result = await storage.get_user_cost_summary("u1", PERIOD) + assert result["totalCost"] == pytest.approx(0.0) + + @pytest.mark.asyncio + async def test_inf_cost_delta_does_not_raise(self, storage, sample_usage_delta): + await storage.update_user_cost_summary( + user_id="u1", period=PERIOD, + cost_delta=float("inf"), usage_delta=sample_usage_delta, + timestamp=TIMESTAMP, + ) + result = await storage.get_user_cost_summary("u1", PERIOD) + assert result["totalCost"] == pytest.approx(0.0) + + @pytest.mark.asyncio + async def test_none_cache_savings_does_not_raise(self, storage, sample_usage_delta): + await storage.update_user_cost_summary( + user_id="u1", period=PERIOD, + cost_delta=1.0, usage_delta=sample_usage_delta, + timestamp=TIMESTAMP, cache_savings_delta=None, + ) + result = await storage.get_user_cost_summary("u1", PERIOD) + assert result["totalCost"] == pytest.approx(1.0) + assert result["cacheSavings"] == pytest.approx(0.0) + + @pytest.mark.asyncio + async def test_dict_cost_delta_with_model_id_does_not_raise(self, storage, sample_usage_delta): + """The model-breakdown sub-write also coerces cost_delta safely.""" + await storage.update_user_cost_summary( + user_id="u1", period=PERIOD, + cost_delta={"total": 0.0}, usage_delta=sample_usage_delta, + timestamp=TIMESTAMP, + model_id="gpt-4o", model_name="GPT-4o", provider="openai", + ) + result = await storage.get_user_cost_summary("u1", PERIOD) + assert "gpt_4o" in result["modelBreakdown"] + assert result["modelBreakdown"]["gpt_4o"]["cost"] == pytest.approx(0.0) + + # ── _update_model_breakdown ────────────────────────────────────────────────── class TestUpdateModelBreakdown: diff --git a/backend/tests/shared/test_sessions_metadata.py b/backend/tests/shared/test_sessions_metadata.py index 87a6309c..977d1a28 100644 --- a/backend/tests/shared/test_sessions_metadata.py +++ b/backend/tests/shared/test_sessions_metadata.py @@ -1,5 +1,7 @@ """Task 10: Sessions metadata tests (moto DynamoDB).""" +import math + import pytest from apis.shared.sessions.models import SessionMetadata, MessageMetadata, TokenUsage, ModelInfo @@ -672,3 +674,65 @@ async def test_paused_turn_independent_of_pending_interrupts(self, sessions_meta interrupts = await get_pending_interrupts("s1", "u1") assert len(interrupts) == 1 assert interrupts[0].interrupt_id == "i1" + + +class TestCoerceCostTotal: + """Normalize ``MessageMetadata.cost`` (float | dict | None) to a finite float total. + + Regression tests for the cost-summary writer crash: the streaming path + builds a breakdown dict (``{"total": ..., "inputCost": ...}``), which + used to flow through ``Decimal(str(...))`` and raise + ``decimal.InvalidOperation``. + """ + + def test_dict_with_total_returns_total(self): + from apis.shared.sessions.metadata import _coerce_cost_total + assert _coerce_cost_total({"total": 0.0105, "inputCost": 0.003}) == pytest.approx(0.0105) + + def test_dict_without_total_returns_zero(self): + from apis.shared.sessions.metadata import _coerce_cost_total + assert _coerce_cost_total({"inputCost": 0.003, "outputCost": 0.0075}) == 0.0 + + def test_dict_with_none_total_returns_zero(self): + from apis.shared.sessions.metadata import _coerce_cost_total + assert _coerce_cost_total({"total": None}) == 0.0 + + def test_float_passthrough(self): + from apis.shared.sessions.metadata import _coerce_cost_total + assert _coerce_cost_total(0.42) == pytest.approx(0.42) + + def test_int_passthrough(self): + from apis.shared.sessions.metadata import _coerce_cost_total + assert _coerce_cost_total(7) == 7.0 + + def test_zero(self): + from apis.shared.sessions.metadata import _coerce_cost_total + assert _coerce_cost_total(0.0) == 0.0 + + def test_none_returns_zero(self): + from apis.shared.sessions.metadata import _coerce_cost_total + assert _coerce_cost_total(None) == 0.0 + + def test_nan_returns_zero(self): + from apis.shared.sessions.metadata import _coerce_cost_total + assert _coerce_cost_total(float("nan")) == 0.0 + + def test_inf_returns_zero(self): + from apis.shared.sessions.metadata import _coerce_cost_total + assert _coerce_cost_total(float("inf")) == 0.0 + assert _coerce_cost_total(float("-inf")) == 0.0 + + def test_non_numeric_returns_zero(self): + from apis.shared.sessions.metadata import _coerce_cost_total + assert _coerce_cost_total("not-a-number") == 0.0 + assert _coerce_cost_total(["list"]) == 0.0 + + def test_string_numeric_coerces(self): + from apis.shared.sessions.metadata import _coerce_cost_total + assert _coerce_cost_total("0.5") == pytest.approx(0.5) + + def test_returns_finite_float(self): + from apis.shared.sessions.metadata import _coerce_cost_total + result = _coerce_cost_total({"total": 1.5}) + assert isinstance(result, float) + assert math.isfinite(result) From 2972f57d0d6468ce109ed6a616d02faa5d0f6834 Mon Sep 17 00:00:00 2001 From: Phil Merrell Date: Fri, 1 May 2026 21:38:14 -0600 Subject: [PATCH 15/17] refactor(inference-params): drop lastTemperature, gate unknown overrides, fix host listener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups for #203: - Remove `lastTemperature` from SessionPreferences/UpdateSessionMetadataRequest and the writer in stream_coordinator. The read site was reaching for a ModelConfig field that no longer exists, so every turn was overwriting the stored value with None. With per-model inference-param overrides now persisted in sessionStorage, the field is redundant rather than just broken — drop it. Existing DDB rows are silently ignored on read via Pydantic `extra="allow"`. - Gate the request-side passthrough in `_merge_inference_params` against a new `KNOWN_CANONICAL_PARAMS` allow-list (union of all provider mapping keys). Without this, a user could submit a future canonical key the admin hasn't yet bounded — or one a future provider mapping starts forwarding — and bypass per-model bounds. - Replace `@HostListener` in model-settings with the `host` decorator map per the frontend project conventions. Co-Authored-By: Claude Opus 4.7 --- .../src/agents/main_agent/core/model_config.py | 9 +++++++++ .../main_agent/streaming/stream_coordinator.py | 3 --- backend/src/apis/app_api/sessions/routes.py | 5 ----- backend/src/apis/inference_api/chat/routes.py | 16 ++++++++++++++-- backend/src/apis/shared/sessions/metadata.py | 3 --- backend/src/apis/shared/sessions/models.py | 2 -- backend/tests/shared/test_sessions_metadata.py | 7 +++---- .../components/model-settings/model-settings.ts | 6 ++++-- .../services/models/session-metadata.model.ts | 2 -- .../services/session/session.service.spec.ts | 2 +- .../session/services/session/session.service.ts | 1 - 11 files changed, 31 insertions(+), 25 deletions(-) diff --git a/backend/src/agents/main_agent/core/model_config.py b/backend/src/agents/main_agent/core/model_config.py index c4cb515c..3b6dd31d 100644 --- a/backend/src/agents/main_agent/core/model_config.py +++ b/backend/src/agents/main_agent/core/model_config.py @@ -52,6 +52,15 @@ class ModelProvider(str, Enum): # them. Suppression happens in `_apply_canonical_params` before dispatch. _THINKING_INCOMPATIBLE = {"temperature", "top_p", "top_k"} +# Union of every canonical key we know how to translate. Used by the request +# merge step to gate user-supplied keys against an allow-list — admins can +# constrain known params with `supportedParams`, but users shouldn't be able +# to bypass that by inventing keys the admin hasn't seen yet (or that the +# provider mapping starts forwarding in a future release). +KNOWN_CANONICAL_PARAMS: frozenset[str] = frozenset( + set(_BEDROCK_PARAM_MAP) | set(_OPENAI_PARAM_MAP) | set(_GEMINI_PARAM_MAP) +) + def _set_nested(target: Dict[str, Any], dotted_path: str, value: Any) -> None: """Assign ``value`` into ``target`` at a dot-separated key path.""" diff --git a/backend/src/agents/main_agent/streaming/stream_coordinator.py b/backend/src/agents/main_agent/streaming/stream_coordinator.py index 79d6aebb..24c372ea 100644 --- a/backend/src/agents/main_agent/streaming/stream_coordinator.py +++ b/backend/src/agents/main_agent/streaming/stream_coordinator.py @@ -1351,12 +1351,10 @@ async def _update_session_metadata(self, session_id: str, user_id: str, message_ from apis.shared.sessions.metadata import update_session_activity last_model = None - last_temperature = None enabled_tools = None system_prompt_hash = None if agent and hasattr(agent, "model_config"): last_model = agent.model_config.model_id - last_temperature = getattr(agent.model_config, "temperature", None) enabled_tools = getattr(agent, "enabled_tools", None) if hasattr(agent, "system_prompt") and agent.system_prompt: system_prompt_hash = hashlib.md5(agent.system_prompt.encode()).hexdigest()[:16] @@ -1367,7 +1365,6 @@ async def _update_session_metadata(self, session_id: str, user_id: str, message_ session_id=session_id, user_id=user_id, last_model=last_model, - last_temperature=last_temperature, enabled_tools=enabled_tools, system_prompt_hash=system_prompt_hash, ) diff --git a/backend/src/apis/app_api/sessions/routes.py b/backend/src/apis/app_api/sessions/routes.py index 0cac994f..8efbfbe6 100644 --- a/backend/src/apis/app_api/sessions/routes.py +++ b/backend/src/apis/app_api/sessions/routes.py @@ -193,7 +193,6 @@ async def update_session_metadata_endpoint( preferences = None if any([ request.last_model, - request.last_temperature is not None, request.enabled_tools, request.selected_prompt_id, request.custom_prompt_text, @@ -201,7 +200,6 @@ async def update_session_metadata_endpoint( ]): preferences = SessionPreferences( last_model=request.last_model, - last_temperature=request.last_temperature, enabled_tools=request.enabled_tools, selected_prompt_id=request.selected_prompt_id, custom_prompt_text=request.custom_prompt_text, @@ -232,7 +230,6 @@ async def update_session_metadata_endpoint( preferences = existing_metadata.preferences if any([ request.last_model, - request.last_temperature is not None, request.enabled_tools, request.selected_prompt_id, request.custom_prompt_text, @@ -243,8 +240,6 @@ async def update_session_metadata_endpoint( new_prefs = {} if request.last_model: new_prefs['last_model'] = request.last_model - if request.last_temperature is not None: - new_prefs['last_temperature'] = request.last_temperature if request.enabled_tools: new_prefs['enabled_tools'] = request.enabled_tools if request.selected_prompt_id: diff --git a/backend/src/apis/inference_api/chat/routes.py b/backend/src/apis/inference_api/chat/routes.py index 6bea39ce..a13b4fa3 100644 --- a/backend/src/apis/inference_api/chat/routes.py +++ b/backend/src/apis/inference_api/chat/routes.py @@ -16,6 +16,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from fastapi.responses import StreamingResponse +from agents.main_agent.core.model_config import KNOWN_CANONICAL_PARAMS from agents.main_agent.session.session_factory import SessionFactory from apis.shared.auth.dependencies import get_current_user_trusted from apis.shared.auth.models import User @@ -144,11 +145,22 @@ def _merge_inference_params( elif spec.default is not None: merged[name] = spec.default - # Pass through request keys the admin spec doesn't mention. The provider - # translation table will silently drop ones the SDK doesn't know. + # Pass through request keys the admin spec doesn't mention, but only when + # they're in the canonical allow-list. Without this gate, a user could + # submit a future canonical key (or one a future provider mapping starts + # forwarding) and bypass the admin's per-model bounds entirely. Unknown + # keys are dropped here; the provider translation table is the second + # line of defense for ones it doesn't understand. for name, value in request_params.items(): if name in seen_keys or value is None: continue + if name not in KNOWN_CANONICAL_PARAMS: + logger.info( + "Dropping unrecognized inference param '%s' for model %s", + _sanitize_log(name), + _sanitize_log(getattr(managed_model, "model_id", "?")), + ) + continue merged[name] = value # Final cross-param safety check. Anthropic rejects requests where diff --git a/backend/src/apis/shared/sessions/metadata.py b/backend/src/apis/shared/sessions/metadata.py index 3c7e0b39..7c0ad918 100644 --- a/backend/src/apis/shared/sessions/metadata.py +++ b/backend/src/apis/shared/sessions/metadata.py @@ -842,7 +842,6 @@ async def update_session_activity( user_id: str, *, last_model: Optional[str] = None, - last_temperature: Optional[float] = None, enabled_tools: Optional[List[str]] = None, system_prompt_hash: Optional[str] = None, ) -> bool: @@ -911,8 +910,6 @@ async def update_session_activity( prefs_dict = existing_prefs.model_dump(by_alias=False, exclude_none=True) if last_model is not None: prefs_dict["last_model"] = last_model - if last_temperature is not None: - prefs_dict["last_temperature"] = last_temperature if enabled_tools is not None: prefs_dict["enabled_tools"] = enabled_tools if system_prompt_hash is not None: diff --git a/backend/src/apis/shared/sessions/models.py b/backend/src/apis/shared/sessions/models.py index ff7b909f..0cda459e 100644 --- a/backend/src/apis/shared/sessions/models.py +++ b/backend/src/apis/shared/sessions/models.py @@ -122,7 +122,6 @@ class SessionPreferences(BaseModel): model_config = ConfigDict(populate_by_name=True, extra="allow") last_model: Optional[str] = Field(default=None, alias="lastModel", description="Last model used in this session") - last_temperature: Optional[float] = Field(default=None, alias="lastTemperature", description="Last temperature setting used") enabled_tools: Optional[List[str]] = Field(default=None, alias="enabledTools", description="List of enabled tool names") selected_prompt_id: Optional[str] = Field(default=None, alias="selectedPromptId", description="ID of selected prompt template") custom_prompt_text: Optional[str] = Field(default=None, alias="customPromptText", description="Custom prompt text if used") @@ -197,7 +196,6 @@ class UpdateSessionMetadataRequest(BaseModel): starred: Optional[bool] = Field(None, description="Whether session is starred") tags: Optional[List[str]] = Field(None, description="Custom tags") last_model: Optional[str] = Field(None, alias="lastModel", description="Last model used") - last_temperature: Optional[float] = Field(None, alias="lastTemperature", description="Last temperature setting") enabled_tools: Optional[List[str]] = Field(None, alias="enabledTools", description="Enabled tools list") selected_prompt_id: Optional[str] = Field(None, alias="selectedPromptId", description="Selected prompt ID") custom_prompt_text: Optional[str] = Field(None, alias="customPromptText", description="Custom prompt text") diff --git a/backend/tests/shared/test_sessions_metadata.py b/backend/tests/shared/test_sessions_metadata.py index 977d1a28..ac6027c2 100644 --- a/backend/tests/shared/test_sessions_metadata.py +++ b/backend/tests/shared/test_sessions_metadata.py @@ -249,7 +249,7 @@ async def test_increments_message_count(self, sessions_metadata_table): assert before.message_count == 0 applied = await update_session_activity( - session_id="s1", user_id="u1", last_model="claude-3", last_temperature=0.7, + session_id="s1", user_id="u1", last_model="claude-3", ) assert applied is True after = await get_session_metadata("s1", "u1") @@ -271,7 +271,7 @@ async def test_preserves_title_set_by_title_gen(self, sessions_metadata_table): await ensure_session_metadata_exists("s1", "u1") await update_session_title("s1", "u1", "My Generated Title") await update_session_activity( - session_id="s1", user_id="u1", last_model="claude-3", last_temperature=0.5, + session_id="s1", user_id="u1", last_model="claude-3", ) result = await get_session_metadata("s1", "u1") assert result.title == "My Generated Title" @@ -322,12 +322,11 @@ async def test_preserves_assistant_id_in_preferences(self, sessions_metadata_tab await store_session_metadata("s1", "u1", seeded) await update_session_activity( - session_id="s1", user_id="u1", last_model="claude-3", last_temperature=0.5, + session_id="s1", user_id="u1", last_model="claude-3", ) result = await get_session_metadata("s1", "u1") assert result.preferences.assistant_id == "asst-abc" assert result.preferences.last_model == "claude-3" - assert result.preferences.last_temperature == 0.5 @pytest.mark.asyncio async def test_rotates_sk_to_new_timestamp(self, sessions_metadata_table): diff --git a/frontend/ai.client/src/app/components/model-settings/model-settings.ts b/frontend/ai.client/src/app/components/model-settings/model-settings.ts index de43e855..bb1faba2 100644 --- a/frontend/ai.client/src/app/components/model-settings/model-settings.ts +++ b/frontend/ai.client/src/app/components/model-settings/model-settings.ts @@ -1,4 +1,4 @@ -import { Component, ChangeDetectionStrategy, inject, input, output, signal, computed, effect, ElementRef, HostListener } from '@angular/core'; +import { Component, ChangeDetectionStrategy, inject, input, output, signal, computed, effect, ElementRef } from '@angular/core'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { heroXMark, heroCheck, heroChevronDown, heroChevronRight } from '@ng-icons/heroicons/outline'; import { ModelService } from '../../session/services/model/model.service'; @@ -40,6 +40,9 @@ interface AdvancedParamRow { changeDetection: ChangeDetectionStrategy.OnPush, imports: [NgIcon], providers: [provideIcons({ heroXMark, heroCheck, heroChevronDown, heroChevronRight })], + host: { + '(document:click)': 'onDocumentClick($event)', + }, templateUrl: './model-settings.html', styleUrl: './model-settings.css', }) @@ -187,7 +190,6 @@ export class ModelSettings { }); } - @HostListener('document:click', ['$event']) onDocumentClick(event: MouseEvent): void { // Close dropdown if clicking outside if (this.isModelDropdownOpen() && !this.elementRef.nativeElement.contains(event.target)) { diff --git a/frontend/ai.client/src/app/session/services/models/session-metadata.model.ts b/frontend/ai.client/src/app/session/services/models/session-metadata.model.ts index 809e1df5..4c0f6bd1 100644 --- a/frontend/ai.client/src/app/session/services/models/session-metadata.model.ts +++ b/frontend/ai.client/src/app/session/services/models/session-metadata.model.ts @@ -13,7 +13,6 @@ export interface VisualDisplayState { export interface SessionPreferences { lastModel?: string; - lastTemperature?: number; enabledTools?: string[]; selectedPromptId?: string; customPromptText?: string; @@ -42,7 +41,6 @@ export interface UpdateSessionMetadataRequest { starred?: boolean; tags?: string[]; lastModel?: string; - lastTemperature?: number; enabledTools?: string[]; selectedPromptId?: string; customPromptText?: string; diff --git a/frontend/ai.client/src/app/session/services/session/session.service.spec.ts b/frontend/ai.client/src/app/session/services/session/session.service.spec.ts index 8b90a112..3b00f69f 100644 --- a/frontend/ai.client/src/app/session/services/session/session.service.spec.ts +++ b/frontend/ai.client/src/app/session/services/session/session.service.spec.ts @@ -207,7 +207,7 @@ describe('SessionService', () => { describe('updateSessionPreferences', () => { it('should delegate to updateSessionMetadata with preferences', async () => { const spy = vi.spyOn(service, 'updateSessionMetadata').mockResolvedValue(mockSession); - const prefs = { lastModel: 'claude', lastTemperature: 0.7 }; + const prefs = { lastModel: 'claude' }; await service.updateSessionPreferences('test-id', prefs); expect(spy).toHaveBeenCalledWith('test-id', prefs); }); diff --git a/frontend/ai.client/src/app/session/services/session/session.service.ts b/frontend/ai.client/src/app/session/services/session/session.service.ts index 0360a9de..481b6553 100644 --- a/frontend/ai.client/src/app/session/services/session/session.service.ts +++ b/frontend/ai.client/src/app/session/services/session/session.service.ts @@ -630,7 +630,6 @@ export class SessionService { sessionId: string, preferences: { lastModel?: string; - lastTemperature?: number; enabledTools?: string[]; selectedPromptId?: string; customPromptText?: string; From 369666a030012f1f9187b14d5bf4c9262e4e176e Mon Sep 17 00:00:00 2001 From: Phil Merrell Date: Fri, 1 May 2026 21:56:16 -0600 Subject: [PATCH 16/17] refactor(models): remove unused isReasoningModel field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flag had no behavioral consumer — every reference was a schema definition, CRUD passthrough, or form binding. Reasoning capability is now expressed per-param via `supportedParams.thinking` (Anthropic) and `supportedParams.reasoning_effort` (OpenAI o-series), so a UI badge that wants to show "reasoning model" can derive it from the spec. Pydantic v2 defaults to `extra="ignore"` on ManagedModel, so existing DDB rows with `isReasoningModel: false` load cleanly post-removal. Co-Authored-By: Claude Opus 4.7 --- backend/scripts/seed_bootstrap_data.py | 4 ---- .../admin/services/tests/test_model_access.py | 1 - backend/src/apis/shared/models/managed_models.py | 2 -- backend/src/apis/shared/models/models.py | 3 --- backend/tests/routes/test_admin.py | 1 - backend/tests/routes/test_models.py | 1 - .../app/admin/gemini-models/gemini-models.page.ts | 1 - .../app/admin/manage-models/model-form.page.html | 13 ------------- .../src/app/admin/manage-models/model-form.page.ts | 5 ----- .../manage-models/models/managed-model.model.ts | 4 ---- .../model-settings/model-settings.spec.ts | 1 - .../session/services/model/model.service.spec.ts | 4 ++-- .../src/app/session/services/model/model.service.ts | 1 - 13 files changed, 2 insertions(+), 39 deletions(-) diff --git a/backend/scripts/seed_bootstrap_data.py b/backend/scripts/seed_bootstrap_data.py index 607615b4..5f745311 100644 --- a/backend/scripts/seed_bootstrap_data.py +++ b/backend/scripts/seed_bootstrap_data.py @@ -222,7 +222,6 @@ def seed_default_quota_assignment( "outputPricePerMillionTokens": Decimal("5.00"), "cacheWritePricePerMillionTokens": Decimal("1.25"), "cacheReadPricePerMillionTokens": Decimal("0.10"), - "isReasoningModel": False, "supportsCaching": True, "isDefault": True, "supportedParams": CLAUDE_CHAT_SUPPORTED_PARAMS, @@ -240,7 +239,6 @@ def seed_default_quota_assignment( "outputPricePerMillionTokens": Decimal("15.00"), "cacheWritePricePerMillionTokens": Decimal("3.75"), "cacheReadPricePerMillionTokens": Decimal("0.30"), - "isReasoningModel": False, "supportsCaching": True, "isDefault": False, "supportedParams": CLAUDE_CHAT_SUPPORTED_PARAMS, @@ -258,7 +256,6 @@ def seed_default_quota_assignment( "outputPricePerMillionTokens": Decimal("12.00"), "cacheWritePricePerMillionTokens": Decimal("0"), "cacheReadPricePerMillionTokens": Decimal("0"), - "isReasoningModel": False, "supportsCaching": False, "isDefault": False, # Voice/bidi model: param shape differs from chat models. Leave @@ -326,7 +323,6 @@ def seed_default_models( "outputPricePerMillionTokens": model_def["outputPricePerMillionTokens"], "cacheWritePricePerMillionTokens": model_def["cacheWritePricePerMillionTokens"], "cacheReadPricePerMillionTokens": model_def["cacheReadPricePerMillionTokens"], - "isReasoningModel": model_def["isReasoningModel"], "supportsCaching": model_def["supportsCaching"], "isDefault": model_def["isDefault"], "createdAt": now, diff --git a/backend/src/apis/app_api/admin/services/tests/test_model_access.py b/backend/src/apis/app_api/admin/services/tests/test_model_access.py index 11affeb4..6dd803d0 100644 --- a/backend/src/apis/app_api/admin/services/tests/test_model_access.py +++ b/backend/src/apis/app_api/admin/services/tests/test_model_access.py @@ -52,7 +52,6 @@ def create_test_model( enabled=enabled, input_price_per_million_tokens=3.0, output_price_per_million_tokens=15.0, - is_reasoning_model=False, supports_caching=True, created_at=now, updated_at=now, diff --git a/backend/src/apis/shared/models/managed_models.py b/backend/src/apis/shared/models/managed_models.py index 7c2e360b..d46ab624 100644 --- a/backend/src/apis/shared/models/managed_models.py +++ b/backend/src/apis/shared/models/managed_models.py @@ -215,7 +215,6 @@ async def _create_managed_model_cloud(model_data: ManagedModelCreate, table_name output_price_per_million_tokens=model_data.output_price_per_million_tokens, cache_write_price_per_million_tokens=model_data.cache_write_price_per_million_tokens, cache_read_price_per_million_tokens=model_data.cache_read_price_per_million_tokens, - is_reasoning_model=model_data.is_reasoning_model, knowledge_cutoff_date=model_data.knowledge_cutoff_date, supports_caching=_resolve_supports_caching(model_data.supports_caching, model_data.provider), is_default=model_data.is_default, @@ -244,7 +243,6 @@ async def _create_managed_model_cloud(model_data: ManagedModelCreate, table_name 'enabled': model_data.enabled, 'inputPricePerMillionTokens': model_data.input_price_per_million_tokens, 'outputPricePerMillionTokens': model_data.output_price_per_million_tokens, - 'isReasoningModel': model_data.is_reasoning_model, 'supportsCaching': _resolve_supports_caching(model_data.supports_caching, model_data.provider), 'isDefault': model_data.is_default, 'createdAt': now.isoformat(), diff --git a/backend/src/apis/shared/models/models.py b/backend/src/apis/shared/models/models.py index 87df87f6..38d5f367 100644 --- a/backend/src/apis/shared/models/models.py +++ b/backend/src/apis/shared/models/models.py @@ -127,7 +127,6 @@ class ManagedModelCreate(BaseModel): ge=0, description="Price per million tokens read from cache (Bedrock only, ~90% discount)" ) - is_reasoning_model: bool = Field(False, alias="isReasoningModel") knowledge_cutoff_date: Optional[str] = Field(None, alias="knowledgeCutoffDate") supports_caching: Optional[bool] = Field( None, @@ -185,7 +184,6 @@ class ManagedModelUpdate(BaseModel): ge=0, description="Price per million tokens read from cache (Bedrock only, ~90% discount)" ) - is_reasoning_model: Optional[bool] = Field(None, alias="isReasoningModel") knowledge_cutoff_date: Optional[str] = Field(None, alias="knowledgeCutoffDate") supports_caching: Optional[bool] = Field( None, @@ -241,7 +239,6 @@ class ManagedModel(BaseModel): alias="cacheReadPricePerMillionTokens", description="Price per million tokens read from cache (Bedrock only, ~90% discount)" ) - is_reasoning_model: bool = Field(..., alias="isReasoningModel") knowledge_cutoff_date: Optional[str] = Field(None, alias="knowledgeCutoffDate") supports_caching: bool = Field( True, diff --git a/backend/tests/routes/test_admin.py b/backend/tests/routes/test_admin.py index ce07359e..d187f426 100644 --- a/backend/tests/routes/test_admin.py +++ b/backend/tests/routes/test_admin.py @@ -44,7 +44,6 @@ enabled=True, inputPricePerMillionTokens=0.25, outputPricePerMillionTokens=1.25, - isReasoningModel=False, supportsCaching=True, isDefault=False, createdAt=datetime(2024, 1, 1), diff --git a/backend/tests/routes/test_models.py b/backend/tests/routes/test_models.py index 3d04af92..b7b886a6 100644 --- a/backend/tests/routes/test_models.py +++ b/backend/tests/routes/test_models.py @@ -44,7 +44,6 @@ enabled=True, inputPricePerMillionTokens=0.25, outputPricePerMillionTokens=1.25, - isReasoningModel=False, createdAt=datetime(2024, 1, 1), updatedAt=datetime(2024, 1, 1), ) diff --git a/frontend/ai.client/src/app/admin/gemini-models/gemini-models.page.ts b/frontend/ai.client/src/app/admin/gemini-models/gemini-models.page.ts index eceb0f79..2a9eeb50 100644 --- a/frontend/ai.client/src/app/admin/gemini-models/gemini-models.page.ts +++ b/frontend/ai.client/src/app/admin/gemini-models/gemini-models.page.ts @@ -130,7 +130,6 @@ export class GeminiModelsPage { providerName: 'Google', inputModalities: inputModalities.join(','), outputModalities: outputModalities.join(','), - isReasoningModel: model.thinking || false, maxInputTokens: 1000000, // Default value for Gemini models, user can adjust maxOutputTokens: 8192, // Default value, user can adjust } diff --git a/frontend/ai.client/src/app/admin/manage-models/model-form.page.html b/frontend/ai.client/src/app/admin/manage-models/model-form.page.html index 55e8bfa0..88748bcb 100644 --- a/frontend/ai.client/src/app/admin/manage-models/model-form.page.html +++ b/frontend/ai.client/src/app/admin/manage-models/model-form.page.html @@ -98,19 +98,6 @@

Basic Inf }

- -
- - -
-