The official, HTTP-only Python client for the public Modaic API. It includes matching synchronous and asynchronous clients and never invokes Git or reads or writes repository files.
pip install modaicSet an API key from https://modaic.dev/settings/keys:
export MODAIC_API_KEY="mdc_..."The Python package installs the modaic CLI:
modaic login
modaic auth status
modaic logoutUse modaic login --headless to sign in from another device. Match the code
and device before confirming a request you started. Session secrets use the OS
keyring by default; --storage file explicitly opts into private plaintext
storage on POSIX. Sessions refresh automatically.
CLI sessions are for interactive commands. Programmatic Modaic/AsyncModaic
clients and CI use API keys. MODAIC_API_KEY takes precedence over stored
credentials. See the CLI reference.
from modaic import Modaic
with Modaic() as modaic:
result = modaic.decisions.create(
state={
"ticket": "I was charged twice for order 4832. Please refund one charge."
},
model="mo-latest",
questions={
"needs_refund": {
"type": "noul",
"instructions": "Should this customer receive a refund?",
"criteria": {
"true": "A duplicate or invalid charge should be refunded.",
"false": "The charge is valid or more information is required.",
},
},
"priority": {
"type": "choice",
"instructions": "Choose the support priority.",
"criteria": {
"low": "No financial or time-sensitive impact.",
"normal": "Routine customer issue.",
"high": "Financial impact or an urgent blocker.",
},
},
},
idempotency_key="ticket-4832-v1",
)
print(result.answers["priority"])The API URL defaults to https://modaic.dev/api/v1. Set MODAIC_API_URL to
override it, or pass base_url to the client. The client option takes precedence
over the environment variable. This applies to both Modaic and AsyncModaic.
For a local server, pass the versioned API URL explicitly:
modaic = Modaic(api_key="mdc_...", base_url="http://localhost:3001/v1")Use Noul, Choice, and Score to define questions. Dictionary questions still
work, including alongside question objects. Both forms are accepted by
decisions.create, models.create, and models.update.
Choice requires at least one option; Score requires at least one rubric
level. Empty choice criteria sent as a dictionary are rejected by the API with
422 and code validation_error.
Define a Pydantic response model by extending DecisionResponse with answer
fields matching your question names:
from modaic import DecisionResponse, Modaic, Noul, NoulAnswer
class BillingResponse(DecisionResponse):
billing: NoulAnswer
with Modaic() as modaic:
result = modaic.decisions.create(
model="mo-latest",
state="I was charged twice.",
questions={"billing": Noul(instructions="Is this about billing?")},
response_model=BillingResponse,
)
assert result.billing == result.nouls["billing"]
print(result.billing.noul)
print(result.request_id)Use ChoiceAnswer and ScoreAnswer for choice and score fields. answers
retains every answer, with nouls, choices, and scores providing typed
views. Usage and capture metadata remain available. Required answer fields are
validated; missing answers or mismatched types raise ModaicConnectionError.
Use Pydantic Field(alias="question-name") for names that are not Python
identifiers. Keep response metadata names, such as model and usage, reserved.
response_model accepts any Pydantic model, not only DecisionResponse
subclasses. Extending DecisionResponse is the shorter path, since it
supplies model, answers, usage, the typed views, and request_id, and
lifts your answer fields to the top level. To control the whole schema
instead, declare a plain BaseModel and spell out the parts you want:
from pydantic import BaseModel
from modaic import NoulAnswer
class BillingAnswers(BaseModel):
billing: NoulAnswer
class BillingEnvelope(BaseModel):
answers: BillingAnswersPydantic drops response fields the model does not declare, so this validates
only answers.billing and reads as result.answers.billing.noul. Nothing is
grafted on: request_id and the typed views exist only on DecisionResponse
subclasses. BaseModel itself is rejected, because Pydantic cannot validate
into it.
response_model also works with AsyncModaic and model.decisions.create.
It controls local response parsing only and is not sent to the API. The
request_id comes from the HTTP response header and is excluded from
model_dump() and model_dump_json().
Large training datasets and artifacts use resumable upload sessions. The high-level helper streams the file twice: once to compute its whole-file SHA-256 and once to upload server-sized parts. It never materializes the whole file in memory, and it waits for validation by default:
with Modaic() as modaic:
upload = modaic.uploads.upload_file(
"acme",
"training.jsonl",
kind="dataset",
name="support-hard-labels",
repos=["support-priority"],
progress=lambda uploaded, total: print(f"{uploaded}/{total}"),
)Set wait_for_validation=False to return as soon as completion starts, or use
the lower-level create, prepare_part, upload_part, confirm_part,
complete, and wait methods to resume an existing session. Resources are
available through resources.list, get, update, and
download. Every call names its workspace. Listing accepts glob= with *
and ?; uploads also support kind="eval-only-dataset" for held-out sets.
Start head-only or LoRA tuning with the uploaded dataset name. The JSONL dataset defines each question and answer on the row; a base model requires a destination, while an existing repository publishes back to itself:
{"state":"Duplicate charge","questions":{"route":{"instructions":"Route?","type":"choice","criteria":{"support":"Support","payments":"Payments"},"answer":"payments"},"severity":{"instructions":"Severity?","type":"score","criteria":["low","high"],"answer":[0.1,0.9]}}}Keyed criteria use string hard labels or probability objects with exactly the
same keys. Positional criteria use zero-based integer labels or probability
arrays of the same length. Soft-label probabilities must sum to 1.
Rows do not contain split labels. Use validation_file and
eval_only_dataset for separate uploaded validation and held-out files; omitted
partitions are selected deterministically from the training file.
job = modaic.fine_tunes.create(
model="mo-1.3.1",
destination="acme/support-priority",
training_file="support-hard-labels",
training_type="lora",
lora_r=8,
lora_alpha=16,
)
finished = modaic.fine_tunes.wait(job.id)Once finished.status == "done", call acme/support-priority normally. Pin an
immutable published checkpoint with acme/support-priority@<full-commit-sha>.
Fine-tuning requires mo-1.3.1.
The async client has the same resources and methods:
import asyncio
from modaic import AsyncModaic
async def main() -> None:
async with AsyncModaic() as modaic:
models = await modaic.models.list()
print([model.name for model in models.models])
asyncio.run(main())Models returned by models.create, models.get, and models.update can run
decisions directly:
with Modaic() as modaic:
model = modaic.models.get(workspace="acme", model="support-priority")
result = model.decisions.create(
state={"ticket": "Please refund my duplicate charge."},
)
examples = model.examples.list(page_size=10)
batches = model.jobs.batch_decisions.list()
alignments = model.jobs.alignments.list()models.get includes configuration with the model and questions saved on
the default branch (None until the model has a manifest), so you can diff
before updating. models.update with questions that already match is a
no-op: it returns unchanged=True, a commit whose commit_sha equals
previous_sha, and the stored configuration. Nothing is committed and the
checkpoint is not reset.
Alignment writes its optimized instructions into the same questions, so a
setup script that re-pushes the schema as authored would revert an aligned
model. Once a model has a checkpoint, models.update with questions that
differ from the stored ones fails with 409 alignment_would_be_discarded.
Omit questions to keep the aligned instructions, or pass
discard_alignment=True to replace them and reset the checkpoint to 0.
modaic.__version__ is the installed SDK version, for logging alongside
results. models.get also returns commit, the head of the default branch. Pass
commit.commit_sha back as expected_head_sha on models.update and a
stale client gets 409 expected_head_mismatch instead of overwriting newer
commits.
Every model is a Git repository, and models exposes its history:
model = modaic.models.get(workspace="acme", model="support-priority")
# Name the aligned commit so production can pin to it.
modaic.models.create_tag(model.id, name="v1", commit_sha=model.commit.commit_sha)
modaic.decisions.create(model="acme/support-priority", revision="v1", state={...})
# Undo a bad update: move main back to the aligned commit.
commits = modaic.models.list_commits(model.id, branch="main")
modaic.models.rollback(
model.id,
branch="main",
target_commit_sha=commits.commits[1].sha,
expected_head_sha=commits.commits[0].sha,
)Rollback commits the target's files back onto the branch, so model.json
returns with its checkpoint and metrics intact. list_branches,
create_branch(name=, source_ref=), delete_branch, list_tags, and
delete_tag round out the surface.
With AsyncModaic, await both calls. The bound method accepts every decision
option except model and uses the same client; keep that client open while
running decisions. Pass revision to pin a version. model_dump() and
model_dump_json() contain only response data. The top-level
modaic.decisions.create remains available.
model.examples exposes ingest, list, get, annotate, and list_decisions
without a model ID argument. model.jobs.alignments and
model.jobs.batch_decisions expose model-bound create and list. Use the
top-level job resources to retrieve, wait for, or cancel a job by its ID.
Pass progress=True to either job resource's wait() method for a tqdm display:
finished = modaic.batch_decisions.wait(job.id, progress=True)
finished = modaic.alignments.wait(alignment.id, progress=True)With AsyncModaic, use await with the same option. Progress is off by default.
Batch jobs show processed examples and failures; alignment shows its stage and
metric-call budget usage, not an overall completion percentage. Updates use the
existing polling interval. Timing out stops waiting without cancelling the job.
| Resource | Methods |
|---|---|
decisions |
create |
models |
list, create, get, update, delete |
examples |
ingest, list, get, annotate, list_decisions |
batch_decisions |
create, list, get, cancel, wait |
alignments |
create, list, get, logs, cancel, wait |
protege |
create, get, cancel, wait |
resources |
list, get, update, download |
Responses are Pydantic models. Python attributes use snake_case even when the
wire format uses camelCase.
Non-2xx responses raise ModaicAPIError, which exposes status_code, code,
request_id, details, and the decoded response body. Network failures raise
ModaicConnectionError; request and polling deadlines raise
ModaicTimeoutError.
See the complete API documentation at https://docs.modaic.dev.
Select mo-2.0 explicitly. Extraction (type: "extraction") extracts an exact
substring from a string state; Box (type: "box") localizes an image region.
Both require nonempty string instructions and reject criteria.
Extraction answers contain extraction: {start, end, text} and confidence; offsets count
UTF-16 code units and end is exclusive. Box answers contain
box: {top, bottom, left, right} normalized to [0, 1], and confidence.
Pass inline assets to decisions.create, mapping an asset ID to
{type: "image", format: "png", data_base64: "..."}. Reference IDs in
state, instructions or supported criteria; no separate upload is required.
mo-latest selects mo-2.0.
Run a JSON inference request (including inline assets) with
modaic --json decisions create --request request.json.
The CLI accepts these question shapes in modaic models create WORKSPACE SLUG --config model.json and modaic models update WORKSPACE/MODEL --config model.json. Put the base model and questions in the configuration; supply image assets on inference requests.