chatlas 0.2.0__py3-none-any.whl → 0.4.0__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.

Potentially problematic release.


This version of chatlas might be problematic. Click here for more details.

chatlas/_google.py CHANGED
@@ -1,7 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import base64
3
4
  import json
4
- from typing import TYPE_CHECKING, Any, Literal, Optional, overload
5
+ from typing import TYPE_CHECKING, Any, Literal, Optional, cast, overload
5
6
 
6
7
  from pydantic import BaseModel
7
8
 
@@ -16,20 +17,19 @@ from ._content import (
16
17
  ContentToolResult,
17
18
  )
18
19
  from ._logging import log_model_default
20
+ from ._merge import merge_dicts
19
21
  from ._provider import Provider
20
- from ._tools import Tool, basemodel_to_param_schema
21
- from ._turn import Turn, normalize_turns
22
+ from ._tokens import tokens_log
23
+ from ._tools import Tool
24
+ from ._turn import Turn, normalize_turns, user_turn
22
25
 
23
26
  if TYPE_CHECKING:
24
- from google.generativeai.types.content_types import (
25
- ContentDict,
26
- FunctionDeclaration,
27
- PartType,
28
- )
29
- from google.generativeai.types.generation_types import (
30
- AsyncGenerateContentResponse,
27
+ from google.genai.types import Content as GoogleContent
28
+ from google.genai.types import (
31
29
  GenerateContentResponse,
32
- GenerationConfig,
30
+ GenerateContentResponseDict,
31
+ Part,
32
+ PartDict,
33
33
  )
34
34
 
35
35
  from .types.google import ChatClientArgs, SubmitInputArgs
@@ -61,8 +61,8 @@ def ChatGoogle(
61
61
  ::: {.callout-note}
62
62
  ## Python requirements
63
63
 
64
- `ChatGoogle` requires the `google-generativeai` package
65
- (e.g., `pip install google-generativeai`).
64
+ `ChatGoogle` requires the `google-genai` package
65
+ (e.g., `pip install google-genai`).
66
66
  :::
67
67
 
68
68
  Examples
@@ -95,17 +95,13 @@ def ChatGoogle(
95
95
  The API key to use for authentication. You generally should not supply
96
96
  this directly, but instead set the `GOOGLE_API_KEY` environment variable.
97
97
  kwargs
98
- Additional arguments to pass to the `genai.GenerativeModel` constructor.
98
+ Additional arguments to pass to the `genai.Client` constructor.
99
99
 
100
100
  Returns
101
101
  -------
102
102
  Chat
103
103
  A Chat object.
104
104
 
105
- Limitations
106
- -----------
107
- `ChatGoogle` currently doesn't work with streaming tools.
108
-
109
105
  Note
110
106
  ----
111
107
  Pasting an API key into a chat constructor (e.g., `ChatGoogle(api_key="...")`)
@@ -144,63 +140,49 @@ def ChatGoogle(
144
140
  """
145
141
 
146
142
  if model is None:
147
- model = log_model_default("gemini-1.5-flash")
148
-
149
- turns = normalize_turns(
150
- turns or [],
151
- system_prompt=system_prompt,
152
- )
143
+ model = log_model_default("gemini-2.0-flash")
153
144
 
154
145
  return Chat(
155
146
  provider=GoogleProvider(
156
- turns=turns,
157
147
  model=model,
158
148
  api_key=api_key,
159
149
  kwargs=kwargs,
160
150
  ),
161
- turns=turns,
151
+ turns=normalize_turns(
152
+ turns or [],
153
+ system_prompt=system_prompt,
154
+ ),
162
155
  )
163
156
 
164
157
 
165
- # The dictionary form of ChatCompletion (TODO: stronger typing)?
166
- GenerateContentDict = dict[str, Any]
167
-
168
-
169
158
  class GoogleProvider(
170
- Provider[GenerateContentResponse, GenerateContentResponse, GenerateContentDict]
159
+ Provider[
160
+ GenerateContentResponse, GenerateContentResponse, "GenerateContentResponseDict"
161
+ ]
171
162
  ):
172
163
  def __init__(
173
164
  self,
174
165
  *,
175
- turns: list[Turn],
176
166
  model: str,
177
167
  api_key: str | None,
178
168
  kwargs: Optional["ChatClientArgs"],
179
169
  ):
180
170
  try:
181
- from google.generativeai import GenerativeModel
171
+ from google import genai
182
172
  except ImportError:
183
173
  raise ImportError(
184
- f"The {self.__class__.__name__} class requires the `google-generativeai` package. "
185
- "Install it with `pip install google-generativeai`."
174
+ f"The {self.__class__.__name__} class requires the `google-genai` package. "
175
+ "Install it with `pip install google-genai`."
186
176
  )
187
177
 
188
- if api_key is not None:
189
- import google.generativeai as genai
190
-
191
- genai.configure(api_key=api_key)
192
-
193
- system_prompt = None
194
- if len(turns) > 0 and turns[0].role == "system":
195
- system_prompt = turns[0].text
178
+ self._model = model
196
179
 
197
180
  kwargs_full: "ChatClientArgs" = {
198
- "model_name": model,
199
- "system_instruction": system_prompt,
181
+ "api_key": api_key,
200
182
  **(kwargs or {}),
201
183
  }
202
184
 
203
- self._client = GenerativeModel(**kwargs_full)
185
+ self._client = genai.Client(**kwargs_full)
204
186
 
205
187
  @overload
206
188
  def chat_perform(
@@ -232,8 +214,11 @@ class GoogleProvider(
232
214
  data_model: Optional[type[BaseModel]] = None,
233
215
  kwargs: Optional["SubmitInputArgs"] = None,
234
216
  ):
235
- kwargs = self._chat_perform_args(stream, turns, tools, data_model, kwargs)
236
- return self._client.generate_content(**kwargs)
217
+ kwargs = self._chat_perform_args(turns, tools, data_model, kwargs)
218
+ if stream:
219
+ return self._client.models.generate_content_stream(**kwargs)
220
+ else:
221
+ return self._client.models.generate_content(**kwargs)
237
222
 
238
223
  @overload
239
224
  async def chat_perform_async(
@@ -265,101 +250,160 @@ class GoogleProvider(
265
250
  data_model: Optional[type[BaseModel]] = None,
266
251
  kwargs: Optional["SubmitInputArgs"] = None,
267
252
  ):
268
- kwargs = self._chat_perform_args(stream, turns, tools, data_model, kwargs)
269
- return await self._client.generate_content_async(**kwargs)
253
+ kwargs = self._chat_perform_args(turns, tools, data_model, kwargs)
254
+ if stream:
255
+ return await self._client.aio.models.generate_content_stream(**kwargs)
256
+ else:
257
+ return await self._client.aio.models.generate_content(**kwargs)
270
258
 
271
259
  def _chat_perform_args(
272
260
  self,
273
- stream: bool,
274
261
  turns: list[Turn],
275
262
  tools: dict[str, Tool],
276
263
  data_model: Optional[type[BaseModel]] = None,
277
264
  kwargs: Optional["SubmitInputArgs"] = None,
278
265
  ) -> "SubmitInputArgs":
266
+ from google.genai.types import FunctionDeclaration, GenerateContentConfig
267
+ from google.genai.types import Tool as GoogleTool
268
+
279
269
  kwargs_full: "SubmitInputArgs" = {
280
- "contents": self._google_contents(turns),
281
- "stream": stream,
282
- "tools": self._gemini_tools(list(tools.values())) if tools else None,
270
+ "model": self._model,
271
+ "contents": cast("GoogleContent", self._google_contents(turns)),
283
272
  **(kwargs or {}),
284
273
  }
285
274
 
286
- if data_model:
287
- config = kwargs_full.get("generation_config", {})
288
- params = basemodel_to_param_schema(data_model)
275
+ config = kwargs_full.get("config")
276
+ if config is None:
277
+ config = GenerateContentConfig()
278
+ if isinstance(config, dict):
279
+ config = GenerateContentConfig.model_construct(**config)
289
280
 
290
- if "additionalProperties" in params:
291
- del params["additionalProperties"]
281
+ if config.system_instruction is None:
282
+ if len(turns) > 0 and turns[0].role == "system":
283
+ config.system_instruction = turns[0].text
292
284
 
293
- mime_type = "application/json"
294
- if isinstance(config, dict):
295
- config["response_schema"] = params
296
- config["response_mime_type"] = mime_type
297
- elif isinstance(config, GenerationConfig):
298
- config.response_schema = params
299
- config.response_mime_type = mime_type
285
+ if data_model:
286
+ config.response_schema = data_model
287
+ config.response_mime_type = "application/json"
288
+
289
+ if tools:
290
+ config.tools = [
291
+ GoogleTool(
292
+ function_declarations=[
293
+ FunctionDeclaration.from_callable(
294
+ client=self._client, callable=tool.func
295
+ )
296
+ for tool in tools.values()
297
+ ]
298
+ )
299
+ ]
300
300
 
301
- kwargs_full["generation_config"] = config
301
+ kwargs_full["config"] = config
302
302
 
303
303
  return kwargs_full
304
304
 
305
305
  def stream_text(self, chunk) -> Optional[str]:
306
- if chunk.parts:
306
+ try:
307
+ # Errors if there is no text (e.g., tool request)
307
308
  return chunk.text
308
- return None
309
+ except Exception:
310
+ return None
309
311
 
310
312
  def stream_merge_chunks(self, completion, chunk):
311
- # The .resolve() in .stream_turn() does the merging for us
312
- return {}
313
-
314
- def stream_turn(
315
- self, completion, has_data_model, stream: GenerateContentResponse
316
- ) -> Turn:
317
- stream.resolve()
318
- return self._as_turn(
319
- stream,
320
- has_data_model,
313
+ chunkd = chunk.model_dump()
314
+ if completion is None:
315
+ return cast("GenerateContentResponseDict", chunkd)
316
+ return cast(
317
+ "GenerateContentResponseDict",
318
+ merge_dicts(completion, chunkd), # type: ignore
321
319
  )
322
320
 
323
- async def stream_turn_async(
324
- self, completion, has_data_model, stream: AsyncGenerateContentResponse
325
- ) -> Turn:
326
- await stream.resolve()
321
+ def stream_turn(self, completion, has_data_model) -> Turn:
327
322
  return self._as_turn(
328
- stream,
323
+ completion,
329
324
  has_data_model,
330
325
  )
331
326
 
332
327
  def value_turn(self, completion, has_data_model) -> Turn:
328
+ completion = cast("GenerateContentResponseDict", completion.model_dump())
333
329
  return self._as_turn(completion, has_data_model)
334
330
 
335
- def _google_contents(self, turns: list[Turn]) -> list["ContentDict"]:
336
- contents: list["ContentDict"] = []
331
+ def token_count(
332
+ self,
333
+ *args: Content | str,
334
+ tools: dict[str, Tool],
335
+ data_model: Optional[type[BaseModel]],
336
+ ):
337
+ kwargs = self._token_count_args(
338
+ *args,
339
+ tools=tools,
340
+ data_model=data_model,
341
+ )
342
+
343
+ res = self._client.models.count_tokens(**kwargs)
344
+ return res.total_tokens or 0
345
+
346
+ async def token_count_async(
347
+ self,
348
+ *args: Content | str,
349
+ tools: dict[str, Tool],
350
+ data_model: Optional[type[BaseModel]],
351
+ ):
352
+ kwargs = self._token_count_args(
353
+ *args,
354
+ tools=tools,
355
+ data_model=data_model,
356
+ )
357
+
358
+ res = await self._client.aio.models.count_tokens(**kwargs)
359
+ return res.total_tokens or 0
360
+
361
+ def _token_count_args(
362
+ self,
363
+ *args: Content | str,
364
+ tools: dict[str, Tool],
365
+ data_model: Optional[type[BaseModel]],
366
+ ) -> dict[str, Any]:
367
+ turn = user_turn(*args)
368
+
369
+ kwargs = self._chat_perform_args(
370
+ turns=[turn],
371
+ tools=tools,
372
+ data_model=data_model,
373
+ )
374
+
375
+ args_to_keep = ["model", "contents", "tools"]
376
+
377
+ return {arg: kwargs[arg] for arg in args_to_keep if arg in kwargs}
378
+
379
+ def _google_contents(self, turns: list[Turn]) -> list["GoogleContent"]:
380
+ from google.genai.types import Content as GoogleContent
381
+
382
+ contents: list["GoogleContent"] = []
337
383
  for turn in turns:
338
384
  if turn.role == "system":
339
385
  continue # System messages are handled separately
340
386
  elif turn.role == "user":
341
387
  parts = [self._as_part_type(c) for c in turn.contents]
342
- contents.append({"role": turn.role, "parts": parts})
388
+ contents.append(GoogleContent(role=turn.role, parts=parts))
343
389
  elif turn.role == "assistant":
344
390
  parts = [self._as_part_type(c) for c in turn.contents]
345
- contents.append({"role": "model", "parts": parts})
391
+ contents.append(GoogleContent(role="model", parts=parts))
346
392
  else:
347
393
  raise ValueError(f"Unknown role {turn.role}")
348
394
  return contents
349
395
 
350
- def _as_part_type(self, content: Content) -> "PartType":
351
- from google.generativeai.types.content_types import protos
396
+ def _as_part_type(self, content: Content) -> "Part":
397
+ from google.genai.types import FunctionCall, FunctionResponse, Part
352
398
 
353
399
  if isinstance(content, ContentText):
354
- return protos.Part(text=content.text)
400
+ return Part.from_text(text=content.text)
355
401
  elif isinstance(content, ContentJson):
356
- return protos.Part(text="<structured data/>")
357
- elif isinstance(content, ContentImageInline):
358
- return protos.Part(
359
- inline_data={
360
- "mime_type": content.content_type,
361
- "data": content.data,
362
- }
402
+ return Part.from_text(text="<structured data/>")
403
+ elif isinstance(content, ContentImageInline) and content.data:
404
+ return Part.from_bytes(
405
+ data=base64.b64decode(content.data),
406
+ mime_type=content.content_type,
363
407
  )
364
408
  elif isinstance(content, ContentImageRemote):
365
409
  raise NotImplementedError(
@@ -367,90 +411,197 @@ class GoogleProvider(
367
411
  "Consider downloading the image and using content_image_file() instead."
368
412
  )
369
413
  elif isinstance(content, ContentToolRequest):
370
- return protos.Part(
371
- function_call={
372
- "name": content.id,
373
- "args": content.arguments,
374
- }
414
+ return Part(
415
+ function_call=FunctionCall(
416
+ id=content.id,
417
+ name=content.name,
418
+ # Goes in a dict, so should come out as a dict
419
+ args=cast(dict[str, Any], content.arguments),
420
+ )
375
421
  )
376
422
  elif isinstance(content, ContentToolResult):
377
- return protos.Part(
378
- function_response={
379
- "name": content.id,
380
- "response": {"value": content.get_final_value()},
381
- }
423
+ if content.error:
424
+ resp = {"error": content.error}
425
+ else:
426
+ resp = {"result": str(content.value)}
427
+ return Part(
428
+ # TODO: seems function response parts might need role='tool'???
429
+ # https://github.com/googleapis/python-genai/blame/c8cfef85c/README.md#L344
430
+ function_response=FunctionResponse(
431
+ id=content.id,
432
+ name=content.name,
433
+ response=resp,
434
+ )
382
435
  )
383
436
  raise ValueError(f"Unknown content type: {type(content)}")
384
437
 
385
438
  def _as_turn(
386
439
  self,
387
- message: "GenerateContentResponse | AsyncGenerateContentResponse",
440
+ message: "GenerateContentResponseDict",
388
441
  has_data_model: bool,
389
442
  ) -> Turn:
390
- contents = []
391
-
392
- msg = message.candidates[0].content
393
-
394
- for part in msg.parts:
395
- if part.text:
443
+ from google.genai.types import FinishReason
444
+
445
+ candidates = message.get("candidates")
446
+ if not candidates:
447
+ return Turn("assistant", "")
448
+
449
+ parts: list["PartDict"] = []
450
+ finish_reason = None
451
+ for candidate in candidates:
452
+ content = candidate.get("content")
453
+ if content:
454
+ parts.extend(content.get("parts") or {})
455
+ finish = candidate.get("finish_reason")
456
+ if finish:
457
+ finish_reason = finish
458
+
459
+ contents: list[Content] = []
460
+ for part in parts:
461
+ text = part.get("text")
462
+ if text:
396
463
  if has_data_model:
397
- contents.append(ContentJson(json.loads(part.text)))
464
+ contents.append(ContentJson(json.loads(text)))
398
465
  else:
399
- contents.append(ContentText(part.text))
400
- if part.function_call:
401
- func = part.function_call
402
- contents.append(
403
- ContentToolRequest(
404
- func.name,
405
- name=func.name,
406
- arguments=dict(func.args),
466
+ contents.append(ContentText(text))
467
+ function_call = part.get("function_call")
468
+ if function_call:
469
+ # Seems name is required but id is optional?
470
+ name = function_call.get("name")
471
+ if name:
472
+ contents.append(
473
+ ContentToolRequest(
474
+ id=function_call.get("id") or name,
475
+ name=name,
476
+ arguments=function_call.get("args"),
477
+ )
407
478
  )
408
- )
409
- if part.function_response:
410
- func = part.function_response
411
- contents.append(
412
- ContentToolResult(
413
- func.name,
414
- value=func.response,
479
+ function_response = part.get("function_response")
480
+ if function_response:
481
+ # Seems name is required but id is optional?
482
+ name = function_response.get("name")
483
+ if name:
484
+ contents.append(
485
+ ContentToolResult(
486
+ id=function_response.get("id") or name,
487
+ value=function_response.get("response"),
488
+ name=name,
489
+ )
415
490
  )
416
- )
417
491
 
418
- usage = message.usage_metadata
419
- tokens = (
420
- usage.prompt_token_count,
421
- usage.candidates_token_count,
422
- )
492
+ usage = message.get("usage_metadata")
493
+ tokens = (0, 0)
494
+ if usage:
495
+ tokens = (
496
+ usage.get("prompt_token_count") or 0,
497
+ usage.get("candidates_token_count") or 0,
498
+ )
423
499
 
424
- finish = message.candidates[0].finish_reason
500
+ tokens_log(self, tokens)
501
+
502
+ if isinstance(finish_reason, FinishReason):
503
+ finish_reason = finish_reason.name
425
504
 
426
505
  return Turn(
427
506
  "assistant",
428
507
  contents,
429
508
  tokens=tokens,
430
- finish_reason=finish.name,
509
+ finish_reason=finish_reason,
431
510
  completion=message,
432
511
  )
433
512
 
434
- def _gemini_tools(self, tools: list[Tool]) -> list["FunctionDeclaration"]:
435
- from google.generativeai.types.content_types import FunctionDeclaration
436
-
437
- res: list["FunctionDeclaration"] = []
438
- for tool in tools:
439
- fn = tool.schema["function"]
440
- params = None
441
- if "parameters" in fn and fn["parameters"]["properties"]:
442
- params = {
443
- "type": "object",
444
- "properties": fn["parameters"]["properties"],
445
- "required": fn["parameters"]["required"],
446
- }
447
-
448
- res.append(
449
- FunctionDeclaration(
450
- name=fn["name"],
451
- description=fn.get("description", ""),
452
- parameters=params,
453
- )
454
- )
455
513
 
456
- return res
514
+ def ChatVertex(
515
+ *,
516
+ model: Optional[str] = None,
517
+ project: Optional[str] = None,
518
+ location: Optional[str] = None,
519
+ api_key: Optional[str] = None,
520
+ system_prompt: Optional[str] = None,
521
+ turns: Optional[list[Turn]] = None,
522
+ kwargs: Optional["ChatClientArgs"] = None,
523
+ ) -> Chat["SubmitInputArgs", GenerateContentResponse]:
524
+ """
525
+ Chat with a Google Vertex AI model.
526
+
527
+ Prerequisites
528
+ -------------
529
+
530
+ ::: {.callout-note}
531
+ ## Python requirements
532
+
533
+ `ChatGoogle` requires the `google-genai` package
534
+ (e.g., `pip install google-genai`).
535
+ :::
536
+
537
+ ::: {.callout-note}
538
+ ## Credentials
539
+
540
+ To use Google's models (i.e., Vertex AI), you'll need to sign up for an account
541
+ with [Vertex AI](https://cloud.google.com/vertex-ai), then specify the appropriate
542
+ model, project, and location.
543
+ :::
544
+
545
+ Parameters
546
+ ----------
547
+ model
548
+ The model to use for the chat. The default, None, will pick a reasonable
549
+ default, and warn you about it. We strongly recommend explicitly choosing
550
+ a model for all but the most casual use.
551
+ project
552
+ The Google Cloud project ID (e.g., "your-project-id"). If not provided, the
553
+ GOOGLE_CLOUD_PROJECT environment variable will be used.
554
+ location
555
+ The Google Cloud location (e.g., "us-central1"). If not provided, the
556
+ GOOGLE_CLOUD_LOCATION environment variable will be used.
557
+ system_prompt
558
+ A system prompt to set the behavior of the assistant.
559
+ turns
560
+ A list of turns to start the chat with (i.e., continuing a previous
561
+ conversation). If not provided, the conversation begins from scratch.
562
+ Do not provide non-`None` values for both `turns` and `system_prompt`.
563
+ Each message in the list should be a dictionary with at least `role`
564
+ (usually `system`, `user`, or `assistant`, but `tool` is also possible).
565
+ Normally there is also a `content` field, which is a string.
566
+
567
+ Returns
568
+ -------
569
+ Chat
570
+ A Chat object.
571
+
572
+ Examples
573
+ --------
574
+
575
+ ```python
576
+ import os
577
+ from chatlas import ChatVertex
578
+
579
+ chat = ChatVertex(
580
+ project="your-project-id",
581
+ location="us-central1",
582
+ )
583
+ chat.chat("What is the capital of France?")
584
+ ```
585
+ """
586
+
587
+ if kwargs is None:
588
+ kwargs = {}
589
+
590
+ kwargs["vertexai"] = True
591
+ kwargs["project"] = project
592
+ kwargs["location"] = location
593
+
594
+ if model is None:
595
+ model = log_model_default("gemini-2.0-flash")
596
+
597
+ return Chat(
598
+ provider=GoogleProvider(
599
+ model=model,
600
+ api_key=api_key,
601
+ kwargs=kwargs,
602
+ ),
603
+ turns=normalize_turns(
604
+ turns or [],
605
+ system_prompt=system_prompt,
606
+ ),
607
+ )
chatlas/_merge.py CHANGED
@@ -1,5 +1,5 @@
1
1
  # Adapted from https://github.com/langchain-ai/langchain/blob/master/libs/core/langchain_core/utils/_merge.py
2
- # Also tweaked to more closely match https://github.com/hadley/elmer/blob/main/R/utils-merge.R
2
+ # Also tweaked to more closely match https://github.com/hadley/ellmer/blob/main/R/utils-merge.R
3
3
 
4
4
  from __future__ import annotations
5
5
 
chatlas/_ollama.py CHANGED
@@ -48,6 +48,13 @@ def ChatOllama(
48
48
  (e.g. `ollama pull llama3.2`).
49
49
  :::
50
50
 
51
+ ::: {.callout-note}
52
+ ## Python requirements
53
+
54
+ `ChatOllama` requires the `openai` package (e.g., `pip install openai`).
55
+ :::
56
+
57
+
51
58
  Examples
52
59
  --------
53
60
 
@@ -103,6 +110,7 @@ def ChatOllama(
103
110
 
104
111
  return ChatOpenAI(
105
112
  system_prompt=system_prompt,
113
+ api_key="ollama", # ignored
106
114
  turns=turns,
107
115
  base_url=f"{base_url}/v1",
108
116
  model=model,