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 +0 -0
- contextmd/__init__.py +9 -0
- contextmd/_version.py +34 -0
- contextmd/adapters/__init__.py +6 -0
- contextmd/adapters/anthropic.py +106 -0
- contextmd/adapters/base.py +118 -0
- contextmd/adapters/litellm.py +151 -0
- contextmd/adapters/openai.py +110 -0
- contextmd/adapters/registry.py +89 -0
- contextmd/cli/__init__.py +5 -0
- contextmd/cli/main.py +237 -0
- contextmd/client.py +296 -0
- contextmd/config.py +102 -0
- contextmd/extraction/__init__.py +5 -0
- contextmd/extraction/dedup.py +147 -0
- contextmd/extraction/engine.py +199 -0
- contextmd/extraction/prompts.py +43 -0
- contextmd/memory/__init__.py +7 -0
- contextmd/memory/bootstrap.py +55 -0
- contextmd/memory/router.py +129 -0
- contextmd/memory/types.py +81 -0
- contextmd/session.py +85 -0
- contextmd/storage/__init__.py +6 -0
- contextmd/storage/base.py +78 -0
- contextmd/storage/markdown.py +53 -0
- contextmd/storage/memory.py +194 -0
- contextmd-0.1.1.dist-info/METADATA +258 -0
- contextmd-0.1.1.dist-info/RECORD +31 -0
- contextmd-0.1.1.dist-info/WHEEL +4 -0
- contextmd-0.1.1.dist-info/entry_points.txt +2 -0
- contextmd-0.1.1.dist-info/licenses/LICENSE +21 -0
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,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()
|