fix(reasoning): apply explicit per-call levels without runner config caching

This commit is contained in:
RockChinQ
2026-09-19 00:05:49 +08:00
parent da4b0d6ca2
commit 79cac0c6a3
24 changed files with 262 additions and 427 deletions
@@ -1,85 +0,0 @@
"""Host-owned, run-scoped reasoning policy for schema-declared model selectors.
This policy is not an SDK resource or a provider kwarg. Only the Host binding
assembler supplies it; model actions consult it after resource authorization.
"""
from __future__ import annotations
import copy
import typing
from ...provider.modelmgr.reasoning import normalize_reasoning_config, validate_reasoning_config
from .config_schema import NONE_SENTINELS, iter_schema_items
from .descriptor import RunnerDescriptor
if typing.TYPE_CHECKING:
from ...provider.modelmgr.requester import RuntimeLLMModel
ModelReasoningOverrides = dict[str, dict[str, str]]
def extract_model_reasoning_overrides(
descriptor: RunnerDescriptor,
runner_config: dict[str, typing.Any],
resources: typing.Mapping[str, typing.Any],
) -> ModelReasoningOverrides:
"""Normalize explicit UUID-to-level mappings, intersecting selection and grants.
An absent entry preserves persisted model defaults. Explicit provider_default
overrides them using the native requester's semantics. Descriptor defaults and
undeclared config fields cannot silently introduce reasoning overrides.
"""
authorized = {model.get('model_id') for model in resources.get('models', [])}
overrides: ModelReasoningOverrides = {}
for item in iter_schema_items(descriptor, {'model-fallback-selector'}):
field_name = item.get('name')
if not isinstance(field_name, str):
continue
selection = runner_config.get(field_name)
if not isinstance(selection, dict) or 'reasoning' not in selection:
continue
configured = selection['reasoning']
if not isinstance(configured, dict):
raise ValueError('Invalid runner model reasoning configuration')
candidates = [selection.get('primary')]
fallbacks = selection.get('fallbacks')
if isinstance(fallbacks, list):
candidates.extend(fallbacks)
selected = {value for value in candidates if isinstance(value, str) and value not in NONE_SENTINELS}
for model_id, level in configured.items():
# Validate with Core, but do not echo arbitrary config/secret values.
try:
if not isinstance(model_id, str) or not isinstance(level, str):
raise ValueError
config = normalize_reasoning_config({'level': level})
except (TypeError, ValueError):
raise ValueError('Invalid runner model reasoning configuration') from None
if model_id not in selected or model_id not in authorized:
continue
if model_id in overrides and overrides[model_id] != config:
raise ValueError('Conflicting runner model reasoning overrides')
overrides[model_id] = config
return overrides
def model_with_reasoning_override(
model: RuntimeLLMModel,
model_id: str,
session: typing.Mapping[str, typing.Any] | None,
) -> RuntimeLLMModel:
"""Clone only the runtime wrapper, after the caller has authorized the model.
Do not mutate shared model entities or providers. Requesters retain ownership
of ability/capability validation and provider-specific argument translation.
"""
if session is None:
return model
overrides = session.get('authorization', {}).get('model_reasoning_overrides', {})
if model_id not in overrides:
return model
config = validate_reasoning_config(overrides[model_id], model.model_entity.abilities, model.model_entity.extra_args)
scoped_model = copy.copy(model)
scoped_model.reasoning_config_override = copy.deepcopy(config)
return scoped_model
@@ -28,7 +28,6 @@ from .execution_context import (
)
from .host_models import AgentBinding, AgentEventEnvelope
from .invoker import RunnerInvoker
from .model_reasoning import extract_model_reasoning_overrides
from .interaction_manager import InteractionManager
from .query_bridge import QueryRunBridge
from .registry import RunnerRegistry
@@ -143,7 +142,6 @@ class AgentRunOrchestrator:
binding=binding,
descriptor=descriptor,
)
model_reasoning_overrides = extract_model_reasoning_overrides(descriptor, binding.runner_config, resources)
context = await self.context_builder.build_context_from_event(
event=execution_event,
@@ -209,7 +207,6 @@ class AgentRunOrchestrator:
'state_scopes': list(binding.state_policy.state_scopes),
},
'state_context': state_context,
'model_reasoning_overrides': model_reasoning_overrides,
}
seen_sequences: set[int] = set()
@@ -246,7 +243,6 @@ class AgentRunOrchestrator:
execution_query=execution_query,
platform_context=freeze_platform_context(event),
reply_streams=reply_streams,
model_reasoning_overrides=model_reasoning_overrides,
)
event_log_id = await self.journal.write_event_log(
@@ -13,7 +13,6 @@ import threading
from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
from .context_builder import AgentResources
from .model_reasoning import ModelReasoningOverrides
from ...provider.tools.toolmgr import ToolSourceRef
@@ -53,7 +52,6 @@ class RunAuthorizationSnapshot(typing.TypedDict):
platform_context: dict[str, typing.Any]
authorized_ids: dict[str, set[str]]
authorized_operations: dict[str, dict[str, set[str]]]
model_reasoning_overrides: ModelReasoningOverrides
SteeringQueueItem = dict[str, typing.Any]
@@ -121,7 +119,6 @@ class AgentRunSessionRegistry:
execution_query: pipeline_query.Query | None = None,
platform_context: dict[str, typing.Any] | None = None,
reply_streams: typing.Any = None,
model_reasoning_overrides: ModelReasoningOverrides | None = None,
) -> None:
"""Register a new agent run session.
@@ -167,7 +164,6 @@ class AgentRunSessionRegistry:
'platform_context': copy.deepcopy(platform_context or {}),
'authorized_ids': self._build_authorized_ids(resources_snapshot),
'authorized_operations': self._build_authorized_operations(resources_snapshot),
'model_reasoning_overrides': copy.deepcopy(model_reasoning_overrides or {}),
}
session: AgentRunSession = {
@@ -28,7 +28,6 @@ from ....agent.runner.config_resolver import RunnerConfigResolver
from ....agent.runner import config_schema
from ....agent.runner.resource_builder import AgentResourceBuilder
from ....agent.runner.resource_policy import ResourcePolicyProjector
from ....agent.runner.model_reasoning import extract_model_reasoning_overrides
from ....entity.persistence.model import LLMModel, RerankModel, EmbeddingModel
from ....entity.persistence.rag import KnowledgeBase
from ....entity.persistence.mcp import MCPServer
@@ -706,18 +705,15 @@ class PipelineMigrationService:
raise MigrationError('runner_resource_unavailable')
permissions = getattr(descriptor, 'permissions', None)
model_resources = []
for model_type, model_uuid in config_schema.iter_config_model_refs(descriptor, runner_config):
allowed = set(getattr(permissions, 'models', []))
if not (allowed & ({'rerank'} if model_type == 'rerank' else {'invoke', 'stream'})):
raise MigrationError('runner_resource_unavailable')
await require_row(RerankModel if model_type == 'rerank' else LLMModel, model_uuid)
model_resources.append({'model_id': model_uuid})
for field in config_schema.iter_schema_items(descriptor, {'embedding-model-selector'}):
value = runner_config.get(field['name'])
if value and value not in config_schema.NONE_SENTINELS:
await require_row(EmbeddingModel, value)
extract_model_reasoning_overrides(descriptor, runner_config, {'models': model_resources})
kb_ids = runner_config.get('knowledge-bases', [])
if not isinstance(kb_ids, list) or any(not isinstance(v, str) or not v for v in kb_ids):
+6 -7
View File
@@ -52,7 +52,7 @@ from ..entity.persistence import model as persistence_model
from ..core import app
from ..utils import constants
from ..agent.runner.session_registry import get_session_registry
from ..agent.runner.model_reasoning import model_with_reasoning_override
from ..provider.modelmgr.reasoning import model_with_reasoning_level
from ..agent.runner.config_resolver import RunnerConfigResolver
from ..agent.runner import config_schema
from ..agent.runner.platform_tools import execute_platform_tool, get_platform_tool_detail, resolve_platform_api_call
@@ -1129,6 +1129,7 @@ class RuntimeConnectionHandler(handler.Handler):
return handler.ActionResponse.success(
data={
'version': constants.semantic_version,
'api_features': ['llm.reasoning_level'],
},
)
@@ -1362,7 +1363,7 @@ class RuntimeConnectionHandler(handler.Handler):
caller_plugin_identity = data.get('caller_plugin_identity')
if run_id:
session, error = await _validate_run_authorization(
_, error = await _validate_run_authorization(
run_id,
'model',
llm_model_uuid,
@@ -1373,8 +1374,6 @@ class RuntimeConnectionHandler(handler.Handler):
)
if error:
return error
else:
session = None
if not await self._resource_exists(
persistence_model.LLMModel,
@@ -1397,7 +1396,7 @@ class RuntimeConnectionHandler(handler.Handler):
if getattr(llm_model.model_entity, 'workspace_uuid', None) not in (None, action_context.workspace_uuid):
return handler.ActionResponse.error(message='LLM model belongs to another Workspace')
llm_model = model_with_reasoning_override(llm_model, llm_model_uuid, session)
llm_model = model_with_reasoning_level(llm_model, data.get('reasoning_level'))
messages_obj = [provider_message.Message.model_validate(message) for message in messages]
async def _placeholder_func(**kwargs):
@@ -1479,7 +1478,7 @@ class RuntimeConnectionHandler(handler.Handler):
message=f'LLM model with llm_model_uuid {llm_model_uuid} not found',
)
llm_model = model_with_reasoning_override(llm_model, llm_model_uuid, session)
llm_model = model_with_reasoning_level(llm_model, data.get('reasoning_level'))
messages_obj = [provider_message.Message.model_validate(message) for message in messages]
# The func field is excluded during model_dump() in plugin side (marked as exclude=True),
@@ -1576,7 +1575,7 @@ class RuntimeConnectionHandler(handler.Handler):
if getattr(llm_model.model_entity, 'workspace_uuid', None) not in (None, action_context.workspace_uuid):
yield handler.ActionResponse.error(message='LLM model belongs to another Workspace')
return
llm_model = model_with_reasoning_override(llm_model, llm_model_uuid, session)
llm_model = model_with_reasoning_level(llm_model, data.get('reasoning_level'))
messages_obj = [provider_message.Message.model_validate(message) for message in messages]
# The func field is excluded during model_dump() in plugin side
@@ -1,6 +1,10 @@
from __future__ import annotations
import typing
import copy
if typing.TYPE_CHECKING:
from .requester import RuntimeLLMModel
ReasoningLevel = typing.Literal[
@@ -123,3 +127,30 @@ def default_reasoning_capabilities(
'levels': ['provider_default'],
'source': source,
}
def model_with_reasoning_level(
model: RuntimeLLMModel,
level: ReasoningLevel | None,
) -> RuntimeLLMModel:
"""Clone only the runtime wrapper, after the caller has authorized the model.
Do not mutate shared model entities or providers. Requesters retain ownership
of ability/capability validation and provider-specific argument translation.
"""
if level is None:
return model
abilities = set(model.model_entity.abilities or [])
# Managed Space models are read-only. Their detected capability is also what
# the model API and UI expose, even when the catalog omits the ability flag.
# Custom providers still require the user's explicit ability setting.
if (
'reasoning' not in abilities
and model.provider.provider_entity.requester == 'space-chat-completions'
and model.provider.requester.get_reasoning_capabilities(model).get('supported') is True
):
abilities.add('reasoning')
config = validate_reasoning_config({'level': level}, abilities, model.model_entity.extra_args)
scoped_model = copy.copy(model)
scoped_model.reasoning_config_override = copy.deepcopy(config)
return scoped_model