timbal 2.0.2__tar.gz → 2.0.4__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.
- {timbal-2.0.2 → timbal-2.0.4}/.gitignore +1 -0
- {timbal-2.0.2 → timbal-2.0.4}/PKG-INFO +1 -1
- {timbal-2.0.2 → timbal-2.0.4}/pyproject.toml +1 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/__init__.py +4 -2
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/_version.py +2 -2
- timbal-2.0.4/python/timbal/collectors/impl/timbal.py +84 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/core/__init__.py +5 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/core/agent.py +121 -20
- timbal-2.0.4/python/timbal/core/fallback_model.py +133 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/core/llm_router.py +53 -4
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/core/runnable.py +401 -13
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/core/workflow.py +36 -8
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/errors.py +29 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/platform/utils.py +14 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/context.py +53 -8
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/tracing/exporters/otel.py +100 -28
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/tracing/providers/base.py +16 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/tracing/providers/jsonl.py +64 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/tracing/providers/sqlite.py +72 -11
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/__init__.py +1 -0
- timbal-2.0.4/python/timbal/types/approval.py +71 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/events/__init__.py +2 -1
- timbal-2.0.4/python/timbal/types/events/approval.py +30 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/file.py +1 -5
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/utils/model.py +7 -1
- timbal-2.0.2/python/timbal/collectors/impl/timbal.py +0 -50
- {timbal-2.0.2 → timbal-2.0.4}/LICENSE +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/README.md +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/docs/api-reference/ace/chat-completions.mdx +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/docs/api-reference/ace/create-from-workforce.mdx +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/docs/api-reference/ace/jobs/cancel.mdx +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/docs/api-reference/ace/jobs/get.mdx +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/docs/api-reference/ace/jobs/list.mdx +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/docs/api-reference/ace/jobs/retry.mdx +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/docs/api-reference/ace/link.mdx +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/docs/api-reference/ace/list-context-vars.mdx +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/docs/api-reference/ace/list-policies.mdx +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/docs/api-reference/ace/messages.mdx +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/docs/api-reference/ace/responses.mdx +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/docs/api-reference/ace/unlink.mdx +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/pyrightconfig.json +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/__init__.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/__main__.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/cst_utils.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/flow.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/format.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/model_discovery.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/test.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/tool_discovery.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/transformers/__init__.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/transformers/add_edge.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/transformers/add_step.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/transformers/add_tool.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/transformers/convert_to_workflow.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/transformers/remove_edge.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/transformers/remove_step.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/transformers/remove_tool.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/transformers/set_config.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/transformers/set_param.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/codegen/transformers/set_position.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/collectors/__init__.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/collectors/base.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/collectors/impl/__init__.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/collectors/impl/anthropic.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/collectors/impl/default.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/collectors/impl/message.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/collectors/impl/openai.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/collectors/impl/string.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/collectors/registry.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/core/mcp.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/core/memory_compaction.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/core/models.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/core/skill.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/core/test_model.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/core/tool.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/core/tool_set.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/__init__.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/cli.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/display.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/fixtures/agent.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/fixtures/eval_examples.yaml +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/fixtures/eval_flow_validators.yaml +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/fixtures/eval_negations.yaml +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/fixtures/eval_parallel.yaml +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/fixtures/eval_subagent.yaml +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/fixtures/eval_transforms.yaml +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/fixtures/test_invalid_validator.yaml +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/models.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/runner.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/utils.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/__init__.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/base.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/comparison_base.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/contains.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/contains_all.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/contains_any.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/context.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/email.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/ends_with.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/eq.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/gt.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/gte.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/json.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/language.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/length.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/llm_base.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/lt.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/lte.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/max_length.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/min_length.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/not_null.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/parallel.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/pattern.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/prompt.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/semantic.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/seq.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/starts_with.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/evals/validators/type.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/logs.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/models.yaml +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/platform/__init__.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/platform/integrations.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/platform/knowledge_bases/__init__.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/platform/knowledge_bases/query.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/platform/types.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/server/__init__.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/server/__main__.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/server/http.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/server/jobs.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/server/voice.html +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/server/voice.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/__init__.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/config.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/config_loader.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/dependency_analyzer.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/tracing/__init__.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/tracing/exporters/__init__.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/tracing/providers/__init__.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/tracing/providers/in_memory.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/tracing/providers/platform.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/tracing/span.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/state/tracing/trace.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/__init__.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/asana.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/bash.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/cala.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/cloudflare.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/dynamics_business_central.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/dynamics_sales.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/edit.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/elasticsearch.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/elevenlabs.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/excel.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/fal.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/firecrawl.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/gemini_images.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/gmail.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/google_calendar.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/google_docs.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/google_drive.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/google_maps.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/google_sheets.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/hubspot.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/knowledge_base.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/lancedb.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/linkedin.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/mongodb.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/netsuite.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/onedrive.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/outlook.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/pinecone.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/powerbi.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/read.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/replicate.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/salesforce.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/scraperapi.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/shopify.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/slack.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/stripe.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/tavily.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/web_search.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/write.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/xai.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/zendesk.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/tools/zoho_crm.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/content/__init__.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/content/base.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/content/custom.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/content/file.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/content/text.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/content/thinking.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/content/tool_result.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/content/tool_use.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/events/base.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/events/delta.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/events/output.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/events/start.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/message.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/types/run_status.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/utils/__init__.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/utils/import_spec.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/utils/net.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/utils/schema.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/utils/serialization.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/voice/__init__.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/voice/elevenlabs.py +0 -0
- {timbal-2.0.2 → timbal-2.0.4}/python/timbal/voice/session.py +0 -0
|
@@ -5,17 +5,19 @@ from __future__ import annotations
|
|
|
5
5
|
from typing import TYPE_CHECKING
|
|
6
6
|
|
|
7
7
|
if TYPE_CHECKING:
|
|
8
|
-
from .core import Agent, Tool, Workflow
|
|
8
|
+
from .core import Agent, FallbackModel, ModelEntry, Tool, Workflow
|
|
9
9
|
|
|
10
10
|
try:
|
|
11
11
|
from ._version import __version__ # type: ignore
|
|
12
12
|
except ImportError:
|
|
13
13
|
__version__ = "0.0.0.dev0"
|
|
14
14
|
|
|
15
|
-
__all__ = ["Agent", "Tool", "Workflow"]
|
|
15
|
+
__all__ = ["Agent", "FallbackModel", "ModelEntry", "Tool", "Workflow"]
|
|
16
16
|
|
|
17
17
|
_LAZY_IMPORTS = {
|
|
18
18
|
"Agent": ".core",
|
|
19
|
+
"FallbackModel": ".core",
|
|
20
|
+
"ModelEntry": ".core",
|
|
19
21
|
"Tool": ".core",
|
|
20
22
|
"Workflow": ".core",
|
|
21
23
|
}
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '2.0.
|
|
22
|
-
__version_tuple__ = version_tuple = (2, 0,
|
|
21
|
+
__version__ = version = '2.0.4'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 0, 4)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
# `override` was introduced in Python 3.12; use `typing_extensions` for compatibility with older versions
|
|
4
|
+
try:
|
|
5
|
+
from typing import override
|
|
6
|
+
except ImportError:
|
|
7
|
+
from typing_extensions import override
|
|
8
|
+
|
|
9
|
+
import structlog
|
|
10
|
+
|
|
11
|
+
from ...types.events.approval import ApprovalEvent as TimbalApprovalEvent
|
|
12
|
+
from ...types.events.base import BaseEvent as TimbalBaseEvent
|
|
13
|
+
from ...types.events.delta import DeltaEvent as TimbalDeltaEvent
|
|
14
|
+
from ...types.events.output import OutputEvent as TimbalOutputEvent
|
|
15
|
+
from ...types.events.start import StartEvent as TimbalStartEvent
|
|
16
|
+
from .. import register_collector
|
|
17
|
+
from ..base import BaseCollector
|
|
18
|
+
|
|
19
|
+
logger = structlog.get_logger("timbal.collectors.impl.timbal")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@register_collector
|
|
23
|
+
class TimbalCollector(BaseCollector):
|
|
24
|
+
"""Collector for Timbal events."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, **kwargs: Any):
|
|
27
|
+
super().__init__(**kwargs)
|
|
28
|
+
self._output_event: TimbalOutputEvent | None = None
|
|
29
|
+
# Capture every approval gate that fires during the stream so callers
|
|
30
|
+
# of .collect() can react to all pending approvals — not just the
|
|
31
|
+
# first one — when concurrent runnables (parallel workflow steps,
|
|
32
|
+
# multiplexed tools) gate on the same iteration.
|
|
33
|
+
self._pending_approvals: list[dict[str, Any]] = []
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
@override
|
|
37
|
+
def can_handle(cls, event: Any) -> bool:
|
|
38
|
+
return isinstance(event, TimbalBaseEvent)
|
|
39
|
+
|
|
40
|
+
@override
|
|
41
|
+
def process(self, event: TimbalBaseEvent) -> TimbalBaseEvent | None:
|
|
42
|
+
"""Processes Timbal events."""
|
|
43
|
+
if isinstance(event, TimbalStartEvent):
|
|
44
|
+
return event
|
|
45
|
+
elif isinstance(event, TimbalDeltaEvent):
|
|
46
|
+
return event
|
|
47
|
+
elif isinstance(event, TimbalApprovalEvent):
|
|
48
|
+
self._pending_approvals.append({
|
|
49
|
+
"approval_id": event.approval_id,
|
|
50
|
+
"runnable_path": event.runnable_path,
|
|
51
|
+
"runnable_name": event.runnable_name,
|
|
52
|
+
"runnable_type": event.runnable_type,
|
|
53
|
+
"input": event.input,
|
|
54
|
+
"prompt": event.prompt,
|
|
55
|
+
"description": event.description,
|
|
56
|
+
"metadata": event.metadata,
|
|
57
|
+
"t0": event.t0,
|
|
58
|
+
"call_id": event.call_id,
|
|
59
|
+
"parent_call_id": event.parent_call_id,
|
|
60
|
+
})
|
|
61
|
+
return event
|
|
62
|
+
elif isinstance(event, TimbalOutputEvent):
|
|
63
|
+
self._output_event = event
|
|
64
|
+
return event
|
|
65
|
+
elif isinstance(event, TimbalBaseEvent):
|
|
66
|
+
return event
|
|
67
|
+
else:
|
|
68
|
+
logger.warning("Unknown Timbal event type", event_type=type(event), event=event)
|
|
69
|
+
|
|
70
|
+
@override
|
|
71
|
+
def result(self) -> Any:
|
|
72
|
+
"""Returns the final OutputEvent enriched with pending_approvals.
|
|
73
|
+
|
|
74
|
+
When concurrent runnables gate, the OutputEvent only references the
|
|
75
|
+
*first* pending approval through ``status``/``output``. We attach the
|
|
76
|
+
full list under ``metadata['pending_approvals']`` so consumers driving
|
|
77
|
+
the resume loop can see every gate from one ``.collect()`` call.
|
|
78
|
+
"""
|
|
79
|
+
if self._output_event is not None and self._pending_approvals:
|
|
80
|
+
self._output_event.metadata = {
|
|
81
|
+
**(self._output_event.metadata or {}),
|
|
82
|
+
"pending_approvals": list(self._pending_approvals),
|
|
83
|
+
}
|
|
84
|
+
return self._output_event
|
|
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING
|
|
|
6
6
|
|
|
7
7
|
if TYPE_CHECKING:
|
|
8
8
|
from .agent import Agent
|
|
9
|
+
from .fallback_model import FallbackModel, ModelEntry
|
|
9
10
|
from .mcp import MCPServer
|
|
10
11
|
from .memory_compaction import MemoryCompactor # noqa: F401 - type alias
|
|
11
12
|
from .skill import Skill
|
|
@@ -16,7 +17,9 @@ if TYPE_CHECKING:
|
|
|
16
17
|
|
|
17
18
|
__all__ = [
|
|
18
19
|
"Agent",
|
|
20
|
+
"FallbackModel",
|
|
19
21
|
"MCPServer",
|
|
22
|
+
"ModelEntry",
|
|
20
23
|
"Skill",
|
|
21
24
|
"TestModel",
|
|
22
25
|
"Tool",
|
|
@@ -26,7 +29,9 @@ __all__ = [
|
|
|
26
29
|
|
|
27
30
|
_LAZY_IMPORTS = {
|
|
28
31
|
"Agent": ".agent",
|
|
32
|
+
"FallbackModel": ".fallback_model",
|
|
29
33
|
"MCPServer": ".mcp",
|
|
34
|
+
"ModelEntry": ".fallback_model",
|
|
30
35
|
"Skill": ".skill",
|
|
31
36
|
"TestModel": ".test_model",
|
|
32
37
|
"Tool": ".tool",
|
|
@@ -26,7 +26,7 @@ from pydantic import (
|
|
|
26
26
|
)
|
|
27
27
|
from uuid_extensions import uuid7
|
|
28
28
|
|
|
29
|
-
from ..errors import InterruptError, bail
|
|
29
|
+
from ..errors import ApprovalRequired, InterruptError, bail
|
|
30
30
|
from ..state import get_run_context
|
|
31
31
|
from ..types.content import CustomContent, FileContent, TextContent, ToolResultContent, ToolUseContent
|
|
32
32
|
from ..types.events import BaseEvent, OutputEvent
|
|
@@ -195,12 +195,14 @@ class Agent(Runnable):
|
|
|
195
195
|
|
|
196
196
|
# Build default params for the internal LLM tool from individual fields
|
|
197
197
|
_llm_default_params = {
|
|
198
|
-
k: v
|
|
198
|
+
k: v
|
|
199
|
+
for k, v in [
|
|
199
200
|
("max_tokens", self.max_tokens),
|
|
200
201
|
("temperature", self.temperature),
|
|
201
202
|
("base_url", self.base_url),
|
|
202
203
|
("api_key", self.api_key),
|
|
203
|
-
]
|
|
204
|
+
]
|
|
205
|
+
if v is not None
|
|
204
206
|
}
|
|
205
207
|
if self.model_params:
|
|
206
208
|
_llm_default_params["provider_params"] = self.model_params
|
|
@@ -338,6 +340,37 @@ If the file is relevant for the user query, USE the `read_skill` tool to get its
|
|
|
338
340
|
|
|
339
341
|
return system_prompt
|
|
340
342
|
|
|
343
|
+
def _find_pending_tool_uses(self, memory: list[Message]) -> list[ToolUseContent]:
|
|
344
|
+
"""Return any tool_uses in the most recent assistant message that
|
|
345
|
+
still have no matching tool_result anywhere later in memory.
|
|
346
|
+
|
|
347
|
+
Used on approval-resume: the previous turn left tool_uses unresolved
|
|
348
|
+
because the user hadn't approved yet. Now that we have a decision we
|
|
349
|
+
re-execute those gated tool_uses directly without re-calling the LLM
|
|
350
|
+
(which would fail because most providers reject a request whose last
|
|
351
|
+
assistant message has unresolved tool_uses).
|
|
352
|
+
"""
|
|
353
|
+
if not memory:
|
|
354
|
+
return []
|
|
355
|
+
for i in range(len(memory) - 1, -1, -1):
|
|
356
|
+
msg = memory[i]
|
|
357
|
+
if msg.role != "assistant":
|
|
358
|
+
continue
|
|
359
|
+
tool_uses = [
|
|
360
|
+
c for c in msg.content
|
|
361
|
+
if isinstance(c, ToolUseContent) and not c.is_server_tool_use
|
|
362
|
+
]
|
|
363
|
+
if not tool_uses:
|
|
364
|
+
# Most recent assistant message has no tool_uses to resume.
|
|
365
|
+
return []
|
|
366
|
+
fulfilled: set[str] = set()
|
|
367
|
+
for later in memory[i + 1:]:
|
|
368
|
+
for c in later.content:
|
|
369
|
+
if isinstance(c, ToolResultContent):
|
|
370
|
+
fulfilled.add(c.id)
|
|
371
|
+
return [tu for tu in tool_uses if tu.id not in fulfilled]
|
|
372
|
+
return []
|
|
373
|
+
|
|
341
374
|
def _synthesize_missing_tool_results(self, memory: list[Message]) -> None:
|
|
342
375
|
"""Append synthetic error results for any tool_use blocks that were interrupted.
|
|
343
376
|
|
|
@@ -435,13 +468,28 @@ If the file is relevant for the user query, USE the `read_skill` tool to get its
|
|
|
435
468
|
return
|
|
436
469
|
|
|
437
470
|
previous_span = self_spans[0]
|
|
438
|
-
|
|
471
|
+
prev_status = previous_span.status
|
|
472
|
+
if isinstance(prev_status, dict):
|
|
473
|
+
prev_code = prev_status.get("code")
|
|
474
|
+
prev_reason = prev_status.get("reason")
|
|
475
|
+
elif prev_status is not None:
|
|
476
|
+
prev_code = prev_status.code
|
|
477
|
+
prev_reason = prev_status.reason
|
|
478
|
+
else:
|
|
479
|
+
prev_code = None
|
|
480
|
+
prev_reason = None
|
|
439
481
|
if not isinstance(previous_span.memory, list):
|
|
440
482
|
return
|
|
441
483
|
memory = [Message.validate(m) for m in previous_span.memory]
|
|
442
484
|
|
|
443
|
-
#
|
|
444
|
-
|
|
485
|
+
# On approval-required resume the gated tool_uses will be re-executed
|
|
486
|
+
# by the agent loop (see _find_pending_tool_uses), so we must NOT
|
|
487
|
+
# inject synthetic "tool failed" results for them. Without this guard
|
|
488
|
+
# the LLM would see fake failures and probably skip retrying the
|
|
489
|
+
# gated calls, silently dropping the user's approval decisions.
|
|
490
|
+
is_approval_resume = prev_code == "cancelled" and prev_reason == "approval_required"
|
|
491
|
+
if not is_approval_resume:
|
|
492
|
+
self._synthesize_missing_tool_results(memory)
|
|
445
493
|
current_span.memory = memory + current_span.memory
|
|
446
494
|
|
|
447
495
|
# Cache the already-serialized previous memory so handler() can skip re-dumping.
|
|
@@ -472,9 +520,7 @@ If the file is relevant for the user query, USE the `read_skill` tool to get its
|
|
|
472
520
|
)
|
|
473
521
|
should_compact = True
|
|
474
522
|
elif previous_span.usage:
|
|
475
|
-
prev_input_tokens = sum(
|
|
476
|
-
v for k, v in previous_span.usage.items() if ":input" in k and "token" in k
|
|
477
|
-
)
|
|
523
|
+
prev_input_tokens = sum(v for k, v in previous_span.usage.items() if ":input" in k and "token" in k)
|
|
478
524
|
prev_output_tokens = sum(
|
|
479
525
|
v for k, v in previous_span.usage.items() if ":output" in k and "token" in k
|
|
480
526
|
)
|
|
@@ -495,9 +541,7 @@ If the file is relevant for the user query, USE the `read_skill` tool to get its
|
|
|
495
541
|
|
|
496
542
|
if should_compact:
|
|
497
543
|
compactors = (
|
|
498
|
-
[self.memory_compaction]
|
|
499
|
-
if not isinstance(self.memory_compaction, list)
|
|
500
|
-
else self.memory_compaction
|
|
544
|
+
[self.memory_compaction] if not isinstance(self.memory_compaction, list) else self.memory_compaction
|
|
501
545
|
)
|
|
502
546
|
compaction_steps = []
|
|
503
547
|
for compactor in compactors:
|
|
@@ -509,11 +553,13 @@ If the file is relevant for the user query, USE the `read_skill` tool to get its
|
|
|
509
553
|
current_span.memory = await compactor(current_span.memory)
|
|
510
554
|
else:
|
|
511
555
|
current_span.memory = compactor(current_span.memory)
|
|
512
|
-
compaction_steps.append(
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
556
|
+
compaction_steps.append(
|
|
557
|
+
{
|
|
558
|
+
"compactor": getattr(compactor, "__name__", repr(compactor)),
|
|
559
|
+
"before": before,
|
|
560
|
+
"after": len(current_span.memory),
|
|
561
|
+
}
|
|
562
|
+
)
|
|
517
563
|
current_span.metadata["compaction"] = {
|
|
518
564
|
"triggered": True,
|
|
519
565
|
"utilization": round(utilization, 4) if utilization is not None else None,
|
|
@@ -537,6 +583,9 @@ If the file is relevant for the user query, USE the `read_skill` tool to get its
|
|
|
537
583
|
tools_names.add(tool.name)
|
|
538
584
|
if tool.command:
|
|
539
585
|
commands[tool.command] = tool
|
|
586
|
+
stripped = tool.command.strip("/")
|
|
587
|
+
if stripped:
|
|
588
|
+
commands[stripped] = tool
|
|
540
589
|
|
|
541
590
|
for t in self.tools:
|
|
542
591
|
if isinstance(t, ToolSet):
|
|
@@ -632,7 +681,7 @@ If the file is relevant for the user query, USE the `read_skill` tool to get its
|
|
|
632
681
|
# Reuse the already-serialized previous messages; only dump new ones
|
|
633
682
|
# (prompt + any synthetic tool results added by _synthesize_missing_tool_results).
|
|
634
683
|
# If compaction ran it rewrites memory, invalidating the cached dump.
|
|
635
|
-
new_messages = current_span.memory[len(prev_dump):]
|
|
684
|
+
new_messages = current_span.memory[len(prev_dump) :]
|
|
636
685
|
current_span._memory_dump = prev_dump + await dump(new_messages)
|
|
637
686
|
else:
|
|
638
687
|
current_span._memory_dump = await dump(current_span.memory)
|
|
@@ -649,10 +698,15 @@ If the file is relevant for the user query, USE the `read_skill` tool to get its
|
|
|
649
698
|
"""Helper to process tool output events and create tool results."""
|
|
650
699
|
if not isinstance(event, OutputEvent) or event.path.count(".") != self._path.count(".") + 1:
|
|
651
700
|
return
|
|
701
|
+
if event.status.code == "cancelled" and event.status.reason == "approval_required":
|
|
702
|
+
return
|
|
652
703
|
if event.status.code == "cancelled" and event.status.reason == "early_exit":
|
|
653
704
|
bail(event.status.message)
|
|
654
705
|
content = None
|
|
655
|
-
if event.status.code == "cancelled" and event.status.reason == "
|
|
706
|
+
if event.status.code == "cancelled" and event.status.reason == "approval_denied":
|
|
707
|
+
msg = event.status.message or "The tool call was denied."
|
|
708
|
+
content = f"[Approval denied] {msg}"
|
|
709
|
+
elif event.status.code == "cancelled" and event.status.reason == "early_exit_local":
|
|
656
710
|
msg = event.status.message or "The tool exited early."
|
|
657
711
|
content = f"[Cancelled] {msg}"
|
|
658
712
|
elif event.error is not None:
|
|
@@ -713,7 +767,12 @@ If the file is relevant for the user query, USE the `read_skill` tool to get its
|
|
|
713
767
|
{
|
|
714
768
|
"role": "assistant",
|
|
715
769
|
"content": [
|
|
716
|
-
{
|
|
770
|
+
{
|
|
771
|
+
"type": "tool_use",
|
|
772
|
+
"id": tool_use_id,
|
|
773
|
+
"name": tool.name,
|
|
774
|
+
"input": tool_input,
|
|
775
|
+
}
|
|
717
776
|
],
|
|
718
777
|
}
|
|
719
778
|
)
|
|
@@ -721,6 +780,12 @@ If the file is relevant for the user query, USE the `read_skill` tool to get its
|
|
|
721
780
|
async for event in tool(**tool_input):
|
|
722
781
|
await _process_tool_event(event, tool_use_id, append_to_messages=False)
|
|
723
782
|
if isinstance(event, OutputEvent) and event.output is not None:
|
|
783
|
+
if (
|
|
784
|
+
event.status.code == "cancelled"
|
|
785
|
+
and event.status.reason == "approval_required"
|
|
786
|
+
):
|
|
787
|
+
yield event
|
|
788
|
+
raise ApprovalRequired(event)
|
|
724
789
|
current_span.memory.append(
|
|
725
790
|
Message.validate(
|
|
726
791
|
{
|
|
@@ -732,6 +797,32 @@ If the file is relevant for the user query, USE the `read_skill` tool to get its
|
|
|
732
797
|
yield event
|
|
733
798
|
return
|
|
734
799
|
|
|
800
|
+
# Resume path: if the trailing assistant message has tool_uses
|
|
801
|
+
# that were left unresolved by an earlier approval gate, run
|
|
802
|
+
# them directly. Skipping the LLM call here is important —
|
|
803
|
+
# most providers reject a request whose conversation ends in
|
|
804
|
+
# an assistant message whose tool_uses have no matching
|
|
805
|
+
# tool_results.
|
|
806
|
+
pending_tool_uses = self._find_pending_tool_uses(current_span.memory)
|
|
807
|
+
if pending_tool_uses:
|
|
808
|
+
_llm_memory_saved = True # nothing to salvage; we never called the LLM
|
|
809
|
+
tool_calls = pending_tool_uses
|
|
810
|
+
first_pending_approval: OutputEvent | None = None
|
|
811
|
+
async for tool_call, event in self._multiplex_tools(tools, tool_calls):
|
|
812
|
+
await _process_tool_event(event, tool_call.id, append_to_messages=True)
|
|
813
|
+
yield event
|
|
814
|
+
if (
|
|
815
|
+
isinstance(event, OutputEvent)
|
|
816
|
+
and event.status.code == "cancelled"
|
|
817
|
+
and event.status.reason == "approval_required"
|
|
818
|
+
and first_pending_approval is None
|
|
819
|
+
):
|
|
820
|
+
first_pending_approval = event
|
|
821
|
+
if first_pending_approval is not None:
|
|
822
|
+
raise ApprovalRequired(first_pending_approval)
|
|
823
|
+
i += 1
|
|
824
|
+
continue
|
|
825
|
+
|
|
735
826
|
async for event in self._llm(
|
|
736
827
|
model=model,
|
|
737
828
|
messages=current_span.memory,
|
|
@@ -819,9 +910,19 @@ If the file is relevant for the user query, USE the `read_skill` tool to get its
|
|
|
819
910
|
if not tool_calls:
|
|
820
911
|
break
|
|
821
912
|
|
|
913
|
+
first_pending_approval: OutputEvent | None = None
|
|
822
914
|
async for tool_call, event in self._multiplex_tools(tools, tool_calls):
|
|
823
915
|
await _process_tool_event(event, tool_call.id, append_to_messages=True)
|
|
824
916
|
yield event
|
|
917
|
+
if (
|
|
918
|
+
isinstance(event, OutputEvent)
|
|
919
|
+
and event.status.code == "cancelled"
|
|
920
|
+
and event.status.reason == "approval_required"
|
|
921
|
+
and first_pending_approval is None
|
|
922
|
+
):
|
|
923
|
+
first_pending_approval = event
|
|
924
|
+
if first_pending_approval is not None:
|
|
925
|
+
raise ApprovalRequired(first_pending_approval)
|
|
825
926
|
i += 1
|
|
826
927
|
finally:
|
|
827
928
|
if not _llm_memory_saved:
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncGenerator, Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import structlog
|
|
8
|
+
from anthropic import APIConnectionError as AnthropicAPIConnectionError
|
|
9
|
+
from anthropic import APIStatusError as AnthropicAPIStatusError
|
|
10
|
+
from anthropic import APITimeoutError as AnthropicAPITimeoutError
|
|
11
|
+
from anthropic import RateLimitError as AnthropicRateLimitError
|
|
12
|
+
from openai import APIConnectionError as OpenAIAPIConnectionError
|
|
13
|
+
from openai import APIStatusError as OpenAIAPIStatusError
|
|
14
|
+
from openai import APITimeoutError as OpenAIAPITimeoutError
|
|
15
|
+
from openai import RateLimitError as OpenAIRateLimitError
|
|
16
|
+
|
|
17
|
+
from ..errors import FallbackExhausted
|
|
18
|
+
|
|
19
|
+
logger = structlog.get_logger("timbal.core.fallback_model")
|
|
20
|
+
|
|
21
|
+
_RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class ModelEntry:
|
|
26
|
+
"""One model in a fallback chain."""
|
|
27
|
+
|
|
28
|
+
model: str
|
|
29
|
+
max_retries: int = 2
|
|
30
|
+
retry_delay: float = 1.0
|
|
31
|
+
api_key: str | None = None
|
|
32
|
+
base_url: str | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class FallbackModel:
|
|
36
|
+
"""Ordered fallback chain for LLM providers.
|
|
37
|
+
|
|
38
|
+
The first model is tried first. If it fails with a retryable provider error
|
|
39
|
+
after its per-model retries are exhausted, the next entry is attempted.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
__timbal_fallback_model__ = True
|
|
43
|
+
provider = "fallback"
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
*models: str | ModelEntry,
|
|
48
|
+
fallback_on: type[BaseException]
|
|
49
|
+
| tuple[type[BaseException], ...]
|
|
50
|
+
| list[type[BaseException]]
|
|
51
|
+
| Callable[[BaseException], bool]
|
|
52
|
+
| None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
if not models:
|
|
55
|
+
raise ValueError("FallbackModel requires at least one model.")
|
|
56
|
+
|
|
57
|
+
self.entries = tuple(entry if isinstance(entry, ModelEntry) else ModelEntry(entry) for entry in models)
|
|
58
|
+
self.fallback_on = fallback_on
|
|
59
|
+
self.model_name = " -> ".join(entry.model for entry in self.entries)
|
|
60
|
+
|
|
61
|
+
def __str__(self) -> str:
|
|
62
|
+
return self.entries[0].model
|
|
63
|
+
|
|
64
|
+
async def route(
|
|
65
|
+
self,
|
|
66
|
+
router: Callable[..., AsyncGenerator[Any, None]],
|
|
67
|
+
**llm_router_kwargs: Any,
|
|
68
|
+
) -> AsyncGenerator[Any, None]:
|
|
69
|
+
errors: list[tuple[str, BaseException]] = []
|
|
70
|
+
|
|
71
|
+
for index, entry in enumerate(self.entries):
|
|
72
|
+
started = False
|
|
73
|
+
kwargs = {
|
|
74
|
+
**llm_router_kwargs,
|
|
75
|
+
"model": entry.model,
|
|
76
|
+
"max_retries": entry.max_retries,
|
|
77
|
+
"retry_delay": entry.retry_delay,
|
|
78
|
+
}
|
|
79
|
+
if entry.api_key is not None:
|
|
80
|
+
kwargs["api_key"] = entry.api_key
|
|
81
|
+
if entry.base_url is not None:
|
|
82
|
+
kwargs["base_url"] = entry.base_url
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
async for chunk in router(**kwargs):
|
|
86
|
+
started = True
|
|
87
|
+
yield chunk
|
|
88
|
+
return
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
if started:
|
|
91
|
+
raise
|
|
92
|
+
if not self._should_fallback(exc):
|
|
93
|
+
raise
|
|
94
|
+
|
|
95
|
+
errors.append((entry.model, exc))
|
|
96
|
+
next_model = self.entries[index + 1].model if index + 1 < len(self.entries) else None
|
|
97
|
+
logger.warning(
|
|
98
|
+
"Falling back to next LLM model",
|
|
99
|
+
failed_model=entry.model,
|
|
100
|
+
next_model=next_model,
|
|
101
|
+
error_type=type(exc).__name__,
|
|
102
|
+
error=str(exc),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
raise FallbackExhausted(errors)
|
|
106
|
+
|
|
107
|
+
def _should_fallback(self, exc: BaseException) -> bool:
|
|
108
|
+
if self.fallback_on is None:
|
|
109
|
+
return is_retryable_provider_error(exc)
|
|
110
|
+
if isinstance(self.fallback_on, type) and issubclass(self.fallback_on, BaseException):
|
|
111
|
+
return isinstance(exc, self.fallback_on)
|
|
112
|
+
if isinstance(self.fallback_on, (tuple, list)):
|
|
113
|
+
return isinstance(exc, self.fallback_on)
|
|
114
|
+
return bool(self.fallback_on(exc))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def is_retryable_provider_error(exc: BaseException) -> bool:
|
|
118
|
+
if isinstance(exc, (OpenAIRateLimitError, AnthropicRateLimitError)):
|
|
119
|
+
return True
|
|
120
|
+
if isinstance(exc, (OpenAIAPITimeoutError, AnthropicAPITimeoutError)):
|
|
121
|
+
return True
|
|
122
|
+
if isinstance(exc, (OpenAIAPIConnectionError, AnthropicAPIConnectionError)):
|
|
123
|
+
return True
|
|
124
|
+
if isinstance(exc, (OpenAIAPIStatusError, AnthropicAPIStatusError)):
|
|
125
|
+
status_code = getattr(exc, "status_code", None)
|
|
126
|
+
if status_code is None:
|
|
127
|
+
status_code = getattr(getattr(exc, "response", None), "status_code", None)
|
|
128
|
+
return status_code in _RETRYABLE_STATUS_CODES
|
|
129
|
+
if isinstance(exc, StopAsyncIteration):
|
|
130
|
+
return True
|
|
131
|
+
|
|
132
|
+
message = str(exc).lower()
|
|
133
|
+
return "overload" in message or "capacity" in message
|