""" Plugin LLM facade — host-owned LLM access for trusted plugins. ============================================================== Plugins that need their own out-of-band model call (rewrite a tool error, translate inbound text, summarise a paste, score a scheduled job) get ``ctx.llm`` on :class:`~hermes_cli.plugins.PluginContext`: ``complete`` / ``complete_structured`` (text + image inputs, JSON schema validation) and their async siblings ``acomplete`` / ``acomplete_structured``. Provider/model/agent_id/profile are explicit keyword arguments mirroring the host config shape (``model.provider`` + ``model.model``) — no embedded slugs. The host owns routing, auth, timeouts, and fallback; the plugin never sees raw tokens or keys. Every override knob is gated by per-plugin trust flags:: plugins: entries: my-plugin: llm: allow_provider_override: true allow_model_override: true allowed_providers: [openrouter, anthropic] # optional allowed_models: [openai/gpt-4o-mini] # optional allow_agent_id_override: false allow_profile_override: false allow_task_override: false # borrow the host's built-in aux tasks The gate is fail-closed: a missing config block means "no overrides". ``task=`` routes a call through a plugin-registered auxiliary model slot (``ctx.register_auxiliary_task``). A plugin may always name a slot it registered itself; ``allow_task_override`` additionally lets it use the host's *built-in* auxiliary tasks. A foreign or unknown key is rejected loudly (error + logged warning), never silently downgraded to the main model. Backed by :func:`agent.auxiliary_client.call_llm`. """ from __future__ import annotations import base64 import json import logging import re from dataclasses import dataclass, field from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence, Union logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Public dataclasses # --------------------------------------------------------------------------- @dataclass class PluginLlmTextInput: """Text block in a structured input list.""" text: str type: str = "text" @dataclass class PluginLlmImageInput: """Image block. Provide ``data`` (raw bytes) or ``url`` (http(s)/data: URL). ``mime_type`` is required for non-PNG bytes to render across providers.""" data: Optional[bytes] = None url: Optional[str] = None mime_type: str = "image/png" file_name: str = "" type: str = "image" PluginLlmInput = Union[PluginLlmTextInput, PluginLlmImageInput, Dict[str, Any]] """A single structured input block: one of the dataclasses above or a plain dict of the same shape (``{"type": "text", "text": ...}`` / ``{"type": "image", "data": , "mime_type": ..., "file_name": ...}`` / ``{"type": "image", "url": ...}``).""" @dataclass class PluginLlmUsage: """Token + cost usage. All fields optional — providers differ on what they return. ``cost_usd`` is the host's best estimate.""" input_tokens: int = 0 output_tokens: int = 0 total_tokens: int = 0 cache_read_tokens: int = 0 cache_write_tokens: int = 0 cost_usd: Optional[float] = None @dataclass class PluginLlmCompleteResult: """Result of :meth:`PluginLlm.complete`.""" text: str provider: str model: str agent_id: str usage: PluginLlmUsage = field(default_factory=PluginLlmUsage) audit: Dict[str, Any] = field(default_factory=dict) @dataclass class PluginLlmStructuredResult: """Result of :meth:`PluginLlm.complete_structured`. ``parsed`` is set only when JSON output was requested (``json_mode`` or ``json_schema``) AND the response was valid JSON; ``content_type`` is then ``"json"``, otherwise ``"text"``.""" text: str provider: str model: str agent_id: str usage: PluginLlmUsage = field(default_factory=PluginLlmUsage) parsed: Optional[Any] = None content_type: str = "text" audit: Dict[str, Any] = field(default_factory=dict) # --------------------------------------------------------------------------- # Trust gate # --------------------------------------------------------------------------- @dataclass(frozen=True) class _TrustPolicy: """Resolved trust gate for one plugin's LLM access.""" plugin_id: str allow_provider_override: bool = False allowed_providers: Optional[frozenset] = None # None = no allowlist allow_any_provider: bool = False # True when allowed_providers == ["*"] allow_model_override: bool = False allowed_models: Optional[frozenset] = None # None = no allowlist allow_any_model: bool = False # True when allowed_models == ["*"] allow_agent_id_override: bool = False allow_profile_override: bool = False # Lets ``complete(task=...)`` borrow the host's *built-in* aux task slots. # Slots the plugin registered itself are always allowed. Fail-closed. allow_task_override: bool = False # The ``allow_*_override`` config keys; each is a same-named ``_TrustPolicy`` field. _OVERRIDE_FLAGS = ( "allow_provider_override", "allow_model_override", "allow_agent_id_override", "allow_profile_override", "allow_task_override", ) def _normalize_ref(raw: str) -> str: """Lower-case + strip whitespace. Used for allowlist matching.""" return (raw or "").strip().lower() def _coerce_allowlist(raw: Any) -> tuple[Optional[frozenset], bool]: """YAML list → ``(frozenset_or_None, allow_any)``. A ``"*"`` entry sets ``allow_any``; missing / non-list → ``(None, False)`` = no allowlist.""" if not isinstance(raw, list): return None, False normalized = [_normalize_ref(item) for item in raw if isinstance(item, str)] allow_any = "*" in normalized cleaned = {item for item in normalized if item and item != "*"} return frozenset(cleaned), allow_any def _resolve_trust_policy(plugin_id: str) -> _TrustPolicy: """Read ``plugins.entries..llm`` from config.yaml. Missing config → fully restrictive policy. Resolved per call (not cached) so config edits take effect without restarting the agent.""" if not plugin_id: return _TrustPolicy(plugin_id="") try: from hermes_cli.config import load_config_readonly config = load_config_readonly() or {} except Exception: # pragma: no cover — config IO failure return _TrustPolicy(plugin_id=plugin_id) llm_cfg: Any = config.get("plugins") for key in ("entries", plugin_id, "llm"): llm_cfg = llm_cfg.get(key) if isinstance(llm_cfg, dict) else None if not isinstance(llm_cfg, dict): return _TrustPolicy(plugin_id=plugin_id) allowed_models, allow_any_model = _coerce_allowlist(llm_cfg.get("allowed_models")) allowed_providers, allow_any_provider = _coerce_allowlist(llm_cfg.get("allowed_providers")) return _TrustPolicy( plugin_id=plugin_id, allowed_providers=allowed_providers, allow_any_provider=allow_any_provider, allowed_models=allowed_models, allow_any_model=allow_any_model, **{name: bool(llm_cfg.get(name, False)) for name in _OVERRIDE_FLAGS}, ) class PluginLlmTrustError(PermissionError): """Raised when a plugin attempts an LLM override without trust.""" def _denied(plugin_id: str, what: str, flag: str) -> PluginLlmTrustError: """Uniform "flag not set" trust error.""" return PluginLlmTrustError( f"Plugin {plugin_id!r} cannot {what} " f"(set plugins.entries.{plugin_id}.llm.{flag} to true to allow)." ) def _gate_ref_override(policy: _TrustPolicy, kind: str, requested: str) -> str: """Gate a ``provider`` / ``model`` override: trust flag, then optional allowlist. Returns the stripped value or raises.""" if not getattr(policy, f"allow_{kind}_override"): raise _denied(policy.plugin_id, f"override the {kind}", f"allow_{kind}_override") allowed = getattr(policy, f"allowed_{kind}s") if not getattr(policy, f"allow_any_{kind}") and allowed is not None and _normalize_ref(requested) not in allowed: raise PluginLlmTrustError( f"Plugin {policy.plugin_id!r} {kind} override " f"{requested!r} is not in plugins.entries." f"{policy.plugin_id}.llm.allowed_{kind}s." ) return requested.strip() # Overrides gated by a bare trust flag (no allowlist): ``kind`` -> denial wording. _FLAG_ONLY_OVERRIDES = { "agent_id": "run completions against a non-default agent id", "profile": "override the auth profile", } def _check_overrides( policy: _TrustPolicy, *, requested_provider: Optional[str], requested_model: Optional[str], requested_agent_id: Optional[str], requested_profile: Optional[str], ) -> tuple[Optional[str], Optional[str], Optional[str], Optional[str]]: """Apply the trust gate; each override is gated independently, in the order provider, model, agent_id, profile. Returns ``(provider, model, agent_id, profile)`` (agent_id unstripped) or raises :class:`PluginLlmTrustError`.""" final_provider = _gate_ref_override(policy, "provider", requested_provider) if requested_provider else None final_model = _gate_ref_override(policy, "model", requested_model) if requested_model else None for kind, requested in (("agent_id", requested_agent_id), ("profile", requested_profile)): if requested and not getattr(policy, f"allow_{kind}_override"): raise _denied(policy.plugin_id, _FLAG_ONLY_OVERRIDES[kind], f"allow_{kind}_override") final_profile = requested_profile.strip() if requested_profile else None return final_provider, final_model, requested_agent_id, final_profile def _resolve_task_ownership(plugin_id: str) -> tuple[frozenset, frozenset]: """Return ``(owned_keys, builtin_keys)`` for the task trust gate. Imports are lazy (circular import at plugin discovery). An unreadable registry yields empty sets, failing the gate closed. Ownership matches on the canonical id ``ctx.llm`` is bound to (``manifest.key or manifest.name``), which is what ``register_auxiliary_task`` stores as the entry's ``plugin``.""" owned: set = set() builtin: set = set() try: from hermes_cli.plugins import get_plugin_auxiliary_tasks owned = { e.get("key") for e in get_plugin_auxiliary_tasks() if e.get("plugin") == plugin_id and isinstance(e.get("key"), str) and e.get("key") } except Exception: # pragma: no cover — registry unavailable pass try: from hermes_cli.main import _AUX_TASKS builtin = {k for k, _name, _desc in _AUX_TASKS} except Exception: # pragma: no cover — main import failure pass return frozenset(owned), frozenset(builtin) def _check_task( policy: _TrustPolicy, *, plugin_id: str, requested_task: Optional[str], ) -> Optional[str]: """Validate a plugin's requested auxiliary ``task`` key. * unset / ``""`` / ``"auto"`` → ``None`` (main-model path). * a key the plugin registered itself → allowed. * a built-in key → allowed only with ``allow_task_override``. * anything else → raises + logs a warning. Never silently downgraded to ``auto``: that would mask the misconfiguration and could route to a main model the user steered elsewhere on purpose.""" if not requested_task: return None task = requested_task.strip() if not task or task.lower() == "auto": return None owned, builtin = _resolve_task_ownership(plugin_id) if task in owned or (task in builtin and policy.allow_task_override): return task if task in builtin: logger.warning( "plugin_llm task routing denied: plugin %r requested built-in " "auxiliary task %r without plugins.entries.%s.llm.allow_task_override", plugin_id, task, plugin_id, ) raise _denied(plugin_id, f"route through the built-in auxiliary task {task!r}", "allow_task_override") logger.warning( "plugin_llm task routing denied: plugin %r requested auxiliary task %r it did not register", plugin_id, task, ) raise PluginLlmTrustError( f"Plugin {plugin_id!r} cannot route through auxiliary task {task!r} — a " f"plugin may only pass a task key it registered itself via " f"ctx.register_auxiliary_task() (or a built-in key when plugins.entries." f"{plugin_id}.llm.allow_task_override is true)." ) # --------------------------------------------------------------------------- # Input normalization # --------------------------------------------------------------------------- def _normalize_input_block(block: PluginLlmInput) -> Dict[str, Any]: """Coerce a structured input block to a plain dict. Unknown shapes raise ``ValueError``.""" if isinstance(block, PluginLlmTextInput): return {"type": "text", "text": block.text} if isinstance(block, PluginLlmImageInput): d: Dict[str, Any] = {"type": "image", "mime_type": block.mime_type, "file_name": block.file_name} if block.data is not None: d["data"] = block.data if block.url: d["url"] = block.url return d if not isinstance(block, dict): raise ValueError(f"Unsupported input block: {type(block).__name__}") kind = block.get("type") if kind == "text": text = block.get("text") if not isinstance(text, str): raise ValueError("text input block requires 'text' string") return {"type": "text", "text": text} if kind == "image": if "data" not in block and not block.get("url"): raise ValueError("image input block requires 'data' bytes or 'url'") return { "type": "image", "data": block.get("data"), "url": block.get("url"), "mime_type": block.get("mime_type") or "image/png", "file_name": block.get("file_name") or "", } raise ValueError(f"Unknown input block type: {kind!r}") def _image_part(norm: Dict[str, Any]) -> Dict[str, Any]: """Normalized image block → OpenAI ``image_url`` part (data: URL for bytes).""" url = norm.get("url") if not url: data = norm.get("data") or b"" if not isinstance(data, (bytes, bytearray)): raise ValueError("image input 'data' must be bytes") b64 = base64.b64encode(data).decode("ascii") url = f"data:{norm.get('mime_type') or 'image/png'};base64,{b64}" return {"type": "image_url", "image_url": {"url": url}} def _build_structured_messages( *, instructions: str, inputs: Sequence[PluginLlmInput], json_mode: bool, json_schema: Optional[Any], schema_name: Optional[str], system_prompt: Optional[str], ) -> List[Dict[str, Any]]: """Build OpenAI-style messages for a structured call: optional system message (prompt + JSON-only directive), then a user message whose first text part is the instructions (+ schema name / JSON schema) followed by the input blocks.""" messages: List[Dict[str, Any]] = [] sys_parts: List[str] = [system_prompt.strip()] if system_prompt else [] if json_mode or json_schema is not None: sys_parts.append( "Respond with a single JSON object that matches the requested shape. " "Do not include prose or markdown fences." ) if sys_parts: messages.append({"role": "system", "content": "\n\n".join(sys_parts)}) header = instructions.strip() if schema_name: header = f"{header}\n\nSchema name: {schema_name}" if json_schema is not None: try: schema_text = json.dumps(json_schema, ensure_ascii=False, sort_keys=True) except (TypeError, ValueError): schema_text = str(json_schema) header = f"{header}\n\nJSON schema:\n{schema_text}" user_parts: List[Dict[str, Any]] = [{"type": "text", "text": header}] for block in inputs: norm = _normalize_input_block(block) # always "text" or "image" user_parts.append({"type": "text", "text": norm["text"]} if norm["type"] == "text" else _image_part(norm)) messages.append({"role": "user", "content": user_parts}) return messages # --------------------------------------------------------------------------- # JSON parsing # --------------------------------------------------------------------------- _FENCE_RE = re.compile(r"```(?:json)?\s*(.+?)```", re.DOTALL | re.IGNORECASE) def _strip_code_fences(text: str) -> str: """Return the first fenced code block's body, or the stripped text when unfenced.""" match = _FENCE_RE.search(text) return match.group(1).strip() if match else text.strip() def _parse_structured_text( *, text: str, json_mode: bool, json_schema: Optional[Any] ) -> tuple[Optional[Any], str]: """Return ``(parsed, content_type)``: ``"json"`` when parsing (and schema validation, if a schema was given) succeeded, ``"text"`` otherwise. Schema violations raise ``ValueError``; a missing ``jsonschema`` package skips validation with a debug log.""" if not (json_mode or json_schema is not None) or not text: return None, "text" try: parsed = json.loads(_strip_code_fences(text)) except (json.JSONDecodeError, ValueError): return None, "text" if json_schema is not None: try: import jsonschema # type: ignore[import-untyped] jsonschema.validate(parsed, json_schema) except ImportError: logger.debug("jsonschema unavailable; skipping schema validation") except jsonschema.ValidationError as exc: # type: ignore[attr-defined] raise ValueError( f"Plugin LLM structured output did not match schema: {exc.message}" ) from exc return parsed, "json" # --------------------------------------------------------------------------- # Response extraction # --------------------------------------------------------------------------- def _extract_usage(response: Any) -> PluginLlmUsage: """Pull token usage out of an OpenAI-shaped response, tolerating provider naming differences (Anthropic via the aux adapter: ``prompt_tokens`` / ``completion_tokens``; direct OpenAI adds ``cache_read_input_tokens``).""" usage = PluginLlmUsage() raw = getattr(response, "usage", None) if raw is None: return usage def _g(name: str) -> int: v = getattr(raw, name, None) if v is None and isinstance(raw, dict): v = raw.get(name) try: return int(v) if v is not None else 0 except (TypeError, ValueError): return 0 usage.input_tokens = _g("prompt_tokens") or _g("input_tokens") usage.output_tokens = _g("completion_tokens") or _g("output_tokens") usage.total_tokens = _g("total_tokens") or (usage.input_tokens + usage.output_tokens) usage.cache_read_tokens = _g("cache_read_input_tokens") or _g("cache_read_tokens") usage.cache_write_tokens = _g("cache_creation_input_tokens") or _g("cache_write_tokens") return usage def _extract_text(response: Any) -> str: """Pull the assistant text out of an OpenAI-shaped response object.""" try: content = getattr(response.choices[0].message, "content", None) if isinstance(content, str): return content if isinstance(content, list): texts = ( (part.get("text") if part.get("type") == "text" else None) if isinstance(part, dict) else getattr(part, "text", None) for part in content ) return "".join(t for t in texts if isinstance(t, str)) except (AttributeError, IndexError, TypeError): pass return "" def _main_config_value(reader: str, default: str) -> str: """Read the current main provider/model via ``agent.auxiliary_client``.""" try: import agent.auxiliary_client as ac return (getattr(ac, reader)() or "").strip() or default except Exception: # pragma: no cover — defensive return default def _resolve_attribution( *, provider_override: Optional[str], model_override: Optional[str], response: Any, route_info: Optional[Dict[str, str]] = None, ) -> tuple[str, str]: """Decide what to record as ``result.provider`` / ``result.model``. Provider: route selected by ``auxiliary_client`` > explicit override > current main provider > ``"auto"``. Model: ``response.model`` (providers return the canonical id that actually ran, e.g. ``gpt-4o-2024-08-06``) > route > override > current main model > ``"default"``.""" route_info = route_info or {} provider = route_info.get("provider") or provider_override or _main_config_value("_read_main_provider", "auto") response_model = getattr(response, "model", None) if isinstance(response_model, str) and response_model.strip(): return provider, response_model.strip() return provider, route_info.get("model") or model_override or _main_config_value("_read_main_model", "default") # --------------------------------------------------------------------------- # PluginLlm facade # --------------------------------------------------------------------------- def _json_response_format(*, json_mode: bool, json_schema: Optional[Any]) -> Optional[Dict[str, Any]]: """``extra_body.response_format`` for the request; falls back to ``json_object`` without a schema so schema-blind providers still get a hint.""" if json_schema is not None: schema = {"name": "plugin_structured_output", "schema": json_schema, "strict": False} return {"response_format": {"type": "json_schema", "json_schema": schema}} if json_mode: return {"response_format": {"type": "json_object"}} return None def _structured_spec( name: str, instructions: str, input: Sequence[PluginLlmInput], system_prompt: Optional[str], json_mode: bool, json_schema: Optional[Any], schema_name: Optional[str], ) -> Dict[str, Any]: """Argument check for the structured methods (runs before the trust gate); returns the spec ``_gate`` / ``_finish`` consume.""" if not instructions or not instructions.strip(): raise ValueError(f"{name} requires non-empty instructions") if not input: raise ValueError(f"{name} requires at least one input block") return dict( instructions=instructions, inputs=list(input), system_prompt=system_prompt, json_mode=json_mode, json_schema=json_schema, schema_name=schema_name, ) class PluginLlm: """Host-owned LLM access for one trusted plugin. Constructed by :class:`hermes_cli.plugins.PluginContext` and exposed as ``ctx.llm``; the constructor binds plugin identity for trust enforcement, so plugins should not instantiate it directly. Every public method is ``_gate`` (trust checks → call kwargs) → ``_invoke_*`` (host ``call_llm`` or injected caller) → ``_finish`` (result + audit log); the sync/async and plain/structured variants differ only in which pieces they pass through.""" def __init__( self, *, plugin_id: str, policy_loader: Optional[Callable[[str], _TrustPolicy]] = None, sync_caller: Optional[Callable[..., Any]] = None, async_caller: Optional[Callable[..., Awaitable[Any]]] = None, ) -> None: self._plugin_id = plugin_id self._policy_loader = policy_loader or _resolve_trust_policy self._sync_caller = sync_caller self._async_caller = async_caller # -- public sync API ---------------------------------------------------- def complete( self, messages: List[Dict[str, Any]], *, provider: Optional[str] = None, model: Optional[str] = None, temperature: Optional[float] = None, max_tokens: Optional[int] = None, timeout: Optional[float] = None, agent_id: Optional[str] = None, profile: Optional[str] = None, purpose: Optional[str] = None, task: Optional[str] = None, ) -> PluginLlmCompleteResult: """Run a host-owned chat completion against the user's active model. ``messages`` is the standard OpenAI shape. ``provider``/``model``/ ``agent_id``/``profile`` are each gated by ``plugins.entries..llm.allow_*_override``. ``task`` routes through a plugin-registered auxiliary slot (see :func:`_check_task`).""" agent, kw = self._gate(provider, model, agent_id, profile, task, messages, temperature, max_tokens, timeout) return self._finish("complete", agent, kw, self._invoke_sync(kw), purpose) def complete_structured( self, *, instructions: str, input: Sequence[PluginLlmInput], json_schema: Optional[Any] = None, json_mode: bool = False, schema_name: Optional[str] = None, system_prompt: Optional[str] = None, provider: Optional[str] = None, model: Optional[str] = None, temperature: Optional[float] = None, max_tokens: Optional[int] = None, timeout: Optional[float] = None, agent_id: Optional[str] = None, profile: Optional[str] = None, purpose: Optional[str] = None, task: Optional[str] = None, ) -> PluginLlmStructuredResult: """Run a bounded host-owned structured completion. ``input`` accepts text and image blocks. With ``json_mode=True`` or a ``json_schema`` the response is parsed (and validated when the optional ``jsonschema`` package is installed) into ``result.parsed``. ``task`` routes as in :meth:`complete`.""" spec = _structured_spec("complete_structured", instructions, input, system_prompt, json_mode, json_schema, schema_name) agent, kw = self._gate(provider, model, agent_id, profile, task, None, temperature, max_tokens, timeout, spec) return self._finish("complete_structured", agent, kw, self._invoke_sync(kw), purpose, spec) # -- public async API --------------------------------------------------- async def acomplete( self, messages: List[Dict[str, Any]], *, provider: Optional[str] = None, model: Optional[str] = None, temperature: Optional[float] = None, max_tokens: Optional[int] = None, timeout: Optional[float] = None, agent_id: Optional[str] = None, profile: Optional[str] = None, purpose: Optional[str] = None, task: Optional[str] = None, ) -> PluginLlmCompleteResult: """Async sibling of :meth:`complete`.""" agent, kw = self._gate(provider, model, agent_id, profile, task, messages, temperature, max_tokens, timeout) return self._finish("acomplete", agent, kw, await self._invoke_async(kw), purpose) async def acomplete_structured( self, *, instructions: str, input: Sequence[PluginLlmInput], json_schema: Optional[Any] = None, json_mode: bool = False, schema_name: Optional[str] = None, system_prompt: Optional[str] = None, provider: Optional[str] = None, model: Optional[str] = None, temperature: Optional[float] = None, max_tokens: Optional[int] = None, timeout: Optional[float] = None, agent_id: Optional[str] = None, profile: Optional[str] = None, purpose: Optional[str] = None, task: Optional[str] = None, ) -> PluginLlmStructuredResult: """Async sibling of :meth:`complete_structured`.""" spec = _structured_spec("acomplete_structured", instructions, input, system_prompt, json_mode, json_schema, schema_name) agent, kw = self._gate(provider, model, agent_id, profile, task, None, temperature, max_tokens, timeout, spec) return self._finish("acomplete_structured", agent, kw, await self._invoke_async(kw), purpose, spec) # -- shared core -------------------------------------------------------- def _gate( self, provider: Optional[str], model: Optional[str], agent_id: Optional[str], profile: Optional[str], task: Optional[str], messages: Optional[List[Dict[str, Any]]], temperature: Optional[float], max_tokens: Optional[int], timeout: Optional[float], spec: Optional[Dict[str, Any]] = None, ) -> tuple[Optional[str], Dict[str, Any]]: """Run the trust gate (task first, then overrides), then — for a structured ``spec`` — build messages/response_format (input-shape errors surface only after trust passes). Returns the effective agent id (result-only) and the call kwargs handed to ``_invoke_*`` / an injected caller, in the documented order: messages, provider_override, model_override, profile_override, temperature, max_tokens, timeout, extra_body, task.""" policy = self._policy_loader(self._plugin_id) eff_task = _check_task(policy, plugin_id=self._plugin_id, requested_task=task) eff_provider, eff_model, eff_agent, eff_profile = _check_overrides( policy, requested_provider=provider, requested_model=model, requested_agent_id=agent_id, requested_profile=profile, ) extra_body = None if spec is not None: messages = _build_structured_messages(**spec) extra_body = _json_response_format(json_mode=spec["json_mode"], json_schema=spec["json_schema"]) return eff_agent, dict( messages=messages, provider_override=eff_provider, model_override=eff_model, profile_override=eff_profile, temperature=temperature, max_tokens=max_tokens, timeout=timeout, extra_body=extra_body, task=eff_task, ) def _finish( self, name: str, agent_id: Optional[str], kw: Dict[str, Any], invoked: tuple[str, str, Any], purpose: Optional[str], spec: Optional[Dict[str, Any]] = None, ) -> Any: """Build the result object + audit dict and emit the INFO audit line.""" real_provider, real_model, response = invoked text = _extract_text(response) usage = _extract_usage(response) eff_task = kw["task"] or "" audit: Dict[str, Any] = {"plugin_id": self._plugin_id, "purpose": purpose or "", "profile": kw["profile_override"] or ""} fields: Dict[str, Any] = dict(text=text, provider=real_provider, model=real_model, agent_id=agent_id or "default", usage=usage) fmt = f"plugin_llm.{name} plugin=%s provider=%s model=%s task=%s purpose=%s " log_args = [self._plugin_id, real_provider, real_model, eff_task, purpose or ""] cls: Any = PluginLlmCompleteResult if spec is not None: parsed, content_type = _parse_structured_text(text=text, json_mode=spec["json_mode"], json_schema=spec["json_schema"]) audit["schema_name"] = spec["schema_name"] or "" fields.update(parsed=parsed, content_type=content_type) fmt += "content_type=%s " log_args.append(content_type) cls = PluginLlmStructuredResult audit["task"] = eff_task logger.info(fmt + "tokens=%d", *log_args, usage.total_tokens) return cls(**fields, audit=audit) # -- host invocation --------------------------------------------------- @staticmethod def _host_kwargs(kw: Dict[str, Any]) -> tuple[Dict[str, Any], Optional[Dict[str, str]]]: """Translate call kwargs into ``call_llm`` kwargs. The auth profile rides in ``extra_body.metadata.auth_profile``; ``route_info`` is only requested when routing through a task slot.""" merged_extra = dict(kw["extra_body"] or {}) if kw["profile_override"]: merged_extra.setdefault("metadata", {})["auth_profile"] = kw["profile_override"] route_info: Optional[Dict[str, str]] = {} if kw["task"] else None return dict( task=kw["task"], provider=kw["provider_override"], model=kw["model_override"], messages=kw["messages"], temperature=kw["temperature"], max_tokens=kw["max_tokens"], timeout=kw["timeout"], extra_body=merged_extra or None, route_info=route_info, ), route_info @staticmethod def _attributed(kw: Dict[str, Any], response: Any, route_info: Optional[Dict[str, str]]) -> tuple[str, str, Any]: provider, model = _resolve_attribution( provider_override=kw["provider_override"], model_override=kw["model_override"], response=response, route_info=route_info, ) return provider, model, response def _invoke_sync(self, kw: Dict[str, Any]) -> tuple[str, str, Any]: """Invoke the host's ``call_llm`` (lazy import: circular deps at plugin discovery) and return ``(provider, model, response)``. ``task`` is already trust-checked; ``None`` keeps the main model. An injected ``sync_caller`` replaces the whole path and receives the call kwargs.""" if self._sync_caller is not None: return self._sync_caller(**kw) from agent.auxiliary_client import call_llm call_kw, route_info = self._host_kwargs(kw) return self._attributed(kw, call_llm(**call_kw), route_info) async def _invoke_async(self, kw: Dict[str, Any]) -> tuple[str, str, Any]: """Async sibling of :meth:`_invoke_sync` (``async_call_llm`` / ``async_caller``).""" if self._async_caller is not None: return await self._async_caller(**kw) from agent.auxiliary_client import async_call_llm call_kw, route_info = self._host_kwargs(kw) return self._attributed(kw, await async_call_llm(**call_kw), route_info) # --------------------------------------------------------------------------- # Test helpers # --------------------------------------------------------------------------- def make_plugin_llm_for_test( *, plugin_id: str, policy: _TrustPolicy, sync_caller: Optional[Callable[..., Any]] = None, async_caller: Optional[Callable[..., Awaitable[Any]]] = None, ) -> PluginLlm: """:class:`PluginLlm` with an injected policy and caller (no config.yaml, no provider). Not part of the public plugin API.""" return PluginLlm(plugin_id=plugin_id, policy_loader=lambda _pid: policy, sync_caller=sync_caller, async_caller=async_caller) __all__ = [ "PluginLlm", "PluginLlmTextInput", "PluginLlmImageInput", "PluginLlmInput", "PluginLlmUsage", "PluginLlmCompleteResult", "PluginLlmStructuredResult", "PluginLlmTrustError", "make_plugin_llm_for_test", ]