contexory-sdk 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.
- contexory/__init__.py +67 -0
- contexory/adapters/__init__.py +0 -0
- contexory/adapters/claude_agent_sdk.py +58 -0
- contexory/adapters/langchain.py +36 -0
- contexory/adapters/pydantic_ai.py +27 -0
- contexory/apply_tools.py +149 -0
- contexory/cache.py +103 -0
- contexory/client.py +229 -0
- contexory/errors.py +23 -0
- contexory/instrument/__init__.py +118 -0
- contexory/mcp_upstream.py +258 -0
- contexory/payload.py +67 -0
- contexory/py.typed +0 -0
- contexory/release.py +30 -0
- contexory/signing.py +60 -0
- contexory/types.py +74 -0
- contexory_sdk-0.1.0.dist-info/METADATA +160 -0
- contexory_sdk-0.1.0.dist-info/RECORD +20 -0
- contexory_sdk-0.1.0.dist-info/WHEEL +4 -0
- contexory_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
contexory/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from .apply_tools import AppliedTools, apply_tools
|
|
2
|
+
from .client import (
|
|
3
|
+
DEFAULT_BASE_URL,
|
|
4
|
+
DEFAULT_LABEL,
|
|
5
|
+
Contexory,
|
|
6
|
+
FetchResponse,
|
|
7
|
+
ResolvedContext,
|
|
8
|
+
default_fetch,
|
|
9
|
+
)
|
|
10
|
+
from .errors import ContexoryResolveError, ContexorySignatureError
|
|
11
|
+
from .instrument import (
|
|
12
|
+
AGENT_VERSION_ATTRIBUTE,
|
|
13
|
+
RELEASE_BAGGAGE_KEY,
|
|
14
|
+
VENDOR_ATTRIBUTES,
|
|
15
|
+
VENDORS,
|
|
16
|
+
Instrumentation,
|
|
17
|
+
instrument,
|
|
18
|
+
open_inference_metadata,
|
|
19
|
+
open_llmetry_association_properties,
|
|
20
|
+
openai_agents_trace_metadata,
|
|
21
|
+
)
|
|
22
|
+
from .mcp_upstream import (
|
|
23
|
+
canonical_json,
|
|
24
|
+
clamp_ttl,
|
|
25
|
+
description_hash,
|
|
26
|
+
hash_mcp_tools,
|
|
27
|
+
mcp_tools_hash,
|
|
28
|
+
report_upstream_hashes,
|
|
29
|
+
schema_hash,
|
|
30
|
+
watch_mcp_tools,
|
|
31
|
+
)
|
|
32
|
+
from .release import normalize_release
|
|
33
|
+
from .types import ResolvedTool, ResolvePayload, Upstream
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"AGENT_VERSION_ATTRIBUTE",
|
|
37
|
+
"DEFAULT_BASE_URL",
|
|
38
|
+
"DEFAULT_LABEL",
|
|
39
|
+
"RELEASE_BAGGAGE_KEY",
|
|
40
|
+
"VENDORS",
|
|
41
|
+
"VENDOR_ATTRIBUTES",
|
|
42
|
+
"AppliedTools",
|
|
43
|
+
"Contexory",
|
|
44
|
+
"ContexoryResolveError",
|
|
45
|
+
"ContexorySignatureError",
|
|
46
|
+
"FetchResponse",
|
|
47
|
+
"Instrumentation",
|
|
48
|
+
"ResolvePayload",
|
|
49
|
+
"ResolvedContext",
|
|
50
|
+
"ResolvedTool",
|
|
51
|
+
"Upstream",
|
|
52
|
+
"canonical_json",
|
|
53
|
+
"clamp_ttl",
|
|
54
|
+
"description_hash",
|
|
55
|
+
"hash_mcp_tools",
|
|
56
|
+
"mcp_tools_hash",
|
|
57
|
+
"report_upstream_hashes",
|
|
58
|
+
"schema_hash",
|
|
59
|
+
"watch_mcp_tools",
|
|
60
|
+
"apply_tools",
|
|
61
|
+
"default_fetch",
|
|
62
|
+
"instrument",
|
|
63
|
+
"normalize_release",
|
|
64
|
+
"open_inference_metadata",
|
|
65
|
+
"open_llmetry_association_properties",
|
|
66
|
+
"openai_agents_trace_metadata",
|
|
67
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Claude Agent SDK: an in-process MCP proxy that re-exposes external tools with released descriptions.
|
|
2
|
+
|
|
3
|
+
Claude Code's built-in tools and external MCP servers describe themselves; their
|
|
4
|
+
descriptions cannot be rewritten in flight. The proxy registers each upstream tool again
|
|
5
|
+
under an in-process MCP server with the released description and forwards the call, so
|
|
6
|
+
the model reads Contexory's text. Name the proxy server so the model sees
|
|
7
|
+
`mcp__<server>__<tool>` and disallow the upstream originals with `disallowed_tools`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Awaitable, Callable, Iterable, Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class UpstreamTool:
|
|
19
|
+
name: str
|
|
20
|
+
description: str
|
|
21
|
+
input_schema: Any
|
|
22
|
+
#: Forwards one call: `await call(args)` returns the MCP result dict (`{"content": [...]}`).
|
|
23
|
+
call: Callable[[Mapping[str, Any]], Awaitable[dict[str, Any]]]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def proxied_tools(context: Any, upstream: Iterable[UpstreamTool], *, include_unmatched: bool = True) -> list[Any]:
|
|
27
|
+
"""`UpstreamTool`s with the released description applied.
|
|
28
|
+
|
|
29
|
+
A tool with no released description is kept as-is unless `include_unmatched=False`.
|
|
30
|
+
"""
|
|
31
|
+
tools = list(upstream)
|
|
32
|
+
applied = context.apply_tools({t.name: {"name": t.name, "description": t.description} for t in tools})
|
|
33
|
+
out: list[UpstreamTool] = []
|
|
34
|
+
released = {t.name for t in context.tools}
|
|
35
|
+
for tool in tools:
|
|
36
|
+
if tool.name not in released and not include_unmatched:
|
|
37
|
+
continue
|
|
38
|
+
description = applied[tool.name]["description"]
|
|
39
|
+
out.append(UpstreamTool(tool.name, description, tool.input_schema, tool.call))
|
|
40
|
+
return out
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def create_proxy_server(
|
|
44
|
+
context: Any, upstream: Iterable[UpstreamTool], *, name: str = "contexory", include_unmatched: bool = True
|
|
45
|
+
) -> Any:
|
|
46
|
+
"""An SDK MCP server config for `ClaudeAgentOptions(mcp_servers={name: server})`."""
|
|
47
|
+
try:
|
|
48
|
+
from claude_agent_sdk import create_sdk_mcp_server, tool
|
|
49
|
+
except ImportError as error:
|
|
50
|
+
raise ImportError(
|
|
51
|
+
"create_proxy_server() needs the Claude Agent SDK. pip install 'contexory-sdk[claude-agent-sdk]'"
|
|
52
|
+
) from error
|
|
53
|
+
|
|
54
|
+
wrapped = [
|
|
55
|
+
tool(t.name, t.description, t.input_schema)(t.call)
|
|
56
|
+
for t in proxied_tools(context, upstream, include_unmatched=include_unmatched)
|
|
57
|
+
]
|
|
58
|
+
return create_sdk_mcp_server(name=name, tools=wrapped)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""LangChain / LangGraph: apply released tool descriptions and the system prompt per model call."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from langchain.agents.middleware import AgentMiddleware as _Base
|
|
9
|
+
except ImportError: # pragma: no cover - the adapter still works duck-typed against any object with `override`
|
|
10
|
+
_Base = object # type: ignore[assignment,misc]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ContexoryMiddleware(_Base): # type: ignore[misc,valid-type]
|
|
14
|
+
"""Agent middleware: `create_agent(model, tools, middleware=[ContexoryMiddleware(ctx)])`.
|
|
15
|
+
|
|
16
|
+
Rewrites each model request's tools to the released descriptions. `apply_system=True`
|
|
17
|
+
also replaces the system message with the release's system prompt.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, context: Any, *, apply_system: bool = False):
|
|
21
|
+
self.context = context
|
|
22
|
+
self.apply_system = apply_system
|
|
23
|
+
|
|
24
|
+
def _rewrite(self, request: Any) -> Any:
|
|
25
|
+
overrides: dict[str, Any] = {}
|
|
26
|
+
if request.tools:
|
|
27
|
+
overrides["tools"] = self.context.apply_tools(list(request.tools))
|
|
28
|
+
if self.apply_system:
|
|
29
|
+
overrides["system_prompt"] = self.context.system
|
|
30
|
+
return request.override(**overrides) if overrides else request
|
|
31
|
+
|
|
32
|
+
def wrap_model_call(self, request: Any, handler: Any) -> Any:
|
|
33
|
+
return handler(self._rewrite(request))
|
|
34
|
+
|
|
35
|
+
async def awrap_model_call(self, request: Any, handler: Any) -> Any:
|
|
36
|
+
return await handler(self._rewrite(request))
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Pydantic AI: apply released tool descriptions through `prepare_tools`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..apply_tools import describe_tool
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def prepare_tools(context: Any) -> Any:
|
|
12
|
+
"""`Agent(model, prepare_tools=prepare_tools(ctx))`.
|
|
13
|
+
|
|
14
|
+
Descriptions only: Pydantic AI routes a call back by the tool name it registered,
|
|
15
|
+
so renames are not offered here.
|
|
16
|
+
"""
|
|
17
|
+
released = {tool.name: tool for tool in context.tools}
|
|
18
|
+
|
|
19
|
+
async def prepare(_run_context: Any, tool_defs: list[Any]) -> list[Any]:
|
|
20
|
+
return [
|
|
21
|
+
dataclasses.replace(definition, description=describe_tool(released[definition.name]))
|
|
22
|
+
if definition.name in released
|
|
23
|
+
else definition
|
|
24
|
+
for definition in tool_defs
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
return prepare
|
contexory/apply_tools.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import dataclasses
|
|
5
|
+
from collections.abc import Iterable, Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, TypeVar
|
|
8
|
+
|
|
9
|
+
from .mcp_upstream import description_hash
|
|
10
|
+
from .types import ResolvedTool
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class AppliedTools:
|
|
17
|
+
tools: Any
|
|
18
|
+
#: Applied name to the host's original name; present only for renamed tools.
|
|
19
|
+
reverse: dict[str, str] = field(default_factory=dict)
|
|
20
|
+
#: Host tools no resolved tool matched; they pass through unchanged.
|
|
21
|
+
unmatched: list[str] = field(default_factory=list)
|
|
22
|
+
#: Resolved tools nothing in the host matched.
|
|
23
|
+
unused: list[str] = field(default_factory=list)
|
|
24
|
+
#: Host tools whose override was not applied because the MCP server changed the description
|
|
25
|
+
#: the override was written against and Contexory has not accepted the change. The server's own text stands.
|
|
26
|
+
held: list[str] = field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def describe_tool(tool: ResolvedTool) -> str:
|
|
30
|
+
return f"{tool.description}\n\n{tool.parameter_notes}" if tool.parameter_notes else tool.description
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _get(tool: Any, key: str) -> Any:
|
|
34
|
+
return tool.get(key) if isinstance(tool, Mapping) else getattr(tool, key, None)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _has(tool: Any, key: str) -> bool:
|
|
38
|
+
return key in tool if isinstance(tool, Mapping) else hasattr(tool, key)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def with_fields(tool: Any, fields: Mapping[str, Any]) -> Any:
|
|
42
|
+
"""A shallow copy with `fields` replaced. Dicts stay dicts and objects keep their class."""
|
|
43
|
+
if isinstance(tool, Mapping):
|
|
44
|
+
return {**tool, **fields}
|
|
45
|
+
if dataclasses.is_dataclass(tool) and not isinstance(tool, type):
|
|
46
|
+
return dataclasses.replace(tool, **fields)
|
|
47
|
+
if hasattr(tool, "model_copy"):
|
|
48
|
+
return tool.model_copy(update=dict(fields))
|
|
49
|
+
clone = copy.copy(tool)
|
|
50
|
+
for key, value in fields.items():
|
|
51
|
+
object.__setattr__(clone, key, value)
|
|
52
|
+
return clone
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _takes_description(tool: Any) -> bool:
|
|
56
|
+
# A function tool declares `description`; hosted tools have a name and no
|
|
57
|
+
# description, and growing one would change what the host sends.
|
|
58
|
+
return isinstance(_get(tool, "description"), str) or _get(tool, "type") == "function"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _upstream_name(host_name: str, server: str | None) -> str:
|
|
62
|
+
"""The name the MCP server gives a tool a host lists as `mcp__<server>__<tool>`."""
|
|
63
|
+
prefix = f"mcp__{server}__" if server is not None else ""
|
|
64
|
+
return (
|
|
65
|
+
host_name[len(prefix) :]
|
|
66
|
+
if prefix and host_name.startswith(prefix) and len(host_name) > len(prefix)
|
|
67
|
+
else host_name
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _is_held(match: ResolvedTool, host_name: str, tool: Any, upstream_hashes: Mapping[str, Any] | None) -> bool:
|
|
72
|
+
"""An override pinned to an upstream description is held once the upstream hash is no longer the accepted one."""
|
|
73
|
+
accepted = match.upstream.hash if match.upstream else None
|
|
74
|
+
if accepted is None:
|
|
75
|
+
return False
|
|
76
|
+
name = _upstream_name(host_name, match.upstream.server)
|
|
77
|
+
observed = None
|
|
78
|
+
for key in (name, host_name):
|
|
79
|
+
entry = (upstream_hashes or {}).get(key)
|
|
80
|
+
observed = entry.get("d") if isinstance(entry, Mapping) else None
|
|
81
|
+
if observed is not None:
|
|
82
|
+
break
|
|
83
|
+
if observed is None:
|
|
84
|
+
description = _get(tool, "description")
|
|
85
|
+
observed = description_hash(name, description) if isinstance(description, str) else None
|
|
86
|
+
return observed is not None and observed != accepted
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def apply_tools(
|
|
90
|
+
resolved: Iterable[ResolvedTool],
|
|
91
|
+
tools: Any,
|
|
92
|
+
renames: Mapping[str, str] | None = None,
|
|
93
|
+
upstream_hashes: Mapping[str, Any] | None = None,
|
|
94
|
+
) -> AppliedTools:
|
|
95
|
+
"""Replace tool descriptions with the released ones.
|
|
96
|
+
|
|
97
|
+
`tools` is a dict keyed by tool name or a list of tool objects (dicts or objects
|
|
98
|
+
with `name`/`description`). Without `renames` a tool is matched by name and only
|
|
99
|
+
its description changes. A renamed tool (`{your_name: contexory_name}`) takes the
|
|
100
|
+
Contexory name and description; `reverse` maps the new name back.
|
|
101
|
+
|
|
102
|
+
`upstream_hashes` is what each MCP tool's description hashes to now, by host tool name
|
|
103
|
+
(`watch_mcp_tools(...).latest()`); without it the hash is taken from the host tool's own
|
|
104
|
+
`description`. An override whose upstream hash differs from the accepted one is `held`.
|
|
105
|
+
"""
|
|
106
|
+
resolved = list(resolved)
|
|
107
|
+
by_name = {t.name: t for t in resolved}
|
|
108
|
+
renames = renames or {}
|
|
109
|
+
reverse: dict[str, str] = {}
|
|
110
|
+
unmatched: list[str] = []
|
|
111
|
+
held: list[str] = []
|
|
112
|
+
used: set[str] = set()
|
|
113
|
+
taken: set[str] = set()
|
|
114
|
+
|
|
115
|
+
def apply(host_name: str, tool: Any) -> tuple[str, Any]:
|
|
116
|
+
target = renames[host_name] if host_name in renames else host_name
|
|
117
|
+
match = by_name.get(target)
|
|
118
|
+
out_name, out = host_name, tool
|
|
119
|
+
if match is not None and _takes_description(tool) and _is_held(match, host_name, tool, upstream_hashes):
|
|
120
|
+
used.add(match.name)
|
|
121
|
+
held.append(host_name)
|
|
122
|
+
elif match is not None and _takes_description(tool):
|
|
123
|
+
used.add(match.name)
|
|
124
|
+
out = with_fields(tool, {"description": describe_tool(match)})
|
|
125
|
+
if target != host_name:
|
|
126
|
+
out_name = target
|
|
127
|
+
reverse[target] = host_name
|
|
128
|
+
else:
|
|
129
|
+
unmatched.append(host_name)
|
|
130
|
+
# An unnamed list tool is an unmatched pass-through, so it can't collide.
|
|
131
|
+
if out_name != "":
|
|
132
|
+
if out_name in taken:
|
|
133
|
+
raise ValueError(f'apply_tools: two tools would be named "{out_name}"')
|
|
134
|
+
taken.add(out_name)
|
|
135
|
+
if out_name != host_name and _has(out, "name"):
|
|
136
|
+
out = with_fields(out, {"name": out_name})
|
|
137
|
+
return out_name, out
|
|
138
|
+
|
|
139
|
+
if isinstance(tools, Mapping):
|
|
140
|
+
result: Any = dict(apply(name, tool) for name, tool in tools.items())
|
|
141
|
+
else:
|
|
142
|
+
result = [apply(_get(tool, "name") or "", tool)[1] for tool in tools]
|
|
143
|
+
return AppliedTools(
|
|
144
|
+
tools=result,
|
|
145
|
+
reverse=reverse,
|
|
146
|
+
unmatched=unmatched,
|
|
147
|
+
unused=[t.name for t in resolved if t.name not in used],
|
|
148
|
+
held=held,
|
|
149
|
+
)
|
contexory/cache.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .payload import parse_payload
|
|
10
|
+
from .signing import sha256_hex, verify_body
|
|
11
|
+
from .types import ResolvePayload
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class CachedResolution:
|
|
16
|
+
payload: ResolvePayload
|
|
17
|
+
body: str
|
|
18
|
+
etag: str
|
|
19
|
+
fetched_at: float
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ResolveCache:
|
|
23
|
+
"""File cache under one directory, byte-compatible with the TypeScript SDK's:
|
|
24
|
+
|
|
25
|
+
labels/<h>.json the label's ETag and which release it last pointed at
|
|
26
|
+
releases/<hash>.json the signed body, named by its content hash
|
|
27
|
+
keys.json signing keys seen so far
|
|
28
|
+
|
|
29
|
+
A release is immutable, so its file is write-once. Every read re-verifies the
|
|
30
|
+
signature against the cached keys: a file edited on disk, or a key that is no
|
|
31
|
+
longer known, reads as a miss instead of as trusted context.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, directory: str | os.PathLike[str]):
|
|
35
|
+
self.dir = Path(directory)
|
|
36
|
+
|
|
37
|
+
def _read_json(self, *parts: str) -> Any:
|
|
38
|
+
try:
|
|
39
|
+
return json.loads(self.dir.joinpath(*parts).read_text("utf8"))
|
|
40
|
+
except (OSError, ValueError):
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
def _write_json(self, value: Any, *parts: str) -> None:
|
|
44
|
+
"""Atomic, and best effort: a cache that cannot be written must not fail a resolve."""
|
|
45
|
+
path = self.dir.joinpath(*parts)
|
|
46
|
+
tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
|
|
47
|
+
try:
|
|
48
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
49
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
50
|
+
with os.fdopen(fd, "w", encoding="utf8") as handle:
|
|
51
|
+
handle.write(json.dumps(value, separators=(",", ":")))
|
|
52
|
+
os.replace(tmp, path)
|
|
53
|
+
except OSError:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
def read_keys(self) -> list[dict[str, str]]:
|
|
57
|
+
data = self._read_json("keys.json")
|
|
58
|
+
if not isinstance(data, list):
|
|
59
|
+
return []
|
|
60
|
+
return [
|
|
61
|
+
k
|
|
62
|
+
for k in data
|
|
63
|
+
if isinstance(k, dict) and isinstance(k.get("keyId"), str) and isinstance(k.get("publicKey"), str)
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
def write_keys(self, keys: list[dict[str, str]]) -> None:
|
|
67
|
+
self._write_json(keys, "keys.json")
|
|
68
|
+
|
|
69
|
+
def read(self, label_key: str) -> CachedResolution | None:
|
|
70
|
+
label = self._read_json("labels", f"{label_key}.json")
|
|
71
|
+
if (
|
|
72
|
+
not isinstance(label, dict)
|
|
73
|
+
or not isinstance(label.get("etag"), str)
|
|
74
|
+
or not isinstance(label.get("hash"), str)
|
|
75
|
+
or isinstance(label.get("fetchedAt"), bool)
|
|
76
|
+
or not isinstance(label.get("fetchedAt"), (int, float))
|
|
77
|
+
):
|
|
78
|
+
return None
|
|
79
|
+
release = self._read_json("releases", f"{label['hash']}.json")
|
|
80
|
+
if (
|
|
81
|
+
not isinstance(release, dict)
|
|
82
|
+
or not isinstance(release.get("body"), str)
|
|
83
|
+
or not isinstance(release.get("signature"), str)
|
|
84
|
+
or not isinstance(release.get("keyId"), str)
|
|
85
|
+
or sha256_hex(release["body"]) != label["hash"]
|
|
86
|
+
):
|
|
87
|
+
return None
|
|
88
|
+
key = next((k for k in self.read_keys() if k["keyId"] == release["keyId"]), None)
|
|
89
|
+
if key is None or not verify_body(key["publicKey"], release["body"], release["signature"]):
|
|
90
|
+
return None
|
|
91
|
+
payload = parse_payload(release["body"])
|
|
92
|
+
if payload is None:
|
|
93
|
+
return None
|
|
94
|
+
return CachedResolution(payload, release["body"], label["etag"], label["fetchedAt"])
|
|
95
|
+
|
|
96
|
+
def write(self, label_key: str, *, body: str, signature: str, key_id: str, etag: str, now: float) -> None:
|
|
97
|
+
"""Only ever called with a body whose signature has just verified."""
|
|
98
|
+
digest = sha256_hex(body)
|
|
99
|
+
self._write_json({"body": body, "signature": signature, "keyId": key_id}, "releases", f"{digest}.json")
|
|
100
|
+
self.touch(label_key, etag=etag, digest=digest, fetched_at=now)
|
|
101
|
+
|
|
102
|
+
def touch(self, label_key: str, *, etag: str, digest: str, fetched_at: float) -> None:
|
|
103
|
+
self._write_json({"etag": etag, "hash": digest, "fetchedAt": fetched_at}, "labels", f"{label_key}.json")
|
contexory/client.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
from collections.abc import Callable, Mapping
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .apply_tools import apply_tools as _apply_tools
|
|
16
|
+
from .cache import CachedResolution, ResolveCache
|
|
17
|
+
from .errors import ContexoryResolveError, ContexorySignatureError
|
|
18
|
+
from .payload import parse_payload
|
|
19
|
+
from .signing import parse_keys, sha256_hex, verify_body
|
|
20
|
+
from .types import ResolvedTool, ResolvePayload, ResolveSource
|
|
21
|
+
|
|
22
|
+
DEFAULT_BASE_URL = "https://sync.contexory.com"
|
|
23
|
+
DEFAULT_LABEL = "production"
|
|
24
|
+
_REFUSAL_STATUSES = (401, 403, 404)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class FetchResponse:
|
|
29
|
+
status: int
|
|
30
|
+
#: Header names lower-cased.
|
|
31
|
+
headers: Mapping[str, str]
|
|
32
|
+
body: bytes
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def ok(self) -> bool:
|
|
36
|
+
return 200 <= self.status < 300
|
|
37
|
+
|
|
38
|
+
def text(self) -> str:
|
|
39
|
+
return self.body.decode("utf8")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
#: `fetch(url, headers, timeout_seconds)`; raise on a network failure, return any HTTP status.
|
|
43
|
+
Fetch = Callable[[str, Mapping[str, str], float], FetchResponse]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def default_fetch(url: str, headers: Mapping[str, str], timeout: float) -> FetchResponse:
|
|
47
|
+
request = urllib.request.Request(url, headers=dict(headers))
|
|
48
|
+
try:
|
|
49
|
+
with urllib.request.urlopen(request, timeout=timeout) as res: # noqa: S310 - https/http base URL
|
|
50
|
+
return FetchResponse(res.status, {k.lower(): v for k, v in res.headers.items()}, res.read())
|
|
51
|
+
except urllib.error.HTTPError as err:
|
|
52
|
+
return FetchResponse(err.code, {k.lower(): v for k, v in err.headers.items()}, err.read())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class ResolvedContext:
|
|
57
|
+
agent: Any
|
|
58
|
+
label: str
|
|
59
|
+
release: Any
|
|
60
|
+
#: The agent's system prompt for this release.
|
|
61
|
+
system: str
|
|
62
|
+
skills: list[dict[str, Any]]
|
|
63
|
+
tools: list[ResolvedTool]
|
|
64
|
+
source: ResolveSource
|
|
65
|
+
#: True when Contexory failed and this is the last good release.
|
|
66
|
+
stale: bool = False
|
|
67
|
+
#: Why the live fetch failed, when `stale`.
|
|
68
|
+
error: BaseException | None = None
|
|
69
|
+
#: Host tools the last `apply_tools` left alone because their MCP server's description changed.
|
|
70
|
+
held: list[str] = field(default_factory=list)
|
|
71
|
+
_reverse: dict[str, str] = field(default_factory=dict, repr=False)
|
|
72
|
+
|
|
73
|
+
def apply_tools(
|
|
74
|
+
self,
|
|
75
|
+
tools: Any,
|
|
76
|
+
renames: Mapping[str, str] | None = None,
|
|
77
|
+
upstream_hashes: Mapping[str, Any] | None = None,
|
|
78
|
+
) -> Any:
|
|
79
|
+
"""Apply the released tool descriptions to a dict or list of your tool objects."""
|
|
80
|
+
applied = _apply_tools(self.tools, tools, renames, upstream_hashes)
|
|
81
|
+
self._reverse.update(applied.reverse)
|
|
82
|
+
self.held = applied.held
|
|
83
|
+
return applied.tools
|
|
84
|
+
|
|
85
|
+
def original_tool_name(self, name: str) -> str:
|
|
86
|
+
"""The name you registered a tool under, given the name the model called."""
|
|
87
|
+
return self._reverse.get(name, name)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_refusal(error: BaseException) -> bool:
|
|
91
|
+
return isinstance(error, ContexoryResolveError) and error.status in _REFUSAL_STATUSES
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Contexory:
|
|
95
|
+
"""Resolve the release a label points at. Never worse than the last verified release.
|
|
96
|
+
|
|
97
|
+
An unreachable or erroring Contexory serves the last verified release, marked
|
|
98
|
+
`stale`. A bad signature, or a refusal (revoked key, agent or label gone),
|
|
99
|
+
raises instead: the cache never overrides an answer.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(
|
|
103
|
+
self,
|
|
104
|
+
api_key: str | None = None,
|
|
105
|
+
*,
|
|
106
|
+
base_url: str | None = None,
|
|
107
|
+
cache_dir: str | os.PathLike[str] | None = None,
|
|
108
|
+
ttl_ms: float = 60_000,
|
|
109
|
+
timeout_ms: float = 3_000,
|
|
110
|
+
first_fetch_timeout_ms: float = 10_000,
|
|
111
|
+
fetch: Fetch | None = None,
|
|
112
|
+
now: Callable[[], float] | None = None,
|
|
113
|
+
env: Mapping[str, str] | None = None,
|
|
114
|
+
):
|
|
115
|
+
env = os.environ if env is None else env
|
|
116
|
+
self._api_key = api_key if api_key is not None else env.get("CONTEXORY_API_TOKEN")
|
|
117
|
+
self._fingerprint = sha256_hex(self._api_key or "")[:16]
|
|
118
|
+
self._base_url = (base_url or env.get("CONTEXORY_API_BASE") or DEFAULT_BASE_URL).rstrip("/")
|
|
119
|
+
self._ttl_ms = ttl_ms
|
|
120
|
+
self._timeout = timeout_ms / 1000
|
|
121
|
+
self._first_timeout = first_fetch_timeout_ms / 1000
|
|
122
|
+
self._fetch = fetch or default_fetch
|
|
123
|
+
self._now = now or (lambda: time.time() * 1000)
|
|
124
|
+
directory = cache_dir or env.get("CONTEXORY_CACHE_DIR") or Path.home() / ".cache" / "contexory" / "sdk"
|
|
125
|
+
self._cache = ResolveCache(directory)
|
|
126
|
+
|
|
127
|
+
def resolve(self, agent: str, label: str = DEFAULT_LABEL) -> ResolvedContext:
|
|
128
|
+
if not self._api_key:
|
|
129
|
+
raise ContexoryResolveError("No Contexory runtime key: pass api_key or set CONTEXORY_API_TOKEN")
|
|
130
|
+
label_key = sha256_hex(f"{self._base_url}\0{self._fingerprint}\0{agent}\0{label}")[:32]
|
|
131
|
+
cached = self._cache.read(label_key)
|
|
132
|
+
if cached is not None and self._now() - cached.fetched_at < self._ttl_ms:
|
|
133
|
+
return _build(cached.payload, "cache")
|
|
134
|
+
try:
|
|
135
|
+
return _build(self._fetch_release(label_key, agent, label, cached), "network")
|
|
136
|
+
except (ContexorySignatureError, ContexoryResolveError) as error:
|
|
137
|
+
if isinstance(error, ContexorySignatureError) or _is_refusal(error):
|
|
138
|
+
raise
|
|
139
|
+
return self._fall_back(cached, error)
|
|
140
|
+
except Exception as error: # noqa: BLE001 - any transport failure is "unreachable"
|
|
141
|
+
return self._fall_back(cached, error, wrap=True)
|
|
142
|
+
|
|
143
|
+
async def aresolve(self, agent: str, label: str = DEFAULT_LABEL) -> ResolvedContext:
|
|
144
|
+
"""`resolve` off the event loop, for async agents."""
|
|
145
|
+
return await asyncio.to_thread(self.resolve, agent, label)
|
|
146
|
+
|
|
147
|
+
def _fall_back(self, cached: CachedResolution | None, error: BaseException, wrap: bool = False) -> ResolvedContext:
|
|
148
|
+
if cached is not None:
|
|
149
|
+
context = _build(cached.payload, "stale-cache")
|
|
150
|
+
context.error = error
|
|
151
|
+
return context
|
|
152
|
+
if wrap:
|
|
153
|
+
raise ContexoryResolveError(f"Could not reach Contexory: {error}", cause=error) from error
|
|
154
|
+
raise error
|
|
155
|
+
|
|
156
|
+
def _get(self, path: str, timeout: float, headers: Mapping[str, str] | None = None) -> FetchResponse:
|
|
157
|
+
return self._fetch(f"{self._base_url}{path}", dict(headers or {}), timeout)
|
|
158
|
+
|
|
159
|
+
def _fetch_release(self, label_key: str, agent: str, label: str, cached: CachedResolution | None) -> ResolvePayload:
|
|
160
|
+
headers = {"Authorization": f"Bearer {self._api_key}"}
|
|
161
|
+
if cached is not None:
|
|
162
|
+
headers["If-None-Match"] = cached.etag
|
|
163
|
+
# With nothing to fall back on a timeout raises, so the request gets the longer budget.
|
|
164
|
+
budget = self._timeout if cached is not None else self._first_timeout
|
|
165
|
+
res = self._get(
|
|
166
|
+
f"/api/v1/agents/{urllib.parse.quote(agent, safe='')}/resolve?label={urllib.parse.quote(label, safe='')}",
|
|
167
|
+
budget,
|
|
168
|
+
headers,
|
|
169
|
+
)
|
|
170
|
+
if res.status == 304 and cached is not None:
|
|
171
|
+
self._cache.touch(label_key, etag=cached.etag, digest=sha256_hex(cached.body), fetched_at=self._now())
|
|
172
|
+
return cached.payload
|
|
173
|
+
if not res.ok:
|
|
174
|
+
raise ContexoryResolveError(f"Contexory resolve answered {res.status}", status=res.status)
|
|
175
|
+
|
|
176
|
+
body = res.text()
|
|
177
|
+
signature = res.headers.get("contexory-signature")
|
|
178
|
+
key_id = res.headers.get("contexory-key-id")
|
|
179
|
+
if not signature or not key_id:
|
|
180
|
+
raise ContexorySignatureError("missing")
|
|
181
|
+
key = self._key_for(key_id, budget)
|
|
182
|
+
if key is None:
|
|
183
|
+
raise ContexorySignatureError("unknown_key")
|
|
184
|
+
if not verify_body(key["publicKey"], body, signature):
|
|
185
|
+
raise ContexorySignatureError("invalid")
|
|
186
|
+
|
|
187
|
+
payload = parse_payload(body)
|
|
188
|
+
if payload is None:
|
|
189
|
+
raise ContexoryResolveError("Contexory resolve sent a payload this SDK cannot read")
|
|
190
|
+
if payload.agent.slug != agent or payload.label != label:
|
|
191
|
+
raise ContexoryResolveError("Contexory resolve answered for a different agent or label")
|
|
192
|
+
self._cache.write(
|
|
193
|
+
label_key, body=body, signature=signature, key_id=key_id, etag=res.headers.get("etag", ""), now=self._now()
|
|
194
|
+
)
|
|
195
|
+
return payload
|
|
196
|
+
|
|
197
|
+
def _key_for(self, key_id: str, budget: float) -> dict[str, str] | None:
|
|
198
|
+
"""An id not seen before triggers one discovery fetch, which is how a rotation arrives.
|
|
199
|
+
|
|
200
|
+
Keys are merged, not replaced, so a release cached under the previous key still verifies.
|
|
201
|
+
"""
|
|
202
|
+
known = self._cache.read_keys()
|
|
203
|
+
hit = next((k for k in known if k["keyId"] == key_id), None)
|
|
204
|
+
if hit is not None:
|
|
205
|
+
return hit
|
|
206
|
+
res = self._get("/api/v1/signing-keys", budget)
|
|
207
|
+
if not res.ok:
|
|
208
|
+
raise ContexoryResolveError(f"Contexory signing-keys answered {res.status}", status=res.status)
|
|
209
|
+
try:
|
|
210
|
+
data = json.loads(res.text())
|
|
211
|
+
except ValueError:
|
|
212
|
+
data = None
|
|
213
|
+
fetched = parse_keys(data)
|
|
214
|
+
merged = [k for k in known if not any(f["keyId"] == k["keyId"] for f in fetched)] + fetched
|
|
215
|
+
self._cache.write_keys(merged)
|
|
216
|
+
return next((k for k in merged if k["keyId"] == key_id), None)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _build(payload: ResolvePayload, source: ResolveSource) -> ResolvedContext:
|
|
220
|
+
return ResolvedContext(
|
|
221
|
+
agent=payload.agent,
|
|
222
|
+
label=payload.label,
|
|
223
|
+
release=payload.release,
|
|
224
|
+
system=payload.system_prompt,
|
|
225
|
+
skills=payload.skills,
|
|
226
|
+
tools=payload.tools,
|
|
227
|
+
source=source,
|
|
228
|
+
stale=source == "stale-cache",
|
|
229
|
+
)
|