Skip to main content

flyteplugins-typesafe-ai

Run TypeSafe's System One model ("Jev") inside durable Flyte tasks.

Jev does not write text. It answers typed questions — in parallel, in isolation, and with calibrated confidence attached to every answer. The documented property that makes it worth building around is that "adding questions barely changes the response time", so the right move is to ask many small questions in one call and compose the result in code you can read and change.

This plugin supplies the two things that takes on Flyte: a shape for the answers that survives a task boundary, and a way to ask a whole battery at once.

pip install flyteplugins-typesafe-ai

Quickstart

import enum
from dataclasses import dataclass, field

import flyte
from flyteplugins.typesafe_ai import Choice, Noul, Score, ask

env = flyte.TaskEnvironment(
    "triage",
    secrets=[flyte.Secret(key="TYPESAFE_API_KEY", as_env_var="TYPESAFE_API_KEY")],
)


class Intent(enum.Enum):
    """What is this customer asking for?"""

    REFUND = "refund"
    """they want money back"""
    DELIVERY = "delivery status"
    """they are asking where their order is"""
    OTHER = "something else"


class Severity(enum.IntEnum):
    """How badly are they blocked?"""

    NONE = 0
    """no impact; a question or a comment"""
    MINOR = 1
    """inconvenient, but they can carry on"""
    SERIOUS = 2
    """they are blocked and a deadline is involved"""
    BLOCKING = 3
    """they cannot use the product at all"""


@dataclass
class Triage:
    # The enums document themselves: class docstring -> question, member docstrings -> criteria.
    intent: Choice[Intent]
    severity: Score[Severity]
    # A Noul has no vocabulary to document itself with, so it carries both.
    hostile: Noul = field(
        metadata={
            "question": "Is the customer hostile?",
            "criteria": {"true": "insults or threats", "false": "civil"},
        }
    )
    money_at_stake: Noul = field(metadata={"question": "Is a payment or refund involved?"})


@env.task
async def handle(ticket: str) -> str:
    t = await ask(Triage, {"ticket": ticket})        # one request, four answers

    if t.hostile.at(0.8):                            # thresholds live in your code
        return "escalate"
    if not t.intent.certain(0.85):
        return "review"
    return f"auto: {t.intent.value.value}, severity {t.severity.value.name}"

The three answer types

Type Holds Useful members
Choice[SomeEnum] the picked member, confidence, probabilities .certain(threshold), .runner_up()
Score[SomeIntEnum] the picked rung, the unrounded position, confidence .at_least(rung)
Noul truthfulness in 0..1 .at(threshold)

Choice comes back as the enum member, not a string, and Score keeps both representations on purpose: value is the rung you branch on, position is where on the scale the answer actually landed, which is what you sort and threshold by.

Noul deliberately has no __bool__. if noul: would treat 0.02 and 0.98 alike, and picking the threshold is the part that belongs in reviewable code.

These are plain dataclasses, so pydantic is not required and no bespoke type transformer exists — they reuse Flyte's built-in DataclassTransformer. The plugin registers them with the type engine through the standard flyte.plugins.types entry point, so flyte.init() picks them up, and importing the package registers them too.

Declaring questions

The vocabulary documents itself. An enum's class docstring is the question and its member docstrings are the criteria, so a documented enum needs nothing at the call site:

@dataclass
class Triage:
    intent: Choice[Intent]        # question and criteria both come from Intent
    severity: Score[Severity]

Override either in ordinary dataclasses.field metadata, under two keys named after the SDK's own arguments:

key meaning
question the instructions for this question
criteria the same shape typesafe_sdk takes for that question type
intent: Choice[Intent] = field(metadata={"question": "asked a different way"})

criteria follows the SDK exactly: a mapping keyed by enum member name for a Choice, a positional sequence of rungs for a Score (so the IntEnum must number its rungs 0..n-1 — a gap is rejected with an error that says so), and {"true": ..., "false": ...} for a Noul.

Noul is the one that always needs you: it has no vocabulary to document itself with, so a Noul without a question is an error naming the field.

Member docstrings are not stored on the object at runtime — Severity.NONE.__doc__ returns the class docstring it inherits — so they are read by parsing the source, the same way pydantic implements use_attribute_docstrings. That makes them best-effort: where the source is not available (a REPL, exec, some frozen deployments) the criterion falls back to the member name rather than failing.

Three ways to ask

ask() takes any of these and compiles them into a single system_one call:

triage = await ask(Triage, state)                      # a battery dataclass -> Triage
intent = await ask(Choice[Intent], state)              # one question        -> Choice[Intent]
answers = await ask({"intent": Choice[Intent],         # an ad-hoc battery   -> dict
                     "hostile": Noul}, state)

Outside a dataclass there is no field to hang metadata on, so Annotated carries it instead — a mapping, or a bare string when all you have is the question:

await ask(Annotated[Score[Harm], {"question": "How much harm would this do?"}], state)
await ask(Annotated[Noul, "Is this aimed at a specific person?"], state)

Both forms work on a dataclass field too. If a field has metadata and an Annotated annotation, the field metadata wins — it is the more specific place to say it.

Prefer one call to several: three separate ask() calls are three round trips, while a battery or a mapping asks everything at once, which is the property the whole design rests on. Use ask_with_info() when you want the model name, question count, token usage and latency back alongside the answers.

The name collision with typesafe_sdk.Choice / Score / Noul is deliberate and one-directional: those describe the question, these hold the answer. You write the ones in this package; the plugin builds the SDK's from your battery.

The API key

The SDK reads the key from TYPESAFE_API_KEY, so mount your secret as that env var. There is no helper for this — it is a plain flyte.Secret:

env = flyte.TaskEnvironment(
    "triage",
    secrets=[flyte.Secret(key="TYPESAFE_API_KEY", as_env_var="TYPESAFE_API_KEY")],
)

flyte.Secret derives as_env_var from the key by upper-casing it and swapping - for _, so a secret named TYPESAFE_API_KEY mounts correctly from flyte.Secret(key="TYPESAFE_API_KEY") alone. Spelling as_env_var out is worth the extra words: it is the string you will grep for when a task cannot find the key.

If your secret is stored under a different name, point the key at it and keep the mount:

flyte.Secret(key="my-org-typesafe-key", as_env_var="TYPESAFE_API_KEY")

Create the secret once:

flyte create secret TYPESAFE_API_KEY --value <your key>

If the key is missing, the failure happens at the point of use — in the task that actually calls System One — with a message naming the declaration and the CLI command. It is deliberately not an import-time check: a module's tasks are imported together on the dataplane, so an import-time raise would take down tasks that never touch System One, and a task that merely passes answers along needs no key at all.

Examples

  • examples/triage.py — fourteen questions in one call, then confidence-gated routing in ordinary Python
  • examples/fanout.py — a backlog of tickets, one durable Flyte task each, one System One call inside each
  • examples/single.py — Choice, Score and Noul used on their own, without a battery dataclass

Both run against a cluster:

flyte run --root-dir plugins/typesafe-ai/examples plugins/typesafe-ai/examples/triage.py handle

While this package is unpublished, set TYPESAFE_LOCAL_WHEELS=1 and build the wheels first with make dist && make dist-plugins.

Answers as task inputs and outputs

Choice, Score and Noul are plain dataclasses, so Flyte carries them with nothing registered — including on their own, not just inside a battery:

@env.task
async def classify(message: str) -> Choice[Action]:
    return await ask(Choice[Action], {"message": message})

They also get the dict coercion every dataclass input gets, so a caller may pass {"value": "refund", "confidence": 0.91} where a Choice[Intent] is expected and omitted fields fall back to their defaults. Note that an enum nested in a dataclass is spelled by its value ("refund"), not its name — that is mashumaro's convention for dataclass fields, and it differs from the name-based spelling Flyte uses for a bare enum at the top level.

Inference is never implicit. A dict arriving for a Choice[Intent] is the serialized answer, never a state to go ask about — the two are indistinguishable by shape, and replay depends on the serialized reading winning. If you want the interface to say "System 1 produces this", make it a task: you get durability, caching and retries, and the call stays visible in the run graph.

A note on IntEnum

Score takes an IntEnum because a rubric is ordered. Flyte's enum transformer used to accept string-valued enums only; it now supports IntEnum as well, serialized by member name like every other enum, so severity: Severity also works as a bare task input or output. Flag/IntFlag remain unsupported, with a message explaining why: a composite member like READ|WRITE has a name but cannot be looked up by it, so it cannot come back.

A second core change makes Choice[Intent] work as a task type at all: a parameterized dataclass is a generic alias, which dataclasses.is_dataclass() rejects, so it used to fall through the type engine to pickle. The alias now resolves to its origin for structural checks while the alias itself is kept for decoding, which is what binds the type variable.

Release files for flyteplugins-typesafe-ai 2.9.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Built distribution (wheel)

Table of built distributions (wheels) for flyteplugins-typesafe-ai 2.9.0
File Interpreter ABI Platform
flyteplugins_typesafe_ai-2.9.0-py3-none-any.whl Python 3 none any Details

Release files / flyteplugins_typesafe_ai-2.9.0-py3-none-any.whl

Download URL flyteplugins_typesafe_ai-2.9.0-py3-none-any.whl
Size 16.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
fd0c21445656f22e61b308f4bffe05ec41681a757c064bcaac58cdba599aea09
BLAKE2b-256 checksum
How to use checksums
e7061e6186f2377a5063582ac89dc9da95a730d2e7dbe58ecb575b5ca71be0d8
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.15

Release history Release notifications | RSS feed

2.10.1

1 release file

2.10.0

1 release file

This release

2.9.0 This release

1 release file

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page