mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-21 16:05:19 +08:00
fix(reasoning): apply explicit per-call levels without runner config caching
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user