construct-labs-crm-env 0.1.9__py3-none-any.whl → 0.1.10__py3-none-any.whl

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.
@@ -6,15 +6,18 @@ the CRM environment tools. The LLM can make multiple tool calls to query
6
6
  and modify CRM data before providing a final answer.
7
7
 
8
8
  Requirements:
9
- pip install construct-labs-crm-env google-genai python-dotenv
9
+ pip install construct-labs-crm-env openai python-dotenv
10
10
 
11
11
  Usage:
12
12
  export CRM_AGENT_API_KEY=your-crm-api-key
13
- export GOOGLE_API_KEY=your-google-api-key
13
+ export OPENAI_API_KEY=your-openrouter-api-key
14
14
  python chat.py --help
15
15
  python chat.py
16
16
  python chat.py --question "How many companies are there?"
17
- python chat.py --model gemini-2.0-flash --stream
17
+ python chat.py --model anthropic/claude-sonnet-4 --stream
18
+
19
+ # Using OpenAI directly:
20
+ python chat.py --llm-base-url https://api.openai.com/v1 --model gpt-4o
18
21
  """
19
22
 
20
23
  from __future__ import annotations
@@ -23,6 +26,7 @@ import argparse
23
26
  import json
24
27
  import os
25
28
  import sys
29
+ from typing import Any
26
30
 
27
31
  try:
28
32
  from dotenv import load_dotenv
@@ -30,11 +34,9 @@ except ImportError:
30
34
  load_dotenv = None # type: ignore[assignment, misc]
31
35
 
32
36
  try:
33
- from google import genai
34
- from google.genai import types
37
+ from openai import OpenAI
35
38
  except ImportError:
36
- genai = None # type: ignore[assignment]
37
- types = None # type: ignore[assignment]
39
+ OpenAI = None # type: ignore[assignment, misc]
38
40
 
39
41
  from construct_labs_crm_env import CrmAgentEnv
40
42
 
@@ -42,8 +44,8 @@ from construct_labs_crm_env import CrmAgentEnv
42
44
  def _check_dependencies() -> None:
43
45
  """Check that optional dependencies are installed."""
44
46
  missing = []
45
- if genai is None:
46
- missing.append("google-genai")
47
+ if OpenAI is None:
48
+ missing.append("openai")
47
49
  if load_dotenv is None:
48
50
  missing.append("python-dotenv")
49
51
 
@@ -81,30 +83,6 @@ def print_separator(char: str = "-", width: int = 60) -> None:
81
83
  print(char * width)
82
84
 
83
85
 
84
- def convert_openai_tools_to_gemini(openai_tools: list[dict]) -> list[types.Tool]:
85
- """Convert OpenAI-format tools to Gemini format."""
86
- function_declarations = []
87
-
88
- for tool in openai_tools:
89
- if tool.get("type") != "function":
90
- continue
91
-
92
- func = tool["function"]
93
- name = func["name"]
94
- description = func.get("description", "")
95
- parameters = func.get("parameters", {})
96
-
97
- function_declarations.append(
98
- types.FunctionDeclaration(
99
- name=name,
100
- description=description,
101
- parameters=parameters if parameters else None,
102
- )
103
- )
104
-
105
- return [types.Tool(function_declarations=function_declarations)]
106
-
107
-
108
86
  def format_tool_call_compact(tool_name: str, tool_args: dict) -> str:
109
87
  """Format a tool call in compact function-call style: func_name(arg1, arg2, ...)"""
110
88
  if not tool_args:
@@ -125,96 +103,130 @@ def format_tool_call_compact(tool_name: str, tool_args: dict) -> str:
125
103
 
126
104
 
127
105
  def _stream_response(
128
- client: genai.Client,
106
+ client: OpenAI,
129
107
  model_name: str,
130
- contents: list[types.Content],
131
- config: types.GenerateContentConfig,
108
+ messages: list[dict[str, Any]],
109
+ tools: list[dict[str, Any]],
132
110
  print_output: bool = True,
133
- ) -> tuple[list[str], list[types.FunctionCall], types.Content | None]:
111
+ ) -> tuple[str | None, list[dict[str, Any]], dict[str, Any] | None]:
134
112
  """
135
113
  Make a streaming call to the model, optionally printing tokens as they arrive.
136
114
 
137
115
  Returns:
138
- Tuple of (text_parts, function_calls, final_content)
116
+ Tuple of (text_content, tool_calls, assistant_message)
139
117
  """
140
118
  text_parts: list[str] = []
141
- function_calls: list[types.FunctionCall] = []
142
- all_parts: list[types.Part] = []
119
+ tool_calls_by_index: dict[int, dict[str, Any]] = {}
143
120
 
144
- for chunk in client.models.generate_content_stream(
121
+ stream = client.chat.completions.create(
145
122
  model=model_name,
146
- contents=contents,
147
- config=config,
148
- ):
149
- if not chunk.candidates:
150
- continue
151
-
152
- candidate = chunk.candidates[0]
123
+ messages=messages,
124
+ tools=tools,
125
+ temperature=0.7,
126
+ stream=True,
127
+ )
153
128
 
154
- for part in candidate.content.parts:
155
- if part.text:
156
- if print_output:
157
- print(part.text, end="", flush=True)
158
- text_parts.append(part.text)
159
- all_parts.append(part)
129
+ for chunk in stream:
130
+ if not chunk.choices:
131
+ continue
160
132
 
161
- if part.function_call:
162
- function_calls.append(part.function_call)
163
- all_parts.append(part)
133
+ delta = chunk.choices[0].delta
134
+
135
+ if delta.content:
136
+ if print_output:
137
+ print(delta.content, end="", flush=True)
138
+ text_parts.append(delta.content)
139
+
140
+ if delta.tool_calls:
141
+ for tc in delta.tool_calls:
142
+ idx = tc.index
143
+ if idx not in tool_calls_by_index:
144
+ tool_calls_by_index[idx] = {
145
+ "id": tc.id or "",
146
+ "type": "function",
147
+ "function": {"name": "", "arguments": ""},
148
+ }
149
+ if tc.id:
150
+ tool_calls_by_index[idx]["id"] = tc.id
151
+ if tc.function:
152
+ if tc.function.name:
153
+ tool_calls_by_index[idx]["function"]["name"] = tc.function.name
154
+ if tc.function.arguments:
155
+ tool_calls_by_index[idx]["function"][
156
+ "arguments"
157
+ ] += tc.function.arguments
164
158
 
165
159
  if print_output and text_parts:
166
160
  print()
167
161
 
168
- if not all_parts:
169
- return [], [], None
162
+ text_content = "".join(text_parts) if text_parts else None
163
+ tool_calls = [tool_calls_by_index[i] for i in sorted(tool_calls_by_index.keys())]
170
164
 
171
- final_content = types.Content(role="model", parts=all_parts)
172
- full_text = "".join(text_parts)
173
- text_result = [full_text] if full_text else []
165
+ if not text_content and not tool_calls:
166
+ return None, [], None
174
167
 
175
- return text_result, function_calls, final_content
168
+ assistant_message: dict[str, Any] = {"role": "assistant"}
169
+ if text_content:
170
+ assistant_message["content"] = text_content
171
+ if tool_calls:
172
+ assistant_message["tool_calls"] = tool_calls
173
+
174
+ return text_content, tool_calls, assistant_message
176
175
 
177
176
 
178
177
  def _non_stream_response(
179
- client: genai.Client,
178
+ client: OpenAI,
180
179
  model_name: str,
181
- contents: list[types.Content],
182
- config: types.GenerateContentConfig,
183
- ) -> tuple[list[str], list[types.FunctionCall], types.Content | None]:
180
+ messages: list[dict[str, Any]],
181
+ tools: list[dict[str, Any]],
182
+ ) -> tuple[str | None, list[dict[str, Any]], dict[str, Any] | None]:
184
183
  """Make a non-streaming call to the model."""
185
- response = client.models.generate_content(
184
+ response = client.chat.completions.create(
186
185
  model=model_name,
187
- contents=contents,
188
- config=config,
186
+ messages=messages,
187
+ tools=tools,
188
+ temperature=0.7,
189
189
  )
190
190
 
191
- if not response.candidates:
192
- return [], [], None
193
-
194
- candidate = response.candidates[0]
195
- text_parts: list[str] = []
196
- function_calls: list[types.FunctionCall] = []
191
+ if not response.choices:
192
+ return None, [], None
193
+
194
+ message = response.choices[0].message
195
+ text_content = message.content
196
+ tool_calls = []
197
+
198
+ if message.tool_calls:
199
+ for tc in message.tool_calls:
200
+ tool_calls.append(
201
+ {
202
+ "id": tc.id,
203
+ "type": "function",
204
+ "function": {
205
+ "name": tc.function.name,
206
+ "arguments": tc.function.arguments,
207
+ },
208
+ }
209
+ )
197
210
 
198
- for part in candidate.content.parts:
199
- if part.text:
200
- text_parts.append(part.text)
201
- if part.function_call:
202
- function_calls.append(part.function_call)
211
+ assistant_message: dict[str, Any] = {"role": "assistant"}
212
+ if text_content:
213
+ assistant_message["content"] = text_content
214
+ if tool_calls:
215
+ assistant_message["tool_calls"] = tool_calls
203
216
 
204
- return text_parts, function_calls, candidate.content
217
+ return text_content, tool_calls, assistant_message
205
218
 
206
219
 
207
220
  def run_agent_loop(
208
221
  env: CrmAgentEnv,
209
- client: genai.Client,
222
+ client: OpenAI,
210
223
  model_name: str,
211
- tools: list[types.Tool],
212
- system_instruction: str,
213
- contents: list[types.Content],
224
+ tools: list[dict[str, Any]],
225
+ messages: list[dict[str, Any]],
214
226
  max_iterations: int = 20,
215
227
  verbose: bool = False,
216
228
  stream: bool = False,
217
- ) -> tuple[str | None, list[types.Content]]:
229
+ ) -> tuple[str | None, list[dict[str, Any]]]:
218
230
  """
219
231
  Run the agent loop until it calls submit_answer or reaches max iterations.
220
232
 
@@ -222,69 +234,64 @@ def run_agent_loop(
222
234
  until it's ready to submit a final answer.
223
235
 
224
236
  Returns:
225
- Tuple of (final_answer or None, updated contents)
237
+ Tuple of (final_answer or None, updated messages)
226
238
  """
227
239
  iteration = 0
228
240
 
229
241
  while iteration < max_iterations:
230
242
  iteration += 1
231
243
 
232
- config = types.GenerateContentConfig(
233
- tools=tools,
234
- system_instruction=system_instruction,
235
- temperature=0.7,
236
- )
237
-
238
244
  try:
239
245
  if stream:
240
- text_parts, function_calls, final_content = _stream_response(
246
+ text_content, tool_calls, assistant_message = _stream_response(
241
247
  client=client,
242
248
  model_name=model_name,
243
- contents=contents,
244
- config=config,
249
+ messages=messages,
250
+ tools=tools,
245
251
  print_output=False,
246
252
  )
247
253
  else:
248
- text_parts, function_calls, final_content = _non_stream_response(
254
+ text_content, tool_calls, assistant_message = _non_stream_response(
249
255
  client=client,
250
256
  model_name=model_name,
251
- contents=contents,
252
- config=config,
257
+ messages=messages,
258
+ tools=tools,
253
259
  )
254
260
  except Exception as e:
255
261
  print_colored(f"API Error: {e}", "red")
256
- return None, contents
262
+ return None, messages
257
263
 
258
- if final_content is None:
264
+ if assistant_message is None:
259
265
  print_colored("Error: No response from model", "red")
260
- return None, contents
266
+ return None, messages
261
267
 
262
- contents.append(final_content)
268
+ messages.append(assistant_message)
263
269
 
264
- if not function_calls:
265
- if text_parts:
266
- return "\n".join(text_parts), contents
267
- return None, contents
270
+ if not tool_calls:
271
+ return text_content, messages
268
272
 
269
- if text_parts:
273
+ if text_content:
270
274
  print_colored("\nThinking:", "dim")
271
- print("\n".join(text_parts))
275
+ print(text_content)
272
276
 
273
- function_response_parts = []
274
277
  final_answer = None
275
278
 
276
- for func_call in function_calls:
277
- tool_name = func_call.name
278
- tool_args = dict(func_call.args) if func_call.args else {}
279
+ for tc in tool_calls:
280
+ tool_name = tc["function"]["name"]
281
+ try:
282
+ tool_args = json.loads(tc["function"]["arguments"])
283
+ except json.JSONDecodeError:
284
+ tool_args = {}
279
285
 
280
286
  if tool_name == "submit_answer":
281
287
  final_answer = tool_args.get("answer", "")
282
288
  print_colored("\n>>> Agent submitting answer", "cyan")
283
- function_response_parts.append(
284
- types.Part.from_function_response(
285
- name=func_call.name,
286
- response={"result": "Answer submitted successfully."},
287
- )
289
+ messages.append(
290
+ {
291
+ "role": "tool",
292
+ "tool_call_id": tc["id"],
293
+ "content": "Answer submitted successfully.",
294
+ }
288
295
  )
289
296
  continue
290
297
 
@@ -312,28 +319,27 @@ def run_agent_loop(
312
319
  obs_text = f"Error executing action: {e}"
313
320
  print_colored(f" {obs_text}", "red")
314
321
 
315
- function_response_parts.append(
316
- types.Part.from_function_response(
317
- name=func_call.name,
318
- response={"result": obs_text},
319
- )
322
+ messages.append(
323
+ {
324
+ "role": "tool",
325
+ "tool_call_id": tc["id"],
326
+ "content": obs_text,
327
+ }
320
328
  )
321
329
 
322
- contents.append(types.Content(role="user", parts=function_response_parts))
323
-
324
330
  if final_answer is not None:
325
- return final_answer, contents
331
+ return final_answer, messages
326
332
 
327
333
  print_colored(f"\nMax iterations ({max_iterations}) reached", "yellow")
328
- return None, contents
334
+ return None, messages
329
335
 
330
336
 
331
337
  def run_chat(
332
338
  env: CrmAgentEnv,
333
- client: genai.Client,
339
+ client: OpenAI,
334
340
  model_name: str,
335
- tools: list[types.Tool],
336
- system_instruction: str,
341
+ tools: list[dict[str, Any]],
342
+ system_prompt: str,
337
343
  initial_question: str | None = None,
338
344
  max_iterations: int = 20,
339
345
  verbose: bool = False,
@@ -353,7 +359,7 @@ def run_chat(
353
359
  env.reset()
354
360
  print_colored("Environment ready.\n", "green")
355
361
 
356
- contents: list[types.Content] = []
362
+ messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
357
363
 
358
364
  while True:
359
365
  if initial_question:
@@ -374,17 +380,14 @@ def run_chat(
374
380
 
375
381
  print_separator("=")
376
382
 
377
- contents.append(
378
- types.Content(role="user", parts=[types.Part.from_text(text=question)])
379
- )
383
+ messages.append({"role": "user", "content": question})
380
384
 
381
- answer, contents = run_agent_loop(
385
+ answer, messages = run_agent_loop(
382
386
  env=env,
383
387
  client=client,
384
388
  model_name=model_name,
385
389
  tools=tools,
386
- system_instruction=system_instruction,
387
- contents=contents,
390
+ messages=messages,
388
391
  max_iterations=max_iterations,
389
392
  verbose=verbose,
390
393
  stream=stream,
@@ -403,8 +406,6 @@ def main() -> None:
403
406
  """Main entry point for the chat CLI."""
404
407
  _check_dependencies()
405
408
 
406
- # load_dotenv already called at module level if available
407
-
408
409
  parser = argparse.ArgumentParser(
409
410
  description="Interactive chat CLI for the CRM environment",
410
411
  formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -413,27 +414,36 @@ Examples:
413
414
  python chat.py
414
415
  python chat.py --stream
415
416
  python chat.py --question "How many companies are there?"
416
- python chat.py --model gemini-2.0-flash --stream
417
+ python chat.py --model anthropic/claude-sonnet-4 --stream
418
+
419
+ # Using OpenAI directly:
420
+ python chat.py --llm-base-url https://api.openai.com/v1 --model gpt-4o
417
421
  """,
418
422
  )
419
423
 
420
424
  parser.add_argument(
421
- "--base-url",
425
+ "--crm-base-url",
422
426
  type=str,
423
427
  default="https://env.crm.construct-labs.com",
424
428
  help="Base URL for the CRM environment (default: https://env.crm.construct-labs.com)",
425
429
  )
430
+ parser.add_argument(
431
+ "--llm-base-url",
432
+ type=str,
433
+ default="https://openrouter.ai/api/v1",
434
+ help="Base URL for OpenAI-compatible API (default: OpenRouter)",
435
+ )
426
436
  parser.add_argument(
427
437
  "--model",
428
438
  type=str,
429
- default="gemini-2.0-flash",
430
- help="Gemini model to use (default: gemini-2.0-flash)",
439
+ default="anthropic/claude-sonnet-4.5",
440
+ help="Model to use (default: anthropic/claude-sonnet-4.5)",
431
441
  )
432
442
  parser.add_argument(
433
443
  "--api-key",
434
444
  type=str,
435
445
  default=None,
436
- help="Google API key (default: uses GOOGLE_API_KEY env var)",
446
+ help="API key (default: uses OPENAI_API_KEY env var)",
437
447
  )
438
448
  parser.add_argument(
439
449
  "--question",
@@ -460,23 +470,21 @@ Examples:
460
470
 
461
471
  args = parser.parse_args()
462
472
 
463
- api_key = args.api_key or os.getenv("GOOGLE_API_KEY")
473
+ api_key = args.api_key or os.getenv("OPENAI_API_KEY")
464
474
  if not api_key:
465
- print_colored("Error: No Google API key provided.", "red")
466
- print_colored("Set GOOGLE_API_KEY in .env file or use --api-key", "dim")
475
+ print_colored("Error: No API key provided.", "red")
476
+ print_colored("Set OPENAI_API_KEY in .env file or use --api-key", "dim")
467
477
  sys.exit(1)
468
478
 
469
- client = genai.Client(api_key=api_key)
479
+ client = OpenAI(api_key=api_key, base_url=args.llm_base_url)
470
480
 
471
- print_colored(f"Connecting to CRM environment at {args.base_url}...", "dim")
481
+ print_colored(f"Connecting to CRM environment at {args.crm_base_url}...", "dim")
472
482
  try:
473
- env = CrmAgentEnv(base_url=args.base_url)
483
+ env = CrmAgentEnv(base_url=args.crm_base_url)
474
484
  except Exception as e:
475
485
  print_colored(f"Error connecting to environment: {e}", "red")
476
486
  sys.exit(1)
477
487
 
478
- gemini_tools = convert_openai_tools_to_gemini(env.tools)
479
-
480
488
  system_prompt = """You are a helpful assistant with access to CRM tools.
481
489
 
482
490
  Use the provided tools to help the user with their requests. You can make multiple tool calls to complete a task.
@@ -489,8 +497,8 @@ When you have gathered enough information or completed the task, call submit_ans
489
497
  env=env,
490
498
  client=client,
491
499
  model_name=args.model,
492
- tools=gemini_tools,
493
- system_instruction=system_prompt,
500
+ tools=env.tools,
501
+ system_prompt=system_prompt,
494
502
  initial_question=args.question,
495
503
  max_iterations=args.max_iterations,
496
504
  verbose=args.verbose,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: construct-labs-crm-env
3
- Version: 0.1.9
3
+ Version: 0.1.10
4
4
  Summary: CRM Agent Environment SDK by Construct Labs - Train RL agents to interact with CRM systems
5
5
  Project-URL: Homepage, https://construct-labs.com
6
6
  Author-email: Construct Labs GmbH <hello@construct-labs.com>
@@ -23,7 +23,7 @@ Requires-Dist: openenv-core>=0.2.0
23
23
  Requires-Dist: pydantic>=2.0.0
24
24
  Requires-Dist: websockets>=12.0
25
25
  Provides-Extra: chat
26
- Requires-Dist: google-genai>=1.0.0; extra == 'chat'
26
+ Requires-Dist: openai>=1.0.0; extra == 'chat'
27
27
  Requires-Dist: python-dotenv>=1.0.0; extra == 'chat'
28
28
  Provides-Extra: dev
29
29
  Requires-Dist: mypy>=1.0.0; extra == 'dev'
@@ -52,8 +52,8 @@ uv add construct-labs-crm-env
52
52
  Run the interactive chat agent without installing:
53
53
 
54
54
  ```bash
55
- # Google API key (https://ai.google.dev/gemini-api/docs/api-key)
56
- export GOOGLE_API_KEY=<api_key>
55
+ # OpenRouter API key (https://openrouter.ai/keys)
56
+ export OPENAI_API_KEY=<api_key>
57
57
  # Construct Labs CRM API Key (provided by Construct Labs)
58
58
  export CRM_AGENT_API_KEY=<api_key>
59
59
 
@@ -5,9 +5,9 @@ construct_labs_crm_env/protocol.py,sha256=h_7-0XaV9gBNHiSXoW4aITSD9K21R1Swc5yZR6
5
5
  construct_labs_crm_env/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
6
  construct_labs_crm_env/tools.py,sha256=X0nrbamk2mjRvdDupLR_E_b3NUh90dYtU4bA6ydZf34,32225
7
7
  construct_labs_crm_env/examples/__init__.py,sha256=cvlN7EhBvf73Df4NhAjAQZ_MVBwPjQEVq90TzfO1q9A,50
8
- construct_labs_crm_env/examples/chat.py,sha256=NjaSK2-Bn0ccMDQZLlLO1nPB08H9ed8jFY7XBbYxqms,15178
9
- construct_labs_crm_env-0.1.9.dist-info/METADATA,sha256=49EQHAoSh0h-e_ScyO6jCwJ4mfvf1La7uOmTThzYn28,12407
10
- construct_labs_crm_env-0.1.9.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
11
- construct_labs_crm_env-0.1.9.dist-info/entry_points.txt,sha256=XwAyFOckOED5VzLohiiWTvI01QPfvcUt8n9g0_dlrFM,73
12
- construct_labs_crm_env-0.1.9.dist-info/licenses/LICENSE,sha256=zPT_KqeG9QTE0zTfKGheMHluFJB1fn_bNgNehnnpXGM,2210
13
- construct_labs_crm_env-0.1.9.dist-info/RECORD,,
8
+ construct_labs_crm_env/examples/chat.py,sha256=k6R17Lf3C5kYSmh5vnUlV1JzYQ35VTljOud2Rokg-5Y,15543
9
+ construct_labs_crm_env-0.1.10.dist-info/METADATA,sha256=GT9lcVNEhWDW88RV10-9ZmBDUerHhUg8rDouygrAUVM,12387
10
+ construct_labs_crm_env-0.1.10.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
11
+ construct_labs_crm_env-0.1.10.dist-info/entry_points.txt,sha256=XwAyFOckOED5VzLohiiWTvI01QPfvcUt8n9g0_dlrFM,73
12
+ construct_labs_crm_env-0.1.10.dist-info/licenses/LICENSE,sha256=zPT_KqeG9QTE0zTfKGheMHluFJB1fn_bNgNehnnpXGM,2210
13
+ construct_labs_crm_env-0.1.10.dist-info/RECORD,,