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 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
+ )