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.
Files changed (109) hide show
  1. coding_client/__init__.py +1 -0
  2. coding_client/api/__init__.py +19 -0
  3. coding_client/api/exception_handlers.py +158 -0
  4. coding_client/api/logging_setup.py +44 -0
  5. coding_client/api/metrics.py +40 -0
  6. coding_client/api/openapi_config.py +124 -0
  7. coding_client/api/request_logging.py +60 -0
  8. coding_client/api/validation.py +150 -0
  9. coding_client/auth/__init__.py +78 -0
  10. coding_client/auth/_default_tenant.py +132 -0
  11. coding_client/auth/_header_trust.py +98 -0
  12. coding_client/auth/_onbehalf.py +315 -0
  13. coding_client/auth/_trusted_networks.py +94 -0
  14. coding_client/auth/dependencies.py +61 -0
  15. coding_client/auth/middleware.py +445 -0
  16. coding_client/auth/models.py +83 -0
  17. coding_client/auth/outbound.py +137 -0
  18. coding_client/auth/rbac.py +21 -0
  19. coding_client/auth/rbac_coding.py +5 -0
  20. coding_client/auth/tenant.py +650 -0
  21. coding_client/auth/websocket.py +148 -0
  22. coding_client/clients/__init__.py +24 -0
  23. coding_client/clients/base.py +131 -0
  24. coding_client/clients/coding_ai.py +33 -0
  25. coding_client/clients/coding_analysis.py +36 -0
  26. coding_client/clients/coding_containers.py +45 -0
  27. coding_client/clients/coding_github.py +127 -0
  28. coding_client/clients/coding_realtime.py +127 -0
  29. coding_client/clients/coding_security.py +51 -0
  30. coding_client/clients/coding_testing.py +31 -0
  31. coding_client/clients/coding_workspaces.py +265 -0
  32. coding_client/config/__init__.py +12 -0
  33. coding_client/config/base_settings.py +61 -0
  34. coding_client/config/yaml_config_loader.py +172 -0
  35. coding_client/db/__init__.py +42 -0
  36. coding_client/db/db_types.py +122 -0
  37. coding_client/db/exceptions.py +59 -0
  38. coding_client/db/postgres_connection.py +572 -0
  39. coding_client/db/tenant_query_helper.py +417 -0
  40. coding_client/db_init.py +49 -0
  41. coding_client/exceptions/__init__.py +67 -0
  42. coding_client/exceptions/api_exceptions.py +395 -0
  43. coding_client/exceptions/pydantic_ai_exceptions.py +533 -0
  44. coding_client/interfaces/__init__.py +22 -0
  45. coding_client/interfaces/cache_interface.py +179 -0
  46. coding_client/interfaces/container_interface.py +49 -0
  47. coding_client/interfaces/docker_interface.py +283 -0
  48. coding_client/interfaces/graph_interface.py +28 -0
  49. coding_client/interfaces/security_interface.py +72 -0
  50. coding_client/interfaces/webhook_interface.py +67 -0
  51. coding_client/llm/__init__.py +54 -0
  52. coding_client/llm/base_agent.py +195 -0
  53. coding_client/llm/context.py +121 -0
  54. coding_client/llm/responses.py +102 -0
  55. coding_client/llm/routing.py +82 -0
  56. coding_client/llm/settings.py +102 -0
  57. coding_client/models/__init__.py +62 -0
  58. coding_client/models/agent.py +38 -0
  59. coding_client/models/ai_test_failure.py +745 -0
  60. coding_client/models/analysis.py +330 -0
  61. coding_client/models/architecture.py +685 -0
  62. coding_client/models/bug.py +128 -0
  63. coding_client/models/code_graph.py +317 -0
  64. coding_client/models/container.py +241 -0
  65. coding_client/models/core.py +878 -0
  66. coding_client/models/docker_container.py +104 -0
  67. coding_client/models/github.py +293 -0
  68. coding_client/models/logging.py +29 -0
  69. coding_client/models/package_manager.py +257 -0
  70. coding_client/models/pr_validation.py +440 -0
  71. coding_client/models/pydantic_ai.py +395 -0
  72. coding_client/models/repo_scanner.py +249 -0
  73. coding_client/models/responses.py +431 -0
  74. coding_client/models/security.py +185 -0
  75. coding_client/models/static_analysis.py +508 -0
  76. coding_client/models/system.py +48 -0
  77. coding_client/models/test_executor.py +204 -0
  78. coding_client/models/testing.py +673 -0
  79. coding_client/models/testing_config.py +200 -0
  80. coding_client/models/validation.py +127 -0
  81. coding_client/models_init.py +272 -0
  82. coding_client/telemetry.py +195 -0
  83. coding_client/testing.py +180 -0
  84. coding_client/types/__init__.py +130 -0
  85. coding_client/types/constraint_types.py +276 -0
  86. coding_client/types/core_types.py +112 -0
  87. coding_client/types/state_machine.py +443 -0
  88. coding_client/utils/__init__.py +86 -0
  89. coding_client/utils/archive_utils.py +387 -0
  90. coding_client/utils/ast_debug.py +45 -0
  91. coding_client/utils/async_timeout.py +364 -0
  92. coding_client/utils/constants.py +518 -0
  93. coding_client/utils/content_processing.py +326 -0
  94. coding_client/utils/database.py +916 -0
  95. coding_client/utils/encoding_helper.py +222 -0
  96. coding_client/utils/enum_helpers.py +166 -0
  97. coding_client/utils/file_operations.py +177 -0
  98. coding_client/utils/filesystem_ops.py +528 -0
  99. coding_client/utils/http_client.py +562 -0
  100. coding_client/utils/path_utils.py +471 -0
  101. coding_client/utils/profiling.py +421 -0
  102. coding_client/utils/response_builder.py +240 -0
  103. coding_client/utils/retry_helper.py +712 -0
  104. coding_client/utils/security_validation.py +423 -0
  105. coding_client-1.21.2.dist-info/METADATA +234 -0
  106. coding_client-1.21.2.dist-info/RECORD +109 -0
  107. coding_client-1.21.2.dist-info/WHEEL +5 -0
  108. coding_client-1.21.2.dist-info/licenses/LICENSE +201 -0
  109. 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