diff --git a/README.md b/README.md index 2954d242e..6801ccc71 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ Some examples require extra dependencies. See each sample's directory for specif without wrapping them in a workflow. * [open_telemetry](open_telemetry) - Trace workflows with OpenTelemetry. * [openai_agents](openai_agents) - Run OpenAI Agents SDK agents as durable Temporal workflows. +* [openrouter](openrouter) - Call OpenRouter from Activities: fan out a prompt batch, and pause instead of failing when the budget or credits run out. * [patching](patching) - Alter workflows safely with `patch` and `deprecate_patch`. * [polling](polling) - Recommended implementation of an activity that needs to periodically poll an external resource waiting its successful completion. * [prometheus](prometheus) - Configure Prometheus metrics on clients/workers. diff --git a/openai_agents/model_providers/README.md b/openai_agents/model_providers/README.md index df8f286e8..4b1a5f55b 100644 --- a/openai_agents/model_providers/README.md +++ b/openai_agents/model_providers/README.md @@ -30,6 +30,23 @@ The example uses Anthropic Claude by default but can be modified to use other Li Find more LiteLLM providers at: https://docs.litellm.ai/docs/providers +#### OpenRouter +Uses [OpenRouter](https://openrouter.ai/) as the model provider, so the agent can run on any of the hundreds of models OpenRouter serves through one API key. OpenRouter speaks the OpenAI Chat Completions API, so the stock `OpenAIProvider` works once it is pointed at OpenRouter's base URL. + +Start the OpenRouter worker: +```bash +export OPENROUTER_API_KEY="your_openrouter_api_key" + +uv run openai_agents/model_providers/run_openrouter_worker.py +``` + +Then run the example in a separate terminal: +```bash +uv run openai_agents/model_providers/run_openrouter_workflow.py +``` + +The workflow uses `openai/gpt-4o-mini`; change `OPENROUTER_MODEL` in [workflows/openrouter_workflow.py](workflows/openrouter_workflow.py) to any OpenRouter model slug, or to `openrouter/auto` to let OpenRouter pick. See the [openrouter](../../openrouter) sample for calling OpenRouter directly from Activities with cost tracking and budgets. + ### Extra #### GPT-OSS with Ollama diff --git a/openai_agents/model_providers/run_openrouter_worker.py b/openai_agents/model_providers/run_openrouter_worker.py new file mode 100644 index 000000000..eacd7433c --- /dev/null +++ b/openai_agents/model_providers/run_openrouter_worker.py @@ -0,0 +1,78 @@ +import asyncio +import logging +import os +from datetime import timedelta + +from agents import OpenAIProvider, set_tracing_disabled +from openai import AsyncOpenAI +from temporalio.client import Client +from temporalio.contrib.openai_agents import ModelActivityParameters, OpenAIAgentsPlugin +from temporalio.worker import Worker + +from openai_agents.model_providers.workflows.openrouter_workflow import ( + OpenRouterAgentWorkflow, +) + + +# @@@SNIPSTART python-openai-agents-openrouter-provider +def openrouter_provider() -> OpenAIProvider: + """OpenAI Agents SDK model provider backed by OpenRouter. + + OpenRouter speaks the OpenAI Chat Completions API, so the stock provider + works once it is pointed at OpenRouter's base URL. Client retries are off: + the plugin runs each model call as a Temporal Activity, and Temporal owns + the retries. + """ + default_headers: dict[str, str] = {} + # Optional app attribution for OpenRouter's rankings. + if referer := os.getenv("OPENROUTER_HTTP_REFERER"): + default_headers["HTTP-Referer"] = referer + if title := os.getenv("OPENROUTER_APP_TITLE"): + default_headers["X-OpenRouter-Title"] = title + + client = AsyncOpenAI( + base_url="https://openrouter.ai/api/v1", + api_key=os.environ["OPENROUTER_API_KEY"], + max_retries=0, + default_headers=default_headers or None, + ) + # Chat Completions is OpenRouter's primary endpoint; the Agents SDK + # defaults to the Responses API, which OpenRouter offers only in beta. + return OpenAIProvider(openai_client=client, use_responses=False) + + +# @@@SNIPEND + + +async def main(): + # Disable Agents SDK tracing: the default exporter sends traces to OpenAI's + # backend, which needs an OpenAI API key that this sample does not have. + set_tracing_disabled(disabled=True) + + logging.basicConfig(level=logging.WARNING) + logging.getLogger("temporalio.workflow").setLevel(logging.DEBUG) + + client = await Client.connect( + "localhost:7233", + plugins=[ + OpenAIAgentsPlugin( + model_params=ModelActivityParameters( + start_to_close_timeout=timedelta(seconds=60) + ), + model_provider=openrouter_provider(), + ), + ], + ) + + worker = Worker( + client, + task_queue="openai-agents-model-providers-task-queue", + workflows=[ + OpenRouterAgentWorkflow, + ], + ) + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/openai_agents/model_providers/run_openrouter_workflow.py b/openai_agents/model_providers/run_openrouter_workflow.py new file mode 100644 index 000000000..8fd4554d6 --- /dev/null +++ b/openai_agents/model_providers/run_openrouter_workflow.py @@ -0,0 +1,29 @@ +import asyncio + +from temporalio.client import Client +from temporalio.contrib.openai_agents import OpenAIAgentsPlugin + +from openai_agents.model_providers.workflows.openrouter_workflow import ( + OpenRouterAgentWorkflow, +) + + +async def main(): + client = await Client.connect( + "localhost:7233", + plugins=[ + OpenAIAgentsPlugin(), + ], + ) + + result = await client.execute_workflow( + OpenRouterAgentWorkflow.run, + "What's the weather in Tokyo?", + id="openai-agents-openrouter-workflow-id", + task_queue="openai-agents-model-providers-task-queue", + ) + print(f"Result: {result}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/openai_agents/model_providers/workflows/openrouter_workflow.py b/openai_agents/model_providers/workflows/openrouter_workflow.py new file mode 100644 index 000000000..8e58418ab --- /dev/null +++ b/openai_agents/model_providers/workflows/openrouter_workflow.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from agents import Agent, Runner, function_tool +from temporalio import workflow + +# Any OpenRouter model slug works here. A fixed, tool-capable model keeps the +# sample reproducible; swap in "openrouter/auto" to let OpenRouter choose. +OPENROUTER_MODEL = "openai/gpt-4o-mini" + + +@workflow.defn +class OpenRouterAgentWorkflow: + @workflow.run + async def run(self, prompt: str) -> str: + @function_tool + def get_weather(city: str): + workflow.logger.debug(f"Getting weather for {city}") + return f"The weather in {city} is sunny." + + agent = Agent( + name="Assistant", + instructions="You only respond in haikus. When asked about the weather always use the tool to get the current weather.", + model=OPENROUTER_MODEL, + tools=[get_weather], + ) + + result = await Runner.run(agent, prompt) + return result.final_output diff --git a/openrouter/README.md b/openrouter/README.md new file mode 100644 index 000000000..0e26c5b01 --- /dev/null +++ b/openrouter/README.md @@ -0,0 +1,75 @@ +# OpenRouter + +These samples call [OpenRouter](https://openrouter.ai/) from Temporal Activities. OpenRouter serves hundreds of models from many providers behind one OpenAI-compatible API and one API key, and picks providers and models per request. Temporal handles everything around those calls: retries with backoff, fan-out with bounded concurrency, crash recovery, pausing for a human, and a durable per-attempt record of what was called and what it cost. + +| Sample | Description | +|--------|-------------| +| [prompt_batch](prompt_batch) | Fan one OpenRouter call out per prompt with OpenRouter's Auto Router, and collect answer, model, and cost per prompt. Shows Temporal-owned retries, `Retry-After` handling, and retries served for free from OpenRouter's response cache. Start here. | +| [budget_gate](budget_gate) | The same batch, but it pauses instead of failing when money runs out, whether a soft budget in the Workflow or OpenRouter's own "insufficient credits" error, and resumes on a `raise_budget` Update. | + +For OpenRouter as the model provider behind the [OpenAI Agents SDK plugin](../openai_agents), see [openai_agents/model_providers](../openai_agents/model_providers#openrouter). + +## Prerequisites + +1. Follow the [repository prerequisites](../README.md), then install this sample's dependencies: + + ```bash + uv sync --group openrouter + ``` + +2. Start a local dev server with the [Temporal CLI](https://docs.temporal.io/cli): + + ```bash + temporal server start-dev + ``` + +3. Set an [OpenRouter API key](https://openrouter.ai/settings/keys) in the Worker's environment. A few cents of credit is enough for these samples. + + ```bash + export OPENROUTER_API_KEY="sk-or-v1-..." + ``` + + Optional: set `OPENROUTER_HTTP_REFERER` and `OPENROUTER_APP_TITLE` for [app attribution](https://openrouter.ai/docs/app-attribution) in OpenRouter's rankings. + +The API key stays in the Worker process. Prompts, answers, models, and costs go through the Workflow and are recorded in Event History; the key never does. + +## Running a sample + +Each sample has a Worker and a starter. Run them in separate terminals: + +```bash +# Terminal 1 +uv run --group openrouter openrouter/prompt_batch/run_worker.py + +# Terminal 2 +uv run --group openrouter openrouter/prompt_batch/run_workflow.py "Explain retries in one sentence." "Write a haiku about databases." +``` + +## How the Activity calls OpenRouter + +[activities.py](activities.py) uses the `openai` SDK pointed at `https://openrouter.ai/api/v1`, which is the setup OpenRouter documents for OpenAI-compatible clients. OpenRouter-specific fields go in `extra_body`. Four things matter for durable execution: + +- **Temporal owns retries.** The client is created with `max_retries=0`, so every attempt is one HTTP call and shows up in Event History. If you use OpenRouter's official `openrouter` package instead, pass `retry_config=RetryConfig("none", ...)`: by default it retries 5xx and connection errors for up to an hour, invisibly. +- **Errors are classified.** 408, 429, and 5xx raise a retryable `ApplicationError`; 400, 401, 402 (out of credits), 403 (moderation), and other 4xx raise a non-retryable one. A `Retry-After` header becomes the next retry delay. OpenRouter can also return HTTP 200 with an `error` body and no `choices`; the Activity checks for that. +- **Retries are free when the first call succeeded.** The Activity sends `X-OpenRouter-Cache: true`, so if a Worker dies after OpenRouter answered but before Temporal recorded the result, the retried, byte-identical request is served from OpenRouter's response cache and billed at $0. Nothing per-attempt goes in the request body, so attempts stay identical. +- **Heartbeats.** The Activity heartbeats so a dead Worker is detected after `heartbeat_timeout` (10s) rather than after the full `start_to_close_timeout`. + +Each result carries the concrete model OpenRouter chose, OpenRouter's reported `usage.cost`, the generation id, and the cache status. + +## What Temporal does and does not guarantee + +Activities are at-least-once. If a Worker dies mid-call, the retry re-sends the request; within the cache TTL that retry costs nothing, but two identical requests in flight at the same time both miss the cache and both bill. Completed Activities are never re-run, so a restarted batch resumes at the first unfinished prompt. + +OpenRouter decides which provider and model serve a request, in milliseconds (Auto Router, `models` fallback lists, provider preferences). Temporal decides what happens over time: waiting out a rate limit, surviving a Worker crash, pausing for hours until a human acts, and keeping the audit trail. + +## Batch size + +Each Activity adds a few events to the Workflow's Event History, and every answer is part of the Workflow result. These samples cap a batch at 100 prompts. For larger batches, use one Workflow per slice, or the pattern in [batch_sliding_window](../batch_sliding_window) with continue-as-new. + +## Tests + +The tests replace OpenRouter with a fake HTTP transport and the Activity with a fake, so they need no API key and make no network calls: + +```bash +uv run --group openrouter pytest tests/openrouter +``` diff --git a/openrouter/__init__.py b/openrouter/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openrouter/activities.py b/openrouter/activities.py new file mode 100644 index 000000000..9de19c4bb --- /dev/null +++ b/openrouter/activities.py @@ -0,0 +1,196 @@ +import asyncio +import json +import os +from datetime import timedelta +from typing import Any, Mapping, NoReturn, Optional + +from openai import APIStatusError, AsyncOpenAI +from temporalio import activity +from temporalio.exceptions import ApplicationError + +from openrouter.shared import ( + OPENROUTER_BASE_URL, + OpenRouterRequest, + OpenRouterResult, +) + + +def build_client(api_key: Optional[str] = None) -> AsyncOpenAI: + """OpenAI SDK client pointed at OpenRouter. + + Client-side retries are disabled so that Temporal owns every retry and each + attempt is visible in Event History. (OpenRouter's official SDKs retry 5xx + and connection errors for up to an hour by default; if you use one of them + instead, turn that off too.) + """ + default_headers: dict[str, str] = {} + # App attribution is optional. When set, OpenRouter lists your app in its + # public rankings; add X-OpenRouter-App-Visibility: hidden to opt out. + if referer := os.getenv("OPENROUTER_HTTP_REFERER"): + default_headers["HTTP-Referer"] = referer + if title := os.getenv("OPENROUTER_APP_TITLE"): + default_headers["X-OpenRouter-Title"] = title + return AsyncOpenAI( + base_url=OPENROUTER_BASE_URL, + api_key=api_key or os.environ["OPENROUTER_API_KEY"], + max_retries=0, + timeout=60.0, + default_headers=default_headers or None, + ) + + +def error_type(status: int) -> str: + """Error type recorded in Event History for an OpenRouter HTTP status.""" + return f"OpenRouterHTTP{status}" + + +def _retry_after(headers: Mapping[str, str]) -> Optional[timedelta]: + value = headers.get("retry-after") + if value is None: + return None + try: + return timedelta(seconds=float(value)) + except ValueError: + # HTTP-date form; let the Activity retry policy decide the delay. + return None + + +def raise_for_status(status: int, message: str, headers: Mapping[str, str]) -> NoReturn: + """Turn an OpenRouter error into an ApplicationError with the right retry posture. + + Retryable: 408 (timeout), 429 (rate limited, honoring Retry-After), and + any 5xx (500, 502 model down, 503 no provider available, 524, 529). + Non-retryable: other 4xx. 400 is a bad request, 401 a bad key, 402 means + the key is out of credits, 403 a moderation or permission block. Retrying + those only costs time. + """ + retryable = status in (408, 429) or status >= 500 + raise ApplicationError( + f"OpenRouter returned HTTP {status}: {message}", + {"status": status}, + type=error_type(status), + non_retryable=not retryable, + next_retry_delay=_retry_after(headers) if retryable else None, + ) + + +def _error_message(body: Any) -> str: + if isinstance(body, dict): + error = body.get("error") + if isinstance(error, dict) and isinstance(error.get("message"), str): + return error["message"] + return "" + + +def _content_to_text(content: Any) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + return "\n".join( + part["text"] + for part in content + if isinstance(part, dict) and isinstance(part.get("text"), str) + ) + return "" + + +async def _heartbeat_forever(interval: timedelta) -> None: + while True: + await asyncio.sleep(interval.total_seconds()) + activity.heartbeat(activity.info().attempt) + + +class OpenRouterActivities: + def __init__(self, client: AsyncOpenAI) -> None: + self._client = client + + # @@@SNIPSTART python-openrouter-call-activity + @activity.defn + async def call_openrouter(self, request: OpenRouterRequest) -> OpenRouterResult: + """One chat completion. One HTTP call per attempt; Temporal retries.""" + # Heartbeat so a killed Worker is noticed after heartbeat_timeout + # rather than after the full start_to_close_timeout. + heartbeat_timeout = activity.info().heartbeat_timeout + heartbeat_task = ( + asyncio.create_task(_heartbeat_forever(heartbeat_timeout / 2)) + if heartbeat_timeout + else None + ) + try: + return await self._send(request) + finally: + if heartbeat_task: + heartbeat_task.cancel() + + async def _send(self, request: OpenRouterRequest) -> OpenRouterResult: + extra_body: dict[str, Any] = {} + if request.fallback_models: + # OpenRouter tries these in order within the same request. + extra_body["models"] = request.fallback_models + elif request.model == "openrouter/auto": + extra_body["plugins"] = [ + {"id": "auto-router", "cost_tier": request.cost_tier} + ] + model = request.fallback_models[0] if request.fallback_models else request.model + + try: + raw = await self._client.chat.completions.with_raw_response.create( + model=model, + messages=[{"role": "user", "content": request.prompt}], + extra_body=extra_body or None, + extra_headers={ + # Ask OpenRouter to cache the successful response. A retry + # of the byte-identical request within the TTL is served + # from cache and billed at $0. + "X-OpenRouter-Cache": "true", + "X-OpenRouter-Cache-TTL": str(request.cache_ttl_seconds), + }, + ) + except APIStatusError as e: + raise_for_status( + e.status_code, _error_message(e.body) or e.message, e.response.headers + ) + # Connection errors and timeouts propagate as-is: Temporal retries them. + + payload = json.loads(raw.text) + error = payload.get("error") + if isinstance(error, dict): + # OpenRouter can return HTTP 200 with an error body and no choices + # when the upstream provider failed after the request was accepted. + raise_for_status( + int(error.get("code") or 500), _error_message(payload), raw.headers + ) + + choices = payload.get("choices") or [] + usage = payload.get("usage") or {} + cost = usage.get("cost") + result = OpenRouterResult( + prompt=request.prompt, + model=str(payload.get("model", model)), + answer=_content_to_text((choices[0].get("message") or {}).get("content")) + if choices + else "", + cost_usd=float(cost) if isinstance(cost, (int, float)) else 0.0, + generation_id=str(payload.get("id", "")), + cache_status=raw.headers.get("x-openrouter-cache-status", ""), + ) + activity.logger.info( + "OpenRouter call completed: attempt=%d model=%s cost_usd=%.6f cache=%s id=%s", + activity.info().attempt, + result.model, + result.cost_usd, + result.cache_status or "-", + result.generation_id, + ) + + if request.fail_once_after_call and activity.info().attempt == 1: + # Demo hook: the Worker "crashes" after the response arrived. The + # retry re-sends the identical request and gets a cache hit. + raise ApplicationError( + "Simulated failure after the response was received", + type="SimulatedFailure", + ) + + return result + + # @@@SNIPEND diff --git a/openrouter/budget_gate/README.md b/openrouter/budget_gate/README.md new file mode 100644 index 000000000..a017b1d1d --- /dev/null +++ b/openrouter/budget_gate/README.md @@ -0,0 +1,81 @@ +# Budget gate + +A prompt batch that pauses instead of failing when money runs out, and resumes when a human raises the budget. + +## What this sample demonstrates + +- A soft budget enforced by the Workflow from the cost OpenRouter reports on every response. When the next call would exceed it, the batch parks on `workflow.wait_condition` and stays parked for as long as it takes (hours, days) without a Worker doing anything. +- OpenRouter's own "insufficient credits" error (HTTP 402, raised when the API key hits its credit limit) handled the same way: the failing prompt parks instead of failing, and is re-run after the operator tops up. +- A `raise_budget` Update to resume, with a validator that rejects lowering the budget, and a `spend_report` Query showing spend, reservations, the ledger, and which prompts are parked and why. +- Completed prompts are never re-run. A restarted Worker, or a resumed batch, continues from the first unfinished prompt. + +## Running the sample + +Set `OPENROUTER_API_KEY` (see the [parent README](../README.md)), then: + +```bash +# Terminal 1 +uv run --group openrouter openrouter/budget_gate/run_worker.py + +# Terminal 2: a budget small enough to pause after a prompt or two +uv run --group openrouter openrouter/budget_gate/run_workflow.py --budget-usd 0.0002 --estimate-usd 0.0001 --max-concurrency 1 +``` + +The starter prints the Workflow ID and waits. In a third terminal, watch it pause: + +```bash +temporal workflow query -w --type spend_report +``` + +```json +{ + "budget_usd": 0.0002, + "spent_usd": 0.000629, + "reserved_usd": 0, + "completed": 2, + "paused": { "Name two causes of HTTP 429.": "soft_budget_exhausted" }, + "ledger": [ ... ] +} +``` + +Raise the budget to resume: + +```bash +uv run --group openrouter openrouter/budget_gate/raise_budget.py 0.01 +# or: temporal workflow update execute -w --name raise_budget --input '0.01' +``` + +The starter then prints the completed batch: + +``` +[deepseek/deepseek-v4-flash-0731] $0.000096 cache=MISS Define durable execution in one sentence. +[deepseek/deepseek-v4-flash-0731] $0.000532 cache=MISS Why do LLM calls belong in Activities? +[deepseek/deepseek-v4-flash-0731] $0.000180 cache=MISS Name two causes of HTTP 429. +[deepseek/deepseek-v4-flash-0731] $0.000065 cache=MISS What does a heartbeat timeout detect? + +Total cost: $0.000873 +``` + +`temporal workflow show -w ` shows the pause as a `TimerStarted` (the approval timeout), then `WorkflowExecutionUpdateAccepted` and `WorkflowExecutionUpdateCompleted` when the budget is raised, `TimerCanceled`, and the remaining Activities. + +### Out of credits at OpenRouter + +Set a credit limit on your API key in the [OpenRouter dashboard](https://openrouter.ai/settings/keys) below what the batch needs, and run with a generous soft budget. When OpenRouter returns 402, the prompt parks with reason `insufficient_credits`. Raise the key's limit, then send `raise_budget` with the current budget value to resume; the parked prompt is re-run. + +If nobody raises the budget within `--approval-timeout-seconds` (default one hour), the batch completes with the remaining prompts listed as skipped. + +## What the soft budget does and does not guarantee + +The cost of a call is only known after the response, so the Workflow reserves `--estimate-usd` per in-flight call and checks `spent + reserved + estimate <= budget` before starting one. Overshoot is therefore bounded by `max_concurrency * estimate`, plus the gap between the estimate and the real cost of the calls already in flight. In the run above, the second prompt alone cost more than the whole budget; the third prompt is where the gate closed. To bound the cost of a single call, set `provider.max_price` in the request (see OpenRouter's provider routing docs). The hard cap is the credit limit on the OpenRouter API key, which is what produces the 402. + +While parked, in-flight prompts keep their concurrency slots and every remaining prompt parks on the same condition, so nothing spends until the budget is raised. + +## Files + +| File | Description | +|------|-------------| +| [workflow.py](workflow.py) | `BudgetGateWorkflow`: reservation ledger, pause on soft budget or 402, `raise_budget` Update with validator, `spend_report` Query. | +| [run_worker.py](run_worker.py) | Builds the OpenRouter client once and runs the Worker. | +| [run_workflow.py](run_workflow.py) | Starts a batch with a budget and prints the result. | +| [raise_budget.py](raise_budget.py) | Sends the `raise_budget` Update. | +| [../activities.py](../activities.py) | `call_openrouter`, shared with [prompt_batch](../prompt_batch). | diff --git a/openrouter/budget_gate/__init__.py b/openrouter/budget_gate/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openrouter/budget_gate/raise_budget.py b/openrouter/budget_gate/raise_budget.py new file mode 100644 index 000000000..b658564d9 --- /dev/null +++ b/openrouter/budget_gate/raise_budget.py @@ -0,0 +1,30 @@ +import asyncio +import sys + +from temporalio.client import Client +from temporalio.envconfig import ClientConfig + +from openrouter.budget_gate.workflow import BudgetGateWorkflow +from openrouter.shared import BatchResult + + +async def main() -> None: + if len(sys.argv) != 3: + print("usage: raise_budget.py ") + raise SystemExit(2) + workflow_id, new_budget = sys.argv[1], float(sys.argv[2]) + + config = ClientConfig.load_client_connect_config() + config.setdefault("target_host", "localhost:7233") + client = await Client.connect(**config) + + handle = client.get_workflow_handle(workflow_id, result_type=BatchResult) + report = await handle.execute_update(BudgetGateWorkflow.raise_budget, new_budget) + print( + f"Budget is now ${report.budget_usd:.6f}; spent ${report.spent_usd:.6f} " + f"across {report.completed} prompts; paused: {report.paused or 'none'}" + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/openrouter/budget_gate/run_worker.py b/openrouter/budget_gate/run_worker.py new file mode 100644 index 000000000..40f115c10 --- /dev/null +++ b/openrouter/budget_gate/run_worker.py @@ -0,0 +1,32 @@ +import asyncio +import logging + +from temporalio.client import Client +from temporalio.envconfig import ClientConfig +from temporalio.worker import Worker + +from openrouter.activities import OpenRouterActivities, build_client +from openrouter.budget_gate.workflow import BudgetGateWorkflow +from openrouter.shared import BUDGET_GATE_TASK_QUEUE + + +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) + + activities = OpenRouterActivities(build_client()) + + worker = Worker( + client, + task_queue=BUDGET_GATE_TASK_QUEUE, + workflows=[BudgetGateWorkflow], + activities=[activities.call_openrouter], + ) + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/openrouter/budget_gate/run_workflow.py b/openrouter/budget_gate/run_workflow.py new file mode 100644 index 000000000..b9a44fa65 --- /dev/null +++ b/openrouter/budget_gate/run_workflow.py @@ -0,0 +1,74 @@ +import argparse +import asyncio +import uuid + +from temporalio.client import Client +from temporalio.envconfig import ClientConfig + +from openrouter.budget_gate.workflow import BudgetGateWorkflow +from openrouter.shared import BUDGET_GATE_TASK_QUEUE, DEFAULT_MODEL, BudgetGateInput + +DEFAULT_PROMPTS = [ + "Explain retries in one sentence.", + "Write a haiku about databases.", + "Name three uses for embeddings.", + "Summarize eventual consistency in two sentences.", + "What is a task queue?", + "Give one reason to use idempotency keys.", +] + + +async def main() -> None: + parser = argparse.ArgumentParser( + description="Run a prompt batch that pauses when the budget runs out." + ) + parser.add_argument("prompts", nargs="*", default=DEFAULT_PROMPTS) + parser.add_argument("--model", default=DEFAULT_MODEL) + parser.add_argument( + "--budget-usd", + type=float, + default=0.001, + help="Soft budget. The default is small enough to pause a few prompts in.", + ) + parser.add_argument("--estimate-usd", type=float, default=0.0005) + parser.add_argument("--max-concurrency", type=int, default=2) + parser.add_argument("--approval-timeout-seconds", type=int, default=3600) + args = parser.parse_args() + + config = ClientConfig.load_client_connect_config() + config.setdefault("target_host", "localhost:7233") + client = await Client.connect(**config) + + workflow_id = f"openrouter-budget-gate-{uuid.uuid4()}" + handle = await client.start_workflow( + BudgetGateWorkflow.run, + BudgetGateInput( + prompts=args.prompts, + budget_usd=args.budget_usd, + estimated_cost_usd=args.estimate_usd, + model=args.model, + max_concurrency=args.max_concurrency, + approval_timeout_seconds=args.approval_timeout_seconds, + ), + id=workflow_id, + task_queue=BUDGET_GATE_TASK_QUEUE, + ) + print(f"Started {workflow_id}") + print("While it runs:") + print(f" temporal workflow query -w {workflow_id} --type spend_report") + print(f" uv run openrouter/budget_gate/raise_budget.py {workflow_id} 0.05") + print("Waiting for the batch to finish...\n", flush=True) + + result = await handle.result() + for r in result.results: + print( + f"[{r.model}] ${r.cost_usd:.6f} cache={r.cache_status or '-'} {r.prompt}" + ) + for s in result.skipped: + print(f"[skipped: {s.reason}] {s.prompt}") + print(f"\nTotal cost: ${result.total_cost_usd:.6f}") + print(f"Inspect: temporal workflow show -w {workflow_id}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/openrouter/budget_gate/workflow.py b/openrouter/budget_gate/workflow.py new file mode 100644 index 000000000..a7f5795ce --- /dev/null +++ b/openrouter/budget_gate/workflow.py @@ -0,0 +1,202 @@ +import asyncio +from datetime import timedelta +from typing import Optional, Union + +from temporalio import workflow +from temporalio.exceptions import ActivityError, ApplicationError + +from openrouter.prompt_batch.workflow import OPENROUTER_RETRY_POLICY + +# The shared dataclasses are passed through the sandbox so that objects the +# Activity returns are the same classes the Workflow compares against. +with workflow.unsafe.imports_passed_through(): + from openrouter.activities import OpenRouterActivities, error_type + from openrouter.shared import ( + MAX_PROMPTS_PER_BATCH, + BatchResult, + BudgetGateInput, + LedgerEntry, + OpenRouterRequest, + OpenRouterResult, + SkippedPrompt, + SpendReport, + ) + +INSUFFICIENT_CREDITS = error_type(402) + + +@workflow.defn +class BudgetGateWorkflow: + """A prompt batch that pauses instead of failing when money runs out. + + Two things can pause it: the soft budget in the input (checked against the + cost OpenRouter reports per response) and OpenRouter itself returning 402 + because the API key hit its credit limit. Either way the batch parks until + a `raise_budget` Update arrives, then resumes exactly where it stopped. + Completed prompts are never re-run. + """ + + def __init__(self) -> None: + self._budget_usd = 0.0 + self._spent_usd = 0.0 + self._reserved_usd = 0.0 + # Bumped by every raise_budget Update, so a prompt parked on a 402 can + # tell that the operator acted even if the soft budget did not change. + self._budget_version = 0 + self._ledger: list[LedgerEntry] = [] + self._paused: dict[str, str] = {} + + @workflow.run + async def run(self, gate: BudgetGateInput) -> BatchResult: + if len(gate.prompts) > MAX_PROMPTS_PER_BATCH: + raise ApplicationError( + f"Batch has {len(gate.prompts)} prompts; the limit is " + f"{MAX_PROMPTS_PER_BATCH}.", + non_retryable=True, + ) + self._budget_usd = gate.budget_usd + semaphore = asyncio.Semaphore(gate.max_concurrency) + try: + outcomes = await asyncio.gather( + *(self._answer(prompt, gate, semaphore) for prompt in gate.prompts) + ) + finally: + # Let an in-flight raise_budget Update finish before returning. + await workflow.wait_condition(workflow.all_handlers_finished) + + results = [o for o in outcomes if isinstance(o, OpenRouterResult)] + skipped = [o for o in outcomes if isinstance(o, SkippedPrompt)] + return BatchResult( + results=results, + skipped=skipped, + total_cost_usd=round(self._spent_usd, 6), + ) + + # @@@SNIPSTART python-openrouter-budget-gate-handlers + @workflow.update + def raise_budget(self, new_budget_usd: float) -> SpendReport: + """Raise the soft budget and wake every parked prompt. + + Send the current budget unchanged to resume after topping up credits + in the OpenRouter dashboard. + """ + self._budget_usd = new_budget_usd + self._budget_version += 1 + return self.spend_report() + + @raise_budget.validator + def validate_raise_budget(self, new_budget_usd: float) -> None: + if new_budget_usd < self._budget_usd: + raise ValueError( + f"New budget ${new_budget_usd} is below the current budget " + f"${self._budget_usd}; the budget can only go up." + ) + + @workflow.query + def spend_report(self) -> SpendReport: + return SpendReport( + budget_usd=self._budget_usd, + spent_usd=round(self._spent_usd, 6), + reserved_usd=round(self._reserved_usd, 6), + completed=len(self._ledger), + paused=dict(self._paused), + paused_reason=next(iter(self._paused.values()), None), + ledger=list(self._ledger), + ) + + # @@@SNIPEND + + async def _answer( + self, prompt: str, gate: BudgetGateInput, semaphore: asyncio.Semaphore + ) -> Union[OpenRouterResult, SkippedPrompt]: + timeout = timedelta(seconds=gate.approval_timeout_seconds) + async with semaphore: + if not await self._reserve(prompt, gate.estimated_cost_usd, timeout): + return SkippedPrompt(prompt=prompt, reason="soft_budget_exhausted") + try: + while True: + try: + result = await workflow.execute_activity_method( + OpenRouterActivities.call_openrouter, + OpenRouterRequest(prompt=prompt, model=gate.model), + start_to_close_timeout=timedelta(seconds=90), + heartbeat_timeout=timedelta(seconds=10), + retry_policy=OPENROUTER_RETRY_POLICY, + ) + break + except ActivityError as e: + cause = e.cause + if ( + isinstance(cause, ApplicationError) + and cause.type == INSUFFICIENT_CREDITS + ): + # The API key is out of credits. Park until the + # operator tops up and sends raise_budget. + if await self._wait_for_more_credits(prompt, timeout): + continue + return SkippedPrompt( + prompt=prompt, reason="insufficient_credits" + ) + reason = ( + cause.type + if isinstance(cause, ApplicationError) and cause.type + else type(cause).__name__ + ) + workflow.logger.warning( + "Skipping prompt %r: %s", prompt, reason + ) + return SkippedPrompt(prompt=prompt, reason=reason) + self._spent_usd += result.cost_usd + self._ledger.append( + LedgerEntry( + prompt=prompt, + model=result.model, + cost_usd=result.cost_usd, + generation_id=result.generation_id, + cache_status=result.cache_status, + ) + ) + return result + finally: + self._reserved_usd -= gate.estimated_cost_usd + + # @@@SNIPSTART python-openrouter-budget-gate-pause + async def _reserve(self, prompt: str, estimate: float, timeout: timedelta) -> bool: + """Reserve `estimate` against the budget, parking until it fits.""" + + def fits() -> bool: + return self._spent_usd + self._reserved_usd + estimate <= self._budget_usd + + if not fits(): + workflow.logger.info( + "Soft budget reached (spent $%.6f of $%.6f); pausing %r", + self._spent_usd, + self._budget_usd, + prompt, + ) + self._paused[prompt] = "soft_budget_exhausted" + try: + # Durable pause: survives Worker restarts and can wait for hours. + await workflow.wait_condition(fits, timeout=timeout) + except asyncio.TimeoutError: + return False + finally: + self._paused.pop(prompt, None) + self._reserved_usd += estimate + return True + + # @@@SNIPEND + + async def _wait_for_more_credits(self, prompt: str, timeout: timedelta) -> bool: + seen = self._budget_version + workflow.logger.info("OpenRouter key is out of credits; pausing %r", prompt) + self._paused[prompt] = "insufficient_credits" + try: + await workflow.wait_condition( + lambda: self._budget_version > seen, timeout=timeout + ) + return True + except asyncio.TimeoutError: + return False + finally: + self._paused.pop(prompt, None) diff --git a/openrouter/prompt_batch/README.md b/openrouter/prompt_batch/README.md new file mode 100644 index 000000000..8c511cdbc --- /dev/null +++ b/openrouter/prompt_batch/README.md @@ -0,0 +1,72 @@ +# Prompt batch + +Fan one OpenRouter call out per prompt and collect the answers. + +## What this sample demonstrates + +- One Activity per prompt, run concurrently under a semaphore, so a slow or failing prompt never blocks the others. +- OpenRouter's Auto Router (`openrouter/auto`) choosing a model per prompt, with the chosen model and OpenRouter's reported cost returned for each. +- Temporal-owned retries: 429 and 5xx retry with backoff and honor `Retry-After`; 4xx errors fail fast and the prompt is reported as skipped instead of failing the batch. +- Retries served from OpenRouter's response cache at $0 when the first call already succeeded. + +## Running the sample + +Set `OPENROUTER_API_KEY` (see the [parent README](../README.md)), then: + +```bash +# Terminal 1 +uv run --group openrouter openrouter/prompt_batch/run_worker.py + +# Terminal 2 +uv run --group openrouter openrouter/prompt_batch/run_workflow.py "Explain retries in one sentence." "Write a haiku about databases." +``` + +Output: + +``` +Starting openrouter-prompt-batch-6923ab3d-... + +[deepseek/deepseek-v4-flash-0731] $0.000022 cache=MISS + Q: Explain retries in one sentence. + A: Retries are the automatic re-attempts of a failed operation, often after a delay ... + +[deepseek/deepseek-v4-flash-0731] $0.000525 cache=MISS + Q: Write a haiku about databases. + A: Columns and table, ... + +Total cost: $0.000547 +Inspect: temporal workflow show -w openrouter-prompt-batch-6923ab3d-... +``` + +### See a retry that costs nothing + +`--fail-once` makes each Activity fail its first attempt *after* OpenRouter has answered, which is what a Worker crash at the wrong moment looks like. The retry re-sends the identical request and OpenRouter serves it from cache: + +```bash +uv run --group openrouter openrouter/prompt_batch/run_workflow.py --fail-once "Explain idempotency in one sentence." +``` + +``` +[deepseek/deepseek-v4-flash-0731] $0.000000 cache=HIT + Q: Explain idempotency in one sentence. + A: Idempotency means that an operation can be applied multiple times, but the result is the same ... + +Total cost: $0.000000 +``` + +`temporal workflow show -w ` shows both attempts. The cache is keyed on your API key and the exact request body, so running the same prompt again within the cache TTL (10 minutes by default here) is also a hit. OpenRouter writes the cache shortly after the response completes; a retry that arrives before that write lands is a `MISS` and is billed, which you may see occasionally with the one-second retry interval used here. + +### Other options + +- `--model `: any OpenRouter model instead of the Auto Router. +- `--max-concurrency N`: how many prompts are in flight at once (default 5). + +## Files + +| File | Description | +|------|-------------| +| [workflow.py](workflow.py) | `PromptBatchWorkflow`: fan-out under a semaphore, per-prompt failure handling, retry policy. | +| [run_worker.py](run_worker.py) | Builds the OpenRouter client once and runs the Worker. | +| [run_workflow.py](run_workflow.py) | Starts a batch and prints answer, model, cost, and cache status per prompt. | +| [../activities.py](../activities.py) | `call_openrouter`: one HTTP call per attempt, error classification, cache headers, heartbeats. | +| [../shared.py](../shared.py) | Dataclasses shared by starter, Workflow, and Activity. | diff --git a/openrouter/prompt_batch/__init__.py b/openrouter/prompt_batch/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openrouter/prompt_batch/run_worker.py b/openrouter/prompt_batch/run_worker.py new file mode 100644 index 000000000..3f527733d --- /dev/null +++ b/openrouter/prompt_batch/run_worker.py @@ -0,0 +1,34 @@ +import asyncio +import logging + +from temporalio.client import Client +from temporalio.envconfig import ClientConfig +from temporalio.worker import Worker + +from openrouter.activities import OpenRouterActivities, build_client +from openrouter.prompt_batch.workflow import PromptBatchWorkflow +from openrouter.shared import PROMPT_BATCH_TASK_QUEUE + + +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) + + # One OpenRouter client for the Worker's lifetime, shared by every + # concurrent Activity. Reads OPENROUTER_API_KEY from the environment. + activities = OpenRouterActivities(build_client()) + + worker = Worker( + client, + task_queue=PROMPT_BATCH_TASK_QUEUE, + workflows=[PromptBatchWorkflow], + activities=[activities.call_openrouter], + ) + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/openrouter/prompt_batch/run_workflow.py b/openrouter/prompt_batch/run_workflow.py new file mode 100644 index 000000000..2e7249d17 --- /dev/null +++ b/openrouter/prompt_batch/run_workflow.py @@ -0,0 +1,61 @@ +import argparse +import asyncio +import uuid + +from temporalio.client import Client +from temporalio.envconfig import ClientConfig + +from openrouter.prompt_batch.workflow import PromptBatchWorkflow +from openrouter.shared import DEFAULT_MODEL, PROMPT_BATCH_TASK_QUEUE, BatchInput + +DEFAULT_PROMPTS = [ + "Explain retries in one sentence.", + "Write a haiku about databases.", +] + + +async def main() -> None: + parser = argparse.ArgumentParser( + description="Run a prompt batch through OpenRouter." + ) + parser.add_argument("prompts", nargs="*", default=DEFAULT_PROMPTS) + parser.add_argument("--model", default=DEFAULT_MODEL) + parser.add_argument("--max-concurrency", type=int, default=5) + parser.add_argument( + "--fail-once", + action="store_true", + help="Fail each Activity's first attempt after the response arrives, " + "so the retry shows a cache hit billed at $0.", + ) + args = parser.parse_args() + + config = ClientConfig.load_client_connect_config() + config.setdefault("target_host", "localhost:7233") + client = await Client.connect(**config) + + workflow_id = f"openrouter-prompt-batch-{uuid.uuid4()}" + print(f"Starting {workflow_id}", flush=True) + result = await client.execute_workflow( + PromptBatchWorkflow.run, + BatchInput( + prompts=args.prompts, + model=args.model, + max_concurrency=args.max_concurrency, + fail_once_after_call=args.fail_once, + ), + id=workflow_id, + task_queue=PROMPT_BATCH_TASK_QUEUE, + ) + + for r in result.results: + print(f"\n[{r.model}] ${r.cost_usd:.6f} cache={r.cache_status or '-'}") + print(f" Q: {r.prompt}") + print(f" A: {r.answer.strip()}") + for s in result.skipped: + print(f"\n[skipped: {s.reason}] {s.prompt}") + print(f"\nTotal cost: ${result.total_cost_usd:.6f}") + print(f"Inspect: temporal workflow show -w {workflow_id}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/openrouter/prompt_batch/workflow.py b/openrouter/prompt_batch/workflow.py new file mode 100644 index 000000000..6c61468ab --- /dev/null +++ b/openrouter/prompt_batch/workflow.py @@ -0,0 +1,88 @@ +import asyncio +from datetime import timedelta +from typing import Union + +from temporalio import workflow +from temporalio.common import RetryPolicy +from temporalio.exceptions import ActivityError, ApplicationError + +# The shared dataclasses are passed through the sandbox so that objects the +# Activity returns are the same classes the Workflow compares against. +with workflow.unsafe.imports_passed_through(): + from openrouter.activities import OpenRouterActivities + from openrouter.shared import ( + MAX_PROMPTS_PER_BATCH, + BatchInput, + BatchResult, + OpenRouterRequest, + OpenRouterResult, + SkippedPrompt, + ) + +# Temporal owns retries: 1s, 2s, 4s, ... capped at 60s, five attempts. The +# Activity marks 4xx errors non-retryable and passes OpenRouter's Retry-After +# through as the next retry delay, so this policy only governs the rest. +OPENROUTER_RETRY_POLICY = RetryPolicy( + initial_interval=timedelta(seconds=1), + backoff_coefficient=2.0, + maximum_interval=timedelta(seconds=60), + maximum_attempts=5, +) + + +@workflow.defn +class PromptBatchWorkflow: + """Fan one OpenRouter call out per prompt and collect the answers.""" + + @workflow.run + async def run(self, batch: BatchInput) -> BatchResult: + if len(batch.prompts) > MAX_PROMPTS_PER_BATCH: + raise ApplicationError( + f"Batch has {len(batch.prompts)} prompts; the limit is " + f"{MAX_PROMPTS_PER_BATCH}. Split it, or see the README for the " + "sliding-window pattern.", + non_retryable=True, + ) + + # @@@SNIPSTART python-openrouter-prompt-batch-fan-out + semaphore = asyncio.Semaphore(batch.max_concurrency) + outcomes = await asyncio.gather( + *(self._answer(prompt, batch, semaphore) for prompt in batch.prompts) + ) + # @@@SNIPEND + + results = [o for o in outcomes if isinstance(o, OpenRouterResult)] + skipped = [o for o in outcomes if isinstance(o, SkippedPrompt)] + return BatchResult( + results=results, + skipped=skipped, + total_cost_usd=round(sum(r.cost_usd for r in results), 6), + ) + + async def _answer( + self, prompt: str, batch: BatchInput, semaphore: asyncio.Semaphore + ) -> Union[OpenRouterResult, SkippedPrompt]: + async with semaphore: + try: + return await workflow.execute_activity_method( + OpenRouterActivities.call_openrouter, + OpenRouterRequest( + prompt=prompt, + model=batch.model, + fail_once_after_call=batch.fail_once_after_call, + ), + start_to_close_timeout=timedelta(seconds=90), + heartbeat_timeout=timedelta(seconds=10), + retry_policy=OPENROUTER_RETRY_POLICY, + ) + except ActivityError as e: + # One bad prompt should not fail the batch. Record why and + # carry on; the caller decides what to do with skipped prompts. + cause = e.cause + reason = ( + cause.type + if isinstance(cause, ApplicationError) and cause.type + else type(cause).__name__ + ) + workflow.logger.warning("Skipping prompt %r: %s", prompt, reason) + return SkippedPrompt(prompt=prompt, reason=reason) diff --git a/openrouter/shared.py b/openrouter/shared.py new file mode 100644 index 000000000..10f1470cd --- /dev/null +++ b/openrouter/shared.py @@ -0,0 +1,110 @@ +from dataclasses import dataclass, field +from typing import Optional + +OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" + +# OpenRouter's Auto Router picks a concrete model per request. The response's +# `model` field reports which one it chose. +DEFAULT_MODEL = "openrouter/auto" + +PROMPT_BATCH_TASK_QUEUE = "openrouter-prompt-batch" +BUDGET_GATE_TASK_QUEUE = "openrouter-budget-gate" + +# Each Activity adds a few events to the Workflow's Event History and each +# answer is stored in the Workflow result payload. Keep batches small enough to +# stay well under the history and payload limits; see the README for the +# sliding-window pattern for larger batches. +MAX_PROMPTS_PER_BATCH = 100 + + +@dataclass +class OpenRouterRequest: + """One chat completion request. Everything here ends up in the request + body, so keep it free of per-attempt values (attempt number, timestamps): + OpenRouter's response cache keys on the exact body, and a retried attempt + should be byte-identical to the first one.""" + + prompt: str + model: str = DEFAULT_MODEL + # When set, sent as OpenRouter's `models` list and tried in order. This + # replaces the Auto Router. + fallback_models: list[str] = field(default_factory=list) + # Auto Router cost tier: low, medium, high, xhigh, or max. Only used with + # `openrouter/auto`. + cost_tier: str = "low" + # How long OpenRouter keeps a successful response cached so that a retry of + # the identical request is served for free. + cache_ttl_seconds: int = 600 + # Demo hook: fail the first attempt *after* the response arrives, so the + # retry shows a cache hit billed at $0 in Event History. + fail_once_after_call: bool = False + + +@dataclass +class OpenRouterResult: + prompt: str + model: str + answer: str + cost_usd: float + generation_id: str + # "HIT" or "MISS" from OpenRouter's X-OpenRouter-Cache-Status header, or "" + # when the header is absent. + cache_status: str + + +@dataclass +class SkippedPrompt: + prompt: str + reason: str + + +@dataclass +class BatchInput: + prompts: list[str] + model: str = DEFAULT_MODEL + max_concurrency: int = 5 + fail_once_after_call: bool = False + + +@dataclass +class BatchResult: + results: list[OpenRouterResult] + skipped: list[SkippedPrompt] + total_cost_usd: float + + +@dataclass +class BudgetGateInput: + prompts: list[str] + # Soft budget enforced by the Workflow from OpenRouter's reported cost. + budget_usd: float + # Reserved per in-flight call before its real cost is known. Overshoot is + # bounded by max_concurrency * estimated_cost_usd. + estimated_cost_usd: float = 0.001 + model: str = DEFAULT_MODEL + max_concurrency: int = 3 + # How long a paused batch waits for a `raise_budget` Update before giving + # up on the remaining prompts. + approval_timeout_seconds: int = 3600 + + +@dataclass +class LedgerEntry: + prompt: str + model: str + cost_usd: float + generation_id: str + cache_status: str + + +@dataclass +class SpendReport: + budget_usd: float + spent_usd: float + reserved_usd: float + completed: int + # Prompts currently parked, with why: "soft_budget_exhausted" or + # "insufficient_credits" (OpenRouter returned 402). + paused: dict[str, str] + ledger: list[LedgerEntry] + paused_reason: Optional[str] = None diff --git a/pyproject.toml b/pyproject.toml index 3bcb3ffd5..77b4f42bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,6 +85,7 @@ openai-agents = [ "temporalio[openai-agents,opentelemetry] >= 1.32.0", "requests>=2.32.0,<3", ] +openrouter = ["openai>=1.4.0,<3"] pydantic-converter = ["pydantic>=2.10.6,<3"] sentry = ["sentry-sdk>=2.13.0"] strands-agents = [ diff --git a/tests/openrouter/__init__.py b/tests/openrouter/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/openrouter/activity_test.py b/tests/openrouter/activity_test.py new file mode 100644 index 000000000..e869ba1af --- /dev/null +++ b/tests/openrouter/activity_test.py @@ -0,0 +1,201 @@ +import dataclasses +import json +from datetime import timedelta +from typing import Any, Callable + +import httpx +import pytest +from openai import AsyncOpenAI +from temporalio.exceptions import ApplicationError +from temporalio.testing import ActivityEnvironment + +from openrouter.activities import OpenRouterActivities +from openrouter.shared import OPENROUTER_BASE_URL, OpenRouterRequest + +Handler = Callable[[httpx.Request], httpx.Response] + + +def make_activities(handler: Handler) -> OpenRouterActivities: + """Activities backed by a fake OpenRouter; no network, no API key.""" + client = AsyncOpenAI( + base_url=OPENROUTER_BASE_URL, + api_key="test-key", + max_retries=0, + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + return OpenRouterActivities(client) + + +def completion_body( + answer: str = "Retries repeat a failed call.", + model: str = "openai/gpt-4o-mini", + cost: Any = 0.000123, +) -> dict[str, Any]: + return { + "id": "gen-123", + "object": "chat.completion", + "created": 0, + "model": model, + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": {"role": "assistant", "content": answer}, + } + ], + "usage": { + "prompt_tokens": 5, + "completion_tokens": 7, + "total_tokens": 12, + "cost": cost, + }, + } + + +async def test_success_returns_model_cost_and_cache_status() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + json=completion_body(), + headers={"X-OpenRouter-Cache-Status": "MISS"}, + ) + + result = await ActivityEnvironment().run( + make_activities(handler).call_openrouter, + OpenRouterRequest(prompt="Explain retries in one sentence."), + ) + + assert result.model == "openai/gpt-4o-mini" + assert result.answer == "Retries repeat a failed call." + assert result.cost_usd == pytest.approx(0.000123) + assert result.generation_id == "gen-123" + assert result.cache_status == "MISS" + + # Exactly one HTTP call per attempt: the client does not retry on its own. + assert len(requests) == 1 + body = json.loads(requests[0].content) + assert body["model"] == "openrouter/auto" + assert body["plugins"] == [{"id": "auto-router", "cost_tier": "low"}] + assert requests[0].headers["X-OpenRouter-Cache"] == "true" + assert requests[0].headers["X-OpenRouter-Cache-TTL"] == "600" + + +async def test_fallback_models_replace_auto_router() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json=completion_body(model="b/second")) + + result = await ActivityEnvironment().run( + make_activities(handler).call_openrouter, + OpenRouterRequest(prompt="hi", fallback_models=["a/first", "b/second"]), + ) + + body = json.loads(requests[0].content) + assert body["model"] == "a/first" + assert body["models"] == ["a/first", "b/second"] + assert "plugins" not in body + assert result.model == "b/second" + + +async def test_rate_limit_is_retryable_and_honors_retry_after() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 429, + json={"error": {"code": 429, "message": "Rate limited"}}, + headers={"Retry-After": "7"}, + ) + + with pytest.raises(ApplicationError) as excinfo: + await ActivityEnvironment().run( + make_activities(handler).call_openrouter, OpenRouterRequest(prompt="hi") + ) + + assert excinfo.value.type == "OpenRouterHTTP429" + assert not excinfo.value.non_retryable + assert excinfo.value.next_retry_delay == timedelta(seconds=7) + + +async def test_insufficient_credits_is_non_retryable() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 402, json={"error": {"code": 402, "message": "Insufficient credits"}} + ) + + with pytest.raises(ApplicationError) as excinfo: + await ActivityEnvironment().run( + make_activities(handler).call_openrouter, OpenRouterRequest(prompt="hi") + ) + + assert excinfo.value.type == "OpenRouterHTTP402" + assert excinfo.value.non_retryable + assert "Insufficient credits" in str(excinfo.value) + + +async def test_server_error_is_retryable() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(502, json={"error": {"code": 502, "message": "down"}}) + + with pytest.raises(ApplicationError) as excinfo: + await ActivityEnvironment().run( + make_activities(handler).call_openrouter, OpenRouterRequest(prompt="hi") + ) + + assert excinfo.value.type == "OpenRouterHTTP502" + assert not excinfo.value.non_retryable + + +async def test_error_body_inside_200_is_classified_by_its_code() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={"error": {"code": 403, "message": "Flagged by moderation"}}, + ) + + with pytest.raises(ApplicationError) as excinfo: + await ActivityEnvironment().run( + make_activities(handler).call_openrouter, OpenRouterRequest(prompt="hi") + ) + + assert excinfo.value.type == "OpenRouterHTTP403" + assert excinfo.value.non_retryable + + +async def test_fail_once_after_call_fails_first_attempt_only() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json=completion_body(cost=0), + headers={"X-OpenRouter-Cache-Status": "HIT"}, + ) + + activities = make_activities(handler) + request = OpenRouterRequest(prompt="hi", fail_once_after_call=True) + + env = ActivityEnvironment() + with pytest.raises(ApplicationError) as excinfo: + await env.run(activities.call_openrouter, request) + assert excinfo.value.type == "SimulatedFailure" + assert not excinfo.value.non_retryable + + env.info = dataclasses.replace(env.info, attempt=2) + result = await env.run(activities.call_openrouter, request) + assert result.cache_status == "HIT" + assert result.cost_usd == 0.0 + + +async def test_missing_cost_is_reported_as_zero() -> None: + def handler(request: httpx.Request) -> httpx.Response: + body = completion_body() + del body["usage"]["cost"] + return httpx.Response(200, json=body) + + result = await ActivityEnvironment().run( + make_activities(handler).call_openrouter, OpenRouterRequest(prompt="hi") + ) + assert result.cost_usd == 0.0 + assert result.cache_status == "" diff --git a/tests/openrouter/budget_gate_test.py b/tests/openrouter/budget_gate_test.py new file mode 100644 index 000000000..8c2cc6f5a --- /dev/null +++ b/tests/openrouter/budget_gate_test.py @@ -0,0 +1,211 @@ +import asyncio +import uuid +from typing import AsyncIterator + +import pytest +import pytest_asyncio +from temporalio import activity +from temporalio.client import Client, WorkflowHandle, WorkflowUpdateFailedError +from temporalio.exceptions import ApplicationError +from temporalio.worker import Worker + +from openrouter.budget_gate.workflow import BudgetGateWorkflow +from openrouter.shared import ( + BatchResult, + BudgetGateInput, + OpenRouterRequest, + OpenRouterResult, + SpendReport, +) + +COST_PER_CALL = 0.001 + + +class FakeOpenRouter: + """Mock Activity with a per-prompt count and an optional 402 on first call.""" + + def __init__(self, out_of_credits_for: set[str] | None = None) -> None: + self.calls: dict[str, int] = {} + self.out_of_credits_for = out_of_credits_for or set() + + @activity.defn(name="call_openrouter") + async def call_openrouter(self, request: OpenRouterRequest) -> OpenRouterResult: + self.calls[request.prompt] = self.calls.get(request.prompt, 0) + 1 + if ( + request.prompt in self.out_of_credits_for + and self.calls[request.prompt] == 1 + ): + raise ApplicationError( + "OpenRouter returned HTTP 402: Insufficient credits", + type="OpenRouterHTTP402", + non_retryable=True, + ) + return OpenRouterResult( + prompt=request.prompt, + model="openai/gpt-4o-mini", + answer="ok", + cost_usd=COST_PER_CALL, + generation_id=f"gen-{request.prompt}-{self.calls[request.prompt]}", + cache_status="MISS", + ) + + +@pytest_asyncio.fixture +async def task_queue(client: Client) -> AsyncIterator[str]: + yield f"test-openrouter-budget-{uuid.uuid4()}" + + +async def start( + client: Client, task_queue: str, gate: BudgetGateInput +) -> WorkflowHandle[BudgetGateWorkflow, BatchResult]: + return await client.start_workflow( + BudgetGateWorkflow.run, + gate, + id=f"test-openrouter-budget-{uuid.uuid4()}", + task_queue=task_queue, + ) + + +async def wait_until_paused( + handle: WorkflowHandle[BudgetGateWorkflow, BatchResult], reason: str +) -> SpendReport: + for _ in range(100): + report = await handle.query(BudgetGateWorkflow.spend_report) + if reason in report.paused.values(): + return report + await asyncio.sleep(0.1) + raise AssertionError(f"workflow never paused with reason {reason!r}") + + +async def test_soft_budget_pauses_then_resumes_on_raise_budget( + client: Client, task_queue: str +) -> None: + fake = FakeOpenRouter() + async with Worker( + client, + task_queue=task_queue, + workflows=[BudgetGateWorkflow], + activities=[fake.call_openrouter], + ): + # Budget covers exactly one call; the second prompt must park. + handle = await start( + client, + task_queue, + BudgetGateInput( + prompts=["a", "b", "c"], + budget_usd=0.0015, + estimated_cost_usd=COST_PER_CALL, + max_concurrency=1, + approval_timeout_seconds=60, + ), + ) + report = await wait_until_paused(handle, "soft_budget_exhausted") + assert report.completed == 1 + assert report.spent_usd == pytest.approx(COST_PER_CALL) + assert report.paused == {"b": "soft_budget_exhausted"} + + report = await handle.execute_update(BudgetGateWorkflow.raise_budget, 0.01) + assert report.budget_usd == 0.01 + + result = await handle.result() + + assert [r.prompt for r in result.results] == ["a", "b", "c"] + assert result.skipped == [] + assert result.total_cost_usd == pytest.approx(3 * COST_PER_CALL) + assert fake.calls == {"a": 1, "b": 1, "c": 1} + + +async def test_insufficient_credits_pauses_and_reruns_same_prompt( + client: Client, task_queue: str +) -> None: + fake = FakeOpenRouter(out_of_credits_for={"b"}) + async with Worker( + client, + task_queue=task_queue, + workflows=[BudgetGateWorkflow], + activities=[fake.call_openrouter], + ): + handle = await start( + client, + task_queue, + BudgetGateInput( + prompts=["a", "b", "c"], + budget_usd=1.0, + estimated_cost_usd=COST_PER_CALL, + max_concurrency=1, + approval_timeout_seconds=60, + ), + ) + report = await wait_until_paused(handle, "insufficient_credits") + assert report.paused == {"b": "insufficient_credits"} + assert report.completed == 1 + + # Re-sending the same budget is how an operator says "I topped up". + await handle.execute_update(BudgetGateWorkflow.raise_budget, 1.0) + result = await handle.result() + + assert [r.prompt for r in result.results] == ["a", "b", "c"] + assert result.skipped == [] + # "b" was called twice: once for the 402, once after the budget bump. + assert fake.calls == {"a": 1, "b": 2, "c": 1} + assert result.results[1].generation_id == "gen-b-2" + + +async def test_lowering_the_budget_is_rejected(client: Client, task_queue: str) -> None: + fake = FakeOpenRouter() + async with Worker( + client, + task_queue=task_queue, + workflows=[BudgetGateWorkflow], + activities=[fake.call_openrouter], + ): + handle = await start( + client, + task_queue, + BudgetGateInput( + prompts=["a", "b"], + budget_usd=0.0015, + estimated_cost_usd=COST_PER_CALL, + max_concurrency=1, + approval_timeout_seconds=60, + ), + ) + await wait_until_paused(handle, "soft_budget_exhausted") + + with pytest.raises(WorkflowUpdateFailedError): + await handle.execute_update(BudgetGateWorkflow.raise_budget, 0.0001) + + await handle.execute_update(BudgetGateWorkflow.raise_budget, 0.01) + result = await handle.result() + + assert [r.prompt for r in result.results] == ["a", "b"] + + +async def test_approval_timeout_skips_remaining_prompts( + client: Client, task_queue: str +) -> None: + fake = FakeOpenRouter() + async with Worker( + client, + task_queue=task_queue, + workflows=[BudgetGateWorkflow], + activities=[fake.call_openrouter], + ): + result = await client.execute_workflow( + BudgetGateWorkflow.run, + BudgetGateInput( + prompts=["a", "b", "c"], + budget_usd=0.0015, + estimated_cost_usd=COST_PER_CALL, + max_concurrency=2, + approval_timeout_seconds=1, + ), + id=f"test-openrouter-budget-{uuid.uuid4()}", + task_queue=task_queue, + ) + + assert [r.prompt for r in result.results] == ["a"] + assert sorted(s.prompt for s in result.skipped) == ["b", "c"] + assert {s.reason for s in result.skipped} == {"soft_budget_exhausted"} + assert result.total_cost_usd == pytest.approx(COST_PER_CALL) + assert fake.calls == {"a": 1} diff --git a/tests/openrouter/prompt_batch_test.py b/tests/openrouter/prompt_batch_test.py new file mode 100644 index 000000000..f39758466 --- /dev/null +++ b/tests/openrouter/prompt_batch_test.py @@ -0,0 +1,55 @@ +import uuid + +from temporalio import activity +from temporalio.client import Client +from temporalio.exceptions import ApplicationError +from temporalio.worker import Worker + +from openrouter.prompt_batch.workflow import PromptBatchWorkflow +from openrouter.shared import BatchInput, OpenRouterRequest, OpenRouterResult + + +def fake_result(request: OpenRouterRequest, cost: float = 0.001) -> OpenRouterResult: + return OpenRouterResult( + prompt=request.prompt, + model="openai/gpt-4o-mini", + answer=f"Answer to: {request.prompt}", + cost_usd=cost, + generation_id=f"gen-{request.prompt}", + cache_status="MISS", + ) + + +async def test_prompt_batch_collects_results_and_skips_failures( + client: Client, +) -> None: + @activity.defn(name="call_openrouter") + async def mock_call_openrouter(request: OpenRouterRequest) -> OpenRouterResult: + if request.prompt == "bad": + raise ApplicationError( + "OpenRouter returned HTTP 400: bad request", + type="OpenRouterHTTP400", + non_retryable=True, + ) + return fake_result(request) + + task_queue = f"test-openrouter-{uuid.uuid4()}" + async with Worker( + client, + task_queue=task_queue, + workflows=[PromptBatchWorkflow], + activities=[mock_call_openrouter], + ): + result = await client.execute_workflow( + PromptBatchWorkflow.run, + BatchInput(prompts=["one", "bad", "two"], max_concurrency=2), + id=f"test-openrouter-{uuid.uuid4()}", + task_queue=task_queue, + ) + + assert [r.prompt for r in result.results] == ["one", "two"] + assert all(r.answer.startswith("Answer to:") for r in result.results) + assert [(s.prompt, s.reason) for s in result.skipped] == [ + ("bad", "OpenRouterHTTP400") + ] + assert result.total_cost_usd == 0.002 diff --git a/uv.lock b/uv.lock index 8469affea..ff1884fa2 100644 --- a/uv.lock +++ b/uv.lock @@ -244,14 +244,14 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "anyio", marker = "python_full_version < '3.11'" }, - { name = "distro", marker = "python_full_version < '3.11'" }, - { name = "docstring-parser", marker = "python_full_version < '3.11'" }, - { name = "httpx", marker = "python_full_version < '3.11'" }, - { name = "jiter", marker = "python_full_version < '3.11'" }, - { name = "pydantic", marker = "python_full_version < '3.11'" }, - { name = "sniffio", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fb/57/0b758b08cf4606c94d63a997d67a0063f7438efbaf81cfedd0d7c0c69d67/anthropic-0.103.1.tar.gz", hash = "sha256:21c12f4fc0fdd87a2e80d58479cd0af640062b3cfb82bbfa01c7977acd4defeb", size = 848877, upload-time = "2026-05-19T15:43:27.698Z" } wheels = [ @@ -270,14 +270,14 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "anyio", marker = "python_full_version >= '3.11'" }, - { name = "distro", marker = "python_full_version >= '3.11'" }, - { name = "docstring-parser", marker = "python_full_version >= '3.11'" }, - { name = "httpx", marker = "python_full_version >= '3.11'" }, - { name = "jiter", marker = "python_full_version >= '3.11'" }, - { name = "pydantic", marker = "python_full_version >= '3.11'" }, - { name = "sniffio", marker = "python_full_version >= '3.11'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.11'" }, + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/ca/3cb2c20ee729736fbd4546d5d8b67e818288529fe70cb7a80dbf80aef70b/anthropic-0.121.0.tar.gz", hash = "sha256:e79d6e08ab3376602fc9a70d4d5ea3540817c76cf7e16658bed790834e1833d6", size = 1013292, upload-time = "2026-08-07T17:11:07.241Z" } wheels = [ @@ -709,12 +709,12 @@ name = "deepagents" version = "0.6.12" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "langchain", version = "1.3.15", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "langchain-anthropic", version = "1.5.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "langchain-core", version = "1.5.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "langchain-google-genai", marker = "python_full_version >= '3.11'" }, - { name = "langsmith", version = "0.8.18", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "wcmatch", marker = "python_full_version >= '3.11'" }, + { name = "langchain", version = "1.3.15", source = { registry = "https://pypi.org/simple" } }, + { name = "langchain-anthropic", version = "1.5.5", source = { registry = "https://pypi.org/simple" } }, + { name = "langchain-core", version = "1.5.4", source = { registry = "https://pypi.org/simple" } }, + { name = "langchain-google-genai" }, + { name = "langsmith", version = "0.8.18", source = { registry = "https://pypi.org/simple" } }, + { name = "wcmatch" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e0/db/a6acdc72a9e90c3f07ed10de35c951734a02d4facb693bb59684ad368801/deepagents-0.6.12.tar.gz", hash = "sha256:1f281c0bc5a63132f62e2ee345c1dc593b23188da6e23016401f6879fbe54b5f", size = 211364, upload-time = "2026-06-25T17:26:52.775Z" } wheels = [ @@ -767,7 +767,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1689,9 +1689,9 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "langchain-core", version = "1.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "langgraph", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "pydantic", marker = "python_full_version < '3.11'" }, + { name = "langchain-core", version = "1.4.0", source = { registry = "https://pypi.org/simple" } }, + { name = "langgraph", version = "1.2.0", source = { registry = "https://pypi.org/simple" } }, + { name = "pydantic" }, ] sdist = { url = "https://files.pythonhosted.org/packages/11/e5/6350e77a9e2764eaafcb2d581cbf0b800f53c6bc98fdf5ebc85f3a931ded/langchain-1.3.1.tar.gz", hash = "sha256:bc283c220233230f48b8e50ab1fbf1b688bcb206d933fa448d40a9b143177f62", size = 581329, upload-time = "2026-05-15T18:14:55.368Z" } wheels = [ @@ -1710,9 +1710,9 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "langchain-core", version = "1.5.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "langgraph", version = "1.2.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "pydantic", marker = "python_full_version >= '3.11'" }, + { name = "langchain-core", version = "1.5.4", source = { registry = "https://pypi.org/simple" } }, + { name = "langgraph", version = "1.2.11", source = { registry = "https://pypi.org/simple" } }, + { name = "pydantic" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6a/1c/b84579174a8e82ed79f4c3e0cd5a7f2323facc5ccd4d1b8390e7d175b663/langchain-1.3.15.tar.gz", hash = "sha256:ab4b775b9703f7e37babe0b325dbbaef25573bda60ecf79f7850bc875f252795", size = 665047, upload-time = "2026-08-11T19:10:52.455Z" } wheels = [ @@ -1727,9 +1727,9 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "anthropic", version = "0.103.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "langchain-core", version = "1.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "pydantic", marker = "python_full_version < '3.11'" }, + { name = "anthropic", version = "0.103.1", source = { registry = "https://pypi.org/simple" } }, + { name = "langchain-core", version = "1.4.0", source = { registry = "https://pypi.org/simple" } }, + { name = "pydantic" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fa/e3/d2f9dec95602524b1cfb4be2747ba5bc38d32501b2a56cb4bcb76e80bb45/langchain_anthropic-1.4.3.tar.gz", hash = "sha256:f8a2442463c0629b1b3110eaeaa56fdbdc87df2a802f8c7f5ecf611eb4874ec8", size = 685219, upload-time = "2026-05-03T17:33:27.118Z" } wheels = [ @@ -1748,9 +1748,9 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "anthropic", version = "0.121.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "langchain-core", version = "1.5.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "pydantic", marker = "python_full_version >= '3.11'" }, + { name = "anthropic", version = "0.121.0", source = { registry = "https://pypi.org/simple" } }, + { name = "langchain-core", version = "1.5.4", source = { registry = "https://pypi.org/simple" } }, + { name = "pydantic" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/ce/5fdff0c55c4711da9d87a4a0a066d0b14b633402dc9d441214cc64889be9/langchain_anthropic-1.5.5.tar.gz", hash = "sha256:e8697f13b93fe95b7c7c17679f5d0143c239a8fcf45c0f498349c54482322dc9", size = 720572, upload-time = "2026-08-11T19:16:38.842Z" } wheels = [ @@ -1765,15 +1765,15 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "jsonpatch", marker = "python_full_version < '3.11'" }, - { name = "langchain-protocol", version = "0.0.15", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "langsmith", version = "0.8.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "packaging", marker = "python_full_version < '3.11'" }, - { name = "pydantic", marker = "python_full_version < '3.11'" }, - { name = "pyyaml", marker = "python_full_version < '3.11'" }, - { name = "tenacity", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, - { name = "uuid-utils", marker = "python_full_version < '3.11'" }, + { name = "jsonpatch" }, + { name = "langchain-protocol", version = "0.0.15", source = { registry = "https://pypi.org/simple" } }, + { name = "langsmith", version = "0.8.9", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, ] sdist = { url = "https://files.pythonhosted.org/packages/59/de/679a53472c25860837e32c0442c962fa86e95317a36460e2c9d5c91b17c2/langchain_core-1.4.0.tar.gz", hash = "sha256:1dc341eed802ed9c117c0df3923c991e5e9e226571e5725c194eeb5bd93d1a7f", size = 920260, upload-time = "2026-05-11T18:42:35.919Z" } wheels = [ @@ -1792,15 +1792,15 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "jsonpatch", marker = "python_full_version >= '3.11'" }, - { name = "langchain-protocol", version = "0.0.18", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "langsmith", version = "0.8.18", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "packaging", marker = "python_full_version >= '3.11'" }, - { name = "pydantic", marker = "python_full_version >= '3.11'" }, - { name = "pyyaml", marker = "python_full_version >= '3.11'" }, - { name = "tenacity", marker = "python_full_version >= '3.11'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.11'" }, - { name = "uuid-utils", marker = "python_full_version >= '3.11'" }, + { name = "jsonpatch" }, + { name = "langchain-protocol", version = "0.0.18", source = { registry = "https://pypi.org/simple" } }, + { name = "langsmith", version = "0.8.18", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9e/18/20c3eec05ccf2fff8e553866bec3bb2f92880aea3cb878603e5a854bd5c0/langchain_core-1.5.4.tar.gz", hash = "sha256:aa76104f30b6c7305f292cb2c364e67cb52c321940ae812d7969471dce32a89a", size = 980540, upload-time = "2026-08-11T18:02:52.239Z" } wheels = [ @@ -1812,10 +1812,10 @@ name = "langchain-google-genai" version = "4.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "filetype", marker = "python_full_version >= '3.11'" }, - { name = "google-genai", marker = "python_full_version >= '3.11'" }, - { name = "langchain-core", version = "1.5.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "pydantic", marker = "python_full_version >= '3.11'" }, + { name = "filetype" }, + { name = "google-genai" }, + { name = "langchain-core", version = "1.5.4", source = { registry = "https://pypi.org/simple" } }, + { name = "pydantic" }, ] sdist = { url = "https://files.pythonhosted.org/packages/11/98/39b62fb50236fc582449beb96e0392d108bd7baac7ac6158d656bfac1561/langchain_google_genai-4.3.3.tar.gz", hash = "sha256:f051b98aaf223cf9092fc27c280dfec63070fc0022dc513640b2da138c9fa2f0", size = 286010, upload-time = "2026-08-10T18:34:26.499Z" } wheels = [ @@ -1830,7 +1830,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4f/24/9777489d6fbbee64af0c8f96d4f840239c408cf694f3394672807dafc490/langchain_protocol-0.0.15.tar.gz", hash = "sha256:9ab2d11ee73944754f10e037e717098d3a6796f0e58afa9cadda6154e7655ade", size = 5862, upload-time = "2026-05-01T22:30:04.748Z" } wheels = [ @@ -1849,7 +1849,7 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "typing-extensions", marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" } wheels = [ @@ -1864,12 +1864,12 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "langchain-core", version = "1.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "langgraph-checkpoint", marker = "python_full_version < '3.11'" }, - { name = "langgraph-prebuilt", marker = "python_full_version < '3.11'" }, - { name = "langgraph-sdk", version = "0.3.14", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "pydantic", marker = "python_full_version < '3.11'" }, - { name = "xxhash", marker = "python_full_version < '3.11'" }, + { name = "langchain-core", version = "1.4.0", source = { registry = "https://pypi.org/simple" } }, + { name = "langgraph-checkpoint" }, + { name = "langgraph-prebuilt" }, + { name = "langgraph-sdk", version = "0.3.14", source = { registry = "https://pypi.org/simple" } }, + { name = "pydantic" }, + { name = "xxhash" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/61/d5d25e783035aa307d289b37e082258a6061c0fb4caa4a284f3bf1e87169/langgraph-1.2.0.tar.gz", hash = "sha256:4a9baaf62afc5d5f63144a50095140a34b9aa9b7cea695d25326d564775348e7", size = 690248, upload-time = "2026-05-12T03:46:39.164Z" } wheels = [ @@ -1888,12 +1888,12 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "langchain-core", version = "1.5.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "langgraph-checkpoint", marker = "python_full_version >= '3.11'" }, - { name = "langgraph-prebuilt", marker = "python_full_version >= '3.11'" }, - { name = "langgraph-sdk", version = "0.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "pydantic", marker = "python_full_version >= '3.11'" }, - { name = "xxhash", marker = "python_full_version >= '3.11'" }, + { name = "langchain-core", version = "1.5.4", source = { registry = "https://pypi.org/simple" } }, + { name = "langgraph-checkpoint" }, + { name = "langgraph-prebuilt" }, + { name = "langgraph-sdk", version = "0.4.2", source = { registry = "https://pypi.org/simple" } }, + { name = "pydantic" }, + { name = "xxhash" }, ] sdist = { url = "https://files.pythonhosted.org/packages/56/0d/c8e7ee98896659e1b6555db0ab115a9ca899844744645d5d894032bab1d7/langgraph-1.2.11.tar.gz", hash = "sha256:9ecfe11e50d338b34b15cf4d8a442642de103e8ae6971320efba84e4542eb363", size = 725753, upload-time = "2026-08-11T14:00:36.945Z" } wheels = [ @@ -1936,8 +1936,8 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "httpx", marker = "python_full_version < '3.11'" }, - { name = "orjson", marker = "python_full_version < '3.11'" }, + { name = "httpx" }, + { name = "orjson" }, ] sdist = { url = "https://files.pythonhosted.org/packages/02/f1/134046c20bc4a4a15d410d1d21c9e298a3e9923777b4cc867b8669bc636b/langgraph_sdk-0.3.14.tar.gz", hash = "sha256:acd1674c538e97f3cdaa610f6dd7e34bc9bad30167f0ccc482dcd563325e81f5", size = 198162, upload-time = "2026-05-05T18:40:03.524Z" } wheels = [ @@ -1956,11 +1956,11 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "httpx", marker = "python_full_version >= '3.11'" }, - { name = "langchain-core", version = "1.5.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "langchain-protocol", version = "0.0.18", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "orjson", marker = "python_full_version >= '3.11'" }, - { name = "websockets", marker = "python_full_version >= '3.11'" }, + { name = "httpx" }, + { name = "langchain-core", version = "1.5.4", source = { registry = "https://pypi.org/simple" } }, + { name = "langchain-protocol", version = "0.0.18", source = { registry = "https://pypi.org/simple" } }, + { name = "orjson" }, + { name = "websockets" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b4/2b/bd8ac26d4e97f6df88ef05ce5b6a38945a3903e1025d926f4752aa88aa97/langgraph_sdk-0.4.2.tar.gz", hash = "sha256:b88f0f5f6328ac0680d6790614a905b2bcfa257f2276dba4e38f0e86db0aa738", size = 348327, upload-time = "2026-06-01T17:51:19.856Z" } wheels = [ @@ -1975,16 +1975,16 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "httpx", marker = "python_full_version < '3.11'" }, - { name = "orjson", marker = "python_full_version < '3.11' and platform_python_implementation != 'PyPy'" }, - { name = "packaging", marker = "python_full_version < '3.11'" }, - { name = "pydantic", marker = "python_full_version < '3.11'" }, - { name = "requests", marker = "python_full_version < '3.11'" }, - { name = "requests-toolbelt", marker = "python_full_version < '3.11'" }, - { name = "uuid-utils", marker = "python_full_version < '3.11'" }, - { name = "websockets", marker = "python_full_version < '3.11'" }, - { name = "xxhash", marker = "python_full_version < '3.11'" }, - { name = "zstandard", marker = "python_full_version < '3.11'" }, + { name = "httpx" }, + { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "uuid-utils" }, + { name = "websockets" }, + { name = "xxhash" }, + { name = "zstandard" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/dd/f4c8a12987318e505b10760d30c3c2d45e8dc87ba8f47a004c753a9e7b35/langsmith-0.8.9.tar.gz", hash = "sha256:f16e37fcd5a8a2d4db30eae0e399a866a65ce5cc86218825c59409ed57a3bf53", size = 4428684, upload-time = "2026-06-03T17:56:09.448Z" } wheels = [ @@ -2003,16 +2003,16 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "httpx", marker = "python_full_version >= '3.11'" }, - { name = "orjson", marker = "python_full_version >= '3.11' and platform_python_implementation != 'PyPy'" }, - { name = "packaging", marker = "python_full_version >= '3.11'" }, - { name = "pydantic", marker = "python_full_version >= '3.11'" }, - { name = "requests", marker = "python_full_version >= '3.11'" }, - { name = "requests-toolbelt", marker = "python_full_version >= '3.11'" }, - { name = "uuid-utils", marker = "python_full_version >= '3.11'" }, - { name = "websockets", marker = "python_full_version >= '3.11'" }, - { name = "xxhash", marker = "python_full_version >= '3.11'" }, - { name = "zstandard", marker = "python_full_version >= '3.11'" }, + { name = "httpx" }, + { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "uuid-utils" }, + { name = "websockets" }, + { name = "xxhash" }, + { name = "zstandard" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9a/d9/a6681aa9847bbbc5ec21abe20a5e233b94e5edcfe39624db607ac7e8ccb4/langsmith-0.8.18.tar.gz", hash = "sha256:32dde9c0e67e053e0fb738921fc8ced768af7b8fa83d7a0e3fd63597cf8776dd", size = 4526988, upload-time = "2026-06-19T13:12:17.123Z" } wheels = [ @@ -4643,6 +4643,9 @@ openai-agents = [ { name = "requests" }, { name = "temporalio", extra = ["openai-agents", "opentelemetry"] }, ] +openrouter = [ + { name = "openai" }, +] pydantic-converter = [ { name = "pydantic" }, ] @@ -4750,6 +4753,7 @@ openai-agents = [ { name = "requests", specifier = ">=2.32.0,<3" }, { name = "temporalio", extras = ["openai-agents", "opentelemetry"], specifier = ">=1.32.0" }, ] +openrouter = [{ name = "openai", specifier = ">=1.4.0,<3" }] pydantic-converter = [{ name = "pydantic", specifier = ">=2.10.6,<3" }] sentry = [{ name = "sentry-sdk", specifier = ">=2.13.0" }] strands-agents = [ @@ -5221,7 +5225,7 @@ name = "wcmatch" version = "11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "bracex", marker = "python_full_version >= '3.11'" }, + { name = "bracex" }, ] sdist = { url = "https://files.pythonhosted.org/packages/16/25/1da725838132221e33568973da484ff43813662ccc06ebf7f6e3abddfcd5/wcmatch-11.0.tar.gz", hash = "sha256:55d95c2447789712774b198ceec72939e88b5618f1f8f0a9b605bf7740b63b96", size = 141360, upload-time = "2026-07-10T05:50:24.183Z" } wheels = [