langchain-valen 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,7 @@
1
+ node_modules/
2
+ dist/
3
+ *.tgz
4
+ *.difypkg
5
+ __pycache__/
6
+ .venv/
7
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Valen Team
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: langchain-valen
3
+ Version: 0.1.0
4
+ Summary: Typed Valen decisions and uncertainty-aware routing for LangChain and LangGraph
5
+ Project-URL: Repository, https://github.com/Valen-Team/valen-integrations
6
+ Project-URL: Documentation, https://github.com/Valen-Team/valen-integrations/tree/main/langchain
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: langchain-core<2,>=1.0
11
+ Requires-Dist: valen-sdk<0.4,>=0.3.1
12
+ Provides-Extra: test
13
+ Requires-Dist: build>=1; extra == 'test'
14
+ Requires-Dist: langgraph<2,>=1; extra == 'test'
15
+ Requires-Dist: pytest>=8; extra == 'test'
16
+ Requires-Dist: twine<7,>=6; extra == 'test'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # LangChain Valen
20
+
21
+ [中文](README_zh.md)
22
+
23
+ Add ticket routing, image checks and structured judgments to your LangChain app. Valen returns named answers and probabilities you can use directly in LCEL, agent tools and LangGraph conditional edges. Keep the routing rules and review thresholds in your application.
24
+
25
+ ## Install
26
+
27
+ ```sh
28
+ python -m pip install langchain-valen==0.1.0
29
+ ```
30
+
31
+ [View on PyPI](https://pypi.org/project/langchain-valen/)
32
+
33
+ Requires Python 3.10+, `langchain-core` 1.x and `valen-sdk` 0.3.x. The wheel does not include model weights. LangGraph examples additionally need `python -m pip install 'langgraph>=1,<2'`.
34
+
35
+ ## Evaluate
36
+
37
+ ```python
38
+ from langchain_valen import ValenEvaluator
39
+
40
+ evaluate = ValenEvaluator([
41
+ {"name": "refund", "type": "predicate",
42
+ "instructions": "The customer requests a refund."},
43
+ ])
44
+ result = evaluate.invoke("I was charged twice. Please refund the duplicate charge.")
45
+ print(result["answers"]["refund"]["probability"])
46
+ ```
47
+
48
+ The default free API needs no key. For a keyed server, pass `base_url` and `api_key` or set `VALEN_API_KEY`. A trailing `/v1` is optional. `model` defaults to `valen-2b`; self-hosted names work too. Each call uses the SDK's 120-second timeout per attempt and two retries for supported transient failures; `timeout` and `max_retries` are configurable.
49
+
50
+ Input can be text, a JSON object/array, or an envelope `{"input": ..., "image": image_bytes}`. Use the envelope for structured evidence containing a field named `input`. Images must be PNG, JPEG or WebP bytes, one per request. Download URLs yourself before invoking; this prevents hidden network fetches in the runnable.
51
+
52
+ Evaluate returns `{"answers": {question_name: answer}, "model": resolved_model_id}`. Choice answers include `choice`, `confidence`, and candidate probabilities; Score includes expected `score`, most likely `level`, confidence and probabilities. Probabilities are retained without rounding.
53
+
54
+ ## Route once in LCEL
55
+
56
+ ```python
57
+ from langchain_core.runnables import RunnablePassthrough
58
+ from langchain_valen import ValenRouter
59
+
60
+ router = ValenRouter({
61
+ "name": "refund", "type": "predicate",
62
+ "instructions": "The customer requests a refund.",
63
+ }, yes_at=0.7, no_at=0.3)
64
+ chain = RunnablePassthrough.assign(branch=router)
65
+ print(chain.invoke({"input": "Please refund my order."}))
66
+ ```
67
+
68
+ The router returns `yes`, `no` or `unsure`. Require `0 ≤ no_at < yes_at ≤ 1`. Choice returns the selected candidate value; Score returns the most probable level (earlier level on ties). Choice must have an explicit outside-scope candidate when none may fit. Choice and Score have no automatic uncertainty gate; use an Evaluator and your own rule if you need one.
69
+
70
+ `invoke`, `batch`, `ainvoke` and runnable composition use LangChain's interfaces. Async calls use its executor bridge around the synchronous SDK client. A supplied `client=` is caller-owned and must support `predict(payload)`; do not share a non-thread-safe local client across concurrent batches.
71
+
72
+ ## LangGraph
73
+
74
+ [`examples/support_routing.py`](examples/support_routing.py) builds a real graph with billing, technical and review destinations, then runs three synthetic tickets:
75
+
76
+ ```sh
77
+ python -m langchain_valen.example
78
+ ```
79
+
80
+ Use `router` directly in `builder.add_conditional_edges("judge", router, branch_map)`. The example makes no external business actions. Its predictions are a demonstration, not an accuracy guarantee.
81
+
82
+ ## Agent tool and failures
83
+
84
+ ```python
85
+ from langchain_valen import ValenDecisionTool
86
+
87
+ tool = ValenDecisionTool(evaluator=evaluate)
88
+ print(tool.invoke({"input": "Please refund the duplicate charge."}))
89
+ ```
90
+
91
+ The tool exposes a text-input schema and returns the same named answers. It raises `ToolException` on failure, so LangChain's `handle_tool_error` policy works. Evaluators and routers raise errors directly; failures never become a branch or a probability. Invalid questions, thresholds, incomplete responses and unsupported image formats fail explicitly.
92
+
93
+ The integration adds no storage or logs. Evidence and questions go to your configured endpoint; LangChain tracing and endpoint retention settings still apply. See [Valen data handling](https://valen-team.github.io/docs/valen/api/data-handling/).
94
+
95
+ ## Development
96
+
97
+ Run the root tests with the `test` extras installed. Tests exercise the real SDK against a local fixture server, real LangGraph edges, LCEL composition and tool error handling; fixtures do not measure model accuracy. Run the example separately for live inference. Shared validation is generated from `shared/protocol.py`; update it with `python scripts/build_shared.py` before building.
@@ -0,0 +1,79 @@
1
+ # LangChain Valen
2
+
3
+ [中文](README_zh.md)
4
+
5
+ Add ticket routing, image checks and structured judgments to your LangChain app. Valen returns named answers and probabilities you can use directly in LCEL, agent tools and LangGraph conditional edges. Keep the routing rules and review thresholds in your application.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ python -m pip install langchain-valen==0.1.0
11
+ ```
12
+
13
+ [View on PyPI](https://pypi.org/project/langchain-valen/)
14
+
15
+ Requires Python 3.10+, `langchain-core` 1.x and `valen-sdk` 0.3.x. The wheel does not include model weights. LangGraph examples additionally need `python -m pip install 'langgraph>=1,<2'`.
16
+
17
+ ## Evaluate
18
+
19
+ ```python
20
+ from langchain_valen import ValenEvaluator
21
+
22
+ evaluate = ValenEvaluator([
23
+ {"name": "refund", "type": "predicate",
24
+ "instructions": "The customer requests a refund."},
25
+ ])
26
+ result = evaluate.invoke("I was charged twice. Please refund the duplicate charge.")
27
+ print(result["answers"]["refund"]["probability"])
28
+ ```
29
+
30
+ The default free API needs no key. For a keyed server, pass `base_url` and `api_key` or set `VALEN_API_KEY`. A trailing `/v1` is optional. `model` defaults to `valen-2b`; self-hosted names work too. Each call uses the SDK's 120-second timeout per attempt and two retries for supported transient failures; `timeout` and `max_retries` are configurable.
31
+
32
+ Input can be text, a JSON object/array, or an envelope `{"input": ..., "image": image_bytes}`. Use the envelope for structured evidence containing a field named `input`. Images must be PNG, JPEG or WebP bytes, one per request. Download URLs yourself before invoking; this prevents hidden network fetches in the runnable.
33
+
34
+ Evaluate returns `{"answers": {question_name: answer}, "model": resolved_model_id}`. Choice answers include `choice`, `confidence`, and candidate probabilities; Score includes expected `score`, most likely `level`, confidence and probabilities. Probabilities are retained without rounding.
35
+
36
+ ## Route once in LCEL
37
+
38
+ ```python
39
+ from langchain_core.runnables import RunnablePassthrough
40
+ from langchain_valen import ValenRouter
41
+
42
+ router = ValenRouter({
43
+ "name": "refund", "type": "predicate",
44
+ "instructions": "The customer requests a refund.",
45
+ }, yes_at=0.7, no_at=0.3)
46
+ chain = RunnablePassthrough.assign(branch=router)
47
+ print(chain.invoke({"input": "Please refund my order."}))
48
+ ```
49
+
50
+ The router returns `yes`, `no` or `unsure`. Require `0 ≤ no_at < yes_at ≤ 1`. Choice returns the selected candidate value; Score returns the most probable level (earlier level on ties). Choice must have an explicit outside-scope candidate when none may fit. Choice and Score have no automatic uncertainty gate; use an Evaluator and your own rule if you need one.
51
+
52
+ `invoke`, `batch`, `ainvoke` and runnable composition use LangChain's interfaces. Async calls use its executor bridge around the synchronous SDK client. A supplied `client=` is caller-owned and must support `predict(payload)`; do not share a non-thread-safe local client across concurrent batches.
53
+
54
+ ## LangGraph
55
+
56
+ [`examples/support_routing.py`](examples/support_routing.py) builds a real graph with billing, technical and review destinations, then runs three synthetic tickets:
57
+
58
+ ```sh
59
+ python -m langchain_valen.example
60
+ ```
61
+
62
+ Use `router` directly in `builder.add_conditional_edges("judge", router, branch_map)`. The example makes no external business actions. Its predictions are a demonstration, not an accuracy guarantee.
63
+
64
+ ## Agent tool and failures
65
+
66
+ ```python
67
+ from langchain_valen import ValenDecisionTool
68
+
69
+ tool = ValenDecisionTool(evaluator=evaluate)
70
+ print(tool.invoke({"input": "Please refund the duplicate charge."}))
71
+ ```
72
+
73
+ The tool exposes a text-input schema and returns the same named answers. It raises `ToolException` on failure, so LangChain's `handle_tool_error` policy works. Evaluators and routers raise errors directly; failures never become a branch or a probability. Invalid questions, thresholds, incomplete responses and unsupported image formats fail explicitly.
74
+
75
+ The integration adds no storage or logs. Evidence and questions go to your configured endpoint; LangChain tracing and endpoint retention settings still apply. See [Valen data handling](https://valen-team.github.io/docs/valen/api/data-handling/).
76
+
77
+ ## Development
78
+
79
+ Run the root tests with the `test` extras installed. Tests exercise the real SDK against a local fixture server, real LangGraph edges, LCEL composition and tool error handling; fixtures do not measure model accuracy. Run the example separately for live inference. Shared validation is generated from `shared/protocol.py`; update it with `python scripts/build_shared.py` before building.
@@ -0,0 +1,36 @@
1
+ # 在 LangChain 与 LangGraph 中使用 Valen
2
+
3
+ [English](README.md)
4
+
5
+ 为 LangChain 应用增加工单分流、图片检查和告警分级。写下要判断的问题,即可得到有名称的答案与概率,直接用于 LCEL、LangGraph 分支或 Agent 工具。人工复核阈值和后续动作由你的应用控制。
6
+
7
+ ## 安装与首次运行
8
+
9
+ ```sh
10
+ python -m pip install langchain-valen==0.1.0
11
+ ```
12
+
13
+ 需要 Python 3.10+,安装时会自动获取 LangChain Core 和 Valen SDK。[PyPI 包](https://pypi.org/project/langchain-valen/)
14
+
15
+ ```python
16
+ from langchain_valen import ValenEvaluator
17
+
18
+ client = ValenEvaluator([
19
+ {"name": "refund", "type": "predicate", "instructions": "客户要求退款。"}
20
+ ])
21
+ print(client.invoke("我被重复扣款了,请退还多收的钱。"))
22
+ ```
23
+
24
+ 默认调用免费 API,无需密钥。带密钥的服务可传 `base_url`、`api_key`,也可设置 `VALEN_API_KEY` 环境变量。不要把密钥写入示例或提交到仓库。
25
+
26
+ 输入支持文本、JSON,以及 `{"input": 内容, "image": 图片字节}`;一次最多一张 PNG、JPEG 或 WebP,最多 8 个问题。
27
+
28
+ ## 分流与实际工作流
29
+
30
+ `ValenRouter(question, yes_at=0.7, no_at=0.3)` 对是否题返回 `yes`、`no`、`unsure`。Choice 返回选项值,Score 返回概率最高的等级(平局取靠前等级),不是对期望分数四舍五入。
31
+
32
+ 安装 `langgraph` 后运行 `python -m langchain_valen.example`,即可在实际 LangGraph 中把三条合成工单分到账单、技术和人工复核。示例不执行外部业务动作。
33
+
34
+ 失败会抛出异常,不会伪装为正常分支。`ValenDecisionTool` 抛出标准 `ToolException`,可使用 LangChain 的 `handle_tool_error`。Choice 必须按需要提供范围外选项,低置信度规则留在工作流中。
35
+
36
+ 完整接口、异步行为和开发说明见[英文说明](README.md)。
@@ -0,0 +1,32 @@
1
+ # Build, release and upgrade
2
+
3
+ Run from the repository root with Python 3.12:
4
+
5
+ ```sh
6
+ python scripts/build_shared.py --check
7
+ python -m pip install -r dify/requirements.txt -e './langchain[test]'
8
+ python -m pytest tests coze/tests
9
+ python -m build langchain --outdir artifacts/langchain
10
+ python -m twine check artifacts/langchain/*
11
+ ```
12
+
13
+ Install the wheel in a separate environment outside the checkout. Run the LangChain
14
+ contract tests against that installation and run `python -m langchain_valen.example` with
15
+ synthetic data against the configured Valen API. Check an image and a failed request too.
16
+ The repository's Python workflow builds artifacts; it does not publish them.
17
+
18
+ The release.yml workflow builds, tests and publishes on matching langchain-v* tags. Configure the PyPI trusted publisher for Valen-Team/valen-integrations and workflow release.yml first (https://docs.pypi.org/trusted-publishers/using-a-publisher/). Preparing this workflow does not publish a package. An authorized release operator publishes the tested wheel and source archive to PyPI,
19
+ using a scoped API token or configured Trusted Publishing. Keep credentials outside the
20
+ repository. After publication, create a new environment and install the exact version
21
+ from PyPI, repeat the example and record the public URL and artifact checksums in the
22
+ acceptance record. A successful local build is not a published release.
23
+
24
+ Before upgrading, retain the current pinned version and question/threshold configuration.
25
+ Review changes to SPEC.md, update the package version and User-Agent together, regenerate
26
+ shared code, and run the same synthetic workflow in the target LangChain/LangGraph versions.
27
+ Rollback by reinstalling the previous pinned package and restoring the matching question set.
28
+
29
+ The release job also accepts the PYPI_API_TOKEN repository secret for account-authorized
30
+ bootstrap publishing. Without that secret it uses the configured trusted publisher.
31
+ No secret has been uploaded to GitHub and no publication was triggered while preparing
32
+ this workflow.
@@ -0,0 +1,33 @@
1
+ """Run with a real Valen endpoint, or point VALEN_BASE_URL at a local fixture server."""
2
+ import os
3
+ from typing import TypedDict
4
+ from langgraph.graph import StateGraph, START, END
5
+ from langchain_valen import ValenRouter
6
+
7
+
8
+ class State(TypedDict):
9
+ input: str
10
+ team: str
11
+
12
+
13
+ router = ValenRouter({
14
+ 'name': 'team', 'type': 'choice', 'instructions': 'Which team should handle this ticket?',
15
+ 'choices': [
16
+ {'value': 'billing', 'description': 'Payments, duplicate charges and refunds'},
17
+ {'value': 'technical', 'description': 'Software bugs and crashes'},
18
+ {'value': 'review', 'description': 'Outside billing and technical support, or too little information'},
19
+ ],
20
+ }, base_url=os.environ.get('VALEN_BASE_URL', 'https://api.valen-team.workers.dev'))
21
+
22
+ builder = StateGraph(State)
23
+ builder.add_node('judge', lambda state: state)
24
+ builder.add_edge(START, 'judge')
25
+ builder.add_conditional_edges('judge', router, {k: k for k in ('billing', 'technical', 'review')})
26
+ for team in ('billing', 'technical', 'review'):
27
+ builder.add_node(team, lambda state, team=team: {'team': team})
28
+ builder.add_edge(team, END)
29
+ graph = builder.compile()
30
+
31
+ if __name__ == '__main__':
32
+ for text in ('I was charged twice. Please help.', 'The app crashes.', 'Can you recommend a birthday gift?'):
33
+ print(graph.invoke({'input': text}))
@@ -0,0 +1,6 @@
1
+ """Valen decisions for LangChain. Input data is sent only to the configured endpoint."""
2
+ from .runnables import ValenEvaluator, ValenRouter
3
+ from .tools import ValenDecisionTool
4
+
5
+ __all__ = ['ValenEvaluator', 'ValenRouter', 'ValenDecisionTool']
6
+ __version__ = '0.1.0'
@@ -0,0 +1,162 @@
1
+ # Generated from shared/protocol.py by scripts/build_shared.py. Do not edit.
2
+ """Platform-neutral Valen workflow rules. Vendored by scripts/build_shared.py."""
3
+ import base64
4
+ import json
5
+ import math
6
+ import re
7
+ from urllib.parse import urlsplit
8
+
9
+
10
+ def endpoint(value):
11
+ parts = urlsplit(value)
12
+ if parts.scheme not in ('http', 'https') or not parts.netloc or parts.username or parts.password or parts.query or parts.fragment:
13
+ raise ValueError('Base URL must be HTTP(S), without credentials, query or fragment')
14
+ return value.rstrip('/').removesuffix('/v1') + '/v1'
15
+
16
+
17
+ def parse_lines(text):
18
+ result = []
19
+ for line in str(text or '').splitlines():
20
+ if not line.strip():
21
+ continue
22
+ value, sep, description = line.strip().partition(': ')
23
+ value, description = value.strip(), description.strip() if sep else value.strip()
24
+ if not value or not description:
25
+ raise ValueError('Options and descriptions must not be empty')
26
+ result.append((value, description))
27
+ if len(result) < 2 or len({p[0] for p in result}) != len(result):
28
+ raise ValueError('Use at least two distinct options or levels')
29
+ return result
30
+
31
+
32
+ def validate_questions(value):
33
+ rows = json.loads(value) if isinstance(value, str) else value
34
+ if not isinstance(rows, list) or not rows:
35
+ raise ValueError('Use a nonempty questions array')
36
+ names = set()
37
+ for q in rows:
38
+ if not isinstance(q, dict) or not isinstance(q.get('name'), str) or not re.fullmatch(r'[A-Za-z0-9_]+', q['name']) or q['name'] in names:
39
+ raise ValueError('Question IDs must be unique letters, digits or underscores')
40
+ names.add(q['name'])
41
+ if not isinstance(q.get('instructions'), str) or not q['instructions'].strip():
42
+ raise ValueError('Each question needs instructions')
43
+ if q.get('type') not in ('predicate', 'choice', 'score'):
44
+ raise ValueError('Question type must be predicate, choice or score')
45
+ if q['type'] != 'predicate':
46
+ field, key = ('choices', 'value') if q['type'] == 'choice' else ('levels', 'label')
47
+ values = q.get(field)
48
+ if not isinstance(values, list) or len(values) < 2:
49
+ raise ValueError('Use at least two distinct, described choices or levels')
50
+ for v in values:
51
+ if not isinstance(v, dict) or not isinstance(v.get(key), str) or not v[key].strip() or not isinstance(v.get('description'), str) or not v['description'].strip():
52
+ raise ValueError('Each choice or level needs a nonempty value and description')
53
+ if len({v[key] for v in values}) != len(values):
54
+ raise ValueError('Choice values or level labels must be distinct')
55
+ return rows
56
+
57
+
58
+ def single_question(kind, instructions, lines=''):
59
+ q = {'name': 'q', 'type': kind, 'instructions': instructions}
60
+ if kind in ('choice', 'score'):
61
+ key = 'value' if kind == 'choice' else 'label'
62
+ q['choices' if kind == 'choice' else 'levels'] = [{key: v, 'description': d} for v, d in parse_lines(lines)]
63
+ return validate_questions([q])[0]
64
+
65
+
66
+ def thresholds(yes_at=0.7, no_at=0.3):
67
+ if not all(isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v) for v in (yes_at, no_at)) or not 0 <= no_at < yes_at <= 1:
68
+ raise ValueError('Thresholds must satisfy 0 ≤ No at < Yes at ≤ 1')
69
+ return yes_at, no_at
70
+
71
+
72
+ def probability(value):
73
+ if not isinstance(value, (int, float)) or isinstance(value, bool) or not math.isfinite(value) or not 0 <= value <= 1:
74
+ raise ValueError('API returned an invalid probability')
75
+ return value
76
+
77
+
78
+ def map_answers(response, asked):
79
+ rows = response.get('answers') if isinstance(response, dict) else None
80
+ if not isinstance(rows, list) or len(rows) != len(asked):
81
+ raise ValueError('API returned an incomplete answer list')
82
+ mapped = {}
83
+ for q in asked:
84
+ matches = [a for a in rows if isinstance(a, dict) and a.get('name') == q['name']]
85
+ if len(matches) != 1 or matches[0].get('type') != q['type']:
86
+ raise ValueError('API returned a missing, duplicate or wrong-type answer')
87
+ a = matches[0]
88
+ if q['type'] == 'predicate':
89
+ mapped[q['name']] = {'probability': probability(a.get('probability'))}
90
+ continue
91
+ key = 'value' if q['type'] == 'choice' else 'label'
92
+ labels = [p[key] for p in q['choices' if q['type'] == 'choice' else 'levels']]
93
+ values = a.get('probabilities')
94
+ if not isinstance(values, list) or len(values) != len(labels):
95
+ raise ValueError('API returned incomplete probabilities')
96
+ distribution = {}
97
+ for v in values:
98
+ label = v.get(key) if isinstance(v, dict) else None
99
+ if label not in labels or label in distribution:
100
+ raise ValueError('API returned unknown or duplicate candidates')
101
+ distribution[label] = probability(v.get('probability'))
102
+ if abs(sum(distribution.values()) - 1) > 0.001:
103
+ raise ValueError('API probabilities do not sum to one')
104
+ answer = {'confidence': probability(a.get('confidence')), 'probabilities': distribution}
105
+ if q['type'] == 'choice':
106
+ if a.get('choice') not in labels:
107
+ raise ValueError('API returned an unknown choice')
108
+ answer['choice'] = a['choice']
109
+ else:
110
+ score = a.get('score')
111
+ if not isinstance(score, (int, float)) or isinstance(score, bool) or not math.isfinite(score) or not 0 <= score <= len(labels)-1:
112
+ raise ValueError('API returned an invalid score')
113
+ answer.update(score=score, level=max(labels, key=distribution.get))
114
+ mapped[q['name']] = answer
115
+ model = response.get('model_id') or response.get('model')
116
+ if not isinstance(model, str) or not model:
117
+ raise ValueError('API returned no model ID')
118
+ return {'answers': mapped, 'model': model}
119
+
120
+
121
+ def route_answer(answer, kind, yes_at=0.7, no_at=0.3):
122
+ thresholds(yes_at, no_at)
123
+ if kind == 'predicate':
124
+ branch = 'yes' if answer['probability'] >= yes_at else 'no' if answer['probability'] <= no_at else 'unsure'
125
+ else:
126
+ branch = answer['choice' if kind == 'choice' else 'level']
127
+ return dict(answer, branch=branch)
128
+
129
+
130
+ def image_type(data):
131
+ if data[:8] == b'\x89PNG\r\n\x1a\n':
132
+ return 'image/png'
133
+ if data[:3] == b'\xff\xd8\xff':
134
+ return 'image/jpeg'
135
+ if data[:4] == b'RIFF' and data[8:12] == b'WEBP':
136
+ return 'image/webp'
137
+ raise ValueError('Image must be PNG, JPEG or WebP')
138
+
139
+
140
+ def api_error_message(error):
141
+ """Preserve SDK metadata when a platform only displays exception text."""
142
+ status = getattr(error, 'status_code', None)
143
+ request_id = getattr(error, 'request_id', None)
144
+ message = (f'HTTP {status}: ' if status is not None else '') + str(error)
145
+ if request_id:
146
+ message += f' (request ID: {request_id})'
147
+ if status == 429:
148
+ message += '; wait at least a minute before trying again'
149
+ return message
150
+
151
+
152
+ def evidence(value, image=None):
153
+ if not isinstance(value, str):
154
+ if not isinstance(value, (dict, list)):
155
+ raise ValueError('Input must be text, a JSON object or array')
156
+ value = json.dumps(value, indent=2, ensure_ascii=False, allow_nan=False)
157
+ if image is None:
158
+ return value
159
+ if not isinstance(image, bytes):
160
+ raise ValueError('Image must be file bytes; download remote files before calling')
161
+ data_url = 'data:' + image_type(image) + ';base64,' + base64.b64encode(image).decode('ascii')
162
+ return [{'role': 'user', 'content': [{'type': 'input_text', 'text': value}, {'type': 'input_image', 'image_url': data_url}]}]
@@ -0,0 +1,33 @@
1
+ """Run with a real Valen endpoint, or point VALEN_BASE_URL at a local fixture server."""
2
+ import os
3
+ from typing import TypedDict
4
+ from langgraph.graph import StateGraph, START, END
5
+ from langchain_valen import ValenRouter
6
+
7
+
8
+ class State(TypedDict):
9
+ input: str
10
+ team: str
11
+
12
+
13
+ router = ValenRouter({
14
+ 'name': 'team', 'type': 'choice', 'instructions': 'Which team should handle this ticket?',
15
+ 'choices': [
16
+ {'value': 'billing', 'description': 'Payments, duplicate charges and refunds'},
17
+ {'value': 'technical', 'description': 'Software bugs and crashes'},
18
+ {'value': 'review', 'description': 'Outside billing and technical support, or too little information'},
19
+ ],
20
+ }, base_url=os.environ.get('VALEN_BASE_URL', 'https://api.valen-team.workers.dev'))
21
+
22
+ builder = StateGraph(State)
23
+ builder.add_node('judge', lambda state: state)
24
+ builder.add_edge(START, 'judge')
25
+ builder.add_conditional_edges('judge', router, {k: k for k in ('billing', 'technical', 'review')})
26
+ for team in ('billing', 'technical', 'review'):
27
+ builder.add_node(team, lambda state, team=team: {'team': team})
28
+ builder.add_edge(team, END)
29
+ graph = builder.compile()
30
+
31
+ if __name__ == '__main__':
32
+ for text in ('I was charged twice. Please help.', 'The app crashes.', 'Can you recommend a birthday gift?'):
33
+ print(graph.invoke({'input': text}))
@@ -0,0 +1,67 @@
1
+ """Runnable integrations, with routing rules kept outside the model."""
2
+ import os
3
+ from copy import deepcopy
4
+ from typing import Any
5
+
6
+ from langchain_core.runnables import Runnable, RunnableConfig
7
+ from valen_sdk import Valen
8
+ from valen_sdk.errors import ValenAPIError
9
+
10
+ from ._protocol import api_error_message, endpoint, evidence, map_answers, route_answer, thresholds, validate_questions
11
+
12
+
13
+ class ValenEvaluator(Runnable[Any, dict]):
14
+ """Ask a fixed question set. Input is text or {input, image?: bytes}.
15
+
16
+ Use ``{input: {...}}`` for structured evidence. A supplied client is caller-owned;
17
+ otherwise each invocation opens and closes a remote SDK client. ``ainvoke`` uses
18
+ LangChain's default executor bridge, leaving the event loop free.
19
+ """
20
+
21
+ name = "valen_evaluator"
22
+
23
+ def __init__(self, questions, *, base_url='https://api.valen-team.workers.dev', api_key=None,
24
+ model='valen-2b', timeout=120.0, max_retries=2, client=None):
25
+ self.questions = deepcopy(validate_questions(questions))
26
+ self.base_url = endpoint(base_url)
27
+ self.api_key = api_key if api_key is not None else os.environ.get('VALEN_API_KEY')
28
+ self.model, self.timeout, self.max_retries = model, timeout, max_retries
29
+ self.client = client
30
+
31
+ def invoke(self, input, config: RunnableConfig | None = None, **kwargs):
32
+ if isinstance(input, dict) and 'input' in input:
33
+ value, image = input['input'], input.get('image')
34
+ else:
35
+ value, image = input, None
36
+ payload = {'input': evidence(value, image), 'questions': deepcopy(self.questions), 'model': self.model}
37
+ try:
38
+ if self.client is not None:
39
+ result = self.client.predict(payload)
40
+ else:
41
+ with Valen.remote(self.base_url, api_key=self.api_key, model=self.model,
42
+ timeout=self.timeout, max_retries=self.max_retries,
43
+ headers={'User-Agent': 'valen-langchain/0.1.0'}) as client:
44
+ result = client.predict(payload)
45
+ except ValenAPIError as error:
46
+ raise RuntimeError(api_error_message(error)) from error
47
+ return map_answers(result, self.questions)
48
+
49
+
50
+ class ValenRouter(Runnable[Any, str]):
51
+ """Return one branch key for LCEL or LangGraph conditional edges.
52
+
53
+ Yes/No yields yes/no/unsure. Choice yields a candidate value; Score yields the
54
+ most likely level (earlier level on ties), not rounded expected score.
55
+ """
56
+
57
+ name = "valen_router"
58
+
59
+ def __init__(self, question, *, yes_at=0.7, no_at=0.3, **kwargs):
60
+ self.yes_at, self.no_at = thresholds(yes_at, no_at)
61
+ self.evaluator = ValenEvaluator([question], **kwargs)
62
+ self.question = self.evaluator.questions[0]
63
+
64
+ def invoke(self, input, config: RunnableConfig | None = None, **kwargs):
65
+ result = self.evaluator.invoke(input, config=config)
66
+ answer = result['answers'][self.question['name']]
67
+ return route_answer(answer, self.question['type'], self.yes_at, self.no_at)['branch']
@@ -0,0 +1,24 @@
1
+ """An agent tool with an explicit schema and standard LangChain error handling."""
2
+ from typing import Any
3
+ from pydantic import BaseModel, Field, ConfigDict
4
+ from langchain_core.tools import BaseTool, ToolException
5
+ from .runnables import ValenEvaluator
6
+ from ._protocol import api_error_message
7
+
8
+
9
+ class DecisionInput(BaseModel):
10
+ input: str = Field(description='Text to judge against the configured questions')
11
+
12
+
13
+ class ValenDecisionTool(BaseTool):
14
+ model_config = ConfigDict(arbitrary_types_allowed=True)
15
+ name: str = 'valen_decisions'
16
+ description: str = 'Evaluate text against configured Valen questions and return named probabilities.'
17
+ args_schema: type[BaseModel] = DecisionInput
18
+ evaluator: Any = Field(exclude=True, repr=False)
19
+
20
+ def _run(self, input: str):
21
+ try:
22
+ return self.evaluator.invoke(input)
23
+ except Exception as error:
24
+ raise ToolException(api_error_message(error)) from error
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27,<2"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "langchain-valen"
7
+ version = "0.1.0"
8
+ description = "Typed Valen decisions and uncertainty-aware routing for LangChain and LangGraph"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ dependencies = ["langchain-core>=1.0,<2", "valen-sdk>=0.3.1,<0.4"]
13
+
14
+ [project.optional-dependencies]
15
+ test = ["pytest>=8", "langgraph>=1,<2", "build>=1", "twine>=6,<7"]
16
+
17
+ [project.urls]
18
+ Repository = "https://github.com/Valen-Team/valen-integrations"
19
+ Documentation = "https://github.com/Valen-Team/valen-integrations/tree/main/langchain"
20
+
21
+ [tool.hatch.build.targets.wheel]
22
+ packages = ["langchain_valen"]
23
+ core-metadata-version = "2.4"
24
+
25
+ [tool.hatch.build.targets.sdist]
26
+ core-metadata-version = "2.4"