MLflow¶
jevper is a plain library that drives a caller-supplied client, so integrating it with MLflow means two
different things: MLflow's tracing sees the calls jevper makes, and MLflow's hosting surfaces can wrap
jevper as a model. Both are covered here, verified against MLflow 3.16.1 (2026-09-16), openai 3.19.2,
anthropic 1.8.0 — offline against a stub server, and live against a local LM Studio server on the probe
box, which is the only local server that answers all three surfaces jevper drives.
Nothing in jevper imports MLflow, and MLflow is not a dependency. The integration suite is in
tests/test_mlflow.py and skips unless MLflow is installed:
uv pip install -e '.[test,mlflow]' # mlflow[gateway,langchain] + openai + anthropic + pytest
pytest tests/test_mlflow.py -q
| Distribution | Version | Notes |
|---|---|---|
mlflow |
3.16.1 | The full package; mlflow-skinny and mlflow-tracing are pinned alongside it |
mlflow[gateway] |
— | FastAPI/uvicorn for mlflow gateway; without it the CLI has no gateway command at all |
mlflow[genai] |
— | mlflow.genai.evaluate, scorers and judges (already present in the full package) |
| Python | ≥ 3.10 | jevper supports 3.10–3.14; the suite runs on 3.13 locally and 3.12 on the probe box |
Tracing¶
mlflow.openai.autolog() and mlflow.anthropic.autolog() patch the SDK resource classes, not the
client constructors, so every call jevper makes through a caller-supplied SDK client is traced — including
the attempts it makes before settling on one the provider accepts. A duck-typed client of your own is not an
SDK client, so autolog cannot see it; wrap those calls in @mlflow.trace yourself if you need the span.
import mlflow
from jevper import Choice, SystemOneClient
from openai import OpenAI
mlflow.set_tracking_uri("sqlite:///mlflow.db") # or an HTTP tracking server
mlflow.set_experiment("triage")
mlflow.openai.autolog() # chat, responses, embeddings, legacy completions
mlflow.anthropic.autolog() # messages
client = SystemOneClient(OpenAI(base_url="http://127.0.0.1:11434/v1", api_key="x"),
model="qwen3:4b-instruct-2507-q4_K_M", method="auto")
@mlflow.trace(name="system_one") # one trace per jevper call
def classify(text: str) -> str:
response = client.system_one(state=text, questions={"intent": Choice(criteria={...})})
return response.answers["intent"].choice or ""
classify("I was charged twice for the same subscription.")
mlflow.flush_trace_async_logging() # trace export is asynchronous
| jevper surface | Span name | Traced by |
|---|---|---|
api="chat_completions" (sync) |
Completions |
mlflow.openai.autolog() |
api="chat_completions" (async) |
AsyncCompletions |
mlflow.openai.autolog() |
api="responses" |
Responses (AsyncResponses async) |
mlflow.openai.autolog() |
api="messages" |
Messages.create (AsyncMessages.create async) |
mlflow.anthropic.autolog() |
Each SDK call is its own trace unless it happens inside a span you opened. jevper answers several
questions on a thread pool, and each worker gets a copy of the caller's context, so a @mlflow.trace
span around system_one is the parent of every question's SDK span: one trace, one child span per
question, whether the call carried one question or ten. (MLflow's active run is a thread-local rather
than a context variable, so a run started with mlflow.start_run() is not attached to spans created on
those workers. A @mlflow.trace span is the way to get one trace per call.)
The fixture tests/fixtures/jevper_langchain_model.py reads its base_url and model from the
model_config MLflow logged beside the code. MLflow deliberately refuses that read when a model was
logged without one, so the fixture falls back to its own defaults: log_model(..., model_config=...)
is what a deployment passes when it has something else to say.
What lands on a span, all observed on real calls:
| Attribute | Value |
|---|---|
mlflow.llm.model |
The model the provider reported — on every server here the served name, which is the one jevper sent; a gateway or a stub that renames it is the exception, and the request's own model is in mlflow.spanInputs either way |
mlflow.llm.provider |
anthropic on the Messages route (absent on the OpenAI routes) |
mlflow.message.format |
openai or anthropic |
mlflow.chat.tokenUsage |
{"input_tokens", "output_tokens", "total_tokens"}, plus cache_read_input_tokens on the Responses route. It is absent when the provider sent no usage at all, and holds null for each token when the provider sent a usage object with those fields missing — so a test asserting one of those shapes fails on the other |
mlflow.spanInputs / mlflow.spanOutputs |
The request kwargs jevper built, and the raw provider response |
mlflow.spanLogLevel |
20 on a successful SDK call, 40 on a failed one; a plain @mlflow.trace span of your own is 10 |
| request fields | mlflow.spanInputs always holds every keyword jevper sent. MLflow also promotes some of them to attributes, and which ones depends on the route: logprobs, top_logprobs, model on chat; include, store, top_logprobs, model on responses; none on the Messages route, where max_tokens lives in span.inputs. prompt_cache_key is in span.inputs on both OpenAI routes and is promoted to no attribute, because jevper sends it in extra_body — a field of the API, not of any SDK release, so that the openai floor in pyproject.toml works |
The request fields are what make the fallback ladder auditable: a server that refuses response_format
leaves one error span per rung, each naming the exact field it turned down, followed by the span that
answered. The same is true of a retried transient failure, and of the corrective retry after a malformed
answer — so response.debug["llm_attempts"] and the trace tell the same story.
Two failure modes are worth knowing. A request the provider rejects — a 400, a 404, a refused field —
leaves a span with an error status, and the call still raises jevper's own error. A response the provider
answers that jevper cannot read is different: a refusal or a spent budget arrives as an HTTP 200, so the
span is OK while jevper raises ModelRefusalError/IncompleteAnswerError (both ProviderError
subclasses, and both terminal — a refusal is not re-asked and a spent budget is not corrected). The rule
behind all of it is that the
span's status follows the SDK call: OK if it returned, error if it raised — which also means a 200 whose
body the SDK itself cannot parse is an error span, while one whose shape the SDK tolerates and jevper then
trips over is not. The span tells you what the provider said; jevper's error tells you whether an answer came
out of it. And an unwritable tracking store does not break the call — MLflow cannot export the span, and the
answer comes back anyway.
mlflow.tracing.disable() records nothing and changes nothing else.
Hosting jevper as an MLflow model¶
MLflow 3.16.1 offers three base classes for a chat model (a fourth, ChatAgent, exists for agents
rather than chat). Two of them are exercised end to end in
tests/fixtures/, logged with the models-from-code pattern (python_model=<path> — a cloudpickled instance
cannot carry an HTTP client, and the client belongs in load_context anyway).
| Base class | Status in 3.16.1 | Contract |
|---|---|---|
mlflow.pyfunc.PythonModel |
current | predict(self, context, model_input, params=None) |
mlflow.pyfunc.ChatModel |
deprecated since 3.0.0 | predict(self, context, messages: list[ChatMessage], params: ChatParams) -> ChatCompletionResponse |
mlflow.pyfunc.ResponsesAgent |
recommended for new code | predict(self, request: ResponsesAgentRequest) -> ResponsesAgentResponse, plus predict_stream, which the base class raises NotImplementedError for until you write it |
The fixtures in tests/fixtures/ are the working examples: jevper_chat_model.py (ChatModel),
jevper_responses_agent.py (ResponsesAgent, including predict_stream and the Message-object input
MLflow actually sends), and jevper_langchain_model.py (a SimpleChatModel for the LangChain flavour).
# jevper_chat_model.py — logged with python_model="jevper_chat_model.py"
import mlflow
from mlflow.pyfunc import ChatModel
from mlflow.types.llm import ChatChoice, ChatCompletionResponse, ChatMessage
from jevper import Choice, SystemOneClient
class JevperChatModel(ChatModel):
def load_context(self, context): # the client is built here, not pickled
from openai import OpenAI
config = context.model_config or {}
self.client = SystemOneClient(
OpenAI(base_url=config["base_url"], api_key="x"), model=config["model"],
api="chat_completions", method="structured",
)
def predict(self, context, messages, params):
state = "\n".join(str(getattr(m, "content", "")) for m in messages)
answer = self.client.system_one(state=state, questions={"intent": Choice(criteria={...})})
return ChatCompletionResponse(model="jevper", choices=[ChatChoice(
index=0, message=ChatMessage(role="assistant", content=answer.answers["intent"].choice or ""),
finish_reason="stop")])
mlflow.models.set_model(JevperChatModel())
mlflow.pyfunc.log_model runs the input_example through the model while logging, so a model that cannot
answer its own example cannot be logged — a jevper model whose provider will not return the shape the method
needs fails there, not later. Invoke the logged model with mlflow.pyfunc.load_model(uri).predict(...), or
with mlflow.models.predict(model_uri=..., input_data=..., output_path=..., env_manager="local"), which
writes the answer to output_path (its return value is None).
The AI Gateway¶
MLflow 3.16.1 still ships the standalone gateway — mlflow gateway start --config-path config.yaml, behind
the gateway extra — but marks the command deprecated in favour of the server-hosted gateway that current
documentation describes (mlflow server, endpoints managed through the UI/API, /gateway/mlflow/v1/...
passthrough routes). The two are different contracts, and the differences below are the 3.16.1 standalone
one, measured on the probe box.
endpoints:
- name: local-chat
endpoint_type: llm/v1/chat
model:
provider: openai # not "openai-compatible": that provider does not exist
name: qwen3:4b-instruct-2507-q4_K_M
config:
openai_api_key: test
openai_api_base: http://127.0.0.1:11434/v1
client = SystemOneClient(
OpenAI(base_url="http://127.0.0.1:5000/v1", api_key="dummy"), # the route's /v1 root
model="local-chat", # the endpoint name is the model
method="auto",
)
Routes: GET /health, POST /v1/chat/completions, /v1/completions, /v1/embeddings, and the legacy
POST /endpoints/{name}/invocations. The request's model field selects the endpoint, so it has to be the
endpoint name (local-chat above) rather than the backend model name — a wrong name is refused by the
gateway and never reaches the backend. There is no /v1/responses route, so api="auto" pays one 404 and
answers on chat; an unknown path is FastAPI's 404 {"detail": "Not Found"}, which jevper reads as "this
server has no such route" rather than a bad request.
Requests pass through faithfully — the gateway's request model allows extra fields and it calls the backend
over HTTP rather than through the OpenAI SDK, so response_format, logprobs, top_logprobs,
prompt_cache_key, and vendor knobs in extra_body (chat_template_kwargs, thinking) all arrive at the
backend. Only model is replaced with the configured backend model.
Responses do not pass through. The answer is re-shaped into MLflow's ChatCompletionResponse, which has
no room for several fields jevper knows how to read:
| Field | Survives the gateway | Consequence for jevper |
|---|---|---|
choices[].finish_reason, choices[].message.refusal |
yes | the truncation note and the refusal readout still work |
usage.prompt_tokens / completion_tokens / total_tokens / prompt_tokens_details / completion_tokens_details |
yes | usage.cached_tokens and usage.reasoning_tokens both work |
choices[].logprobs |
no | method="logprobs" raises LabelReadoutError; method="auto" falls back to structured and reports it |
choices[].message.reasoning_content |
no | the reasoning readout is empty, so reasoning_text(response.reasoning) is empty too |
A backend error keeps its status code but is rewrapped as {"detail": "<provider message>"}; the message
itself survives, so jevper's field-level fallbacks still fire and the caller still sees the provider's words.
Two client-side mismatches are worth knowing before you build on them: mlflow.deployments.get_deploy_client
posts to /v1/endpoints/{name}/invocations while the standalone gateway serves /endpoints/{name}/invocations
(the raw route works; the client does not), and a judge configured with gateway:/<route> posts to
/gateway/mlflow/v1/chat/completions, which only the server-hosted gateway mounts.
One deployment gotcha, found the hard way on the probe box: the standalone gateway watches the config file's
directory (so a config edit reloads routes). On a systemd host a config under /tmp makes that walk trip
over /tmp/systemd-private-* with Permission denied and the server exits before it ever listens — keep the
config in a directory you own.
Evaluation¶
mlflow.genai.evaluate calls a predict_fn with the dataset's inputs as keyword arguments, and expects a
trace per call. Wrap the jevper call in @mlflow.trace and let autolog trace what happens inside it:
from mlflow.genai.scorers import Correctness, scorer
@scorer
def label_matches(outputs, expectations) -> bool:
return outputs == expectations["label"]
@mlflow.trace(name="jevper.system_one")
def predict(text: str) -> str:
response = client.system_one(state=text, questions={"intent": Choice(criteria={...})})
return response.answers["intent"].choice or ""
result = mlflow.genai.evaluate(
data=[{"inputs": {"text": "I was charged twice"}, "expectations": {"label": "billing"}}],
scorers=[label_matches, Correctness(model="openai:/qwen3:4b-instruct-2507-q4_K_M")],
predict_fn=predict,
)
result.metrics # {'label_matches/mean': 1.0, 'correctness/mean': 1.0}
One MLflow quirk to know before you build a judge-based evaluation: with a SQLite tracking store,
logging an LLM-judge metric fails inside MLflow (logged_model_metrics foreign-key error), and the
filesystem store is refused outright unless you set MLFLOW_ALLOW_FILE_STORE=true. The suite runs those
evaluations against an opted-in file store for that reason; a real tracking server avoids it entirely.
Three things the suite pins, because they change how you write the wrapper:
- A
predict_fnthat raises fails the whole evaluation (MlflowException: Failed to run the prediction function) — so a model that sometimes answers prose where a label is required needs a total function: catchMalformedAnswerError(orLabelReadoutError) and return a sentinel, and let a scorer judge it. - A scorer that raises is recorded against its row and the run continues.
Correctness(and the other built-in judges) needsexpectations.expected_responseorexpected_facts; without one MLflow drops the scorer with a warning and no metric appears.
A judge is an OpenAI-compatible caller like any other, so it can be pointed at a local server: model="openai:/<name>"
plus OPENAI_API_KEY and OPENAI_API_BASE (verified against a stub and, live, against LM Studio on the probe box), or
model="ollama:/<name>". The gateway:/<route> form resolves through MLFLOW_GATEWAY_URI (or an HTTP
tracking URI) to the server-hosted gateway's passthrough path, so it does not reach the standalone gateway.
Flavours¶
| Flavour | Applies to jevper? | Notes |
|---|---|---|
mlflow.pyfunc |
yes | PythonModel, ChatModel (deprecated), ResponsesAgent (recommended); the verified hosting path |
mlflow.openai |
partly | autolog() traces jevper's calls; save_model/log_model are deprecated since 3.8.0 (they read OPENAI_API_BASE, so they can front the same server, but jevper is not what they wrap) |
mlflow.anthropic |
partly | Tracing only — there is no log_model/save_model in this flavour |
mlflow.genai |
yes | evaluate, scorers, judges; the verified evaluation path |
mlflow.gateway |
yes | An OpenAI-compatible front door over any provider, including a local server; see the response caveats above |
mlflow.deployments |
partly | The client posts /v1/endpoints/{name}/invocations; the standalone gateway serves /endpoints/{name}/invocations, so the client and this gateway do not meet |
mlflow.langchain |
yes, via an adapter | LangChain v1 requires models-from-code; a SimpleChatModel subclass over jevper logs and predicts (tests/fixtures/jevper_langchain_model.py) |
mlflow.dspy, mlflow.llama_index |
not covered | They host their own framework objects; a jevper-backed adapter for either would be new code, not a jevper integration |
mlflow.transformers, mlflow.sentence_transformers |
no | They host local model weights; jevper is an API client |
sklearn, xgboost, pytorch, spark, keras, onnx, … |
no | Trained-model flavours; nothing for an LLM client to attach to |
Limits¶
- Verified on MLflow 3.16.1 only. The gateway in particular is being reshaped: the standalone command is
deprecated, and the server-hosted routes (
/gateway/mlflow/v1/...) are what current documentation describes. Re-runtests/test_mlflow.pyafter upgrading MLflow. - Streaming is not covered: jevper answers in one call per question, so there is no streaming path to trace,
and MLflow's Anthropic autolog does not record streaming output in any case —
messages.create(stream=True)leaves a span whose output is the stream object's repr, andmessages.stream()produces no span at all. - The trace-attribute names (
mlflow.chat.tokenUsage,mlflow.llm.model, …) are MLflow's, not jevper's; the suite asserts them, so a rename shows up as a failing test rather than a silent gap.