coderouter-cli 2.2.0__py3-none-any.whl → 2.3.0a4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,7 +14,7 @@ Design notes (see plan.md §2 / §5.4):
14
14
  from __future__ import annotations
15
15
 
16
16
  import re
17
- from typing import Literal, Self
17
+ from typing import Any, Literal, Self
18
18
 
19
19
  from pydantic import BaseModel, ConfigDict, Field, HttpUrl, model_validator
20
20
 
@@ -852,6 +852,51 @@ class AutoRouterConfig(BaseModel):
852
852
  )
853
853
 
854
854
 
855
+ class PluginsConfig(BaseModel):
856
+ """The ``plugins:`` block in providers.yaml (v2.3.0).
857
+
858
+ Declarative opt-in for in-process plugins distributed as separate
859
+ PyPI packages (``coderouter-plugin-*``). Two-step gating:
860
+
861
+ 1. ``pip install coderouter-plugin-X`` makes the entry point
862
+ discoverable.
863
+ 2. The plugin's entry-point name MUST appear in :attr:`enabled`
864
+ before the loader will instantiate it.
865
+
866
+ Step 2 is the supply-chain defense: a malicious transitive dep
867
+ cannot wedge itself into the request flow without an explicit
868
+ user action in providers.yaml. See
869
+ :mod:`coderouter.plugins.loader` for the full discovery logic.
870
+ """
871
+
872
+ model_config = ConfigDict(extra="forbid")
873
+
874
+ enabled: list[str] = Field(
875
+ default_factory=list,
876
+ description=(
877
+ "v2.3.0: ordered list of plugin entry-point names to load. "
878
+ "An entry-point name is the LHS of an entry in a plugin's "
879
+ "``[project.entry-points.\"coderouter.<group>\"]`` block — "
880
+ "e.g. ``memory`` for ``coderouter-plugin-memory``. Order "
881
+ "controls the order InputFilter chains apply (each filter "
882
+ "sees the previous filter's output). Empty list = no "
883
+ "plugins active (default behavior, identical to v2.2.0)."
884
+ ),
885
+ )
886
+ config: dict[str, dict[str, Any]] = Field(
887
+ default_factory=dict,
888
+ description=(
889
+ "v2.3.0: per-plugin keyword arguments. The dict at "
890
+ "``config[<plugin-name>]`` is splatted into the plugin's "
891
+ "``__init__`` as ``**kwargs``. Validation of each "
892
+ "sub-dict's schema is the plugin's responsibility — Core "
893
+ "stays out of plugin-specific config shapes. Plugins not "
894
+ "listed in :attr:`enabled` are ignored even if they have "
895
+ "config entries here."
896
+ ),
897
+ )
898
+
899
+
855
900
  class CodeRouterConfig(BaseModel):
856
901
  """Top-level config loaded from providers.yaml."""
857
902
 
@@ -1001,6 +1046,21 @@ class CodeRouterConfig(BaseModel):
1001
1046
  ),
1002
1047
  )
1003
1048
 
1049
+ # v2.3.0: in-process plugin SDK. Optional — when None, the engine
1050
+ # builds an empty ``PluginRegistry`` and the hook chains are
1051
+ # short-circuited (zero-cost path, identical to v2.2.0 behavior).
1052
+ plugins: PluginsConfig | None = Field(
1053
+ default=None,
1054
+ description=(
1055
+ "v2.3.0: in-process plugin configuration. Plugins are "
1056
+ "distributed as separate PyPI packages (e.g. "
1057
+ "``coderouter-plugin-memory``); this block lists which of "
1058
+ "the installed plugins to actually activate, and supplies "
1059
+ "their per-plugin keyword arguments. Absent or empty = no "
1060
+ "plugins (zero-cost, backward-compatible default)."
1061
+ ),
1062
+ )
1063
+
1004
1064
  @model_validator(mode="after")
1005
1065
  def _check_default_profile_exists(self) -> CodeRouterConfig:
1006
1066
  """v0.6-A: surface a typo'd ``default_profile`` at load time.
coderouter/ingress/app.py CHANGED
@@ -17,6 +17,7 @@ from coderouter.ingress.metrics_routes import router as metrics_router
17
17
  from coderouter.ingress.openai_routes import router as openai_router
18
18
  from coderouter.logging import configure_logging, get_logger
19
19
  from coderouter.metrics import install_collector
20
+ from coderouter.plugins import discover_and_load
20
21
  from coderouter.routing import FallbackEngine
21
22
  from coderouter.routing.capability import check_claude_code_chain_suitability
22
23
 
@@ -38,7 +39,13 @@ def create_app(config_path: str | None = None) -> FastAPI:
38
39
  # so multiple create_app() calls (tests) don't stack handlers.
39
40
  install_collector()
40
41
  config = load_config(config_path)
41
- engine = FallbackEngine(config)
42
+ # v2.3.0: discover plugins from importlib.metadata entry points and
43
+ # apply the user's explicit ``plugins.enabled`` allowlist. When the
44
+ # ``plugins`` block is absent or empty, the loader returns an empty
45
+ # registry and the engine's hook loops short-circuit — the request
46
+ # flow is bit-identical to v2.2.0 in that default case.
47
+ plugin_registry = discover_and_load(config)
48
+ engine = FallbackEngine(config, plugins=plugin_registry)
42
49
 
43
50
  @asynccontextmanager
44
51
  async def lifespan(app: FastAPI) -> AsyncIterator[None]:
@@ -0,0 +1,56 @@
1
+ """Plugin SDK — extension points for in-process CodeRouter plugins (v2.3.0).
2
+
3
+ CodeRouter Core stays at 5 deps. Optional functionality (memory, PII
4
+ redaction, observability bridges, alternative ingresses, etc.) ships
5
+ as separate ``coderouter-plugin-*`` packages on PyPI. Each plugin
6
+ declares one or more *entry points* in its ``pyproject.toml``; this
7
+ SDK discovers them at startup, applies the user's explicit ``enabled``
8
+ allowlist (supply chain defense, see :func:`loader.discover_and_load`),
9
+ and exposes a :class:`registry.PluginRegistry` to the engine.
10
+
11
+ Six extension points are defined as :mod:`Protocols <typing>` in
12
+ :mod:`coderouter.plugins.base`. v2.3.0 implements the engine-side hook
13
+ integration for two of them (``input_filter`` and ``observer``); the
14
+ remaining four (``frontend``, ``guard``, ``output_filter``,
15
+ ``adapter``) have a stable Protocol contract so plugins can target
16
+ them now, but the engine doesn't yet wire them into the request flow.
17
+ That's intentional — adding contracts is cheap, adding hot-path code
18
+ isn't, so we wait for a real plugin to drive each integration.
19
+
20
+ Public API:
21
+
22
+ - :class:`InputFilter`, :class:`Observer` — implementable today.
23
+ - :class:`Frontend`, :class:`Guard`, :class:`OutputFilter`,
24
+ :class:`Adapter` — Protocol-only, integration in v2.4+.
25
+ - :func:`discover_and_load` — called once during config load.
26
+ - :class:`PluginRegistry` — held by :class:`FallbackEngine`.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ from coderouter.plugins.base import (
31
+ Adapter,
32
+ Frontend,
33
+ Guard,
34
+ InputFilter,
35
+ Observer,
36
+ OutputFilter,
37
+ )
38
+ from coderouter.plugins.loader import (
39
+ PLUGIN_GROUPS_FUTURE,
40
+ PLUGIN_GROUPS_V2_3,
41
+ discover_and_load,
42
+ )
43
+ from coderouter.plugins.registry import PluginRegistry
44
+
45
+ __all__ = [
46
+ "PLUGIN_GROUPS_FUTURE",
47
+ "PLUGIN_GROUPS_V2_3",
48
+ "Adapter",
49
+ "Frontend",
50
+ "Guard",
51
+ "InputFilter",
52
+ "Observer",
53
+ "OutputFilter",
54
+ "PluginRegistry",
55
+ "discover_and_load",
56
+ ]
@@ -0,0 +1,168 @@
1
+ """Plugin SDK Protocol contracts (v2.3.0).
2
+
3
+ Six extension points are defined here. Two are wired into the engine
4
+ in v2.3.0 (:class:`InputFilter`, :class:`Observer`); four are
5
+ Protocol-only (:class:`Frontend`, :class:`Guard`, :class:`OutputFilter`,
6
+ :class:`Adapter`) and will get engine integration when a real plugin
7
+ drives the requirement — see ``docs/inside/plugin-architecture-draft.md``
8
+ §3 for the full design rationale.
9
+
10
+ Why declare contracts ahead of integration? It lets a plugin author
11
+ build against the SDK *now* (and ship a working ``coderouter.frontend``
12
+ plugin once integration lands) without us having to do a
13
+ backward-incompatible Protocol revision later. ``runtime_checkable``
14
+ is used so :func:`isinstance` checks work in the loader for clearer
15
+ error messages.
16
+
17
+ All hooks are async. Failures must NEVER block the engine response —
18
+ the engine wraps every hook call in try/except and degrades gracefully
19
+ (see ``coderouter/routing/fallback.py`` integration site).
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
24
+
25
+ if TYPE_CHECKING:
26
+ # Avoid circular imports at runtime — Protocol typing only needs
27
+ # these for documentation and static analysis.
28
+ from coderouter.config.schemas import CodeRouterConfig
29
+ from coderouter.translation.anthropic import (
30
+ AnthropicRequest,
31
+ AnthropicResponse,
32
+ )
33
+
34
+
35
+ # ====================================================================
36
+ # Active hooks (engine integration in v2.3.0)
37
+ # ====================================================================
38
+
39
+
40
+ @runtime_checkable
41
+ class InputFilter(Protocol):
42
+ """Mutates an inbound :class:`AnthropicRequest` before chain dispatch.
43
+
44
+ Plugins MUST treat the input as immutable: build the modified
45
+ request with ``request.model_copy(update={...})`` and return the
46
+ new instance. The engine assumes the returned value is a
47
+ *replacement*, not the same object.
48
+
49
+ Engine semantics:
50
+
51
+ - Filters run sequentially in the order they appear in
52
+ ``plugins.enabled``. The first filter sees the raw inbound
53
+ request; each subsequent filter sees the previous filter's
54
+ output.
55
+ - If :meth:`transform` raises, the engine logs ``input-filter-failed``
56
+ and continues with the *pre-mutation* request for that filter.
57
+ Other filters still run.
58
+ - The transform runs *after* the v1.9-E tool-loop guard but
59
+ *before* chain resolution and the v2.0-F context budget guard.
60
+ That order matters: filters can grow ``request.system`` (e.g.
61
+ memory injection) without bypassing the budget cap, because the
62
+ budget guard reruns over the post-filter payload.
63
+ """
64
+
65
+ name: str
66
+
67
+ async def transform(self, request: AnthropicRequest) -> AnthropicRequest: ...
68
+
69
+
70
+ @runtime_checkable
71
+ class Observer(Protocol):
72
+ """Passive event consumer. Cannot mutate anything.
73
+
74
+ The engine calls observers via :func:`asyncio.create_task` (fire
75
+ and forget). An observer that takes 30s won't slow a 200ms
76
+ response down — it just runs in the background. If it raises,
77
+ the engine logs ``observer-failed`` and discards the exception.
78
+
79
+ Event vocabulary (v2.3.0):
80
+
81
+ - ``request_completed`` — payload: ``{request, response,
82
+ latency_ms, provider}``. Fires after a successful Anthropic or
83
+ OpenAI-compat response. Streaming requests fire this event once,
84
+ after the SSE stream has terminated (not on each chunk).
85
+
86
+ Plugins MUST tolerate unknown event types — the vocabulary will
87
+ grow over time, and an old plugin should silently ignore events
88
+ it doesn't recognize rather than crash.
89
+ """
90
+
91
+ name: str
92
+
93
+ async def on_event(self, event_type: str, payload: dict[str, Any]) -> None: ...
94
+
95
+
96
+ # ====================================================================
97
+ # Future hooks (Protocol-only, engine integration in v2.4+)
98
+ # ====================================================================
99
+
100
+
101
+ @runtime_checkable
102
+ class Frontend(Protocol):
103
+ """Alternative ingress (Discord, Telegram, MCP, Voice, ...).
104
+
105
+ Frontends *run* the engine rather than being called by it.
106
+ Integration plan: each frontend gets its own
107
+ :func:`asyncio.create_task` started by ``coderouter serve``;
108
+ SIGTERM cancels all of them and waits for cleanup.
109
+
110
+ Not yet integrated — Protocol contract only.
111
+ """
112
+
113
+ name: str
114
+
115
+ async def serve(
116
+ self, engine: Any, config: CodeRouterConfig
117
+ ) -> None: ...
118
+
119
+
120
+ @runtime_checkable
121
+ class Guard(Protocol):
122
+ """Reliability guard, parallel to the built-in tool-loop guard.
123
+
124
+ Runs synchronously on the hot path, so implementations must be
125
+ cheap. Heavy work (HTTP calls, model invocations, etc.) belongs
126
+ in an :class:`Observer`.
127
+
128
+ Not yet integrated — Protocol contract only.
129
+ """
130
+
131
+ name: str
132
+
133
+ async def check(
134
+ self, request: AnthropicRequest, config: CodeRouterConfig
135
+ ) -> None: ...
136
+
137
+
138
+ @runtime_checkable
139
+ class OutputFilter(Protocol):
140
+ """Mutates a response before it returns to the client.
141
+
142
+ For streaming, the engine plans to call :meth:`transform` once
143
+ per ``AnthropicStreamEvent``; for non-streaming, once per
144
+ response.
145
+
146
+ Not yet integrated — Protocol contract only.
147
+ """
148
+
149
+ name: str
150
+
151
+ async def transform(
152
+ self, response: AnthropicResponse
153
+ ) -> AnthropicResponse: ...
154
+
155
+
156
+ @runtime_checkable
157
+ class Adapter(Protocol):
158
+ """New ``kind`` value in providers.yaml (e.g. ``bedrock-native``).
159
+
160
+ Plugins implement the same async surface as
161
+ :class:`coderouter.adapters.base.BaseAdapter` so the engine can
162
+ treat them indistinguishably from built-in adapters once the
163
+ loader registers the new ``kind`` mapping.
164
+
165
+ Not yet integrated — Protocol contract only.
166
+ """
167
+
168
+ name: str
@@ -0,0 +1,176 @@
1
+ """Plugin discovery + instantiation (v2.3.0).
2
+
3
+ Reads ``importlib.metadata`` entry points under the ``coderouter.*``
4
+ groups, applies the user's explicit ``plugins.enabled`` allowlist,
5
+ constructs each plugin with its config dict, and returns a
6
+ :class:`PluginRegistry` ready for the engine to consume.
7
+
8
+ Supply-chain defense
9
+ ====================
10
+
11
+ Just having ``coderouter-plugin-X`` installed is **not enough** to
12
+ activate a plugin — the user must list the entry-point name in
13
+ ``providers.yaml`` under ``plugins.enabled``. Without that explicit
14
+ opt-in, an installed-but-unlisted plugin is silently skipped (logged
15
+ as ``plugin-skipped``). This stops a compromised transitive
16
+ dependency from injecting itself into the request flow.
17
+
18
+ Failure mode: degraded continue
19
+ ===============================
20
+
21
+ Plugin loading is best-effort. A failure to import a module, find a
22
+ class, or call ``__init__`` is logged at error level and the engine
23
+ keeps booting without that plugin. The rationale: a misconfigured
24
+ optional plugin shouldn't take down the wire-level router that other
25
+ plugins (and the core) depend on. Operators see the failure in logs
26
+ and the ``/dashboard`` plugin panel.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import importlib.metadata as md
31
+ from typing import TYPE_CHECKING
32
+
33
+ from coderouter.logging import get_logger
34
+ from coderouter.plugins.registry import PluginRegistry
35
+
36
+ if TYPE_CHECKING:
37
+ from coderouter.config.schemas import CodeRouterConfig
38
+
39
+ logger = get_logger(__name__)
40
+
41
+ # Active hook groups in v2.3.0. The engine wires these into the
42
+ # request flow; plugins targeting them will see their methods called
43
+ # at runtime.
44
+ PLUGIN_GROUPS_V2_3: tuple[str, ...] = ("input_filter", "observer")
45
+
46
+ # Hook groups whose Protocol contracts are stable but whose engine
47
+ # integration is deferred. Listing them here means a plugin author
48
+ # can publish today and the loader will silently skip them with a
49
+ # clear log line — no surprise crashes when integration lands.
50
+ PLUGIN_GROUPS_FUTURE: tuple[str, ...] = (
51
+ "frontend",
52
+ "guard",
53
+ "output_filter",
54
+ "adapter",
55
+ )
56
+
57
+
58
+ def discover_and_load(config: CodeRouterConfig) -> PluginRegistry:
59
+ """Load plugins listed in ``config.plugins.enabled``.
60
+
61
+ Returns an empty registry when the ``plugins`` block is absent
62
+ from providers.yaml or its ``enabled`` list is empty. The empty
63
+ return is the same one ``PluginRegistry.empty()`` produces, so
64
+ the engine's hook loops short-circuit and incur zero cost in the
65
+ default (no-plugin) configuration.
66
+
67
+ Each enabled plugin is instantiated as
68
+ ``cls(**config.plugins.config.get(name, {}))``. The plugin's
69
+ ``__init__`` gets a fresh dict (validation happens inside the
70
+ plugin's own constructor — Core stays out of plugin-specific
71
+ schemas).
72
+
73
+ Logs emitted (one per outcome, all structured):
74
+
75
+ - ``plugin-loaded`` (info) — module + class loaded and constructed.
76
+ - ``plugin-skipped`` (info) — entry point exists but not enabled.
77
+ - ``plugin-load-failed`` (error) — import or ``__init__`` raised;
78
+ the engine continues without that plugin.
79
+ """
80
+ plugins_cfg = getattr(config, "plugins", None)
81
+ if plugins_cfg is None or not plugins_cfg.enabled:
82
+ return PluginRegistry.empty()
83
+
84
+ enabled = set(plugins_cfg.enabled)
85
+ registry = PluginRegistry()
86
+
87
+ # Track which enabled names actually matched an entry point so we
88
+ # can warn the operator about typos in ``plugins.enabled`` —
89
+ # otherwise a misspelled name silently does nothing and is hard
90
+ # to diagnose.
91
+ seen_names: set[str] = set()
92
+
93
+ for group in PLUGIN_GROUPS_V2_3 + PLUGIN_GROUPS_FUTURE:
94
+ ep_group = f"coderouter.{group}"
95
+ for ep in md.entry_points(group=ep_group):
96
+ if ep.name not in enabled:
97
+ # Installed but not enabled — silently skip. Logged
98
+ # at info level so operators have a paper trail of
99
+ # plugins they could enable.
100
+ logger.info(
101
+ "plugin-skipped",
102
+ extra={
103
+ "plugin": ep.name,
104
+ "group": group,
105
+ "reason": "not in plugins.enabled",
106
+ },
107
+ )
108
+ continue
109
+
110
+ seen_names.add(ep.name)
111
+
112
+ # Future-only groups: plugin is installed *and* enabled,
113
+ # but the engine doesn't yet wire this group into the
114
+ # request flow. Load it anyway so the plugin can sanity-
115
+ # check its own construction; just log a clear warning.
116
+ if group in PLUGIN_GROUPS_FUTURE:
117
+ logger.warning(
118
+ "plugin-group-not-yet-active",
119
+ extra={
120
+ "plugin": ep.name,
121
+ "group": group,
122
+ "note": (
123
+ f"engine integration for '{group}' "
124
+ "is deferred to v2.4+; the plugin will "
125
+ "be loaded but its hook is never called"
126
+ ),
127
+ },
128
+ )
129
+
130
+ try:
131
+ cls = ep.load()
132
+ cfg = plugins_cfg.config.get(ep.name, {}) or {}
133
+ instance = cls(**cfg)
134
+ registry.add(group, instance)
135
+ logger.info(
136
+ "plugin-loaded",
137
+ extra={
138
+ "plugin": ep.name,
139
+ "group": group,
140
+ "entry_point": ep.value,
141
+ },
142
+ )
143
+ except Exception as exc:
144
+ # Don't propagate — engine boot must succeed even when
145
+ # an optional plugin is broken.
146
+ logger.error(
147
+ "plugin-load-failed",
148
+ extra={
149
+ "plugin": ep.name,
150
+ "group": group,
151
+ "entry_point": ep.value,
152
+ "error_type": type(exc).__name__,
153
+ "error": str(exc),
154
+ },
155
+ )
156
+
157
+ # Warn on enabled names that didn't match any installed entry
158
+ # point — most often this means the user typed a name in
159
+ # providers.yaml but forgot to ``pip install`` the corresponding
160
+ # plugin package.
161
+ missing = enabled - seen_names
162
+ if missing:
163
+ for name in sorted(missing):
164
+ logger.warning(
165
+ "plugin-not-found",
166
+ extra={
167
+ "plugin": name,
168
+ "hint": (
169
+ "listed in plugins.enabled but no entry point "
170
+ "with this name was discovered. Did you forget "
171
+ "to install the plugin package?"
172
+ ),
173
+ },
174
+ )
175
+
176
+ return registry
@@ -0,0 +1,83 @@
1
+ """PluginRegistry — group-keyed container for loaded plugin instances.
2
+
3
+ The registry is constructed once during config load (by
4
+ :func:`coderouter.plugins.loader.discover_and_load`) and held by the
5
+ :class:`coderouter.routing.fallback.FallbackEngine` for the process
6
+ lifetime. Lookups are O(1) per group; insertion order is preserved so
7
+ that ``plugins.enabled`` order in providers.yaml controls the order
8
+ filters run.
9
+
10
+ Group keys are lowercase, the same names used in pyproject.toml
11
+ ``[project.entry-points."coderouter.<group>"]`` sections (e.g.
12
+ ``input_filter``, ``observer``).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from collections import defaultdict
17
+ from typing import Any
18
+
19
+
20
+ class PluginRegistry:
21
+ """In-memory registry of loaded plugin instances grouped by hook kind.
22
+
23
+ Use the typed ``input_filters`` / ``observers`` properties on the
24
+ hot path; plugin code should not iterate ``_by_group`` directly.
25
+ """
26
+
27
+ def __init__(self) -> None:
28
+ # ``defaultdict(list)`` keeps insertion order, which matters for
29
+ # InputFilter chaining (filters apply in registration order).
30
+ self._by_group: dict[str, list[Any]] = defaultdict(list)
31
+
32
+ @classmethod
33
+ def empty(cls) -> PluginRegistry:
34
+ """Convenience constructor for the no-plugin baseline.
35
+
36
+ ``FallbackEngine`` defaults to this when ``providers.yaml``
37
+ omits the ``plugins`` block, so all existing call sites
38
+ (``__init__``, tests that build engines via ``__new__``) keep
39
+ their zero-cost behavior.
40
+ """
41
+ return cls()
42
+
43
+ def add(self, group: str, instance: Any) -> None:
44
+ """Register an instance under a hook group.
45
+
46
+ The instance is appended to the group's list — duplicates are
47
+ not deduplicated here because the loader applies the
48
+ ``enabled`` allowlist upstream (no instance reaches this
49
+ method without an explicit enable).
50
+ """
51
+ self._by_group[group].append(instance)
52
+
53
+ @property
54
+ def input_filters(self) -> list[Any]:
55
+ """Plugins registered as ``coderouter.input_filter``.
56
+
57
+ Returns a copy so iteration during chain execution can't be
58
+ invalidated by a concurrent registration (registrations only
59
+ happen at startup, but defensive copying is cheap).
60
+ """
61
+ return list(self._by_group.get("input_filter", ()))
62
+
63
+ @property
64
+ def observers(self) -> list[Any]:
65
+ """Plugins registered as ``coderouter.observer``."""
66
+ return list(self._by_group.get("observer", ()))
67
+
68
+ def is_empty(self) -> bool:
69
+ """True iff no plugin instance has been registered, in any group.
70
+
71
+ ``FallbackEngine`` uses this to short-circuit the hook loops
72
+ entirely when no plugins are configured — keeping the no-
73
+ plugin code path bit-identical to v2.2.0 behavior.
74
+ """
75
+ return not any(self._by_group.values())
76
+
77
+ def count(self, group: str) -> int:
78
+ """Number of instances registered in ``group``. Used by tests + logs."""
79
+ return len(self._by_group.get(group, ()))
80
+
81
+ def groups(self) -> list[str]:
82
+ """All group names that have at least one registered instance."""
83
+ return [g for g, items in self._by_group.items() if items]
@@ -75,6 +75,7 @@ from coderouter.logging import (
75
75
  log_skip_memory_pressure,
76
76
  log_tool_loop_detected,
77
77
  )
78
+ from coderouter.plugins.registry import PluginRegistry
78
79
  from coderouter.routing.adaptive import AdaptiveAdjuster
79
80
  from coderouter.routing.budget import BudgetTracker
80
81
  from coderouter.routing.capability import (
@@ -803,7 +804,11 @@ class FallbackEngine:
803
804
  translation behavior.
804
805
  """
805
806
 
806
- def __init__(self, config: CodeRouterConfig) -> None:
807
+ def __init__(
808
+ self,
809
+ config: CodeRouterConfig,
810
+ plugins: PluginRegistry | None = None,
811
+ ) -> None:
807
812
  """Pre-build one adapter per configured provider.
808
813
 
809
814
  Adapters are stateless with respect to requests (all state is
@@ -816,8 +821,29 @@ class FallbackEngine:
816
821
  with ``adaptive: true`` actually fires. Adapter calls under
817
822
  non-adaptive profiles record nothing — zero observation
818
823
  overhead in the default configuration.
824
+
825
+ v2.3.0: an optional :class:`PluginRegistry` carries
826
+ InputFilter / Observer instances loaded by
827
+ :func:`coderouter.plugins.discover_and_load`. When omitted (or
828
+ empty), all hook loops in :meth:`generate_anthropic` /
829
+ :meth:`stream_anthropic` short-circuit and the request flow is
830
+ bit-identical to v2.2.0 — that's the zero-cost no-plugin path.
819
831
  """
820
832
  self.config = config
833
+ # v2.3.0: plugin registry. Default empty so legacy callers
834
+ # (engine constructed without going through the loader) keep
835
+ # working unchanged. Stored under ``_plugin_registry`` and
836
+ # surfaced via the ``plugins`` property — same lazy fallback
837
+ # pattern as ``_adaptive`` / ``_budget`` / ``_memory_pressure_guard``
838
+ # so tests that build the engine via ``FallbackEngine.__new__``
839
+ # see an empty registry instead of AttributeError.
840
+ self._plugin_registry: PluginRegistry = plugins or PluginRegistry.empty()
841
+ # v2.3.0: holds strong refs to in-flight Observer fanout tasks
842
+ # so the asyncio event loop's weak-ref bookkeeping doesn't GC
843
+ # them mid-flight (RUF006). Tasks remove themselves on done
844
+ # via ``add_done_callback(_observer_tasks.discard)`` in
845
+ # :meth:`_fanout_observers`.
846
+ self._observer_tasks: set[asyncio.Task[None]] = set()
821
847
  # Cache adapters so we don't re-instantiate per request
822
848
  self._adapters: dict[str, BaseAdapter] = {
823
849
  p.name: build_adapter(p) for p in config.providers
@@ -875,6 +901,23 @@ class FallbackEngine:
875
901
  # v2.0-K: persistent state store (None = in-memory only).
876
902
  self._state_store: StateStore | None = None
877
903
 
904
+ @property
905
+ def plugins(self) -> PluginRegistry:
906
+ """Return the plugin registry, lazily building an empty one if absent.
907
+
908
+ Same legacy-test compatibility pattern as :py:attr:`_adaptive` /
909
+ :py:attr:`_budget`. Tests that construct the engine via
910
+ ``FallbackEngine.__new__`` (bypassing ``__init__``) see an
911
+ empty registry here instead of AttributeError, so the hook
912
+ helpers ``_apply_input_filters`` / ``_fanout_observers``
913
+ short-circuit cleanly without any plugin work happening.
914
+ """
915
+ existing = getattr(self, "_plugin_registry", None)
916
+ if existing is None:
917
+ self._plugin_registry = PluginRegistry.empty()
918
+ existing = self._plugin_registry
919
+ return existing
920
+
878
921
  @property
879
922
  def last_drift_severity(self) -> str | None:
880
923
  """Return the severity string of the most recent drift verdict, or None.
@@ -1785,6 +1828,106 @@ class FallbackEngine:
1785
1828
  request, config=self.config, first_provider_config=first_provider_config,
1786
1829
  )
1787
1830
 
1831
+ # ====================================================================
1832
+ # v2.3.0: Plugin SDK hook helpers
1833
+ # ====================================================================
1834
+
1835
+ async def _apply_input_filters(
1836
+ self, request: AnthropicRequest
1837
+ ) -> AnthropicRequest:
1838
+ """Run the InputFilter chain over ``request``.
1839
+
1840
+ Filters apply in registration order (= the order the user
1841
+ listed them in ``plugins.enabled``). A filter that raises is
1842
+ logged at warn level and skipped — its predecessor's output
1843
+ flows through unchanged. The chain is short-circuited
1844
+ entirely when no plugin is registered (zero overhead).
1845
+
1846
+ Plugin contract: filters MUST return a new
1847
+ :class:`AnthropicRequest` (typically via ``model_copy``) and
1848
+ not mutate the input in place. The engine treats the return
1849
+ value as the new authoritative request and discards the
1850
+ previous one.
1851
+ """
1852
+ if self.plugins.is_empty():
1853
+ return request
1854
+ filters = self.plugins.input_filters
1855
+ if not filters:
1856
+ return request
1857
+ for filt in filters:
1858
+ try:
1859
+ request = await filt.transform(request)
1860
+ except Exception as exc: # pragma: no cover - defensive
1861
+ logger.warning(
1862
+ "input-filter-failed",
1863
+ extra={
1864
+ "plugin": getattr(filt, "name", filt.__class__.__name__),
1865
+ "error_type": type(exc).__name__,
1866
+ "error": str(exc)[:500],
1867
+ },
1868
+ )
1869
+ return request
1870
+
1871
+ def _fanout_observers(
1872
+ self,
1873
+ event_type: str,
1874
+ **payload: Any,
1875
+ ) -> None:
1876
+ """Fan out an Observer event as a fire-and-forget asyncio task.
1877
+
1878
+ The engine never awaits these tasks: a slow observer (e.g. a
1879
+ Langfuse uploader) cannot stretch the wire-level latency the
1880
+ client measures. Errors are caught inside
1881
+ :meth:`_safe_observe` and logged, never propagated.
1882
+
1883
+ ``payload`` is whatever the call site supplied; the receiving
1884
+ plugin's :meth:`Observer.on_event` is expected to tolerate
1885
+ unknown keys (forward-compat).
1886
+ """
1887
+ observers = self.plugins.observers
1888
+ if not observers:
1889
+ return
1890
+ # Lazy-init the task set for engines built via ``__new__`` —
1891
+ # mirrors the lazy ``plugins`` property pattern so legacy
1892
+ # tests that bypass __init__ don't crash here.
1893
+ if not hasattr(self, "_observer_tasks"):
1894
+ self._observer_tasks = set()
1895
+ for obs in observers:
1896
+ task = asyncio.create_task(
1897
+ self._safe_observe(obs, event_type, payload)
1898
+ )
1899
+ # Strong-ref keeps the task alive past the loop iteration;
1900
+ # ``discard`` cleans up after the task completes (success
1901
+ # or exception). Avoids the RUF006 footgun where
1902
+ # asyncio.create_task's weakref-only bookkeeping can let
1903
+ # the loop GC a fanout-in-progress task.
1904
+ self._observer_tasks.add(task)
1905
+ task.add_done_callback(self._observer_tasks.discard)
1906
+
1907
+ async def _safe_observe(
1908
+ self,
1909
+ obs: Any,
1910
+ event_type: str,
1911
+ payload: dict[str, Any],
1912
+ ) -> None:
1913
+ """Wrap a single Observer.on_event call in try/except.
1914
+
1915
+ Separated from the fanout loop so the test suite can call it
1916
+ directly with a synthetic observer + payload.
1917
+ """
1918
+ try:
1919
+ await obs.on_event(event_type, payload)
1920
+ except Exception as exc: # pragma: no cover - defensive
1921
+ logger.warning(
1922
+ "observer-failed",
1923
+ extra={
1924
+ "plugin": getattr(obs, "name", obs.__class__.__name__),
1925
+ "event": event_type,
1926
+ "error_type": type(exc).__name__,
1927
+ "error": str(exc)[:500],
1928
+ },
1929
+ )
1930
+
1788
1931
  async def generate_anthropic(self, request: AnthropicRequest) -> AnthropicResponse:
1789
1932
  """Non-streaming Anthropic request, per-provider dispatch."""
1790
1933
  # v1.9-E (L3): tool-loop guard runs before chain dispatch so the
@@ -1792,6 +1935,12 @@ class FallbackEngine:
1792
1935
  # naturally. `break` raises ToolLoopBreakError, which the
1793
1936
  # ingress converts to a 400 response.
1794
1937
  request = _apply_tool_loop_guard(request, config=self.config)
1938
+ # v2.3.0: input_filter plugin chain runs *after* the built-in
1939
+ # tool-loop guard but *before* chain resolution + context
1940
+ # budget guard. That order lets a filter (e.g. memory inject)
1941
+ # grow ``request.system`` without bypassing the budget cap —
1942
+ # the budget guard sees the post-filter payload.
1943
+ request = await self._apply_input_filters(request)
1795
1944
  chain = self._resolve_anthropic_chain(request)
1796
1945
 
1797
1946
  # v2.0-F (L1): context budget guard runs after chain resolution
@@ -1961,6 +2110,19 @@ class FallbackEngine:
1961
2110
  provider_config=adapter.config,
1962
2111
  budget=self._budget,
1963
2112
  )
2113
+ # v2.3.0: observer plugin fanout — fire-and-forget, never
2114
+ # blocks the engine response. Latency in ms uses the same
2115
+ # per-attempt clock the adaptive recorder used above so a
2116
+ # plugin's view of "this request took N ms" matches the
2117
+ # /metrics view.
2118
+ self._fanout_observers(
2119
+ "request_completed",
2120
+ request=request,
2121
+ response=resp,
2122
+ provider=adapter.name,
2123
+ latency_ms=(time.monotonic() - attempt_started) * 1000.0,
2124
+ stream=False,
2125
+ )
1964
2126
  return resp
1965
2127
 
1966
2128
  profile = request.profile or self.config.default_profile
@@ -1982,6 +2144,11 @@ class FallbackEngine:
1982
2144
  """
1983
2145
  # v1.9-E (L3): tool-loop guard mirrors the non-streaming path.
1984
2146
  request = _apply_tool_loop_guard(request, config=self.config)
2147
+ # v2.3.0: input_filter chain — same ordering rules as the
2148
+ # non-streaming path. Filters can grow ``request.system``
2149
+ # before chain resolution, and the budget guard below sees
2150
+ # the post-filter payload.
2151
+ request = await self._apply_input_filters(request)
1985
2152
  chain = self._resolve_anthropic_chain(request)
1986
2153
 
1987
2154
  # v2.0-F (L1): context budget guard mirrors the non-streaming path.
@@ -2169,6 +2336,19 @@ class FallbackEngine:
2169
2336
  provider_config=adapter.config,
2170
2337
  budget=self._budget,
2171
2338
  )
2339
+ # v2.3.0: streaming observer fanout fires once, after the
2340
+ # SSE terminates successfully. We hand the accumulator's
2341
+ # final view (text + usage + tool-use flag) so plugins can
2342
+ # treat it like the non-streaming response — they don't
2343
+ # need to re-aggregate SSE chunks themselves.
2344
+ self._fanout_observers(
2345
+ "request_completed",
2346
+ request=request,
2347
+ response=acc,
2348
+ provider=adapter.name,
2349
+ latency_ms=None, # streaming latency is end-of-stream-relative; left to plugin
2350
+ stream=True,
2351
+ )
2172
2352
  return
2173
2353
 
2174
2354
  profile = request.profile or self.config.default_profile
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coderouter-cli
3
- Version: 2.2.0
3
+ Version: 2.3.0a4
4
4
  Summary: Local-first, free-first, fallback-built-in LLM router. Claude Code / OpenAI compatible.
5
5
  Project-URL: Homepage, https://github.com/zephel01/CodeRouter
6
6
  Project-URL: Repository, https://github.com/zephel01/CodeRouter
@@ -19,7 +19,7 @@ coderouter/config/__init__.py,sha256=FODEn74fN-qZnt4INPSHswqhOlEgpL6-_onxsitSx8g
19
19
  coderouter/config/capability_registry.py,sha256=F6DetVL5oM03R4QeK1g6h_Q_zrXH0opnYDp3duZmkN4,15808
20
20
  coderouter/config/env_file.py,sha256=CoMK27fuAXm-NtoLzXb8yN2E-wDFjHQuFwiIlmgTBQw,10356
21
21
  coderouter/config/loader.py,sha256=FUEe8m4Tnmj_aul0vSctD8vKvNW-oLRoMRbTpSKqSmc,4077
22
- coderouter/config/schemas.py,sha256=lwTHUnaL4eqTIk1n22RBBhisVPoUVQxiPzEenEXQNFk,52143
22
+ coderouter/config/schemas.py,sha256=NuD107zOGiQTIg_8Ngu8i3Rcc5mIRv9QhvrlCEJ4rfU,54756
23
23
  coderouter/data/__init__.py,sha256=uNyfD9jaCvTWsBAWtaw1Fr25OSxzv3psGMfBjT1z0Cc,328
24
24
  coderouter/data/model-capabilities.yaml,sha256=2ztY4PUbGN_cWG-UUB-iPy-baeVFnGV8OcZHJUfZE7c,19290
25
25
  coderouter/guards/__init__.py,sha256=D_4bEHvLIp4deEKX9odTIoeUCdEcpWBAaO6noLTUTRk,1011
@@ -33,19 +33,23 @@ coderouter/guards/self_healing.py,sha256=_fT_EJvTTp5VSi-qAP93J_1LkgPK5jkzsyrUHdK
33
33
  coderouter/guards/tool_loop.py,sha256=EzeMcmU7BLeTW2jsRVevU81l5rhWcn1oUr7EpzgXjVM,15209
34
34
  coderouter/ingress/__init__.py,sha256=WQsCH2CGJCAhy0mS6GSEdeYZRkkQu2OHDsP4CJWTLug,155
35
35
  coderouter/ingress/anthropic_routes.py,sha256=It2f7XGe3fgKQX01J2F5JOCoZr96t_Tx_kY2om99MVo,16894
36
- coderouter/ingress/app.py,sha256=qX4koRVXXyy1aOdIY-fsR4nJNLTNwCnhG2ZnBICcW0A,11639
36
+ coderouter/ingress/app.py,sha256=FBgn9Vujn-xesmeUzQcaPhtdvAYfs9cIQSMBgs0V_XI,12110
37
37
  coderouter/ingress/dashboard_routes.py,sha256=jVFc_4Q67YrP0VIppuQfBLnLvnvaCWJJ4z7aZsHmu1s,21822
38
38
  coderouter/ingress/metrics_routes.py,sha256=M22dwOGn24P05Ge4W3c7d7mYytSGWjIR-pPSPOAiHJY,3965
39
39
  coderouter/ingress/openai_routes.py,sha256=Zw1efPw9DI6GgV8ZcLrzS6Cda0KLrFkKn2GBZWSe6Vo,6322
40
40
  coderouter/metrics/__init__.py,sha256=7Es351DPS7yLM0yVF_F0eesmiD83n7Zzhie44chht38,1465
41
41
  coderouter/metrics/collector.py,sha256=Q0_CY0orX8_i0EICBME5sYW2RqL2VD4SpNs8qfCnBM0,47432
42
42
  coderouter/metrics/prometheus.py,sha256=YRqyT931s40zVkIj07D-M2UNfDhIEElVFRz3izdJcnQ,24419
43
+ coderouter/plugins/__init__.py,sha256=76hMLe5dV_ilripHXzWn3HSYoIALjzlw4EJVyI-GyIM,1974
44
+ coderouter/plugins/base.py,sha256=n9hsck2NCSqi6oeHIumKC5zhQ8JGwCXUz7J5AZQCQss,5772
45
+ coderouter/plugins/loader.py,sha256=xAIf6bIuth0QXCzwxO_ja6aSUlLzIqZNbrbQNJDgSE8,6841
46
+ coderouter/plugins/registry.py,sha256=Tx0QHJHozZ5LTUliGylBdNVcdzHTBV0nedCUwGlbLMM,3236
43
47
  coderouter/routing/__init__.py,sha256=g2vhutbozRx5QBThReqwPN3imk5qXdpDiaogILd3IRc,257
44
48
  coderouter/routing/adaptive.py,sha256=G2o377twGSjbUh65wiIFx6klnpFGjsD_nI3oDvcBwhY,21257
45
49
  coderouter/routing/auto_router.py,sha256=4_sQR0ztSED9FgQSvQqgqSiydyQVY_qOSRvwyZ5BfRc,12909
46
50
  coderouter/routing/budget.py,sha256=A3_i44tmS3SrqVNnoGkLKMsiYwI_Ug6m5-3gitVoQSM,8452
47
51
  coderouter/routing/capability.py,sha256=ziIDuE5keH_jxYDlXSKufRVxxSYOAvUxJ6Rw5QkYDDU,18436
48
- coderouter/routing/fallback.py,sha256=MXyht62vew33JW603ia-XqPtDuSgSzYanaSkv0LqTDM,94752
52
+ coderouter/routing/fallback.py,sha256=j2clcgcr9Y5wQqJDwy8UhEMWRYJr1ZDV0bEvo_tgOfA,102930
49
53
  coderouter/state/__init__.py,sha256=mmINqwIyIu6-zt-Qo-7Ddxdxz_-5vzuPjLvjYW5T_bk,782
50
54
  coderouter/state/audit_log.py,sha256=JwGd0OkkDlkh0Fdc6SmnuyViwKzEaFA7Ux_VqHzakWE,8358
51
55
  coderouter/state/replay.py,sha256=Z_YHKroTKZdrL8qObFxcoLOAQWWXZvXFdLfxzvBhEJg,11230
@@ -55,8 +59,8 @@ coderouter/translation/__init__.py,sha256=PYXN7XVEwpG1uC8RLy6fvnGbzEZhhrEuUapH8I
55
59
  coderouter/translation/anthropic.py,sha256=JpvIWNXHUPVqOGvps7o_6ZADhXuJuvpU7RdMqQFtwwM,6421
56
60
  coderouter/translation/convert.py,sha256=-qyzFzmmr9hhQV6_Sg75kJnvCZvHe3n7vRdaZtk_JqQ,47269
57
61
  coderouter/translation/tool_repair.py,sha256=Ok2PF947Liegc5oaytfptv5MWMkpfJYQie-zdP1y3cY,9946
58
- coderouter_cli-2.2.0.dist-info/METADATA,sha256=VYLzUypwOhGmB0iZhFueTv_WobHZKCXBoWdSdLS8WVc,9991
59
- coderouter_cli-2.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
60
- coderouter_cli-2.2.0.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
61
- coderouter_cli-2.2.0.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
62
- coderouter_cli-2.2.0.dist-info/RECORD,,
62
+ coderouter_cli-2.3.0a4.dist-info/METADATA,sha256=6U8KQgMiL-g1TbAyu4SPEINFKroIPicJKsLTnm6fE4U,9993
63
+ coderouter_cli-2.3.0a4.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
64
+ coderouter_cli-2.3.0a4.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
65
+ coderouter_cli-2.3.0a4.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
66
+ coderouter_cli-2.3.0a4.dist-info/RECORD,,