context-overlay 0.0.1__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.
- context_overlay/__init__.py +13 -0
- context_overlay/cli.py +25 -0
- context_overlay/config.py +84 -0
- context_overlay/matching.py +42 -0
- context_overlay/server.py +114 -0
- context_overlay/skills.py +84 -0
- context_overlay/transforms.py +130 -0
- context_overlay-0.0.1.dist-info/METADATA +160 -0
- context_overlay-0.0.1.dist-info/RECORD +13 -0
- context_overlay-0.0.1.dist-info/WHEEL +5 -0
- context_overlay-0.0.1.dist-info/entry_points.txt +2 -0
- context_overlay-0.0.1.dist-info/licenses/LICENSE +21 -0
- context_overlay-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""OpenAI-compatible context overlay proxy."""
|
|
2
|
+
|
|
3
|
+
from .config import ContextOverlayConfig, load_config
|
|
4
|
+
from .skills import Skill, SkillStore
|
|
5
|
+
from .transforms import apply_rules
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ContextOverlayConfig",
|
|
9
|
+
"Skill",
|
|
10
|
+
"SkillStore",
|
|
11
|
+
"apply_rules",
|
|
12
|
+
"load_config",
|
|
13
|
+
]
|
context_overlay/cli.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
import uvicorn
|
|
6
|
+
|
|
7
|
+
from .config import load_config
|
|
8
|
+
from .server import create_app
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> None:
|
|
12
|
+
parser = argparse.ArgumentParser(prog="context-overlay")
|
|
13
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
14
|
+
|
|
15
|
+
serve_parser = subparsers.add_parser("serve", help="Run the OpenAI-compatible context overlay proxy")
|
|
16
|
+
serve_parser.add_argument("--config", required=True, help="Path to YAML config")
|
|
17
|
+
serve_parser.add_argument("--host", default="127.0.0.1")
|
|
18
|
+
serve_parser.add_argument("--port", type=int, default=8011)
|
|
19
|
+
serve_parser.add_argument("--reload", action="store_true")
|
|
20
|
+
|
|
21
|
+
args = parser.parse_args()
|
|
22
|
+
if args.command == "serve":
|
|
23
|
+
config = load_config(args.config)
|
|
24
|
+
app = create_app(config)
|
|
25
|
+
uvicorn.run(app, host=args.host, port=args.port, reload=args.reload)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class UpstreamConfig(BaseModel):
|
|
12
|
+
base_url: str
|
|
13
|
+
api_key: str | None = None
|
|
14
|
+
timeout_seconds: float = 600.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthConfig(BaseModel):
|
|
18
|
+
api_key: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MatchConfig(BaseModel):
|
|
22
|
+
path: str | None = None
|
|
23
|
+
model_regex: str | None = None
|
|
24
|
+
messages_regex: list[str] = Field(default_factory=list)
|
|
25
|
+
extra_body: dict[str, Any] = Field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ContentSourceConfig(BaseModel):
|
|
29
|
+
type: Literal["text", "file", "skill_dir"]
|
|
30
|
+
text: str | None = None
|
|
31
|
+
path: str | None = None
|
|
32
|
+
top_k: int = 3
|
|
33
|
+
max_chars: int = 24000
|
|
34
|
+
title: str = "Context Overlay"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TransformConfig(BaseModel):
|
|
38
|
+
type: Literal[
|
|
39
|
+
"prepend_system",
|
|
40
|
+
"append_system",
|
|
41
|
+
"insert_before",
|
|
42
|
+
"insert_after",
|
|
43
|
+
"regex_replace",
|
|
44
|
+
"prepend_user",
|
|
45
|
+
"append_user",
|
|
46
|
+
"route",
|
|
47
|
+
"reject",
|
|
48
|
+
]
|
|
49
|
+
target: Literal["system", "user"] = "system"
|
|
50
|
+
pattern: str | None = None
|
|
51
|
+
replacement: str | None = None
|
|
52
|
+
content: str | ContentSourceConfig | None = None
|
|
53
|
+
upstream_base_url: str | None = None
|
|
54
|
+
model: str | None = None
|
|
55
|
+
reason: str | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class RuleConfig(BaseModel):
|
|
59
|
+
name: str
|
|
60
|
+
match: MatchConfig = Field(default_factory=MatchConfig)
|
|
61
|
+
transforms: list[TransformConfig] = Field(default_factory=list)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ContextOverlayConfig(BaseModel):
|
|
65
|
+
upstream: UpstreamConfig
|
|
66
|
+
auth: AuthConfig = Field(default_factory=AuthConfig)
|
|
67
|
+
rules: list[RuleConfig] = Field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _expand_env(value: Any) -> Any:
|
|
71
|
+
if isinstance(value, str):
|
|
72
|
+
return os.path.expandvars(value)
|
|
73
|
+
if isinstance(value, list):
|
|
74
|
+
return [_expand_env(item) for item in value]
|
|
75
|
+
if isinstance(value, dict):
|
|
76
|
+
return {key: _expand_env(item) for key, item in value.items()}
|
|
77
|
+
return value
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load_config(path: str | Path) -> ContextOverlayConfig:
|
|
81
|
+
config_path = Path(path)
|
|
82
|
+
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
|
83
|
+
expanded = _expand_env(raw)
|
|
84
|
+
return ContextOverlayConfig.model_validate(expanded)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .config import MatchConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def message_text(messages: list[dict[str, Any]]) -> str:
|
|
10
|
+
parts: list[str] = []
|
|
11
|
+
for message in messages:
|
|
12
|
+
content = message.get("content")
|
|
13
|
+
if isinstance(content, str):
|
|
14
|
+
parts.append(content)
|
|
15
|
+
elif isinstance(content, list):
|
|
16
|
+
for item in content:
|
|
17
|
+
if isinstance(item, dict) and item.get("type") == "text":
|
|
18
|
+
parts.append(str(item.get("text", "")))
|
|
19
|
+
return "\n".join(parts)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _contains_extra_body(body: dict[str, Any], expected: dict[str, Any]) -> bool:
|
|
23
|
+
for key, value in expected.items():
|
|
24
|
+
if body.get(key) != value:
|
|
25
|
+
return False
|
|
26
|
+
return True
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def request_matches(path: str, body: dict[str, Any], match: MatchConfig) -> bool:
|
|
30
|
+
if match.path and match.path != path:
|
|
31
|
+
return False
|
|
32
|
+
if match.model_regex:
|
|
33
|
+
model = str(body.get("model", ""))
|
|
34
|
+
if not re.search(match.model_regex, model):
|
|
35
|
+
return False
|
|
36
|
+
if match.extra_body and not _contains_extra_body(body, match.extra_body):
|
|
37
|
+
return False
|
|
38
|
+
if match.messages_regex:
|
|
39
|
+
text = message_text(body.get("messages") or [])
|
|
40
|
+
if not all(re.search(pattern, text, flags=re.IGNORECASE) for pattern in match.messages_regex):
|
|
41
|
+
return False
|
|
42
|
+
return True
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from fastapi import FastAPI, Header, HTTPException, Request
|
|
7
|
+
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
|
8
|
+
|
|
9
|
+
from .config import ContextOverlayConfig
|
|
10
|
+
from .transforms import apply_rules
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _check_auth(config: ContextOverlayConfig, authorization: str | None) -> None:
|
|
14
|
+
expected = config.auth.api_key
|
|
15
|
+
if not expected:
|
|
16
|
+
return
|
|
17
|
+
if authorization == f"Bearer {expected}":
|
|
18
|
+
return
|
|
19
|
+
raise HTTPException(status_code=401, detail="Invalid context-overlay API key")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _upstream_url(config: ContextOverlayConfig, path: str, body: dict[str, Any] | None = None) -> str:
|
|
23
|
+
base_url = config.upstream.base_url.rstrip("/")
|
|
24
|
+
if body and body.get("_context_overlay_upstream_base_url"):
|
|
25
|
+
base_url = str(body["_context_overlay_upstream_base_url"]).rstrip("/")
|
|
26
|
+
suffix = path.removeprefix("/v1")
|
|
27
|
+
return base_url + suffix
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _forward_headers(config: ContextOverlayConfig, request: Request) -> dict[str, str]:
|
|
31
|
+
headers = {
|
|
32
|
+
key: value
|
|
33
|
+
for key, value in request.headers.items()
|
|
34
|
+
if key.lower() not in {"host", "content-length", "authorization"}
|
|
35
|
+
}
|
|
36
|
+
api_key = config.upstream.api_key
|
|
37
|
+
if api_key:
|
|
38
|
+
headers["authorization"] = f"Bearer {api_key}"
|
|
39
|
+
return headers
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def create_app(config: ContextOverlayConfig) -> FastAPI:
|
|
43
|
+
app = FastAPI(title="context-overlay")
|
|
44
|
+
|
|
45
|
+
@app.get("/health")
|
|
46
|
+
async def health() -> dict[str, str]:
|
|
47
|
+
return {"status": "ok"}
|
|
48
|
+
|
|
49
|
+
@app.api_route("/v1/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
|
|
50
|
+
async def proxy(path: str, request: Request, authorization: str | None = Header(default=None)) -> Response:
|
|
51
|
+
_check_auth(config, authorization)
|
|
52
|
+
full_path = f"/v1/{path}"
|
|
53
|
+
method = request.method
|
|
54
|
+
headers = _forward_headers(config, request)
|
|
55
|
+
timeout = httpx.Timeout(config.upstream.timeout_seconds)
|
|
56
|
+
|
|
57
|
+
if method == "POST" and full_path == "/v1/chat/completions":
|
|
58
|
+
body = await request.json()
|
|
59
|
+
try:
|
|
60
|
+
body = apply_rules(body, config, path=full_path)
|
|
61
|
+
except PermissionError as exc:
|
|
62
|
+
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
63
|
+
except Exception as exc: # noqa: BLE001 - return explicit transform errors.
|
|
64
|
+
raise HTTPException(status_code=400, detail=f"context-overlay transform error: {exc}") from exc
|
|
65
|
+
stream = bool(body.get("stream"))
|
|
66
|
+
upstream_url = _upstream_url(config, full_path, body)
|
|
67
|
+
body.pop("_context_overlay_upstream_base_url", None)
|
|
68
|
+
if stream:
|
|
69
|
+
return await _stream_request(method, upstream_url, headers, json_body=body, timeout=timeout)
|
|
70
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
71
|
+
response = await client.request(method, upstream_url, headers=headers, json=body)
|
|
72
|
+
return Response(
|
|
73
|
+
content=response.content,
|
|
74
|
+
status_code=response.status_code,
|
|
75
|
+
media_type=response.headers.get("content-type"),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
upstream_url = _upstream_url(config, full_path)
|
|
79
|
+
content = await request.body()
|
|
80
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
81
|
+
response = await client.request(method, upstream_url, headers=headers, content=content, params=request.query_params)
|
|
82
|
+
return Response(
|
|
83
|
+
content=response.content,
|
|
84
|
+
status_code=response.status_code,
|
|
85
|
+
media_type=response.headers.get("content-type"),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return app
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def _stream_request(
|
|
92
|
+
method: str,
|
|
93
|
+
url: str,
|
|
94
|
+
headers: dict[str, str],
|
|
95
|
+
json_body: dict[str, Any],
|
|
96
|
+
timeout: httpx.Timeout,
|
|
97
|
+
) -> StreamingResponse:
|
|
98
|
+
client = httpx.AsyncClient(timeout=timeout)
|
|
99
|
+
request = client.build_request(method, url, headers=headers, json=json_body)
|
|
100
|
+
response = await client.send(request, stream=True)
|
|
101
|
+
|
|
102
|
+
async def iterator():
|
|
103
|
+
try:
|
|
104
|
+
async for chunk in response.aiter_bytes():
|
|
105
|
+
yield chunk
|
|
106
|
+
finally:
|
|
107
|
+
await response.aclose()
|
|
108
|
+
await client.aclose()
|
|
109
|
+
|
|
110
|
+
return StreamingResponse(
|
|
111
|
+
iterator(),
|
|
112
|
+
status_code=response.status_code,
|
|
113
|
+
media_type=response.headers.get("content-type", "text/event-stream"),
|
|
114
|
+
)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
TOKEN_RE = re.compile(r"[A-Za-z0-9_+\-.]+")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def tokenize(text: str) -> set[str]:
|
|
14
|
+
return {token.lower() for token in TOKEN_RE.findall(text) if len(token) > 2}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Skill:
|
|
19
|
+
path: Path
|
|
20
|
+
name: str
|
|
21
|
+
description: str
|
|
22
|
+
content: str
|
|
23
|
+
category: str | None = None
|
|
24
|
+
score: int | None = None
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def from_json_file(cls, path: Path) -> "Skill":
|
|
28
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
29
|
+
return cls(
|
|
30
|
+
path=path,
|
|
31
|
+
name=str(data.get("name") or path.stem),
|
|
32
|
+
description=str(data.get("description") or ""),
|
|
33
|
+
content=str(data.get("content") or ""),
|
|
34
|
+
category=data.get("category"),
|
|
35
|
+
score=data.get("score"),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def searchable_text(self) -> str:
|
|
39
|
+
return "\n".join(part for part in [self.name, self.description, self.category or "", self.content] if part)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SkillStore:
|
|
43
|
+
def __init__(self, skills: list[Skill]) -> None:
|
|
44
|
+
self.skills = skills
|
|
45
|
+
self._tokens = [tokenize(skill.searchable_text()) for skill in skills]
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_dir(cls, path: str | Path) -> "SkillStore":
|
|
49
|
+
root = Path(path)
|
|
50
|
+
skills = [Skill.from_json_file(item) for item in sorted(root.glob("*.json"))]
|
|
51
|
+
return cls(skills)
|
|
52
|
+
|
|
53
|
+
def retrieve(self, query: str, top_k: int = 3) -> list[Skill]:
|
|
54
|
+
query_tokens = tokenize(query)
|
|
55
|
+
if not query_tokens:
|
|
56
|
+
return self.skills[:top_k]
|
|
57
|
+
scored: list[tuple[float, int, Skill]] = []
|
|
58
|
+
for idx, (skill, skill_tokens) in enumerate(zip(self.skills, self._tokens)):
|
|
59
|
+
if not skill_tokens:
|
|
60
|
+
continue
|
|
61
|
+
overlap = len(query_tokens & skill_tokens)
|
|
62
|
+
if overlap == 0:
|
|
63
|
+
continue
|
|
64
|
+
score = overlap / (len(query_tokens) ** 0.5 * len(skill_tokens) ** 0.5)
|
|
65
|
+
scored.append((score, -idx, skill))
|
|
66
|
+
scored.sort(reverse=True)
|
|
67
|
+
return [skill for _, _, skill in scored[:top_k]]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def render_skills(skills: list[Skill], title: str = "Context Overlay", max_chars: int = 24000) -> str:
|
|
71
|
+
if not skills:
|
|
72
|
+
return ""
|
|
73
|
+
blocks = [f"# {title}", "Use the following planning context when relevant. Do not mention that it was injected."]
|
|
74
|
+
for idx, skill in enumerate(skills, 1):
|
|
75
|
+
blocks.append(f"\n## Skill {idx}: {skill.name}")
|
|
76
|
+
if skill.description:
|
|
77
|
+
blocks.append(f"Description: {skill.description}")
|
|
78
|
+
if skill.category:
|
|
79
|
+
blocks.append(f"Category: {skill.category}")
|
|
80
|
+
blocks.append(skill.content)
|
|
81
|
+
text = "\n".join(blocks).strip()
|
|
82
|
+
if len(text) <= max_chars:
|
|
83
|
+
return text
|
|
84
|
+
return text[: max_chars - 80].rstrip() + "\n\n[Context overlay truncated to fit the configured budget.]"
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .config import ContentSourceConfig, ContextOverlayConfig, TransformConfig
|
|
9
|
+
from .matching import message_text, request_matches
|
|
10
|
+
from .skills import SkillStore, render_skills
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def ensure_messages(body: dict[str, Any]) -> list[dict[str, Any]]:
|
|
14
|
+
messages = body.setdefault("messages", [])
|
|
15
|
+
if not isinstance(messages, list):
|
|
16
|
+
raise ValueError("Request body field 'messages' must be a list")
|
|
17
|
+
return messages
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def ensure_system_message(messages: list[dict[str, Any]]) -> dict[str, Any]:
|
|
21
|
+
for message in messages:
|
|
22
|
+
if message.get("role") == "system":
|
|
23
|
+
if not isinstance(message.get("content"), str):
|
|
24
|
+
message["content"] = str(message.get("content", ""))
|
|
25
|
+
return message
|
|
26
|
+
system = {"role": "system", "content": ""}
|
|
27
|
+
messages.insert(0, system)
|
|
28
|
+
return system
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def ensure_last_user_message(messages: list[dict[str, Any]]) -> dict[str, Any]:
|
|
32
|
+
for message in reversed(messages):
|
|
33
|
+
if message.get("role") == "user":
|
|
34
|
+
if not isinstance(message.get("content"), str):
|
|
35
|
+
message["content"] = _content_to_text(message.get("content"))
|
|
36
|
+
return message
|
|
37
|
+
user = {"role": "user", "content": ""}
|
|
38
|
+
messages.append(user)
|
|
39
|
+
return user
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _content_to_text(content: Any) -> str:
|
|
43
|
+
if isinstance(content, str):
|
|
44
|
+
return content
|
|
45
|
+
if isinstance(content, list):
|
|
46
|
+
parts = []
|
|
47
|
+
for item in content:
|
|
48
|
+
if isinstance(item, dict) and item.get("type") == "text":
|
|
49
|
+
parts.append(str(item.get("text", "")))
|
|
50
|
+
return "\n".join(parts)
|
|
51
|
+
return str(content or "")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def resolve_content(content: str | ContentSourceConfig | None, body: dict[str, Any]) -> str:
|
|
55
|
+
if content is None:
|
|
56
|
+
return ""
|
|
57
|
+
if isinstance(content, str):
|
|
58
|
+
return content
|
|
59
|
+
if content.type == "text":
|
|
60
|
+
return content.text or ""
|
|
61
|
+
if content.type == "file":
|
|
62
|
+
if not content.path:
|
|
63
|
+
raise ValueError("file content source requires path")
|
|
64
|
+
return Path(content.path).read_text(encoding="utf-8")
|
|
65
|
+
if content.type == "skill_dir":
|
|
66
|
+
if not content.path:
|
|
67
|
+
raise ValueError("skill_dir content source requires path")
|
|
68
|
+
store = SkillStore.from_dir(content.path)
|
|
69
|
+
skills = store.retrieve(message_text(body.get("messages") or []), top_k=content.top_k)
|
|
70
|
+
return render_skills(skills, title=content.title, max_chars=content.max_chars)
|
|
71
|
+
raise ValueError(f"Unsupported content source: {content.type}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _join(prefix: str, suffix: str) -> str:
|
|
75
|
+
if not prefix:
|
|
76
|
+
return suffix
|
|
77
|
+
if not suffix:
|
|
78
|
+
return prefix
|
|
79
|
+
return prefix.rstrip() + "\n\n" + suffix.lstrip()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def apply_transform(body: dict[str, Any], transform: TransformConfig) -> dict[str, Any]:
|
|
83
|
+
messages = ensure_messages(body)
|
|
84
|
+
overlay = resolve_content(transform.content, body)
|
|
85
|
+
if transform.type == "reject":
|
|
86
|
+
raise PermissionError(transform.reason or "Request rejected by context-overlay rule")
|
|
87
|
+
if transform.type == "route":
|
|
88
|
+
if transform.model:
|
|
89
|
+
body["model"] = transform.model
|
|
90
|
+
if transform.upstream_base_url:
|
|
91
|
+
body["_context_overlay_upstream_base_url"] = transform.upstream_base_url
|
|
92
|
+
return body
|
|
93
|
+
if transform.type in {"prepend_system", "append_system", "insert_before", "insert_after", "regex_replace"}:
|
|
94
|
+
target = ensure_system_message(messages)
|
|
95
|
+
elif transform.type in {"prepend_user", "append_user"}:
|
|
96
|
+
target = ensure_last_user_message(messages)
|
|
97
|
+
else:
|
|
98
|
+
raise ValueError(f"Unsupported transform type: {transform.type}")
|
|
99
|
+
|
|
100
|
+
content = str(target.get("content") or "")
|
|
101
|
+
if transform.type in {"prepend_system", "prepend_user"}:
|
|
102
|
+
target["content"] = _join(overlay, content)
|
|
103
|
+
elif transform.type in {"append_system", "append_user"}:
|
|
104
|
+
target["content"] = _join(content, overlay)
|
|
105
|
+
elif transform.type == "insert_before":
|
|
106
|
+
if not transform.pattern:
|
|
107
|
+
target["content"] = _join(overlay, content)
|
|
108
|
+
else:
|
|
109
|
+
target["content"] = re.sub(transform.pattern, overlay + "\n\n" + r"\g<0>", content, count=1)
|
|
110
|
+
elif transform.type == "insert_after":
|
|
111
|
+
if not transform.pattern:
|
|
112
|
+
target["content"] = _join(content, overlay)
|
|
113
|
+
else:
|
|
114
|
+
target["content"] = re.sub(transform.pattern, r"\g<0>" + "\n\n" + overlay, content, count=1)
|
|
115
|
+
elif transform.type == "regex_replace":
|
|
116
|
+
if not transform.pattern:
|
|
117
|
+
raise ValueError("regex_replace requires pattern")
|
|
118
|
+
replacement = transform.replacement if transform.replacement is not None else overlay
|
|
119
|
+
target["content"] = re.sub(transform.pattern, replacement, content)
|
|
120
|
+
return body
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def apply_rules(body: dict[str, Any], config: ContextOverlayConfig, path: str = "/v1/chat/completions") -> dict[str, Any]:
|
|
124
|
+
transformed = copy.deepcopy(body)
|
|
125
|
+
for rule in config.rules:
|
|
126
|
+
if not request_matches(path, transformed, rule.match):
|
|
127
|
+
continue
|
|
128
|
+
for transform in rule.transforms:
|
|
129
|
+
transformed = apply_transform(transformed, transform)
|
|
130
|
+
return transformed
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: context-overlay
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: OpenAI-compatible context overlay proxy for injecting skills, memory, policies, and prompt patches.
|
|
5
|
+
Author: InternScience
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/black-yt/context-overlay
|
|
8
|
+
Project-URL: Repository, https://github.com/black-yt/context-overlay
|
|
9
|
+
Project-URL: Issues, https://github.com/black-yt/context-overlay/issues
|
|
10
|
+
Keywords: llm,openai,proxy,prompt,context,middleware
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: fastapi>=0.110
|
|
22
|
+
Requires-Dist: httpx>=0.27
|
|
23
|
+
Requires-Dist: pydantic>=2
|
|
24
|
+
Requires-Dist: PyYAML>=6
|
|
25
|
+
Requires-Dist: uvicorn[standard]>=0.27
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: build>=1; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
29
|
+
Requires-Dist: respx>=0.21; extra == "dev"
|
|
30
|
+
Requires-Dist: twine>=5; extra == "dev"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# context-overlay
|
|
34
|
+
|
|
35
|
+
`context-overlay` is an OpenAI-compatible request proxy that injects additional context into chat-completion requests without changing the upstream model server.
|
|
36
|
+
|
|
37
|
+
It can be used for:
|
|
38
|
+
|
|
39
|
+
- skill injection from local JSON skills
|
|
40
|
+
- prompt overlays and prompt patches
|
|
41
|
+
- policy or profile insertion
|
|
42
|
+
- lightweight request routing
|
|
43
|
+
- public demos through tools such as `ngrok` or `cloudflared`
|
|
44
|
+
|
|
45
|
+
The package does not run an agent loop and does not execute tools. It only transforms OpenAI-compatible HTTP requests and forwards them to an upstream OpenAI-compatible endpoint.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install context-overlay
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
For local development:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install -e ".[dev]"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Quick Start
|
|
60
|
+
|
|
61
|
+
Create `config.yaml`:
|
|
62
|
+
|
|
63
|
+
```yaml
|
|
64
|
+
upstream:
|
|
65
|
+
base_url: "http://127.0.0.1:8010/v1"
|
|
66
|
+
api_key: "unused"
|
|
67
|
+
|
|
68
|
+
auth:
|
|
69
|
+
api_key: "proxy-key"
|
|
70
|
+
|
|
71
|
+
rules:
|
|
72
|
+
- name: inject_science_skills
|
|
73
|
+
match:
|
|
74
|
+
path: "/v1/chat/completions"
|
|
75
|
+
messages_regex:
|
|
76
|
+
- "scientific"
|
|
77
|
+
transforms:
|
|
78
|
+
- type: append_system
|
|
79
|
+
content:
|
|
80
|
+
type: skill_dir
|
|
81
|
+
path: "./skills/generated_skills"
|
|
82
|
+
top_k: 3
|
|
83
|
+
max_chars: 24000
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Run:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
context-overlay serve --config config.yaml --host 127.0.0.1 --port 8011
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Use it with the OpenAI SDK:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from openai import OpenAI
|
|
96
|
+
|
|
97
|
+
client = OpenAI(api_key="proxy-key", base_url="http://127.0.0.1:8011/v1")
|
|
98
|
+
|
|
99
|
+
response = client.chat.completions.create(
|
|
100
|
+
model="Qwen3.5-9B",
|
|
101
|
+
messages=[{"role": "user", "content": "Analyze this scientific task."}],
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
print(response.choices[0].message.content)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Public URL With ngrok
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
context-overlay serve --config config.yaml --host 127.0.0.1 --port 8011
|
|
111
|
+
ngrok http 8011
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Then set the SDK base URL to the public ngrok URL plus `/v1`.
|
|
115
|
+
|
|
116
|
+
## Public URL With cloudflared
|
|
117
|
+
|
|
118
|
+
Temporary URL:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
context-overlay serve --config config.yaml --host 127.0.0.1 --port 8011
|
|
122
|
+
cloudflared tunnel --url http://127.0.0.1:8011
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Long-running production use should use a named Cloudflare Tunnel and your own access policy.
|
|
126
|
+
|
|
127
|
+
## Rule Model
|
|
128
|
+
|
|
129
|
+
Each rule has:
|
|
130
|
+
|
|
131
|
+
- `match`: decides whether a request should be transformed.
|
|
132
|
+
- `transforms`: one or more operations applied to the request body before forwarding.
|
|
133
|
+
|
|
134
|
+
Supported match fields:
|
|
135
|
+
|
|
136
|
+
- `path`
|
|
137
|
+
- `model_regex`
|
|
138
|
+
- `messages_regex`
|
|
139
|
+
- `extra_body`
|
|
140
|
+
|
|
141
|
+
Supported transform types:
|
|
142
|
+
|
|
143
|
+
- `prepend_system`
|
|
144
|
+
- `append_system`
|
|
145
|
+
- `insert_before`
|
|
146
|
+
- `insert_after`
|
|
147
|
+
- `regex_replace`
|
|
148
|
+
- `prepend_user`
|
|
149
|
+
- `append_user`
|
|
150
|
+
- `route`
|
|
151
|
+
- `reject`
|
|
152
|
+
|
|
153
|
+
Skill injection is implemented as a content source, not a special runtime mode.
|
|
154
|
+
|
|
155
|
+
## Security Notes
|
|
156
|
+
|
|
157
|
+
- Use `auth.api_key` before exposing the proxy publicly.
|
|
158
|
+
- Keep upstream API keys on the server side.
|
|
159
|
+
- Do not put secrets in skill files or prompt overlays.
|
|
160
|
+
- Set upstream and client-side timeouts appropriate for your deployment.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
context_overlay/__init__.py,sha256=_Cr5Y-Ruiv1dOJHuZC8y50IJiATRG2_rnDciGzHVGfU,288
|
|
2
|
+
context_overlay/cli.py,sha256=SCKtVgRlMtz5GDnyx0cJVdo7RoseivaEX2KXnLuUSF0,881
|
|
3
|
+
context_overlay/config.py,sha256=sTeKsI8sCBWBLGpt-wPxZYJsuVv0NiksTikbZ62-yGo,2235
|
|
4
|
+
context_overlay/matching.py,sha256=tI8ZVzz-pBO_u9tUkjyvbp6eQ7DWzLwHQmaSrKma20Y,1384
|
|
5
|
+
context_overlay/server.py,sha256=SJ9auIVd1Tf5ZLsWd6VDVv-qvFc-NPfo1TF2YL3tIek,4381
|
|
6
|
+
context_overlay/skills.py,sha256=3im0p793_vV2bhbU-P1wDD8hNHtV4v5c6pph3_-lI88,2925
|
|
7
|
+
context_overlay/transforms.py,sha256=I2FjNtqfTKZ-_OZ7yd-dCFmUmE7Ri_lMtP1Er3SimmM,5218
|
|
8
|
+
context_overlay-0.0.1.dist-info/licenses/LICENSE,sha256=K1d9tbfSQ3rDkd7ALFcKEwhY38vrGhFXBaVCkR6AQd0,1070
|
|
9
|
+
context_overlay-0.0.1.dist-info/METADATA,sha256=8c20Wt4PrNUqcEfR0sx54Fe0LZm4EQ58qN2WTyB7Hzo,4091
|
|
10
|
+
context_overlay-0.0.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
context_overlay-0.0.1.dist-info/entry_points.txt,sha256=xRXzBHMeHRdgnk8nHqNrkyoa5an__7mdjlLh8KwFftk,61
|
|
12
|
+
context_overlay-0.0.1.dist-info/top_level.txt,sha256=RGebGsK7jYk8TyqUJby0YFSb6OGslb-xhexx03ppnQI,16
|
|
13
|
+
context_overlay-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 InternScience
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
context_overlay
|