-
Notifications
You must be signed in to change notification settings - Fork 116
Add LiteLLM activity sample #343
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| # LiteLLM Activity | ||
|
|
||
| This sample calls an LLM provider through [LiteLLM](https://docs.litellm.ai/) from a Temporal Activity. | ||
|
|
||
| LLM calls perform network I/O and return nondeterministic results, so they must not run in Workflow code. The Workflow only schedules the Activity and records its result, keeping replay deterministic. | ||
|
|
||
| ## Prerequisites | ||
|
|
||
| Follow the [repository prerequisites](../README.md), then install the sample's dependencies: | ||
|
|
||
| ```bash | ||
| uv sync --group litellm | ||
| ``` | ||
|
|
||
| Set the API key expected by your provider. This example uses OpenAI by default: | ||
|
|
||
| ```bash | ||
| export OPENAI_API_KEY="your-api-key" | ||
| ``` | ||
|
|
||
| To use another [LiteLLM-supported provider](https://docs.litellm.ai/docs/providers), set its credentials and model name. For example: | ||
|
|
||
| ```bash | ||
| export ANTHROPIC_API_KEY="your-api-key" | ||
| export LITELLM_MODEL="anthropic/claude-sonnet-4-5-20250929" | ||
| ``` | ||
|
|
||
| Provider credentials stay in the Worker environment; they are not passed through the Workflow or stored in Event History. | ||
|
|
||
| ## Run the sample | ||
|
|
||
| Start a local Temporal server, then run these commands in separate terminals: | ||
|
|
||
| ```bash | ||
| # Terminal 1: run the Worker | ||
| uv run --group litellm python -m litellm_activity.worker | ||
|
|
||
| # Terminal 2: start a Workflow | ||
| uv run --group litellm python -m litellm_activity.starter \ | ||
| "Why should LLM calls run in Temporal Activities?" | ||
| ``` | ||
|
|
||
| The Activity gives each provider call a 30-second client timeout. The Workflow gives each Activity attempt 45 seconds, limits the entire Activity execution to two minutes, and retries failures up to three times with exponential backoff. LiteLLM's own retries are disabled so Temporal records and controls every attempt. | ||
|
|
||
|
|
||
| ## Tests | ||
|
|
||
| The tests replace the provider call and Activity with deterministic fakes, so they do not require an API key or make live LLM requests: | ||
|
|
||
| ```bash | ||
| uv run --group litellm pytest tests/litellm_activity | ||
| ``` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| """Call LiteLLM from a Temporal Activity.""" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| from litellm import ModelResponse, acompletion | ||
| from temporalio import activity | ||
|
|
||
| from litellm_activity.shared import LLMRequest | ||
|
|
||
|
|
||
| @activity.defn | ||
| async def call_litellm(request: LLMRequest) -> str: | ||
| """Make the nondeterministic network call outside Workflow code.""" | ||
| response = await acompletion( | ||
| model=request.model, | ||
| messages=[ | ||
| {"role": "system", "content": request.system_prompt}, | ||
| {"role": "user", "content": request.prompt}, | ||
| ], | ||
| timeout=30, | ||
| # Let Temporal own retries so every attempt is visible in Event History. | ||
| num_retries=0, | ||
| ) | ||
|
|
||
| if not isinstance(response, ModelResponse): | ||
| raise TypeError("Expected a non-streaming LiteLLM response") | ||
|
Comment on lines
+21
to
+22
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Optional, but it would be great to see a streaming example as well, where we demonstrate how to integrate LiteLLM with Temporal Workflow Streams.
Comment on lines
+21
to
+22
|
||
|
|
||
| content = response.choices[0].message.content | ||
| if not content: | ||
| raise ValueError("LiteLLM returned an empty response") | ||
|
Comment on lines
+25
to
+26
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does this happen often? I would opt to remove this and make the example less defensive. |
||
| return content | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| from dataclasses import dataclass | ||
|
|
||
|
|
||
| @dataclass | ||
| class LLMRequest: | ||
| """Serializable input shared by the client, Workflow, and Activity.""" | ||
|
|
||
| prompt: str | ||
| model: str = "openai/gpt-4o-mini" | ||
| system_prompt: str = "You are a helpful assistant." |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import asyncio | ||
| import os | ||
| import sys | ||
| import uuid | ||
|
|
||
| from temporalio.client import Client | ||
| from temporalio.envconfig import ClientConfig | ||
|
|
||
| from litellm_activity.shared import LLMRequest | ||
| from litellm_activity.worker import TASK_QUEUE | ||
| from litellm_activity.workflow import LiteLLMWorkflow | ||
|
|
||
|
|
||
| async def main() -> None: | ||
| config = ClientConfig.load_client_connect_config() | ||
| config.setdefault("target_host", "localhost:7233") | ||
| client = await Client.connect(**config) | ||
|
|
||
| prompt = " ".join(sys.argv[1:]) or "Explain Temporal in one sentence." | ||
| request = LLMRequest( | ||
| prompt=prompt, | ||
| model=os.getenv("LITELLM_MODEL", "openai/gpt-4o-mini"), | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Declare "openai/gpt-4o-mini" as a constant in shared.py and import it here to reduce duplication |
||
| ) | ||
| result = await client.execute_workflow( | ||
| LiteLLMWorkflow.run, | ||
| request, | ||
| id=f"litellm-activity-{uuid.uuid4()}", | ||
| task_queue=TASK_QUEUE, | ||
| ) | ||
| print(result) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| asyncio.run(main()) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| import asyncio | ||
| import logging | ||
|
|
||
| from temporalio.client import Client | ||
| from temporalio.envconfig import ClientConfig | ||
| from temporalio.worker import Worker | ||
|
|
||
| from litellm_activity.activities import call_litellm | ||
| from litellm_activity.workflow import LiteLLMWorkflow | ||
|
|
||
| TASK_QUEUE = "litellm-activity-task-queue" | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Move to shared.py since it's used here and in starter.py? |
||
|
|
||
|
|
||
| async def main() -> None: | ||
| logging.basicConfig(level=logging.INFO) | ||
|
|
||
| config = ClientConfig.load_client_connect_config() | ||
| config.setdefault("target_host", "localhost:7233") | ||
| client = await Client.connect(**config) | ||
|
|
||
| worker = Worker( | ||
| client, | ||
| task_queue=TASK_QUEUE, | ||
| workflows=[LiteLLMWorkflow], | ||
| activities=[call_litellm], | ||
| ) | ||
| await worker.run() | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| asyncio.run(main()) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| from datetime import timedelta | ||
|
|
||
| from temporalio import workflow | ||
| from temporalio.common import RetryPolicy | ||
|
|
||
| from litellm_activity.shared import LLMRequest | ||
|
|
||
| with workflow.unsafe.imports_passed_through(): | ||
| from litellm_activity.activities import call_litellm | ||
|
|
||
|
|
||
| @workflow.defn | ||
| class LiteLLMWorkflow: | ||
| @workflow.run | ||
| async def run(self, request: LLMRequest) -> str: | ||
| return await workflow.execute_activity( | ||
| call_litellm, | ||
| request, | ||
| start_to_close_timeout=timedelta(seconds=45), | ||
| schedule_to_close_timeout=timedelta(minutes=2), | ||
| retry_policy=RetryPolicy( | ||
| initial_interval=timedelta(seconds=1), | ||
| maximum_interval=timedelta(seconds=10), | ||
| maximum_attempts=3, | ||
| ), | ||
| ) |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,44 @@ | ||||||
| from typing import Any | ||||||
|
|
||||||
| from litellm import ModelResponse | ||||||
|
|
||||||
| from litellm_activity import activities | ||||||
| from litellm_activity.shared import LLMRequest | ||||||
|
|
||||||
|
|
||||||
| async def test_call_litellm(monkeypatch: Any) -> None: | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does this work?
Suggested change
|
||||||
| captured: dict[str, Any] = {} | ||||||
|
|
||||||
| async def mock_acompletion(**kwargs: Any) -> ModelResponse: | ||||||
| captured.update(kwargs) | ||||||
| return ModelResponse( | ||||||
| model="test-model", | ||||||
| choices=[ | ||||||
| { | ||||||
| "index": 0, | ||||||
| "finish_reason": "stop", | ||||||
| "message": {"role": "assistant", "content": "Hello from LiteLLM"}, | ||||||
| } | ||||||
| ], | ||||||
| ) | ||||||
|
|
||||||
| monkeypatch.setattr(activities, "acompletion", mock_acompletion) | ||||||
|
|
||||||
| result = await activities.call_litellm( | ||||||
| LLMRequest( | ||||||
| prompt="Hello", | ||||||
| model="test/model", | ||||||
| system_prompt="Be concise.", | ||||||
| ) | ||||||
| ) | ||||||
|
|
||||||
| assert result == "Hello from LiteLLM" | ||||||
| assert captured == { | ||||||
| "model": "test/model", | ||||||
| "messages": [ | ||||||
| {"role": "system", "content": "Be concise."}, | ||||||
| {"role": "user", "content": "Hello"}, | ||||||
| ], | ||||||
| "timeout": 30, | ||||||
| "num_retries": 0, | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import uuid | ||
|
|
||
| from temporalio import activity | ||
| from temporalio.client import Client | ||
| from temporalio.testing import WorkflowEnvironment | ||
| from temporalio.worker import Worker | ||
|
|
||
| from litellm_activity.shared import LLMRequest | ||
| from litellm_activity.workflow import LiteLLMWorkflow | ||
|
|
||
|
|
||
| async def test_litellm_workflow(client: Client, env: WorkflowEnvironment) -> None: | ||
| expected = "Temporal makes LLM calls durable." | ||
|
|
||
| @activity.defn(name="call_litellm") | ||
| async def mock_call_litellm(request: LLMRequest) -> str: | ||
| assert request.prompt == "What does Temporal add to LLM calls?" | ||
| return expected | ||
|
|
||
| task_queue = f"test-litellm-{uuid.uuid4()}" | ||
| async with Worker( | ||
| client, | ||
| task_queue=task_queue, | ||
| workflows=[LiteLLMWorkflow], | ||
| activities=[mock_call_litellm], | ||
| ): | ||
| result = await client.execute_workflow( | ||
| LiteLLMWorkflow.run, | ||
| LLMRequest(prompt="What does Temporal add to LLM calls?"), | ||
| id=f"test-litellm-{uuid.uuid4()}", | ||
| task_queue=task_queue, | ||
| ) | ||
|
|
||
| assert result == expected |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.