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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ langchain_idemcore