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.
- adapters/__init__.py +0 -0
- adapters/base.py +37 -0
- adapters/byok/__init__.py +0 -0
- adapters/byok/_http.py +66 -0
- adapters/byok/anthropic_provider.py +104 -0
- adapters/byok/openai_provider.py +116 -0
- adapters/local_stub/__init__.py +0 -0
- adapters/local_stub/provider.py +16 -0
- adapters/mock.py +30 -0
- adapters/mock_provider_a/__init__.py +0 -0
- adapters/mock_provider_a/provider.py +11 -0
- adapters/mock_provider_b/__init__.py +0 -0
- adapters/mock_provider_b/provider.py +11 -0
- adapters/registry.py +27 -0
- apl_sidecar-0.1.0.dist-info/METADATA +271 -0
- apl_sidecar-0.1.0.dist-info/RECORD +83 -0
- apl_sidecar-0.1.0.dist-info/WHEEL +5 -0
- apl_sidecar-0.1.0.dist-info/entry_points.txt +2 -0
- apl_sidecar-0.1.0.dist-info/licenses/LICENSE +21 -0
- apl_sidecar-0.1.0.dist-info/top_level.txt +7 -0
- app/__init__.py +0 -0
- app/local_playground/README.md +14 -0
- app/local_playground/app.js +352 -0
- app/local_playground/index.html +142 -0
- app/local_playground/style.css +95 -0
- cli/__init__.py +0 -0
- cli/apl.py +179 -0
- cli/commands/__init__.py +0 -0
- cli/commands/_common.py +267 -0
- cli/commands/_resources.py +78 -0
- cli/commands/_signing.py +52 -0
- cli/commands/break_receipt.py +31 -0
- cli/commands/demo.py +145 -0
- cli/commands/inspect.py +59 -0
- cli/commands/mask.py +37 -0
- cli/commands/preview.py +35 -0
- cli/commands/rehydrate.py +30 -0
- cli/commands/run_live.py +413 -0
- cli/commands/run_mock.py +95 -0
- cli/commands/verify.py +17 -0
- examples/00_private_idea/README.md +56 -0
- examples/00_private_idea/final_rehydrated_answer.txt +34 -0
- examples/00_private_idea/input.original.example.txt +35 -0
- examples/00_private_idea/inspect.expected.txt +35 -0
- examples/00_private_idea/local_only.json +9 -0
- examples/00_private_idea/masking_plan.yaml +24 -0
- examples/00_private_idea/mock_answer_a.txt +27 -0
- examples/00_private_idea/mock_answer_b.txt +26 -0
- examples/00_private_idea/provider_a_payload.txt +10 -0
- examples/00_private_idea/provider_b_payload.txt +9 -0
- examples/00_private_idea/receipt.json +78 -0
- examples/00_private_idea/tampered_receipt.example.json +78 -0
- examples/01_private_code_context/README.md +51 -0
- examples/01_private_code_context/final_rehydrated_answer.txt +26 -0
- examples/01_private_code_context/input.original.example.txt +44 -0
- examples/01_private_code_context/inspect.expected.txt +35 -0
- examples/01_private_code_context/local_only.json +9 -0
- examples/01_private_code_context/masking_plan.yaml +21 -0
- examples/01_private_code_context/mock_answer_a.txt +30 -0
- examples/01_private_code_context/mock_answer_b.txt +24 -0
- examples/01_private_code_context/provider_a_payload.txt +16 -0
- examples/01_private_code_context/provider_b_payload.txt +9 -0
- examples/01_private_code_context/receipt.json +78 -0
- examples/01_private_code_context/tampered_receipt.example.json +78 -0
- examples/02_market_entry_three_way/README.md +46 -0
- examples/02_market_entry_three_way/final_rehydrated_answer.txt +29 -0
- examples/02_market_entry_three_way/input.original.example.txt +30 -0
- examples/02_market_entry_three_way/local_only.json +7 -0
- examples/02_market_entry_three_way/masking_plan.yaml +31 -0
- examples/02_market_entry_three_way/mock_answer_a.txt +20 -0
- examples/02_market_entry_three_way/mock_answer_b.txt +17 -0
- examples/02_market_entry_three_way/mock_answer_c.txt +20 -0
- examples/02_market_entry_three_way/provider_a_payload.txt +8 -0
- examples/02_market_entry_three_way/provider_b_payload.txt +8 -0
- examples/02_market_entry_three_way/provider_c_payload.txt +8 -0
- examples/__init__.py +0 -0
- relay/__init__.py +2 -0
- relay/openai_proxy.py +104 -0
- relay/relay_server.py +161 -0
- spec/__init__.py +0 -0
- spec/apl-oss-demo-key.pem +3 -0
- spec/demo_policy_manifest.json +9 -0
- 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
|
+
|