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.
- coderouter/config/schemas.py +61 -1
- coderouter/ingress/app.py +8 -1
- coderouter/plugins/__init__.py +56 -0
- coderouter/plugins/base.py +168 -0
- coderouter/plugins/loader.py +176 -0
- coderouter/plugins/registry.py +83 -0
- coderouter/routing/fallback.py +181 -1
- {coderouter_cli-2.2.0.dist-info → coderouter_cli-2.3.0a4.dist-info}/METADATA +1 -1
- {coderouter_cli-2.2.0.dist-info → coderouter_cli-2.3.0a4.dist-info}/RECORD +12 -8
- {coderouter_cli-2.2.0.dist-info → coderouter_cli-2.3.0a4.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.2.0.dist-info → coderouter_cli-2.3.0a4.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.2.0.dist-info → coderouter_cli-2.3.0a4.dist-info}/licenses/LICENSE +0 -0
coderouter/config/schemas.py
CHANGED
|
@@ -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
|
-
|
|
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]
|
coderouter/routing/fallback.py
CHANGED
|
@@ -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__(
|
|
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.
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
59
|
-
coderouter_cli-2.
|
|
60
|
-
coderouter_cli-2.
|
|
61
|
-
coderouter_cli-2.
|
|
62
|
-
coderouter_cli-2.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|