coding-client 1.21.2__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.
- coding_client/__init__.py +1 -0
- coding_client/api/__init__.py +19 -0
- coding_client/api/exception_handlers.py +158 -0
- coding_client/api/logging_setup.py +44 -0
- coding_client/api/metrics.py +40 -0
- coding_client/api/openapi_config.py +124 -0
- coding_client/api/request_logging.py +60 -0
- coding_client/api/validation.py +150 -0
- coding_client/auth/__init__.py +78 -0
- coding_client/auth/_default_tenant.py +132 -0
- coding_client/auth/_header_trust.py +98 -0
- coding_client/auth/_onbehalf.py +315 -0
- coding_client/auth/_trusted_networks.py +94 -0
- coding_client/auth/dependencies.py +61 -0
- coding_client/auth/middleware.py +445 -0
- coding_client/auth/models.py +83 -0
- coding_client/auth/outbound.py +137 -0
- coding_client/auth/rbac.py +21 -0
- coding_client/auth/rbac_coding.py +5 -0
- coding_client/auth/tenant.py +650 -0
- coding_client/auth/websocket.py +148 -0
- coding_client/clients/__init__.py +24 -0
- coding_client/clients/base.py +131 -0
- coding_client/clients/coding_ai.py +33 -0
- coding_client/clients/coding_analysis.py +36 -0
- coding_client/clients/coding_containers.py +45 -0
- coding_client/clients/coding_github.py +127 -0
- coding_client/clients/coding_realtime.py +127 -0
- coding_client/clients/coding_security.py +51 -0
- coding_client/clients/coding_testing.py +31 -0
- coding_client/clients/coding_workspaces.py +265 -0
- coding_client/config/__init__.py +12 -0
- coding_client/config/base_settings.py +61 -0
- coding_client/config/yaml_config_loader.py +172 -0
- coding_client/db/__init__.py +42 -0
- coding_client/db/db_types.py +122 -0
- coding_client/db/exceptions.py +59 -0
- coding_client/db/postgres_connection.py +572 -0
- coding_client/db/tenant_query_helper.py +417 -0
- coding_client/db_init.py +49 -0
- coding_client/exceptions/__init__.py +67 -0
- coding_client/exceptions/api_exceptions.py +395 -0
- coding_client/exceptions/pydantic_ai_exceptions.py +533 -0
- coding_client/interfaces/__init__.py +22 -0
- coding_client/interfaces/cache_interface.py +179 -0
- coding_client/interfaces/container_interface.py +49 -0
- coding_client/interfaces/docker_interface.py +283 -0
- coding_client/interfaces/graph_interface.py +28 -0
- coding_client/interfaces/security_interface.py +72 -0
- coding_client/interfaces/webhook_interface.py +67 -0
- coding_client/llm/__init__.py +54 -0
- coding_client/llm/base_agent.py +195 -0
- coding_client/llm/context.py +121 -0
- coding_client/llm/responses.py +102 -0
- coding_client/llm/routing.py +82 -0
- coding_client/llm/settings.py +102 -0
- coding_client/models/__init__.py +62 -0
- coding_client/models/agent.py +38 -0
- coding_client/models/ai_test_failure.py +745 -0
- coding_client/models/analysis.py +330 -0
- coding_client/models/architecture.py +685 -0
- coding_client/models/bug.py +128 -0
- coding_client/models/code_graph.py +317 -0
- coding_client/models/container.py +241 -0
- coding_client/models/core.py +878 -0
- coding_client/models/docker_container.py +104 -0
- coding_client/models/github.py +293 -0
- coding_client/models/logging.py +29 -0
- coding_client/models/package_manager.py +257 -0
- coding_client/models/pr_validation.py +440 -0
- coding_client/models/pydantic_ai.py +395 -0
- coding_client/models/repo_scanner.py +249 -0
- coding_client/models/responses.py +431 -0
- coding_client/models/security.py +185 -0
- coding_client/models/static_analysis.py +508 -0
- coding_client/models/system.py +48 -0
- coding_client/models/test_executor.py +204 -0
- coding_client/models/testing.py +673 -0
- coding_client/models/testing_config.py +200 -0
- coding_client/models/validation.py +127 -0
- coding_client/models_init.py +272 -0
- coding_client/telemetry.py +195 -0
- coding_client/testing.py +180 -0
- coding_client/types/__init__.py +130 -0
- coding_client/types/constraint_types.py +276 -0
- coding_client/types/core_types.py +112 -0
- coding_client/types/state_machine.py +443 -0
- coding_client/utils/__init__.py +86 -0
- coding_client/utils/archive_utils.py +387 -0
- coding_client/utils/ast_debug.py +45 -0
- coding_client/utils/async_timeout.py +364 -0
- coding_client/utils/constants.py +518 -0
- coding_client/utils/content_processing.py +326 -0
- coding_client/utils/database.py +916 -0
- coding_client/utils/encoding_helper.py +222 -0
- coding_client/utils/enum_helpers.py +166 -0
- coding_client/utils/file_operations.py +177 -0
- coding_client/utils/filesystem_ops.py +528 -0
- coding_client/utils/http_client.py +562 -0
- coding_client/utils/path_utils.py +471 -0
- coding_client/utils/profiling.py +421 -0
- coding_client/utils/response_builder.py +240 -0
- coding_client/utils/retry_helper.py +712 -0
- coding_client/utils/security_validation.py +423 -0
- coding_client-1.21.2.dist-info/METADATA +234 -0
- coding_client-1.21.2.dist-info/RECORD +109 -0
- coding_client-1.21.2.dist-info/WHEEL +5 -0
- coding_client-1.21.2.dist-info/licenses/LICENSE +201 -0
- coding_client-1.21.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Shared LLM client for coding-* services.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
BaseLLMAgent: Abstract base class wrapping pydantic-ai + inference-router.
|
|
5
|
+
LLMSettings: pydantic-settings model for env vars.
|
|
6
|
+
llm_request_context: contextmanager attaching X-Tenant-Id / X-Job-Id /
|
|
7
|
+
X-License-Id / X-Schema-Id headers to LLM-plane HTTP requests.
|
|
8
|
+
last_inference_id / last_failover_chain_id: helpers to read the
|
|
9
|
+
inference-router metadata captured from the most recent _call_llm.
|
|
10
|
+
AgentRouting + AGENT_ROUTING_REGISTRY: routing-rule registry used by
|
|
11
|
+
scripts/register_routing_rules.py to bootstrap model-registry.
|
|
12
|
+
Response models:
|
|
13
|
+
TestFailureAnalysisResponse, CodeQualityAnalysisResponse,
|
|
14
|
+
PerformanceAnalysisResponse, plus their nested item types.
|
|
15
|
+
LLMEmptyResponseError: raised when the LLM plane returns nothing usable.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from coding_client.exceptions.pydantic_ai_exceptions import LLMEmptyResponseError
|
|
21
|
+
from coding_client.llm.base_agent import (
|
|
22
|
+
BaseLLMAgent,
|
|
23
|
+
close_llm_clients,
|
|
24
|
+
last_failover_chain_id,
|
|
25
|
+
last_inference_id,
|
|
26
|
+
)
|
|
27
|
+
from coding_client.llm.context import llm_request_context
|
|
28
|
+
from coding_client.llm.responses import (
|
|
29
|
+
Bottleneck,
|
|
30
|
+
CodeQualityAnalysisResponse,
|
|
31
|
+
PerformanceAnalysisResponse,
|
|
32
|
+
QualityIssue,
|
|
33
|
+
TestFailureAnalysisResponse,
|
|
34
|
+
)
|
|
35
|
+
from coding_client.llm.routing import AGENT_ROUTING_REGISTRY, AgentRouting
|
|
36
|
+
from coding_client.llm.settings import LLMSettings, get_llm_settings
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"AGENT_ROUTING_REGISTRY",
|
|
40
|
+
"AgentRouting",
|
|
41
|
+
"BaseLLMAgent",
|
|
42
|
+
"Bottleneck",
|
|
43
|
+
"CodeQualityAnalysisResponse",
|
|
44
|
+
"LLMEmptyResponseError",
|
|
45
|
+
"LLMSettings",
|
|
46
|
+
"PerformanceAnalysisResponse",
|
|
47
|
+
"QualityIssue",
|
|
48
|
+
"TestFailureAnalysisResponse",
|
|
49
|
+
"close_llm_clients",
|
|
50
|
+
"get_llm_settings",
|
|
51
|
+
"last_failover_chain_id",
|
|
52
|
+
"last_inference_id",
|
|
53
|
+
"llm_request_context",
|
|
54
|
+
]
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Base LLM agent for coding-* services.
|
|
2
|
+
|
|
3
|
+
Wraps pydantic-ai + an OpenAI-compatible endpoint (inference-router in
|
|
4
|
+
production). Subclasses set ``schema_id``, ``response_model``, and provide
|
|
5
|
+
a ``system_prompt`` (or override ``get_system_prompt``); they then call
|
|
6
|
+
``self._call_llm(prompt)`` to get a parsed response model.
|
|
7
|
+
|
|
8
|
+
The shared ``httpx.AsyncClient`` carries event hooks that:
|
|
9
|
+
1. Inject X-Tenant-Id / X-Job-Id / X-License-Id / X-Schema-Id headers from
|
|
10
|
+
``llm_request_context()``.
|
|
11
|
+
2. Capture X-Inference-Id / X-Failover-Chain-Id from the response into a
|
|
12
|
+
contextvar sink that ``_call_llm`` reads back after the run completes.
|
|
13
|
+
|
|
14
|
+
This module is import-safe: pydantic-ai / openai are imported lazily so
|
|
15
|
+
just importing the package doesn't pull heavy deps into every consumer.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from abc import ABC, abstractmethod
|
|
21
|
+
from contextvars import ContextVar
|
|
22
|
+
from typing import Any, ClassVar
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
from coding_client.exceptions.pydantic_ai_exceptions import LLMEmptyResponseError
|
|
26
|
+
from coding_client.llm.context import (
|
|
27
|
+
apply_request_headers,
|
|
28
|
+
capture_response_metadata,
|
|
29
|
+
install_response_sink,
|
|
30
|
+
reset_response_sink,
|
|
31
|
+
)
|
|
32
|
+
from coding_client.llm.settings import LLMSettings, get_llm_settings
|
|
33
|
+
from pydantic import BaseModel
|
|
34
|
+
|
|
35
|
+
# Module-level singleton, mirrors karma's pattern.
|
|
36
|
+
_llm_http_client: httpx.AsyncClient | None = None
|
|
37
|
+
_last_metadata: ContextVar[dict[str, str]] = ContextVar(
|
|
38
|
+
"coding_client_last_llm_metadata",
|
|
39
|
+
default={},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _get_llm_http_client(settings: LLMSettings) -> httpx.AsyncClient:
|
|
44
|
+
"""Get or create the shared httpx client used for all LLM-plane calls."""
|
|
45
|
+
global _llm_http_client
|
|
46
|
+
if _llm_http_client is None or _llm_http_client.is_closed:
|
|
47
|
+
_llm_http_client = httpx.AsyncClient(
|
|
48
|
+
timeout=settings.llm_api_timeout_seconds,
|
|
49
|
+
event_hooks={
|
|
50
|
+
"request": [apply_request_headers],
|
|
51
|
+
"response": [capture_response_metadata],
|
|
52
|
+
},
|
|
53
|
+
)
|
|
54
|
+
return _llm_http_client
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def close_llm_clients() -> None:
|
|
58
|
+
"""Close the shared httpx client. Call from FastAPI lifespan shutdown."""
|
|
59
|
+
global _llm_http_client
|
|
60
|
+
if _llm_http_client is not None and not _llm_http_client.is_closed:
|
|
61
|
+
await _llm_http_client.aclose()
|
|
62
|
+
_llm_http_client = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def last_inference_id() -> str | None:
|
|
66
|
+
"""Return X-Inference-Id captured during the most recent _call_llm, if any."""
|
|
67
|
+
return _last_metadata.get({}).get("inference_id")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def last_failover_chain_id() -> str | None:
|
|
71
|
+
"""Return X-Failover-Chain-Id captured during the most recent _call_llm, if any."""
|
|
72
|
+
return _last_metadata.get({}).get("failover_chain_id")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class BaseLLMAgent(ABC):
|
|
76
|
+
"""Abstract base class for coding-* LLM agents.
|
|
77
|
+
|
|
78
|
+
Subclasses set:
|
|
79
|
+
|
|
80
|
+
* ``schema_id``: stable identifier registered in model-registry.
|
|
81
|
+
* ``response_model``: pydantic model for structured output.
|
|
82
|
+
* ``system_prompt``: default system prompt (overridable per call).
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
schema_id: ClassVar[str] = ""
|
|
86
|
+
response_model: ClassVar[type[BaseModel] | None] = None
|
|
87
|
+
system_prompt: ClassVar[str] = ""
|
|
88
|
+
|
|
89
|
+
def __init__(self, settings: LLMSettings | None = None):
|
|
90
|
+
"""Initialize the agent.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
settings: Optional LLMSettings override; defaults to the singleton.
|
|
94
|
+
"""
|
|
95
|
+
self.settings = settings or get_llm_settings()
|
|
96
|
+
if not self.schema_id:
|
|
97
|
+
raise ValueError(f"{type(self).__name__} must set the class attribute 'schema_id'")
|
|
98
|
+
|
|
99
|
+
@abstractmethod
|
|
100
|
+
async def execute(self, **kwargs: Any) -> Any:
|
|
101
|
+
"""Execute the agent's main task. Subclasses implement."""
|
|
102
|
+
raise NotImplementedError
|
|
103
|
+
|
|
104
|
+
def get_system_prompt(self) -> str:
|
|
105
|
+
"""Return the system prompt for this call (overridable)."""
|
|
106
|
+
return self.system_prompt
|
|
107
|
+
|
|
108
|
+
async def _call_llm(
|
|
109
|
+
self,
|
|
110
|
+
prompt: str,
|
|
111
|
+
system_prompt: str | None = None,
|
|
112
|
+
response_model: type[BaseModel] | None = None,
|
|
113
|
+
) -> Any:
|
|
114
|
+
"""Call the configured LLM plane via pydantic-ai.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
prompt: User prompt to send.
|
|
118
|
+
system_prompt: Optional override; defaults to ``get_system_prompt()``.
|
|
119
|
+
response_model: Optional structured-output type; defaults to the
|
|
120
|
+
class-level ``response_model``.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Parsed pydantic instance when a response model is in play,
|
|
124
|
+
otherwise the raw stripped response text.
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
LLMEmptyResponseError: Underlying pydantic-ai retry budget
|
|
128
|
+
exhausted, or empty/whitespace text in unstructured mode.
|
|
129
|
+
httpx.HTTPError: Transport-level failure.
|
|
130
|
+
"""
|
|
131
|
+
# Lazy imports — keep coding-client importable without pydantic-ai.
|
|
132
|
+
from openai import AsyncOpenAI
|
|
133
|
+
from pydantic_ai import Agent
|
|
134
|
+
from pydantic_ai.exceptions import AgentRunError
|
|
135
|
+
from pydantic_ai.models.openai import OpenAIChatModel
|
|
136
|
+
from pydantic_ai.providers.openai import OpenAIProvider
|
|
137
|
+
|
|
138
|
+
if system_prompt is None:
|
|
139
|
+
system_prompt = self.get_system_prompt()
|
|
140
|
+
if response_model is None:
|
|
141
|
+
response_model = self.response_model
|
|
142
|
+
|
|
143
|
+
http_client = _get_llm_http_client(self.settings)
|
|
144
|
+
|
|
145
|
+
# Mutable sink — see context.py for the rationale.
|
|
146
|
+
sink: dict[str, str] = {}
|
|
147
|
+
sink_token = install_response_sink(sink)
|
|
148
|
+
|
|
149
|
+
openai_client = AsyncOpenAI(
|
|
150
|
+
base_url=self.settings.llm_api_base_url or None,
|
|
151
|
+
api_key=self.settings.llm_api_key or "unused",
|
|
152
|
+
http_client=http_client,
|
|
153
|
+
max_retries=0,
|
|
154
|
+
)
|
|
155
|
+
provider = OpenAIProvider(openai_client=openai_client)
|
|
156
|
+
model = OpenAIChatModel(self.schema_id, provider=provider)
|
|
157
|
+
|
|
158
|
+
output_type: Any = response_model if response_model is not None else str
|
|
159
|
+
pai_agent = Agent(
|
|
160
|
+
model,
|
|
161
|
+
system_prompt=system_prompt,
|
|
162
|
+
output_type=output_type,
|
|
163
|
+
retries=1,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
result = await pai_agent.run(prompt)
|
|
168
|
+
except AgentRunError as exc:
|
|
169
|
+
_last_metadata.set(dict(sink))
|
|
170
|
+
raise LLMEmptyResponseError(
|
|
171
|
+
f"LLM plane returned no usable output for {type(self).__name__}: {exc}",
|
|
172
|
+
agent_name=type(self).__name__,
|
|
173
|
+
cause=exc,
|
|
174
|
+
) from exc
|
|
175
|
+
finally:
|
|
176
|
+
reset_response_sink(sink_token)
|
|
177
|
+
|
|
178
|
+
_last_metadata.set(dict(sink))
|
|
179
|
+
|
|
180
|
+
if response_model is not None:
|
|
181
|
+
return result.output
|
|
182
|
+
|
|
183
|
+
output = result.output
|
|
184
|
+
if output is None:
|
|
185
|
+
raise LLMEmptyResponseError(
|
|
186
|
+
f"LLM plane returned empty output for {type(self).__name__}",
|
|
187
|
+
agent_name=type(self).__name__,
|
|
188
|
+
)
|
|
189
|
+
text = str(output).strip()
|
|
190
|
+
if not text:
|
|
191
|
+
raise LLMEmptyResponseError(
|
|
192
|
+
f"LLM plane returned whitespace-only output for {type(self).__name__}",
|
|
193
|
+
agent_name=type(self).__name__,
|
|
194
|
+
)
|
|
195
|
+
return text
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Per-request context plumbing for inference-router headers.
|
|
2
|
+
|
|
3
|
+
Uses contextvars so that nested asyncio tasks (pydantic-ai schedules child
|
|
4
|
+
tasks during a single Agent.run) inherit the routing identity automatically.
|
|
5
|
+
|
|
6
|
+
Critical: the response-side metadata sink uses a MUTABLE dict pattern. The
|
|
7
|
+
parent installs an empty dict via ``ContextVar.set(...)``; the response
|
|
8
|
+
event hook ``.update()``s that dict in place. This works across child task
|
|
9
|
+
boundaries because asyncio.copy_context shares the reference — whereas a
|
|
10
|
+
``.set()`` inside a child task would be invisible to the parent.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from contextlib import contextmanager
|
|
16
|
+
from contextvars import ContextVar
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Iterator
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_llm_request_context: ContextVar[dict[str, str]] = ContextVar(
|
|
26
|
+
"coding_client_llm_request_context",
|
|
27
|
+
default={},
|
|
28
|
+
)
|
|
29
|
+
_llm_response_metadata: ContextVar[dict[str, str] | None] = ContextVar(
|
|
30
|
+
"coding_client_llm_response_metadata",
|
|
31
|
+
default=None,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@contextmanager
|
|
36
|
+
def llm_request_context(
|
|
37
|
+
*,
|
|
38
|
+
tenant_id: str | None = None,
|
|
39
|
+
job_id: str | None = None,
|
|
40
|
+
license_id: str | None = None,
|
|
41
|
+
schema_id: str | None = None,
|
|
42
|
+
) -> Iterator[None]:
|
|
43
|
+
"""Attach request-scoped routing headers for downstream LLM calls.
|
|
44
|
+
|
|
45
|
+
Usage::
|
|
46
|
+
|
|
47
|
+
with llm_request_context(tenant_id="t1", job_id="j2"):
|
|
48
|
+
await agent._call_llm(...)
|
|
49
|
+
"""
|
|
50
|
+
current = dict(_llm_request_context.get({}))
|
|
51
|
+
updates = {
|
|
52
|
+
key: value
|
|
53
|
+
for key, value in {
|
|
54
|
+
"tenant_id": tenant_id,
|
|
55
|
+
"job_id": job_id,
|
|
56
|
+
"license_id": license_id,
|
|
57
|
+
"schema_id": schema_id,
|
|
58
|
+
}.items()
|
|
59
|
+
if value
|
|
60
|
+
}
|
|
61
|
+
token = _llm_request_context.set({**current, **updates})
|
|
62
|
+
try:
|
|
63
|
+
yield
|
|
64
|
+
finally:
|
|
65
|
+
_llm_request_context.reset(token)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _current_request_headers() -> dict[str, str]:
|
|
69
|
+
"""Build inference-router headers from the current contextvar state."""
|
|
70
|
+
ctx = _llm_request_context.get({})
|
|
71
|
+
headers: dict[str, str] = {}
|
|
72
|
+
if value := ctx.get("tenant_id"):
|
|
73
|
+
headers["X-Tenant-Id"] = value
|
|
74
|
+
if value := ctx.get("job_id"):
|
|
75
|
+
headers["X-Job-Id"] = value
|
|
76
|
+
if value := ctx.get("license_id"):
|
|
77
|
+
headers["X-License-Id"] = value
|
|
78
|
+
if value := ctx.get("schema_id"):
|
|
79
|
+
headers["X-Schema-Id"] = value
|
|
80
|
+
return headers
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def apply_request_headers(request: httpx.Request) -> None:
|
|
84
|
+
"""httpx request event hook: inject routing headers just before send."""
|
|
85
|
+
for header, value in _current_request_headers().items():
|
|
86
|
+
request.headers[header] = value
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def capture_response_metadata(response: httpx.Response) -> None:
|
|
90
|
+
"""httpx response event hook: write inference-router metadata to sink."""
|
|
91
|
+
sink = _llm_response_metadata.get()
|
|
92
|
+
if sink is None:
|
|
93
|
+
return
|
|
94
|
+
headers = response.headers
|
|
95
|
+
for key, candidates in (
|
|
96
|
+
("inference_id", ("X-Inference-Id", "inference-id")),
|
|
97
|
+
("failover_chain_id", ("X-Failover-Chain-Id", "failover-chain-id")),
|
|
98
|
+
):
|
|
99
|
+
for candidate in candidates:
|
|
100
|
+
value = headers.get(candidate)
|
|
101
|
+
if value:
|
|
102
|
+
sink[key] = value
|
|
103
|
+
break
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def install_response_sink(sink: dict[str, str]):
|
|
107
|
+
"""Install a metadata sink for the duration of one LLM call.
|
|
108
|
+
|
|
109
|
+
Returns the contextvar token; the caller must reset it in a finally.
|
|
110
|
+
"""
|
|
111
|
+
return _llm_response_metadata.set(sink)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def reset_response_sink(token) -> None:
|
|
115
|
+
"""Reset the response sink contextvar to its prior state."""
|
|
116
|
+
_llm_response_metadata.reset(token)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def current_response_sink() -> dict[str, str] | None:
|
|
120
|
+
"""Return the currently installed response sink, if any (test helper)."""
|
|
121
|
+
return _llm_response_metadata.get()
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Pydantic v2 response models for coding-* LLM agents.
|
|
2
|
+
|
|
3
|
+
All models use ``extra="forbid"`` so pydantic-ai surfaces schema mismatches
|
|
4
|
+
cleanly to inference-router (which then triggers fallback to the next
|
|
5
|
+
provider in the chain).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Test failure analysis (used by coding-testing).
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TestFailureAnalysisResponse(BaseModel):
|
|
20
|
+
"""Structured analysis of a single failed test."""
|
|
21
|
+
|
|
22
|
+
__test__ = False # Pytest: don't try to collect — class name starts with "Test".
|
|
23
|
+
|
|
24
|
+
model_config = ConfigDict(extra="forbid")
|
|
25
|
+
|
|
26
|
+
root_cause: str = Field(..., description="Concise root-cause explanation.")
|
|
27
|
+
suggested_fix: str = Field(..., description="Concrete, actionable fix.")
|
|
28
|
+
confidence: float = Field(
|
|
29
|
+
...,
|
|
30
|
+
ge=0.0,
|
|
31
|
+
le=1.0,
|
|
32
|
+
description="Model confidence in the diagnosis (0..1).",
|
|
33
|
+
)
|
|
34
|
+
category: Literal[
|
|
35
|
+
"assertion",
|
|
36
|
+
"timeout",
|
|
37
|
+
"import",
|
|
38
|
+
"syntax",
|
|
39
|
+
"environment",
|
|
40
|
+
"flaky",
|
|
41
|
+
"other",
|
|
42
|
+
] = Field(..., description="Failure category for routing/aggregation.")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Code quality analysis (used by coding-ai).
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class QualityIssue(BaseModel):
|
|
51
|
+
"""One code-quality finding."""
|
|
52
|
+
|
|
53
|
+
model_config = ConfigDict(extra="forbid")
|
|
54
|
+
|
|
55
|
+
severity: Literal["info", "warning", "error", "critical"]
|
|
56
|
+
category: str = Field(..., description="Issue category, e.g. 'maintainability'.")
|
|
57
|
+
description: str = Field(..., description="Human-readable issue description.")
|
|
58
|
+
suggested_refactor: str | None = Field(
|
|
59
|
+
default=None,
|
|
60
|
+
description="Optional suggested refactor, when actionable.",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class CodeQualityAnalysisResponse(BaseModel):
|
|
65
|
+
"""Structured code-quality analysis output."""
|
|
66
|
+
|
|
67
|
+
model_config = ConfigDict(extra="forbid")
|
|
68
|
+
|
|
69
|
+
issues: list[QualityIssue] = Field(
|
|
70
|
+
default_factory=list,
|
|
71
|
+
description="List of identified quality issues (may be empty).",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Performance analysis (used by coding-containers / coding-ai).
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Bottleneck(BaseModel):
|
|
81
|
+
"""One performance bottleneck."""
|
|
82
|
+
|
|
83
|
+
model_config = ConfigDict(extra="forbid")
|
|
84
|
+
|
|
85
|
+
location: str = Field(..., description="File path / function / span identifier.")
|
|
86
|
+
impact: Literal["low", "medium", "high"]
|
|
87
|
+
description: str = Field(..., description="Why this is a bottleneck.")
|
|
88
|
+
suggested_optimisation: str = Field(
|
|
89
|
+
...,
|
|
90
|
+
description="Concrete optimisation suggestion.",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class PerformanceAnalysisResponse(BaseModel):
|
|
95
|
+
"""Structured performance-analysis output."""
|
|
96
|
+
|
|
97
|
+
model_config = ConfigDict(extra="forbid")
|
|
98
|
+
|
|
99
|
+
bottlenecks: list[Bottleneck] = Field(
|
|
100
|
+
default_factory=list,
|
|
101
|
+
description="Identified bottlenecks (may be empty).",
|
|
102
|
+
)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Routing-rule registry for coding-* LLM agents.
|
|
2
|
+
|
|
3
|
+
Each entry maps a stable ``schema_id`` to its pydantic response model and
|
|
4
|
+
optional fallback chain override. ``scripts/register_routing_rules.py``
|
|
5
|
+
iterates this registry on deploy and PUTs each rule into model-registry so
|
|
6
|
+
that inference-router can route + fall back per schema_id.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from coding_client.llm.responses import (
|
|
15
|
+
CodeQualityAnalysisResponse,
|
|
16
|
+
PerformanceAnalysisResponse,
|
|
17
|
+
TestFailureAnalysisResponse,
|
|
18
|
+
)
|
|
19
|
+
from pydantic import BaseModel
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class AgentRouting:
|
|
24
|
+
"""Routing config for a single coding-* LLM agent."""
|
|
25
|
+
|
|
26
|
+
schema_id: str
|
|
27
|
+
response_model: type[BaseModel]
|
|
28
|
+
fallback_chain: list[str] | None = field(default=None)
|
|
29
|
+
system_prompt: str = field(default="")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
AGENT_ROUTING_REGISTRY: dict[str, AgentRouting] = {
|
|
33
|
+
"test_failure_analysis": AgentRouting(
|
|
34
|
+
schema_id="test_failure_analysis",
|
|
35
|
+
response_model=TestFailureAnalysisResponse,
|
|
36
|
+
system_prompt=(
|
|
37
|
+
"You are a senior test engineer. Diagnose the failed test and "
|
|
38
|
+
"return a structured root cause, fix, confidence, and category."
|
|
39
|
+
),
|
|
40
|
+
),
|
|
41
|
+
"code_quality_analysis": AgentRouting(
|
|
42
|
+
schema_id="code_quality_analysis",
|
|
43
|
+
response_model=CodeQualityAnalysisResponse,
|
|
44
|
+
system_prompt=(
|
|
45
|
+
"You are a senior code reviewer. Identify quality issues with "
|
|
46
|
+
"severity, category, description, and an optional refactor."
|
|
47
|
+
),
|
|
48
|
+
),
|
|
49
|
+
"performance_analysis": AgentRouting(
|
|
50
|
+
schema_id="performance_analysis",
|
|
51
|
+
response_model=PerformanceAnalysisResponse,
|
|
52
|
+
system_prompt=(
|
|
53
|
+
"You are a performance engineer. Identify bottlenecks and "
|
|
54
|
+
"propose concrete optimisations."
|
|
55
|
+
),
|
|
56
|
+
),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_routing_rule_payload(
|
|
61
|
+
routing: AgentRouting,
|
|
62
|
+
*,
|
|
63
|
+
default_fallback_chain: list[str],
|
|
64
|
+
) -> dict[str, Any]:
|
|
65
|
+
"""Build the model-registry PUT body for one routing rule.
|
|
66
|
+
|
|
67
|
+
Mirrors ``UpdateRoutingRequest`` in model-registry. Endpoint is
|
|
68
|
+
``PUT /api/routing/{schema_id}`` — see the bootstrap script.
|
|
69
|
+
"""
|
|
70
|
+
chain = list(routing.fallback_chain or default_fallback_chain)
|
|
71
|
+
if not chain:
|
|
72
|
+
raise ValueError(f"AgentRouting {routing.schema_id} resolved to an empty fallback chain")
|
|
73
|
+
return {
|
|
74
|
+
"fallback_chain": chain,
|
|
75
|
+
"json_schema": routing.response_model.model_json_schema(),
|
|
76
|
+
"system_prompt": routing.system_prompt,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build_routing_rule_url(base_url: str, schema_id: str) -> str:
|
|
81
|
+
"""Build the model-registry endpoint URL for one routing rule."""
|
|
82
|
+
return f"{base_url.rstrip('/')}/api/routing/{schema_id}"
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""LLM client settings.
|
|
2
|
+
|
|
3
|
+
All env vars are read via pydantic-settings so callers can subclass or
|
|
4
|
+
override per-service. Production deployments inject these via the
|
|
5
|
+
the platform repo Deployment env block / SOPS secrets.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from pydantic import Field, field_validator
|
|
13
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LLMSettings(BaseSettings):
|
|
17
|
+
"""Settings for LLM-plane access.
|
|
18
|
+
|
|
19
|
+
Production points ``llm_api_base_url`` at inference-router; dev/bypass
|
|
20
|
+
can point it at OpenAI, Ollama, LM Studio, or any other OpenAI-compatible
|
|
21
|
+
endpoint.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
model_config = SettingsConfigDict(
|
|
25
|
+
env_file=None,
|
|
26
|
+
case_sensitive=False,
|
|
27
|
+
extra="ignore",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
llm_api_base_url: str = Field(
|
|
31
|
+
default="",
|
|
32
|
+
description=(
|
|
33
|
+
"OpenAI-compatible base URL. In production: the inference-router "
|
|
34
|
+
"service URL. Required when llm_mode=='inference_router' and the "
|
|
35
|
+
"service is actually wired up."
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
llm_api_key: str = Field(
|
|
39
|
+
default="",
|
|
40
|
+
description=(
|
|
41
|
+
"API key for the OpenAI-compatible endpoint. Inference-router "
|
|
42
|
+
"doesn't consume this; pydantic-ai/openai SDK requires non-empty."
|
|
43
|
+
),
|
|
44
|
+
)
|
|
45
|
+
llm_mode: Literal["inference_router", "direct"] = Field(
|
|
46
|
+
default="inference_router",
|
|
47
|
+
description="Plane mode: inference-router (provider chain) or direct.",
|
|
48
|
+
)
|
|
49
|
+
llm_api_timeout_seconds: float = Field(
|
|
50
|
+
default=300.0,
|
|
51
|
+
description="httpx timeout for LLM-plane calls (seconds).",
|
|
52
|
+
)
|
|
53
|
+
llm_routing_default_fallback_chain: str = Field(
|
|
54
|
+
# Must name inference-router PROVIDERS, not models. The historical
|
|
55
|
+
# "gemma,deepseek,claude" default named zero usable pairs: `gemma` is a
|
|
56
|
+
# MODEL served by the `pgx-local` provider and is not a provider name in
|
|
57
|
+
# the router catalog at all, and the `claude` provider ships
|
|
58
|
+
# `enabled: false`. Both were therefore dropped at resolution and the
|
|
59
|
+
# chain collapsed to the single hop ["deepseek"] — a "fallback chain"
|
|
60
|
+
# with no fallback. `pgx-local,deepseek` mirrors inference-router's live
|
|
61
|
+
# `default_chain: [pgx-local, deepseek]` (see the platform repo
|
|
62
|
+
# clusters/strato/services/inference-router/providers-configmap.yaml):
|
|
63
|
+
# free local vLLM primary, paid DeepSeek failover while pgx is DEGRADED.
|
|
64
|
+
default="pgx-local,deepseek",
|
|
65
|
+
description="Comma-separated default fallback chain for routing rules.",
|
|
66
|
+
)
|
|
67
|
+
model_registry_url: str = Field(
|
|
68
|
+
default="",
|
|
69
|
+
description=(
|
|
70
|
+
"Base URL for model-registry — used by routing-rule bootstrap. "
|
|
71
|
+
"Production injects via env var MODEL_REGISTRY_URL on the Deployment "
|
|
72
|
+
"(see the platform configuration policy)."
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
@field_validator("llm_routing_default_fallback_chain")
|
|
77
|
+
@classmethod
|
|
78
|
+
def _strip_csv(cls, value: str) -> str:
|
|
79
|
+
return value.strip()
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def fallback_chain_list(self) -> list[str]:
|
|
83
|
+
"""Parse the CSV fallback chain into a list."""
|
|
84
|
+
raw = self.llm_routing_default_fallback_chain
|
|
85
|
+
return [item.strip() for item in raw.split(",") if item.strip()]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
_settings: LLMSettings | None = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_llm_settings() -> LLMSettings:
|
|
92
|
+
"""Get LLMSettings singleton."""
|
|
93
|
+
global _settings
|
|
94
|
+
if _settings is None:
|
|
95
|
+
_settings = LLMSettings()
|
|
96
|
+
return _settings
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def reset_llm_settings() -> None:
|
|
100
|
+
"""Reset the cached settings singleton (test helper)."""
|
|
101
|
+
global _settings
|
|
102
|
+
_settings = None
|