langchain-mljunction 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.
Files changed (24) hide show
  1. langchain_mljunction-0.1.0/.github/workflows/release.yml +105 -0
  2. langchain_mljunction-0.1.0/.gitignore +9 -0
  3. langchain_mljunction-0.1.0/LICENSE +21 -0
  4. langchain_mljunction-0.1.0/PKG-INFO +327 -0
  5. langchain_mljunction-0.1.0/README.md +291 -0
  6. langchain_mljunction-0.1.0/docs/assets/readme-hero.png +0 -0
  7. langchain_mljunction-0.1.0/pyproject.toml +75 -0
  8. langchain_mljunction-0.1.0/src/langchain_mljunction/__init__.py +28 -0
  9. langchain_mljunction-0.1.0/src/langchain_mljunction/_client.py +120 -0
  10. langchain_mljunction-0.1.0/src/langchain_mljunction/chat_models.py +651 -0
  11. langchain_mljunction-0.1.0/src/langchain_mljunction/embeddings.py +77 -0
  12. langchain_mljunction-0.1.0/src/langchain_mljunction/outcomes.py +305 -0
  13. langchain_mljunction-0.1.0/src/langchain_mljunction/telemetry.py +287 -0
  14. langchain_mljunction-0.1.0/src/langchain_mljunction/tracer.py +901 -0
  15. langchain_mljunction-0.1.0/tests/cassettes/TestChatMLJunctionIntegration.test_stream_time.yaml +74 -0
  16. langchain_mljunction-0.1.0/tests/conftest.py +24 -0
  17. langchain_mljunction-0.1.0/tests/standard_tests/test_chat_models_integration.py +74 -0
  18. langchain_mljunction-0.1.0/tests/standard_tests/test_chat_models_unit.py +34 -0
  19. langchain_mljunction-0.1.0/tests/standard_tests/test_vcr_cassette.py +25 -0
  20. langchain_mljunction-0.1.0/tests/test_chat_models.py +449 -0
  21. langchain_mljunction-0.1.0/tests/test_live.py +149 -0
  22. langchain_mljunction-0.1.0/tests/test_outcomes.py +153 -0
  23. langchain_mljunction-0.1.0/tests/test_tracer.py +314 -0
  24. langchain_mljunction-0.1.0/uv.lock +1864 -0
@@ -0,0 +1,105 @@
1
+ name: Release
2
+
3
+ # Manual only: Actions > Release > Run workflow.
4
+ # Builds the package from the chosen ref and uploads it to PyPI. The version published is
5
+ # whatever pyproject.toml says at that ref, so bump it (and __init__.py) before running.
6
+ # Upload uses PyPI Trusted Publishing (OIDC), so no API token is stored in this repo.
7
+ on:
8
+ workflow_dispatch:
9
+ inputs:
10
+ ref:
11
+ description: "Branch, tag or commit SHA to release"
12
+ type: string
13
+ default: master
14
+
15
+ # One release at a time, and never cancel one midway.
16
+ concurrency:
17
+ group: release-pypi
18
+ cancel-in-progress: false
19
+
20
+ permissions:
21
+ contents: read
22
+
23
+ jobs:
24
+ build:
25
+ runs-on: ubuntu-latest
26
+ timeout-minutes: 20
27
+ # Every step below runs inside this image (Astral's official uv + Python), not on
28
+ # the runner, so nothing about the build depends on what Ubuntu happens to ship.
29
+ container: ghcr.io/astral-sh/uv:python3.12-bookworm-slim
30
+ outputs:
31
+ version: ${{ steps.version.outputs.version }}
32
+ steps:
33
+ - uses: actions/checkout@v4
34
+ with:
35
+ ref: ${{ inputs.ref }}
36
+ persist-credentials: false
37
+
38
+ - name: Check the version is consistent and not already on PyPI
39
+ id: version
40
+ run: |
41
+ set -eu
42
+ project=$(grep -m1 '^version = ' pyproject.toml | cut -d'"' -f2)
43
+ module=$(grep -m1 '^__version__ = ' src/langchain_mljunction/__init__.py | cut -d'"' -f2)
44
+ echo "pyproject.toml=$project __init__.py=$module"
45
+ if [ "$project" != "$module" ]; then
46
+ echo "::error::pyproject.toml ($project) and __init__.py ($module) disagree. Bump both."
47
+ exit 1
48
+ fi
49
+ # PyPI never accepts the same version twice, so fail here, before the tests,
50
+ # rather than at the upload.
51
+ status=$(python -c "import sys, urllib.request, urllib.error
52
+ try:
53
+ urllib.request.urlopen('https://pypi.org/pypi/langchain-mljunction/$project/json')
54
+ print('taken')
55
+ except urllib.error.HTTPError as e:
56
+ print('free' if e.code == 404 else f'error {e.code}')")
57
+ if [ "$status" != "free" ]; then
58
+ echo "::error::Version $project is not publishable on PyPI ($status). Bump the version."
59
+ exit 1
60
+ fi
61
+ echo "version=$project" >> "$GITHUB_OUTPUT"
62
+
63
+ - run: uv sync --locked
64
+ # Live tests skip themselves: no RUN_LIVE_PROVIDER_TESTS or LIVE_API_KEY here.
65
+ - run: uv run pytest -q
66
+ - run: uv build
67
+ - run: uvx twine check --strict dist/*
68
+ - uses: actions/upload-artifact@v4
69
+ with:
70
+ name: dist
71
+ path: dist/
72
+
73
+ publish:
74
+ needs: build
75
+ runs-on: ubuntu-latest
76
+ timeout-minutes: 10
77
+ # The PyPI trusted publisher is bound to this environment name. Required reviewers
78
+ # set on it in repo settings become an approval gate before the upload.
79
+ environment:
80
+ name: pypi
81
+ url: https://pypi.org/project/langchain-mljunction/${{ needs.build.outputs.version }}/
82
+ permissions:
83
+ id-token: write
84
+ steps:
85
+ - uses: actions/download-artifact@v4
86
+ with:
87
+ name: dist
88
+ path: dist/
89
+ - uses: pypa/gh-action-pypi-publish@release/v1
90
+
91
+ summary:
92
+ needs: [build, publish]
93
+ if: always()
94
+ runs-on: ubuntu-latest
95
+ steps:
96
+ - name: Summary
97
+ run: |
98
+ {
99
+ echo "### Release to PyPI"
100
+ echo ""
101
+ echo "- Ref: \`${{ inputs.ref }}\`"
102
+ echo "- Version: ${{ needs.build.outputs.version || 'unknown' }}"
103
+ echo "- Build: ${{ needs.build.result }}"
104
+ echo "- Publish: ${{ needs.publish.result }}"
105
+ } >> "$GITHUB_STEP_SUMMARY"
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ .venv/
6
+ dist/
7
+ build/
8
+ *.egg-info/
9
+ .env
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 UGIBO (ML Junction)
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,327 @@
1
+ Metadata-Version: 2.5
2
+ Name: langchain-mljunction
3
+ Version: 0.1.0
4
+ Summary: Native LangChain integration for the ML Junction gateway
5
+ Project-URL: Homepage, https://mljunction.com
6
+ Project-URL: Repository, https://github.com/mljunction/langchain-mljunction
7
+ Project-URL: Issues, https://github.com/mljunction/langchain-mljunction/issues
8
+ Author-email: ML Junction <mail@mljunction.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: langchain,llm,llm-gateway,llm-router,mljunction
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Requires-Python: <4,>=3.10
22
+ Requires-Dist: httpx<1,>=0.27
23
+ Requires-Dist: langchain-core<2,>=0.3.70
24
+ Requires-Dist: pydantic<3,>=2.9
25
+ Provides-Extra: otel
26
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http<2,>=1.36; extra == 'otel'
27
+ Requires-Dist: opentelemetry-sdk<2,>=1.36; extra == 'otel'
28
+ Provides-Extra: test
29
+ Requires-Dist: langchain-tests<2,>=1.1.9; extra == 'test'
30
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http<2,>=1.36; extra == 'test'
31
+ Requires-Dist: opentelemetry-sdk<2,>=1.36; extra == 'test'
32
+ Requires-Dist: pytest-asyncio>=1.0; extra == 'test'
33
+ Requires-Dist: pytest>=8.4; extra == 'test'
34
+ Requires-Dist: ruff>=0.12; extra == 'test'
35
+ Description-Content-Type: text/markdown
36
+
37
+ <p align="center"><img src="https://raw.githubusercontent.com/mljunction/langchain-mljunction/master/docs/assets/readme-hero.png" alt="langchain-mljunction - LangChain, routed" width="100%"></p>
38
+
39
+ # langchain-mljunction
40
+
41
+ The native LangChain integration for ML Junction. It calls `/v1/responses` and `/v1/embeddings`
42
+ directly—there is no `ChatOpenAI` wrapper and no vendor-shaped ceiling between your chain and ML
43
+ Junction's routing, governance, privacy, billing, and observability features.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install langchain-mljunction
49
+ ```
50
+
51
+ For local SDK development, from a checkout:
52
+
53
+ ```bash
54
+ git clone https://github.com/mljunction/langchain-mljunction.git
55
+ cd langchain-mljunction
56
+ pip install -e ".[test]"
57
+ ```
58
+
59
+ Agent tracing uses the optional OpenTelemetry transport:
60
+
61
+ ```bash
62
+ pip install "langchain-mljunction[otel]"
63
+ ```
64
+
65
+ Set `MLJUNCTION_API_KEY` and optionally `MLJUNCTION_BASE_URL`, or pass both to the constructor.
66
+ The default base URL is `https://api.mljunction.com`. Point `MLJUNCTION_BASE_URL` at
67
+ `http://localhost:8001` to use a local ML Junction server instead.
68
+
69
+ ## Start in 30 seconds
70
+
71
+ ```python
72
+ from langchain_mljunction import ChatMLJunction
73
+
74
+ llm = ChatMLJunction(
75
+ model="gpt-5-mini",
76
+ routing={
77
+ "strategy": "balanced",
78
+ "service_tier": "auto",
79
+ "require_zdr": True,
80
+ "max_platform_charge_usd": 0.05,
81
+ },
82
+ app_name="support-agent",
83
+ session_name="customer-42",
84
+ task_name="triage-ticket",
85
+ )
86
+
87
+ answer = llm.invoke("Explain the incident in three bullets")
88
+ print(answer.content)
89
+ print(answer.response_metadata["routing"])
90
+ print(answer.response_metadata["receipt"])
91
+ ```
92
+
93
+ ## Native controls, first class
94
+
95
+ `ChatMLJunction` exposes the native request surface: sampling controls, `reasoning`, `output`,
96
+ `requirements`, `routing`, `context`, `metadata`, `idempotency_key`, session/task identity, and
97
+ `compatibility`. Per-call overrides work with `invoke`, `stream`, `batch`, and async equivalents.
98
+
99
+ ```python
100
+ llm = ChatMLJunction(
101
+ model="gpt-5-mini",
102
+ reasoning={"enabled": True, "effort": "high"},
103
+ requirements={"reasoning": "required", "tools": "preferred"},
104
+ routing={
105
+ "strategy": "quality",
106
+ "mode": "platform_only",
107
+ "fallbacks": {"provider": True, "model": True},
108
+ },
109
+ context={"mode": "strict", "preserve_tool_chains": True},
110
+ )
111
+ ```
112
+
113
+ Provider-native reasoning details are retained in
114
+ `AIMessage.additional_kwargs["reasoning_details"]` and replayed on that exact assistant message.
115
+ The encrypted `gwrt_v3` routing token is retained in `response_metadata["reasoning"]` and is
116
+ automatically supplied with the next full message history. Pass
117
+ `reasoning={"continuation_token": None}` to intentionally abandon replay for a fork. The token
118
+ contains routing affinity only; it does not contain conversation history, reasoning, or credentials.
119
+
120
+ ## Tools, structured output, and streaming
121
+
122
+ ```python
123
+ from langchain_core.messages import HumanMessage, ToolMessage
124
+ from langchain_core.tools import tool
125
+ from pydantic import BaseModel
126
+
127
+
128
+ class Decision(BaseModel):
129
+ action: str
130
+ confidence: float
131
+
132
+
133
+ decision = llm.with_structured_output(Decision).invoke("Choose: retry or escalate")
134
+ print(decision)
135
+
136
+ # LangChain's standard method selector is supported.
137
+ decision = llm.with_structured_output(
138
+ Decision,
139
+ method="json_schema", # also "function_calling" or "json_mode"
140
+ ).invoke("Choose: retry or escalate")
141
+
142
+
143
+ @tool
144
+ def lookup_customer(customer_id: str) -> str:
145
+ """Look up a customer account."""
146
+ return f"Customer {customer_id} is active"
147
+
148
+
149
+ tool_llm = llm.bind_tools(
150
+ [lookup_customer],
151
+ tool_choice="auto",
152
+ strict=True,
153
+ parallel_tool_calls=True,
154
+ )
155
+
156
+ messages = [HumanMessage("Check account 42")]
157
+ assistant = tool_llm.invoke(messages)
158
+ messages.append(assistant)
159
+
160
+ for call in assistant.tool_calls:
161
+ result = lookup_customer.invoke(call["args"])
162
+ messages.append(ToolMessage(result, tool_call_id=call["id"]))
163
+
164
+ final = tool_llm.invoke(messages)
165
+ print(final.content)
166
+ ```
167
+
168
+ Text and ordinary bound tool-call arguments can also be consumed incrementally:
169
+
170
+ ```python
171
+ for chunk in tool_llm.stream("Check accounts 42 and 84"):
172
+ if chunk.content:
173
+ print(chunk.content, end="", flush=True)
174
+ for call in chunk.tool_call_chunks:
175
+ print("tool delta:", call)
176
+ ```
177
+
178
+ Sync/async invocation, text streaming, streamed tool arguments, tool binding, strict JSON Schema,
179
+ parallel tool calls, multimodal content, and LangSmith metadata are supported. Tool execution remains
180
+ the application's responsibility: append each `ToolMessage` and invoke the model again. ML Junction
181
+ reasoning continuation automatically marks that follow-up as tool results, even when workflow
182
+ messages appear after the tool result.
183
+
184
+ Multimodal input accepts LangChain standard blocks and the common OpenAI/Anthropic-compatible forms
185
+ for image URLs/base64 images, base64 audio, PDFs/files, multimodal tool results, cache-control text,
186
+ and thinking blocks. The adapter normalizes them to ML Junction's provider-neutral message contract.
187
+
188
+ Structured-output runnables deliberately coerce `stream()` and `astream()` to one complete
189
+ `AIMessageChunk`. This applies to native JSON Schema/JSON mode and to function-calling used as a
190
+ structured-output method, so a LangChain parser never sees partial JSON. Ordinary
191
+ `bind_tools().stream()` calls remain incremental and LangChain reassembles their argument deltas.
192
+
193
+ `include_raw=True` follows LangChain's standard result contract and returns a dictionary containing
194
+ `raw`, `parsed`, and `parsing_error`. The complete unparsed `AIMessage` is available under `raw`;
195
+ read its `content` for native JSON Schema/JSON mode or `tool_calls` for function-calling.
196
+
197
+ ## Sync, async, batch, and per-call overrides
198
+
199
+ ```python
200
+ answer = llm.invoke("Hello")
201
+ answer = await llm.ainvoke("Hello")
202
+
203
+ answers = llm.batch(["One", "Two"])
204
+ answers = await llm.abatch(["One", "Two"])
205
+
206
+ high_effort = llm.invoke(
207
+ "Analyze this carefully",
208
+ reasoning={"enabled": True, "effort": "high"},
209
+ session_id="analysis-session-1",
210
+ )
211
+ ```
212
+
213
+ Use a stable `session_id` for related requests when you want sticky routing and coherent request
214
+ grouping in ML Junction's activity logs.
215
+
216
+ ## Embeddings
217
+
218
+ ```python
219
+ from langchain_mljunction import MLJunctionEmbeddings
220
+
221
+ embeddings = MLJunctionEmbeddings(
222
+ model="text-embedding-3-small",
223
+ dimensions=1024,
224
+ routing={"strategy": "latency", "max_platform_charge_usd": 0.01},
225
+ )
226
+ vectors = embeddings.embed_documents(["route", "receipt", "reasoning"])
227
+ ```
228
+
229
+ ## OpenTelemetry agent tracing
230
+
231
+ ```python
232
+ from langchain_mljunction import MLJunction
233
+
234
+ telemetry = MLJunction(
235
+ api_key="mlj_...",
236
+ base_url="https://api.mljunction.com",
237
+ app_id="support-bot",
238
+ )
239
+ run = telemetry.context(agent_name="coordinator", session_id="conversation-42")
240
+ agent.invoke({"messages": [...]}, config=run.config)
241
+ telemetry.flush()
242
+ ```
243
+
244
+ The callback bridge emits standard OTLP spans to `/v1/traces`, reuses an
245
+ existing global OpenTelemetry provider when present, and supports dual export.
246
+ Set `export_inflight=True` only when debugging hung agents; it emits an extra
247
+ start snapshot for each span and is off by default.
248
+
249
+ Every `AIMessage` carries standard `usage_metadata`. `response_metadata` retains request ID,
250
+ model, routing, receipt, warnings, session/task/app identity, and reasoning state—nothing important
251
+ is discarded to imitate another provider.
252
+
253
+ ## Reporting outcomes
254
+
255
+ An adaptive model learns which candidate to route to from evidence. Besides offline
256
+ evaluation suites, your application can report what actually happened afterwards: a
257
+ ticket closing, a patch passing its tests, a meeting getting booked. ML Junction never
258
+ interprets a metric's name; it compares each one against itself across the models in a
259
+ pool.
260
+
261
+ ```python
262
+ from langchain_mljunction import ChatMLJunction, Outcomes, request_id_of
263
+
264
+ # One-time: register each metric name. Needs a management key with adaptive:write
265
+ # (management_api_key= or MLJUNCTION_MANAGEMENT_API_KEY).
266
+ outcomes = Outcomes()
267
+ outcomes.register("resolved", direction="maximize")
268
+ outcomes.register("csat", direction="maximize", lower_bound=1, upper_bound=5)
269
+
270
+ # A support ticket: many requests, one outcome, so report it on the session.
271
+ chat = ChatMLJunction(model="support-agent", session_id="ticket_8842")
272
+ reply = chat.invoke("I want a refund")
273
+
274
+ receipt = outcomes.report(
275
+ session_id="ticket_8842",
276
+ event_id="ticket_8842_closed", # your idempotency key
277
+ metrics={"resolved": True, "csat": 4.8},
278
+ )
279
+ print(receipt.accepted, receipt.duplicates, receipt.discarded)
280
+
281
+ # One-shot work is reported on the request itself.
282
+ outcomes.report(request_id=request_id_of(reply), event_id="evt_1", metrics={"resolved": True})
283
+ ```
284
+
285
+ Three rules:
286
+
287
+ - **Register a name before sending it.** An unregistered name is refused, so a typo
288
+ cannot quietly become a second metric.
289
+ - **`event_id` is required and is yours.** A report retried by a queue is counted once
290
+ (`receipt.duplicates`), not twice.
291
+ - **Report against exactly one target**: `request_id`, `session_id` or `task_id`,
292
+ whichever unit the outcome is about.
293
+
294
+ Reporting uses the ordinary inference key. Queue-flushed reports go in one round trip
295
+ with `report_batch([...])`, where each entry takes the same arguments as `report`. Pass
296
+ `observed_at=` when the event happened earlier than the call. `areport`,
297
+ `areport_batch` and `aregister` are the async forms. `discarded` in the receipt is not
298
+ an error: it counts outcomes that could not be attributed to a single candidate.
299
+
300
+ ## Errors and transport lifecycle
301
+
302
+ API failures raise `MLJunctionAPIError` with the HTTP status and ML Junction error payload. Streaming
303
+ errors are read and decoded before the exception is raised, for both sync and async clients.
304
+
305
+ Long-lived applications should close the owned HTTP transports:
306
+
307
+ ```python
308
+ llm.close()
309
+
310
+ # Async applications:
311
+ await llm.aclose()
312
+ ```
313
+
314
+ ## Development
315
+
316
+ ```bash
317
+ pip install -e ".[test]"
318
+ pytest
319
+ ruff check src tests
320
+ ```
321
+
322
+ Live tests are opt-in with `RUN_LIVE_PROVIDER_TESTS=1`, `LIVE_API_KEY`, and `LIVE_API_BASE`.
323
+ The `tests/standard_tests` directory subclasses LangChain's `ChatModelUnitTests` and
324
+ `ChatModelIntegrationTests`; run it whenever the supported LangChain contract changes.
325
+ The stream timing test uses a sanitized cassette in `tests/cassettes` and can be verified with
326
+ `--record-mode=none`. Its replay has also been validated with the gateway stopped and pytest sockets
327
+ disabled, ensuring routine benchmark runs cannot make an accidental provider request.