nora-lib-impl 1.0.2__tar.gz → 1.2.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 (31) hide show
  1. {nora_lib_impl-1.0.2 → nora_lib_impl-1.2.0}/PKG-INFO +1 -1
  2. {nora_lib_impl-1.0.2 → nora_lib_impl-1.2.0}/src/nora_lib/impl/context/agent_context.py +8 -1
  3. {nora_lib_impl-1.0.2 → nora_lib_impl-1.2.0}/src/nora_lib/impl/interactions/models.py +215 -15
  4. {nora_lib_impl-1.0.2 → nora_lib_impl-1.2.0}/src/nora_lib_impl.egg-info/PKG-INFO +1 -1
  5. {nora_lib_impl-1.0.2 → nora_lib_impl-1.2.0}/README.md +0 -0
  6. {nora_lib_impl-1.0.2 → nora_lib_impl-1.2.0}/pyproject.toml +0 -0
  7. {nora_lib_impl-1.0.2 → nora_lib_impl-1.2.0}/setup.cfg +0 -0
  8. {nora_lib_impl-1.0.2 → nora_lib_impl-1.2.0}/setup.py +0 -0
  9. {nora_lib_impl-1.0.2 → nora_lib_impl-1.2.0}/src/nora_lib/__init__.py +0 -0
  10. {nora_lib_impl-1.0.2 → nora_lib_impl-1.2.0}/src/nora_lib/impl/__init__.py +0 -0
  11. {nora_lib_impl-1.0.2 → nora_lib_impl-1.2.0}/src/nora_lib/impl/context/__init__.py +0 -0
  12. {nora_lib_impl-1.0.2 → nora_lib_impl-1.2.0}/src/nora_lib/impl/context/context_service.py +0 -0
  13. {nora_lib_impl-1.0.2 → nora_lib_impl-1.2.0}/src/nora_lib/impl/context/models.py +0 -0
  14. {nora_lib_impl-1.0.2 → nora_lib_impl-1.2.0}/src/nora_lib/impl/interactions/__init__.py +0 -0
  15. {nora_lib_impl-1.0.2 → nora_lib_impl-1.2.0}/src/nora_lib/impl/interactions/interactions_service.py +0 -0
  16. {nora_lib_impl-1.0.2 → nora_lib_impl-1.2.0}/src/nora_lib/impl/interactions/step_progress.py +0 -0
  17. {nora_lib_impl-1.0.2 → nora_lib_impl-1.2.0}/src/nora_lib/impl/pubsub.py +0 -0
  18. {nora_lib_impl-1.0.2 → nora_lib_impl-1.2.0}/src/nora_lib/impl/tasks/__init__.py +0 -0
  19. {nora_lib_impl-1.0.2 → nora_lib_impl-1.2.0}/src/nora_lib/impl/tasks/state.py +0 -0
  20. {nora_lib_impl-1.0.2 → nora_lib_impl-1.2.0}/src/nora_lib/progress/__init__.py +0 -0
  21. {nora_lib_impl-1.0.2 → nora_lib_impl-1.2.0}/src/nora_lib/progress/models.py +0 -0
  22. {nora_lib_impl-1.0.2 → nora_lib_impl-1.2.0}/src/nora_lib/progress/reporter.py +0 -0
  23. {nora_lib_impl-1.0.2 → nora_lib_impl-1.2.0}/src/nora_lib/py.typed +0 -0
  24. {nora_lib_impl-1.0.2 → nora_lib_impl-1.2.0}/src/nora_lib/serializers.py +0 -0
  25. {nora_lib_impl-1.0.2 → nora_lib_impl-1.2.0}/src/nora_lib/tasks/__init__.py +0 -0
  26. {nora_lib_impl-1.0.2 → nora_lib_impl-1.2.0}/src/nora_lib/tasks/models.py +0 -0
  27. {nora_lib_impl-1.0.2 → nora_lib_impl-1.2.0}/src/nora_lib/tasks/state.py +0 -0
  28. {nora_lib_impl-1.0.2 → nora_lib_impl-1.2.0}/src/nora_lib_impl.egg-info/SOURCES.txt +0 -0
  29. {nora_lib_impl-1.0.2 → nora_lib_impl-1.2.0}/src/nora_lib_impl.egg-info/dependency_links.txt +0 -0
  30. {nora_lib_impl-1.0.2 → nora_lib_impl-1.2.0}/src/nora_lib_impl.egg-info/requires.txt +0 -0
  31. {nora_lib_impl-1.0.2 → nora_lib_impl-1.2.0}/src/nora_lib_impl.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: nora_lib-impl
3
- Version: 1.0.2
3
+ Version: 1.2.0
4
4
  Summary: For making and coordinating agents and tools
5
5
  Home-page: https://github.com/allenai/nora_lib/impl
6
6
  Requires-Python: >=3.9
@@ -1,17 +1,24 @@
1
1
  from typing import Optional
2
+ from uuid import UUID
2
3
 
3
4
  from nora_lib.impl.interactions.models import Surface
4
5
  from pydantic import BaseModel
5
6
 
7
+ from nora_lib.serializers import UuidWithSerializer
8
+
6
9
 
7
10
  class MessageAgentContext(BaseModel):
8
11
  """
9
- Identifiers for the triggering user message
12
+ Identifiers for the triggering message
10
13
  """
11
14
 
12
15
  message_id: str
13
16
  thread_id: str
14
17
  channel_id: str
18
+ # Pydantic models don't actually need this, but some codebases pass model_dump() objects
19
+ # to other JSON serializers (e.g. FastAPI) which don't handle UUIDs and other python built-ins.
20
+ # So we use this special UUID wrapper for convenience.
21
+ actor_id: UuidWithSerializer
15
22
  surface: Surface
16
23
 
17
24
 
@@ -5,10 +5,18 @@ Model for interactions to be sent to the interactions service.
5
5
  import json
6
6
  from datetime import datetime
7
7
  from enum import Enum
8
- from typing import Dict, List, Optional, Tuple
8
+ from typing import Annotated, Dict, List, Literal, Optional, Tuple, Union
9
9
  from uuid import UUID
10
10
 
11
- from pydantic import BaseModel, Field, field_serializer, ConfigDict, model_validator
11
+ from pydantic import (
12
+ BaseModel,
13
+ ConfigDict,
14
+ Discriminator,
15
+ Field,
16
+ field_serializer,
17
+ field_validator,
18
+ model_validator,
19
+ )
12
20
 
13
21
 
14
22
  class Surface(str, Enum):
@@ -300,25 +308,51 @@ class VirtualThread:
300
308
  class CostDetail(BaseModel):
301
309
  """
302
310
  Base class to store details of cost to service a request by an agent.
303
- If an agent has different cost details,
304
- it should create another class inheriting this class and add those fields.
305
- See LLMCost and LLMTokenBreakdown below for examples.
311
+ If an agent has different cost details (e.g. for non-llm costs), it should:
312
+
313
+ - create another class inheriting this class and add any additional fields
314
+ - give the class a unique detail_type
315
+ - add the class to CostDetailType below
316
+
317
+ See LLMCost below as an example.
318
+
319
+ To add new details about the cost of an LLM call, add to LLMCost below
320
+ rather than creating a new detail. A ServiceCost can have details of
321
+ multiple LLM calls, so if details are split into multiple instances then
322
+ they can't easily be connected.
323
+
324
+ Any new fields added to an existing subclass must have a default value
325
+ specified for backward-compatibility.
306
326
  """
307
327
 
308
- model_config = ConfigDict(protected_namespaces=())
309
- pass
328
+ detail_type: str = "unknown"
310
329
 
330
+ model_config = ConfigDict(protected_namespaces=(), extra="allow")
311
331
 
312
- class LLMCost(CostDetail):
313
- """LLM cost detail"""
332
+ def try_subclass_conversion(self):
333
+ """For events with no detail_type, attempt to convert to an appropriate
334
+ subclass based on the fields. This is useful for handling legacy
335
+ events but should not be needed for new events that have detail_type."""
336
+ # Already a subclass
337
+ if type(self) is not CostDetail:
338
+ return self
314
339
 
315
- token_count: int
316
- model_name: str
340
+ d = self.dict()
341
+ del d["detail_type"]
342
+ if "token_count" in d and "model_name" in d:
343
+ return LLMCost(**d)
344
+ if "prompt_tokens" in d and "completion_tokens" in d:
345
+ return LLMTokenBreakdown(**d)
346
+ if "run_id" in d:
347
+ return LangChainRun(**d)
348
+ return self
317
349
 
318
350
 
319
351
  class LLMTokenBreakdown(CostDetail):
320
352
  """Token usage breakdown"""
321
353
 
354
+ detail_type: Literal["llm_token_breakdown"] = "llm_token_breakdown"
355
+
322
356
  prompt_tokens: int
323
357
  completion_tokens: int
324
358
 
@@ -326,6 +360,8 @@ class LLMTokenBreakdown(CostDetail):
326
360
  class LangChainRun(CostDetail):
327
361
  """LangChain Run"""
328
362
 
363
+ detail_type: Literal["langchain_run"] = "langchain_run"
364
+
329
365
  # Subset of run fields which allow future lookup of run details.
330
366
  run_id: UUID
331
367
  run_name: Optional[str] = None
@@ -336,15 +372,51 @@ class LangChainRun(CostDetail):
336
372
  # Serialize the UUIDs as strings
337
373
  @field_serializer("run_id")
338
374
  def serialize_id(self, run_id: UUID):
339
- return str(run_id)
375
+ return str(run_id) if run_id is not None else None
340
376
 
341
377
  @field_serializer("trace_id")
342
378
  def serialize_trace_id(self, trace_id: UUID):
343
- return str(trace_id)
379
+ return str(trace_id) if trace_id is not None else None
344
380
 
345
381
  @field_serializer("session_id")
346
382
  def serialize_session_id(self, session_id: UUID):
347
- return str(session_id)
383
+ return str(session_id) if session_id is not None else None
384
+
385
+ # Validators to handle legacy "None" strings
386
+ @field_validator("trace_id", "session_id", mode="before")
387
+ @classmethod
388
+ def validate_optional_uuid(cls, value):
389
+ if value == "None":
390
+ return None
391
+ return value
392
+
393
+
394
+ class LLMCost(CostDetail):
395
+ """Details for the cost/usage of an LLM call."""
396
+
397
+ detail_type: Literal["llm_cost"] = "llm_cost"
398
+
399
+ model_name: str
400
+ token_count: int
401
+
402
+ token_breakdown: Optional[LLMTokenBreakdown] = None
403
+
404
+
405
+ # Note: CostDetailType is a Union of all the subclasses of CostDetail, with
406
+ # a discriminator for pydantic deserialization
407
+ CostDetailType = Union[
408
+ Annotated[
409
+ Union[
410
+ LLMCost,
411
+ LLMTokenBreakdown,
412
+ LangChainRun,
413
+ ],
414
+ Discriminator("detail_type"),
415
+ ],
416
+ # We fall back to the base class if the discriminator is not found (legacy
417
+ # events or custom ones written by other apps may be missing detail_type)
418
+ CostDetail,
419
+ ]
348
420
 
349
421
 
350
422
  class ServiceCost(BaseModel):
@@ -363,10 +435,32 @@ class ServiceCost(BaseModel):
363
435
  description="Agent generated task_id used to track nora assigned tasks",
364
436
  )
365
437
  tool_call_id: Optional[str] = None
366
- details: List[CostDetail] = []
438
+ details: list[CostDetailType] = Field(default_factory=list)
367
439
  env: Optional[str] = None
368
440
  git_sha: Optional[str] = None
369
441
 
442
+ def with_unified_llm_costs(self) -> "ServiceCost":
443
+ """
444
+ Creates a new ServiceCost object with unified LLMCost details.
445
+
446
+ This method converts old-style separate LLMCost and LLMTokenBreakdown details
447
+ into unified LLMCost objects that include token breakdown information.
448
+
449
+ If the details are already in the unified format (no separate LLMTokenBreakdown
450
+ objects), the method will return a copy without modifying the details.
451
+
452
+ Returns:
453
+ A new ServiceCost object with unified LLMCost details
454
+
455
+ Raises:
456
+ ValueError: If there is ambiguity in matching LLMCost with LLMTokenBreakdown
457
+ """
458
+ unified_details = unify_llm_cost_details(self.details)
459
+
460
+ data = self.model_dump()
461
+ data["details"] = unified_details
462
+ return ServiceCost.model_validate(data)
463
+
370
464
 
371
465
  class StepCost(BaseModel):
372
466
  """Wrapping service cost with event metadata so that it can be converted to an Event object."""
@@ -402,6 +496,112 @@ class StepCost(BaseModel):
402
496
  )
403
497
 
404
498
 
499
+ def unify_llm_cost_details(details: List[CostDetailType]) -> List[CostDetailType]:
500
+ """
501
+ Convert a list of old-style LLMCost and LLMTokenBreakdown details into a list
502
+ where these pairs are combined into the new unified LLMCost format.
503
+
504
+ This is useful for migrating old ServiceCost events which might have separate
505
+ LLMCost and LLMTokenBreakdown details for the same LLM call.
506
+
507
+ If the function encounters ambiguity (can't confidently pair LLMCost with
508
+ its corresponding LLMTokenBreakdown), it will raise a ValueError.
509
+
510
+ Args:
511
+ details: List of CostDetail objects that might contain old-style
512
+ LLMCost and LLMTokenBreakdown details
513
+
514
+ Returns:
515
+ A new list where matching LLMCost and LLMTokenBreakdown pairs are combined
516
+ into unified LLMCost objects, and other details are left unchanged.
517
+
518
+ Raises:
519
+ ValueError: If there is ambiguity in matching LLMCost with LLMTokenBreakdown
520
+ """
521
+ result: List[CostDetailType] = []
522
+ llm_costs: List[LLMCost] = []
523
+ token_breakdowns: List[LLMTokenBreakdown] = []
524
+ other_details: List[CostDetailType] = []
525
+
526
+ if any(
527
+ isinstance(detail, LLMCost) and detail.token_breakdown is not None
528
+ for detail in details
529
+ ) and any(isinstance(detail, LLMTokenBreakdown) for detail in details):
530
+ raise ValueError(
531
+ "Cannot mix LLMCost with token breakdowns with unified LLMCost details"
532
+ )
533
+
534
+ # Separate details by type
535
+ for detail in details:
536
+ if isinstance(detail, LLMCost):
537
+ llm_costs.append(detail)
538
+ elif isinstance(detail, LLMTokenBreakdown):
539
+ token_breakdowns.append(detail)
540
+ else:
541
+ other_details.append(detail)
542
+
543
+ # If there are no token breakdowns, just return the original details
544
+ if not token_breakdowns:
545
+ return details
546
+
547
+ # If there's a mismatch in count, we can't be confident about pairing
548
+ if len(llm_costs) != len(token_breakdowns):
549
+ raise ValueError(
550
+ f"Cannot confidently pair LLMCost and LLMTokenBreakdown details: "
551
+ f"Found {len(llm_costs)} LLMCost and {len(token_breakdowns)} LLMTokenBreakdown details."
552
+ )
553
+
554
+ # Try to pair costs with breakdowns based on token counts
555
+ matched_breakdowns = set()
556
+
557
+ for cost in llm_costs:
558
+ match_found = False
559
+ matching_breakdown = None
560
+ matched_index = -1
561
+
562
+ # Look for a token breakdown where total tokens matches the cost token count
563
+ for i, breakdown in enumerate(token_breakdowns):
564
+ if i in matched_breakdowns:
565
+ continue
566
+
567
+ total_tokens = breakdown.prompt_tokens + breakdown.completion_tokens
568
+ if total_tokens == cost.token_count:
569
+ if match_found:
570
+ # If we already found a matching breakdown, we have ambiguity
571
+ raise ValueError(
572
+ f"Ambiguity in matching LLMCost with token count {cost.token_count} "
573
+ f"to LLMTokenBreakdown - multiple matches found."
574
+ )
575
+ match_found = True
576
+ matching_breakdown = breakdown
577
+ matched_index = i
578
+
579
+ if match_found and matching_breakdown is not None:
580
+ # Create a new unified LLMCost object
581
+ new_cost = LLMCost(
582
+ model_name=cost.model_name,
583
+ token_count=cost.token_count,
584
+ token_breakdown=matching_breakdown,
585
+ )
586
+ result.append(new_cost)
587
+ matched_breakdowns.add(matched_index)
588
+ else:
589
+ # If no matching breakdown was found based on token count
590
+ raise ValueError(
591
+ f"Could not find matching LLMTokenBreakdown for LLMCost with token count {cost.token_count}"
592
+ )
593
+
594
+ # Check if all breakdowns were matched
595
+ if len(matched_breakdowns) != len(token_breakdowns):
596
+ raise ValueError("Not all LLMTokenBreakdown details were matched to an LLMCost")
597
+
598
+ # Add the other details
599
+ for detail in other_details:
600
+ result.append(detail)
601
+
602
+ return result
603
+
604
+
405
605
  def thread_message_lookup_request(message_id: str, event_type: str) -> dict:
406
606
  """retrieve messages and events for the thread associated with a message"""
407
607
  return {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: nora_lib-impl
3
- Version: 1.0.2
3
+ Version: 1.2.0
4
4
  Summary: For making and coordinating agents and tools
5
5
  Home-page: https://github.com/allenai/nora_lib/impl
6
6
  Requires-Python: >=3.9
File without changes
File without changes
File without changes