omna-plugin 0.6.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.
File without changes
@@ -0,0 +1,16 @@
1
+ """Entry point for `python -m omna_plugin` and for the bundled binary.
2
+
3
+ The console script in pyproject.toml points at ``cli:main``; PyInstaller needs
4
+ a real module to start from, and having one also makes ``python -m omna_plugin``
5
+ work, which the daemon already relies on when it re-spawns itself.
6
+
7
+ The import is ABSOLUTE on purpose: PyInstaller runs this file as a top-level
8
+ script, where a relative import has no parent package and fails at startup.
9
+ """
10
+
11
+ import sys
12
+
13
+ from omna_plugin.cli import main
14
+
15
+ if __name__ == "__main__":
16
+ sys.exit(main())
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from .base import SiteAdapter
4
+ from .generic import GenericAdapter
5
+
6
+ _GENERIC = GenericAdapter()
7
+ _REGISTRY: list[SiteAdapter] = [] # site adapters register themselves (a later task, not yours)
8
+
9
+
10
+ def register(adapter: SiteAdapter) -> None:
11
+ _REGISTRY.append(adapter)
12
+
13
+
14
+ def for_host(host: str) -> SiteAdapter:
15
+ h = (host or "").lower().rsplit(":", 1)[0]
16
+ for a in _REGISTRY:
17
+ if any(h == s or h.endswith("." + s) for s in a.hosts):
18
+ return a
19
+ return _GENERIC
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ from typing import Literal, Protocol
6
+
7
+ from ..pipeline import MaskStats, Pipeline
8
+
9
+ # Same rule the Chrome extension uses to decide "this POST carries a prompt".
10
+ ENDPOINT_RE = re.compile(
11
+ r"/(append_message|completion|conversation|stream|chat|generate|backend-api|message|messages|prompt|responses|complete|embeddings)",
12
+ re.I,
13
+ )
14
+
15
+ ResponseMode = Literal["stream-json", "stream-text", "json", "passthrough"]
16
+
17
+
18
+ @dataclass
19
+ class RequestView:
20
+ host: str
21
+ method: str
22
+ path: str
23
+ content_type: str
24
+ body: bytes
25
+ headers: dict[str, str] = field(default_factory=dict)
26
+
27
+
28
+ @dataclass
29
+ class MaskOutcome:
30
+ body: bytes | None # the masked body to forward (None = forward original, or refused)
31
+ stats: MaskStats
32
+ refused: str | None = None # set → do NOT forward; answer 400
33
+ passthrough: bool = False # forwarded unchanged on purpose (not a prompt)
34
+
35
+
36
+ class SiteAdapter(Protocol):
37
+ name: str
38
+ hosts: tuple[str, ...]
39
+
40
+ def is_prompt(self, req: RequestView) -> bool: ...
41
+ def mask(self, pipeline: Pipeline, req: RequestView) -> MaskOutcome: ...
42
+ def response_mode(self, content_type: str) -> ResponseMode: ...
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from ..pipeline import MaskStats, Pipeline
4
+ from .base import ENDPOINT_RE, MaskOutcome, RequestView, ResponseMode
5
+
6
+ _MASKABLE = ("json", "x-www-form-urlencoded", "text/")
7
+
8
+
9
+ class GenericAdapter:
10
+ """Default for every AI host: mask every prose string in a prompt body; refuse what we cannot parse."""
11
+
12
+ name = "generic"
13
+ hosts: tuple[str, ...] = ()
14
+
15
+ def is_prompt(self, req: RequestView) -> bool:
16
+ return req.method in ("POST", "PUT", "PATCH") and ENDPOINT_RE.search(req.path) is not None
17
+
18
+ def mask(self, pipeline: Pipeline, req: RequestView) -> MaskOutcome:
19
+ ct = (req.content_type or "").lower()
20
+ maskable = any(m in ct for m in _MASKABLE)
21
+ if not self.is_prompt(req):
22
+ if maskable and req.body:
23
+ out = pipeline.mask_bytes(req.body, ct) # best effort on non-prompt text; never refuse
24
+ if out.refused is None:
25
+ return MaskOutcome(out.body, out.stats)
26
+ return MaskOutcome(None, MaskStats(), passthrough=True)
27
+ if not req.body:
28
+ return MaskOutcome(None, MaskStats(), passthrough=True)
29
+ if not maskable:
30
+ return MaskOutcome(None, MaskStats(), refused="unparseable")
31
+ out = pipeline.mask_bytes(req.body, ct)
32
+ return MaskOutcome(out.body, out.stats, refused=out.refused)
33
+
34
+ def response_mode(self, content_type: str) -> ResponseMode:
35
+ ct = (content_type or "").lower()
36
+ if "event-stream" in ct or "x-ndjson" in ct:
37
+ return "stream-json"
38
+ if "json" in ct:
39
+ return "json"
40
+ if ct.startswith("text/"):
41
+ return "stream-text"
42
+ return "passthrough"
omna_plugin/aider.py ADDED
@@ -0,0 +1,147 @@
1
+ """Wire aider to the proxy by editing its own config files.
2
+
3
+ aider reads two different places depending on which provider you're using:
4
+ - OpenAI: the ``openai-api-base`` key in ``~/.aider.conf.yml``, aider's own
5
+ dedicated config file (https://aider.chat/docs/config/aider_conf.html).
6
+ - Anthropic/Claude: aider runs on litellm underneath, which honours the
7
+ standard ``ANTHROPIC_BASE_URL`` environment variable directly; aider itself
8
+ has no YAML key for this. Rather than touch the person's shell profile
9
+ (which affects every program in every terminal — the exact mistake that
10
+ caused real, hours-long confusion earlier today, 2026-09-19), we set it in
11
+ aider's own auto-loaded ``~/.env`` instead
12
+ (https://aider.chat/docs/config/dotenv.html) — the officially documented
13
+ mechanism for exactly this, and scoped to aider (and anything else that
14
+ chooses to read that file), not every terminal session on the machine.
15
+
16
+ Both files are edited as marked lines, not round-tripped through a YAML or
17
+ dotenv library, so any other settings a person already has stay untouched.
18
+ The ``.env`` edit puts its marker on its own comment line above the value
19
+ (rather than a trailing inline comment) since dotenv parsers vary on whether
20
+ an inline ``#`` after an unquoted value is treated as a comment or as part of
21
+ the value — getting that wrong here would silently point aider at a broken
22
+ address instead of Omna, an easy way to end up sending things unmasked.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import re
28
+ import shutil
29
+ from pathlib import Path
30
+
31
+ from . import config
32
+
33
+ MARK = "# added by omna"
34
+ _YAML_KEY_RE = re.compile(r'^openai-api-base:.*$', re.MULTILINE)
35
+ _ENV_BLOCK_RE = re.compile(rf'^{re.escape(MARK)}\nANTHROPIC_BASE_URL=.*$', re.MULTILINE)
36
+ _ENV_KEY_RE = re.compile(r'^ANTHROPIC_BASE_URL=.*$', re.MULTILINE)
37
+
38
+
39
+ def conf_file() -> Path:
40
+ return Path.home() / ".aider.conf.yml"
41
+
42
+
43
+ def env_file() -> Path:
44
+ return Path.home() / ".env"
45
+
46
+
47
+ def _backup(path: Path, changes: dict, key: str) -> None:
48
+ if path.exists():
49
+ backup = path.with_name(path.name + ".omna-backup")
50
+ if not backup.exists():
51
+ shutil.copyfile(path, backup)
52
+ changes[key] = str(backup)
53
+
54
+
55
+ def _set_yaml_key(port: int, changes: dict) -> None:
56
+ path = conf_file()
57
+ text = path.read_text() if path.exists() else ""
58
+ new_line = f'openai-api-base: "{config.base_url(port)}/v1" {MARK}'
59
+ existing = _YAML_KEY_RE.search(text)
60
+ if existing and MARK not in existing.group(0):
61
+ return # someone set this deliberately; don't fight it silently
62
+ _backup(path, changes, "openai_backup")
63
+ if existing:
64
+ if existing.group(0).strip() == new_line.strip():
65
+ return
66
+ text = _YAML_KEY_RE.sub(new_line, text, count=1)
67
+ else:
68
+ sep = "" if not text or text.endswith("\n") else "\n"
69
+ text = f"{text}{sep}{new_line}\n"
70
+ path.write_text(text)
71
+ changes["openai_base"] = True
72
+
73
+
74
+ def _set_env_block(port: int, changes: dict) -> None:
75
+ path = env_file()
76
+ text = path.read_text() if path.exists() else ""
77
+ _backup(path, changes, "anthropic_backup")
78
+ new_block = f"{MARK}\nANTHROPIC_BASE_URL={config.base_url(port)}"
79
+ existing = _ENV_BLOCK_RE.search(text)
80
+ if existing:
81
+ if existing.group(0).strip() == new_block.strip():
82
+ return
83
+ text = _ENV_BLOCK_RE.sub(new_block, text, count=1)
84
+ elif _ENV_KEY_RE.search(text):
85
+ # a value we didn't set (no marker line above it) — leave it alone,
86
+ # someone configured this deliberately; don't fight it silently.
87
+ return
88
+ else:
89
+ sep = "" if not text or text.endswith("\n") else "\n"
90
+ text = f"{text}{sep}{new_block}\n"
91
+ path.write_text(text)
92
+ changes["anthropic_base"] = True
93
+
94
+
95
+ def init(port: int = config.DEFAULT_PORT) -> dict:
96
+ changes = {"openai_base": False, "anthropic_base": False, "openai_backup": None, "anthropic_backup": None}
97
+ _set_yaml_key(port, changes)
98
+ _set_env_block(port, changes)
99
+ return changes
100
+
101
+
102
+ def uninstall() -> dict:
103
+ """Remove exactly what ``init`` added; leave everything else untouched."""
104
+ changes = {"openai_base": False, "anthropic_base": False, "openai_backup": False, "anthropic_backup": False}
105
+
106
+ conf = conf_file()
107
+ conf_backup = conf.with_name(conf.name + ".omna-backup")
108
+ if conf_backup.exists():
109
+ conf_backup.unlink()
110
+ changes["openai_backup"] = True
111
+ if conf.exists():
112
+ text = conf.read_text()
113
+ m = _YAML_KEY_RE.search(text)
114
+ if m and MARK in m.group(0):
115
+ text = _YAML_KEY_RE.sub("", text, count=1)
116
+ text = re.sub(r"\n{3,}", "\n\n", text)
117
+ conf.write_text(text)
118
+ changes["openai_base"] = True
119
+
120
+ env = env_file()
121
+ env_backup = env.with_name(env.name + ".omna-backup")
122
+ if env_backup.exists():
123
+ env_backup.unlink()
124
+ changes["anthropic_backup"] = True
125
+ if env.exists():
126
+ text = env.read_text()
127
+ m = _ENV_BLOCK_RE.search(text)
128
+ if m:
129
+ text = _ENV_BLOCK_RE.sub("", text, count=1)
130
+ text = re.sub(r"\n{3,}", "\n\n", text)
131
+ env.write_text(text)
132
+ changes["anthropic_base"] = True
133
+
134
+ return changes
135
+
136
+
137
+ def status() -> dict:
138
+ conf_text = conf_file().read_text() if conf_file().exists() else ""
139
+ env_text = env_file().read_text() if env_file().exists() else ""
140
+ m1 = _YAML_KEY_RE.search(conf_text)
141
+ m2 = _ENV_BLOCK_RE.search(env_text)
142
+ return {
143
+ "openai_base_file": str(conf_file()),
144
+ "openai_base": m1.group(0).split(":", 1)[1].split(MARK)[0].strip().strip('"') if m1 else None,
145
+ "anthropic_base_file": str(env_file()),
146
+ "anthropic_base": m2.group(0).splitlines()[1].split("=", 1)[1].strip() if m2 else None,
147
+ }
Binary file
Binary file
omna_plugin/body.py ADDED
@@ -0,0 +1,94 @@
1
+ """Walk JSON request/response bodies, masking or restoring the human text.
2
+
3
+ Generic on purpose: Anthropic Messages, OpenAI chat completions and the OpenAI
4
+ Responses API all put text in nested dicts/lists. We mask every string value
5
+ except the ones that must stay byte-identical (signatures, thinking blocks,
6
+ base64 media, tool schemas, config fields) and skip anything that looks like a
7
+ binary blob.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ from .engine import MaskingSession
15
+
16
+ # Keys whose values are never human prose, or must reach the upstream untouched.
17
+ SKIP_KEYS = frozenset(
18
+ {
19
+ # identity / config
20
+ "id", "model", "type", "role", "name", "tool_use_id", "tool_call_id",
21
+ "call_id", "media_type", "stop_reason", "stop_sequence", "stop_sequences",
22
+ "url", "file_id", "service_tier", "anthropic_version", "stream",
23
+ "tool_choice", "output_config", "context_management",
24
+ "mcp_servers", "container", "response_format", "modalities", "user",
25
+ # must be byte-identical for the API to accept the request back
26
+ "signature", "thinking", "redacted_thinking", "cache_control",
27
+ # binary / opaque
28
+ "data", "image_url", "b64_json", "encrypted_content",
29
+ # tool definitions are schemas, not prose
30
+ "tools", "functions", "input_schema", "parameters",
31
+ }
32
+ )
33
+
34
+ # Long runs with no whitespace and only base64 characters: media, not prose.
35
+ _BLOB_RE = re.compile(r"^[A-Za-z0-9+/=_\-]{256,}$")
36
+
37
+
38
+ def _is_blob(s: str) -> bool:
39
+ return len(s) >= 256 and _BLOB_RE.match(s) is not None
40
+
41
+
42
+ def _walk(obj, fn, counts: dict[str, int], key: str | None = None):
43
+ if isinstance(obj, str):
44
+ if key in SKIP_KEYS or _is_blob(obj):
45
+ return obj
46
+ return fn(obj, counts)
47
+ if isinstance(obj, list):
48
+ if key in SKIP_KEYS:
49
+ return obj
50
+ return [_walk(item, fn, counts, key) for item in obj]
51
+ if isinstance(obj, dict):
52
+ if key in SKIP_KEYS:
53
+ return obj
54
+ out = {}
55
+ for k, v in obj.items():
56
+ out[k] = _walk(v, fn, counts, k)
57
+ return out
58
+ return obj
59
+
60
+
61
+ def mask_body(session: MaskingSession, obj):
62
+ """Return (masked_copy, counts). ``obj`` is not modified.
63
+
64
+ ``counts`` maps entity name -> occurrences and carries reserved keys that
65
+ the receipt writer pops off: ``_secrets`` / ``_pii`` (totals by layer),
66
+ ``_validated`` (catches a checksum actually proved) and ``_layer_L1`` etc.
67
+ """
68
+ counts: dict[str, int] = {}
69
+
70
+ def fn(s: str, c: dict[str, int]) -> str:
71
+ r = session.mask_text(s)
72
+ for k, n in r.counts.items():
73
+ c[k] = c.get(k, 0) + n
74
+ if r.secrets:
75
+ c["_secrets"] = c.get("_secrets", 0) + r.secrets
76
+ if r.pii:
77
+ c["_pii"] = c.get("_pii", 0) + r.pii
78
+ if r.validated:
79
+ c["_validated"] = c.get("_validated", 0) + r.validated
80
+ for layer, n in r.by_layer.items():
81
+ k = f"_layer_{layer}"
82
+ c[k] = c.get(k, 0) + n
83
+ return r.masked
84
+
85
+ return _walk(obj, fn, counts), counts
86
+
87
+
88
+ def restore_body(session: MaskingSession, obj):
89
+ """Return a copy with every reversible token replaced by its real value."""
90
+
91
+ def fn(s: str, _c) -> str:
92
+ return session.restore_text(s)
93
+
94
+ return _walk(obj, fn, {})
@@ -0,0 +1,120 @@
1
+ """Wire Claude Code to the proxy by editing its ``settings.json``.
2
+
3
+ Claude Code reads an ``env`` block from its settings, so we set
4
+ ``ANTHROPIC_BASE_URL`` there (no shell-profile edits) and add a ``SessionStart``
5
+ hook that runs ``omna ensure`` so the proxy is started on demand. Both edits are
6
+ marked and reversible; ``uninstall`` removes only what ``init`` added.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import shutil
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from . import config
17
+
18
+ ENV_KEY = "ANTHROPIC_BASE_URL"
19
+ HOOK_MARK = "omna ensure"
20
+
21
+
22
+ def settings_file(scope: str = "user") -> Path:
23
+ if scope == "project":
24
+ return Path.cwd() / ".claude" / "settings.json"
25
+ return Path.home() / ".claude" / "settings.json"
26
+
27
+
28
+ def _load(path: Path) -> dict:
29
+ if not path.exists():
30
+ return {}
31
+ try:
32
+ return json.loads(path.read_text() or "{}")
33
+ except ValueError as e:
34
+ raise SystemExit(f"omna: {path} is not valid JSON ({e}); fix it first, nothing was changed")
35
+
36
+
37
+ def _omna_command() -> str:
38
+ """Absolute command for the hook, so it works even if ~/.local/bin is not on PATH."""
39
+ exe = shutil.which("omna")
40
+ if exe:
41
+ return f'"{exe}" ensure' if " " in exe else f"{exe} ensure"
42
+ return f'"{sys.executable}" -m omna_plugin.cli ensure'
43
+
44
+
45
+ def _is_ours(hook: dict) -> bool:
46
+ cmd = str(hook.get("command", "")).strip()
47
+ return "omna" in cmd and cmd.endswith(" ensure")
48
+
49
+
50
+ def init(path: Path, port: int = config.DEFAULT_PORT) -> dict:
51
+ """Add our env var + SessionStart hook. Returns what changed."""
52
+ data = _load(path)
53
+ changes = {"backup": None, "env": False, "hook": False}
54
+ if path.exists():
55
+ backup = path.with_name(path.name + ".omna-backup")
56
+ if not backup.exists():
57
+ shutil.copyfile(path, backup)
58
+ changes["backup"] = str(backup)
59
+ env = data.setdefault("env", {})
60
+ url = config.base_url(port)
61
+ if env.get(ENV_KEY) != url:
62
+ env[ENV_KEY] = url
63
+ changes["env"] = True
64
+ hooks = data.setdefault("hooks", {})
65
+ starts = hooks.setdefault("SessionStart", [])
66
+ already = any(_is_ours(h) for entry in starts for h in entry.get("hooks", []))
67
+ if not already:
68
+ starts.append({"hooks": [{"type": "command", "command": _omna_command(), "timeout": 30}]})
69
+ changes["hook"] = True
70
+ path.parent.mkdir(parents=True, exist_ok=True)
71
+ path.write_text(json.dumps(data, indent=2) + "\n")
72
+ return changes
73
+
74
+
75
+ def uninstall(path: Path) -> dict:
76
+ """Remove exactly what ``init`` added; leave everything else untouched."""
77
+ changes = {"env": False, "hook": False, "backup": False}
78
+ backup = path.with_name(path.name + ".omna-backup")
79
+ if backup.exists():
80
+ backup.unlink()
81
+ changes["backup"] = True
82
+ if not path.exists():
83
+ return changes
84
+ data = _load(path)
85
+ env = data.get("env") or {}
86
+ if isinstance(env.get(ENV_KEY), str) and env[ENV_KEY].startswith(f"http://{config.DEFAULT_HOST}:"):
87
+ del env[ENV_KEY]
88
+ changes["env"] = True
89
+ if not env:
90
+ data.pop("env", None)
91
+ hooks = data.get("hooks") or {}
92
+ starts = hooks.get("SessionStart") or []
93
+ kept = []
94
+ for entry in starts:
95
+ inner = [h for h in entry.get("hooks", []) if not _is_ours(h)]
96
+ if len(inner) != len(entry.get("hooks", [])):
97
+ changes["hook"] = True
98
+ if inner:
99
+ entry = dict(entry, hooks=inner)
100
+ kept.append(entry)
101
+ if starts:
102
+ if kept:
103
+ hooks["SessionStart"] = kept
104
+ else:
105
+ hooks.pop("SessionStart", None)
106
+ if not hooks:
107
+ data.pop("hooks", None)
108
+ path.write_text(json.dumps(data, indent=2) + "\n")
109
+ return changes
110
+
111
+
112
+ def status(path: Path) -> dict:
113
+ data = _load(path) if path.exists() else {}
114
+ env = data.get("env") or {}
115
+ starts = (data.get("hooks") or {}).get("SessionStart") or []
116
+ return {
117
+ "file": str(path),
118
+ "base_url": env.get(ENV_KEY),
119
+ "hook": any(_is_ours(h) for e in starts for h in e.get("hooks", [])),
120
+ }