apl-sidecar 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.
Files changed (83) hide show
  1. adapters/__init__.py +0 -0
  2. adapters/base.py +37 -0
  3. adapters/byok/__init__.py +0 -0
  4. adapters/byok/_http.py +66 -0
  5. adapters/byok/anthropic_provider.py +104 -0
  6. adapters/byok/openai_provider.py +116 -0
  7. adapters/local_stub/__init__.py +0 -0
  8. adapters/local_stub/provider.py +16 -0
  9. adapters/mock.py +30 -0
  10. adapters/mock_provider_a/__init__.py +0 -0
  11. adapters/mock_provider_a/provider.py +11 -0
  12. adapters/mock_provider_b/__init__.py +0 -0
  13. adapters/mock_provider_b/provider.py +11 -0
  14. adapters/registry.py +27 -0
  15. apl_sidecar-0.1.0.dist-info/METADATA +271 -0
  16. apl_sidecar-0.1.0.dist-info/RECORD +83 -0
  17. apl_sidecar-0.1.0.dist-info/WHEEL +5 -0
  18. apl_sidecar-0.1.0.dist-info/entry_points.txt +2 -0
  19. apl_sidecar-0.1.0.dist-info/licenses/LICENSE +21 -0
  20. apl_sidecar-0.1.0.dist-info/top_level.txt +7 -0
  21. app/__init__.py +0 -0
  22. app/local_playground/README.md +14 -0
  23. app/local_playground/app.js +352 -0
  24. app/local_playground/index.html +142 -0
  25. app/local_playground/style.css +95 -0
  26. cli/__init__.py +0 -0
  27. cli/apl.py +179 -0
  28. cli/commands/__init__.py +0 -0
  29. cli/commands/_common.py +267 -0
  30. cli/commands/_resources.py +78 -0
  31. cli/commands/_signing.py +52 -0
  32. cli/commands/break_receipt.py +31 -0
  33. cli/commands/demo.py +145 -0
  34. cli/commands/inspect.py +59 -0
  35. cli/commands/mask.py +37 -0
  36. cli/commands/preview.py +35 -0
  37. cli/commands/rehydrate.py +30 -0
  38. cli/commands/run_live.py +413 -0
  39. cli/commands/run_mock.py +95 -0
  40. cli/commands/verify.py +17 -0
  41. examples/00_private_idea/README.md +56 -0
  42. examples/00_private_idea/final_rehydrated_answer.txt +34 -0
  43. examples/00_private_idea/input.original.example.txt +35 -0
  44. examples/00_private_idea/inspect.expected.txt +35 -0
  45. examples/00_private_idea/local_only.json +9 -0
  46. examples/00_private_idea/masking_plan.yaml +24 -0
  47. examples/00_private_idea/mock_answer_a.txt +27 -0
  48. examples/00_private_idea/mock_answer_b.txt +26 -0
  49. examples/00_private_idea/provider_a_payload.txt +10 -0
  50. examples/00_private_idea/provider_b_payload.txt +9 -0
  51. examples/00_private_idea/receipt.json +78 -0
  52. examples/00_private_idea/tampered_receipt.example.json +78 -0
  53. examples/01_private_code_context/README.md +51 -0
  54. examples/01_private_code_context/final_rehydrated_answer.txt +26 -0
  55. examples/01_private_code_context/input.original.example.txt +44 -0
  56. examples/01_private_code_context/inspect.expected.txt +35 -0
  57. examples/01_private_code_context/local_only.json +9 -0
  58. examples/01_private_code_context/masking_plan.yaml +21 -0
  59. examples/01_private_code_context/mock_answer_a.txt +30 -0
  60. examples/01_private_code_context/mock_answer_b.txt +24 -0
  61. examples/01_private_code_context/provider_a_payload.txt +16 -0
  62. examples/01_private_code_context/provider_b_payload.txt +9 -0
  63. examples/01_private_code_context/receipt.json +78 -0
  64. examples/01_private_code_context/tampered_receipt.example.json +78 -0
  65. examples/02_market_entry_three_way/README.md +46 -0
  66. examples/02_market_entry_three_way/final_rehydrated_answer.txt +29 -0
  67. examples/02_market_entry_three_way/input.original.example.txt +30 -0
  68. examples/02_market_entry_three_way/local_only.json +7 -0
  69. examples/02_market_entry_three_way/masking_plan.yaml +31 -0
  70. examples/02_market_entry_three_way/mock_answer_a.txt +20 -0
  71. examples/02_market_entry_three_way/mock_answer_b.txt +17 -0
  72. examples/02_market_entry_three_way/mock_answer_c.txt +20 -0
  73. examples/02_market_entry_three_way/provider_a_payload.txt +8 -0
  74. examples/02_market_entry_three_way/provider_b_payload.txt +8 -0
  75. examples/02_market_entry_three_way/provider_c_payload.txt +8 -0
  76. examples/__init__.py +0 -0
  77. relay/__init__.py +2 -0
  78. relay/openai_proxy.py +104 -0
  79. relay/relay_server.py +161 -0
  80. spec/__init__.py +0 -0
  81. spec/apl-oss-demo-key.pem +3 -0
  82. spec/demo_policy_manifest.json +9 -0
  83. verifier/apl_verify.py +368 -0
adapters/__init__.py ADDED
File without changes
adapters/base.py ADDED
@@ -0,0 +1,37 @@
1
+ """Provider adapter contract shared by offline and future network providers."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any, Protocol, runtime_checkable
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ProviderCapabilities:
11
+ network: bool
12
+ streaming: bool = False
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class ProviderRequest:
17
+ prompt: str
18
+ model: str
19
+ fixture_dir: Path | None = None
20
+ metadata: dict[str, Any] = field(default_factory=dict)
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ProviderResponse:
25
+ text: str
26
+ provider_id: str
27
+ model: str
28
+ metadata: dict[str, Any] = field(default_factory=dict)
29
+
30
+
31
+ @runtime_checkable
32
+ class ProviderAdapter(Protocol):
33
+ provider_id: str
34
+ capabilities: ProviderCapabilities
35
+
36
+ def complete(self, request: ProviderRequest) -> ProviderResponse: ...
37
+
File without changes
adapters/byok/_http.py ADDED
@@ -0,0 +1,66 @@
1
+ """Minimal JSON POST transport for BYOK adapters. Stdlib only, fail-close.
2
+
3
+ Design rules (mirrors the receipt philosophy: evidence over trust):
4
+ - hard timeout and response size cap — a hung or oversized response is a failure;
5
+ - secrets passed by the caller are scrubbed from every raised message, so an
6
+ API key can never leak through a traceback, log line, or receipt;
7
+ - no retries — a live call either happened once or it did not, which keeps
8
+ provider_events honest.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import urllib.error
14
+ import urllib.request
15
+ from typing import Any
16
+
17
+ DEFAULT_TIMEOUT_S = 60.0
18
+ MAX_RESPONSE_BYTES = 2 * 1024 * 1024 # 2 MiB
19
+
20
+
21
+ class ByokConfigError(ValueError):
22
+ """Adapter is not configured correctly (missing key, model, or URL)."""
23
+
24
+
25
+ class TransportError(RuntimeError):
26
+ """Network call failed. Message is always secret-scrubbed."""
27
+
28
+
29
+ def _scrub(message: str, secrets: tuple[str, ...]) -> str:
30
+ for secret in secrets:
31
+ if secret:
32
+ message = message.replace(secret, "[redacted]")
33
+ return message
34
+
35
+
36
+ def post_json(url: str, headers: dict[str, str], body: dict[str, Any],
37
+ timeout: float = DEFAULT_TIMEOUT_S,
38
+ max_bytes: int = MAX_RESPONSE_BYTES,
39
+ secrets: tuple[str, ...] = ()) -> dict[str, Any]:
40
+ """POST a JSON body, return the parsed JSON response. Raises TransportError."""
41
+ data = json.dumps(body).encode("utf-8")
42
+ request = urllib.request.Request(
43
+ url, data=data, method="POST",
44
+ headers={**headers, "Content-Type": "application/json"})
45
+ try:
46
+ with urllib.request.urlopen(request, timeout=timeout) as response:
47
+ raw = response.read(max_bytes + 1)
48
+ except urllib.error.HTTPError as exc:
49
+ detail = ""
50
+ try:
51
+ detail = exc.read(500).decode("utf-8", errors="replace")
52
+ except Exception: # noqa: BLE001 — never let error reporting raise
53
+ pass
54
+ raise TransportError(_scrub(
55
+ f"provider returned HTTP {exc.code}: {detail}".strip(), secrets)) from None
56
+ except Exception as exc: # URLError, timeout, ConnectionError, ...
57
+ raise TransportError(_scrub(f"request failed: {exc}", secrets)) from None
58
+ if len(raw) > max_bytes:
59
+ raise TransportError(f"response exceeded {max_bytes} bytes")
60
+ try:
61
+ parsed = json.loads(raw.decode("utf-8"))
62
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
63
+ raise TransportError(_scrub(f"response is not valid JSON: {exc}", secrets)) from None
64
+ if not isinstance(parsed, dict):
65
+ raise TransportError("response JSON is not an object")
66
+ return parsed
@@ -0,0 +1,104 @@
1
+ """BYOK adapter: Anthropic Messages API. Network, your key, off by default.
2
+
3
+ Configuration is environment-only — a key never appears on a command line
4
+ (shell history) or in a config file (accidental commit):
5
+
6
+ ANTHROPIC_API_KEY required
7
+ APL_ANTHROPIC_MODEL optional, default claude-sonnet-4-6
8
+ APL_ANTHROPIC_MODEL_A/_B optional per-seat override
9
+ APL_ANTHROPIC_BASE_URL optional, default https://api.anthropic.com
10
+ (corporate gateways / test harnesses)
11
+ APL_ANTHROPIC_MAX_TOKENS optional, default 1024
12
+
13
+ The adapter sends exactly one user message containing the approved provider
14
+ payload — no system prompt, no metadata, temperature 0. What is sent is what
15
+ the receipt hashes.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ from dataclasses import dataclass, field
21
+ from typing import Any, Callable
22
+ from urllib.parse import urlparse
23
+
24
+ from ..base import ProviderCapabilities, ProviderRequest, ProviderResponse
25
+ from . import _http
26
+ from ._http import ByokConfigError
27
+
28
+ ANTHROPIC_VERSION = "2023-06-01"
29
+ DEFAULT_BASE_URL = "https://api.anthropic.com"
30
+ DEFAULT_MODEL = "claude-sonnet-4-6"
31
+
32
+ Transport = Callable[..., dict[str, Any]]
33
+
34
+
35
+ def _seat_env(name: str, seat: str | None) -> str | None:
36
+ if seat:
37
+ value = os.environ.get(f"{name}_{seat.upper()}")
38
+ if value:
39
+ return value
40
+ return os.environ.get(name)
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class AnthropicAdapter:
45
+ api_key: str
46
+ model: str = DEFAULT_MODEL
47
+ base_url: str = DEFAULT_BASE_URL
48
+ max_tokens: int = 1024
49
+ timeout: float = _http.DEFAULT_TIMEOUT_S
50
+ provider_id: str = "byok_anthropic"
51
+ capabilities: ProviderCapabilities = ProviderCapabilities(network=True)
52
+ transport: Transport = field(default=_http.post_json, repr=False)
53
+
54
+ @classmethod
55
+ def from_env(cls, seat: str | None = None, provider_id: str | None = None,
56
+ transport: Transport | None = None) -> "AnthropicAdapter":
57
+ api_key = os.environ.get("ANTHROPIC_API_KEY", "")
58
+ if not api_key:
59
+ raise ByokConfigError("ANTHROPIC_API_KEY is not set")
60
+ return cls(
61
+ api_key=api_key,
62
+ model=_seat_env("APL_ANTHROPIC_MODEL", seat) or DEFAULT_MODEL,
63
+ base_url=os.environ.get("APL_ANTHROPIC_BASE_URL", DEFAULT_BASE_URL).rstrip("/"),
64
+ max_tokens=int(os.environ.get("APL_ANTHROPIC_MAX_TOKENS", "1024")),
65
+ provider_id=provider_id or "byok_anthropic",
66
+ transport=transport or _http.post_json)
67
+
68
+ @property
69
+ def endpoint(self) -> str:
70
+ return f"{self.base_url}/v1/messages"
71
+
72
+ @property
73
+ def endpoint_host(self) -> str:
74
+ return urlparse(self.base_url).hostname or self.base_url
75
+
76
+ def complete(self, request: ProviderRequest) -> ProviderResponse:
77
+ body = {"model": request.model or self.model,
78
+ "max_tokens": self.max_tokens,
79
+ "temperature": 0,
80
+ "messages": [{"role": "user", "content": request.prompt}]}
81
+ headers = {"x-api-key": self.api_key, "anthropic-version": ANTHROPIC_VERSION}
82
+ data = self.transport(self.endpoint, headers, body,
83
+ timeout=self.timeout, secrets=(self.api_key,))
84
+ blocks = data.get("content")
85
+ if not isinstance(blocks, list):
86
+ raise _http.TransportError("anthropic response missing content blocks")
87
+ text = "\n".join(b.get("text", "") for b in blocks
88
+ if isinstance(b, dict) and b.get("type") == "text")
89
+ stop_reason = data.get("stop_reason")
90
+ # Tri-state honesty: only known-complete stop reasons count as
91
+ # complete; anything unexpected is "unknown", never silently complete.
92
+ if stop_reason == "max_tokens":
93
+ completion = "truncated"
94
+ elif stop_reason in ("end_turn", "stop_sequence"):
95
+ completion = "complete"
96
+ else:
97
+ completion = "unknown"
98
+ return ProviderResponse(
99
+ text=text, provider_id=self.provider_id, model=body["model"],
100
+ metadata={"stop_reason": stop_reason,
101
+ "completion": completion,
102
+ "truncated": completion == "truncated",
103
+ "usage": data.get("usage"),
104
+ "endpoint_host": self.endpoint_host})
@@ -0,0 +1,116 @@
1
+ """BYOK adapter: any OpenAI-compatible /v1/chat/completions endpoint.
2
+
3
+ This one seat covers three deployments with the same code path:
4
+ - OpenAI (or another hosted OpenAI-compatible vendor);
5
+ - a local model server (vLLM, Ollama, llama.cpp) on loopback — the
6
+ customer-controlled "local seat" the enterprise docs describe;
7
+ - the repo's own offline mock proxy (`apl proxy`), which turns the entire
8
+ live pipeline into an offline end-to-end rehearsal.
9
+
10
+ Configuration is environment-only:
11
+
12
+ OPENAI_API_KEY required, EXCEPT when the endpoint is loopback
13
+ APL_OPENAI_BASE_URL optional, default https://api.openai.com/v1
14
+ APL_OPENAI_BASE_URL_A/_B optional per-seat override
15
+ APL_OPENAI_MODEL required (no default — "OpenAI-compatible"
16
+ does not imply any particular model exists)
17
+ APL_OPENAI_MODEL_A/_B optional per-seat override
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ from dataclasses import dataclass, field
23
+ from typing import Any, Callable
24
+ from urllib.parse import urlparse
25
+
26
+ from ..base import ProviderCapabilities, ProviderRequest, ProviderResponse
27
+ from . import _http
28
+ from ._http import ByokConfigError
29
+
30
+ DEFAULT_BASE_URL = "https://api.openai.com/v1"
31
+ _LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"}
32
+
33
+ Transport = Callable[..., dict[str, Any]]
34
+
35
+
36
+ def _seat_env(name: str, seat: str | None) -> str | None:
37
+ if seat:
38
+ value = os.environ.get(f"{name}_{seat.upper()}")
39
+ if value:
40
+ return value
41
+ return os.environ.get(name)
42
+
43
+
44
+ def is_loopback(base_url: str) -> bool:
45
+ return (urlparse(base_url).hostname or "") in _LOOPBACK_HOSTS
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class OpenAICompatAdapter:
50
+ model: str
51
+ api_key: str = ""
52
+ base_url: str = DEFAULT_BASE_URL
53
+ timeout: float = _http.DEFAULT_TIMEOUT_S
54
+ provider_id: str = "byok_openai"
55
+ capabilities: ProviderCapabilities = ProviderCapabilities(network=True)
56
+ transport: Transport = field(default=_http.post_json, repr=False)
57
+
58
+ @classmethod
59
+ def from_env(cls, seat: str | None = None, provider_id: str | None = None,
60
+ transport: Transport | None = None) -> "OpenAICompatAdapter":
61
+ base_url = (_seat_env("APL_OPENAI_BASE_URL", seat) or DEFAULT_BASE_URL).rstrip("/")
62
+ api_key = os.environ.get("OPENAI_API_KEY", "")
63
+ if not api_key and not is_loopback(base_url):
64
+ raise ByokConfigError(
65
+ "OPENAI_API_KEY is not set and the endpoint is not loopback")
66
+ model = _seat_env("APL_OPENAI_MODEL", seat)
67
+ if not model:
68
+ raise ByokConfigError("APL_OPENAI_MODEL is not set")
69
+ return cls(model=model, api_key=api_key, base_url=base_url,
70
+ provider_id=provider_id or "byok_openai",
71
+ transport=transport or _http.post_json)
72
+
73
+ @property
74
+ def endpoint(self) -> str:
75
+ return f"{self.base_url}/v1/chat/completions" \
76
+ if not self.base_url.endswith("/v1") else f"{self.base_url}/chat/completions"
77
+
78
+ @property
79
+ def endpoint_host(self) -> str:
80
+ return urlparse(self.base_url).hostname or self.base_url
81
+
82
+ def complete(self, request: ProviderRequest) -> ProviderResponse:
83
+ body = {"model": request.model or self.model,
84
+ "temperature": 0,
85
+ "stream": False,
86
+ "messages": [{"role": "user", "content": request.prompt}]}
87
+ headers = {}
88
+ if self.api_key:
89
+ headers["Authorization"] = f"Bearer {self.api_key}"
90
+ data = self.transport(self.endpoint, headers, body,
91
+ timeout=self.timeout, secrets=(self.api_key,))
92
+ choices = data.get("choices")
93
+ if not isinstance(choices, list) or not choices:
94
+ raise _http.TransportError("openai-compatible response missing choices")
95
+ message = choices[0].get("message") if isinstance(choices[0], dict) else None
96
+ text = message.get("content") if isinstance(message, dict) else None
97
+ if not isinstance(text, str):
98
+ raise _http.TransportError("openai-compatible response missing message content")
99
+ finish_reason = choices[0].get("finish_reason")
100
+ # Tri-state honesty: absence of "length" is NOT proof of completeness.
101
+ # vLLM/Ollama/llama.cpp variants return null or vendor-specific reasons
102
+ # for silently capped output — those must surface as "unknown", never
103
+ # masquerade as complete.
104
+ if finish_reason == "length":
105
+ completion = "truncated"
106
+ elif finish_reason == "stop":
107
+ completion = "complete"
108
+ else:
109
+ completion = "unknown"
110
+ return ProviderResponse(
111
+ text=text, provider_id=self.provider_id, model=body["model"],
112
+ metadata={"finish_reason": finish_reason,
113
+ "completion": completion,
114
+ "truncated": completion == "truncated",
115
+ "usage": data.get("usage"),
116
+ "endpoint_host": self.endpoint_host})
File without changes
@@ -0,0 +1,16 @@
1
+ """Local stub — deterministic placeholder for local-model execution.
2
+
3
+ In enterprise deployments this seat is taken by a customer-controlled local
4
+ model endpoint. In P0 it simply echoes a deterministic marker so flows that
5
+ route a fragment to "local" have a well-defined, offline behavior.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+
11
+ PROVIDER_ID = "local_stub"
12
+
13
+
14
+ def respond(prompt_text: str) -> str:
15
+ digest = hashlib.sha256(prompt_text.encode("utf-8")).hexdigest()[:12]
16
+ return f"[local-stub] processed locally ({digest})"
adapters/mock.py ADDED
@@ -0,0 +1,30 @@
1
+ """Explicit, fixture-backed offline provider adapters."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+ from .base import ProviderCapabilities, ProviderRequest, ProviderResponse
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class MockProviderAdapter:
11
+ provider_id: str
12
+ fixture_name: str
13
+ model: str
14
+ capabilities: ProviderCapabilities = ProviderCapabilities(network=False)
15
+
16
+ def complete(self, request: ProviderRequest) -> ProviderResponse:
17
+ if request.fixture_dir is None:
18
+ text = f"Offline mock completion from {self.provider_id}."
19
+ else:
20
+ text = (request.fixture_dir / self.fixture_name).read_text(encoding="utf-8")
21
+ return ProviderResponse(text=text, provider_id=self.provider_id, model=request.model)
22
+
23
+
24
+ def default_registry():
25
+ from .registry import ProviderRegistry
26
+
27
+ registry = ProviderRegistry()
28
+ registry.register(MockProviderAdapter("mock_provider_a", "mock_answer_a.txt", "apl-mock-a"))
29
+ registry.register(MockProviderAdapter("mock_provider_b", "mock_answer_b.txt", "apl-mock-b"))
30
+ return registry
File without changes
@@ -0,0 +1,11 @@
1
+ """Mock provider A — offline fixture-backed. Never touches the network."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ PROVIDER_ID = "mock_provider_a"
7
+
8
+
9
+ def respond(example_dir: Path | str) -> str:
10
+ """Return the curated mock answer for this example. Zero network."""
11
+ return (Path(example_dir) / "mock_answer_a.txt").read_text(encoding="utf-8")
File without changes
@@ -0,0 +1,11 @@
1
+ """Mock provider B — offline fixture-backed. Never touches the network."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ PROVIDER_ID = "mock_provider_b"
7
+
8
+
9
+ def respond(example_dir: Path | str) -> str:
10
+ """Return the curated mock answer for this example. Zero network."""
11
+ return (Path(example_dir) / "mock_answer_b.txt").read_text(encoding="utf-8")
adapters/registry.py ADDED
@@ -0,0 +1,27 @@
1
+ """Explicit provider registry; importing this module never discovers plugins."""
2
+ from __future__ import annotations
3
+
4
+ from .base import ProviderAdapter
5
+
6
+
7
+ class ProviderRegistry:
8
+ def __init__(self) -> None:
9
+ self._adapters: dict[str, ProviderAdapter] = {}
10
+
11
+ def register(self, adapter: ProviderAdapter) -> None:
12
+ provider_id = adapter.provider_id
13
+ if not provider_id:
14
+ raise ValueError("provider_id must not be empty")
15
+ if provider_id in self._adapters:
16
+ raise ValueError(f"provider already registered: {provider_id}")
17
+ self._adapters[provider_id] = adapter
18
+
19
+ def get(self, provider_id: str) -> ProviderAdapter:
20
+ try:
21
+ return self._adapters[provider_id]
22
+ except KeyError as exc:
23
+ raise KeyError(f"unknown provider: {provider_id}") from exc
24
+
25
+ def ids(self) -> tuple[str, ...]:
26
+ return tuple(self._adapters)
27
+