langchain-odile 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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Odile Labs
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,39 @@
1
+ Metadata-Version: 2.4
2
+ Name: langchain-odile
3
+ Version: 0.1.0
4
+ Summary: LangChain tools for Odile Direct
5
+ Author-email: Odile Labs <hello@odilelabs.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://odilelabs.com/developers
8
+ Project-URL: Source, https://github.com/tolga-boop/langchain-odile
9
+ Keywords: langchain,mcp,odile,business,phone
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: langchain-core>=0.3
14
+ Dynamic: license-file
15
+
16
+ # langchain-odile
17
+
18
+ LangChain tools for finding a business by phone and sending requests through Odile Direct.
19
+
20
+ ```sh
21
+ pip install langchain-odile
22
+ ```
23
+
24
+ ```python
25
+ from langchain.agents import create_agent
26
+ from langchain_odile import OdileDirectToolkit
27
+
28
+ # Supply a LangChain-compatible model and, optionally, a Direct API key.
29
+ toolkit = OdileDirectToolkit(api_key="YOUR_API_KEY")
30
+ tools = toolkit.get_tools()
31
+ agent = create_agent(model=model, tools=tools)
32
+ result = agent.invoke({
33
+ "messages": [{"role": "user", "content": "Find +15615550123"}]
34
+ })
35
+ print(result)
36
+ ```
37
+
38
+ Lookups need no API key. See the [developer documentation](https://odilelabs.com/developers)
39
+ and [pricing](https://odilelabs.com/pricing).
@@ -0,0 +1,24 @@
1
+ # langchain-odile
2
+
3
+ LangChain tools for finding a business by phone and sending requests through Odile Direct.
4
+
5
+ ```sh
6
+ pip install langchain-odile
7
+ ```
8
+
9
+ ```python
10
+ from langchain.agents import create_agent
11
+ from langchain_odile import OdileDirectToolkit
12
+
13
+ # Supply a LangChain-compatible model and, optionally, a Direct API key.
14
+ toolkit = OdileDirectToolkit(api_key="YOUR_API_KEY")
15
+ tools = toolkit.get_tools()
16
+ agent = create_agent(model=model, tools=tools)
17
+ result = agent.invoke({
18
+ "messages": [{"role": "user", "content": "Find +15615550123"}]
19
+ })
20
+ print(result)
21
+ ```
22
+
23
+ Lookups need no API key. See the [developer documentation](https://odilelabs.com/developers)
24
+ and [pricing](https://odilelabs.com/pricing).
@@ -0,0 +1,7 @@
1
+ """Odile Direct tools for LangChain."""
2
+
3
+ from .client import OdileDirectClient, OdileError
4
+ from .toolkit import OdileDirectToolkit
5
+
6
+ __version__ = "0.1.0"
7
+ __all__ = ["OdileDirectToolkit", "OdileDirectClient", "OdileError", "__version__"]
@@ -0,0 +1,58 @@
1
+ """HTTP client for Odile Direct."""
2
+
3
+ import json
4
+ from urllib.error import HTTPError
5
+ from urllib.request import Request, urlopen
6
+
7
+
8
+ class OdileError(Exception):
9
+ def __init__(self, code, message):
10
+ self.code = code
11
+ self.message = message
12
+ super().__init__(f"{code}: {message}")
13
+
14
+
15
+ class OdileDirectClient:
16
+ def __init__(self, base_url="https://odilelabs.com/mcp/direct", api_key=None, timeout=30, fetch=None):
17
+ self.base_url = base_url
18
+ self.api_key = api_key
19
+ self.timeout = timeout
20
+ self.fetch = fetch or self._fetch
21
+
22
+ def _fetch(self, request):
23
+ try:
24
+ with urlopen(request, timeout=self.timeout) as response:
25
+ return response.status, response.read()
26
+ except HTTPError as error:
27
+ return error.code, error.read()
28
+
29
+ def call(self, tool, arguments):
30
+ headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
31
+ if self.api_key:
32
+ headers["Authorization"] = f"Bearer {self.api_key}"
33
+ request = Request(self.base_url, data=json.dumps({
34
+ "jsonrpc": "2.0", "id": 1, "method": "tools/call",
35
+ "params": {"name": tool, "arguments": arguments},
36
+ }).encode(), headers=headers, method="POST")
37
+ status, body = self.fetch(request)
38
+ try:
39
+ response = json.loads(body)
40
+ except (ValueError, UnicodeDecodeError):
41
+ if not 200 <= status < 300:
42
+ raise OdileError(status, body.decode("utf-8", errors="replace"))
43
+ raise
44
+ if not 200 <= status < 300:
45
+ error = response.get("error", response)
46
+ message = error.get("message", str(error)) if isinstance(error, dict) else str(error)
47
+ raise OdileError(status, message)
48
+ if "error" in response:
49
+ error = response["error"]
50
+ raise OdileError(error.get("code"), error.get("message", ""))
51
+ result = response["result"]
52
+ if "structuredContent" in result:
53
+ return result["structuredContent"]
54
+ content = "\n".join(part["text"] for part in result.get("content", []) if part.get("type") == "text")
55
+ try:
56
+ return json.loads(content)
57
+ except ValueError:
58
+ return content
@@ -0,0 +1,294 @@
1
+ {
2
+ "get_m2m_network": {
3
+ "description": "Read the OdileM2M network card for Direct: the live skills a business can accept, what each costs in credits, the credit packs, the limits that apply, and the Gate MCP address and quickstart. Use it to cost Direct tasks before sending any. It does not choose between products (get_agent_services) and does not show your own balance (get_direct_usage). Returns the pricing card (currency, credit_cents, packs, lookups) plus live_skills[{id, name, description, credits}], receipts, mcp_url, gate_mcp_url, quickstart_url, docs_url and connect_steps[]. Reference data, nothing account-specific: takes no arguments, needs no sign-in, contacts no business and changes nothing. Example: call it once with no arguments before the first send_to_business of a session.",
4
+ "inputSchema": {
5
+ "$schema": "http://json-schema.org/draft-07/schema#",
6
+ "type": "object",
7
+ "properties": {}
8
+ }
9
+ },
10
+ "find_business_agent": {
11
+ "description": "Look up a business by its phone number in the Odile Direct directory and get its Direct card. Start here: send_to_business needs the business id (<biz>) from this card. Pass the number as customers dial it, in E.164 with the country code and no spaces or dashes (+15615550123); any other format is rejected. Returns {agent_card, business, trade, town}: agent_card is the A2A card URL (/b/<biz>/ for a business line, /c/<biz>/ for a claimed listing; pass that <biz> to send_to_business), and the card lists the skills the business accepts. A number that is not on Direct returns not_found with claim_url and listings_url; for a US or Canadian number, voice_fallback says whether call_business is available. Capped at 30 lookups per IP per hour without sign-in and 10000 per key per day signed in. Read-only; contacts no business. Example: number='+15615550123' returns the card whose id you pass to send_to_business as biz.",
12
+ "inputSchema": {
13
+ "$schema": "http://json-schema.org/draft-07/schema#",
14
+ "type": "object",
15
+ "properties": {
16
+ "number": {
17
+ "type": "string",
18
+ "pattern": "^\\+[1-9]\\d{6,14}$",
19
+ "description": "The business's phone number in E.164, e.g. +15615550123."
20
+ }
21
+ },
22
+ "required": [
23
+ "number"
24
+ ]
25
+ }
26
+ },
27
+ "send_to_business": {
28
+ "description": "Send one request to a business found with find_business_agent, using a skill its card lists: take_message (leave a message), check_availability (read open times), book (book a slot check_availability returned), book_first_available (book the first open slot in up to 5 preferred windows) or cancel_booking (cancel a booking you made). Call find_business_agent first; for a business without a card, use call_business. Say an automated assistant is acting, and get the person's permission before booking. A delivered message cannot be recalled; a booking is undone only with cancel_booking. message_id is the retry key: reuse it unchanged on every retry so the request runs once. If you omit it, an identical request from you counts as a retry only within the same UTC day. Returns the A2A v1.0 {task}: read its state before telling anyone a message was delivered or a booking confirmed. A deposit booking returns INPUT_REQUIRED with a payment_url for the person; it is held 30 minutes and confirmed only after payment, which get_direct_payment checks. Failed, refused and unconfirmed tasks are not charged; costs are in get_m2m_network and your balance in get_direct_usage. Messages and confirmed bookings return thread_id and thread_token: use get_replies to read the business\u2019s replies and reply_to_business to answer in the same conversation. Signed-in requesters may omit callback_number for take_message; anonymous requesters still need their own valid number. Example: biz='harbor-plumbing', skill='take_message', message_id='req-8f2', data={caller_name:'Ana Diaz', callback_number:'+15615550199', need:'Leaking water heater, today if possible'}.",
29
+ "inputSchema": {
30
+ "$schema": "http://json-schema.org/draft-07/schema#",
31
+ "type": "object",
32
+ "properties": {
33
+ "biz": {
34
+ "type": "string",
35
+ "pattern": "^[a-zA-Z0-9_-]{1,100}$",
36
+ "description": "The business id from its card: the <biz> in /b/<biz>/ or /c/<biz>/."
37
+ },
38
+ "target": {
39
+ "description": "line (default) for a business line (/b/ card); claim for a claimed listing (/c/ card), which takes messages only.",
40
+ "type": "string",
41
+ "enum": [
42
+ "line",
43
+ "claim"
44
+ ]
45
+ },
46
+ "message_id": {
47
+ "description": "Your id for this request; reuse it unchanged on every retry so the request runs once.",
48
+ "type": "string",
49
+ "minLength": 1,
50
+ "maxLength": 160
51
+ },
52
+ "approval_id": {
53
+ "description": "Only when the business approves bookings itself: the action_id (da_...) from its approval_required reply, sent with the same message_id and data once the owner approves.",
54
+ "type": "string",
55
+ "pattern": "^da_[a-f0-9]{24}$"
56
+ },
57
+ "skill": {
58
+ "type": "string",
59
+ "enum": [
60
+ "take_message",
61
+ "check_availability",
62
+ "book",
63
+ "book_first_available",
64
+ "cancel_booking"
65
+ ],
66
+ "description": "One skill the business card lists."
67
+ },
68
+ "data": {
69
+ "type": "object",
70
+ "propertyNames": {
71
+ "type": "string"
72
+ },
73
+ "additionalProperties": {},
74
+ "description": "The skill's fields; put skill beside data, never inside it. take_message: caller_name, callback_number (the person's own E.164 number, never the business's), need, optional on_behalf_of and urgency (low, normal, high or urgent). check_availability: optional service and date (YYYY-MM-DD in the business's time zone). book: slot exactly as check_availability returned it, caller_name, callback_number, need. book_first_available: options (1 to 5 windows in order, each {date, optional after and before as HH:MM, optional service}), fallback (none or take_message), caller_name, callback_number, need. cancel_booking: booking_id and cancel_token from the confirmed booking, optional reason (up to 300 characters); cancelling is not charged and does not refund a booking fee. For take_message only, a signed-in requester may omit callback_number and receive replies in the thread."
75
+ }
76
+ },
77
+ "required": [
78
+ "biz",
79
+ "skill",
80
+ "data"
81
+ ]
82
+ }
83
+ },
84
+ "get_replies": {
85
+ "description": "Read the replies in a Direct conversation you started with send_to_business: its status and messages, newest last. Use it to find out whether the business has answered; it only reads and does not wait, so call it again later if there is no reply yet. Not for messages callers left on your own answering line (get_line_messages) or the outcome of a phone call (get_call_result). Pass the thread_id that send_to_business returned; sign in as the original requester with direct:tasks, or supply the private thread_token instead. Returns {thread_id, status (received, business_notified, business_replied, booked, waiting_for_you or closed), caller_name, callback_number, messages[{from: customer or business, text, at}]}. An unknown thread or a wrong token returns not_found, and a thread expires 90 days after its last message. Changes nothing and uses no credits. Example: thread_id='th_7c1' (with thread_token unless you started the thread while signed in) returns the status and messages so far.",
86
+ "inputSchema": {
87
+ "$schema": "http://json-schema.org/draft-07/schema#",
88
+ "type": "object",
89
+ "properties": {
90
+ "thread_id": {
91
+ "type": "string",
92
+ "minLength": 1,
93
+ "maxLength": 160,
94
+ "description": "The thread_id returned by send_to_business."
95
+ },
96
+ "thread_token": {
97
+ "description": "The private thread_token; required unless signed in as the requester who started the thread.",
98
+ "type": "string",
99
+ "minLength": 1,
100
+ "maxLength": 128
101
+ }
102
+ },
103
+ "required": [
104
+ "thread_id"
105
+ ]
106
+ }
107
+ },
108
+ "reply_to_business": {
109
+ "description": "Send a customer reply in an open Direct conversation and notify the business. Use it after get_replies shows the business answered or asked something; to start a new conversation use send_to_business, and to answer a question during a live phone call use answer_call_question. Pass the thread_id that send_to_business returned and the reply text (1 to 500 characters); sign in as the original requester with direct:tasks, or supply the private thread_token instead. message_id is the retry key: reuse it unchanged when retrying. Returns the A2A {task} for the reply: read its state before telling anyone it was delivered. A delivered reply cannot be recalled and is billed the same way as a take_message task; closed conversations refuse replies. Example: thread_id='th_7c1', text='Thursday after 2pm works, thank you.', message_id='rep-1'.",
110
+ "inputSchema": {
111
+ "$schema": "http://json-schema.org/draft-07/schema#",
112
+ "type": "object",
113
+ "properties": {
114
+ "thread_id": {
115
+ "type": "string",
116
+ "minLength": 1,
117
+ "maxLength": 160,
118
+ "description": "The thread_id returned by send_to_business."
119
+ },
120
+ "thread_token": {
121
+ "description": "The private thread_token; required unless signed in as the requester who started the thread.",
122
+ "type": "string",
123
+ "minLength": 1,
124
+ "maxLength": 128
125
+ },
126
+ "text": {
127
+ "type": "string",
128
+ "minLength": 1,
129
+ "maxLength": 500,
130
+ "description": "The customer\u2019s reply to the business."
131
+ },
132
+ "message_id": {
133
+ "description": "Your id for this reply; reuse it unchanged when retrying.",
134
+ "type": "string",
135
+ "minLength": 1,
136
+ "maxLength": 160
137
+ }
138
+ },
139
+ "required": [
140
+ "thread_id",
141
+ "text"
142
+ ]
143
+ }
144
+ },
145
+ "call_business": {
146
+ "description": "Place an outbound phone call to a business to ask for information on a named person\u2019s behalf. Use it when find_business_agent returned not_found for a US or Canadian number; when a Direct card exists use send_to_business. It dials a real number and a call cannot be recalled once placed. Requires direct:tasks, human opt-in at https://odilelabs.com/developers#voice and the person\u2019s permission for this call (on_behalf_consent true). Informational calls only: no sales, payments or account access. Calls run Monday to Friday, 9:00 to 17:00 in the business\u2019s local time; outside that window the request is refused and the error includes next_window. A number is not called twice within 24 hours, and each account has a daily call cap. number and callback_number must be callable US or Canadian E.164 numbers, callback_number being the person\u2019s own, never the business\u2019s. message_id is required: reuse it unchanged on every retry so only one call is placed. Returns {task_id, state (queued, completed or failed), status}; read get_call_result for progress and answers. Voice credit costs: get_m2m_network. Example: number='+15615550123', business_name='Harbor Plumbing', caller_name='Ana Diaz', callback_number='+15615550199', need='Do you replace water heaters?', on_behalf_consent=true, message_id='call-8f2'.",
147
+ "inputSchema": {
148
+ "$schema": "http://json-schema.org/draft-07/schema#",
149
+ "type": "object",
150
+ "properties": {
151
+ "number": {
152
+ "type": "string",
153
+ "pattern": "^\\+[1-9]\\d{6,14}$",
154
+ "description": "The business\u2019s E.164 phone number, e.g. +15615550123."
155
+ },
156
+ "business_name": {
157
+ "type": "string",
158
+ "minLength": 1,
159
+ "maxLength": 120,
160
+ "description": "The business to call."
161
+ },
162
+ "caller_name": {
163
+ "type": "string",
164
+ "minLength": 1,
165
+ "maxLength": 100,
166
+ "description": "The named person who requested this call."
167
+ },
168
+ "callback_number": {
169
+ "type": "string",
170
+ "pattern": "^\\+[1-9]\\d{6,14}$",
171
+ "description": "That person\u2019s own callback number in E.164, never the business\u2019s."
172
+ },
173
+ "need": {
174
+ "type": "string",
175
+ "minLength": 1,
176
+ "maxLength": 500,
177
+ "description": "The information the person wants from the business."
178
+ },
179
+ "on_behalf_consent": {
180
+ "type": "boolean",
181
+ "const": true,
182
+ "description": "True only after that person asked for this call."
183
+ },
184
+ "questions": {
185
+ "description": "Up to five questions to ask the business.",
186
+ "maxItems": 5,
187
+ "type": "array",
188
+ "items": {
189
+ "type": "string",
190
+ "minLength": 1,
191
+ "maxLength": 200
192
+ }
193
+ },
194
+ "message_id": {
195
+ "type": "string",
196
+ "minLength": 1,
197
+ "maxLength": 160,
198
+ "description": "A stable request id; reuse it unchanged on every retry."
199
+ },
200
+ "max_seconds": {
201
+ "description": "Maximum call duration, from 60 to 600 seconds, subject to available credits.",
202
+ "type": "integer",
203
+ "minimum": 60,
204
+ "maximum": 600
205
+ }
206
+ },
207
+ "required": [
208
+ "number",
209
+ "business_name",
210
+ "caller_name",
211
+ "callback_number",
212
+ "need",
213
+ "on_behalf_consent",
214
+ "message_id"
215
+ ]
216
+ }
217
+ },
218
+ "get_call_result": {
219
+ "description": "Read the state and outcome of a business call you placed with call_business (an outbound call; the calls your own answering line took are in get_line_calls), using its task_id. Call it again until the state is final: queued, dialing and in_call are still in progress; completed, no_answer, voicemail, refused and failed are final; input_required means the business asked a question that answer_call_question can answer within 45 seconds. Requires direct:tasks and ownership of the call (another account\u2019s task_id returns Not found). Returns {task_id, state, outcome, duration_seconds, transcript, answers, summary, cost {credits}}, plus pending_question {question, expires_at} while a question waits. Changes nothing, starts no call and is safe to repeat. Example: task_id='dv_9a3' from call_business; stop polling once state is completed, failed or no_answer.",
220
+ "inputSchema": {
221
+ "$schema": "http://json-schema.org/draft-07/schema#",
222
+ "type": "object",
223
+ "properties": {
224
+ "task_id": {
225
+ "type": "string",
226
+ "minLength": 1,
227
+ "maxLength": 100,
228
+ "description": "The task_id returned by call_business."
229
+ }
230
+ },
231
+ "required": [
232
+ "task_id"
233
+ ]
234
+ }
235
+ },
236
+ "answer_call_question": {
237
+ "description": "Give the calling assistant your answer to a question the business asked during a call placed with call_business, so the call can continue. Use it only when get_call_result shows state input_required (its pending_question holds the question); reply within 45 seconds or the assistant tells the business the person will follow up and carries on. The assistant uses the answer on the live call and it cannot be taken back, so keep it short and factual (up to 300 characters). It is refused if it contains a number of four to eight digits, a card-length number, or words such as password, PIN or code; never include payment card numbers, passwords or one-time codes. Requires direct:tasks and ownership of the call. Returns the call state; read get_call_result for the outcome. Example: task_id='dv_9a3', answer='A 40-gallon gas heater, installed in 2016.'",
238
+ "inputSchema": {
239
+ "$schema": "http://json-schema.org/draft-07/schema#",
240
+ "type": "object",
241
+ "properties": {
242
+ "task_id": {
243
+ "type": "string",
244
+ "minLength": 1,
245
+ "maxLength": 100,
246
+ "description": "The task_id returned by call_business."
247
+ },
248
+ "answer": {
249
+ "type": "string",
250
+ "minLength": 1,
251
+ "maxLength": 300,
252
+ "description": "The answer to the pending question, in plain words, without card numbers, passwords, one-time codes or any number of four to eight digits."
253
+ }
254
+ },
255
+ "required": [
256
+ "task_id",
257
+ "answer"
258
+ ]
259
+ }
260
+ },
261
+ "get_direct_usage": {
262
+ "description": "Read your own Direct account ledger: the signed-in account\u2019s UTC free-send allowance, prepaid balance in cents, reserved cents and last 20 charges. It never buys credit and does not list costs (see get_m2m_network). Use it before sending tasks that may cost credit; a payment_required reply from send_to_business carries the top-up link. Uses the same ledger as REST and your developer page. Requires direct:tasks and sign-in. Changes nothing; phone minutes and legacy credits are tracked separately. Example: call it with no arguments before a booking to confirm the allowance or balance covers it.",
263
+ "inputSchema": {
264
+ "$schema": "http://json-schema.org/draft-07/schema#",
265
+ "type": "object",
266
+ "properties": {}
267
+ }
268
+ },
269
+ "get_direct_payment": {
270
+ "description": "Check an existing deposit booking on your signed-in Direct account: reconcile its Stripe Checkout payment and return its receipt and the separate booking state. Use it after send_to_business returned a payment_url for a deposit booking, to see whether the person has paid; it cannot start a payment, which the person makes on Stripe\u2019s hosted page. Pass biz (the business id from its card) and task_id (the task id send_to_business returned for that booking). Requires direct:tasks. Uses only the stored merchant account and amount and creates no new charge or booking identity; a verified paid checkout may finish its already-authorized held booking. Payout status remains unknown: paid does not mean a bank payout. Safe to repeat. Example: biz='harbor-plumbing', task_id='tk_5d0' after send_to_business returned a payment_url for a deposit booking.",
271
+ "inputSchema": {
272
+ "$schema": "http://json-schema.org/draft-07/schema#",
273
+ "type": "object",
274
+ "properties": {
275
+ "biz": {
276
+ "type": "string",
277
+ "minLength": 1,
278
+ "maxLength": 100,
279
+ "description": "The business id (from its card) the deposit booking was made with."
280
+ },
281
+ "task_id": {
282
+ "type": "string",
283
+ "minLength": 1,
284
+ "maxLength": 100,
285
+ "description": "The task id send_to_business returned for that booking."
286
+ }
287
+ },
288
+ "required": [
289
+ "biz",
290
+ "task_id"
291
+ ]
292
+ }
293
+ }
294
+ }
@@ -0,0 +1,19 @@
1
+ """Toolkit for Odile Direct."""
2
+
3
+ from langchain_core.tools import BaseToolkit
4
+ from pydantic import ConfigDict, Field
5
+
6
+ from .client import OdileDirectClient
7
+ from .tools import make_tools
8
+
9
+
10
+ class OdileDirectToolkit(BaseToolkit):
11
+ model_config = ConfigDict(arbitrary_types_allowed=True)
12
+ client: OdileDirectClient = Field(exclude=True)
13
+
14
+ def __init__(self, api_key=None, base_url="https://odilelabs.com/mcp/direct", **kwargs):
15
+ client = kwargs.pop("client", None) or OdileDirectClient(base_url=base_url, api_key=api_key)
16
+ super().__init__(client=client, **kwargs)
17
+
18
+ def get_tools(self):
19
+ return make_tools(self.client)
@@ -0,0 +1,48 @@
1
+ """LangChain tools from the Direct schemas."""
2
+
3
+ import hashlib
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Literal, Optional
7
+
8
+ from langchain_core.tools import StructuredTool
9
+ from pydantic import Field, create_model
10
+
11
+ SCHEMAS = json.loads(Path(__file__).with_name("direct-tools.json").read_text(encoding="utf-8"))
12
+
13
+
14
+ def _model(name, schema):
15
+ fields = {}
16
+ required = schema.get("required", [])
17
+ for key, spec in schema["properties"].items():
18
+ field_type = dict if spec["type"] == "object" else str
19
+ if "enum" in spec:
20
+ field_type = Literal[tuple(spec["enum"])]
21
+ if key not in required:
22
+ field_type = Optional[field_type]
23
+ description = spec.get("description", "")
24
+ if spec["type"] == "object":
25
+ description += " Schema: " + json.dumps(spec, sort_keys=True)
26
+ if name == "send_to_business" and key == "message_id":
27
+ description += " If omitted, a deterministic SHA-256 retry key is derived from biz, skill and data."
28
+ constraints = {target: spec[source] for source, target in (
29
+ ("pattern", "pattern"), ("minLength", "min_length"), ("maxLength", "max_length")
30
+ ) if source in spec}
31
+ fields[key] = (field_type, Field(... if key in required else None, description=description, **constraints))
32
+ return create_model(name + "_args", **fields)
33
+
34
+
35
+ def make_tools(client):
36
+ def bind(name):
37
+ def call(**arguments):
38
+ arguments = {key: value for key, value in arguments.items() if value is not None}
39
+ if name == "send_to_business" and "message_id" not in arguments:
40
+ value = arguments["biz"] + arguments["skill"] + json.dumps(arguments["data"], sort_keys=True)
41
+ arguments["message_id"] = hashlib.sha256(value.encode()).hexdigest()[:40]
42
+ return client.call(name, arguments)
43
+ return call
44
+
45
+ return [StructuredTool.from_function(
46
+ func=bind(name), name=name, description=definition["description"],
47
+ args_schema=_model(name, definition["inputSchema"]),
48
+ ) for name, definition in SCHEMAS.items()]
@@ -0,0 +1,39 @@
1
+ Metadata-Version: 2.4
2
+ Name: langchain-odile
3
+ Version: 0.1.0
4
+ Summary: LangChain tools for Odile Direct
5
+ Author-email: Odile Labs <hello@odilelabs.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://odilelabs.com/developers
8
+ Project-URL: Source, https://github.com/tolga-boop/langchain-odile
9
+ Keywords: langchain,mcp,odile,business,phone
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: langchain-core>=0.3
14
+ Dynamic: license-file
15
+
16
+ # langchain-odile
17
+
18
+ LangChain tools for finding a business by phone and sending requests through Odile Direct.
19
+
20
+ ```sh
21
+ pip install langchain-odile
22
+ ```
23
+
24
+ ```python
25
+ from langchain.agents import create_agent
26
+ from langchain_odile import OdileDirectToolkit
27
+
28
+ # Supply a LangChain-compatible model and, optionally, a Direct API key.
29
+ toolkit = OdileDirectToolkit(api_key="YOUR_API_KEY")
30
+ tools = toolkit.get_tools()
31
+ agent = create_agent(model=model, tools=tools)
32
+ result = agent.invoke({
33
+ "messages": [{"role": "user", "content": "Find +15615550123"}]
34
+ })
35
+ print(result)
36
+ ```
37
+
38
+ Lookups need no API key. See the [developer documentation](https://odilelabs.com/developers)
39
+ and [pricing](https://odilelabs.com/pricing).
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ langchain_odile/__init__.py
5
+ langchain_odile/client.py
6
+ langchain_odile/direct-tools.json
7
+ langchain_odile/toolkit.py
8
+ langchain_odile/tools.py
9
+ langchain_odile.egg-info/PKG-INFO
10
+ langchain_odile.egg-info/SOURCES.txt
11
+ langchain_odile.egg-info/dependency_links.txt
12
+ langchain_odile.egg-info/requires.txt
13
+ langchain_odile.egg-info/top_level.txt
14
+ tests/test_client.py
15
+ tests/test_tools.py
@@ -0,0 +1 @@
1
+ langchain-core>=0.3
@@ -0,0 +1 @@
1
+ langchain_odile
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "langchain-odile"
7
+ version = "0.1.0"
8
+ description = "LangChain tools for Odile Direct"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = ["langchain-core>=0.3"]
12
+ license = {text = "MIT"}
13
+ authors = [{name = "Odile Labs", email = "hello@odilelabs.com"}]
14
+ keywords = ["langchain", "mcp", "odile", "business", "phone"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://odilelabs.com/developers"
18
+ Source = "https://github.com/tolga-boop/langchain-odile"
19
+
20
+ [tool.setuptools.package-data]
21
+ langchain_odile = ["direct-tools.json"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,70 @@
1
+ import json
2
+ import unittest
3
+ from unittest.mock import patch
4
+ from urllib.error import HTTPError
5
+ from io import BytesIO
6
+
7
+ from langchain_odile import OdileDirectClient, OdileError
8
+
9
+
10
+ class ClientTests(unittest.TestCase):
11
+ def test_envelope_and_headers(self):
12
+ for key in (None, "test-key"):
13
+ with self.subTest(key=key):
14
+ requests = []
15
+ def fetch(request):
16
+ requests.append(request)
17
+ return 200, b'{"result":{"content":[{"type":"text","text":"ok"}]}}'
18
+ client = OdileDirectClient(base_url="https://example.test/mcp", api_key=key, fetch=fetch)
19
+ self.assertEqual(client.call("find_business_agent", {"number": "+15615550123"}), "ok")
20
+ req = requests[0]
21
+ self.assertEqual(req.full_url, "https://example.test/mcp")
22
+ self.assertEqual(req.get_method(), "POST")
23
+ self.assertEqual(dict(req.header_items()), {
24
+ "Content-type": "application/json", "Accept": "application/json, text/event-stream",
25
+ **({"Authorization": "Bearer test-key"} if key else {}),
26
+ })
27
+ self.assertEqual(json.loads(req.data), {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {
28
+ "name": "find_business_agent", "arguments": {"number": "+15615550123"}}})
29
+
30
+ def test_structured_content_takes_precedence_even_when_empty(self):
31
+ client = OdileDirectClient(fetch=lambda _: (200, b'{"result":{"structuredContent":{},"content":[{"type":"text","text":"ignored"}]}}'))
32
+ self.assertEqual(client.call("x", {}), {})
33
+
34
+ def test_text_parts_join_and_parse_json(self):
35
+ result = {"result": {"content": [{"type": "text", "text": '{"ok":'}, {"type": "image"}, {"type": "text", "text": "true}"}]}}
36
+ client = OdileDirectClient(fetch=lambda _: (200, json.dumps(result).encode()))
37
+ self.assertEqual(client.call("x", {}), {"ok": True})
38
+
39
+ def test_text_fallback(self):
40
+ client = OdileDirectClient(fetch=lambda _: (200, b'{"result":{"content":[{"type":"text","text":"hello"},{"type":"text","text":"world"}]}}'))
41
+ self.assertEqual(client.call("x", {}), "hello\nworld")
42
+
43
+ def test_rpc_error(self):
44
+ client = OdileDirectClient(fetch=lambda _: (200, b'{"error":{"code":-32602,"message":"bad arguments"}}'))
45
+ with self.assertRaises(OdileError) as caught:
46
+ client.call("x", {})
47
+ self.assertEqual((caught.exception.code, caught.exception.message), (-32602, "bad arguments"))
48
+
49
+ def test_http_errors(self):
50
+ for status in (402, 429):
51
+ with self.subTest(status=status):
52
+ client = OdileDirectClient(fetch=lambda _: (status, b'{"error":{"message":"server says no"}}'))
53
+ with self.assertRaises(OdileError) as caught:
54
+ client.call("x", {})
55
+ self.assertEqual((caught.exception.code, caught.exception.message), (status, "server says no"))
56
+
57
+ def test_http_plain_error(self):
58
+ client = OdileDirectClient(fetch=lambda _: (503, b"unavailable"))
59
+ with self.assertRaises(OdileError) as caught:
60
+ client.call("x", {})
61
+ self.assertEqual((caught.exception.code, caught.exception.message), (503, "unavailable"))
62
+
63
+ def test_real_fetch_passes_timeout_and_handles_http_error(self):
64
+ error = HTTPError("https://example.test", 429, "Too Many", {}, BytesIO(b'{"message":"slow down"}'))
65
+ with patch("langchain_odile.client.urlopen", side_effect=error) as urlopen:
66
+ with self.assertRaises(OdileError) as caught:
67
+ OdileDirectClient(timeout=7).call("x", {})
68
+ error.close()
69
+ self.assertEqual(caught.exception.message, "slow down")
70
+ self.assertEqual(urlopen.call_args.kwargs["timeout"], 7)
@@ -0,0 +1,79 @@
1
+ import hashlib
2
+ import json
3
+ import unittest
4
+ from pathlib import Path
5
+
6
+ from langchain_core.tools import StructuredTool
7
+ from pydantic import ValidationError
8
+
9
+ from langchain_odile import OdileDirectClient, OdileDirectToolkit, __version__
10
+
11
+
12
+ class ToolTests(unittest.TestCase):
13
+ def setUp(self):
14
+ self.calls = []
15
+ self.client = OdileDirectClient(fetch=lambda request: self._fetch(request))
16
+ self.toolkit = OdileDirectToolkit(client=self.client)
17
+ self.tools = {tool.name: tool for tool in self.toolkit.get_tools()}
18
+ self.schemas = json.loads(Path("langchain_odile/direct-tools.json").read_text())
19
+
20
+ def _fetch(self, request):
21
+ self.calls.append(json.loads(request.data)["params"])
22
+ return 200, b'{"result":{"structuredContent":{"ok":true}}}'
23
+
24
+ def test_all_tools_names_descriptions_and_types(self):
25
+ self.assertEqual(set(self.tools), set(self.schemas))
26
+ self.assertEqual(len(self.toolkit.get_tools()), len(self.schemas))
27
+ for name, tool in self.tools.items():
28
+ self.assertIsInstance(tool, StructuredTool)
29
+ self.assertEqual(tool.description, self.schemas[name]["description"])
30
+ self.assertEqual(set(tool.args_schema.model_json_schema().get("required", [])),
31
+ set(self.schemas[name]["inputSchema"].get("required", [])))
32
+ self.assertEqual(__version__, "0.1.0")
33
+
34
+ def test_missing_every_required_field(self):
35
+ def example(prop): # one value that satisfies the server's own constraints for that property
36
+ if "enum" in prop: return prop["enum"][0]
37
+ if prop.get("type") == "object": return {}
38
+ if prop.get("type") == "integer": return 1
39
+ if prop.get("type") == "boolean": return True
40
+ if prop.get("pattern", "").startswith("^\\+"): return "+15615550123"
41
+ return "harbor"
42
+ for name, tool in self.tools.items():
43
+ props = self.schemas[name]["inputSchema"].get("properties", {})
44
+ valid = {key: example(props[key]) for key in self.schemas[name]["inputSchema"].get("required", [])}
45
+ for key in valid:
46
+ with self.subTest(tool=name, missing=key), self.assertRaises(ValidationError):
47
+ tool.invoke({k: v for k, v in valid.items() if k != key})
48
+ self.assertEqual(self.calls, [])
49
+
50
+ def test_patterns_enums_and_lengths(self):
51
+ for number in ("15615550123", "+01234567", "+1 5615550123"):
52
+ with self.subTest(number=number), self.assertRaises(ValidationError):
53
+ self.tools["find_business_agent"].invoke({"number": number})
54
+ base = {"biz": "harbor", "skill": "take_message", "data": {}}
55
+ for override in ({"biz": "bad id"}, {"skill": "unknown"}, {"target": "other"},
56
+ {"message_id": ""}, {"message_id": "x" * 161}, {"approval_id": "bad"}):
57
+ with self.subTest(override=override), self.assertRaises(ValidationError):
58
+ self.tools["send_to_business"].invoke({**base, **override})
59
+ self.assertEqual(self.calls, [])
60
+
61
+ def test_send_derives_stable_id_and_keeps_supplied_id(self):
62
+ tool = self.tools["send_to_business"]
63
+ args = {"biz": "harbor", "skill": "take_message", "data": {"b": 2, "a": 1}}
64
+ self.assertEqual(tool.invoke(args), {"ok": True})
65
+ tool.invoke({**args, "data": {"a": 1, "b": 2}})
66
+ expected = hashlib.sha256((args["biz"] + args["skill"] + json.dumps(args["data"], sort_keys=True)).encode()).hexdigest()[:40]
67
+ self.assertEqual([c["arguments"]["message_id"] for c in self.calls], [expected, expected])
68
+ tool.invoke({**args, "message_id": "supplied"})
69
+ self.assertEqual(self.calls[-1]["arguments"]["message_id"], "supplied")
70
+ description = tool.args_schema.model_fields["message_id"].description
71
+ self.assertIn("deterministic", description)
72
+ self.assertIn('"type": "object"', tool.args_schema.model_fields["data"].description)
73
+
74
+ def test_tools_share_client_and_constructor_options(self):
75
+ self.tools["find_business_agent"].invoke({"number": "+15615550123"})
76
+ self.tools["send_to_business"].invoke({"biz": "x", "skill": "take_message", "data": {}})
77
+ self.assertEqual([c["name"] for c in self.calls], ["find_business_agent", "send_to_business"])
78
+ toolkit = OdileDirectToolkit(base_url="https://example.test", api_key="key")
79
+ self.assertEqual((toolkit.client.base_url, toolkit.client.api_key), ("https://example.test", "key"))