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.
- api/__init__.py +17 -0
- api/admin_config.py +1303 -0
- api/admin_routes.py +287 -0
- api/admin_static/admin.css +459 -0
- api/admin_static/admin.js +497 -0
- api/admin_static/index.html +77 -0
- api/admin_urls.py +34 -0
- api/app.py +194 -0
- api/command_utils.py +164 -0
- api/dependencies.py +144 -0
- api/detection.py +152 -0
- api/gateway_model_ids.py +54 -0
- api/model_catalog.py +133 -0
- api/model_router.py +125 -0
- api/models/__init__.py +45 -0
- api/models/anthropic.py +234 -0
- api/models/openai_responses.py +28 -0
- api/models/responses.py +60 -0
- api/optimization_handlers.py +154 -0
- api/request_pipeline.py +424 -0
- api/routes.py +156 -0
- api/runtime.py +334 -0
- api/validation_log.py +48 -0
- api/web_server_tools.py +22 -0
- api/web_tools/__init__.py +17 -0
- api/web_tools/constants.py +15 -0
- api/web_tools/egress.py +99 -0
- api/web_tools/outbound.py +278 -0
- api/web_tools/parsers.py +104 -0
- api/web_tools/request.py +87 -0
- api/web_tools/streaming.py +206 -0
- cli/__init__.py +5 -0
- cli/claude_env.py +12 -0
- cli/entrypoints.py +166 -0
- cli/env.example +209 -0
- cli/launchers/__init__.py +1 -0
- cli/launchers/claude.py +84 -0
- cli/launchers/codex.py +204 -0
- cli/launchers/codex_model_catalog.py +186 -0
- cli/launchers/common.py +93 -0
- cli/managed/__init__.py +6 -0
- cli/managed/claude.py +215 -0
- cli/managed/manager.py +157 -0
- cli/managed/session.py +260 -0
- cli/process_registry.py +78 -0
- config/__init__.py +5 -0
- config/constants.py +13 -0
- config/logging_config.py +159 -0
- config/nim.py +118 -0
- config/paths.py +91 -0
- config/provider_catalog.py +259 -0
- config/provider_ids.py +7 -0
- config/settings.py +538 -0
- core/__init__.py +1 -0
- core/anthropic/__init__.py +46 -0
- core/anthropic/content.py +31 -0
- core/anthropic/conversion.py +587 -0
- core/anthropic/emitted_sse_tracker.py +346 -0
- core/anthropic/errors.py +70 -0
- core/anthropic/native_messages_request.py +280 -0
- core/anthropic/native_sse_block_policy.py +313 -0
- core/anthropic/provider_stream_error.py +34 -0
- core/anthropic/server_tool_sse.py +14 -0
- core/anthropic/sse.py +440 -0
- core/anthropic/stream_contracts.py +205 -0
- core/anthropic/stream_recovery.py +346 -0
- core/anthropic/stream_recovery_session.py +133 -0
- core/anthropic/thinking.py +140 -0
- core/anthropic/tokens.py +117 -0
- core/anthropic/tools.py +212 -0
- core/anthropic/utils.py +9 -0
- core/openai_responses/__init__.py +5 -0
- core/openai_responses/adapter.py +31 -0
- core/openai_responses/anthropic_sse.py +59 -0
- core/openai_responses/errors.py +22 -0
- core/openai_responses/events.py +19 -0
- core/openai_responses/ids.py +21 -0
- core/openai_responses/input.py +258 -0
- core/openai_responses/items.py +37 -0
- core/openai_responses/reasoning.py +52 -0
- core/openai_responses/stream.py +25 -0
- core/openai_responses/stream_state.py +654 -0
- core/openai_responses/tools.py +374 -0
- core/openai_responses/usage.py +37 -0
- core/rate_limit.py +60 -0
- core/trace.py +216 -0
- devcopilot-0.2.0.dist-info/METADATA +687 -0
- devcopilot-0.2.0.dist-info/RECORD +189 -0
- devcopilot-0.2.0.dist-info/WHEEL +4 -0
- devcopilot-0.2.0.dist-info/entry_points.txt +6 -0
- devcopilot-0.2.0.dist-info/licenses/LICENSE +21 -0
- messaging/__init__.py +26 -0
- messaging/cli_event_constants.py +67 -0
- messaging/command_context.py +66 -0
- messaging/command_dispatcher.py +37 -0
- messaging/commands.py +275 -0
- messaging/event_parser.py +181 -0
- messaging/limiter.py +300 -0
- messaging/models.py +36 -0
- messaging/node_event_pipeline.py +127 -0
- messaging/node_runner.py +342 -0
- messaging/platforms/__init__.py +15 -0
- messaging/platforms/base.py +228 -0
- messaging/platforms/discord.py +567 -0
- messaging/platforms/factory.py +103 -0
- messaging/platforms/outbox.py +144 -0
- messaging/platforms/telegram.py +688 -0
- messaging/platforms/voice_flow.py +295 -0
- messaging/rendering/__init__.py +3 -0
- messaging/rendering/discord_markdown.py +318 -0
- messaging/rendering/markdown_tables.py +49 -0
- messaging/rendering/profiles.py +55 -0
- messaging/rendering/telegram_markdown.py +327 -0
- messaging/safe_diagnostics.py +17 -0
- messaging/session.py +334 -0
- messaging/transcript.py +581 -0
- messaging/transcription.py +164 -0
- messaging/trees/__init__.py +15 -0
- messaging/trees/data.py +482 -0
- messaging/trees/manager.py +433 -0
- messaging/trees/processor.py +179 -0
- messaging/trees/repository.py +177 -0
- messaging/turn_intake.py +235 -0
- messaging/ui_updates.py +101 -0
- messaging/voice.py +76 -0
- messaging/workflow.py +200 -0
- providers/__init__.py +31 -0
- providers/base.py +152 -0
- providers/cerebras/__init__.py +7 -0
- providers/cerebras/client.py +31 -0
- providers/cerebras/request.py +55 -0
- providers/codestral/__init__.py +7 -0
- providers/codestral/client.py +34 -0
- providers/deepseek/__init__.py +11 -0
- providers/deepseek/client.py +51 -0
- providers/deepseek/request.py +475 -0
- providers/defaults.py +41 -0
- providers/error_mapping.py +309 -0
- providers/exceptions.py +113 -0
- providers/fireworks/__init__.py +5 -0
- providers/fireworks/client.py +45 -0
- providers/fireworks/request.py +48 -0
- providers/gemini/__init__.py +7 -0
- providers/gemini/client.py +49 -0
- providers/gemini/request.py +199 -0
- providers/groq/__init__.py +7 -0
- providers/groq/client.py +31 -0
- providers/groq/request.py +83 -0
- providers/kimi/__init__.py +10 -0
- providers/kimi/client.py +53 -0
- providers/kimi/request.py +42 -0
- providers/llamacpp/__init__.py +3 -0
- providers/llamacpp/client.py +16 -0
- providers/lmstudio/__init__.py +5 -0
- providers/lmstudio/client.py +16 -0
- providers/mistral/__init__.py +7 -0
- providers/mistral/client.py +31 -0
- providers/mistral/request.py +37 -0
- providers/model_listing.py +133 -0
- providers/nvidia_nim/__init__.py +7 -0
- providers/nvidia_nim/client.py +91 -0
- providers/nvidia_nim/request.py +430 -0
- providers/nvidia_nim/voice.py +95 -0
- providers/ollama/__init__.py +7 -0
- providers/ollama/client.py +39 -0
- providers/open_router/__init__.py +7 -0
- providers/open_router/client.py +124 -0
- providers/open_router/request.py +42 -0
- providers/opencode/__init__.py +11 -0
- providers/opencode/client.py +31 -0
- providers/opencode/request.py +35 -0
- providers/rate_limit.py +300 -0
- providers/registry.py +527 -0
- providers/transports/__init__.py +1 -0
- providers/transports/anthropic_messages/__init__.py +5 -0
- providers/transports/anthropic_messages/http.py +118 -0
- providers/transports/anthropic_messages/recovery.py +206 -0
- providers/transports/anthropic_messages/stream.py +295 -0
- providers/transports/anthropic_messages/transport.py +236 -0
- providers/transports/openai_chat/__init__.py +5 -0
- providers/transports/openai_chat/recovery.py +217 -0
- providers/transports/openai_chat/stream.py +384 -0
- providers/transports/openai_chat/tool_calls.py +293 -0
- providers/transports/openai_chat/transport.py +156 -0
- providers/wafer/__init__.py +10 -0
- providers/wafer/client.py +50 -0
- providers/zai/__init__.py +10 -0
- providers/zai/client.py +46 -0
- 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
|