flense 0.1.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.
- flense/__init__.py +1 -0
- flense/app.py +54 -0
- flense/cli.py +124 -0
- flense/code_writer.py +230 -0
- flense/compression/__init__.py +244 -0
- flense/compression/classifier.py +136 -0
- flense/compression/ctags.py +102 -0
- flense/compression/estimator.py +50 -0
- flense/compression/grammar.py +108 -0
- flense/compression/language.py +112 -0
- flense/compression/regex.py +58 -0
- flense/compression/treesitter.py +214 -0
- flense/compression/types.py +65 -0
- flense/config.py +118 -0
- flense/daemon.py +82 -0
- flense/pricing.py +83 -0
- flense/providers/__init__.py +31 -0
- flense/providers/anthropic.py +23 -0
- flense/providers/base.py +66 -0
- flense/providers/openai.py +28 -0
- flense/proxy.py +198 -0
- flense/server.py +31 -0
- flense/telemetry.py +169 -0
- flense/tui.py +156 -0
- flense-0.1.0.dist-info/METADATA +148 -0
- flense-0.1.0.dist-info/RECORD +29 -0
- flense-0.1.0.dist-info/WHEEL +4 -0
- flense-0.1.0.dist-info/entry_points.txt +2 -0
- flense-0.1.0.dist-info/licenses/LICENSE +6 -0
flense/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
flense/app.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from fastapi import FastAPI, Request
|
|
7
|
+
from fastapi.responses import JSONResponse
|
|
8
|
+
|
|
9
|
+
from .config import FlenseConfig
|
|
10
|
+
from .providers import get_adapter, register_providers
|
|
11
|
+
from .proxy import proxy_request
|
|
12
|
+
from .telemetry import SessionStats
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@asynccontextmanager
|
|
16
|
+
async def _lifespan(app: FastAPI):
|
|
17
|
+
app.state.httpx_client = httpx.AsyncClient(
|
|
18
|
+
timeout=httpx.Timeout(
|
|
19
|
+
connect=10.0,
|
|
20
|
+
read=300.0, # AI responses can be slow
|
|
21
|
+
write=10.0,
|
|
22
|
+
pool=10.0,
|
|
23
|
+
),
|
|
24
|
+
follow_redirects=False,
|
|
25
|
+
http2=True,
|
|
26
|
+
)
|
|
27
|
+
yield
|
|
28
|
+
await app.state.httpx_client.aclose()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def create_app(config: FlenseConfig) -> FastAPI:
|
|
32
|
+
app = FastAPI(title="flense", lifespan=_lifespan)
|
|
33
|
+
app.state.config = config
|
|
34
|
+
app.state.session_stats = SessionStats()
|
|
35
|
+
register_providers(config)
|
|
36
|
+
|
|
37
|
+
@app.get("/health")
|
|
38
|
+
async def health():
|
|
39
|
+
return {"status": "ok"}
|
|
40
|
+
|
|
41
|
+
@app.api_route(
|
|
42
|
+
"/{provider}/{path:path}",
|
|
43
|
+
methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
|
|
44
|
+
)
|
|
45
|
+
async def proxy_route(provider: str, path: str, request: Request):
|
|
46
|
+
adapter = get_adapter(provider)
|
|
47
|
+
if adapter is None:
|
|
48
|
+
return JSONResponse(
|
|
49
|
+
{"error": f"Unknown provider: {provider}"},
|
|
50
|
+
status_code=404,
|
|
51
|
+
)
|
|
52
|
+
return await proxy_request(request, adapter)
|
|
53
|
+
|
|
54
|
+
return app
|
flense/cli.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
app = typer.Typer(
|
|
9
|
+
name="flense",
|
|
10
|
+
help="Lightweight reverse proxy that compresses AI API payloads.",
|
|
11
|
+
add_completion=False,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command()
|
|
16
|
+
def start(
|
|
17
|
+
port: Optional[int] = typer.Option(None, help="Override server port"),
|
|
18
|
+
foreground: bool = typer.Option(
|
|
19
|
+
False, "--fg", help="Run in foreground (no daemon)"
|
|
20
|
+
),
|
|
21
|
+
headless: Optional[bool] = typer.Option(
|
|
22
|
+
None, help="Headless mode (JSON logs, no TUI)"
|
|
23
|
+
),
|
|
24
|
+
config: Optional[Path] = typer.Option(
|
|
25
|
+
None, help="Path to flense.toml"
|
|
26
|
+
),
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Start the flense proxy."""
|
|
29
|
+
from .config import load_config
|
|
30
|
+
|
|
31
|
+
cfg = load_config(config)
|
|
32
|
+
effective_port = port if port is not None else cfg.server.port
|
|
33
|
+
|
|
34
|
+
# CLI flag overrides config file
|
|
35
|
+
use_headless = headless if headless is not None else cfg.server.headless
|
|
36
|
+
|
|
37
|
+
if foreground:
|
|
38
|
+
if use_headless:
|
|
39
|
+
_run_headless(cfg, effective_port)
|
|
40
|
+
else:
|
|
41
|
+
_run_tui(cfg, effective_port)
|
|
42
|
+
else:
|
|
43
|
+
from .daemon import is_running, start_daemon
|
|
44
|
+
|
|
45
|
+
if is_running():
|
|
46
|
+
from .daemon import read_pid
|
|
47
|
+
|
|
48
|
+
typer.echo(f"flense is already running (PID {read_pid()})")
|
|
49
|
+
raise typer.Exit(1)
|
|
50
|
+
|
|
51
|
+
pid = start_daemon(cfg.server.host, effective_port, config)
|
|
52
|
+
typer.echo(f"flense started on {cfg.server.host}:{effective_port} (PID {pid})")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _run_headless(cfg, port: int) -> None:
|
|
56
|
+
"""Run the proxy in foreground with structured JSON logs (no TUI)."""
|
|
57
|
+
import uvicorn
|
|
58
|
+
|
|
59
|
+
from .app import create_app
|
|
60
|
+
|
|
61
|
+
cfg.server.headless = True
|
|
62
|
+
instance = create_app(cfg)
|
|
63
|
+
uvicorn.run(instance, host=cfg.server.host, port=port)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _run_tui(cfg, port: int) -> None:
|
|
67
|
+
"""Run the proxy in foreground with the Textual TUI dashboard."""
|
|
68
|
+
import threading
|
|
69
|
+
|
|
70
|
+
import uvicorn
|
|
71
|
+
|
|
72
|
+
from .app import create_app
|
|
73
|
+
from .tui import FlenseDashboard
|
|
74
|
+
|
|
75
|
+
instance = create_app(cfg)
|
|
76
|
+
stats = instance.state.session_stats
|
|
77
|
+
|
|
78
|
+
# Run uvicorn in a background thread so Textual owns the main thread.
|
|
79
|
+
server = uvicorn.Server(
|
|
80
|
+
uvicorn.Config(
|
|
81
|
+
instance,
|
|
82
|
+
host=cfg.server.host,
|
|
83
|
+
port=port,
|
|
84
|
+
log_level="warning",
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
thread = threading.Thread(target=server.run, daemon=True)
|
|
88
|
+
thread.start()
|
|
89
|
+
|
|
90
|
+
dashboard = FlenseDashboard(
|
|
91
|
+
session_stats=stats,
|
|
92
|
+
server_host=cfg.server.host,
|
|
93
|
+
server_port=port,
|
|
94
|
+
)
|
|
95
|
+
dashboard.run()
|
|
96
|
+
|
|
97
|
+
# TUI exited, shut down the server.
|
|
98
|
+
server.should_exit = True
|
|
99
|
+
thread.join(timeout=5)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@app.command()
|
|
103
|
+
def stop() -> None:
|
|
104
|
+
"""Stop the flense proxy."""
|
|
105
|
+
from .daemon import stop_daemon
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
stop_daemon()
|
|
109
|
+
except RuntimeError as exc:
|
|
110
|
+
typer.echo(str(exc))
|
|
111
|
+
raise typer.Exit(1)
|
|
112
|
+
|
|
113
|
+
typer.echo("flense stopped.")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.command()
|
|
117
|
+
def status() -> None:
|
|
118
|
+
"""Check whether flense is running."""
|
|
119
|
+
from .daemon import is_running, read_pid
|
|
120
|
+
|
|
121
|
+
if is_running():
|
|
122
|
+
typer.echo(f"flense is running (PID {read_pid()})")
|
|
123
|
+
else:
|
|
124
|
+
typer.echo("flense is not running.")
|
flense/code_writer.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Code-writer bypass: route code generation tasks to a cheap model.
|
|
2
|
+
|
|
3
|
+
Triggered by X-Flense-Strategy: code-writer. Sends the request to a cheap
|
|
4
|
+
cloud model, writes the output to disk, and returns a synthetic ACK to the
|
|
5
|
+
client. The frontier model is never called.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from .config import CodeWriterConfig, FlenseConfig
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
_ANTHROPIC_UPSTREAM = "https://api.anthropic.com"
|
|
23
|
+
_OPENAI_UPSTREAM = "https://api.openai.com"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _infer_provider(model: str) -> str:
|
|
27
|
+
"""Infer provider from model name prefix."""
|
|
28
|
+
if model.startswith("claude"):
|
|
29
|
+
return "anthropic"
|
|
30
|
+
return "openai"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _get_api_key(provider: str, headers: dict[str, str]) -> str | None:
|
|
34
|
+
"""Extract API key for the given provider from request headers, then env vars."""
|
|
35
|
+
if provider == "anthropic":
|
|
36
|
+
return headers.get("x-api-key") or os.environ.get("ANTHROPIC_API_KEY")
|
|
37
|
+
if provider == "openai":
|
|
38
|
+
auth = headers.get("authorization", "")
|
|
39
|
+
key = auth.removeprefix("Bearer ").strip() or None
|
|
40
|
+
return key or os.environ.get("OPENAI_API_KEY")
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _extract_spec(body: bytes) -> str:
|
|
45
|
+
"""Extract the spec from the last user message in the request body."""
|
|
46
|
+
try:
|
|
47
|
+
payload = json.loads(body)
|
|
48
|
+
messages = payload.get("messages", [])
|
|
49
|
+
for msg in reversed(messages):
|
|
50
|
+
if msg.get("role") == "user":
|
|
51
|
+
content = msg.get("content", "")
|
|
52
|
+
if isinstance(content, str):
|
|
53
|
+
return content
|
|
54
|
+
if isinstance(content, list):
|
|
55
|
+
parts = [
|
|
56
|
+
block.get("text", "")
|
|
57
|
+
for block in content
|
|
58
|
+
if isinstance(block, dict) and block.get("type") == "text"
|
|
59
|
+
]
|
|
60
|
+
return "\n".join(parts)
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
return ""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _build_prompt(spec: str, ref_file_path: str, ref_content: str) -> str:
|
|
67
|
+
return (
|
|
68
|
+
f"You are a code generator. Write code following the exact style and "
|
|
69
|
+
f"conventions shown in the reference file.\n\n"
|
|
70
|
+
f"Reference file ({ref_file_path}):\n"
|
|
71
|
+
f"```\n{ref_content}\n```\n\n"
|
|
72
|
+
f"Task: {spec}\n\n"
|
|
73
|
+
f"Output only the raw code. No markdown formatting, no explanations, no preamble."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _resolve_output_path(
|
|
78
|
+
output_file: str | None,
|
|
79
|
+
ref_file_path: str | None,
|
|
80
|
+
output_dir: str,
|
|
81
|
+
) -> Path:
|
|
82
|
+
if output_file:
|
|
83
|
+
p = Path(output_file)
|
|
84
|
+
if not p.is_absolute():
|
|
85
|
+
p = Path(output_dir) / p
|
|
86
|
+
return p
|
|
87
|
+
|
|
88
|
+
if ref_file_path:
|
|
89
|
+
ref = Path(ref_file_path)
|
|
90
|
+
suffix = ref.suffix or ".txt"
|
|
91
|
+
timestamp = int(time.time())
|
|
92
|
+
return Path(output_dir) / f"{ref.stem}_generated_{timestamp}{suffix}"
|
|
93
|
+
|
|
94
|
+
timestamp = int(time.time())
|
|
95
|
+
return Path(output_dir) / f"generated_{timestamp}.txt"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def _call_anthropic(
|
|
99
|
+
model: str,
|
|
100
|
+
prompt: str,
|
|
101
|
+
api_key: str,
|
|
102
|
+
client: httpx.AsyncClient,
|
|
103
|
+
upstream: str,
|
|
104
|
+
) -> str:
|
|
105
|
+
resp = await client.post(
|
|
106
|
+
f"{upstream}/v1/messages",
|
|
107
|
+
headers={
|
|
108
|
+
"x-api-key": api_key,
|
|
109
|
+
"anthropic-version": "2023-06-01",
|
|
110
|
+
"content-type": "application/json",
|
|
111
|
+
},
|
|
112
|
+
content=json.dumps({
|
|
113
|
+
"model": model,
|
|
114
|
+
"max_tokens": 8192,
|
|
115
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
116
|
+
}).encode(),
|
|
117
|
+
timeout=120.0,
|
|
118
|
+
)
|
|
119
|
+
resp.raise_for_status()
|
|
120
|
+
data = resp.json()
|
|
121
|
+
return data["content"][0]["text"]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def _call_openai(
|
|
125
|
+
model: str,
|
|
126
|
+
prompt: str,
|
|
127
|
+
api_key: str,
|
|
128
|
+
client: httpx.AsyncClient,
|
|
129
|
+
upstream: str,
|
|
130
|
+
) -> str:
|
|
131
|
+
resp = await client.post(
|
|
132
|
+
f"{upstream}/v1/chat/completions",
|
|
133
|
+
headers={
|
|
134
|
+
"authorization": f"Bearer {api_key}",
|
|
135
|
+
"content-type": "application/json",
|
|
136
|
+
},
|
|
137
|
+
content=json.dumps({
|
|
138
|
+
"model": model,
|
|
139
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
140
|
+
}).encode(),
|
|
141
|
+
timeout=120.0,
|
|
142
|
+
)
|
|
143
|
+
resp.raise_for_status()
|
|
144
|
+
data = resp.json()
|
|
145
|
+
return data["choices"][0]["message"]["content"]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def _call_model(
|
|
149
|
+
model: str,
|
|
150
|
+
prompt: str,
|
|
151
|
+
headers: dict[str, str],
|
|
152
|
+
client: httpx.AsyncClient,
|
|
153
|
+
config: FlenseConfig,
|
|
154
|
+
) -> str:
|
|
155
|
+
provider = _infer_provider(model)
|
|
156
|
+
api_key = _get_api_key(provider, headers)
|
|
157
|
+
if not api_key:
|
|
158
|
+
raise ValueError(
|
|
159
|
+
f"No API key found for provider '{provider}' (model '{model}'). "
|
|
160
|
+
f"Pass the key via request headers or set the appropriate env var."
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
provider_config = config.providers.get(provider)
|
|
164
|
+
if provider == "anthropic":
|
|
165
|
+
upstream = (
|
|
166
|
+
provider_config.upstream
|
|
167
|
+
if provider_config and provider_config.upstream
|
|
168
|
+
else _ANTHROPIC_UPSTREAM
|
|
169
|
+
)
|
|
170
|
+
return await _call_anthropic(model, prompt, api_key, client, upstream)
|
|
171
|
+
|
|
172
|
+
upstream = (
|
|
173
|
+
provider_config.upstream
|
|
174
|
+
if provider_config and provider_config.upstream
|
|
175
|
+
else _OPENAI_UPSTREAM
|
|
176
|
+
)
|
|
177
|
+
return await _call_openai(model, prompt, api_key, client, upstream)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def handle_code_writer(
|
|
181
|
+
body: bytes,
|
|
182
|
+
headers: dict[str, str],
|
|
183
|
+
ref_file_header: str | None,
|
|
184
|
+
output_file_header: str | None,
|
|
185
|
+
cw_config: CodeWriterConfig,
|
|
186
|
+
config: FlenseConfig,
|
|
187
|
+
httpx_client: httpx.AsyncClient,
|
|
188
|
+
) -> tuple[str, str]:
|
|
189
|
+
"""Execute the code-writer bypass.
|
|
190
|
+
|
|
191
|
+
Returns (generated_code, output_path_str). Raises on error.
|
|
192
|
+
"""
|
|
193
|
+
if not ref_file_header:
|
|
194
|
+
raise ValueError(
|
|
195
|
+
"X-Flense-Ref-File header is required for the code-writer strategy"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
ref_path = Path(ref_file_header)
|
|
199
|
+
if not ref_path.is_file():
|
|
200
|
+
raise FileNotFoundError(f"Reference file not found: {ref_file_header}")
|
|
201
|
+
ref_content = ref_path.read_text(errors="replace")
|
|
202
|
+
|
|
203
|
+
spec = _extract_spec(body)
|
|
204
|
+
if not spec:
|
|
205
|
+
raise ValueError("Could not extract a spec from the request messages")
|
|
206
|
+
|
|
207
|
+
prompt = _build_prompt(spec, ref_file_header, ref_content)
|
|
208
|
+
|
|
209
|
+
generated_code: str | None = None
|
|
210
|
+
last_error: Exception | None = None
|
|
211
|
+
for model in [cw_config.model, cw_config.fallback]:
|
|
212
|
+
if not model:
|
|
213
|
+
continue
|
|
214
|
+
try:
|
|
215
|
+
generated_code = await _call_model(model, prompt, headers, httpx_client, config)
|
|
216
|
+
logger.info("Code-writer used model %s", model)
|
|
217
|
+
break
|
|
218
|
+
except Exception as exc:
|
|
219
|
+
logger.warning("Code-writer model %s failed: %s", model, exc)
|
|
220
|
+
last_error = exc
|
|
221
|
+
|
|
222
|
+
if generated_code is None:
|
|
223
|
+
raise RuntimeError(f"All code-writer models failed. Last error: {last_error}")
|
|
224
|
+
|
|
225
|
+
output_path = _resolve_output_path(output_file_header, ref_file_header, cw_config.output_dir)
|
|
226
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
227
|
+
output_path.write_text(generated_code)
|
|
228
|
+
logger.info("Code-writer wrote output to %s", output_path)
|
|
229
|
+
|
|
230
|
+
return generated_code, str(output_path)
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from .classifier import detect_signals, resolve_strategy
|
|
10
|
+
from .ctags import compress_with_ctags
|
|
11
|
+
from .estimator import estimate_messages_tokens, estimate_tokens
|
|
12
|
+
from .language import detect_language
|
|
13
|
+
from .regex import compress_with_regex
|
|
14
|
+
from .treesitter import compress_with_treesitter
|
|
15
|
+
from .types import CodeBlock, CompressedBlock, CompressionResult, Strategy
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
_CODE_BLOCK_RE = re.compile(r"```(\w*)\n(.*?)```", re.DOTALL)
|
|
20
|
+
_FILE_HINT_RE = re.compile(r"(?:File|Path|file|path):\s*(\S+)")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def compress_payload(
|
|
24
|
+
body: bytes,
|
|
25
|
+
provider: str,
|
|
26
|
+
threshold: int = 5000,
|
|
27
|
+
header_strategy: str | None = None,
|
|
28
|
+
provider_strategy: str | None = None,
|
|
29
|
+
global_strategy: str = "auto",
|
|
30
|
+
) -> CompressionResult:
|
|
31
|
+
"""Main entry point for the compression pipeline.
|
|
32
|
+
|
|
33
|
+
Takes the raw request body (bytes) and returns a CompressionResult
|
|
34
|
+
containing the (possibly compressed) body and metadata.
|
|
35
|
+
"""
|
|
36
|
+
start_time = time.monotonic()
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
payload = json.loads(body)
|
|
40
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
41
|
+
return _passthrough(body, start_time)
|
|
42
|
+
|
|
43
|
+
if not isinstance(payload, dict):
|
|
44
|
+
return _passthrough(body, start_time)
|
|
45
|
+
|
|
46
|
+
messages = payload.get("messages", [])
|
|
47
|
+
if not messages:
|
|
48
|
+
return _passthrough(body, start_time)
|
|
49
|
+
|
|
50
|
+
system = payload.get("system", "")
|
|
51
|
+
if isinstance(system, list):
|
|
52
|
+
system = " ".join(
|
|
53
|
+
b.get("text", "") for b in system if isinstance(b, dict)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Token estimation
|
|
57
|
+
tokens_before = estimate_messages_tokens(messages, provider)
|
|
58
|
+
if system:
|
|
59
|
+
tokens_before += estimate_tokens(system, provider)
|
|
60
|
+
|
|
61
|
+
if tokens_before < threshold:
|
|
62
|
+
return _passthrough(body, start_time, tokens_before=tokens_before)
|
|
63
|
+
|
|
64
|
+
# Classification
|
|
65
|
+
signals = detect_signals(messages, system if system else None)
|
|
66
|
+
strategy = resolve_strategy(
|
|
67
|
+
header_strategy, provider_strategy, global_strategy, signals,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if strategy == Strategy.PASSTHROUGH:
|
|
71
|
+
return _passthrough(body, start_time, tokens_before=tokens_before)
|
|
72
|
+
|
|
73
|
+
# Extract code blocks
|
|
74
|
+
code_blocks = _extract_code_blocks(messages)
|
|
75
|
+
if not code_blocks:
|
|
76
|
+
return _passthrough(body, start_time, tokens_before=tokens_before)
|
|
77
|
+
|
|
78
|
+
# Compress each block through the fallback chain
|
|
79
|
+
compressed_blocks: list[CompressedBlock] = []
|
|
80
|
+
for block in code_blocks:
|
|
81
|
+
result = _compress_block(block, strategy)
|
|
82
|
+
if result is not None:
|
|
83
|
+
compressed_blocks.append(result)
|
|
84
|
+
|
|
85
|
+
if not compressed_blocks:
|
|
86
|
+
return _passthrough(body, start_time, tokens_before=tokens_before)
|
|
87
|
+
|
|
88
|
+
# Reconstruct the payload with compressed blocks
|
|
89
|
+
compressed_payload = _reconstruct_payload(payload, compressed_blocks)
|
|
90
|
+
compressed_body = json.dumps(compressed_payload).encode("utf-8")
|
|
91
|
+
|
|
92
|
+
# Estimate tokens after compression
|
|
93
|
+
compressed_messages = compressed_payload.get("messages", [])
|
|
94
|
+
tokens_after = estimate_messages_tokens(compressed_messages, provider)
|
|
95
|
+
if system:
|
|
96
|
+
tokens_after += estimate_tokens(system, provider)
|
|
97
|
+
|
|
98
|
+
elapsed_ms = (time.monotonic() - start_time) * 1000
|
|
99
|
+
|
|
100
|
+
return CompressionResult(
|
|
101
|
+
original_body=body,
|
|
102
|
+
compressed_body=compressed_body,
|
|
103
|
+
strategy_applied=strategy,
|
|
104
|
+
tokens_before=tokens_before,
|
|
105
|
+
tokens_after=tokens_after,
|
|
106
|
+
blocks_compressed=len(compressed_blocks),
|
|
107
|
+
blocks_total=len(code_blocks),
|
|
108
|
+
compression_time_ms=elapsed_ms,
|
|
109
|
+
was_compressed=True,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _compress_block(block: CodeBlock, strategy: Strategy) -> CompressedBlock | None:
|
|
114
|
+
"""Apply the fallback chain: tree-sitter -> ctags -> regex."""
|
|
115
|
+
if strategy in (Strategy.AST, Strategy.AUTO):
|
|
116
|
+
result = compress_with_treesitter(block)
|
|
117
|
+
if result is not None:
|
|
118
|
+
return result
|
|
119
|
+
|
|
120
|
+
if strategy in (Strategy.CTAGS, Strategy.AST, Strategy.AUTO):
|
|
121
|
+
result = compress_with_ctags(block)
|
|
122
|
+
if result is not None:
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
# Regex as last resort for any non-passthrough strategy
|
|
126
|
+
result = compress_with_regex(block)
|
|
127
|
+
if result is not None:
|
|
128
|
+
return result
|
|
129
|
+
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _extract_code_blocks(messages: list[dict]) -> list[CodeBlock]:
|
|
134
|
+
"""Extract fenced code blocks from all messages."""
|
|
135
|
+
blocks: list[CodeBlock] = []
|
|
136
|
+
for msg in messages:
|
|
137
|
+
text_parts = _get_text_parts(msg)
|
|
138
|
+
for text in text_parts:
|
|
139
|
+
for match in _CODE_BLOCK_RE.finditer(text):
|
|
140
|
+
fence_tag = match.group(1) or None
|
|
141
|
+
content = match.group(2)
|
|
142
|
+
|
|
143
|
+
# Look for a file path hint in the ~200 chars before the fence
|
|
144
|
+
pre_text = text[max(0, match.start() - 200):match.start()]
|
|
145
|
+
path_match = _FILE_HINT_RE.search(pre_text)
|
|
146
|
+
source_path = path_match.group(1) if path_match else None
|
|
147
|
+
|
|
148
|
+
lang = detect_language(
|
|
149
|
+
content, fence_tag=fence_tag, file_path=source_path,
|
|
150
|
+
)
|
|
151
|
+
blocks.append(CodeBlock(
|
|
152
|
+
content=content,
|
|
153
|
+
language=lang,
|
|
154
|
+
fence_tag=fence_tag,
|
|
155
|
+
source_path=source_path,
|
|
156
|
+
))
|
|
157
|
+
return blocks
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _get_text_parts(msg: dict) -> list[str]:
|
|
161
|
+
"""Extract all text content from a message."""
|
|
162
|
+
content = msg.get("content", "")
|
|
163
|
+
if isinstance(content, str):
|
|
164
|
+
return [content]
|
|
165
|
+
parts: list[str] = []
|
|
166
|
+
if isinstance(content, list):
|
|
167
|
+
for block in content:
|
|
168
|
+
if isinstance(block, dict):
|
|
169
|
+
if block.get("text"):
|
|
170
|
+
parts.append(block["text"])
|
|
171
|
+
inner = block.get("content", "")
|
|
172
|
+
if isinstance(inner, str) and inner:
|
|
173
|
+
parts.append(inner)
|
|
174
|
+
elif isinstance(inner, list):
|
|
175
|
+
for sub in inner:
|
|
176
|
+
if isinstance(sub, dict) and sub.get("text"):
|
|
177
|
+
parts.append(sub["text"])
|
|
178
|
+
return parts
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _reconstruct_payload(
|
|
182
|
+
payload: dict,
|
|
183
|
+
compressed_blocks: list[CompressedBlock],
|
|
184
|
+
) -> dict:
|
|
185
|
+
"""Replace original code blocks in the payload with compressed versions."""
|
|
186
|
+
replacements: dict[str, str] = {}
|
|
187
|
+
for cb in compressed_blocks:
|
|
188
|
+
tag = cb.original.fence_tag or ""
|
|
189
|
+
original_fenced = f"```{tag}\n{cb.original.content}```"
|
|
190
|
+
compressed_fenced = (
|
|
191
|
+
f"```{tag}\n"
|
|
192
|
+
f"# [flense: compressed with {cb.method}]\n"
|
|
193
|
+
f"{cb.compressed}\n```"
|
|
194
|
+
)
|
|
195
|
+
replacements[original_fenced] = compressed_fenced
|
|
196
|
+
|
|
197
|
+
new_payload = copy.deepcopy(payload)
|
|
198
|
+
for msg in new_payload.get("messages", []):
|
|
199
|
+
_apply_replacements(msg, replacements)
|
|
200
|
+
|
|
201
|
+
return new_payload
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _apply_replacements(msg: dict, replacements: dict[str, str]) -> None:
|
|
205
|
+
"""Apply text replacements to a message's content in place."""
|
|
206
|
+
content = msg.get("content")
|
|
207
|
+
if isinstance(content, str):
|
|
208
|
+
for original, compressed in replacements.items():
|
|
209
|
+
content = content.replace(original, compressed)
|
|
210
|
+
msg["content"] = content
|
|
211
|
+
elif isinstance(content, list):
|
|
212
|
+
for block in content:
|
|
213
|
+
if isinstance(block, dict):
|
|
214
|
+
if "text" in block and isinstance(block["text"], str):
|
|
215
|
+
for original, compressed in replacements.items():
|
|
216
|
+
block["text"] = block["text"].replace(original, compressed)
|
|
217
|
+
inner = block.get("content")
|
|
218
|
+
if isinstance(inner, str):
|
|
219
|
+
for original, compressed in replacements.items():
|
|
220
|
+
inner = inner.replace(original, compressed)
|
|
221
|
+
block["content"] = inner
|
|
222
|
+
elif isinstance(inner, list):
|
|
223
|
+
for sub in inner:
|
|
224
|
+
if isinstance(sub, dict) and "text" in sub:
|
|
225
|
+
for original, compressed in replacements.items():
|
|
226
|
+
sub["text"] = sub["text"].replace(original, compressed)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _passthrough(
|
|
230
|
+
body: bytes,
|
|
231
|
+
start_time: float,
|
|
232
|
+
tokens_before: int = 0,
|
|
233
|
+
) -> CompressionResult:
|
|
234
|
+
return CompressionResult(
|
|
235
|
+
original_body=body,
|
|
236
|
+
compressed_body=body,
|
|
237
|
+
strategy_applied=Strategy.PASSTHROUGH,
|
|
238
|
+
tokens_before=tokens_before,
|
|
239
|
+
tokens_after=tokens_before,
|
|
240
|
+
blocks_compressed=0,
|
|
241
|
+
blocks_total=0,
|
|
242
|
+
compression_time_ms=(time.monotonic() - start_time) * 1000,
|
|
243
|
+
was_compressed=False,
|
|
244
|
+
)
|