langchain-idemcore 0.1.0__tar.gz
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-0.1.0/PKG-INFO +99 -0
- langchain_idemcore-0.1.0/README.md +83 -0
- langchain_idemcore-0.1.0/pyproject.toml +35 -0
- langchain_idemcore-0.1.0/setup.cfg +4 -0
- langchain_idemcore-0.1.0/src/langchain_idemcore/__init__.py +13 -0
- langchain_idemcore-0.1.0/src/langchain_idemcore/_convert.py +79 -0
- langchain_idemcore-0.1.0/src/langchain_idemcore/_receipt.py +72 -0
- langchain_idemcore-0.1.0/src/langchain_idemcore/callback.py +67 -0
- langchain_idemcore-0.1.0/src/langchain_idemcore/chat_models.py +269 -0
- langchain_idemcore-0.1.0/src/langchain_idemcore/py.typed +0 -0
- langchain_idemcore-0.1.0/src/langchain_idemcore/verify.py +113 -0
- langchain_idemcore-0.1.0/src/langchain_idemcore.egg-info/PKG-INFO +99 -0
- langchain_idemcore-0.1.0/src/langchain_idemcore.egg-info/SOURCES.txt +17 -0
- langchain_idemcore-0.1.0/src/langchain_idemcore.egg-info/dependency_links.txt +1 -0
- langchain_idemcore-0.1.0/src/langchain_idemcore.egg-info/requires.txt +7 -0
- langchain_idemcore-0.1.0/src/langchain_idemcore.egg-info/top_level.txt +1 -0
- langchain_idemcore-0.1.0/tests/test_callback_jsonl.py +27 -0
- langchain_idemcore-0.1.0/tests/test_chat_models.py +142 -0
- langchain_idemcore-0.1.0/tests/test_langchain_verify.py +66 -0
|
@@ -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,83 @@
|
|
|
1
|
+
# langchain-idemcore
|
|
2
|
+
|
|
3
|
+
LangChain chat model for the [Idemcore](https://idemcore.net) API. Every answer
|
|
4
|
+
comes with a signed, independently verifiable, bit-exact receipt, stored on
|
|
5
|
+
`AIMessage.response_metadata`.
|
|
6
|
+
|
|
7
|
+
Private package. Do not publish to PyPI without explicit approval.
|
|
8
|
+
|
|
9
|
+
## Install (local)
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install -e /workspace/idemcore-integrations/packages/langchain-idemcore
|
|
13
|
+
export IDEMCORE_API_KEY=ick_...
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Requires `idemcore>=0.3` (r2 receipts) and `idemcore-verify>=0.3.6`.
|
|
17
|
+
|
|
18
|
+
## Quickstart
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from langchain_core.messages import HumanMessage
|
|
22
|
+
from langchain_idemcore import ChatIdemcore, IdemcoreReceiptHandler, verify_langchain_receipt
|
|
23
|
+
|
|
24
|
+
llm = ChatIdemcore(model="your-model-id", max_tokens=64)
|
|
25
|
+
handler = IdemcoreReceiptHandler()
|
|
26
|
+
|
|
27
|
+
msg = llm.invoke(
|
|
28
|
+
[HumanMessage(content="Name the three primary colours.")],
|
|
29
|
+
config={"callbacks": [handler]},
|
|
30
|
+
)
|
|
31
|
+
print(msg.content)
|
|
32
|
+
print(msg.response_metadata["receipt_id"]) # rcpt_...
|
|
33
|
+
print(handler.to_jsonl())
|
|
34
|
+
|
|
35
|
+
# Local check of the signed receipt (and of a public token log, if you have one):
|
|
36
|
+
# bundle = verify_langchain_receipt(msg, log_text=log_text)
|
|
37
|
+
# print(bundle.ok, bundle.verification.levels)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Streaming works the same way: text arrives in chunks, and the receipt is
|
|
41
|
+
attached to a later chunk's `response_metadata` once the answer is complete.
|
|
42
|
+
Merging the `AIMessageChunk`s (or using `IdemcoreReceiptHandler`) preserves it.
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
for chunk in llm.stream([HumanMessage(content="Write one short sentence.")]):
|
|
46
|
+
print(chunk.content, end="")
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Receipt metadata
|
|
50
|
+
|
|
51
|
+
`AIMessage.response_metadata` (and `ChatResult.llm_output`) contains:
|
|
52
|
+
|
|
53
|
+
- `receipt_id`, `receipt` (the full signed r2 receipt)
|
|
54
|
+
- `salt` (for `hash_only` receipts; store it with the conversation)
|
|
55
|
+
- `privacy`, `model`, `finish_reason`, `id`, `usage`
|
|
56
|
+
|
|
57
|
+
`IdemcoreReceiptHandler` collects those payloads for a whole run and can write
|
|
58
|
+
them as JSONL.
|
|
59
|
+
|
|
60
|
+
`verify_langchain_receipt` runs `idemcore.verify_receipt` (signature, optional
|
|
61
|
+
token-log chain, optional prompt/answer commitments). When `log_text` is
|
|
62
|
+
given it also runs the open `idemcore-verify` log check.
|
|
63
|
+
|
|
64
|
+
## Sampling
|
|
65
|
+
|
|
66
|
+
Idemcore decoding is greedy: **temperature is fixed at 0**. Passing any other
|
|
67
|
+
temperature, or sampling settings such as `top_p`, `top_k`, penalties, `seed`,
|
|
68
|
+
`n`, or stop sequences, raises an error before a request is made. Tool and
|
|
69
|
+
function calling are not supported.
|
|
70
|
+
|
|
71
|
+
`max_tokens` must be a whole number from 1 to 2048. `privacy` is `hash_only`
|
|
72
|
+
(default) or `full`. Auth is `IDEMCORE_API_KEY` (or `api_key=`). Billing is
|
|
73
|
+
measured server-side in tokens.
|
|
74
|
+
|
|
75
|
+
## Tests
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
pip install -e ".[dev]"
|
|
79
|
+
pytest
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Unit tests mock the SDK. A live smoke test runs only when `IDEMCORE_API_KEY`
|
|
83
|
+
is set in the environment.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "langchain-idemcore"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "LangChain chat model for Idemcore, with signed verifiable receipts in response metadata."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
authors = [{ name = "Don Wick", email = "don.wick@idemcore.net" }]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"idemcore>=0.3.0,<0.4",
|
|
15
|
+
"idemcore-verify>=0.3.6,<0.4",
|
|
16
|
+
"langchain-core>=0.3.0",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.24"]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://idemcore.net"
|
|
24
|
+
|
|
25
|
+
[tool.setuptools]
|
|
26
|
+
package-dir = {"" = "src"}
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
where = ["src"]
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
testpaths = ["tests"]
|
|
33
|
+
asyncio_mode = "auto"
|
|
34
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
35
|
+
pythonpath = ["src", "."]
|
|
@@ -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,17 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/langchain_idemcore/__init__.py
|
|
4
|
+
src/langchain_idemcore/_convert.py
|
|
5
|
+
src/langchain_idemcore/_receipt.py
|
|
6
|
+
src/langchain_idemcore/callback.py
|
|
7
|
+
src/langchain_idemcore/chat_models.py
|
|
8
|
+
src/langchain_idemcore/py.typed
|
|
9
|
+
src/langchain_idemcore/verify.py
|
|
10
|
+
src/langchain_idemcore.egg-info/PKG-INFO
|
|
11
|
+
src/langchain_idemcore.egg-info/SOURCES.txt
|
|
12
|
+
src/langchain_idemcore.egg-info/dependency_links.txt
|
|
13
|
+
src/langchain_idemcore.egg-info/requires.txt
|
|
14
|
+
src/langchain_idemcore.egg-info/top_level.txt
|
|
15
|
+
tests/test_callback_jsonl.py
|
|
16
|
+
tests/test_chat_models.py
|
|
17
|
+
tests/test_langchain_verify.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
langchain_idemcore
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from uuid import uuid4
|
|
4
|
+
|
|
5
|
+
from langchain_core.messages import AIMessage
|
|
6
|
+
from langchain_core.outputs import ChatGeneration, LLMResult
|
|
7
|
+
|
|
8
|
+
from langchain_idemcore import IdemcoreReceiptHandler
|
|
9
|
+
from langchain_idemcore._receipt import receipt_payload
|
|
10
|
+
from tests.conftest import make_result
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_handler_dump_jsonl(tmp_path):
|
|
14
|
+
result = make_result()
|
|
15
|
+
payload = receipt_payload(result)
|
|
16
|
+
message = AIMessage(content=result.text, response_metadata=payload)
|
|
17
|
+
llm_result = LLMResult(generations=[[ChatGeneration(message=message, generation_info=payload)]])
|
|
18
|
+
handler = IdemcoreReceiptHandler()
|
|
19
|
+
handler.on_llm_end(llm_result, run_id=uuid4())
|
|
20
|
+
path = tmp_path / "receipts.jsonl"
|
|
21
|
+
handler.dump_jsonl(path)
|
|
22
|
+
text = path.read_text(encoding="utf-8")
|
|
23
|
+
assert result.receipt_id in text
|
|
24
|
+
assert text.endswith("\n")
|
|
25
|
+
handler.clear()
|
|
26
|
+
assert handler.to_list() == []
|
|
27
|
+
assert handler.to_jsonl() == ""
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from idemcore import InvalidRequestError
|
|
7
|
+
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
|
|
8
|
+
|
|
9
|
+
from langchain_idemcore import ChatIdemcore, IdemcoreReceiptHandler
|
|
10
|
+
from langchain_idemcore._convert import UnsupportedMessageError
|
|
11
|
+
from tests.conftest import FakeAsyncClient, FakeClient, make_result
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _model(client, async_client=None) -> ChatIdemcore:
|
|
15
|
+
llm = ChatIdemcore(model="demo-model", max_tokens=64, privacy="hash_only")
|
|
16
|
+
llm._sync_client = client
|
|
17
|
+
llm._async_client = async_client or FakeAsyncClient(client.result)
|
|
18
|
+
return llm
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_invoke_attaches_receipt_to_response_metadata():
|
|
22
|
+
result = make_result()
|
|
23
|
+
client = FakeClient(result)
|
|
24
|
+
llm = _model(client)
|
|
25
|
+
handler = IdemcoreReceiptHandler()
|
|
26
|
+
|
|
27
|
+
msg = llm.invoke(
|
|
28
|
+
[SystemMessage(content="Be brief."), HumanMessage(content="Name the three primary colours.")],
|
|
29
|
+
config={"callbacks": [handler]},
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
assert isinstance(msg, AIMessage)
|
|
33
|
+
assert msg.content == "red green blue"
|
|
34
|
+
assert msg.response_metadata["receipt_id"] == result.receipt_id
|
|
35
|
+
assert msg.response_metadata["receipt"]["receipt_version"] == "r2"
|
|
36
|
+
assert msg.response_metadata["salt"] == "c2FsdA"
|
|
37
|
+
assert msg.additional_kwargs["idemcore"]["receipt_id"] == result.receipt_id
|
|
38
|
+
assert msg.usage_metadata["total_tokens"] == 11
|
|
39
|
+
assert client.last_messages == [
|
|
40
|
+
{"role": "system", "content": "Be brief."},
|
|
41
|
+
{"role": "user", "content": "Name the three primary colours."},
|
|
42
|
+
]
|
|
43
|
+
assert client.last_kwargs["model"] == "demo-model"
|
|
44
|
+
assert client.last_kwargs["max_tokens"] == 64
|
|
45
|
+
assert client.last_kwargs["privacy"] == "hash_only"
|
|
46
|
+
assert len(handler.receipts) == 1
|
|
47
|
+
assert handler.receipts[0]["receipt_id"] == result.receipt_id
|
|
48
|
+
assert "run_id" in handler.receipts[0]
|
|
49
|
+
jsonl = handler.to_jsonl()
|
|
50
|
+
assert result.receipt_id in jsonl
|
|
51
|
+
assert jsonl.endswith("\n")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.mark.asyncio
|
|
55
|
+
async def test_ainvoke_attaches_receipt():
|
|
56
|
+
result = make_result(text="ok")
|
|
57
|
+
async_client = FakeAsyncClient(result)
|
|
58
|
+
llm = _model(FakeClient(result), async_client)
|
|
59
|
+
msg = await llm.ainvoke([HumanMessage(content="Hi")])
|
|
60
|
+
assert msg.content == "ok"
|
|
61
|
+
assert msg.response_metadata["receipt_id"] == result.receipt_id
|
|
62
|
+
assert async_client.last_messages == [{"role": "user", "content": "Hi"}]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_stream_attaches_receipt_on_final_chunk():
|
|
66
|
+
result = make_result(text="abc")
|
|
67
|
+
llm = _model(FakeClient(result, stream_text="abc"))
|
|
68
|
+
chunks = list(llm.stream([HumanMessage(content="go")]))
|
|
69
|
+
text = "".join(c.content for c in chunks)
|
|
70
|
+
assert text == "abc"
|
|
71
|
+
# LangChain may append an empty trailing chunk; the receipt is on a chunk
|
|
72
|
+
# and is preserved when AIMessageChunks are merged.
|
|
73
|
+
with_receipt = [c for c in chunks if c.response_metadata.get("receipt_id")]
|
|
74
|
+
assert with_receipt
|
|
75
|
+
assert with_receipt[0].response_metadata["receipt_id"] == result.receipt_id
|
|
76
|
+
assert with_receipt[0].response_metadata["receipt"]["receipt_version"] == "r2"
|
|
77
|
+
merged = chunks[0]
|
|
78
|
+
for c in chunks[1:]:
|
|
79
|
+
merged = merged + c
|
|
80
|
+
assert merged.response_metadata["receipt_id"] == result.receipt_id
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@pytest.mark.asyncio
|
|
84
|
+
async def test_astream_attaches_receipt_on_final_chunk():
|
|
85
|
+
result = make_result(text="xyz")
|
|
86
|
+
async_client = FakeAsyncClient(result, stream_text="xyz")
|
|
87
|
+
llm = _model(FakeClient(result), async_client)
|
|
88
|
+
chunks = [c async for c in llm.astream([HumanMessage(content="go")])]
|
|
89
|
+
assert "".join(c.content for c in chunks) == "xyz"
|
|
90
|
+
with_receipt = [c for c in chunks if c.response_metadata.get("receipt_id")]
|
|
91
|
+
assert with_receipt and with_receipt[0].response_metadata["receipt_id"] == result.receipt_id
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_rejects_nonzero_temperature():
|
|
95
|
+
llm = _model(FakeClient(make_result()))
|
|
96
|
+
with pytest.raises(InvalidRequestError, match="temperature is fixed at 0"):
|
|
97
|
+
llm.invoke([HumanMessage(content="hi")], temperature=0.7)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_rejects_top_p_and_seed():
|
|
101
|
+
llm = _model(FakeClient(make_result()))
|
|
102
|
+
with pytest.raises(InvalidRequestError, match="sampling"):
|
|
103
|
+
llm.invoke([HumanMessage(content="hi")], top_p=0.9)
|
|
104
|
+
with pytest.raises(InvalidRequestError, match="sampling"):
|
|
105
|
+
llm.invoke([HumanMessage(content="hi")], seed=1)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_rejects_stop_sequences():
|
|
109
|
+
llm = _model(FakeClient(make_result()))
|
|
110
|
+
with pytest.raises(InvalidRequestError, match="stop sequences"):
|
|
111
|
+
llm.invoke([HumanMessage(content="hi")], stop=["\n"])
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_rejects_tool_messages_and_bind_tools():
|
|
115
|
+
llm = _model(FakeClient(make_result()))
|
|
116
|
+
with pytest.raises(UnsupportedMessageError):
|
|
117
|
+
llm.invoke([HumanMessage(content="hi"), ToolMessage(content="tool", tool_call_id="1")])
|
|
118
|
+
with pytest.raises(NotImplementedError, match="tool calling"):
|
|
119
|
+
llm.bind_tools([{"type": "function", "function": {"name": "x"}}])
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_temperature_zero_is_allowed():
|
|
123
|
+
client = FakeClient(make_result())
|
|
124
|
+
llm = _model(client)
|
|
125
|
+
llm.invoke([HumanMessage(content="hi")], temperature=0)
|
|
126
|
+
assert client.last_kwargs.get("temperature") == 0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@pytest.mark.skipif(not os.environ.get("IDEMCORE_API_KEY"), reason="IDEMCORE_API_KEY not set")
|
|
130
|
+
def test_live_smoke_chat():
|
|
131
|
+
llm = ChatIdemcore(max_tokens=16)
|
|
132
|
+
models = llm._get_sync().models()
|
|
133
|
+
assert models, "API returned no models"
|
|
134
|
+
llm.model = models[0]["id"]
|
|
135
|
+
handler = IdemcoreReceiptHandler()
|
|
136
|
+
msg = llm.invoke(
|
|
137
|
+
[HumanMessage(content="Reply with the single word yes.")],
|
|
138
|
+
config={"callbacks": [handler]},
|
|
139
|
+
)
|
|
140
|
+
assert msg.content
|
|
141
|
+
assert msg.response_metadata.get("receipt_id", "").startswith("rcpt_")
|
|
142
|
+
assert handler.receipts
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from types import SimpleNamespace
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from idemcore import VerificationResult
|
|
7
|
+
from idemcore.verify import LevelResult
|
|
8
|
+
|
|
9
|
+
from langchain_idemcore import verify_langchain_receipt
|
|
10
|
+
from langchain_idemcore._receipt import receipt_payload
|
|
11
|
+
from tests.conftest import make_result
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_verify_helper_uses_sdk_and_open_verifier(monkeypatch):
|
|
15
|
+
result = make_result()
|
|
16
|
+
payload = receipt_payload(result)
|
|
17
|
+
message = SimpleNamespace(content=result.text, response_metadata=payload, messages=result.messages)
|
|
18
|
+
|
|
19
|
+
calls = {}
|
|
20
|
+
|
|
21
|
+
def fake_verify_receipt(receipt, **kwargs):
|
|
22
|
+
calls["verify"] = {"receipt": receipt, **kwargs}
|
|
23
|
+
return VerificationResult(
|
|
24
|
+
ok=True,
|
|
25
|
+
levels={"L1": LevelResult(level="L1", ok=True)},
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
class FakeChecked:
|
|
29
|
+
ok = True
|
|
30
|
+
n_tokens = 2
|
|
31
|
+
all_claimed = "0123456789abcdef"
|
|
32
|
+
all_recomputed = "0123456789abcdef"
|
|
33
|
+
errors = []
|
|
34
|
+
|
|
35
|
+
class FakeParsed:
|
|
36
|
+
errors = []
|
|
37
|
+
|
|
38
|
+
def fake_parse_log(path, lines=None):
|
|
39
|
+
calls["parse"] = {"path": path, "lines": list(lines)}
|
|
40
|
+
return FakeParsed()
|
|
41
|
+
|
|
42
|
+
def fake_check_log(log):
|
|
43
|
+
calls["check"] = log
|
|
44
|
+
return FakeChecked()
|
|
45
|
+
|
|
46
|
+
monkeypatch.setattr("langchain_idemcore.verify.verify_receipt", fake_verify_receipt)
|
|
47
|
+
monkeypatch.setattr("langchain_idemcore.verify.parse_log", fake_parse_log)
|
|
48
|
+
monkeypatch.setattr("langchain_idemcore.verify.check_log", fake_check_log)
|
|
49
|
+
|
|
50
|
+
log_text = "0 0 0123456789abcdef 0123456789abcdef\nALL 0123456789abcdef\n"
|
|
51
|
+
bundle = verify_langchain_receipt(message, log_text=log_text)
|
|
52
|
+
|
|
53
|
+
assert bundle.ok is True
|
|
54
|
+
assert bundle.receipt_id == result.receipt_id
|
|
55
|
+
assert calls["verify"]["receipt"]["receipt_id"] == result.receipt_id
|
|
56
|
+
assert calls["verify"]["log_text"] == log_text
|
|
57
|
+
assert calls["verify"]["salt"] == result.salt
|
|
58
|
+
assert calls["verify"]["output_text"] == result.text
|
|
59
|
+
assert calls["parse"]["path"] == "<memory>"
|
|
60
|
+
assert bundle.log_ok is True
|
|
61
|
+
assert bundle.log_details["n_tokens"] == 2
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_verify_helper_requires_a_receipt():
|
|
65
|
+
with pytest.raises(ValueError, match="No Idemcore receipt"):
|
|
66
|
+
verify_langchain_receipt({"foo": "bar"})
|