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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ )
@@ -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,7 @@
1
+ idemcore<0.4,>=0.3.0
2
+ idemcore-verify<0.4,>=0.3.6
3
+ langchain-core>=0.3.0
4
+
5
+ [dev]
6
+ pytest>=8
7
+ pytest-asyncio>=0.24
@@ -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"})