langchain-idemcore 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.
- langchain_idemcore/__init__.py +13 -0
- langchain_idemcore/_convert.py +79 -0
- langchain_idemcore/_receipt.py +72 -0
- langchain_idemcore/callback.py +67 -0
- langchain_idemcore/chat_models.py +269 -0
- langchain_idemcore/py.typed +0 -0
- langchain_idemcore/verify.py +113 -0
- langchain_idemcore-0.1.0.dist-info/METADATA +99 -0
- langchain_idemcore-0.1.0.dist-info/RECORD +11 -0
- langchain_idemcore-0.1.0.dist-info/WHEEL +5 -0
- langchain_idemcore-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""LangChain integration for the Idemcore API."""
|
|
2
|
+
from .callback import IdemcoreReceiptHandler
|
|
3
|
+
from .chat_models import ChatIdemcore
|
|
4
|
+
from .verify import VerificationBundle, verify_langchain_receipt
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ChatIdemcore",
|
|
9
|
+
"IdemcoreReceiptHandler",
|
|
10
|
+
"VerificationBundle",
|
|
11
|
+
"verify_langchain_receipt",
|
|
12
|
+
"__version__",
|
|
13
|
+
]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Convert LangChain messages to the Idemcore chat message list."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, List, Sequence
|
|
5
|
+
|
|
6
|
+
from langchain_core.messages import (
|
|
7
|
+
AIMessage,
|
|
8
|
+
BaseMessage,
|
|
9
|
+
HumanMessage,
|
|
10
|
+
SystemMessage,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class UnsupportedMessageError(ValueError):
|
|
15
|
+
"""Raised when a message cannot be sent to the Idemcore chat API."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _content_to_text(content: Any) -> str:
|
|
19
|
+
if content is None:
|
|
20
|
+
return ""
|
|
21
|
+
if isinstance(content, str):
|
|
22
|
+
return content
|
|
23
|
+
if isinstance(content, list):
|
|
24
|
+
parts: List[str] = []
|
|
25
|
+
for block in content:
|
|
26
|
+
if isinstance(block, str):
|
|
27
|
+
parts.append(block)
|
|
28
|
+
elif isinstance(block, dict):
|
|
29
|
+
btype = block.get("type")
|
|
30
|
+
if btype in (None, "text") and "text" in block:
|
|
31
|
+
parts.append(str(block["text"]))
|
|
32
|
+
elif btype in ("image", "image_url", "file", "audio", "video", "media"):
|
|
33
|
+
raise UnsupportedMessageError(
|
|
34
|
+
"Idemcore chat accepts text messages only; multimodal content is not supported."
|
|
35
|
+
)
|
|
36
|
+
else:
|
|
37
|
+
raise UnsupportedMessageError(
|
|
38
|
+
f"Unsupported LangChain content block type: {btype!r}."
|
|
39
|
+
)
|
|
40
|
+
else:
|
|
41
|
+
text = getattr(block, "text", None)
|
|
42
|
+
if isinstance(text, str):
|
|
43
|
+
parts.append(text)
|
|
44
|
+
else:
|
|
45
|
+
raise UnsupportedMessageError(
|
|
46
|
+
"Idemcore chat accepts text messages only."
|
|
47
|
+
)
|
|
48
|
+
return "".join(parts)
|
|
49
|
+
return str(content)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def messages_to_idemcore(messages: Sequence[BaseMessage]) -> List[dict]:
|
|
53
|
+
"""Map LangChain messages to ``[{role, content}, ...]``.
|
|
54
|
+
|
|
55
|
+
Only ``system``, ``user``, and ``assistant`` text messages are accepted,
|
|
56
|
+
matching the public Idemcore SDK.
|
|
57
|
+
"""
|
|
58
|
+
out: List[dict] = []
|
|
59
|
+
for msg in messages:
|
|
60
|
+
typ = getattr(msg, "type", None)
|
|
61
|
+
if isinstance(msg, SystemMessage) or typ == "system":
|
|
62
|
+
role = "system"
|
|
63
|
+
elif isinstance(msg, HumanMessage) or typ == "human":
|
|
64
|
+
role = "user"
|
|
65
|
+
elif isinstance(msg, AIMessage) or typ == "ai":
|
|
66
|
+
role = "assistant"
|
|
67
|
+
else:
|
|
68
|
+
raise UnsupportedMessageError(
|
|
69
|
+
f"Idemcore chat does not accept {type(msg).__name__} messages "
|
|
70
|
+
"(tool/function/multimodal roles are not supported)."
|
|
71
|
+
)
|
|
72
|
+
if getattr(msg, "tool_calls", None):
|
|
73
|
+
raise UnsupportedMessageError(
|
|
74
|
+
"Idemcore chat does not support tool calls."
|
|
75
|
+
)
|
|
76
|
+
out.append({"role": role, "content": _content_to_text(msg.content)})
|
|
77
|
+
if not (1 <= len(out) <= 64):
|
|
78
|
+
raise ValueError("Idemcore chat requires between 1 and 64 messages.")
|
|
79
|
+
return out
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Build a JSON-serializable receipt payload from an Idemcore ChatResult."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Dict, Mapping, Optional
|
|
5
|
+
|
|
6
|
+
from idemcore import ChatResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def receipt_payload(result: ChatResult) -> Dict[str, Any]:
|
|
10
|
+
"""Fields stored on LangChain ``response_metadata`` / ``llm_output``."""
|
|
11
|
+
usage = dict(result.usage) if result.usage else {}
|
|
12
|
+
return {
|
|
13
|
+
"receipt_id": result.receipt_id,
|
|
14
|
+
"receipt": result.receipt,
|
|
15
|
+
"salt": result.salt,
|
|
16
|
+
"privacy": result.privacy,
|
|
17
|
+
"model": result.model,
|
|
18
|
+
"finish_reason": result.finish_reason,
|
|
19
|
+
"id": result.id,
|
|
20
|
+
"usage": usage,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def extract_receipt_payload(source: Any) -> Optional[Dict[str, Any]]:
|
|
25
|
+
"""Pull a receipt payload out of a LangChain or Idemcore object."""
|
|
26
|
+
if source is None:
|
|
27
|
+
return None
|
|
28
|
+
if isinstance(source, ChatResult):
|
|
29
|
+
return receipt_payload(source)
|
|
30
|
+
if isinstance(source, Mapping):
|
|
31
|
+
if "receipt" in source and "receipt_id" in source:
|
|
32
|
+
return dict(source)
|
|
33
|
+
if "receipt_id" in source and "receipt_version" in source:
|
|
34
|
+
return {"receipt_id": source["receipt_id"], "receipt": dict(source)}
|
|
35
|
+
meta = source.get("response_metadata") or source.get("llm_output") or source.get("idemcore")
|
|
36
|
+
if meta:
|
|
37
|
+
return extract_receipt_payload(meta)
|
|
38
|
+
inner = source.get("receipt")
|
|
39
|
+
if isinstance(inner, Mapping):
|
|
40
|
+
return {
|
|
41
|
+
"receipt_id": source.get("receipt_id") or inner.get("receipt_id"),
|
|
42
|
+
"receipt": dict(inner),
|
|
43
|
+
"salt": source.get("salt"),
|
|
44
|
+
"privacy": source.get("privacy") or inner.get("privacy"),
|
|
45
|
+
"model": source.get("model") or inner.get("model_id"),
|
|
46
|
+
"finish_reason": source.get("finish_reason"),
|
|
47
|
+
"id": source.get("id"),
|
|
48
|
+
"usage": source.get("usage") or {},
|
|
49
|
+
}
|
|
50
|
+
return None
|
|
51
|
+
meta = getattr(source, "response_metadata", None)
|
|
52
|
+
if meta:
|
|
53
|
+
found = extract_receipt_payload(meta)
|
|
54
|
+
if found:
|
|
55
|
+
return found
|
|
56
|
+
llm_output = getattr(source, "llm_output", None)
|
|
57
|
+
if llm_output:
|
|
58
|
+
found = extract_receipt_payload(llm_output)
|
|
59
|
+
if found:
|
|
60
|
+
return found
|
|
61
|
+
generations = getattr(source, "generations", None)
|
|
62
|
+
if generations:
|
|
63
|
+
first = generations[0]
|
|
64
|
+
# ChatResult.generations is list[ChatGeneration] or list[list[...]]
|
|
65
|
+
candidate = first[0] if isinstance(first, list) and first else first
|
|
66
|
+
message = getattr(candidate, "message", None)
|
|
67
|
+
if message is not None:
|
|
68
|
+
return extract_receipt_payload(message)
|
|
69
|
+
info = getattr(candidate, "generation_info", None)
|
|
70
|
+
if info:
|
|
71
|
+
return extract_receipt_payload(info)
|
|
72
|
+
return None
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Collect signed receipts produced during a LangChain run."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, List, Optional, Union
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
from langchain_core.callbacks import BaseCallbackHandler
|
|
10
|
+
from langchain_core.outputs import LLMResult
|
|
11
|
+
|
|
12
|
+
from ._receipt import extract_receipt_payload
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class IdemcoreReceiptHandler(BaseCallbackHandler):
|
|
16
|
+
"""Callback that records every Idemcore receipt attached to an LLM result.
|
|
17
|
+
|
|
18
|
+
Use ``receipts`` / ``to_list()`` for in-memory access, or ``to_jsonl()`` /
|
|
19
|
+
``dump_jsonl(path)`` to persist one JSON object per receipt.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
super().__init__()
|
|
24
|
+
self.receipts: List[Dict[str, Any]] = []
|
|
25
|
+
|
|
26
|
+
def on_llm_end(
|
|
27
|
+
self,
|
|
28
|
+
response: LLMResult,
|
|
29
|
+
*,
|
|
30
|
+
run_id: UUID,
|
|
31
|
+
parent_run_id: Optional[UUID] = None,
|
|
32
|
+
**kwargs: Any,
|
|
33
|
+
) -> None:
|
|
34
|
+
payload = extract_receipt_payload(response)
|
|
35
|
+
if payload is None:
|
|
36
|
+
# Fall back to scanning each generation in case only some are Idemcore.
|
|
37
|
+
for group in response.generations or []:
|
|
38
|
+
items = group if isinstance(group, list) else [group]
|
|
39
|
+
for gen in items:
|
|
40
|
+
found = extract_receipt_payload(gen) or extract_receipt_payload(
|
|
41
|
+
getattr(gen, "message", None)
|
|
42
|
+
)
|
|
43
|
+
if found:
|
|
44
|
+
payload = found
|
|
45
|
+
break
|
|
46
|
+
if payload:
|
|
47
|
+
break
|
|
48
|
+
if payload is not None:
|
|
49
|
+
record = dict(payload)
|
|
50
|
+
record["run_id"] = str(run_id)
|
|
51
|
+
if parent_run_id is not None:
|
|
52
|
+
record["parent_run_id"] = str(parent_run_id)
|
|
53
|
+
self.receipts.append(record)
|
|
54
|
+
|
|
55
|
+
def to_list(self) -> List[Dict[str, Any]]:
|
|
56
|
+
return list(self.receipts)
|
|
57
|
+
|
|
58
|
+
def to_jsonl(self) -> str:
|
|
59
|
+
if not self.receipts:
|
|
60
|
+
return ""
|
|
61
|
+
return "\n".join(json.dumps(r, ensure_ascii=False, default=str) for r in self.receipts) + "\n"
|
|
62
|
+
|
|
63
|
+
def dump_jsonl(self, path: Union[str, Path]) -> None:
|
|
64
|
+
Path(path).write_text(self.to_jsonl(), encoding="utf-8")
|
|
65
|
+
|
|
66
|
+
def clear(self) -> None:
|
|
67
|
+
self.receipts.clear()
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""LangChain chat model backed by the public Idemcore Python SDK."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, AsyncIterator, Dict, Iterator, List, Optional, Sequence
|
|
5
|
+
|
|
6
|
+
from idemcore import AsyncIdemcore, ChatResult, Idemcore, InvalidRequestError
|
|
7
|
+
from langchain_core.callbacks import AsyncCallbackManagerForLLMRun, CallbackManagerForLLMRun
|
|
8
|
+
from langchain_core.language_models.chat_models import BaseChatModel
|
|
9
|
+
from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage, UsageMetadata
|
|
10
|
+
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult as LCChatResult
|
|
11
|
+
from pydantic import Field, PrivateAttr
|
|
12
|
+
|
|
13
|
+
from ._convert import messages_to_idemcore
|
|
14
|
+
from ._receipt import receipt_payload
|
|
15
|
+
|
|
16
|
+
# Sampling keys the public SDK does not accept. A value of 0 or 1 that means
|
|
17
|
+
# "off / default" is ignored; any other value is rejected.
|
|
18
|
+
_BLOCKED_SAMPLING = {
|
|
19
|
+
"top_p": lambda v: v is not None and v != 1,
|
|
20
|
+
"topP": lambda v: v is not None and v != 1,
|
|
21
|
+
"top_k": lambda v: v is not None,
|
|
22
|
+
"topK": lambda v: v is not None,
|
|
23
|
+
"presence_penalty": lambda v: v not in (None, 0, 0.0),
|
|
24
|
+
"presencePenalty": lambda v: v not in (None, 0, 0.0),
|
|
25
|
+
"frequency_penalty": lambda v: v not in (None, 0, 0.0),
|
|
26
|
+
"frequencyPenalty": lambda v: v not in (None, 0, 0.0),
|
|
27
|
+
"seed": lambda v: v is not None,
|
|
28
|
+
"n": lambda v: v is not None and v != 1,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _usage_metadata(result: ChatResult) -> Optional[UsageMetadata]:
|
|
33
|
+
usage = result.usage or {}
|
|
34
|
+
if not usage:
|
|
35
|
+
return None
|
|
36
|
+
return UsageMetadata(
|
|
37
|
+
input_tokens=int(usage.get("prompt_tokens") or 0),
|
|
38
|
+
output_tokens=int(usage.get("completion_tokens") or 0),
|
|
39
|
+
total_tokens=int(usage.get("total_tokens") or 0),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _ai_message(result: ChatResult) -> AIMessage:
|
|
44
|
+
payload = receipt_payload(result)
|
|
45
|
+
return AIMessage(
|
|
46
|
+
content=result.text,
|
|
47
|
+
id=result.id or None,
|
|
48
|
+
usage_metadata=_usage_metadata(result),
|
|
49
|
+
response_metadata=payload,
|
|
50
|
+
additional_kwargs={"idemcore": payload},
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ChatIdemcore(BaseChatModel):
|
|
55
|
+
"""LangChain chat model that calls Idemcore and attaches a signed receipt.
|
|
56
|
+
|
|
57
|
+
Decoding is greedy: ``temperature`` must be omitted or 0, matching the
|
|
58
|
+
public SDK. The signed receipt is stored on ``AIMessage.response_metadata``.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
model: Optional[str] = Field(
|
|
62
|
+
default=None,
|
|
63
|
+
description="Idemcore model id. If omitted, the API default is used.",
|
|
64
|
+
)
|
|
65
|
+
api_key: Optional[str] = Field(
|
|
66
|
+
default=None,
|
|
67
|
+
description="API key. Defaults to the IDEMCORE_API_KEY environment variable.",
|
|
68
|
+
)
|
|
69
|
+
base_url: Optional[str] = Field(
|
|
70
|
+
default=None,
|
|
71
|
+
description="API base URL. Defaults to IDEMCORE_BASE_URL or https://api.idemcore.net.",
|
|
72
|
+
)
|
|
73
|
+
max_tokens: Optional[int] = Field(
|
|
74
|
+
default=None,
|
|
75
|
+
description="Maximum completion tokens (1–2048, per the public SDK).",
|
|
76
|
+
)
|
|
77
|
+
privacy: Optional[str] = Field(
|
|
78
|
+
default=None,
|
|
79
|
+
description="Receipt privacy: 'hash_only' (default) or 'full'.",
|
|
80
|
+
)
|
|
81
|
+
timeout: float = Field(default=120.0, description="Request timeout in seconds.")
|
|
82
|
+
temperature: Optional[float] = Field(
|
|
83
|
+
default=None,
|
|
84
|
+
description="Must be omitted or 0. Idemcore decoding is greedy.",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
_sync_client: Optional[Idemcore] = PrivateAttr(default=None)
|
|
88
|
+
_async_client: Optional[AsyncIdemcore] = PrivateAttr(default=None)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def _llm_type(self) -> str:
|
|
92
|
+
return "idemcore"
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def _identifying_params(self) -> Dict[str, Any]:
|
|
96
|
+
return {
|
|
97
|
+
"model": self.model,
|
|
98
|
+
"max_tokens": self.max_tokens,
|
|
99
|
+
"privacy": self.privacy,
|
|
100
|
+
"base_url": self.base_url,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
def bind_tools(self, tools: Sequence[Any], **kwargs: Any) -> Any:
|
|
104
|
+
raise NotImplementedError(
|
|
105
|
+
"Idemcore chat does not support tool calling; bind_tools is unavailable."
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def _get_sync(self) -> Idemcore:
|
|
109
|
+
if self._sync_client is None:
|
|
110
|
+
self._sync_client = Idemcore(
|
|
111
|
+
api_key=self.api_key, base_url=self.base_url, timeout=self.timeout
|
|
112
|
+
)
|
|
113
|
+
return self._sync_client
|
|
114
|
+
|
|
115
|
+
def _get_async(self) -> AsyncIdemcore:
|
|
116
|
+
if self._async_client is None:
|
|
117
|
+
self._async_client = AsyncIdemcore(
|
|
118
|
+
api_key=self.api_key, base_url=self.base_url, timeout=self.timeout
|
|
119
|
+
)
|
|
120
|
+
return self._async_client
|
|
121
|
+
|
|
122
|
+
def _chat_kwargs(self, **kwargs: Any) -> Dict[str, Any]:
|
|
123
|
+
stop = kwargs.pop("stop", None)
|
|
124
|
+
if stop:
|
|
125
|
+
raise InvalidRequestError(
|
|
126
|
+
"Idemcore does not support stop sequences; they would not be "
|
|
127
|
+
"reflected in the signed receipt.",
|
|
128
|
+
type="invalid_request_error",
|
|
129
|
+
)
|
|
130
|
+
temperature = kwargs.pop("temperature", self.temperature)
|
|
131
|
+
if temperature not in (None, 0, 0.0):
|
|
132
|
+
raise InvalidRequestError(
|
|
133
|
+
"Idemcore uses greedy decoding: temperature is fixed at 0.",
|
|
134
|
+
type="invalid_request_error",
|
|
135
|
+
)
|
|
136
|
+
for key, is_blocked in _BLOCKED_SAMPLING.items():
|
|
137
|
+
if key in kwargs and is_blocked(kwargs[key]):
|
|
138
|
+
raise InvalidRequestError(
|
|
139
|
+
f"Idemcore does not accept the sampling parameter {key!r}. "
|
|
140
|
+
"Decoding is greedy and temperature is fixed at 0.",
|
|
141
|
+
type="invalid_request_error",
|
|
142
|
+
)
|
|
143
|
+
kwargs.pop(key, None)
|
|
144
|
+
# Anything else is not a public SDK chat argument.
|
|
145
|
+
for leftover in ("tools", "tool_choice", "toolChoice", "functions", "function_call"):
|
|
146
|
+
if kwargs.get(leftover):
|
|
147
|
+
raise InvalidRequestError(
|
|
148
|
+
"Idemcore chat does not support tool or function calling.",
|
|
149
|
+
type="invalid_request_error",
|
|
150
|
+
)
|
|
151
|
+
kwargs.pop(leftover, None)
|
|
152
|
+
model = kwargs.pop("model", self.model)
|
|
153
|
+
max_tokens = kwargs.pop("max_tokens", None)
|
|
154
|
+
if max_tokens is None:
|
|
155
|
+
max_tokens = kwargs.pop("max_output_tokens", None)
|
|
156
|
+
if max_tokens is None:
|
|
157
|
+
max_tokens = kwargs.pop("maxOutputTokens", self.max_tokens)
|
|
158
|
+
privacy = kwargs.pop("privacy", self.privacy)
|
|
159
|
+
if kwargs:
|
|
160
|
+
unexpected = ", ".join(sorted(kwargs))
|
|
161
|
+
raise InvalidRequestError(
|
|
162
|
+
f"Unsupported Idemcore chat argument(s): {unexpected}.",
|
|
163
|
+
type="invalid_request_error",
|
|
164
|
+
)
|
|
165
|
+
out: Dict[str, Any] = {}
|
|
166
|
+
if model is not None:
|
|
167
|
+
out["model"] = model
|
|
168
|
+
if max_tokens is not None:
|
|
169
|
+
out["max_tokens"] = max_tokens
|
|
170
|
+
if privacy is not None:
|
|
171
|
+
out["privacy"] = privacy
|
|
172
|
+
if temperature is not None:
|
|
173
|
+
out["temperature"] = temperature
|
|
174
|
+
return out
|
|
175
|
+
|
|
176
|
+
def _generate(
|
|
177
|
+
self,
|
|
178
|
+
messages: List[BaseMessage],
|
|
179
|
+
stop: Optional[List[str]] = None,
|
|
180
|
+
run_manager: Optional[CallbackManagerForLLMRun] = None,
|
|
181
|
+
**kwargs: Any,
|
|
182
|
+
) -> LCChatResult:
|
|
183
|
+
kw = self._chat_kwargs(stop=stop, **kwargs)
|
|
184
|
+
result = self._get_sync().chat(messages_to_idemcore(messages), **kw)
|
|
185
|
+
message = _ai_message(result)
|
|
186
|
+
payload = receipt_payload(result)
|
|
187
|
+
return LCChatResult(
|
|
188
|
+
generations=[ChatGeneration(message=message, generation_info=payload, text=result.text)],
|
|
189
|
+
llm_output=payload,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
async def _agenerate(
|
|
193
|
+
self,
|
|
194
|
+
messages: List[BaseMessage],
|
|
195
|
+
stop: Optional[List[str]] = None,
|
|
196
|
+
run_manager: Optional[AsyncCallbackManagerForLLMRun] = None,
|
|
197
|
+
**kwargs: Any,
|
|
198
|
+
) -> LCChatResult:
|
|
199
|
+
kw = self._chat_kwargs(stop=stop, **kwargs)
|
|
200
|
+
result = await self._get_async().chat(messages_to_idemcore(messages), **kw)
|
|
201
|
+
message = _ai_message(result)
|
|
202
|
+
payload = receipt_payload(result)
|
|
203
|
+
return LCChatResult(
|
|
204
|
+
generations=[ChatGeneration(message=message, generation_info=payload, text=result.text)],
|
|
205
|
+
llm_output=payload,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def _stream(
|
|
209
|
+
self,
|
|
210
|
+
messages: List[BaseMessage],
|
|
211
|
+
stop: Optional[List[str]] = None,
|
|
212
|
+
run_manager: Optional[CallbackManagerForLLMRun] = None,
|
|
213
|
+
**kwargs: Any,
|
|
214
|
+
) -> Iterator[ChatGenerationChunk]:
|
|
215
|
+
kw = self._chat_kwargs(stop=stop, **kwargs)
|
|
216
|
+
stream = self._get_sync().chat_stream(messages_to_idemcore(messages), **kw)
|
|
217
|
+
for chunk in stream:
|
|
218
|
+
if not chunk.delta:
|
|
219
|
+
continue
|
|
220
|
+
cg = ChatGenerationChunk(message=AIMessageChunk(content=chunk.delta), text=chunk.delta)
|
|
221
|
+
if run_manager:
|
|
222
|
+
run_manager.on_llm_new_token(chunk.delta, chunk=cg)
|
|
223
|
+
yield cg
|
|
224
|
+
result = stream.result
|
|
225
|
+
if result is None:
|
|
226
|
+
raise RuntimeError("Idemcore stream ended without a signed receipt.")
|
|
227
|
+
payload = receipt_payload(result)
|
|
228
|
+
final = ChatGenerationChunk(
|
|
229
|
+
message=AIMessageChunk(
|
|
230
|
+
content="",
|
|
231
|
+
response_metadata=payload,
|
|
232
|
+
additional_kwargs={"idemcore": payload},
|
|
233
|
+
usage_metadata=_usage_metadata(result),
|
|
234
|
+
),
|
|
235
|
+
generation_info=payload,
|
|
236
|
+
text="",
|
|
237
|
+
)
|
|
238
|
+
yield final
|
|
239
|
+
|
|
240
|
+
async def _astream(
|
|
241
|
+
self,
|
|
242
|
+
messages: List[BaseMessage],
|
|
243
|
+
stop: Optional[List[str]] = None,
|
|
244
|
+
run_manager: Optional[AsyncCallbackManagerForLLMRun] = None,
|
|
245
|
+
**kwargs: Any,
|
|
246
|
+
) -> AsyncIterator[ChatGenerationChunk]:
|
|
247
|
+
kw = self._chat_kwargs(stop=stop, **kwargs)
|
|
248
|
+
stream = await self._get_async().chat_stream(messages_to_idemcore(messages), **kw)
|
|
249
|
+
async for chunk in stream:
|
|
250
|
+
if not chunk.delta:
|
|
251
|
+
continue
|
|
252
|
+
cg = ChatGenerationChunk(message=AIMessageChunk(content=chunk.delta), text=chunk.delta)
|
|
253
|
+
if run_manager:
|
|
254
|
+
await run_manager.on_llm_new_token(chunk.delta, chunk=cg)
|
|
255
|
+
yield cg
|
|
256
|
+
result = stream.result
|
|
257
|
+
if result is None:
|
|
258
|
+
raise RuntimeError("Idemcore stream ended without a signed receipt.")
|
|
259
|
+
payload = receipt_payload(result)
|
|
260
|
+
yield ChatGenerationChunk(
|
|
261
|
+
message=AIMessageChunk(
|
|
262
|
+
content="",
|
|
263
|
+
response_metadata=payload,
|
|
264
|
+
additional_kwargs={"idemcore": payload},
|
|
265
|
+
usage_metadata=_usage_metadata(result),
|
|
266
|
+
),
|
|
267
|
+
generation_info=payload,
|
|
268
|
+
text="",
|
|
269
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Verify receipts attached to LangChain results.
|
|
2
|
+
|
|
3
|
+
Full receipt checks (signature, token-log chain when a log is provided, and
|
|
4
|
+
prompt/answer commitments) use ``idemcore.verify_receipt``. When a public
|
|
5
|
+
token log is supplied, it is also checked with the open ``idemcore-verify``
|
|
6
|
+
package (``parse_log`` + ``check_log``).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Dict, List, Mapping, Optional, Sequence, Union
|
|
12
|
+
|
|
13
|
+
from idemcore import VerificationResult, verify_receipt
|
|
14
|
+
from idemcore_verify.core import check_log, parse_log
|
|
15
|
+
|
|
16
|
+
from ._receipt import extract_receipt_payload
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class VerificationBundle:
|
|
21
|
+
"""Combined result of receipt verification and an optional public-log check."""
|
|
22
|
+
|
|
23
|
+
ok: bool
|
|
24
|
+
receipt: Dict[str, Any]
|
|
25
|
+
receipt_id: str
|
|
26
|
+
verification: VerificationResult
|
|
27
|
+
log_ok: Optional[bool] = None
|
|
28
|
+
log_errors: List[str] = field(default_factory=list)
|
|
29
|
+
log_details: Dict[str, Any] = field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
def __bool__(self) -> bool:
|
|
32
|
+
return self.ok
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def verify_langchain_receipt(
|
|
36
|
+
source: Any,
|
|
37
|
+
*,
|
|
38
|
+
log_text: Optional[str] = None,
|
|
39
|
+
messages: Optional[Sequence[Mapping[str, Any]]] = None,
|
|
40
|
+
salt: Optional[str] = None,
|
|
41
|
+
output_text: Optional[str] = None,
|
|
42
|
+
output_token_ids: Optional[Sequence[int]] = None,
|
|
43
|
+
keys: Any = None,
|
|
44
|
+
check_signature: bool = True,
|
|
45
|
+
check_public_log: bool = True,
|
|
46
|
+
) -> VerificationBundle:
|
|
47
|
+
"""Verify a receipt taken from a LangChain message, ChatResult, or payload dict.
|
|
48
|
+
|
|
49
|
+
Parameters
|
|
50
|
+
----------
|
|
51
|
+
source:
|
|
52
|
+
An ``AIMessage``, LangChain ``ChatResult`` / ``LLMResult``, an Idemcore
|
|
53
|
+
``ChatResult``, or a mapping that contains ``receipt`` / ``receipt_id``.
|
|
54
|
+
log_text:
|
|
55
|
+
Public token log text. Enables the L2 chain check inside
|
|
56
|
+
``idemcore.verify_receipt`` and, when ``check_public_log`` is true, a
|
|
57
|
+
standalone ``idemcore-verify`` log check.
|
|
58
|
+
messages, salt, output_text, output_token_ids:
|
|
59
|
+
Disclosure values used to confirm the receipt commits to this prompt
|
|
60
|
+
and answer. When omitted, values stored on ``source`` are used if present.
|
|
61
|
+
"""
|
|
62
|
+
payload = extract_receipt_payload(source)
|
|
63
|
+
if not payload or not payload.get("receipt"):
|
|
64
|
+
raise ValueError("No Idemcore receipt found on the given object.")
|
|
65
|
+
receipt = dict(payload["receipt"])
|
|
66
|
+
receipt_id = payload.get("receipt_id") or receipt.get("receipt_id") or ""
|
|
67
|
+
if salt is None:
|
|
68
|
+
salt = payload.get("salt")
|
|
69
|
+
if output_text is None:
|
|
70
|
+
content = getattr(source, "content", None)
|
|
71
|
+
if isinstance(content, str) and content:
|
|
72
|
+
output_text = content
|
|
73
|
+
if messages is None:
|
|
74
|
+
# ChatResult (SDK) keeps the exact messages that were sent.
|
|
75
|
+
maybe = getattr(source, "messages", None)
|
|
76
|
+
if maybe:
|
|
77
|
+
messages = list(maybe)
|
|
78
|
+
|
|
79
|
+
verification = verify_receipt(
|
|
80
|
+
receipt,
|
|
81
|
+
keys=keys,
|
|
82
|
+
log_text=log_text,
|
|
83
|
+
messages=messages,
|
|
84
|
+
salt=salt,
|
|
85
|
+
output_token_ids=output_token_ids,
|
|
86
|
+
output_text=output_text,
|
|
87
|
+
check_signature=check_signature,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
log_ok: Optional[bool] = None
|
|
91
|
+
log_errors: List[str] = []
|
|
92
|
+
log_details: Dict[str, Any] = {}
|
|
93
|
+
if log_text and check_public_log:
|
|
94
|
+
parsed = parse_log("<memory>", lines=log_text.splitlines())
|
|
95
|
+
checked = check_log(parsed)
|
|
96
|
+
log_ok = bool(checked.ok) and not parsed.errors
|
|
97
|
+
log_errors = list(parsed.errors) + list(checked.errors)
|
|
98
|
+
log_details = {
|
|
99
|
+
"n_tokens": checked.n_tokens,
|
|
100
|
+
"all_claimed": checked.all_claimed,
|
|
101
|
+
"all_recomputed": checked.all_recomputed,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
ok = bool(verification.ok) and (log_ok is not False)
|
|
105
|
+
return VerificationBundle(
|
|
106
|
+
ok=ok,
|
|
107
|
+
receipt=receipt,
|
|
108
|
+
receipt_id=receipt_id,
|
|
109
|
+
verification=verification,
|
|
110
|
+
log_ok=log_ok,
|
|
111
|
+
log_errors=log_errors,
|
|
112
|
+
log_details=log_details,
|
|
113
|
+
)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: langchain-idemcore
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LangChain chat model for Idemcore, with signed verifiable receipts in response metadata.
|
|
5
|
+
Author-email: Don Wick <don.wick@idemcore.net>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://idemcore.net
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: idemcore<0.4,>=0.3.0
|
|
11
|
+
Requires-Dist: idemcore-verify<0.4,>=0.3.6
|
|
12
|
+
Requires-Dist: langchain-core>=0.3.0
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
15
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
|
|
16
|
+
|
|
17
|
+
# langchain-idemcore
|
|
18
|
+
|
|
19
|
+
LangChain chat model for the [Idemcore](https://idemcore.net) API. Every answer
|
|
20
|
+
comes with a signed, independently verifiable, bit-exact receipt, stored on
|
|
21
|
+
`AIMessage.response_metadata`.
|
|
22
|
+
|
|
23
|
+
Private package. Do not publish to PyPI without explicit approval.
|
|
24
|
+
|
|
25
|
+
## Install (local)
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install -e /workspace/idemcore-integrations/packages/langchain-idemcore
|
|
29
|
+
export IDEMCORE_API_KEY=ick_...
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Requires `idemcore>=0.3` (r2 receipts) and `idemcore-verify>=0.3.6`.
|
|
33
|
+
|
|
34
|
+
## Quickstart
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from langchain_core.messages import HumanMessage
|
|
38
|
+
from langchain_idemcore import ChatIdemcore, IdemcoreReceiptHandler, verify_langchain_receipt
|
|
39
|
+
|
|
40
|
+
llm = ChatIdemcore(model="your-model-id", max_tokens=64)
|
|
41
|
+
handler = IdemcoreReceiptHandler()
|
|
42
|
+
|
|
43
|
+
msg = llm.invoke(
|
|
44
|
+
[HumanMessage(content="Name the three primary colours.")],
|
|
45
|
+
config={"callbacks": [handler]},
|
|
46
|
+
)
|
|
47
|
+
print(msg.content)
|
|
48
|
+
print(msg.response_metadata["receipt_id"]) # rcpt_...
|
|
49
|
+
print(handler.to_jsonl())
|
|
50
|
+
|
|
51
|
+
# Local check of the signed receipt (and of a public token log, if you have one):
|
|
52
|
+
# bundle = verify_langchain_receipt(msg, log_text=log_text)
|
|
53
|
+
# print(bundle.ok, bundle.verification.levels)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Streaming works the same way: text arrives in chunks, and the receipt is
|
|
57
|
+
attached to a later chunk's `response_metadata` once the answer is complete.
|
|
58
|
+
Merging the `AIMessageChunk`s (or using `IdemcoreReceiptHandler`) preserves it.
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
for chunk in llm.stream([HumanMessage(content="Write one short sentence.")]):
|
|
62
|
+
print(chunk.content, end="")
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Receipt metadata
|
|
66
|
+
|
|
67
|
+
`AIMessage.response_metadata` (and `ChatResult.llm_output`) contains:
|
|
68
|
+
|
|
69
|
+
- `receipt_id`, `receipt` (the full signed r2 receipt)
|
|
70
|
+
- `salt` (for `hash_only` receipts; store it with the conversation)
|
|
71
|
+
- `privacy`, `model`, `finish_reason`, `id`, `usage`
|
|
72
|
+
|
|
73
|
+
`IdemcoreReceiptHandler` collects those payloads for a whole run and can write
|
|
74
|
+
them as JSONL.
|
|
75
|
+
|
|
76
|
+
`verify_langchain_receipt` runs `idemcore.verify_receipt` (signature, optional
|
|
77
|
+
token-log chain, optional prompt/answer commitments). When `log_text` is
|
|
78
|
+
given it also runs the open `idemcore-verify` log check.
|
|
79
|
+
|
|
80
|
+
## Sampling
|
|
81
|
+
|
|
82
|
+
Idemcore decoding is greedy: **temperature is fixed at 0**. Passing any other
|
|
83
|
+
temperature, or sampling settings such as `top_p`, `top_k`, penalties, `seed`,
|
|
84
|
+
`n`, or stop sequences, raises an error before a request is made. Tool and
|
|
85
|
+
function calling are not supported.
|
|
86
|
+
|
|
87
|
+
`max_tokens` must be a whole number from 1 to 2048. `privacy` is `hash_only`
|
|
88
|
+
(default) or `full`. Auth is `IDEMCORE_API_KEY` (or `api_key=`). Billing is
|
|
89
|
+
measured server-side in tokens.
|
|
90
|
+
|
|
91
|
+
## Tests
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
pip install -e ".[dev]"
|
|
95
|
+
pytest
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Unit tests mock the SDK. A live smoke test runs only when `IDEMCORE_API_KEY`
|
|
99
|
+
is set in the environment.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
langchain_idemcore/__init__.py,sha256=ezUwjGKRgVMbQVpF87MZ0ubNSJE0P_ZGscBn4z6xrzI,362
|
|
2
|
+
langchain_idemcore/_convert.py,sha256=yuq8wPMouAi9rBHAIhWLu_iwyhI8srNr7nKz6Er6NLM,2898
|
|
3
|
+
langchain_idemcore/_receipt.py,sha256=5t0cAycsa77mzsIp812VWD-ipiHHwX2KVEUHZPDy5nk,2839
|
|
4
|
+
langchain_idemcore/callback.py,sha256=ig0QoYjjwoslzHhIWMuveD_2J-adpGHwqgWCf6H9BZ0,2279
|
|
5
|
+
langchain_idemcore/chat_models.py,sha256=KjuKYC8eny9bZmpKAd3tu8p_oRIBCmKh034itfNokbI,10556
|
|
6
|
+
langchain_idemcore/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
langchain_idemcore/verify.py,sha256=Fcd7bliUSEcI7UqHq_HIURMccVeuKNMvkivdRRrREM4,3967
|
|
8
|
+
langchain_idemcore-0.1.0.dist-info/METADATA,sha256=NFUBU4bHDUJPfZvubno7TpVYMugQtG4L_KKL6_1pyQU,3299
|
|
9
|
+
langchain_idemcore-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
10
|
+
langchain_idemcore-0.1.0.dist-info/top_level.txt,sha256=PoKvSO6P2io2oFmD4H3NFDBh9mpRJ0psuGxlGJj9uoU,19
|
|
11
|
+
langchain_idemcore-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
langchain_idemcore
|