gatewaysdk 0.3.2__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.
- gatewaysdk/__init__.py +540 -0
- gatewaysdk/adapter/__init__.py +15 -0
- gatewaysdk/adapter/base.py +94 -0
- gatewaysdk/adapter/messages.py +270 -0
- gatewaysdk/adapter/triplet.py +1026 -0
- gatewaysdk/algorithm/__init__.py +39 -0
- gatewaysdk/algorithm/apo/__init__.py +5 -0
- gatewaysdk/algorithm/apo/apo.py +898 -0
- gatewaysdk/algorithm/apo/prompts/apply_edit_variant01.poml +22 -0
- gatewaysdk/algorithm/apo/prompts/apply_edit_variant02.poml +18 -0
- gatewaysdk/algorithm/apo/prompts/text_gradient_variant01.poml +18 -0
- gatewaysdk/algorithm/apo/prompts/text_gradient_variant02.poml +16 -0
- gatewaysdk/algorithm/apo/prompts/text_gradient_variant03.poml +107 -0
- gatewaysdk/algorithm/base.py +262 -0
- gatewaysdk/algorithm/decorator.py +264 -0
- gatewaysdk/algorithm/evals/__init__.py +7 -0
- gatewaysdk/algorithm/evals/evals.py +217 -0
- gatewaysdk/algorithm/fast.py +250 -0
- gatewaysdk/algorithm/gepa/__init__.py +61 -0
- gatewaysdk/algorithm/gepa/adapter.py +495 -0
- gatewaysdk/algorithm/gepa/gepa.py +570 -0
- gatewaysdk/algorithm/gepa/lib/__init__.py +18 -0
- gatewaysdk/algorithm/gepa/lib/adapters/README.md +12 -0
- gatewaysdk/algorithm/gepa/lib/adapters/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/adapters/anymaths_adapter/README.md +341 -0
- gatewaysdk/algorithm/gepa/lib/adapters/anymaths_adapter/__init__.py +1 -0
- gatewaysdk/algorithm/gepa/lib/adapters/anymaths_adapter/anymaths_adapter.py +174 -0
- gatewaysdk/algorithm/gepa/lib/adapters/anymaths_adapter/requirements.txt +1 -0
- gatewaysdk/algorithm/gepa/lib/adapters/default_adapter/README.md +0 -0
- gatewaysdk/algorithm/gepa/lib/adapters/default_adapter/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/adapters/default_adapter/default_adapter.py +209 -0
- gatewaysdk/algorithm/gepa/lib/adapters/dspy_adapter/README.md +7 -0
- gatewaysdk/algorithm/gepa/lib/adapters/dspy_adapter/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/adapters/dspy_adapter/dspy_adapter.py +307 -0
- gatewaysdk/algorithm/gepa/lib/adapters/dspy_full_program_adapter/README.md +99 -0
- gatewaysdk/algorithm/gepa/lib/adapters/dspy_full_program_adapter/dspy_program_proposal_signature.py +137 -0
- gatewaysdk/algorithm/gepa/lib/adapters/dspy_full_program_adapter/full_program_adapter.py +268 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/GEPA_RAG.md +621 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/__init__.py +56 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/evaluation_metrics.py +226 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/generic_rag_adapter.py +496 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/rag_pipeline.py +238 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_store_interface.py +212 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/__init__.py +2 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/chroma_store.py +196 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/lancedb_store.py +422 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/milvus_store.py +409 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/qdrant_store.py +368 -0
- gatewaysdk/algorithm/gepa/lib/adapters/generic_rag_adapter/vector_stores/weaviate_store.py +418 -0
- gatewaysdk/algorithm/gepa/lib/adapters/mcp_adapter/README.md +552 -0
- gatewaysdk/algorithm/gepa/lib/adapters/mcp_adapter/__init__.py +37 -0
- gatewaysdk/algorithm/gepa/lib/adapters/mcp_adapter/mcp_adapter.py +699 -0
- gatewaysdk/algorithm/gepa/lib/adapters/mcp_adapter/mcp_client.py +364 -0
- gatewaysdk/algorithm/gepa/lib/adapters/terminal_bench_adapter/README.md +9 -0
- gatewaysdk/algorithm/gepa/lib/adapters/terminal_bench_adapter/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/adapters/terminal_bench_adapter/terminal_bench_adapter.py +217 -0
- gatewaysdk/algorithm/gepa/lib/api.py +382 -0
- gatewaysdk/algorithm/gepa/lib/core/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/core/adapter.py +180 -0
- gatewaysdk/algorithm/gepa/lib/core/data_loader.py +74 -0
- gatewaysdk/algorithm/gepa/lib/core/engine.py +379 -0
- gatewaysdk/algorithm/gepa/lib/core/result.py +233 -0
- gatewaysdk/algorithm/gepa/lib/core/state.py +636 -0
- gatewaysdk/algorithm/gepa/lib/examples/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/examples/aime.py +24 -0
- gatewaysdk/algorithm/gepa/lib/examples/anymaths-bench/eval_default.py +111 -0
- gatewaysdk/algorithm/gepa/lib/examples/anymaths-bench/prompt-templates/instruction_prompt.txt +9 -0
- gatewaysdk/algorithm/gepa/lib/examples/anymaths-bench/prompt-templates/optimal_prompt.txt +24 -0
- gatewaysdk/algorithm/gepa/lib/examples/anymaths-bench/train_anymaths.py +177 -0
- gatewaysdk/algorithm/gepa/lib/examples/dspy_full_program_evolution/arc_agi.ipynb +25705 -0
- gatewaysdk/algorithm/gepa/lib/examples/dspy_full_program_evolution/example.ipynb +348 -0
- gatewaysdk/algorithm/gepa/lib/examples/mcp_adapter/__init__.py +4 -0
- gatewaysdk/algorithm/gepa/lib/examples/mcp_adapter/mcp_optimization_example.py +456 -0
- gatewaysdk/algorithm/gepa/lib/examples/rag_adapter/RAG_GUIDE.md +613 -0
- gatewaysdk/algorithm/gepa/lib/examples/rag_adapter/__init__.py +9 -0
- gatewaysdk/algorithm/gepa/lib/examples/rag_adapter/rag_optimization.py +820 -0
- gatewaysdk/algorithm/gepa/lib/examples/rag_adapter/requirements-rag.txt +29 -0
- gatewaysdk/algorithm/gepa/lib/examples/terminal-bench/prompt-templates/instruction_prompt.txt +16 -0
- gatewaysdk/algorithm/gepa/lib/examples/terminal-bench/prompt-templates/terminus.txt +9 -0
- gatewaysdk/algorithm/gepa/lib/examples/terminal-bench/train_terminus.py +161 -0
- gatewaysdk/algorithm/gepa/lib/gepa_utils.py +117 -0
- gatewaysdk/algorithm/gepa/lib/logging/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/logging/experiment_tracker.py +187 -0
- gatewaysdk/algorithm/gepa/lib/logging/logger.py +75 -0
- gatewaysdk/algorithm/gepa/lib/logging/utils.py +103 -0
- gatewaysdk/algorithm/gepa/lib/proposer/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/proposer/base.py +31 -0
- gatewaysdk/algorithm/gepa/lib/proposer/merge.py +357 -0
- gatewaysdk/algorithm/gepa/lib/proposer/reflective_mutation/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/proposer/reflective_mutation/base.py +49 -0
- gatewaysdk/algorithm/gepa/lib/proposer/reflective_mutation/reflective_mutation.py +176 -0
- gatewaysdk/algorithm/gepa/lib/py.typed +0 -0
- gatewaysdk/algorithm/gepa/lib/strategies/__init__.py +0 -0
- gatewaysdk/algorithm/gepa/lib/strategies/batch_sampler.py +77 -0
- gatewaysdk/algorithm/gepa/lib/strategies/candidate_selector.py +50 -0
- gatewaysdk/algorithm/gepa/lib/strategies/component_selector.py +36 -0
- gatewaysdk/algorithm/gepa/lib/strategies/eval_policy.py +64 -0
- gatewaysdk/algorithm/gepa/lib/strategies/instruction_proposal.py +126 -0
- gatewaysdk/algorithm/gepa/lib/utils/__init__.py +10 -0
- gatewaysdk/algorithm/gepa/lib/utils/stop_condition.py +196 -0
- gatewaysdk/algorithm/gepa/tracing.py +105 -0
- gatewaysdk/algorithm/utils.py +177 -0
- gatewaysdk/algorithm/verl/__init__.py +5 -0
- gatewaysdk/algorithm/verl/interface.py +202 -0
- gatewaysdk/automations.py +111 -0
- gatewaysdk/benchmark_hub/README.md +88 -0
- gatewaysdk/benchmark_hub/__init__.py +119 -0
- gatewaysdk/benchmark_hub/_archive.py +189 -0
- gatewaysdk/benchmark_hub/_tar.py +6 -0
- gatewaysdk/benchmark_hub/client.py +992 -0
- gatewaysdk/benchmark_hub/dispatch_shard.py +34 -0
- gatewaysdk/benchmark_hub/eval_config.py +20 -0
- gatewaysdk/benchmark_hub/evals.py +351 -0
- gatewaysdk/benchmark_hub/harbor_adapter.py +673 -0
- gatewaysdk/benchmark_hub/path_utils.py +81 -0
- gatewaysdk/benchmark_hub/save_utils.py +101 -0
- gatewaysdk/benchmark_hub/verifiers_adapter.py +327 -0
- gatewaysdk/benchmark_hub/versioning.py +72 -0
- gatewaysdk/build.py +515 -0
- gatewaysdk/cli/__init__.py +58 -0
- gatewaysdk/cli/agent_runner.py +132 -0
- gatewaysdk/cli/http_client.py +115 -0
- gatewaysdk/cli/platform.py +4086 -0
- gatewaysdk/cli/prometheus.py +115 -0
- gatewaysdk/cli/release_gate.py +215 -0
- gatewaysdk/cli/store.py +131 -0
- gatewaysdk/cli/vllm.py +29 -0
- gatewaysdk/client.py +406 -0
- gatewaysdk/config.py +348 -0
- gatewaysdk/connectors/__init__.py +25 -0
- gatewaysdk/connectors/client.py +203 -0
- gatewaysdk/connectors/skill.py +25 -0
- gatewaysdk/connectors/template.py +106 -0
- gatewaysdk/context.py +606 -0
- gatewaysdk/emitter/__init__.py +43 -0
- gatewaysdk/emitter/annotation.py +370 -0
- gatewaysdk/emitter/exception.py +54 -0
- gatewaysdk/emitter/message.py +61 -0
- gatewaysdk/emitter/object.py +117 -0
- gatewaysdk/emitter/reward.py +320 -0
- gatewaysdk/env_var.py +156 -0
- gatewaysdk/environment/__init__.py +108 -0
- gatewaysdk/environment/_bundle.py +281 -0
- gatewaysdk/environment/_harbor.py +276 -0
- gatewaysdk/environment/_materialize.py +97 -0
- gatewaysdk/environment/_tar.py +33 -0
- gatewaysdk/environment/_world.py +346 -0
- gatewaysdk/environment/core.py +527 -0
- gatewaysdk/environment/errors.py +58 -0
- gatewaysdk/environment/runtime.py +150 -0
- gatewaysdk/environment/schema/__init__.py +35 -0
- gatewaysdk/environment/schema/__main__.py +225 -0
- gatewaysdk/environment/schema/_slack_fidelity.py +116 -0
- gatewaysdk/environment/schema/_slack_scenario.py +183 -0
- gatewaysdk/environment/schema/api.py +1628 -0
- gatewaysdk/environment/schema/batch.py +755 -0
- gatewaysdk/environment/schema/compiler.py +615 -0
- gatewaysdk/environment/schema/conform.py +428 -0
- gatewaysdk/environment/schema/connector.py +777 -0
- gatewaysdk/environment/schema/connectors/apple-business-manager/handlers.py +17 -0
- gatewaysdk/environment/schema/connectors/apple-business-manager/parity.json +28 -0
- gatewaysdk/environment/schema/connectors/apple-business-manager/provenance.json +233 -0
- gatewaysdk/environment/schema/connectors/apple-business-manager/scope.toml +112 -0
- gatewaysdk/environment/schema/connectors/apple-business-manager/world.json +1380 -0
- gatewaysdk/environment/schema/connectors/base.json +28 -0
- gatewaysdk/environment/schema/connectors/custom/handlers.py +18 -0
- gatewaysdk/environment/schema/connectors/custom/world.json +123 -0
- gatewaysdk/environment/schema/connectors/github/handlers.py +81 -0
- gatewaysdk/environment/schema/connectors/github/parity.json +37 -0
- gatewaysdk/environment/schema/connectors/github/provenance.json +134 -0
- gatewaysdk/environment/schema/connectors/github/scope.toml +158 -0
- gatewaysdk/environment/schema/connectors/github/world.json +5869 -0
- gatewaysdk/environment/schema/connectors/google-calendar/handlers.py +23 -0
- gatewaysdk/environment/schema/connectors/google-calendar/world.json +209 -0
- gatewaysdk/environment/schema/connectors/google-drive/handlers.py +18 -0
- gatewaysdk/environment/schema/connectors/google-drive/world.json +286 -0
- gatewaysdk/environment/schema/connectors/jamf/handlers.py +34 -0
- gatewaysdk/environment/schema/connectors/jamf/parity.json +49 -0
- gatewaysdk/environment/schema/connectors/jamf/provenance.json +181 -0
- gatewaysdk/environment/schema/connectors/jamf/scope.toml +128 -0
- gatewaysdk/environment/schema/connectors/jamf/world.json +2218 -0
- gatewaysdk/environment/schema/connectors/jira/handlers.py +28 -0
- gatewaysdk/environment/schema/connectors/jira/world.json +389 -0
- gatewaysdk/environment/schema/connectors/kandji/handlers.py +38 -0
- gatewaysdk/environment/schema/connectors/kandji/parity.json +29 -0
- gatewaysdk/environment/schema/connectors/kandji/provenance.json +152 -0
- gatewaysdk/environment/schema/connectors/kandji/scope.toml +120 -0
- gatewaysdk/environment/schema/connectors/kandji/world.json +765 -0
- gatewaysdk/environment/schema/connectors/linear/handlers.py +20 -0
- gatewaysdk/environment/schema/connectors/linear/world.json +363 -0
- gatewaysdk/environment/schema/connectors/microsoft-teams/fidelity.json +45 -0
- gatewaysdk/environment/schema/connectors/microsoft-teams/handlers.py +1 -0
- gatewaysdk/environment/schema/connectors/microsoft-teams/provenance.json +851 -0
- gatewaysdk/environment/schema/connectors/microsoft-teams/scope.md +231 -0
- gatewaysdk/environment/schema/connectors/microsoft-teams/teams-types.json +1710 -0
- gatewaysdk/environment/schema/connectors/microsoft-teams/world.json +858 -0
- gatewaysdk/environment/schema/connectors/netsuite/handlers.py +32 -0
- gatewaysdk/environment/schema/connectors/netsuite/parity.json +50 -0
- gatewaysdk/environment/schema/connectors/netsuite/provenance.json +137 -0
- gatewaysdk/environment/schema/connectors/netsuite/scope.toml +78 -0
- gatewaysdk/environment/schema/connectors/netsuite/world.json +16243 -0
- gatewaysdk/environment/schema/connectors/salesforce/handlers.py +806 -0
- gatewaysdk/environment/schema/connectors/salesforce/parity.json +46 -0
- gatewaysdk/environment/schema/connectors/salesforce/provenance.json +143 -0
- gatewaysdk/environment/schema/connectors/salesforce/scope.toml +174 -0
- gatewaysdk/environment/schema/connectors/salesforce/world.json +4695 -0
- gatewaysdk/environment/schema/connectors/slack/MODEL.md +236 -0
- gatewaysdk/environment/schema/connectors/slack/capabilities.json +1039 -0
- gatewaysdk/environment/schema/connectors/slack/handlers.py +762 -0
- gatewaysdk/environment/schema/connectors/slack/parity.json +65 -0
- gatewaysdk/environment/schema/connectors/slack/provenance.json +294 -0
- gatewaysdk/environment/schema/connectors/slack/scope.toml +44 -0
- gatewaysdk/environment/schema/connectors/slack/world.json +4993 -0
- gatewaysdk/environment/schema/connectors/workday/handlers.py +75 -0
- gatewaysdk/environment/schema/connectors/workday/parity.json +36 -0
- gatewaysdk/environment/schema/connectors/workday/provenance.json +179 -0
- gatewaysdk/environment/schema/connectors/workday/scope.toml +180 -0
- gatewaysdk/environment/schema/connectors/workday/world.json +806 -0
- gatewaysdk/environment/schema/data.py +593 -0
- gatewaysdk/environment/schema/extract.py +263 -0
- gatewaysdk/environment/schema/host.py +2437 -0
- gatewaysdk/environment/schema/host_surfaces.py +275 -0
- gatewaysdk/environment/schema/platform.py +1272 -0
- gatewaysdk/environment/schema/scaffold.py +250 -0
- gatewaysdk/environment/schema/skill.py +118 -0
- gatewaysdk/environment/schema/skills/agents/connector-template-author.md +202 -0
- gatewaysdk/environment/schema/skills/connector-schema-authoring/CONNECTORS.md +331 -0
- gatewaysdk/environment/schema/skills/connector-schema-authoring/CONVENTIONS.md +100 -0
- gatewaysdk/environment/schema/skills/connector-schema-authoring/SKILL.md +313 -0
- gatewaysdk/environment/schema/skills/world-data-ingestion/ROWS.md +223 -0
- gatewaysdk/environment/schema/skills/world-data-ingestion/SKILL.md +293 -0
- gatewaysdk/environment/schema/skills/worlds-getting-started/SKILL.md +390 -0
- gatewaysdk/environment/schema/slack.py +757 -0
- gatewaysdk/environment/schema/snapshot.py +479 -0
- gatewaysdk/environment/schema/store.py +904 -0
- gatewaysdk/environment/schema/tasks.py +525 -0
- gatewaysdk/environment/schema/tools_world.py +322 -0
- gatewaysdk/environment/schema/validation.py +577 -0
- gatewaysdk/environment/schema/worker.py +78 -0
- gatewaysdk/environment/schema/workers.py +500 -0
- gatewaysdk/environment/schema/world_tests.py +336 -0
- gatewaysdk/environments/__init__.py +24 -0
- gatewaysdk/environments/client.py +486 -0
- gatewaysdk/environments/types.py +318 -0
- gatewaysdk/execution/__init__.py +15 -0
- gatewaysdk/execution/base.py +64 -0
- gatewaysdk/execution/client_server.py +443 -0
- gatewaysdk/execution/events.py +69 -0
- gatewaysdk/execution/inter_process.py +16 -0
- gatewaysdk/execution/shared_memory.py +282 -0
- gatewaysdk/experiments/__init__.py +90 -0
- gatewaysdk/experiments/assignment.py +177 -0
- gatewaysdk/experiments/client.py +877 -0
- gatewaysdk/experiments/exposure.py +222 -0
- gatewaysdk/experiments/types.py +81 -0
- gatewaysdk/importers/__init__.py +49 -0
- gatewaysdk/importers/_normalize.py +91 -0
- gatewaysdk/importers/builder.py +173 -0
- gatewaysdk/importers/client.py +95 -0
- gatewaysdk/importers/langsmith.py +196 -0
- gatewaysdk/importers/recipes.py +249 -0
- gatewaysdk/instrumentation/__init__.py +300 -0
- gatewaysdk/instrumentation/agentops.py +314 -0
- gatewaysdk/instrumentation/agentops_langchain.py +45 -0
- gatewaysdk/instrumentation/base.py +119 -0
- gatewaysdk/instrumentation/litellm.py +83 -0
- gatewaysdk/instrumentation/registry.py +273 -0
- gatewaysdk/instrumentation/vllm.py +81 -0
- gatewaysdk/instrumentation/weave.py +500 -0
- gatewaysdk/integrations/__init__.py +15 -0
- gatewaysdk/integrations/gateway/__init__.py +11 -0
- gatewaysdk/integrations/gateway/client.py +171 -0
- gatewaysdk/integrations/tool_access.py +549 -0
- gatewaysdk/litagent/__init__.py +11 -0
- gatewaysdk/litagent/decorator.py +536 -0
- gatewaysdk/litagent/litagent.py +252 -0
- gatewaysdk/llm_proxy.py +1742 -0
- gatewaysdk/logging.py +370 -0
- gatewaysdk/memory.py +278 -0
- gatewaysdk/personas.py +186 -0
- gatewaysdk/platform/__init__.py +17 -0
- gatewaysdk/platform/builder.py +221 -0
- gatewaysdk/platform/compatibility.py +67 -0
- gatewaysdk/platform/manifest.py +185 -0
- gatewaysdk/platform/orchestrator.py +901 -0
- gatewaysdk/platform/registry.py +122 -0
- gatewaysdk/platform/worker.py +864 -0
- gatewaysdk/replay/__init__.py +1032 -0
- gatewaysdk/replay/pytest.py +56 -0
- gatewaysdk/reward.py +7 -0
- gatewaysdk/run.py +1781 -0
- gatewaysdk/runner/__init__.py +11 -0
- gatewaysdk/runner/agent.py +878 -0
- gatewaysdk/runner/base.py +182 -0
- gatewaysdk/runner/legacy.py +309 -0
- gatewaysdk/security.py +700 -0
- gatewaysdk/semconv.py +170 -0
- gatewaysdk/server.py +399 -0
- gatewaysdk/sessions.py +282 -0
- gatewaysdk/store/__init__.py +45 -0
- gatewaysdk/store/base.py +908 -0
- gatewaysdk/store/client_server.py +2093 -0
- gatewaysdk/store/collection/__init__.py +30 -0
- gatewaysdk/store/collection/base.py +587 -0
- gatewaysdk/store/collection/memory.py +970 -0
- gatewaysdk/store/collection/mongo.py +1412 -0
- gatewaysdk/store/collection_based.py +1823 -0
- gatewaysdk/store/gateway.py +983 -0
- gatewaysdk/store/gateway_listener.py +465 -0
- gatewaysdk/store/listener.py +58 -0
- gatewaysdk/store/memory.py +396 -0
- gatewaysdk/store/mongo.py +165 -0
- gatewaysdk/store/redis_stream.py +517 -0
- gatewaysdk/store/sqlite.py +3 -0
- gatewaysdk/store/threading.py +370 -0
- gatewaysdk/store/utils.py +142 -0
- gatewaysdk/tracer/__init__.py +14 -0
- gatewaysdk/tracer/base.py +286 -0
- gatewaysdk/tracer/dummy.py +106 -0
- gatewaysdk/tracer/otel.py +559 -0
- gatewaysdk/tracing/__init__.py +110 -0
- gatewaysdk/tracing/api.py +808 -0
- gatewaysdk/tracing/attributes.py +9 -0
- gatewaysdk/tracing/context.py +272 -0
- gatewaysdk/tracing/exporters/__init__.py +10 -0
- gatewaysdk/tracing/exporters/gateway.py +228 -0
- gatewaysdk/tracing/identity.py +288 -0
- gatewaysdk/tracing/init.py +620 -0
- gatewaysdk/tracing/instrumentors/__init__.py +15 -0
- gatewaysdk/tracing/instrumentors/claude_agent_sdk.py +766 -0
- gatewaysdk/tracing/instrumentors/instrumentation_principles.md +294 -0
- gatewaysdk/tracing/instrumentors/registry.py +352 -0
- gatewaysdk/tracing/mapping.py +729 -0
- gatewaysdk/tracing/processors.py +393 -0
- gatewaysdk/tracing/push.py +617 -0
- gatewaysdk/tracing/push_models.py +247 -0
- gatewaysdk/tracing/semconv.py +294 -0
- gatewaysdk/tracing/span_builder.py +356 -0
- gatewaysdk/trainer/__init__.py +6 -0
- gatewaysdk/trainer/init_utils.py +263 -0
- gatewaysdk/trainer/legacy.py +359 -0
- gatewaysdk/trainer/registry.py +12 -0
- gatewaysdk/trainer/trainer.py +638 -0
- gatewaysdk/types/__init__.py +63 -0
- gatewaysdk/types/core.py +556 -0
- gatewaysdk/types/resources.py +204 -0
- gatewaysdk/types/tracer.py +515 -0
- gatewaysdk/types/tracing.py +162 -0
- gatewaysdk/users.py +251 -0
- gatewaysdk/utils/__init__.py +1 -0
- gatewaysdk/utils/id.py +18 -0
- gatewaysdk/utils/metrics.py +1025 -0
- gatewaysdk/utils/otel.py +550 -0
- gatewaysdk/utils/otlp.py +556 -0
- gatewaysdk/utils/redact.py +22 -0
- gatewaysdk/utils/server_launcher.py +1045 -0
- gatewaysdk/utils/system_snapshot.py +90 -0
- gatewaysdk/verl/__init__.py +8 -0
- gatewaysdk/verl/__main__.py +6 -0
- gatewaysdk/verl/async_server.py +46 -0
- gatewaysdk/verl/config.yaml +27 -0
- gatewaysdk/verl/daemon.py +1154 -0
- gatewaysdk/verl/dataset.py +44 -0
- gatewaysdk/verl/entrypoint.py +248 -0
- gatewaysdk/verl/trainer.py +549 -0
- gatewaysdk/world_browser.py +748 -0
- gatewaysdk/world_data.py +453 -0
- gatewaysdk/world_sessions.py +978 -0
- gatewaysdk/world_tasks.py +285 -0
- gatewaysdk/world_tools.py +115 -0
- gatewaysdk/worlds.py +639 -0
- gatewaysdk-0.3.2.dist-info/METADATA +236 -0
- gatewaysdk-0.3.2.dist-info/RECORD +376 -0
- gatewaysdk-0.3.2.dist-info/WHEEL +4 -0
- gatewaysdk-0.3.2.dist-info/entry_points.txt +5 -0
- gatewaysdk-0.3.2.dist-info/licenses/LICENSE +19 -0
gatewaysdk/llm_proxy.py
ADDED
|
@@ -0,0 +1,1742 @@
|
|
|
1
|
+
# Copyright (c) Microsoft. All rights reserved.
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import tempfile
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from contextlib import asynccontextmanager
|
|
15
|
+
from contextvars import ContextVar
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from typing import (
|
|
18
|
+
Any,
|
|
19
|
+
AsyncGenerator,
|
|
20
|
+
Awaitable,
|
|
21
|
+
Callable,
|
|
22
|
+
Dict,
|
|
23
|
+
Iterable,
|
|
24
|
+
List,
|
|
25
|
+
Literal,
|
|
26
|
+
Optional,
|
|
27
|
+
Sequence,
|
|
28
|
+
Tuple,
|
|
29
|
+
Type,
|
|
30
|
+
TypedDict,
|
|
31
|
+
Union,
|
|
32
|
+
cast,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
import litellm
|
|
36
|
+
import opentelemetry.trace as trace_api
|
|
37
|
+
import yaml
|
|
38
|
+
from fastapi import Request, Response
|
|
39
|
+
from fastapi.responses import StreamingResponse
|
|
40
|
+
from litellm.integrations.custom_logger import CustomLogger
|
|
41
|
+
from litellm.integrations.opentelemetry import OpenTelemetry, OpenTelemetryConfig
|
|
42
|
+
|
|
43
|
+
# litellm.proxy requires the [proxy] extras which have strict dependency pins
|
|
44
|
+
# (boto3, grpcio, uvicorn) that may conflict with other packages.
|
|
45
|
+
# Install with: pip install 'gatewaysdk[proxy]'
|
|
46
|
+
try:
|
|
47
|
+
from litellm.proxy.proxy_server import app, save_worker_config # pyright: ignore[reportUnknownVariableType]
|
|
48
|
+
except ImportError as e:
|
|
49
|
+
raise ImportError(
|
|
50
|
+
"LLMProxy requires litellm[proxy] extras which are not installed. "
|
|
51
|
+
"The litellm[proxy] extras have strict dependency pins (boto3, grpcio, uvicorn) "
|
|
52
|
+
"that may conflict with other packages in your environment. "
|
|
53
|
+
"To use LLMProxy, install with: pip install 'gatewaysdk[proxy]'"
|
|
54
|
+
) from e
|
|
55
|
+
|
|
56
|
+
from litellm.types.utils import CallTypes
|
|
57
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
58
|
+
from opentelemetry.sdk.resources import Resource
|
|
59
|
+
from opentelemetry.sdk.trace import ReadableSpan, SpanContext
|
|
60
|
+
from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
|
|
61
|
+
from opentelemetry.trace import Link, Status
|
|
62
|
+
from opentelemetry.util.types import Attributes
|
|
63
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
64
|
+
from starlette.types import Scope
|
|
65
|
+
|
|
66
|
+
from gatewaysdk.semconv import LightningResourceAttributes
|
|
67
|
+
from gatewaysdk.types import LLM, ProxyLLM
|
|
68
|
+
from gatewaysdk.utils.server_launcher import (
|
|
69
|
+
LaunchMode,
|
|
70
|
+
PythonServerLauncher,
|
|
71
|
+
PythonServerLauncherArgs,
|
|
72
|
+
noop_context,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
from .store.base import LightningStore
|
|
76
|
+
|
|
77
|
+
logger = logging.getLogger(__name__)
|
|
78
|
+
|
|
79
|
+
__all__ = [
|
|
80
|
+
"LLMProxy",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ModelConfig(TypedDict):
|
|
85
|
+
"""LiteLLM model registration entry.
|
|
86
|
+
|
|
87
|
+
This mirrors the items in LiteLLM's `model_list` section.
|
|
88
|
+
|
|
89
|
+
Attributes:
|
|
90
|
+
model_name: Logical model name exposed by the proxy.
|
|
91
|
+
litellm_params: Parameters passed to LiteLLM for this model
|
|
92
|
+
(e.g., backend model id, api_base, additional options).
|
|
93
|
+
""" # Google style kept concise.
|
|
94
|
+
|
|
95
|
+
model_name: str
|
|
96
|
+
litellm_params: Dict[str, Any]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _get_pre_call_data(args: Any, kwargs: Any) -> Dict[str, Any]:
|
|
100
|
+
"""Extract LiteLLM request payload from hook args.
|
|
101
|
+
|
|
102
|
+
The LiteLLM logger hooks receive `(*args, **kwargs)` whose third positional
|
|
103
|
+
argument or `data=` kwarg contains the request payload.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
args: Positional arguments from the hook.
|
|
107
|
+
kwargs: Keyword arguments from the hook.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
The request payload dict.
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
ValueError: If the payload cannot be located or is not a dict.
|
|
114
|
+
"""
|
|
115
|
+
if kwargs.get("data"):
|
|
116
|
+
data = kwargs["data"]
|
|
117
|
+
elif len(args) >= 3:
|
|
118
|
+
data = args[2]
|
|
119
|
+
else:
|
|
120
|
+
raise ValueError(f"Unable to get request data from args or kwargs: {args}, {kwargs}")
|
|
121
|
+
if not isinstance(data, dict):
|
|
122
|
+
raise ValueError(f"Request data is not a dictionary: {data}")
|
|
123
|
+
return cast(Dict[str, Any], data)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _reset_litellm_logging_worker() -> None:
|
|
127
|
+
"""Reset LiteLLM's global logging worker to the current event loop.
|
|
128
|
+
|
|
129
|
+
LiteLLM keeps a module-level ``GLOBAL_LOGGING_WORKER`` singleton that owns an
|
|
130
|
+
``asyncio.Queue``. The queue is bound to the event loop where it was created.
|
|
131
|
+
When the proxy is restarted, Uvicorn spins up a brand new event loop in a new
|
|
132
|
+
thread. If the existing logging worker (and its queue) are reused, LiteLLM
|
|
133
|
+
raises ``RuntimeError: <Queue ...> is bound to a different event loop`` the
|
|
134
|
+
next time it tries to log. Recreating the worker ensures that LiteLLM will
|
|
135
|
+
lazily initialise a fresh queue on the new loop.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
# ``GLOBAL_LOGGING_WORKER`` is imported in a few LiteLLM modules at runtime.
|
|
139
|
+
# Update any already-imported references so future calls use the fresh worker.
|
|
140
|
+
try:
|
|
141
|
+
import litellm.utils as litellm_utils
|
|
142
|
+
from litellm.litellm_core_utils import logging_worker as litellm_logging_worker
|
|
143
|
+
|
|
144
|
+
litellm_logging_worker.GLOBAL_LOGGING_WORKER = litellm_logging_worker.LoggingWorker()
|
|
145
|
+
litellm_utils.GLOBAL_LOGGING_WORKER = litellm_logging_worker.GLOBAL_LOGGING_WORKER # type: ignore[reportAttributeAccessIssue]
|
|
146
|
+
except Exception: # pragma: no cover - best-effort hygiene
|
|
147
|
+
logger.warning("Unable to propagate LiteLLM logging worker reset.", exc_info=True)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _reset_litellm_logging_callback_manager() -> None:
|
|
151
|
+
"""Reset LiteLLM's global callback manager.
|
|
152
|
+
|
|
153
|
+
To get rid of the warning message: "Cannot add callback - would exceed MAX_CALLBACKS limit of 30."
|
|
154
|
+
when litellm is restarted multiple times in the same process.
|
|
155
|
+
|
|
156
|
+
It does not respect existing input/output callbacks.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
litellm.logging_callback_manager._reset_all_callbacks() # pyright: ignore[reportPrivateUsage]
|
|
161
|
+
except Exception: # pragma: no cover - best-effort hygiene
|
|
162
|
+
logger.warning("Unable to reset LiteLLM logging callback manager.", exc_info=True)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class AddReturnTokenIds(CustomLogger):
|
|
166
|
+
"""LiteLLM logger hook to request token ids from vLLM.
|
|
167
|
+
|
|
168
|
+
This mutates the outgoing request payload to include `return_token_ids=True`
|
|
169
|
+
for backends that support token id return (e.g., vLLM).
|
|
170
|
+
|
|
171
|
+
See also:
|
|
172
|
+
[vLLM PR #22587](https://github.com/vllm-project/vllm/pull/22587)
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
async def async_pre_call_hook(self, *args: Any, **kwargs: Any) -> Optional[Union[Exception, str, Dict[str, Any]]]:
|
|
176
|
+
"""Async pre-call hook to adjust request payload.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
args: Positional args from LiteLLM.
|
|
180
|
+
kwargs: Keyword args from LiteLLM.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Either an updated payload dict or an Exception to short-circuit.
|
|
184
|
+
"""
|
|
185
|
+
try:
|
|
186
|
+
data = _get_pre_call_data(args, kwargs)
|
|
187
|
+
except Exception as e:
|
|
188
|
+
return e
|
|
189
|
+
|
|
190
|
+
# Ensure token ids are requested from the backend when supported.
|
|
191
|
+
return {**data, "return_token_ids": True}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class AddLogprobs(CustomLogger):
|
|
195
|
+
"""LiteLLM logger hook to request logprobs from vLLM.
|
|
196
|
+
|
|
197
|
+
This mutates the outgoing request payload to include `logprobs=1`
|
|
198
|
+
for backends that support logprobs return (e.g., vLLM).
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
async def async_pre_call_hook(self, *args: Any, **kwargs: Any) -> Optional[Union[Exception, str, Dict[str, Any]]]:
|
|
202
|
+
"""Async pre-call hook to adjust request payload."""
|
|
203
|
+
try:
|
|
204
|
+
data = _get_pre_call_data(args, kwargs)
|
|
205
|
+
except Exception as e:
|
|
206
|
+
return e
|
|
207
|
+
|
|
208
|
+
# Ensure logprobs are requested from the backend when supported.
|
|
209
|
+
return {**data, "logprobs": 1}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class LightningSpanExporter(SpanExporter):
|
|
213
|
+
"""Buffered OTEL span exporter with subtree flushing and training-store sink.
|
|
214
|
+
|
|
215
|
+
Design:
|
|
216
|
+
|
|
217
|
+
* Spans are buffered until a root span's entire subtree is available.
|
|
218
|
+
* A private event loop on a daemon thread runs async flush logic.
|
|
219
|
+
* Rollout/attempt/sequence metadata is reconstructed by merging headers
|
|
220
|
+
from any span within a subtree.
|
|
221
|
+
|
|
222
|
+
Thread-safety:
|
|
223
|
+
|
|
224
|
+
* Buffer access is protected by a re-entrant lock.
|
|
225
|
+
* Export is synchronous to the caller yet schedules an async flush on the
|
|
226
|
+
internal loop, then waits for completion.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
def __init__(
|
|
230
|
+
self,
|
|
231
|
+
_store: Optional[LightningStore] = None,
|
|
232
|
+
otlp_endpoint: Optional[str] = None,
|
|
233
|
+
otlp_headers: Optional[Dict[str, str]] = None,
|
|
234
|
+
):
|
|
235
|
+
self._store: Optional[LightningStore] = _store # this is only for testing purposes
|
|
236
|
+
self._otlp_endpoint: Optional[str] = otlp_endpoint # Direct OTLP export endpoint
|
|
237
|
+
self._buffer: List[ReadableSpan] = []
|
|
238
|
+
self._lock: Optional[threading.Lock] = None
|
|
239
|
+
self._loop_lock_pid: Optional[int] = None
|
|
240
|
+
|
|
241
|
+
# Single dedicated event loop running in a daemon thread.
|
|
242
|
+
# This decouples OTEL SDK threads from our async store I/O.
|
|
243
|
+
# Deferred creation until first use.
|
|
244
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
245
|
+
self._loop_thread: Optional[threading.Thread] = None
|
|
246
|
+
|
|
247
|
+
# Initialize OTLP exporter with custom endpoint and headers if provided
|
|
248
|
+
if otlp_endpoint:
|
|
249
|
+
self._otlp_exporter = OTLPSpanExporter(endpoint=otlp_endpoint, headers=otlp_headers or {})
|
|
250
|
+
else:
|
|
251
|
+
self._otlp_exporter = OTLPSpanExporter()
|
|
252
|
+
|
|
253
|
+
def _ensure_loop(self) -> asyncio.AbstractEventLoop:
|
|
254
|
+
"""Lazily initialize the event loop and thread on first use.
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
asyncio.AbstractEventLoop: The initialized event loop.
|
|
258
|
+
"""
|
|
259
|
+
self._clear_loop_and_lock()
|
|
260
|
+
if self._loop is None:
|
|
261
|
+
self._loop = asyncio.new_event_loop()
|
|
262
|
+
self._loop_thread = threading.Thread(target=self._run_loop, name="LightningSpanExporterLoop", daemon=True)
|
|
263
|
+
self._loop_thread.start()
|
|
264
|
+
return self._loop
|
|
265
|
+
|
|
266
|
+
def _ensure_lock(self) -> threading.Lock:
|
|
267
|
+
"""Lazily initialize the lock on first use.
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
threading.Lock: The initialized lock.
|
|
271
|
+
"""
|
|
272
|
+
self._clear_loop_and_lock()
|
|
273
|
+
if self._lock is None:
|
|
274
|
+
self._lock = threading.Lock()
|
|
275
|
+
return self._lock
|
|
276
|
+
|
|
277
|
+
def _clear_loop_and_lock(self) -> None:
|
|
278
|
+
"""Clear the loop and lock.
|
|
279
|
+
This happens if the exporter was used in a process then used in another process.
|
|
280
|
+
|
|
281
|
+
This should only happen in CI.
|
|
282
|
+
"""
|
|
283
|
+
if os.getpid() != self._loop_lock_pid:
|
|
284
|
+
logger.warning("Loop and lock are not owned by the current process. Clearing them.")
|
|
285
|
+
self._loop = None
|
|
286
|
+
self._loop_thread = None
|
|
287
|
+
self._lock = None
|
|
288
|
+
self._loop_lock_pid = os.getpid()
|
|
289
|
+
elif self._loop_lock_pid is None:
|
|
290
|
+
self._loop_lock_pid = os.getpid()
|
|
291
|
+
|
|
292
|
+
def _run_loop(self) -> None:
|
|
293
|
+
"""Run the private asyncio loop forever on the exporter thread."""
|
|
294
|
+
assert self._loop is not None, "Loop should be initialized before thread starts"
|
|
295
|
+
asyncio.set_event_loop(self._loop)
|
|
296
|
+
self._loop.run_forever()
|
|
297
|
+
|
|
298
|
+
def shutdown(self) -> None:
|
|
299
|
+
"""Shut down the exporter event loop.
|
|
300
|
+
|
|
301
|
+
Safe to call at process exit.
|
|
302
|
+
|
|
303
|
+
"""
|
|
304
|
+
if self._loop is None:
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
|
|
309
|
+
def _stop():
|
|
310
|
+
assert self._loop is not None
|
|
311
|
+
self._loop.stop()
|
|
312
|
+
|
|
313
|
+
self._loop.call_soon_threadsafe(_stop)
|
|
314
|
+
if self._loop_thread is not None:
|
|
315
|
+
self._loop_thread.join(timeout=2.0)
|
|
316
|
+
self._loop.close()
|
|
317
|
+
except Exception:
|
|
318
|
+
logger.exception("Error during exporter shutdown")
|
|
319
|
+
|
|
320
|
+
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
|
|
321
|
+
"""Export spans via buffered subtree flush.
|
|
322
|
+
|
|
323
|
+
Appends spans to the internal buffer, then triggers an async flush on the
|
|
324
|
+
private event loop. Blocks until that flush completes.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
spans: Sequence of spans to export.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
SpanExportResult: SUCCESS on flush success, else FAILURE.
|
|
331
|
+
"""
|
|
332
|
+
# Buffer append under lock to protect against concurrent exporters.
|
|
333
|
+
with self._ensure_lock():
|
|
334
|
+
for span in spans:
|
|
335
|
+
self._buffer.append(span)
|
|
336
|
+
default_endpoint = self._otlp_exporter._endpoint # pyright: ignore[reportPrivateUsage]
|
|
337
|
+
try:
|
|
338
|
+
self._maybe_flush()
|
|
339
|
+
except Exception as e:
|
|
340
|
+
logger.exception("Export flush failed: %s", e)
|
|
341
|
+
return SpanExportResult.FAILURE
|
|
342
|
+
finally:
|
|
343
|
+
self._otlp_exporter._endpoint = default_endpoint # pyright: ignore[reportPrivateUsage]
|
|
344
|
+
|
|
345
|
+
return SpanExportResult.SUCCESS
|
|
346
|
+
|
|
347
|
+
def _get_job_id_from_store(self, store: Any) -> Optional[str]:
|
|
348
|
+
"""Get the job_id from the store's listeners (if any GatewayTracker is attached)."""
|
|
349
|
+
if hasattr(store, "listeners"):
|
|
350
|
+
for listener in store.listeners:
|
|
351
|
+
if hasattr(listener, "job_id"):
|
|
352
|
+
return listener.job_id
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
def _maybe_flush(self):
|
|
356
|
+
"""Flush ready subtrees from the buffer.
|
|
357
|
+
|
|
358
|
+
Strategy:
|
|
359
|
+
We consider a subtree "ready" if we can identify a root span. We
|
|
360
|
+
then take that root and all its descendants out of the buffer and
|
|
361
|
+
try to reconstruct rollout/attempt/sequence headers by merging any
|
|
362
|
+
span's `metadata.requester_custom_headers` within the subtree.
|
|
363
|
+
|
|
364
|
+
Span types:
|
|
365
|
+
- Rollout spans: Have rollout_id/attempt_id/sequence_id headers
|
|
366
|
+
- Job spans: No rollout context, tagged with job_id for experiment tracking
|
|
367
|
+
|
|
368
|
+
Direct OTLP mode:
|
|
369
|
+
When `otlp_endpoint` is configured, spans are exported directly to the
|
|
370
|
+
endpoint without requiring a store or header validation.
|
|
371
|
+
|
|
372
|
+
Raises:
|
|
373
|
+
None directly. Logs and skips malformed spans.
|
|
374
|
+
|
|
375
|
+
"""
|
|
376
|
+
# Iterate over current roots. Each iteration pops a whole subtree.
|
|
377
|
+
for root_span_id in self._get_root_span_ids():
|
|
378
|
+
subtree_spans = self._pop_subtrees(root_span_id)
|
|
379
|
+
if not subtree_spans:
|
|
380
|
+
continue
|
|
381
|
+
|
|
382
|
+
# Merge all custom headers found in the subtree.
|
|
383
|
+
# This must happen BEFORE the direct OTLP check so both paths can apply tags.
|
|
384
|
+
headers_merged: Dict[str, Any] = {}
|
|
385
|
+
|
|
386
|
+
for span in subtree_spans:
|
|
387
|
+
if span.attributes is None:
|
|
388
|
+
continue
|
|
389
|
+
headers_str = span.attributes.get("metadata.requester_custom_headers")
|
|
390
|
+
if headers_str is None:
|
|
391
|
+
continue
|
|
392
|
+
if not isinstance(headers_str, str):
|
|
393
|
+
logger.debug(f"metadata.requester_custom_headers is not a string: {headers_str}")
|
|
394
|
+
continue
|
|
395
|
+
if not headers_str.strip():
|
|
396
|
+
continue
|
|
397
|
+
try:
|
|
398
|
+
# Use literal_eval to parse the stringified dict safely.
|
|
399
|
+
headers = ast.literal_eval(headers_str)
|
|
400
|
+
except Exception as e:
|
|
401
|
+
logger.debug(f"Failed to parse metadata.requester_custom_headers: {e}")
|
|
402
|
+
continue
|
|
403
|
+
if isinstance(headers, dict):
|
|
404
|
+
headers_merged.update(cast(Dict[str, Any], headers))
|
|
405
|
+
|
|
406
|
+
# Extract rollout context if available
|
|
407
|
+
rollout_id = headers_merged.get("x-rollout-id")
|
|
408
|
+
attempt_id = headers_merged.get("x-attempt-id")
|
|
409
|
+
sequence_id = headers_merged.get("x-sequence-id")
|
|
410
|
+
|
|
411
|
+
# Determine if we're using OTLP export (either direct or store-based)
|
|
412
|
+
store = self._store or get_active_llm_proxy().get_store()
|
|
413
|
+
otlp_enabled = bool(self._otlp_endpoint) or (store and store.capabilities.get("otlp_traces", False))
|
|
414
|
+
|
|
415
|
+
# Get run_id from store for trace-to-run linkage
|
|
416
|
+
_run_id = None
|
|
417
|
+
if store and hasattr(store, "_run") and hasattr(store._run, "id"):
|
|
418
|
+
_run_id = store._run.id
|
|
419
|
+
|
|
420
|
+
# session_id is resolved per-subtree below from span attributes
|
|
421
|
+
# (set by TracingContextSpanProcessor) or falls back to run_id.
|
|
422
|
+
|
|
423
|
+
has_rollout_context = (
|
|
424
|
+
rollout_id
|
|
425
|
+
and attempt_id
|
|
426
|
+
and sequence_id
|
|
427
|
+
and isinstance(rollout_id, str)
|
|
428
|
+
and isinstance(attempt_id, str)
|
|
429
|
+
and isinstance(sequence_id, str)
|
|
430
|
+
and sequence_id.isdigit()
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
if has_rollout_context:
|
|
434
|
+
# Rollout-scoped spans: tag with rollout/attempt/sequence
|
|
435
|
+
sequence_id_decimal = int(sequence_id)
|
|
436
|
+
|
|
437
|
+
# Tracing metadata (environment, tags, session_id) is now injected
|
|
438
|
+
# by TracingContextSpanProcessor from ContextVars set by
|
|
439
|
+
# RolloutAttemptMiddleware. We read from span attributes here to
|
|
440
|
+
# also include them as OTLP resource attributes for Gateway ingestion.
|
|
441
|
+
environment = None
|
|
442
|
+
tags_list: List[str] = []
|
|
443
|
+
for span in subtree_spans:
|
|
444
|
+
if span.attributes:
|
|
445
|
+
if not environment:
|
|
446
|
+
environment = span.attributes.get("gateway.environment")
|
|
447
|
+
span_tags = span.attributes.get("gateway.trace.tags")
|
|
448
|
+
if span_tags and isinstance(span_tags, str) and not tags_list:
|
|
449
|
+
try:
|
|
450
|
+
import json as _json
|
|
451
|
+
|
|
452
|
+
tags_list = _json.loads(span_tags)
|
|
453
|
+
except Exception:
|
|
454
|
+
pass
|
|
455
|
+
call_type = span.attributes.get("gateway.call_type")
|
|
456
|
+
if call_type and isinstance(call_type, str) and call_type not in tags_list:
|
|
457
|
+
tags_list.append(call_type)
|
|
458
|
+
if environment and tags_list:
|
|
459
|
+
break
|
|
460
|
+
|
|
461
|
+
if otlp_enabled:
|
|
462
|
+
from gatewaysdk.utils.otlp import SpanWithResource
|
|
463
|
+
|
|
464
|
+
# Build resource attributes for OTLP routing + Langfuse metadata
|
|
465
|
+
resource_attrs: Dict[str, Any] = {
|
|
466
|
+
LightningResourceAttributes.ROLLOUT_ID.value: rollout_id,
|
|
467
|
+
LightningResourceAttributes.ATTEMPT_ID.value: attempt_id,
|
|
468
|
+
LightningResourceAttributes.SPAN_SEQUENCE_ID.value: sequence_id_decimal,
|
|
469
|
+
LightningResourceAttributes.SPAN_TYPE.value: "rollout",
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
# Use rollout_id as session for Gateway trace grouping
|
|
473
|
+
resource_attrs["session.id"] = rollout_id
|
|
474
|
+
|
|
475
|
+
if environment:
|
|
476
|
+
resource_attrs["langfuse.environment"] = environment
|
|
477
|
+
if tags_list:
|
|
478
|
+
resource_attrs["langfuse.trace.tags"] = tags_list
|
|
479
|
+
if _run_id:
|
|
480
|
+
resource_attrs["gateway.run.id"] = _run_id
|
|
481
|
+
|
|
482
|
+
# Use SpanWithResource wrapper — no direct mutation of span._resource
|
|
483
|
+
enriched_resource = Resource.create(resource_attrs)
|
|
484
|
+
spans_to_export: List[ReadableSpan] = [
|
|
485
|
+
SpanWithResource(span, span.resource.merge(enriched_resource)) for span in subtree_spans
|
|
486
|
+
]
|
|
487
|
+
|
|
488
|
+
export_result = self._otlp_exporter.export(spans_to_export)
|
|
489
|
+
if export_result != SpanExportResult.SUCCESS:
|
|
490
|
+
logger.error(f"Failed to export rollout spans via OTLP. Result: {export_result}")
|
|
491
|
+
else:
|
|
492
|
+
# The old way: store does not support OTLP endpoint
|
|
493
|
+
for span in subtree_spans:
|
|
494
|
+
loop = self._ensure_loop()
|
|
495
|
+
add_otel_span_task = store.add_otel_span(
|
|
496
|
+
rollout_id=rollout_id,
|
|
497
|
+
attempt_id=attempt_id,
|
|
498
|
+
sequence_id=sequence_id_decimal,
|
|
499
|
+
readable_span=span,
|
|
500
|
+
)
|
|
501
|
+
fut = asyncio.run_coroutine_threadsafe(add_otel_span_task, loop)
|
|
502
|
+
fut.result()
|
|
503
|
+
|
|
504
|
+
elif otlp_enabled:
|
|
505
|
+
# Job-scoped spans (no rollout context): tag with job_id
|
|
506
|
+
from gatewaysdk.utils.otlp import SpanWithResource
|
|
507
|
+
|
|
508
|
+
job_id = self._get_job_id_from_store(store)
|
|
509
|
+
|
|
510
|
+
# Read tracing metadata from span attributes (set by
|
|
511
|
+
# TracingContextSpanProcessor from ContextVars, or from
|
|
512
|
+
# x-gateway-* headers still present in headers_merged)
|
|
513
|
+
job_environment = None
|
|
514
|
+
job_tags: List[str] = []
|
|
515
|
+
job_session_id = headers_merged.get("x-gateway-session-id")
|
|
516
|
+
|
|
517
|
+
for span in subtree_spans:
|
|
518
|
+
if span.attributes:
|
|
519
|
+
if not job_environment:
|
|
520
|
+
job_environment = span.attributes.get("gateway.environment") or headers_merged.get(
|
|
521
|
+
"x-gateway-environment"
|
|
522
|
+
)
|
|
523
|
+
if not job_session_id:
|
|
524
|
+
span_session = span.attributes.get("gateway.session.id")
|
|
525
|
+
if span_session and isinstance(span_session, str):
|
|
526
|
+
job_session_id = span_session
|
|
527
|
+
if not job_tags:
|
|
528
|
+
span_tags = span.attributes.get("gateway.trace.tags")
|
|
529
|
+
if span_tags and isinstance(span_tags, str):
|
|
530
|
+
try:
|
|
531
|
+
import json as _json
|
|
532
|
+
|
|
533
|
+
job_tags = _json.loads(span_tags)
|
|
534
|
+
except Exception:
|
|
535
|
+
pass
|
|
536
|
+
call_type = span.attributes.get("gateway.call_type")
|
|
537
|
+
if call_type and isinstance(call_type, str) and call_type not in job_tags:
|
|
538
|
+
job_tags.append(call_type)
|
|
539
|
+
if job_environment and job_tags and job_session_id:
|
|
540
|
+
break
|
|
541
|
+
|
|
542
|
+
# Fallback: use run_id as session if no session found in spans
|
|
543
|
+
if not job_session_id and _run_id:
|
|
544
|
+
job_session_id = _run_id
|
|
545
|
+
|
|
546
|
+
# Build resource attributes
|
|
547
|
+
job_resource_attrs: Dict[str, Any] = {}
|
|
548
|
+
if job_id:
|
|
549
|
+
job_resource_attrs[LightningResourceAttributes.JOB_ID.value] = job_id
|
|
550
|
+
job_resource_attrs[LightningResourceAttributes.SPAN_TYPE.value] = "job"
|
|
551
|
+
if job_session_id:
|
|
552
|
+
job_resource_attrs["session.id"] = job_session_id
|
|
553
|
+
if job_environment:
|
|
554
|
+
job_resource_attrs["langfuse.environment"] = job_environment
|
|
555
|
+
if job_tags:
|
|
556
|
+
job_resource_attrs["langfuse.trace.tags"] = job_tags
|
|
557
|
+
if _run_id:
|
|
558
|
+
job_resource_attrs["gateway.run.id"] = _run_id
|
|
559
|
+
|
|
560
|
+
# Use SpanWithResource — no direct mutation
|
|
561
|
+
if job_resource_attrs:
|
|
562
|
+
enriched = Resource.create(job_resource_attrs)
|
|
563
|
+
spans_to_export: List[ReadableSpan] = [
|
|
564
|
+
SpanWithResource(span, span.resource.merge(enriched)) for span in subtree_spans
|
|
565
|
+
]
|
|
566
|
+
else:
|
|
567
|
+
spans_to_export = list(subtree_spans)
|
|
568
|
+
|
|
569
|
+
export_result = self._otlp_exporter.export(spans_to_export)
|
|
570
|
+
if export_result != SpanExportResult.SUCCESS:
|
|
571
|
+
logger.error(f"Failed to export job spans via OTLP. Result: {export_result}")
|
|
572
|
+
else:
|
|
573
|
+
logger.debug(f"Exported {len(spans_to_export)} job-scoped spans (job_id={job_id})")
|
|
574
|
+
else:
|
|
575
|
+
# No OTLP and no rollout context - skip with warning
|
|
576
|
+
logger.debug(f"Skipping {len(subtree_spans)} spans: no rollout context and OTLP not enabled")
|
|
577
|
+
|
|
578
|
+
def _get_root_span_ids(self) -> Iterable[int]:
|
|
579
|
+
"""Yield span_ids for root spans currently in the buffer.
|
|
580
|
+
|
|
581
|
+
A root span is defined as one with `parent is None`.
|
|
582
|
+
|
|
583
|
+
Yields:
|
|
584
|
+
int: Span id for each root span found.
|
|
585
|
+
"""
|
|
586
|
+
for span in self._buffer:
|
|
587
|
+
if span.parent is None:
|
|
588
|
+
span_context = span.get_span_context()
|
|
589
|
+
if span_context is not None:
|
|
590
|
+
yield span_context.span_id
|
|
591
|
+
|
|
592
|
+
def _get_subtrees(self, root_span_id: int) -> Iterable[int]:
|
|
593
|
+
"""Yield span_ids in the subtree rooted at `root_span_id`.
|
|
594
|
+
|
|
595
|
+
Uses an indexed BFS for O(n) traversal instead of recursive O(n^2).
|
|
596
|
+
|
|
597
|
+
Args:
|
|
598
|
+
root_span_id: The span id of the root.
|
|
599
|
+
|
|
600
|
+
Yields:
|
|
601
|
+
int: Span ids including the root and all descendants found.
|
|
602
|
+
"""
|
|
603
|
+
# Build parent -> children index in one pass (O(n))
|
|
604
|
+
children: Dict[int, List[int]] = {}
|
|
605
|
+
for span in self._buffer:
|
|
606
|
+
if span.parent is not None:
|
|
607
|
+
ctx = span.get_span_context()
|
|
608
|
+
if ctx is not None:
|
|
609
|
+
parent_id = span.parent.span_id
|
|
610
|
+
children.setdefault(parent_id, []).append(ctx.span_id)
|
|
611
|
+
# BFS traversal using index (O(n) total)
|
|
612
|
+
stack = [root_span_id]
|
|
613
|
+
while stack:
|
|
614
|
+
current = stack.pop()
|
|
615
|
+
yield current
|
|
616
|
+
stack.extend(children.get(current, []))
|
|
617
|
+
|
|
618
|
+
def _pop_subtrees(self, root_span_id: int) -> List[ReadableSpan]:
|
|
619
|
+
"""Remove and return the subtree for a particular root from the buffer.
|
|
620
|
+
|
|
621
|
+
Args:
|
|
622
|
+
root_span_id: Root span id identifying the subtree.
|
|
623
|
+
|
|
624
|
+
Returns:
|
|
625
|
+
list[ReadableSpan]: Spans that were part of the subtree. Order follows buffer order.
|
|
626
|
+
"""
|
|
627
|
+
subtree_span_ids = set(self._get_subtrees(root_span_id))
|
|
628
|
+
subtree_spans: List[ReadableSpan] = []
|
|
629
|
+
new_buffer: List[ReadableSpan] = []
|
|
630
|
+
for span in self._buffer:
|
|
631
|
+
span_context = span.get_span_context()
|
|
632
|
+
if span_context is not None and span_context.span_id in subtree_span_ids:
|
|
633
|
+
subtree_spans.append(span)
|
|
634
|
+
else:
|
|
635
|
+
new_buffer.append(span)
|
|
636
|
+
# Replace buffer with remaining spans to avoid re-processing.
|
|
637
|
+
self._buffer = new_buffer
|
|
638
|
+
return subtree_spans
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
class LightningOpenTelemetry(OpenTelemetry):
|
|
642
|
+
"""OpenTelemetry integration that exports spans to the Lightning store.
|
|
643
|
+
|
|
644
|
+
Responsibilities:
|
|
645
|
+
|
|
646
|
+
* Ensures each request is annotated with a per-attempt sequence id so spans
|
|
647
|
+
are ordered deterministically even with clock skew across nodes.
|
|
648
|
+
* Uses [`LightningSpanExporter`][gatewaysdk.llm_proxy.LightningSpanExporter] to persist spans for analytics and training.
|
|
649
|
+
* Adds GatewaySDK-specific attributes (session_id, tags, environment) to spans.
|
|
650
|
+
|
|
651
|
+
Args:
|
|
652
|
+
otlp_endpoint: Optional OTLP endpoint URL for direct trace export to external
|
|
653
|
+
collectors (e.g., Langfuse/Gateway). When set, spans are exported directly without
|
|
654
|
+
requiring a store or rollout/attempt headers.
|
|
655
|
+
otlp_headers: Optional dict of HTTP headers for OTLP authentication (e.g., Basic Auth).
|
|
656
|
+
"""
|
|
657
|
+
|
|
658
|
+
def __init__(self, otlp_endpoint: Optional[str] = None, otlp_headers: Optional[Dict[str, str]] = None):
|
|
659
|
+
exporter = LightningSpanExporter(otlp_endpoint=otlp_endpoint, otlp_headers=otlp_headers)
|
|
660
|
+
config = OpenTelemetryConfig(exporter=exporter)
|
|
661
|
+
|
|
662
|
+
# Check for tracer initialization
|
|
663
|
+
if _check_tracer_provider():
|
|
664
|
+
logger.error("Tracer is already initialized. OpenTelemetry may not work as expected.")
|
|
665
|
+
|
|
666
|
+
super().__init__(config=config) # pyright: ignore[reportUnknownMemberType]
|
|
667
|
+
|
|
668
|
+
# Store exporter reference for debugging
|
|
669
|
+
self._custom_exporter = exporter
|
|
670
|
+
|
|
671
|
+
def _init_tracing(self, tracer_provider):
|
|
672
|
+
"""Override to ensure our span processor is added even when reusing existing TracerProvider.
|
|
673
|
+
|
|
674
|
+
LiteLLM's parent _init_tracing reuses existing TracerProviders but doesn't add
|
|
675
|
+
our custom span processor to them. We override to force adding our processor.
|
|
676
|
+
"""
|
|
677
|
+
from opentelemetry import trace as otel_trace_api
|
|
678
|
+
from opentelemetry.sdk.trace import TracerProvider as TracerProviderSDK
|
|
679
|
+
from opentelemetry.trace import SpanKind
|
|
680
|
+
|
|
681
|
+
# Call parent to set up tracer
|
|
682
|
+
super()._init_tracing(tracer_provider) # pyright: ignore[reportUnknownMemberType]
|
|
683
|
+
|
|
684
|
+
# If an existing provider was reused, add our span processor to it
|
|
685
|
+
current_provider = otel_trace_api.get_tracer_provider()
|
|
686
|
+
if isinstance(current_provider, TracerProviderSDK):
|
|
687
|
+
# Check if our processor is already added
|
|
688
|
+
has_our_processor = False
|
|
689
|
+
if hasattr(current_provider, "_active_span_processor"):
|
|
690
|
+
active_processor = current_provider._active_span_processor # pyright: ignore[reportPrivateUsage]
|
|
691
|
+
if hasattr(active_processor, "_span_processors"):
|
|
692
|
+
for proc in active_processor._span_processors: # pyright: ignore[reportPrivateUsage]
|
|
693
|
+
if hasattr(proc, "_exporter") and isinstance(
|
|
694
|
+
proc._exporter, LightningSpanExporter
|
|
695
|
+
): # pyright: ignore[reportPrivateUsage]
|
|
696
|
+
has_our_processor = True
|
|
697
|
+
break
|
|
698
|
+
|
|
699
|
+
if not has_our_processor:
|
|
700
|
+
# Add our span processor
|
|
701
|
+
span_processor = self._get_span_processor()
|
|
702
|
+
current_provider.add_span_processor(span_processor)
|
|
703
|
+
|
|
704
|
+
self.span_kind = SpanKind
|
|
705
|
+
|
|
706
|
+
def _get_span_processor(self, dynamic_headers: Optional[dict] = None):
|
|
707
|
+
"""Override to ensure our custom exporter is used.
|
|
708
|
+
|
|
709
|
+
LiteLLM's parent class checks if OTEL_EXPORTER has export() method and wraps it
|
|
710
|
+
in SimpleSpanProcessor. We override to add logging and ensure this happens.
|
|
711
|
+
"""
|
|
712
|
+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
713
|
+
|
|
714
|
+
# Use our custom exporter directly
|
|
715
|
+
if hasattr(self.OTEL_EXPORTER, "export"):
|
|
716
|
+
processor = SimpleSpanProcessor(self.OTEL_EXPORTER)
|
|
717
|
+
return processor
|
|
718
|
+
|
|
719
|
+
# Fallback to parent implementation
|
|
720
|
+
return super()._get_span_processor(dynamic_headers) # pyright: ignore[reportUnknownMemberType]
|
|
721
|
+
|
|
722
|
+
def set_attributes(self, span: Any, kwargs: Dict[str, Any], response_obj: Optional[Any]) -> None:
|
|
723
|
+
"""Override to store request headers as span attributes for the export pipeline.
|
|
724
|
+
|
|
725
|
+
The proxy's LightningSpanExporter reads metadata.requester_custom_headers
|
|
726
|
+
to extract rollout_id, attempt_id, and sequence_id for OTLP routing.
|
|
727
|
+
|
|
728
|
+
Tracing metadata (environment, tags, session_id, call_type) is now injected
|
|
729
|
+
by TracingContextSpanProcessor from ContextVars set by RolloutAttemptMiddleware,
|
|
730
|
+
not extracted from headers here.
|
|
731
|
+
"""
|
|
732
|
+
# Call parent implementation first
|
|
733
|
+
super().set_attributes(span, kwargs, response_obj) # pyright: ignore[reportUnknownMemberType]
|
|
734
|
+
|
|
735
|
+
# Store request headers on span for the export pipeline.
|
|
736
|
+
# LightningSpanExporter._maybe_flush() reads these to route spans
|
|
737
|
+
# to the correct rollout/attempt via OTLP.
|
|
738
|
+
extra_headers = kwargs.get("extra_headers") or kwargs.get("extraHeaders")
|
|
739
|
+
request_headers = kwargs.get("headers") or {}
|
|
740
|
+
|
|
741
|
+
merged_headers = {}
|
|
742
|
+
if extra_headers and isinstance(extra_headers, dict):
|
|
743
|
+
merged_headers.update(extra_headers)
|
|
744
|
+
if request_headers and isinstance(request_headers, dict):
|
|
745
|
+
for key, value in request_headers.items():
|
|
746
|
+
if isinstance(key, str) and (
|
|
747
|
+
key.lower().startswith("x-rollout-")
|
|
748
|
+
or key.lower().startswith("x-attempt-")
|
|
749
|
+
or key.lower().startswith("x-sequence-")
|
|
750
|
+
or key.lower().startswith("x-gateway-")
|
|
751
|
+
):
|
|
752
|
+
merged_headers[key] = value
|
|
753
|
+
|
|
754
|
+
if merged_headers:
|
|
755
|
+
span.set_attribute("metadata.requester_custom_headers", str(merged_headers))
|
|
756
|
+
|
|
757
|
+
async def async_pre_call_deployment_hook(
|
|
758
|
+
self, kwargs: Dict[str, Any], call_type: Optional[CallTypes] = None
|
|
759
|
+
) -> Optional[Dict[str, Any]]:
|
|
760
|
+
"""The root span is sometimes missing (e.g., when Anthropic endpoint is used).
|
|
761
|
+
It is created in an auth module in LiteLLM. If it's missing, we create it here.
|
|
762
|
+
"""
|
|
763
|
+
if "metadata" not in kwargs or "litellm_parent_otel_span" not in kwargs["metadata"]:
|
|
764
|
+
parent_otel_span = self.create_litellm_proxy_request_started_span( # type: ignore
|
|
765
|
+
start_time=datetime.now(),
|
|
766
|
+
headers=kwargs.get("headers", {}),
|
|
767
|
+
)
|
|
768
|
+
updated_metadata = {**kwargs.get("metadata", {}), "litellm_parent_otel_span": parent_otel_span}
|
|
769
|
+
|
|
770
|
+
return {**kwargs, "metadata": updated_metadata}
|
|
771
|
+
else:
|
|
772
|
+
return kwargs
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
class RolloutAttemptMiddleware(BaseHTTPMiddleware):
|
|
776
|
+
"""
|
|
777
|
+
Rewrites /rollout/{rid}/attempt/{aid}/... -> /...
|
|
778
|
+
and injects x-rollout-id, x-attempt-id, x-sequence-id headers.
|
|
779
|
+
|
|
780
|
+
Also fetches rollout metadata from the store and sets tracing ContextVars
|
|
781
|
+
so TracingContextSpanProcessor can inject environment, tags, and session_id
|
|
782
|
+
into all spans created during this request.
|
|
783
|
+
"""
|
|
784
|
+
|
|
785
|
+
async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
|
|
786
|
+
path = request.url.path
|
|
787
|
+
|
|
788
|
+
match = re.match(r"^/rollout/([^/]+)/attempt/([^/]+)(/.*)?$", path)
|
|
789
|
+
if match:
|
|
790
|
+
rollout_id = match.group(1)
|
|
791
|
+
attempt_id = match.group(2)
|
|
792
|
+
new_path = match.group(3) if match.group(3) is not None else "/"
|
|
793
|
+
|
|
794
|
+
# Rewrite the ASGI scope path so downstream sees a clean OpenAI path.
|
|
795
|
+
request.scope["path"] = new_path
|
|
796
|
+
request.scope["raw_path"] = new_path.encode()
|
|
797
|
+
|
|
798
|
+
store = get_active_llm_proxy().get_store()
|
|
799
|
+
if store is not None:
|
|
800
|
+
# Allocate a monotonic sequence id per (rollout, attempt).
|
|
801
|
+
sequence_id = await store.get_next_span_sequence_id(rollout_id, attempt_id)
|
|
802
|
+
|
|
803
|
+
# Inject headers so downstream components and exporters can retrieve them.
|
|
804
|
+
request.scope["headers"] = list(request.scope["headers"]) + [
|
|
805
|
+
(b"x-rollout-id", rollout_id.encode()),
|
|
806
|
+
(b"x-attempt-id", attempt_id.encode()),
|
|
807
|
+
(b"x-sequence-id", str(sequence_id).encode()),
|
|
808
|
+
]
|
|
809
|
+
|
|
810
|
+
# Fetch rollout metadata and set tracing ContextVars.
|
|
811
|
+
# TracingContextSpanProcessor reads these and injects into all spans.
|
|
812
|
+
try:
|
|
813
|
+
from gatewaysdk.tracing.context import (
|
|
814
|
+
_environment_context,
|
|
815
|
+
_proxy_session_context,
|
|
816
|
+
_tags_context,
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
rollout = await store.get_rollout_by_id(rollout_id)
|
|
820
|
+
if rollout and rollout.metadata:
|
|
821
|
+
env = rollout.metadata.get("environment")
|
|
822
|
+
tags = rollout.metadata.get("tags")
|
|
823
|
+
if env:
|
|
824
|
+
_environment_context.set(env)
|
|
825
|
+
if tags:
|
|
826
|
+
_tags_context.set(tags)
|
|
827
|
+
_proxy_session_context.set(rollout_id)
|
|
828
|
+
except Exception as e:
|
|
829
|
+
logger.debug(f"Could not set tracing context for rollout {rollout_id}: {e}")
|
|
830
|
+
else:
|
|
831
|
+
logger.warning("Store is not set. Skipping sequence id allocation and header injection.")
|
|
832
|
+
|
|
833
|
+
response = await call_next(request)
|
|
834
|
+
return response
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
class MessageInspectionMiddleware(BaseHTTPMiddleware):
|
|
838
|
+
"""Middleware to inspect the request and response bodies.
|
|
839
|
+
|
|
840
|
+
It's for debugging purposes. Add it via "message_inspection" middleware alias.
|
|
841
|
+
"""
|
|
842
|
+
|
|
843
|
+
async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
|
|
844
|
+
ti = time.time()
|
|
845
|
+
logger.info(f"Received request with scope: {request.scope}")
|
|
846
|
+
logger.info(f"Received request with body: {await request.body()}")
|
|
847
|
+
response = await call_next(request)
|
|
848
|
+
elapsed = time.time() - ti
|
|
849
|
+
logger.info(f"Response to request took {elapsed} seconds")
|
|
850
|
+
logger.info(f"Received response with status code: {response.status_code}")
|
|
851
|
+
logger.info(f"Received response with body: {response.body}")
|
|
852
|
+
return response
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
class StreamConversionMiddleware(BaseHTTPMiddleware):
|
|
856
|
+
"""Middleware to convert streaming responses to non-streaming responses.
|
|
857
|
+
|
|
858
|
+
Useful for backend that only supports non-streaming responses.
|
|
859
|
+
|
|
860
|
+
LiteLLM's OpenTelemetry is also buggy with streaming responses.
|
|
861
|
+
The conversion will hopefully bypass the bug.
|
|
862
|
+
"""
|
|
863
|
+
|
|
864
|
+
async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
|
|
865
|
+
# Only process POST requests to completion endpoints
|
|
866
|
+
if request.method != "POST":
|
|
867
|
+
return await call_next(request)
|
|
868
|
+
|
|
869
|
+
# Check if it's a chat completions or messages endpoint
|
|
870
|
+
endpoint_format: Literal["openai", "anthropic", "unknown"] = "unknown"
|
|
871
|
+
if request.url.path.endswith("/chat/completions") or "/chat/completions?" in request.url.path:
|
|
872
|
+
endpoint_format = "openai"
|
|
873
|
+
elif request.url.path.endswith("/messages") or "/messages?" in request.url.path:
|
|
874
|
+
endpoint_format = "anthropic"
|
|
875
|
+
else:
|
|
876
|
+
endpoint_format = "unknown"
|
|
877
|
+
|
|
878
|
+
if endpoint_format == "unknown":
|
|
879
|
+
# Directly bypass the middleware
|
|
880
|
+
return await call_next(request)
|
|
881
|
+
|
|
882
|
+
# Read the request body
|
|
883
|
+
try:
|
|
884
|
+
json_body = await request.json()
|
|
885
|
+
except json.JSONDecodeError:
|
|
886
|
+
logger.warning(f"Request body is not valid JSON: {request.body}")
|
|
887
|
+
return await call_next(request)
|
|
888
|
+
|
|
889
|
+
# Check if streaming is requested
|
|
890
|
+
is_streaming = json_body.get("stream", False)
|
|
891
|
+
|
|
892
|
+
# Simple case: no streaming requested, just return the response
|
|
893
|
+
if not is_streaming:
|
|
894
|
+
return await call_next(request)
|
|
895
|
+
|
|
896
|
+
# Now the stream case
|
|
897
|
+
return await self._handle_stream_case(request, json_body, endpoint_format, call_next)
|
|
898
|
+
|
|
899
|
+
async def _handle_stream_case(
|
|
900
|
+
self,
|
|
901
|
+
request: Request,
|
|
902
|
+
json_body: Dict[str, Any],
|
|
903
|
+
endpoint_format: Literal["openai", "anthropic"],
|
|
904
|
+
call_next: Callable[[Request], Awaitable[Response]],
|
|
905
|
+
) -> Response:
|
|
906
|
+
# 1) Modify the request body to force stream=False
|
|
907
|
+
modified_json = dict(json_body)
|
|
908
|
+
modified_json["stream"] = False
|
|
909
|
+
modified_body = json.dumps(modified_json).encode("utf-8")
|
|
910
|
+
|
|
911
|
+
# 2) Build a new scope + receive that yields our modified body
|
|
912
|
+
scope: Scope = dict(request.scope)
|
|
913
|
+
# rewrite headers for accept/content-length
|
|
914
|
+
new_headers: List[Tuple[bytes, bytes]] = []
|
|
915
|
+
saw_accept = False
|
|
916
|
+
for k, v in scope["headers"]:
|
|
917
|
+
kl = k.lower()
|
|
918
|
+
if kl == b"accept":
|
|
919
|
+
saw_accept = True
|
|
920
|
+
new_headers.append((k, b"application/json"))
|
|
921
|
+
elif kl == b"content-length":
|
|
922
|
+
# replace with new length
|
|
923
|
+
continue
|
|
924
|
+
else:
|
|
925
|
+
new_headers.append((k, v))
|
|
926
|
+
if not saw_accept:
|
|
927
|
+
new_headers.append((b"accept", b"application/json"))
|
|
928
|
+
new_headers.append((b"content-length", str(len(modified_body)).encode("ascii")))
|
|
929
|
+
scope["headers"] = new_headers
|
|
930
|
+
|
|
931
|
+
# Directly modify the request body
|
|
932
|
+
# Creating a new request won't work because request is cached in the base class
|
|
933
|
+
request._body = modified_body # type: ignore
|
|
934
|
+
|
|
935
|
+
response = await call_next(request)
|
|
936
|
+
|
|
937
|
+
buffered: Optional[bytes] = None
|
|
938
|
+
# 4) If OK, buffer the response body (it should be JSON because we forced stream=False)
|
|
939
|
+
if 200 <= response.status_code < 300:
|
|
940
|
+
try:
|
|
941
|
+
if hasattr(response, "body_iterator"):
|
|
942
|
+
# Buffer body safely
|
|
943
|
+
body_chunks: List[bytes] = []
|
|
944
|
+
async for chunk in response.body_iterator: # type: ignore
|
|
945
|
+
body_chunks.append(chunk) # type: ignore
|
|
946
|
+
buffered = b"".join(body_chunks)
|
|
947
|
+
else:
|
|
948
|
+
buffered = response.body # type: ignore
|
|
949
|
+
|
|
950
|
+
data = json.loads(buffered or b"{}")
|
|
951
|
+
|
|
952
|
+
if endpoint_format == "anthropic":
|
|
953
|
+
return StreamingResponse(
|
|
954
|
+
self.anthropic_stream_generator(data),
|
|
955
|
+
media_type="text/event-stream",
|
|
956
|
+
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
|
|
957
|
+
)
|
|
958
|
+
else:
|
|
959
|
+
# openai format
|
|
960
|
+
return StreamingResponse(
|
|
961
|
+
self.openai_stream_generator(data),
|
|
962
|
+
media_type="text/event-stream",
|
|
963
|
+
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
|
|
964
|
+
)
|
|
965
|
+
except Exception as e:
|
|
966
|
+
# If anything goes wrong, fall back to non-streaming JSON
|
|
967
|
+
logger.exception(f"Error converting to stream; returning non-stream response: {e}")
|
|
968
|
+
# Rebuild the consumed response
|
|
969
|
+
return Response(
|
|
970
|
+
content=buffered if buffered is not None else b"",
|
|
971
|
+
status_code=response.status_code,
|
|
972
|
+
headers=dict(response.headers),
|
|
973
|
+
media_type=response.media_type,
|
|
974
|
+
background=response.background,
|
|
975
|
+
)
|
|
976
|
+
else:
|
|
977
|
+
return response
|
|
978
|
+
|
|
979
|
+
async def anthropic_stream_generator(self, original_response: Dict[str, Any]):
|
|
980
|
+
"""Generate Anthropic SSE-formatted chunks from complete content blocks
|
|
981
|
+
|
|
982
|
+
This is a dirty hack for Anthropic-style streaming from non-streaming response.
|
|
983
|
+
The sse format is subject to change based on Anthropic's implementation.
|
|
984
|
+
If so, try to use `MessageInspectionMiddleware` to inspect the update and fix accordingly.
|
|
985
|
+
"""
|
|
986
|
+
# Anthropic format - handle multiple content blocks (text + tool_use)
|
|
987
|
+
content_blocks: List[Dict[str, Any]] = original_response.get("content", [])
|
|
988
|
+
message_id = original_response.get("id", f"msg_{int(time.time() * 1000)}")
|
|
989
|
+
model = original_response.get("model", "claude")
|
|
990
|
+
|
|
991
|
+
# Send message_start event
|
|
992
|
+
message_start: Dict[str, Any] = {
|
|
993
|
+
"type": "message_start",
|
|
994
|
+
"message": {
|
|
995
|
+
"id": message_id,
|
|
996
|
+
"type": "message",
|
|
997
|
+
"role": "assistant",
|
|
998
|
+
"content": [],
|
|
999
|
+
"model": model,
|
|
1000
|
+
"stop_reason": None,
|
|
1001
|
+
"stop_sequence": None,
|
|
1002
|
+
"usage": original_response.get("usage", {"input_tokens": 0, "output_tokens": 0}),
|
|
1003
|
+
},
|
|
1004
|
+
}
|
|
1005
|
+
yield f"event: message_start\ndata: {json.dumps(message_start)}\n\n"
|
|
1006
|
+
|
|
1007
|
+
# Send ping to keep connection alive
|
|
1008
|
+
ping = {"type": "ping"}
|
|
1009
|
+
yield f"event: ping\ndata: {json.dumps(ping)}\n\n"
|
|
1010
|
+
|
|
1011
|
+
# Process each content block
|
|
1012
|
+
for block_index, block in enumerate(content_blocks):
|
|
1013
|
+
block_type = block.get("type", "text")
|
|
1014
|
+
|
|
1015
|
+
if block_type == "text":
|
|
1016
|
+
# Handle text block
|
|
1017
|
+
content = block.get("text", "")
|
|
1018
|
+
|
|
1019
|
+
# Send content_block_start event
|
|
1020
|
+
content_block_start = {
|
|
1021
|
+
"type": "content_block_start",
|
|
1022
|
+
"index": block_index,
|
|
1023
|
+
"content_block": {"type": "text", "text": ""},
|
|
1024
|
+
}
|
|
1025
|
+
yield f"event: content_block_start\ndata: {json.dumps(content_block_start)}\n\n"
|
|
1026
|
+
|
|
1027
|
+
# Stream text content in chunks
|
|
1028
|
+
if content:
|
|
1029
|
+
words = content.split()
|
|
1030
|
+
chunk_size = 5
|
|
1031
|
+
|
|
1032
|
+
for i in range(0, len(words), chunk_size):
|
|
1033
|
+
chunk_words = words[i : i + chunk_size]
|
|
1034
|
+
text_chunk = " ".join(chunk_words)
|
|
1035
|
+
|
|
1036
|
+
# Add space after chunk unless it's the last one
|
|
1037
|
+
if i + chunk_size < len(words):
|
|
1038
|
+
text_chunk += " "
|
|
1039
|
+
|
|
1040
|
+
content_block_delta = {
|
|
1041
|
+
"type": "content_block_delta",
|
|
1042
|
+
"index": block_index,
|
|
1043
|
+
"delta": {"type": "text_delta", "text": text_chunk},
|
|
1044
|
+
}
|
|
1045
|
+
yield f"event: content_block_delta\ndata: {json.dumps(content_block_delta)}\n\n"
|
|
1046
|
+
await asyncio.sleep(0.02)
|
|
1047
|
+
|
|
1048
|
+
# Send content_block_stop event
|
|
1049
|
+
content_block_stop = {"type": "content_block_stop", "index": block_index}
|
|
1050
|
+
yield f"event: content_block_stop\ndata: {json.dumps(content_block_stop)}\n\n"
|
|
1051
|
+
|
|
1052
|
+
elif block_type == "tool_use":
|
|
1053
|
+
# Handle tool_use block
|
|
1054
|
+
tool_name = block.get("name", "")
|
|
1055
|
+
tool_input = block.get("input", {})
|
|
1056
|
+
tool_id = block.get("id", f"toolu_{int(time.time() * 1000)}")
|
|
1057
|
+
|
|
1058
|
+
# Send content_block_start event for tool use
|
|
1059
|
+
content_block_start: Dict[str, Any] = {
|
|
1060
|
+
"type": "content_block_start",
|
|
1061
|
+
"index": block_index,
|
|
1062
|
+
"content_block": {"type": "tool_use", "id": tool_id, "name": tool_name, "input": {}},
|
|
1063
|
+
}
|
|
1064
|
+
yield f"event: content_block_start\ndata: {json.dumps(content_block_start)}\n\n"
|
|
1065
|
+
|
|
1066
|
+
# Stream tool input as JSON string chunks
|
|
1067
|
+
input_json = json.dumps(tool_input)
|
|
1068
|
+
chunk_size = 20 # characters per chunk for JSON
|
|
1069
|
+
|
|
1070
|
+
for i in range(0, len(input_json), chunk_size):
|
|
1071
|
+
json_chunk = input_json[i : i + chunk_size]
|
|
1072
|
+
|
|
1073
|
+
content_block_delta = {
|
|
1074
|
+
"type": "content_block_delta",
|
|
1075
|
+
"index": block_index,
|
|
1076
|
+
"delta": {"type": "input_json_delta", "partial_json": json_chunk},
|
|
1077
|
+
}
|
|
1078
|
+
yield f"event: content_block_delta\ndata: {json.dumps(content_block_delta)}\n\n"
|
|
1079
|
+
await asyncio.sleep(0.01)
|
|
1080
|
+
|
|
1081
|
+
# Send content_block_stop event
|
|
1082
|
+
content_block_stop = {"type": "content_block_stop", "index": block_index}
|
|
1083
|
+
yield f"event: content_block_stop\ndata: {json.dumps(content_block_stop)}\n\n"
|
|
1084
|
+
|
|
1085
|
+
# Send message_delta event with stop reason
|
|
1086
|
+
message_delta = {
|
|
1087
|
+
"type": "message_delta",
|
|
1088
|
+
"delta": {"stop_reason": original_response.get("stop_reason", "end_turn"), "stop_sequence": None},
|
|
1089
|
+
"usage": {"output_tokens": original_response.get("usage", {}).get("output_tokens", 0)},
|
|
1090
|
+
}
|
|
1091
|
+
yield f"event: message_delta\ndata: {json.dumps(message_delta)}\n\n"
|
|
1092
|
+
|
|
1093
|
+
# Send message_stop event
|
|
1094
|
+
message_stop = {"type": "message_stop"}
|
|
1095
|
+
yield f"event: message_stop\ndata: {json.dumps(message_stop)}\n\n"
|
|
1096
|
+
|
|
1097
|
+
async def openai_stream_generator(self, response_json: Dict[str, Any]) -> AsyncGenerator[str, Any]:
|
|
1098
|
+
"""
|
|
1099
|
+
Convert a *complete* OpenAI chat.completions choice into a stream of
|
|
1100
|
+
OpenAI-compatible SSE chunks.
|
|
1101
|
+
|
|
1102
|
+
This emits:
|
|
1103
|
+
|
|
1104
|
+
- an initial delta with the role ("assistant"),
|
|
1105
|
+
- a sequence of deltas for message.content (split into small chunks),
|
|
1106
|
+
- deltas for any tool_calls (including id/name and chunked arguments),
|
|
1107
|
+
- a terminal chunk with finish_reason,
|
|
1108
|
+
- and finally the literal '[DONE]'.
|
|
1109
|
+
|
|
1110
|
+
Notes:
|
|
1111
|
+
|
|
1112
|
+
- We only handle a *single* choice (index 0 typically).
|
|
1113
|
+
- We purposefully don't attempt to stream logprobs.
|
|
1114
|
+
- Chunking strategy is simple and conservative to avoid splitting
|
|
1115
|
+
multi-byte characters: we slice on spaces where possible, then fall
|
|
1116
|
+
back to fixed-size substrings.
|
|
1117
|
+
"""
|
|
1118
|
+
choice = cast(Dict[str, Any], (response_json.get("choices") or [{}])[0])
|
|
1119
|
+
model = response_json.get("model", "unknown")
|
|
1120
|
+
created: int = int(time.time())
|
|
1121
|
+
index: int = choice.get("index", 0)
|
|
1122
|
+
|
|
1123
|
+
message: Dict[str, Any] = choice.get("message", {}) or {}
|
|
1124
|
+
role: str = message.get("role", "assistant")
|
|
1125
|
+
content: str = message.get("content") or ""
|
|
1126
|
+
tool_calls: List[Any] = message.get("tool_calls") or []
|
|
1127
|
+
finish_reason: Optional[str] = choice.get(
|
|
1128
|
+
"finish_reason"
|
|
1129
|
+
) # e.g., "stop", "length", "tool_calls", "content_filter"
|
|
1130
|
+
|
|
1131
|
+
def sse_chunk(obj: Dict[str, Any]) -> str:
|
|
1132
|
+
return f"data: {json.dumps(obj, ensure_ascii=False)}\n\n"
|
|
1133
|
+
|
|
1134
|
+
# 1) initial chunk with the role
|
|
1135
|
+
yield sse_chunk(
|
|
1136
|
+
{
|
|
1137
|
+
"id": f"chatcmpl-{created}",
|
|
1138
|
+
"object": "chat.completion.chunk",
|
|
1139
|
+
"created": created,
|
|
1140
|
+
"model": model,
|
|
1141
|
+
"choices": [{"index": index, "delta": {"role": role}, "finish_reason": None}],
|
|
1142
|
+
}
|
|
1143
|
+
)
|
|
1144
|
+
|
|
1145
|
+
# 2) stream textual content as small deltas
|
|
1146
|
+
async def stream_content(text: str):
|
|
1147
|
+
if not text:
|
|
1148
|
+
return
|
|
1149
|
+
# prefer splitting on spaces in ~20–40 char pieces
|
|
1150
|
+
approx = 28
|
|
1151
|
+
start = 0
|
|
1152
|
+
n = len(text)
|
|
1153
|
+
while start < n:
|
|
1154
|
+
end = min(start + approx, n)
|
|
1155
|
+
if end < n:
|
|
1156
|
+
# try to break on a space going forward
|
|
1157
|
+
space = text.rfind(" ", start, end)
|
|
1158
|
+
if space > start:
|
|
1159
|
+
end = space + 1
|
|
1160
|
+
delta_text = text[start:end]
|
|
1161
|
+
start = end
|
|
1162
|
+
if not delta_text:
|
|
1163
|
+
break
|
|
1164
|
+
yield sse_chunk(
|
|
1165
|
+
{
|
|
1166
|
+
"id": f"chatcmpl-{created}",
|
|
1167
|
+
"object": "chat.completion.chunk",
|
|
1168
|
+
"created": created,
|
|
1169
|
+
"model": model,
|
|
1170
|
+
"choices": [{"index": index, "delta": {"content": delta_text}, "finish_reason": None}],
|
|
1171
|
+
}
|
|
1172
|
+
)
|
|
1173
|
+
# tiny pause helps some UIs animate smoothly; keep very small
|
|
1174
|
+
await asyncio.sleep(0.0)
|
|
1175
|
+
|
|
1176
|
+
async for piece in stream_content(content): # type: ignore[misc]
|
|
1177
|
+
yield piece # pass through the produced chunks
|
|
1178
|
+
|
|
1179
|
+
# 3) stream tool_calls if present (id/name first, then arguments piecemeal)
|
|
1180
|
+
for tc_index, tc in enumerate(tool_calls):
|
|
1181
|
+
tc_type = tc.get("type", "function")
|
|
1182
|
+
tc_id = tc.get("id") or f"call_{created}_{tc_index}"
|
|
1183
|
+
fn: Dict[str, Any] = (tc.get("function") or {}) if tc_type == "function" else {}
|
|
1184
|
+
fn_name: str = fn.get("name", "")
|
|
1185
|
+
fn_args: str = fn.get("arguments", "") or ""
|
|
1186
|
+
|
|
1187
|
+
# (a) delta that announces the tool call id/type/name
|
|
1188
|
+
yield sse_chunk(
|
|
1189
|
+
{
|
|
1190
|
+
"id": f"chatcmpl-{created}",
|
|
1191
|
+
"object": "chat.completion.chunk",
|
|
1192
|
+
"created": created,
|
|
1193
|
+
"model": model,
|
|
1194
|
+
"choices": [
|
|
1195
|
+
{
|
|
1196
|
+
"index": index,
|
|
1197
|
+
"delta": {
|
|
1198
|
+
"tool_calls": [
|
|
1199
|
+
{"index": tc_index, "id": tc_id, "type": tc_type, "function": {"name": fn_name}}
|
|
1200
|
+
]
|
|
1201
|
+
},
|
|
1202
|
+
"finish_reason": None,
|
|
1203
|
+
}
|
|
1204
|
+
],
|
|
1205
|
+
}
|
|
1206
|
+
)
|
|
1207
|
+
|
|
1208
|
+
# (b) stream arguments in small substrings
|
|
1209
|
+
arg_chunk_size = 40
|
|
1210
|
+
for pos in range(0, len(fn_args), arg_chunk_size):
|
|
1211
|
+
partial = fn_args[pos : pos + arg_chunk_size]
|
|
1212
|
+
yield sse_chunk(
|
|
1213
|
+
{
|
|
1214
|
+
"id": f"chatcmpl-{created}",
|
|
1215
|
+
"object": "chat.completion.chunk",
|
|
1216
|
+
"created": created,
|
|
1217
|
+
"model": model,
|
|
1218
|
+
"choices": [
|
|
1219
|
+
{
|
|
1220
|
+
"index": index,
|
|
1221
|
+
"delta": {"tool_calls": [{"index": tc_index, "function": {"arguments": partial}}]},
|
|
1222
|
+
"finish_reason": None,
|
|
1223
|
+
}
|
|
1224
|
+
],
|
|
1225
|
+
}
|
|
1226
|
+
)
|
|
1227
|
+
await asyncio.sleep(0.0)
|
|
1228
|
+
|
|
1229
|
+
# 4) terminal chunk with finish_reason (default to "stop" if missing)
|
|
1230
|
+
yield sse_chunk(
|
|
1231
|
+
{
|
|
1232
|
+
"id": f"chatcmpl-{created}",
|
|
1233
|
+
"object": "chat.completion.chunk",
|
|
1234
|
+
"created": created,
|
|
1235
|
+
"model": model,
|
|
1236
|
+
"choices": [
|
|
1237
|
+
{
|
|
1238
|
+
"index": index,
|
|
1239
|
+
"delta": {},
|
|
1240
|
+
"finish_reason": finish_reason or ("tool_calls" if tool_calls else "stop"),
|
|
1241
|
+
}
|
|
1242
|
+
],
|
|
1243
|
+
}
|
|
1244
|
+
)
|
|
1245
|
+
|
|
1246
|
+
# 5) literal DONE sentinel
|
|
1247
|
+
yield "data: [DONE]\n\n"
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
_MIDDLEWARE_REGISTRY: Dict[str, Type[BaseHTTPMiddleware]] = {
|
|
1251
|
+
"rollout_attempt": RolloutAttemptMiddleware,
|
|
1252
|
+
"stream_conversion": StreamConversionMiddleware,
|
|
1253
|
+
"message_inspection": MessageInspectionMiddleware,
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
_CALLBACK_REGISTRY = {
|
|
1258
|
+
"return_token_ids": AddReturnTokenIds,
|
|
1259
|
+
"logprobs": AddLogprobs,
|
|
1260
|
+
"opentelemetry": LightningOpenTelemetry,
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
class LLMProxy:
|
|
1265
|
+
"""Host a LiteLLM OpenAI-compatible proxy bound to a LightningStore.
|
|
1266
|
+
|
|
1267
|
+
The proxy:
|
|
1268
|
+
|
|
1269
|
+
* Serves an OpenAI-compatible API via uvicorn.
|
|
1270
|
+
* Adds rollout/attempt routing and headers via middleware.
|
|
1271
|
+
* Registers OTEL export and token-id callbacks.
|
|
1272
|
+
* Writes a LiteLLM worker config file with `model_list` and settings.
|
|
1273
|
+
|
|
1274
|
+
Lifecycle:
|
|
1275
|
+
|
|
1276
|
+
* [`start()`][gatewaysdk.LLMProxy.start] writes config, starts uvicorn server in a thread, and waits until ready.
|
|
1277
|
+
* [`stop()`][gatewaysdk.LLMProxy.stop] tears down the server and removes the temp config file.
|
|
1278
|
+
* [`restart()`][gatewaysdk.LLMProxy.restart] convenience wrapper to stop then start.
|
|
1279
|
+
|
|
1280
|
+
!!! note
|
|
1281
|
+
|
|
1282
|
+
As the LLM Proxy sets up an OpenTelemetry tracer, it's recommended to run it in a different
|
|
1283
|
+
process from the main runner (i.e., tracer from agents). See `launch_mode` for how to change that.
|
|
1284
|
+
|
|
1285
|
+
!!! warning
|
|
1286
|
+
|
|
1287
|
+
By default (or when "stream_conversion" middleware is enabled), the LLM Proxy will convert OpenAI and Anthropic requests with `stream=True`
|
|
1288
|
+
to a non-streaming request before going through the LiteLLM proxy. This is because the OpenTelemetry tracer provided by
|
|
1289
|
+
LiteLLM is buggy with streaming responses. You can disable this by removing the "stream_conversion" middleware.
|
|
1290
|
+
In that case, you might lose some tracing information like token IDs.
|
|
1291
|
+
|
|
1292
|
+
!!! danger
|
|
1293
|
+
|
|
1294
|
+
Do not run LLM proxy in the same process as the main runner. It's easy to cause conflicts in the tracer provider
|
|
1295
|
+
with tracers like [`OtelTracer`][gatewaysdk.OtelTracer].
|
|
1296
|
+
|
|
1297
|
+
Args:
|
|
1298
|
+
port: TCP port to bind. Will bind to a random port if not provided.
|
|
1299
|
+
model_list: LiteLLM `model_list` entries.
|
|
1300
|
+
store: LightningStore used for span sequence and persistence.
|
|
1301
|
+
host: Publicly reachable host used in resource endpoints. See `host` of `launcher_args` for more details.
|
|
1302
|
+
litellm_config: Extra LiteLLM proxy config merged with `model_list`.
|
|
1303
|
+
num_retries: Default LiteLLM retry count injected into `litellm_settings`.
|
|
1304
|
+
num_workers: Number of workers to run in the server. Only applicable for "mp" launch mode. Ignored if launcher_args is provided.
|
|
1305
|
+
When `num_workers > 1`, the server will be run using [gunicorn](https://gunicorn.org/).
|
|
1306
|
+
launch_mode: Launch mode for the server. Defaults to "mp". Cannot be used together with launcher_args. Ignored if launcher_args is provided.
|
|
1307
|
+
It's recommended to use `launch_mode="mp"` to launch the proxy, which will launch the server in a separate process.
|
|
1308
|
+
`launch_mode="thread"` can also be used if used in caution. It will launch the server in a separate thread.
|
|
1309
|
+
`launch_mode="asyncio"` launches the server in the current thread as an asyncio task.
|
|
1310
|
+
It is NOT recommended because it often causes hanging requests. Only use it if you know what you are doing.
|
|
1311
|
+
launcher_args: Arguments for the server launcher. If this is provided, host, port, and launch_mode will be ignored. Cannot be used together with port, host, and launch_mode.
|
|
1312
|
+
middlewares: List of FastAPI middleware classes or strings to register. You can specify the class aliases or classes that have been imported.
|
|
1313
|
+
If not provided, the default middlewares (RolloutAttemptMiddleware and StreamConversionMiddleware) will be used.
|
|
1314
|
+
Available middleware aliases are: "rollout_attempt", "stream_conversion", "message_inspection".
|
|
1315
|
+
Middlewares are the **first layer** of request processing. They are applied to all requests before the LiteLLM proxy.
|
|
1316
|
+
callbacks: List of LiteLLM callback classes or strings to register. You can specify the class aliases or classes that have been imported.
|
|
1317
|
+
If not provided, the default callbacks (AddReturnTokenIds and LightningOpenTelemetry) will be used.
|
|
1318
|
+
Available callback aliases are: "return_token_ids", "opentelemetry", "logprobs".
|
|
1319
|
+
otlp_endpoint: Optional OTLP endpoint URL for direct trace export to external
|
|
1320
|
+
collectors (e.g., Langfuse/Gateway). When set, spans are exported directly without
|
|
1321
|
+
requiring rollout/attempt headers. Format: "http://host:port/api/public/otel/v1/traces"
|
|
1322
|
+
otlp_headers: Optional dict of HTTP headers for OTLP authentication.
|
|
1323
|
+
For Langfuse/Gateway, use Basic Auth: {"Authorization": "Basic base64(publicKey:secretKey)"}
|
|
1324
|
+
"""
|
|
1325
|
+
|
|
1326
|
+
def __init__(
|
|
1327
|
+
self,
|
|
1328
|
+
port: int | None = None,
|
|
1329
|
+
model_list: List[ModelConfig] | None = None,
|
|
1330
|
+
store: Optional[LightningStore] = None,
|
|
1331
|
+
host: str | None = None,
|
|
1332
|
+
litellm_config: Dict[str, Any] | None = None,
|
|
1333
|
+
num_retries: int = 0,
|
|
1334
|
+
num_workers: int = 1,
|
|
1335
|
+
launch_mode: LaunchMode = "mp",
|
|
1336
|
+
launcher_args: PythonServerLauncherArgs | None = None,
|
|
1337
|
+
middlewares: Sequence[Union[Type[BaseHTTPMiddleware], str]] | None = None,
|
|
1338
|
+
callbacks: Sequence[Union[Type[CustomLogger], str]] | None = None,
|
|
1339
|
+
otlp_endpoint: Optional[str] = None,
|
|
1340
|
+
otlp_headers: Optional[Dict[str, str]] = None,
|
|
1341
|
+
):
|
|
1342
|
+
self.store = store
|
|
1343
|
+
self._otlp_endpoint = otlp_endpoint
|
|
1344
|
+
self._otlp_headers = otlp_headers
|
|
1345
|
+
|
|
1346
|
+
# Log OTLP configuration for diagnostics
|
|
1347
|
+
if otlp_endpoint:
|
|
1348
|
+
logger.info(f"LLMProxy initialized with OTLP endpoint: {otlp_endpoint}")
|
|
1349
|
+
if otlp_headers:
|
|
1350
|
+
logger.info(f"LLMProxy OTLP headers configured: {list(otlp_headers.keys())}")
|
|
1351
|
+
else:
|
|
1352
|
+
logger.debug("LLMProxy initialized without OTLP endpoint (will use store-based export)")
|
|
1353
|
+
|
|
1354
|
+
if launcher_args is not None and (
|
|
1355
|
+
port is not None or host is not None or launch_mode != "mp" or num_workers != 1
|
|
1356
|
+
):
|
|
1357
|
+
raise ValueError("port, host, launch_mode, and num_workers cannot be set when launcher_args is provided.")
|
|
1358
|
+
|
|
1359
|
+
self.server_launcher_args = launcher_args or PythonServerLauncherArgs(
|
|
1360
|
+
port=port,
|
|
1361
|
+
host=host,
|
|
1362
|
+
launch_mode=launch_mode,
|
|
1363
|
+
n_workers=num_workers,
|
|
1364
|
+
# NOTE: This /health endpoint can be slow sometimes because it actually probes the backend LLM service.
|
|
1365
|
+
healthcheck_url="/health",
|
|
1366
|
+
startup_timeout=60.0,
|
|
1367
|
+
)
|
|
1368
|
+
|
|
1369
|
+
if self.server_launcher_args.healthcheck_url is None:
|
|
1370
|
+
logger.warning("healthcheck_url is not set. LLM Proxy will not be checked for healthiness after starting.")
|
|
1371
|
+
|
|
1372
|
+
self.model_list = model_list or []
|
|
1373
|
+
self.litellm_config = litellm_config or {}
|
|
1374
|
+
|
|
1375
|
+
# Ensure num_retries is present inside the litellm_settings block.
|
|
1376
|
+
self.litellm_config.setdefault("litellm_settings", {})
|
|
1377
|
+
self.litellm_config["litellm_settings"].setdefault("num_retries", num_retries)
|
|
1378
|
+
self.server_launcher = PythonServerLauncher(app, self.server_launcher_args, noop_context())
|
|
1379
|
+
|
|
1380
|
+
self._config_file = None
|
|
1381
|
+
|
|
1382
|
+
self.middlewares: List[Type[BaseHTTPMiddleware]] = []
|
|
1383
|
+
if middlewares is None:
|
|
1384
|
+
middlewares = ["rollout_attempt", "stream_conversion"]
|
|
1385
|
+
for middleware in middlewares:
|
|
1386
|
+
if isinstance(middleware, str):
|
|
1387
|
+
if middleware not in _MIDDLEWARE_REGISTRY:
|
|
1388
|
+
raise ValueError(
|
|
1389
|
+
f"Invalid middleware alias: {middleware}. Available aliases are: {list(_MIDDLEWARE_REGISTRY.keys())}"
|
|
1390
|
+
)
|
|
1391
|
+
middleware = _MIDDLEWARE_REGISTRY[middleware]
|
|
1392
|
+
self.middlewares.append(middleware)
|
|
1393
|
+
else:
|
|
1394
|
+
self.middlewares.append(middleware)
|
|
1395
|
+
|
|
1396
|
+
self.callbacks: List[Type[CustomLogger]] = []
|
|
1397
|
+
if callbacks is None:
|
|
1398
|
+
callbacks = ["return_token_ids", "opentelemetry"]
|
|
1399
|
+
for callback in callbacks:
|
|
1400
|
+
if isinstance(callback, str):
|
|
1401
|
+
if callback not in _CALLBACK_REGISTRY:
|
|
1402
|
+
raise ValueError(
|
|
1403
|
+
f"Invalid callback alias: {callback}. Available aliases are: {list(_CALLBACK_REGISTRY.keys())}"
|
|
1404
|
+
)
|
|
1405
|
+
callback = _CALLBACK_REGISTRY[callback]
|
|
1406
|
+
self.callbacks.append(callback)
|
|
1407
|
+
else:
|
|
1408
|
+
self.callbacks.append(callback)
|
|
1409
|
+
|
|
1410
|
+
def get_store(self) -> Optional[LightningStore]:
|
|
1411
|
+
"""Get the store used by the proxy.
|
|
1412
|
+
|
|
1413
|
+
Returns:
|
|
1414
|
+
The store used by the proxy.
|
|
1415
|
+
"""
|
|
1416
|
+
return self.store
|
|
1417
|
+
|
|
1418
|
+
def set_store(self, store: LightningStore) -> None:
|
|
1419
|
+
"""Set the store for the proxy.
|
|
1420
|
+
|
|
1421
|
+
Args:
|
|
1422
|
+
store: The store to use for the proxy.
|
|
1423
|
+
"""
|
|
1424
|
+
self.store = store
|
|
1425
|
+
|
|
1426
|
+
def update_model_list(self, model_list: List[ModelConfig]) -> None:
|
|
1427
|
+
"""Replace the in-memory model list.
|
|
1428
|
+
|
|
1429
|
+
Args:
|
|
1430
|
+
model_list: New list of model entries.
|
|
1431
|
+
"""
|
|
1432
|
+
self.model_list = model_list
|
|
1433
|
+
logger.info(f"Updating LLMProxy model list to: {model_list}")
|
|
1434
|
+
# Do nothing if the server is not running.
|
|
1435
|
+
|
|
1436
|
+
def initialize(self):
|
|
1437
|
+
"""Initialize global middleware and LiteLLM callbacks.
|
|
1438
|
+
|
|
1439
|
+
Installs:
|
|
1440
|
+
|
|
1441
|
+
* A FastAPI middleware that rewrites /rollout/{rid}/attempt/{aid}/... paths,
|
|
1442
|
+
injects rollout/attempt/sequence headers, and forwards downstream.
|
|
1443
|
+
* LiteLLM callbacks for token ids and OpenTelemetry export.
|
|
1444
|
+
|
|
1445
|
+
The middleware can only be installed once because once the FastAPI app has started,
|
|
1446
|
+
the middleware cannot be changed any more.
|
|
1447
|
+
|
|
1448
|
+
This function does not start any server. It only wires global hooks.
|
|
1449
|
+
"""
|
|
1450
|
+
if self.store is None:
|
|
1451
|
+
raise ValueError("Store is not set. Please set the store before initializing the LLMProxy.")
|
|
1452
|
+
|
|
1453
|
+
if _global_llm_proxy is not None:
|
|
1454
|
+
logger.warning("A global LLMProxy is already set. Overwriting it with the new instance.")
|
|
1455
|
+
|
|
1456
|
+
# Patch for LiteLLM v1.80.6+: https://github.com/BerriAI/litellm/issues/17243
|
|
1457
|
+
os.environ["USE_OTEL_LITELLM_REQUEST_SPAN"] = "true"
|
|
1458
|
+
|
|
1459
|
+
# Set the global LLMProxy reference for middleware/exporter access.
|
|
1460
|
+
set_active_llm_proxy(self)
|
|
1461
|
+
|
|
1462
|
+
# Install middleware if it's not already installed.
|
|
1463
|
+
installation_status: Dict[Any, bool] = {}
|
|
1464
|
+
for mw in app.user_middleware:
|
|
1465
|
+
installation_status[mw.cls] = True
|
|
1466
|
+
|
|
1467
|
+
for mw in self.middlewares:
|
|
1468
|
+
if mw not in installation_status:
|
|
1469
|
+
logger.info(f"Adding middleware {mw} to the FastAPI app.")
|
|
1470
|
+
app.add_middleware(mw)
|
|
1471
|
+
else:
|
|
1472
|
+
logger.info(f"Middleware {mw} is already installed. Will not install a new one.")
|
|
1473
|
+
|
|
1474
|
+
if not initialize_llm_callbacks(
|
|
1475
|
+
self.callbacks, otlp_endpoint=self._otlp_endpoint, otlp_headers=self._otlp_headers
|
|
1476
|
+
):
|
|
1477
|
+
# If it's not the first time to initialize the callbacks, also
|
|
1478
|
+
# reset LiteLLM's logging worker so its asyncio.Queue binds to the new loop.
|
|
1479
|
+
_reset_litellm_logging_worker()
|
|
1480
|
+
|
|
1481
|
+
@asynccontextmanager
|
|
1482
|
+
async def _serve_context(self) -> AsyncGenerator[None, None]:
|
|
1483
|
+
"""Context manager to serve the proxy server.
|
|
1484
|
+
|
|
1485
|
+
See [`start`][gatewaysdk.LLMProxy.start] and [`stop`][gatewaysdk.LLMProxy.stop] for more details.
|
|
1486
|
+
"""
|
|
1487
|
+
|
|
1488
|
+
if not self.store:
|
|
1489
|
+
raise ValueError("Store is not set. Please set the store before starting the LLMProxy.")
|
|
1490
|
+
|
|
1491
|
+
# Initialize global middleware and callbacks.
|
|
1492
|
+
self.initialize()
|
|
1493
|
+
|
|
1494
|
+
# Persist a temp worker config for LiteLLM and point the proxy at it.
|
|
1495
|
+
self._config_file = tempfile.NamedTemporaryFile(suffix=".yaml", delete=False).name
|
|
1496
|
+
with open(self._config_file, "w") as fp:
|
|
1497
|
+
yaml.safe_dump(
|
|
1498
|
+
{
|
|
1499
|
+
"model_list": self.model_list,
|
|
1500
|
+
**self.litellm_config,
|
|
1501
|
+
},
|
|
1502
|
+
fp,
|
|
1503
|
+
)
|
|
1504
|
+
|
|
1505
|
+
save_worker_config(config=self._config_file)
|
|
1506
|
+
|
|
1507
|
+
# NOTE: When running the _serve_context in current process, you might encounter the following problems:
|
|
1508
|
+
# Problem 1: in litellm worker, <Queue at 0x70f1d028cd90 maxsize=50000> is bound to a different event loop
|
|
1509
|
+
# Problem 2: Proxy has conflicted opentelemetry setup with the main process.
|
|
1510
|
+
|
|
1511
|
+
# Ready
|
|
1512
|
+
logger.info("LLMProxy preparation is done. Will start the server.")
|
|
1513
|
+
yield
|
|
1514
|
+
|
|
1515
|
+
# Clean up
|
|
1516
|
+
|
|
1517
|
+
logger.info("LLMProxy server is cleaning up.")
|
|
1518
|
+
|
|
1519
|
+
# Remove worker config to avoid stale references.
|
|
1520
|
+
if self._config_file and os.path.exists(self._config_file):
|
|
1521
|
+
os.unlink(self._config_file)
|
|
1522
|
+
|
|
1523
|
+
logger.info("LLMProxy server finishes.")
|
|
1524
|
+
|
|
1525
|
+
async def start(self):
|
|
1526
|
+
"""Start the proxy server thread and initialize global wiring.
|
|
1527
|
+
|
|
1528
|
+
Side effects:
|
|
1529
|
+
|
|
1530
|
+
* Sets the module-level global store for middleware/exporter access.
|
|
1531
|
+
* Calls `initialize()` once to register middleware and callbacks.
|
|
1532
|
+
* Writes a temporary YAML config consumed by LiteLLM worker.
|
|
1533
|
+
* Launches uvicorn in a daemon thread and waits for readiness.
|
|
1534
|
+
"""
|
|
1535
|
+
# Refresh the serve context
|
|
1536
|
+
self.server_launcher.serve_context = self._serve_context()
|
|
1537
|
+
|
|
1538
|
+
if self.store is None:
|
|
1539
|
+
raise ValueError("Store is not set. Please set the store before starting the LLMProxy.")
|
|
1540
|
+
|
|
1541
|
+
store_capabilities = self.store.capabilities
|
|
1542
|
+
if self.server_launcher.args.launch_mode == "mp" and not store_capabilities.get("zero_copy", False):
|
|
1543
|
+
raise RuntimeError(
|
|
1544
|
+
"The store does not support zero-copy. Please use another store, or use asyncio or thread mode to launch the server."
|
|
1545
|
+
)
|
|
1546
|
+
elif self.server_launcher.args.launch_mode == "thread" and not store_capabilities.get("thread_safe", False):
|
|
1547
|
+
raise RuntimeError(
|
|
1548
|
+
"The store is not thread-safe. Please use another store, or use asyncio mode to launch the server."
|
|
1549
|
+
)
|
|
1550
|
+
elif self.server_launcher.args.launch_mode == "asyncio" and not store_capabilities.get("async_safe", False):
|
|
1551
|
+
raise RuntimeError("The store is not async-safe. Please use another store.")
|
|
1552
|
+
|
|
1553
|
+
logger.info(
|
|
1554
|
+
f"Starting LLMProxy server in {self.server_launcher.args.launch_mode} mode with store capabilities: {store_capabilities}"
|
|
1555
|
+
)
|
|
1556
|
+
|
|
1557
|
+
await self.server_launcher.start()
|
|
1558
|
+
|
|
1559
|
+
async def stop(self):
|
|
1560
|
+
"""Stop the proxy server and clean up temporary artifacts.
|
|
1561
|
+
|
|
1562
|
+
This is a best-effort graceful shutdown with a bounded join timeout.
|
|
1563
|
+
"""
|
|
1564
|
+
if not self.is_running():
|
|
1565
|
+
logger.warning("LLMProxy is not running. Nothing to stop.")
|
|
1566
|
+
return
|
|
1567
|
+
|
|
1568
|
+
await self.server_launcher.stop()
|
|
1569
|
+
|
|
1570
|
+
async def restart(self, *, _port: int | None = None) -> None:
|
|
1571
|
+
"""Restart the proxy if running, else start it.
|
|
1572
|
+
|
|
1573
|
+
Convenience wrapper calling `stop()` followed by `start()`.
|
|
1574
|
+
"""
|
|
1575
|
+
logger.info("Restarting LLMProxy server...")
|
|
1576
|
+
if self.is_running():
|
|
1577
|
+
await self.stop()
|
|
1578
|
+
if _port is not None:
|
|
1579
|
+
self.server_launcher_args.port = _port
|
|
1580
|
+
await self.start()
|
|
1581
|
+
|
|
1582
|
+
def is_running(self) -> bool:
|
|
1583
|
+
"""Return whether the uvicorn server is active.
|
|
1584
|
+
|
|
1585
|
+
Returns:
|
|
1586
|
+
bool: True if server was started and did not signal exit.
|
|
1587
|
+
"""
|
|
1588
|
+
return self.server_launcher.is_running()
|
|
1589
|
+
|
|
1590
|
+
def as_resource(
|
|
1591
|
+
self,
|
|
1592
|
+
rollout_id: str | None = None,
|
|
1593
|
+
attempt_id: str | None = None,
|
|
1594
|
+
model: str | None = None,
|
|
1595
|
+
sampling_parameters: Dict[str, Any] | None = None,
|
|
1596
|
+
) -> LLM:
|
|
1597
|
+
"""Create an `LLM` resource pointing at this proxy with rollout context.
|
|
1598
|
+
|
|
1599
|
+
The returned endpoint is:
|
|
1600
|
+
`http://{host}:{port}/rollout/{rollout_id}/attempt/{attempt_id}`
|
|
1601
|
+
|
|
1602
|
+
Args:
|
|
1603
|
+
rollout_id: Rollout identifier used for span attribution. If None, will instantiate a ProxyLLM resource.
|
|
1604
|
+
attempt_id: Attempt identifier used for span attribution. If None, will instantiate a ProxyLLM resource.
|
|
1605
|
+
model: Logical model name to use. If omitted and exactly one model
|
|
1606
|
+
is configured or all models have the same name, that model is used.
|
|
1607
|
+
sampling_parameters: Optional default sampling parameters.
|
|
1608
|
+
|
|
1609
|
+
Returns:
|
|
1610
|
+
LLM: Configured resource ready for OpenAI-compatible calls.
|
|
1611
|
+
|
|
1612
|
+
Raises:
|
|
1613
|
+
ValueError: If `model` is omitted and zero or multiple models are configured.
|
|
1614
|
+
"""
|
|
1615
|
+
if model is None:
|
|
1616
|
+
if len(self.model_list) == 1:
|
|
1617
|
+
model = self.model_list[0]["model_name"]
|
|
1618
|
+
elif len(self.model_list) == 0:
|
|
1619
|
+
raise ValueError("No models found in model_list. Please specify the model.")
|
|
1620
|
+
else:
|
|
1621
|
+
first_model_name = self.model_list[0]["model_name"]
|
|
1622
|
+
if all(model_config["model_name"] == first_model_name for model_config in self.model_list):
|
|
1623
|
+
model = first_model_name
|
|
1624
|
+
else:
|
|
1625
|
+
raise ValueError(
|
|
1626
|
+
f"Multiple models found in model_list: {self.model_list}. Please specify the model."
|
|
1627
|
+
)
|
|
1628
|
+
|
|
1629
|
+
if rollout_id is None and attempt_id is None:
|
|
1630
|
+
return ProxyLLM(
|
|
1631
|
+
endpoint=self.server_launcher.access_endpoint,
|
|
1632
|
+
model=model,
|
|
1633
|
+
sampling_parameters=dict(sampling_parameters or {}),
|
|
1634
|
+
)
|
|
1635
|
+
elif rollout_id is not None and attempt_id is not None:
|
|
1636
|
+
return LLM(
|
|
1637
|
+
endpoint=f"{self.server_launcher.access_endpoint}/rollout/{rollout_id}/attempt/{attempt_id}",
|
|
1638
|
+
model=model,
|
|
1639
|
+
sampling_parameters=dict(sampling_parameters or {}),
|
|
1640
|
+
)
|
|
1641
|
+
else:
|
|
1642
|
+
raise ValueError("Either rollout_id and attempt_id must be provided, or neither.")
|
|
1643
|
+
|
|
1644
|
+
|
|
1645
|
+
_global_llm_proxy: Optional[LLMProxy] = None
|
|
1646
|
+
_callbacks_before_litellm_start: Optional[List[Any]] = None
|
|
1647
|
+
|
|
1648
|
+
|
|
1649
|
+
def get_active_llm_proxy() -> LLMProxy:
|
|
1650
|
+
"""Get the current global LLMProxy instance.
|
|
1651
|
+
|
|
1652
|
+
Returns:
|
|
1653
|
+
Optional[LLMProxy]: The current LLMProxy if set, else None.
|
|
1654
|
+
"""
|
|
1655
|
+
if _global_llm_proxy is None:
|
|
1656
|
+
raise ValueError("Global LLMProxy is not set. Please call llm_proxy.start() first.")
|
|
1657
|
+
return _global_llm_proxy
|
|
1658
|
+
|
|
1659
|
+
|
|
1660
|
+
def set_active_llm_proxy(proxy: LLMProxy) -> None:
|
|
1661
|
+
"""Set the current global LLMProxy instance.
|
|
1662
|
+
|
|
1663
|
+
Args:
|
|
1664
|
+
proxy: The LLMProxy instance to set as global.
|
|
1665
|
+
"""
|
|
1666
|
+
global _global_llm_proxy
|
|
1667
|
+
_global_llm_proxy = proxy
|
|
1668
|
+
|
|
1669
|
+
|
|
1670
|
+
def initialize_llm_callbacks(
|
|
1671
|
+
callback_classes: List[Type[CustomLogger]],
|
|
1672
|
+
otlp_endpoint: Optional[str] = None,
|
|
1673
|
+
otlp_headers: Optional[Dict[str, str]] = None,
|
|
1674
|
+
) -> bool:
|
|
1675
|
+
"""Restore `litellm.callbacks` to a state that is just initialized by gatewaysdk.
|
|
1676
|
+
|
|
1677
|
+
When litellm is restarted multiple times in the same process, more and more callbacks
|
|
1678
|
+
will be appended to `litellm.callbacks`, which may exceed the MAX_CALLBACKS limit.
|
|
1679
|
+
This function remembers the initial state of `litellm.callbacks` and always restore to that state.
|
|
1680
|
+
|
|
1681
|
+
Args:
|
|
1682
|
+
callback_classes: List of callback classes to register.
|
|
1683
|
+
otlp_endpoint: Optional OTLP endpoint URL for direct trace export.
|
|
1684
|
+
otlp_headers: Optional dict of HTTP headers for OTLP authentication.
|
|
1685
|
+
|
|
1686
|
+
Returns:
|
|
1687
|
+
Whether the callbacks are initialized for the first time.
|
|
1688
|
+
"""
|
|
1689
|
+
global _callbacks_before_litellm_start
|
|
1690
|
+
|
|
1691
|
+
def _instantiate_callback(cls: Type[CustomLogger]) -> CustomLogger:
|
|
1692
|
+
"""Instantiate callback with appropriate arguments."""
|
|
1693
|
+
if cls is LightningOpenTelemetry:
|
|
1694
|
+
return LightningOpenTelemetry(otlp_endpoint=otlp_endpoint, otlp_headers=otlp_headers)
|
|
1695
|
+
return cls()
|
|
1696
|
+
|
|
1697
|
+
if _callbacks_before_litellm_start is None:
|
|
1698
|
+
litellm.callbacks.extend([_instantiate_callback(cls) for cls in callback_classes]) # type: ignore
|
|
1699
|
+
_callbacks_before_litellm_start = [*litellm.callbacks] # type: ignore
|
|
1700
|
+
return True
|
|
1701
|
+
|
|
1702
|
+
else:
|
|
1703
|
+
# Put whatever is missing in the new callback classes to the existing callbacks.
|
|
1704
|
+
for cls in callback_classes:
|
|
1705
|
+
if not any(isinstance(cb, cls) for cb in _callbacks_before_litellm_start):
|
|
1706
|
+
logger.info(f"Adding missing callback {cls} to the existing callbacks.")
|
|
1707
|
+
_callbacks_before_litellm_start.append(_instantiate_callback(cls))
|
|
1708
|
+
|
|
1709
|
+
_reset_litellm_logging_callback_manager()
|
|
1710
|
+
|
|
1711
|
+
if LightningOpenTelemetry in callback_classes:
|
|
1712
|
+
# Check if tracer provider is malformed due to global tracer clear in tests.
|
|
1713
|
+
if not _check_tracer_provider():
|
|
1714
|
+
logger.warning(
|
|
1715
|
+
"Global tracer provider might have been cleared outside. Re-initializing OpenTelemetry callback."
|
|
1716
|
+
)
|
|
1717
|
+
_callbacks_before_litellm_start = [
|
|
1718
|
+
cb for cb in _callbacks_before_litellm_start if not isinstance(cb, LightningOpenTelemetry)
|
|
1719
|
+
] + [LightningOpenTelemetry(otlp_endpoint=otlp_endpoint, otlp_headers=otlp_headers)]
|
|
1720
|
+
else:
|
|
1721
|
+
logger.debug("Global tracer provider is valid. Reusing existing OpenTelemetry callback.")
|
|
1722
|
+
# Otherwise, we just skip the check for opentelemetry and use the existing callback.
|
|
1723
|
+
|
|
1724
|
+
litellm.callbacks.clear() # type: ignore
|
|
1725
|
+
litellm.callbacks.extend(_callbacks_before_litellm_start) # type: ignore
|
|
1726
|
+
return False
|
|
1727
|
+
|
|
1728
|
+
|
|
1729
|
+
def _check_tracer_provider() -> bool:
|
|
1730
|
+
"""Check if the global tracer provider is properly initialized.
|
|
1731
|
+
|
|
1732
|
+
We don't guarantee the tracer provider is our tracer provider.
|
|
1733
|
+
|
|
1734
|
+
Returns:
|
|
1735
|
+
bool: True if the tracer provider is valid, else False.
|
|
1736
|
+
"""
|
|
1737
|
+
if (
|
|
1738
|
+
hasattr(trace_api, "_TRACER_PROVIDER")
|
|
1739
|
+
and trace_api._TRACER_PROVIDER is not None # pyright: ignore[reportPrivateUsage]
|
|
1740
|
+
):
|
|
1741
|
+
return True
|
|
1742
|
+
return False
|