contextmd 0.1.1__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.
contextmd/CHANGELOG.md ADDED
File without changes
contextmd/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """ContextMD - Markdown-Based Memory Layer for LLM APIs."""
2
+
3
+ from contextmd.client import ContextMD
4
+ from contextmd.config import ContextMDConfig
5
+ from contextmd.memory.types import MemoryType
6
+ from contextmd.session import Session
7
+
8
+ __version__ = "0.1.0"
9
+ __all__ = ["ContextMD", "ContextMDConfig", "Session", "MemoryType"]
contextmd/_version.py ADDED
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '0.1.1'
32
+ __version_tuple__ = version_tuple = (0, 1, 1)
33
+
34
+ __commit_id__ = commit_id = None
@@ -0,0 +1,6 @@
1
+ """Provider adapters for ContextMD."""
2
+
3
+ from contextmd.adapters.base import ProviderAdapter
4
+ from contextmd.adapters.registry import AdapterRegistry, detect_provider
5
+
6
+ __all__ = ["ProviderAdapter", "AdapterRegistry", "detect_provider"]
@@ -0,0 +1,106 @@
1
+ """Anthropic provider adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from contextmd.adapters.base import ProviderAdapter
8
+ from contextmd.memory.types import Message, TokenUsage
9
+
10
+ MODEL_CONTEXT_WINDOWS = {
11
+ # Claude 4.6 family (latest - February 2026)
12
+ "claude-opus-4-6": 200000,
13
+ "claude-sonnet-4-6": 200000,
14
+ # Claude 4.5 family
15
+ "claude-opus-4-5": 200000,
16
+ "claude-sonnet-4-5": 200000,
17
+ "claude-haiku-4-5": 200000,
18
+ # Claude 4.x family
19
+ "claude-opus-4-1": 200000,
20
+ "claude-opus-4": 200000,
21
+ "claude-sonnet-4": 200000,
22
+ # Claude 3.x family (still supported)
23
+ "claude-3-7-sonnet": 200000,
24
+ "claude-3-5-sonnet": 200000,
25
+ "claude-3-5-haiku": 200000,
26
+ "claude-3-haiku": 200000,
27
+ }
28
+
29
+
30
+ class AnthropicAdapter(ProviderAdapter):
31
+ """Adapter for Anthropic API."""
32
+
33
+ @property
34
+ def provider_name(self) -> str:
35
+ return "anthropic"
36
+
37
+ def inject_memory(self, request: dict[str, Any], memory_text: str) -> dict[str, Any]:
38
+ """Inject memory into the system parameter."""
39
+ if not memory_text:
40
+ return request
41
+
42
+ existing_system = request.get("system", "")
43
+
44
+ new_system: str | list[dict[str, str]]
45
+ if isinstance(existing_system, list):
46
+ new_system = [{"type": "text", "text": memory_text}] + existing_system
47
+ elif existing_system:
48
+ new_system = memory_text + str(existing_system)
49
+ else:
50
+ new_system = memory_text
51
+
52
+ return {**request, "system": new_system}
53
+
54
+ def extract_usage(self, response: Any) -> TokenUsage | None:
55
+ """Extract token usage from Anthropic response."""
56
+ usage = getattr(response, "usage", None)
57
+ if usage is None:
58
+ return None
59
+
60
+ input_tokens = getattr(usage, "input_tokens", 0)
61
+ output_tokens = getattr(usage, "output_tokens", 0)
62
+
63
+ return TokenUsage(
64
+ input_tokens=input_tokens,
65
+ output_tokens=output_tokens,
66
+ total_tokens=input_tokens + output_tokens,
67
+ )
68
+
69
+ def normalize_messages(self, response: Any) -> list[Message]:
70
+ """Normalize Anthropic response to Message objects."""
71
+ messages: list[Message] = []
72
+
73
+ content_blocks = getattr(response, "content", [])
74
+ text_parts: list[str] = []
75
+
76
+ for block in content_blocks:
77
+ if getattr(block, "type", None) == "text":
78
+ text_parts.append(getattr(block, "text", ""))
79
+
80
+ if text_parts:
81
+ messages.append(
82
+ Message(
83
+ role="assistant",
84
+ content="\n".join(text_parts),
85
+ )
86
+ )
87
+
88
+ return messages
89
+
90
+ def get_context_window_size(self, model: str) -> int:
91
+ """Get context window size for an Anthropic model."""
92
+ for model_prefix, size in MODEL_CONTEXT_WINDOWS.items():
93
+ if model.startswith(model_prefix):
94
+ return size
95
+ return 200000
96
+
97
+ def _extract_chunk_content(self, chunk: Any) -> str | None:
98
+ """Extract content from a streaming chunk."""
99
+ chunk_type = getattr(chunk, "type", None)
100
+
101
+ if chunk_type == "content_block_delta":
102
+ delta = getattr(chunk, "delta", None)
103
+ if delta and getattr(delta, "type", None) == "text_delta":
104
+ return getattr(delta, "text", None)
105
+
106
+ return None
@@ -0,0 +1,118 @@
1
+ """Abstract base class for provider adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from collections.abc import AsyncIterator, Iterator
7
+ from typing import Any
8
+
9
+ from contextmd.memory.types import Message, TokenUsage
10
+
11
+
12
+ class ProviderAdapter(ABC):
13
+ """Abstract interface for LLM provider adapters."""
14
+
15
+ @property
16
+ @abstractmethod
17
+ def provider_name(self) -> str:
18
+ """Return the provider name (e.g., 'openai', 'anthropic', 'litellm')."""
19
+ ...
20
+
21
+ @abstractmethod
22
+ def inject_memory(self, request: dict[str, Any], memory_text: str) -> dict[str, Any]:
23
+ """Inject memory into the request.
24
+
25
+ Args:
26
+ request: The original request parameters.
27
+ memory_text: The memory content to inject.
28
+
29
+ Returns:
30
+ Modified request with memory injected into system prompt.
31
+ """
32
+ ...
33
+
34
+ @abstractmethod
35
+ def extract_usage(self, response: Any) -> TokenUsage | None:
36
+ """Extract token usage from the response.
37
+
38
+ Args:
39
+ response: The API response object.
40
+
41
+ Returns:
42
+ TokenUsage object or None if not available.
43
+ """
44
+ ...
45
+
46
+ @abstractmethod
47
+ def normalize_messages(self, response: Any) -> list[Message]:
48
+ """Normalize response messages to a common format.
49
+
50
+ Args:
51
+ response: The API response object.
52
+
53
+ Returns:
54
+ List of normalized Message objects.
55
+ """
56
+ ...
57
+
58
+ @abstractmethod
59
+ def get_context_window_size(self, model: str) -> int:
60
+ """Get the context window size for a model.
61
+
62
+ Args:
63
+ model: The model name.
64
+
65
+ Returns:
66
+ Context window size in tokens.
67
+ """
68
+ ...
69
+
70
+ def handle_streaming(
71
+ self,
72
+ stream: Iterator[Any] | AsyncIterator[Any],
73
+ ) -> tuple[Iterator[Any], list[str]]:
74
+ """Handle streaming responses by buffering content.
75
+
76
+ Args:
77
+ stream: The streaming response iterator.
78
+
79
+ Returns:
80
+ Tuple of (passthrough iterator, buffered content chunks).
81
+ """
82
+ buffered_chunks: list[str] = []
83
+
84
+ def passthrough() -> Iterator[Any]:
85
+ for chunk in stream: # type: ignore
86
+ content = self._extract_chunk_content(chunk)
87
+ if content:
88
+ buffered_chunks.append(content)
89
+ yield chunk
90
+
91
+ return passthrough(), buffered_chunks
92
+
93
+ async def handle_streaming_async(
94
+ self,
95
+ stream: AsyncIterator[Any],
96
+ ) -> tuple[AsyncIterator[Any], list[str]]:
97
+ """Handle async streaming responses by buffering content.
98
+
99
+ Args:
100
+ stream: The async streaming response iterator.
101
+
102
+ Returns:
103
+ Tuple of (passthrough async iterator, buffered content chunks).
104
+ """
105
+ buffered_chunks: list[str] = []
106
+
107
+ async def passthrough() -> AsyncIterator[Any]:
108
+ async for chunk in stream:
109
+ content = self._extract_chunk_content(chunk)
110
+ if content:
111
+ buffered_chunks.append(content)
112
+ yield chunk
113
+
114
+ return passthrough(), buffered_chunks
115
+
116
+ def _extract_chunk_content(self, chunk: Any) -> str | None:
117
+ """Extract content from a streaming chunk. Override in subclasses."""
118
+ return None
@@ -0,0 +1,151 @@
1
+ """LiteLLM provider adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from contextmd.adapters.base import ProviderAdapter
8
+ from contextmd.memory.types import Message, TokenUsage
9
+
10
+
11
+ class LiteLLMAdapter(ProviderAdapter):
12
+ """Adapter for LiteLLM unified API.
13
+
14
+ LiteLLM provides a unified interface for 100+ LLM providers.
15
+ It uses OpenAI-compatible request/response format.
16
+ """
17
+
18
+ @property
19
+ def provider_name(self) -> str:
20
+ return "litellm"
21
+
22
+ def inject_memory(self, request: dict[str, Any], memory_text: str) -> dict[str, Any]:
23
+ """Inject memory into the messages array (OpenAI-compatible format)."""
24
+ if not memory_text:
25
+ return request
26
+
27
+ messages = list(request.get("messages", []))
28
+
29
+ if messages and messages[0].get("role") == "system":
30
+ messages[0] = {
31
+ **messages[0],
32
+ "content": memory_text + messages[0].get("content", ""),
33
+ }
34
+ else:
35
+ messages.insert(0, {"role": "system", "content": memory_text})
36
+
37
+ return {**request, "messages": messages}
38
+
39
+ def extract_usage(self, response: Any) -> TokenUsage | None:
40
+ """Extract token usage from LiteLLM response.
41
+
42
+ LiteLLM normalizes usage to OpenAI format.
43
+ """
44
+ usage = getattr(response, "usage", None)
45
+ if usage is None:
46
+ if isinstance(response, dict):
47
+ usage = response.get("usage")
48
+ if usage is None:
49
+ return None
50
+
51
+ if isinstance(usage, dict):
52
+ return TokenUsage(
53
+ input_tokens=usage.get("prompt_tokens", 0),
54
+ output_tokens=usage.get("completion_tokens", 0),
55
+ total_tokens=usage.get("total_tokens", 0),
56
+ )
57
+
58
+ return TokenUsage(
59
+ input_tokens=getattr(usage, "prompt_tokens", 0),
60
+ output_tokens=getattr(usage, "completion_tokens", 0),
61
+ total_tokens=getattr(usage, "total_tokens", 0),
62
+ )
63
+
64
+ def normalize_messages(self, response: Any) -> list[Message]:
65
+ """Normalize LiteLLM response to Message objects.
66
+
67
+ LiteLLM uses OpenAI-compatible response format.
68
+ """
69
+ messages: list[Message] = []
70
+
71
+ choices = getattr(response, "choices", None)
72
+ if choices is None and isinstance(response, dict):
73
+ choices = response.get("choices", [])
74
+
75
+ if choices:
76
+ for choice in choices:
77
+ if isinstance(choice, dict):
78
+ msg = choice.get("message", {})
79
+ messages.append(
80
+ Message(
81
+ role=msg.get("role", "assistant"),
82
+ content=msg.get("content", "") or "",
83
+ tool_calls=msg.get("tool_calls"),
84
+ )
85
+ )
86
+ else:
87
+ msg = getattr(choice, "message", None)
88
+ if msg:
89
+ messages.append(
90
+ Message(
91
+ role=getattr(msg, "role", "assistant"),
92
+ content=getattr(msg, "content", "") or "",
93
+ tool_calls=getattr(msg, "tool_calls", None),
94
+ )
95
+ )
96
+
97
+ return messages
98
+
99
+ def get_context_window_size(self, model: str) -> int:
100
+ """Get context window size for a model via LiteLLM.
101
+
102
+ LiteLLM has built-in model info, but we provide sensible defaults.
103
+ """
104
+ try:
105
+ import litellm
106
+ model_info = litellm.get_model_info(model)
107
+ max_tokens = model_info.get("max_input_tokens", 128000)
108
+ return int(max_tokens) if max_tokens is not None else 128000
109
+ except Exception:
110
+ pass
111
+
112
+ model_lower = model.lower()
113
+
114
+ if "claude" in model_lower:
115
+ return 200000
116
+ elif "gpt-5" in model_lower:
117
+ return 400000
118
+ elif "gpt-4.1" in model_lower:
119
+ return 1047576
120
+ elif "gpt-4" in model_lower:
121
+ return 128000
122
+ elif "gemini" in model_lower:
123
+ return 1000000
124
+ elif "mistral" in model_lower:
125
+ return 32768
126
+ elif "llama" in model_lower:
127
+ return 128000
128
+ elif "o3" in model_lower or "o4" in model_lower:
129
+ return 200000
130
+
131
+ return 128000
132
+
133
+ def _extract_chunk_content(self, chunk: Any) -> str | None:
134
+ """Extract content from a streaming chunk."""
135
+ choices = getattr(chunk, "choices", None)
136
+ if choices is None and isinstance(chunk, dict):
137
+ choices = chunk.get("choices", [])
138
+
139
+ if choices:
140
+ choice = choices[0] if choices else None
141
+ if choice:
142
+ if isinstance(choice, dict):
143
+ delta = choice.get("delta", {})
144
+ content = delta.get("content")
145
+ return str(content) if content is not None else None
146
+ else:
147
+ delta = getattr(choice, "delta", None)
148
+ if delta:
149
+ return getattr(delta, "content", None)
150
+
151
+ return None
@@ -0,0 +1,110 @@
1
+ """OpenAI provider adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from contextmd.adapters.base import ProviderAdapter
8
+ from contextmd.memory.types import Message, TokenUsage
9
+
10
+ MODEL_CONTEXT_WINDOWS = {
11
+ # GPT-5.x family (latest flagship models)
12
+ "gpt-5.2": 400000,
13
+ "gpt-5.2-pro": 400000,
14
+ "gpt-5.1": 400000,
15
+ "gpt-5": 400000,
16
+ "gpt-5-mini": 400000,
17
+ "gpt-5-nano": 400000,
18
+ "gpt-5.2-chat": 128000,
19
+ "gpt-5.1-codex": 400000,
20
+ "gpt-5.1-codex-mini": 400000,
21
+ # GPT-4.1 family
22
+ "gpt-4.1": 1047576,
23
+ "gpt-4.1-mini": 1047576,
24
+ "gpt-4.1-nano": 1047576,
25
+ # GPT-4o family (multimodal)
26
+ "gpt-4o": 128000,
27
+ "gpt-4o-mini": 128000,
28
+ # o-series reasoning models
29
+ "o1": 200000,
30
+ "o1-mini": 128000,
31
+ "o1-preview": 128000,
32
+ "o3": 200000,
33
+ "o3-mini": 200000,
34
+ "o3-pro": 200000,
35
+ "o4-mini": 200000,
36
+ }
37
+
38
+
39
+ class OpenAIAdapter(ProviderAdapter):
40
+ """Adapter for OpenAI API."""
41
+
42
+ @property
43
+ def provider_name(self) -> str:
44
+ return "openai"
45
+
46
+ def inject_memory(self, request: dict[str, Any], memory_text: str) -> dict[str, Any]:
47
+ """Inject memory into the system message."""
48
+ if not memory_text:
49
+ return request
50
+
51
+ messages = list(request.get("messages", []))
52
+
53
+ if messages and messages[0].get("role") == "system":
54
+ messages[0] = {
55
+ **messages[0],
56
+ "content": memory_text + messages[0].get("content", ""),
57
+ }
58
+ else:
59
+ messages.insert(0, {"role": "system", "content": memory_text})
60
+
61
+ return {**request, "messages": messages}
62
+
63
+ def extract_usage(self, response: Any) -> TokenUsage | None:
64
+ """Extract token usage from OpenAI response."""
65
+ usage = getattr(response, "usage", None)
66
+ if usage is None:
67
+ return None
68
+
69
+ return TokenUsage(
70
+ input_tokens=getattr(usage, "prompt_tokens", 0),
71
+ output_tokens=getattr(usage, "completion_tokens", 0),
72
+ total_tokens=getattr(usage, "total_tokens", 0),
73
+ )
74
+
75
+ def normalize_messages(self, response: Any) -> list[Message]:
76
+ """Normalize OpenAI response to Message objects."""
77
+ messages: list[Message] = []
78
+
79
+ choices = getattr(response, "choices", [])
80
+ for choice in choices:
81
+ msg = getattr(choice, "message", None)
82
+ if msg:
83
+ messages.append(
84
+ Message(
85
+ role=getattr(msg, "role", "assistant"),
86
+ content=getattr(msg, "content", "") or "",
87
+ tool_calls=getattr(msg, "tool_calls", None),
88
+ )
89
+ )
90
+
91
+ return messages
92
+
93
+ def get_context_window_size(self, model: str) -> int:
94
+ """Get context window size for an OpenAI model."""
95
+ if model in MODEL_CONTEXT_WINDOWS:
96
+ return MODEL_CONTEXT_WINDOWS[model]
97
+ sorted_prefixes = sorted(MODEL_CONTEXT_WINDOWS.keys(), key=len, reverse=True)
98
+ for model_prefix in sorted_prefixes:
99
+ if model.startswith(model_prefix):
100
+ return MODEL_CONTEXT_WINDOWS[model_prefix]
101
+ return 128000
102
+
103
+ def _extract_chunk_content(self, chunk: Any) -> str | None:
104
+ """Extract content from a streaming chunk."""
105
+ choices = getattr(chunk, "choices", [])
106
+ if choices:
107
+ delta = getattr(choices[0], "delta", None)
108
+ if delta:
109
+ return getattr(delta, "content", None)
110
+ return None
@@ -0,0 +1,89 @@
1
+ """Provider adapter registry and detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from contextmd.adapters.base import ProviderAdapter
8
+
9
+
10
+ class AdapterRegistry:
11
+ """Registry for provider adapters."""
12
+
13
+ _adapters: dict[str, type[ProviderAdapter]] = {}
14
+
15
+ @classmethod
16
+ def register(cls, name: str, adapter_class: type[ProviderAdapter]) -> None:
17
+ """Register an adapter class."""
18
+ cls._adapters[name] = adapter_class
19
+
20
+ @classmethod
21
+ def get(cls, name: str) -> type[ProviderAdapter] | None:
22
+ """Get an adapter class by name."""
23
+ return cls._adapters.get(name)
24
+
25
+ @classmethod
26
+ def create(cls, name: str) -> ProviderAdapter | None:
27
+ """Create an adapter instance by name."""
28
+ adapter_class = cls.get(name)
29
+ if adapter_class:
30
+ return adapter_class()
31
+ return None
32
+
33
+ @classmethod
34
+ def list_providers(cls) -> list[str]:
35
+ """List all registered provider names."""
36
+ return list(cls._adapters.keys())
37
+
38
+
39
+ def _register_builtin_adapters() -> None:
40
+ """Register built-in adapters."""
41
+ from contextmd.adapters.anthropic import AnthropicAdapter
42
+ from contextmd.adapters.litellm import LiteLLMAdapter
43
+ from contextmd.adapters.openai import OpenAIAdapter
44
+
45
+ AdapterRegistry.register("openai", OpenAIAdapter)
46
+ AdapterRegistry.register("anthropic", AnthropicAdapter)
47
+ AdapterRegistry.register("litellm", LiteLLMAdapter)
48
+
49
+
50
+ def detect_provider(client: Any) -> str | None:
51
+ """Detect the provider type from a client instance.
52
+
53
+ Args:
54
+ client: The LLM client instance.
55
+
56
+ Returns:
57
+ Provider name ('openai', 'anthropic', 'litellm') or None.
58
+ """
59
+ client_type = type(client).__name__
60
+ client_module = type(client).__module__
61
+
62
+ if "openai" in client_module.lower():
63
+ return "openai"
64
+
65
+ if "anthropic" in client_module.lower():
66
+ return "anthropic"
67
+
68
+ if "litellm" in client_module.lower():
69
+ return "litellm"
70
+
71
+ if client_type in ("OpenAI", "AsyncOpenAI"):
72
+ return "openai"
73
+
74
+ if client_type in ("Anthropic", "AsyncAnthropic"):
75
+ return "anthropic"
76
+
77
+ if hasattr(client, "completion") and hasattr(client, "acompletion"):
78
+ if "litellm" in str(getattr(client, "completion", "")):
79
+ return "litellm"
80
+
81
+ import types
82
+ if isinstance(client, types.ModuleType):
83
+ if client.__name__ == "litellm":
84
+ return "litellm"
85
+
86
+ return None
87
+
88
+
89
+ _register_builtin_adapters()
@@ -0,0 +1,5 @@
1
+ """CLI for ContextMD."""
2
+
3
+ from contextmd.cli.main import main
4
+
5
+ __all__ = ["main"]