devcopilot 0.2.0__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 (189) hide show
  1. api/__init__.py +17 -0
  2. api/admin_config.py +1303 -0
  3. api/admin_routes.py +287 -0
  4. api/admin_static/admin.css +459 -0
  5. api/admin_static/admin.js +497 -0
  6. api/admin_static/index.html +77 -0
  7. api/admin_urls.py +34 -0
  8. api/app.py +194 -0
  9. api/command_utils.py +164 -0
  10. api/dependencies.py +144 -0
  11. api/detection.py +152 -0
  12. api/gateway_model_ids.py +54 -0
  13. api/model_catalog.py +133 -0
  14. api/model_router.py +125 -0
  15. api/models/__init__.py +45 -0
  16. api/models/anthropic.py +234 -0
  17. api/models/openai_responses.py +28 -0
  18. api/models/responses.py +60 -0
  19. api/optimization_handlers.py +154 -0
  20. api/request_pipeline.py +424 -0
  21. api/routes.py +156 -0
  22. api/runtime.py +334 -0
  23. api/validation_log.py +48 -0
  24. api/web_server_tools.py +22 -0
  25. api/web_tools/__init__.py +17 -0
  26. api/web_tools/constants.py +15 -0
  27. api/web_tools/egress.py +99 -0
  28. api/web_tools/outbound.py +278 -0
  29. api/web_tools/parsers.py +104 -0
  30. api/web_tools/request.py +87 -0
  31. api/web_tools/streaming.py +206 -0
  32. cli/__init__.py +5 -0
  33. cli/claude_env.py +12 -0
  34. cli/entrypoints.py +166 -0
  35. cli/env.example +209 -0
  36. cli/launchers/__init__.py +1 -0
  37. cli/launchers/claude.py +84 -0
  38. cli/launchers/codex.py +204 -0
  39. cli/launchers/codex_model_catalog.py +186 -0
  40. cli/launchers/common.py +93 -0
  41. cli/managed/__init__.py +6 -0
  42. cli/managed/claude.py +215 -0
  43. cli/managed/manager.py +157 -0
  44. cli/managed/session.py +260 -0
  45. cli/process_registry.py +78 -0
  46. config/__init__.py +5 -0
  47. config/constants.py +13 -0
  48. config/logging_config.py +159 -0
  49. config/nim.py +118 -0
  50. config/paths.py +91 -0
  51. config/provider_catalog.py +259 -0
  52. config/provider_ids.py +7 -0
  53. config/settings.py +538 -0
  54. core/__init__.py +1 -0
  55. core/anthropic/__init__.py +46 -0
  56. core/anthropic/content.py +31 -0
  57. core/anthropic/conversion.py +587 -0
  58. core/anthropic/emitted_sse_tracker.py +346 -0
  59. core/anthropic/errors.py +70 -0
  60. core/anthropic/native_messages_request.py +280 -0
  61. core/anthropic/native_sse_block_policy.py +313 -0
  62. core/anthropic/provider_stream_error.py +34 -0
  63. core/anthropic/server_tool_sse.py +14 -0
  64. core/anthropic/sse.py +440 -0
  65. core/anthropic/stream_contracts.py +205 -0
  66. core/anthropic/stream_recovery.py +346 -0
  67. core/anthropic/stream_recovery_session.py +133 -0
  68. core/anthropic/thinking.py +140 -0
  69. core/anthropic/tokens.py +117 -0
  70. core/anthropic/tools.py +212 -0
  71. core/anthropic/utils.py +9 -0
  72. core/openai_responses/__init__.py +5 -0
  73. core/openai_responses/adapter.py +31 -0
  74. core/openai_responses/anthropic_sse.py +59 -0
  75. core/openai_responses/errors.py +22 -0
  76. core/openai_responses/events.py +19 -0
  77. core/openai_responses/ids.py +21 -0
  78. core/openai_responses/input.py +258 -0
  79. core/openai_responses/items.py +37 -0
  80. core/openai_responses/reasoning.py +52 -0
  81. core/openai_responses/stream.py +25 -0
  82. core/openai_responses/stream_state.py +654 -0
  83. core/openai_responses/tools.py +374 -0
  84. core/openai_responses/usage.py +37 -0
  85. core/rate_limit.py +60 -0
  86. core/trace.py +216 -0
  87. devcopilot-0.2.0.dist-info/METADATA +687 -0
  88. devcopilot-0.2.0.dist-info/RECORD +189 -0
  89. devcopilot-0.2.0.dist-info/WHEEL +4 -0
  90. devcopilot-0.2.0.dist-info/entry_points.txt +6 -0
  91. devcopilot-0.2.0.dist-info/licenses/LICENSE +21 -0
  92. messaging/__init__.py +26 -0
  93. messaging/cli_event_constants.py +67 -0
  94. messaging/command_context.py +66 -0
  95. messaging/command_dispatcher.py +37 -0
  96. messaging/commands.py +275 -0
  97. messaging/event_parser.py +181 -0
  98. messaging/limiter.py +300 -0
  99. messaging/models.py +36 -0
  100. messaging/node_event_pipeline.py +127 -0
  101. messaging/node_runner.py +342 -0
  102. messaging/platforms/__init__.py +15 -0
  103. messaging/platforms/base.py +228 -0
  104. messaging/platforms/discord.py +567 -0
  105. messaging/platforms/factory.py +103 -0
  106. messaging/platforms/outbox.py +144 -0
  107. messaging/platforms/telegram.py +688 -0
  108. messaging/platforms/voice_flow.py +295 -0
  109. messaging/rendering/__init__.py +3 -0
  110. messaging/rendering/discord_markdown.py +318 -0
  111. messaging/rendering/markdown_tables.py +49 -0
  112. messaging/rendering/profiles.py +55 -0
  113. messaging/rendering/telegram_markdown.py +327 -0
  114. messaging/safe_diagnostics.py +17 -0
  115. messaging/session.py +334 -0
  116. messaging/transcript.py +581 -0
  117. messaging/transcription.py +164 -0
  118. messaging/trees/__init__.py +15 -0
  119. messaging/trees/data.py +482 -0
  120. messaging/trees/manager.py +433 -0
  121. messaging/trees/processor.py +179 -0
  122. messaging/trees/repository.py +177 -0
  123. messaging/turn_intake.py +235 -0
  124. messaging/ui_updates.py +101 -0
  125. messaging/voice.py +76 -0
  126. messaging/workflow.py +200 -0
  127. providers/__init__.py +31 -0
  128. providers/base.py +152 -0
  129. providers/cerebras/__init__.py +7 -0
  130. providers/cerebras/client.py +31 -0
  131. providers/cerebras/request.py +55 -0
  132. providers/codestral/__init__.py +7 -0
  133. providers/codestral/client.py +34 -0
  134. providers/deepseek/__init__.py +11 -0
  135. providers/deepseek/client.py +51 -0
  136. providers/deepseek/request.py +475 -0
  137. providers/defaults.py +41 -0
  138. providers/error_mapping.py +309 -0
  139. providers/exceptions.py +113 -0
  140. providers/fireworks/__init__.py +5 -0
  141. providers/fireworks/client.py +45 -0
  142. providers/fireworks/request.py +48 -0
  143. providers/gemini/__init__.py +7 -0
  144. providers/gemini/client.py +49 -0
  145. providers/gemini/request.py +199 -0
  146. providers/groq/__init__.py +7 -0
  147. providers/groq/client.py +31 -0
  148. providers/groq/request.py +83 -0
  149. providers/kimi/__init__.py +10 -0
  150. providers/kimi/client.py +53 -0
  151. providers/kimi/request.py +42 -0
  152. providers/llamacpp/__init__.py +3 -0
  153. providers/llamacpp/client.py +16 -0
  154. providers/lmstudio/__init__.py +5 -0
  155. providers/lmstudio/client.py +16 -0
  156. providers/mistral/__init__.py +7 -0
  157. providers/mistral/client.py +31 -0
  158. providers/mistral/request.py +37 -0
  159. providers/model_listing.py +133 -0
  160. providers/nvidia_nim/__init__.py +7 -0
  161. providers/nvidia_nim/client.py +91 -0
  162. providers/nvidia_nim/request.py +430 -0
  163. providers/nvidia_nim/voice.py +95 -0
  164. providers/ollama/__init__.py +7 -0
  165. providers/ollama/client.py +39 -0
  166. providers/open_router/__init__.py +7 -0
  167. providers/open_router/client.py +124 -0
  168. providers/open_router/request.py +42 -0
  169. providers/opencode/__init__.py +11 -0
  170. providers/opencode/client.py +31 -0
  171. providers/opencode/request.py +35 -0
  172. providers/rate_limit.py +300 -0
  173. providers/registry.py +527 -0
  174. providers/transports/__init__.py +1 -0
  175. providers/transports/anthropic_messages/__init__.py +5 -0
  176. providers/transports/anthropic_messages/http.py +118 -0
  177. providers/transports/anthropic_messages/recovery.py +206 -0
  178. providers/transports/anthropic_messages/stream.py +295 -0
  179. providers/transports/anthropic_messages/transport.py +236 -0
  180. providers/transports/openai_chat/__init__.py +5 -0
  181. providers/transports/openai_chat/recovery.py +217 -0
  182. providers/transports/openai_chat/stream.py +384 -0
  183. providers/transports/openai_chat/tool_calls.py +293 -0
  184. providers/transports/openai_chat/transport.py +156 -0
  185. providers/wafer/__init__.py +10 -0
  186. providers/wafer/client.py +50 -0
  187. providers/zai/__init__.py +10 -0
  188. providers/zai/client.py +46 -0
  189. providers/zai/request.py +42 -0
api/app.py ADDED
@@ -0,0 +1,194 @@
1
+ """FastAPI application factory and configuration."""
2
+
3
+ import os
4
+ import traceback
5
+ from contextlib import asynccontextmanager
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from fastapi import FastAPI, Request
10
+ from fastapi.exception_handlers import request_validation_exception_handler
11
+ from fastapi.exceptions import RequestValidationError
12
+ from fastapi.responses import JSONResponse
13
+ from loguru import logger
14
+ from starlette.types import Receive, Scope, Send
15
+
16
+ from config.logging_config import configure_logging
17
+ from config.paths import server_log_path
18
+ from config.settings import get_settings
19
+ from core.trace import extract_claude_session_id_from_headers, trace_event
20
+ from providers.exceptions import ProviderError
21
+
22
+ from .admin_routes import router as admin_router
23
+ from .routes import router
24
+ from .runtime import AppRuntime, startup_failure_message
25
+ from .validation_log import summarize_request_validation_body
26
+
27
+
28
+ @asynccontextmanager
29
+ async def lifespan(app: FastAPI):
30
+ """Application lifespan manager."""
31
+ runtime = AppRuntime.for_app(app, settings=get_settings())
32
+ await runtime.startup()
33
+
34
+ yield
35
+
36
+ await runtime.shutdown()
37
+
38
+
39
+ class GracefulLifespanApp:
40
+ """ASGI wrapper that reports startup failures without Starlette tracebacks."""
41
+
42
+ def __init__(self, app: FastAPI):
43
+ self.app = app
44
+
45
+ def __getattr__(self, name: str) -> Any:
46
+ return getattr(self.app, name)
47
+
48
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
49
+ if scope["type"] != "lifespan":
50
+ await self.app(scope, receive, send)
51
+ return
52
+ await self._lifespan(receive, send)
53
+
54
+ async def _lifespan(self, receive: Receive, send: Send) -> None:
55
+ settings = get_settings()
56
+ runtime = AppRuntime.for_app(self.app, settings=settings)
57
+ startup_complete = False
58
+ while True:
59
+ message = await receive()
60
+ if message["type"] == "lifespan.startup":
61
+ try:
62
+ await runtime.startup()
63
+ except Exception as exc:
64
+ await send(
65
+ {
66
+ "type": "lifespan.startup.failed",
67
+ "message": startup_failure_message(settings, exc),
68
+ }
69
+ )
70
+ return
71
+ startup_complete = True
72
+ await send({"type": "lifespan.startup.complete"})
73
+ continue
74
+
75
+ if message["type"] == "lifespan.shutdown":
76
+ if startup_complete:
77
+ try:
78
+ await runtime.shutdown()
79
+ except Exception as exc:
80
+ logger.error("Shutdown failed: exc_type={}", type(exc).__name__)
81
+ await send({"type": "lifespan.shutdown.failed", "message": ""})
82
+ return
83
+ await send({"type": "lifespan.shutdown.complete"})
84
+ return
85
+
86
+
87
+ def create_app(*, lifespan_enabled: bool = True) -> FastAPI:
88
+ """Create and configure the FastAPI application."""
89
+ settings = get_settings()
90
+ log_path = Path(os.getenv("LOG_FILE", server_log_path()))
91
+ configure_logging(log_path, verbose_third_party=settings.log_raw_api_payloads)
92
+
93
+ app_kwargs: dict[str, Any] = {
94
+ "title": "Claude Code Proxy",
95
+ "version": "2.1.0",
96
+ }
97
+ if lifespan_enabled:
98
+ app_kwargs["lifespan"] = lifespan
99
+ app = FastAPI(**app_kwargs)
100
+
101
+ @app.middleware("http")
102
+ async def trace_http_correlation(request: Request, call_next):
103
+ """Attach HTTP identifiers and optional Claude session id to logs."""
104
+ claude_sid = extract_claude_session_id_from_headers(request.headers)
105
+ with logger.contextualize(
106
+ http_method=request.method,
107
+ http_path=request.url.path,
108
+ claude_session_id=claude_sid,
109
+ ):
110
+ response = await call_next(request)
111
+ return response
112
+
113
+ # Register routes
114
+ app.include_router(admin_router)
115
+ app.include_router(router)
116
+
117
+ # Exception handlers
118
+ @app.exception_handler(RequestValidationError)
119
+ async def validation_error_handler(request: Request, exc: RequestValidationError):
120
+ """Log request shape for 422 debugging without content values."""
121
+ body: Any
122
+ try:
123
+ body = await request.json()
124
+ except Exception as e:
125
+ body = {"_json_error": type(e).__name__}
126
+
127
+ message_summary, tool_names = summarize_request_validation_body(body)
128
+
129
+ trace_event(
130
+ stage="ingress",
131
+ event="server.request.validation_failed",
132
+ source="api",
133
+ path=request.url.path,
134
+ query=dict(request.query_params),
135
+ error_locs=[list(error.get("loc", ())) for error in exc.errors()],
136
+ error_types=[str(error.get("type", "")) for error in exc.errors()],
137
+ message_summary=message_summary,
138
+ tool_names=tool_names,
139
+ )
140
+ return await request_validation_exception_handler(request, exc)
141
+
142
+ @app.exception_handler(ProviderError)
143
+ async def provider_error_handler(request: Request, exc: ProviderError):
144
+ """Handle provider-specific errors and return Anthropic format."""
145
+ err_settings = get_settings()
146
+ if err_settings.log_api_error_tracebacks:
147
+ logger.error(
148
+ "Provider Error: error_type={} status_code={} message={}",
149
+ exc.error_type,
150
+ exc.status_code,
151
+ exc.message,
152
+ )
153
+ else:
154
+ logger.error(
155
+ "Provider Error: error_type={} status_code={}",
156
+ exc.error_type,
157
+ exc.status_code,
158
+ )
159
+ return JSONResponse(
160
+ status_code=exc.status_code,
161
+ content=exc.to_anthropic_format(),
162
+ )
163
+
164
+ @app.exception_handler(Exception)
165
+ async def general_error_handler(request: Request, exc: Exception):
166
+ """Handle general errors and return Anthropic format."""
167
+ settings = get_settings()
168
+ if settings.log_api_error_tracebacks:
169
+ logger.error("General Error: {}", exc)
170
+ logger.error(traceback.format_exc())
171
+ else:
172
+ logger.error(
173
+ "General Error: path={} method={} exc_type={}",
174
+ request.url.path,
175
+ request.method,
176
+ type(exc).__name__,
177
+ )
178
+ return JSONResponse(
179
+ status_code=500,
180
+ content={
181
+ "type": "error",
182
+ "error": {
183
+ "type": "api_error",
184
+ "message": "An unexpected error occurred.",
185
+ },
186
+ },
187
+ )
188
+
189
+ return app
190
+
191
+
192
+ def create_asgi_app() -> GracefulLifespanApp:
193
+ """Create the server ASGI app with graceful lifespan failure reporting."""
194
+ return GracefulLifespanApp(create_app(lifespan_enabled=False))
api/command_utils.py ADDED
@@ -0,0 +1,164 @@
1
+ """Command parsing utilities for API optimizations."""
2
+
3
+ import re
4
+ import shlex
5
+
6
+ _ENV_ASSIGNMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=.*$")
7
+
8
+
9
+ def _is_env_assignment(part: str) -> bool:
10
+ """Return True when a token is a shell-style env assignment."""
11
+ return bool(_ENV_ASSIGNMENT_RE.match(part))
12
+
13
+
14
+ def _strip_env_assignments(parts: list[str]) -> list[str]:
15
+ """Return command parts after leading shell-style env assignments."""
16
+ cmd_start = 0
17
+ for i, part in enumerate(parts):
18
+ if _is_env_assignment(part):
19
+ cmd_start = i + 1
20
+ else:
21
+ break
22
+ return parts[cmd_start:]
23
+
24
+
25
+ def extract_command_prefix(command: str) -> str:
26
+ """Extract the command prefix for fast prefix detection.
27
+
28
+ Parses a shell command safely, handling environment variables and
29
+ command injection attempts. Returns the command prefix suitable
30
+ for quick identification.
31
+
32
+ Returns:
33
+ Command prefix (e.g., "git", "git commit", "npm install")
34
+ or "none" if no valid command found
35
+ """
36
+ if "`" in command or "$(" in command:
37
+ return "command_injection_detected"
38
+
39
+ try:
40
+ parts = shlex.split(command, posix=False)
41
+ if not parts:
42
+ return "none"
43
+
44
+ env_prefix = []
45
+ cmd_start = 0
46
+ for i, part in enumerate(parts):
47
+ if _is_env_assignment(part):
48
+ env_prefix.append(part)
49
+ cmd_start = i + 1
50
+ else:
51
+ break
52
+
53
+ if cmd_start >= len(parts):
54
+ return "none"
55
+
56
+ cmd_parts = parts[cmd_start:]
57
+ if not cmd_parts:
58
+ return "none"
59
+
60
+ first_word = cmd_parts[0]
61
+ two_word_commands = {
62
+ "git",
63
+ "npm",
64
+ "docker",
65
+ "kubectl",
66
+ "cargo",
67
+ "go",
68
+ "pip",
69
+ "yarn",
70
+ }
71
+
72
+ if first_word in two_word_commands and len(cmd_parts) > 1:
73
+ second_word = cmd_parts[1]
74
+ if not second_word.startswith("-"):
75
+ return f"{first_word} {second_word}"
76
+ return first_word
77
+ return first_word if not env_prefix else " ".join(env_prefix) + " " + first_word
78
+
79
+ except ValueError:
80
+ parts = command.split()
81
+ if not parts:
82
+ return "none"
83
+ cmd_parts = _strip_env_assignments(parts)
84
+ return cmd_parts[0] if cmd_parts else "none"
85
+
86
+
87
+ def extract_filepaths_from_command(command: str, output: str) -> str:
88
+ """Extract file paths from a command locally without API call.
89
+
90
+ Determines if the command reads file contents and extracts paths accordingly.
91
+ Commands like ls/dir/find just list files, so return empty.
92
+ Commands like cat/head/tail actually read contents, so extract the file path.
93
+
94
+ Returns:
95
+ Filepath extraction result in <filepaths> format
96
+ """
97
+ listing_commands = {
98
+ "ls",
99
+ "dir",
100
+ "find",
101
+ "tree",
102
+ "pwd",
103
+ "cd",
104
+ "mkdir",
105
+ "rmdir",
106
+ "rm",
107
+ }
108
+
109
+ reading_commands = {"cat", "head", "tail", "less", "more", "bat", "type"}
110
+
111
+ try:
112
+ parts = shlex.split(command, posix=False)
113
+ if not parts:
114
+ return "<filepaths>\n</filepaths>"
115
+
116
+ cmd_parts = _strip_env_assignments(parts)
117
+ if not cmd_parts:
118
+ return "<filepaths>\n</filepaths>"
119
+
120
+ base_cmd = cmd_parts[0].split("/")[-1].split("\\")[-1].lower()
121
+
122
+ if base_cmd in listing_commands:
123
+ return "<filepaths>\n</filepaths>"
124
+
125
+ if base_cmd in reading_commands:
126
+ filepaths = []
127
+ for part in cmd_parts[1:]:
128
+ if part.startswith("-"):
129
+ continue
130
+ filepaths.append(part)
131
+
132
+ if filepaths:
133
+ paths_str = "\n".join(filepaths)
134
+ return f"<filepaths>\n{paths_str}\n</filepaths>"
135
+ return "<filepaths>\n</filepaths>"
136
+
137
+ if base_cmd == "grep":
138
+ flags_with_args = {"-e", "-f", "-m", "-A", "-B", "-C"}
139
+ pattern_provided_via_flag = False
140
+ positional = []
141
+
142
+ skip_next = False
143
+ for part in cmd_parts[1:]:
144
+ if skip_next:
145
+ skip_next = False
146
+ continue
147
+ if part.startswith("-"):
148
+ if part in flags_with_args:
149
+ if part in {"-e", "-f"}:
150
+ pattern_provided_via_flag = True
151
+ skip_next = True
152
+ continue
153
+ positional.append(part)
154
+
155
+ filepaths = positional if pattern_provided_via_flag else positional[1:]
156
+ if filepaths:
157
+ paths_str = "\n".join(filepaths)
158
+ return f"<filepaths>\n{paths_str}\n</filepaths>"
159
+ return "<filepaths>\n</filepaths>"
160
+
161
+ return "<filepaths>\n</filepaths>"
162
+
163
+ except ValueError:
164
+ return "<filepaths>\n</filepaths>"
api/dependencies.py ADDED
@@ -0,0 +1,144 @@
1
+ """Dependency injection for FastAPI."""
2
+
3
+ import secrets
4
+
5
+ from fastapi import Depends, HTTPException, Request
6
+ from loguru import logger
7
+ from starlette.applications import Starlette
8
+
9
+ from config.settings import Settings
10
+ from config.settings import get_settings as _get_settings
11
+ from core.anthropic import get_user_facing_error_message
12
+ from providers.base import BaseProvider
13
+ from providers.exceptions import (
14
+ AuthenticationError,
15
+ ServiceUnavailableError,
16
+ UnknownProviderTypeError,
17
+ )
18
+ from providers.registry import PROVIDER_DESCRIPTORS, ProviderRegistry
19
+
20
+ # Process-level cache: only for :func:`get_provider_for_type` / :func:`get_provider`
21
+ # when there is no ``Request``/``app`` (unit tests, scripts). HTTP handlers must pass
22
+ # ``app`` to :func:`resolve_provider` so the app-scoped registry is used.
23
+ _providers: dict[str, BaseProvider] = {}
24
+
25
+
26
+ def get_settings() -> Settings:
27
+ """Return cached :class:`~config.settings.Settings` (FastAPI-friendly alias)."""
28
+ return _get_settings()
29
+
30
+
31
+ def resolve_provider(
32
+ provider_type: str,
33
+ *,
34
+ app: Starlette | None,
35
+ settings: Settings,
36
+ ) -> BaseProvider:
37
+ """Resolve a provider using the app-scoped registry when ``app`` is set.
38
+
39
+ When ``app`` is not ``None``, the app-owned :attr:`app.state.provider_registry`
40
+ must exist (installed by :class:`~api.runtime.AppRuntime` during startup).
41
+ Callers that construct a bare ``FastAPI`` without lifespan must set
42
+ ``app.state.provider_registry`` explicitly.
43
+
44
+ When ``app`` is ``None`` (no HTTP context), uses the process-level
45
+ :data:`_providers` cache only.
46
+ """
47
+ if app is not None:
48
+ reg = getattr(app.state, "provider_registry", None)
49
+ if reg is None:
50
+ raise ServiceUnavailableError(
51
+ "Provider registry is not configured. Ensure AppRuntime startup ran "
52
+ "or assign app.state.provider_registry for test apps."
53
+ )
54
+ return _resolve_with_registry(reg, provider_type, settings)
55
+ return _resolve_with_registry(ProviderRegistry(_providers), provider_type, settings)
56
+
57
+
58
+ def _resolve_with_registry(
59
+ registry: ProviderRegistry, provider_type: str, settings: Settings
60
+ ) -> BaseProvider:
61
+ should_log_init = not registry.is_cached(provider_type)
62
+ try:
63
+ provider = registry.get(provider_type, settings)
64
+ except AuthenticationError as e:
65
+ # Provider :class:`~providers.exceptions.AuthenticationError` messages are
66
+ # curated configuration hints (env var names, docs links), not upstream noise.
67
+ detail = str(e).strip() or get_user_facing_error_message(e)
68
+ raise HTTPException(status_code=503, detail=detail) from e
69
+ except UnknownProviderTypeError:
70
+ logger.error(
71
+ "Unknown provider_type: '{}'. Supported: {}",
72
+ provider_type,
73
+ ", ".join(f"'{key}'" for key in PROVIDER_DESCRIPTORS),
74
+ )
75
+ raise
76
+ if should_log_init:
77
+ logger.info("Provider initialized: {}", provider_type)
78
+ return provider
79
+
80
+
81
+ def get_provider_for_type(provider_type: str) -> BaseProvider:
82
+ """Get or create a provider in the process-level cache (no ``app``/Request).
83
+
84
+ HTTP route handlers should call :func:`resolve_provider` with the active
85
+ :attr:`request.app` (via :class:`~api.runtime.AppRuntime`) instead of this
86
+ process-wide cache.
87
+ """
88
+ return resolve_provider(provider_type, app=None, settings=get_settings())
89
+
90
+
91
+ def require_api_key(
92
+ request: Request, settings: Settings = Depends(get_settings)
93
+ ) -> None:
94
+ """Require a server API key (Anthropic-style).
95
+
96
+ Checks `x-api-key` header or `Authorization: Bearer ...` against
97
+ `Settings.anthropic_auth_token`. If `ANTHROPIC_AUTH_TOKEN` is empty, this is a no-op.
98
+ """
99
+ anthropic_auth_token = settings.anthropic_auth_token.strip()
100
+ if not anthropic_auth_token:
101
+ # No API key configured -> allow
102
+ return
103
+
104
+ header = (
105
+ request.headers.get("x-api-key")
106
+ or request.headers.get("authorization")
107
+ or request.headers.get("anthropic-auth-token")
108
+ )
109
+ if not header:
110
+ raise HTTPException(status_code=401, detail="Missing API key")
111
+
112
+ # Support both raw key in X-API-Key and Bearer token in Authorization
113
+ token = header.strip()
114
+ if header.lower().startswith("bearer "):
115
+ token = header.split(" ", 1)[1].strip()
116
+
117
+ # Strip anything after the first colon to handle tokens with appended model names
118
+ if token and ":" in token:
119
+ token = token.split(":", 1)[0].strip()
120
+
121
+ # Constant-time comparison to avoid leaking the configured token via
122
+ # response-time differences on a per-byte mismatch (CWE-208).
123
+ if not secrets.compare_digest(
124
+ token.encode("utf-8"), anthropic_auth_token.encode("utf-8")
125
+ ):
126
+ raise HTTPException(status_code=401, detail="Invalid API key")
127
+
128
+
129
+ def get_provider() -> BaseProvider:
130
+ """Get or create the default provider (``MODEL`` / ``provider_type``).
131
+
132
+ Process-cache helper for scripts, unit tests, and non-FastAPI callers. HTTP
133
+ handlers must use :func:`resolve_provider` with :attr:`request.app` so the
134
+ app-scoped :class:`~providers.registry.ProviderRegistry` is used.
135
+ """
136
+ return get_provider_for_type(get_settings().provider_type)
137
+
138
+
139
+ async def cleanup_provider():
140
+ """Cleanup all provider resources."""
141
+ global _providers
142
+ await ProviderRegistry(_providers).cleanup()
143
+ _providers = {}
144
+ logger.debug("Provider cleanup completed")
api/detection.py ADDED
@@ -0,0 +1,152 @@
1
+ """Request detection utilities for API optimizations.
2
+
3
+ Detects quota checks, title generation, prefix detection, safety classifier,
4
+ suggestion mode, and filepath extraction requests to enable targeted handling.
5
+ """
6
+
7
+ from core.anthropic import extract_text_from_content
8
+
9
+ from .models.anthropic import MessagesRequest
10
+
11
+
12
+ def is_quota_check_request(request_data: MessagesRequest) -> bool:
13
+ """Check if this is a quota probe request.
14
+
15
+ Quota checks are typically simple requests with max_tokens=1
16
+ and a single message containing the word "quota".
17
+ """
18
+ if (
19
+ request_data.max_tokens == 1
20
+ and len(request_data.messages) == 1
21
+ and request_data.messages[0].role == "user"
22
+ ):
23
+ text = extract_text_from_content(request_data.messages[0].content)
24
+ if "quota" in text.lower():
25
+ return True
26
+ return False
27
+
28
+
29
+ def is_title_generation_request(request_data: MessagesRequest) -> bool:
30
+ """Check if this is a conversation title generation request.
31
+
32
+ Title generation requests are detected by a system prompt containing
33
+ title extraction instructions, no tools, and a single user message.
34
+
35
+ Matches Claude Code session title prompts (sentence-case title, JSON
36
+ \"title\" field, etc.).
37
+ """
38
+ if not request_data.system or request_data.tools:
39
+ return False
40
+ system_text = extract_text_from_content(request_data.system).lower()
41
+ if "title" not in system_text:
42
+ return False
43
+ return "sentence-case title" in system_text or (
44
+ "return json" in system_text
45
+ and "field" in system_text
46
+ and ("coding session" in system_text or "this session" in system_text)
47
+ )
48
+
49
+
50
+ def is_prefix_detection_request(request_data: MessagesRequest) -> tuple[bool, str]:
51
+ """Check if this is a fast prefix detection request.
52
+
53
+ Prefix detection requests contain a policy_spec block and
54
+ a Command: section for extracting shell command prefixes.
55
+
56
+ Returns:
57
+ Tuple of (is_prefix_request, command_string)
58
+ """
59
+ if len(request_data.messages) != 1 or request_data.messages[0].role != "user":
60
+ return False, ""
61
+
62
+ content = extract_text_from_content(request_data.messages[0].content)
63
+
64
+ if "<policy_spec>" in content and "Command:" in content:
65
+ try:
66
+ cmd_start = content.rfind("Command:") + len("Command:")
67
+ return True, content[cmd_start:].strip()
68
+ except TypeError:
69
+ return False, ""
70
+
71
+ return False, ""
72
+
73
+
74
+ def is_safety_classifier_request(request_data: MessagesRequest) -> bool:
75
+ """Return whether this is Claude Code's auto-mode safety classifier prompt."""
76
+ if request_data.tools:
77
+ return False
78
+
79
+ system_text = (
80
+ extract_text_from_content(request_data.system) if request_data.system else ""
81
+ )
82
+ messages_text = "".join(
83
+ extract_text_from_content(message.content) for message in request_data.messages
84
+ )
85
+ combined = f"{system_text}\n{messages_text}"
86
+ has_verdict_instruction = "yes</block>" in combined or "no</block>" in combined
87
+ return "<transcript>" in combined and has_verdict_instruction
88
+
89
+
90
+ def is_suggestion_mode_request(request_data: MessagesRequest) -> bool:
91
+ """Check if this is a suggestion mode request.
92
+
93
+ Suggestion mode requests contain "[SUGGESTION MODE:" in the user's message,
94
+ used for auto-suggesting what the user might type next.
95
+ """
96
+ for msg in request_data.messages:
97
+ if msg.role == "user":
98
+ text = extract_text_from_content(msg.content)
99
+ if "[SUGGESTION MODE:" in text:
100
+ return True
101
+ return False
102
+
103
+
104
+ def is_filepath_extraction_request(
105
+ request_data: MessagesRequest,
106
+ ) -> tuple[bool, str, str]:
107
+ """Check if this is a filepath extraction request.
108
+
109
+ Filepath extraction requests have a single user message with
110
+ "Command:" and "Output:" sections, asking to extract file paths
111
+ from command output.
112
+
113
+ Returns:
114
+ Tuple of (is_filepath_request, command, output)
115
+ """
116
+ if len(request_data.messages) != 1 or request_data.messages[0].role != "user":
117
+ return False, "", ""
118
+ if request_data.tools:
119
+ return False, "", ""
120
+
121
+ content = extract_text_from_content(request_data.messages[0].content)
122
+
123
+ if "Command:" not in content or "Output:" not in content:
124
+ return False, "", ""
125
+
126
+ # Match if user content OR system block indicates filepath extraction
127
+ user_has_filepaths = (
128
+ "filepaths" in content.lower() or "<filepaths>" in content.lower()
129
+ )
130
+ system_text = (
131
+ extract_text_from_content(request_data.system) if request_data.system else ""
132
+ )
133
+ system_has_extract = (
134
+ "extract any file paths" in system_text.lower()
135
+ or "file paths that this command" in system_text.lower()
136
+ )
137
+ if not user_has_filepaths and not system_has_extract:
138
+ return False, "", ""
139
+
140
+ cmd_start = content.find("Command:") + len("Command:")
141
+ output_marker = content.find("Output:", cmd_start)
142
+ if output_marker == -1:
143
+ return False, "", ""
144
+
145
+ command = content[cmd_start:output_marker].strip()
146
+ output = content[output_marker + len("Output:") :].strip()
147
+
148
+ for marker in ["<", "\n\n"]:
149
+ if marker in output:
150
+ output = output.split(marker)[0].strip()
151
+
152
+ return True, command, output