SOLWYN
Guides

Agent Runs

Attribute LLM calls to a named unit of work with solwyn.run() — grouping cost and latency by run in the dashboard

import os
import solwyn
from openai import OpenAI

client = solwyn.Solwyn(OpenAI(), api_key=os.environ["SOLWYN_API_KEY"])

with solwyn.run("nightly-batch") as run_id:
    client.chat.completions.create(model="gpt-4o", messages=[...])
    client.chat.completions.create(model="gpt-4o", messages=[...])

client.close()

Wrap a unit of work with solwyn.run(name) to attribute every LLM call inside it to a single agent run. The dashboard groups cost and latency by run, so you can answer questions like "what did this nightly batch cost?" or "which agent task is the most expensive?" — without standing up a separate project key per task.

Each call inside the scope is tagged with a fresh agent_run_id and the name you provided, carried on the metadata event. As with everything Solwyn sends, the tag is a structural label — never prompt or response content.

Basic usage

solwyn.run(name, tags=None) returns a context manager that yields the generated run id. tags is optional — see Tags:

import os
import solwyn
from openai import OpenAI

client = solwyn.Solwyn(OpenAI(), api_key=os.environ["SOLWYN_API_KEY"])

with solwyn.run("invoice-extraction") as run_id:
    print(f"run id: {run_id}")  # e.g. "run_3f8a..."
    client.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user", "content": "Extract the totals from this invoice."}],
    )

The run id is generated for you (run_ + a UUID). The dashboard aggregates by id, not name — so two scopes with the same name still produce two distinct runs.

Async

solwyn.run(...) works as both a sync (with) and async (async with) context manager. Because it is built on contextvars, the active run propagates correctly across await points and into asyncio tasks:

import os
import solwyn
from openai import AsyncOpenAI

client = solwyn.AsyncSolwyn(AsyncOpenAI(), api_key=os.environ["SOLWYN_API_KEY"])

async with solwyn.run("ingest-job") as run_id:
    await client.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user", "content": "Hello!"}],
    )

Calls outside a run are still tracked

You do not have to wrap every call. Calls made outside any solwyn.run(...) scope are still reported — the Cloud API groups them into a synthetic per-day run:

_auto-{sdk_instance_id}-{YYYY-MM-DD}

derived from the event's UTC timestamp. Use explicit runs when you want meaningful grouping; rely on the auto group for everything else.

Nesting

Nesting replaces the outer scope for the inner's duration — the same semantics as OpenTelemetry spans. The inner run() gets its own id, and the outer is restored automatically on exit:

with solwyn.run("pipeline") as outer:
    client.chat.completions.create(...)          # tagged: pipeline

    with solwyn.run("pipeline:summarize") as inner:
        client.chat.completions.create(...)      # tagged: pipeline:summarize

    client.chat.completions.create(...)          # tagged: pipeline again

Scopes must exit in LIFO order (guaranteed by with); exiting out of order raises a RuntimeError.

Name validation

solwyn.run(name) validates the name at scope entry. A name must be:

  • a non-empty str (after stripping whitespace)
  • free of control, format, and line-separator characters
  • within the maximum length the SDK enforces

Invalid names raise ValueError (or TypeError for a non-string) immediately, before any call is tagged.

Tags

New in 0.3.0. Attach your own key/value labels to attributed calls — team, environment, job, tenant — for cost attribution and dashboard filtering. There are two entry points:

import os
import solwyn
from openai import OpenAI

client = solwyn.Solwyn(OpenAI(), api_key=os.environ["SOLWYN_API_KEY"])

with solwyn.run("nightly-batch", tags={"team": "research", "env": "prod"}) as run_id:
    # inherits the scope's tags: team=research, env=prod
    client.chat.completions.create(model="gpt-4o", messages=[...])

    # per-call tags merge over the scope's — env=staging wins
    client.chat.completions.create(
        model="gpt-4o",
        messages=[...],
        solwyn_tags={"env": "staging", "job": "backfill"},
    )
  • Per scopesolwyn.run(name, tags={...}) applies to every call inside the scope.
  • Per callsolwyn_tags={...}, a reserved keyword argument the SDK strips before the request reaches the provider.

Merge semantics

The merge is shallow, and per-call keys win on conflict. Nested scopes are the exception: an inner solwyn.run(...) replaces the outer scope's tags entirely — there is no merge across scopes, matching how nesting replaces the run itself. When the merged set is empty, no tags are sent.

Tags are copied when the scope is constructed and when the call starts, so mutating the source dict afterwards does not change what is attributed.

Bounds

RuleLimit
Keys per call10, applied to the merged scope + call set
Key length1–64 characters — a key may not be empty
Value length0–256 characters — an empty value is legal

Violations are rejected, never truncated: the SDK raises ValueError (or TypeError for the wrong type) before the budget check and before provider dispatch, so a malformed tag never reaches a provider or your bill. The message prefix names the side that failed — solwyn.run(tags), solwyn_tags, or merged tags when the two sides are individually legal but exceed a bound together:

# 6 scope keys + 5 call keys --> ValueError("merged tags allows at most 10 keys")
with solwyn.run("batch", tags=six_keys):
    client.chat.completions.create(model="gpt-4o", messages=[...], solwyn_tags=five_keys)

solwyn_tags must be a call argument. Passing it in default_params is a silent no-op — it is stripped from provider dispatch and never attached to attribution, so no error tells you the tags vanished.

Tags and privacy

Tags ride the post-call metadata event only — never budget check or confirm requests. Unlike everything else Solwyn transmits, they are customer-supplied free text and sit outside the zero-content guarantee: never put prompt text, PII, or secrets in them. See Privacy.

Per-run budget caps

New in 0.3.0. Budgets can cap an individual run, not just the project. Every pre-flight budget check made inside a scope carries the run's stable agent_run_id, which is what a per-run cap is keyed on.

Caps are server-enforced and configured in Cloud — the dashboard or CLI, see Budgets. There is no cap argument on solwyn.run(...); the SDK's side of the contract is stamping the run id on the check and enforcing the answer. A denial arrives through the ordinary check path and is enforced client-side exactly like a project budget: BudgetExceededError in hard_deny mode, a warning in alert_only. See Budget Enforcement.

Two behaviors are specific to run-scoped calls:

  • No allow cache. Run-scoped checks bypass the SDK's allow cache on both read and write — every call inside a scope performs a real check. Enforcement is that much more responsive, at the cost of one round-trip per call and the API quota that implies.
  • Sticky denials. Once a run receives an authoritative hard deny, that denial is remembered for that run id (an LRU over the 128 most recent runs) — project-period hard denies stay global as before. If Solwyn Cloud then becomes unreachable, the denial is preserved rather than failing open — and each time it is, the SDK logs a WARNING naming the usage and limit, on every call the preserved deny applies to; see Logging. Absent an authoritative deny, fail-open/fail-closed behavior is unchanged.

Reading the active run

solwyn.current_run() returns the active (agent_run_id, agent_run_name) tuple, or (None, None) when no scope is active. Useful for correlating your own logs with the run the dashboard will show:

import solwyn

with solwyn.run("batch-42"):
    run_id, run_name = solwyn.current_run()
    logger.info("starting work", extra={"solwyn_run_id": run_id})

Concurrency edge cases

asyncio tasks capture the run at creation

A task created with asyncio.create_task(...) inside a run captures that run's context. If the task keeps making LLM calls after the with block exits, those calls are still attributed to the captured run id:

async with solwyn.run("fan-out"):
    task = asyncio.create_task(do_more_calls())   # captures "fan-out"
# task may still be running here — its calls are still tagged "fan-out"

When attribution must end with the block, use asyncio.TaskGroup or await spawned tasks before leaving the scope.

Threads need run_in_executor

contextvars propagate across asyncio tasks but not into ThreadPoolExecutor workers. Use solwyn.run_in_executor(...) so the active run tag follows threaded work:

from concurrent.futures import ThreadPoolExecutor
import solwyn

with solwyn.run("nightly-batch"), ThreadPoolExecutor() as executor:
    future = solwyn.run_in_executor(executor, call_openai, prompt)
    result = future.result()

run_in_executor(...) returns the executor's concurrent.futures.Future, not an awaitable. In asyncio code, bridge it with asyncio.wrap_future(future). If you submit directly to an executor instead, wrap the callable with contextvars.copy_context().run(...) yourself.

Do not open a run inside an async generator

A scope opened before a generator yield would remain active in the consumer's async for body — because Python runs the consumer's body in the same context after the yield — leaking the generator's run into unrelated consumer code. The SDK rejects this at scope entry with a TypeError:

# WRONG — raises TypeError at scope entry
async def stream_items():
    async with solwyn.run("gen"):   # not allowed inside an async generator
        yield await fetch()

# Right — open the scope in the consumer instead
async with solwyn.run("consume"):
    async for item in stream_items():
        ...

What the dashboard sees

Each call inside a run reports two extra metadata fields:

FieldMeaning
agent_run_idThe active run's stable id, or None (then the API synthesizes the _auto-... group).
agent_run_nameThe human-readable label you passed to solwyn.run(name).

Both are structural labels only. See Privacy for the complete list of transmitted fields.

API summary

SymbolPurpose
solwyn.run(name, tags=None)Open an agent-run scope (sync with or async async with); yields the run id. tags is an optional Mapping[str, str] applied to every call in the scope — see Tags.
solwyn.run_in_executor(executor, fn, *args, **kwargs)Submit fn to an executor with the active run tag preserved; returns a Future.
solwyn.current_run()Return the active (agent_run_id, agent_run_name), or (None, None).
solwyn_tags={...}Reserved keyword argument on an intercepted provider call; merges over the scope's tags and is stripped before dispatch — see Tags.

On this page